[codex] Add Cursor SDK agent support (#1399)

* feat(ai): add Cursor SDK agent support

* fix(ai): harden Cursor SDK support

* fix(ai): address Cursor SDK review findings

* fix(ai): refresh Cursor environment handling

* fix(ai): refresh Cursor discovery scans

* fix(ai): enable Cursor recheck without path

* Use official Cursor agent icon

* Clarify Cursor SDK setup requirements

* Split Cursor SDK setup status

* Simplify Cursor settings copy

* Improve Cursor API key error

* Add safe Cursor auth diagnostics

* Disable Cursor local sandbox by default

* Show Cursor MCP tool names in tool cards

* Add spacing inside tool call groups
This commit is contained in:
陈大猫
2026-06-11 16:43:34 +08:00
committed by GitHub
parent 5e00e998a8
commit 3408bba303
35 changed files with 3479 additions and 93 deletions

View File

@@ -67,8 +67,14 @@ function loadBridgeWithMocks(options = {}) {
typeof options.resolveCliFromPath === "function"
? options.resolveCliFromPath(...args)
: null,
getShellEnv: async () => ({}),
invalidateShellEnvCache() {},
getShellEnv: async () => (
typeof options.shellEnv === "function"
? options.shellEnv()
: options.shellEnv || {}
),
invalidateShellEnvCache: () => {
if (typeof options.invalidateShellEnvCache === "function") options.invalidateShellEnvCache();
},
toUnpackedAsarPath: (value) => value,
},
"./ai/codexHelpers.cjs": {
@@ -172,15 +178,15 @@ test("discover returns the 3-layer contract for an installed, authenticated agen
const discover = ipcMain.handlers.get("netcatty:ai:agents:discover");
assert.equal(typeof discover, "function");
const agents = await discover({ sender: { id: 1 } });
assert.equal(agents.length, 1);
assert.equal(agents[0].command, "claude");
assert.equal(agents[0].sdkBackend, "claude");
assert.equal(agents[0].binPath, claudePath);
assert.equal(agents[0].path, claudePath);
assert.equal(agents[0].installed, true);
assert.equal(agents[0].available, true);
assert.equal(agents[0].authenticated, true);
assert.equal(agents[0].authSource, "env");
const claude = agents.find((agent) => agent.command === "claude");
assert.ok(claude);
assert.equal(claude.sdkBackend, "claude");
assert.equal(claude.binPath, claudePath);
assert.equal(claude.path, claudePath);
assert.equal(claude.installed, true);
assert.equal(claude.available, true);
assert.equal(claude.authenticated, true);
assert.equal(claude.authSource, "env");
} finally {
restore();
}
@@ -313,3 +319,190 @@ test("resolve-cli probes Windows Claude exe paths with spaces", { skip: process.
restore();
}
});
test("resolve-cli reports Cursor SDK installed but unavailable without an API key", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli({ sender: { id: 1 } }, { command: "cursor", customPath: "" });
assert.deepEqual(result, {
path: "cursor",
binPath: "cursor",
version: "Cursor SDK",
available: false,
installed: true,
authenticated: false,
authSource: null,
});
} finally {
restore();
}
});
test("resolve-cli separates Cursor SDK installation from API key availability", async () => {
const { bridge, restore } = loadBridgeWithMocks({
normalizeCliPathForPlatform: () => "/Applications/Cursor.app/Contents/MacOS/Cursor",
resolveCliFromPath: () => null,
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli(
{ sender: { id: 1 } },
{ command: "cursor", customPath: "/Applications/Cursor.app/Contents/MacOS/Cursor" },
);
assert.deepEqual(result, {
path: "cursor",
binPath: "cursor",
version: "Cursor SDK",
available: false,
installed: true,
authenticated: false,
authSource: null,
});
} finally {
restore();
}
});
test("resolve-cli ignores custom Cursor paths and stores the SDK sentinel path", async () => {
const { bridge, restore } = loadBridgeWithMocks({
normalizeCliPathForPlatform: () => "/tmp/not-cursor",
resolveCliFromPath: () => null,
shellEnv: { CURSOR_API_KEY: "cur-key" },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli(
{ sender: { id: 1 } },
{ command: "cursor", customPath: "/tmp/not-cursor" },
);
assert.deepEqual(result, {
path: "cursor",
binPath: "cursor",
version: "Cursor SDK",
available: true,
installed: true,
authenticated: true,
authSource: "CURSOR_API_KEY",
});
} finally {
restore();
}
});
test("resolve-cli exposes Cursor SDK support when installed and authenticated", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
shellEnv: { CURSOR_API_KEY: "cur-key" },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli({ sender: { id: 1 } }, { command: "cursor", customPath: "" });
assert.deepEqual(result, {
path: "cursor",
binPath: "cursor",
version: "Cursor SDK",
available: true,
installed: true,
authenticated: true,
authSource: "CURSOR_API_KEY",
});
} finally {
restore();
}
});
test("resolve-cli exposes Cursor SDK support when API key is saved in settings", async () => {
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => "/usr/local/bin/cursor",
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli(
{ sender: { id: 1 } },
{ command: "cursor", customPath: "", apiKeyPresent: true },
);
assert.deepEqual(result, {
path: "/usr/local/bin/cursor",
binPath: "/usr/local/bin/cursor",
version: "Cursor SDK",
available: true,
installed: true,
authenticated: true,
authSource: "settings",
});
} finally {
restore();
}
});
test("resolve-cli can refresh shell env before resolving Cursor", async () => {
let refreshed = false;
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
shellEnv: { CURSOR_API_KEY: "cur-key" },
invalidateShellEnvCache: () => { refreshed = true; },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const resolveCli = ipcMain.handlers.get("netcatty:ai:resolve-cli");
const result = await resolveCli(
{ sender: { id: 1 } },
{ command: "cursor", customPath: "", refreshShellEnv: true },
);
assert.equal(refreshed, true);
assert.equal(result.available, true);
} finally {
restore();
}
});
test("discover can refresh shell env before scanning Cursor", async () => {
let refreshed = false;
const { bridge, restore } = loadBridgeWithMocks({
resolveCliFromPath: () => null,
shellEnv: () => (refreshed ? { CURSOR_API_KEY: "cur-key" } : {}),
invalidateShellEnvCache: () => { refreshed = true; },
});
const ipcMain = createIpcMainStub();
bridge.init({ sessions: new Map(), sftpClients: new Map(), electronModule: { app: { getPath: () => process.cwd() } } });
bridge.registerHandlers(ipcMain);
try {
const discover = ipcMain.handlers.get("netcatty:ai:agents:discover");
const agents = await discover({ sender: { id: 1 } }, { refreshShellEnv: true });
const cursor = agents.find((agent) => agent.command === "cursor");
assert.equal(refreshed, true);
assert.equal(cursor?.path, "cursor");
assert.equal(cursor?.available, true);
} finally {
restore();
}
});

View File

@@ -1,8 +1,43 @@
/* eslint-disable no-undef */
function getCursorPlatformPackageName(platform = process.platform, arch = process.arch) {
if (platform === "darwin" && (arch === "arm64" || arch === "x64")) return `@cursor/sdk-darwin-${arch}`;
if (platform === "linux" && (arch === "arm64" || arch === "x64")) return `@cursor/sdk-linux-${arch}`;
if (platform === "win32" && arch === "x64") return "@cursor/sdk-win32-x64";
return null;
}
async function probeCursorSdkAvailability(shellEnv, options = {}) {
const platformPackageName = getCursorPlatformPackageName();
if (!platformPackageName) {
return { installed: false, available: false, authenticated: false, authSource: null, version: null };
}
try {
await import("@cursor/sdk");
require.resolve(`${platformPackageName}/package.json`);
} catch {
return { installed: false, available: false, authenticated: false, authSource: null, version: null };
}
const hasEnvApiKey = Boolean(shellEnv?.CURSOR_API_KEY);
const hasSettingsApiKey = Boolean(options?.apiKeyPresent);
const authenticated = hasEnvApiKey || hasSettingsApiKey;
return {
installed: true,
available: authenticated,
authenticated,
authSource: hasEnvApiKey ? "CURSOR_API_KEY" : hasSettingsApiKey ? "settings" : null,
version: "Cursor SDK",
};
}
function registerAgentDiscoveryHandlers(ctx) {
with (ctx) {
ipcMain.handle("netcatty:ai:agents:discover", async (event) => {
ipcMain.handle("netcatty:ai:agents:discover", async (event, options = {}) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
if (options?.refreshShellEnv) {
invalidateShellEnvCache();
}
const agents = [];
const knownAgents = [
{ command: "claude", name: "Claude Code", icon: "claude",
@@ -11,6 +46,8 @@ function registerAgentDiscoveryHandlers(ctx) {
description: "OpenAI's coding agent", sdkBackend: "codex", args: [] },
{ command: "copilot", name: "GitHub Copilot CLI", icon: "copilot",
description: "GitHub's coding agent CLI", sdkBackend: "copilot", args: [] },
{ command: "cursor", name: "Cursor", icon: "cursor",
description: "Cursor's coding agent via Cursor SDK", sdkBackend: "cursor", args: [] },
{ command: "codebuddy", name: "CodeBuddy Code", icon: "codebuddy",
description: "Tencent's coding agent CLI (Agent SDK)", sdkBackend: "codebuddy", args: [] },
];
@@ -19,11 +56,24 @@ function registerAgentDiscoveryHandlers(ctx) {
const seenPaths = new Set();
for (const agent of knownAgents) {
const resolvedPath = resolveCliFromPath(agent.command, shellEnv); // Layer-1: locate
let cursorSdkStatus = null;
if (agent.command === "cursor") {
if (!shellEnv.CURSOR_API_KEY) continue;
cursorSdkStatus = await probeCursorSdkAvailability(shellEnv);
if (!cursorSdkStatus.available) continue;
}
const resolvedPath = agent.command === "cursor"
? (resolveCliFromPath(agent.command, shellEnv) || "cursor")
: resolveCliFromPath(agent.command, shellEnv); // Layer-1: locate
if (!resolvedPath || seenPaths.has(resolvedPath)) continue;
const probe = await probeCliVersion(resolvedPath, ["--version"], shellEnv); // Layer-2: version
const hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(probe.version);
const probe = agent.command === "cursor" && resolvedPath === "cursor"
? { exitCode: 0, version: cursorSdkStatus.version }
: await probeCliVersion(resolvedPath, ["--version"], shellEnv); // Layer-2: version
const hasPlausibleVersion = agent.command === "cursor"
? probe.exitCode === 0
: probe.exitCode === 0 && isPlausibleCliVersionOutput(probe.version);
if (!hasPlausibleVersion) continue;
// Layer-3: authentication (best-effort; never blocks discovery).
@@ -37,6 +87,11 @@ function registerAgentDiscoveryHandlers(ctx) {
// codex login status is async; resolve it then inject synchronously.
const codexStatus = await runCodexCli(["login", "status"]).catch(() => null);
auth = probeCodexAuth({ runLoginStatus: () => codexStatus || { exitCode: 1, stdout: "" } });
} else if (agent.command === "cursor") {
auth = {
authenticated: cursorSdkStatus.authenticated,
authSource: cursorSdkStatus.authSource,
};
} else if (agent.command === "codebuddy") {
auth = probeCodebuddyAuth({ env: shellEnv });
}
@@ -64,12 +119,16 @@ function registerAgentDiscoveryHandlers(ctx) {
});
// Resolve a CLI binary path (auto-detect or validate custom path)
ipcMain.handle("netcatty:ai:resolve-cli", async (event, { command, customPath }) => {
ipcMain.handle("netcatty:ai:resolve-cli", async (event, { command, customPath, refreshShellEnv, apiKeyPresent }) => {
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
if (refreshShellEnv) {
invalidateShellEnvCache();
}
const shellEnv = await getShellEnv();
const hasCustomPath = command !== "cursor" && Boolean(String(customPath || "").trim());
let resolvedPath;
if (customPath) {
if (hasCustomPath) {
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
// Fall back to PATH search if the stored path no longer exists
// (e.g. CLI reinstalled to a different location).
@@ -78,12 +137,30 @@ function registerAgentDiscoveryHandlers(ctx) {
resolvedPath = resolveCliFromPath(command, shellEnv);
}
if (command === "cursor") {
const cursorSdkStatus = await probeCursorSdkAvailability(shellEnv, {
apiKeyPresent: Boolean(apiKeyPresent),
});
const cursorPath = resolveCliFromPath(command, shellEnv) || "cursor";
return {
path: cursorSdkStatus.installed ? cursorPath : null,
binPath: cursorSdkStatus.installed ? cursorPath : null,
version: cursorSdkStatus.version,
available: cursorSdkStatus.available,
installed: cursorSdkStatus.installed,
authenticated: cursorSdkStatus.authenticated,
authSource: cursorSdkStatus.authSource,
};
}
if (!resolvedPath) {
return { path: null, binPath: null, version: null, available: false, installed: false };
}
const probe = await probeCliVersion(resolvedPath, ["--version"], shellEnv);
const hasPlausibleVersion = probe.exitCode === 0 && isPlausibleCliVersionOutput(probe.version);
const hasPlausibleVersion = command === "cursor"
? probe.exitCode === 0
: probe.exitCode === 0 && isPlausibleCliVersionOutput(probe.version);
if (!hasPlausibleVersion) {
return { path: resolvedPath, binPath: resolvedPath, version: null, available: false, installed: true };
}

View File

@@ -0,0 +1,457 @@
"use strict";
/**
* Cursor backend driver — wraps @cursor/sdk.
*
* Cursor SDK local agents use Agent.create({ apiKey, model, local:{cwd},
* mcpServers }) and stream SDKMessage events from run.stream().
*/
const { mcpEnvPairsToObject } = require("./injectMcp.cjs");
const DEFAULT_CURSOR_MODEL = "composer-2.5";
function toCursorMcpServers(injectedMcpServers) {
const servers = {};
for (const cfg of injectedMcpServers || []) {
if (!cfg || !cfg.name || !cfg.command) continue;
servers[cfg.name] = {
type: "stdio",
command: cfg.command,
args: cfg.args || [],
env: mcpEnvPairsToObject(cfg.env),
};
}
return servers;
}
function parseCursorModelSelection(model) {
const raw = String(model || DEFAULT_CURSOR_MODEL).trim() || DEFAULT_CURSOR_MODEL;
const queryIndex = raw.indexOf("?");
if (queryIndex < 0) return { id: raw };
const id = raw.slice(0, queryIndex);
const search = new URLSearchParams(raw.slice(queryIndex + 1));
const params = [];
for (const [paramId, value] of search.entries()) {
if (paramId && value) params.push({ id: paramId, value });
}
return params.length > 0 ? { id, params } : { id };
}
function buildCursorAgentOptions({ apiKey, env, model, cwd, injectedMcpServers }) {
const effectiveApiKey = apiKey || env?.CURSOR_API_KEY || process.env.CURSOR_API_KEY;
const options = {
apiKey: effectiveApiKey,
model: parseCursorModelSelection(model),
local: {
cwd: cwd || process.cwd(),
autoReview: false,
},
};
const mcpServers = toCursorMcpServers(injectedMcpServers);
if (Object.keys(mcpServers).length > 0) options.mcpServers = mcpServers;
return options;
}
function applyTemporaryProcessEnv(env) {
if (!env || typeof env !== "object") return () => {};
const previous = new Map();
for (const [key, value] of Object.entries(env)) {
if (typeof value !== "string") continue;
previous.set(key, Object.prototype.hasOwnProperty.call(process.env, key) ? process.env[key] : undefined);
process.env[key] = value;
}
return () => {
for (const [key, value] of previous.entries()) {
if (value === undefined) delete process.env[key];
else process.env[key] = value;
}
};
}
async function withTemporaryProcessEnv(env, fn) {
const restore = applyTemporaryProcessEnv(env);
try {
return await fn();
} finally {
restore();
}
}
function buildCursorSendMessage(prompt, attachments) {
const images = [];
for (const attachment of Array.isArray(attachments) ? attachments : []) {
if (!attachment?.base64Data || !attachment?.mediaType) continue;
if (!String(attachment.mediaType).toLowerCase().startsWith("image/")) continue;
images.push({ data: attachment.base64Data, mimeType: attachment.mediaType });
}
if (images.length === 0) return String(prompt || "");
return { text: String(prompt || ""), images };
}
function resultToText(result) {
if (result == null) return "";
if (typeof result === "string") return result;
if (typeof result === "number" || typeof result === "boolean") return String(result);
const content = result.content;
if (Array.isArray(content)) {
return content
.map((block) => {
if (!block) return "";
if (typeof block.text === "string") return block.text;
if (block.type === "image") return "[image]";
return JSON.stringify(block);
})
.join("");
}
return JSON.stringify(result);
}
function redactCursorSecret(value) {
return String(value || "")
.replace(/crsr[_-]?[A-Za-z0-9_-]{8,}/g, "[redacted-cursor-key]")
.replace(/Bearer\s+[A-Za-z0-9._~+/-]+=*/gi, "Bearer [redacted-token]");
}
function cursorErrorDiagnostics(error) {
if (!error || typeof error !== "object") {
return { message: redactCursorSecret(error) };
}
return {
name: error.name || null,
message: redactCursorSecret(error.message || String(error)),
code: error.code || null,
status: error.status || null,
operation: error.operation || null,
endpoint: error.endpoint || null,
requestId: error.requestId || null,
isRetryable: typeof error.isRetryable === "boolean" ? error.isRetryable : null,
cause: error.cause && typeof error.cause === "object"
? {
name: error.cause.name || null,
message: redactCursorSecret(error.cause.message || String(error.cause)),
}
: null,
};
}
function isCursorAuthMessage(message) {
return /api.?key|auth|unauthorized|unauthenticated/i.test(String(message || ""));
}
async function logCursorApiKeyValidation(resolvedModule, apiKey) {
if (!apiKey || typeof resolvedModule?.Cursor?.me !== "function") return;
try {
const user = await resolvedModule.Cursor.me({ apiKey });
console.info("[Cursor SDK] API key validation ok", {
hasUserId: user?.userId != null,
hasEmail: Boolean(user?.email),
createdAt: user?.createdAt || null,
});
} catch (error) {
console.warn("[Cursor SDK] API key validation failed", cursorErrorDiagnostics(error));
}
}
function closeReasoning(state, emitter) {
if (state?.reasoningOpen) {
emitter.reasoningEnd();
state.reasoningOpen = false;
}
}
function emitCursorToolCallOnce(event, emitter, state, toolName, args, id) {
if (!id) return false;
if (!state.emittedToolCalls) state.emittedToolCalls = new Set();
if (state.emittedToolCalls.has(id)) return false;
state.emittedToolCalls.add(id);
emitter.toolCall(toolName || "tool", args && typeof args === "object" ? args : {}, id);
return true;
}
function emitCursorToolResultOnce(event, emitter, state, id, result, toolName) {
if (!id) return false;
if (!state.emittedToolResults) state.emittedToolResults = new Set();
if (state.emittedToolResults.has(id)) return false;
state.emittedToolResults.add(id);
emitter.toolResult(id, resultToText(result), toolName);
return true;
}
function getCursorDisplayToolName(rawName, args) {
const name = String(rawName || "").trim();
const input = args && typeof args === "object" ? args : {};
const nestedToolName = typeof input.toolName === "string" ? input.toolName.trim() : "";
if ((name === "mcp" || name === "tool" || !name) && nestedToolName) {
return nestedToolName;
}
return name || nestedToolName || "tool";
}
function formatCursorErrorForUser(message) {
const text = String(message || "").trim();
if (/api.?key|auth|unauthorized/i.test(text)) {
return "Cursor authentication failed. Update the Cursor API Key in Settings -> AI.";
}
return text || "Cursor turn failed";
}
function translateCursorEvent(event, emitter, state = {}) {
if (!event || typeof event !== "object") return;
switch (event.type) {
case "thinking":
if (event.text) {
emitter.reasoning(String(event.text));
state.reasoningOpen = true;
}
return;
case "assistant": {
closeReasoning(state, emitter);
const content = event.message?.content;
if (!Array.isArray(content)) return;
for (const block of content) {
if (!block) continue;
if (block.type === "text" && block.text) {
emitter.text(String(block.text));
} else if (block.type === "tool_use") {
emitCursorToolCallOnce(
event,
emitter,
state,
getCursorDisplayToolName(block.name, block.input),
block.input,
block.id,
);
}
}
return;
}
case "tool_call": {
closeReasoning(state, emitter);
const id = event.call_id;
const name = getCursorDisplayToolName(event.name, event.args);
if (event.status === "running") {
emitCursorToolCallOnce(event, emitter, state, name, event.args, id);
} else if (event.status === "completed" || event.status === "error") {
emitCursorToolCallOnce(event, emitter, state, name, event.args, id);
emitCursorToolResultOnce(event, emitter, state, id, event.result || event.error || "", name);
}
return;
}
case "status":
if (event.status === "ERROR") {
closeReasoning(state, emitter);
state.failed = true;
state.errorMessage = String(event.message || "");
console.warn("[Cursor SDK] status error", {
message: redactCursorSecret(event.message || ""),
});
emitter.emitError(formatCursorErrorForUser(event.message));
return true;
}
return false;
default:
return false;
}
}
class CursorTurnAbortError extends Error {
constructor() {
super("Cursor turn aborted");
this.name = "CursorTurnAbortError";
}
}
function isCursorTurnAbortError(error) {
return error instanceof CursorTurnAbortError || error?.name === "CursorTurnAbortError";
}
async function abortable(promise, signal, onLateResolve) {
if (!signal) return promise;
if (signal.aborted) {
promise.then((value) => onLateResolve?.(value)).catch(() => {});
throw new CursorTurnAbortError();
}
let aborted = false;
let removeAbortListener = () => {};
const abortPromise = new Promise((_, reject) => {
const onAbort = () => {
aborted = true;
reject(new CursorTurnAbortError());
};
signal.addEventListener("abort", onAbort, { once: true });
removeAbortListener = () => signal.removeEventListener("abort", onAbort);
});
try {
return await Promise.race([promise, abortPromise]);
} finally {
removeAbortListener();
if (aborted) {
promise.then((value) => onLateResolve?.(value)).catch(() => {});
}
}
}
async function runCursorTurn({
prompt, attachments, agentOptions, runtimeEnv, resumeSessionId, emitter, signal, sdkModule,
}) {
let resolvedModule = sdkModule;
if (!resolvedModule) {
try {
resolvedModule = await import("@cursor/sdk");
} catch {
emitter.emitError("Cursor SDK not installed. Run: npm install @cursor/sdk");
return { sessionId: resumeSessionId || null };
}
}
const { Agent } = resolvedModule;
let agent = null;
let run = null;
let sessionId = resumeSessionId || null;
try {
const restoreCreateEnv = applyTemporaryProcessEnv(runtimeEnv);
try {
const agentPromise = resumeSessionId && typeof Agent.resume === "function"
? Agent.resume(resumeSessionId, agentOptions)
: Agent.create(agentOptions);
agent = await abortable(agentPromise, signal, (lateAgent) => {
try { lateAgent?.close?.(); } catch { /* best effort */ }
});
} finally {
restoreCreateEnv();
}
sessionId = agent.agentId || sessionId;
if (sessionId) emitter.sessionId(sessionId);
if (signal?.aborted) return { sessionId };
const sendMessage = buildCursorSendMessage(prompt, attachments);
const restoreSendEnv = applyTemporaryProcessEnv(runtimeEnv);
try {
run = await abortable(agent.send(sendMessage), signal, (lateRun) => {
if (lateRun && typeof lateRun.cancel === "function") {
void lateRun.cancel().catch(() => {});
}
});
} finally {
restoreSendEnv();
}
const state = { reasoningOpen: false };
let hasContent = false;
let failed = false;
const onAbort = () => {
if (run && typeof run.cancel === "function") {
void run.cancel().catch(() => {});
}
};
if (signal) {
if (signal.aborted) onAbort();
else signal.addEventListener("abort", onAbort, { once: true });
}
try {
for await (const event of run.stream()) {
if (signal?.aborted) break;
if (event?.type === "assistant" || event?.type === "tool_call") hasContent = true;
const streamFailed = translateCursorEvent(event, emitter, state);
if (streamFailed || state.failed) {
failed = true;
break;
}
}
} finally {
if (signal) signal.removeEventListener("abort", onAbort);
}
closeReasoning(state, emitter);
if (failed) {
if (isCursorAuthMessage(state.errorMessage)) {
await logCursorApiKeyValidation(resolvedModule, agentOptions?.apiKey);
}
return { sessionId };
}
if (!hasContent && !signal?.aborted) {
emitter.emitError("Cursor returned an empty response. Check the Cursor API Key in Settings -> AI.");
return { sessionId };
}
if (!signal?.aborted) emitter.emitDone();
return { sessionId };
} catch (error) {
if (isCursorTurnAbortError(error) || signal?.aborted) {
return { sessionId };
}
{
const message = error?.message || String(error);
console.warn("[Cursor SDK] run error", cursorErrorDiagnostics(error));
if (isCursorAuthMessage(message)) {
await logCursorApiKeyValidation(resolvedModule, agentOptions?.apiKey);
}
emitter.emitError(formatCursorErrorForUser(message));
}
return { sessionId };
} finally {
try { await agent?.close?.(); } catch { /* best effort */ }
}
}
function modelVariantId(modelId, params) {
const search = new URLSearchParams();
for (const param of params || []) {
if (param?.id && param?.value) search.set(param.id, param.value);
}
const qs = search.toString();
return qs ? `${modelId}?${qs}` : modelId;
}
function mapCursorModels(models) {
const out = [];
if (!Array.isArray(models)) return out;
for (const model of models) {
if (!model?.id) continue;
const name = model.displayName || model.name || model.id;
out.push({
id: model.id,
name,
...(model.description ? { description: model.description } : {}),
});
for (const variant of model.variants || []) {
const id = modelVariantId(model.id, variant.params || []);
if (id === model.id) continue;
out.push({
id,
name: `${name} - ${variant.displayName || id}`,
...(variant.description ? { description: variant.description } : {}),
});
}
}
return out;
}
async function listCursorModels({ apiKey, env, sdkModule } = {}) {
let resolvedModule = sdkModule;
if (!resolvedModule) {
try { resolvedModule = await import("@cursor/sdk"); } catch { return []; }
}
const effectiveApiKey = apiKey || env?.CURSOR_API_KEY || process.env.CURSOR_API_KEY;
if (!effectiveApiKey) return [];
const models = await resolvedModule.Cursor.models.list({ apiKey: effectiveApiKey });
return mapCursorModels(models);
}
module.exports = {
DEFAULT_CURSOR_MODEL,
abortable,
applyTemporaryProcessEnv,
buildCursorAgentOptions,
buildCursorSendMessage,
formatCursorErrorForUser,
listCursorModels,
mapCursorModels,
parseCursorModelSelection,
runCursorTurn,
toCursorMcpServers,
translateCursorEvent,
withTemporaryProcessEnv,
};

View File

@@ -0,0 +1,416 @@
const test = require("node:test");
const assert = require("node:assert/strict");
const {
buildCursorAgentOptions,
buildCursorSendMessage,
formatCursorErrorForUser,
mapCursorModels,
runCursorTurn,
toCursorMcpServers,
translateCursorEvent,
withTemporaryProcessEnv,
} = require("./cursorDriver.cjs");
function makeEmitter() {
const calls = [];
return {
calls,
text: (value) => calls.push(["text", value]),
reasoning: (value) => calls.push(["reasoning", value]),
reasoningEnd: () => calls.push(["reasoningEnd"]),
toolCall: (name, args, id) => calls.push(["toolCall", name, args, id]),
toolResult: (id, result, name) => calls.push(["toolResult", id, result, name]),
sessionId: (id) => calls.push(["sessionId", id]),
emitDone: () => calls.push(["done"]),
emitError: (message) => calls.push(["error", message]),
};
}
test("buildCursorAgentOptions uses api key, model, cwd, and injected MCP servers", () => {
const options = buildCursorAgentOptions({
apiKey: "cur-key",
model: "composer-2",
cwd: "/repo",
injectedMcpServers: [
{
name: "netcatty",
command: "node",
args: ["server.cjs"],
env: [{ name: "TOKEN", value: "abc" }],
},
],
});
assert.deepEqual(options, {
apiKey: "cur-key",
model: { id: "composer-2" },
local: { cwd: "/repo", autoReview: false },
mcpServers: {
netcatty: {
type: "stdio",
command: "node",
args: ["server.cjs"],
env: { TOKEN: "abc" },
},
},
});
});
test("buildCursorAgentOptions falls back to CURSOR_API_KEY and composer-2.5", () => {
const options = buildCursorAgentOptions({
env: { CURSOR_API_KEY: "env-key" },
cwd: "/repo",
});
assert.equal(options.apiKey, "env-key");
assert.deepEqual(options.model, { id: "composer-2.5" });
});
test("toCursorMcpServers drops invalid server configs", () => {
assert.deepEqual(
toCursorMcpServers([
null,
{ name: "", command: "node" },
{ name: "ok", command: "node", args: [] },
]),
{ ok: { type: "stdio", command: "node", args: [], env: {} } },
);
});
test("withTemporaryProcessEnv restores env after async work", async () => {
const original = process.env.NETCATTY_CURSOR_TEST_ENV;
delete process.env.NETCATTY_CURSOR_TEST_ENV;
const value = await withTemporaryProcessEnv(
{ NETCATTY_CURSOR_TEST_ENV: "present" },
async () => process.env.NETCATTY_CURSOR_TEST_ENV,
);
assert.equal(value, "present");
assert.equal(process.env.NETCATTY_CURSOR_TEST_ENV, undefined);
if (original !== undefined) process.env.NETCATTY_CURSOR_TEST_ENV = original;
});
test("runCursorTurn exposes runtime env while creating and sending", async () => {
const emitter = makeEmitter();
const observed = [];
const sdkModule = {
Agent: {
async create() {
observed.push(["create", process.env.NETCATTY_TOOL_CLI_DISCOVERY_FILE]);
return {
agentId: "agent-env",
async send() {
observed.push(["send", process.env.NETCATTY_TOOL_CLI_DISCOVERY_FILE]);
return {
async *stream() {
yield { type: "assistant", message: { content: [{ type: "text", text: "ok" }] } };
},
};
},
close() {},
};
},
},
};
await runCursorTurn({
prompt: "hi",
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
runtimeEnv: { NETCATTY_TOOL_CLI_DISCOVERY_FILE: "/tmp/discovery.json" },
emitter,
sdkModule,
});
assert.deepEqual(observed, [
["create", "/tmp/discovery.json"],
["send", "/tmp/discovery.json"],
]);
});
test("translateCursorEvent maps assistant, thinking, and tool events", () => {
const emitter = makeEmitter();
const state = {};
translateCursorEvent({ type: "thinking", text: "checking" }, emitter, state);
translateCursorEvent({
type: "assistant",
message: {
content: [
{ type: "text", text: "hello" },
{ type: "tool_use", id: "tool-1", name: "read_file", input: { path: "README.md" } },
],
},
}, emitter, state);
translateCursorEvent({
type: "tool_call",
call_id: "tool-1",
name: "read_file",
status: "completed",
result: { content: [{ type: "text", text: "contents" }] },
}, emitter, state);
assert.deepEqual(emitter.calls, [
["reasoning", "checking"],
["reasoningEnd"],
["text", "hello"],
["toolCall", "read_file", { path: "README.md" }, "tool-1"],
["toolResult", "tool-1", "contents", "read_file"],
]);
});
test("translateCursorEvent uses nested Cursor MCP toolName for display", () => {
const emitter = makeEmitter();
const state = {};
const args = {
providerIdentifier: "netcatty-remote-hosts",
toolName: "terminal_execute",
args: { command: "uname -a" },
};
translateCursorEvent({
type: "tool_call",
call_id: "mcp-1",
name: "mcp",
status: "completed",
args,
result: { content: [{ type: "text", text: "Linux" }] },
}, emitter, state);
assert.deepEqual(emitter.calls, [
["toolCall", "terminal_execute", args, "mcp-1"],
["toolResult", "mcp-1", "Linux", "terminal_execute"],
]);
});
test("translateCursorEvent marks error status as failed", () => {
const emitter = makeEmitter();
const state = {};
const failed = translateCursorEvent({ type: "status", status: "ERROR", message: "bad key" }, emitter, state);
assert.equal(failed, true);
assert.equal(state.failed, true);
assert.deepEqual(emitter.calls, [["error", "bad key"]]);
});
test("translateCursorEvent rewrites Cursor authentication errors", () => {
const emitter = makeEmitter();
const state = {};
const failed = translateCursorEvent({ type: "status", status: "ERROR", message: "bad API key" }, emitter, state);
assert.equal(failed, true);
assert.equal(state.failed, true);
assert.deepEqual(emitter.calls, [[
"error",
"Cursor authentication failed. Update the Cursor API Key in Settings -> AI.",
]]);
});
test("formatCursorErrorForUser points users to the settings API key", () => {
assert.equal(
formatCursorErrorForUser("unauthorized"),
"Cursor authentication failed. Update the Cursor API Key in Settings -> AI.",
);
});
test("runCursorTurn creates or resumes an agent, streams events, and emits done", async () => {
const emitter = makeEmitter();
const captured = {};
const sdkModule = {
Agent: {
async create(options) {
captured.createOptions = options;
return {
agentId: "agent-new",
async send(message) {
captured.message = message;
return {
id: "run-1",
agentId: "agent-new",
async *stream() {
yield { type: "assistant", message: { content: [{ type: "text", text: "done" }] } };
},
};
},
async close() {
captured.closed = true;
},
};
},
},
};
const result = await runCursorTurn({
prompt: "hi",
attachments: [{ mediaType: "image/png", base64Data: "abc", filename: "a.png" }],
agentOptions: { apiKey: "key", model: { id: "composer-2" }, local: { cwd: "/repo" } },
emitter,
sdkModule,
});
assert.equal(result.sessionId, "agent-new");
assert.deepEqual(captured.message, {
text: "hi",
images: [{ data: "abc", mimeType: "image/png" }],
});
assert.deepEqual(emitter.calls, [
["sessionId", "agent-new"],
["text", "done"],
["done"],
]);
assert.equal(captured.closed, true);
});
test("runCursorTurn does not emit done after a Cursor error status", async () => {
const emitter = makeEmitter();
const sdkModule = {
Agent: {
async create() {
return {
agentId: "agent-error",
async send() {
return {
async *stream() {
yield { type: "status", status: "ERROR", message: "bad key" };
yield { type: "assistant", message: { content: [{ type: "text", text: "late" }] } };
},
};
},
close() {},
};
},
},
};
const result = await runCursorTurn({
prompt: "hi",
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
emitter,
sdkModule,
});
assert.equal(result.sessionId, "agent-error");
assert.deepEqual(emitter.calls, [
["sessionId", "agent-error"],
["error", "bad key"],
]);
});
test("runCursorTurn returns when aborted while creating an agent", async () => {
const emitter = makeEmitter();
let resolveCreate;
const createPromise = new Promise((resolve) => {
resolveCreate = resolve;
});
const sdkModule = {
Agent: {
create() {
return createPromise;
},
},
};
const controller = new AbortController();
const turnPromise = runCursorTurn({
prompt: "hi",
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
emitter,
signal: controller.signal,
sdkModule,
});
controller.abort();
const result = await turnPromise;
assert.deepEqual(result, { sessionId: null });
assert.deepEqual(emitter.calls, []);
let closed = false;
resolveCreate({ agentId: "late", close: () => { closed = true; } });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(closed, true);
});
test("runCursorTurn restores runtime env when aborted while creating an agent", async () => {
const emitter = makeEmitter();
const original = process.env.NETCATTY_CURSOR_ABORT_ENV;
delete process.env.NETCATTY_CURSOR_ABORT_ENV;
const sdkModule = {
Agent: {
create() {
return new Promise(() => {});
},
},
};
const controller = new AbortController();
const turnPromise = runCursorTurn({
prompt: "hi",
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
runtimeEnv: { NETCATTY_CURSOR_ABORT_ENV: "present" },
emitter,
signal: controller.signal,
sdkModule,
});
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(process.env.NETCATTY_CURSOR_ABORT_ENV, "present");
controller.abort();
await turnPromise;
assert.equal(process.env.NETCATTY_CURSOR_ABORT_ENV, undefined);
if (original !== undefined) process.env.NETCATTY_CURSOR_ABORT_ENV = original;
});
test("runCursorTurn cancels a late Cursor run when aborted while sending", async () => {
const emitter = makeEmitter();
let resolveSend;
let cancelled = false;
const sendPromise = new Promise((resolve) => {
resolveSend = resolve;
});
const sdkModule = {
Agent: {
async create() {
return {
agentId: "agent-send-abort",
send() {
return sendPromise;
},
close() {},
};
},
},
};
const controller = new AbortController();
const turnPromise = runCursorTurn({
prompt: "hi",
agentOptions: { apiKey: "key", model: { id: "composer-2.5" }, local: { cwd: "/repo" } },
emitter,
signal: controller.signal,
sdkModule,
});
await new Promise((resolve) => setTimeout(resolve, 0));
controller.abort();
const result = await turnPromise;
assert.deepEqual(result, { sessionId: "agent-send-abort" });
assert.deepEqual(emitter.calls, [["sessionId", "agent-send-abort"]]);
resolveSend({ cancel: async () => { cancelled = true; }, stream: async function* stream() {} });
await new Promise((resolve) => setTimeout(resolve, 0));
assert.equal(cancelled, true);
});
test("mapCursorModels maps display names and variants", () => {
assert.deepEqual(
mapCursorModels([
{ id: "composer-2.5", displayName: "Composer 2.5", description: "Default" },
{ id: "gpt-5", displayName: "GPT-5", variants: [{ displayName: "Fast", params: [{ id: "effort", value: "low" }] }] },
]),
[
{ id: "composer-2.5", name: "Composer 2.5", description: "Default" },
{ id: "gpt-5", name: "GPT-5" },
{ id: "gpt-5?effort=low", name: "GPT-5 - Fast" },
],
);
});

View File

@@ -12,6 +12,7 @@
const claude = require("./claudeDriver.cjs");
const codex = require("./codexDriver.cjs");
const copilot = require("./copilotDriver.cjs");
const cursor = require("./cursorDriver.cjs");
const codebuddy = require("./codebuddyDriver.cjs");
const DRIVER_REGISTRY = {
@@ -78,6 +79,29 @@ const DRIVER_REGISTRY = {
return copilot.listCopilotModels({ cliPath: ctx.binPath });
},
},
cursor: {
async runTurn(ctx) {
const agentOptions = cursor.buildCursorAgentOptions({
apiKey: ctx.apiKey,
env: ctx.env,
model: ctx.model,
cwd: ctx.cwd,
injectedMcpServers: ctx.injectedMcpServers,
});
return cursor.runCursorTurn({
prompt: ctx.prompt,
attachments: ctx.attachments,
agentOptions,
runtimeEnv: ctx.env,
resumeSessionId: ctx.resumeSessionId,
emitter: ctx.emitter,
signal: ctx.signal,
});
},
async listModels(ctx) {
return cursor.listCursorModels({ env: ctx.env });
},
},
codebuddy: {
async runTurn(ctx) {
const options = codebuddy.buildCodebuddyQueryOptions({
@@ -102,7 +126,6 @@ const DRIVER_REGISTRY = {
},
},
};
function getDriver(backend) {
const driver = DRIVER_REGISTRY[backend];
if (!driver) throw new Error(`No SDK driver registered for backend: ${backend}`);

View File

@@ -3,11 +3,11 @@ const assert = require("node:assert/strict");
const { getDriver, listBackends, DRIVER_REGISTRY } = require("./index.cjs");
test("registry exposes SDK backends", () => {
assert.deepEqual(listBackends().sort(), ["claude", "codebuddy", "codex", "copilot"]);
assert.deepEqual(listBackends().sort(), ["claude", "codebuddy", "codex", "copilot", "cursor"]);
});
test("getDriver returns a driver with runTurn", () => {
for (const key of ["claude", "codebuddy", "codex", "copilot"]) {
for (const key of ["claude", "codebuddy", "codex", "copilot", "cursor"]) {
const d = getDriver(key);
assert.equal(typeof d.runTurn, "function", `${key} must expose runTurn`);
}
@@ -18,7 +18,7 @@ test("getDriver throws on unknown backend", () => {
});
test("SDK drivers expose listModels; codex returns [] (no catalog)", async () => {
for (const key of ["claude", "codebuddy", "codex", "copilot"]) {
for (const key of ["claude", "codebuddy", "codex", "copilot", "cursor"]) {
assert.equal(typeof getDriver(key).listModels, "function", `${key} must expose listModels`);
}
assert.deepEqual(await getDriver("codex").listModels({}), []);

View File

@@ -4,6 +4,7 @@ const { getDriver, listBackends } = require("./index.cjs");
const { buildSdkAgentEnv } = require("./env.cjs");
const { buildInjectedMcpServers } = require("./injectMcp.cjs");
const { createStreamEmitter } = require("./emit.cjs");
const crypto = require("node:crypto");
const { realpathSync } = require("node:fs");
const VALID_BACKENDS = new Set(listBackends());
@@ -40,6 +41,34 @@ function normalizeHistoryMessages(historyMessages) {
.filter((msg) => msg.content.length > 0);
}
function summarizeSecret(value) {
const text = String(value || "");
if (!text) return null;
return {
length: text.length,
prefix: text.slice(0, 4),
suffix: text.slice(-4),
sha256: crypto.createHash("sha256").update(text).digest("hex").slice(0, 16),
};
}
function logCursorApiKeySummary({ requestedAgentEnv, shellEnv, env }) {
const requestedKey = requestedAgentEnv?.CURSOR_API_KEY;
const shellKey = shellEnv?.CURSOR_API_KEY;
const effectiveKey = env?.CURSOR_API_KEY;
const source = requestedKey
? "settings"
: shellKey
? "environment"
: effectiveKey
? "merged-env"
: "missing";
console.info("[Cursor SDK] API key summary", {
source,
effective: summarizeSecret(effectiveKey),
});
}
function resolveRealCliPath(cliPath, realpath = realpathSync) {
if (!cliPath) return cliPath;
try { return realpath(cliPath); } catch { return cliPath; }
@@ -180,6 +209,9 @@ function registerSdkStreamHandlers(ctx) {
withCliDiscoveryEnv,
normalizeClaudeCodeExecutableEnv: normalizeClaudeCodeExecutableEnvForSdk,
});
if (backendKey === "cursor") {
logCursorApiKeySummary({ requestedAgentEnv: normalizedAgentEnv, shellEnv, env });
}
const binPath = resolveSdkBackendBinPath({
backendKey,

View File

@@ -885,8 +885,8 @@ function createPreloadApi(ctx) {
aiCattyCancelExec: async (chatSessionId) => {
return ipcRenderer.invoke("netcatty:ai:catty:cancel", { chatSessionId });
},
aiDiscoverAgents: async () => {
return ipcRenderer.invoke("netcatty:ai:agents:discover");
aiDiscoverAgents: async (options) => {
return ipcRenderer.invoke("netcatty:ai:agents:discover", options);
},
aiResolveCli: async (params) => {
return ipcRenderer.invoke("netcatty:ai:resolve-cli", params);