From d60955065cd44e5531a2bc784430603bc18d6fb0 Mon Sep 17 00:00:00 2001 From: Som Samantray Date: Sun, 26 Jul 2026 12:10:58 +0530 Subject: [PATCH] feat(desktop): add Cursor CLI native ACP runtime Signed-off-by: Som Samantray --- crates/buzz-acp/src/acp.rs | 29 +- crates/buzz-acp/src/config.rs | 50 +- crates/buzz-acp/src/lib.rs | 48 +- crates/buzz-acp/src/pool.rs | 32 +- desktop/src-tauri/src/commands/agent_auth.rs | 176 ++++++- .../src-tauri/src/commands/agent_config.rs | 36 +- .../src/commands/agent_model_process.rs | 7 +- .../src-tauri/src/commands/agent_models.rs | 17 +- .../config_bridge/reader_tests.rs | 4 + .../src/managed_agents/cursor_launch.rs | 292 ++++++++++++ .../src-tauri/src/managed_agents/discovery.rs | 61 ++- .../discovery/cursor_runtime.rs | 40 ++ .../discovery/runtime_metadata.rs | 18 + .../src-tauri/src/managed_agents/env_vars.rs | 1 + .../src/managed_agents/env_vars/tests.rs | 1 + desktop/src-tauri/src/managed_agents/mod.rs | 6 + .../src-tauri/src/managed_agents/readiness.rs | 9 + .../src/managed_agents/readiness/cli_login.rs | 51 ++- .../src-tauri/src/managed_agents/runtime.rs | 38 +- desktop/src-tauri/src/managed_agents/types.rs | 5 + desktop/src/features/agents/AGENTS.md | 17 +- .../src/features/agents/ui/ModelPicker.tsx | 8 +- .../onboarding/ui/agentReadiness.test.mjs | 13 + .../features/onboarding/ui/agentReadiness.ts | 6 +- desktop/src/shared/api/tauri.ts | 4 + desktop/src/shared/api/types.ts | 4 + docs/cursor-cli.md | 26 ++ ...1-feat-cursor-cli-native-acp-oauth-plan.md | 430 ++++++++++++++++++ 28 files changed, 1287 insertions(+), 142 deletions(-) create mode 100644 desktop/src-tauri/src/managed_agents/cursor_launch.rs create mode 100644 desktop/src-tauri/src/managed_agents/discovery/cursor_runtime.rs create mode 100644 docs/cursor-cli.md create mode 100644 docs/plans/2026-07-26-001-feat-cursor-cli-native-acp-oauth-plan.md diff --git a/crates/buzz-acp/src/acp.rs b/crates/buzz-acp/src/acp.rs index 78db7ff718..b3d657dff9 100644 --- a/crates/buzz-acp/src/acp.rs +++ b/crates/buzz-acp/src/acp.rs @@ -20,6 +20,22 @@ use crate::usage::{TurnUsage, UsageTracker}; /// Lines exceeding this limit are rejected to prevent OOM from rogue agents. const MAX_LINE_SIZE: usize = 10_000_000; // 10 MB +const BUZZ_CHILD_SECRET_ENV_KEYS: &[&str] = &[ + "BUZZ_PRIVATE_KEY", + "BUZZ_ACP_PRIVATE_KEY", + "BUZZ_RELAY_URL", + "BUZZ_AUTH_TAG", + "BUZZ_API_TOKEN", + "BUZZ_ACP_API_TOKEN", + "NOSTR_PRIVATE_KEY", +]; + +fn scrub_buzz_child_secrets(command: &mut tokio::process::Command) { + for key in BUZZ_CHILD_SECRET_ENV_KEYS { + command.env_remove(key); + } +} + /// An MCP server configuration passed to `session/new`. /// /// Corresponds to the `McpServerStdio` variant in the ACP schema. @@ -417,12 +433,18 @@ impl AcpClient { cmd.args(args) .stdin(Stdio::piped()) .stdout(Stdio::piped()) - // Inherit stderr so agent logs are visible in the harness terminal. - .stderr(Stdio::inherit()) + // Vendor stderr may contain OAuth URLs, device codes, or account + // diagnostics. Keep it out of Buzz logs; login is launched in a + // visible terminal by the desktop auth flow. + .stderr(Stdio::null()) // Ensure the child is killed when the AcpClient is dropped (best-effort). // Callers MUST still call shutdown().await for guaranteed cleanup. .kill_on_drop(true); + // Relay keys and Buzz auth material are delivered only to Buzz's MCP + // server through ACP session/new, never through the vendor child. + scrub_buzz_child_secrets(&mut cmd); + // Per-persona env vars (e.g., GOOSE_PROVIDER, BUZZ_AGENT_PROVIDER). // For most keys, operator precedence wins: skip injection if already set // in the parent environment. @@ -448,6 +470,9 @@ impl AcpClient { let codex_merge_active = codex_config_value.is_some(); for (key, value) in extra_env { + if BUZZ_CHILD_SECRET_ENV_KEYS.contains(&key.as_str()) { + continue; + } if key == "CODEX_CONFIG" && codex_merge_active { // Handled by build_codex_config_env; skip here to avoid double-setting. continue; diff --git a/crates/buzz-acp/src/config.rs b/crates/buzz-acp/src/config.rs index a38d6faa14..d713b6ec0d 100644 --- a/crates/buzz-acp/src/config.rs +++ b/crates/buzz-acp/src/config.rs @@ -199,6 +199,11 @@ pub struct AuthAgentArgs { value_delimiter = ',' )] pub agent_args: Vec, + + /// Lossless argv transport used by desktop launchers. The legacy comma + /// separated variable remains supported for backwards compatibility. + #[arg(long, env = "BUZZ_ACP_AGENT_ARGS_JSON")] + pub agent_args_json: Option, } /// CLI args for `buzz-acp auth-methods` — query adapter-advertised login methods. @@ -258,6 +263,10 @@ pub struct CliArgs { )] pub agent_args: Vec, + /// Lossless argv transport used when an argument can contain commas. + #[arg(long, env = "BUZZ_ACP_AGENT_ARGS_JSON")] + pub agent_args_json: Option, + #[arg(long, env = "BUZZ_ACP_MCP_COMMAND", default_value = "")] pub mcp_command: String, @@ -702,6 +711,24 @@ pub fn normalize_agent_args(command: &str, agent_args: Vec) -> Vec, + json_args: Option, +) -> Vec { + let Some(json_args) = json_args else { + return legacy_args; + }; + match serde_json::from_str::>(&json_args) { + Ok(args) => args, + Err(error) => { + tracing::warn!(%error, "ignoring malformed BUZZ_ACP_AGENT_ARGS_JSON"); + legacy_args + } + } +} + /// Propagate legacy env-var aliases to their canonical names. /// /// Must be called **before** the tokio runtime starts — i.e. from the sync @@ -810,7 +837,10 @@ impl Config { )); } - let agent_args = normalize_agent_args(&agent_command, args.agent_args); + let agent_args = normalize_agent_args( + &agent_command, + agent_args_from_transport(args.agent_args, args.agent_args_json), + ); if let Some(ref channels) = args.channels { for ch in channels { @@ -1492,6 +1522,24 @@ mod tests { ); } + #[test] + fn decodes_lossless_agent_args_transport() { + let encoded = serde_json::to_string(&vec![ + "--model".to_string(), + "provider/model,with-comma".to_string(), + "acp".to_string(), + ]) + .expect("encode args"); + assert_eq!( + agent_args_from_transport(vec!["broken".into()], Some(encoded)), + vec!["--model", "provider/model,with-comma", "acp"] + ); + assert_eq!( + agent_args_from_transport(vec!["legacy".into()], Some("not-json".into())), + vec!["legacy"] + ); + } + #[test] fn normalize_agent_command_identity_variants() { assert_eq!(normalize_agent_command_identity("goose"), "goose"); diff --git a/crates/buzz-acp/src/lib.rs b/crates/buzz-acp/src/lib.rs index 0230ea0875..2e263fcd08 100644 --- a/crates/buzz-acp/src/lib.rs +++ b/crates/buzz-acp/src/lib.rs @@ -964,23 +964,28 @@ fn handle_switch_model_control( .any(|m| m.channel_id == Some(channel_id)); let status = if turn_in_flight { - // Busy path: deliver over the oneshot. `false` means the oneshot was - // already consumed this turn (a prior cancel/interrupt) — the turn is - // already ending, so the switch cannot land on it. - if signal_in_flight_task( - pool, - channel_id, - ControlSignal::SwitchModel(model_id.to_string()), - ) { - "sent" + if pool.startup_model_only() { + "unsupported_model" } else { - "turn_ending" + // Busy path: deliver over the oneshot. `false` means the oneshot was + // already consumed this turn (a prior cancel/interrupt) — the turn is + // already ending, so the switch cannot land on it. + if signal_in_flight_task( + pool, + channel_id, + ControlSignal::SwitchModel(model_id.to_string()), + ) { + "sent" + } else { + "turn_ending" + } } } else { // Idle path: validate against the cached catalog before invalidating. match pool.switch_idle_agent_model(channel_id, model_id) { IdleSwitchResult::Switched => "switched", IdleSwitchResult::UnsupportedModel => "unsupported_model", + IdleSwitchResult::UnsupportedRuntime => "unsupported_model", IdleSwitchResult::NoIdleAgent => "no_active_turn", } }; @@ -1315,7 +1320,9 @@ async fn tokio_main() -> Result<()> { } let mut pool = if config.lazy_pool { - AgentPool::from_slots((0..config.agents).map(|_| None).collect()) + let mut pool = AgentPool::from_slots((0..config.agents).map(|_| None).collect()); + pool.set_startup_model_only(pool::startup_model_only_command(&config.agent_command)); + pool } else { initialize_agent_pool(&PoolStartup::from_config(&config, observer.clone()), None).await? }; @@ -1793,6 +1800,7 @@ async fn tokio_main() -> Result<()> { model_capabilities: None, desired_model: config.model.clone(), model_overridden: false, + startup_model_only: pool::startup_model_only_command(&config.agent_command), agent_name, goose_system_prompt_supported: None, protocol_version, @@ -3799,6 +3807,9 @@ async fn initialize_agent_pool( model_capabilities: None, desired_model: startup.model.clone(), model_overridden: false, + startup_model_only: pool::startup_model_only_command( + &startup.agent_command, + ), agent_name, goose_system_prompt_supported: None, protocol_version, @@ -3837,7 +3848,9 @@ async fn initialize_agent_pool( ); } tracing::info!("agent_pool_ready agents={}", live_count); - Ok(AgentPool::from_slots(agent_slots)) + let mut pool = AgentPool::from_slots(agent_slots); + pool.set_startup_model_only(pool::startup_model_only_command(&startup.agent_command)); + Ok(pool) } // ── spawn_and_init ──────────────────────────────────────────────────────────── @@ -3883,7 +3896,10 @@ async fn spawn_and_init( } async fn spawn_auth_client(agent: &AuthAgentArgs) -> Result { - let agent_args = config::normalize_agent_args(&agent.agent_command, agent.agent_args.clone()); + let agent_args = config::normalize_agent_args( + &agent.agent_command, + config::agent_args_from_transport(agent.agent_args.clone(), agent.agent_args_json.clone()), + ); AcpClient::spawn(&agent.agent_command, &agent_args, &[], false).await } @@ -4005,7 +4021,10 @@ async fn run_authenticate(args: AuthenticateArgs) -> Result<()> { async fn run_models(args: ModelsArgs) -> Result<()> { use acp::{extract_model_config_options, extract_model_state}; - let agent_args = config::normalize_agent_args(&args.agent.agent_command, args.agent.agent_args); + let agent_args = config::normalize_agent_args( + &args.agent.agent_command, + config::agent_args_from_transport(args.agent.agent_args, args.agent.agent_args_json), + ); let cwd = std::env::current_dir() .unwrap_or_else(|_| std::path::PathBuf::from("/")) .to_string_lossy() @@ -5182,6 +5201,7 @@ mod error_outcome_emission_tests { model_capabilities: None, desired_model: None, model_overridden: false, + startup_model_only: false, agent_name: "unknown".into(), goose_system_prompt_supported: None, // Error branches under test never read this; 1 is the legacy diff --git a/crates/buzz-acp/src/pool.rs b/crates/buzz-acp/src/pool.rs index cc537f8683..166d23342e 100644 --- a/crates/buzz-acp/src/pool.rs +++ b/crates/buzz-acp/src/pool.rs @@ -40,6 +40,13 @@ use crate::queue::{ }; use crate::relay::{ChannelInfo, RestClient}; +pub(crate) fn startup_model_only_command(command: &str) -> bool { + matches!( + crate::config::normalize_agent_command_identity(command).as_str(), + "agent" | "cursor-agent" | "wsl" + ) +} + /// Window within which agent activity before a hard-cap death qualifies /// the turn as "recently active" (eligible for requeue instead of dead-letter). const RECENT_ACTIVITY_WINDOW: Duration = Duration::from_secs(60); @@ -160,6 +167,9 @@ pub struct OwnedAgent { /// desktop reader to distinguish a genuine runtime override from a stale /// session whose persona model was edited. Reset on spawn/restart. pub model_overridden: bool, + /// Cursor pins the model in its process argv; ACP live switching is not + /// supported for that runtime. + pub startup_model_only: bool, /// Normalized agent name from initialize (`agentInfo.name`/`serverInfo.name`). pub agent_name: String, /// Whether Goose accepted its custom system-prompt method. `None` probes on @@ -215,6 +225,7 @@ pub struct AgentPool { result_rx: mpsc::UnboundedReceiver, pub join_set: JoinSet<()>, task_map: HashMap, + startup_model_only: bool, } /// Result returned by a completed prompt task. @@ -540,15 +551,26 @@ impl AgentPool { /// the index invariant. pub fn from_slots(slots: Vec>) -> Self { let (result_tx, result_rx) = mpsc::unbounded_channel(); + let startup_model_only = slots.iter().flatten().any(|agent| agent.startup_model_only); Self { agents: slots, result_tx, result_rx, join_set: JoinSet::new(), task_map: HashMap::new(), + startup_model_only, } } + /// True when the vendor runtime can only apply a model at process start. + pub fn startup_model_only(&self) -> bool { + self.startup_model_only + } + + pub fn set_startup_model_only(&mut self, enabled: bool) { + self.startup_model_only = enabled; + } + /// Try to claim an idle agent for the given channel (or heartbeat if `None`). /// /// Pass 1: prefer an agent that already has a session for `channel_id`. @@ -743,6 +765,10 @@ impl AgentPool { return IdleSwitchResult::NoIdleAgent; }; + if agent.startup_model_only { + return IdleSwitchResult::UnsupportedRuntime; + } + // Pre-cancel guard against the cached catalog. None = catalog not yet // populated (no session ever created); defer validation to apply time. if let Some(caps) = agent.model_capabilities.as_ref() { @@ -770,6 +796,8 @@ pub enum IdleSwitchResult { /// Desired model is not in the agent's cached catalog — pick rejected, /// session untouched. UnsupportedModel, + /// The runtime accepts a model only as a process-start argument. + UnsupportedRuntime, /// No idle agent available (all checked out / none spawned). NoIdleAgent, } @@ -869,7 +897,9 @@ async fn create_session_and_apply_model( // Apply desired_model if set, matching against the fresh session/new response. // Track whether the switch succeeded so session_config_captured reflects // the post-switch state (not the pre-switch desired state). - let switch_succeeded = if let Some(ref desired) = agent.desired_model { + let switch_succeeded = if agent.startup_model_only { + false + } else if let Some(ref desired) = agent.desired_model { match resolve_model_switch_method(&resp.raw, desired) { Some(method) => { apply_model_switch(&mut agent.acp, &resp.session_id, desired, &method).await?; diff --git a/desktop/src-tauri/src/commands/agent_auth.rs b/desktop/src-tauri/src/commands/agent_auth.rs index bba0f33860..e6a6fa0af2 100644 --- a/desktop/src-tauri/src/commands/agent_auth.rs +++ b/desktop/src-tauri/src/commands/agent_auth.rs @@ -12,6 +12,7 @@ use serde::{Deserialize, Serialize}; use crate::managed_agents::{ default_agent_workdir, known_acp_runtime_exact, normalize_agent_args, resolve_command, + resolve_runtime_adapter, runtime_launch_args, }; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -70,11 +71,95 @@ pub async fn connect_acp_runtime( fn discover_acp_auth_methods_blocking(runtime_id: &str) -> Result { let output = run_buzz_acp_auth_command(runtime_id, ["auth-methods", "--json"])?; if !output.status.success() { + if is_native_auth_required_failure(runtime_id, &output) + || native_cli_status_requires_login(runtime_id) + { + return Ok(AcpAuthMethodsResult { + methods: native_cli_login_fallback(runtime_id).into_iter().collect(), + }); + } return Err(command_error("buzz-acp auth-methods", &output)); } - serde_json::from_slice::(&output.stdout) - .map_err(|error| format!("failed to parse auth methods JSON: {error}")) + let mut result = serde_json::from_slice::(&output.stdout) + .map_err(|error| format!("failed to parse auth methods JSON: {error}"))?; + // Cursor's native ACP currently does not advertise an ACP authenticate + // method. Keep login vendor-owned by exposing a fixed terminal fallback + // only when the adapter returned a valid, empty method list. + if result.methods.is_empty() { + if let Some(method) = native_cli_login_fallback(runtime_id) { + result.methods.push(method); + } + } + Ok(result) +} + +fn native_cli_status_requires_login(runtime_id: &str) -> bool { + let Some(runtime) = known_acp_runtime_exact(runtime_id).filter(|runtime| runtime.id == "cursor") + else { + return false; + }; + let Some(adapter) = resolve_runtime_adapter(runtime) else { + return false; + }; + let probe_args = if adapter.command == "wsl.exe" { + vec![ + adapter.command, + "--cd", + "~", + "--", + adapter.launch_command, + "status", + ] + } else { + vec![adapter.command, "status"] + }; + let refs: Vec<&str> = probe_args; + matches!( + crate::managed_agents::readiness::cli_probe::login_probe( + &adapter.path, + &refs, + crate::managed_agents::readiness::cli_probe::augmented_path().as_deref(), + ), + crate::managed_agents::readiness::cli_probe::ProbeOutcome::LoggedOut + ) +} + +fn native_cli_login_fallback(runtime_id: &str) -> Option { + known_acp_runtime_exact(runtime_id) + .filter(|runtime| runtime.id == "cursor" && runtime.native_acp) + .map(|_| AcpAuthMethod { + id: "cursor-cli-login".to_string(), + name: "Sign in with Cursor CLI".to_string(), + description: Some("Opens Cursor's vendor-owned OAuth login flow.".to_string()), + method_type: Some("terminal".to_string()), + args: vec!["login".to_string()], + command: Vec::new(), + meta: None, + }) +} + +fn is_native_auth_required_failure(runtime_id: &str, output: &std::process::Output) -> bool { + if native_cli_login_fallback(runtime_id).is_none() { + return false; + } + let diagnostic = format!( + "{}\n{}", + String::from_utf8_lossy(&output.stdout), + String::from_utf8_lossy(&output.stderr) + ) + .to_ascii_lowercase(); + [ + "authentication required", + "not authenticated", + "login required", + "please log in", + "please login", + "must log in", + "unauthorized", + ] + .iter() + .any(|marker| diagnostic.contains(marker)) } fn connect_acp_runtime_blocking( @@ -113,10 +198,7 @@ fn run_buzz_acp_auth_command( ) -> Result { let runtime = known_acp_runtime_exact(runtime_id) .ok_or_else(|| format!("unknown ACP runtime: {runtime_id}"))?; - let adapter_command = runtime - .commands - .iter() - .find_map(|command| resolve_command(command).map(|path| (*command, path))) + let adapter_command = resolve_runtime_adapter(runtime) .ok_or_else(|| format!("{} ACP adapter is not installed", runtime.label))?; let acp_path = std::env::current_exe() @@ -128,9 +210,11 @@ fn run_buzz_acp_auth_command( let augmented_path = auth_command_path(); run_buzz_acp_auth_command_with_paths( + runtime, &acp_path, - adapter_command.0, - &adapter_command.1, + adapter_command.command, + adapter_command.launch_command, + &adapter_command.path, args, augmented_path.as_deref(), ) @@ -174,18 +258,29 @@ fn append_inherited_path(augmented: Option, inherited: Option) - } fn run_buzz_acp_auth_command_with_paths( + runtime: &crate::managed_agents::KnownAcpRuntime, acp_path: &Path, adapter_name: &str, + launch_command: &str, adapter_path: &Path, args: [&str; N], augmented_path: Option<&str>, ) -> Result { - let agent_args = normalize_agent_args(adapter_name, Vec::new()); + let agent_args = runtime_launch_args( + runtime, + adapter_name, + launch_command, + normalize_agent_args(adapter_name, Vec::new()), + ); let mut command = Command::new(acp_path); command .args(args) .env("BUZZ_ACP_AGENT_COMMAND", adapter_path.as_os_str()) .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")) + .env( + "BUZZ_ACP_AGENT_ARGS_JSON", + serde_json::to_string(&agent_args).map_err(|error| error.to_string())?, + ) .stdout(Stdio::piped()) .stderr(Stdio::piped()); if let Some(workdir) = default_agent_workdir() { @@ -202,7 +297,7 @@ fn run_buzz_acp_auth_command_with_paths( } fn command_error(label: &str, output: &std::process::Output) -> String { - let stderr = String::from_utf8_lossy(&output.stderr).trim().to_string(); + let stderr = sanitize_diagnostic(&String::from_utf8_lossy(&output.stderr)); if stderr.is_empty() { format!( "{label} failed (exit {})", @@ -216,6 +311,31 @@ fn command_error(label: &str, output: &std::process::Output) -> String { } } +fn sanitize_diagnostic(diagnostic: &str) -> String { + let lines = diagnostic + .lines() + .take(3) + .map(|line| { + line.split_whitespace() + .map(|word| { + if word.starts_with("http://") || word.starts_with("https://") { + "" + } else { + word + } + }) + .collect::>() + .join(" ") + }) + .collect::>(); + let mut result = lines.join(" "); + if result.len() > 1200 { + result.truncate(1200); + result.push_str("..."); + } + result +} + fn is_claude_subscription_login(runtime_id: &str, method: &AcpAuthMethod) -> bool { runtime_id == "claude" && matches!(method.id.as_str(), "claude-login" | "claude-ai-login") } @@ -249,13 +369,21 @@ fn run_claude_subscription_login(runtime_id: &str, method: &AcpAuthMethod) -> Re fn launch_terminal_auth(runtime_id: &str, method: &AcpAuthMethod) -> Result<(), String> { let runtime = known_acp_runtime_exact(runtime_id) .ok_or_else(|| format!("unknown ACP runtime: {runtime_id}"))?; - let adapter_command = runtime - .commands - .iter() - .find_map(|command| resolve_command(command).map(|path| (*command, path))) + let adapter_command = resolve_runtime_adapter(runtime) .ok_or_else(|| format!("{} ACP adapter is not installed", runtime.label))?; - let fallback_command = adapter_command.1.display().to_string(); - let argv = adapter_terminal_argv(runtime.label, method, &fallback_command)?; + let fallback_command = adapter_command.path.display().to_string(); + let argv = if runtime.native_acp && adapter_command.command == "wsl.exe" { + vec![ + fallback_command, + "--cd".to_string(), + "~".to_string(), + "--".to_string(), + adapter_command.launch_command.to_string(), + "login".to_string(), + ] + } else { + adapter_terminal_argv(runtime.label, method, &fallback_command)? + }; launch_visible_terminal(&argv) } @@ -462,10 +590,20 @@ fn shell_escape(arg: &str) -> String { mod tests { use super::{ adapter_terminal_argv, append_inherited_path, is_claude_subscription_login, - run_buzz_acp_auth_command_with_paths, shell_escape, shell_join, uses_terminal_auth, - windows_terminal_args, AcpAuthMethod, + native_cli_login_fallback, run_buzz_acp_auth_command_with_paths, shell_escape, shell_join, + uses_terminal_auth, windows_terminal_args, AcpAuthMethod, }; + #[test] + fn cursor_empty_auth_methods_use_vendor_login_fallback() { + let method = native_cli_login_fallback("cursor").expect("cursor fallback"); + assert_eq!(method.id, "cursor-cli-login"); + assert_eq!(method.method_type.as_deref(), Some("terminal")); + assert_eq!(method.args, vec!["login"]); + assert!(method.command.is_empty()); + assert!(native_cli_login_fallback("claude").is_none()); + } + /// Windows regression: the augmented PATH there holds only Buzz-managed /// dirs and the exe parent (no login-shell PATH, no managed Node), so the /// user's inherited PATH must be appended for npm `.cmd` adapters to find @@ -537,8 +675,10 @@ mod tests { .to_string_lossy() .into_owned(); let output = run_buzz_acp_auth_command_with_paths( + known_acp_runtime_exact("claude").expect("Claude metadata"), &acp_path, "claude-agent-acp", + "claude-agent-acp", &adapter_path, ["auth-methods", "--json"], Some(&augmented_path), diff --git a/desktop/src-tauri/src/commands/agent_config.rs b/desktop/src-tauri/src/commands/agent_config.rs index f6094ed529..88af1a0c6c 100644 --- a/desktop/src-tauri/src/commands/agent_config.rs +++ b/desktop/src-tauri/src/commands/agent_config.rs @@ -12,7 +12,8 @@ use crate::{ NormalizedField, RuntimeConfigSurface, SessionConfigCache, }, }, - current_instance_id, known_acp_runtime, load_managed_agents, load_personas, + current_instance_id, known_acp_runtime, known_acp_runtime_exact, load_managed_agents, + load_personas, resolve_effective_prompt_model_provider, save_managed_agents, sync_managed_agent_processes, AgentDefinition, GlobalAgentConfig, KnownAcpRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, @@ -600,38 +601,7 @@ mod tests { use crate::managed_agents::{BackendKind, RespondTo}; fn goose_runtime() -> &'static KnownAcpRuntime { - &KnownAcpRuntime { - id: "goose", - label: "Goose", - commands: &["goose"], - aliases: &[], - avatar_url: "", - mcp_command: None, - mcp_hooks: false, - underlying_cli: None, - cli_install_commands: &[], - cli_install_commands_windows: &[], - adapter_install_commands: &[], - cli_install_instructions_url: "", - adapter_install_instructions_url: "", - cli_install_hint: "", - adapter_install_hint: "", - skill_dir: None, - supports_acp_model_switching: false, - model_env_var: Some("GOOSE_MODEL"), - provider_env_var: Some("GOOSE_PROVIDER"), - provider_locked: false, - default_env: &[], - config_file_path: Some("~/.config/goose/config.yaml"), - config_file_format: Some("yaml"), - supports_acp_native_config: true, - thinking_env_var: Some("GOOSE_THINKING_EFFORT"), - max_tokens_env_var: Some("GOOSE_MAX_TOKENS"), - context_limit_env_var: Some("GOOSE_CONTEXT_LIMIT"), - required_normalized_fields: &["model", "provider"], - login_hint: None, - auth_probe_args: None, - } + known_acp_runtime_exact("goose").expect("Goose metadata") } fn agent_record() -> ManagedAgentRecord { diff --git a/desktop/src-tauri/src/commands/agent_model_process.rs b/desktop/src-tauri/src/commands/agent_model_process.rs index 998edeca27..e8de9138c5 100644 --- a/desktop/src-tauri/src/commands/agent_model_process.rs +++ b/desktop/src-tauri/src/commands/agent_model_process.rs @@ -39,7 +39,12 @@ pub(super) async fn run_agent_models_command( cmd.arg("models") .arg("--json") .env("BUZZ_ACP_AGENT_COMMAND", &agent_command) - .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); + .env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")) + .env( + "BUZZ_ACP_AGENT_ARGS_JSON", + serde_json::to_string(&agent_args) + .map_err(|error| format!("failed to encode agent args: {error}"))?, + ); if let Some(meta) = known_acp_runtime(&agent_command) { for (key, value) in meta.default_env { if std::env::var(key).is_err() { diff --git a/desktop/src-tauri/src/commands/agent_models.rs b/desktop/src-tauri/src/commands/agent_models.rs index 112f297fa7..0dd0c41c82 100644 --- a/desktop/src-tauri/src/commands/agent_models.rs +++ b/desktop/src-tauri/src/commands/agent_models.rs @@ -19,7 +19,8 @@ use crate::{ managed_agents::{ build_managed_agent_summary, current_instance_id, discovery_env_with_baked_floor, find_managed_agent_mut, known_acp_runtime, load_managed_agents, load_personas, - managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_command, + managed_agent_avatar_url, missing_command_message, normalize_agent_args, resolve_agent_launch, + resolve_command, save_managed_agents, sync_managed_agent_processes, try_regenerate_nest, AgentModelInfo, AgentModelsResponse, UpdateManagedAgentRequest, UpdateManagedAgentResponse, DEFAULT_ACP_COMMAND, @@ -79,11 +80,8 @@ pub async fn get_agent_models( let personas = load_personas(&app).unwrap_or_default(); let effective_command = crate::managed_agents::record_agent_command(record, &personas); - let args = normalize_agent_args(&effective_command, record.agent_args.clone()); - - let resolved_agent = resolve_command(&effective_command) - .map(|p| p.display().to_string()) - .unwrap_or_else(|| effective_command.clone()); + let normalized_args = normalize_agent_args(&effective_command, record.agent_args.clone()); + let (resolved_agent, args) = resolve_agent_launch(&effective_command, normalized_args)?; // ModelPicker can persist a selected model but not rewrite the saved // provider/env snapshot, and runtime spawn reads that same snapshot. @@ -226,12 +224,9 @@ pub async fn discover_agent_models( if agent_command.is_empty() { return Err("agent command is required for model discovery".to_string()); } - let agent_args = normalize_agent_args(agent_command, input.agent_args); - let resolved_agent = resolve_command(agent_command) - .map(|p| p.display().to_string()) - .unwrap_or_else(|| agent_command.to_string()); - let runtime_meta = known_acp_runtime(agent_command); + let normalized_args = normalize_agent_args(agent_command, input.agent_args); + let (resolved_agent, agent_args) = resolve_agent_launch(agent_command, normalized_args)?; let mut derived_env = BTreeMap::new(); if let Some(meta) = runtime_meta { let provider = input diff --git a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs index 4c11cd6c49..222c734e72 100644 --- a/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs +++ b/desktop/src-tauri/src/managed_agents/config_bridge/reader_tests.rs @@ -59,6 +59,8 @@ fn test_runtime() -> &'static KnownAcpRuntime { required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, + native_acp: false, + startup_model_arg: None, } } @@ -635,6 +637,8 @@ fn buzz_agent_runtime() -> &'static KnownAcpRuntime { required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, + native_acp: false, + startup_model_arg: None, } } diff --git a/desktop/src-tauri/src/managed_agents/cursor_launch.rs b/desktop/src-tauri/src/managed_agents/cursor_launch.rs new file mode 100644 index 0000000000..f554860efa --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/cursor_launch.rs @@ -0,0 +1,292 @@ +use std::path::PathBuf; +#[cfg(windows)] +use std::process::Command; +#[cfg(windows)] +use std::time::{Duration, Instant}; + +use super::discovery::{ + find_command, known_acp_runtime, missing_command_message, normalize_command_identity, + resolve_command, +}; +use super::AcpAvailabilityStatus; +use super::KnownAcpRuntime; + +pub(crate) struct ResolvedRuntimeAdapter { + pub command: &'static str, + pub path: PathBuf, + pub launch_command: &'static str, +} + +pub(crate) fn resolve_runtime_adapter( + runtime: &'static KnownAcpRuntime, +) -> Option { + let direct = runtime.commands.iter().find_map(|command| { + #[cfg(windows)] + if runtime.native_acp { + return None; + } + let path = find_command(command)?; + Some(ResolvedRuntimeAdapter { + command: *command, + path, + launch_command: *command, + }) + }); + if direct.is_some() || !runtime.native_acp { + return direct; + } + + #[cfg(windows)] + { + let wsl = find_command("wsl.exe")?; + for &guest_command in runtime.commands { + for probe in [["--version"], ["--help"]] { + if run_wsl_probe(&wsl, guest_command, &probe) { + return Some(ResolvedRuntimeAdapter { + command: "wsl.exe", + path: wsl, + launch_command: guest_command, + }); + } + } + } + None + } + + #[cfg(not(windows))] + { + None + } +} + +#[cfg(windows)] +fn run_wsl_probe(wsl: &PathBuf, guest_command: &str, probe: &[&str]) -> bool { + let mut child = match Command::new(wsl) + .args(["--cd", "~", "--"]) + .arg(guest_command) + .args(probe) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()) + .spawn() + { + Ok(child) => child, + Err(_) => return false, + }; + let deadline = Instant::now() + Duration::from_secs(5); + loop { + match child.try_wait() { + Ok(Some(status)) => return status.success(), + Ok(None) if Instant::now() < deadline => { + std::thread::sleep(Duration::from_millis(50)); + } + _ => { + let _ = child.kill(); + let _ = child.wait(); + return false; + } + } + } +} + +pub(crate) fn runtime_launch_args( + runtime: &KnownAcpRuntime, + command: &str, + launch_command: &str, + mut args: Vec, +) -> Vec { + if !runtime.native_acp + || !matches!( + normalize_command_identity(command).as_str(), + "agent" | "cursor-agent" | "wsl" + ) + { + return args; + } + if !args.iter().any(|arg| arg.eq_ignore_ascii_case("acp")) { + args.push("acp".to_string()); + } + if normalize_command_identity(command) == "wsl" { + let mut launch_args = vec![ + "--cd".to_string(), + "~".to_string(), + "--".to_string(), + launch_command.to_string(), + ]; + launch_args.extend(args); + launch_args + } else { + args + } +} + +pub(crate) fn startup_model_args( + runtime: &KnownAcpRuntime, + command: &str, + launch_command: &str, + args: Vec, + model: Option<&str>, +) -> Vec { + let mut args = runtime_launch_args(runtime, command, launch_command, args); + let Some(model) = model else { + return args; + }; + let Some(model_arg) = runtime.startup_model_arg else { + return args; + }; + let mut filtered = Vec::with_capacity(args.len() + 2); + let mut skip_next = false; + for arg in args { + if skip_next { + skip_next = false; + continue; + } + if arg == model_arg { + skip_next = true; + continue; + } + if arg + .strip_prefix(model_arg) + .is_some_and(|suffix| suffix.starts_with('=')) + { + continue; + } + filtered.push(arg); + } + args = filtered; + let insert_at = args + .iter() + .position(|arg| arg.eq_ignore_ascii_case("acp")) + .unwrap_or(args.len()); + args.splice( + insert_at..insert_at, + [model_arg.to_string(), model.to_string()], + ); + args +} + +pub(crate) fn resolve_known_runtime_launch( + command: &str, + args: Vec, +) -> Option<(String, Vec)> { + let runtime = known_acp_runtime(command)?; + let adapter = resolve_runtime_adapter(runtime)?; + Some(( + adapter.path.display().to_string(), + runtime_launch_args(runtime, adapter.command, adapter.launch_command, args), + )) +} + +pub(crate) fn resolve_agent_launch( + command: &str, + args: Vec, +) -> Result<(String, Vec), String> { + if known_acp_runtime(command).is_some() { + return resolve_known_runtime_launch(command, args) + .ok_or_else(|| missing_command_message(command, "agent CLI")); + } + Ok(( + resolve_command(command) + .map(|path| path.display().to_string()) + .unwrap_or_else(|| command.to_string()), + args, + )) +} + +pub(crate) fn classify_acp_runtime( + runtime: &KnownAcpRuntime, + adapter_result: Option<&ResolvedRuntimeAdapter>, + underlying_cli_found: bool, +) -> (AcpAvailabilityStatus, Option, Option) { + if runtime.native_acp { + return match adapter_result { + Some(adapter) => ( + AcpAvailabilityStatus::Available, + Some(adapter.command.to_string()), + Some(adapter.path.display().to_string()), + ), + None => (AcpAvailabilityStatus::NotInstalled, None, None), + }; + } + super::discovery::classify_runtime( + adapter_result.map(|adapter| (adapter.command, adapter.path.clone())), + runtime.underlying_cli, + underlying_cli_found, + ) +} + +pub(crate) fn native_auth_probe_args(command: &str, launch_command: &str) -> Vec { + if normalize_command_identity(command) == "wsl" { + vec![ + command.to_string(), + "--cd".to_string(), + "~".to_string(), + "--".to_string(), + launch_command.to_string(), + "status".to_string(), + ] + } else { + vec![command.to_string(), "status".to_string()] + } +} + +pub(crate) fn resolve_runtime_launch( + runtime: Option<&'static KnownAcpRuntime>, + command: &str, + args: Vec, + model: Option<&str>, +) -> Option<(String, Vec)> { + if let Some(runtime) = runtime { + let adapter = resolve_runtime_adapter(runtime)?; + return Some(( + adapter.path.display().to_string(), + startup_model_args(runtime, adapter.command, adapter.launch_command, args, model), + )); + } + Some(( + resolve_command(command) + .map(|path| path.display().to_string()) + .unwrap_or_else(|| command.to_string()), + args, + )) +} + +#[cfg(test)] +mod tests { + use super::{startup_model_args, runtime_launch_args}; + use crate::managed_agents::known_acp_runtime_exact; + + #[test] + fn startup_model_args_replace_space_and_equals_forms() { + let runtime = known_acp_runtime_exact("cursor").expect("Cursor metadata"); + let args = startup_model_args( + runtime, + "agent", + "agent", + vec![ + "--model".into(), + "old-one".into(), + "--model=old-two".into(), + "acp".into(), + ], + Some("new-model"), + ); + assert_eq!( + args, + vec![ + "--model".to_string(), + "new-model".to_string(), + "acp".to_string() + ] + ); + } + + #[test] + fn runtime_launch_args_add_acp_once() { + let runtime = known_acp_runtime_exact("cursor").expect("Cursor metadata"); + assert_eq!( + runtime_launch_args(runtime, "agent", "agent", vec!["acp".into()]), + vec!["acp".to_string()] + ); + } +} diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index a2e47d1281..78ba179c54 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -10,7 +10,9 @@ use crate::managed_agents::{ }; mod runtime_metadata; +mod cursor_runtime; +pub(crate) use cursor_runtime::CURSOR_AVATAR_URL; pub(crate) use runtime_metadata::KnownAcpRuntime; const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; @@ -96,6 +98,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, + native_acp: false, startup_model_arg: None, }, KnownAcpRuntime { id: "claude", @@ -128,6 +131,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ required_normalized_fields: &[], login_hint: Some("Run the Claude CLI to complete authentication."), auth_probe_args: Some(&["claude", "auth", "status"]), + native_acp: false, startup_model_arg: None, }, KnownAcpRuntime { id: "codex", @@ -161,6 +165,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ login_hint: Some("Run `codex login` to authenticate."), // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), + native_acp: false, startup_model_arg: None, }, KnownAcpRuntime { id: "buzz-agent", @@ -193,7 +198,9 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ required_normalized_fields: &["model", "provider"], login_hint: None, auth_probe_args: None, + native_acp: false, startup_model_arg: None, }, + cursor_runtime::CURSOR_ACP_RUNTIME, ]; /// Skill discovery directories declared by known runtimes. @@ -219,7 +226,7 @@ fn executable_basename(command: &str) -> String { } } -fn normalize_command_identity(command: &str) -> String { +pub(crate) fn normalize_command_identity(command: &str) -> String { let normalized = command.trim().replace('\\', "/"); let basename = normalized.rsplit('/').next().unwrap_or(normalized.as_str()); let lower = basename @@ -348,7 +355,7 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri fn default_agent_args(command: &str) -> Option> { match normalize_command_identity(command).as_str() { - "goose" => Some(vec!["acp".to_string()]), + "goose" | "agent" | "cursor-agent" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -1179,21 +1186,17 @@ pub(crate) fn codex_adapter_is_outdated_with_path( struct PartialEntry { runtime: &'static KnownAcpRuntime, entry: AcpRuntimeCatalogEntry, + launch_command: Option<&'static str>, } 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 adapter_result = super::resolve_runtime_adapter(runtime); 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); - + super::classify_acp_runtime(runtime, adapter_result.as_ref(), 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. @@ -1205,22 +1208,29 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr 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())) + .map(|cmd| { + super::runtime_launch_args( + runtime, + cmd, + adapter_result + .as_ref() + .map(|adapter| adapter.launch_command) + .unwrap_or(cmd), + normalize_agent_args(cmd, Vec::new()), + ) + }) .unwrap_or_default(); let can_auto_install = !runtime.cli_install_commands_for_os().is_empty() @@ -1265,6 +1275,7 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr PartialEntry { runtime, + launch_command: adapter_result.as_ref().map(|adapter| adapter.launch_command), entry: AcpRuntimeCatalogEntry { id: runtime.id.to_string(), label: runtime.label.to_string(), @@ -1280,12 +1291,13 @@ fn discover_acp_runtime_phase1(runtime: &'static KnownAcpRuntime) -> PartialEntr install_hint, install_instructions_url: install_instructions_url.to_string(), can_auto_install, - requires_external_cli: runtime.underlying_cli.is_some(), + requires_external_cli: runtime.underlying_cli.is_some() || runtime.native_acp, underlying_cli_path, node_required, // Filled in by the auth-probe phase in full catalog discovery. auth_status: AuthStatus::Unknown, login_hint: None, + startup_model_arg: runtime.startup_model_arg.map(str::to_string), }, } } @@ -1306,7 +1318,6 @@ pub fn discover_acp_runtimes() -> Vec { .iter() .map(discover_acp_runtime_phase1) .collect(); - // Phase 2: run auth probes in parallel for entries that need them. // Spawn one thread per probeable entry; total cost = max(probe latency). let probe_handles: Vec<(usize, std::thread::JoinHandle)> = partials @@ -1316,10 +1327,22 @@ pub fn discover_acp_runtimes() -> Vec { if partial.entry.availability != AcpAvailabilityStatus::Available { return None; } - let probe_args = partial.runtime.auth_probe_args?; - // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). - let binary_path = resolve_command(probe_args[0])?; - let probe_args_owned: Vec = probe_args.iter().map(|s| s.to_string()).collect(); + let (binary_path, probe_args_owned) = if let Some(probe_args) = partial.runtime.auth_probe_args { + // Need the resolved binary path for the CLI (e.g. the actual `claude` binary). + (resolve_command(probe_args[0])?, probe_args.iter().map(|s| s.to_string()).collect()) + } else if partial.runtime.native_acp { + let command = partial.entry.command.as_deref()?; + let binary_path = PathBuf::from(partial.entry.binary_path.as_deref()?); + ( + binary_path, + super::native_auth_probe_args( + command, + partial.launch_command.unwrap_or("agent"), + ), + ) + } else { + return None; + }; let handle = std::thread::spawn(move || { let refs: Vec<&str> = probe_args_owned.iter().map(String::as_str).collect(); diff --git a/desktop/src-tauri/src/managed_agents/discovery/cursor_runtime.rs b/desktop/src-tauri/src/managed_agents/discovery/cursor_runtime.rs new file mode 100644 index 0000000000..a58526d1f1 --- /dev/null +++ b/desktop/src-tauri/src/managed_agents/discovery/cursor_runtime.rs @@ -0,0 +1,40 @@ +use super::runtime_metadata::KnownAcpRuntime; + +pub(crate) const CURSOR_AVATAR_URL: &str = "https://cursor.com/brand/icon.svg"; + +pub(crate) const CURSOR_ACP_RUNTIME: KnownAcpRuntime = KnownAcpRuntime { + id: "cursor", + label: "Cursor Agent", + commands: &["agent", "cursor-agent"], + aliases: &[], + avatar_url: CURSOR_AVATAR_URL, + mcp_command: None, + mcp_hooks: false, + underlying_cli: None, + cli_install_commands: &["curl https://cursor.com/install -fsS | bash"], + // Cursor's documented installer is Unix/WSL. Native Windows support + // remains intentionally absent; discovery provides WSL guidance. + cli_install_commands_windows: &[], + adapter_install_commands: &[], + cli_install_instructions_url: "https://docs.cursor.com/en/cli/installation", + adapter_install_instructions_url: "", + cli_install_hint: "Install Cursor Agent CLI. On Windows, use Cursor CLI from WSL.", + adapter_install_hint: "", + skill_dir: Some(".cursor/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: true, + default_env: &[], + config_file_path: Some("~/.cursor/cli-config.json"), + config_file_format: Some("json"), + supports_acp_native_config: false, + thinking_env_var: None, + max_tokens_env_var: None, + context_limit_env_var: None, + required_normalized_fields: &[], + login_hint: Some("Run `agent login` or `cursor-agent login` to authenticate with Cursor."), + auth_probe_args: None, + native_acp: true, + startup_model_arg: Some("--model"), +}; diff --git a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs index 2fb6a471d4..7340527738 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs @@ -62,6 +62,11 @@ pub(crate) struct KnownAcpRuntime { /// CLI args for probing authentication status. `args[0]` is the binary name; /// the remainder are the subcommand. `None` for runtimes with no login step. pub auth_probe_args: Option<&'static [&'static str]>, + /// True when the vendor CLI itself speaks ACP; no separate adapter or + /// vendor CLI dependency is required. + pub native_acp: bool, + /// Optional CLI flag used to pin the model when the process starts. + pub startup_model_arg: Option<&'static str>, } impl KnownAcpRuntime { @@ -73,6 +78,11 @@ impl KnownAcpRuntime { pub fn cli_install_commands_for_os(&self) -> &[&str] { #[cfg(windows)] { + if self.id == "cursor" { + // Cursor is supported on Windows through WSL only; never run + // the Unix installer in the native Windows shell. + return &[]; + } if !self.cli_install_commands_windows.is_empty() { return self.cli_install_commands_windows; } @@ -120,5 +130,13 @@ mod tests { ); assert!(codex.adapter_install_instructions_url.contains("codex-acp")); assert!(codex.cli_install_hint.contains("desktop app alone")); + + let cursor = known_acp_runtime_exact("cursor").unwrap(); + assert_eq!(cursor.avatar_url, "https://cursor.com/brand/icon.svg"); + assert_eq!(cursor.commands, &["agent", "cursor-agent"]); + assert!(cursor.adapter_install_commands.is_empty()); + assert!(cursor.cli_install_instructions_url.contains("cursor")); + assert!(cursor.cli_install_hint.contains("WSL")); + assert_eq!(cursor.startup_model_arg, Some("--model")); } } diff --git a/desktop/src-tauri/src/managed_agents/env_vars.rs b/desktop/src-tauri/src/managed_agents/env_vars.rs index 6318a5c838..83181eef0d 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars.rs @@ -70,6 +70,7 @@ pub(crate) const RESERVED_ENV_KEYS: &[&str] = &[ // binaries/args as the agent process. "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_AGENT_ARGS_JSON", "BUZZ_ACP_MCP_COMMAND", // Security gates: respond-to mode + allowlist + legacy owner-only // fallback. Overriding would make the running agent's gate diverge diff --git a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs index cf57b12546..ad2483336a 100644 --- a/desktop/src-tauri/src/managed_agents/env_vars/tests.rs +++ b/desktop/src-tauri/src/managed_agents/env_vars/tests.rs @@ -165,6 +165,7 @@ fn reserved_keys_include_code_execution_surface() { for key in [ "BUZZ_ACP_AGENT_COMMAND", "BUZZ_ACP_AGENT_ARGS", + "BUZZ_ACP_AGENT_ARGS_JSON", "BUZZ_ACP_MCP_COMMAND", ] { assert!(is_reserved_env_key(key), "{key} should be reserved"); diff --git a/desktop/src-tauri/src/managed_agents/mod.rs b/desktop/src-tauri/src/managed_agents/mod.rs index 66f3d882b9..680b344865 100644 --- a/desktop/src-tauri/src/managed_agents/mod.rs +++ b/desktop/src-tauri/src/managed_agents/mod.rs @@ -7,6 +7,7 @@ pub(crate) use agent_env::{ }; mod backend; pub(crate) mod config_bridge; +mod cursor_launch; mod discovery; mod env_vars; pub(crate) mod git_bash; @@ -45,6 +46,11 @@ pub(crate) fn lock_path_mutex() -> std::sync::MutexGuard<'static, ()> { pub use backend::*; pub use discovery::*; +pub(crate) use cursor_launch::{ + classify_acp_runtime, native_auth_probe_args, resolve_agent_launch, + resolve_known_runtime_launch, resolve_runtime_adapter, + resolve_runtime_launch, runtime_launch_args, startup_model_args, +}; pub use env_vars::*; #[cfg(windows)] pub(crate) use git_bash::git_bash_available; diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index c04dfa88a4..c23bfc53dc 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -289,6 +289,11 @@ fn collect_missing_requirements( rt, ), "codex" => cli_login::requirements(&["codex", "login", "status"], "run `codex login`", rt), + "cursor" => cli_login::requirements( + &["agent", "status"], + "run `agent login` (or `cursor-agent login`) to authenticate with Cursor", + rt, + ), _ => vec![], } } @@ -888,6 +893,8 @@ mod tests { required_normalized_fields: &[], login_hint: None, auth_probe_args: None, + native_acp: false, + startup_model_arg: None, } } @@ -1083,6 +1090,8 @@ mod tests { required_normalized_fields: &[], login_hint: None, auth_probe_args: None, + native_acp: false, + startup_model_arg: None, } } diff --git a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs index 4036d9f239..38e451b605 100644 --- a/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs +++ b/desktop/src-tauri/src/managed_agents/readiness/cli_login.rs @@ -1,4 +1,4 @@ -use std::path::Path; +use std::path::{Path, PathBuf}; use crate::managed_agents::{ discovery::{ @@ -6,6 +6,7 @@ use crate::managed_agents::{ KnownAcpRuntime, }, AcpAvailabilityStatus, + resolve_runtime_adapter, }; use super::{cli_probe, Requirement}; @@ -16,17 +17,27 @@ pub(super) fn requirements( setup_copy: &str, runtime: &KnownAcpRuntime, ) -> Vec { - let adapter_result = runtime - .commands - .iter() - .find_map(|cmd| find_command(cmd).map(|path| (*cmd, path))); + let adapter_result = resolve_runtime_adapter(runtime); let underlying_cli_found = runtime .underlying_cli .map(|cli| find_command(cli).is_some()) .unwrap_or(false); - let (availability, _cmd, adapter_path) = - classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); + let (availability, adapter_command, launch_command, adapter_path) = if runtime.native_acp { + match adapter_result { + Some(adapter) => ( + AcpAvailabilityStatus::Available, + Some(adapter.command), + Some(adapter.launch_command), + Some(adapter.path.display().to_string()), + ), + None => (AcpAvailabilityStatus::NotInstalled, None, None, None), + } + } else { + let (availability, cmd, path) = + classify_runtime(adapter_result, runtime.underlying_cli, underlying_cli_found); + (availability, cmd, None, path) + }; let availability = if runtime.id == "codex" && availability == AcpAvailabilityStatus::Available { adapter_path @@ -39,15 +50,37 @@ pub(super) fn requirements( match availability { AcpAvailabilityStatus::Available => { - let Some(binary_path) = resolve_command(probe_args[0]) else { + let probe_binary = if runtime.native_acp { + adapter_path.as_ref().map(PathBuf::from) + } else { + resolve_command(probe_args[0]) + }; + let Some(binary_path) = probe_binary else { return vec![missing_requirement( probe_args, setup_copy, AcpAvailabilityStatus::Available, )]; }; + let mut effective_probe_args = probe_args.to_vec(); + if runtime.native_acp { + if let Some(command) = adapter_command { + if command == "wsl.exe" { + effective_probe_args = vec![ + "wsl.exe", + "--cd", + "~", + "--", + launch_command.unwrap_or("agent"), + "status", + ]; + } else { + effective_probe_args[0] = command; + } + } + } let augmented_path = cli_probe::augmented_path(); - match cli_probe::login_probe(&binary_path, probe_args, augmented_path.as_deref()) { + match cli_probe::login_probe(&binary_path, &effective_probe_args, augmented_path.as_deref()) { cli_probe::ProbeOutcome::LoggedIn => vec![], cli_probe::ProbeOutcome::LoggedOut => vec![missing_requirement( probe_args, diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 44cee49aeb..27f957104a 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -8,6 +8,7 @@ use crate::{ managed_agents::{ append_log_marker, known_acp_runtime, login_shell_path, managed_agent_log_path, missing_command_message, normalize_agent_args, open_log_file, resolve_command, + resolve_runtime_launch, spawn_key_refusal, KnownAcpRuntime, ManagedAgentPairRuntime, ManagedAgentRecord, ManagedAgentRuntimeKey, ManagedAgentSummary, }, @@ -1483,10 +1484,10 @@ pub fn build_managed_agent_summary( hash_drift || availability_drift }); - // Resolve the effective harness the same way, then derive args/mcp from it, - // so the UI reflects the persona's current harness (or an explicit pin). let effective_command = crate::managed_agents::record_agent_command(record, personas); let effective_args = normalize_agent_args(&effective_command, record.agent_args.clone()); + let startup_model_arg = known_acp_runtime(&effective_command) + .and_then(|runtime| runtime.startup_model_arg.map(str::to_string)); let effective_mcp_command = known_acp_runtime(&effective_command) .and_then(|r| r.mcp_command) .unwrap_or("") @@ -1502,6 +1503,7 @@ pub fn build_managed_agent_summary( agent_command: effective_command, agent_command_override: record.agent_command_override.clone(), agent_args: effective_args, + startup_model_arg, mcp_command: effective_mcp_command, turn_timeout_seconds: record.turn_timeout_seconds, idle_timeout_seconds: record.idle_timeout_seconds, @@ -1657,18 +1659,14 @@ pub fn spawn_agent_child( let stderr = stdout .try_clone() .map_err(|error| format!("failed to clone log handle: {error}"))?; - // Resolve the effective harness (agent command) from the linked persona, so - // persona harness edits propagate on the next spawn; an explicit per-agent - // override wins. `agent_args` and `mcp_command` are pure derivations of the - // command, so we recompute them from the effective value rather than the - // frozen record snapshot. Mirrors the model resolution below. let personas = super::load_personas(app).unwrap_or_default(); let teams = super::load_teams(app).unwrap_or_default(); // Load global config once; used for runtime_metadata_env_vars (model/provider fallback) // and for the env-var merge at spawn time. let global = crate::managed_agents::load_global_agent_config(app).unwrap_or_default(); + let (effective_model, effective_provider) = crate::managed_agents::resolve_effective_model_provider(record, &personas, &global); let effective_command = super::record_agent_command(record, &personas); - let agent_args = normalize_agent_args(&effective_command, record.agent_args.clone()); + let runtime_meta = known_acp_runtime(&effective_command); let resolved_acp_command = resolve_command(&record.acp_command) .ok_or_else(|| missing_command_message(&record.acp_command, "ACP harness command"))?; let effective_mcp_command = known_acp_runtime(&effective_command) @@ -1687,20 +1685,15 @@ pub fn spawn_agent_child( } } }; - // Resolve agent command to a full path (DMG launches have minimal PATH). - let resolved_agent_command = resolve_command(&effective_command) - .map(|p| p.display().to_string()) - .unwrap_or_else(|| effective_command.clone()); + let (resolved_agent_command, agent_args) = resolve_runtime_launch( + runtime_meta, + &effective_command, + normalize_agent_args(&effective_command, record.agent_args.clone()), + effective_model.as_deref(), + ) + .ok_or_else(|| missing_command_message(&effective_command, "agent CLI"))?; - // The caller supplies the explicit canonical pair relay. This is the only - // relay this child may connect to, regardless of the record/workspace default. let effective_relay_url = runtime_key.relay_url.clone(); - - // Augment PATH for DMG launches so child processes can find: - // - bundled CLI via ~/.local/bin symlink - // - nvm-managed node/npm (nvm initializes only in interactive shells) - // - bundled sidecars (buzz, buzz-acp, etc.) via exe parent (Contents/MacOS/) - // - runtimes (node, python, etc.) via login shell PATH let nvm_bin = dirs::home_dir() .as_deref() .and_then(super::find_nvm_default_bin); @@ -1729,6 +1722,7 @@ pub fn spawn_agent_child( command.env("BUZZ_ACP_LAZY_POOL", if lazy { "true" } else { "false" }); command.env("BUZZ_ACP_AGENT_COMMAND", &resolved_agent_command); command.env("BUZZ_ACP_AGENT_ARGS", agent_args.join(",")); + command.env("BUZZ_ACP_AGENT_ARGS_JSON", serde_json::to_string(&agent_args).map_err(|error| error.to_string())?); match &resolved_mcp_command { Some(mcp_cmd) => { command.env("BUZZ_ACP_MCP_COMMAND", mcp_cmd); @@ -1739,7 +1733,6 @@ pub fn spawn_agent_child( } // Enable MCP hook tools (_Stop, _PostCompact) for agents that need them. // Uses "*" because build_mcp_servers() hard-codes the server name to "buzz-mcp". - let runtime_meta = known_acp_runtime(&effective_command); if runtime_meta.is_some_and(|r| r.mcp_hooks) { command.env("MCP_HOOK_SERVERS", "*"); } @@ -1888,9 +1881,6 @@ pub fn spawn_agent_child( // resolver: agent → persona → global → None, so a global-default-only agent // spawns with the correct provider/model env. let effective_prompt = super::spawn_hash::effective_spawn_prompt(record); - let (effective_model, effective_provider) = - crate::managed_agents::resolve_effective_model_provider(record, &personas, &global); - if let Some(prompt) = &effective_prompt { command.env("BUZZ_ACP_SYSTEM_PROMPT", prompt); } else { diff --git a/desktop/src-tauri/src/managed_agents/types.rs b/desktop/src-tauri/src/managed_agents/types.rs index af4908911b..474b3c828c 100644 --- a/desktop/src-tauri/src/managed_agents/types.rs +++ b/desktop/src-tauri/src/managed_agents/types.rs @@ -466,6 +466,9 @@ pub struct ManagedAgentSummary { /// concrete pin (`agent_command` above is the resolved/effective command). pub agent_command_override: Option, pub agent_args: Vec, + /// Catalog-derived startup flag for runtimes that pin model selection in + /// process argv instead of supporting ACP live switching. + pub startup_model_arg: Option, /// Catalog-derived from the effective harness (not the record's stored /// field), so the UI always shows what a spawn would actually use. pub mcp_command: String, @@ -596,6 +599,8 @@ pub struct AcpRuntimeCatalogEntry { /// Hint for completing authentication, shown when `auth_status` is not `logged_in`. #[serde(skip_serializing_if = "Option::is_none")] pub login_hint: Option, + /// Optional startup flag used by runtimes such as Cursor to pin a model. + pub startup_model_arg: Option, } /// Result of a single install step (CLI or adapter). diff --git a/desktop/src/features/agents/AGENTS.md b/desktop/src/features/agents/AGENTS.md index 2af9ddb98c..1c37355ab5 100644 --- a/desktop/src/features/agents/AGENTS.md +++ b/desktop/src/features/agents/AGENTS.md @@ -96,12 +96,17 @@ with a TypeScript lookup table or an id comparison in a component. well as provider/model values. If no available harness can resolve, Create starts in Customize and lets unavailable catalog entries be selected only to expose their setup guidance; submission remains blocked. - Advanced-only required credentials mark the collapsed Advanced toggle - without opening it in Global Defaults and Edit, and block incomplete saves. - Runtime-file credentials satisfy Global Defaults just as they do Create and - Edit. In Edit, - selecting Custom command keeps its required command field beside the harness - picker rather than hiding it in Advanced. + Advanced-only required credentials mark the collapsed Advanced toggle + without opening it in Global Defaults and Edit, and block incomplete saves. + Runtime-file credentials satisfy Global Defaults just as they do Create and + Edit. In Edit, + selecting Custom command keeps its required command field beside the harness + picker rather than hiding it in Advanced. +10. **Startup-only model runtimes remain selectable but are not live-switched.** + The Rust runtime catalog exposes `startup_model_arg` for vendor harnesses + that require the model in process argv. The model picker may persist a new + selection for the next launch, but must not send a live `switch_model` + control frame for those runtimes. ## The tests that enforce this diff --git a/desktop/src/features/agents/ui/ModelPicker.tsx b/desktop/src/features/agents/ui/ModelPicker.tsx index f7bafde99b..6148f4936e 100644 --- a/desktop/src/features/agents/ui/ModelPicker.tsx +++ b/desktop/src/features/agents/ui/ModelPicker.tsx @@ -44,6 +44,9 @@ export function ModelPicker({ const isRunning = agent.status === "running" || agent.status === "deployed"; const activeTurns = useActiveAgentTurns(agent.pubkey); + // Catalog-derived startup-only runtimes persist a selection for the next + // process launch; active sessions cannot switch through ACP. + const startupOnlyModelRuntime = agent.startupModelArg != null; // A live switch rides the agent's running session(s) instead of persisting a // new default. It applies only to a persona-linked running agent with at // least one active turn — those are the channels the desktop can name in the @@ -54,7 +57,10 @@ export function ModelPicker({ // running but wholly idle has no nameable channel here, so it falls through // to persisting the default (the only reachable lever from this surface). const isLiveSwitch = - agent.personaId !== null && isRunning && activeTurns.length > 0; + !startupOnlyModelRuntime && + agent.personaId !== null && + isRunning && + activeTurns.length > 0; const fetchModels = React.useCallback(async () => { setLoading(true); diff --git a/desktop/src/features/onboarding/ui/agentReadiness.test.mjs b/desktop/src/features/onboarding/ui/agentReadiness.test.mjs index 9d6deafb09..9867baa7f5 100644 --- a/desktop/src/features/onboarding/ui/agentReadiness.test.mjs +++ b/desktop/src/features/onboarding/ui/agentReadiness.test.mjs @@ -52,6 +52,19 @@ test("resolveAgentReadiness_cli_returns_ready_when_preferred_cli_runtime_is_logg }); }); +test("resolveAgentReadiness_cursor_returns_ready_when_preferred_cli_is_logged_in", () => { + const result = resolveAgentReadiness( + [makeRuntime({ id: "cursor", label: "Cursor Agent" })], + makeConfig({ preferred_runtime: "cursor" }), + "preferred", + ); + assert.deepEqual(result, { + ready: true, + reason: "cli", + runtimeLabel: "Cursor Agent", + }); +}); + test("resolveAgentReadiness_uses_only_the_preferred_runtime", () => { const runtimes = [ makeRuntime({ id: "claude", label: "Claude" }), diff --git a/desktop/src/features/onboarding/ui/agentReadiness.ts b/desktop/src/features/onboarding/ui/agentReadiness.ts index 86b9721af3..4eda7521eb 100644 --- a/desktop/src/features/onboarding/ui/agentReadiness.ts +++ b/desktop/src/features/onboarding/ui/agentReadiness.ts @@ -12,7 +12,7 @@ export type AgentReadinessResult = /** * Determine whether the user has a working agent path configured. * - * CLI path: the preferred Claude or Codex runtime is available and logged in. + * CLI path: the preferred Claude, Codex, or Cursor runtime is available and logged in. * Provider path: the preferred Buzz Agent or Goose runtime has provider and * model set, plus all required credential env vars for that provider. * @@ -47,7 +47,9 @@ export function resolveAgentReadiness( } if ( - (preferredRuntime.id === "claude" || preferredRuntime.id === "codex") && + (preferredRuntime.id === "claude" || + preferredRuntime.id === "codex" || + preferredRuntime.id === "cursor") && (preferredRuntime.authStatus.status === "logged_in" || preferredRuntime.authStatus.status === "not_applicable") ) { diff --git a/desktop/src/shared/api/tauri.ts b/desktop/src/shared/api/tauri.ts index 5406cf820d..88f4c2e6ed 100644 --- a/desktop/src/shared/api/tauri.ts +++ b/desktop/src/shared/api/tauri.ts @@ -124,6 +124,7 @@ export type RawManagedAgent = { agent_command: string; agent_command_override?: string | null; agent_args: string[]; + startup_model_arg?: string | null; mcp_command: string; turn_timeout_seconds: number; idle_timeout_seconds: number | null; @@ -191,6 +192,7 @@ export type RawAcpRuntimeCatalogEntry = { /** Tagged union with snake_case status values — same shape as `AuthStatus`. */ auth_status: AuthStatus; login_hint?: string; + startup_model_arg?: string | null; }; export type RawInstallStepResult = { @@ -693,6 +695,7 @@ export function fromRawManagedAgent(agent: RawManagedAgent): ManagedAgent { agentCommand: agent.agent_command, agentCommandOverride: agent.agent_command_override ?? null, agentArgs: agent.agent_args, + startupModelArg: agent.startup_model_arg ?? null, mcpCommand: agent.mcp_command, turnTimeoutSeconds: agent.turn_timeout_seconds, idleTimeoutSeconds: agent.idle_timeout_seconds, @@ -750,6 +753,7 @@ function fromRawAcpRuntimeCatalogEntry( nodeRequired: entry.node_required, authStatus: entry.auth_status, loginHint: entry.login_hint ?? null, + startupModelArg: entry.startup_model_arg ?? null, }; } diff --git a/desktop/src/shared/api/types.ts b/desktop/src/shared/api/types.ts index a82f766b63..cb1f9abcd4 100644 --- a/desktop/src/shared/api/types.ts +++ b/desktop/src/shared/api/types.ts @@ -355,6 +355,8 @@ export type ManagedAgent = { */ agentCommandOverride: string | null; agentArgs: string[]; + /** Catalog-derived startup model flag; non-null runtimes are not live-switchable. */ + startupModelArg?: string | null; mcpCommand: string; turnTimeoutSeconds: number; idleTimeoutSeconds: number | null; @@ -557,6 +559,8 @@ export type AcpRuntimeCatalogEntry = { authStatus: AuthStatus; /** Hint for completing authentication; null when not applicable or already logged in. */ loginHint: string | null; + /** Optional startup flag used to pin a model before ACP starts. */ + startupModelArg: string | null; }; /** An AcpRuntimeCatalogEntry that is confirmed available — command and binaryPath are non-null. */ diff --git a/docs/cursor-cli.md b/docs/cursor-cli.md new file mode 100644 index 0000000000..76a85cb1ea --- /dev/null +++ b/docs/cursor-cli.md @@ -0,0 +1,26 @@ +# Cursor CLI in Buzz + +Buzz can use Cursor's own CLI as a native ACP harness when `agent` (or the +backward-compatible `cursor-agent`) is available on the desktop PATH. + +Install Cursor CLI with Cursor's official installer, then sign in through the +vendor CLI: + +```sh +curl https://cursor.com/install -fsS | bash +agent login +``` + +Buzz discovers the command, checks `agent status`, and offers the existing +managed-agent sign-in flow. Buzz does not store Cursor OAuth tokens or API +keys. If Cursor advertises ACP authentication methods, Buzz uses them; +otherwise it opens the visible vendor login command. + +Cursor is launched through its native ACP entrypoint (`agent acp` or +`cursor-agent acp`). Available models are discovered from ACP when the CLI +advertises them. A selected model is passed at process startup with Cursor's +`--model` flag; changing it affects the next launch, not an already-running +session. + +On Windows, use the Cursor CLI from WSL. Buzz does not provide a native +Windows Cursor installer or promise native Windows execution. diff --git a/docs/plans/2026-07-26-001-feat-cursor-cli-native-acp-oauth-plan.md b/docs/plans/2026-07-26-001-feat-cursor-cli-native-acp-oauth-plan.md new file mode 100644 index 0000000000..ebf54d876d --- /dev/null +++ b/docs/plans/2026-07-26-001-feat-cursor-cli-native-acp-oauth-plan.md @@ -0,0 +1,430 @@ +--- +type: feat +title: "Add Cursor CLI native ACP OAuth and model selection" +date: 2026-07-26 +origin: null +product_contract_source: ce-plan-bootstrap +artifact_contract: ce-unified-plan/v1 +artifact_readiness: implementation-ready +execution: code +--- + +# Add Cursor CLI native ACP OAuth and model selection + +## Goal Capsule + +### Objective + +Add Cursor CLI as a first-class Buzz ACP runtime. Buzz should discover an installed Cursor CLI, guide the user through vendor-owned authentication, launch Cursor's native ACP harness, and expose the models advertised by Cursor for launch-time selection. + +### Authority and settled decisions + +The repository's existing discovery, readiness, ACP, and agent-model paths are authoritative for implementation shape. The following session decisions are settled and must be carried forward: + +- Cursor authentication remains vendor-owned: Buzz stores no Cursor OAuth tokens and delegates to Cursor's generic ACP `authMethods` when advertised, with a visible `agent login` / `cursor-agent login` fallback. (session-settled: user-directed — chosen over Buzz-managed OAuth storage: preserves Cursor's own account/session handling and avoids a new credential boundary) +- Model selection is launch-time only. Buzz must not promise switching the active Cursor model inside an already-running session. (session-settled: user-directed — chosen over live ACP switching: Cursor runtime switching is unreliable) +- Models come from Buzz's existing ACP discovery path, not hardcoded IDs or parsing human-readable Cursor output. (session-settled: user-directed — chosen over CLI-output parsing and hardcoded catalogs: keeps the model list vendor-authoritative) +- Detection resolves the command first and validates real ACP/model/auth operations when used; no hardcoded minimum Cursor version and no ACP handshake on every catalog refresh. (session-settled: user-directed — chosen over version gates and eager handshakes: tolerates Cursor CLI churn while keeping discovery responsive) +- Support is native macOS/Linux and Windows only through WSL when Cursor is available there. Do not promise a native Windows installer or native Windows Cursor runtime. (session-settled: user-directed — chosen over native Windows support: matches Cursor's documented availability boundary) + +### Stop conditions + +Stop when Cursor appears in the shared runtime catalog, can be installed or guided into readiness, can complete vendor authentication through the supported path, can launch `agent acp`/`cursor-agent acp`, and can launch with a selected ACP-discovered model. Do not expand into mid-session model switching, Buzz token storage, Cursor API-key management, mobile/web surfaces, or a separate non-ACP Cursor adapter. + +### Execution profile + +Deep implementation plan. The work crosses Tauri discovery/readiness/auth, the Rust ACP helper, process argument construction, model discovery, frontend capability projection, platform handling, and regression coverage. + +### Tail ownership + +The implementer owns focused tests, `just ci`, cleanup of abandoned experiments, and the final contribution handoff. The plan file is not an execution-status ledger. + +## Product Contract + +### Summary + +Buzz already supports vendor CLIs such as Claude Code and Codex through its shared ACP runtime catalog. Cursor CLI now exposes a native ACP entrypoint and vendor-owned login flow. Adding it through the same catalog and onboarding surfaces lets a Buzz user select Cursor as the harness, authenticate through Cursor, choose an available Cursor model before launch, and run the Cursor harness without giving Buzz custody of Cursor credentials. + +### Problem frame + +Cursor CLI is installed and authenticated independently from Buzz, but Buzz currently has no runtime metadata or launch strategy for it. Treating Cursor as an npm adapter would bypass the vendor's native harness and would make authentication/model behavior brittle. The integration must therefore preserve the existing generic ACP contract while allowing a native CLI subcommand and a startup-only model argument. + +### Actors + +- Buzz desktop user configuring or starting a managed agent. +- Buzz desktop discovery/readiness/auth/model IPC. +- `buzz-acp`, which owns ACP subprocess startup and model catalog extraction. +- Cursor CLI (`agent`, with `cursor-agent` compatibility), which owns OAuth/session state and native ACP. +- Windows WSL environment, when the desktop host is Windows and Cursor is installed only inside WSL. + +### Requirements + +#### R1. Cursor runtime discovery + +Add a stable runtime ID for Cursor and expose it through the existing `AcpRuntimeCatalogEntry` path. Resolve the modern `agent` command first and support `cursor-agent` as a compatibility entrypoint. Discovery must distinguish Cursor's native ACP command from an external adapter and must not require an npm package. + +#### R2. Install and platform guidance + +Show the official Cursor CLI installation guidance and installation action where the existing runtime installer can safely provide it. On macOS/Linux use Cursor's official installer path. On Windows guide the user to install/use Cursor CLI in WSL; do not present a native Windows Cursor installer as supported. Preserve the existing command-resolution/PATH behavior used by Claude and Codex. + +#### R3. Readiness and authentication + +When Cursor is detected but not authenticated, show a readiness requirement with a copyable/runnable vendor command. Prefer the generic ACP `authMethods` flow when Cursor advertises one; otherwise launch a visible terminal for `agent login` or `cursor-agent login`, selected from the resolved entrypoint. Re-running discovery/readiness after login must clear the requirement. Buzz must never persist Cursor OAuth tokens or API keys. + +#### R4. Native ACP lifecycle + +Every Cursor ACP helper and managed-agent launch must invoke the native ACP subcommand (`agent acp` or `cursor-agent acp`) with the correct working directory, environment, stdio, and shutdown behavior. `buzz-acp models`, `auth-methods`, `authenticate`, and normal relay startup must all use the same normalized launch specification. + +#### R5. ACP model catalog + +Use the existing `buzz-acp models --json` → ACP `initialize`/`session/new` → catalog normalization path. Surface stable `configOptions` model values and unstable model state when Cursor advertises them, deduplicate them through the shared normalizer, and preserve empty/failure semantics. Do not parse `agent models` output and do not hardcode Cursor model IDs. + +#### R6. Launch-time model selection + +Persist the selected model in the existing managed-agent model field and translate it into Cursor's startup `--model ` argv when the managed agent is spawned. Discovery must enumerate the full catalog without pinning to the persisted model. If no model is selected or the catalog is empty, omit `--model` and let Cursor choose its default. Avoid duplicate/conflicting startup model flags and pass model IDs as argv values, never shell-interpolated command text. + +#### R7. Shared UI and onboarding behavior + +Cursor must flow through the existing runtime catalog, setup/readiness, authentication, and model controls without frontend hardcoded harness checks. The shared capability projection must mark Cursor as ACP-native with model discovery available and live model switching unavailable. Empty model discovery may hide the optional selector using the same existing Claude/Codex rule, while discovery failures remain visible/actionable. + +#### R8. Compatibility and failure behavior + +Command resolution is the detection signal. Real ACP/model/auth operations are the compatibility validation. Missing CLI, missing/unsupported WSL, unauthenticated CLI, failed ACP handshake, and empty model catalogs must produce actionable existing-state errors rather than crashes, panics, or silent fallback to a different harness. + +### Primary flow + +1. Buzz discovers the Cursor entrypoint and records availability/auth status in the shared runtime catalog. +2. The user selects Cursor; if needed, Buzz shows official install or login guidance. +3. Buzz runs the generic ACP auth method when advertised, or opens the vendor login command in a visible terminal. +4. Buzz runs ACP model discovery and populates the existing model picker from Cursor's response. +5. The user selects a model and saves the managed-agent configuration. +6. On launch, Buzz starts `buzz-acp`; `buzz-acp` starts Cursor native ACP with `--model ` when selected. + +### Acceptance examples + +- **AE1 — macOS/Linux happy path:** With `agent` on PATH and Cursor logged in, runtime discovery reports Cursor available and authenticated; model discovery returns Cursor-advertised models; selecting one causes the next launch to include exactly one `--model ` before `acp` reaches Cursor. +- **AE2 — legacy command:** With only `cursor-agent` available, Buzz discovers it, uses `cursor-agent acp`, probes `cursor-agent status`, and uses `cursor-agent login` if auth is needed. +- **AE3 — generic ACP auth:** If Cursor's `initialize` response advertises `authMethods`, Buzz presents those methods and invokes the selected method through the generic ACP helper without writing a token to Buzz storage. +- **AE4 — terminal fallback:** If no usable ACP auth method is advertised, Buzz opens a visible terminal with the resolved Cursor CLI login argv. Completing login and refreshing readiness makes the runtime ready. +- **AE5 — empty/failed model discovery:** An empty catalog omits the optional model selector and preserves Cursor's default. A timeout or malformed ACP response leaves an actionable discovery error and does not claim model selection is available. +- **AE6 — launch-time boundary:** Changing the saved model applies on the next process launch. An already-running Cursor process is not reconfigured and the UI does not claim that it was switched live. +- **AE7 — Windows boundary:** A Windows build never offers native Cursor installation as supported. When WSL and Cursor are available, Buzz can use the WSL launch specification; otherwise it explains that Cursor CLI must be installed in WSL and leaves the runtime unavailable. + +### Success criteria + +- Cursor is selectable from the same managed-agent runtime catalog used by Claude Code and Codex. +- Authentication remains in Cursor's own CLI/ACP flow; no Cursor credential material is added to Buzz persistence or logs. +- A selected model is observable in the child argv/launch contract and is applied only at process startup. +- Model options are sourced exclusively from the ACP response and remain resilient to Cursor model additions/removals. +- Existing Claude, Codex, Goose, Buzz Agent, custom runtime, and non-Cursor model/readiness behavior remains unchanged. +- Focused Rust/frontend tests cover direct launch, legacy alias, auth paths, WSL boundary, model selection, empty discovery, and failure behavior. + +### Scope boundaries + +In scope: desktop runtime catalog metadata, command resolution, install/readiness guidance, generic ACP auth integration, native ACP launch, ACP model discovery, launch-time model argv, shared capability projection, Windows WSL boundary, docs/tests. + +Out of scope: Buzz-managed Cursor OAuth/token storage, direct Cursor API-key entry or secret migration, mid-session model switching, parsing `agent models` text, hardcoded model catalogs, native Windows Cursor support, mobile/web runtime surfaces, Cursor-specific non-ACP adapter packages, automatic Cursor account logout. + +### Dependencies + +- Cursor CLI must expose the native ACP entrypoint and the advertised ACP protocol/auth behavior used at implementation time. +- Cursor's official CLI installation/authentication commands may evolve; keep URLs and command behavior centralized in runtime metadata and test the actual operation rather than a version gate. +- WSL must be installed and contain a usable Cursor CLI for the Windows path; Buzz must not silently execute a Windows alias that is actually a WSL launcher. + +### Sources + +- Cursor CLI authentication: https://docs.cursor.com/en/cli/reference/authentication +- Cursor CLI parameters and `--model`: https://docs.cursor.com/en/cli/reference/parameters +- Cursor CLI overview: https://docs.cursor.com/en/cli/overview +- Cursor CLI installation/platform guidance: https://docs.cursor.com/en/cli/installation +- Cursor CLI changelog describing `agent` and `cursor-agent`: https://cursor.com/changelog/cli-jan-08-2026 +- Cursor ACP/model-selection behavior observed in the vendor forum: https://forum.cursor.com/t/acp-model-selection-api-removed/160063 + +## Planning Contract + +### Key technical decisions + +- **KTD1 — Treat Cursor as a native ACP runtime.** Extend the runtime catalog and ACP helper to carry an executable-plus-argv launch specification. Cursor's executable is `agent` or `cursor-agent`; its ACP mode is the `acp` subcommand. Do not install or invoke an npm adapter. (session-settled: user-directed — chosen over adapter wrapping: preserves Cursor's own harness and OAuth/session behavior) +- **KTD2 — Keep auth vendor-owned and generic first.** Reuse `authMethods`/`authenticate` when advertised. Add a metadata-driven terminal fallback using the resolved Cursor entrypoint and `login`; do not add a Cursor token store or provider secret field. (session-settled: user-directed — chosen over Buzz-owned OAuth: avoids duplicating vendor credential lifecycle) +- **KTD3 — Use one launch-spec normalizer.** The desktop normalizer, `buzz-acp models`, auth helpers, and normal relay spawn must consume the same native-runtime command/argv rules. This prevents the current desktop/helper split from turning `agent` into an accidental zero-arg launch. +- **KTD4 — Apply model at startup through argv.** Keep `BUZZ_ACP_MODEL` as the existing logical selection transport, then convert it in the Cursor launch-spec builder to a safe `--model`, `` pair only for normal Cursor process startup. The model-discovery helper intentionally omits the persisted selection so it can enumerate all advertised models. (session-settled: user-directed — chosen over ACP live switching: Cursor runtime switching is unreliable) +- **KTD5 — Make readiness probe aliases dynamic.** Cursor auth status must be probed as ` status`, and fallback login as ` login`, so the same metadata supports both `agent` and `cursor-agent`. Existing Claude/Codex probe behavior remains compatible. +- **KTD6 — Represent WSL explicitly.** Do not classify a WindowsApps `bash.exe`/WSL alias as a native Cursor executable. Add a WSL launch/discovery branch that invokes the real `wsl.exe` command with the Cursor ACP argv, uses the default WSL distribution and an explicit working-directory conversion, and transports only the required Buzz environment through the existing WSL environment mechanism rather than putting secrets in argv. Report WSL-specific install guidance and use the same model/auth contract. Native Windows discovery remains unavailable by design. +- **KTD7 — Preserve generic capability projection.** Add Cursor facts to `KnownAcpRuntime` and `AcpRuntimeCatalogEntry` only where required; let `agentConfigCore` derive model-control behavior from runtime metadata. No Cursor ID checks in React render paths. + +### High-level technical design + +```text +KnownAcpRuntime (Cursor metadata) + | + v +command/WSL launch-spec resolver ----> availability + auth status + | | + | +--> generic authMethods/authenticate + | +--> vendor login terminal fallback + v +desktop IPC catalog --> shared agentConfigCore --> model picker + | + +--> buzz-acp models --json --> Cursor `... acp` --> ACP session/new catalog + | + +--> managed spawn --> BUZZ_ACP_MODEL --> native launch-spec builder + --> Cursor `... --model acp` +``` + +The launch-spec builder should be a pure, testable function with separate modes for model discovery, auth helper, and normal runtime startup. It must preserve explicit user args, add the native `acp` subcommand exactly once, and add/replace the selected model only in normal startup mode. WSL resolution should produce an explicit `wsl.exe` executable and argument vector rather than relying on shell aliases. + +### Implementation constraints + +- Follow root `AGENTS.md` and `desktop/src/features/agents/AGENTS.md`; use Hermit before Git/hooks and do not add production `unwrap`/`expect`. +- Keep all command arguments structured; never construct a shell command by interpolating a model ID or auth input. +- Keep token/API-key values out of logs, errors, persisted records, and plan/test fixtures. +- Preserve `KnownAcpRuntime` as the authority for harness facts and keep shared onboarding/model acceptance tests green. +- Do not add a version gate for Cursor. Handshake/model/auth failures are runtime compatibility signals. +- Update runtime comments that currently enumerate only Claude/Codex CLI-login runtimes. + +### Sequencing + +Implement in dependency order: U1 launch/discovery primitives → U2 readiness/auth → U3 ACP model discovery and startup model application → U4 shared capability/UI regression coverage → U5 documentation and full verification. U2 and U3 may share the launch-spec helper but must not duplicate its normalization logic. + +### System-wide impact + +This changes the external-process trust boundary and the persisted meaning of a selected model. Cursor owns OAuth/session state; Buzz owns only runtime metadata and the user's selected model ID. The model ID crosses desktop IPC, environment transport, `buzz-acp`, and child argv, so each boundary needs structured validation and redaction-aware errors. Discovery remains cheap; ACP handshakes run only for explicit auth/model operations or actual launch. + +### Risks and mitigations + +- **Cursor CLI churn:** Centralize command/install/auth metadata, support both entrypoints, and validate the actual ACP operation. +- **ACP auth shape differs by build:** Parse generic methods defensively and retain terminal fallback based on explicit vendor command metadata. +- **Model startup flag conflicts with user args:** Build a canonical argv and test empty, explicit, duplicate, and selected-model cases. +- **Windows/WSL path mismatch:** Keep WSL as an explicit launch mode, reject WindowsApps aliases, and make unsupported native Windows state actionable. +- **Credential leakage:** Never serialize tokens; redact subprocess stderr using the existing environment-redaction path; use visible terminal only for vendor-owned login. +- **Regression in existing runtimes:** Preserve current zero-arg/default-arg tests and run focused Claude/Codex auth/model/readiness suites before the full gate. + +## Implementation Units + +### U1. Add Cursor runtime metadata and native/WSL launch-spec resolution + +**Goal:** Make Cursor a first-class discovered runtime with direct `agent`/`cursor-agent` support and an explicit Windows WSL boundary. + +**Requirements:** R1, R2, R4, R8. + +**Files:** + +- `desktop/src-tauri/src/managed_agents/discovery.rs` +- `desktop/src-tauri/src/managed_agents/discovery/runtime_metadata.rs` +- `desktop/src-tauri/src/managed_agents/discovery/tests.rs` +- `desktop/src-tauri/src/managed_agents/types.rs` +- `desktop/src-tauri/src/managed_agents/runtime.rs` +- `desktop/src-tauri/src/commands/agent_model_process.rs` +- `crates/buzz-acp/src/config.rs` +- `crates/buzz-acp/src/lib.rs` +- `crates/buzz-acp/src/acp.rs` (only if the launch-spec integration requires a small ACP spawn seam) + +**Approach:** + +- Add Cursor metadata with a stable ID, label/avatar, `agent` primary command, `cursor-agent` fallback, no adapter install step, official install URL/commands, and native ACP default args. +- Introduce a shared structured launch-spec/argv normalization path that can distinguish direct Unix/macOS/Linux launch from Windows WSL launch. Keep discovery's `command`/`binary_path` catalog fields compatible with existing UI consumers. +- Ensure all helper paths pass `... acp` exactly once and resolve the executable through the existing augmented PATH. WSL must use `wsl.exe` explicitly and must not mistake a WindowsApps alias for a native binary. +- Keep model discovery on the base ACP argv; reserve selected-model insertion for U3's normal launch mode. + +**Test scenarios:** + +- `agent` resolves before `cursor-agent`; both normalize to `acp`. +- Explicit Cursor args remain intact, while a legacy/default `acp` value is normalized without duplication. +- Missing direct command is `NotInstalled`/actionable rather than `AdapterMissing`. +- WindowsApps WSL alias is rejected as a native executable; a real WSL launch spec is recognized only when WSL and Cursor are callable. +- Existing Goose/Claude/Codex argument normalization is unchanged. + +**Verification:** Run the focused discovery and `buzz-acp` config tests; inspect the generated catalog entry and launch argv in unit fixtures. + +### U2. Add Cursor readiness and vendor-owned authentication + +**Goal:** Make Cursor setup and login behave like the existing Claude/Codex CLI flows while preferring generic ACP auth. + +**Requirements:** R2, R3, R8. + +**Files:** + +- `desktop/src-tauri/src/managed_agents/readiness.rs` +- `desktop/src-tauri/src/managed_agents/readiness/cli_login.rs` +- `desktop/src-tauri/src/managed_agents/discovery.rs` +- `desktop/src-tauri/src/commands/agent_auth.rs` +- `desktop/src-tauri/src/managed_agents/discovery/tests.rs` +- `desktop/src-tauri/src/commands/agent_auth.rs` (unit tests) +- `desktop/src-tauri/src/managed_agents/readiness.rs` (unit tests) + +**Approach:** + +- Generalize the CLI-login readiness path so Cursor's status probe is derived from the resolved command (`agent status` or `cursor-agent status`) and its setup copy/login hint is derived from the same entrypoint. +- Feed Cursor's native ACP process into `buzz-acp auth-methods --json`. If an advertised method is usable, invoke `buzz-acp authenticate --method-id` through the existing generic IPC flow. If ACP initialization fails with an auth-required/unauthenticated result before `authMethods` can be read, classify that as the same fallback condition rather than surfacing a dead-end error. +- If no usable auth method is advertised, or auth-method discovery cannot initialize because Cursor is logged out, use explicit terminal auth metadata/fallback argv for ` login`; preserve visible-terminal behavior and do not add token persistence. +- Make missing CLI, missing WSL, logged-out, and invalid-config outcomes use existing `Requirement`/`AuthStatus` states and actionable hints. + +**Test scenarios:** + +- Logged-in `agent status` yields no login requirement; logged-out status yields `Requirement::CliLogin` with a safe setup command. +- Legacy `cursor-agent status`/`login` uses the legacy entrypoint. +- Generic ACP auth method discovery and authenticate route to Cursor native ACP. +- Empty auth methods invoke the visible terminal fallback; malformed auth metadata returns a clear error without shell execution. +- No token or secret is present in the catalog, requirement, or surfaced error. + +**Verification:** Run readiness, auth command, and discovery tests; manually inspect a sanitized auth-method fixture and verify no credential-bearing output is persisted. + +### U3. Discover Cursor models and apply the selected model at process startup + +**Goal:** Populate the shared model picker from Cursor ACP and launch the selected model through Cursor's native startup flag. + +**Requirements:** R5, R6, R8. + +**Files:** + +- `desktop/src-tauri/src/commands/agent_model_process.rs` +- `desktop/src-tauri/src/commands/agent_models.rs` +- `desktop/src-tauri/src/commands/agent_models_tests.rs` +- `desktop/src-tauri/src/managed_agents/runtime.rs` +- `desktop/src-tauri/src/managed_agents/runtime/tests.rs` +- `crates/buzz-acp/src/config.rs` +- `crates/buzz-acp/src/lib.rs` +- `crates/buzz-acp/src/acp.rs` +- `crates/buzz-acp/src/config.rs` (unit tests) +- `crates/buzz-acp/src/lib.rs` (unit tests) + +**Approach:** + +- Keep `buzz-acp models --json` as the only Cursor catalog source and run it with the base native ACP argv, without the persisted model selection. +- Extend the normalized launch configuration so `BUZZ_ACP_MODEL` becomes a structured Cursor startup `--model`, `` pair only for normal managed-agent startup. Validate the selected value against the fresh ACP catalog when the catalog is available; if validation cannot run, preserve existing fallback/error semantics rather than inventing a model. +- Make duplicate `--model` handling deterministic: the Buzz-selected model is represented once, user-supplied conflicting startup flags are not duplicated, and no model ID is interpolated into a shell string. +- Keep existing ACP session model-switch code intact for runtimes that support it, but mark Cursor as not live-switchable in capability metadata so the UI only promises next-launch behavior. + +**Test scenarios:** + +- Cursor ACP `configOptions` and unstable model state normalize to a deduplicated model list. +- Empty catalog returns no models and leaves startup on Cursor default. +- Malformed/timeout discovery produces a failure state without claiming model control. +- Selected `gpt-5`-style value yields `--model`, `gpt-5`, `acp` exactly once in normal startup; no selection yields no `--model`. +- Discovery argv never contains the persisted model; Claude/Codex model behavior remains unchanged. +- Model IDs containing spaces, quotes, or shell metacharacters remain single argv values and are redacted safely in errors. + +**Verification:** Run `agent_models_tests`, managed-agent runtime tests, `buzz-acp` config/lib tests, and a fake ACP subprocess fixture that records argv and emits stable/unstable model responses. + +### U4. Project Cursor capabilities through shared agent configuration and regression surfaces + +**Goal:** Make Cursor model/auth/readiness behavior appear through existing onboarding and agent configuration contracts without frontend-specific runtime branches. + +**Requirements:** R3, R5, R6, R7, R8. + +**Files:** + +- `desktop/src/features/agents/lib/agentConfigCore.ts` +- `desktop/src/features/agents/lib/agentConfigCore.test.mjs` +- `desktop/src/features/agents/ui/agentConfigFieldsContract.test.mjs` +- `desktop/src/features/agents/ui/usePersonaModelDiscovery.test.mjs` +- `desktop/src/features/agents/ui/personaModelDiscoveryStatus.test.mjs` +- `desktop/src/features/agents/ui/editAgentProviderDiscovery.test.mjs` +- `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` +- `desktop/tests/e2e/doctor-states.spec.ts` +- `desktop/tests/e2e/edit-agent.spec.ts` +- `desktop/tests/e2e/agents.spec.ts` +- `desktop/src/shared/api/tauri.ts` (only if the catalog IPC contract gains a field) + +**Approach:** + +- Project the Rust catalog's Cursor-native capability facts through the existing raw catalog and `agentConfigCore` descriptors. Do not add Cursor ID conditionals to render components. +- Verify the model control is optional/hidden after successful empty discovery, stays actionable on failure, and does not advertise live switching. +- Add onboarding/readiness/auth regression coverage using the existing shared fixtures or fake ACP agent rather than real Cursor credentials. +- If no IPC shape changes are required, leave `tauri.ts` untouched and record that the existing generic fields were sufficient. + +**Test scenarios:** + +- Cursor appears in the runtime catalog and shared renderer with the correct native ACP/model metadata. +- Auth-required Cursor shows the same setup/connect state as other CLI-login runtimes. +- Empty/failure model discovery produces the expected control/status contract. +- Selecting a Cursor model persists the managed-agent model field and does not offer live switching. +- Existing Claude/Codex onboarding acceptance tests remain green. + +**Verification:** Run the focused desktop unit tests, then the affected Playwright E2E specs with the repository's supported desktop test setup. + +### U5. Document the supported Cursor path and complete contribution verification + +**Goal:** Make the new runtime discoverable to maintainers/users and leave a clean, reviewable contribution. + +**Requirements:** R1, R2, R3, R6, R8. + +**Files:** + +- `crates/buzz-acp/README.md` +- `desktop/src/features/agents/AGENTS.md` (only if the implementation changes a durable agent-surface rule; otherwise add an explicit “no rules changed” note in the PR description, not this file) +- Any repository documentation page that currently lists supported ACP runtimes, located by `rg` during implementation. + +**Approach:** + +- Add Cursor to the supported ACP runtime documentation with the native `agent acp`/`cursor-agent acp` distinction, vendor login ownership, launch-time model selection, and Windows WSL boundary. +- Keep docs free of real account names, tokens, or machine-specific paths. +- Run the full repository gate and review the final diff for abandoned adapter experiments, duplicated model-switch code, credential leakage, and unintended changes to other runtimes. + +**Test scenarios:** + +- README examples use placeholders and match the implemented environment/argv contract. +- Documentation does not claim native Windows support or live Cursor model switching. +- `AGENTS.md` is changed only if a durable rule genuinely changed. + +**Verification:** Run `just ci`; if environment/toolchain constraints prevent it, run the documented focused gates and report the exact skipped gate and reason. Confirm `git status` contains only intended implementation, test, and documentation changes. + +## Verification Contract + +### Focused gates + +- `just test-unit` +- `cargo test -p buzz-acp` +- The focused Tauri Rust test targets covering discovery, readiness, auth, model process/normalization, and managed runtime tests. +- The focused desktop JavaScript tests listed in U4 through the repository's existing test runner. +- A fake ACP integration fixture that records the child argv and simulates Cursor initialize/session-new/auth responses, with no real credentials. + +### Full gate + +- Activate the repository's Hermit environment before running hooks/tooling as required by `AGENTS.md`. +- Run `just ci` before presenting the contribution as ready. This includes formatting, linting, desktop checks, unit tests, and builds according to the current repository task definitions. +- If desktop E2E requires a separately configured environment, run the affected specs and record any environment-only limitation separately from code failures. + +### Behavioral evidence required + +- Direct `agent` and legacy `cursor-agent` detection/launch evidence. +- macOS/Linux auth and model-discovery fixture evidence. +- Windows WSL supported/unsupported boundary evidence. +- Proof that no selected model is sent during catalog enumeration and that normal startup includes the selected model once. +- Proof that no Cursor credential material enters Buzz persistence, logs, test fixtures, or error messages. + +## Definition of Done + +### Global + +- [ ] R1–R8 and AE1–AE7 are implemented or explicitly demonstrated by tests. +- [ ] Cursor uses native ACP; no separate Cursor adapter package or hardcoded model list was added. +- [ ] Auth remains vendor-owned and no Cursor token/API key is stored by Buzz. +- [ ] Launch-time model selection works, while live switching is not advertised or attempted for Cursor. +- [ ] Direct macOS/Linux and WSL-bounded Windows behavior is explicit and actionable. +- [ ] Existing Claude, Codex, Goose, Buzz Agent, and custom-runtime behavior remains green. +- [ ] Focused tests and `just ci` pass, or exact environment blockers are reported. +- [ ] All abandoned experiments, temporary fixtures, debug logging, and dead code are removed. + +### Per-unit completion + +- [ ] U1 has pure launch-spec/argv tests for direct, legacy alias, and WSL paths. +- [ ] U2 has readiness, generic ACP auth, terminal fallback, and redaction tests. +- [ ] U3 has model normalization, empty/failure, startup `--model`, and no-duplicate tests. +- [ ] U4 has shared capability, onboarding, auth/readiness, and model-control regression coverage. +- [ ] U5 has accurate docs and a clean contribution diff. + +## Appendix + +### Repository anchors + +- `desktop/src-tauri/src/managed_agents/discovery.rs` is the current `KNOWN_ACP_RUNTIMES` catalog and command/argument normalization source. +- `desktop/src-tauri/src/managed_agents/readiness.rs` routes known CLI-login runtimes into `readiness/cli_login.rs`. +- `desktop/src-tauri/src/commands/agent_auth.rs` already provides generic ACP auth-method discovery plus visible terminal fallbacks. +- `desktop/src-tauri/src/commands/agent_model_process.rs` is the existing `buzz-acp models --json` subprocess boundary. +- `crates/buzz-acp/src/lib.rs` owns `models`, `auth-methods`, `authenticate`, and normal pool startup. +- `desktop/src/features/agents/lib/agentConfigCore.ts` projects Rust catalog facts into shared model/auth/config controls. + +### Contribution notes + +The current repository is clean on `main` before this plan artifact. The prior session stopped after cloning `block/buzz` into `Buzz-Opensource`; no product changes from that session were carried into this work.