From b0e42cfa28f2f88aa4a9c26b2b368e8028af8311 Mon Sep 17 00:00:00 2001
From: Will Pfleger <wpfleger@block.xyz>
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<AcpProviderInfo> {
     discover_local_acp_providers()
 }
 
+#[tauri::command]
+pub fn discover_all_acp_providers() -> Vec<AcpProviderCatalogEntry> {
+    crate::managed_agents::discover_all_acp_providers()
+}
+
+#[tauri::command]
+pub async fn install_acp_runtime(provider_id: String) -> Result<InstallRuntimeResult, String> {
+    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<InstallRuntimeResult, String> {
+    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<P
         .find(|candidate| candidate.exists())
 }
 
+fn resolve_cache() -> &'static std::sync::Mutex<std::collections::HashMap<String, Option<PathBuf>>> {
+    use std::collections::HashMap;
+    use std::sync::{Mutex, OnceLock};
+    static CACHE: OnceLock<Mutex<HashMap<String, Option<PathBuf>>>> = 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<PathBuf> {
-    use std::collections::HashMap;
-    use std::sync::{Mutex, OnceLock};
-
-    static CACHE: OnceLock<Mutex<HashMap<String, Option<PathBuf>>>> = 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<PathBuf
     result
 }
 
+/// Clear the resolve_command cache so that newly-installed binaries are detected.
+pub fn clear_resolve_cache() {
+    let mut guard = resolve_cache().lock().unwrap_or_else(|e| e.into_inner());
+    guard.clear();
+}
+
 fn resolve_command_uncached(command: &str, app: Option<&AppHandle>) -> Option<PathBuf> {
     if let Some(path) = resolve_workspace_command(command, app) {
         return Some(path);
@@ -369,6 +408,63 @@ pub fn discover_local_acp_providers() -> Vec<AcpProviderInfo> {
         .collect()
 }
 
+pub fn discover_all_acp_providers() -> Vec<AcpProviderCatalogEntry> {
+    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<String> {
     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<String>,
 }
 
+#[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<String>,
+    pub binary_path: Option<String>,
+    pub default_args: Vec<String>,
+    pub mcp_command: Option<String>,
+    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<String>,
+}
+
+/// 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<i32>,
+}
+
+/// Aggregate result of installing a runtime (may include CLI + adapter steps).
+#[derive(Debug, Clone, Serialize)]
+pub struct InstallRuntimeResult {
+    pub success: bool,
+    pub steps: Vec<InstallStepResult>,
+}
+
 #[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 (
     <div className="space-y-1.5">
       <label className="text-sm font-medium" htmlFor="agent-provider">
@@ -86,6 +92,14 @@ export function CreateAgentRuntimeProviderField({
           command in Advanced setup.
         </p>
       )}
+      {unavailableCount > 0 ? (
+        <p className="text-xs text-muted-foreground">
+          {unavailableCount} additional{" "}
+          {unavailableCount === 1 ? "runtime" : "runtimes"} available to
+          install. Visit Settings &gt; Doctor to set{" "}
+          {unavailableCount === 1 ? "it" : "them"} up.
+        </p>
+      ) : null}
     </div>
   );
 }
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<void>;
@@ -354,13 +354,32 @@ export function PersonaDialog({
                 {providers.map((p) => (
                   <option key={p.id} value={p.id}>
                     {p.label}
+                    {p.availability === "adapter_missing"
+                      ? " (adapter missing)"
+                      : p.availability === "not_installed"
+                        ? " (not installed)"
+                        : ""}
                   </option>
                 ))}
               </select>
               <p className="text-xs text-muted-foreground">
                 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 &gt; Doctor.
               </p>
+              {(() => {
+                const selected = providers.find((p) => p.id === provider);
+                if (!selected || selected.availability === "available")
+                  return null;
+                return (
+                  <p className="text-xs text-warning">
+                    {selected.availability === "adapter_missing"
+                      ? `${selected.label} CLI is installed but the ACP adapter is missing.`
+                      : `${selected.label} is not installed.`}{" "}
+                    Visit Settings &gt; Doctor to set it up.
+                  </p>
+                );
+              })()}
             </div>
 
             <div className="space-y-1.5">
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 ? (
-    <CheckCircle2 className="h-4 w-4 text-status-added" />
-  ) : (
-    <AlertTriangle className="h-4 w-4 text-warning" />
+function StatusIcon({
+  availability,
+}: {
+  availability: AcpProviderCatalogEntry["availability"];
+}) {
+  switch (availability) {
+    case "available":
+      return <CheckCircle2 className="h-4 w-4 text-status-added" />;
+    case "adapter_missing":
+      return <AlertTriangle className="h-4 w-4 text-warning" />;
+    case "not_installed":
+      return <Circle className="h-4 w-4 text-muted-foreground/50" />;
+  }
+}
+
+function InstallActions({
+  isInstalling,
+  onInstall,
+  provider,
+}: {
+  isInstalling: boolean;
+  onInstall: () => void;
+  provider: AcpProviderCatalogEntry;
+}) {
+  return (
+    <div className="mt-2 flex items-center gap-2">
+      {provider.canAutoInstall ? (
+        <Button
+          disabled={isInstalling}
+          onClick={onInstall}
+          size="sm"
+          type="button"
+          variant="outline"
+        >
+          {isInstalling ? (
+            <RefreshCw className="h-3.5 w-3.5 animate-spin" />
+          ) : (
+            <Download className="h-3.5 w-3.5" />
+          )}
+          {isInstalling ? "Installing..." : "Install"}
+        </Button>
+      ) : null}
+      <button
+        className="inline-flex items-center gap-1 text-xs text-muted-foreground underline-offset-2 hover:text-foreground hover:underline"
+        onClick={() => window.open(provider.installInstructionsUrl, "_blank")}
+        type="button"
+      >
+        <ExternalLink className="h-3 w-3" />
+        View instructions
+      </button>
+    </div>
   );
 }
 
 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 (
     <div
-      className="flex items-start gap-3 rounded-xl border border-border/70 bg-background/80 px-4 py-3"
-      data-testid={`doctor-provider-${providerId}`}
+      className={cn(
+        "flex items-start gap-3 rounded-xl border px-4 py-3",
+        provider.availability === "available"
+          ? "border-border/70 bg-background/80"
+          : provider.availability === "adapter_missing"
+            ? "border-amber-500/30 bg-amber-500/5"
+            : "border-border/50 bg-muted/30",
+      )}
+      data-testid={`doctor-provider-${provider.id}`}
     >
       <div className="mt-0.5 shrink-0">
-        <StatusIcon available={true} />
+        <StatusIcon availability={provider.availability} />
       </div>
 
       <div className="min-w-0 flex-1">
         <div className="flex flex-wrap items-center gap-2">
-          <p className="text-sm font-semibold tracking-tight">{label}</p>
-          <code className="rounded bg-muted px-1.5 py-0.5 text-[11px]">
-            {command}
-          </code>
+          <p className="text-sm font-semibold tracking-tight">
+            {provider.label}
+          </p>
+          {provider.command ? (
+            <code className="rounded bg-muted px-1.5 py-0.5 text-[11px]">
+              {provider.command}
+            </code>
+          ) : null}
         </div>
-        <p className="mt-1 text-sm text-muted-foreground">
-          Available via {describeResolvedCommand(command, resolvedPath)}.
-        </p>
-        {defaultArgs.length > 0 ? (
-          <p className="mt-2 text-xs text-muted-foreground">
-            Default args:{" "}
-            <code className="font-mono">{defaultArgs.join(", ")}</code>
+
+        {provider.availability === "available" &&
+        provider.command &&
+        provider.binaryPath ? (
+          <>
+            <p className="mt-1 text-sm text-muted-foreground">
+              Available via{" "}
+              {describeResolvedCommand(provider.command, provider.binaryPath)}.
+            </p>
+            {provider.defaultArgs.length > 0 ? (
+              <p className="mt-2 text-xs text-muted-foreground">
+                Default args:{" "}
+                <code className="font-mono">
+                  {provider.defaultArgs.join(", ")}
+                </code>
+              </p>
+            ) : null}
+            <p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/80">
+              {provider.binaryPath}
+            </p>
+          </>
+        ) : provider.availability === "adapter_missing" ? (
+          <>
+            <p className="mt-1 text-sm text-muted-foreground">
+              CLI detected at{" "}
+              <code className="rounded bg-muted px-1 py-0.5 text-[11px]">
+                {provider.underlyingCliPath ?? "unknown path"}
+              </code>{" "}
+              but ACP adapter not found.
+            </p>
+            <p className="mt-1 text-xs text-muted-foreground">
+              {provider.installHint}
+            </p>
+            <InstallActions
+              isInstalling={isInstalling}
+              onInstall={onInstall}
+              provider={provider}
+            />
+          </>
+        ) : (
+          <>
+            <p className="mt-1 text-sm text-muted-foreground">Not installed</p>
+            <p className="mt-1 text-xs text-muted-foreground">
+              {provider.installHint}
+            </p>
+            <InstallActions
+              isInstalling={isInstalling}
+              onInstall={onInstall}
+              provider={provider}
+            />
+          </>
+        )}
+
+        {installSuccess ? (
+          <p className="mt-2 rounded-lg border border-green-500/30 bg-green-500/10 px-3 py-1.5 text-xs text-green-700 dark:text-green-400">
+            Installed successfully. Re-run Doctor to verify.
+          </p>
+        ) : null}
+        {installError ? (
+          <p className="mt-2 rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-xs text-destructive">
+            {installError}
           </p>
         ) : null}
-        <p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/80">
-          {resolvedPath}
-        </p>
       </div>
     </div>
   );
 }
 
 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<string, { success: boolean; error: string | null }>
+  >({});
+
+  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 (
     <section className="space-y-5" data-testid="settings-doctor">
@@ -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() {
         <div className="rounded-xl border border-border/70 bg-muted/20 p-4">
           <h3 className="text-sm font-semibold tracking-tight">ACP runtimes</h3>
           <p className="mt-1 text-sm text-muted-foreground">
-            Installed runtimes that the desktop app can offer in Create agent.
+            Known runtimes and their installation status.
           </p>
 
           <div className="mt-4 space-y-2">
             {providersQuery.isLoading ? (
               <p className="text-sm text-muted-foreground">
-                Looking for installed ACP runtimes...
+                Looking for ACP runtimes...
               </p>
             ) : providers.length > 0 ? (
               providers.map((provider) => (
                 <ProviderRow
-                  command={provider.command}
-                  defaultArgs={provider.defaultArgs}
+                  installError={installResults[provider.id]?.error ?? null}
+                  installSuccess={installResults[provider.id]?.success ?? false}
+                  isInstalling={
+                    installMutation.isPending &&
+                    installMutation.variables === provider.id
+                  }
                   key={provider.id}
-                  label={provider.label}
-                  providerId={provider.id}
-                  resolvedPath={provider.binaryPath}
+                  onInstall={() => handleInstall(provider.id)}
+                  provider={provider}
                 />
               ))
             ) : (
               <div className="rounded-xl border border-amber-500/30 bg-amber-500/10 px-4 py-3 text-sm text-warning">
-                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.
               </div>
             )}
           </div>
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<AcpProvider[]> {
   );
 }
 
+export async function discoverAllAcpProviders(): Promise<
+  AcpProviderCatalogEntry[]
+> {
+  return (
+    await invokeTauri<RawAcpProviderCatalogEntry[]>(
+      "discover_all_acp_providers",
+    )
+  ).map(fromRawAcpProviderCatalogEntry);
+}
+
+export async function installAcpRuntime(
+  providerId: string,
+): Promise<InstallRuntimeResult> {
+  const raw = await invokeTauri<RawInstallRuntimeResult>(
+    "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<RawAcpProviderCatalogEntry[]> {
+  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<RawInstallRuntimeResult> {
+  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":
