diff --git a/desktop/scripts/check-file-sizes.mjs b/desktop/scripts/check-file-sizes.mjs index dc154f0ca..d93c83bf6 100644 --- a/desktop/scripts/check-file-sizes.mjs +++ b/desktop/scripts/check-file-sizes.mjs @@ -45,27 +45,27 @@ const overrides = new Map([ ["src/features/settings/ui/SettingsView.tsx", 600], ["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav ["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts - ["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions - ["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission ["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests ["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload ["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ ["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests ["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs) - ["src-tauri/src/managed_agents/types.rs", 715], // ManagedAgentRecord/Summary + Create/Update request structs + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field + ["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests + ["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field ["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery) ["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh - ["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation + ["src/features/agents/hooks.ts", 550], // agent query/mutation surface + useAvailableAcpProviders (type-narrowing filter hook) + useInstallAcpRuntimeMutation + built-in persona library activation ["src/features/agents/ui/AgentsView.tsx", 880], // remote agent lifecycle controls + persona/team management + persona import-update dialog wiring + built-in catalog/library state orchestration ["src/features/agents/ui/UnifiedAgentsSection.tsx", 570], // unified persona-grouped agent view with collapsible groups, bulk actions, drag-drop import, empty/loading states ["src/features/agents/ui/ManagedAgentRow.tsx", 530], // EditAgentDialog integration + provider/local branching ["src/features/agents/ui/TeamDialog.tsx", 530], // team create/edit dialog with persona multi-select, import button, window drag detection, removal confirmation ["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation ["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation + ["src/features/agents/ui/PersonaDialog.tsx", 515], // persona create/edit form + env vars editor + drag-drop file import + runtime provider dropdown with availability warnings ["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes ["src/features/channels/ui/AddChannelBotDialog.tsx", 690], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field + reuse guardrail ["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow - ["src/shared/api/types.ts", 620], // ... + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs + ["src/shared/api/types.ts", 650], // ... + AcpProviderCatalogEntry + AcpProvider (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs ["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders ["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring ["src-tauri/src/huddle/models.rs", 950], // model download manager for Parakeet TDT-CTC STT + Pocket TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + MODEL_LICENSE.txt sidecar (fail-closed readiness) + idempotent legacy Moonshine dir cleanup + tts_readiness_requires_license_sidecar test + Mary (VCTK p333) reference voice attribution block @@ -77,7 +77,7 @@ const overrides = new Map([ ["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test ["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers ["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations - ["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + ["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime ]); async function walkFiles(directory) { diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9ded778a0..7c27d73bc 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -1,19 +1,286 @@ +use std::io::Read; use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ - command_availability, discover_local_acp_providers, AcpProviderInfo, - DiscoverManagedAgentPrereqsRequest, ManagedAgentPrereqsInfo, RelayAgentInfo, + command_availability, AcpProviderCatalogEntry, DiscoverManagedAgentPrereqsRequest, + InstallRuntimeResult, InstallStepResult, ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND, }, nostr_convert, relay::query_relay, }; +fn active_installs() -> &'static std::sync::Mutex> { + use std::collections::HashSet; + use std::sync::{Mutex, OnceLock}; + static ACTIVE: OnceLock>> = OnceLock::new(); + ACTIVE.get_or_init(|| Mutex::new(HashSet::new())) +} + +#[tauri::command] +pub fn discover_acp_providers() -> Vec { + crate::managed_agents::clear_resolve_cache(); + crate::managed_agents::discover_acp_providers() +} + #[tauri::command] -pub fn discover_acp_providers() -> Vec { - discover_local_acp_providers() +pub async fn install_acp_runtime(provider_id: String) -> Result { + tokio::task::spawn_blocking(move || install_acp_runtime_blocking(&provider_id)) + .await + .map_err(|e| format!("install task panicked: {e}"))? +} + +/// Err(_) = infrastructure failure (panic, concurrency guard). +/// Ok({success: false}) = an install step failed (stderr captured in steps). +fn install_acp_runtime_blocking(provider_id: &str) -> Result { + // Prevent concurrent installs for the same provider. + { + let mut set = active_installs() + .lock() + .map_err(|_| "install lock poisoned".to_string())?; + if !set.insert(provider_id.to_string()) { + return Err(format!( + "an install is already in progress for {provider_id}" + )); + } + } + + struct Guard(String); + impl Drop for Guard { + fn drop(&mut self) { + if let Ok(mut set) = active_installs().lock() { + set.remove(&self.0); + } + } + } + let _guard = Guard(provider_id.to_string()); + + let provider = crate::managed_agents::known_acp_provider_exact(provider_id) + .ok_or_else(|| format!("unknown provider: {provider_id}"))?; + + let mut steps = Vec::new(); + + // Phase 1: Install CLI if missing and commands are available. + if let Some(cli) = provider.underlying_cli { + if crate::managed_agents::resolve_command(cli, None).is_none() { + for cmd in provider.cli_install_commands { + let result = run_install_command("cli", cmd); + let success = result.success; + steps.push(result); + if !success { + return Ok(InstallRuntimeResult { + success: false, + steps, + }); + } + } + } + } + + // Phase 2: Install adapter if missing and commands are available. + let adapter_found = provider + .commands + .iter() + .any(|cmd| crate::managed_agents::resolve_command(cmd, None).is_some()); + if !adapter_found { + for cmd in provider.adapter_install_commands { + let result = run_install_command("adapter", cmd); + let success = result.success; + steps.push(result); + if !success { + return Ok(InstallRuntimeResult { + success: false, + steps, + }); + } + } + } + + // Clear the resolve cache so the next discovery picks up new binaries. + crate::managed_agents::clear_resolve_cache(); + + Ok(InstallRuntimeResult { + success: true, + steps, + }) +} + +fn run_install_command(step: &str, command: &str) -> InstallStepResult { + let shell_path = crate::managed_agents::login_shell_path(); + let shell = if std::path::Path::new("/bin/zsh").exists() { + "/bin/zsh" + } else { + "/bin/bash" + }; + + let mut cmd = std::process::Command::new(shell); + cmd.args(["-l", "-c", command]); + + // Strip hermit env vars so npm/node use the user's normal registry and + // global prefix rather than the project-local hermit-managed paths. + cmd.env_remove("NPM_CONFIG_PREFIX"); + cmd.env_remove("NPM_CONFIG_CACHE"); + cmd.env_remove("COREPACK_HOME"); + + if let Some(ref path) = shell_path { + cmd.env("PATH", path); + } + + // Detach from the controlling terminal so install scripts that read from + // /dev/tty (e.g. Codex's "Start Codex now? [y/N]") fall back to stdin + // (which is /dev/null) instead of blocking forever. + #[cfg(unix)] + { + use std::os::unix::process::CommandExt; + unsafe { + cmd.pre_exec(|| { + libc::setsid(); + Ok(()) + }); + } + } + + let mut child = match cmd + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::piped()) + .stderr(std::process::Stdio::piped()) + .spawn() + { + Ok(child) => child, + Err(e) => { + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: format!("failed to spawn shell: {e}"), + exit_code: None, + }; + } + }; + + // Drain stdout/stderr on background threads to prevent pipe buffer deadlock. + let stdout_pipe = child.stdout.take(); + let stderr_pipe = child.stderr.take(); + + let stdout_thread = std::thread::spawn(move || { + let mut buf = String::new(); + if let Some(mut pipe) = stdout_pipe { + let _ = pipe.read_to_string(&mut buf); + } + buf + }); + let stderr_thread = std::thread::spawn(move || { + let mut buf = String::new(); + if let Some(mut pipe) = stderr_pipe { + let _ = pipe.read_to_string(&mut buf); + } + buf + }); + + // Save the PID before moving `child` into the wait thread so we can + // kill the process on timeout. + let child_pid = child.id(); + + let (tx, rx) = std::sync::mpsc::channel(); + let wait_thread = std::thread::spawn(move || { + let status = child.wait(); + let _ = tx.send(status); + }); + + // 5-minute timeout for install commands. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300); + loop { + let remaining = deadline.saturating_duration_since(std::time::Instant::now()); + if remaining.is_zero() { + // Timeout: kill the child process via its PID, then join all + // threads so nothing leaks. + #[cfg(unix)] + unsafe { + libc::kill(child_pid as i32, libc::SIGTERM); + } + drop(rx); + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: "install command timed out after 5 minutes".to_string(), + exit_code: None, + }; + } + + match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) { + Ok(Ok(status)) => { + let _ = wait_thread.join(); + let stdout = stdout_thread.join().unwrap_or_default(); + let stderr_raw = stderr_thread.join().unwrap_or_default(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: status.success(), + stdout: truncate_output(stdout), + stderr: truncate_output(stderr_raw), + exit_code: status.code(), + }; + } + Ok(Err(e)) => { + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: format!("failed to check process status: {e}"), + exit_code: None, + }; + } + Err(std::sync::mpsc::RecvTimeoutError::Timeout) => { + // Still running; loop and check deadline again. + continue; + } + Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => { + // wait_thread dropped sender without sending — shouldn't happen. + let _ = wait_thread.join(); + let _ = stdout_thread.join(); + let _ = stderr_thread.join(); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: false, + stdout: String::new(), + stderr: "internal error: wait thread disconnected".to_string(), + exit_code: None, + }; + } + } + } +} + +/// Cap output to head + tail to avoid flooding the UI with large error dumps, +/// while preserving the most useful parts of the output. +fn truncate_output(s: String) -> String { + const HEAD: usize = 512; + const TAIL: usize = 1024; + const LIMIT: usize = HEAD + TAIL; + if s.len() <= LIMIT { + return s; + } + let head_end = s.floor_char_boundary(HEAD); + let tail_start = s.floor_char_boundary(s.len().saturating_sub(TAIL)); + let omitted = tail_start - head_end; + format!( + "{}\n... ({omitted} bytes omitted) ...\n{}", + &s[..head_end], + &s[tail_start..] + ) } #[tauri::command] diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d27a35914..ffc3efafb 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -466,6 +466,7 @@ pub fn run() { get_relay_http_url, get_media_proxy_port, discover_acp_providers, + install_acp_runtime, discover_managed_agent_prereqs, sign_event, decrypt_observer_event, diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index 8e7b9041a..4a47f061c 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -3,7 +3,9 @@ use std::process::Command; use tauri::AppHandle; -use crate::managed_agents::{AcpProviderInfo, CommandAvailabilityInfo}; +use crate::managed_agents::{ + AcpAvailabilityStatus, AcpProviderCatalogEntry, CommandAvailabilityInfo, +}; pub(crate) struct KnownAcpProvider { pub id: &'static str, @@ -15,6 +17,18 @@ pub(crate) struct KnownAcpProvider { pub mcp_command: Option<&'static str>, /// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent. pub mcp_hooks: bool, + /// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing). + pub underlying_cli: Option<&'static str>, + /// Shell commands to install the runtime CLI itself (run sequentially). + pub cli_install_commands: &'static [&'static str], + /// Shell commands to install the ACP adapter (run sequentially, after CLI). + pub adapter_install_commands: &'static [&'static str], + /// Link to docs/repo for manual instructions. + pub install_instructions_url: &'static str, + /// Human-readable hint about installing the CLI binary. + pub cli_install_hint: &'static str, + /// Human-readable hint about installing the ACP adapter. + pub adapter_install_hint: &'static str, } const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -54,6 +68,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ avatar_url: GOOSE_AVATAR_URL, mcp_command: None, mcp_hooks: false, + underlying_cli: Some("goose"), + cli_install_commands: &["curl -fsSL https://github.com/block-open-source/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], + adapter_install_commands: &[], + install_instructions_url: "https://block.github.io/goose/", + cli_install_hint: "Install Goose via the official install script.", + adapter_install_hint: "", }, KnownAcpProvider { id: "claude", @@ -63,6 +83,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ avatar_url: CLAUDE_CODE_AVATAR_URL, mcp_command: None, mcp_hooks: false, + underlying_cli: Some("claude"), + cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"], + adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"], + install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp", + cli_install_hint: "Install the Claude Code CLI via the official install script.", + adapter_install_hint: "Install the Claude Code ACP adapter via npm.", }, KnownAcpProvider { id: "codex", @@ -72,6 +98,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ avatar_url: CODEX_AVATAR_URL, mcp_command: None, mcp_hooks: false, + underlying_cli: Some("codex"), + cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"], + adapter_install_commands: &["npm install -g @zed-industries/codex-acp"], + install_instructions_url: "https://github.com/zed-industries/codex-acp", + cli_install_hint: "Install the Codex CLI via the official install script.", + adapter_install_hint: "Install the Codex ACP adapter via npm.", }, KnownAcpProvider { id: "sprout-agent", @@ -81,6 +113,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ avatar_url: SPROUT_AGENT_AVATAR_URL, mcp_command: Some("sprout-dev-mcp"), mcp_hooks: true, + underlying_cli: None, + cli_install_commands: &[], + adapter_install_commands: &[], + install_instructions_url: "https://github.com/block/sprout", + cli_install_hint: "Ships with the Sprout desktop app.", + adapter_install_hint: "", }, ]; @@ -144,6 +182,10 @@ pub(crate) fn known_acp_provider(command: &str) -> Option<&'static KnownAcpProvi }) } +pub(crate) fn known_acp_provider_exact(id: &str) -> Option<&'static KnownAcpProvider> { + KNOWN_ACP_PROVIDERS.iter().find(|p| p.id == id) +} + fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { "goose" => Some(vec!["acp".to_string()]), @@ -219,15 +261,19 @@ fn resolve_workspace_command(command: &str, app: Option<&AppHandle>) -> Option

&'static std::sync::Mutex>> +{ + use std::collections::HashMap; + use std::sync::{Mutex, OnceLock}; + static CACHE: OnceLock>>> = OnceLock::new(); + CACHE.get_or_init(|| Mutex::new(HashMap::new())) +} + /// Resolve a command to an absolute path, caching results for the app lifetime. /// The cache eliminates redundant login-shell spawns when multiple agents share /// the same binaries (e.g. `npx`, `uvx`). pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option { - use std::collections::HashMap; - use std::sync::{Mutex, OnceLock}; - - static CACHE: OnceLock>>> = OnceLock::new(); - let cache = CACHE.get_or_init(|| Mutex::new(HashMap::new())); + let cache = resolve_cache(); // Fast path: return cached result without allocating a key. if let Ok(guard) = cache.lock() { @@ -248,6 +294,12 @@ pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option) -> Option { if let Some(path) = resolve_workspace_command(command, app) { return Some(path); @@ -349,22 +401,96 @@ pub fn missing_command_message(command: &str, role: &str) -> String { ) } -pub fn discover_local_acp_providers() -> Vec { +fn classify_provider( + adapter_result: Option<(&str, PathBuf)>, + underlying_cli: Option<&str>, + underlying_cli_found: bool, +) -> (AcpAvailabilityStatus, Option, Option) { + if let Some((cmd, path)) = adapter_result { + if underlying_cli.is_some() && !underlying_cli_found { + ( + AcpAvailabilityStatus::CliMissing, + Some(cmd.to_string()), + Some(path.display().to_string()), + ) + } else { + ( + AcpAvailabilityStatus::Available, + Some(cmd.to_string()), + Some(path.display().to_string()), + ) + } + } else if underlying_cli.is_some() && underlying_cli_found { + (AcpAvailabilityStatus::AdapterMissing, None, None) + } else { + (AcpAvailabilityStatus::NotInstalled, None, None) + } +} + +pub fn discover_acp_providers() -> Vec { KNOWN_ACP_PROVIDERS .iter() - .filter_map(|provider| { - provider + .map(|provider| { + // Try to find the ACP adapter binary. + let adapter_result = provider .commands .iter() - .find_map(|command| find_command(command).map(|path| (*command, path))) - .map(|(command, binary_path)| AcpProviderInfo { - id: provider.id.to_string(), - label: provider.label.to_string(), - command: command.to_string(), - binary_path: binary_path.display().to_string(), - default_args: normalize_agent_args(command, Vec::new()), - mcp_command: provider.mcp_command.map(str::to_string), - }) + .find_map(|command| find_command(command).map(|path| (*command, path))); + + let underlying_cli_found = provider + .underlying_cli + .map(|cli| find_command(cli).is_some()) + .unwrap_or(false); + let (availability, command, binary_path) = classify_provider( + adapter_result, + provider.underlying_cli, + underlying_cli_found, + ); + + let underlying_cli_path = provider + .underlying_cli + .and_then(|cli| find_command(cli)) + .map(|p| p.display().to_string()); + + let default_args = command + .as_deref() + .map(|cmd| normalize_agent_args(cmd, Vec::new())) + .unwrap_or_default(); + + let can_auto_install = !provider.cli_install_commands.is_empty() + || !provider.adapter_install_commands.is_empty(); + + let cli_hint = provider.cli_install_hint; + let adapter_hint = provider.adapter_install_hint; + let install_hint = match availability { + AcpAvailabilityStatus::Available => cli_hint.to_string(), + AcpAvailabilityStatus::CliMissing => cli_hint.to_string(), + AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(), + AcpAvailabilityStatus::NotInstalled => { + if !cli_hint.is_empty() && !adapter_hint.is_empty() { + format!("{cli_hint} {adapter_hint}") + } else if !cli_hint.is_empty() { + cli_hint.to_string() + } else { + adapter_hint.to_string() + } + } + }; + + AcpProviderCatalogEntry { + id: provider.id.to_string(), + label: provider.label.to_string(), + avatar_url: provider.avatar_url.to_string(), + availability, + command, + binary_path, + default_args, + mcp_command: provider.mcp_command.map(str::to_string), + install_hint, + install_instructions_url: provider.install_instructions_url.to_string(), + can_auto_install, + underlying_cli_path, + } }) .collect() } @@ -376,10 +502,13 @@ pub fn managed_agent_avatar_url(command: &str) -> Option { #[cfg(test)] mod tests { + use std::path::PathBuf; + use super::{ - find_via_login_shell, managed_agent_avatar_url, normalize_agent_args, + classify_provider, find_via_login_shell, managed_agent_avatar_url, normalize_agent_args, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, SPROUT_AGENT_AVATAR_URL, }; + use crate::managed_agents::AcpAvailabilityStatus; #[test] fn resolves_known_avatar_for_bare_command() { @@ -470,4 +599,52 @@ mod tests { "shell lookup must not execute injected commands" ); } + + #[test] + fn classifies_available_when_adapter_found() { + let (status, cmd, path) = classify_provider( + Some(("goose", PathBuf::from("/usr/local/bin/goose"))), + None, + false, + ); + assert_eq!(status, AcpAvailabilityStatus::Available); + assert_eq!(cmd.as_deref(), Some("goose")); + assert_eq!(path.as_deref(), Some("/usr/local/bin/goose")); + } + + #[test] + fn classifies_adapter_missing_when_cli_present() { + let (status, cmd, path) = classify_provider(None, Some("claude"), true); + assert_eq!(status, AcpAvailabilityStatus::AdapterMissing); + assert!(cmd.is_none()); + assert!(path.is_none()); + } + + #[test] + fn classifies_not_installed_when_nothing_found() { + let (status, cmd, path) = classify_provider(None, Some("claude"), false); + assert_eq!(status, AcpAvailabilityStatus::NotInstalled); + assert!(cmd.is_none()); + assert!(path.is_none()); + } + + #[test] + fn classifies_not_installed_when_no_underlying_cli() { + let (status, cmd, path) = classify_provider(None, None, false); + assert_eq!(status, AcpAvailabilityStatus::NotInstalled); + assert!(cmd.is_none()); + assert!(path.is_none()); + } + + #[test] + fn classifies_cli_missing_when_adapter_found_but_cli_absent() { + let (status, cmd, path) = classify_provider( + Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))), + Some("codex"), + false, + ); + assert_eq!(status, AcpAvailabilityStatus::CliMissing); + assert_eq!(cmd.as_deref(), Some("codex-acp")); + assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp")); + } } diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 09b5274a5..8bb49c614 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -275,15 +275,48 @@ pub struct ManagedAgentLogResponse { pub log_path: String, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AcpAvailabilityStatus { + Available, + AdapterMissing, + CliMissing, + NotInstalled, +} + #[derive(Debug, Clone, Serialize)] -pub struct AcpProviderInfo { +pub struct AcpProviderCatalogEntry { pub id: String, pub label: String, - pub command: String, - pub binary_path: String, + pub avatar_url: String, + pub availability: AcpAvailabilityStatus, + pub command: Option, + pub binary_path: Option, pub default_args: Vec, - /// MCP server binary override. `None` means use the default (`sprout-mcp-server`). pub mcp_command: Option, + pub install_hint: String, + pub install_instructions_url: String, + /// true when at least one automated install step is available + pub can_auto_install: bool, + pub underlying_cli_path: Option, +} + +/// Result of a single install step (CLI or adapter). +#[derive(Debug, Clone, Serialize)] +pub struct InstallStepResult { + pub step: String, + pub command: String, + pub success: bool, + pub stdout: String, + pub stderr: String, + pub exit_code: Option, +} + +/// Aggregate result of installing a runtime (may include CLI + adapter steps). +#[derive(Debug, Clone, Serialize)] +pub struct InstallRuntimeResult { + pub success: bool, + pub steps: Vec, } #[derive(Debug, Clone, Serialize)] diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 0fdb599d1..d7fe52237 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -1,3 +1,4 @@ +import * as React from "react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { @@ -14,6 +15,7 @@ import { discoverBackendProviders, discoverManagedAgentPrereqs, getManagedAgentLog, + installAcpRuntime, listManagedAgents, listRelayAgents, startManagedAgent, @@ -36,6 +38,7 @@ import { updateTeam, } from "@/shared/api/tauriTeams"; import type { + AcpProvider, AgentPersona, AgentTeam, CreateManagedAgentInput, @@ -105,6 +108,28 @@ export function useAcpProvidersQuery() { }); } +export function useAvailableAcpProviders() { + const query = useAcpProvidersQuery(); + const available = React.useMemo( + () => + (query.data ?? []).filter( + (p): p is AcpProvider => p.availability === "available", + ), + [query.data], + ); + return { ...query, data: available }; +} + +export function useInstallAcpRuntimeMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (providerId: string) => installAcpRuntime(providerId), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: acpProvidersQueryKey }); + }, + }); +} + export function useBackendProvidersQuery() { return useQuery({ queryKey: backendProvidersQueryKey, diff --git a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx index 6a82dafe1..39a250b8e 100644 --- a/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx +++ b/desktop/src/features/agents/ui/AddTeamToChannelDialog.tsx @@ -2,7 +2,7 @@ import { AlertTriangle } from "lucide-react"; import * as React from "react"; import { - useAcpProvidersQuery, + useAvailableAcpProviders, useCreateChannelManagedAgentsMutation, } from "@/features/agents/hooks"; import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents"; @@ -50,7 +50,7 @@ export function AddTeamToChannelDialog({ onDeployed, }: AddTeamToChannelDialogProps) { const channelsQuery = useChannelsQuery(); - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); const [channelId, setChannelId] = React.useState(""); const [role, setRole] = React.useState>("bot"); const deployMutation = useCreateChannelManagedAgentsMutation( diff --git a/desktop/src/features/agents/ui/CreateAgentDialog.tsx b/desktop/src/features/agents/ui/CreateAgentDialog.tsx index 3e3dd2871..0bfe31b71 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialog.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialog.tsx @@ -3,6 +3,7 @@ import * as React from "react"; import { useAcpProvidersQuery, + useAvailableAcpProviders, useBackendProvidersQuery, useCreateManagedAgentMutation, useManagedAgentPrereqsQuery, @@ -48,7 +49,8 @@ export function CreateAgentDialog({ onOpenChange: (open: boolean) => void; }) { const createMutation = useCreateManagedAgentMutation(); - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); + const allProvidersQuery = useAcpProvidersQuery(); const backendProvidersQuery = useBackendProvidersQuery(); const { lastProviderId, setLastProvider } = useLastRuntimeProvider(); const [acpCommand, setAcpCommand] = React.useState("sprout-acp"); @@ -85,6 +87,10 @@ export function CreateAgentDialog({ const [probeError, setProbeError] = React.useState(null); const providers = providersQuery.data ?? []; + const allProviders = allProvidersQuery.data ?? []; + const unavailableCount = allProviders.filter( + (p) => p.availability !== "available", + ).length; const backendProviders = backendProvidersQuery.data ?? []; const prereqs = prereqsQuery.data ?? null; const selectedProvider = React.useMemo( @@ -443,6 +449,7 @@ export function CreateAgentDialog({ providersLoading={providersQuery.isLoading} selectedProvider={selectedProvider} selectedProviderId={selectedProviderId} + unavailableCount={unavailableCount} /> ) : null} diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx index 86c791f17..0ba2fdcf6 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx @@ -40,12 +40,14 @@ export function CreateAgentRuntimeProviderField({ providersLoading, selectedProvider, selectedProviderId, + unavailableCount, onProviderChange, }: { providers: AcpProvider[]; providersLoading: boolean; selectedProvider: AcpProvider | null; selectedProviderId: string; + unavailableCount: number; onProviderChange: (value: string) => void; }) { return ( @@ -86,6 +88,14 @@ export function CreateAgentRuntimeProviderField({ command in Advanced setup.

)} + {unavailableCount > 0 ? ( +

+ {unavailableCount} additional{" "} + {unavailableCount === 1 ? "runtime" : "runtimes"} available to + install. Visit Settings > Doctor to set{" "} + {unavailableCount === 1 ? "it" : "them"} up. +

+ ) : null} ); } diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index 7d465e849..ab8f98b05 100644 --- a/desktop/src/features/agents/ui/PersonaDialog.tsx +++ b/desktop/src/features/agents/ui/PersonaDialog.tsx @@ -2,7 +2,7 @@ import * as React from "react"; import { RefreshCw, Upload } from "lucide-react"; import type { - AcpProvider, + AcpProviderCatalogEntry, CreatePersonaInput, UpdatePersonaInput, } from "@/shared/api/types"; @@ -35,7 +35,7 @@ type PersonaDialogProps = { error: Error | null; isPending: boolean; isImportPending?: boolean; - providers: AcpProvider[]; + providers: AcpProviderCatalogEntry[]; providersLoading?: boolean; onOpenChange: (open: boolean) => void; onSubmit: (input: CreatePersonaInput | UpdatePersonaInput) => Promise; @@ -266,6 +266,19 @@ export function PersonaDialog({ importErrorMessage, }); + const selectedProvider = providers.find((p) => p.id === provider); + const providerWarning = + selectedProvider && selectedProvider.availability !== "available" ? ( +

+ {selectedProvider.availability === "adapter_missing" + ? `${selectedProvider.label} CLI is installed but the ACP adapter is missing.` + : selectedProvider.availability === "cli_missing" + ? `${selectedProvider.label} ACP adapter is installed but the CLI is missing.` + : `${selectedProvider.label} is not installed.`}{" "} + Visit Settings > Doctor to set it up. +

+ ) : null; + return ( @@ -354,13 +367,22 @@ export function PersonaDialog({ {providers.map((p) => ( ))}

Optional. When deploying this persona, the selected runtime will - be pre-selected. Falls back to the default if unavailable. + be pre-selected. Unavailable runtimes can be installed from + Settings > Doctor.

+ {providerWarning}
diff --git a/desktop/src/features/channel-templates/useApplyTemplate.ts b/desktop/src/features/channel-templates/useApplyTemplate.ts index a6598011e..c0753ad69 100644 --- a/desktop/src/features/channel-templates/useApplyTemplate.ts +++ b/desktop/src/features/channel-templates/useApplyTemplate.ts @@ -5,7 +5,7 @@ import { type CreateChannelManagedAgentInput, } from "@/features/agents/channelAgents"; import { - useAcpProvidersQuery, + useAvailableAcpProviders, usePersonasQuery, useTeamsQuery, } from "@/features/agents/hooks"; @@ -29,7 +29,7 @@ function toManagedBackend( export function useApplyTemplate() { const queryClient = useQueryClient(); const channelTemplatesQuery = useChannelTemplatesQuery(); - const acpProvidersQuery = useAcpProvidersQuery(); + const acpProvidersQuery = useAvailableAcpProviders(); const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); const { lastProviderId } = useLastRuntimeProvider(); diff --git a/desktop/src/features/channels/ui/ChannelMembersBar.tsx b/desktop/src/features/channels/ui/ChannelMembersBar.tsx index c2ff0734e..ae5273c66 100644 --- a/desktop/src/features/channels/ui/ChannelMembersBar.tsx +++ b/desktop/src/features/channels/ui/ChannelMembersBar.tsx @@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { useHuddle } from "@/features/huddle"; import { HuddleIndicator } from "@/features/huddle/components/HuddleIndicator"; import { - useAcpProvidersQuery, + useAvailableAcpProviders, useBackendProvidersQuery, useManagedAgentsQuery, useRelayAgentsQuery, @@ -32,7 +32,7 @@ export function ChannelMembersBar({ const { startHuddle, isStarting: isStartingHuddle } = useHuddle(); const queryClient = useQueryClient(); const membersQuery = useChannelMembersQuery(channel.id); - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); const backendProvidersQuery = useBackendProvidersQuery(); const managedAgentsQuery = useManagedAgentsQuery(); const relayAgentsQuery = useRelayAgentsQuery(); diff --git a/desktop/src/features/channels/ui/useQuickBotDrop.ts b/desktop/src/features/channels/ui/useQuickBotDrop.ts index 34b83c5e1..fed3a6156 100644 --- a/desktop/src/features/channels/ui/useQuickBotDrop.ts +++ b/desktop/src/features/channels/ui/useQuickBotDrop.ts @@ -1,7 +1,7 @@ import * as React from "react"; import { - useAcpProvidersQuery, + useAvailableAcpProviders, useCreateChannelManagedAgentMutation, } from "@/features/agents/hooks"; import { resolvePersonaProvider } from "@/features/agents/lib/resolvePersonaProvider"; @@ -17,7 +17,7 @@ type QuickBotDropState = { */ export function useQuickBotDrop(channelId: string | null) { const createMutation = useCreateChannelManagedAgentMutation(channelId); - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); const [state, setState] = React.useState({ pending: false, error: null, diff --git a/desktop/src/features/onboarding/ui/SetupStep.tsx b/desktop/src/features/onboarding/ui/SetupStep.tsx index c6ffadf3c..875c77123 100644 --- a/desktop/src/features/onboarding/ui/SetupStep.tsx +++ b/desktop/src/features/onboarding/ui/SetupStep.tsx @@ -1,6 +1,6 @@ import { TerminalSquare } from "lucide-react"; -import { useAcpProvidersQuery } from "@/features/agents/hooks"; +import { useAvailableAcpProviders } from "@/features/agents/hooks"; import { Badge } from "@/shared/ui/badge"; import { Button } from "@/shared/ui/button"; import type { SetupStepActions, SetupStepState } from "./types"; @@ -15,7 +15,7 @@ type SetupStepContentProps = { }; function useSetupStepState(): SetupStepState { - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); const items = providersQuery.data ?? []; const isChecking = providersQuery.isLoading; const errorMessage = diff --git a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx index fe89413c3..f18dd768d 100644 --- a/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx +++ b/desktop/src/features/settings/ui/ChannelTemplatesSettingsCard.tsx @@ -11,7 +11,7 @@ import * as React from "react"; import { toast } from "sonner"; import { - useAcpProvidersQuery, + useAvailableAcpProviders, usePersonasQuery, useTeamsQuery, } from "@/features/agents/hooks"; @@ -295,7 +295,7 @@ function TemplateFormDialog({ const updateMutation = useUpdateChannelTemplateMutation(); const personasQuery = usePersonasQuery(); const teamsQuery = useTeamsQuery(); - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAvailableAcpProviders(); const providers = providersQuery.data ?? []; const [name, setName] = React.useState(""); diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index b3e99d363..be7dda159 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -1,64 +1,221 @@ +import * as React from "react"; import { AlertTriangle, CheckCircle2, + Circle, + Download, + ExternalLink, RefreshCw, Stethoscope, } from "lucide-react"; +import { openUrl } from "@tauri-apps/plugin-opener"; -import { useAcpProvidersQuery } from "@/features/agents/hooks"; +import { + useAcpProvidersQuery, + useInstallAcpRuntimeMutation, +} from "@/features/agents/hooks"; import { describeResolvedCommand } from "@/features/agents/ui/agentUi"; +import type { AcpProviderCatalogEntry } from "@/shared/api/types"; import { cn } from "@/shared/lib/cn"; import { Button } from "@/shared/ui/button"; -function StatusIcon({ available }: { available: boolean }) { - return available ? ( - - ) : ( - +function StatusIcon({ + availability, +}: { + availability: AcpProviderCatalogEntry["availability"]; +}) { + switch (availability) { + case "available": + return ; + case "adapter_missing": + return ; + case "cli_missing": + return ; + case "not_installed": + return ; + } +} + +function InstallActions({ + isInstalling, + onInstall, + provider, +}: { + isInstalling: boolean; + onInstall: () => void; + provider: AcpProviderCatalogEntry; +}) { + return ( +
+ {provider.canAutoInstall ? ( + + ) : null} + +
); } function ProviderRow({ - command, - defaultArgs, - label, - providerId, - resolvedPath, + installError, + installSuccess, + isInstalling, + onInstall, + provider, }: { - command: string; - defaultArgs: string[]; - label: string; - providerId: string; - resolvedPath: string; + installError: string | null; + installSuccess: boolean; + isInstalling: boolean; + onInstall: () => void; + provider: AcpProviderCatalogEntry; }) { return (
- +
-

{label}

- - {command} - +

+ {provider.label} +

+ {provider.command ? ( + + {provider.command} + + ) : null}
-

- Available via {describeResolvedCommand(command, resolvedPath)}. -

- {defaultArgs.length > 0 ? ( -

- Default args:{" "} - {defaultArgs.join(", ")} + + {provider.availability === "available" && + provider.command && + provider.binaryPath ? ( + <> +

+ Available via{" "} + {describeResolvedCommand(provider.command, provider.binaryPath)}. +

+ {provider.defaultArgs.length > 0 ? ( +

+ Default args:{" "} + + {provider.defaultArgs.join(", ")} + +

+ ) : null} + {provider.underlyingCliPath && + provider.underlyingCliPath !== provider.binaryPath ? ( +
+

+ CLI:{" "} + {provider.underlyingCliPath} +

+

+ ACP adapter:{" "} + {provider.binaryPath} +

+
+ ) : ( + <> +

+ {provider.binaryPath} +

+

+ ACP support built-in — no separate adapter needed. +

+ + )} + + ) : provider.availability === "adapter_missing" ? ( + <> +

+ CLI detected at{" "} + + {provider.underlyingCliPath ?? "unknown path"} + {" "} + but ACP adapter not found. +

+

+ {provider.installHint} +

+ + + ) : provider.availability === "cli_missing" ? ( + <> +

+ ACP adapter found at{" "} + + {provider.binaryPath ?? "unknown path"} + {" "} + but the {provider.label} CLI is not installed. +

+

+ {provider.installHint} +

+ + + ) : ( + <> +

Not installed

+

+ {provider.installHint} +

+ + + )} + + {installSuccess && provider.availability !== "available" ? ( +

+ Installed successfully! +

+ ) : null} + {installError ? ( +

+ {installError}

) : null} -

- {resolvedPath} -

); @@ -68,6 +225,47 @@ export function DoctorSettingsPanel() { const providersQuery = useAcpProvidersQuery(); const providers = providersQuery.data ?? []; const isRefreshing = providersQuery.isFetching; + const installMutation = useInstallAcpRuntimeMutation(); + const [installResults, setInstallResults] = React.useState< + Record + >({}); + + function handleInstall(providerId: string) { + setInstallResults((prev) => ({ + ...prev, + [providerId]: { success: false, error: null }, + })); + installMutation.mutate(providerId, { + onSuccess: (result) => { + if (result.success) { + setInstallResults((prev) => ({ + ...prev, + [providerId]: { success: true, error: null }, + })); + } else { + const lastStep = result.steps[result.steps.length - 1]; + setInstallResults((prev) => ({ + ...prev, + [providerId]: { + success: false, + error: lastStep + ? `Step "${lastStep.step}" failed: ${lastStep.stderr || lastStep.stdout || "unknown error"}` + : "Install failed with no output.", + }, + })); + } + }, + onError: (error) => { + setInstallResults((prev) => ({ + ...prev, + [providerId]: { + success: false, + error: error instanceof Error ? error.message : "Install failed.", + }, + })); + }, + }); + } return (
@@ -86,6 +284,7 @@ export function DoctorSettingsPanel() { className="shrink-0" disabled={isRefreshing} onClick={() => { + setInstallResults({}); void providersQuery.refetch(); }} size="sm" @@ -101,31 +300,35 @@ export function DoctorSettingsPanel() {
-

ACP runtimes

+

+ Agent CLIs and ACP runtimes +

- Installed runtimes that the desktop app can offer in Create agent. + Installation status of supported agent CLIs and their ACP runtimes.

{providersQuery.isLoading ? (

- Looking for installed ACP runtimes... + Looking for ACP runtimes...

) : providers.length > 0 ? ( providers.map((provider) => ( handleInstall(provider.id)} + provider={provider} /> )) ) : (
- No known ACP runtime was detected on your PATH yet. You can - still use a custom command in Create agent. + No known ACP runtimes found.
)}
diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 8cbee3f76..3da85ff2d 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -39,8 +39,10 @@ import type { CreateManagedAgentInput, AgentModelsResponse, UpdateManagedAgentInput, - AcpProvider, + AcpAvailabilityStatus, + AcpProviderCatalogEntry, CommandAvailability, + InstallRuntimeResult, OpenDmInput, } from "@/shared/api/types"; @@ -245,13 +247,33 @@ type RawManagedAgentLog = { log_path: string; }; -type RawAcpProvider = { +export type RawAcpProviderCatalogEntry = { id: string; label: string; - command: string; - binary_path: string; + avatar_url: string; + availability: AcpAvailabilityStatus; + command: string | null; + binary_path: string | null; default_args: string[]; mcp_command: string | null; + install_hint: string; + install_instructions_url: string; + can_auto_install: boolean; + underlying_cli_path: string | null; +}; + +export type RawInstallStepResult = { + step: string; + command: string; + success: boolean; + stdout: string; + stderr: string; + exit_code: number | null; +}; + +export type RawInstallRuntimeResult = { + success: boolean; + steps: RawInstallStepResult[]; }; type RawCommandAvailability = { @@ -850,14 +872,38 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { }; } -function fromRawAcpProvider(provider: RawAcpProvider): AcpProvider { +function fromRawAcpProviderCatalogEntry( + entry: RawAcpProviderCatalogEntry, +): AcpProviderCatalogEntry { return { - id: provider.id, - label: provider.label, - command: provider.command, - binaryPath: provider.binary_path, - defaultArgs: provider.default_args, - mcpCommand: provider.mcp_command, + id: entry.id, + label: entry.label, + avatarUrl: entry.avatar_url, + availability: entry.availability, + command: entry.command, + binaryPath: entry.binary_path, + defaultArgs: entry.default_args, + mcpCommand: entry.mcp_command, + installHint: entry.install_hint, + installInstructionsUrl: entry.install_instructions_url, + canAutoInstall: entry.can_auto_install, + underlyingCliPath: entry.underlying_cli_path, + }; +} + +function fromRawInstallRuntimeResult( + raw: RawInstallRuntimeResult, +): InstallRuntimeResult { + return { + success: raw.success, + steps: raw.steps.map((step) => ({ + step: step.step, + command: step.command, + success: step.success, + stdout: step.stdout, + stderr: step.stderr, + exitCode: step.exit_code, + })), }; } @@ -1013,10 +1059,22 @@ export async function getManagedAgentLog(pubkey: string, lineCount?: number) { }; } -export async function discoverAcpProviders(): Promise { - return (await invokeTauri("discover_acp_providers")).map( - fromRawAcpProvider, +export async function discoverAcpProviders(): Promise< + AcpProviderCatalogEntry[] +> { + return ( + await invokeTauri("discover_acp_providers") + ).map(fromRawAcpProviderCatalogEntry); +} + +export async function installAcpRuntime( + providerId: string, +): Promise { + const raw = await invokeTauri( + "install_acp_runtime", + { providerId }, ); + return fromRawInstallRuntimeResult(raw); } export async function discoverManagedAgentPrereqs(input: { diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 4dd100283..cb7b8d842 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -366,14 +366,46 @@ export type CancelManagedAgentTurnResult = { status: "sent" | "no_active_turn"; }; -export type AcpProvider = { +export type AcpAvailabilityStatus = + | "available" + | "adapter_missing" + | "cli_missing" + | "not_installed"; + +export type AcpProviderCatalogEntry = { id: string; label: string; - command: string; - binaryPath: string; + avatarUrl: string; + availability: AcpAvailabilityStatus; + command: string | null; + binaryPath: string | null; defaultArgs: string[]; - /** MCP server binary override, or `null` for the default (`sprout-mcp-server`). */ mcpCommand: string | null; + installHint: string; + installInstructionsUrl: string; + canAutoInstall: boolean; + underlyingCliPath: string | null; +}; + +/** An AcpProviderCatalogEntry that is confirmed available — command and binaryPath are non-null. */ +export type AcpProvider = AcpProviderCatalogEntry & { + availability: "available"; + command: string; + binaryPath: string; +}; + +export type InstallStepResult = { + step: string; + command: string; + success: boolean; + stdout: string; + stderr: string; + exitCode: number | null; +}; + +export type InstallRuntimeResult = { + success: boolean; + steps: InstallStepResult[]; }; export type CommandAvailability = { diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index a2f481ba8..56fceccca 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -4,6 +4,10 @@ import { finalizeEvent } from "nostr-tools/pure"; import { parse as yamlParse } from "yaml"; import type { RelayEvent } from "@/shared/api/types"; +import type { + RawAcpProviderCatalogEntry, + RawInstallRuntimeResult, +} from "@/shared/api/tauri"; type TestIdentity = { privateKey: string; @@ -11,15 +15,6 @@ type TestIdentity = { username: string; }; -type MockAcpProvider = { - id: string; - label: string; - command: string; - binaryPath: string; - defaultArgs: string[]; - mcpCommand?: string | null; -}; - type MockCommandAvailability = { available?: boolean; command?: string; @@ -29,7 +24,8 @@ type MockCommandAvailability = { type E2eConfig = { mode?: "mock" | "relay"; mock?: { - acpProviders?: MockAcpProvider[]; + acpProvidersCatalog?: RawAcpProviderCatalogEntry[]; + installAcpRuntimeResult?: RawInstallRuntimeResult; managedAgentPrereqs?: { acp?: MockCommandAvailability; mcp?: MockCommandAvailability; @@ -318,15 +314,6 @@ type RawManagedAgentLog = { log_path: string; }; -type RawAcpProvider = { - id: string; - label: string; - command: string; - binary_path: string; - default_args: string[]; - mcp_command: string | null; -}; - type RawCommandAvailability = { command: string; resolved_path: string | null; @@ -3468,39 +3455,98 @@ async function handleListRelayAgents(): Promise { async function handleDiscoverAcpProviders( config: E2eConfig | undefined, -): Promise { - const configuredProviders = config?.mock?.acpProviders; - if (configuredProviders) { - return configuredProviders.map((provider) => ({ - id: provider.id, - label: provider.label, - command: provider.command, - binary_path: provider.binaryPath, - default_args: [...provider.defaultArgs], - mcp_command: provider.mcpCommand ?? null, - })); +): Promise { + const configured = config?.mock?.acpProvidersCatalog; + if (configured) { + return configured; } - return [ { id: "goose", label: "Goose", + avatar_url: "", + availability: "available", command: "goose", binary_path: "/usr/local/bin/goose", default_args: ["acp"], mcp_command: null, + install_hint: "Install Goose via the official install script.", + install_instructions_url: "https://block.github.io/goose/", + can_auto_install: true, + underlying_cli_path: null, + }, + { + id: "claude", + label: "Claude Code", + avatar_url: "", + availability: "adapter_missing", + command: null, + binary_path: null, + default_args: [], + mcp_command: null, + install_hint: "Install the Claude Code ACP adapter via npm.", + install_instructions_url: + "https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp", + can_auto_install: true, + underlying_cli_path: "/usr/local/bin/claude", }, { id: "codex", label: "Codex", - command: "codex-acp", - binary_path: "/usr/local/bin/codex-acp", + avatar_url: "", + availability: "not_installed", + command: null, + binary_path: null, default_args: [], mcp_command: null, + install_hint: + "The codex-acp adapter must be built from source. See the GitHub repo.", + install_instructions_url: "https://github.com/openai/codex", + can_auto_install: false, + underlying_cli_path: null, + }, + { + id: "sprout-agent", + label: "Sprout Agent", + avatar_url: "", + availability: "available", + command: "sprout-agent", + binary_path: "/usr/local/bin/sprout-agent", + default_args: [], + mcp_command: "sprout-dev-mcp", + install_hint: "Ships with the Sprout desktop app.", + install_instructions_url: "https://github.com/block/sprout", + can_auto_install: false, + underlying_cli_path: null, }, ]; } +async function handleInstallAcpRuntime( + args: { + providerId?: string; + }, + config: E2eConfig | undefined, +): Promise { + const configured = config?.mock?.installAcpRuntimeResult; + if (configured) { + return configured; + } + return { + success: true, + steps: [ + { + step: "adapter", + command: `mock install ${args.providerId ?? "unknown"}`, + success: true, + stdout: "mock: installed successfully", + stderr: "", + exit_code: 0, + }, + ], + }; +} + async function handleDiscoverManagedAgentPrereqs( args: { input?: { @@ -4673,6 +4719,11 @@ export function maybeInstallE2eTauriMocks() { return getRelayHttpUrl(activeConfig); case "discover_acp_providers": return handleDiscoverAcpProviders(activeConfig); + case "install_acp_runtime": + return handleInstallAcpRuntime( + payload as { providerId?: string }, + activeConfig, + ); case "discover_backend_providers": return []; case "probe_backend_provider": diff --git a/desktop/tests/e2e/onboarding.spec.ts b/desktop/tests/e2e/onboarding.spec.ts index 0d2b61f3f..71d5adf4e 100644 --- a/desktop/tests/e2e/onboarding.spec.ts +++ b/desktop/tests/e2e/onboarding.spec.ts @@ -197,7 +197,7 @@ test("page 2 falls back to Doctor guidance when ACP tools are not installed", as await installMockBridge( page, { - acpProviders: [], + acpProvidersCatalog: [], }, { skipOnboardingSeed: true }, ); diff --git a/desktop/tests/helpers/bridge.ts b/desktop/tests/helpers/bridge.ts index 5bb4d8487..e318b9dc8 100644 --- a/desktop/tests/helpers/bridge.ts +++ b/desktop/tests/helpers/bridge.ts @@ -35,14 +35,6 @@ export const TEST_IDENTITIES = { type BridgeMode = "mock" | "relay"; -type MockAcpProvider = { - id: string; - label: string; - command: string; - binaryPath: string; - defaultArgs: string[]; -}; - type MockCommandAvailability = { available?: boolean; command?: string; @@ -50,7 +42,7 @@ type MockCommandAvailability = { }; type MockBridgeOptions = { - acpProviders?: MockAcpProvider[]; + acpProvidersCatalog?: Record[]; managedAgentPrereqs?: { acp?: MockCommandAvailability; mcp?: MockCommandAvailability;