diff --git a/docs/architecture.md b/docs/architecture.md index fff6b65..df9e614 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -65,13 +65,16 @@ built-ins by reusing the same slug. - **Outputs:** FileRecord `findings[]` populated, `status: "analyzed"`, `analysisHistory[]` appended. -Two agent backends are supported, both routed through Vercel AI Gateway -by default: +Three agent backends are supported: -| `--agent` | SDK | Default model | +| `--agent` | Runtime | Default model | |---|---|---| | `codex` (default) | `@openai/codex-sdk` | `gpt-5.5` | | `claude` | `@anthropic-ai/claude-agent-sdk` | `claude-opus-4-7` | +| `cursor` | Cursor Agent CLI (`agent`) | `composer-2.5` | + +`codex` and `claude` route through Vercel AI Gateway by default; `cursor` +uses Cursor credentials (`CURSOR_API_KEY` or `agent login`). Same prompt, same JSON output schema. You can mix backends within a project — re-process a file with a different agent and the second run's diff --git a/docs/configuration.md b/docs/configuration.md index e0cb6ba..e484524 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -27,7 +27,7 @@ see [`samples/webapp/deepsec.config.ts`](../samples/webapp/deepsec.config.ts). | `projects` | `ProjectDeclaration[]` | The codebases deepsec knows about. | | `plugins` | `DeepsecPlugin[]` | Loaded in order; later plugins override single-slot capabilities. | | `matchers` | `{ only?: string[]; exclude?: string[] }` | Filter the matcher set used by `scan`. | -| `defaultAgent` | `string` | Default `--agent` value (`codex` or `claude`). See [models.md](models.md). | +| `defaultAgent` | `string` | Default `--agent` value (`codex`, `claude`, or `cursor`). See [models.md](models.md). | | `dataDir` | `string` | Override the `data/` directory. Defaults to `./data`. | ## ProjectDeclaration diff --git a/docs/models.md b/docs/models.md index f58e1d6..c716220 100644 --- a/docs/models.md +++ b/docs/models.md @@ -1,18 +1,23 @@ # Models -deepsec talks to LLMs through two interchangeable backends: +deepsec talks to LLMs through interchangeable agent backends: | Backend | Default model | Used by | |-----------------------------|-----------------------|------------------------------| | `codex` (default) | `gpt-5.5` | `process`, `revalidate` | | `claude` | `claude-opus-4-7` | `process`, `revalidate` | +| `cursor` | `composer-2.5` | `process`, `revalidate` | | `claude` (triage) | `claude-sonnet-4-6` | `triage` (Claude-only) | -Both backends route through [Vercel AI Gateway](https://vercel.com/ai-gateway) +`codex` and `claude` route through [Vercel AI Gateway](https://vercel.com/ai-gateway) by default, so a single token covers Claude **and** Codex. To use Anthropic or OpenAI directly, point `ANTHROPIC_BASE_URL` / `OPENAI_BASE_URL` at the provider. +`cursor` uses the [Cursor Agent CLI](https://cursor.com/docs) (`agent` on +PATH). Set `CURSOR_API_KEY` in `.env.local`, or run `agent login` on the +host. List models with `agent --list-models`. + ## CLI selection ```bash @@ -28,6 +33,10 @@ pnpm deepsec process --project-id my-app --agent codex # Codex backend, specific model: pnpm deepsec process --project-id my-app --agent codex --model gpt-5.4 +# Cursor Agent CLI (local `agent` binary or CURSOR_API_KEY): +pnpm deepsec process --project-id my-app --agent cursor +pnpm deepsec process --project-id my-app --agent cursor --model gpt-5.3-codex-high + # Triage uses Claude; pass a cheaper model if you want: pnpm deepsec triage --project-id my-app --model claude-haiku-4-5 ``` diff --git a/packages/deepsec/src/__tests__/preflight.test.ts b/packages/deepsec/src/__tests__/preflight.test.ts index 02915f1..6eb5a1a 100644 --- a/packages/deepsec/src/__tests__/preflight.test.ts +++ b/packages/deepsec/src/__tests__/preflight.test.ts @@ -35,10 +35,12 @@ describe("assertAgentCredential", () => { OPENAI_API_KEY: process.env.OPENAI_API_KEY, CLAUDE_HOME: process.env.CLAUDE_HOME, CODEX_HOME: process.env.CODEX_HOME, + CURSOR_API_KEY: process.env.CURSOR_API_KEY, PATH: process.env.PATH, }; delete process.env.ANTHROPIC_AUTH_TOKEN; delete process.env.OPENAI_API_KEY; + delete process.env.CURSOR_API_KEY; // Point CLAUDE_HOME / CODEX_HOME and PATH at empty tmp dirs so the // suite is hermetic — the dev running tests may have a real // ~/.codex/auth.json or `claude` on $PATH, which would cause @@ -115,6 +117,27 @@ describe("assertAgentCredential", () => { expect(() => assertAgentCredential("codex")).toThrow(/AI_GATEWAY_API_KEY/); }); + it("passes for cursor when CURSOR_API_KEY is set", () => { + process.env.CURSOR_API_KEY = "x"; + expect(() => assertAgentCredential("cursor")).not.toThrow(); + }); + + it("passes for cursor when `agent` is on PATH (login mode)", () => { + writeFileSync(join(emptyPathDir, "agent"), "#!/bin/sh\n", { mode: 0o755 }); + expect(() => assertAgentCredential("cursor")).not.toThrow(); + }); + + it("throws actionable message for cursor when no key and no agent CLI", () => { + expect(() => assertAgentCredential("cursor")).toThrow(/--agent cursor/); + expect(() => assertAgentCredential("cursor")).toThrow(/CURSOR_API_KEY/); + expect(() => assertAgentCredential("cursor")).toThrow(/agent login/); + }); + + it("ignores cursor login auth in sandbox mode", () => { + writeFileSync(join(emptyPathDir, "agent"), "#!/bin/sh\n", { mode: 0o755 }); + expect(() => assertAgentCredential("cursor", { inSandbox: true })).toThrow(/CURSOR_API_KEY/); + }); + it("skips the credential check for custom plugin agents", () => { // Tests use this so a stub agent registered via plugins[] doesn't // require fake ANTHROPIC_AUTH_TOKEN env vars. diff --git a/packages/deepsec/src/__tests__/resolve-agent-type.test.ts b/packages/deepsec/src/__tests__/resolve-agent-type.test.ts index 8b3c48d..8aa1c8b 100644 --- a/packages/deepsec/src/__tests__/resolve-agent-type.test.ts +++ b/packages/deepsec/src/__tests__/resolve-agent-type.test.ts @@ -30,4 +30,8 @@ describe("resolveAgentType", () => { setLoadedConfig(defineConfig({ projects: [] })); expect(resolveAgentType(undefined)).toBe("codex"); }); + + it("passes cursor through unchanged", () => { + expect(resolveAgentType("cursor")).toBe("cursor"); + }); }); diff --git a/packages/deepsec/src/agent-defaults.ts b/packages/deepsec/src/agent-defaults.ts index edc94bd..8fc73aa 100644 --- a/packages/deepsec/src/agent-defaults.ts +++ b/packages/deepsec/src/agent-defaults.ts @@ -6,6 +6,8 @@ export function defaultModelForAgent(agentType: string): string { switch (agentType) { case "codex": return "gpt-5.5"; + case "cursor": + return "composer-2.5"; default: return "claude-opus-4-7"; } diff --git a/packages/deepsec/src/cli.ts b/packages/deepsec/src/cli.ts index 4beb708..9051455 100755 --- a/packages/deepsec/src/cli.ts +++ b/packages/deepsec/src/cli.ts @@ -132,11 +132,11 @@ program .option("--run-id ", "Resume a specific processing run") .option( "--agent ", - "Agent plugin type: codex or claude (default: defaultAgent in deepsec.config.ts, else codex)", + "Agent plugin type: codex, claude, or cursor (default: defaultAgent in deepsec.config.ts, else codex)", ) .option( "--model ", - "Model to use (default: claude-opus-4-7 for claude, gpt-5.5 for codex)", + "Model to use (default: claude-opus-4-7 for claude, gpt-5.5 for codex, composer-2.5 for cursor)", ) .option("--max-turns ", "Max conversation turns per batch (default: 150)", parseInt) .option( @@ -192,11 +192,11 @@ program .option("--run-id ", "Resume a specific revalidation run") .option( "--agent ", - "Agent plugin type: codex or claude (default: defaultAgent in deepsec.config.ts, else codex)", + "Agent plugin type: codex, claude, or cursor (default: defaultAgent in deepsec.config.ts, else codex)", ) .option( "--model ", - "Model to use (default: claude-opus-4-7 for claude, gpt-5.5 for codex)", + "Model to use (default: claude-opus-4-7 for claude, gpt-5.5 for codex, composer-2.5 for cursor)", ) .option("--max-turns ", "Max conversation turns per batch (default: 150)", parseInt) .option( diff --git a/packages/deepsec/src/preflight.ts b/packages/deepsec/src/preflight.ts index 30ef742..a313a16 100644 --- a/packages/deepsec/src/preflight.ts +++ b/packages/deepsec/src/preflight.ts @@ -81,6 +81,14 @@ function isCodex(agentType: string | undefined): boolean { return agentType === "codex"; } +function isCursor(agentType: string | undefined): boolean { + return agentType === "cursor"; +} + +function hasLocalCursorAgent(): boolean { + return whichSync("agent"); +} + /** * Walk `$PATH` looking for a binary. Used as a positive signal that an * agent CLI (`claude`, `codex`) is set up on this host — if it's @@ -138,7 +146,7 @@ function hasLocalCodexAgent(): boolean { // via plugins (deepsec.config.ts → plugins: [{ agents: [...] }]) handle // their own credential resolution, so we skip the check for anything // other than these. -const KNOWN_BACKENDS = new Set(["claude-agent-sdk", "codex"]); +const KNOWN_BACKENDS = new Set(["claude-agent-sdk", "codex", "cursor"]); /** * Verify the orchestrator has an AI credential the chosen agent can use. @@ -177,6 +185,18 @@ export function assertAgentCredential( ); } + if (isCursor(agentType)) { + if (process.env.CURSOR_API_KEY) return; + if (!options.inSandbox && hasLocalCursorAgent()) return; + throw new Error( + `Missing AI credentials for --agent cursor.\n` + + `\n` + + ` Add to .env.local: CURSOR_API_KEY=…\n` + + ` Or install the Cursor CLI and run \`agent login\` on this host.\n` + + ` Setup: ${SETUP_DOC_URL}`, + ); + } + if (anthropic) return; if (!options.inSandbox && hasLocalClaudeAgent()) return; const displayAgent = diff --git a/packages/processor/src/__tests__/registry.test.ts b/packages/processor/src/__tests__/registry.test.ts index 744e48a..fd9684c 100644 --- a/packages/processor/src/__tests__/registry.test.ts +++ b/packages/processor/src/__tests__/registry.test.ts @@ -52,10 +52,11 @@ describe("AgentRegistry", () => { }); describe("createDefaultAgentRegistry", () => { - it("registers both backends", () => { + it("registers built-in backends", () => { const registry = createDefaultAgentRegistry(); expect(registry.get("claude-agent-sdk")).toBeDefined(); expect(registry.get("codex")).toBeDefined(); - expect(registry.types().sort()).toEqual(["claude-agent-sdk", "codex"]); + expect(registry.get("cursor")).toBeDefined(); + expect(registry.types().sort()).toEqual(["claude-agent-sdk", "codex", "cursor"]); }); }); diff --git a/packages/processor/src/agents/cursor-cli.ts b/packages/processor/src/agents/cursor-cli.ts new file mode 100644 index 0000000..0d20a19 --- /dev/null +++ b/packages/processor/src/agents/cursor-cli.ts @@ -0,0 +1,440 @@ +import { spawn } from "node:child_process"; +import { existsSync } from "node:fs"; +import { join } from "node:path"; +import type { RefusalReport } from "@deepsec/core"; +import { + backoff, + buildInvestigatePrompt, + buildRevalidatePrompt, + classifyQuotaError, + isTransientError, + MAX_ATTEMPTS, + parseInvestigateResults, + parseRefusalReport, + parseRevalidateVerdicts, + QuotaExhaustedError, + REFUSAL_FOLLOWUP_PROMPT, +} from "./shared.js"; +import type { + AgentPlugin, + AgentProgress, + BatchMeta, + InvestigateOutput, + InvestigateParams, + RevalidateOutput, + RevalidateParams, +} from "./types.js"; + +const DEFAULT_MODEL = "composer-2.5"; + +/** Override path to the Cursor Agent CLI (`agent`). */ +const CURSOR_AGENT_EXECUTABLE = process.env.CURSOR_AGENT_EXECUTABLE; + +const CURSOR_ENV_ALLOWLIST = new Set([ + "PATH", + "HOME", + "USER", + "LOGNAME", + "SHELL", + "TERM", + "TZ", + "LANG", + "LANGUAGE", + "LC_ALL", + "LC_CTYPE", + "LC_MESSAGES", + "PWD", + "TMPDIR", + "TMP", + "TEMP", + "DEEPSEC_INSIDE_SANDBOX", +]); + +function whichSync(bin: string): string | undefined { + const pathEnv = process.env.PATH || ""; + const sep = process.platform === "win32" ? ";" : ":"; + for (const dir of pathEnv.split(sep)) { + if (!dir) continue; + const p = join(dir, bin); + if (existsSync(p)) return p; + if (process.platform === "win32") { + if (existsSync(join(dir, `${bin}.exe`))) return join(dir, `${bin}.exe`); + if (existsSync(join(dir, `${bin}.cmd`))) return join(dir, `${bin}.cmd`); + } + } + return undefined; +} + +function resolveCursorExecutable(): string { + if (CURSOR_AGENT_EXECUTABLE) return CURSOR_AGENT_EXECUTABLE; + return whichSync("agent") ?? "agent"; +} + +function buildCursorEnv(): Record { + const env: Record = {}; + for (const [k, v] of Object.entries(process.env)) { + if (typeof v !== "string") continue; + if (CURSOR_ENV_ALLOWLIST.has(k) || k.startsWith("LC_")) env[k] = v; + } + const apiKey = process.env.CURSOR_API_KEY; + if (typeof apiKey === "string") env.CURSOR_API_KEY = apiKey; + return env; +} + +interface CursorRunResult { + resultText: string; + meta: Partial; + lastError: string; + sessionId?: string; +} + +type CursorNdjson = Record; + +function toolCallLabel(msg: CursorNdjson): string | undefined { + const tc = msg.tool_call as Record | undefined; + if (!tc) return undefined; + if ("readToolCall" in tc) { + const path = (tc.readToolCall as { args?: { path?: string } })?.args?.path; + return path ? `Read: ${path.split("/").slice(-3).join("/")}` : "Read"; + } + if ("grepToolCall" in tc) return "Grep"; + if ("globToolCall" in tc) return "Glob"; + if ("shellToolCall" in tc) { + const cmd = (tc.shellToolCall as { args?: { command?: string } })?.args?.command; + return cmd ? `Shell: ${cmd.slice(0, 80)}` : "Shell"; + } + return "tool"; +} + +function usageFromResult(msg: CursorNdjson): BatchMeta["usage"] | undefined { + const usage = msg.usage as + | { + inputTokens?: number; + outputTokens?: number; + cacheReadTokens?: number; + cacheWriteTokens?: number; + } + | undefined; + if (!usage) return undefined; + return { + inputTokens: usage.inputTokens ?? 0, + outputTokens: usage.outputTokens ?? 0, + cacheReadInputTokens: usage.cacheReadTokens ?? 0, + cacheCreationInputTokens: usage.cacheWriteTokens ?? 0, + }; +} + +async function* runCursorAgent(params: { + projectRoot: string; + model: string; + prompt: string; + signal?: AbortSignal; + /** Resume an existing Cursor agent session (for short follow-up prompts). */ + resumeSessionId?: string; +}): AsyncGenerator { + const executable = resolveCursorExecutable(); + const args = [ + "-p", + "--trust", + "--force", + "--workspace", + params.projectRoot, + "--output-format", + "stream-json", + "--model", + params.model, + ]; + if (params.resumeSessionId) { + args.push("--resume", params.resumeSessionId); + } + if (process.env.DEEPSEC_INSIDE_SANDBOX === "1") { + args.push("--sandbox", "disabled"); + } + + const child = spawn(executable, args, { + cwd: params.projectRoot, + env: buildCursorEnv(), + stdio: ["pipe", "pipe", "pipe"], + }); + + let resultText = ""; + let lastError = ""; + let sessionId: string | undefined; + const meta: Partial = {}; + let toolUseCount = 0; + + const abortHandler = () => { + child.kill("SIGTERM"); + }; + if (params.signal) { + if (params.signal.aborted) abortHandler(); + else params.signal.addEventListener("abort", abortHandler, { once: true }); + } + + child.stdin.write(params.prompt); + child.stdin.end(); + + let stderr = ""; + child.stderr.on("data", (chunk: Buffer) => { + stderr += chunk.toString(); + }); + + const lines: string[] = []; + let buffer = ""; + + await new Promise((resolve, reject) => { + child.stdout.on("data", (chunk: Buffer) => { + buffer += chunk.toString(); + let idx: number; + while ((idx = buffer.indexOf("\n")) !== -1) { + const line = buffer.slice(0, idx).trim(); + buffer = buffer.slice(idx + 1); + if (line) lines.push(line); + } + }); + child.on("error", (err) => reject(err)); + child.on("close", (code) => { + if (buffer.trim()) lines.push(buffer.trim()); + if (code !== 0 && !resultText) { + lastError = stderr.trim() || `Cursor agent exited with code ${code}`; + } + resolve(); + }); + }); + + if (params.signal) { + params.signal.removeEventListener("abort", abortHandler); + } + + for (const line of lines) { + let msg: CursorNdjson; + try { + msg = JSON.parse(line) as CursorNdjson; + } catch { + continue; + } + + switch (msg.type) { + case "system": + if (msg.subtype === "init") { + sessionId = msg.session_id as string | undefined; + } + break; + + case "tool_call": + if (msg.subtype === "started") { + toolUseCount++; + const label = toolCallLabel(msg); + if (label) { + yield { type: "tool_use", message: label }; + } + } + break; + + case "result": + if (msg.subtype === "success" && !msg.is_error) { + resultText = String(msg.result ?? ""); + meta.durationApiMs = msg.duration_api_ms as number | undefined; + meta.agentSessionId = (msg.session_id as string) ?? sessionId; + meta.usage = usageFromResult(msg); + } else { + lastError = String(msg.result ?? msg.error ?? "unknown"); + yield { + type: "error", + message: `Agent error: ${lastError.slice(0, 300)}`, + }; + } + break; + } + } + + if (!resultText && stderr && !lastError) { + lastError = stderr.slice(0, 500); + } + + return { resultText, meta, lastError, sessionId }; +} + +async function runRefusalFollowUp( + sessionId: string | undefined, + model: string, + projectRoot: string, +): Promise { + if (!sessionId) return undefined; + try { + const gen = runCursorAgent({ + projectRoot, + model, + prompt: REFUSAL_FOLLOWUP_PROMPT, + resumeSessionId: sessionId, + }); + let step = await gen.next(); + while (!step.done) step = await gen.next(); + return parseRefusalReport(step.value.resultText); + } catch { + return undefined; + } +} + +export class CursorCliPlugin implements AgentPlugin { + type = "cursor"; + + async *investigate(params: InvestigateParams): AsyncGenerator { + const { batch, projectRoot, promptTemplate, projectInfo, config, signal } = params; + const model = (config.model as string) ?? DEFAULT_MODEL; + + yield { + type: "started", + message: `Investigating ${batch.length} file(s) with Cursor Agent CLI (${model})`, + }; + + const prompt = buildInvestigatePrompt({ promptTemplate, projectInfo, batch }); + const startTime = Date.now(); + let toolUseCount = 0; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + if (attempt > 1) { + yield { + type: "thinking", + message: `Retrying batch (attempt ${attempt}/${MAX_ATTEMPTS})`, + }; + } + + const gen = runCursorAgent({ + projectRoot, + model, + prompt, + signal, + }); + let step = await gen.next(); + while (!step.done) { + if (step.value.type === "tool_use") toolUseCount++; + yield step.value; + step = await gen.next(); + } + const run = step.value; + + if (run.resultText) { + const durationMs = Date.now() - startTime; + const refusal = await runRefusalFollowUp(run.sessionId, model, projectRoot); + if (refusal?.refused) { + yield { + type: "thinking", + message: `Refusal detected: ${refusal.reason ?? "see raw"}`, + }; + } + + const tokensStr = run.meta.usage + ? ` ${run.meta.usage.inputTokens + run.meta.usage.outputTokens} tokens` + : ""; + yield { + type: "complete", + message: `Investigation complete (${(durationMs / 1000).toFixed(1)}s, ${toolUseCount} tool calls${tokensStr}${refusal?.refused ? " ⚠️ refusal" : ""})`, + }; + + return { + results: parseInvestigateResults(run.resultText, batch), + meta: { + durationMs, + ...run.meta, + refusal, + }, + }; + } + + const lastError = run.lastError; + yield { + type: "error", + message: `Agent error: ${lastError.slice(0, 300)}`, + }; + + const quotaSource = classifyQuotaError(lastError); + if (quotaSource) throw new QuotaExhaustedError(quotaSource, lastError); + if (attempt >= MAX_ATTEMPTS || !isTransientError(lastError)) break; + await backoff(attempt); + } + + throw new Error( + `Cursor Agent CLI produced no result after ${MAX_ATTEMPTS} attempt(s). ` + + `Ensure \`agent\` is on PATH (Cursor CLI) or set CURSOR_AGENT_EXECUTABLE.`, + ); + } + + async *revalidate(params: RevalidateParams): AsyncGenerator { + const { batch, projectRoot, projectInfo, config, force = false, signal } = params; + const model = (config.model as string) ?? DEFAULT_MODEL; + + const { prompt, totalFindings } = buildRevalidatePrompt({ + batch, + projectRoot, + projectInfo, + force, + }); + + yield { + type: "started", + message: `Revalidating ${totalFindings} finding(s) across ${batch.length} file(s) with Cursor Agent CLI (${model})`, + }; + + const startTime = Date.now(); + let toolUseCount = 0; + + for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) { + if (attempt > 1) { + yield { + type: "thinking", + message: `Retrying batch (attempt ${attempt}/${MAX_ATTEMPTS})`, + }; + } + + const gen = runCursorAgent({ + projectRoot, + model, + prompt, + signal, + }); + let step = await gen.next(); + while (!step.done) { + if (step.value.type === "tool_use") toolUseCount++; + yield step.value; + step = await gen.next(); + } + const run = step.value; + + if (run.resultText) { + const durationMs = Date.now() - startTime; + const refusal = await runRefusalFollowUp(run.sessionId, model, projectRoot); + if (refusal?.refused) { + yield { + type: "thinking", + message: `Refusal detected during revalidation: ${refusal.reason ?? "see raw"}`, + }; + } + yield { + type: "complete", + message: `Revalidation complete (${(durationMs / 1000).toFixed(1)}s, ${toolUseCount} tool calls${refusal?.refused ? " ⚠️ refusal" : ""})`, + }; + return { + verdicts: parseRevalidateVerdicts(run.resultText), + meta: { durationMs, ...run.meta, refusal }, + }; + } + + const lastError = run.lastError; + yield { + type: "error", + message: `Agent error: ${lastError.slice(0, 300)}`, + }; + + const quotaSource = classifyQuotaError(lastError); + if (quotaSource) throw new QuotaExhaustedError(quotaSource, lastError); + if (attempt >= MAX_ATTEMPTS || !isTransientError(lastError)) break; + await backoff(attempt); + } + + throw new Error( + `Cursor Agent CLI produced no result after ${MAX_ATTEMPTS} attempt(s). ` + + `Ensure \`agent\` is on PATH (Cursor CLI) or set CURSOR_AGENT_EXECUTABLE.`, + ); + } +} diff --git a/packages/processor/src/index.ts b/packages/processor/src/index.ts index 99c9cd1..2462fab 100644 --- a/packages/processor/src/index.ts +++ b/packages/processor/src/index.ts @@ -21,6 +21,7 @@ import { import { noiseScore, readTechJson } from "@deepsec/scanner"; import { ClaudeAgentSdkPlugin } from "./agents/claude-agent-sdk.js"; import { CodexAgentSdkPlugin } from "./agents/codex-sdk.js"; +import { CursorCliPlugin } from "./agents/cursor-cli.js"; import { AgentRegistry } from "./agents/registry.js"; import { QuotaExhaustedError, type QuotaSource } from "./agents/shared.js"; import type { @@ -36,6 +37,7 @@ import { languagesForBatch } from "./prompt/file-language.js"; export { ClaudeAgentSdkPlugin } from "./agents/claude-agent-sdk.js"; export { CodexAgentSdkPlugin } from "./agents/codex-sdk.js"; +export { CursorCliPlugin } from "./agents/cursor-cli.js"; export { AgentRegistry } from "./agents/registry.js"; export { classifyQuotaError, @@ -61,6 +63,7 @@ export function createDefaultAgentRegistry(): AgentRegistry { const registry = new AgentRegistry(); registry.register(new ClaudeAgentSdkPlugin()); registry.register(new CodexAgentSdkPlugin()); + registry.register(new CursorCliPlugin()); // Plugins can contribute additional backends via `agents: []` in their // DeepsecPlugin export. The shape is validated by AgentRegistry at use. for (const a of getRegistry().agents as AgentPlugin[]) {