From b0e42cfa28f2f88aa4a9c26b2b368e8028af8311 Mon Sep 17 00:00:00 2001 From: Will Pfleger Date: Thu, 21 May 2026 19:32:27 -0400 Subject: [PATCH] feat(desktop): show all ACP runtimes with install status and install buttons The "Preferred runtime" dropdown in Agents > Add Persona and the Doctor panel only showed runtimes with a resolved ACP adapter binary, silently hiding Claude Code and Codex when their adapters weren't installed. Add three-state availability detection (Available / AdapterMissing / NotInstalled) to distinguish "CLI present but adapter missing" from "nothing installed." Expose a full provider catalog via `discover_all_acp_providers` and an `install_acp_runtime` command that runs server-defined install scripts in a login shell with a 5-minute timeout. Doctor panel now shows all four known runtimes with status badges and Install buttons. PersonaDialog shows all runtimes with status labels so users can store a preference for later. CreateAgentDialog shows a hint when additional runtimes are available to install. --- .../src-tauri/src/commands/agent_discovery.rs | 173 +++++++++++- desktop/src-tauri/src/lib.rs | 2 + .../src-tauri/src/managed_agents/discovery.rs | 108 +++++++- desktop/src-tauri/src/managed_agents/types.rs | 43 +++ desktop/src/features/agents/hooks.ts | 24 ++ .../agents/ui/CreateAgentDialogSections.tsx | 14 + .../src/features/agents/ui/PersonaDialog.tsx | 25 +- .../features/agents/ui/usePersonaActions.ts | 4 +- .../settings/ui/DoctorSettingsPanel.tsx | 246 +++++++++++++++--- desktop/src/shared/api/tauri.ts | 87 +++++++ desktop/src/shared/api/types.ts | 34 +++ desktop/src/testing/e2eBridge.ts | 114 ++++++++ 12 files changed, 817 insertions(+), 57 deletions(-) diff --git a/desktop/src-tauri/src/commands/agent_discovery.rs b/desktop/src-tauri/src/commands/agent_discovery.rs index 9ded778a0..47cb80178 100644 --- a/desktop/src-tauri/src/commands/agent_discovery.rs +++ b/desktop/src-tauri/src/commands/agent_discovery.rs @@ -3,9 +3,9 @@ use tauri::{AppHandle, State}; use crate::{ app_state::AppState, managed_agents::{ - command_availability, discover_local_acp_providers, AcpProviderInfo, - DiscoverManagedAgentPrereqsRequest, ManagedAgentPrereqsInfo, RelayAgentInfo, - DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND, + command_availability, discover_local_acp_providers, AcpProviderCatalogEntry, AcpProviderInfo, + DiscoverManagedAgentPrereqsRequest, InstallRuntimeResult, InstallStepResult, + ManagedAgentPrereqsInfo, RelayAgentInfo, DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND, }, nostr_convert, relay::query_relay, @@ -16,6 +16,173 @@ pub fn discover_acp_providers() -> Vec { discover_local_acp_providers() } +#[tauri::command] +pub fn discover_all_acp_providers() -> Vec { + crate::managed_agents::discover_all_acp_providers() +} + +#[tauri::command] +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}"))? +} + +fn install_acp_runtime_blocking(provider_id: &str) -> Result { + let provider = crate::managed_agents::known_acp_provider(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]); + + if let Some(ref path) = shell_path { + cmd.env("PATH", path); + } + + let mut child = match cmd + .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, + }; + } + }; + + // 5-minute timeout for install commands. + let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300); + loop { + match child.try_wait() { + Ok(Some(status)) => { + let stdout = child + .stdout + .take() + .map(|mut s| { + let mut buf = String::new(); + let _ = std::io::Read::read_to_string(&mut s, &mut buf); + buf + }) + .unwrap_or_default(); + let stderr_raw = child + .stderr + .take() + .map(|mut s| { + let mut buf = String::new(); + let _ = std::io::Read::read_to_string(&mut s, &mut buf); + buf + }) + .unwrap_or_default(); + let stderr = truncate_output(stderr_raw); + return InstallStepResult { + step: step.to_string(), + command: command.to_string(), + success: status.success(), + stdout: truncate_output(stdout), + stderr, + exit_code: status.code(), + }; + } + Ok(None) => { + if std::time::Instant::now() >= deadline { + let _ = child.kill(); + let _ = child.wait(); + 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, + }; + } + std::thread::sleep(std::time::Duration::from_millis(200)); + } + Err(e) => { + 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, + }; + } + } + } +} + +/// Cap output at 2 KB to avoid flooding the UI with large error dumps. +fn truncate_output(s: String) -> String { + if s.len() > 2048 { + format!("{}... (truncated)", &s[..2048]) + } else { + s + } +} + #[tauri::command] pub fn discover_managed_agent_prereqs( input: DiscoverManagedAgentPrereqsRequest, diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d27a35914..fa8e5fc92 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -466,6 +466,8 @@ pub fn run() { get_relay_http_url, get_media_proxy_port, discover_acp_providers, + discover_all_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..cc75deaed 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -3,7 +3,7 @@ use std::process::Command; use tauri::AppHandle; -use crate::managed_agents::{AcpProviderInfo, CommandAvailabilityInfo}; +use crate::managed_agents::{AcpAvailabilityStatus, AcpProviderCatalogEntry, AcpProviderInfo, CommandAvailabilityInfo}; pub(crate) struct KnownAcpProvider { pub id: &'static str, @@ -15,6 +15,16 @@ 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 guidance for the UI. + pub install_hint: &'static str, } const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -54,6 +64,11 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ avatar_url: GOOSE_AVATAR_URL, mcp_command: None, mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &[], + adapter_install_commands: &["curl -fsSL https://github.com/block-open-source/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"], + install_instructions_url: "https://block.github.io/goose/", + install_hint: "Install Goose via the official install script.", }, KnownAcpProvider { id: "claude", @@ -63,6 +78,11 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ avatar_url: CLAUDE_CODE_AVATAR_URL, mcp_command: None, mcp_hooks: false, + underlying_cli: Some("claude"), + cli_install_commands: &["npm install -g @anthropic-ai/claude-code"], + adapter_install_commands: &["npm install -g @anthropic-ai/claude-agent-acp"], + install_instructions_url: "https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp", + install_hint: "Install the Claude Code ACP adapter via npm.", }, KnownAcpProvider { id: "codex", @@ -72,6 +92,11 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[ avatar_url: CODEX_AVATAR_URL, mcp_command: None, mcp_hooks: false, + underlying_cli: Some("codex"), + cli_install_commands: &[], + adapter_install_commands: &[], + install_instructions_url: "https://github.com/openai/codex", + install_hint: "The codex-acp adapter must be built from source. See the GitHub repo.", }, KnownAcpProvider { id: "sprout-agent", @@ -81,6 +106,11 @@ 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", + install_hint: "Ships with the Sprout desktop app.", }, ]; @@ -219,15 +249,18 @@ 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 +281,12 @@ pub fn resolve_command(command: &str, app: Option<&AppHandle>) -> Option) -> Option { if let Some(path) = resolve_workspace_command(command, app) { return Some(path); @@ -369,6 +408,63 @@ pub fn discover_local_acp_providers() -> Vec { .collect() } +pub fn discover_all_acp_providers() -> Vec { + KNOWN_ACP_PROVIDERS + .iter() + .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))); + + let (availability, command, binary_path) = if let Some((cmd, path)) = adapter_result { + ( + AcpAvailabilityStatus::Available, + Some(cmd.to_string()), + Some(path.display().to_string()), + ) + } else if let Some(cli) = provider.underlying_cli { + if find_command(cli).is_some() { + (AcpAvailabilityStatus::AdapterMissing, None, None) + } else { + (AcpAvailabilityStatus::NotInstalled, None, None) + } + } else { + (AcpAvailabilityStatus::NotInstalled, None, None) + }; + + 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(); + + 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: provider.install_hint.to_string(), + install_instructions_url: provider.install_instructions_url.to_string(), + can_auto_install, + underlying_cli_path, + } + }) + .collect() +} + pub fn managed_agent_avatar_url(command: &str) -> Option { let provider = known_acp_provider(command)?; Some(provider.avatar_url.to_string()) diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index 09b5274a5..6e7da3ba3 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -286,6 +286,49 @@ pub struct AcpProviderInfo { pub mcp_command: Option, } +#[derive(Debug, Clone, Serialize, PartialEq, Eq)] +#[serde(rename_all = "snake_case")] +pub enum AcpAvailabilityStatus { + Available, + AdapterMissing, + NotInstalled, +} + +#[derive(Debug, Clone, Serialize)] +pub struct AcpProviderCatalogEntry { + pub id: String, + pub label: String, + pub avatar_url: String, + pub availability: AcpAvailabilityStatus, + pub command: Option, + pub binary_path: Option, + pub default_args: Vec, + 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)] pub struct CommandAvailabilityInfo { pub command: String, diff --git a/desktop/src/features/agents/hooks.ts b/desktop/src/features/agents/hooks.ts index 0fdb599d1..59b64053b 100644 --- a/desktop/src/features/agents/hooks.ts +++ b/desktop/src/features/agents/hooks.ts @@ -11,9 +11,11 @@ import { createManagedAgent, deleteManagedAgent, discoverAcpProviders, + discoverAllAcpProviders, discoverBackendProviders, discoverManagedAgentPrereqs, getManagedAgentLog, + installAcpRuntime, listManagedAgents, listRelayAgents, startManagedAgent, @@ -72,6 +74,7 @@ export const managedAgentsQueryKey = ["managed-agents"] as const; export const personasQueryKey = ["personas"] as const; export const teamsQueryKey = ["teams"] as const; export const acpProvidersQueryKey = ["acp-providers"] as const; +export const allAcpProvidersQueryKey = ["all-acp-providers"] as const; export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const; export const backendProvidersQueryKey = ["backend-providers"] as const; @@ -105,6 +108,27 @@ export function useAcpProvidersQuery() { }); } +export function useAllAcpProvidersQuery() { + return useQuery({ + queryKey: allAcpProvidersQueryKey, + queryFn: discoverAllAcpProviders, + staleTime: 60_000, + }); +} + +export function useInstallAcpRuntimeMutation() { + const queryClient = useQueryClient(); + return useMutation({ + mutationFn: (providerId: string) => installAcpRuntime(providerId), + onSettled: () => { + void queryClient.invalidateQueries({ queryKey: acpProvidersQueryKey }); + void queryClient.invalidateQueries({ + queryKey: allAcpProvidersQueryKey, + }); + }, + }); +} + export function useBackendProvidersQuery() { return useQuery({ queryKey: backendProvidersQueryKey, diff --git a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx index 86c791f17..a8494e7fa 100644 --- a/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx +++ b/desktop/src/features/agents/ui/CreateAgentDialogSections.tsx @@ -1,4 +1,5 @@ import type { AcpProvider, ManagedAgentPrereqs } from "@/shared/api/types"; +import { useAllAcpProvidersQuery } from "@/features/agents/hooks"; import { cn } from "@/shared/lib/cn"; import { Input } from "@/shared/ui/input"; import { Textarea } from "@/shared/ui/textarea"; @@ -48,6 +49,11 @@ export function CreateAgentRuntimeProviderField({ selectedProviderId: string; onProviderChange: (value: string) => void; }) { + const allProvidersQuery = useAllAcpProvidersQuery(); + const unavailableCount = (allProvidersQuery.data ?? []).filter( + (p) => p.availability !== "available", + ).length; + return (

); } diff --git a/desktop/src/features/agents/ui/PersonaDialog.tsx b/desktop/src/features/agents/ui/PersonaDialog.tsx index 7d465e849..dba647a82 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; @@ -354,13 +354,32 @@ 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.

+ {(() => { + const selected = providers.find((p) => p.id === provider); + if (!selected || selected.availability === "available") + return null; + return ( +

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

+ ); + })()}
diff --git a/desktop/src/features/agents/ui/usePersonaActions.ts b/desktop/src/features/agents/ui/usePersonaActions.ts index 6d01f6c1f..f7f3d0fa5 100644 --- a/desktop/src/features/agents/ui/usePersonaActions.ts +++ b/desktop/src/features/agents/ui/usePersonaActions.ts @@ -3,7 +3,7 @@ import { useQueryClient } from "@tanstack/react-query"; import { personasQueryKey, - useAcpProvidersQuery, + useAllAcpProvidersQuery, useCreatePersonaMutation, useDeletePersonaMutation, useExportPersonaJsonMutation, @@ -36,7 +36,7 @@ type PersonaFeedbackSurface = "catalog" | "library"; export function usePersonaActions() { const queryClient = useQueryClient(); const personasQuery = usePersonasQuery(); - const acpProvidersQuery = useAcpProvidersQuery(); + const acpProvidersQuery = useAllAcpProvidersQuery(); const createPersonaMutation = useCreatePersonaMutation(); const updatePersonaMutation = useUpdatePersonaMutation(); const deletePersonaMutation = useDeletePersonaMutation(); diff --git a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx index b3e99d363..41c279f44 100644 --- a/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx +++ b/desktop/src/features/settings/ui/DoctorSettingsPanel.tsx @@ -1,73 +1,230 @@ +import * as React from "react"; import { AlertTriangle, CheckCircle2, + Circle, + Download, + ExternalLink, RefreshCw, Stethoscope, } from "lucide-react"; -import { useAcpProvidersQuery } from "@/features/agents/hooks"; +import { + useAllAcpProvidersQuery, + 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 "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.binaryPath} +

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

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

+

+ {provider.installHint} +

+ + + ) : ( + <> +

Not installed

+

+ {provider.installHint} +

+ + + )} + + {installSuccess ? ( +

+ Installed successfully. Re-run Doctor to verify. +

+ ) : null} + {installError ? ( +

+ {installError}

) : null} -

- {resolvedPath} -

); } export function DoctorSettingsPanel() { - const providersQuery = useAcpProvidersQuery(); + const providersQuery = useAllAcpProvidersQuery(); 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 || "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 +243,7 @@ export function DoctorSettingsPanel() { className="shrink-0" disabled={isRefreshing} onClick={() => { + setInstallResults({}); void providersQuery.refetch(); }} size="sm" @@ -103,29 +261,31 @@ export function DoctorSettingsPanel() {

ACP runtimes

- Installed runtimes that the desktop app can offer in Create agent. + Known runtimes and their installation status.

{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..48d76cd8f 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -39,8 +39,11 @@ import type { CreateManagedAgentInput, AgentModelsResponse, UpdateManagedAgentInput, + AcpAvailabilityStatus, AcpProvider, + AcpProviderCatalogEntry, CommandAvailability, + InstallRuntimeResult, OpenDmInput, } from "@/shared/api/types"; @@ -254,6 +257,35 @@ type RawAcpProvider = { mcp_command: string | null; }; +type RawAcpProviderCatalogEntry = { + id: string; + label: 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; +}; + +type RawInstallStepResult = { + step: string; + command: string; + success: boolean; + stdout: string; + stderr: string; + exit_code: number | null; +}; + +type RawInstallRuntimeResult = { + success: boolean; + steps: RawInstallStepResult[]; +}; + type RawCommandAvailability = { command: string; resolved_path: string | null; @@ -861,6 +893,41 @@ function fromRawAcpProvider(provider: RawAcpProvider): AcpProvider { }; } +function fromRawAcpProviderCatalogEntry( + entry: RawAcpProviderCatalogEntry, +): AcpProviderCatalogEntry { + return { + 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, + })), + }; +} + function fromRawCommandAvailability( command: RawCommandAvailability, ): CommandAvailability { @@ -1019,6 +1086,26 @@ export async function discoverAcpProviders(): Promise { ); } +export async function discoverAllAcpProviders(): Promise< + AcpProviderCatalogEntry[] +> { + return ( + await invokeTauri( + "discover_all_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: { acpCommand?: string; mcpCommand?: string; diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index 4dd100283..5f1df9d15 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -376,6 +376,40 @@ export type AcpProvider = { mcpCommand: string | null; }; +export type AcpAvailabilityStatus = + | "available" + | "adapter_missing" + | "not_installed"; + +export type AcpProviderCatalogEntry = { + id: string; + label: string; + avatarUrl: string; + availability: AcpAvailabilityStatus; + command: string | null; + binaryPath: string | null; + defaultArgs: string[]; + mcpCommand: string | null; + installHint: string; + installInstructionsUrl: string; + canAutoInstall: boolean; + underlyingCliPath: string | null; +}; + +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 = { command: string; resolvedPath: string | null; diff --git a/desktop/src/testing/e2eBridge.ts b/desktop/src/testing/e2eBridge.ts index a2f481ba8..120296239 100644 --- a/desktop/src/testing/e2eBridge.ts +++ b/desktop/src/testing/e2eBridge.ts @@ -327,6 +327,33 @@ type RawAcpProvider = { mcp_command: string | null; }; +type RawAcpProviderCatalogEntry = { + id: string; + label: string; + avatar_url: string; + availability: "available" | "adapter_missing" | "not_installed"; + 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; +}; + +type RawInstallRuntimeResult = { + success: boolean; + steps: Array<{ + step: string; + command: string; + success: boolean; + stdout: string; + stderr: string; + exit_code: number | null; + }>; +}; + type RawCommandAvailability = { command: string; resolved_path: string | null; @@ -3501,6 +3528,89 @@ async function handleDiscoverAcpProviders( ]; } +async function handleDiscoverAllAcpProviders( + _config: E2eConfig | undefined, +): Promise { + 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", + 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; +}): Promise { + 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 +4783,10 @@ export function maybeInstallE2eTauriMocks() { return getRelayHttpUrl(activeConfig); case "discover_acp_providers": return handleDiscoverAcpProviders(activeConfig); + case "discover_all_acp_providers": + return handleDiscoverAllAcpProviders(activeConfig); + case "install_acp_runtime": + return handleInstallAcpRuntime(payload as { providerId?: string }); case "discover_backend_providers": return []; case "probe_backend_provider":