diff --git a/docs/cursor.md b/docs/cursor.md new file mode 100644 index 0000000000..a6d65da04a --- /dev/null +++ b/docs/cursor.md @@ -0,0 +1,65 @@ +# Cursor Agent CLI + +Cursor is an optional OpenMausBot engine. OpenMausBot runs the official +[`cursor-agent` CLI](https://cursor.com/docs/cli) in ACP stdio mode (`cursor-agent acp`), so +sessions, streaming, coding tools, permission requests, MCP integrations, +resume, and cancellation use the same runtime as the other ACP engines. + +Bots on this engine consume the user's Cursor subscription (or a +`CURSOR_API_KEY` / `CURSOR_AUTH_TOKEN`), not a separate Anthropic/OpenAI/xAI +key. + +## Setup + +1. Install Cursor CLI: + + ```sh + curl https://cursor.com/install -fsS | bash # macOS / Linux + ``` + + Windows (native): `irm 'https://cursor.com/install?win32=true' | iex` + +2. Sign in with `cursor-agent login`, or set `CURSOR_API_KEY` / `CURSOR_AUTH_TOKEN` + in the environment of the Cursor instance. + +3. Confirm `cursor-agent --version` works. The binary installs to `~/.local/bin` by + default; OpenMausBot already looks there when launched from a GUI. + +The engine stays unavailable until the `cursor-agent` executable is on PATH. A +missing login shows as unauthenticated rather than crashing the fleet. + +## Models + +The picker starts from a small static catalog and refreshes from plain +`cursor-agent models` output (`slug - Label`, with `(default)` / `(current)` markers). +Live ids are merged into the main cloud rail (not the local-models pane). A +failed listing keeps the last usable catalog (then the static fallback) rather +than emptying the rail. + +`--model ` is passed as a global CLI flag before `acp`. When the running +CLI also implements ACP `session/set_model`, OpenMausBot pins the same id over +the wire. If that method is missing (`-32601`), the argv pin is left to stand +and the turn continues. + +## Autonomy + +Instance `fullAuto: true` adds `--force` (the CLI's documented auto-approve +switch). OpenMausBot still answers ACP `session/request_permission` itself: +full-auto selects an allow option when the CLI offered one. + +## What this driver does not do yet + +- Cursor ACP extension methods (`cursor/ask_question`, `cursor/create_plan`, + todos/tasks/images) are not given a dedicated UI. Unknown JSON-RPC requests + are rejected with method-not-found so the CLI is not left blocked. +- MCP servers passed in `session/new` follow Cursor's ACP limitations; prefer + project or user `.cursor/mcp.json` where needed. +- Live smoke (`cursor-agent login`, `cursor-agent models`, one real turn) should be run on + a machine with the CLI installed and signed in before relying on this in + production. + +## Testing + +Normal unit and ACP protocol tests use the scripted fake CLI and do not +require a Cursor subscription. Do not print credentials or upload native +protocol logs from a credentialed live run. diff --git a/server/config.test.ts b/server/config.test.ts index 1b4d1c618f..b79f90acc6 100644 --- a/server/config.test.ts +++ b/server/config.test.ts @@ -75,11 +75,17 @@ describe("default fleet", () => { expect(map.hermes).toEqual({ driver: "hermesAgent", environment: {} }); }); + it("ships Cursor as a default-fleet subscription engine", () => { + const map = instanceConfigs({}); + expect(map.cursor).toEqual({ driver: "cursorAgent", environment: {} }); + }); + it("adds missing custom-only engines onto an existing product fleet", () => { const map = instanceConfigs({ instances: { claude: { driver: "claudeAgent" } } }); expect(map.claude.driver).toBe("claudeAgent"); expect(map.qwen?.driver).toBe("qwenAgent"); expect(map.hermes?.driver).toBe("hermesAgent"); + expect(map.cursor?.driver).toBe("cursorAgent"); }); it("does not expand a one-off shadow fleet", () => { diff --git a/server/config.ts b/server/config.ts index 842f04d28f..d5036ce386 100644 --- a/server/config.ts +++ b/server/config.ts @@ -322,6 +322,7 @@ export function instanceConfigs(cfg: AppConfig): InstanceConfigMap { grok: { driver: "grokAgent" }, kimi: { driver: "kimiAgent" }, droid: { driver: "droidAgent" }, + cursor: { driver: "cursorAgent" }, claude: { driver: "claudeAgent" }, codex: { driver: "codex" }, antigravity: { driver: "antigravityAgent" }, @@ -334,15 +335,22 @@ export function instanceConfigs(cfg: AppConfig): InstanceConfigMap { qwen: { driver: "qwenAgent" }, hermes: { driver: "hermesAgent" }, } as const; + // New default-fleet engines that existing product configs would otherwise + // never see. Custom-only engines stay in CUSTOM_ONLY so a one-off test map + // is not expanded, matching the claude/grok/codex product-fleet probe. + const PRODUCT_FLEET_ADDITIONS = { + cursor: { driver: "cursorAgent" }, + ...CUSTOM_ONLY, + } as const; const configured = cfg.instances && Object.keys(cfg.instances).length ? cfg.instances : null; const map: InstanceConfigMap = configured ? { ...configured } : { ...DEFAULT_FLEET }; - // Product fleets pick up newly shipped custom-only engines. A one-off - // test/shadow map (no claude/grok/codex) is left exactly as written. + // Product fleets pick up newly shipped engines. A one-off test/shadow map + // (no claude/grok/codex) is left exactly as written. if ( configured && (Object.hasOwn(configured, "claude") || Object.hasOwn(configured, "grok") || Object.hasOwn(configured, "codex")) ) { - for (const [id, entry] of Object.entries(CUSTOM_ONLY)) { + for (const [id, entry] of Object.entries(PRODUCT_FLEET_ADDITIONS)) { if (!Object.hasOwn(map, id)) map[id] = { ...entry }; } } diff --git a/server/drivers/acp/acp.test.ts b/server/drivers/acp/acp.test.ts index aad4857165..ff3dabfb8f 100644 --- a/server/drivers/acp/acp.test.ts +++ b/server/drivers/acp/acp.test.ts @@ -20,6 +20,7 @@ import { GrokAgentDriver } from "./grok.ts"; import { GeminiAgentDriver } from "./gemini.ts"; import { KimiAgentDriver } from "./kimi.ts"; import { DroidAgentDriver } from "./droid.ts"; +import { CursorAgentDriver } from "./cursor.ts"; import { removeTempDir } from "../../testing/cleanup.ts"; const FAKE_CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "testing", "fake-acp-cli.ts"); @@ -128,6 +129,19 @@ describe("ACP decodeConfig", () => { }); expect(DroidAgentDriver.install?.signInCommand).toBe("droid"); }); + it("cursor defaults to its unambiguous binary and declares cross-platform setup", () => { + expect(CursorAgentDriver.decodeConfig(undefined)).toEqual({ + cli: "cursor-agent", + fullAuto: false, + workspace: undefined, + }); + expect(CursorAgentDriver.install?.command).toMatchObject({ + darwin: expect.stringContaining("cursor.com/install"), + linux: expect.stringContaining("cursor.com/install"), + win32: expect.stringContaining("cursor.com/install"), + }); + expect(CursorAgentDriver.install?.signInCommand).toBe("cursor-agent login"); + }); it("fullAuto only when explicitly true", () => { expect(GrokAgentDriver.decodeConfig({ fullAuto: "yes" }).fullAuto).toBe(false); expect(GrokAgentDriver.decodeConfig({ fullAuto: true }).fullAuto).toBe(true); @@ -189,6 +203,8 @@ describe("ACP turns (fake CLI)", () => { delete process.env.FAKE_ACP_DUMP; delete process.env.XAI_API_KEY; delete process.env.OPENCODE_API_KEY; + delete process.env.CURSOR_API_KEY; + delete process.env.CURSOR_AUTH_TOKEN; delete process.env.BOX_TOKEN; delete process.env.OMB_TTS_KEY; delete process.env.FAKE_ACP_MODELS; @@ -241,6 +257,8 @@ describe("ACP turns (fake CLI)", () => { process.env.FAKE_ACP_DUMP = dump; process.env.XAI_API_KEY = "xai-should-not-leak"; process.env.OPENCODE_API_KEY = "opencode-should-not-leak"; + process.env.CURSOR_API_KEY = "cursor-should-not-leak"; + process.env.CURSOR_AUTH_TOKEN = "cursor-token-should-not-leak"; // workspace credentials with no CLI consumer at all — held by the // harness (env-injected at boot by the desktop shell), used in-process process.env.BOX_TOKEN = "box-should-not-leak"; @@ -255,6 +273,8 @@ describe("ACP turns (fake CLI)", () => { expect(seen.argv).toContain("--permission-mode"); expect(seen.env.XAI_API_KEY).toBeUndefined(); expect(seen.env.OPENCODE_API_KEY).toBeUndefined(); + expect(seen.env.CURSOR_API_KEY).toBeUndefined(); + expect(seen.env.CURSOR_AUTH_TOKEN).toBeUndefined(); expect(seen.env.BOX_TOKEN).toBeUndefined(); expect(seen.env.OMB_TTS_KEY).toBeUndefined(); }); diff --git a/server/drivers/acp/core.ts b/server/drivers/acp/core.ts index 49c1cd2d8d..52f5b0d1a3 100644 --- a/server/drivers/acp/core.ts +++ b/server/drivers/acp/core.ts @@ -64,8 +64,13 @@ export interface AcpSupport { effortLevels?: readonly EffortLevel[]; /** Default CLI binary name if the instance config doesn't override it. */ defaultCli: string; - /** Optional live model catalog. A failed lookup keeps the last usable catalog. */ - resolveModels?(environment: Record): ModelCatalog | Promise; + /** Optional live model catalog. A failed lookup keeps the last usable catalog. + * `config` is the instance decode so a support can ask the same binary it + * will spawn (custom `cli` paths), not whatever happens to be named on PATH. */ + resolveModels?( + environment: Record, + config: AcpConfig, + ): ModelCatalog | Promise; /** Native-protocol log label, e.g. "grok.acp". */ nativeSource: string; /** Whether models behind this ACP harness can consume a referenced image. @@ -133,6 +138,8 @@ const PROVIDER_CREDENTIAL_ENV = [ "OPENAI_API_KEY", "OPENCODE_API_KEY", "XAI_API_KEY", + "CURSOR_API_KEY", + "CURSOR_AUTH_TOKEN", ] as const; function decodeAcpConfig(defaultCli: string) { @@ -189,7 +196,7 @@ export function createAcpDriver(support: AcpSupport): ProviderDriver const refreshModels = async () => { if (!support.resolveModels) return; try { - const resolved = await support.resolveModels(childEnv()); + const resolved = await support.resolveModels(childEnv(), config); if (resolved.options.length) models = resolved; } catch { // Keep the last usable catalog when an optional discovery source is down. diff --git a/server/drivers/acp/cursor.test.ts b/server/drivers/acp/cursor.test.ts new file mode 100644 index 0000000000..904584462b --- /dev/null +++ b/server/drivers/acp/cursor.test.ts @@ -0,0 +1,289 @@ +import { chmodSync, mkdtempSync, readFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { afterEach, describe, expect, it } from "vitest"; + +import { ensureDirs } from "../../config.ts"; +import { recordEvents } from "../../testing/events.ts"; +import { removeTempDir } from "../../testing/cleanup.ts"; +import { + classifyCursorError, + createCursorAgentDriver, + CursorAgentDriver, + decodeCursorAuthStatus, + decodeCursorAuthText, + decodeCursorModelCatalog, + decodeCursorModelText, + STATIC_CURSOR_MODELS, +} from "./cursor.ts"; + +const FAKE_CLI = join(dirname(fileURLToPath(import.meta.url)), "..", "..", "testing", "fake-acp-cli.ts"); + +describe("decodeCursorAuthStatus", () => { + it("reads isAuthenticated from live CLI JSON", () => { + expect(decodeCursorAuthStatus({ isAuthenticated: true })).toBe(true); + expect(decodeCursorAuthStatus({ isAuthenticated: false })).toBe(false); + }); + + it("accepts sibling field names without inventing a yes from a missing flag", () => { + expect(decodeCursorAuthStatus({ authenticated: true })).toBe(true); + expect(decodeCursorAuthStatus({ loggedIn: false })).toBe(false); + expect(decodeCursorAuthStatus({ auth: { isAuthenticated: true } })).toBe(true); + expect(decodeCursorAuthStatus({ email: "user@example.com" })).toBeNull(); + expect(decodeCursorAuthStatus("logged in")).toBeNull(); + }); +}); + +describe("decodeCursorAuthText", () => { + it("recognizes the documented status output without confusing logged-out text", () => { + expect(decodeCursorAuthText("✓ Login successful! Logged in")).toBe(true); + expect(decodeCursorAuthText("Not logged in")).toBe(false); + expect(decodeCursorAuthText("Cursor Agent CLI")).toBeNull(); + }); +}); + +describe("decodeCursorModelCatalog", () => { + it("merges live ids onto the static cloud set", () => { + const catalog = decodeCursorModelCatalog({ + default: "composer-2.5", + models: [ + { id: "composer-2.5", name: "Composer 2.5" }, + { id: "cursor-live", displayName: "Cursor Live" }, + { id: "bad id" }, + "gpt-5.3-codex", + ], + }); + expect(catalog?.default).toBe("composer-2.5"); + expect(catalog?.options.slice(0, STATIC_CURSOR_MODELS.options.length)).toEqual(STATIC_CURSOR_MODELS.options); + expect(catalog?.options).toContainEqual({ id: "cursor-live", label: "Cursor Live" }); + expect(catalog?.options.some((option) => option.id === "bad id")).toBe(false); + }); + + it("reads { data: [...] } and a bare array", () => { + expect(decodeCursorModelCatalog({ data: [{ id: "extra-one", label: "Extra One" }] })?.options).toContainEqual({ + id: "extra-one", + label: "Extra One", + }); + expect(decodeCursorModelCatalog(["composer-2.5", "brand-new"])?.options).toContainEqual({ + id: "brand-new", + label: "Brand New", + }); + }); + + it("returns null for an unusable payload so the static fallback can win", () => { + expect(decodeCursorModelCatalog(null)).toBeNull(); + expect(decodeCursorModelCatalog({ models: [] })).toBeNull(); + expect(decodeCursorModelCatalog({ hello: "world" })).toBeNull(); + }); +}); + +describe("decodeCursorModelText", () => { + it("parses slug-label lines and (default)/(current) markers", () => { + const catalog = decodeCursorModelText(` +Available models + +auto - Auto (default) +composer-2.5 - Composer 2.5 (current) +cursor-text - Cursor Text +`); + expect(catalog?.default).toBe("auto"); + expect(catalog?.options).toContainEqual({ id: "cursor-text", label: "Cursor Text" }); + expect(catalog?.options).toContainEqual({ id: "composer-2.5", label: "Composer 2.5" }); + }); + + it("falls back to the first parsed id when static default is absent", () => { + const catalog = decodeCursorModelText("cursor-only - Cursor Only"); + expect(catalog?.default).toBe("cursor-only"); + }); +}); + +describe("classifyCursorError", () => { + it("maps auth and subscription failures onto provider codes", () => { + expect(classifyCursorError({ code: -32000, message: "Authentication required" })).toBe("invalid_credentials"); + expect(classifyCursorError(new Error("not logged in"))).toBe("invalid_credentials"); + expect(classifyCursorError(new Error("upgrade your subscription"))).toBe("inactive_subscription"); + expect(classifyCursorError(new Error("model not found"))).toBeUndefined(); + }); +}); + +describe("CursorAgentDriver", () => { + const scratchDirs: string[] = []; + + afterEach(async () => { + delete process.env.FAKE_ACP_DUMP; + delete process.env.FAKE_ACP_AUTH; + delete process.env.CURSOR_API_KEY; + delete process.env.CURSOR_AUTH_TOKEN; + delete process.env.XAI_API_KEY; + for (const dir of scratchDirs.splice(0)) await removeTempDir(dir); + }); + + it("defaults to the unambiguous cursor-agent binary and declares cross-platform setup", () => { + expect(CursorAgentDriver.decodeConfig(undefined)).toEqual({ + cli: "cursor-agent", + fullAuto: false, + workspace: undefined, + }); + expect(CursorAgentDriver.driverKind).toBe("cursorAgent"); + expect(CursorAgentDriver.install?.command).toMatchObject({ + darwin: expect.stringContaining("cursor.com/install"), + linux: expect.stringContaining("cursor.com/install"), + win32: expect.stringContaining("cursor.com/install"), + }); + expect(CursorAgentDriver.install?.signInCommand).toBe("cursor-agent login"); + }); + + it("refreshes the catalog from `cursor-agent models` on the instance CLI", async () => { + chmodSync(FAKE_CLI, 0o755); + const driver = createCursorAgentDriver(); + const instance = await driver.create({ + instanceId: "cursor-catalog", + displayName: "Cursor", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + try { + expect(instance.models.options.some((option) => option.id === "cursor-live")).toBe(true); + expect(instance.models.default).toBe("auto"); + } finally { + await instance.dispose(); + } + }); + + it("treats CURSOR_API_KEY as signed in without asking the CLI", async () => { + chmodSync(FAKE_CLI, 0o755); + const instance = await CursorAgentDriver.create({ + instanceId: "cursor-key", + displayName: "Cursor", + environment: { CURSOR_API_KEY: "key-from-env" }, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + try { + expect((await instance.snapshot()).authenticated).toBe(true); + } finally { + await instance.dispose(); + } + }); + + it("reads isAuthenticated from `cursor-agent status --format json`", async () => { + chmodSync(FAKE_CLI, 0o755); + process.env.FAKE_ACP_AUTH = "0"; + const loggedOut = await CursorAgentDriver.create({ + instanceId: "cursor-status-out", + displayName: "Cursor", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + try { + expect((await loggedOut.snapshot()).authenticated).toBe(false); + } finally { + await loggedOut.dispose(); + } + + delete process.env.FAKE_ACP_AUTH; + const loggedIn = await CursorAgentDriver.create({ + instanceId: "cursor-status-in", + displayName: "Cursor", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + try { + expect((await loggedIn.snapshot()).authenticated).toBe(true); + } finally { + await loggedIn.dispose(); + } + }); + + it("spawns `agent [--force] [--model …] acp` and keeps Cursor credentials", async () => { + ensureDirs(); + chmodSync(FAKE_CLI, 0o755); + const scratch = mkdtempSync(join(tmpdir(), "omb-cursor-")); + scratchDirs.push(scratch); + const dump = join(scratch, "dump.json"); + process.env.FAKE_ACP_DUMP = dump; + process.env.CURSOR_API_KEY = "cursor-should-keep"; + process.env.XAI_API_KEY = "xai-should-not-leak"; + + const instance = await CursorAgentDriver.create({ + instanceId: "cursor-argv", + displayName: "Cursor", + environment: { CURSOR_API_KEY: "cursor-should-keep" }, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: true }, + }); + const recorder = recordEvents(instance.adapter); + try { + await instance.adapter.sendTurn({ threadId: "t-cursor", text: "hi", model: "gpt-5.3-codex" }); + await recorder.until((e) => e.type === "turn.completed"); + + const seen = JSON.parse(readFileSync(dump, "utf8")); + expect(seen.argv).toEqual(["--force", "--model", "gpt-5.3-codex", "acp"]); + expect(seen.env.CURSOR_API_KEY).toBe("cursor-should-keep"); + expect(seen.env.XAI_API_KEY).toBeUndefined(); + + const applied = JSON.parse(readFileSync(`${dump}.config.json`, "utf8")); + expect(applied).toEqual([ + { method: "session/set_model", params: { sessionId: "fake-acp-session", modelId: "gpt-5.3-codex" } }, + ]); + } finally { + recorder.stop(); + await instance.dispose(); + } + }); + + it("omits --force when fullAuto is off and still completes a turn", async () => { + ensureDirs(); + chmodSync(FAKE_CLI, 0o755); + const scratch = mkdtempSync(join(tmpdir(), "omb-cursor-safe-")); + scratchDirs.push(scratch); + const dump = join(scratch, "dump.json"); + process.env.FAKE_ACP_DUMP = dump; + + const instance = await CursorAgentDriver.create({ + instanceId: "cursor-safe", + displayName: "Cursor", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + const recorder = recordEvents(instance.adapter); + try { + const { turnId } = await instance.adapter.sendTurn({ threadId: "t-cursor-safe", text: "hi" }); + await recorder.until((e) => e.type === "turn.completed"); + expect(JSON.parse(readFileSync(dump, "utf8")).argv).toEqual(["acp"]); + expect(recorder.events.every((e) => e.turnId === turnId && e.provider === "cursorAgent")).toBe(true); + expect(recorder.events.at(-1)).toMatchObject({ type: "turn.completed", ok: true }); + } finally { + recorder.stop(); + await instance.dispose(); + } + }); + + it("keeps going when session/set_model is missing and argv already pinned the model", async () => { + ensureDirs(); + chmodSync(FAKE_CLI, 0o755); + process.env.FAKE_ACP_MODE = "no-session-config"; + const instance = await CursorAgentDriver.create({ + instanceId: "cursor-old", + displayName: "Cursor", + environment: {}, + enabled: true, + config: { cli: FAKE_CLI, fullAuto: false }, + }); + const recorder = recordEvents(instance.adapter); + try { + await instance.adapter.sendTurn({ threadId: "t-cursor-old", text: "hi", model: "gpt-5.3-codex" }); + const done = await recorder.until((e) => e.type === "turn.completed"); + expect(done).toMatchObject({ type: "turn.completed", ok: true }); + } finally { + recorder.stop(); + await instance.dispose(); + delete process.env.FAKE_ACP_MODE; + } + }); +}); diff --git a/server/drivers/acp/cursor.ts b/server/drivers/acp/cursor.ts new file mode 100644 index 0000000000..599407c75e --- /dev/null +++ b/server/drivers/acp/cursor.ts @@ -0,0 +1,349 @@ +// Cursor Agent CLI harness support — Anysphere's `cursor-agent acp` over ACP +// stdio, billed on the Cursor subscription (`cursor-agent login`, +// CURSOR_API_KEY, or CURSOR_AUTH_TOKEN). The generic protocol runtime lives in +// acp/core.ts; this file is only the per-harness quirks. +// +// Verified against the public CLI contract (cursor.com/docs/cli/acp, +// …/reference/parameters): `cursor-agent acp` speaks JSON-RPC on stdio, advertises +// `cursor_login`, and takes `--force` / `--model` as global flags before the +// `acp` subcommand. `session/set_model` is attempted when the CLI supports it; +// a missing method falls back to the argv `--model` pin. +import type { ModelCatalog, ProviderErrorCode } from "../../contracts.ts"; +import { execCli } from "../../procs.ts"; +import { createAcpDriver, type AcpSupport } from "./core.ts"; + +export const STATIC_CURSOR_MODELS: ModelCatalog = { + default: "auto", + options: [ + { id: "auto", label: "Auto" }, + { id: "composer-2.5", label: "Composer 2.5" }, + { id: "composer-2.5-fast", label: "Composer 2.5 Fast" }, + { id: "gpt-5.3-codex", label: "Codex 5.3" }, + { id: "claude-sonnet-5-thinking-high", label: "Claude Sonnet 5 1M Thinking" }, + ], +}; + +const SLUG = /^[a-z0-9][a-z0-9._:+-]*$/i; +const EXEC_TIMEOUT_MS = 8_000; + +const nonBlank = (value: string | undefined): boolean => Boolean(value?.trim()); + +function firstJsonValue(text: string): unknown { + const trimmed = text.trim(); + if (!trimmed) return null; + try { + return JSON.parse(trimmed); + } catch { + // CLIs sometimes print a banner before the payload. Take the first + // {...} or [...] span that parses, rather than requiring a clean stdout. + } + const start = trimmed.search(/[{[]/); + if (start < 0) return null; + for (let end = trimmed.length; end > start + 1; end--) { + const slice = trimmed.slice(start, end).trim(); + if (!slice.endsWith("}") && !slice.endsWith("]")) continue; + try { + return JSON.parse(slice); + } catch { + // keep shrinking + } + } + return null; +} + +function asRecord(value: unknown): Record | null { + return value && typeof value === "object" && !Array.isArray(value) ? (value as Record) : null; +} + +function labelFor(id: string, explicit?: string): string { + if (explicit?.trim()) return explicit.trim(); + return id + .split(/[-_./]+/g) + .filter(Boolean) + .map((part) => part.charAt(0).toUpperCase() + part.slice(1)) + .join(" "); +} + +function pushModel( + options: ModelCatalog["options"], + seen: Set, + id: string, + label?: string, + custom = false, +): void { + if (!SLUG.test(id) || seen.has(id)) return; + seen.add(id); + options.push({ id, label: labelFor(id, label), ...(custom ? { custom: true as const } : {}) }); +} + +/** Turn `cursor-agent models` JSON (or a close cousin) into a picker catalog. + * Unknown shapes return null so the caller can try text / keep the fallback. */ +export function decodeCursorModelCatalog(payload: unknown): ModelCatalog | null { + const records: unknown[] = Array.isArray(payload) + ? payload + : Array.isArray(asRecord(payload)?.models) + ? (asRecord(payload)!.models as unknown[]) + : Array.isArray(asRecord(payload)?.data) + ? (asRecord(payload)!.data as unknown[]) + : Array.isArray(asRecord(payload)?.modelIds) + ? (asRecord(payload)!.modelIds as unknown[]) + : []; + if (!records.length) return null; + + const options: ModelCatalog["options"] = []; + const seen = new Set(); + for (const row of records) { + if (typeof row === "string") { + pushModel(options, seen, row); + continue; + } + const rec = asRecord(row); + if (!rec) continue; + const id = rec.id ?? rec.modelId ?? rec.slug ?? rec.name; + if (typeof id !== "string") continue; + const label = + (typeof rec.label === "string" && rec.label) || + (typeof rec.displayName === "string" && rec.displayName) || + (typeof rec.name === "string" && rec.name !== id ? rec.name : undefined); + pushModel(options, seen, id, label || undefined); + } + if (!options.length) return null; + + const defaultId = + (typeof asRecord(payload)?.default === "string" && seen.has(asRecord(payload)!.default as string) + ? (asRecord(payload)!.default as string) + : undefined) ?? + (seen.has(STATIC_CURSOR_MODELS.default) ? STATIC_CURSOR_MODELS.default : options[0]!.id); + + // Keep the static cloud set at the front (stable picker rail) and append + // live ids the static list does not already name. + const merged: ModelCatalog["options"] = STATIC_CURSOR_MODELS.options.map((option) => ({ ...option })); + const mergedSeen = new Set(merged.map((option) => option.id)); + for (const option of options) { + if (mergedSeen.has(option.id)) continue; + mergedSeen.add(option.id); + merged.push(option); + } + return { default: defaultId, options: merged }; +} + +function stripModelMarkers(label: string): { label: string; isDefault: boolean; isCurrent: boolean } { + let cleaned = label.trim(); + let isDefault = false; + let isCurrent = false; + const defaultMatch = cleaned.match(/\s*\(default\)\s*$/i); + if (defaultMatch) { + isDefault = true; + cleaned = cleaned.slice(0, defaultMatch.index).trim(); + } + const currentMatch = cleaned.match(/\s*\(current\)\s*$/i); + if (currentMatch) { + isCurrent = true; + cleaned = cleaned.slice(0, currentMatch.index).trim(); + } + return { label: cleaned, isDefault, isCurrent }; +} + +/** Parse plain `cursor-agent models` text: one slug per line, optional `id - label` + * columns, and `(default)` / `(current)` markers on the label. */ +export function decodeCursorModelText(text: string): ModelCatalog | null { + const options: ModelCatalog["options"] = []; + const seen = new Set(); + let markedDefault: string | undefined; + let markedCurrent: string | undefined; + for (const raw of text.split(/\r?\n/)) { + const line = raw.trim(); + if (!line || line.startsWith("#") || /^available\s+models?\b/i.test(line) || /^models?\b/i.test(line)) continue; + const stripped = line.replace(/^[\s*•\-]+\s*/, ""); + const parts = stripped.split(/\s+[—–|:]\s+|\s+-\s+|\s{2,}/); + const id = (parts[0] ?? "").trim(); + const rawLabel = parts.slice(1).join(" ").trim(); + const { label, isDefault, isCurrent } = stripModelMarkers(rawLabel); + pushModel(options, seen, id, label || undefined); + if (isDefault) markedDefault = id; + if (isCurrent) markedCurrent = id; + } + if (!options.length) return null; + const merged: ModelCatalog["options"] = STATIC_CURSOR_MODELS.options.map((option) => ({ ...option })); + const mergedSeen = new Set(merged.map((option) => option.id)); + for (const option of options) { + if (mergedSeen.has(option.id)) continue; + mergedSeen.add(option.id); + merged.push(option); + } + const defaultId = + (markedDefault && seen.has(markedDefault) ? markedDefault : undefined) ?? + (markedCurrent && seen.has(markedCurrent) ? markedCurrent : undefined) ?? + (seen.has(STATIC_CURSOR_MODELS.default) ? STATIC_CURSOR_MODELS.default : options[0]!.id); + return { default: defaultId, options: merged }; +} + +function truthyAuthFlag(value: unknown): boolean | null { + if (value === true) return true; + if (value === false) return false; + if (typeof value === "string") { + const normalized = value.trim().toLowerCase(); + if (["true", "authenticated", "logged_in", "logged-in", "yes"].includes(normalized)) return true; + if (["false", "unauthenticated", "logged_out", "logged-out", "no"].includes(normalized)) return false; + } + return null; +} + +/** Read `cursor-agent status --format json`. + * Returns null when the payload does not actually answer the question. */ +export function decodeCursorAuthStatus(payload: unknown): boolean | null { + const rec = asRecord(payload); + if (!rec) return null; + const auth = asRecord(rec.auth); + const candidates = [ + rec.isAuthenticated, + rec.authenticated, + rec.loggedIn, + rec.logged_in, + auth?.isAuthenticated, + auth?.authenticated, + rec.status, + auth?.status, + ]; + for (const candidate of candidates) { + const flag = truthyAuthFlag(candidate); + if (flag !== null) return flag; + } + return null; +} + +/** Parse the documented human-readable `cursor-agent status` fallback. */ +export function decodeCursorAuthText(text: string): boolean | null { + const normalized = text.trim().toLowerCase(); + if (!normalized) return null; + if (/not (?:logged|signed) in|not authenticated|unauthenticated|logged out/.test(normalized)) return false; + if (/login successful|logged in|signed in|authenticated/.test(normalized)) return true; + return null; +} + +function execText( + run: typeof execCli, + cli: string, + args: string[], + env: Record, +): Promise { + return new Promise((resolve) => { + run(cli, args, { timeout: EXEC_TIMEOUT_MS, env: env as NodeJS.ProcessEnv }, (err, stdout) => { + if (err) return resolve(null); + resolve(String(stdout ?? "")); + }); + }); +} + +export async function probeCursorAuth( + cli: string, + env: Record, + run: typeof execCli = execCli, +): Promise { + if (nonBlank(env.CURSOR_API_KEY) || nonBlank(env.CURSOR_AUTH_TOKEN)) return true; + for (const args of [["status", "--format", "json"], ["status"]] as const) { + const stdout = await execText(run, cli, [...args], env); + if (stdout == null) continue; + const decoded = decodeCursorAuthStatus(firstJsonValue(stdout)) ?? decodeCursorAuthText(stdout); + if (decoded !== null) return decoded; + } + return false; +} + +export async function fetchCursorModels( + cli: string, + env: Record, + run: typeof execCli = execCli, +): Promise { + // Live CLI prints plain text (`slug - Label`); `--format json` is not supported yet. + for (const args of [["models"], ["--list-models"]] as const) { + const stdout = await execText(run, cli, [...args], env); + if (stdout == null) continue; + const fromText = decodeCursorModelText(stdout); + if (fromText) return fromText; + const fromJson = decodeCursorModelCatalog(firstJsonValue(stdout)); + if (fromJson) return fromJson; + } + return STATIC_CURSOR_MODELS; +} + +export function classifyCursorError(error: unknown): ProviderErrorCode | undefined { + const message = error instanceof Error ? error.message : String(error ?? ""); + const code = error && typeof error === "object" ? (error as { code?: unknown }).code : undefined; + const blob = `${code ?? ""} ${message}`.toLowerCase(); + if ( + code === -32000 || + /unauthoriz|unauthenticated|not signed in|not logged in|invalid api key|invalid_credentials|authentication required/.test( + blob, + ) + ) { + return "invalid_credentials"; + } + if (/inactive subscription|subscription.*(expired|inactive)|upgrade your (plan|subscription)/.test(blob)) { + return "inactive_subscription"; + } + return undefined; +} + +const support = (run: typeof execCli): AcpSupport => ({ + driverKind: "cursorAgent", + displayName: "Cursor", + models: STATIC_CURSOR_MODELS, + // Cursor and other coding agents can both install a generic `agent` shim. + // Cursor's compatibility alias is unambiguous and ships with the same CLI. + defaultCli: "cursor-agent", + nativeSource: "cursor.acp", + loginNote: "Cursor CLI is not signed in — run `cursor-agent login` in a terminal, or set CURSOR_API_KEY", + + install: { + command: { + darwin: "curl https://cursor.com/install -fsS | bash", + linux: "curl https://cursor.com/install -fsS | bash", + win32: "irm 'https://cursor.com/install?win32=true' | iex", + }, + docsUrl: "https://cursor.com/docs/cli/installation", + signInCommand: "cursor-agent login", + }, + + // Global flags must precede `acp` (cursor.com/docs/cli/reference/parameters). + // `--force` is the documented auto-approve switch (`--yolo` is an alias); + // `--model` is the reliable pin — ACP session/set_model is best-effort below. + spawnArgs: (config, turn) => [ + ...(config.fullAuto ? ["--force"] : []), + ...(turn.model ? ["--model", turn.model] : []), + "acp", + ], + credentialEnv: ["CURSOR_API_KEY", "CURSOR_AUTH_TOKEN"], + + resolveModels: (environment, config) => fetchCursorModels(config.cli || "cursor-agent", environment, run), + + // Prefer the advertised ACP method. An already-signed-in CLI should accept + // cursor_login without a browser; a missing method rides the ambient login + // (CURSOR_API_KEY / `cursor-agent login`) instead of failing the turn. + pickAuthMethod: (methods) => (methods.some((m) => m.id === "cursor_login") ? "cursor_login" : null), + authFailure: "continue", + isAuthenticated: (env, config) => probeCursorAuth(config.cli || "cursor-agent", env, run), + classifyError: classifyCursorError, + + async configureSession({ request, sessionId, turn }) { + if (!turn.model) return; + try { + await request("session/set_model", { sessionId, modelId: turn.model }); + } catch (e) { + const err = e as Error & { code?: unknown }; + if (err.code === -32601) return; + throw new Error( + `Cursor rejected model "${turn.model}" via session/set_model: ${err.message}. ` + + `Check that \`cursor-agent\` is current and that this account can use that model.`, + ); + } + }, + + buildPromptText: (turn) => (turn.system ? `${turn.system}\n\n${turn.text}` : turn.text), +}); + +export function createCursorAgentDriver(run: typeof execCli = execCli) { + return createAcpDriver(support(run)); +} + +export const CursorAgentDriver = createCursorAgentDriver(); diff --git a/server/drivers/builtIn.ts b/server/drivers/builtIn.ts index adbb7fe188..2d5d9c7478 100644 --- a/server/drivers/builtIn.ts +++ b/server/drivers/builtIn.ts @@ -10,6 +10,7 @@ import { GrokAgentDriver } from "./acp/grok.ts"; import { GeminiAgentDriver } from "./acp/gemini.ts"; import { KimiAgentDriver } from "./acp/kimi.ts"; import { DroidAgentDriver } from "./acp/droid.ts"; +import { CursorAgentDriver } from "./acp/cursor.ts"; import { OpenCodeGoDriver } from "./acp/opencode-go.ts"; import { QwenAgentDriver } from "./acp/qwen.ts"; import { HermesAgentDriver } from "./acp/hermes.ts"; @@ -20,6 +21,7 @@ export const BUILT_IN_DRIVERS: readonly AnyProviderDriver[] = [ GeminiAgentDriver, KimiAgentDriver, DroidAgentDriver, + CursorAgentDriver, OpenCodeGoDriver, QwenAgentDriver, HermesAgentDriver, diff --git a/server/testing/fake-acp-cli.ts b/server/testing/fake-acp-cli.ts index 31b75441f4..d2bd1e9585 100755 --- a/server/testing/fake-acp-cli.ts +++ b/server/testing/fake-acp-cli.ts @@ -72,6 +72,8 @@ const dumpEnv = Object.fromEntries( "BOX_TOKEN", "OMB_TTS_KEY", "UNSLOTH_STUDIO_AUTH_TOKEN", + "CURSOR_API_KEY", + "CURSOR_AUTH_TOKEN", ].flatMap((key) => (process.env[key] === undefined ? [] : [[key, process.env[key]]] as const)), ); const dumpState: Record = { argv, env: dumpEnv }; @@ -82,6 +84,27 @@ if (argv.includes("--version")) { console.log("fake-acp 1.0.0"); process.exit(0); } +// Cursor's driver probes `agent status` / `agent models` on the same binary +// it later spawns for ACP. Answer those without entering the JSON-RPC loop +// so catalog/auth tests do not hang on stdin. +if (argv[0] === "status" || argv[0] === "whoami") { + const authenticated = process.env.FAKE_ACP_AUTH !== "0"; + console.log(JSON.stringify({ isAuthenticated: authenticated })); + process.exit(0); +} +if (argv[0] === "models" || argv.includes("--list-models")) { + console.log( + [ + "Available models", + "", + "auto - Auto (default)", + "composer-2.5 - Composer 2.5 (current)", + "gpt-5.3-codex - Codex 5.3", + "cursor-live - Cursor Live", + ].join("\n"), + ); + process.exit(0); +} const out = (obj: unknown) => process.stdout.write(JSON.stringify(obj) + "\n"); const result = (id: unknown, res: unknown) => out({ jsonrpc: "2.0", id, result: res }); diff --git a/src/components/CursorMark.tsx b/src/components/CursorMark.tsx new file mode 100644 index 0000000000..63cb2ab434 --- /dev/null +++ b/src/components/CursorMark.tsx @@ -0,0 +1,15 @@ +// Official Cursor mark (cursor.com favicon / wordmark companion). +import { cn } from "@/lib/cn"; + +interface IconProps { + size?: number; + className?: string; +} + +export function CursorMark({ size = 16, className }: IconProps) { + return ( + + + + ); +} diff --git a/src/components/ProviderIcons.tsx b/src/components/ProviderIcons.tsx index 49c6f51312..6c421bd4d4 100644 --- a/src/components/ProviderIcons.tsx +++ b/src/components/ProviderIcons.tsx @@ -2,8 +2,9 @@ import { Monitor } from "lucide-react"; import { cn } from "@/lib/cn"; import { HermesMark } from "./HermesMark"; +import { CursorMark } from "./CursorMark"; -export { HermesMark }; +export { HermesMark, CursorMark }; export interface IconProps { size?: number; @@ -121,6 +122,8 @@ export function ProviderMark({ driverKind, size, className }: IconProps & { driv return ; case "droidAgent": return ; + case "cursorAgent": + return ; case "antigravityAgent": return ; case "opencodeGo":