feat: add OpenCode CLI path override and settings UI

- Add opencodeBinary field to settings and persistence flow
- Introduce OpenCode CLI settings panel with Browse and Save actions
This commit is contained in:
Bohdan Triapitsyn
2026-02-06 01:32:32 +02:00
parent b17fb60393
commit 234a91b444
17 changed files with 736 additions and 31 deletions
+48 -2
View File
@@ -93,10 +93,39 @@ const CLIENT_RELOAD_DELAY_MS = 800;
const execFileAsync = promisify(execFile);
const gpgconfCandidates = ['gpgconf', '/opt/homebrew/bin/gpgconf', '/usr/local/bin/gpgconf'];
const OPENCHAMBER_SHARED_SETTINGS_PATH = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
const readSharedSettingsFromDisk = (): Record<string, unknown> => {
try {
const raw = fs.readFileSync(OPENCHAMBER_SHARED_SETTINGS_PATH, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
return parsed as Record<string, unknown>;
}
return {};
} catch {
return {};
}
};
const writeSharedSettingsToDisk = async (changes: Record<string, unknown>): Promise<void> => {
try {
await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true });
const current = readSharedSettingsFromDisk();
const next: Record<string, unknown> = { ...current, ...changes };
// Keep empty-string sentinel (""), so other runtimes can detect explicit clears.
await fs.promises.writeFile(OPENCHAMBER_SHARED_SETTINGS_PATH, JSON.stringify(next, null, 2), 'utf8');
} catch {
// ignore
}
};
const readSettings = (ctx?: BridgeContext) => {
const stored = ctx?.context?.globalState.get<Record<string, unknown>>(SETTINGS_KEY) || {};
const restStored = { ...stored };
delete (restStored as Record<string, unknown>).lastDirectory;
const shared = readSharedSettingsFromDisk();
const sharedOpencodeBinary = typeof shared.opencodeBinary === 'string' ? shared.opencodeBinary.trim() : '';
const workspaceFolder = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath || '';
const themeVariant =
vscode.window.activeColorTheme.kind === vscode.ColorThemeKind.Light ||
@@ -108,6 +137,10 @@ const readSettings = (ctx?: BridgeContext) => {
themeVariant,
lastDirectory: workspaceFolder,
...restStored,
opencodeBinary:
typeof restStored.opencodeBinary === 'string'
? String(restStored.opencodeBinary).trim()
: (sharedOpencodeBinary || undefined),
};
};
@@ -177,10 +210,13 @@ const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeCon
const restChanges = { ...(changes || {}) };
delete restChanges.lastDirectory;
const keysToClear = new Set<string>();
// Normalize empty-string clears to key removal (match web/desktop behavior)
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId']) {
for (const key of ['defaultModel', 'defaultVariant', 'defaultAgent', 'defaultGitIdentityId', 'opencodeBinary']) {
const value = restChanges[key];
if (typeof value === 'string' && value.trim().length === 0) {
keysToClear.add(key);
delete restChanges[key];
}
}
@@ -195,8 +231,18 @@ const persistSettings = async (changes: Record<string, unknown>, ctx?: BridgeCon
delete restChanges.usageRefreshIntervalMs;
}
const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory };
const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory } as Record<string, unknown>;
for (const key of keysToClear) {
delete merged[key];
}
await ctx?.context?.globalState.update(SETTINGS_KEY, merged);
if (keysToClear.has('opencodeBinary')) {
await writeSharedSettingsToDisk({ opencodeBinary: '' });
} else if (typeof restChanges.opencodeBinary === 'string') {
await writeSharedSettingsToDisk({ opencodeBinary: restChanges.opencodeBinary.trim() });
}
return merged;
};
+1
View File
@@ -455,6 +455,7 @@ export async function activate(context: vscode.ExtensionContext) {
`Working directory: ${workingDirectory}`,
`Working dir matches workspace: ${workingDirectoryMatchesWorkspace ? 'yes' : 'no'}`,
`API URL (configured): ${configuredApiUrl || '(none)'}`,
`OpenCode binary (configured): ${(vscode.workspace.getConfiguration('openchamber').get<string>('opencodeBinary') || '').trim() || '(none)'}`,
`API URL (resolved): ${openCodeManager?.getApiUrl() ?? '(none)'}`,
`API URL path: ${resolvedApiPath || '(none)'}`,
debug
+48
View File
@@ -84,6 +84,53 @@ function appendToPath(dir: string) {
}
function resolveOpencodeCliPath(): string | null {
const configured = (() => {
try {
const config = vscode.workspace.getConfiguration('openchamber');
const raw = config.get<string>('opencodeBinary') || '';
const trimmed = raw.trim();
if (!trimmed) return null;
try {
const stat = fs.statSync(trimmed);
if (stat.isDirectory()) {
return path.join(trimmed, process.platform === 'win32' ? 'opencode.exe' : 'opencode');
}
} catch {
// ignore
}
return trimmed;
} catch {
return null;
}
})();
if (configured && isExecutable(configured)) {
return configured;
}
const sharedFromOpenChamber = (() => {
try {
const settingsPath = path.join(os.homedir(), '.config', 'openchamber', 'settings.json');
const raw = fs.readFileSync(settingsPath, 'utf8');
const parsed = JSON.parse(raw) as unknown;
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
return null;
}
const candidate = (parsed as Record<string, unknown>).opencodeBinary;
if (typeof candidate !== 'string') {
return null;
}
const trimmed = candidate.trim();
return trimmed.length > 0 ? trimmed : null;
} catch {
return null;
}
})();
if (sharedFromOpenChamber && isExecutable(sharedFromOpenChamber)) {
return sharedFromOpenChamber;
}
const explicit = [
process.env.OPENCODE_BINARY,
process.env.OPENCODE_PATH,
@@ -368,6 +415,7 @@ export function createOpenCodeManager(_context: vscode.ExtensionContext): OpenCo
if (resolvedCli) {
cliPath = resolvedCli;
appendToPath(path.dirname(resolvedCli));
process.env.OPENCODE_BINARY = resolvedCli;
}
// SDK spawns `opencode serve` in current process cwd.