Some checks failed
build-packages / resolve bundled mosh-client (push) Has been cancelled
build-packages / resolve bundled et-client (push) Has been cancelled
build-packages / build-macos (push) Has been cancelled
build-packages / build-windows (push) Has been cancelled
build-packages / build-linux-x64 (push) Has been cancelled
build-packages / build-linux-arm64 (push) Has been cancelled
build-packages / release (push) Has been cancelled
build-packages / update Nix release metadata (push) Has been cancelled
build-packages / bump homebrew tap (push) Has been cancelled
test / lint-and-test (push) Has been cancelled
AI automation / Route event (push) Has been cancelled
AI automation / Hand reopened issue to maintainers (push) Has been cancelled
AI automation / Clean source issue state (push) Has been cancelled
AI automation / Reconcile handoffs (push) Has been cancelled
AI automation / Classify issue (push) Has been cancelled
AI automation / Claude Code smoke (push) Has been cancelled
AI automation / Review issue follow-up (push) Has been cancelled
AI automation / Publish issue follow-up (push) Has been cancelled
AI automation / Implement with Claude Code (push) Has been cancelled
AI automation / Publish implement PR (push) Has been cancelled
AI automation / Continue queued issue comments (push) Has been cancelled
AI automation / Codex review loop (push) Has been cancelled
AI automation / Publish Codex fix (push) Has been cancelled
AI automation / Clear Codex dispatch marker (push) Has been cancelled
AI automation / Own PR re-request Codex (push) Has been cancelled
AI automation / External PR re-request Codex (push) Has been cancelled
AI automation / Poll Codex reaction / retry (push) Has been cancelled
build-et-binaries / build-linux-x64 (push) Has been cancelled
build-et-binaries / build-linux-arm64 (push) Has been cancelled
build-et-binaries / build-macos-universal (push) Has been cancelled
build-et-binaries / build-windows-x64 (push) Has been cancelled
build-et-binaries / release (push) Has been cancelled
488 lines
19 KiB
JavaScript
488 lines
19 KiB
JavaScript
/* 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;
|
|
}
|
|
|
|
// Bundled @cursor/sdk is importable in every Netcatty build. "installed" is the
|
|
// user's Cursor Agent CLI, not that bundled package.
|
|
function computeCursorInstallState({ sdkInstalled, cliBinPath, cliLoginOk } = {}) {
|
|
return {
|
|
sdkInstalled: Boolean(sdkInstalled),
|
|
installed: Boolean(cliBinPath) || Boolean(cliLoginOk),
|
|
};
|
|
}
|
|
|
|
async function probeCursorSdkAvailability(shellEnv, options = {}) {
|
|
const platformPackageName = getCursorPlatformPackageName();
|
|
|
|
let sdkInstalled = false;
|
|
if (platformPackageName) {
|
|
try {
|
|
await import("@cursor/sdk");
|
|
require.resolve(`${platformPackageName}/package.json`);
|
|
sdkInstalled = true;
|
|
} catch {
|
|
sdkInstalled = false;
|
|
}
|
|
}
|
|
|
|
const hasEnvApiKey = Boolean(shellEnv?.CURSOR_API_KEY);
|
|
const hasSettingsApiKey = Boolean(options?.apiKeyPresent);
|
|
const apiKeyOk = hasSettingsApiKey || hasEnvApiKey;
|
|
const probeCli = typeof options?.probeCursorCliAuth === "function"
|
|
? options.probeCursorCliAuth
|
|
: null;
|
|
let cliAuth = { authenticated: false, authSource: null, email: null, binPath: null };
|
|
try {
|
|
if (probeCli) {
|
|
cliAuth = probeCli({ env: shellEnv }) || cliAuth;
|
|
}
|
|
} catch {
|
|
cliAuth = { authenticated: false, authSource: null, email: null, binPath: null };
|
|
}
|
|
|
|
const cliLoginOk = Boolean(cliAuth.authenticated);
|
|
const authenticated = apiKeyOk || cliLoginOk;
|
|
// authSource describes the primary credential for display priority; CLI UI
|
|
// must use cliLoginOk, not this field alone.
|
|
let authSource = null;
|
|
if (hasSettingsApiKey) authSource = "settings";
|
|
else if (hasEnvApiKey) authSource = "CURSOR_API_KEY";
|
|
else if (cliLoginOk) authSource = "cli-login";
|
|
|
|
const installState = computeCursorInstallState({
|
|
sdkInstalled,
|
|
cliBinPath: cliAuth.binPath,
|
|
cliLoginOk,
|
|
});
|
|
sdkInstalled = installState.sdkInstalled;
|
|
// Available if either mode can run a turn (API key + SDK, or CLI login).
|
|
const available = (apiKeyOk && sdkInstalled) || cliLoginOk;
|
|
const installed = installState.installed;
|
|
return {
|
|
installed,
|
|
sdkInstalled,
|
|
available,
|
|
authenticated,
|
|
authSource,
|
|
apiKeyOk,
|
|
cliLoginOk,
|
|
version: sdkInstalled ? "Cursor SDK" : (cliLoginOk || cliAuth.binPath ? "Cursor Agent CLI" : null),
|
|
cliBinPath: cliAuth.binPath || null,
|
|
cliEmail: cliAuth.email || null,
|
|
};
|
|
}
|
|
|
|
function registerAgentDiscoveryHandlers(ctx) {
|
|
with (ctx) {
|
|
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",
|
|
description: "Anthropic's agentic coding assistant", sdkBackend: "claude", args: [] },
|
|
{ command: "codex", name: "Codex CLI", icon: "openai",
|
|
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: [] },
|
|
{ command: "opencode", name: "OpenCode", icon: "opencode",
|
|
description: "Open source coding agent via the official OpenCode SDK", sdkBackend: "opencode", args: [] },
|
|
{ command: "grok", name: "Grok Build", icon: "grok",
|
|
description: "xAI's Grok Build coding agent CLI", sdkBackend: "grok", args: [] },
|
|
];
|
|
|
|
const shellEnv = await getShellEnv();
|
|
const seenPaths = new Set();
|
|
|
|
for (const agent of knownAgents) {
|
|
let cursorSdkStatus = null;
|
|
if (agent.command === "cursor") {
|
|
cursorSdkStatus = await probeCursorSdkAvailability(shellEnv, {
|
|
apiKeyPresent: Boolean(options?.apiKeyPresent),
|
|
probeCursorCliAuth,
|
|
});
|
|
if (!cursorSdkStatus.available) continue;
|
|
}
|
|
|
|
const resolvedPath = agent.command === "cursor"
|
|
? (cursorSdkStatus.cliLoginOk
|
|
? (cursorSdkStatus.cliBinPath || "cursor")
|
|
: (cursorSdkStatus.sdkInstalled ? "cursor" : (cursorSdkStatus.cliBinPath || "cursor")))
|
|
: await resolveCliFromPathAsync(agent.command, shellEnv); // Layer-1: locate
|
|
if (!resolvedPath || seenPaths.has(resolvedPath)) continue;
|
|
|
|
const probe = agent.command === "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).
|
|
let auth = { authenticated: false, authSource: null };
|
|
try {
|
|
if (agent.command === "claude") {
|
|
auth = probeClaudeAuth({ env: shellEnv });
|
|
} else if (agent.command === "copilot") {
|
|
auth = probeCopilotAuth({});
|
|
} else if (agent.command === "codex") {
|
|
auth = { authenticated: false, authSource: null };
|
|
} else if (agent.command === "cursor") {
|
|
auth = {
|
|
authenticated: cursorSdkStatus.authenticated,
|
|
authSource: cursorSdkStatus.authSource,
|
|
};
|
|
} else if (agent.command === "codebuddy") {
|
|
auth = probeCodebuddyAuth({ env: shellEnv });
|
|
} else if (agent.command === "opencode") {
|
|
auth = { authenticated: true, authSource: "opencode-config" };
|
|
} else if (agent.command === "grok") {
|
|
auth = probeGrokAuth({ env: shellEnv });
|
|
}
|
|
} catch { /* auth probe is best-effort */ }
|
|
|
|
agents.push({
|
|
command: agent.command,
|
|
name: agent.name,
|
|
icon: agent.icon,
|
|
description: agent.description,
|
|
sdkBackend: agent.sdkBackend,
|
|
args: agent.args,
|
|
path: resolvedPath,
|
|
binPath: resolvedPath,
|
|
version: probe.version,
|
|
installed: agent.command === "cursor" ? Boolean(cursorSdkStatus.installed) : true,
|
|
available: true,
|
|
authenticated: auth.authenticated,
|
|
authSource: auth.authSource,
|
|
...(agent.command === "cursor" ? {
|
|
cliEmail: cursorSdkStatus.cliEmail || null,
|
|
cliBinPath: cursorSdkStatus.cliBinPath || null,
|
|
cliLoginOk: Boolean(cursorSdkStatus.cliLoginOk),
|
|
apiKeyOk: Boolean(cursorSdkStatus.apiKeyOk),
|
|
sdkInstalled: Boolean(cursorSdkStatus.sdkInstalled),
|
|
} : {}),
|
|
});
|
|
seenPaths.add(resolvedPath);
|
|
}
|
|
|
|
return agents;
|
|
});
|
|
|
|
ipcMain.handle("netcatty:ai:shell-env:prewarm", async (event) => {
|
|
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
|
try {
|
|
await getShellEnv();
|
|
return { ok: true };
|
|
} catch (err) {
|
|
return { ok: false, error: err?.message || String(err) };
|
|
}
|
|
});
|
|
|
|
// Resolve a CLI binary path (auto-detect or validate custom path)
|
|
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 (hasCustomPath) {
|
|
// Normalize Windows shim paths like `codex` -> `codex.cmd` when present.
|
|
// A user-supplied path must be validated as-is; falling back to PATH would
|
|
// make Settings appear to accept one binary while actually using another.
|
|
resolvedPath = normalizeCliPathForPlatform(customPath);
|
|
} else {
|
|
resolvedPath = await resolveCliFromPathAsync(command, shellEnv);
|
|
}
|
|
|
|
if (command === "cursor") {
|
|
const cursorSdkStatus = await probeCursorSdkAvailability(shellEnv, {
|
|
apiKeyPresent: Boolean(apiKeyPresent),
|
|
probeCursorCliAuth,
|
|
});
|
|
// Prefer CLI bin only when CLI login is proven. Otherwise do not use a
|
|
// PATH `agent` binary (generic name) for API-key/SDK path identity.
|
|
const resolvedSdkPath = await resolveCliFromPathAsync(command, shellEnv);
|
|
const cursorPath = cursorSdkStatus.cliLoginOk
|
|
? (cursorSdkStatus.cliBinPath || resolvedSdkPath || "cursor")
|
|
: (resolvedSdkPath || "cursor");
|
|
// Keep the SDK sentinel path when the bundled SDK is importable so
|
|
// API-key mode still has an identity without Cursor.app / Agent CLI.
|
|
const hasCursorPath = cursorSdkStatus.sdkInstalled
|
|
|| cursorSdkStatus.installed
|
|
|| cursorSdkStatus.available;
|
|
return {
|
|
path: hasCursorPath ? cursorPath : null,
|
|
binPath: hasCursorPath ? cursorPath : null,
|
|
version: cursorSdkStatus.version,
|
|
available: cursorSdkStatus.available,
|
|
installed: cursorSdkStatus.installed,
|
|
authenticated: cursorSdkStatus.authenticated,
|
|
authSource: cursorSdkStatus.authSource,
|
|
cliEmail: cursorSdkStatus.cliEmail || null,
|
|
cliBinPath: cursorSdkStatus.cliBinPath || null,
|
|
cliLoginOk: Boolean(cursorSdkStatus.cliLoginOk),
|
|
apiKeyOk: Boolean(cursorSdkStatus.apiKeyOk),
|
|
sdkInstalled: Boolean(cursorSdkStatus.sdkInstalled),
|
|
};
|
|
}
|
|
|
|
if (!resolvedPath) {
|
|
return { path: null, binPath: null, version: null, available: false, installed: false };
|
|
}
|
|
|
|
const probe = await probeCliVersion(resolvedPath, ["--version"], shellEnv);
|
|
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 };
|
|
}
|
|
|
|
return { path: resolvedPath, binPath: resolvedPath, version: probe.version, available: true, installed: true };
|
|
});
|
|
|
|
ipcMain.handle("netcatty:ai:codex:get-integration", async (event, options) => {
|
|
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
|
// When the user clicks "Refresh Status" in Settings we also want to
|
|
// rescan the shell env — otherwise a newly-exported variable in
|
|
// .zshrc stays invisible until they restart netcatty entirely.
|
|
if (options && options.refreshShellEnv) {
|
|
invalidateShellEnvCache();
|
|
}
|
|
try {
|
|
const codexCliOptions = { codexPath: options?.codexPath };
|
|
const result = await runCodexCli(["login", "status"], codexCliOptions);
|
|
const rawOutput = [result.stdout, result.stderr]
|
|
.filter((chunk) => chunk.trim().length > 0)
|
|
.join("\n")
|
|
.trim();
|
|
let state = normalizeCodexIntegrationState(rawOutput);
|
|
let effectiveRawOutput = rawOutput;
|
|
|
|
if (state === "connected_chatgpt" && options?.validateChatGptAuth === true) {
|
|
const validation = await validateCodexChatGptAuth({ maxAgeMs: 10000, codexPath: options?.codexPath });
|
|
if (!validation.ok) {
|
|
if (isCodexAuthError(validation)) {
|
|
try {
|
|
await runCodexCli(["logout"], codexCliOptions);
|
|
} catch {
|
|
// Ignore logout failures; we still want to surface the invalid state.
|
|
}
|
|
invalidateCodexValidationCache();
|
|
state = "not_logged_in";
|
|
}
|
|
|
|
effectiveRawOutput = appendCodexChatGptValidationFailure(
|
|
rawOutput,
|
|
validation.error || "Unknown validation error",
|
|
);
|
|
}
|
|
}
|
|
|
|
// `codex login status` only reflects ~/.codex/auth.json. A user who
|
|
// configured a custom provider directly in ~/.codex/config.toml is
|
|
// functional from the CLI but would look "not_logged_in" here. Probe
|
|
// config.toml so we can surface that as a valid ready state instead of
|
|
// pushing the user into the ChatGPT login flow.
|
|
let customConfig = null;
|
|
if (state !== "connected_chatgpt" && state !== "connected_api_key") {
|
|
try {
|
|
const shellEnv = await getShellEnv();
|
|
customConfig = readCodexCustomProviderConfig(shellEnv);
|
|
if (customConfig) {
|
|
state = "connected_custom_config";
|
|
}
|
|
} catch {
|
|
customConfig = null;
|
|
}
|
|
}
|
|
|
|
return {
|
|
state,
|
|
isConnected:
|
|
state === "connected_chatgpt" ||
|
|
state === "connected_api_key" ||
|
|
state === "connected_custom_config",
|
|
rawOutput: effectiveRawOutput,
|
|
exitCode: result.exitCode,
|
|
customConfig,
|
|
};
|
|
} catch (err) {
|
|
return {
|
|
state: "unknown",
|
|
isConnected: false,
|
|
rawOutput: err?.message || String(err),
|
|
exitCode: null,
|
|
customConfig: null,
|
|
};
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("netcatty:ai:codex:start-login", async (event, options = {}) => {
|
|
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
|
const requestedPath = String(options?.codexPath || "").trim();
|
|
const requestedCodexPath = requestedPath ? normalizeCliPathForPlatform?.(requestedPath) : null;
|
|
if (requestedPath && !requestedCodexPath) {
|
|
return { ok: false, error: `Codex CLI path not found: ${requestedPath}` };
|
|
}
|
|
|
|
try {
|
|
const shellEnv = await getShellEnv();
|
|
const codexCliPath = requestedCodexPath
|
|
|| await resolveCliFromPathAsync("codex", shellEnv)
|
|
|| "codex";
|
|
const existingSession = getActiveCodexLoginSession();
|
|
if (existingSession) {
|
|
const existingPath = existingSession.codexPath || null;
|
|
if (existingPath && codexCliPath !== existingPath) {
|
|
return { ok: false, error: "A Codex login is already running for a different CLI path." };
|
|
}
|
|
return { ok: true, session: toCodexLoginSessionResponse(existingSession) };
|
|
}
|
|
|
|
const sessionId = `codex_login_${randomUUID()}`;
|
|
const spawnSpec = prepareCommandForSpawn(codexCliPath, ["login"]);
|
|
const child = spawn(spawnSpec.command, spawnSpec.args, {
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
env: shellEnv,
|
|
shell: spawnSpec.shell,
|
|
windowsHide: true,
|
|
});
|
|
|
|
const session = {
|
|
id: sessionId,
|
|
process: child,
|
|
state: "running",
|
|
output: "",
|
|
url: null,
|
|
error: null,
|
|
exitCode: null,
|
|
codexPath: codexCliPath,
|
|
};
|
|
|
|
const stdoutDecoder = createCodexLoginOutputDecoder(session);
|
|
const stderrDecoder = createCodexLoginOutputDecoder(session);
|
|
let outputEnded = false;
|
|
const endOutput = () => {
|
|
if (outputEnded) return;
|
|
outputEnded = true;
|
|
stdoutDecoder.end();
|
|
stderrDecoder.end();
|
|
};
|
|
|
|
child.stdout.on("data", (chunk) => stdoutDecoder.write(chunk));
|
|
child.stderr.on("data", (chunk) => stderrDecoder.write(chunk));
|
|
|
|
child.once("error", (error) => {
|
|
endOutput();
|
|
clearCodexLoginKillTimer(session);
|
|
session.state = "error";
|
|
session.error = `[codex] Failed to start login flow: ${error.message}`;
|
|
session.process = null;
|
|
recordCodexLoginSession(session);
|
|
});
|
|
|
|
child.once("close", (exitCode) => {
|
|
endOutput();
|
|
clearCodexLoginKillTimer(session);
|
|
session.exitCode = exitCode;
|
|
session.process = null;
|
|
|
|
if (session.state === "cancelled") {
|
|
recordCodexLoginSession(session);
|
|
return;
|
|
}
|
|
|
|
if (exitCode === 0) {
|
|
session.state = "success";
|
|
session.error = null;
|
|
} else {
|
|
session.state = "error";
|
|
session.error = session.error || `Codex login exited with code ${exitCode ?? "unknown"}`;
|
|
}
|
|
recordCodexLoginSession(session);
|
|
});
|
|
|
|
recordCodexLoginSession(session);
|
|
invalidateCodexValidationCache();
|
|
return { ok: true, session: toCodexLoginSessionResponse(session) };
|
|
} catch (err) {
|
|
return { ok: false, error: err?.message || String(err) };
|
|
}
|
|
});
|
|
|
|
ipcMain.handle("netcatty:ai:codex:get-login-session", async (event, { sessionId }) => {
|
|
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
|
const session = codexLoginSessions.get(sessionId);
|
|
if (!session) {
|
|
return { ok: false, error: "Codex login session not found" };
|
|
}
|
|
return { ok: true, session: toCodexLoginSessionResponse(session) };
|
|
});
|
|
|
|
ipcMain.handle("netcatty:ai:codex:cancel-login", async (event, { sessionId }) => {
|
|
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
|
const session = codexLoginSessions.get(sessionId);
|
|
if (!session) {
|
|
return { ok: true, found: false };
|
|
}
|
|
|
|
session.state = "cancelled";
|
|
session.error = null;
|
|
stopCodexLoginProcess(session);
|
|
recordCodexLoginSession(session);
|
|
|
|
invalidateCodexValidationCache();
|
|
return { ok: true, found: true, session: toCodexLoginSessionResponse(session) };
|
|
});
|
|
|
|
ipcMain.handle("netcatty:ai:codex:logout", async (event, options = {}) => {
|
|
if (!validateSenderOrSettings(event)) return { ok: false, error: "Unauthorized IPC sender" };
|
|
try {
|
|
const codexCliOptions = { codexPath: options?.codexPath };
|
|
const logoutResult = await runCodexCli(["logout"], codexCliOptions);
|
|
invalidateCodexValidationCache();
|
|
const statusResult = await runCodexCli(["login", "status"], codexCliOptions);
|
|
const rawOutput = [statusResult.stdout, statusResult.stderr]
|
|
.filter((chunk) => chunk.trim().length > 0)
|
|
.join("\n")
|
|
.trim();
|
|
const state = normalizeCodexIntegrationState(rawOutput);
|
|
|
|
return {
|
|
ok: true,
|
|
state,
|
|
isConnected:
|
|
state === "connected_chatgpt" ||
|
|
state === "connected_api_key" ||
|
|
state === "connected_custom_config",
|
|
rawOutput,
|
|
logoutOutput: [logoutResult.stdout, logoutResult.stderr]
|
|
.filter((chunk) => chunk.trim().length > 0)
|
|
.join("\n")
|
|
.trim(),
|
|
};
|
|
} catch (err) {
|
|
return { ok: false, error: err?.message || String(err) };
|
|
}
|
|
});
|
|
}
|
|
}
|
|
|
|
module.exports = { registerAgentDiscoveryHandlers, computeCursorInstallState };
|