diff --git a/ui/goose2/src-tauri/Cargo.lock b/ui/goose2/src-tauri/Cargo.lock index 715f999200..2523a9b10e 100644 --- a/ui/goose2/src-tauri/Cargo.lock +++ b/ui/goose2/src-tauri/Cargo.lock @@ -27,9 +27,9 @@ checksum = "320119579fcad9c21884f5c4861d16174d0e06250625266f50fe6898340abefa" [[package]] name = "agent-client-protocol" -version = "0.10.3" +version = "0.10.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e5bbb6e9d91077607468af8b5bb0f75d10d0b8ac0db49a0c46542cba8ff9f88" +checksum = "10eeef5e80864f9c3c148a3f395c3e35a66d37ec7561c7845b2bffae8e841759" dependencies = [ "agent-client-protocol-schema", "anyhow", @@ -44,9 +44,9 @@ dependencies = [ [[package]] name = "agent-client-protocol-schema" -version = "0.11.3" +version = "0.11.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "96daddd0d00f2eab88f8099d38190881bf8d6c5e46b6fa21751f482775d0dba7" +checksum = "ca68e7e55681ce56546c0cecc6bc8f20493d24b44c6d93ec46174f310730bba2" dependencies = [ "anyhow", "derive_more 2.1.1", @@ -273,9 +273,9 @@ dependencies = [ [[package]] name = "aws-lc-sys" -version = "0.39.0" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1fa7e52a4c5c547c741610a2c6f123f3881e409b714cd27e6798ef020c514f0a" +checksum = "83a25cf98105baa966497416dbd42565ce3a8cf8dbfd59803ec9ad46f3126399" dependencies = [ "cc", "cmake", @@ -561,9 +561,9 @@ dependencies = [ [[package]] name = "cmake" -version = "0.1.57" +version = "0.1.58" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75443c44cd6b379beb8c5b45d85d0773baf31cce901fe7bb252f4eff3008ef7d" +checksum = "c0f78a02292a74a88ac736019ab962ece0bc380e3f977bf72e376c5d78ff0678" dependencies = [ "cc", ] @@ -872,7 +872,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.61.2", ] [[package]] @@ -921,6 +921,15 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "doctor" +version = "0.1.0" +source = "git+https://github.com/block/builderbot?rev=bfce29264181236a2e5b4fd903ace6291f6a7594#bfce29264181236a2e5b4fd903ace6291f6a7594" +dependencies = [ + "serde", + "tokio", +] + [[package]] name = "dom_query" version = "0.27.0" @@ -1052,7 +1061,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1562,6 +1571,7 @@ dependencies = [ "async-trait", "chrono", "dirs", + "doctor", "futures", "log", "rand 0.10.0", @@ -1759,9 +1769,9 @@ checksum = "6dbf3de79e51f3d586ab4cb9d5c3e2c14aa28ed23d180cf89b4df0454a69cc87" [[package]] name = "hyper" -version = "1.8.1" +version = "1.9.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2ab2d4f250c3d7b1c9fcdff1cece94ea4e2dfbec68614f7b87cb205f24ca9d11" +checksum = "6299f016b246a94207e63da54dbe807655bf9e00044f73ded42c3ac5305fbcca" dependencies = [ "atomic-waker", "bytes", @@ -1773,7 +1783,6 @@ dependencies = [ "httparse", "itoa", "pin-project-lite", - "pin-utils", "smallvec", "tokio", "want", @@ -2008,9 +2017,9 @@ checksum = "d98f6fed1fde3f8c21bc40a1abb88dd75e67924f9cffc3ef95607bad8017f8e2" [[package]] name = "iri-string" -version = "0.7.11" +version = "0.7.12" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8e7418f59cc01c88316161279a7f665217ae316b388e58a0d10e29f54f1e5eb" +checksum = "25e659a4bb38e810ebc252e53b5814ff908a8c58c2a9ce2fae1bbec24cbf4e20" dependencies = [ "memchr", "serde", @@ -2120,9 +2129,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.92" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cc4c90f45aa2e6eacbe8645f77fdea542ac97a494bcd117a67df9ff4d611f995" +checksum = "2e04e2ef80ce82e13552136fabeef8a5ed1f985a96805761cbb9a2c34e7664d9" dependencies = [ "cfg-if", "futures-util", @@ -2887,12 +2896,6 @@ version = "0.2.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" -[[package]] -name = "pin-utils" -version = "0.1.0" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b870d8c151b6f2fb93e84a13146138f05d02ed11c7e7c54f8826aaaf7c9f184" - [[package]] name = "piper" version = "0.2.5" @@ -3016,7 +3019,7 @@ version = "3.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e67ba7e9b2b56446f1d419b1d807906278ffa1a658a8a5d8a39dcb1f5a78614f" dependencies = [ - "toml_edit 0.25.8+spec-1.1.0", + "toml_edit 0.25.9+spec-1.1.0", ] [[package]] @@ -3452,7 +3455,7 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3509,7 +3512,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -3788,9 +3791,9 @@ dependencies = [ [[package]] name = "serde_spanned" -version = "1.1.0" +version = "1.1.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "876ac351060d4f882bb1032b6369eb0aef79ad9df1ea8bc404874d8cc3d0cd98" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" dependencies = [ "serde_core", ] @@ -3944,7 +3947,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -4543,7 +4546,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -4747,7 +4750,7 @@ checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" dependencies = [ "indexmap 2.13.0", "serde_core", - "serde_spanned 1.1.0", + "serde_spanned 1.1.1", "toml_datetime 0.7.5+spec-1.1.0", "toml_parser", "toml_writer", @@ -4774,9 +4777,9 @@ dependencies = [ [[package]] name = "toml_datetime" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "97251a7c317e03ad83774a8752a7e81fb6067740609f75ea2b585b569a59198f" +checksum = "3165f65f62e28e0115a00b2ebdd37eb6f3b641855f9d636d3cd4103767159ad7" dependencies = [ "serde_core", ] @@ -4807,30 +4810,30 @@ dependencies = [ [[package]] name = "toml_edit" -version = "0.25.8+spec-1.1.0" +version = "0.25.9+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" +checksum = "da053d28fe57e2c9d21b48261e14e7b4c8b670b54d2c684847b91feaf4c7dac5" dependencies = [ "indexmap 2.13.0", - "toml_datetime 1.1.0+spec-1.1.0", + "toml_datetime 1.1.1+spec-1.1.0", "toml_parser", - "winnow 1.0.0", + "winnow 1.0.1", ] [[package]] name = "toml_parser" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2334f11ee363607eb04df9b8fc8a13ca1715a72ba8662a26ac285c98aabb4011" +checksum = "39ca317ebc49f06bd748bfba29533eac9485569dc9bf80b849024b025e814fb9" dependencies = [ - "winnow 1.0.0", + "winnow 1.0.1", ] [[package]] name = "toml_writer" -version = "1.1.0+spec-1.1.0" +version = "1.1.1+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d282ade6016312faf3e41e57ebbba0c073e4056dab1232ab1cb624199648f8ed" +checksum = "756daf9b1013ebe47a8776667b466417e2d4c5679d441c26230efd9ef78692db" [[package]] name = "tower" @@ -4956,7 +4959,7 @@ checksum = "f2f6fb2847f6742cd76af783a2a2c49e9375d0a111c7bef6f71cd9e738c72d6e" dependencies = [ "memoffset", "tempfile", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5171,9 +5174,9 @@ dependencies = [ [[package]] name = "wasm-bindgen" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6523d69017b7633e396a89c5efab138161ed5aafcbc8d3e5c5a42ae38f50495a" +checksum = "0551fc1bb415591e3372d0bc4780db7e587d84e2a7e79da121051c5c4b89d0b0" dependencies = [ "cfg-if", "once_cell", @@ -5184,9 +5187,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.65" +version = "0.4.67" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2d1faf851e778dfa54db7cd438b70758eba9755cb47403f3496edd7c8fc212f0" +checksum = "03623de6905b7206edd0a75f69f747f134b7f0a2323392d664448bf2d3c5d87e" dependencies = [ "js-sys", "wasm-bindgen", @@ -5194,9 +5197,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4e3a6c758eb2f701ed3d052ff5737f5bfe6614326ea7f3bbac7156192dc32e67" +checksum = "7fbdf9a35adf44786aecd5ff89b4563a90325f9da0923236f6104e603c7e86be" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -5204,9 +5207,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "921de2737904886b52bcbb237301552d05969a6f9c40d261eb0533c8b055fedf" +checksum = "dca9693ef2bab6d4e6707234500350d8dad079eb508dca05530c85dc3a529ff2" dependencies = [ "bumpalo", "proc-macro2", @@ -5217,9 +5220,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.115" +version = "0.2.117" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a93e946af942b58934c604527337bad9ae33ba1d5c6900bbb41c2c07c2364a93" +checksum = "39129a682a6d2d841b6c429d0c51e5cb0ed1a03829d8b3d1e69a011e62cb3d3b" dependencies = [ "unicode-ident", ] @@ -5273,9 +5276,9 @@ dependencies = [ [[package]] name = "web-sys" -version = "0.3.92" +version = "0.3.94" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "84cde8507f4d7cfcb1185b8cb5890c494ffea65edbe1ba82cfd63661c805ed94" +checksum = "cd70027e39b12f0849461e08ffc50b9cd7688d942c1c8e3c7b22273236b4dd0a" dependencies = [ "js-sys", "wasm-bindgen", @@ -5414,7 +5417,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5857,9 +5860,9 @@ dependencies = [ [[package]] name = "winnow" -version = "1.0.0" +version = "1.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a90e88e4667264a994d34e6d1ab2d26d398dcdca8b7f52bec8668957517fc7d8" +checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" dependencies = [ "memchr", ] @@ -6119,18 +6122,18 @@ dependencies = [ [[package]] name = "zerocopy" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efbb2a062be311f2ba113ce66f697a4dc589f85e78a4aea276200804cea0ed87" +checksum = "eed437bf9d6692032087e337407a86f04cd8d6a16a37199ed57949d415bd68e9" dependencies = [ "zerocopy-derive", ] [[package]] name = "zerocopy-derive" -version = "0.8.47" +version = "0.8.48" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0e8bc7269b54418e7aeeef514aa68f8690b8c0489a06b0136e5f57c4c5ccab89" +checksum = "70e3cd084b1788766f53af483dd21f93881ff30d7320490ec3ef7526d203bad4" dependencies = [ "proc-macro2", "quote", diff --git a/ui/goose2/src-tauri/Cargo.toml b/ui/goose2/src-tauri/Cargo.toml index c334e5b54a..ca2ec126b5 100644 --- a/ui/goose2/src-tauri/Cargo.toml +++ b/ui/goose2/src-tauri/Cargo.toml @@ -32,3 +32,4 @@ tokio-stream = "0.1" tokio-util = { version = "0.7", features = ["rt"] } async-trait = "0.1" acp-client = { git = "https://github.com/block/builderbot", rev = "dbd5bc9c0cd559c7a67ab2d56545dba110f4a4cb" } +doctor = { git = "https://github.com/block/builderbot", rev = "bfce29264181236a2e5b4fd903ace6291f6a7594" } diff --git a/ui/goose2/src-tauri/src/commands/doctor.rs b/ui/goose2/src-tauri/src/commands/doctor.rs new file mode 100644 index 0000000000..985af1b9c4 --- /dev/null +++ b/ui/goose2/src-tauri/src/commands/doctor.rs @@ -0,0 +1,15 @@ +//! Tauri command wrappers for the doctor health-check system. + +pub use doctor::{DoctorReport, FixType}; + +/// Run all health checks and return the report. +#[tauri::command] +pub async fn run_doctor() -> DoctorReport { + doctor::run_checks().await +} + +/// Run a fix command for a doctor check, identified by check ID and fix type. +#[tauri::command] +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/ui/goose2/src-tauri/src/commands/doctor/agents.rs b/ui/goose2/src-tauri/src/commands/doctor/agents.rs deleted file mode 100644 index 6252653042..0000000000 --- a/ui/goose2/src-tauri/src/commands/doctor/agents.rs +++ /dev/null @@ -1,254 +0,0 @@ -//! AI agent checks and fix command lookup. - -use std::process::Command; - -use super::resolve::format_command_output; -use super::types::{CheckStatus, DoctorCheck, FixType, ResolvedBinary}; - -/// Metadata for an individual AI agent check. -pub(super) 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(super) 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(super) 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(super) 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/ui/goose2/src-tauri/src/commands/doctor/checks.rs b/ui/goose2/src-tauri/src/commands/doctor/checks.rs deleted file mode 100644 index f756abe35c..0000000000 --- a/ui/goose2/src-tauri/src/commands/doctor/checks.rs +++ /dev/null @@ -1,438 +0,0 @@ -//! Individual health-check functions for tool dependencies (git, gh, git-lfs, etc.). - -use std::process::Command; - -use super::resolve::format_command_output; -use super::types::{CheckStatus, DoctorCheck, FixType, ResolvedBinary}; - -/// Check that `git` is installed and reachable. -pub(super) 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(super) 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(super) 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(super) 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(super) 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/ui/goose2/src-tauri/src/commands/doctor/mod.rs b/ui/goose2/src-tauri/src/commands/doctor/mod.rs deleted file mode 100644 index 5ad9db5718..0000000000 --- a/ui/goose2/src-tauri/src/commands/doctor/mod.rs +++ /dev/null @@ -1,187 +0,0 @@ -//! 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. - -mod agents; -mod checks; -mod resolve; -mod types; - -pub use types::{CheckStatus, DoctorCheck, DoctorReport, FixType}; - -use std::collections::HashMap; -use std::process::Command; - -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. -#[tauri::command] -pub async fn run_doctor() -> 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 frontend never sends a raw command string. -#[tauri::command] -pub async fn run_doctor_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:?}'"))?; - - 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 = 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/ui/goose2/src-tauri/src/commands/doctor/resolve.rs b/ui/goose2/src-tauri/src/commands/doctor/resolve.rs deleted file mode 100644 index 2418c6ae61..0000000000 --- a/ui/goose2/src-tauri/src/commands/doctor/resolve.rs +++ /dev/null @@ -1,74 +0,0 @@ -//! 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(super) 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(super) 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/ui/goose2/src-tauri/src/commands/doctor/types.rs b/ui/goose2/src-tauri/src/commands/doctor/types.rs deleted file mode 100644 index 7863ddc59f..0000000000 --- a/ui/goose2/src-tauri/src/commands/doctor/types.rs +++ /dev/null @@ -1,65 +0,0 @@ -//! 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(super) struct ResolvedBinary { - pub path: Option, - pub search_output: String, -}