From d19bed6c50e4799ef51a9f1dbdb597b6ed0e9b8c Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 1 Apr 2026 12:52:39 +1100 Subject: [PATCH 1/3] feat: extract doctor health-check system into shared crate MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create a new crates/doctor/ crate containing the health-check system for verifying external tool dependencies (git, gh, git-lfs, AI agents, etc.). The crate exposes: - types: CheckStatus, DoctorCheck, DoctorReport, FixType, ResolvedBinary - resolve: binary resolution via login shell and common install paths - checks: individual health-check functions for git, gh, gh-auth, git-lfs, clonefile - agents: AI agent checks (goose, claude, codex, pi, amp) and fix command lookup - run_checks(): async orchestrator that runs all checks in parallel - execute_fix(): lookup-based fix execution by check ID and fix type - execute_command(): raw command execution for backward compatibility The Staged app's doctor.rs is replaced with thin Tauri command wrappers that delegate to the crate. The existing frontend API (run_doctor_fix accepting a raw command string) is preserved via execute_command(). The new FixType/fix_type fields on DoctorCheck are additive — the frontend will simply ignore the extra camelCase JSON field until it adopts the new API. --- Cargo.lock | 8 + apps/staged/src-tauri/Cargo.lock | 9 + apps/staged/src-tauri/Cargo.toml | 1 + apps/staged/src-tauri/src/doctor.rs | 971 +--------------------------- crates/doctor/Cargo.toml | 9 + crates/doctor/src/agents.rs | 254 ++++++++ crates/doctor/src/checks.rs | 438 +++++++++++++ crates/doctor/src/lib.rs | 193 ++++++ crates/doctor/src/resolve.rs | 74 +++ crates/doctor/src/types.rs | 65 ++ 10 files changed, 1056 insertions(+), 966 deletions(-) create mode 100644 crates/doctor/Cargo.toml create mode 100644 crates/doctor/src/agents.rs create mode 100644 crates/doctor/src/checks.rs create mode 100644 crates/doctor/src/lib.rs create mode 100644 crates/doctor/src/resolve.rs create mode 100644 crates/doctor/src/types.rs diff --git a/Cargo.lock b/Cargo.lock index 2833c2d5..a5b7a916 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -553,6 +553,14 @@ dependencies = [ "syn", ] +[[package]] +name = "doctor" +version = "0.1.0" +dependencies = [ + "serde", + "tokio", +] + [[package]] name = "dunce" version = "1.0.5" diff --git a/apps/staged/src-tauri/Cargo.lock b/apps/staged/src-tauri/Cargo.lock index 23f33be2..3332790f 100644 --- a/apps/staged/src-tauri/Cargo.lock +++ b/apps/staged/src-tauri/Cargo.lock @@ -14,6 +14,7 @@ dependencies = [ "blox-cli", "builderbot-actions", "dirs", + "doctor", "git-diff", "include_dir", "log", @@ -1190,6 +1191,14 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "doctor" +version = "0.1.0" +dependencies = [ + "serde", + "tokio", +] + [[package]] name = "dom_query" version = "0.27.0" diff --git a/apps/staged/src-tauri/Cargo.toml b/apps/staged/src-tauri/Cargo.toml index 15e06d6d..b33d1c2b 100644 --- a/apps/staged/src-tauri/Cargo.toml +++ b/apps/staged/src-tauri/Cargo.toml @@ -46,6 +46,7 @@ tauri-plugin-updater = "2" # Shared crates git-diff = { path = "../../../crates/git-diff" } +doctor = { path = "../../../crates/doctor" } # Actions framework builderbot-actions = { path = "../../../crates/builderbot-actions" } diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index 88b9335b..1f41f108 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -1,978 +1,17 @@ -//! Health Check ("Doctor") — backend checks for external dependencies. -//! -//! Each check probes a single external dependency and returns a status -//! (pass / warn / fail) with a human-readable summary and an optional -//! URL the user can visit to install or configure the dependency. +//! Tauri command wrappers for the doctor health-check system. -use serde::{Deserialize, Serialize}; -use std::collections::HashMap; -use std::path::PathBuf; -use std::process::Command; - -// ============================================================================= -// Types -// ============================================================================= - -/// Severity level for a single check. -#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] -#[serde(rename_all = "lowercase")] -pub enum CheckStatus { - Pass, - Warn, - Fail, -} - -/// A single health-check result shown in the UI. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DoctorCheck { - /// Short identifier, e.g. "git" - pub id: String, - /// Human-readable label, e.g. "Git" - pub label: String, - pub status: CheckStatus, - /// One-line explanation shown next to the status badge. - pub message: String, - /// If non-None, the UI shows an "Install" link that opens this URL. - pub fix_url: Option, - /// If non-None, the UI shows a "Fix" button that runs this shell command. - pub fix_command: Option, - /// If non-None, the resolved path to the main executable on disk. - pub path: Option, - /// If non-None, the resolved path to the ACP bridge executable on disk. - pub bridge_path: Option, - /// Raw debug output: command stdout/stderr, search paths tried, etc. - /// Used by the "Copy details" feature for support diagnostics. - pub raw_output: Option, -} - -/// The full report returned to the frontend. -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(rename_all = "camelCase")] -pub struct DoctorReport { - pub checks: Vec, -} - -// ============================================================================= -// Binary resolution -// ============================================================================= - -/// A resolved binary: the path (if found) and the diagnostic search trace. -#[derive(Clone)] -struct ResolvedBinary { - path: Option, - search_output: String, -} - -/// Resolve a binary by trying login shell `which` then common install paths. -/// -/// Combines the work of `find_command` and diagnostic search output into a -/// single pass, so each binary only needs to be resolved once. -fn resolve_binary(cmd: &str) -> ResolvedBinary { - let mut lines = vec![format!("resolve '{cmd}':")]; - - // Strategy 1: Login shell `which` (primary) - lines.push(" strategy 1 — login shell `which`:".to_string()); - for shell in &["/bin/zsh", "/bin/bash"] { - let which_cmd = format!("which {cmd}"); - match Command::new(shell).args(["-l", "-c", &which_cmd]).output() { - Ok(output) => { - let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); - if output.status.success() && !result.is_empty() { - lines.push(format!( - " {shell} -l -c 'which {cmd}' => {result} (resolved)" - )); - return ResolvedBinary { - path: Some(PathBuf::from(&result)), - search_output: lines.join("\n"), - }; - } - lines.push(format!(" {shell} -l -c 'which {cmd}' => not found")); - } - Err(e) => { - lines.push(format!(" {shell} -l -c 'which {cmd}' => error: {e}")); - } - } - } - - // Strategy 2: Common install paths (fallback) - lines.push(" strategy 2 — common install paths (fallback):".to_string()); - for dir in &[ - "/opt/homebrew/bin", - "/usr/local/bin", - "/usr/bin", - "/home/linuxbrew/.linuxbrew/bin", - ] { - let path = PathBuf::from(dir).join(cmd); - if path.exists() { - lines.push(format!(" {} => found (resolved)", path.display())); - return ResolvedBinary { - path: Some(path), - search_output: lines.join("\n"), - }; - } - lines.push(format!(" {} => not found", path.display())); - } - - lines.push(" not found in any location".to_string()); - ResolvedBinary { - path: None, - search_output: lines.join("\n"), - } -} - -// ============================================================================= -// Individual checks -// ============================================================================= - -/// Format the raw output of a command invocation for debug diagnostics. -fn format_command_output(cmd_desc: &str, output: &std::process::Output) -> String { - let stdout = String::from_utf8_lossy(&output.stdout); - let stderr = String::from_utf8_lossy(&output.stderr); - let mut raw = format!("$ {cmd_desc}\nexit code: {}", output.status); - if !stdout.trim().is_empty() { - raw.push_str(&format!("\nstdout:\n{}", stdout.trim())); - } - if !stderr.trim().is_empty() { - raw.push_str(&format!("\nstderr:\n{}", stderr.trim())); - } - raw -} - -/// Check that `git` is installed and reachable. -fn check_git(resolved: &ResolvedBinary) -> DoctorCheck { - let label = "Git".to_string(); - let id = "git".to_string(); - let search = &resolved.search_output; - let header = "# Check: Git — verify git is installed and reachable"; - - let git_path = match &resolved.path { - Some(p) => p, - None => { - return DoctorCheck { - id, - label, - status: CheckStatus::Fail, - message: "Git not found".to_string(), - fix_url: Some("https://git-scm.com/downloads".to_string()), - fix_command: None, - path: None, - bridge_path: None, - raw_output: Some(format!("{header}\nnot found via resolve_binary\n{search}")), - }; - } - }; - let path_str = git_path.to_string_lossy().to_string(); - - match Command::new(git_path).arg("--version").output() { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let raw = format!( - "{header}\n{}\n{}", - format_command_output("git --version", &output), - search - ); - DoctorCheck { - id, - label, - status: CheckStatus::Pass, - message: version, - fix_url: None, - fix_command: None, - path: Some(path_str), - bridge_path: None, - raw_output: Some(raw), - } - } - Ok(output) => { - let raw = format!( - "{header}\n{}\n{}", - format_command_output("git --version", &output), - search - ); - DoctorCheck { - id, - label, - status: CheckStatus::Fail, - message: "Git not found".to_string(), - fix_url: Some("https://git-scm.com/downloads".to_string()), - fix_command: None, - path: Some(path_str), - bridge_path: None, - raw_output: Some(raw), - } - } - Err(e) => DoctorCheck { - id, - label, - status: CheckStatus::Fail, - message: "Git not found".to_string(), - fix_url: Some("https://git-scm.com/downloads".to_string()), - fix_command: None, - path: Some(path_str), - bridge_path: None, - raw_output: Some(format!("{header}\n$ git --version\nerror: {e}\n{search}")), - }, - } -} - -/// Check that the GitHub CLI (`gh`) is installed. -fn check_gh(resolved: &ResolvedBinary) -> DoctorCheck { - let label = "GitHub CLI".to_string(); - let id = "gh".to_string(); - let search = &resolved.search_output; - let header = "# Check: GitHub CLI — verify gh is installed"; - - let gh_path = match &resolved.path { - Some(p) => p, - None => { - return DoctorCheck { - id, - label, - status: CheckStatus::Fail, - message: "GitHub CLI not found".to_string(), - fix_url: Some("https://cli.github.com".to_string()), - fix_command: None, - path: None, - bridge_path: None, - raw_output: Some(format!("{header}\nnot found via resolve_binary\n{search}")), - }; - } - }; - let path_str = gh_path.to_string_lossy().to_string(); - - match Command::new(gh_path).arg("--version").output() { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout); - let first_line = version.lines().next().unwrap_or("gh").trim().to_string(); - let raw = format!( - "{header}\n{}\n{}", - format_command_output("gh --version", &output), - search - ); - DoctorCheck { - id, - label, - status: CheckStatus::Pass, - message: first_line, - fix_url: None, - fix_command: None, - path: Some(path_str), - bridge_path: None, - raw_output: Some(raw), - } - } - Ok(output) => { - let raw = format!( - "{header}\n{}\n{}", - format_command_output("gh --version", &output), - search - ); - DoctorCheck { - id, - label, - status: CheckStatus::Fail, - message: "GitHub CLI not found".to_string(), - fix_url: Some("https://cli.github.com".to_string()), - fix_command: None, - path: Some(path_str), - bridge_path: None, - raw_output: Some(raw), - } - } - Err(e) => DoctorCheck { - id, - label, - status: CheckStatus::Fail, - message: "GitHub CLI not found".to_string(), - fix_url: Some("https://cli.github.com".to_string()), - fix_command: None, - path: Some(path_str), - bridge_path: None, - raw_output: Some(format!("{header}\n$ gh --version\nerror: {e}\n{search}")), - }, - } -} - -/// Check that `gh auth status` succeeds (user is logged in). -/// -/// Uses the pre-resolved `gh` path to run `gh auth status` directly, -/// avoiding a redundant binary resolution and command invocation. -fn check_gh_auth(gh: &ResolvedBinary) -> DoctorCheck { - let label = "GitHub Auth".to_string(); - let id = "gh-auth".to_string(); - let header = "# Check: GitHub Auth — verify user is logged in to GitHub"; - - let gh_path = match &gh.path { - Some(p) => p, - None => { - return DoctorCheck { - id, - label, - status: CheckStatus::Fail, - message: "GitHub CLI not found — install gh first".to_string(), - fix_url: Some("https://cli.github.com".to_string()), - fix_command: None, - path: None, - bridge_path: None, - raw_output: Some(format!("{header}\ngh not found via resolve_binary")), - }; - } - }; - - match Command::new(gh_path).args(["auth", "status"]).output() { - Ok(output) => { - let raw = format!( - "{header}\n{}", - format_command_output("gh auth status", &output) - ); - if output.status.success() { - DoctorCheck { - id, - label, - status: CheckStatus::Pass, - message: "Authenticated".to_string(), - fix_url: None, - fix_command: None, - path: None, - bridge_path: None, - raw_output: Some(raw), - } - } else { - let stderr = String::from_utf8_lossy(&output.stderr); - let hint = if stderr.contains("not logged in") || stderr.contains("no oauth token") - { - "Not authenticated — run `gh auth login`".to_string() - } else { - "Not authenticated".to_string() - }; - DoctorCheck { - id, - label, - status: CheckStatus::Fail, - message: hint, - fix_url: Some("https://cli.github.com/manual/gh_auth_login".to_string()), - fix_command: None, - path: None, - bridge_path: None, - raw_output: Some(raw), - } - } - } - Err(e) => DoctorCheck { - id, - label, - status: CheckStatus::Fail, - message: "Not authenticated".to_string(), - fix_url: Some("https://cli.github.com/manual/gh_auth_login".to_string()), - fix_command: None, - path: None, - bridge_path: None, - raw_output: Some(format!("{header}\n$ gh auth status\nerror: {e}")), - }, - } -} - -/// Check that Git LFS is installed. -fn check_git_lfs(git: &ResolvedBinary, git_lfs: &ResolvedBinary) -> DoctorCheck { - let label = "Git LFS".to_string(); - let id = "git-lfs".to_string(); - let search = &git_lfs.search_output; - let header = - "# Check: Git LFS — verify git-lfs is installed (optional, needed for large files)"; - - let git_path = match &git.path { - Some(p) => p, - None => { - return DoctorCheck { - id, - label, - status: CheckStatus::Warn, - message: "Git LFS not installed (optional, needed for large files)".to_string(), - fix_url: Some("https://git-lfs.com".to_string()), - fix_command: None, - path: None, - bridge_path: None, - raw_output: Some(format!( - "{header}\ngit not found via resolve_binary\n{search}" - )), - }; - } - }; - - match Command::new(git_path).args(["lfs", "version"]).output() { - Ok(output) if output.status.success() => { - let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); - let path = git_lfs - .path - .as_ref() - .map(|p| p.to_string_lossy().to_string()); - let raw = format!( - "{header}\n{}\n{}", - format_command_output("git lfs version", &output), - search - ); - DoctorCheck { - id, - label, - status: CheckStatus::Pass, - message: version, - fix_url: None, - fix_command: None, - path, - bridge_path: None, - raw_output: Some(raw), - } - } - Ok(output) => { - let raw = format!( - "{header}\n{}\n{}", - format_command_output("git lfs version", &output), - search - ); - DoctorCheck { - id, - label, - status: CheckStatus::Warn, - message: "Git LFS not installed (optional, needed for large files)".to_string(), - fix_url: Some("https://git-lfs.com".to_string()), - fix_command: None, - path: None, - bridge_path: None, - raw_output: Some(raw), - } - } - Err(e) => DoctorCheck { - id, - label, - status: CheckStatus::Warn, - message: "Git LFS not installed (optional, needed for large files)".to_string(), - fix_url: Some("https://git-lfs.com".to_string()), - fix_command: None, - path: None, - bridge_path: None, - raw_output: Some(format!("{header}\n$ git lfs version\nerror: {e}\n{search}")), - }, - } -} - -/// Check that `core.clonefile` is enabled in the global git config. -/// -/// On macOS (APFS), `core.clonefile = true` enables copy-on-write clones -/// which makes git worktrees use significantly less disk space. -fn check_clonefile(git: &ResolvedBinary) -> DoctorCheck { - let label = "Copy on Write Git Clones".to_string(); - let id = "git-clonefile".to_string(); - let fix_cmd = "git config --global core.clonefile true".to_string(); - let header = "# Check: Copy on Write Git Clones — verify core.clonefile is enabled for disk space savings"; - - let git_path = match &git.path { - Some(p) => p, - None => { - return DoctorCheck { - id, - label, - status: CheckStatus::Warn, - message: "Git not found — cannot check clonefile setting".to_string(), - fix_url: Some("https://git-scm.com/downloads".to_string()), - fix_command: None, - path: None, - bridge_path: None, - raw_output: Some(format!("{header}\ngit not found via resolve_binary")), - }; - } - }; - - match Command::new(git_path) - .args(["config", "--global", "core.clonefile"]) - .output() - { - Ok(output) if output.status.success() => { - let raw = format!( - "{header}\n{}", - format_command_output("git config --global core.clonefile", &output) - ); - let value = String::from_utf8_lossy(&output.stdout) - .trim() - .to_lowercase(); - if value == "true" { - DoctorCheck { - id, - label, - status: CheckStatus::Pass, - message: "Enabled — reduces disk space used by new worktrees".to_string(), - fix_url: None, - fix_command: None, - path: None, - bridge_path: None, - raw_output: Some(raw), - } - } else { - DoctorCheck { - id, - label, - status: CheckStatus::Warn, - message: "Disabled — enable to reduce disk space used by new worktrees" - .to_string(), - fix_url: None, - fix_command: Some(fix_cmd), - path: None, - bridge_path: None, - raw_output: Some(raw), - } - } - } - // Key not set — treat as not enabled - Ok(output) => { - let raw = format!( - "{header}\n{}", - format_command_output("git config --global core.clonefile", &output) - ); - DoctorCheck { - id, - label, - status: CheckStatus::Warn, - message: "Not set — enable to reduce disk space used by new worktrees".to_string(), - fix_url: None, - fix_command: Some(fix_cmd), - path: None, - bridge_path: None, - raw_output: Some(raw), - } - } - Err(e) => DoctorCheck { - id, - label, - status: CheckStatus::Warn, - message: "Not set — enable to reduce disk space used by new worktrees".to_string(), - fix_url: None, - fix_command: Some(fix_cmd), - path: None, - bridge_path: None, - raw_output: Some(format!( - "{header}\n$ git config --global core.clonefile\nerror: {e}" - )), - }, - } -} - -/// Metadata for an individual AI agent check. -struct AgentCheckInfo { - /// Check ID used in the doctor report, e.g. "ai-agent-goose". - id: &'static str, - /// Human-readable label, e.g. "Goose". - label: &'static str, - /// ACP bridge binary names to search for (first entry is preferred/current). - commands: &'static [&'static str], - /// Main CLI tool name (e.g. "claude"), if separate from the ACP bridge. - main_command: Option<&'static str>, - /// URL to install the main tool. - install_url: Option<&'static str>, - /// Shell command to install the main tool. - install_command: Option<&'static str>, - /// URL to install the ACP bridge, when the main tool is present but the bridge is not. - bridge_install_url: Option<&'static str>, - /// Shell command to install the ACP bridge (used as fix_command for partial installs). - bridge_install_command: Option<&'static str>, -} - -/// All AI agents we check for individually. -/// At least one must be installed; the rest are optional. -const AI_AGENT_CHECKS: &[AgentCheckInfo] = &[ - AgentCheckInfo { - id: "ai-agent-goose", - label: "Goose", - commands: &["goose"], - main_command: None, - install_url: Some("https://github.com/block/goose"), - install_command: None, - bridge_install_url: None, - bridge_install_command: None, - }, - AgentCheckInfo { - id: "ai-agent-claude", - label: "Claude Code", - commands: &["claude-agent-acp"], - main_command: Some("claude"), - install_url: Some("https://code.claude.com/docs/en/overview"), - install_command: Some("curl -fsSL https://claude.ai/install.sh | bash"), - bridge_install_url: Some("https://github.com/zed-industries/claude-agent-acp#installation"), - bridge_install_command: Some("npm install -g @zed-industries/claude-agent-acp"), - }, - AgentCheckInfo { - id: "ai-agent-codex", - label: "Codex", - commands: &["codex-acp"], - main_command: Some("codex"), - install_url: Some("https://github.com/openai/codex#quickstart"), - install_command: Some("brew install --cask codex"), - bridge_install_url: Some("https://github.com/zed-industries/codex-acp#installation"), - bridge_install_command: Some("npm install -g @zed-industries/codex-acp"), - }, - AgentCheckInfo { - id: "ai-agent-pi", - label: "Pi", - commands: &["pi-acp"], - main_command: Some("pi"), - install_url: None, - install_command: None, - bridge_install_url: None, - bridge_install_command: None, - }, - AgentCheckInfo { - id: "ai-agent-amp", - label: "Amp", - commands: &["amp-acp"], - main_command: Some("amp"), - install_url: Some("https://ampcode.com"), - install_command: Some("curl -fsSL https://ampcode.com/install.sh | bash"), - bridge_install_url: Some("https://www.npmjs.com/package/amp-acp"), - bridge_install_command: Some("npm install -g amp-acp"), - }, -]; - -/// Check whether a single AI agent is installed. -/// -/// Uses pre-resolved binaries from the resolution phase. If at least one other -/// agent is already found (`any_agent_found`), missing agents get `Warn`; -/// otherwise the first missing agent gets `Warn` too since only one agent is -/// required overall. -fn check_single_ai_agent( - info: &AgentCheckInfo, - any_agent_found: bool, - resolved_cmds: &[ResolvedBinary], - resolved_main: Option<&ResolvedBinary>, -) -> DoctorCheck { - let header = format!( - "# Check: {} — verify {} agent is installed", - info.label, info.label - ); - // Collect search output from pre-resolved binaries. - let search_lines: Vec<&str> = resolved_cmds - .iter() - .map(|rb| rb.search_output.as_str()) - .collect(); - let search = search_lines.join("\n"); - - // Find the first resolved path among the bridge commands. - let resolved_path = resolved_cmds - .iter() - .find_map(|rb| rb.path.as_ref()) - .map(|p| p.to_string_lossy().to_string()); - - if let Some(ref path_str) = resolved_path { - // Special handling for Goose: verify ACP subcommand is available - if info.id == "ai-agent-goose" { - match Command::new(path_str).arg("acp").arg("--help").output() { - Ok(output) if output.status.success() => { - let raw = format!( - "{header}\n{}\n{}", - format_command_output("goose acp --help", &output), - search - ); - DoctorCheck { - id: info.id.to_string(), - label: info.label.to_string(), - status: CheckStatus::Pass, - message: "Installed".to_string(), - fix_url: None, - fix_command: None, - path: resolved_path, - bridge_path: None, - raw_output: Some(raw), - } - } - Ok(output) => { - let raw = format!( - "{header}\n{}\n{}", - format_command_output("goose acp --help", &output), - search - ); - DoctorCheck { - id: info.id.to_string(), - label: info.label.to_string(), - status: CheckStatus::Fail, - message: "Goose ACP subcommand not available — upgrade required" - .to_string(), - fix_url: Some("https://github.com/block/goose".to_string()), - fix_command: None, - path: resolved_path, - bridge_path: None, - raw_output: Some(raw), - } - } - Err(e) => DoctorCheck { - id: info.id.to_string(), - label: info.label.to_string(), - status: CheckStatus::Fail, - message: "Goose ACP subcommand not available — upgrade required".to_string(), - fix_url: Some("https://github.com/block/goose".to_string()), - fix_command: None, - path: resolved_path, - bridge_path: None, - raw_output: Some(format!( - "{header}\n$ goose acp --help\nerror: {e}\n{search}" - )), - }, - } - } else { - // For agents with a separate main command, show main path + bridge path. - let (main_path, bridge_path) = if info.main_command.is_some() { - let main_p = resolved_main - .and_then(|rb| rb.path.as_ref()) - .map(|p| p.to_string_lossy().to_string()); - (main_p, resolved_path) - } else { - (resolved_path, None) - }; - DoctorCheck { - id: info.id.to_string(), - label: info.label.to_string(), - status: CheckStatus::Pass, - message: "Installed".to_string(), - fix_url: None, - fix_command: None, - path: main_path, - bridge_path, - raw_output: Some(format!("{header}\n{search}")), - } - } - } else { - // Bridge not found — check if the main CLI tool is installed (partial install). - if let Some(resolved_main) = resolved_main { - let main_search = &resolved_main.search_output; - if let Some(ref main_path) = resolved_main.path { - let bridge_cmd = info.commands[0]; - return DoctorCheck { - id: info.id.to_string(), - label: info.label.to_string(), - status: CheckStatus::Warn, - message: format!( - "{} is installed but {} also needs to be installed", - info.label, bridge_cmd - ), - fix_url: info - .bridge_install_url - .or(info.install_url) - .map(|s| s.to_string()), - fix_command: info.bridge_install_command.map(|s| s.to_string()), - path: Some(main_path.to_string_lossy().to_string()), - bridge_path: None, - raw_output: Some(format!("{header}\n{search}\n{main_search}")), - }; - } - // Main tool also not found — fall through to fully-missing case, - // but include main_search in the debug output. - return DoctorCheck { - id: info.id.to_string(), - label: info.label.to_string(), - status: CheckStatus::Warn, - message: if any_agent_found { - "Not installed (optional)".to_string() - } else { - "Not installed — at least one AI agent is needed".to_string() - }, - fix_url: info.install_url.map(|s| s.to_string()), - fix_command: info.install_command.map(|s| s.to_string()), - path: None, - bridge_path: None, - raw_output: Some(format!("{header}\n{search}\n{main_search}")), - }; - } - - DoctorCheck { - id: info.id.to_string(), - label: info.label.to_string(), - status: CheckStatus::Warn, - message: if any_agent_found { - "Not installed (optional)".to_string() - } else { - "Not installed — at least one AI agent is needed".to_string() - }, - fix_url: info.install_url.map(|s| s.to_string()), - fix_command: info.install_command.map(|s| s.to_string()), - path: None, - bridge_path: None, - raw_output: Some(format!("{header}\n{search}")), - } - } -} - -// ============================================================================= -// Tauri commands -// ============================================================================= - -/// Fallback check returned when a spawn_blocking task panics. -fn empty_check(id: &str, label: &str) -> DoctorCheck { - DoctorCheck { - id: id.to_string(), - label: label.to_string(), - status: CheckStatus::Fail, - message: "Check failed to run".to_string(), - fix_url: None, - fix_command: None, - path: None, - bridge_path: None, - raw_output: None, - } -} +use doctor::DoctorReport; /// Run all health checks and return the report. -/// -/// Binary resolution is done once upfront for all unique command names, then -/// all checks run concurrently using the pre-resolved paths. This avoids -/// redundant login shell spawns when multiple checks need the same binary. #[tauri::command] pub async fn run_doctor() -> DoctorReport { - // Phase 0: Resolve all unique binaries concurrently. - // Collect every command name we'll need across all checks. - let mut binary_names: Vec<&'static str> = vec!["git", "gh", "git-lfs"]; - for info in AI_AGENT_CHECKS { - for cmd in info.commands { - if !binary_names.contains(cmd) { - binary_names.push(cmd); - } - } - if let Some(main) = info.main_command { - if !binary_names.contains(&main) { - binary_names.push(main); - } - } - } - - let handles: Vec<_> = binary_names - .iter() - .map(|&name| tokio::task::spawn_blocking(move || (name, resolve_binary(name)))) - .collect(); - - let mut resolved: HashMap<&str, ResolvedBinary> = HashMap::new(); - for handle in handles { - if let Ok((name, rb)) = handle.await { - resolved.insert(name, rb); - } - } - - let fallback = ResolvedBinary { - path: None, - search_output: "resolution task panicked".to_string(), - }; - let r_git = resolved - .get("git") - .cloned() - .unwrap_or_else(|| fallback.clone()); - let r_gh = resolved - .get("gh") - .cloned() - .unwrap_or_else(|| fallback.clone()); - let r_git_lfs = resolved - .get("git-lfs") - .cloned() - .unwrap_or_else(|| fallback.clone()); - - // Determine if any agent is installed from the already-resolved data. - let any_agent_found = AI_AGENT_CHECKS.iter().any(|info| { - info.commands - .iter() - .any(|cmd| resolved.get(cmd).is_some_and(|rb| rb.path.is_some())) - }); - - // Phase 1: Run all checks concurrently using pre-resolved binaries. - let git_r = r_git.clone(); - let gh_r = r_gh.clone(); - let gh_r2 = r_gh.clone(); - let git_r2 = r_git.clone(); - let git_lfs_r = r_git_lfs; - let git_r3 = r_git; - - let c_git = tokio::task::spawn_blocking(move || check_git(&git_r)); - let c_gh = tokio::task::spawn_blocking(move || check_gh(&gh_r)); - let c_gh_auth = tokio::task::spawn_blocking(move || check_gh_auth(&gh_r2)); - let c_git_lfs = tokio::task::spawn_blocking(move || check_git_lfs(&git_r2, &git_lfs_r)); - let c_clonefile = tokio::task::spawn_blocking(move || check_clonefile(&git_r3)); - - let agent_handles: Vec<_> = AI_AGENT_CHECKS - .iter() - .map(|info| { - let found = any_agent_found; - let cmds: Vec = info - .commands - .iter() - .map(|cmd| { - resolved - .get(cmd) - .cloned() - .unwrap_or_else(|| fallback.clone()) - }) - .collect(); - let main = info.main_command.and_then(|cmd| resolved.get(cmd).cloned()); - tokio::task::spawn_blocking(move || { - check_single_ai_agent(info, found, &cmds, main.as_ref()) - }) - }) - .collect(); - - let (c_git, c_gh, c_gh_auth, c_git_lfs, c_clonefile) = - tokio::join!(c_git, c_gh, c_gh_auth, c_git_lfs, c_clonefile); - - let mut checks = vec![ - c_git.unwrap_or_else(|_| empty_check("git", "Git")), - c_gh.unwrap_or_else(|_| empty_check("gh", "GitHub CLI")), - c_gh_auth.unwrap_or_else(|_| empty_check("gh-auth", "GitHub Auth")), - c_git_lfs.unwrap_or_else(|_| empty_check("git-lfs", "Git LFS")), - c_clonefile.unwrap_or_else(|_| empty_check("git-clonefile", "Copy on Write Git Clones")), - ]; - - for (i, handle) in agent_handles.into_iter().enumerate() { - let info = &AI_AGENT_CHECKS[i]; - checks.push( - handle - .await - .unwrap_or_else(|_| empty_check(info.id, info.label)), - ); - } - - DoctorReport { checks } + doctor::run_checks().await } /// Run a fix command from a doctor check. /// -/// Executes the given shell command and returns Ok(()) on success, -/// or an error message if the command fails. +/// The frontend sends the raw command string from `DoctorCheck.fixCommand`. #[tauri::command] pub async fn run_doctor_fix(command: String) -> Result<(), String> { - tokio::task::spawn_blocking(move || { - // Use a login shell so commands like `npm` installed via nvm are visible. - let (shell, args) = if std::path::Path::new("/bin/zsh").exists() { - ("/bin/zsh", vec!["-l", "-c", &command]) - } else { - ("/bin/bash", vec!["-l", "-c", &command]) - }; - // Clear the inherited environment so directory-local tool managers - // (e.g. Hermit) don't intercept the command. The login shell will - // rebuild PATH etc. from the user's profile. - let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string()); - let user = std::env::var("USER").unwrap_or_default(); - let output = Command::new(shell) - .args(&args) - .env_clear() - .env("HOME", &home) - .env("USER", &user) - .env("TERM", "xterm-256color") - .current_dir(&home) - .output() - .map_err(|e| format!("Failed to run command: {e}"))?; - - if output.status.success() { - Ok(()) - } else { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); - Err(if stderr.is_empty() { - format!("Command failed with exit code {}", output.status) - } else { - stderr - }) - } - }) - .await - .unwrap_or_else(|e| Err(format!("Task failed: {e}"))) + doctor::execute_command(command).await } diff --git a/crates/doctor/Cargo.toml b/crates/doctor/Cargo.toml new file mode 100644 index 00000000..46ddff82 --- /dev/null +++ b/crates/doctor/Cargo.toml @@ -0,0 +1,9 @@ +[package] +name = "doctor" +version = "0.1.0" +edition = "2021" +description = "Health-check system for verifying external tool dependencies" + +[dependencies] +serde = { version = "1.0", features = ["derive"] } +tokio = { version = "1.0", features = ["rt", "macros"] } diff --git a/crates/doctor/src/agents.rs b/crates/doctor/src/agents.rs new file mode 100644 index 00000000..26968174 --- /dev/null +++ b/crates/doctor/src/agents.rs @@ -0,0 +1,254 @@ +//! AI agent checks and fix command lookup. + +use std::process::Command; + +use crate::resolve::format_command_output; +use crate::types::{CheckStatus, DoctorCheck, FixType, ResolvedBinary}; + +/// Metadata for an individual AI agent check. +pub struct AgentCheckInfo { + /// Check ID used in the doctor report, e.g. "ai-agent-goose". + pub id: &'static str, + /// Human-readable label, e.g. "Goose". + pub label: &'static str, + /// ACP bridge binary names to search for (first entry is preferred/current). + pub commands: &'static [&'static str], + /// Main CLI tool name (e.g. "claude"), if separate from the ACP bridge. + pub main_command: Option<&'static str>, + /// URL to install the main tool. + pub install_url: Option<&'static str>, + /// Shell command to install the main tool. + pub install_command: Option<&'static str>, + /// URL to install the ACP bridge, when the main tool is present but the bridge is not. + pub bridge_install_url: Option<&'static str>, + /// Shell command to install the ACP bridge (used as fix_command for partial installs). + pub bridge_install_command: Option<&'static str>, +} + +/// All AI agents we check for individually. +pub const AI_AGENT_CHECKS: &[AgentCheckInfo] = &[ + AgentCheckInfo { + id: "ai-agent-goose", + label: "Goose", + commands: &["goose"], + main_command: None, + install_url: Some("https://github.com/block/goose"), + install_command: None, + bridge_install_url: None, + bridge_install_command: None, + }, + AgentCheckInfo { + id: "ai-agent-claude", + label: "Claude Code", + commands: &["claude-agent-acp"], + main_command: Some("claude"), + install_url: Some("https://code.claude.com/docs/en/overview"), + install_command: Some("curl -fsSL https://claude.ai/install.sh | bash"), + bridge_install_url: Some("https://github.com/zed-industries/claude-agent-acp#installation"), + bridge_install_command: Some("npm install -g @zed-industries/claude-agent-acp"), + }, + AgentCheckInfo { + id: "ai-agent-codex", + label: "Codex", + commands: &["codex-acp"], + main_command: Some("codex"), + install_url: Some("https://github.com/openai/codex#quickstart"), + install_command: Some("brew install --cask codex"), + bridge_install_url: Some("https://github.com/zed-industries/codex-acp#installation"), + bridge_install_command: Some("npm install -g @zed-industries/codex-acp"), + }, + AgentCheckInfo { + id: "ai-agent-pi", + label: "Pi", + commands: &["pi-acp"], + main_command: Some("pi"), + install_url: None, + install_command: None, + bridge_install_url: None, + bridge_install_command: None, + }, + AgentCheckInfo { + id: "ai-agent-amp", + label: "Amp", + commands: &["amp-acp"], + main_command: Some("amp"), + install_url: Some("https://ampcode.com"), + install_command: Some("curl -fsSL https://ampcode.com/install.sh | bash"), + bridge_install_url: Some("https://www.npmjs.com/package/amp-acp"), + bridge_install_command: Some("npm install -g amp-acp"), + }, +]; + +/// Check whether a single AI agent is installed. +pub fn check_single_ai_agent( + info: &AgentCheckInfo, + any_agent_found: bool, + resolved_cmds: &[ResolvedBinary], + resolved_main: Option<&ResolvedBinary>, +) -> DoctorCheck { + let header = format!( + "# Check: {} — verify {} agent is installed", + info.label, info.label + ); + let search_lines: Vec<&str> = resolved_cmds + .iter() + .map(|rb| rb.search_output.as_str()) + .collect(); + let search = search_lines.join("\n"); + + let resolved_path = resolved_cmds + .iter() + .find_map(|rb| rb.path.as_ref()) + .map(|p| p.to_string_lossy().to_string()); + + if let Some(ref path_str) = resolved_path { + if info.id == "ai-agent-goose" { + match Command::new(path_str).arg("acp").arg("--help").output() { + Ok(output) if output.status.success() => { + let raw = format!( + "{header}\n{}\n{}", + format_command_output("goose acp --help", &output), + search + ); + DoctorCheck { + id: info.id.to_string(), + label: info.label.to_string(), + status: CheckStatus::Pass, + message: "Installed".to_string(), + fix_url: None, + fix_command: None, + fix_type: None, + path: resolved_path, + bridge_path: None, + raw_output: Some(raw), + } + } + Ok(output) => { + let raw = format!( + "{header}\n{}\n{}", + format_command_output("goose acp --help", &output), + search + ); + DoctorCheck { + id: info.id.to_string(), + label: info.label.to_string(), + status: CheckStatus::Fail, + message: "Goose ACP subcommand not available — upgrade required" + .to_string(), + fix_url: Some("https://github.com/block/goose".to_string()), + fix_command: None, + fix_type: None, + path: resolved_path, + bridge_path: None, + raw_output: Some(raw), + } + } + Err(e) => DoctorCheck { + id: info.id.to_string(), + label: info.label.to_string(), + status: CheckStatus::Fail, + message: "Goose ACP subcommand not available — upgrade required".to_string(), + fix_url: Some("https://github.com/block/goose".to_string()), + fix_command: None, + fix_type: None, + path: resolved_path, + bridge_path: None, + raw_output: Some(format!( + "{header}\n$ goose acp --help\nerror: {e}\n{search}" + )), + }, + } + } else { + let (main_path, bridge_path) = if info.main_command.is_some() { + let main_p = resolved_main + .and_then(|rb| rb.path.as_ref()) + .map(|p| p.to_string_lossy().to_string()); + (main_p, resolved_path) + } else { + (resolved_path, None) + }; + DoctorCheck { + id: info.id.to_string(), + label: info.label.to_string(), + status: CheckStatus::Pass, + message: "Installed".to_string(), + fix_url: None, + fix_command: None, + fix_type: None, + path: main_path, + bridge_path, + raw_output: Some(format!("{header}\n{search}")), + } + } + } else { + // Bridge binary not found. If the main binary exists, suggest installing + // just the bridge; otherwise report the agent as not installed. + if let Some(main_path) = resolved_main.as_ref().and_then(|rm| rm.path.as_ref()) { + let bridge_cmd = info.commands[0]; + let main_search = &resolved_main.as_ref().unwrap().search_output; + return DoctorCheck { + id: info.id.to_string(), + label: info.label.to_string(), + status: CheckStatus::Warn, + message: format!( + "{} is installed but {} also needs to be installed", + info.label, bridge_cmd + ), + fix_url: info + .bridge_install_url + .or(info.install_url) + .map(|s| s.to_string()), + fix_command: info.bridge_install_command.map(|s| s.to_string()), + fix_type: info.bridge_install_command.map(|_| FixType::Bridge), + path: Some(main_path.to_string_lossy().to_string()), + bridge_path: None, + raw_output: Some(format!("{header}\n{search}\n{main_search}")), + }; + } + + // Neither bridge nor main binary found — agent is not installed. + let extra_search = resolved_main + .as_ref() + .map(|rm| format!("\n{}", rm.search_output)) + .unwrap_or_default(); + + DoctorCheck { + id: info.id.to_string(), + label: info.label.to_string(), + status: CheckStatus::Warn, + message: if any_agent_found { + "Not installed (optional)".to_string() + } else { + "Not installed — at least one AI agent is needed".to_string() + }, + fix_url: info.install_url.map(|s| s.to_string()), + fix_command: info.install_command.map(|s| s.to_string()), + fix_type: info.install_command.map(|_| FixType::Command), + path: None, + bridge_path: None, + raw_output: Some(format!("{header}\n{search}{extra_search}")), + } + } +} + +/// Look up the shell command for a given check ID and fix type. +/// +/// Returns `None` if the check ID is unknown or has no fix of the requested type. +pub fn lookup_fix_command(check_id: &str, fix_type: &FixType) -> Option { + // Tool checks with hardcoded fix commands + if check_id == "git-clonefile" && *fix_type == FixType::Command { + return Some("git config --global core.clonefile true".to_string()); + } + + // AI agent checks + for info in AI_AGENT_CHECKS { + if info.id == check_id { + return match fix_type { + FixType::Command => info.install_command.map(|s| s.to_string()), + FixType::Bridge => info.bridge_install_command.map(|s| s.to_string()), + }; + } + } + + None +} diff --git a/crates/doctor/src/checks.rs b/crates/doctor/src/checks.rs new file mode 100644 index 00000000..3cc218b1 --- /dev/null +++ b/crates/doctor/src/checks.rs @@ -0,0 +1,438 @@ +//! Individual health-check functions for tool dependencies (git, gh, git-lfs, etc.). + +use std::process::Command; + +use crate::resolve::format_command_output; +use crate::types::{CheckStatus, DoctorCheck, FixType, ResolvedBinary}; + +/// Check that `git` is installed and reachable. +pub fn check_git(resolved: &ResolvedBinary) -> DoctorCheck { + let label = "Git".to_string(); + let id = "git".to_string(); + let search = &resolved.search_output; + let header = "# Check: Git — verify git is installed and reachable"; + + let git_path = match &resolved.path { + Some(p) => p, + None => { + return DoctorCheck { + id, + label, + status: CheckStatus::Fail, + message: "Git not found".to_string(), + fix_url: Some("https://git-scm.com/downloads".to_string()), + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(format!("{header}\nnot found via resolve_binary\n{search}")), + }; + } + }; + let path_str = git_path.to_string_lossy().to_string(); + + match Command::new(git_path).arg("--version").output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let raw = format!( + "{header}\n{}\n{}", + format_command_output("git --version", &output), + search + ); + DoctorCheck { + id, + label, + status: CheckStatus::Pass, + message: version, + fix_url: None, + fix_command: None, + fix_type: None, + path: Some(path_str), + bridge_path: None, + raw_output: Some(raw), + } + } + Ok(output) => { + let raw = format!( + "{header}\n{}\n{}", + format_command_output("git --version", &output), + search + ); + DoctorCheck { + id, + label, + status: CheckStatus::Fail, + message: "Git not found".to_string(), + fix_url: Some("https://git-scm.com/downloads".to_string()), + fix_command: None, + fix_type: None, + path: Some(path_str), + bridge_path: None, + raw_output: Some(raw), + } + } + Err(e) => DoctorCheck { + id, + label, + status: CheckStatus::Fail, + message: "Git not found".to_string(), + fix_url: Some("https://git-scm.com/downloads".to_string()), + fix_command: None, + fix_type: None, + path: Some(path_str), + bridge_path: None, + raw_output: Some(format!("{header}\n$ git --version\nerror: {e}\n{search}")), + }, + } +} + +/// Check that the GitHub CLI (`gh`) is installed. +pub fn check_gh(resolved: &ResolvedBinary) -> DoctorCheck { + let label = "GitHub CLI".to_string(); + let id = "gh".to_string(); + let search = &resolved.search_output; + let header = "# Check: GitHub CLI — verify gh is installed"; + + let gh_path = match &resolved.path { + Some(p) => p, + None => { + return DoctorCheck { + id, + label, + status: CheckStatus::Fail, + message: "GitHub CLI not found".to_string(), + fix_url: Some("https://cli.github.com".to_string()), + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(format!("{header}\nnot found via resolve_binary\n{search}")), + }; + } + }; + let path_str = gh_path.to_string_lossy().to_string(); + + match Command::new(gh_path).arg("--version").output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout); + let first_line = version.lines().next().unwrap_or("gh").trim().to_string(); + let raw = format!( + "{header}\n{}\n{}", + format_command_output("gh --version", &output), + search + ); + DoctorCheck { + id, + label, + status: CheckStatus::Pass, + message: first_line, + fix_url: None, + fix_command: None, + fix_type: None, + path: Some(path_str), + bridge_path: None, + raw_output: Some(raw), + } + } + Ok(output) => { + let raw = format!( + "{header}\n{}\n{}", + format_command_output("gh --version", &output), + search + ); + DoctorCheck { + id, + label, + status: CheckStatus::Fail, + message: "GitHub CLI not found".to_string(), + fix_url: Some("https://cli.github.com".to_string()), + fix_command: None, + fix_type: None, + path: Some(path_str), + bridge_path: None, + raw_output: Some(raw), + } + } + Err(e) => DoctorCheck { + id, + label, + status: CheckStatus::Fail, + message: "GitHub CLI not found".to_string(), + fix_url: Some("https://cli.github.com".to_string()), + fix_command: None, + fix_type: None, + path: Some(path_str), + bridge_path: None, + raw_output: Some(format!("{header}\n$ gh --version\nerror: {e}\n{search}")), + }, + } +} + +/// Check that `gh auth status` succeeds (user is logged in). +pub fn check_gh_auth(gh: &ResolvedBinary) -> DoctorCheck { + let label = "GitHub Auth".to_string(); + let id = "gh-auth".to_string(); + let header = "# Check: GitHub Auth — verify user is logged in to GitHub"; + + let gh_path = match &gh.path { + Some(p) => p, + None => { + return DoctorCheck { + id, + label, + status: CheckStatus::Fail, + message: "GitHub CLI not found — install gh first".to_string(), + fix_url: Some("https://cli.github.com".to_string()), + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(format!("{header}\ngh not found via resolve_binary")), + }; + } + }; + + match Command::new(gh_path).args(["auth", "status"]).output() { + Ok(output) => { + let raw = format!( + "{header}\n{}", + format_command_output("gh auth status", &output) + ); + if output.status.success() { + DoctorCheck { + id, + label, + status: CheckStatus::Pass, + message: "Authenticated".to_string(), + fix_url: None, + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(raw), + } + } else { + let stderr = String::from_utf8_lossy(&output.stderr); + let hint = if stderr.contains("not logged in") || stderr.contains("no oauth token") + { + "Not authenticated — run `gh auth login`".to_string() + } else { + "Not authenticated".to_string() + }; + DoctorCheck { + id, + label, + status: CheckStatus::Fail, + message: hint, + fix_url: Some("https://cli.github.com/manual/gh_auth_login".to_string()), + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(raw), + } + } + } + Err(e) => DoctorCheck { + id, + label, + status: CheckStatus::Fail, + message: "Not authenticated".to_string(), + fix_url: Some("https://cli.github.com/manual/gh_auth_login".to_string()), + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(format!("{header}\n$ gh auth status\nerror: {e}")), + }, + } +} + +/// Check that Git LFS is installed. +pub fn check_git_lfs(git: &ResolvedBinary, git_lfs: &ResolvedBinary) -> DoctorCheck { + let label = "Git LFS".to_string(); + let id = "git-lfs".to_string(); + let search = &git_lfs.search_output; + let header = + "# Check: Git LFS — verify git-lfs is installed (optional, needed for large files)"; + + let git_path = match &git.path { + Some(p) => p, + None => { + return DoctorCheck { + id, + label, + status: CheckStatus::Warn, + message: "Git LFS not installed (optional, needed for large files)".to_string(), + fix_url: Some("https://git-lfs.com".to_string()), + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(format!( + "{header}\ngit not found via resolve_binary\n{search}" + )), + }; + } + }; + + match Command::new(git_path).args(["lfs", "version"]).output() { + Ok(output) if output.status.success() => { + let version = String::from_utf8_lossy(&output.stdout).trim().to_string(); + let path = git_lfs + .path + .as_ref() + .map(|p| p.to_string_lossy().to_string()); + let raw = format!( + "{header}\n{}\n{}", + format_command_output("git lfs version", &output), + search + ); + DoctorCheck { + id, + label, + status: CheckStatus::Pass, + message: version, + fix_url: None, + fix_command: None, + fix_type: None, + path, + bridge_path: None, + raw_output: Some(raw), + } + } + Ok(output) => { + let raw = format!( + "{header}\n{}\n{}", + format_command_output("git lfs version", &output), + search + ); + DoctorCheck { + id, + label, + status: CheckStatus::Warn, + message: "Git LFS not installed (optional, needed for large files)".to_string(), + fix_url: Some("https://git-lfs.com".to_string()), + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(raw), + } + } + Err(e) => DoctorCheck { + id, + label, + status: CheckStatus::Warn, + message: "Git LFS not installed (optional, needed for large files)".to_string(), + fix_url: Some("https://git-lfs.com".to_string()), + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(format!("{header}\n$ git lfs version\nerror: {e}\n{search}")), + }, + } +} + +/// Check that `core.clonefile` is enabled in the global git config. +pub fn check_clonefile(git: &ResolvedBinary) -> DoctorCheck { + let label = "Copy on Write Git Clones".to_string(); + let id = "git-clonefile".to_string(); + let fix_cmd = "git config --global core.clonefile true".to_string(); + let header = "# Check: Copy on Write Git Clones — verify core.clonefile is enabled for disk space savings"; + + let git_path = match &git.path { + Some(p) => p, + None => { + return DoctorCheck { + id, + label, + status: CheckStatus::Warn, + message: "Git not found — cannot check clonefile setting".to_string(), + fix_url: Some("https://git-scm.com/downloads".to_string()), + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(format!("{header}\ngit not found via resolve_binary")), + }; + } + }; + + match Command::new(git_path) + .args(["config", "--global", "core.clonefile"]) + .output() + { + Ok(output) if output.status.success() => { + let raw = format!( + "{header}\n{}", + format_command_output("git config --global core.clonefile", &output) + ); + let value = String::from_utf8_lossy(&output.stdout) + .trim() + .to_lowercase(); + if value == "true" { + DoctorCheck { + id, + label, + status: CheckStatus::Pass, + message: "Enabled — reduces disk space used by new worktrees".to_string(), + fix_url: None, + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: Some(raw), + } + } else { + DoctorCheck { + id, + label, + status: CheckStatus::Warn, + message: "Disabled — enable to reduce disk space used by new worktrees" + .to_string(), + fix_url: None, + fix_command: Some(fix_cmd), + fix_type: Some(FixType::Command), + path: None, + bridge_path: None, + raw_output: Some(raw), + } + } + } + // Key not set — treat as not enabled + Ok(output) => { + let raw = format!( + "{header}\n{}", + format_command_output("git config --global core.clonefile", &output) + ); + DoctorCheck { + id, + label, + status: CheckStatus::Warn, + message: "Not set — enable to reduce disk space used by new worktrees".to_string(), + fix_url: None, + fix_command: Some(fix_cmd), + fix_type: Some(FixType::Command), + path: None, + bridge_path: None, + raw_output: Some(raw), + } + } + Err(e) => DoctorCheck { + id, + label, + status: CheckStatus::Warn, + message: "Not set — enable to reduce disk space used by new worktrees".to_string(), + fix_url: None, + fix_command: Some(fix_cmd), + fix_type: Some(FixType::Command), + path: None, + bridge_path: None, + raw_output: Some(format!( + "{header}\n$ git config --global core.clonefile\nerror: {e}" + )), + }, + } +} diff --git a/crates/doctor/src/lib.rs b/crates/doctor/src/lib.rs new file mode 100644 index 00000000..96cf67d7 --- /dev/null +++ b/crates/doctor/src/lib.rs @@ -0,0 +1,193 @@ +//! Health Check ("Doctor") — backend checks for external dependencies. +//! +//! Each check probes a single external dependency and returns a status +//! (pass / warn / fail) with a human-readable summary and an optional +//! URL the user can visit to install or configure the dependency. + +pub mod agents; +pub mod checks; +pub mod resolve; +pub mod types; + +pub use types::{CheckStatus, DoctorCheck, DoctorReport, FixType}; + +use std::collections::HashMap; + +use agents::{check_single_ai_agent, lookup_fix_command, AI_AGENT_CHECKS}; +use checks::{check_clonefile, check_gh, check_gh_auth, check_git, check_git_lfs}; +use resolve::resolve_binary; +use types::ResolvedBinary; + +/// Fallback check returned when a spawn_blocking task panics. +fn empty_check(id: &str, label: &str) -> DoctorCheck { + DoctorCheck { + id: id.to_string(), + label: label.to_string(), + status: CheckStatus::Fail, + message: "Check failed to run".to_string(), + fix_url: None, + fix_command: None, + fix_type: None, + path: None, + bridge_path: None, + raw_output: None, + } +} + +/// Run all health checks and return the report. +pub async fn run_checks() -> DoctorReport { + let mut binary_names: Vec<&'static str> = vec!["git", "gh", "git-lfs"]; + for info in AI_AGENT_CHECKS { + for cmd in info.commands { + if !binary_names.contains(cmd) { + binary_names.push(cmd); + } + } + if let Some(main) = info.main_command { + if !binary_names.contains(&main) { + binary_names.push(main); + } + } + } + + let handles: Vec<_> = binary_names + .iter() + .map(|&name| tokio::task::spawn_blocking(move || (name, resolve_binary(name)))) + .collect(); + + let mut resolved: HashMap<&str, ResolvedBinary> = HashMap::new(); + for handle in handles { + if let Ok((name, rb)) = handle.await { + resolved.insert(name, rb); + } + } + + let fallback = ResolvedBinary { + path: None, + search_output: "resolution task panicked".to_string(), + }; + let r_git = resolved + .get("git") + .cloned() + .unwrap_or_else(|| fallback.clone()); + let r_gh = resolved + .get("gh") + .cloned() + .unwrap_or_else(|| fallback.clone()); + let r_git_lfs = resolved + .get("git-lfs") + .cloned() + .unwrap_or_else(|| fallback.clone()); + + let any_agent_found = AI_AGENT_CHECKS.iter().any(|info| { + info.commands + .iter() + .any(|cmd| resolved.get(cmd).is_some_and(|rb| rb.path.is_some())) + }); + + let git_r = r_git.clone(); + let gh_r = r_gh.clone(); + let gh_r2 = r_gh.clone(); + let git_r2 = r_git.clone(); + let git_lfs_r = r_git_lfs; + let git_r3 = r_git; + + let c_git = tokio::task::spawn_blocking(move || check_git(&git_r)); + let c_gh = tokio::task::spawn_blocking(move || check_gh(&gh_r)); + let c_gh_auth = tokio::task::spawn_blocking(move || check_gh_auth(&gh_r2)); + let c_git_lfs = tokio::task::spawn_blocking(move || check_git_lfs(&git_r2, &git_lfs_r)); + let c_clonefile = tokio::task::spawn_blocking(move || check_clonefile(&git_r3)); + + let agent_handles: Vec<_> = AI_AGENT_CHECKS + .iter() + .map(|info| { + let found = any_agent_found; + let cmds: Vec = info + .commands + .iter() + .map(|cmd| { + resolved + .get(cmd) + .cloned() + .unwrap_or_else(|| fallback.clone()) + }) + .collect(); + let main = info.main_command.and_then(|cmd| resolved.get(cmd).cloned()); + tokio::task::spawn_blocking(move || { + check_single_ai_agent(info, found, &cmds, main.as_ref()) + }) + }) + .collect(); + + let (c_git, c_gh, c_gh_auth, c_git_lfs, c_clonefile) = + tokio::join!(c_git, c_gh, c_gh_auth, c_git_lfs, c_clonefile); + + let mut checks = vec![ + c_git.unwrap_or_else(|_| empty_check("git", "Git")), + c_gh.unwrap_or_else(|_| empty_check("gh", "GitHub CLI")), + c_gh_auth.unwrap_or_else(|_| empty_check("gh-auth", "GitHub Auth")), + c_git_lfs.unwrap_or_else(|_| empty_check("git-lfs", "Git LFS")), + c_clonefile.unwrap_or_else(|_| empty_check("git-clonefile", "Copy on Write Git Clones")), + ]; + + for (i, handle) in agent_handles.into_iter().enumerate() { + let info = &AI_AGENT_CHECKS[i]; + checks.push( + handle + .await + .unwrap_or_else(|_| empty_check(info.id, info.label)), + ); + } + + DoctorReport { checks } +} + +/// Run a fix command for a doctor check, identified by check ID and fix type. +/// +/// The actual shell command is looked up from the static check definitions — +/// the caller never sends a raw command string. +pub async fn execute_fix(check_id: String, fix_type: FixType) -> Result<(), String> { + let command = lookup_fix_command(&check_id, &fix_type) + .ok_or_else(|| format!("Unknown check '{check_id}' or fix type '{fix_type:?}'"))?; + + execute_command(command).await +} + +/// Run an arbitrary shell command in a login shell. +/// +/// This is the lower-level primitive used by [`execute_fix`] and can also be +/// called directly by consumers that already have a validated command string +/// (e.g. the Staged app's existing frontend API). +pub async fn execute_command(command: String) -> Result<(), String> { + tokio::task::spawn_blocking(move || { + let (shell, args) = if std::path::Path::new("/bin/zsh").exists() { + ("/bin/zsh", vec!["-l", "-c", &command]) + } else { + ("/bin/bash", vec!["-l", "-c", &command]) + }; + let home = std::env::var("HOME").unwrap_or_else(|_| "/".to_string()); + let user = std::env::var("USER").unwrap_or_default(); + let output = std::process::Command::new(shell) + .args(&args) + .env_clear() + .env("HOME", &home) + .env("USER", &user) + .env("TERM", "xterm-256color") + .current_dir(&home) + .output() + .map_err(|e| format!("Failed to run command: {e}"))?; + + if output.status.success() { + Ok(()) + } else { + let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + Err(if stderr.is_empty() { + format!("Command failed with exit code {}", output.status) + } else { + stderr + }) + } + }) + .await + .unwrap_or_else(|e| Err(format!("Task failed: {e}"))) +} diff --git a/crates/doctor/src/resolve.rs b/crates/doctor/src/resolve.rs new file mode 100644 index 00000000..cc495e0a --- /dev/null +++ b/crates/doctor/src/resolve.rs @@ -0,0 +1,74 @@ +//! Binary resolution and command output formatting helpers. + +use std::path::PathBuf; +use std::process::Command; + +use super::types::ResolvedBinary; + +/// Resolve a binary by trying login shell `which` then common install paths. +pub fn resolve_binary(cmd: &str) -> ResolvedBinary { + let mut lines = vec![format!("resolve '{cmd}':")]; + + // Strategy 1: Login shell `which` (primary) + lines.push(" strategy 1 — login shell `which`:".to_string()); + for shell in &["/bin/zsh", "/bin/bash"] { + let which_cmd = format!("which {cmd}"); + match Command::new(shell).args(["-l", "-c", &which_cmd]).output() { + Ok(output) => { + let result = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if output.status.success() && !result.is_empty() { + lines.push(format!( + " {shell} -l -c 'which {cmd}' => {result} (resolved)" + )); + return ResolvedBinary { + path: Some(PathBuf::from(&result)), + search_output: lines.join("\n"), + }; + } + lines.push(format!(" {shell} -l -c 'which {cmd}' => not found")); + } + Err(e) => { + lines.push(format!(" {shell} -l -c 'which {cmd}' => error: {e}")); + } + } + } + + // Strategy 2: Common install paths (fallback) + lines.push(" strategy 2 — common install paths (fallback):".to_string()); + for dir in &[ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/home/linuxbrew/.linuxbrew/bin", + ] { + let path = PathBuf::from(dir).join(cmd); + if path.exists() { + lines.push(format!(" {} => found (resolved)", path.display())); + return ResolvedBinary { + path: Some(path), + search_output: lines.join("\n"), + }; + } + lines.push(format!(" {} => not found", path.display())); + } + + lines.push(" not found in any location".to_string()); + ResolvedBinary { + path: None, + search_output: lines.join("\n"), + } +} + +/// Format the raw output of a command invocation for debug diagnostics. +pub fn format_command_output(cmd_desc: &str, output: &std::process::Output) -> String { + let stdout = String::from_utf8_lossy(&output.stdout); + let stderr = String::from_utf8_lossy(&output.stderr); + let mut raw = format!("$ {cmd_desc}\nexit code: {}", output.status); + if !stdout.trim().is_empty() { + raw.push_str(&format!("\nstdout:\n{}", stdout.trim())); + } + if !stderr.trim().is_empty() { + raw.push_str(&format!("\nstderr:\n{}", stderr.trim())); + } + raw +} diff --git a/crates/doctor/src/types.rs b/crates/doctor/src/types.rs new file mode 100644 index 00000000..24981b72 --- /dev/null +++ b/crates/doctor/src/types.rs @@ -0,0 +1,65 @@ +//! Types shared across the doctor sub-modules. + +use serde::{Deserialize, Serialize}; +use std::path::PathBuf; + +/// Severity level for a single check. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CheckStatus { + Pass, + Warn, + Fail, +} + +/// The type of fix available for a check. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum FixType { + /// A shell command to install or configure the dependency. + Command, + /// A shell command to install the ACP bridge binary. + Bridge, +} + +/// A single health-check result shown in the UI. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DoctorCheck { + /// Short identifier, e.g. "git" + pub id: String, + /// Human-readable label, e.g. "Git" + pub label: String, + pub status: CheckStatus, + /// One-line explanation shown next to the status badge. + pub message: String, + /// If non-None, the UI shows an "Install" link that opens this URL. + pub fix_url: Option, + /// If non-None, the UI shows the command text in a confirmation dialog. + /// Display-only — never sent back to the backend for execution. + pub fix_command: Option, + /// The type of fix available for this check. + /// Sent back to the backend along with the check ID to execute the fix. + pub fix_type: Option, + /// If non-None, the resolved path to the main executable on disk. + pub path: Option, + /// If non-None, the resolved path to the ACP bridge executable on disk. + pub bridge_path: Option, + /// Raw debug output: command stdout/stderr, search paths tried, etc. + /// Used by the "Copy details" feature for support diagnostics. + pub raw_output: Option, +} + +/// The full report returned to the frontend. +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct DoctorReport { + pub checks: Vec, +} + +/// A resolved binary: the path (if found) and the diagnostic search trace. +#[derive(Clone)] +pub struct ResolvedBinary { + pub path: Option, + pub search_output: String, +} From d5ad84bd4c8e31544ed7ffa1b045a893a6b3824b Mon Sep 17 00:00:00 2001 From: Matt Toohey Date: Wed, 1 Apr 2026 13:38:26 +1100 Subject: [PATCH 2/3] fix: close shell injection vector in doctor fix execution Replace the raw command string pattern in the Staged app's doctor fix flow with the secure check-ID + fix-type pattern already used by goose2. Backend (doctor.rs): - Import and re-export FixType from the doctor crate - Change run_doctor_fix signature from (command: String) to (check_id: String, fix_type: FixType) - Delegate to doctor::execute_fix which looks up the command from static definitions server-side Frontend (commands.ts): - Add fixType field to DoctorCheck interface - Update runDoctorFix to accept (checkId, fixType) instead of a raw command string UI (DoctorCheckRow.svelte): - Fix button visibility now checks check.fixType instead of check.fixCommand - confirmFix sends check.id and check.fixType instead of check.fixCommand - fixCommand remains as a display-only field in the ConfirmDialog Crate (doctor/src/lib.rs): - Make execute_command pub(crate) since it is an internal primitive and execute_fix is the intended public API --- apps/staged/src-tauri/src/doctor.rs | 11 ++++++----- apps/staged/src/lib/commands.ts | 7 ++++--- .../src/lib/features/doctor/DoctorCheckRow.svelte | 8 ++++---- crates/doctor/src/lib.rs | 7 +++---- 4 files changed, 17 insertions(+), 16 deletions(-) diff --git a/apps/staged/src-tauri/src/doctor.rs b/apps/staged/src-tauri/src/doctor.rs index 1f41f108..28a1522f 100644 --- a/apps/staged/src-tauri/src/doctor.rs +++ b/apps/staged/src-tauri/src/doctor.rs @@ -1,6 +1,6 @@ //! Tauri command wrappers for the doctor health-check system. -use doctor::DoctorReport; +pub use doctor::{CheckStatus, DoctorCheck, DoctorReport, FixType}; /// Run all health checks and return the report. #[tauri::command] @@ -8,10 +8,11 @@ pub async fn run_doctor() -> DoctorReport { doctor::run_checks().await } -/// Run a fix command from a doctor check. +/// Run a fix for a doctor check, identified by check ID and fix type. /// -/// The frontend sends the raw command string from `DoctorCheck.fixCommand`. +/// The actual shell command is looked up from the static check definitions — +/// the caller never sends a raw command string. #[tauri::command] -pub async fn run_doctor_fix(command: String) -> Result<(), String> { - doctor::execute_command(command).await +pub async fn run_doctor_fix(check_id: String, fix_type: FixType) -> Result<(), String> { + doctor::execute_fix(check_id, fix_type).await } diff --git a/apps/staged/src/lib/commands.ts b/apps/staged/src/lib/commands.ts index fca3715a..8e93f5a5 100644 --- a/apps/staged/src/lib/commands.ts +++ b/apps/staged/src/lib/commands.ts @@ -843,6 +843,7 @@ export interface DoctorCheck { message: string; fixUrl: string | null; fixCommand: string | null; + fixType: 'command' | 'bridge' | null; path: string | null; bridgePath: string | null; rawOutput: string | null; @@ -857,9 +858,9 @@ export function runDoctor(): Promise { return invoke('run_doctor'); } -/** Run a fix command from a doctor check. */ -export function runDoctorFix(command: string): Promise { - return invoke('run_doctor_fix', { command }); +/** Run a fix for a doctor check, identified by check ID and fix type. */ +export function runDoctorFix(checkId: string, fixType: string): Promise { + return invoke('run_doctor_fix', { checkId, fixType }); } // ============================================================================= diff --git a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte index 5be7fa40..07af7b07 100644 --- a/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte +++ b/apps/staged/src/lib/features/doctor/DoctorCheckRow.svelte @@ -24,18 +24,18 @@ let showFixDialog = $state(false); function promptFix() { - if (!check.fixCommand) return; + if (!check.fixType) return; fixError = null; fixing = false; showFixDialog = true; } async function confirmFix() { - if (!check.fixCommand) return; + if (!check.fixType) return; fixing = true; fixError = null; try { - await runDoctorFix(check.fixCommand); + await runDoctorFix(check.id, check.fixType); showFixDialog = false; onFixed?.(); } catch (e) { @@ -78,7 +78,7 @@ {/if} - {#if check.fixCommand && check.status !== 'pass'} + {#if check.fixType && check.status !== 'pass'}