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.
This commit is contained in:
Will Pfleger
2026-05-22 17:31:00 -04:00
parent f8a0cb0147
commit 38bc4bdab9
12 changed files with 817 additions and 57 deletions
@@ -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,
+2
View File
@@ -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,
@@ -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())
@@ -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,
+24
View File
@@ -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,
@@ -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>
);
}
@@ -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">
@@ -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();
@@ -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>
+87
View File
@@ -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;
+34
View File
@@ -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;
+114
View File
@@ -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":