Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
46 changes: 44 additions & 2 deletions crates/buzz-acp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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
Expand Down
99 changes: 94 additions & 5 deletions desktop/src-tauri/src/managed_agents/discovery.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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] {
Expand All @@ -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"),
]);
Expand Down Expand Up @@ -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",
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -342,7 +414,9 @@ pub use overrides::{apply_agent_command_update, create_time_agent_command_overri

fn default_agent_args(command: &str) -> Option<Vec<String>> {
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,
Expand Down Expand Up @@ -1168,6 +1242,21 @@ pub fn discover_acp_runtimes() -> Vec<AcpRuntimeCatalogEntry> {
.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())
Expand Down
69 changes: 68 additions & 1 deletion desktop/src-tauri/src/managed_agents/discovery/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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::<String>::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!(
Expand Down
9 changes: 9 additions & 0 deletions desktop/src-tauri/src/managed_agents/readiness.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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![],
}
}
Expand Down
4 changes: 4 additions & 0 deletions desktop/src-tauri/src/managed_agents/runtime.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions desktop/src-tauri/src/managed_agents/runtime/tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand Down
4 changes: 3 additions & 1 deletion desktop/src/features/onboarding/ui/agentReadiness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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")
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -23,14 +24,15 @@ 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"),
];

assert.deepEqual(
getVisibleOnboardingRuntimes(runtimes).map(({ id }) => id),
["claude", "codex"],
["claude", "codex", "cursor"],
);
});

Expand Down
Loading