Show all ACP runtimes with install status and one-click install (#724)

This commit is contained in:
Will Pfleger
2026-05-22 20:33:35 -04:00
committed by GitHub
parent f8a0cb0147
commit 4269fd2720
21 changed files with 1031 additions and 153 deletions
+6 -6
View File
@@ -45,27 +45,27 @@ const overrides = new Map([
["src/features/settings/ui/SettingsView.tsx", 600],
["src/features/sidebar/ui/AppSidebar.tsx", 860], // channels + forums creation forms + Pulse nav
["src/shared/api/relayClientSession.ts", 1040], // durable websocket session manager with reconnect/replay/recovery state + sendTypingIndicator + fetchChannelHistoryBefore + subscribeToChannelLive (huddle TTS) + subscribeToHuddleEvents (huddle indicator) + disconnect() for workspace switch teardown + fetchEvents/subscribeLive/publishEvent for NIP-RS read state + publishUserStatus/subscribeToUserStatusUpdates (NIP-38) + ConnectionState plumbing & stall-watchdog wiring for half-open WS detection (Warp orange-icon case) + terminal session latch (auth rejection no longer racing back to reconnecting) — emitter + watchdog + reconnect policy logic extracted to relayConnectionStateEmitter.ts / relayStallWatchdog.ts / relayReconnectPolicy.ts
["src/shared/api/tauri.ts", 1100], // remote agent provider API bindings + canvas API functions
["src-tauri/src/lib.rs", 710], // sprout-media:// proxy + Range headers + Sprout nest init (ensure_nest) in setup() + huddle command registration + PTT global shortcut handler + persona pack commands + app_handle storage for event emission
["src-tauri/src/commands/media.rs", 730], // ffmpeg video transcode + poster frame extraction + run_ffmpeg_with_timeout (find_ffmpeg via resolve_command, is_video_file, transcode_to_mp4, extract_poster_frame, transcode_and_extract_poster) + spawn_blocking wrappers + tests
["src-tauri/src/commands/agents.rs", 881], // remote agent lifecycle routing (local + provider branches) + scope enforcement + persona pack metadata wiring + mcp_toolsets field + NIP-OA auth_tag in deploy payload
["src-tauri/src/commands/messages.rs", 510], // feed multi-query + NIP-50 search + forum thread resolution + thread ref + reactions via REQ
["src-tauri/src/nostr_convert.rs", 1150], // 12 Nostr event→model converters (channels, profiles, members, notes, search, agents, relay members) + rank_user_search_results helper for NIP-50 user search + 33 unit tests
["src-tauri/src/managed_agents/runtime.rs", 1110], // ... + respond-to gate env (SPROUT_ACP_RESPOND_TO[_ALLOWLIST]) + per-mode env builder + tests + persona/agent env_vars spawn merge (helper + tests now in env_vars.rs)
["src-tauri/src/managed_agents/types.rs", 715], // ManagedAgentRecord/Summary + Create/Update request structs + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field
["src-tauri/src/managed_agents/discovery.rs", 680], // KNOWN_ACP_PROVIDERS catalog + resolve_command cache + login_shell_path + classify_provider (four-state: Available/AdapterMissing/CliMissing/NotInstalled) + discover_acp_providers with dynamic install_hint + known_acp_provider/known_acp_provider_exact + normalize_agent_args + 15 unit tests
["src-tauri/src/managed_agents/types.rs", 745], // ManagedAgentRecord/Summary + Create/Update request structs + AcpProviderCatalogEntry + InstallRuntimeResult + RespondTo enum + validate_respond_to_allowlist + tests + persona/agent env_vars field
["src-tauri/src/managed_agents/backend.rs", 700], // provider IPC, validation, discovery, binary resolution + tests + redact_secrets_with for user env values + env_secrets_from_request + redact_env_values_in (shared with model discovery)
["src/features/huddle/HuddleContext.tsx", 650], // huddle lifecycle context + joinHuddle + connectAndSetupMedia shared helper + activeSpeakers/isReconnecting state + PTT (reusable AudioContext) + TTS subscription + mic level analyser (10fps throttle) + agent pubkey refresh
["src/features/agents/hooks.ts", 540], // agent query/mutation surface now includes built-in persona library activation + useUpdateManagedAgentMutation
["src/features/agents/hooks.ts", 550], // agent query/mutation surface + useAvailableAcpProviders (type-narrowing filter hook) + useInstallAcpRuntimeMutation + built-in persona library activation
["src/features/agents/ui/AgentsView.tsx", 880], // remote agent lifecycle controls + persona/team management + persona import-update dialog wiring + built-in catalog/library state orchestration
["src/features/agents/ui/UnifiedAgentsSection.tsx", 570], // unified persona-grouped agent view with collapsible groups, bulk actions, drag-drop import, empty/loading states
["src/features/agents/ui/ManagedAgentRow.tsx", 530], // EditAgentDialog integration + provider/local branching
["src/features/agents/ui/TeamDialog.tsx", 530], // team create/edit dialog with persona multi-select, import button, window drag detection, removal confirmation
["src/features/agents/ui/TeamImportUpdateDialog.tsx", 660], // team import diff preview with member matching/updating/adding/removing sections, LCS line counts, removal confirmation
["src/features/agents/ui/useTeamActions.ts", 510], // team CRUD + export + import + import-update orchestration with query invalidation
["src/features/agents/ui/PersonaDialog.tsx", 515], // persona create/edit form + env vars editor + drag-drop file import + runtime provider dropdown with availability warnings
["src/features/agents/ui/CreateAgentDialog.tsx", 685], // provider selector + config form + schema-typed config coercion + required field validation + locked scopes
["src/features/channels/ui/AddChannelBotDialog.tsx", 690], // provider mode: Run on selector, trust warning, probe effect, single-agent enforcement, provider warnings display + RespondTo field + reuse guardrail
["src/features/settings/ui/ChannelTemplatesSettingsCard.tsx", 850], // template CRUD card + TemplateFormDialog (persona/team chip selectors + provider assignments + canvas template) + TemplateTeamSelector + ProviderAssignments + ProviderRow
["src/shared/api/types.ts", 620], // ... + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
["src/shared/api/types.ts", 650], // ... + AcpProviderCatalogEntry + AcpProvider (narrowed subtype) + InstallRuntimeResult + RespondToMode + respondTo/respondToAllowlist on ManagedAgent/Create/Update inputs
["src-tauri/src/events.rs", 610], // event builders + build_huddle_guidelines (kind:48106) + post_event_raw transport helper + participant p-tag on join/leave + NIP-43 relay admin builders (add/remove/change-role) + check_relay_role + DM/presence/workflow command builders
["src-tauri/src/huddle/mod.rs", 1020], // huddle state machine + Tauri commands + sync protocol doc; state/relay/pipeline extracted + emit_huddle_state_changed wiring
["src-tauri/src/huddle/models.rs", 950], // model download manager for Parakeet TDT-CTC STT + Pocket TTS with streaming downloads + SHA-256 verification + Rust-native tar extraction + version manifest + atomic swap + hot-start signaling + MODEL_LICENSE.txt sidecar (fail-closed readiness) + idempotent legacy Moonshine dir cleanup + tts_readiness_requires_license_sidecar test + Mary (VCTK p333) reference voice attribution block
@@ -77,7 +77,7 @@ const overrides = new Map([
["src-tauri/src/relay.rs", 510], // +4 lines for NIP-OA auth tag injection in profile sync (build_profile_event) + verification test
["src-tauri/src/commands/pairing.rs", 600], // NIP-AB pairing actor: 3 Tauri commands + background WS task + NIP-42 auth + NIP-43 probe + event parsing helpers
["src-tauri/src/lib.rs", 715], // +4 lines for PairingHandle managed state + 3 pairing command registrations
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep
["src/shared/api/tauri.ts", 1212], // pairing command wrappers + applyWorkspace + NIP-44 encrypt/decrypt wrappers + observer_url field + relay member API functions (list/get/add/remove/change-role) + prevent sleep + AcpProviderCatalogEntry raw types + fromRawAcpProviderCatalogEntry converter + installAcpRuntime
]);
async function walkFiles(directory) {
@@ -1,19 +1,286 @@
use std::io::Read;
use tauri::{AppHandle, State};
use crate::{
app_state::AppState,
managed_agents::{
command_availability, discover_local_acp_providers, AcpProviderInfo,
DiscoverManagedAgentPrereqsRequest, ManagedAgentPrereqsInfo, RelayAgentInfo,
command_availability, AcpProviderCatalogEntry, DiscoverManagedAgentPrereqsRequest,
InstallRuntimeResult, InstallStepResult, ManagedAgentPrereqsInfo, RelayAgentInfo,
DEFAULT_ACP_COMMAND, DEFAULT_MCP_COMMAND,
},
nostr_convert,
relay::query_relay,
};
fn active_installs() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
static ACTIVE: OnceLock<Mutex<HashSet<String>>> = OnceLock::new();
ACTIVE.get_or_init(|| Mutex::new(HashSet::new()))
}
#[tauri::command]
pub fn discover_acp_providers() -> Vec<AcpProviderInfo> {
discover_local_acp_providers()
pub fn discover_acp_providers() -> Vec<AcpProviderCatalogEntry> {
crate::managed_agents::clear_resolve_cache();
crate::managed_agents::discover_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}"))?
}
/// Err(_) = infrastructure failure (panic, concurrency guard).
/// Ok({success: false}) = an install step failed (stderr captured in steps).
fn install_acp_runtime_blocking(provider_id: &str) -> Result<InstallRuntimeResult, String> {
// Prevent concurrent installs for the same provider.
{
let mut set = active_installs()
.lock()
.map_err(|_| "install lock poisoned".to_string())?;
if !set.insert(provider_id.to_string()) {
return Err(format!(
"an install is already in progress for {provider_id}"
));
}
}
struct Guard(String);
impl Drop for Guard {
fn drop(&mut self) {
if let Ok(mut set) = active_installs().lock() {
set.remove(&self.0);
}
}
}
let _guard = Guard(provider_id.to_string());
let provider = crate::managed_agents::known_acp_provider_exact(provider_id)
.ok_or_else(|| format!("unknown provider: {provider_id}"))?;
let mut steps = Vec::new();
// Phase 1: Install CLI if missing and commands are available.
if let Some(cli) = provider.underlying_cli {
if crate::managed_agents::resolve_command(cli, None).is_none() {
for cmd in provider.cli_install_commands {
let result = run_install_command("cli", cmd);
let success = result.success;
steps.push(result);
if !success {
return Ok(InstallRuntimeResult {
success: false,
steps,
});
}
}
}
}
// Phase 2: Install adapter if missing and commands are available.
let adapter_found = provider
.commands
.iter()
.any(|cmd| crate::managed_agents::resolve_command(cmd, None).is_some());
if !adapter_found {
for cmd in provider.adapter_install_commands {
let result = run_install_command("adapter", cmd);
let success = result.success;
steps.push(result);
if !success {
return Ok(InstallRuntimeResult {
success: false,
steps,
});
}
}
}
// Clear the resolve cache so the next discovery picks up new binaries.
crate::managed_agents::clear_resolve_cache();
Ok(InstallRuntimeResult {
success: true,
steps,
})
}
fn run_install_command(step: &str, command: &str) -> InstallStepResult {
let shell_path = crate::managed_agents::login_shell_path();
let shell = if std::path::Path::new("/bin/zsh").exists() {
"/bin/zsh"
} else {
"/bin/bash"
};
let mut cmd = std::process::Command::new(shell);
cmd.args(["-l", "-c", command]);
// Strip hermit env vars so npm/node use the user's normal registry and
// global prefix rather than the project-local hermit-managed paths.
cmd.env_remove("NPM_CONFIG_PREFIX");
cmd.env_remove("NPM_CONFIG_CACHE");
cmd.env_remove("COREPACK_HOME");
if let Some(ref path) = shell_path {
cmd.env("PATH", path);
}
// Detach from the controlling terminal so install scripts that read from
// /dev/tty (e.g. Codex's "Start Codex now? [y/N]") fall back to stdin
// (which is /dev/null) instead of blocking forever.
#[cfg(unix)]
{
use std::os::unix::process::CommandExt;
unsafe {
cmd.pre_exec(|| {
libc::setsid();
Ok(())
});
}
}
let mut child = match cmd
.stdin(std::process::Stdio::null())
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.spawn()
{
Ok(child) => child,
Err(e) => {
return InstallStepResult {
step: step.to_string(),
command: command.to_string(),
success: false,
stdout: String::new(),
stderr: format!("failed to spawn shell: {e}"),
exit_code: None,
};
}
};
// Drain stdout/stderr on background threads to prevent pipe buffer deadlock.
let stdout_pipe = child.stdout.take();
let stderr_pipe = child.stderr.take();
let stdout_thread = std::thread::spawn(move || {
let mut buf = String::new();
if let Some(mut pipe) = stdout_pipe {
let _ = pipe.read_to_string(&mut buf);
}
buf
});
let stderr_thread = std::thread::spawn(move || {
let mut buf = String::new();
if let Some(mut pipe) = stderr_pipe {
let _ = pipe.read_to_string(&mut buf);
}
buf
});
// Save the PID before moving `child` into the wait thread so we can
// kill the process on timeout.
let child_pid = child.id();
let (tx, rx) = std::sync::mpsc::channel();
let wait_thread = std::thread::spawn(move || {
let status = child.wait();
let _ = tx.send(status);
});
// 5-minute timeout for install commands.
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(300);
loop {
let remaining = deadline.saturating_duration_since(std::time::Instant::now());
if remaining.is_zero() {
// Timeout: kill the child process via its PID, then join all
// threads so nothing leaks.
#[cfg(unix)]
unsafe {
libc::kill(child_pid as i32, libc::SIGTERM);
}
drop(rx);
let _ = wait_thread.join();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return InstallStepResult {
step: step.to_string(),
command: command.to_string(),
success: false,
stdout: String::new(),
stderr: "install command timed out after 5 minutes".to_string(),
exit_code: None,
};
}
match rx.recv_timeout(std::time::Duration::from_millis(200).min(remaining)) {
Ok(Ok(status)) => {
let _ = wait_thread.join();
let stdout = stdout_thread.join().unwrap_or_default();
let stderr_raw = stderr_thread.join().unwrap_or_default();
return InstallStepResult {
step: step.to_string(),
command: command.to_string(),
success: status.success(),
stdout: truncate_output(stdout),
stderr: truncate_output(stderr_raw),
exit_code: status.code(),
};
}
Ok(Err(e)) => {
let _ = wait_thread.join();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return InstallStepResult {
step: step.to_string(),
command: command.to_string(),
success: false,
stdout: String::new(),
stderr: format!("failed to check process status: {e}"),
exit_code: None,
};
}
Err(std::sync::mpsc::RecvTimeoutError::Timeout) => {
// Still running; loop and check deadline again.
continue;
}
Err(std::sync::mpsc::RecvTimeoutError::Disconnected) => {
// wait_thread dropped sender without sending — shouldn't happen.
let _ = wait_thread.join();
let _ = stdout_thread.join();
let _ = stderr_thread.join();
return InstallStepResult {
step: step.to_string(),
command: command.to_string(),
success: false,
stdout: String::new(),
stderr: "internal error: wait thread disconnected".to_string(),
exit_code: None,
};
}
}
}
}
/// Cap output to head + tail to avoid flooding the UI with large error dumps,
/// while preserving the most useful parts of the output.
fn truncate_output(s: String) -> String {
const HEAD: usize = 512;
const TAIL: usize = 1024;
const LIMIT: usize = HEAD + TAIL;
if s.len() <= LIMIT {
return s;
}
let head_end = s.floor_char_boundary(HEAD);
let tail_start = s.floor_char_boundary(s.len().saturating_sub(TAIL));
let omitted = tail_start - head_end;
format!(
"{}\n... ({omitted} bytes omitted) ...\n{}",
&s[..head_end],
&s[tail_start..]
)
}
#[tauri::command]
+1
View File
@@ -466,6 +466,7 @@ pub fn run() {
get_relay_http_url,
get_media_proxy_port,
discover_acp_providers,
install_acp_runtime,
discover_managed_agent_prereqs,
sign_event,
decrypt_observer_event,
+196 -19
View File
@@ -3,7 +3,9 @@ use std::process::Command;
use tauri::AppHandle;
use crate::managed_agents::{AcpProviderInfo, CommandAvailabilityInfo};
use crate::managed_agents::{
AcpAvailabilityStatus, AcpProviderCatalogEntry, CommandAvailabilityInfo,
};
pub(crate) struct KnownAcpProvider {
pub id: &'static str,
@@ -15,6 +17,18 @@ pub(crate) struct KnownAcpProvider {
pub mcp_command: Option<&'static str>,
/// Whether to enable MCP hook tools (`_Stop`, `_PostCompact`) for this agent.
pub mcp_hooks: bool,
/// CLI binary that indicates partial install (e.g. `"claude"` when `claude-agent-acp` is missing).
pub underlying_cli: Option<&'static str>,
/// Shell commands to install the runtime CLI itself (run sequentially).
pub cli_install_commands: &'static [&'static str],
/// Shell commands to install the ACP adapter (run sequentially, after CLI).
pub adapter_install_commands: &'static [&'static str],
/// Link to docs/repo for manual instructions.
pub install_instructions_url: &'static str,
/// Human-readable hint about installing the CLI binary.
pub cli_install_hint: &'static str,
/// Human-readable hint about installing the ACP adapter.
pub adapter_install_hint: &'static str,
}
const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png";
@@ -54,6 +68,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
avatar_url: GOOSE_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
underlying_cli: Some("goose"),
cli_install_commands: &["curl -fsSL https://github.com/block-open-source/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"],
adapter_install_commands: &[],
install_instructions_url: "https://block.github.io/goose/",
cli_install_hint: "Install Goose via the official install script.",
adapter_install_hint: "",
},
KnownAcpProvider {
id: "claude",
@@ -63,6 +83,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
avatar_url: CLAUDE_CODE_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
underlying_cli: Some("claude"),
cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"],
adapter_install_commands: &["npm install -g @agentclientprotocol/claude-agent-acp"],
install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp",
cli_install_hint: "Install the Claude Code CLI via the official install script.",
adapter_install_hint: "Install the Claude Code ACP adapter via npm.",
},
KnownAcpProvider {
id: "codex",
@@ -72,6 +98,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
avatar_url: CODEX_AVATAR_URL,
mcp_command: None,
mcp_hooks: false,
underlying_cli: Some("codex"),
cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"],
adapter_install_commands: &["npm install -g @zed-industries/codex-acp"],
install_instructions_url: "https://github.com/zed-industries/codex-acp",
cli_install_hint: "Install the Codex CLI via the official install script.",
adapter_install_hint: "Install the Codex ACP adapter via npm.",
},
KnownAcpProvider {
id: "sprout-agent",
@@ -81,6 +113,12 @@ const KNOWN_ACP_PROVIDERS: &[KnownAcpProvider] = &[
avatar_url: SPROUT_AGENT_AVATAR_URL,
mcp_command: Some("sprout-dev-mcp"),
mcp_hooks: true,
underlying_cli: None,
cli_install_commands: &[],
adapter_install_commands: &[],
install_instructions_url: "https://github.com/block/sprout",
cli_install_hint: "Ships with the Sprout desktop app.",
adapter_install_hint: "",
},
];
@@ -144,6 +182,10 @@ pub(crate) fn known_acp_provider(command: &str) -> Option<&'static KnownAcpProvi
})
}
pub(crate) fn known_acp_provider_exact(id: &str) -> Option<&'static KnownAcpProvider> {
KNOWN_ACP_PROVIDERS.iter().find(|p| p.id == id)
}
fn default_agent_args(command: &str) -> Option<Vec<String>> {
match normalize_command_identity(command).as_str() {
"goose" => Some(vec!["acp".to_string()]),
@@ -219,15 +261,19 @@ fn resolve_workspace_command(command: &str, app: Option<&AppHandle>) -> Option<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 +294,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);
@@ -349,22 +401,96 @@ pub fn missing_command_message(command: &str, role: &str) -> String {
)
}
pub fn discover_local_acp_providers() -> Vec<AcpProviderInfo> {
fn classify_provider(
adapter_result: Option<(&str, PathBuf)>,
underlying_cli: Option<&str>,
underlying_cli_found: bool,
) -> (AcpAvailabilityStatus, Option<String>, Option<String>) {
if let Some((cmd, path)) = adapter_result {
if underlying_cli.is_some() && !underlying_cli_found {
(
AcpAvailabilityStatus::CliMissing,
Some(cmd.to_string()),
Some(path.display().to_string()),
)
} else {
(
AcpAvailabilityStatus::Available,
Some(cmd.to_string()),
Some(path.display().to_string()),
)
}
} else if underlying_cli.is_some() && underlying_cli_found {
(AcpAvailabilityStatus::AdapterMissing, None, None)
} else {
(AcpAvailabilityStatus::NotInstalled, None, None)
}
}
pub fn discover_acp_providers() -> Vec<AcpProviderCatalogEntry> {
KNOWN_ACP_PROVIDERS
.iter()
.filter_map(|provider| {
provider
.map(|provider| {
// Try to find the ACP adapter binary.
let adapter_result = provider
.commands
.iter()
.find_map(|command| find_command(command).map(|path| (*command, path)))
.map(|(command, binary_path)| AcpProviderInfo {
id: provider.id.to_string(),
label: provider.label.to_string(),
command: command.to_string(),
binary_path: binary_path.display().to_string(),
default_args: normalize_agent_args(command, Vec::new()),
mcp_command: provider.mcp_command.map(str::to_string),
})
.find_map(|command| find_command(command).map(|path| (*command, path)));
let underlying_cli_found = provider
.underlying_cli
.map(|cli| find_command(cli).is_some())
.unwrap_or(false);
let (availability, command, binary_path) = classify_provider(
adapter_result,
provider.underlying_cli,
underlying_cli_found,
);
let underlying_cli_path = provider
.underlying_cli
.and_then(|cli| find_command(cli))
.map(|p| p.display().to_string());
let default_args = command
.as_deref()
.map(|cmd| normalize_agent_args(cmd, Vec::new()))
.unwrap_or_default();
let can_auto_install = !provider.cli_install_commands.is_empty()
|| !provider.adapter_install_commands.is_empty();
let cli_hint = provider.cli_install_hint;
let adapter_hint = provider.adapter_install_hint;
let install_hint = match availability {
AcpAvailabilityStatus::Available => cli_hint.to_string(),
AcpAvailabilityStatus::CliMissing => cli_hint.to_string(),
AcpAvailabilityStatus::AdapterMissing => adapter_hint.to_string(),
AcpAvailabilityStatus::NotInstalled => {
if !cli_hint.is_empty() && !adapter_hint.is_empty() {
format!("{cli_hint} {adapter_hint}")
} else if !cli_hint.is_empty() {
cli_hint.to_string()
} else {
adapter_hint.to_string()
}
}
};
AcpProviderCatalogEntry {
id: provider.id.to_string(),
label: provider.label.to_string(),
avatar_url: provider.avatar_url.to_string(),
availability,
command,
binary_path,
default_args,
mcp_command: provider.mcp_command.map(str::to_string),
install_hint,
install_instructions_url: provider.install_instructions_url.to_string(),
can_auto_install,
underlying_cli_path,
}
})
.collect()
}
@@ -376,10 +502,13 @@ pub fn managed_agent_avatar_url(command: &str) -> Option<String> {
#[cfg(test)]
mod tests {
use std::path::PathBuf;
use super::{
find_via_login_shell, managed_agent_avatar_url, normalize_agent_args,
classify_provider, find_via_login_shell, managed_agent_avatar_url, normalize_agent_args,
CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, GOOSE_AVATAR_URL, SPROUT_AGENT_AVATAR_URL,
};
use crate::managed_agents::AcpAvailabilityStatus;
#[test]
fn resolves_known_avatar_for_bare_command() {
@@ -470,4 +599,52 @@ mod tests {
"shell lookup must not execute injected commands"
);
}
#[test]
fn classifies_available_when_adapter_found() {
let (status, cmd, path) = classify_provider(
Some(("goose", PathBuf::from("/usr/local/bin/goose"))),
None,
false,
);
assert_eq!(status, AcpAvailabilityStatus::Available);
assert_eq!(cmd.as_deref(), Some("goose"));
assert_eq!(path.as_deref(), Some("/usr/local/bin/goose"));
}
#[test]
fn classifies_adapter_missing_when_cli_present() {
let (status, cmd, path) = classify_provider(None, Some("claude"), true);
assert_eq!(status, AcpAvailabilityStatus::AdapterMissing);
assert!(cmd.is_none());
assert!(path.is_none());
}
#[test]
fn classifies_not_installed_when_nothing_found() {
let (status, cmd, path) = classify_provider(None, Some("claude"), false);
assert_eq!(status, AcpAvailabilityStatus::NotInstalled);
assert!(cmd.is_none());
assert!(path.is_none());
}
#[test]
fn classifies_not_installed_when_no_underlying_cli() {
let (status, cmd, path) = classify_provider(None, None, false);
assert_eq!(status, AcpAvailabilityStatus::NotInstalled);
assert!(cmd.is_none());
assert!(path.is_none());
}
#[test]
fn classifies_cli_missing_when_adapter_found_but_cli_absent() {
let (status, cmd, path) = classify_provider(
Some(("codex-acp", PathBuf::from("/opt/homebrew/bin/codex-acp"))),
Some("codex"),
false,
);
assert_eq!(status, AcpAvailabilityStatus::CliMissing);
assert_eq!(cmd.as_deref(), Some("codex-acp"));
assert_eq!(path.as_deref(), Some("/opt/homebrew/bin/codex-acp"));
}
}
+37 -4
View File
@@ -275,15 +275,48 @@ pub struct ManagedAgentLogResponse {
pub log_path: String,
}
#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum AcpAvailabilityStatus {
Available,
AdapterMissing,
CliMissing,
NotInstalled,
}
#[derive(Debug, Clone, Serialize)]
pub struct AcpProviderInfo {
pub struct AcpProviderCatalogEntry {
pub id: String,
pub label: String,
pub command: String,
pub binary_path: String,
pub avatar_url: String,
pub availability: AcpAvailabilityStatus,
pub command: Option<String>,
pub binary_path: Option<String>,
pub default_args: Vec<String>,
/// MCP server binary override. `None` means use the default (`sprout-mcp-server`).
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)]
+25
View File
@@ -1,3 +1,4 @@
import * as React from "react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import {
@@ -14,6 +15,7 @@ import {
discoverBackendProviders,
discoverManagedAgentPrereqs,
getManagedAgentLog,
installAcpRuntime,
listManagedAgents,
listRelayAgents,
startManagedAgent,
@@ -36,6 +38,7 @@ import {
updateTeam,
} from "@/shared/api/tauriTeams";
import type {
AcpProvider,
AgentPersona,
AgentTeam,
CreateManagedAgentInput,
@@ -105,6 +108,28 @@ export function useAcpProvidersQuery() {
});
}
export function useAvailableAcpProviders() {
const query = useAcpProvidersQuery();
const available = React.useMemo(
() =>
(query.data ?? []).filter(
(p): p is AcpProvider => p.availability === "available",
),
[query.data],
);
return { ...query, data: available };
}
export function useInstallAcpRuntimeMutation() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: (providerId: string) => installAcpRuntime(providerId),
onSettled: () => {
void queryClient.invalidateQueries({ queryKey: acpProvidersQueryKey });
},
});
}
export function useBackendProvidersQuery() {
return useQuery({
queryKey: backendProvidersQueryKey,
@@ -2,7 +2,7 @@ import { AlertTriangle } from "lucide-react";
import * as React from "react";
import {
useAcpProvidersQuery,
useAvailableAcpProviders,
useCreateChannelManagedAgentsMutation,
} from "@/features/agents/hooks";
import type { CreateChannelManagedAgentsResult } from "@/features/agents/channelAgents";
@@ -50,7 +50,7 @@ export function AddTeamToChannelDialog({
onDeployed,
}: AddTeamToChannelDialogProps) {
const channelsQuery = useChannelsQuery();
const providersQuery = useAcpProvidersQuery();
const providersQuery = useAvailableAcpProviders();
const [channelId, setChannelId] = React.useState("");
const [role, setRole] = React.useState<Exclude<ChannelRole, "owner">>("bot");
const deployMutation = useCreateChannelManagedAgentsMutation(
@@ -3,6 +3,7 @@ import * as React from "react";
import {
useAcpProvidersQuery,
useAvailableAcpProviders,
useBackendProvidersQuery,
useCreateManagedAgentMutation,
useManagedAgentPrereqsQuery,
@@ -48,7 +49,8 @@ export function CreateAgentDialog({
onOpenChange: (open: boolean) => void;
}) {
const createMutation = useCreateManagedAgentMutation();
const providersQuery = useAcpProvidersQuery();
const providersQuery = useAvailableAcpProviders();
const allProvidersQuery = useAcpProvidersQuery();
const backendProvidersQuery = useBackendProvidersQuery();
const { lastProviderId, setLastProvider } = useLastRuntimeProvider();
const [acpCommand, setAcpCommand] = React.useState("sprout-acp");
@@ -85,6 +87,10 @@ export function CreateAgentDialog({
const [probeError, setProbeError] = React.useState<string | null>(null);
const providers = providersQuery.data ?? [];
const allProviders = allProvidersQuery.data ?? [];
const unavailableCount = allProviders.filter(
(p) => p.availability !== "available",
).length;
const backendProviders = backendProvidersQuery.data ?? [];
const prereqs = prereqsQuery.data ?? null;
const selectedProvider = React.useMemo(
@@ -443,6 +449,7 @@ export function CreateAgentDialog({
providersLoading={providersQuery.isLoading}
selectedProvider={selectedProvider}
selectedProviderId={selectedProviderId}
unavailableCount={unavailableCount}
/>
) : null}
@@ -40,12 +40,14 @@ export function CreateAgentRuntimeProviderField({
providersLoading,
selectedProvider,
selectedProviderId,
unavailableCount,
onProviderChange,
}: {
providers: AcpProvider[];
providersLoading: boolean;
selectedProvider: AcpProvider | null;
selectedProviderId: string;
unavailableCount: number;
onProviderChange: (value: string) => void;
}) {
return (
@@ -86,6 +88,14 @@ export function CreateAgentRuntimeProviderField({
command in Advanced setup.
</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>;
@@ -266,6 +266,19 @@ export function PersonaDialog({
importErrorMessage,
});
const selectedProvider = providers.find((p) => p.id === provider);
const providerWarning =
selectedProvider && selectedProvider.availability !== "available" ? (
<p className="text-xs text-warning">
{selectedProvider.availability === "adapter_missing"
? `${selectedProvider.label} CLI is installed but the ACP adapter is missing.`
: selectedProvider.availability === "cli_missing"
? `${selectedProvider.label} ACP adapter is installed but the CLI is missing.`
: `${selectedProvider.label} is not installed.`}{" "}
Visit Settings &gt; Doctor to set it up.
</p>
) : null;
return (
<Dialog onOpenChange={handleOpenChange} open={open}>
<DialogContent className="max-w-2xl overflow-hidden p-0">
@@ -354,13 +367,22 @@ export function PersonaDialog({
{providers.map((p) => (
<option key={p.id} value={p.id}>
{p.label}
{p.availability === "adapter_missing"
? " (adapter missing)"
: p.availability === "cli_missing"
? " (CLI 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>
{providerWarning}
</div>
<div className="space-y-1.5">
@@ -5,7 +5,7 @@ import {
type CreateChannelManagedAgentInput,
} from "@/features/agents/channelAgents";
import {
useAcpProvidersQuery,
useAvailableAcpProviders,
usePersonasQuery,
useTeamsQuery,
} from "@/features/agents/hooks";
@@ -29,7 +29,7 @@ function toManagedBackend(
export function useApplyTemplate() {
const queryClient = useQueryClient();
const channelTemplatesQuery = useChannelTemplatesQuery();
const acpProvidersQuery = useAcpProvidersQuery();
const acpProvidersQuery = useAvailableAcpProviders();
const personasQuery = usePersonasQuery();
const teamsQuery = useTeamsQuery();
const { lastProviderId } = useLastRuntimeProvider();
@@ -4,7 +4,7 @@ import { useQueryClient } from "@tanstack/react-query";
import { useHuddle } from "@/features/huddle";
import { HuddleIndicator } from "@/features/huddle/components/HuddleIndicator";
import {
useAcpProvidersQuery,
useAvailableAcpProviders,
useBackendProvidersQuery,
useManagedAgentsQuery,
useRelayAgentsQuery,
@@ -32,7 +32,7 @@ export function ChannelMembersBar({
const { startHuddle, isStarting: isStartingHuddle } = useHuddle();
const queryClient = useQueryClient();
const membersQuery = useChannelMembersQuery(channel.id);
const providersQuery = useAcpProvidersQuery();
const providersQuery = useAvailableAcpProviders();
const backendProvidersQuery = useBackendProvidersQuery();
const managedAgentsQuery = useManagedAgentsQuery();
const relayAgentsQuery = useRelayAgentsQuery();
@@ -1,7 +1,7 @@
import * as React from "react";
import {
useAcpProvidersQuery,
useAvailableAcpProviders,
useCreateChannelManagedAgentMutation,
} from "@/features/agents/hooks";
import { resolvePersonaProvider } from "@/features/agents/lib/resolvePersonaProvider";
@@ -17,7 +17,7 @@ type QuickBotDropState = {
*/
export function useQuickBotDrop(channelId: string | null) {
const createMutation = useCreateChannelManagedAgentMutation(channelId);
const providersQuery = useAcpProvidersQuery();
const providersQuery = useAvailableAcpProviders();
const [state, setState] = React.useState<QuickBotDropState>({
pending: false,
error: null,
@@ -1,6 +1,6 @@
import { TerminalSquare } from "lucide-react";
import { useAcpProvidersQuery } from "@/features/agents/hooks";
import { useAvailableAcpProviders } from "@/features/agents/hooks";
import { Badge } from "@/shared/ui/badge";
import { Button } from "@/shared/ui/button";
import type { SetupStepActions, SetupStepState } from "./types";
@@ -15,7 +15,7 @@ type SetupStepContentProps = {
};
function useSetupStepState(): SetupStepState {
const providersQuery = useAcpProvidersQuery();
const providersQuery = useAvailableAcpProviders();
const items = providersQuery.data ?? [];
const isChecking = providersQuery.isLoading;
const errorMessage =
@@ -11,7 +11,7 @@ import * as React from "react";
import { toast } from "sonner";
import {
useAcpProvidersQuery,
useAvailableAcpProviders,
usePersonasQuery,
useTeamsQuery,
} from "@/features/agents/hooks";
@@ -295,7 +295,7 @@ function TemplateFormDialog({
const updateMutation = useUpdateChannelTemplateMutation();
const personasQuery = usePersonasQuery();
const teamsQuery = useTeamsQuery();
const providersQuery = useAcpProvidersQuery();
const providersQuery = useAvailableAcpProviders();
const providers = providersQuery.data ?? [];
const [name, setName] = React.useState("");
@@ -1,64 +1,221 @@
import * as React from "react";
import {
AlertTriangle,
CheckCircle2,
Circle,
Download,
ExternalLink,
RefreshCw,
Stethoscope,
} from "lucide-react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useAcpProvidersQuery } from "@/features/agents/hooks";
import {
useAcpProvidersQuery,
useInstallAcpRuntimeMutation,
} from "@/features/agents/hooks";
import { describeResolvedCommand } from "@/features/agents/ui/agentUi";
import type { AcpProviderCatalogEntry } from "@/shared/api/types";
import { cn } from "@/shared/lib/cn";
import { Button } from "@/shared/ui/button";
function StatusIcon({ available }: { available: boolean }) {
return available ? (
<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 "cli_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={() => void openUrl(provider.installInstructionsUrl)}
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" ||
provider.availability === "cli_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}
{provider.underlyingCliPath &&
provider.underlyingCliPath !== provider.binaryPath ? (
<div className="mt-1 space-y-0.5">
<p className="break-all font-mono text-[11px] text-muted-foreground/80">
<span className="text-muted-foreground">CLI:</span>{" "}
{provider.underlyingCliPath}
</p>
<p className="break-all font-mono text-[11px] text-muted-foreground/80">
<span className="text-muted-foreground">ACP adapter:</span>{" "}
{provider.binaryPath}
</p>
</div>
) : (
<>
<p className="mt-1 break-all font-mono text-[11px] text-muted-foreground/80">
{provider.binaryPath}
</p>
<p className="mt-1 text-[11px] text-muted-foreground/60">
ACP support built-in no separate adapter needed.
</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}
/>
</>
) : provider.availability === "cli_missing" ? (
<>
<p className="mt-1 text-sm text-muted-foreground">
ACP adapter found at{" "}
<code className="rounded bg-muted px-1 py-0.5 text-[11px]">
{provider.binaryPath ?? "unknown path"}
</code>{" "}
but the {provider.label} CLI is not installed.
</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 && provider.availability !== "available" ? (
<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!
</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>
);
@@ -68,6 +225,47 @@ export function DoctorSettingsPanel() {
const providersQuery = useAcpProvidersQuery();
const providers = providersQuery.data ?? [];
const isRefreshing = providersQuery.isFetching;
const installMutation = useInstallAcpRuntimeMutation();
const [installResults, setInstallResults] = React.useState<
Record<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 || lastStep.stdout || "unknown error"}`
: "Install failed with no output.",
},
}));
}
},
onError: (error) => {
setInstallResults((prev) => ({
...prev,
[providerId]: {
success: false,
error: error instanceof Error ? error.message : "Install failed.",
},
}));
},
});
}
return (
<section className="space-y-5" data-testid="settings-doctor">
@@ -86,6 +284,7 @@ export function DoctorSettingsPanel() {
className="shrink-0"
disabled={isRefreshing}
onClick={() => {
setInstallResults({});
void providersQuery.refetch();
}}
size="sm"
@@ -101,31 +300,35 @@ export function DoctorSettingsPanel() {
<div className="mt-5 space-y-4">
<div className="rounded-xl border border-border/70 bg-muted/20 p-4">
<h3 className="text-sm font-semibold tracking-tight">ACP runtimes</h3>
<h3 className="text-sm font-semibold tracking-tight">
Agent CLIs and ACP runtimes
</h3>
<p className="mt-1 text-sm text-muted-foreground">
Installed runtimes that the desktop app can offer in Create agent.
Installation status of supported agent CLIs and their ACP runtimes.
</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>
+72 -14
View File
@@ -39,8 +39,10 @@ import type {
CreateManagedAgentInput,
AgentModelsResponse,
UpdateManagedAgentInput,
AcpProvider,
AcpAvailabilityStatus,
AcpProviderCatalogEntry,
CommandAvailability,
InstallRuntimeResult,
OpenDmInput,
} from "@/shared/api/types";
@@ -245,13 +247,33 @@ type RawManagedAgentLog = {
log_path: string;
};
type RawAcpProvider = {
export type RawAcpProviderCatalogEntry = {
id: string;
label: string;
command: string;
binary_path: string;
avatar_url: string;
availability: AcpAvailabilityStatus;
command: string | null;
binary_path: string | null;
default_args: string[];
mcp_command: string | null;
install_hint: string;
install_instructions_url: string;
can_auto_install: boolean;
underlying_cli_path: string | null;
};
export type RawInstallStepResult = {
step: string;
command: string;
success: boolean;
stdout: string;
stderr: string;
exit_code: number | null;
};
export type RawInstallRuntimeResult = {
success: boolean;
steps: RawInstallStepResult[];
};
type RawCommandAvailability = {
@@ -850,14 +872,38 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent {
};
}
function fromRawAcpProvider(provider: RawAcpProvider): AcpProvider {
function fromRawAcpProviderCatalogEntry(
entry: RawAcpProviderCatalogEntry,
): AcpProviderCatalogEntry {
return {
id: provider.id,
label: provider.label,
command: provider.command,
binaryPath: provider.binary_path,
defaultArgs: provider.default_args,
mcpCommand: provider.mcp_command,
id: entry.id,
label: entry.label,
avatarUrl: entry.avatar_url,
availability: entry.availability,
command: entry.command,
binaryPath: entry.binary_path,
defaultArgs: entry.default_args,
mcpCommand: entry.mcp_command,
installHint: entry.install_hint,
installInstructionsUrl: entry.install_instructions_url,
canAutoInstall: entry.can_auto_install,
underlyingCliPath: entry.underlying_cli_path,
};
}
function fromRawInstallRuntimeResult(
raw: RawInstallRuntimeResult,
): InstallRuntimeResult {
return {
success: raw.success,
steps: raw.steps.map((step) => ({
step: step.step,
command: step.command,
success: step.success,
stdout: step.stdout,
stderr: step.stderr,
exitCode: step.exit_code,
})),
};
}
@@ -1013,10 +1059,22 @@ export async function getManagedAgentLog(pubkey: string, lineCount?: number) {
};
}
export async function discoverAcpProviders(): Promise<AcpProvider[]> {
return (await invokeTauri<RawAcpProvider[]>("discover_acp_providers")).map(
fromRawAcpProvider,
export async function discoverAcpProviders(): Promise<
AcpProviderCatalogEntry[]
> {
return (
await invokeTauri<RawAcpProviderCatalogEntry[]>("discover_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: {
+36 -4
View File
@@ -366,14 +366,46 @@ export type CancelManagedAgentTurnResult = {
status: "sent" | "no_active_turn";
};
export type AcpProvider = {
export type AcpAvailabilityStatus =
| "available"
| "adapter_missing"
| "cli_missing"
| "not_installed";
export type AcpProviderCatalogEntry = {
id: string;
label: string;
avatarUrl: string;
availability: AcpAvailabilityStatus;
command: string | null;
binaryPath: string | null;
defaultArgs: string[];
mcpCommand: string | null;
installHint: string;
installInstructionsUrl: string;
canAutoInstall: boolean;
underlyingCliPath: string | null;
};
/** An AcpProviderCatalogEntry that is confirmed available — command and binaryPath are non-null. */
export type AcpProvider = AcpProviderCatalogEntry & {
availability: "available";
command: string;
binaryPath: string;
defaultArgs: string[];
/** MCP server binary override, or `null` for the default (`sprout-mcp-server`). */
mcpCommand: 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 = {
+84 -33
View File
@@ -4,6 +4,10 @@ import { finalizeEvent } from "nostr-tools/pure";
import { parse as yamlParse } from "yaml";
import type { RelayEvent } from "@/shared/api/types";
import type {
RawAcpProviderCatalogEntry,
RawInstallRuntimeResult,
} from "@/shared/api/tauri";
type TestIdentity = {
privateKey: string;
@@ -11,15 +15,6 @@ type TestIdentity = {
username: string;
};
type MockAcpProvider = {
id: string;
label: string;
command: string;
binaryPath: string;
defaultArgs: string[];
mcpCommand?: string | null;
};
type MockCommandAvailability = {
available?: boolean;
command?: string;
@@ -29,7 +24,8 @@ type MockCommandAvailability = {
type E2eConfig = {
mode?: "mock" | "relay";
mock?: {
acpProviders?: MockAcpProvider[];
acpProvidersCatalog?: RawAcpProviderCatalogEntry[];
installAcpRuntimeResult?: RawInstallRuntimeResult;
managedAgentPrereqs?: {
acp?: MockCommandAvailability;
mcp?: MockCommandAvailability;
@@ -318,15 +314,6 @@ type RawManagedAgentLog = {
log_path: string;
};
type RawAcpProvider = {
id: string;
label: string;
command: string;
binary_path: string;
default_args: string[];
mcp_command: string | null;
};
type RawCommandAvailability = {
command: string;
resolved_path: string | null;
@@ -3468,39 +3455,98 @@ async function handleListRelayAgents(): Promise<RawRelayAgent[]> {
async function handleDiscoverAcpProviders(
config: E2eConfig | undefined,
): Promise<RawAcpProvider[]> {
const configuredProviders = config?.mock?.acpProviders;
if (configuredProviders) {
return configuredProviders.map((provider) => ({
id: provider.id,
label: provider.label,
command: provider.command,
binary_path: provider.binaryPath,
default_args: [...provider.defaultArgs],
mcp_command: provider.mcpCommand ?? null,
}));
): Promise<RawAcpProviderCatalogEntry[]> {
const configured = config?.mock?.acpProvidersCatalog;
if (configured) {
return configured;
}
return [
{
id: "goose",
label: "Goose",
avatar_url: "",
availability: "available",
command: "goose",
binary_path: "/usr/local/bin/goose",
default_args: ["acp"],
mcp_command: null,
install_hint: "Install Goose via the official install script.",
install_instructions_url: "https://block.github.io/goose/",
can_auto_install: true,
underlying_cli_path: null,
},
{
id: "claude",
label: "Claude Code",
avatar_url: "",
availability: "adapter_missing",
command: null,
binary_path: null,
default_args: [],
mcp_command: null,
install_hint: "Install the Claude Code ACP adapter via npm.",
install_instructions_url:
"https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp",
can_auto_install: true,
underlying_cli_path: "/usr/local/bin/claude",
},
{
id: "codex",
label: "Codex",
command: "codex-acp",
binary_path: "/usr/local/bin/codex-acp",
avatar_url: "",
availability: "not_installed",
command: null,
binary_path: null,
default_args: [],
mcp_command: null,
install_hint:
"The codex-acp adapter must be built from source. See the GitHub repo.",
install_instructions_url: "https://github.com/openai/codex",
can_auto_install: false,
underlying_cli_path: null,
},
{
id: "sprout-agent",
label: "Sprout Agent",
avatar_url: "",
availability: "available",
command: "sprout-agent",
binary_path: "/usr/local/bin/sprout-agent",
default_args: [],
mcp_command: "sprout-dev-mcp",
install_hint: "Ships with the Sprout desktop app.",
install_instructions_url: "https://github.com/block/sprout",
can_auto_install: false,
underlying_cli_path: null,
},
];
}
async function handleInstallAcpRuntime(
args: {
providerId?: string;
},
config: E2eConfig | undefined,
): Promise<RawInstallRuntimeResult> {
const configured = config?.mock?.installAcpRuntimeResult;
if (configured) {
return configured;
}
return {
success: true,
steps: [
{
step: "adapter",
command: `mock install ${args.providerId ?? "unknown"}`,
success: true,
stdout: "mock: installed successfully",
stderr: "",
exit_code: 0,
},
],
};
}
async function handleDiscoverManagedAgentPrereqs(
args: {
input?: {
@@ -4673,6 +4719,11 @@ export function maybeInstallE2eTauriMocks() {
return getRelayHttpUrl(activeConfig);
case "discover_acp_providers":
return handleDiscoverAcpProviders(activeConfig);
case "install_acp_runtime":
return handleInstallAcpRuntime(
payload as { providerId?: string },
activeConfig,
);
case "discover_backend_providers":
return [];
case "probe_backend_provider":
+1 -1
View File
@@ -197,7 +197,7 @@ test("page 2 falls back to Doctor guidance when ACP tools are not installed", as
await installMockBridge(
page,
{
acpProviders: [],
acpProvidersCatalog: [],
},
{ skipOnboardingSeed: true },
);
+1 -9
View File
@@ -35,14 +35,6 @@ export const TEST_IDENTITIES = {
type BridgeMode = "mock" | "relay";
type MockAcpProvider = {
id: string;
label: string;
command: string;
binaryPath: string;
defaultArgs: string[];
};
type MockCommandAvailability = {
available?: boolean;
command?: string;
@@ -50,7 +42,7 @@ type MockCommandAvailability = {
};
type MockBridgeOptions = {
acpProviders?: MockAcpProvider[];
acpProvidersCatalog?: Record<string, unknown>[];
managedAgentPrereqs?: {
acp?: MockCommandAvailability;
mcp?: MockCommandAvailability;