From b7e7a8791c0beb65a9406905e6178e9cb09e3838 Mon Sep 17 00:00:00 2001 From: Benjamin Shafii Date: Thu, 15 Jan 2026 14:23:02 -0800 Subject: [PATCH] Add engine doctor and guided install --- .opencode/skill/opencode-mirror/SKILL.md | 2 +- .opencode/skill/openwork-core/SKILL.md | 4 +- prd-opencode-install.md | 8 +- src-tauri/src/lib.rs | 190 ++++++++++++++++++++++- src/App.tsx | 163 ++++++++++++++++++- src/lib/tauri.ts | 17 ++ 6 files changed, 375 insertions(+), 9 deletions(-) diff --git a/.opencode/skill/opencode-mirror/SKILL.md b/.opencode/skill/opencode-mirror/SKILL.md index 9d5c579864..cf3359934e 100644 --- a/.opencode/skill/opencode-mirror/SKILL.md +++ b/.opencode/skill/opencode-mirror/SKILL.md @@ -19,5 +19,5 @@ git -C vendor/opencode pull --ff-only ### Clone mirror ```bash -git clone https://github.com/opencode-ai/opencode vendor/opencode +git clone https://github.com/anomalyco/opencode vendor/opencode ``` diff --git a/.opencode/skill/openwork-core/SKILL.md b/.opencode/skill/openwork-core/SKILL.md index cd92a4660e..55fddb7d8d 100644 --- a/.opencode/skill/openwork-core/SKILL.md +++ b/.opencode/skill/openwork-core/SKILL.md @@ -58,7 +58,7 @@ opencode -p "your prompt" -f json -q ### Clone the OpenCode mirror ```bash -git clone https://github.com/opencode-ai/opencode vendor/opencode +git clone https://github.com/anomalyco/opencode vendor/opencode ``` ### Initialize Tauri project @@ -81,5 +81,5 @@ pnpm tauri android init ### Clone the OpenCode mirror ```bash -git clone https://github.com/opencode-ai/opencode vendor/opencode +git clone https://github.com/anomalyco/opencode vendor/opencode ``` diff --git a/prd-opencode-install.md b/prd-opencode-install.md index f6bc9321d6..ecc8e3addb 100644 --- a/prd-opencode-install.md +++ b/prd-opencode-install.md @@ -132,8 +132,8 @@ Add a Tauri command to check engine availability and return structured info: Candidate URLs: -- `https://opencode.ai/install` (if this is the canonical stable redirect) -- `https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install` (as documented upstream) +- `https://opencode.ai/install` (canonical stable installer URL) +- `https://raw.githubusercontent.com/anomalyco/opencode/dev/install` (direct script fallback; matches upstream `dev` branch) Execution strategy (conceptual): @@ -183,9 +183,9 @@ OpenWork should show OS-specific instructions with copy buttons. Example: -- macOS (Homebrew): `brew install opencode-ai/tap/opencode` +- macOS/Linux (Homebrew, recommended): `brew install anomalyco/tap/opencode` - macOS/Linux (script): `curl -fsSL https://opencode.ai/install | bash` -- Linux (script fallback): `curl -fsSL https://raw.githubusercontent.com/opencode-ai/opencode/refs/heads/main/install | bash` +- macOS/Linux (script fallback): `curl -fsSL https://raw.githubusercontent.com/anomalyco/opencode/dev/install | bash` ## Acceptance Criteria diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index 9f979d0117..0b6d3b5f8a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -1,5 +1,6 @@ use std::{ env, + ffi::OsStr, fs, net::TcpListener, path::{Path, PathBuf}, @@ -35,6 +36,17 @@ pub struct EngineInfo { pub pid: Option, } +#[derive(Debug, Serialize, Clone)] +#[serde(rename_all = "camelCase")] +pub struct EngineDoctorResult { + pub found: bool, + pub in_path: bool, + pub resolved_path: Option, + pub version: Option, + pub supports_serve: bool, + pub notes: Vec, +} + #[derive(Debug, Serialize, Clone)] #[serde(rename_all = "camelCase")] pub struct ExecResult { @@ -58,6 +70,114 @@ fn find_free_port() -> Result { Ok(port) } +#[cfg(windows)] +const OPENCODE_EXECUTABLE: &str = "opencode.exe"; + +#[cfg(not(windows))] +const OPENCODE_EXECUTABLE: &str = "opencode"; + +fn home_dir() -> Option { + if let Ok(home) = env::var("HOME") { + if !home.trim().is_empty() { + return Some(PathBuf::from(home)); + } + } + + if let Ok(profile) = env::var("USERPROFILE") { + if !profile.trim().is_empty() { + return Some(PathBuf::from(profile)); + } + } + + None +} + +fn path_entries() -> Vec { + let mut entries = Vec::new(); + let Some(path) = env::var_os("PATH") else { + return entries; + }; + + entries.extend(env::split_paths(&path)); + entries +} + +fn resolve_in_path(name: &str) -> Option { + for dir in path_entries() { + let candidate = dir.join(name); + if candidate.is_file() { + return Some(candidate); + } + } + None +} + +fn candidate_opencode_paths() -> Vec { + let mut candidates = Vec::new(); + + if let Some(home) = home_dir() { + candidates.push(home.join(".opencode").join("bin").join(OPENCODE_EXECUTABLE)); + } + + // Homebrew default paths. + candidates.push(PathBuf::from("/opt/homebrew/bin").join(OPENCODE_EXECUTABLE)); + candidates.push(PathBuf::from("/usr/local/bin").join(OPENCODE_EXECUTABLE)); + + // Common Linux paths. + candidates.push(PathBuf::from("/usr/bin").join(OPENCODE_EXECUTABLE)); + candidates.push(PathBuf::from("/usr/local/bin").join(OPENCODE_EXECUTABLE)); + + candidates +} + +fn opencode_version(program: &OsStr) -> Option { + let output = Command::new(program).arg("--version").output().ok()?; + let stdout = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + + if !stdout.is_empty() { + return Some(stdout); + } + if !stderr.is_empty() { + return Some(stderr); + } + + None +} + +fn opencode_supports_serve(program: &OsStr) -> bool { + Command::new(program) + .arg("serve") + .arg("--help") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn resolve_opencode_executable() -> (Option, bool, Vec) { + let mut notes = Vec::new(); + + if let Some(path) = resolve_in_path(OPENCODE_EXECUTABLE) { + notes.push(format!("Found in PATH: {}", path.display())); + return (Some(path), true, notes); + } + + notes.push("Not found on PATH".to_string()); + + for candidate in candidate_opencode_paths() { + if candidate.is_file() { + notes.push(format!("Found at {}", candidate.display())); + return (Some(candidate), false, notes); + } + + notes.push(format!("Missing: {}", candidate.display())); + } + + (None, false, notes) +} + fn run_capture_optional(command: &mut Command) -> Result, String> { match command.output() { Ok(output) => { @@ -181,6 +301,64 @@ fn engine_stop(manager: State) -> EngineInfo { EngineManager::snapshot_locked(&mut state) } +#[tauri::command] +fn engine_doctor() -> EngineDoctorResult { + let (resolved, in_path, notes) = resolve_opencode_executable(); + + let (version, supports_serve) = match resolved.as_ref() { + Some(path) => ( + opencode_version(path.as_os_str()), + opencode_supports_serve(path.as_os_str()), + ), + None => (None, false), + }; + + EngineDoctorResult { + found: resolved.is_some(), + in_path, + resolved_path: resolved.map(|path| path.to_string_lossy().to_string()), + version, + supports_serve, + notes, + } +} + +#[tauri::command] +fn engine_install() -> Result { + #[cfg(windows)] + { + return Ok(ExecResult { + ok: false, + status: -1, + stdout: String::new(), + stderr: "Guided install is not supported on Windows yet. Install OpenCode via Scoop/Chocolatey or https://opencode.ai/install, then restart OpenWork.".to_string(), + }); + } + + #[cfg(not(windows))] + { + let install_dir = home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".opencode") + .join("bin"); + + let output = Command::new("bash") + .arg("-lc") + .arg("curl -fsSL https://opencode.ai/install | bash") + .env("OPENCODE_INSTALL_DIR", install_dir) + .output() + .map_err(|e| format!("Failed to run installer: {e}"))?; + + let status = output.status.code().unwrap_or(-1); + Ok(ExecResult { + ok: output.status.success(), + status, + stdout: String::from_utf8_lossy(&output.stdout).to_string(), + stderr: String::from_utf8_lossy(&output.stderr).to_string(), + }) + } +} + #[tauri::command] fn engine_start(manager: State, project_dir: String) -> Result { let project_dir = project_dir.trim().to_string(); @@ -196,7 +374,15 @@ fn engine_start(manager: State, project_dir: String) -> Result("home"); const [engine, setEngine] = createSignal(null); + const [engineDoctorResult, setEngineDoctorResult] = createSignal(null); + const [engineDoctorCheckedAt, setEngineDoctorCheckedAt] = createSignal(null); + const [engineInstallLogs, setEngineInstallLogs] = createSignal(null); const [projectDir, setProjectDir] = createSignal(""); const [authorizedDirs, setAuthorizedDirs] = createSignal([]); @@ -607,6 +613,20 @@ export default function App() { } } + async function refreshEngineDoctor() { + if (!isTauriRuntime()) return; + + try { + const result = await engineDoctor(); + setEngineDoctorResult(result); + setEngineDoctorCheckedAt(Date.now()); + } catch (e) { + setEngineDoctorResult(null); + setEngineDoctorCheckedAt(Date.now()); + setEngineInstallLogs(e instanceof Error ? e.message : safeStringify(e)); + } + } + async function loadSessions(c: Client) { const list = unwrap(await c.session.list()); setSessions(list); @@ -672,6 +692,26 @@ export default function App() { return false; } + try { + const result = await engineDoctor(); + setEngineDoctorResult(result); + setEngineDoctorCheckedAt(Date.now()); + + if (!result.found) { + setError( + "OpenCode CLI not found. Install with `brew install anomalyco/tap/opencode` or `curl -fsSL https://opencode.ai/install | bash`, then retry.", + ); + return false; + } + + if (!result.supportsServe) { + setError("OpenCode CLI is installed, but `opencode serve` is unavailable. Update OpenCode and retry."); + return false; + } + } catch (e) { + setEngineInstallLogs(e instanceof Error ? e.message : safeStringify(e)); + } + setError(null); setBusy(true); setBusyLabel("Starting engine"); @@ -1198,6 +1238,7 @@ export default function App() { } await refreshEngine(); + await refreshEngineDoctor(); const info = engine(); if (info?.baseUrl) { @@ -1256,6 +1297,12 @@ export default function App() { } }); + createEffect(() => { + if (!isTauriRuntime()) return; + if (onboardingStep() !== "host") return; + void refreshEngineDoctor(); + }); + createEffect(() => { if (typeof window === "undefined") return; try { @@ -1642,6 +1689,115 @@ export default function App() { + +
+
+
+
OpenCode CLI
+
+ Checking install…} + > + Not found. Install to run Host mode.} + > + + {engineDoctorResult()?.version ?? "Installed"} + + + · + + {engineDoctorResult()?.resolvedPath} + + + + +
+
+ + +
+ + +
+
Install one of these:
+
+ brew install anomalyco/tap/opencode +
+
+ curl -fsSL https://opencode.ai/install | bash +
+ +
+ + +
+
+
+ + +
{engineInstallLogs()}
+
+ + +
+ Last checked {new Date(engineDoctorCheckedAt()!).toLocaleTimeString()} +
+
+
+
+