diff --git a/src-tauri/src/acp/binary_cache.rs b/src-tauri/src/acp/binary_cache.rs index 17295f9c..c4ad928b 100644 --- a/src-tauri/src/acp/binary_cache.rs +++ b/src-tauri/src/acp/binary_cache.rs @@ -347,6 +347,35 @@ pub fn detect_installed_version( installed_version_for_agent(agent_type, cmd_name) } +/// Resolve a user-managed binary that can launch the registry command. +/// +/// Most binary agents expose the same command name on PATH. OpenCode Desktop +/// for macOS is the notable exception: it installs an app bundle whose ACP-capable +/// CLI is `Contents/MacOS/opencode-cli`, not a PATH-visible `opencode`. +pub fn resolve_system_binary_for_agent(agent_type: AgentType, cmd_name: &str) -> Option { + if let Ok(path) = which::which(cmd_name) { + return Some(path); + } + + if agent_type == AgentType::OpenCode && cmd_name == "opencode" { + let mut candidates = vec![PathBuf::from( + "/Applications/OpenCode.app/Contents/MacOS/opencode-cli", + )]; + if let Some(home) = dirs::home_dir() { + candidates.push( + home.join("Applications") + .join("OpenCode.app") + .join("Contents") + .join("MacOS") + .join("opencode-cli"), + ); + } + return candidates.into_iter().find(|path| path.is_file()); + } + + None +} + /// Return the best cached binary across all installed versions. /// /// This returns the path + version label of the highest semver-ish diff --git a/src-tauri/src/acp/connection.rs b/src-tauri/src/acp/connection.rs index e99fec40..70489c29 100644 --- a/src-tauri/src/acp/connection.rs +++ b/src-tauri/src/acp/connection.rs @@ -279,14 +279,25 @@ async fn build_agent( // `src/contexts/acp-connections-context.tsx` to surface a // localized install prompt. Do not change the wording. let (binary_path, cached_version) = - crate::acp::binary_cache::find_best_cached_binary_for_agent(agent_type, cmd)? - .ok_or_else(|| { - AcpError::SdkNotInstalled(format!( - "{} is not installed. Please install it in Agent Settings.", - meta.name - )) - })?; - if cached_version == registry_version { + match crate::acp::binary_cache::find_best_cached_binary_for_agent(agent_type, cmd)? + { + Some(cached) => cached, + None => { + let path = crate::acp::binary_cache::resolve_system_binary_for_agent( + agent_type, cmd, + ) + .ok_or_else(|| { + AcpError::SdkNotInstalled(format!( + "{} is not installed. Please install it in Agent Settings.", + meta.name + )) + })?; + (path, "system".to_string()) + } + }; + if cached_version == "system" { + eprintln!("[ACP][{}] Using system binary {}", meta.name, binary_path.display()); + } else if cached_version == registry_version { eprintln!("[ACP][{}] Using cached binary {cached_version}", meta.name); } else { eprintln!( diff --git a/src-tauri/src/acp/preflight.rs b/src-tauri/src/acp/preflight.rs index 5d4f3026..595d0c9e 100644 --- a/src-tauri/src/acp/preflight.rs +++ b/src-tauri/src/acp/preflight.rs @@ -433,7 +433,7 @@ async fn check_binary_environment( }; checks.push(platform_check); - // Check binary cache. + // Check binary cache / system binary. // // Pass as long as *any* cached version is present — the session-page // connect path uses the best cached version via @@ -441,7 +441,9 @@ async fn check_binary_environment( // should still be considered "ready". If the cached version differs // from the registry's recommended version, we note it in the message // but still pass — the Settings page's version-badge flow is the - // canonical place to surface "upgrade available". + // canonical place to surface "upgrade available". If Codeg did not + // download the binary but the command is already available on PATH (for + // example a user-managed `opencode`), that is launchable too. if platform_supported { let cache_check = match binary_cache::find_best_cached_binary_for_agent(agent_type, cmd) { Ok(Some((_, cached_version))) => { @@ -458,15 +460,27 @@ async fn check_binary_environment( fixes: vec![], } } - Ok(None) => CheckItem { - check_id: "binary_cached".into(), - label: "Binary cache".into(), - status: CheckStatus::Warn, - message: - "Binary is not installed. Download it from Agent Settings before connecting." - .into(), - fixes: vec![], - }, + Ok(None) => { + if let Some(path) = binary_cache::resolve_system_binary_for_agent(agent_type, cmd) { + CheckItem { + check_id: "binary_cached".into(), + label: "Binary".into(), + status: CheckStatus::Pass, + message: format!("Using system binary {}", path.display()), + fixes: vec![], + } + } else { + CheckItem { + check_id: "binary_cached".into(), + label: "Binary cache".into(), + status: CheckStatus::Warn, + message: + "Binary is not installed. Download it from Agent Settings before connecting." + .into(), + fixes: vec![], + } + } + } Err(_) => CheckItem { check_id: "binary_cached".into(), label: "Binary cache".into(), diff --git a/src-tauri/src/acp/types.rs b/src-tauri/src/acp/types.rs index a525692b..5fd435e8 100644 --- a/src-tauri/src/acp/types.rs +++ b/src-tauri/src/acp/types.rs @@ -492,6 +492,13 @@ pub struct AcpAgentInfo { pub enabled: bool, pub sort_order: i32, pub installed_version: Option, + /// Version of the upstream/original CLI detected on PATH (for example + /// `claude` or `codex`) when the managed ACP adapter/wrapper itself is not + /// installed. This is informational for Settings and must not be treated as + /// ACP-launch readiness. + pub base_cli_version: Option, + pub base_cli_command: Option, + pub base_cli_package: Option, pub env: BTreeMap, pub config_json: Option, pub config_file_path: Option, @@ -512,6 +519,10 @@ pub struct AcpAgentStatus { pub available: bool, pub enabled: bool, pub installed_version: Option, + /// Informational upstream/original CLI detection; see `AcpAgentInfo`. + pub base_cli_version: Option, + pub base_cli_command: Option, + pub base_cli_package: Option, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] diff --git a/src-tauri/src/commands/acp.rs b/src-tauri/src/commands/acp.rs index b6c07b8e..87612991 100644 --- a/src-tauri/src/commands/acp.rs +++ b/src-tauri/src/commands/acp.rs @@ -27,6 +27,9 @@ use crate::web::event_bridge::EventEmitter; const ACP_AGENTS_UPDATED_EVENT: &str = "app://acp-agents-updated"; const NPM_PREFIX_TIMEOUT: Duration = Duration::from_millis(1500); +const NPM_LIST_TIMEOUT: Duration = Duration::from_millis(1500); +const NPX_VERSION_COMMAND_TIMEOUT: Duration = Duration::from_millis(3000); +const UNKNOWN_INSTALLED_VERSION: &str = "unknown"; static NPM_GLOBAL_PREFIX_CACHE: tokio::sync::OnceCell = tokio::sync::OnceCell::const_new(); @@ -89,7 +92,11 @@ fn is_version_like(value: &str) -> bool { } fn normalize_version_candidate(value: &str) -> Option { - let normalized = value.trim().trim_start_matches('v'); + let trimmed = value.trim(); + let normalized = trimmed + .strip_prefix('v') + .or_else(|| trimmed.strip_prefix('V')) + .unwrap_or(trimmed); if is_version_like(normalized) { Some(normalized.to_string()) } else { @@ -233,6 +240,38 @@ fn uvx_agent_launchable(system_cmd: Option<(&'static str, &'static [&'static str .unwrap_or(false) } +async fn detect_uvx_installed_version( + agent_type: AgentType, + system_cmd: Option<(&'static str, &'static [&'static str])>, +) -> Option { + if let Some(version) = binary_cache::uvx_prepared_version(agent_type) { + return Some(version); + } + + // A user-installed system CLI (for example `hermes`) is a valid launch + // path even when Codeg did not prepare the uvx package. Surface it as + // installed/launchable so Settings and the connect gate agree. + if let Some((cmd, _)) = system_cmd { + if let Some(path) = resolve_command_on_path(cmd) { + return Some( + version_from_command_output(&path) + .await + .unwrap_or_else(|| UNKNOWN_INSTALLED_VERSION.to_string()), + ); + } + } + + // If uvx itself is available, the pinned package can be fetched and run on + // demand. We deliberately do not run `uvx --from ... --version` here: + // passive detection must not download packages. Use a sentinel instead of + // `None` so the UI warns rather than falsely saying "not installed". + if resolve_uvx_command().is_some() { + return Some(UNKNOWN_INSTALLED_VERSION.to_string()); + } + + None +} + /// The `uvx` flags that pin the interpreter for a `Uvx` agent, inserted before /// `--from`. Returns `["--python", ]` when the distribution sets a /// `python` pin, else an empty vec. Centralizes the pin so every uvx invocation @@ -434,6 +473,221 @@ fn is_npm_command_candidate(path: &Path) -> bool { .unwrap_or(false) } +fn npm_package_path(package_name: &str) -> Option { + let trimmed = package_name.trim(); + if trimmed.is_empty() { + return None; + } + + let mut path = PathBuf::new(); + for part in trimmed.split('/') { + if part.is_empty() || part == "." || part == ".." { + return None; + } + path.push(part); + } + Some(path) +} + +fn npm_package_json_from_prefix(prefix: &Path, package_name: &str) -> Option { + let package_path = npm_package_path(package_name)?; + let node_modules = if cfg!(windows) { + prefix.join("node_modules") + } else { + prefix.join("lib").join("node_modules") + }; + Some(node_modules.join(package_path).join("package.json")) +} + +fn version_from_package_json(path: &Path, expected_name: &str) -> Option { + let raw = fs::read_to_string(path).ok()?; + let json: serde_json::Value = serde_json::from_str(&raw).ok()?; + if json.get("name")?.as_str()? != expected_name { + return None; + } + normalize_version_candidate(json.get("version")?.as_str()?) +} + +fn version_from_npm_prefix_package_json(prefix: &Path, package_name: &str) -> Option { + let package_json = npm_package_json_from_prefix(prefix, package_name)?; + version_from_package_json(&package_json, package_name) +} + +fn version_from_package_json_ancestors(start: &Path, package_name: &str) -> Option { + let first_dir = if start.is_dir() { start } else { start.parent()? }; + for dir in first_dir.ancestors() { + if let Some(version) = version_from_package_json(&dir.join("package.json"), package_name) { + return Some(version); + } + } + None +} + +fn version_from_resolved_npx_command(command_path: &Path, package_name: &str) -> Option { + // npm's Unix shims are often symlinks into + // `/lib/node_modules//...`; canonicalizing lets us read the + // package.json without spawning npm. Real-file shims are handled by the + // prefix fallback below. + if let Some(version) = version_from_package_json_ancestors(command_path, package_name) { + return Some(version); + } + if let Ok(canonical) = fs::canonicalize(command_path) { + if let Some(version) = version_from_package_json_ancestors(&canonical, package_name) { + return Some(version); + } + } + + // npm global shims live in `/bin` on Unix and directly under + // `` on Windows. This catches non-symlink shims (notably Windows + // `.cmd` files) without parsing shell/batch contents. + let prefix = if cfg!(windows) { + command_path.parent().map(Path::to_path_buf) + } else { + command_path + .parent() + .and_then(|bin_dir| (bin_dir.file_name()? == "bin").then(|| bin_dir.parent())) + .flatten() + .map(Path::to_path_buf) + }; + prefix + .as_deref() + .and_then(|p| version_from_npm_prefix_package_json(p, package_name)) +} + +fn first_version_like_in_text(text: &str) -> Option { + text.split(|c: char| { + c.is_whitespace() + || matches!( + c, + '/' | '\\' | '=' | ':' | ';' | ',' | '(' | ')' | '[' | ']' | '{' | '}' | '"' | '\'' + ) + }) + .find_map(|part| { + let candidate = part.trim_matches(|c: char| { + !(c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '+' | 'v' | 'V')) + }); + normalize_version_candidate(candidate) + }) +} + +async fn version_from_command_output(command_path: &Path) -> Option { + let mut cmd = crate::process::tokio_command(command_path); + cmd.arg("--version").kill_on_drop(true); + let output = tokio::time::timeout(NPX_VERSION_COMMAND_TIMEOUT, cmd.output()) + .await + .ok()? + .ok()?; + if !output.status.success() { + return None; + } + let mut combined = String::from_utf8_lossy(&output.stdout).to_string(); + if !output.stderr.is_empty() { + combined.push('\n'); + combined.push_str(&String::from_utf8_lossy(&output.stderr)); + } + first_version_like_in_text(&combined) +} + +async fn detect_npx_installed_version( + package_name: &str, + resolved_cmd: Option<&Path>, +) -> Option { + if package_name.trim().is_empty() { + return None; + } + + if let Some(path) = resolved_cmd { + if let Some(version) = version_from_resolved_npx_command(path, package_name) { + return Some(version); + } + } + if let Some(version) = detect_npm_global_version(package_name).await { + return Some(version); + } + if let Some(path) = resolved_cmd { + if let Some(version) = version_from_command_output(path).await { + return Some(version); + } + } + None +} + +async fn detect_binary_installed_version(agent_type: AgentType, cmd: &str) -> Option { + if let Ok(Some(version)) = binary_cache::detect_installed_version(agent_type, cmd) { + return Some(version); + } + + let resolved = binary_cache::resolve_system_binary_for_agent(agent_type, cmd)?; + Some( + version_from_command_output(&resolved) + .await + .unwrap_or_else(|| UNKNOWN_INSTALLED_VERSION.to_string()), + ) +} + +fn npx_installed_version_or_unknown(version: Option) -> String { + version.unwrap_or_else(|| UNKNOWN_INSTALLED_VERSION.to_string()) +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +struct BaseCliFallback { + cmd: &'static str, + package: Option<&'static str>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +struct BaseCliDetection { + cmd: &'static str, + package: Option<&'static str>, + version: String, +} + +fn base_cli_fallback(agent_type: AgentType) -> Option { + match agent_type { + // The managed ACP package exposes `claude-agent-acp`, but many users + // already have the upstream Claude Code CLI (`claude`) installed and + // reasonably expect Settings to notice it. Keep this informational: + // launching still requires the ACP adapter. + AgentType::ClaudeCode => Some(BaseCliFallback { + cmd: "claude", + package: Some("@anthropic-ai/claude-code"), + }), + // Codeg launches Zed's `codex-acp` adapter, while the common local + // install is OpenAI's upstream `codex` CLI (`@openai/codex`). + AgentType::Codex => Some(BaseCliFallback { + cmd: "codex", + package: Some("@openai/codex"), + }), + _ => None, + } +} + +async fn detect_base_cli(agent_type: AgentType) -> Option { + let fallback = base_cli_fallback(agent_type)?; + detect_base_cli_with_fallback(fallback).await +} + +async fn detect_base_cli_with_fallback(fallback: BaseCliFallback) -> Option { + let resolved = resolve_npx_command(fallback.cmd).await?; + + let mut version = None; + if let Some(package) = fallback.package { + version = version_from_resolved_npx_command(&resolved, package); + if version.is_none() { + version = detect_npm_global_version(package).await; + } + } + if version.is_none() { + version = version_from_command_output(&resolved).await; + } + + Some(BaseCliDetection { + cmd: fallback.cmd, + package: fallback.package, + version: version.unwrap_or_else(|| UNKNOWN_INSTALLED_VERSION.to_string()), + }) +} + /// Verify that the agent SDK / binary is installed and usable. /// /// This is the pre-spawn guard used by the session-page connect path: @@ -470,8 +724,12 @@ pub(crate) async fn verify_agent_installed(agent_type: AgentType) -> Result<(), } // Accept any cached version — the Settings page will still // surface "upgrade available" for stale caches via its own - // version-badge flow. - if binary_cache::find_best_cached_binary_for_agent(agent_type, cmd)?.is_none() { + // version-badge flow. Also accept a user-installed binary on PATH + // (notably OpenCode's `opencode` and globally installed adapters + // such as `codex-acp`). + if binary_cache::find_best_cached_binary_for_agent(agent_type, cmd)?.is_none() + && binary_cache::resolve_system_binary_for_agent(agent_type, cmd).is_none() + { // INVARIANT: see note above — "is not installed" is a // stable substring the frontend matches against. return Err(AcpError::SdkNotInstalled(format!( @@ -507,6 +765,20 @@ pub(crate) async fn verify_agent_installed(agent_type: AgentType) -> Result<(), async fn detect_npm_global_version(package_name: &str) -> Option { let npm_path = which::which("npm").ok()?; + // Fast path: read package.json from known prefixes before spawning npm. + if let Some(prefix) = cached_npm_global_prefix().await { + if let Some(version) = version_from_npm_prefix_package_json(&prefix, package_name) { + return Some(version); + } + } + if let Some(prefix) = crate::process::user_npm_prefix() { + if prefix.exists() { + if let Some(version) = version_from_npm_prefix_package_json(&prefix, package_name) { + return Some(version); + } + } + } + // Try the default global prefix first. if let Some(v) = npm_list_version(&npm_path, package_name, None).await { return Some(v); @@ -538,7 +810,11 @@ async fn npm_list_version( if let Some(p) = prefix { cmd.arg(format!("--prefix={}", p.display())); } - let output = cmd.output().await.ok()?; + cmd.kill_on_drop(true); + let output = tokio::time::timeout(NPM_LIST_TIMEOUT, cmd.output()) + .await + .ok()? + .ok()?; let stdout = String::from_utf8_lossy(&output.stdout); let json: serde_json::Value = serde_json::from_str(&stdout).ok()?; let version = json @@ -553,19 +829,16 @@ async fn detect_local_version(agent_type: AgentType) -> Option { let meta = registry::get_agent_meta(agent_type); match meta.distribution { registry::AgentDistribution::Npx { cmd, package, .. } => { - if !is_cmd_available(cmd).await { - return None; - } - // Try `npm list -g --json` to get the real installed version. + let resolved = resolve_npx_command(cmd).await?; let pkg_name = package_name_from_spec(package); - detect_npm_global_version(&pkg_name).await + detect_npx_installed_version(&pkg_name, Some(resolved.as_path())).await } registry::AgentDistribution::Binary { cmd, .. } => { - binary_cache::detect_installed_version(agent_type, cmd) - .ok() - .flatten() + detect_binary_installed_version(agent_type, cmd).await + } + registry::AgentDistribution::Uvx { system_cmd, .. } => { + detect_uvx_installed_version(agent_type, system_cmd).await } - registry::AgentDistribution::Uvx { .. } => binary_cache::uvx_prepared_version(agent_type), } } @@ -4319,29 +4592,51 @@ pub(crate) async fn acp_get_agent_status_core( .map_err(|e| AcpError::protocol(e.to_string()))?; let (available, installed_version) = match &meta.distribution { - registry::AgentDistribution::Npx { cmd, .. } => ( - true, - resolve_npx_command(cmd) - .await - .and_then(|_| setting.as_ref().and_then(|m| m.installed_version.clone())), - ), + registry::AgentDistribution::Npx { cmd, package, .. } => { + let resolved = resolve_npx_command(cmd).await; + let version = if let Some(resolved_path) = resolved.as_deref() { + if let Some(v) = setting.as_ref().and_then(|m| m.installed_version.clone()) { + Some(v) + } else { + // Command found on disk but DB has no version record — + // probe the package/shim for the real installed version. + // If the command is launchable but the version cannot be + // proven, return "unknown" so the UI warns instead of + // showing a false "not installed" fail. + let pkg_name = package_name_from_spec(package); + Some(npx_installed_version_or_unknown( + detect_npx_installed_version(&pkg_name, Some(resolved_path)).await, + )) + } + } else { + None + }; + (true, version) + } registry::AgentDistribution::Binary { platforms, cmd, .. } => { - let detected = binary_cache::detect_installed_version(agent_type, cmd) - .ok() - .flatten(); + let detected = detect_binary_installed_version(agent_type, cmd).await; (platforms.iter().any(|p| p.platform == platform), detected) } registry::AgentDistribution::Uvx { system_cmd, .. } => ( uvx_agent_launchable(*system_cmd), - binary_cache::uvx_prepared_version(agent_type), + detect_uvx_installed_version(agent_type, *system_cmd).await, ), }; + let base_cli = if installed_version.is_none() { + detect_base_cli(agent_type).await + } else { + None + }; + Ok(crate::acp::types::AcpAgentStatus { agent_type, available, enabled: setting.map(|m| m.enabled).unwrap_or(true), installed_version, + base_cli_version: base_cli.as_ref().map(|d| d.version.clone()), + base_cli_command: base_cli.as_ref().map(|d| d.cmd.to_string()), + base_cli_package: base_cli.and_then(|d| d.package.map(ToString::to_string)), }) } @@ -4383,20 +4678,35 @@ pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result { + registry::AgentDistribution::Npx { + cmd, package, .. + } => { // Keep the list path bounded: each list request probes npm // global prefix at most once, then reuses the result across // all NPX agents in the loop. - let cached = npx_resolver - .resolve_for_list(cmd) - .await - .and_then(|_| setting.and_then(|m| m.installed_version.clone())); - (true, "npx", cached) + let resolved = npx_resolver.resolve_for_list(cmd).await; + let version = if let Some(resolved_path) = resolved.as_deref() { + if let Some(v) = setting.and_then(|m| m.installed_version.clone()) { + Some(v) + } else { + // Command found on disk but DB has no version record + // (e.g. user installed outside Codeg, or DB was + // cleared). Probe the package/shim for the real + // installed version; if only launchability can be + // proven, use "unknown" so the UI warns instead of + // showing "fail". + let pkg_name = package_name_from_spec(package); + Some(npx_installed_version_or_unknown( + detect_npx_installed_version(&pkg_name, Some(resolved_path)).await, + )) + } + } else { + None + }; + (true, "npx", version) } registry::AgentDistribution::Binary { platforms, cmd, .. } => { - let detected = binary_cache::detect_installed_version(agent_type, cmd) - .ok() - .flatten(); + let detected = detect_binary_installed_version(agent_type, cmd).await; ( platforms.iter().any(|p| p.platform == platform), "binary", @@ -4406,7 +4716,7 @@ pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result ( uvx_agent_launchable(*system_cmd), "uvx", - binary_cache::uvx_prepared_version(agent_type), + detect_uvx_installed_version(agent_type, *system_cmd).await, ), }; @@ -4439,7 +4749,11 @@ pub(crate) async fn acp_list_agents_core(db: &AppDatabase) -> Result Result Result): AcpAgentInfo { enabled: true, sort_order: 0, installed_version: null, + base_cli_version: null, + base_cli_command: null, + base_cli_package: null, env: {}, config_json: null, config_file_path: null, @@ -127,6 +130,25 @@ describe("buildVersionCheck", () => { expect(check?.status).toBe("fail") expect(check?.fixes).toHaveLength(0) }) + + it("warns instead of failing when only the upstream CLI is detected", () => { + const check = buildVersionCheck( + makeAgent({ + agent_type: "codex" as AgentType, + distribution_type: "binary", + installed_version: null, + base_cli_version: "0.128.0", + base_cli_command: "codex", + base_cli_package: "@openai/codex", + }) + ) + + expect(check?.status).toBe("warn") + expect(check?.message).toContain("codex 0.128.0") + expect(check?.fixes.some((fix) => fix.kind === "download_binary")).toBe( + true + ) + }) }) describe("getAgentChecks uv gating", () => { @@ -146,6 +168,22 @@ describe("getAgentChecks uv gating", () => { ], }, } + const systemCliPreflight: { result: PreflightResult } = { + result: { + agent_type: "hermes" as AgentType, + agent_name: "Hermes Agent", + passed: true, + checks: [ + { + check_id: "uv_available", + label: "uv", + status: "warn", + message: "uv not found; will launch via the system `hermes` command", + fixes: [{ label: "Install uv", kind: "install_uv", payload: "" }], + }, + ], + }, + } // When uv is confirmed missing, the version-status install is blocked AND the // actionable "Install uv" fix is present in the same result — never a dead end. @@ -186,4 +224,20 @@ describe("getAgentChecks uv gating", () => { expect(installFix).toBeDefined() expect(fixDisabled(installFix!)).toBe(false) }) + + it("does not block when uv is missing but a system uvx-agent CLI is launchable", () => { + const checks = getAgentChecks( + makeAgent({ + distribution_type: "uvx", + available: true, + installed_version: "unknown", + }), + systemCliPreflight + ) + + const versionCheck = checks.find((c) => c.check_id === "version_status") + expect(versionCheck?.status).toBe("warn") + expect(versionCheck?.fixes.some((fix) => fixDisabled(fix))).toBe(false) + expect(versionCheck?.message).toContain("unknown") + }) }) diff --git a/src/components/settings/acp-agent-settings.tsx b/src/components/settings/acp-agent-settings.tsx index 7f591abc..8c03e773 100644 --- a/src/components/settings/acp-agent-settings.tsx +++ b/src/components/settings/acp-agent-settings.tsx @@ -212,7 +212,13 @@ function acpText( fallback: string, values?: Record ): string { - if (!acpTranslator) return fallback + if (!acpTranslator) { + if (!values) return fallback + return fallback.replace(/\{(\w+)\}/g, (match, name: string) => { + const value = values[name] + return value === undefined ? match : String(value) + }) + } return acpTranslator(key, values) } @@ -2639,6 +2645,30 @@ export function buildVersionCheck( const withCustomInstall = (fixes: UiFixAction[]): UiFixAction[] => supportsCustomInstall ? [...fixes, customInstallFix] : fixes + if (!agent.installed_version && agent.base_cli_version) { + return { + check_id: "version_status", + label: acpText("version.statusLabel", "Version Status"), + status: "warn", + message: acpText( + "version.baseCliDetected", + "{versionText}. Detected upstream CLI {command} {version}, but the ACP adapter is not installed yet. Click Install to install the adapter.", + { + versionText, + command: agent.base_cli_command ?? "CLI", + version: agent.base_cli_version, + } + ), + fixes: withCustomInstall([ + { + label: acpText("actions.install", "Install"), + kind: installAction, + payload: agent.agent_type, + }, + ]), + } + } + if (!agent.installed_version) { return { check_id: "version_status", @@ -2759,16 +2789,17 @@ export function getAgentChecks( current?: AgentCheckState ): UiCheckItem[] { // For uvx agents, only treat uv as not-ready when the preflight result is - // present AND its uv check isn't passing. With no result yet (or an errored - // preflight) stay optimistic — otherwise we'd block the version-status - // install while the "Install uv" button (which lives in that same preflight - // result) is absent, a dead end. When the result IS present, the button is - // present alongside it, so blocking is always paired with an actionable fix. + // present AND its uv check is a hard failure. A warn result means a system + // agent CLI fallback (for example `hermes`) is launchable even though uv is + // absent, so Settings must not downgrade that to "not installed". With no + // result yet (or an errored one), stay optimistic — otherwise we'd block the + // version-status install while the "Install uv" button (which lives in that + // same preflight result) is absent, a dead end. const uvCheck = current?.result?.checks?.find( (check) => check.check_id === "uv_available" ) const uvReady = - agent.distribution_type !== "uvx" || !uvCheck || uvCheck.status === "pass" + agent.distribution_type !== "uvx" || !uvCheck || uvCheck.status !== "fail" const versionCheck = buildVersionCheck(agent, uvReady) const remoteChecks: UiCheckItem[] = (current?.result?.checks ?? []).map( (check) => ({ @@ -3024,14 +3055,29 @@ export function AcpAgentSettings() { // install that provisions the runtime flips it true here — otherwise // the version-status panel would stay stuck on the unavailable / // "runtime not ready" branch with the freshly installed version shown. + // Also refresh upstream/base CLI metadata (`claude`, `codex`) so a + // manual check can downgrade "not installed" to the adapter-missing + // warning without requiring a full list reload. if (statusState.status === "fulfilled") { setAgents((prev) => { let changed = false const next = prev.map((agent) => { if (agent.agent_type !== agentType) return agent - if (agent.available === statusState.value.available) return agent + if ( + agent.available === statusState.value.available && + agent.base_cli_version === statusState.value.base_cli_version && + agent.base_cli_command === statusState.value.base_cli_command && + agent.base_cli_package === statusState.value.base_cli_package + ) + return agent changed = true - return { ...agent, available: statusState.value.available } + return { + ...agent, + available: statusState.value.available, + base_cli_version: statusState.value.base_cli_version ?? null, + base_cli_command: statusState.value.base_cli_command ?? null, + base_cli_package: statusState.value.base_cli_package ?? null, + } }) return changed ? next : prev }) diff --git a/src/contexts/acp-connections-context.tsx b/src/contexts/acp-connections-context.tsx index ca2f1d07..f2f0b254 100644 --- a/src/contexts/acp-connections-context.tsx +++ b/src/contexts/acp-connections-context.tsx @@ -2115,6 +2115,17 @@ export function AcpConnectionsProvider({ children }: { children: ReactNode }) { return { kind: "none", reason: "" } } + if (agent.base_cli_version) { + return { + kind: "sdk_missing", + reason: t("blocked.adapterMissingWithBaseCli", { + agent: agentLabel, + command: agent.base_cli_command ?? "CLI", + version: agent.base_cli_version, + }), + } + } + return { kind: "sdk_missing", reason: t("blocked.sdkMissing", { agent: agentLabel }), diff --git a/src/i18n/messages/ar.json b/src/i18n/messages/ar.json index 103927f4..d1043ac2 100644 --- a/src/i18n/messages/ar.json +++ b/src/i18n/messages/ar.json @@ -790,7 +790,8 @@ "localUnrecognized": "{versionText}. الإصدار المحلي غير قابل للمقارنة؛ جرّب الترقية للكتابة فوق التثبيت.", "upgradeAvailable": "{versionText}. تتوفر ترقية.", "remoteUnavailable": "{versionText}. الإصدار البعيد غير متاح حاليًا.", - "latest": "{versionText}. أنت على أحدث إصدار." + "latest": "{versionText}. أنت على أحدث إصدار.", + "baseCliDetected": "{versionText}. تم اكتشاف CLI الأصلي {command} {version}، لكن محوّل ACP غير مثبت بعد. انقر على تثبيت لتثبيت المحوّل." }, "cline": { "configDescription": "تكوين مزود واجهة برمجة التطبيقات وبيانات اعتماد Cline. يتم حفظ الإعدادات في ~/.cline/data/." @@ -1761,6 +1762,7 @@ "missingConfig": "تعذر قراءة إعدادات الوكيل الحالية.", "disabled": "{agent} معطّل في إعدادات الوكلاء. قم بتمكينه قبل الاتصال.", "unavailable": "{agent} غير متاح على المنصة الحالية.", + "adapterMissingWithBaseCli": "تم اكتشاف واجهة CLI الأصلية {command} {version}، لكن محوّل ACP لـ {agent} غير مثبت بعد.", "sdkMissing": "لم يتم تثبيت SDK الخاص بـ {agent}" }, "backendErrors": { diff --git a/src/i18n/messages/de.json b/src/i18n/messages/de.json index 39c9732b..7dfeb1e0 100644 --- a/src/i18n/messages/de.json +++ b/src/i18n/messages/de.json @@ -790,7 +790,8 @@ "localUnrecognized": "{versionText}. Lokale Version ist nicht vergleichbar; zum Überschreiben bitte Upgrade versuchen.", "upgradeAvailable": "{versionText}. Upgrade verfügbar.", "remoteUnavailable": "{versionText}. Remote-Version ist derzeit nicht verfügbar.", - "latest": "{versionText}. Bereits aktuell." + "latest": "{versionText}. Bereits aktuell.", + "baseCliDetected": "{versionText}. Upstream-CLI {command} {version} erkannt, aber der ACP-Adapter ist noch nicht installiert. Klicke auf Installieren, um den Adapter zu installieren." }, "cline": { "configDescription": "Konfigurieren Sie den Cline API-Anbieter und die Anmeldedaten. Einstellungen werden in ~/.cline/data/ gespeichert." @@ -1761,6 +1762,7 @@ "missingConfig": "Aktuelle Agenten-Konfiguration kann nicht gelesen werden.", "disabled": "{agent} ist in den Agenten-Einstellungen deaktiviert. Aktivieren Sie ihn vor dem Verbinden.", "unavailable": "{agent} ist auf der aktuellen Plattform nicht verfügbar.", + "adapterMissingWithBaseCli": "Upstream-CLI {command} {version} erkannt, aber der ACP-Adapter für {agent} ist noch nicht installiert.", "sdkMissing": "{agent} SDK ist nicht installiert" }, "backendErrors": { diff --git a/src/i18n/messages/en.json b/src/i18n/messages/en.json index d695d39d..5d961ee7 100644 --- a/src/i18n/messages/en.json +++ b/src/i18n/messages/en.json @@ -790,7 +790,8 @@ "localUnrecognized": "{versionText}. Local version is not comparable; try upgrade to overwrite install.", "upgradeAvailable": "{versionText}. Upgrade available.", "remoteUnavailable": "{versionText}. Remote version is currently unavailable.", - "latest": "{versionText}. Already latest." + "latest": "{versionText}. Already latest.", + "baseCliDetected": "{versionText}. Detected upstream CLI {command} {version}, but the ACP adapter is not installed yet. Click Install to install the adapter." }, "cline": { "configDescription": "Configure Cline API provider and credentials. Settings are saved to ~/.cline/data/." @@ -1761,6 +1762,7 @@ "missingConfig": "Unable to read current Agent configuration.", "disabled": "{agent} is disabled in Agents settings. Enable it before connecting.", "unavailable": "{agent} is unavailable on the current platform.", + "adapterMissingWithBaseCli": "Detected upstream CLI {command} {version}, but the {agent} ACP adapter is not installed yet.", "sdkMissing": "{agent} SDK is not installed" }, "backendErrors": { diff --git a/src/i18n/messages/es.json b/src/i18n/messages/es.json index 31046c83..76b8a1a3 100644 --- a/src/i18n/messages/es.json +++ b/src/i18n/messages/es.json @@ -790,7 +790,8 @@ "localUnrecognized": "{versionText}. La versión local no es comparable; intenta actualizar para sobrescribir la instalación.", "upgradeAvailable": "{versionText}. Hay actualización disponible.", "remoteUnavailable": "{versionText}. La versión remota no está disponible por ahora.", - "latest": "{versionText}. Ya está en la última versión." + "latest": "{versionText}. Ya está en la última versión.", + "baseCliDetected": "{versionText}. Se detectó la CLI original {command} {version}, pero el adaptador ACP aún no está instalado. Haz clic en Instalar para instalarlo." }, "cline": { "configDescription": "Configure el proveedor de API y las credenciales de Cline. La configuración se guarda en ~/.cline/data/." @@ -1761,6 +1762,7 @@ "missingConfig": "No se puede leer la configuración actual del agente.", "disabled": "{agent} está deshabilitado en Ajustes de agentes. Actívalo antes de conectar.", "unavailable": "{agent} no está disponible en la plataforma actual.", + "adapterMissingWithBaseCli": "Se detectó la CLI upstream {command} {version}, pero el adaptador ACP de {agent} aún no está instalado.", "sdkMissing": "El SDK de {agent} no está instalado" }, "backendErrors": { diff --git a/src/i18n/messages/fr.json b/src/i18n/messages/fr.json index 96227bbf..35d40b14 100644 --- a/src/i18n/messages/fr.json +++ b/src/i18n/messages/fr.json @@ -790,7 +790,8 @@ "localUnrecognized": "{versionText}. La version locale n’est pas comparable ; essayez une mise à niveau pour écraser l’installation.", "upgradeAvailable": "{versionText}. Mise à niveau disponible.", "remoteUnavailable": "{versionText}. La version distante est actuellement indisponible.", - "latest": "{versionText}. Déjà à jour." + "latest": "{versionText}. Déjà à jour.", + "baseCliDetected": "{versionText}. La CLI amont {command} {version} a été détectée, mais l’adaptateur ACP n’est pas encore installé. Cliquez sur Installer pour l’installer." }, "cline": { "configDescription": "Configurez le fournisseur API et les identifiants Cline. Les paramètres sont enregistrés dans ~/.cline/data/." @@ -1761,6 +1762,7 @@ "missingConfig": "Impossible de lire la configuration actuelle de l'agent.", "disabled": "{agent} est désactivé dans les paramètres des agents. Activez-le avant de vous connecter.", "unavailable": "{agent} n'est pas disponible sur la plateforme actuelle.", + "adapterMissingWithBaseCli": "CLI amont {command} {version} détectée, mais l’adaptateur ACP de {agent} n’est pas encore installé.", "sdkMissing": "Le SDK de {agent} n'est pas installé" }, "backendErrors": { diff --git a/src/i18n/messages/ja.json b/src/i18n/messages/ja.json index 880af460..a61587c7 100644 --- a/src/i18n/messages/ja.json +++ b/src/i18n/messages/ja.json @@ -790,7 +790,8 @@ "localUnrecognized": "{versionText}。ローカルバージョンは比較できません。上書きインストールのためアップグレードを試してください。", "upgradeAvailable": "{versionText}。アップグレード可能です。", "remoteUnavailable": "{versionText}。現在リモートバージョンは取得できません。", - "latest": "{versionText}。すでに最新です。" + "latest": "{versionText}。すでに最新です。", + "baseCliDetected": "{versionText}。上流 CLI {command} {version} を検出しましたが、ACP アダプターはまだインストールされていません。インストールをクリックしてアダプターをインストールしてください。" }, "cline": { "configDescription": "Cline API プロバイダーと認証情報を設定します。設定は ~/.cline/data/ に保存されます。" @@ -1761,6 +1762,7 @@ "missingConfig": "現在のエージェント設定を読み取れません。", "disabled": "{agent} はエージェント設定で無効になっています。接続前に有効化してください。", "unavailable": "{agent} は現在のプラットフォームでは利用できません。", + "adapterMissingWithBaseCli": "上流 CLI {command} {version} は検出されましたが、{agent} の ACP アダプターはまだインストールされていません。", "sdkMissing": "{agent} SDK がインストールされていません" }, "backendErrors": { diff --git a/src/i18n/messages/ko.json b/src/i18n/messages/ko.json index 2e49c591..9d140097 100644 --- a/src/i18n/messages/ko.json +++ b/src/i18n/messages/ko.json @@ -790,7 +790,8 @@ "localUnrecognized": "{versionText}. 로컬 버전을 비교할 수 없습니다. 덮어쓰기 설치를 위해 업그레이드를 시도하세요.", "upgradeAvailable": "{versionText}. 업그레이드가 가능합니다.", "remoteUnavailable": "{versionText}. 현재 원격 버전을 사용할 수 없습니다.", - "latest": "{versionText}. 이미 최신입니다." + "latest": "{versionText}. 이미 최신입니다.", + "baseCliDetected": "{versionText}. 업스트림 CLI {command} {version}이 감지되었지만 ACP 어댑터는 아직 설치되지 않았습니다. 설치를 클릭해 어댑터를 설치하세요." }, "cline": { "configDescription": "Cline API 제공자와 자격 증명을 구성합니다. 설정은 ~/.cline/data/에 저장됩니다." @@ -1761,6 +1762,7 @@ "missingConfig": "현재 에이전트 구성을 읽을 수 없습니다.", "disabled": "{agent}은(는) 에이전트 설정에서 비활성화되어 있습니다. 연결 전에 활성화하세요.", "unavailable": "{agent}은(는) 현재 플랫폼에서 사용할 수 없습니다.", + "adapterMissingWithBaseCli": "업스트림 CLI {command} {version}이 감지되었지만 {agent} ACP 어댑터가 아직 설치되어 있지 않습니다.", "sdkMissing": "{agent} SDK가 설치되어 있지 않습니다" }, "backendErrors": { diff --git a/src/i18n/messages/pt.json b/src/i18n/messages/pt.json index 8fe7d195..a4fea486 100644 --- a/src/i18n/messages/pt.json +++ b/src/i18n/messages/pt.json @@ -790,7 +790,8 @@ "localUnrecognized": "{versionText}. A versão local não é comparável; tente atualizar para sobrescrever a instalação.", "upgradeAvailable": "{versionText}. Atualização disponível.", "remoteUnavailable": "{versionText}. A versão remota está indisponível no momento.", - "latest": "{versionText}. Já está na versão mais recente." + "latest": "{versionText}. Já está na versão mais recente.", + "baseCliDetected": "{versionText}. CLI upstream {command} {version} detectada, mas o adaptador ACP ainda não está instalado. Clique em Instalar para instalar o adaptador." }, "cline": { "configDescription": "Configure o provedor de API e as credenciais do Cline. As configurações são salvas em ~/.cline/data/." @@ -1761,6 +1762,7 @@ "missingConfig": "Não foi possível ler a configuração atual do agente.", "disabled": "{agent} está desativado nas configurações de agentes. Ative-o antes de conectar.", "unavailable": "{agent} está indisponível na plataforma atual.", + "adapterMissingWithBaseCli": "CLI upstream {command} {version} detectada, mas o adaptador ACP de {agent} ainda não está instalado.", "sdkMissing": "O SDK de {agent} não está instalado" }, "backendErrors": { diff --git a/src/i18n/messages/zh-CN.json b/src/i18n/messages/zh-CN.json index f768a8c5..37fd42f7 100644 --- a/src/i18n/messages/zh-CN.json +++ b/src/i18n/messages/zh-CN.json @@ -790,7 +790,8 @@ "localUnrecognized": "{versionText}。本地版本无法识别,可尝试升级覆盖安装。", "upgradeAvailable": "{versionText}。发现可升级版本。", "remoteUnavailable": "{versionText}。远程版本暂不可用。", - "latest": "{versionText}。已是最新版本。" + "latest": "{versionText}。已是最新版本。", + "baseCliDetected": "{versionText}。已检测到上游 CLI {command} {version},但 ACP 适配器尚未安装。请点击安装来安装适配器。" }, "cline": { "configDescription": "配置 Cline API 提供商和凭证。设置将保存到 ~/.cline/data/。" @@ -1761,6 +1762,7 @@ "missingConfig": "无法读取当前 Agent 配置。", "disabled": "{agent} 已在 Agents 管理中禁用,请先启用后再连接。", "unavailable": "{agent} 当前平台不可用。", + "adapterMissingWithBaseCli": "已检测到上游 CLI {command} {version},但 {agent} ACP 适配器尚未安装。", "sdkMissing": "{agent} SDK 尚未安装" }, "backendErrors": { diff --git a/src/i18n/messages/zh-TW.json b/src/i18n/messages/zh-TW.json index d651fbd5..016a02da 100644 --- a/src/i18n/messages/zh-TW.json +++ b/src/i18n/messages/zh-TW.json @@ -790,7 +790,8 @@ "localUnrecognized": "{versionText}。本地版本無法識別,可嘗試升級覆蓋安裝。", "upgradeAvailable": "{versionText}。發現可升級版本。", "remoteUnavailable": "{versionText}。遠端版本暫不可用。", - "latest": "{versionText}。已是最新版本。" + "latest": "{versionText}。已是最新版本。", + "baseCliDetected": "{versionText}。已偵測到上游 CLI {command} {version},但 ACP 轉接器尚未安裝。請點擊安裝來安裝轉接器。" }, "cline": { "configDescription": "配置 Cline API 提供商和憑證。設定將儲存到 ~/.cline/data/。" @@ -1761,6 +1762,7 @@ "missingConfig": "無法讀取目前 Agent 設定。", "disabled": "{agent} 已在 Agents 管理中停用,請先啟用後再連線。", "unavailable": "{agent} 目前平台不可用。", + "adapterMissingWithBaseCli": "已偵測到上游 CLI {command} {version},但 {agent} ACP 轉接器尚未安裝。", "sdkMissing": "{agent} SDK 尚未安裝" }, "backendErrors": { diff --git a/src/lib/types.ts b/src/lib/types.ts index 8ae8ad2a..07155fa0 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1340,6 +1340,10 @@ export interface AcpAgentInfo { enabled: boolean sort_order: number installed_version: string | null + /** Upstream/original CLI detected on PATH; informational only. */ + base_cli_version?: string | null + base_cli_command?: string | null + base_cli_package?: string | null env: Record config_json: string | null config_file_path: string | null @@ -1358,6 +1362,10 @@ export interface AcpAgentStatus { available: boolean enabled: boolean installed_version: string | null + /** Upstream/original CLI detected on PATH; informational only. */ + base_cli_version?: string | null + base_cli_command?: string | null + base_cli_package?: string | null } export type AgentSkillScope = "global" | "project"