diff --git a/crates/buzz-acp/README.md b/crates/buzz-acp/README.md index d9cd362cb8..a3ea5ef332 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,27 @@ 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 `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="cursor-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 +190,28 @@ 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 `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="cursor-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..66876b13ba 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,49 @@ 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 (`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", + // 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("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 (provides `cursor-agent` and an `agent` shim).", + 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 `cursor-agent login` to authenticate (or set CURSOR_API_KEY)."), + auth_probe_args: Some(&["cursor-agent", "status"]), + }, + KnownAcpRuntime { id: "buzz-agent", label: "Buzz Agent", @@ -242,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 @@ -342,7 +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" => 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, @@ -1168,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 0ed4fe0f6a..41128dcfe1 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,73 @@ fn normalizes_claude_and_codex_args_to_empty() { ); } + + +#[test] +fn normalizes_cursor_args_to_acp_subcommand() { + // 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()] + ); + assert_eq!( + normalize_agent_args("cursor", Vec::new()), + vec!["acp".to_string()] + ); + assert_eq!( + 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("cursor-agent"), + Some(CURSOR_AVATAR_URL.to_string()) + ); + assert_eq!( + 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() { assert_eq!( diff --git a/desktop/src-tauri/src/managed_agents/readiness.rs b/desktop/src-tauri/src/managed_agents/readiness.rs index 87ee6241ee..865f0d257f 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 @@ -289,6 +291,13 @@ fn collect_missing_requirements( rt, ), "codex" => cli_login::requirements(&["codex", "login", "status"], "run `codex login`", rt), + "cursor" => { + 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 6687cbbcf2..3f9353af68 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 `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, // 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..5c21166214 100644 --- a/desktop/src-tauri/src/managed_agents/runtime/tests.rs +++ b/desktop/src-tauri/src/managed_agents/runtime/tests.rs @@ -98,6 +98,31 @@ 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, 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").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"); 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,