From 18acd8afc09a6918ba9bc8802261af4b991b1df9 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Thu, 23 Jul 2026 12:02:43 -0400 Subject: [PATCH 1/3] feat(desktop): register Cursor as native ACP runtime + onboarding harness Users only saw Claude Code and Codex on the harness setup page. Cursor already speaks ACP natively (`agent acp` / `cursor-agent acp`). - Add cursor to KNOWN_ACP_RUNTIMES (install, auth probe, skills, PATH) - Default args acp; readiness via `agent status` / `agent login` - Show Cursor on onboarding harness page (with Claude + Codex) - Tests + buzz-acp README note on extension-method follow-up Refs #1683. Companion: Grok Build remains #2347. Signed-off-by: Bartok9 --- crates/buzz-acp/README.md | 44 ++++++++++++++++- .../src-tauri/src/managed_agents/discovery.rs | 49 +++++++++++++++++-- .../src/managed_agents/discovery/tests.rs | 45 ++++++++++++++++- .../src-tauri/src/managed_agents/readiness.rs | 3 ++ .../src-tauri/src/managed_agents/runtime.rs | 4 ++ .../src/managed_agents/runtime/tests.rs | 19 +++++++ .../features/onboarding/ui/agentReadiness.ts | 4 +- .../ui/onboardingRuntimeSelection.test.mjs | 6 ++- .../ui/onboardingRuntimeSelection.ts | 2 +- 9 files changed, 164 insertions(+), 12 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index d9cd362cb8..8451827caf 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -9,7 +9,7 @@ Buzz Relay ──WS──→ buzz-acp ──stdio──→ Your Agent (send_message, etc.) ``` -Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)). +Supports any agent that speaks [ACP](https://agentclientprotocol.com/) over stdio: **goose**, **cursor** (native `agent acp`), **codex** (via [codex-acp](https://github.com/agentclientprotocol/codex-acp)), and **claude code** (via [claude-agent-acp](https://github.com/agentclientprotocol/claude-agent-acp)). ## Prerequisites @@ -89,6 +89,26 @@ buzz-acp Older installs that still expose `claude-code-acp` are also supported. `buzz-acp` treats both Claude ACP command names as the same zero-arg runtime. +## Running with Cursor + +Cursor's Agent CLI speaks ACP natively — no separate `*-acp` adapter. + +```bash +# Install: https://cursor.com/docs/cli (binary lands as `agent` / `cursor-agent`) +agent login # or export CURSOR_API_KEY=... + +export BUZZ_ACP_AGENT_COMMAND="agent" +export BUZZ_ACP_AGENT_ARGS="acp" + +buzz-acp +``` + +> **Harness note:** Cursor ACP can emit blocking extension methods +> (`cursor/ask_question`, `cursor/create_plan`). Headless `buzz-acp` needs an +> explicit client policy for those (auto-allow vs reject-with-reason) so turns +> neither hang nor over-permit. That policy is a separate design item from +> registering the runtime in Desktop's `KNOWN_ACP_RUNTIMES`. + ## Configuration All configuration is via environment variables (or CLI flags — every env var has a matching flag). @@ -169,7 +189,27 @@ buzz-acp --respond-to anyone buzz-acp --respond-to nobody --heartbeat-interval 300 ``` -### Configuration Examples +### Running with Cursor + +Cursor's Agent CLI speaks ACP natively — no separate `*-acp` adapter. + +```bash +# Install: https://cursor.com/docs/cli (binary lands as `agent` / `cursor-agent`) +agent login # or export CURSOR_API_KEY=... + +export BUZZ_ACP_AGENT_COMMAND="agent" +export BUZZ_ACP_AGENT_ARGS="acp" + +buzz-acp +``` + +> **Harness note:** Cursor ACP can emit blocking extension methods +> (`cursor/ask_question`, `cursor/create_plan`). Headless `buzz-acp` needs an +> explicit client policy for those (auto-allow vs reject-with-reason) so turns +> neither hang nor over-permit. That policy is a separate design item from +> registering the runtime in Desktop's `KNOWN_ACP_RUNTIMES`. + +## Configuration Examples **Single agent, no heartbeat (default):** ```bash diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index f40eed5a13..a3019579fe 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -13,10 +13,11 @@ mod runtime_metadata; pub(crate) use runtime_metadata::KnownAcpRuntime; -const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; -const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; -const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; -const BUZZ_AGENT_AVATAR_URL: &str = +pub(crate) const GOOSE_AVATAR_URL: &str = "https://goose-docs.ai/img/logo_dark.png"; +pub(crate) const CLAUDE_CODE_AVATAR_URL: &str = "https://anthropic.gallerycdn.vsassets.io/extensions/anthropic/claude-code/2.1.77/1773707456892/Microsoft.VisualStudio.Services.Icons.Default"; +pub(crate) const CODEX_AVATAR_URL: &str = "https://openai.gallerycdn.vsassets.io/extensions/openai/chatgpt/26.5313.41514/1773706730621/Microsoft.VisualStudio.Services.Icons.Default"; +pub(crate) const CURSOR_AVATAR_URL: &str = "https://www.cursor.com/favicon.ico"; +pub(crate) const BUZZ_AGENT_AVATAR_URL: &str = "https://raw.githubusercontent.com/block/buzz/refs/heads/main/crates/buzz-agent/buzz-agent.png"; fn common_binary_paths() -> &'static [PathBuf] { @@ -38,6 +39,7 @@ fn common_binary_paths() -> &'static [PathBuf] { paths.extend([ home.join(".local/share/mise/shims"), home.join(".local/bin"), + home.join(".cursor/bin"), home.join(".volta/bin"), home.join(".asdf/shims"), ]); @@ -157,6 +159,42 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ // Verified: `codex login status` exits 0 when logged in, non-zero otherwise. auth_probe_args: Some(&["codex", "login", "status"]), }, + + // Cursor Agent CLI speaks ACP natively (`agent acp` / `cursor-agent acp`) — + // no separate *-acp npm adapter (same class as Goose). Auth: `agent login` + // or CURSOR_API_KEY. Headless extension methods (ask_question / create_plan) + // need a separate buzz-acp client policy; not part of this registry entry. + KnownAcpRuntime { + id: "cursor", + label: "Cursor", + commands: &["agent", "cursor-agent"], + aliases: &["cursor"], + avatar_url: CURSOR_AVATAR_URL, + mcp_command: Some("buzz-dev-mcp"), + mcp_hooks: false, + underlying_cli: Some("agent"), + cli_install_commands: &["curl -fsSL https://cursor.com/install | bash"], + cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://cursor.com/install?win32=true | iex\""], + adapter_install_commands: &[], + install_instructions_url: "https://cursor.com/docs/cli/acp", + cli_install_hint: "Install the Cursor Agent CLI via the official install script.", + adapter_install_hint: "", + skill_dir: Some(".cursor/skills"), + supports_acp_model_switching: false, + model_env_var: None, + provider_env_var: None, + provider_locked: false, + 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` to authenticate (or set CURSOR_API_KEY)."), + auth_probe_args: Some(&["agent", "status"]), + }, KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -342,7 +380,8 @@ 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 + Cursor speak ACP as a subcommand (`goose acp`, `agent acp`). + "goose" | "agent" | "cursor-agent" | "cursor" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 0ed4fe0f6a..4035696e44 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -7,7 +7,7 @@ use super::{ effective_agent_command, find_nvm_default_bin, find_via_login_shell, is_login_shell_path_uninit, is_safe_nvm_tag, managed_agent_avatar_url, normalize_agent_args, parse_semver_tag, probe_codex_acp_major_version, record_agent_command, - refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, + refresh_login_shell_path, BUZZ_AGENT_AVATAR_URL, CLAUDE_CODE_AVATAR_URL, CODEX_AVATAR_URL, CURSOR_AVATAR_URL, GOOSE_AVATAR_URL, }; use crate::managed_agents::AcpAvailabilityStatus; @@ -72,6 +72,49 @@ fn normalizes_claude_and_codex_args_to_empty() { ); } + +#[test] +fn normalizes_cursor_args_to_acp_subcommand() { + // Cursor speaks ACP natively as `agent acp` (same shape as goose). + assert_eq!( + normalize_agent_args("agent", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + normalize_agent_args("cursor-agent", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + normalize_agent_args("cursor", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + normalize_agent_args("agent", vec!["acp".into()]), + vec!["acp".to_string()] + ); +} + +#[test] +fn resolves_cursor_avatar() { + assert_eq!( + managed_agent_avatar_url("agent"), + Some(CURSOR_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("cursor-agent"), + Some(CURSOR_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("/Users/me/.local/bin/agent"), + Some(CURSOR_AVATAR_URL.to_string()) + ); + assert_eq!( + managed_agent_avatar_url("cursor"), + Some(CURSOR_AVATAR_URL.to_string()) + ); +} + + #[test] fn resolves_buzz_agent_avatar() { assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 87ee6241ee..f94aaa9b79 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -289,6 +289,9 @@ 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`", rt) + } _ => vec![], } } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 6687cbbcf2..2cade64d8f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -46,6 +46,10 @@ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ "codex-acp", "codex_acp", "goose", + // Cursor Agent CLI (native ACP via `agent acp` / `cursor-agent acp`). + "agent", + "cursor-agent", + "cursor_agent", // buzz-dev-mcp's multicall personalities (rg, tree, buzz, // git-credential-nostr, git-sign-nostr) are short-lived per-tool-call // invocations — not listed here. diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index fd758047c3..73be19673f 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -98,6 +98,25 @@ fn codex_has_mcp_command() { assert_eq!(p.mcp_command, Some("buzz-dev-mcp")); } +#[test] +fn cursor_runtime_resolves_native_acp() { + use crate::managed_agents::discovery::known_acp_runtime; + let p = known_acp_runtime("agent").expect("cursor agent should resolve"); + assert_eq!(p.id, "cursor"); + assert!(!p.mcp_hooks); + assert_eq!(p.skill_dir, Some(".cursor/skills")); + assert_eq!(p.mcp_command, Some("buzz-dev-mcp")); + assert!( + known_acp_runtime("cursor-agent").is_some_and(|r| r.id == "cursor"), + "cursor-agent alias should resolve" + ); + assert!( + known_acp_runtime("cursor").is_some_and(|r| r.id == "cursor"), + "cursor id/alias should resolve" + ); +} + + #[test] fn goose_has_no_mcp_hooks() { let p = known_acp_runtime("goose").expect("should resolve"); diff --git a/desktop/src/features/onboarding/ui/agentReadiness.ts b/desktop/src/features/onboarding/ui/agentReadiness.ts index 86b9721af3..a3c1ccdabf 100644 --- a/desktop/src/features/onboarding/ui/agentReadiness.ts +++ b/desktop/src/features/onboarding/ui/agentReadiness.ts @@ -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/features/onboarding/ui/onboardingRuntimeSelection.test.mjs b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs index b10aa19154..f1be6efb82 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.test.mjs @@ -12,9 +12,10 @@ function runtime(id, availability, status) { return { id, availability, authStatus: { status } }; } -test("only Claude Code and Codex are visible in onboarding", () => { +test("Claude Code, Codex, and Cursor are visible in onboarding", () => { assert.equal(runtimeIsVisibleInOnboarding("claude"), true); assert.equal(runtimeIsVisibleInOnboarding("codex"), true); + assert.equal(runtimeIsVisibleInOnboarding("cursor"), true); assert.equal(runtimeIsVisibleInOnboarding("goose"), false); assert.equal(runtimeIsVisibleInOnboarding("buzz-agent"), false); assert.equal(runtimeIsVisibleInOnboarding("custom"), false); @@ -23,6 +24,7 @@ test("only Claude Code and Codex are visible in onboarding", () => { test("visible onboarding runtimes use the product order", () => { const runtimes = [ runtime("buzz-agent", "available", "not_applicable"), + runtime("cursor", "available", "logged_in"), runtime("codex", "available", "logged_in"), runtime("goose", "available", "not_applicable"), runtime("claude", "available", "logged_in"), @@ -30,7 +32,7 @@ test("visible onboarding runtimes use the product order", () => { assert.deepEqual( getVisibleOnboardingRuntimes(runtimes).map(({ id }) => id), - ["claude", "codex"], + ["claude", "codex", "cursor"], ); }); diff --git a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts index 51339e2afe..fba5c927d4 100644 --- a/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts +++ b/desktop/src/features/onboarding/ui/onboardingRuntimeSelection.ts @@ -1,6 +1,6 @@ import type { AcpRuntimeCatalogEntry } from "@/shared/api/types"; -export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex"]; +export const ONBOARDING_RUNTIME_ORDER = ["claude", "codex", "cursor"]; const VISIBLE_ONBOARDING_RUNTIME_IDS = new Set( ONBOARDING_RUNTIME_ORDER, From a2d17ab260c0d24ee92931d766a78311d92715b7 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Thu, 23 Jul 2026 12:15:18 -0400 Subject: [PATCH 2/3] polish(desktop): Cursor harness review fixes (docs URL, readiness notes) Pre-maintainer pass on #2536 / Refs #2535. Signed-off-by: Bartok9 --- desktop/src-tauri/src/managed_agents/discovery.rs | 2 +- desktop/src-tauri/src/managed_agents/readiness.rs | 2 ++ 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index a3019579fe..acae7e0617 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -176,7 +176,7 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ cli_install_commands: &["curl -fsSL https://cursor.com/install | bash"], cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://cursor.com/install?win32=true | iex\""], adapter_install_commands: &[], - install_instructions_url: "https://cursor.com/docs/cli/acp", + install_instructions_url: "https://cursor.com/docs/cli", cli_install_hint: "Install the Cursor Agent CLI via the official install script.", adapter_install_hint: "", skill_dir: Some(".cursor/skills"), diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index f94aaa9b79..2cb49d55b8 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -247,6 +247,8 @@ impl AgentReadiness { /// * **claude**: a successful `claude auth status` probe. /// * **codex**: a successful `codex login status` probe (checks the codex /// credential store — NOT `OPENAI_API_KEY`). +/// * **cursor**: a successful `agent status` probe (`agent login` / `CURSOR_API_KEY`). +/// * **grok**: a successful `grok auth status` probe when available, else env `XAI_API_KEY`. /// * **unknown / custom command**: always `Ready` (no requirements known). /// /// Databricks note: `DATABRICKS_TOKEN` is `.unwrap_or_default()` in From 8d28f2fec44315cfe42942448116f63bd840f958 Mon Sep 17 00:00:00 2001 From: Bartok9 Date: Thu, 23 Jul 2026 12:21:27 -0400 Subject: [PATCH 3/3] fix(desktop): disambiguate Cursor from Grok's shared `agent` PATH shim Both Cursor and Grok Build install `~/.local/bin/agent`. Binding Cursor to the bare `agent` basename mis-routes Grok installs as Cursor. - Prefer `cursor-agent` as the only catalog command - Path-qualify bare `agent` only when the binary lives under cursor-agent/ - Drop bare `agent` from process-sweep + default-args mapping - Auth probe / login hint use `cursor-agent` - Tests cover Grok path non-match Pre-maintainer polish for #2536 / Refs #2535. Signed-off-by: Bartok9 --- crates/buzz-acp/README.md | 14 ++-- .../src-tauri/src/managed_agents/discovery.rs | 72 ++++++++++++++++--- .../src/managed_agents/discovery/tests.rs | 46 +++++++++--- .../src-tauri/src/managed_agents/readiness.rs | 6 +- .../src-tauri/src/managed_agents/runtime.rs | 4 +- .../src/managed_agents/runtime/tests.rs | 18 +++-- 6 files changed, 123 insertions(+), 37 deletions(-) diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index 8451827caf..a3ea5ef332 100644 --- a/crates/buzz-acp/README.md +++ b/crates/buzz-acp/README.md @@ -94,10 +94,11 @@ treats both Claude ACP command names as the same zero-arg runtime. Cursor's Agent CLI speaks ACP natively — no separate `*-acp` adapter. ```bash -# Install: https://cursor.com/docs/cli (binary lands as `agent` / `cursor-agent`) -agent login # or export CURSOR_API_KEY=... +# Install: https://cursor.com/docs/cli (binary lands as `cursor-agent` (+ optional `agent` shim)) +cursor-agent login # or export CURSOR_API_KEY=... +# Prefer cursor-agent — bare `agent` collides with Grok Build PATH shim. -export BUZZ_ACP_AGENT_COMMAND="agent" +export BUZZ_ACP_AGENT_COMMAND="cursor-agent" export BUZZ_ACP_AGENT_ARGS="acp" buzz-acp @@ -194,10 +195,11 @@ buzz-acp --respond-to nobody --heartbeat-interval 300 Cursor's Agent CLI speaks ACP natively — no separate `*-acp` adapter. ```bash -# Install: https://cursor.com/docs/cli (binary lands as `agent` / `cursor-agent`) -agent login # or export CURSOR_API_KEY=... +# Install: https://cursor.com/docs/cli (binary lands as `cursor-agent` (+ optional `agent` shim)) +cursor-agent login # or export CURSOR_API_KEY=... +# Prefer cursor-agent — bare `agent` collides with Grok Build PATH shim. -export BUZZ_ACP_AGENT_COMMAND="agent" +export BUZZ_ACP_AGENT_COMMAND="cursor-agent" export BUZZ_ACP_AGENT_ARGS="acp" buzz-acp diff --git a/desktop/src-tauri/src/managed_agents/discovery.rs b/desktop/src-tauri/src/managed_agents/discovery.rs index acae7e0617..66876b13ba 100644 --- a/desktop/src-tauri/src/managed_agents/discovery.rs +++ b/desktop/src-tauri/src/managed_agents/discovery.rs @@ -160,24 +160,30 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ auth_probe_args: Some(&["codex", "login", "status"]), }, - // Cursor Agent CLI speaks ACP natively (`agent acp` / `cursor-agent acp`) — - // no separate *-acp npm adapter (same class as Goose). Auth: `agent login` - // or CURSOR_API_KEY. Headless extension methods (ask_question / create_plan) - // need a separate buzz-acp client policy; not part of this registry entry. + // Cursor Agent CLI speaks ACP natively (`cursor-agent acp` / `agent acp`). + // IMPORTANT: both Cursor and Grok Build install a PATH shim named `agent` + // (`~/.local/bin/agent`). Prefer the unambiguous `cursor-agent` binary and + // only accept bare `agent` when the resolved path is clearly Cursor's + // (see `is_cursor_agent_binary`). Never process-sweep bare `agent`. + // Auth: `cursor-agent login` / `agent login` or CURSOR_API_KEY. + // Headless extension methods (ask_question / create_plan) need a separate + // buzz-acp client policy; not part of this registry entry. KnownAcpRuntime { id: "cursor", label: "Cursor", - commands: &["agent", "cursor-agent"], + // Prefer the unambiguous name first so discovery does not bind Grok's + // `~/.local/bin/agent` shim when both are installed. + commands: &["cursor-agent"], aliases: &["cursor"], avatar_url: CURSOR_AVATAR_URL, mcp_command: Some("buzz-dev-mcp"), mcp_hooks: false, - underlying_cli: Some("agent"), + underlying_cli: Some("cursor-agent"), cli_install_commands: &["curl -fsSL https://cursor.com/install | bash"], cli_install_commands_windows: &["powershell.exe -NoProfile -ExecutionPolicy Bypass -Command \"irm https://cursor.com/install?win32=true | iex\""], adapter_install_commands: &[], install_instructions_url: "https://cursor.com/docs/cli", - cli_install_hint: "Install the Cursor Agent CLI via the official install script.", + cli_install_hint: "Install the Cursor Agent CLI via the official install script (provides `cursor-agent` and an `agent` shim).", adapter_install_hint: "", skill_dir: Some(".cursor/skills"), supports_acp_model_switching: false, @@ -192,9 +198,10 @@ const KNOWN_ACP_RUNTIMES: &[KnownAcpRuntime] = &[ max_tokens_env_var: None, context_limit_env_var: None, required_normalized_fields: &[], - login_hint: Some("Run `agent login` to authenticate (or set CURSOR_API_KEY)."), - auth_probe_args: Some(&["agent", "status"]), + login_hint: Some("Run `cursor-agent login` to authenticate (or set CURSOR_API_KEY)."), + auth_probe_args: Some(&["cursor-agent", "status"]), }, + KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -280,9 +287,36 @@ fn normalize_command_identity(command: &str) -> String { lower } + +/// True when a resolved binary path is Cursor's Agent CLI (not Grok's `agent` shim). +/// +/// Cursor installs: +/// ~/.local/bin/cursor-agent -> ~/.local/share/cursor-agent/versions/.../cursor-agent +/// ~/.local/bin/agent -> same target (ambiguous name) +/// Grok Build installs: +/// ~/.grok/bin/agent -> grok binary +/// ~/.local/bin/agent -> ~/.grok/bin/agent +fn is_cursor_agent_binary(path: &Path) -> bool { + let s = path.to_string_lossy().replace('\\', "/").to_ascii_lowercase(); + s.contains("cursor-agent") + || s.contains("/cursor-agent/") + || s.contains(".local/share/cursor-agent/") +} + pub(crate) fn known_acp_runtime(command: &str) -> Option<&'static KnownAcpRuntime> { let normalized = normalize_command_identity(command); + // Path-qualified bare `agent` that resolves to Cursor's install tree. + // Basename-only `agent` must NOT match Cursor — Grok uses the same name. + if normalized == "agent" { + let path = Path::new(command.trim()); + if path.is_absolute() || command.contains('/') || command.contains('\\') { + if is_cursor_agent_binary(path) { + return known_acp_runtime_exact("cursor"); + } + } + } + KNOWN_ACP_RUNTIMES.iter().find(|runtime| { normalized == runtime.id || runtime @@ -380,8 +414,9 @@ 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 + Cursor speak ACP as a subcommand (`goose acp`, `agent acp`). - "goose" | "agent" | "cursor-agent" | "cursor" => Some(vec!["acp".to_string()]), + // goose + Cursor speak ACP as a subcommand (`goose acp`, `cursor-agent acp`). + // Bare `agent` omitted: Grok Build also installs an `agent` PATH shim. + "goose" | "cursor-agent" | "cursor" => Some(vec!["acp".to_string()]), "codex" | "codex-acp" | "claude-agent-acp" | "claude-code-acp" | "claude-code" | "claudecode" | "buzz-agent" => Some(Vec::new()), _ => None, @@ -1207,6 +1242,21 @@ pub fn discover_acp_runtimes() -> Vec { .iter() .find_map(|command| find_command(command).map(|path| (*command, path))); + // Cursor: install also ships a PATH shim named `agent`. If + // `cursor-agent` is missing from PATH but the shim points at + // Cursor's tree, treat it as the adapter (never Grok's agent). + let adapter_result = if adapter_result.is_none() && runtime.id == "cursor" { + find_command("agent").and_then(|path| { + if is_cursor_agent_binary(&path) { + Some(("agent", path)) + } else { + None + } + }) + } else { + adapter_result + }; + let underlying_cli_found = runtime .underlying_cli .map(|cli| find_command(cli).is_some()) diff --git a/desktop/src-tauri/src/managed_agents/discovery/tests.rs b/desktop/src-tauri/src/managed_agents/discovery/tests.rs index 4035696e44..41128dcfe1 100644 --- a/desktop/src-tauri/src/managed_agents/discovery/tests.rs +++ b/desktop/src-tauri/src/managed_agents/discovery/tests.rs @@ -73,13 +73,11 @@ fn normalizes_claude_and_codex_args_to_empty() { } + #[test] fn normalizes_cursor_args_to_acp_subcommand() { - // Cursor speaks ACP natively as `agent acp` (same shape as goose). - assert_eq!( - normalize_agent_args("agent", Vec::new()), - vec!["acp".to_string()] - ); + // Cursor speaks ACP natively as `cursor-agent acp` (same shape as goose). + // Bare `agent` must NOT get Cursor defaults — Grok owns that PATH name too. assert_eq!( normalize_agent_args("cursor-agent", Vec::new()), vec!["acp".to_string()] @@ -89,31 +87,57 @@ fn normalizes_cursor_args_to_acp_subcommand() { vec!["acp".to_string()] ); assert_eq!( - normalize_agent_args("agent", vec!["acp".into()]), + normalize_agent_args("cursor-agent", vec!["acp".into()]), vec!["acp".to_string()] ); + assert_eq!( + normalize_agent_args("agent", Vec::new()), + Vec::::new(), + "bare agent must not inherit Cursor acp defaults (Grok collision)" + ); } #[test] fn resolves_cursor_avatar() { - assert_eq!( - managed_agent_avatar_url("agent"), - Some(CURSOR_AVATAR_URL.to_string()) - ); assert_eq!( managed_agent_avatar_url("cursor-agent"), Some(CURSOR_AVATAR_URL.to_string()) ); assert_eq!( - managed_agent_avatar_url("/Users/me/.local/bin/agent"), + managed_agent_avatar_url("/Users/me/.local/bin/cursor-agent"), Some(CURSOR_AVATAR_URL.to_string()) ); assert_eq!( managed_agent_avatar_url("cursor"), Some(CURSOR_AVATAR_URL.to_string()) ); + // Basename-only `agent` is ambiguous (Grok) — no Cursor avatar. + assert_eq!(managed_agent_avatar_url("agent"), None); } +#[test] +fn path_qualified_cursor_agent_resolves_to_cursor_runtime() { + use super::known_acp_runtime; + let rt = known_acp_runtime( + "/Users/me/.local/share/cursor-agent/versions/1.0.0/cursor-agent", + ) + .expect("path-qualified cursor-agent should resolve"); + assert_eq!(rt.id, "cursor"); + + // Grok's agent path must not resolve as Cursor. + assert!( + known_acp_runtime("/Users/me/.grok/bin/agent").is_none() + || known_acp_runtime("/Users/me/.grok/bin/agent") + .is_some_and(|r| r.id != "cursor"), + "Grok agent path must not map to Cursor" + ); + assert!( + known_acp_runtime("agent").is_none(), + "basename-only agent must not map to Cursor" + ); +} + + #[test] fn resolves_buzz_agent_avatar() { diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 2cb49d55b8..865f0d257f 100644 --- a/desktop/src-tauri/src/managed_agents/readiness.rs +++ b/desktop/src-tauri/src/managed_agents/readiness.rs @@ -292,7 +292,11 @@ fn collect_missing_requirements( ), "codex" => cli_login::requirements(&["codex", "login", "status"], "run `codex login`", rt), "cursor" => { - cli_login::requirements(&["agent", "status"], "run `agent login`", rt) + cli_login::requirements( + &["cursor-agent", "status"], + "run `cursor-agent login`", + rt, + ) } _ => vec![], } diff --git a/desktop/src-tauri/src/managed_agents/runtime.rs b/desktop/src-tauri/src/managed_agents/runtime.rs index 2cade64d8f..3f9353af68 100644 --- a/desktop/src-tauri/src/managed_agents/runtime.rs +++ b/desktop/src-tauri/src/managed_agents/runtime.rs @@ -46,8 +46,8 @@ pub(crate) const KNOWN_AGENT_BINARIES: &[&str] = &[ "codex-acp", "codex_acp", "goose", - // Cursor Agent CLI (native ACP via `agent acp` / `cursor-agent acp`). - "agent", + // Cursor Agent CLI (native ACP via `cursor-agent acp`). + // Do NOT list bare "agent" — Grok Build installs the same process name. "cursor-agent", "cursor_agent", // buzz-dev-mcp's multicall personalities (rg, tree, buzz, diff --git a/desktop/src-tauri/src/managed_agents/runtime/tests.rs b/desktop/src-tauri/src/managed_agents/runtime/tests.rs index 73be19673f..5c21166214 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -100,23 +100,29 @@ fn codex_has_mcp_command() { #[test] fn cursor_runtime_resolves_native_acp() { - use crate::managed_agents::discovery::known_acp_runtime; - let p = known_acp_runtime("agent").expect("cursor agent should resolve"); + use crate::managed_agents::discovery::{known_acp_runtime, normalize_agent_args}; + let p = known_acp_runtime("cursor-agent").expect("cursor-agent should resolve"); assert_eq!(p.id, "cursor"); assert!(!p.mcp_hooks); assert_eq!(p.skill_dir, Some(".cursor/skills")); assert_eq!(p.mcp_command, Some("buzz-dev-mcp")); - assert!( - known_acp_runtime("cursor-agent").is_some_and(|r| r.id == "cursor"), - "cursor-agent alias should resolve" - ); assert!( known_acp_runtime("cursor").is_some_and(|r| r.id == "cursor"), "cursor id/alias should resolve" ); + // Bare agent is NOT Cursor (Grok collision). + assert!( + known_acp_runtime("agent").is_none(), + "bare agent must not resolve as Cursor" + ); + assert_eq!( + normalize_agent_args("cursor-agent", Vec::new()), + vec!["acp".to_string()] + ); } + #[test] fn goose_has_no_mcp_hooks() { let p = known_acp_runtime("goose").expect("should resolve");