From 234a91b444fdd622a1cca0af06c566d90ac3b3ce Mon Sep 17 00:00:00 2001 From: Bohdan Triapitsyn Date: Fri, 6 Feb 2026 01:32:26 +0200 Subject: [PATCH] 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 --- bun.lock | 18 +- package.json | 2 +- packages/desktop/src-tauri/src/main.rs | 77 ++++- packages/ui/package.json | 2 +- packages/ui/src/App.tsx | 4 +- .../onboarding/OnboardingScreen.tsx | 98 ++++++ .../sections/openchamber/OpenChamberPage.tsx | 13 + .../openchamber/OpenCodeCliSettings.tsx | 123 +++++++ packages/ui/src/lib/api/types.ts | 1 + packages/ui/src/lib/desktop.ts | 2 + packages/ui/src/lib/persistence.ts | 5 + packages/vscode/package.json | 7 +- packages/vscode/src/bridge.ts | 50 ++- packages/vscode/src/extension.ts | 1 + packages/vscode/src/opencode.ts | 48 +++ packages/web/package.json | 2 +- packages/web/server/index.js | 314 +++++++++++++++++- 17 files changed, 736 insertions(+), 31 deletions(-) create mode 100644 packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx diff --git a/bun.lock b/bun.lock index 490c2f48..54022f6c 100644 --- a/bun.lock +++ b/bun.lock @@ -32,7 +32,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.48", + "@opencode-ai/sdk": "^1.1.52", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -96,7 +96,7 @@ }, "packages/desktop": { "name": "@openchamber/desktop", - "version": "1.6.3", + "version": "1.6.4", "devDependencies": { "@tauri-apps/cli": "^2", "@types/node": "^24.3.1", @@ -105,7 +105,7 @@ }, "packages/ui": { "name": "@openchamber/ui", - "version": "1.6.3", + "version": "1.6.4", "dependencies": { "@codemirror/autocomplete": "^6.20.0", "@codemirror/commands": "^6.10.1", @@ -135,7 +135,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.48", + "@opencode-ai/sdk": "^1.1.52", "@pierre/diffs": "^1.0.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", @@ -199,10 +199,10 @@ }, "packages/vscode": { "name": "openchamber", - "version": "1.6.3", + "version": "1.6.4", "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.48", + "@opencode-ai/sdk": "^1.1.52", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", @@ -222,7 +222,7 @@ }, "packages/web": { "name": "@openchamber/web", - "version": "1.6.3", + "version": "1.6.4", "bin": { "openchamber": "./bin/cli.js", }, @@ -231,7 +231,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.48", + "@opencode-ai/sdk": "^1.1.52", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", @@ -847,7 +847,7 @@ "@openchamber/web": ["@openchamber/web@workspace:packages/web"], - "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.48", "", {}, "sha512-j5/79X45fUPWVD2Ffm/qvwLclDCdPeV+TYMDrm9to0p4pmzhmeKevCsyiRdLg0o0HE3AFRUnOo2rdO9NetN79A=="], + "@opencode-ai/sdk": ["@opencode-ai/sdk@1.1.53", "", {}, "sha512-RUIVnPOP1CyyU32FrOOYuE7Ge51lOBuhaFp2NSX98ncApT7ffoNetmwzqrhOiJQgZB1KrbCHLYOCK6AZfacxag=="], "@pierre/diffs": ["@pierre/diffs@1.0.5", "", { "dependencies": { "@shikijs/core": "^3.0.0", "@shikijs/engine-javascript": "^3.0.0", "@shikijs/transformers": "^3.0.0", "diff": "8.0.2", "hast-util-to-html": "9.0.5", "lru_map": "0.4.1", "shiki": "^3.0.0" }, "peerDependencies": { "react": "^18.3.1 || ^19.0.0", "react-dom": "^18.3.1 || ^19.0.0" } }, "sha512-QcFhO6BW1Zz3BP+WFuH1tO2DjFJY5Sb6NmRjmEoVeEu1AVOd3HoUEFTnztxgxn+2c2ZFyFZP+6T4X/g8LDuZLw=="], diff --git a/package.json b/package.json index 283d78c7..ecd7883e 100644 --- a/package.json +++ b/package.json @@ -85,7 +85,7 @@ "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.48", + "@opencode-ai/sdk": "^1.1.52", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/desktop/src-tauri/src/main.rs b/packages/desktop/src-tauri/src/main.rs index 241fa473..2827de72 100644 --- a/packages/desktop/src-tauri/src/main.rs +++ b/packages/desktop/src-tauri/src/main.rs @@ -858,18 +858,52 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { let mut path_segments: Vec = Vec::new(); let mut seen = std::collections::HashSet::::new(); - let resolved_home_dir = app - .path() - .home_dir() - .ok() - .and_then(|p| { - let s = p.to_string_lossy().to_string(); - if s.trim().is_empty() { - None - } else { - Some(s) - } - }); + let resolved_home_dir_path = app.path().home_dir().ok(); + let resolved_home_dir = resolved_home_dir_path.as_ref().and_then(|p| { + let s = p.to_string_lossy().to_string(); + if s.trim().is_empty() { + None + } else { + Some(s) + } + }); + + let opencode_binary_from_settings: Option = (|| { + let data_dir = env::var("OPENCHAMBER_DATA_DIR") + .ok() + .and_then(|v| { + let t = v.trim().to_string(); + if t.is_empty() { + None + } else { + Some(PathBuf::from(t)) + } + }) + .or_else(|| { + resolved_home_dir_path + .as_ref() + .map(|home| home.join(".config").join("openchamber")) + }); + let data_dir = data_dir?; + let settings_path = data_dir.join("settings.json"); + let raw = fs::read_to_string(&settings_path).ok()?; + let json = serde_json::from_str::(&raw).ok()?; + let value = json.get("opencodeBinary")?.as_str()?.trim(); + if value.is_empty() { + return None; + } + + let mut candidate = value.to_string(); + if fs::metadata(&candidate).map(|m| m.is_dir()).unwrap_or(false) { + let bin_name = if cfg!(windows) { "opencode.exe" } else { "opencode" }; + candidate = PathBuf::from(candidate) + .join(bin_name) + .to_string_lossy() + .to_string(); + } + + Some(candidate) + })(); let mut push_unique = |value: String| { let trimmed = value.trim(); @@ -882,6 +916,16 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { }; // Respect explicit binary overrides by adding their parent dir first. + if let Some(val) = opencode_binary_from_settings.as_deref() { + let trimmed = val.trim(); + if !trimmed.is_empty() { + let path = std::path::Path::new(trimmed); + if let Some(parent) = path.parent() { + push_unique(parent.to_string_lossy().to_string()); + } + } + } + for var in [ "OPENCHAMBER_OPENCODE_PATH", "OPENCHAMBER_OPENCODE_BIN", @@ -908,7 +952,7 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { push_unique("/usr/sbin".to_string()); push_unique("/sbin".to_string()); - if let Some(home) = resolved_home_dir.as_deref() { + if let Some(home) = resolved_home_dir.as_deref() { // OpenCode installer default. push_unique(format!("{home}/.opencode/bin")); push_unique(format!("{home}/.local/bin")); @@ -948,6 +992,13 @@ async fn spawn_local_server(app: &tauri::AppHandle) -> Result { cmd = cmd.env("HOME", home); } + if let Some(bin) = opencode_binary_from_settings.as_deref() { + let trimmed = bin.trim(); + if !trimmed.is_empty() { + cmd = cmd.env("OPENCODE_BINARY", trimmed); + } + } + let (rx, child) = match cmd.spawn() { Ok(v) => v, Err(err) => { diff --git a/packages/ui/package.json b/packages/ui/package.json index f0b9b3be..7fc45ce6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -39,7 +39,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@lezer/highlight": "^1.2.3", - "@opencode-ai/sdk": "^1.1.48", + "@opencode-ai/sdk": "^1.1.52", "@pierre/diffs": "^1.0.5", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", diff --git a/packages/ui/src/App.tsx b/packages/ui/src/App.tsx index 6e87be15..b4f28459 100644 --- a/packages/ui/src/App.tsx +++ b/packages/ui/src/App.tsx @@ -221,7 +221,9 @@ function App({ apis }: AppProps) { if (!data || cancelled) return; const openCodeRunning = data.openCodeRunning === true; const err = typeof data.lastOpenCodeError === 'string' ? data.lastOpenCodeError : ''; - const cliMissing = !openCodeRunning && /ENOENT|spawn\s+opencode|opencode(\.exe)?\s+not\s+found|not\s+found/i.test(err); + const cliMissing = + !openCodeRunning && + /ENOENT|spawn\s+opencode|Unable\s+to\s+locate\s+the\s+opencode\s+CLI|OpenCode\s+CLI\s+not\s+found|opencode(\.exe)?\s+not\s+found|env:\s*(node|bun):\s*No\s+such\s+file\s+or\s+directory|(node|bun):\s*No\s+such\s+file\s+or\s+directory/i.test(err); setShowCliOnboarding(cliMissing); } catch { // ignore diff --git a/packages/ui/src/components/onboarding/OnboardingScreen.tsx b/packages/ui/src/components/onboarding/OnboardingScreen.tsx index 23e95dbd..3c771b6d 100644 --- a/packages/ui/src/components/onboarding/OnboardingScreen.tsx +++ b/packages/ui/src/components/onboarding/OnboardingScreen.tsx @@ -1,6 +1,9 @@ import React from 'react'; import { RiFileCopyLine, RiCheckLine, RiExternalLinkLine } from '@remixicon/react'; import { isDesktopShell, isTauriShell } from '@/lib/desktop'; +import { Input } from '@/components/ui/input'; +import { Button } from '@/components/ui/button'; +import { updateDesktopSettings } from '@/lib/persistence'; const INSTALL_COMMAND = 'curl -fsSL https://opencode.ai/install | bash'; const POLL_INTERVAL_MS = 3000; @@ -37,6 +40,7 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) { const [showHint, setShowHint] = React.useState(false); const [isDesktopApp, setIsDesktopApp] = React.useState(false); const [isRetrying, setIsRetrying] = React.useState(false); + const [opencodeBinary, setOpencodeBinary] = React.useState(''); React.useEffect(() => { const timer = setTimeout(() => setShowHint(true), HINT_DELAY_MS); @@ -47,6 +51,27 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) { setIsDesktopApp(isDesktopShell()); }, []); + React.useEffect(() => { + let cancelled = false; + void (async () => { + try { + const response = await fetch('/api/config/settings', { method: 'GET', headers: { Accept: 'application/json' } }); + if (!response.ok) return; + const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown }; + if (!data || cancelled) return; + const value = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : ''; + if (value) { + setOpencodeBinary(value); + } + } catch { + // ignore + } + })(); + return () => { + cancelled = true; + }; + }, []); + const handleDragStart = React.useCallback(async (e: React.MouseEvent) => { if ((e.target as HTMLElement).closest('button, a, input, select, textarea, code')) { return; @@ -83,6 +108,43 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) { } }, []); + const handleBrowse = React.useCallback(async () => { + if (typeof window === 'undefined') { + return; + } + if (!isDesktopApp || !isTauriShell()) { + return; + } + + const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record) => Promise } } }).__TAURI__; + if (!tauri?.dialog?.open) { + return; + } + + try { + const selected = await tauri.dialog.open({ + title: 'Select opencode binary', + multiple: false, + directory: false, + }); + if (typeof selected === 'string' && selected.trim().length > 0) { + setOpencodeBinary(selected.trim()); + } + } catch { + // ignore + } + }, [isDesktopApp]); + + const handleApplyPath = React.useCallback(async () => { + setIsRetrying(true); + try { + await updateDesktopSettings({ opencodeBinary: opencodeBinary.trim() }); + await fetch('/api/config/reload', { method: 'POST' }); + } finally { + setTimeout(() => setIsRetrying(false), 1000); + } + }, [opencodeBinary]); + const handleCopy = React.useCallback(async () => { try { await navigator.clipboard.writeText(INSTALL_COMMAND); @@ -168,6 +230,39 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) { {isRetrying ? 'Retrying…' : 'Retry'} + +
+
+
Already installed? Set the OpenCode CLI path:
+
+ setOpencodeBinary(e.target.value)} + placeholder="/Users/you/.bun/bin/opencode" + disabled={isRetrying} + className="flex-1 font-mono text-xs" + /> + + +
+
+ Saves to ~/.config/openchamber/settings.json and reloads OpenCode configuration. +
+
+
{showHint && ( @@ -178,6 +273,9 @@ export function OnboardingScreen({ onCliAvailable }: OnboardingScreenProps) {

or set OPENCODE_BINARY environment variable.

+

+ If you see env: node: No such file or directory or env: bun: No such file or directory, install that runtime or ensure it is on PATH. +

)} diff --git a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx index 7ea8a693..0bb22743 100644 --- a/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx +++ b/packages/ui/src/components/sections/openchamber/OpenChamberPage.tsx @@ -8,6 +8,7 @@ import { GitSettings } from './GitSettings'; import { WorktreeSectionContent } from './WorktreeSectionContent'; import { NotificationSettings } from './NotificationSettings'; import { GitHubSettings } from './GitHubSettings'; +import { OpenCodeCliSettings } from './OpenCodeCliSettings'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; import { useDeviceInfo } from '@/lib/device'; import { isVSCodeRuntime, isWebRuntime } from '@/lib/desktop'; @@ -21,6 +22,7 @@ interface OpenChamberPageProps { export const OpenChamberPage: React.FC = ({ section }) => { const { isMobile } = useDeviceInfo(); const showAbout = isMobile && isWebRuntime(); + const isVSCode = isVSCodeRuntime(); // If no section specified, show all (mobile/legacy behavior) if (!section) { @@ -35,6 +37,11 @@ export const OpenChamberPage: React.FC = ({ section }) =>
+ {!isVSCode && ( +
+ +
+ )}
@@ -93,9 +100,15 @@ const ChatSectionContent: React.FC = () => { // Sessions section: Default model & agent, Session retention, Memory limits const SessionsSectionContent: React.FC = () => { + const isVSCode = isVSCodeRuntime(); return (
+ {!isVSCode && ( +
+ +
+ )}
diff --git a/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx new file mode 100644 index 00000000..504dc6ee --- /dev/null +++ b/packages/ui/src/components/sections/openchamber/OpenCodeCliSettings.tsx @@ -0,0 +1,123 @@ +import * as React from 'react'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { isDesktopShell, isTauriShell } from '@/lib/desktop'; +import { updateDesktopSettings } from '@/lib/persistence'; +import { reloadOpenCodeConfiguration } from '@/stores/useAgentsStore'; + +export const OpenCodeCliSettings: React.FC = () => { + const [value, setValue] = React.useState(''); + const [isLoading, setIsLoading] = React.useState(true); + const [isSaving, setIsSaving] = React.useState(false); + + React.useEffect(() => { + let cancelled = false; + void (async () => { + try { + const response = await fetch('/api/config/settings', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + if (!response.ok) { + return; + } + const data = (await response.json().catch(() => null)) as null | { opencodeBinary?: unknown }; + if (cancelled || !data) { + return; + } + const next = typeof data.opencodeBinary === 'string' ? data.opencodeBinary.trim() : ''; + setValue(next); + } catch { + // ignore + } finally { + if (!cancelled) { + setIsLoading(false); + } + } + })(); + return () => { + cancelled = true; + }; + }, []); + + const handleBrowse = React.useCallback(async () => { + if (typeof window === 'undefined') { + return; + } + + if (!isDesktopShell() || !isTauriShell()) { + return; + } + + const tauri = (window as unknown as { __TAURI__?: { dialog?: { open?: (opts: Record) => Promise } } }).__TAURI__; + if (!tauri?.dialog?.open) { + return; + } + + try { + const selected = await tauri.dialog.open({ + title: 'Select opencode binary', + multiple: false, + directory: false, + }); + if (typeof selected === 'string' && selected.trim().length > 0) { + setValue(selected.trim()); + } + } catch { + // ignore + } + }, []); + + const handleSaveAndReload = React.useCallback(async () => { + setIsSaving(true); + try { + await updateDesktopSettings({ opencodeBinary: value.trim() }); + await reloadOpenCodeConfiguration({ message: 'Restarting OpenCode…', mode: 'projects', scopes: ['all'] }); + } finally { + setIsSaving(false); + } + }, [value]); + + return ( +
+
+

OpenCode CLI

+

+ Optional absolute path to the opencode binary. + Useful when your desktop app launch environment has a stale PATH. + If your opencode shim requires Node/Bun (e.g. env node or env bun), make sure that runtime is installed. +

+
+ +
+ setValue(e.target.value)} + placeholder="/Users/you/.bun/bin/opencode" + disabled={isLoading || isSaving} + className="flex-1 font-mono text-xs" + /> + + +
+ +
+ Tip: you can also use OPENCODE_BINARY env var, but this setting persists in + ~/.config/openchamber/settings.json. +
+
+ ); +}; diff --git a/packages/ui/src/lib/api/types.ts b/packages/ui/src/lib/api/types.ts index 5db3812e..fead69cf 100644 --- a/packages/ui/src/lib/api/types.ts +++ b/packages/ui/src/lib/api/types.ts @@ -389,6 +389,7 @@ export interface SettingsPayload { darkThemeId?: string; lastDirectory?: string; homeDirectory?: string; + opencodeBinary?: string; projects?: ProjectEntry[]; activeProjectId?: string; approvedDirectories?: string[]; diff --git a/packages/ui/src/lib/desktop.ts b/packages/ui/src/lib/desktop.ts index 6e8714ad..1df46bb1 100644 --- a/packages/ui/src/lib/desktop.ts +++ b/packages/ui/src/lib/desktop.ts @@ -37,6 +37,8 @@ export type DesktopSettings = { darkThemeId?: string; lastDirectory?: string; homeDirectory?: string; + // Optional absolute path to `opencode` binary. + opencodeBinary?: string; projects?: ProjectEntry[]; activeProjectId?: string; approvedDirectories?: string[]; diff --git a/packages/ui/src/lib/persistence.ts b/packages/ui/src/lib/persistence.ts index 01196441..d3f07efd 100644 --- a/packages/ui/src/lib/persistence.ts +++ b/packages/ui/src/lib/persistence.ts @@ -350,6 +350,11 @@ const sanitizeWebSettings = (payload: unknown): DesktopSettings | null => { result.homeDirectory = candidate.homeDirectory; } + if (typeof candidate.opencodeBinary === 'string') { + const trimmed = candidate.opencodeBinary.trim(); + result.opencodeBinary = trimmed.length > 0 ? trimmed : undefined; + } + const projects = sanitizeProjects(candidate.projects); if (projects) { result.projects = projects; diff --git a/packages/vscode/package.json b/packages/vscode/package.json index 2a5c4444..9aa51025 100644 --- a/packages/vscode/package.json +++ b/packages/vscode/package.json @@ -196,6 +196,11 @@ "type": "string", "default": "", "description": "URL of an external OpenCode API server. Leave empty to auto-start a local instance." + }, + "openchamber.opencodeBinary": { + "type": "string", + "default": "", + "description": "Optional absolute path to the opencode CLI binary. Useful if PATH lookup fails. Requires window reload or API restart to apply." } } } @@ -224,7 +229,7 @@ }, "dependencies": { "@openchamber/ui": "workspace:*", - "@opencode-ai/sdk": "^1.1.48", + "@opencode-ai/sdk": "^1.1.52", "adm-zip": "^0.5.16", "jsonc-parser": "^3.3.1", "react": "^19.1.1", diff --git a/packages/vscode/src/bridge.ts b/packages/vscode/src/bridge.ts index ab4cfa3e..4596a559 100644 --- a/packages/vscode/src/bridge.ts +++ b/packages/vscode/src/bridge.ts @@ -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 => { + 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; + } + return {}; + } catch { + return {}; + } +}; + +const writeSharedSettingsToDisk = async (changes: Record): Promise => { + try { + await fs.promises.mkdir(path.dirname(OPENCHAMBER_SHARED_SETTINGS_PATH), { recursive: true }); + const current = readSharedSettingsFromDisk(); + const next: Record = { ...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>(SETTINGS_KEY) || {}; const restStored = { ...stored }; delete (restStored as Record).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, ctx?: BridgeCon const restChanges = { ...(changes || {}) }; delete restChanges.lastDirectory; + const keysToClear = new Set(); + // 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, ctx?: BridgeCon delete restChanges.usageRefreshIntervalMs; } - const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory }; + const merged = { ...current, ...restChanges, lastDirectory: current.lastDirectory } as Record; + 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; }; diff --git a/packages/vscode/src/extension.ts b/packages/vscode/src/extension.ts index 6e272495..04352756 100644 --- a/packages/vscode/src/extension.ts +++ b/packages/vscode/src/extension.ts @@ -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('opencodeBinary') || '').trim() || '(none)'}`, `API URL (resolved): ${openCodeManager?.getApiUrl() ?? '(none)'}`, `API URL path: ${resolvedApiPath || '(none)'}`, debug diff --git a/packages/vscode/src/opencode.ts b/packages/vscode/src/opencode.ts index d2deccbb..adbdddc2 100644 --- a/packages/vscode/src/opencode.ts +++ b/packages/vscode/src/opencode.ts @@ -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('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).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. diff --git a/packages/web/package.json b/packages/web/package.json index cf00c95e..c9591f13 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -26,7 +26,7 @@ "@fontsource/ibm-plex-sans": "^5.1.1", "@ibm/plex": "^6.4.1", "@octokit/rest": "^22.0.1", - "@opencode-ai/sdk": "^1.1.48", + "@opencode-ai/sdk": "^1.1.52", "@radix-ui/react-collapsible": "^1.1.12", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-dropdown-menu": "^2.1.16", diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 9b547cda..d5504517 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -948,6 +948,14 @@ const sanitizeSettingsUpdate = (payload) => { if (typeof candidate.homeDirectory === 'string' && candidate.homeDirectory.length > 0) { result.homeDirectory = candidate.homeDirectory; } + + // Absolute path to the opencode CLI binary (optional override). + // Accept empty-string to clear (we persist an empty string sentinel so the running + // process can reliably drop a previously applied OPENCODE_BINARY override). + if (typeof candidate.opencodeBinary === 'string') { + const normalized = normalizeDirectoryPath(candidate.opencodeBinary).trim(); + result.opencodeBinary = normalized; + } if (Array.isArray(candidate.projects)) { const projects = sanitizeProjects(candidate.projects); if (projects) { @@ -1867,6 +1875,8 @@ const ENV_CONFIGURED_API_PREFIX = normalizeApiPrefix( let globalEventWatcherAbortController = null; let resolvedOpencodeBinary = null; +let resolvedNodeBinary = null; +let resolvedBunBinary = null; function isExecutable(filePath) { try { @@ -2006,8 +2016,299 @@ function resolveOpencodeCliPath() { return null; } +function resolveNodeCliPath() { + const explicit = [process.env.NODE_BINARY, process.env.OPENCHAMBER_NODE_BINARY] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + return candidate; + } + } + + const resolvedFromPath = searchPathFor('node'); + if (resolvedFromPath) { + return resolvedFromPath; + } + + const unixFallbacks = [ + '/opt/homebrew/bin/node', + '/usr/local/bin/node', + '/usr/bin/node', + '/bin/node', + ]; + for (const candidate of unixFallbacks) { + if (isExecutable(candidate)) { + return candidate; + } + } + + if (process.platform === 'win32') { + try { + const result = spawnSync('where', ['node'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) return found; + } + } catch { + // ignore + } + return null; + } + + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); + for (const shell of shells) { + if (!isExecutable(shell)) continue; + try { + const result = spawnSync(shell, ['-lic', 'command -v node'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + // ignore + } + } + + return null; +} + +function resolveBunCliPath() { + const explicit = [process.env.BUN_BINARY, process.env.OPENCHAMBER_BUN_BINARY] + .map((v) => (typeof v === 'string' ? v.trim() : '')) + .filter(Boolean); + + for (const candidate of explicit) { + if (isExecutable(candidate)) { + return candidate; + } + } + + const resolvedFromPath = searchPathFor('bun'); + if (resolvedFromPath) { + return resolvedFromPath; + } + + const home = os.homedir(); + const unixFallbacks = [ + path.join(home, '.bun', 'bin', 'bun'), + '/opt/homebrew/bin/bun', + '/usr/local/bin/bun', + '/usr/bin/bun', + '/bin/bun', + ]; + for (const candidate of unixFallbacks) { + if (isExecutable(candidate)) { + return candidate; + } + } + + if (process.platform === 'win32') { + const userProfile = process.env.USERPROFILE || home; + const winFallbacks = [ + path.join(userProfile, '.bun', 'bin', 'bun.exe'), + path.join(userProfile, '.bun', 'bin', 'bun.cmd'), + ]; + for (const candidate of winFallbacks) { + if (isExecutable(candidate)) return candidate; + } + + try { + const result = spawnSync('where', ['bun'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status === 0) { + const lines = (result.stdout || '') + .split(/\r?\n/) + .map((line) => line.trim()) + .filter(Boolean); + const found = lines.find((line) => isExecutable(line)); + if (found) return found; + } + } catch { + // ignore + } + return null; + } + + const shells = [process.env.SHELL, '/bin/zsh', '/bin/bash', '/bin/sh'].filter(Boolean); + for (const shell of shells) { + if (!isExecutable(shell)) continue; + try { + const result = spawnSync(shell, ['-lic', 'command -v bun'], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'pipe'], + }); + if (result.status === 0) { + const found = (result.stdout || '').trim().split(/\s+/).pop() || ''; + if (found && isExecutable(found)) { + return found; + } + } + } catch { + // ignore + } + } + + return null; +} + +function ensureBunCliEnv() { + if (resolvedBunBinary) { + return resolvedBunBinary; + } + + const resolved = resolveBunCliPath(); + if (resolved) { + prependToPath(path.dirname(resolved)); + resolvedBunBinary = resolved; + return resolved; + } + + return null; +} + +function ensureNodeCliEnv() { + if (resolvedNodeBinary) { + return resolvedNodeBinary; + } + + const resolved = resolveNodeCliPath(); + if (resolved) { + prependToPath(path.dirname(resolved)); + resolvedNodeBinary = resolved; + return resolved; + } + + return null; +} + +function readShebang(opencodePath) { + if (!opencodePath || typeof opencodePath !== 'string') { + return null; + } + try { + // Best effort: detect "#!/usr/bin/env " without reading whole file. + const fd = fs.openSync(opencodePath, 'r'); + try { + const buf = Buffer.alloc(256); + const bytes = fs.readSync(fd, buf, 0, buf.length, 0); + const head = buf.subarray(0, bytes).toString('utf8'); + const firstLine = head.split(/\r?\n/, 1)[0] || ''; + if (!firstLine.startsWith('#!')) { + return null; + } + const shebang = firstLine.slice(2).trim(); + if (!shebang) { + return null; + } + return shebang; + } finally { + try { + fs.closeSync(fd); + } catch { + // ignore + } + } + } catch { + return null; + } +} + +function opencodeShimInterpreter(opencodePath) { + const shebang = readShebang(opencodePath); + if (!shebang) return null; + if (/\bnode\b/i.test(shebang)) return 'node'; + if (/\bbun\b/i.test(shebang)) return 'bun'; + return null; +} + +function ensureOpencodeShimRuntime(opencodePath) { + const runtime = opencodeShimInterpreter(opencodePath); + if (runtime === 'node') { + ensureNodeCliEnv(); + } + if (runtime === 'bun') { + ensureBunCliEnv(); + } +} + +function normalizeOpencodeBinarySetting(raw) { + if (typeof raw !== 'string') { + return null; + } + const trimmed = normalizeDirectoryPath(raw).trim(); + if (!trimmed) { + return ''; + } + + try { + const stat = fs.statSync(trimmed); + if (stat.isDirectory()) { + const bin = process.platform === 'win32' ? 'opencode.exe' : 'opencode'; + return path.join(trimmed, bin); + } + } catch { + // ignore + } + + return trimmed; +} + +async function applyOpencodeBinaryFromSettings() { + try { + const settings = await readSettingsFromDiskMigrated(); + if (!settings || typeof settings !== 'object') { + return null; + } + if (!Object.prototype.hasOwnProperty.call(settings, 'opencodeBinary')) { + return null; + } + + const normalized = normalizeOpencodeBinarySetting(settings.opencodeBinary); + + if (normalized === '') { + delete process.env.OPENCODE_BINARY; + resolvedOpencodeBinary = null; + return null; + } + + if (normalized && isExecutable(normalized)) { + process.env.OPENCODE_BINARY = normalized; + prependToPath(path.dirname(normalized)); + resolvedOpencodeBinary = normalized; + ensureOpencodeShimRuntime(normalized); + return normalized; + } + + const raw = typeof settings.opencodeBinary === 'string' ? settings.opencodeBinary.trim() : ''; + if (raw) { + console.warn(`Configured settings.opencodeBinary is not executable: ${raw}`); + } + } catch { + // ignore + } + + return null; +} + function ensureOpencodeCliEnv() { if (resolvedOpencodeBinary) { + ensureOpencodeShimRuntime(resolvedOpencodeBinary); return resolvedOpencodeBinary; } @@ -2015,6 +2316,7 @@ function ensureOpencodeCliEnv() { if (existing && isExecutable(existing)) { resolvedOpencodeBinary = existing; prependToPath(path.dirname(existing)); + ensureOpencodeShimRuntime(existing); return resolvedOpencodeBinary; } @@ -2022,6 +2324,7 @@ function ensureOpencodeCliEnv() { if (resolved) { process.env.OPENCODE_BINARY = resolved; prependToPath(path.dirname(resolved)); + ensureOpencodeShimRuntime(resolved); resolvedOpencodeBinary = resolved; console.log(`Resolved opencode CLI: ${resolved}`); return resolved; @@ -2874,6 +3177,7 @@ async function startOpenCode() { ); // Note: SDK starts in current process CWD. openCodeWorkingDirectory is tracked but not used for spawn in SDK. + await applyOpencodeBinaryFromSettings(); ensureOpencodeCliEnv(); try { @@ -2913,10 +3217,11 @@ async function startOpenCode() { throw new Error('Server started but health check failed (timeout)'); } } catch (error) { - lastOpenCodeError = error.message; + const message = error instanceof Error ? error.message : String(error); + lastOpenCodeError = message; openCodePort = null; syncToHmrState(); - console.error(`Failed to start OpenCode: ${error.message}`); + console.error(`Failed to start OpenCode: ${message}`); throw error; } } @@ -3162,6 +3467,11 @@ async function refreshOpenCodeAfterConfigChange(reason, options = {}) { const { agentName } = options; console.log(`Refreshing OpenCode after ${reason}`); + + // Settings might include a new opencodeBinary; drop cache before restart. + resolvedOpencodeBinary = null; + await applyOpencodeBinaryFromSettings(); + await restartOpenCode(); try {