fix(desktop): clarify CLI runtime setup (#2680)

## Why

Installing the Codex, Claude, or Goose desktop app does not install the
command-line harness Buzz needs. The current UI makes that distinction
unclear, links some missing-CLI states to adapter documentation, and can
report a successful install from the installer exit code even when
runtime discovery still fails. On Windows, Buzz also invokes Goose's
Bash installer, which writes the executable somewhere Buzz does not
discover.

## What

- distinguish missing vendor CLIs from missing or outdated ACP adapters
in runtime metadata and UI guidance
- link Codex, Claude Code, and Goose missing-CLI states to their
official CLI installation documentation
- explain in Settings, onboarding, and agent configuration that the
desktop app alone is not sufficient
- use Goose's official PowerShell installer on Windows
- refresh PATH and rediscover the requested runtime after installation,
keeping the control retryable if the runtime is still unavailable
- add Rust and Playwright regression coverage for Windows installer
selection, CLI/adapter guidance, false-success prevention, verified
installs, and onboarding copy

## Risk Assessment

Medium. This changes desktop onboarding and runtime installation
behavior. Successful installs now require the runtime catalog to verify
availability; previously hidden discovery failures will surface as
actionable errors instead of a false success state.

## References

- [Codex CLI installation](https://developers.openai.com/codex/cli/)
- [Claude Code
installation](https://code.claude.com/docs/en/getting-started)
- [Goose
installation](https://goose-docs.ai/docs/getting-started/installation/)
- Follow-up to #2563 and #2587

## Validation

- `just desktop-typecheck`
- `just desktop-test` — 3,455 passed
- focused Rust post-install verification tests
- focused Playwright Doctor/onboarding coverage (in progress; CI and
local sequential rerun will provide final results)

Generated with Codex

---------

Signed-off-by: Atish Patel <atish@squareup.com>
Co-authored-by: Codex <noreply@openai.com>
Co-authored-by: Goose <opensource@block.xyz>
This commit is contained in:
Atish Patel
2026-07-24 20:21:25 -04:00
committed by GitHub
co-authored by Codex Goose
parent 8398468ec9
commit b8510ede1b
19 changed files with 502 additions and 198 deletions
+3 -1
View File
@@ -498,7 +498,9 @@ const overrides = new Map([
// route PowerShell CLI installs natively on Windows (bypasses Git Bash PATH
// poisoning that resolved GNU tar instead of bsdtar → Codex install failure).
// Includes unit tests for detection, routing, and -Command body preservation.
["src-tauri/src/commands/agent_discovery.rs", 1810],
// +16: test_powershell_command_goose_catalog_dequoted proves the \$→$ escape
// fix for the Goose Windows installer (PR #2680 interaction with #2750).
["src-tauri/src/commands/agent_discovery.rs", 1826],
// draft-persistence predicate: submit-time `loadDraft` check + inline comment
// + deps-array entry in submitMessage closes the never-persisted-boundary
// defect (Thufir Pass-3 finding). Load-bearing correctness fix; queued to
@@ -596,8 +596,6 @@ fn parse_models(raw: Option<&serde_json::Value>) -> (Vec<AcpModelEntry>, Option<
#[cfg(test)]
mod tests {
use std::collections::BTreeMap;
use super::*;
use crate::managed_agents::{BackendKind, RespondTo};
@@ -614,7 +612,8 @@ mod tests {
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
install_instructions_url: "",
cli_install_instructions_url: "",
adapter_install_instructions_url: "",
cli_install_hint: "",
adapter_install_hint: "",
skill_dir: None,
@@ -654,7 +653,7 @@ mod tests {
parallelism: 1,
system_prompt: None,
model: None,
env_vars: BTreeMap::new(),
env_vars: Default::default(),
start_on_app_launch: false,
auto_restart_on_config_change: true,
runtime_pid: None,
@@ -705,7 +704,7 @@ mod tests {
is_active: true,
source_team: None,
source_team_persona_slug: None,
env_vars: BTreeMap::new(),
env_vars: Default::default(),
respond_to: None,
respond_to_allowlist: Vec::new(),
parallelism: None,
@@ -12,6 +12,8 @@ use crate::{
relay::query_relay,
};
mod post_install_verification;
fn active_installs() -> &'static std::sync::Mutex<std::collections::HashSet<String>> {
use std::collections::HashSet;
use std::sync::{Mutex, OnceLock};
@@ -229,11 +231,10 @@ fn install_acp_runtime_blocking(runtime_id: &str) -> Result<InstallRuntimeResult
}
}
// Clear the resolve cache so the next discovery picks up new binaries.
crate::managed_agents::clear_resolve_cache();
post_install_verification::run(runtime_id, &mut steps);
Ok(InstallRuntimeResult {
success: true,
success: steps.iter().all(|step| step.success),
steps,
restarted_count: 0,
failed_restart_count: 0,
@@ -1500,17 +1501,6 @@ mod tests {
);
}
/// Goose install commands are the same on all platforms (script is Windows-aware).
#[test]
fn test_goose_install_commands_same_on_all_platforms() {
let goose = crate::managed_agents::known_acp_runtime_exact("goose").unwrap();
assert_eq!(
goose.cli_install_commands_for_os(),
goose.cli_install_commands,
"goose install commands must be identical across platforms"
);
}
/// buzz-agent has no install commands on any platform.
#[test]
fn test_buzz_agent_has_no_install_commands() {
@@ -1675,6 +1665,32 @@ mod tests {
);
}
/// Goose Windows catalog command (discovery.rs:78) must dequote to a bare pipeline
/// with a literal `$env:` prefix — no backslash before the dollar sign.
/// This proves the `\$` → `$` escape fix: post-#2750 the spawn is native and
/// PowerShell receives the body verbatim, so a residual `\` would produce
/// `\$env:CONFIGURE='false'` which is a malformed statement.
#[cfg(windows)]
#[test]
fn test_powershell_command_goose_catalog_dequoted() {
let cmd = super::install_powershell_command(
r#"powershell.exe -NoProfile -ExecutionPolicy Bypass -Command "$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex""#,
);
assert_eq!(
cmd.get_args()
.map(|a| a.to_string_lossy().into_owned())
.collect::<Vec<_>>(),
vec![
"-NoProfile",
"-ExecutionPolicy",
"Bypass",
"-Command",
"$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex",
],
"Goose catalog command must dequote with bare $env: (no backslash before $)"
);
}
// ── install retry ─────────────────────────────────────────────────────────
/// Build an `InstallStepResult` with just the fields the retry loop reads.
@@ -0,0 +1,74 @@
use crate::managed_agents::{AcpAvailabilityStatus, InstallStepResult};
pub(super) fn run(runtime_id: &str, steps: &mut Vec<InstallStepResult>) {
// Observe PATH changes and binaries added after Buzz launched.
crate::managed_agents::refresh_login_shell_path();
crate::managed_agents::clear_resolve_cache();
let availability = crate::managed_agents::discover_acp_runtime_availability(runtime_id);
if let Some(failure) = failure(runtime_id, availability) {
steps.push(failure);
}
}
fn failure(
runtime_id: &str,
availability: Option<AcpAvailabilityStatus>,
) -> Option<InstallStepResult> {
if availability == Some(AcpAvailabilityStatus::Available) {
return None;
}
let observed = availability
.map(availability_label)
.unwrap_or_else(|| "missing from the runtime catalog".to_string());
Some(InstallStepResult {
step: "verify".to_string(),
command: format!("discover {runtime_id}"),
success: false,
stdout: String::new(),
stderr: format!(
"The installer finished, but Buzz still could not use {runtime_id} (observed: {observed})."
),
exit_code: None,
hint: Some(
"Buzz requires the vendor CLI executable, not only its desktop app. If the CLI was installed while Buzz was open, restart Buzz and check again."
.to_string(),
),
})
}
fn availability_label(status: AcpAvailabilityStatus) -> String {
match status {
AcpAvailabilityStatus::Available => "available",
AcpAvailabilityStatus::AdapterMissing => "ACP adapter missing",
AcpAvailabilityStatus::AdapterOutdated => "ACP adapter outdated",
AcpAvailabilityStatus::CliMissing => "CLI missing",
AcpAvailabilityStatus::NotInstalled => "not installed",
}
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn accepts_available_runtime() {
assert!(failure("goose", Some(AcpAvailabilityStatus::Available)).is_none());
}
#[test]
fn rejects_unresolved_runtime() {
let failure = failure("goose", Some(AcpAvailabilityStatus::NotInstalled))
.expect("not-installed runtime must fail verification");
assert_eq!(failure.step, "verify");
assert!(!failure.success);
assert!(failure.stderr.contains("observed: not installed"));
assert!(failure
.hint
.as_deref()
.is_some_and(|hint| hint.contains("desktop app")));
}
}
@@ -40,7 +40,8 @@ fn test_runtime() -> &'static KnownAcpRuntime {
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
install_instructions_url: "",
cli_install_instructions_url: "",
adapter_install_instructions_url: "",
cli_install_hint: "",
adapter_install_hint: "",
skill_dir: None,
@@ -615,7 +616,8 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime {
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
install_instructions_url: "",
cli_install_instructions_url: "",
adapter_install_instructions_url: "",
cli_install_hint: "",
adapter_install_hint: "",
skill_dir: None,
+134 -107
View File
@@ -73,10 +73,13 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
mcp_hooks: false,
underlying_cli: Some("goose"),
cli_install_commands: &["curl -fsSL https://github.com/aaif-goose/goose/releases/download/stable/download_cli.sh | CONFIGURE=false bash"],
cli_install_commands_windows: &[], // goose install script is already Windows-aware
// Goose's stable release currently publishes only the Unix installer;
// its official Windows instructions intentionally point at this main-branch script.
cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"$env:CONFIGURE='false'; irm https://raw.githubusercontent.com/aaif-goose/goose/main/download_cli.ps1 | iex\""],
adapter_install_commands: &[],
install_instructions_url: "https://block.github.io/goose/",
cli_install_hint: "Install Goose via the official install script.",
cli_install_instructions_url: "https://goose-docs.ai/docs/getting-started/installation/",
adapter_install_instructions_url: "",
cli_install_hint: "Buzz requires the Goose CLI; the desktop app alone is not enough.",
adapter_install_hint: "",
skill_dir: Some(".goose/skills"),
supports_acp_model_switching: false,
@@ -106,8 +109,9 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
cli_install_commands: &["curl -fsSL https://claude.ai/install.sh | bash"],
cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://claude.ai/install.ps1 | iex\""],
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.",
cli_install_instructions_url: "https://code.claude.com/docs/en/getting-started",
adapter_install_instructions_url: "https://github.com/agentclientprotocol/claude-agent-acp",
cli_install_hint: "Buzz requires the Claude Code CLI; the desktop app alone is not enough.",
adapter_install_hint: "Install the Claude Code ACP adapter via npm.",
skill_dir: Some(".claude/skills"),
supports_acp_model_switching: false,
@@ -137,8 +141,9 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
cli_install_commands: &["curl -fsSL https://chatgpt.com/codex/install.sh | sh"],
cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://chatgpt.com/codex/install.ps1 | iex\""],
adapter_install_commands: &["npm install -g @agentclientprotocol/codex-acp"],
install_instructions_url: "https://github.com/agentclientprotocol/codex-acp",
cli_install_hint: "Install the Codex CLI via the official install script.",
cli_install_instructions_url: "https://developers.openai.com/codex/cli/",
adapter_install_instructions_url: "https://github.com/agentclientprotocol/codex-acp",
cli_install_hint: "Buzz requires the Codex CLI; the desktop app alone is not enough.",
adapter_install_hint: "Install the Codex ACP adapter via npm.",
skill_dir: Some(".codex/skills"),
supports_acp_model_switching: false,
@@ -169,7 +174,8 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
install_instructions_url: "https://github.com/block/buzz",
cli_install_instructions_url: "https://github.com/block/buzz",
adapter_install_instructions_url: "https://github.com/block/buzz",
cli_install_hint: "Ships with the Buzz desktop app.",
adapter_install_hint: "",
skill_dir: None,
@@ -1158,109 +1164,130 @@ struct PartialEntry {
entry: AcpRuntimeCatalogEntry,
}
fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntry {
let adapter_result = runtime
.commands
.iter()
.find_map(|command| find_command(command).map(|path| (*command, path)));
let underlying_cli_found = runtime
.underlying_cli
.map(|cli| find_command(cli).is_some())
.unwrap_or(false);
let (mut availability, command, binary_path) =
classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found);
// For codex-acp: when the adapter resolves as Available, probe the
// version. An adapter with major version < 1 is treated as outdated —
// the CODEX_CONFIG spawn contract requires 1.x.
if runtime.id == "codex"
&& availability == AcpAvailabilityStatus::Available
&& command.as_deref() == Some("codex-acp")
{
if let Some(path_str) = &binary_path {
availability = codex_adapter_availability(&PathBuf::from(path_str));
}
}
// Warm the adapter-availability cache for the badge fallback.
// The cache is scoped to the codex runtime; other runtimes leave it
// unchanged. Invalidated by `clear_resolve_cache`.
if runtime.id == "codex" {
cache_adapter_availability(availability.clone());
}
let underlying_cli_path = runtime
.underlying_cli
.and_then(find_command)
.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 = !runtime.cli_install_commands_for_os().is_empty()
|| !runtime.adapter_install_commands.is_empty();
let cli_hint = runtime.cli_install_hint;
let adapter_hint = runtime.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::AdapterOutdated => 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()
}
}
};
let install_instructions_url = match availability {
AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::AdapterOutdated => {
runtime.adapter_install_instructions_url
}
AcpAvailabilityStatus::Available
| AcpAvailabilityStatus::CliMissing
| AcpAvailabilityStatus::NotInstalled => runtime.cli_install_instructions_url,
};
// node_required now means Buzz cannot provide npm for this platform.
// On supported desktop platforms, Buzz downloads a private Node/npm
// runtime into app data before running npm-backed adapter installs.
let node_required = matches!(
availability,
AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled
) && runtime_needs_npm(runtime)
&& buzz_managed_node_bin_dir().is_none()
&& resolve_command("npm").is_none()
&& resolve_command("node").is_none();
PartialEntry {
runtime,
entry: AcpRuntimeCatalogEntry {
id: runtime.id.to_string(),
label: runtime.label.to_string(),
avatar_url: runtime.avatar_url.to_string(),
availability,
command,
binary_path,
default_args,
mcp_command: runtime.mcp_command.map(str::to_string),
model_env_var: runtime.model_env_var.map(str::to_string),
provider_env_var: runtime.provider_env_var.map(str::to_string),
thinking_env_var: runtime.thinking_env_var.map(str::to_string),
install_hint,
install_instructions_url: install_instructions_url.to_string(),
can_auto_install,
requires_external_cli: runtime.underlying_cli.is_some(),
underlying_cli_path,
node_required,
// Filled in by the auth-probe phase in full catalog discovery.
auth_status: AuthStatus::Unknown,
login_hint: None,
},
}
}
/// Discover one runtime's filesystem availability without running any auth probes.
///
/// Post-install verification only needs to know whether the requested runtime
/// resolves, so it should not pay the cost of authenticating every catalog entry.
pub(crate) fn discover_acp_runtime_availability(runtime_id: &str) -> Option<AcpAvailabilityStatus> {
known_acp_runtime_exact(runtime_id)
.map(discover_acp_runtime_phase1)
.map(|partial| partial.entry.availability)
}
pub fn discover_acp_runtimes() -> Vec<AcpRuntimeCatalogEntry> {
// Phase 1: build all entries (fast — no probes yet).
let mut partials: Vec<PartialEntry> = KNOWN_ACP_RUNTIMES
.iter()
.map(|runtime| {
let adapter_result = runtime
.commands
.iter()
.find_map(|command| find_command(command).map(|path| (*command, path)));
let underlying_cli_found = runtime
.underlying_cli
.map(|cli| find_command(cli).is_some())
.unwrap_or(false);
let (mut availability, command, binary_path) =
classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found);
// For codex-acp: when the adapter resolves as Available, probe the
// version. An adapter with major version < 1 is treated as outdated —
// the CODEX_CONFIG spawn contract requires 1.x.
if runtime.id == "codex"
&& availability == AcpAvailabilityStatus::Available
&& command.as_deref() == Some("codex-acp")
{
if let Some(path_str) = &binary_path {
availability = codex_adapter_availability(&PathBuf::from(path_str));
}
}
// Warm the adapter-availability cache for the badge fallback.
// The cache is scoped to the codex runtime; other runtimes leave it
// unchanged. Invalidated by `clear_resolve_cache`.
if runtime.id == "codex" {
cache_adapter_availability(availability.clone());
}
let underlying_cli_path = runtime
.underlying_cli
.and_then(find_command)
.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 = !runtime.cli_install_commands_for_os().is_empty()
|| !runtime.adapter_install_commands.is_empty();
let cli_hint = runtime.cli_install_hint;
let adapter_hint = runtime.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::AdapterOutdated => 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()
}
}
};
// node_required now means Buzz cannot provide npm for this platform.
// On supported desktop platforms, Buzz downloads a private Node/npm
// runtime into app data before running npm-backed adapter installs.
let node_required = matches!(
availability,
AcpAvailabilityStatus::AdapterMissing | AcpAvailabilityStatus::NotInstalled
) && runtime_needs_npm(runtime)
&& buzz_managed_node_bin_dir().is_none()
&& resolve_command("npm").is_none()
&& resolve_command("node").is_none();
PartialEntry {
runtime,
entry: AcpRuntimeCatalogEntry {
id: runtime.id.to_string(),
label: runtime.label.to_string(),
avatar_url: runtime.avatar_url.to_string(),
availability,
command,
binary_path,
default_args,
mcp_command: runtime.mcp_command.map(str::to_string),
model_env_var: runtime.model_env_var.map(str::to_string),
provider_env_var: runtime.provider_env_var.map(str::to_string),
thinking_env_var: runtime.thinking_env_var.map(str::to_string),
install_hint,
install_instructions_url: runtime.install_instructions_url.to_string(),
can_auto_install,
underlying_cli_path,
node_required,
// Filled in by the probe phase below.
auth_status: AuthStatus::Unknown,
login_hint: None,
},
}
})
.map(discover_acp_runtime_phase1)
.collect();
// Phase 2: run auth probes in parallel for entries that need them.
@@ -20,8 +20,10 @@ pub(crate) struct KnownAcpRuntime {
pub cli_install_commands_windows: &'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,
/// Official CLI installation documentation.
pub cli_install_instructions_url: &'static str,
/// ACP adapter installation documentation.
pub adapter_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.
@@ -78,3 +80,45 @@ impl KnownAcpRuntime {
self.cli_install_commands
}
}
#[cfg(test)]
mod tests {
use super::super::known_acp_runtime_exact;
#[test]
fn vendor_metadata_distinguishes_cli_and_adapter_guidance() {
let goose = known_acp_runtime_exact("goose").unwrap();
assert_eq!(
goose.cli_install_instructions_url,
"https://goose-docs.ai/docs/getting-started/installation/"
);
assert!(goose.adapter_install_instructions_url.is_empty());
assert!(goose.cli_install_hint.contains("desktop app alone"));
assert!(goose
.cli_install_commands_windows
.iter()
.any(|command| command.contains("raw.githubusercontent.com/aaif-goose/goose/main")));
assert!(goose
.cli_install_commands_windows
.iter()
.any(|command| command.contains("$env:CONFIGURE='false'")));
let claude = known_acp_runtime_exact("claude").unwrap();
assert_eq!(
claude.cli_install_instructions_url,
"https://code.claude.com/docs/en/getting-started"
);
assert!(claude
.adapter_install_instructions_url
.contains("claude-agent-acp"));
assert!(claude.cli_install_hint.contains("desktop app alone"));
let codex = known_acp_runtime_exact("codex").unwrap();
assert_eq!(
codex.cli_install_instructions_url,
"https://developers.openai.com/codex/cli/"
);
assert!(codex.adapter_install_instructions_url.contains("codex-acp"));
assert!(codex.cli_install_hint.contains("desktop app alone"));
}
}
@@ -1104,16 +1104,18 @@ fn test_cli_install_commands_for_os_non_empty_for_claude_codex() {
);
}
/// On Windows, Claude and Codex select the PowerShell install commands.
/// On Windows, every vendor CLI selects its official PowerShell installer.
#[cfg(windows)]
#[test]
fn test_cli_install_commands_for_os_selects_powershell_on_windows() {
let claude = super::known_acp_runtime_exact("claude").unwrap();
let codex = super::known_acp_runtime_exact("codex").unwrap();
let goose = super::known_acp_runtime_exact("goose").unwrap();
// Windows must select the PowerShell commands, not the curl|bash ones.
let claude_cmds = claude.cli_install_commands_for_os();
let codex_cmds = codex.cli_install_commands_for_os();
let goose_cmds = goose.cli_install_commands_for_os();
assert_ne!(
claude_cmds, claude.cli_install_commands,
@@ -1123,6 +1125,7 @@ fn test_cli_install_commands_for_os_selects_powershell_on_windows() {
codex_cmds, codex.cli_install_commands,
"Windows must NOT use the default curl|bash commands for codex"
);
assert_eq!(goose_cmds, goose.cli_install_commands_windows);
// Verify they are the PowerShell installers.
assert!(
@@ -1133,13 +1136,9 @@ fn test_cli_install_commands_for_os_selects_powershell_on_windows() {
codex_cmds.iter().any(|c| c.contains("powershell")),
"codex Windows install must use powershell; got: {codex_cmds:?}"
);
// Goose and buzz-agent must NOT use Windows-specific commands.
let goose = super::known_acp_runtime_exact("goose").unwrap();
assert_eq!(
goose.cli_install_commands_for_os(),
goose.cli_install_commands,
"goose must use the same commands on all platforms"
assert!(
goose_cmds.iter().any(|c| c.contains("download_cli.ps1")),
"Goose Windows install must use download_cli.ps1; got: {goose_cmds:?}"
);
}
@@ -869,7 +869,8 @@ mod tests {
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
install_instructions_url: "",
cli_install_instructions_url: "",
adapter_install_instructions_url: "",
cli_install_hint: "",
adapter_install_hint: "",
skill_dir: None,
@@ -1063,7 +1064,8 @@ mod tests {
cli_install_commands: &[],
cli_install_commands_windows: &[],
adapter_install_commands: &[],
install_instructions_url: "",
cli_install_instructions_url: "",
adapter_install_instructions_url: "",
cli_install_hint: "",
adapter_install_hint: "",
skill_dir: None,
@@ -585,6 +585,8 @@ pub struct AcpRuntimeCatalogEntry {
pub install_instructions_url: String,
/// true when at least one automated install step is available
pub can_auto_install: bool,
/// true when this runtime depends on a separately installed vendor CLI.
pub requires_external_cli: bool,
pub underlying_cli_path: Option<String>,
/// true when an npm adapter step is pending but Node.js / npm is absent.
/// The UI hides the Install button and shows a Node.js install callout.
+3 -1
View File
@@ -15,7 +15,9 @@ Plan of record: `Buzz/Harness-Provider-Model.md` in Morgan's Obsidian vault
declares each harness's model/provider/effort env keys and capabilities. Spawn
applies them; `AcpRuntimeCatalogEntry` exposes them over IPC; and
`lib/agentConfigCore.ts` projects them into field descriptors. The frontend
never maintains a rival copy of this table.
never maintains a rival copy of this table. Setup guidance follows the same
rule: `requires_external_cli` is derived from `KnownAcpRuntime` and projected
to the UI rather than inferred from a runtime ID in a component.
If you need a new capability fact (a new env key, a native option, a "supports
X" flag): add it to `KnownAcpRuntime` first, expose it on
@@ -627,8 +627,8 @@ export function AgentDefinitionDialog({
? `${selectedRuntime.label} CLI is installed but the ACP adapter is missing.`
: selectedRuntime.availability === "adapter_outdated"
? `${selectedRuntime.label} ACP adapter is outdated — reinstall to continue.`
: selectedRuntime.availability === "cli_missing"
? `${selectedRuntime.label} ACP adapter is installed but the CLI is missing.`
: selectedRuntime.requiresExternalCli
? `${selectedRuntime.label} CLI is missing. ${selectedRuntime.installHint}`
: `${selectedRuntime.label} is not installed.`}{" "}
Visit Settings &gt; Agents to set it up.
</p>
@@ -384,8 +384,11 @@ function runtimeDetailText(runtime: AcpRuntimeCatalogEntry): string {
if (runtime.availability === "adapter_outdated") {
return "ACP adapter detected but outdated — reinstall required.";
}
if (runtime.availability === "cli_missing") {
return "ACP adapter detected; CLI missing.";
if (
runtime.availability === "cli_missing" ||
runtime.availability === "not_installed"
) {
return "CLI not detected; the desktop app alone isnt enough.";
}
return "";
}
@@ -605,8 +608,8 @@ function RuntimeProvidersSection({
Set up your agent harnesses
</h1>
<p className="mx-auto mt-3 max-w-[760px] text-sm leading-6 text-foreground/90">
Buzz detected the harnesses available on this machine. Install or sign
in to at least one to continue.
Buzz checks for command-line harnesses on this machine. Install the
CLI or sign in to at least one to continue.
</p>
</div>
@@ -629,8 +632,8 @@ function RuntimeProvidersSection({
className="max-w-[560px] rounded-2xl bg-white/70 px-6 py-6 text-sm text-muted-foreground"
data-testid="onboarding-acp-empty"
>
No supported agent harnesses were detected yet. Install Claude Code
or Codex, then check again.
No supported command-line harnesses were detected yet. Install a
supported CLI, then check again.
</p>
)}
@@ -53,6 +53,13 @@ const RUNTIME_SORT_PRIORITY: Record<string, number> = {
goose: 1,
};
function runtimeInstallGuideLabel(runtime: AcpRuntimeCatalogEntry) {
return runtime.availability === "adapter_missing" ||
runtime.availability === "adapter_outdated"
? "Adapter install guide"
: "CLI setup guide";
}
function RuntimeLogo({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
const avatarUrl = RUNTIME_LOGO_URLS[runtime.id] ?? runtime.avatarUrl;
@@ -131,7 +138,7 @@ function RuntimeOverflowMenu({
onSelect={() => void openUrl(runtime.installInstructionsUrl)}
>
<ExternalLink className="h-4 w-4" />
Instructions
{runtimeInstallGuideLabel(runtime)}
</DropdownMenuItem>
) : null}
</DropdownMenuContent>
@@ -142,7 +149,6 @@ function RuntimeOverflowMenu({
function RuntimeActions({
authMethods,
connectingMethodId,
installSuccess,
isConnecting,
isInstalling,
onConnect,
@@ -151,7 +157,6 @@ function RuntimeActions({
}: {
authMethods: AcpAuthMethod[];
connectingMethodId: string | null;
installSuccess: boolean;
isConnecting: boolean;
isInstalling: boolean;
onConnect: (method: AcpAuthMethod) => void;
@@ -160,7 +165,6 @@ function RuntimeActions({
}) {
const isAvailable = runtime.availability === "available";
const canInstall = runtime.canAutoInstall && !runtime.nodeRequired;
const isOn = isAvailable || installSuccess;
const isWorking = isInstalling || isConnecting;
return (
@@ -183,10 +187,10 @@ function RuntimeActions({
) : (
<Switch
aria-label={`${runtime.label} availability`}
checked={isOn}
checked={isAvailable}
className="disabled:cursor-default disabled:opacity-100"
data-testid={`doctor-runtime-toggle-${runtime.id}`}
disabled={isAvailable || installSuccess || !canInstall}
disabled={isAvailable || !canInstall}
onCheckedChange={(checked) => {
if (checked) {
onInstall();
@@ -206,7 +210,8 @@ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
? "Adapter needed"
: runtime.availability === "adapter_outdated"
? "Update needed"
: runtime.availability === "cli_missing"
: runtime.availability === "cli_missing" ||
runtime.availability === "not_installed"
? "CLI needed"
: null;
@@ -239,7 +244,6 @@ function RuntimeStatusChip({ runtime }: { runtime: AcpRuntimeCatalogEntry }) {
function RuntimeHeader({
authMethods,
connectingMethodId,
installSuccess,
isConnecting,
isInstalling,
onConnect,
@@ -248,7 +252,6 @@ function RuntimeHeader({
}: {
authMethods: AcpAuthMethod[];
connectingMethodId: string | null;
installSuccess: boolean;
isConnecting: boolean;
isInstalling: boolean;
onConnect: (method: AcpAuthMethod) => void;
@@ -267,7 +270,6 @@ function RuntimeHeader({
<RuntimeActions
authMethods={authMethods}
connectingMethodId={connectingMethodId}
installSuccess={installSuccess}
isConnecting={isConnecting}
isInstalling={isInstalling}
onConnect={onConnect}
@@ -305,7 +307,6 @@ function RuntimeRow({
}, [resetEpoch]);
const isInstalling = installMutation.isPending;
const installError = installResult?.error ?? null;
const installSuccess = installResult?.success ?? false;
function handleInstall() {
setInstallResult(null);
@@ -362,7 +363,6 @@ function RuntimeRow({
<RuntimeHeader
authMethods={authMethods}
connectingMethodId={connectMutation.variables?.methodId ?? null}
installSuccess={installSuccess}
isConnecting={connectMutation.isPending}
isInstalling={isInstalling}
onConnect={(method) => {
@@ -391,6 +391,25 @@ function RuntimeRow({
runtime={runtime}
/>
{runtime.availability !== "available" ? (
<div
className="mt-2 flex flex-wrap items-center justify-between gap-2 text-sm text-muted-foreground"
data-testid={`doctor-runtime-guidance-${runtime.id}`}
>
<p>{runtime.installHint}</p>
{runtime.installInstructionsUrl.trim().length > 0 ? (
<button
className="inline-flex shrink-0 items-center gap-1 underline-offset-2 hover:text-foreground hover:underline"
onClick={() => void openUrl(runtime.installInstructionsUrl)}
type="button"
>
<ExternalLink className="h-4 w-4" />
{runtimeInstallGuideLabel(runtime)}
</button>
) : null}
</div>
) : null}
{runtime.authStatus.status === "config_invalid" ? (
<p
className="mt-2 whitespace-pre-line rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive"
@@ -400,11 +419,6 @@ function RuntimeRow({
</p>
) : null}
{installSuccess && runtime.availability !== "available" ? (
<p className="mt-2 rounded-lg border border-green-500/30 bg-green-500/10 px-3 py-1.5 text-sm text-green-700 dark:text-green-400">
{runtime.label} installed. Checking for sign-in options...
</p>
) : null}
{installError ? (
<p
className="mt-2 whitespace-pre-line rounded-lg border border-destructive/30 bg-destructive/10 px-3 py-1.5 text-sm text-destructive"
+3
View File
@@ -184,6 +184,8 @@ export type RawAcpRuntimeCatalogEntry = {
install_hint: string;
install_instructions_url: string;
can_auto_install: boolean;
/** Optional only for older E2E fixtures; the Rust catalog always supplies it. */
requires_external_cli?: boolean;
underlying_cli_path: string | null;
node_required: boolean;
/** Tagged union with snake_case status values — same shape as `AuthStatus`. */
@@ -743,6 +745,7 @@ function fromRawAcpRuntimeCatalogEntry(
installHint: entry.install_hint,
installInstructionsUrl: entry.install_instructions_url,
canAutoInstall: entry.can_auto_install,
requiresExternalCli: entry.requires_external_cli ?? false,
underlyingCliPath: entry.underlying_cli_path,
nodeRequired: entry.node_required,
authStatus: entry.auth_status,
+2
View File
@@ -548,6 +548,8 @@ export type AcpRuntimeCatalogEntry = {
installHint: string;
installInstructionsUrl: string;
canAutoInstall: boolean;
/** True when the runtime depends on a separately installed vendor CLI. */
requiresExternalCli: boolean;
underlyingCliPath: string | null;
/** True when an npm adapter step is pending but Node.js / npm is absent. */
nodeRequired: boolean;
+4
View File
@@ -6972,6 +6972,7 @@ async function handleDiscoverAcpRuntimes(
install_hint: "Install Goose via the official install script.",
install_instructions_url: "https://block.github.io/goose/",
can_auto_install: true,
requires_external_cli: true,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "not_applicable" },
@@ -6990,6 +6991,7 @@ async function handleDiscoverAcpRuntimes(
install_instructions_url:
"https://www.npmjs.com/package/@anthropic-ai/claude-agent-acp",
can_auto_install: true,
requires_external_cli: true,
underlying_cli_path: "/usr/local/bin/claude",
node_required: false,
auth_status: { status: "unknown" },
@@ -7008,6 +7010,7 @@ async function handleDiscoverAcpRuntimes(
"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,
requires_external_cli: true,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "unknown" },
@@ -7025,6 +7028,7 @@ async function handleDiscoverAcpRuntimes(
install_hint: "Ships with the Buzz desktop app.",
install_instructions_url: "https://github.com/block/buzz",
can_auto_install: false,
requires_external_cli: false,
underlying_cli_path: null,
node_required: false,
auth_status: { status: "not_applicable" },
+106 -31
View File
@@ -22,7 +22,8 @@ const GOOSE_AVAILABLE = {
default_args: ["acp"],
mcp_command: null,
install_hint: "",
install_instructions_url: "https://block.github.io/goose/",
install_instructions_url:
"https://goose-docs.ai/docs/getting-started/installation/",
can_auto_install: false,
underlying_cli_path: null,
node_required: false,
@@ -82,8 +83,9 @@ const CODEX_NOT_INSTALLED = {
binary_path: null,
default_args: [],
mcp_command: null,
install_hint: "Install the Codex CLI, then install the ACP adapter via npm.",
install_instructions_url: "https://github.com/zed-industries/codex-acp",
install_hint:
"Buzz requires the Codex CLI; the desktop app alone is not enough.",
install_instructions_url: "https://developers.openai.com/codex/cli/",
can_auto_install: true,
underlying_cli_path: null,
node_required: false,
@@ -160,7 +162,7 @@ test.describe("Doctor panel state screenshots", () => {
),
),
);
expect(new Set(rowHeights).size).toBe(1);
expect(rowHeights[2]).toBeGreaterThan(rowHeights[0]);
const [gooseColors, codexColors] = await Promise.all(
["goose", "codex"].map((runtimeId) =>
page.getByTestId(`doctor-runtime-${runtimeId}`).evaluate((element) => {
@@ -194,11 +196,11 @@ test.describe("Doctor panel state screenshots", () => {
await expect(toggle.locator("span")).toHaveClass(/shadow-none/);
}
await expect(
page.getByRole("menuitem", { name: "Instructions" }),
page.getByRole("menuitem", { name: "CLI setup guide" }),
).toHaveCount(0);
await page.getByTestId("doctor-runtime-menu-codex").click();
await expect(
page.getByRole("menuitem", { name: "Instructions" }),
page.getByRole("menuitem", { name: "CLI setup guide" }),
).toBeVisible();
await waitForAnimations(page);
await page.screenshot({
@@ -212,6 +214,17 @@ test.describe("Doctor panel state screenshots", () => {
await expect(page.getByTestId("doctor-runtime-codex")).not.toContainText(
"Not installed",
);
await expect(page.getByTestId("doctor-runtime-status-codex")).toHaveText(
"CLI needed",
);
await expect(
page.getByTestId("doctor-runtime-guidance-codex"),
).toContainText("desktop app alone is not enough");
await expect(
page
.getByTestId("doctor-runtime-guidance-codex")
.getByRole("button", { name: "CLI setup guide" }),
).toBeVisible();
await runtimeList.scrollIntoViewIfNeeded();
await waitForAnimations(page);
@@ -287,7 +300,7 @@ test.describe("Doctor panel state screenshots", () => {
);
await page.getByTestId("doctor-runtime-menu-codex").click();
await expect(
page.getByRole("menuitem", { name: "Instructions" }),
page.getByRole("menuitem", { name: "CLI setup guide" }),
).toBeVisible();
await page.keyboard.press("Escape");
@@ -331,7 +344,7 @@ test.describe("Doctor panel state screenshots", () => {
);
await page.getByTestId("doctor-runtime-menu-claude").click();
await expect(
page.getByRole("menuitem", { name: "Instructions" }),
page.getByRole("menuitem", { name: "CLI setup guide" }),
).toBeVisible();
await page.keyboard.press("Escape");
@@ -354,8 +367,9 @@ test.describe("Doctor panel state screenshots", () => {
availability: "adapter_missing",
underlying_cli_path: "/usr/local/bin/codex",
node_required: true,
install_hint:
"Install the Codex ACP adapter: npm install -g @zed-industries/codex-acp",
install_hint: "Install the Codex ACP adapter via npm.",
install_instructions_url:
"https://github.com/agentclientprotocol/codex-acp",
},
BUZZ_AGENT_AVAILABLE,
],
@@ -373,16 +387,20 @@ test.describe("Doctor panel state screenshots", () => {
"Adapter needed",
);
await expect(row).not.toContainText("Node.js is required");
await expect(row).toHaveCSS(
"height",
expect(
await row.evaluate((element) => element.getBoundingClientRect().height),
).toBeGreaterThan(
await page
.getByTestId("doctor-runtime-goose")
.evaluate((element) => getComputedStyle(element).height),
.evaluate((element) => element.getBoundingClientRect().height),
);
await page.getByTestId("doctor-runtime-menu-codex").click();
await expect(
page.getByRole("menuitem", { name: "Install Node.js" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Adapter install guide" }),
).toBeVisible();
await page.keyboard.press("Escape");
await row.scrollIntoViewIfNeeded();
@@ -395,9 +413,10 @@ test.describe("Doctor panel state screenshots", () => {
*
* The mock is configured with a two-call sequence:
* call 1 failure (E404)
* call 2 success
* call 2 installer exit 0, but post-install discovery still cannot find
* the runtime
* This exercises the full retry path: fail state toggle on again
* success banner.
* verified failure without a false installed state.
*/
test("05-retry-after-failure", async ({ page }) => {
await installMockBridge(page, {
@@ -427,16 +446,26 @@ test.describe("Doctor panel state screenshots", () => {
],
},
{
success: true,
success: false,
steps: [
{
step: "adapter",
command: "npm install -g @zed-industries/codex-acp",
step: "cli",
command: "powershell.exe install codex",
success: true,
stdout: "added 1 package",
stdout: "installed",
stderr: "",
exit_code: 0,
},
{
step: "verify",
command: "discover codex",
success: false,
stdout: "",
stderr:
"The installer finished, but Buzz still could not use codex (observed: NotInstalled).",
exit_code: null,
hint: "Buzz requires the vendor CLI executable, not only its desktop app. If the CLI was installed while Buzz was open, restart Buzz and check again.",
},
],
},
],
@@ -470,25 +499,71 @@ test.describe("Doctor panel state screenshots", () => {
await waitForAnimations(page);
await row.screenshot({ path: `${SHOTS}/05-retry-after-failure.png` });
// Toggle on again — the mock returns success on the second call.
// Toggle on again — the install command exits 0, but verification fails.
await toggle.click();
await expect(loading).toBeVisible();
await expect(toggle).toHaveCount(0);
// The error disappears, then the success banner and on state render.
// The runtime remains retryable and never renders a false success state.
await expect(loading).toHaveCount(0, { timeout: 5_000 });
await expect(row).not.toContainText("failed", { timeout: 5_000 });
await expect(
row.getByText("Codex installed. Checking for sign-in options..."),
).toBeVisible({
timeout: 10_000,
});
await expect(toggle).toBeChecked();
await expect(toggle).toBeDisabled();
await expect(row).toContainText("desktop app", { timeout: 5_000 });
await expect(row).toContainText('Step "verify" failed');
await expect(row.getByText(/installed\. Checking/)).toHaveCount(0);
await expect(toggle).not.toBeChecked();
await expect(toggle).toBeEnabled();
await row.scrollIntoViewIfNeeded();
await waitForAnimations(page);
await row.screenshot({ path: `${SHOTS}/05-retry-success.png` });
await row.screenshot({ path: `${SHOTS}/05-verification-failure.png` });
});
test("05b-verified-install-enables-runtime", async ({ page }) => {
await installMockBridge(page, {
acpRuntimesCatalog: [
GOOSE_AVAILABLE,
CLAUDE_AVAILABLE_LOGGED_IN,
CODEX_NOT_INSTALLED,
BUZZ_AGENT_AVAILABLE,
],
acpRuntimesCatalogAfterInstall: [
GOOSE_AVAILABLE,
CLAUDE_AVAILABLE_LOGGED_IN,
{
...CODEX_NOT_INSTALLED,
availability: "available",
command: "codex-acp",
binary_path: "/usr/local/bin/codex-acp",
underlying_cli_path: "/usr/local/bin/codex",
auth_status: { status: "logged_in" },
},
BUZZ_AGENT_AVAILABLE,
],
installAcpRuntimeResult: {
success: true,
steps: [
{
step: "adapter",
command: "npm install -g @agentclientprotocol/codex-acp",
success: true,
stdout: "added 1 package",
stderr: "",
exit_code: 0,
},
],
},
});
await page.goto("/", { waitUntil: "domcontentloaded" });
await openSettings(page, "agents");
const toggle = page.getByTestId("doctor-runtime-toggle-codex");
await expect(toggle).toBeEnabled();
await toggle.click();
await expect(toggle).toBeChecked({ timeout: 5_000 });
await expect(toggle).toBeDisabled();
await expect(page.getByTestId("doctor-runtime-guidance-codex")).toHaveCount(
0,
);
});
/**
@@ -590,7 +665,7 @@ test.describe("Doctor panel state screenshots", () => {
);
await page.getByTestId("doctor-runtime-menu-claude").click();
await expect(
page.getByRole("menuitem", { name: "Instructions" }),
page.getByRole("menuitem", { name: "CLI setup guide" }),
).toBeVisible();
await expect(
page.getByRole("menuitem", { name: "Sign in with ChatGPT" }),
@@ -84,6 +84,40 @@ test("setup shows only Claude Code and Codex as detected harnesses", async ({
await expect(page.getByRole("checkbox")).toHaveCount(0);
});
test("setup distinguishes a missing CLI from an installed desktop app", async ({
page,
}) => {
await installMockBridge(
page,
{
acpRuntimesCatalog: [
runtime(
"codex",
"not_installed",
{ status: "unknown" },
{
install_hint:
"Buzz requires the Codex CLI; the desktop app alone is not enough.",
install_instructions_url:
"https://developers.openai.com/codex/cli/",
},
),
],
},
{ skipCommunitySeed: true, skipOnboardingSeed: true },
);
await page.goto("/");
await navigateToSetupPage(page);
const card = page.getByTestId("onboarding-runtime-codex");
await expect(card).toContainText(
"CLI not detected; the desktop app alone isnt enough.",
);
await expect(card.getByTestId("onboarding-runtime-install-codex")).toHaveText(
"INSTALL",
);
});
test("ready state is detected and enables Next without persisting a default", async ({
page,
}) => {