[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:
@@ -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();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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 };
|
||||
}
|
||||
|
||||
457
electron/bridges/aiBridge/sdk/cursorDriver.cjs
Normal file
457
electron/bridges/aiBridge/sdk/cursorDriver.cjs
Normal 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,
|
||||
};
|
||||
416
electron/bridges/aiBridge/sdk/cursorDriver.test.cjs
Normal file
416
electron/bridges/aiBridge/sdk/cursorDriver.test.cjs
Normal 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" },
|
||||
],
|
||||
);
|
||||
});
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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({}), []);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user