[Init] Initial commit - NetMesh terminal manager
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
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
This commit is contained in:
160
electron/bridges/externalMcp/boundedCliCommand.cjs
Normal file
160
electron/bridges/externalMcp/boundedCliCommand.cjs
Normal file
@@ -0,0 +1,160 @@
|
||||
"use strict";
|
||||
|
||||
const { StringDecoder } = require("node:string_decoder");
|
||||
|
||||
const DEFAULT_EXTERNAL_MCP_CLI_TIMEOUT_MS = 30_000;
|
||||
const DEFAULT_EXTERNAL_MCP_CLI_MAX_OUTPUT_BYTES = 1024 * 1024;
|
||||
const DEFAULT_EXTERNAL_MCP_CLI_KILL_GRACE_MS = 750;
|
||||
|
||||
function runBoundedCliCommand(deps, command, args = [], options = {}) {
|
||||
const timeoutMs = Math.max(
|
||||
1,
|
||||
Number(options.timeoutMs) || DEFAULT_EXTERNAL_MCP_CLI_TIMEOUT_MS,
|
||||
);
|
||||
const maxOutputBytes = Math.max(
|
||||
1,
|
||||
Number(options.maxOutputBytes) || DEFAULT_EXTERNAL_MCP_CLI_MAX_OUTPUT_BYTES,
|
||||
);
|
||||
const killGraceMs = Math.max(
|
||||
1,
|
||||
Number(options.killGraceMs) || DEFAULT_EXTERNAL_MCP_CLI_KILL_GRACE_MS,
|
||||
);
|
||||
const signal = options.signal || null;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let child;
|
||||
let settled = false;
|
||||
let closed = false;
|
||||
let timeoutTimer = null;
|
||||
let forceKillTimer = null;
|
||||
let stdout = "";
|
||||
let stderr = "";
|
||||
let outputBytes = 0;
|
||||
const stdoutDecoder = new StringDecoder("utf8");
|
||||
const stderrDecoder = new StringDecoder("utf8");
|
||||
let decodersEnded = false;
|
||||
|
||||
const clearTimeoutTimer = () => {
|
||||
if (timeoutTimer) clearTimeout(timeoutTimer);
|
||||
timeoutTimer = null;
|
||||
};
|
||||
const clearForceKillTimer = () => {
|
||||
if (forceKillTimer) clearTimeout(forceKillTimer);
|
||||
forceKillTimer = null;
|
||||
};
|
||||
const removeDataListeners = () => {
|
||||
child?.stdout?.removeListener?.("data", onStdout);
|
||||
child?.stderr?.removeListener?.("data", onStderr);
|
||||
};
|
||||
const detachAbort = () => signal?.removeEventListener?.("abort", onAbort);
|
||||
const armForcedKill = () => {
|
||||
if (!child || closed || forceKillTimer) return;
|
||||
try { child.kill?.("SIGTERM"); } catch { /* ignore */ }
|
||||
forceKillTimer = setTimeout(() => {
|
||||
if (closed) return;
|
||||
try { child.kill?.("SIGKILL"); } catch { /* ignore */ }
|
||||
}, killGraceMs);
|
||||
forceKillTimer.unref?.();
|
||||
};
|
||||
const rejectAndTerminate = (error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
clearTimeoutTimer();
|
||||
detachAbort();
|
||||
removeDataListeners();
|
||||
armForcedKill();
|
||||
reject(error);
|
||||
};
|
||||
const append = (target, chunk) => {
|
||||
if (settled) return;
|
||||
const buffer = Buffer.isBuffer(chunk) ? chunk : Buffer.from(String(chunk));
|
||||
const remaining = Math.max(0, maxOutputBytes - outputBytes);
|
||||
if (remaining > 0) {
|
||||
const accepted = buffer.length <= remaining ? buffer : buffer.subarray(0, remaining);
|
||||
if (target === "stdout") stdout += stdoutDecoder.write(accepted);
|
||||
else stderr += stderrDecoder.write(accepted);
|
||||
outputBytes += accepted.length;
|
||||
}
|
||||
if (buffer.length > remaining) {
|
||||
const error = new Error(`CLI output exceeded ${maxOutputBytes} bytes`);
|
||||
error.code = "CLI_OUTPUT_LIMIT";
|
||||
rejectAndTerminate(error);
|
||||
}
|
||||
};
|
||||
const onStdout = (chunk) => append("stdout", chunk);
|
||||
const onStderr = (chunk) => append("stderr", chunk);
|
||||
const onAbort = () => {
|
||||
const reason = signal?.reason;
|
||||
const error = reason instanceof Error ? reason : new Error("CLI command was cancelled");
|
||||
if (!error.code) error.code = "ABORT_ERR";
|
||||
rejectAndTerminate(error);
|
||||
};
|
||||
const onError = (error) => {
|
||||
closed = true;
|
||||
clearTimeoutTimer();
|
||||
clearForceKillTimer();
|
||||
detachAbort();
|
||||
removeDataListeners();
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
reject(error);
|
||||
};
|
||||
const onClose = (exitCode) => {
|
||||
closed = true;
|
||||
clearTimeoutTimer();
|
||||
clearForceKillTimer();
|
||||
detachAbort();
|
||||
removeDataListeners();
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
if (!decodersEnded) {
|
||||
decodersEnded = true;
|
||||
stdout += stdoutDecoder.end();
|
||||
stderr += stderrDecoder.end();
|
||||
}
|
||||
resolve({
|
||||
exitCode,
|
||||
stdout: deps.stripAnsi(stdout),
|
||||
stderr: deps.stripAnsi(stderr),
|
||||
});
|
||||
};
|
||||
|
||||
if (signal?.aborted) {
|
||||
onAbort();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const spawnSpec = deps.prepareCommandForSpawn(command, args);
|
||||
child = deps.spawn(spawnSpec.command, spawnSpec.args || [], {
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
cwd: options.cwd || undefined,
|
||||
env: options.env || process.env,
|
||||
shell: spawnSpec.shell,
|
||||
windowsHide: true,
|
||||
});
|
||||
} catch (error) {
|
||||
settled = true;
|
||||
reject(error);
|
||||
return;
|
||||
}
|
||||
|
||||
child.stdout?.on?.("data", onStdout);
|
||||
child.stderr?.on?.("data", onStderr);
|
||||
child.once?.("error", onError);
|
||||
child.once?.("close", onClose);
|
||||
signal?.addEventListener?.("abort", onAbort, { once: true });
|
||||
timeoutTimer = setTimeout(() => {
|
||||
const error = new Error(`CLI command timed out after ${timeoutMs} ms`);
|
||||
error.code = "CLI_TIMEOUT";
|
||||
rejectAndTerminate(error);
|
||||
}, timeoutMs);
|
||||
if (signal?.aborted) onAbort();
|
||||
});
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
DEFAULT_EXTERNAL_MCP_CLI_TIMEOUT_MS,
|
||||
DEFAULT_EXTERNAL_MCP_CLI_MAX_OUTPUT_BYTES,
|
||||
DEFAULT_EXTERNAL_MCP_CLI_KILL_GRACE_MS,
|
||||
runBoundedCliCommand,
|
||||
};
|
||||
129
electron/bridges/externalMcp/boundedCliCommand.test.cjs
Normal file
129
electron/bridges/externalMcp/boundedCliCommand.test.cjs
Normal file
@@ -0,0 +1,129 @@
|
||||
"use strict";
|
||||
|
||||
const test = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const { EventEmitter } = require("node:events");
|
||||
const { runBoundedCliCommand } = require("./boundedCliCommand.cjs");
|
||||
|
||||
function createChild() {
|
||||
const child = new EventEmitter();
|
||||
child.stdout = new EventEmitter();
|
||||
child.stderr = new EventEmitter();
|
||||
child.kills = [];
|
||||
child.kill = (signal) => {
|
||||
child.kills.push(signal);
|
||||
return true;
|
||||
};
|
||||
return child;
|
||||
}
|
||||
|
||||
function depsFor(child) {
|
||||
return {
|
||||
prepareCommandForSpawn: (command, args) => ({ command, args, shell: false }),
|
||||
spawn: () => child,
|
||||
stripAnsi: (value) => value,
|
||||
};
|
||||
}
|
||||
|
||||
test("bounded external MCP CLI times out and escalates termination", async () => {
|
||||
const child = createChild();
|
||||
await assert.rejects(
|
||||
runBoundedCliCommand(depsFor(child), "codex", [], { timeoutMs: 5, killGraceMs: 5 }),
|
||||
(error) => error.code === "CLI_TIMEOUT",
|
||||
);
|
||||
assert.deepEqual(child.kills, ["SIGTERM"]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(child.kills, ["SIGTERM", "SIGKILL"]);
|
||||
child.emit("close", null);
|
||||
});
|
||||
|
||||
test("bounded external MCP CLI caps combined output and removes data listeners", async () => {
|
||||
const child = createChild();
|
||||
const result = runBoundedCliCommand(depsFor(child), "claude", [], {
|
||||
timeoutMs: 100,
|
||||
maxOutputBytes: 8,
|
||||
});
|
||||
child.stdout.emit("data", Buffer.from("12345"));
|
||||
child.stderr.emit("data", Buffer.from("67890"));
|
||||
await assert.rejects(result, (error) => error.code === "CLI_OUTPUT_LIMIT");
|
||||
assert.equal(child.stdout.listenerCount("data"), 0);
|
||||
assert.equal(child.stderr.listenerCount("data"), 0);
|
||||
assert.deepEqual(child.kills, ["SIGTERM"]);
|
||||
child.emit("close", null);
|
||||
});
|
||||
|
||||
test("bounded external MCP CLI propagates spawn errors and clears timers", async () => {
|
||||
const child = createChild();
|
||||
const result = runBoundedCliCommand(depsFor(child), "grok", [], { timeoutMs: 5 });
|
||||
child.emit("error", new Error("spawn failed"));
|
||||
await assert.rejects(result, /spawn failed/);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(child.kills, []);
|
||||
});
|
||||
|
||||
test("bounded external MCP CLI cancels and force-closes a stuck child", async () => {
|
||||
const child = createChild();
|
||||
const controller = new AbortController();
|
||||
const result = runBoundedCliCommand(depsFor(child), "codex", [], {
|
||||
signal: controller.signal,
|
||||
timeoutMs: 100,
|
||||
killGraceMs: 5,
|
||||
});
|
||||
controller.abort(new Error("cancelled"));
|
||||
await assert.rejects(result, /cancelled/);
|
||||
assert.deepEqual(child.kills, ["SIGTERM"]);
|
||||
await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
assert.deepEqual(child.kills, ["SIGTERM", "SIGKILL"]);
|
||||
child.emit("close", null);
|
||||
});
|
||||
|
||||
test("bounded external MCP CLI resolves output and leaves no listeners", async () => {
|
||||
const child = createChild();
|
||||
const result = runBoundedCliCommand(depsFor(child), "codex", [], { timeoutMs: 100 });
|
||||
child.stdout.emit("data", Buffer.from("ok"));
|
||||
child.stderr.emit("data", Buffer.from("warn"));
|
||||
child.emit("close", 0);
|
||||
assert.deepEqual(await result, { exitCode: 0, stdout: "ok", stderr: "warn" });
|
||||
assert.equal(child.stdout.listenerCount("data"), 0);
|
||||
assert.equal(child.stderr.listenerCount("data"), 0);
|
||||
});
|
||||
|
||||
test("bounded external MCP CLI preserves UTF-8 split across stdout chunks", async () => {
|
||||
const child = createChild();
|
||||
const result = runBoundedCliCommand(depsFor(child), "codex", [], { timeoutMs: 100 });
|
||||
const bytes = Buffer.from("中文", "utf8");
|
||||
child.stdout.emit("data", bytes.subarray(0, 2));
|
||||
child.stdout.emit("data", bytes.subarray(2, 4));
|
||||
child.stdout.emit("data", bytes.subarray(4));
|
||||
child.emit("close", 0);
|
||||
|
||||
assert.deepEqual(await result, { exitCode: 0, stdout: "中文", stderr: "" });
|
||||
});
|
||||
|
||||
test("bounded external MCP CLI completes a UTF-8 code point at the byte limit", async () => {
|
||||
const child = createChild();
|
||||
const bytes = Buffer.from("中", "utf8");
|
||||
const result = runBoundedCliCommand(depsFor(child), "codex", [], {
|
||||
timeoutMs: 100,
|
||||
maxOutputBytes: bytes.length,
|
||||
});
|
||||
child.stdout.emit("data", bytes.subarray(0, 2));
|
||||
child.stdout.emit("data", bytes.subarray(2));
|
||||
child.emit("close", 0);
|
||||
|
||||
assert.deepEqual(await result, { exitCode: 0, stdout: "中", stderr: "" });
|
||||
});
|
||||
|
||||
test("bounded external MCP CLI decodes stdout and stderr independently", async () => {
|
||||
const child = createChild();
|
||||
const stdoutBytes = Buffer.from("中", "utf8");
|
||||
const stderrBytes = Buffer.from("文", "utf8");
|
||||
const result = runBoundedCliCommand(depsFor(child), "codex", [], { timeoutMs: 100 });
|
||||
child.stdout.emit("data", stdoutBytes.subarray(0, 2));
|
||||
child.stderr.emit("data", stderrBytes.subarray(0, 1));
|
||||
child.stdout.emit("data", stdoutBytes.subarray(2));
|
||||
child.stderr.emit("data", stderrBytes.subarray(1));
|
||||
child.emit("close", 0);
|
||||
|
||||
assert.deepEqual(await result, { exitCode: 0, stdout: "中", stderr: "文" });
|
||||
});
|
||||
431
electron/bridges/externalMcp/claudeSetup.cjs
Normal file
431
electron/bridges/externalMcp/claudeSetup.cjs
Normal file
@@ -0,0 +1,431 @@
|
||||
"use strict";
|
||||
|
||||
const { runBoundedCliCommand } = require("./boundedCliCommand.cjs");
|
||||
|
||||
const EXTERNAL_MCP_CLAUDE_NAME = "netcatty-external";
|
||||
const {
|
||||
formatDiscoveryEnvCliFlags,
|
||||
} = require("../../cli/externalMcpDiscoveryPath.cjs");
|
||||
|
||||
function loadShellUtils() {
|
||||
return require("../ai/shellUtils.cjs");
|
||||
}
|
||||
|
||||
function loadDesktopCliResolver() {
|
||||
return require("./desktopCliResolver.cjs");
|
||||
}
|
||||
|
||||
function formatClaudeCommandText(args, cliPath = "claude") {
|
||||
const executable = typeof cliPath === "string" && cliPath.trim()
|
||||
? cliPath.trim()
|
||||
: "claude";
|
||||
return [quoteCommandArg(executable), ...args.map(quoteCommandArg)].join(" ");
|
||||
}
|
||||
|
||||
function quoteCommandArg(value) {
|
||||
if (typeof value !== "string" || value.length === 0) return '""';
|
||||
// Match ExternalMcpCard quoteShellArg so copyable commands stay shell-safe
|
||||
// for paths with spaces, quotes, apostrophes, or backslashes.
|
||||
if (!/[\s"'\\]/u.test(value)) return value;
|
||||
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
|
||||
}
|
||||
|
||||
function getCombinedOutput(result) {
|
||||
return String(`${result?.stdout || ""}\n${result?.stderr || ""}`).trim();
|
||||
}
|
||||
|
||||
function isMissingClaudeServer(result) {
|
||||
const output = getCombinedOutput(result);
|
||||
return /No MCP server (?:found with name:|named)\s*["']?netcatty-external["']?/i.test(output);
|
||||
}
|
||||
|
||||
function normalizePathForCompare(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
let normalized = value.trim().replace(/^["']|["']$/gu, "");
|
||||
if (process.platform === "win32") {
|
||||
normalized = normalized.replace(/\.cmd$/iu, "");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pathsMatch(left, right) {
|
||||
return normalizePathForCompare(left) === normalizePathForCompare(right);
|
||||
}
|
||||
|
||||
function extractExistingCommand(result) {
|
||||
const output = getCombinedOutput(result);
|
||||
if (!output) return null;
|
||||
|
||||
const commandLine = output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => /^Command:\s*/iu.test(line));
|
||||
if (commandLine) {
|
||||
return commandLine.replace(/^Command:\s*/iu, "").trim() || null;
|
||||
}
|
||||
|
||||
const matchingLine = output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => line.includes(EXTERNAL_MCP_CLAUDE_NAME));
|
||||
if (!matchingLine) return output;
|
||||
|
||||
const colonIndex = matchingLine.indexOf(":");
|
||||
const afterName = colonIndex >= 0 ? matchingLine.slice(colonIndex + 1).trim() : matchingLine;
|
||||
const statusSeparatorIndex = afterName.lastIndexOf(" - ");
|
||||
if (statusSeparatorIndex >= 0 && /connected/i.test(afterName.slice(statusSeparatorIndex + 3))) {
|
||||
return afterName.slice(0, statusSeparatorIndex).trim() || output;
|
||||
}
|
||||
return afterName || output;
|
||||
}
|
||||
|
||||
function extractExistingArgs(result) {
|
||||
const output = getCombinedOutput(result);
|
||||
if (!output) return [];
|
||||
const argsLine = output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => /^Args?:\s*/iu.test(line));
|
||||
if (!argsLine) return [];
|
||||
const raw = argsLine.replace(/^Args?:\s*/iu, "").trim();
|
||||
if (!raw || raw === "[]" || raw === "(none)" || raw === "none") return [];
|
||||
return raw.split(/\s+/u).filter(Boolean);
|
||||
}
|
||||
|
||||
function extractExistingScope(result) {
|
||||
const output = getCombinedOutput(result);
|
||||
if (!output) return null;
|
||||
const scopeLine = output
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.find((line) => /^Scope:\s*/iu.test(line));
|
||||
if (scopeLine) {
|
||||
const scopeText = scopeLine.replace(/^Scope:\s*/iu, "").trim().toLowerCase();
|
||||
if (scopeText.startsWith("user")) return "user";
|
||||
if (scopeText.startsWith("local")) return "local";
|
||||
if (scopeText.startsWith("project")) return "project";
|
||||
}
|
||||
const removeHint = output.match(/claude\s+mcp\s+remove[^\n]*?-s\s+(user|local|project)/iu);
|
||||
if (removeHint) return removeHint[1].toLowerCase();
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildClaudeAddArgs(launcherPath, discoveryEnv) {
|
||||
return [
|
||||
"mcp",
|
||||
"add",
|
||||
"-s",
|
||||
"user",
|
||||
EXTERNAL_MCP_CLAUDE_NAME,
|
||||
...formatDiscoveryEnvCliFlags(discoveryEnv, "claude"),
|
||||
"--",
|
||||
launcherPath,
|
||||
];
|
||||
}
|
||||
|
||||
function extractCommandExecutable(commandText) {
|
||||
if (typeof commandText !== "string") return "";
|
||||
const trimmed = commandText.trim();
|
||||
if (!trimmed) return "";
|
||||
// Prefer the last path-like token so env flags before `--` do not confuse matching.
|
||||
const dashDashIndex = trimmed.lastIndexOf(" -- ");
|
||||
const candidate = dashDashIndex >= 0
|
||||
? trimmed.slice(dashDashIndex + 4).trim()
|
||||
: trimmed;
|
||||
const match = candidate.match(/("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)/u);
|
||||
if (!match) return candidate;
|
||||
// Extra args after the executable mean a different launch command.
|
||||
const remainder = candidate.slice(match[0].length).trim();
|
||||
if (remainder) return "";
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function extractExistingEnv(result) {
|
||||
const output = getCombinedOutput(result);
|
||||
if (!output) return null;
|
||||
const env = {};
|
||||
const lines = output.split(/\r?\n/u);
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const line = lines[i];
|
||||
const header = line.match(/^\s*(?:Env|Environment|env)\s*[:=]\s*(.*)\s*$/iu);
|
||||
if (header) {
|
||||
const inline = String(header[1] || "").trim();
|
||||
if (inline) {
|
||||
const inlinePair = inline.match(/^([A-Z0-9_]+)\s*=\s*(.+)$/u);
|
||||
if (inlinePair) {
|
||||
env[inlinePair[1]] = inlinePair[2].trim().replace(/^["']|["']$/gu, "");
|
||||
}
|
||||
}
|
||||
// Claude prints indented KEY=VALUE lines under an Environment: header.
|
||||
for (let j = i + 1; j < lines.length; j += 1) {
|
||||
const nested = lines[j].match(/^\s+([A-Z0-9_]+)\s*=\s*(.+)\s*$/u);
|
||||
if (!nested) break;
|
||||
env[nested[1]] = nested[2].trim().replace(/^["']|["']$/gu, "");
|
||||
i = j;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
const pair = line.match(/^\s*([A-Z0-9_]+)\s*=\s*(.+)\s*$/u);
|
||||
if (pair) {
|
||||
env[pair[1]] = pair[2].trim().replace(/^["']|["']$/gu, "");
|
||||
}
|
||||
}
|
||||
// Also accept inline -e KEY=VALUE fragments in the Command line.
|
||||
const command = extractExistingCommand(result) || "";
|
||||
for (const match of command.matchAll(/(?:^|\s)-e\s+([A-Z0-9_]+)=("[^"]*"|'[^']*'|[^\s]+)/gu)) {
|
||||
env[match[1]] = match[2].replace(/^["']|["']$/gu, "");
|
||||
}
|
||||
return Object.keys(env).length > 0 ? env : null;
|
||||
}
|
||||
|
||||
function hasRequiredDiscoveryEnv(entryEnv, discoveryEnv) {
|
||||
const required = discoveryEnv && typeof discoveryEnv === "object" ? discoveryEnv : {};
|
||||
const keys = Object.keys(required).filter((key) => typeof required[key] === "string" && required[key]);
|
||||
if (keys.length === 0) return true;
|
||||
if (!entryEnv || typeof entryEnv !== "object") return false;
|
||||
return keys.every((key) => String(entryEnv[key] || "") === String(required[key]));
|
||||
}
|
||||
|
||||
function classifyClaudeExternalMcpStatus({
|
||||
getResult,
|
||||
launcherPath,
|
||||
claudePath,
|
||||
discoveryEnv,
|
||||
commandExecutable,
|
||||
}) {
|
||||
const commandArgs = buildClaudeAddArgs(launcherPath, discoveryEnv || {});
|
||||
const base = {
|
||||
ok: true,
|
||||
claudePath: claudePath || null,
|
||||
launcherPath: launcherPath || null,
|
||||
command: formatClaudeCommandText(commandArgs, commandExecutable || claudePath),
|
||||
existingCommand: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
if (getResult?.exitCode !== 0) {
|
||||
if (isMissingClaudeServer(getResult)) {
|
||||
return {
|
||||
...base,
|
||||
state: claudePath ? "not_configured" : "claude_not_found",
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: "error",
|
||||
error: summarizeFailure(getResult, `Claude exited with code ${getResult?.exitCode ?? "unknown"}`),
|
||||
};
|
||||
}
|
||||
|
||||
const existingCommand = extractExistingCommand(getResult);
|
||||
const existingArgs = extractExistingArgs(getResult);
|
||||
const existingScope = extractExistingScope(getResult);
|
||||
if (pathsMatch(extractCommandExecutable(existingCommand), launcherPath)) {
|
||||
if (existingArgs.length > 0) {
|
||||
return {
|
||||
...base,
|
||||
state: "conflict",
|
||||
existingCommand,
|
||||
existingScope,
|
||||
};
|
||||
}
|
||||
if (!hasRequiredDiscoveryEnv(extractExistingEnv(getResult), discoveryEnv)) {
|
||||
return {
|
||||
...base,
|
||||
state: "not_configured",
|
||||
existingCommand,
|
||||
existingScope,
|
||||
};
|
||||
}
|
||||
// One-click setup targets user scope. Local/project matches still need
|
||||
// remove+re-add so the entry is available across projects.
|
||||
if (existingScope && existingScope !== "user") {
|
||||
return {
|
||||
...base,
|
||||
state: "not_configured",
|
||||
existingCommand,
|
||||
existingScope,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: "configured",
|
||||
existingCommand,
|
||||
existingScope,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
state: "conflict",
|
||||
existingCommand,
|
||||
existingScope,
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeFailure(result, fallback) {
|
||||
return String(result?.stderr || result?.stdout || fallback || "Claude command failed").trim();
|
||||
}
|
||||
|
||||
function createExternalMcpClaudeSetup(options = {}) {
|
||||
const deps = {
|
||||
launcherPath: options.launcherPath || null,
|
||||
discoveryEnv: options.discoveryEnv && typeof options.discoveryEnv === "object"
|
||||
? options.discoveryEnv
|
||||
: {},
|
||||
getShellEnv: options.getShellEnv || loadShellUtils().getShellEnv,
|
||||
resolveCliFromPath: options.resolveCliFromPath || loadShellUtils().resolveCliFromPath,
|
||||
resolveDesktopManagedCli: options.resolveDesktopManagedCli
|
||||
|| loadDesktopCliResolver().resolveDesktopManagedCli,
|
||||
prepareCommandForSpawn: options.prepareCommandForSpawn || loadShellUtils().prepareCommandForSpawn,
|
||||
spawn: options.spawn || require("node:child_process").spawn,
|
||||
stripAnsi: options.stripAnsi || loadShellUtils().stripAnsi,
|
||||
};
|
||||
|
||||
function getManualCommand(cliPath) {
|
||||
return formatClaudeCommandText(
|
||||
buildClaudeAddArgs(deps.launcherPath, deps.discoveryEnv),
|
||||
cliPath,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveClaude() {
|
||||
const shellEnv = await deps.getShellEnv();
|
||||
// PATH installs keep the bare `claude` copyable command (portable across
|
||||
// shells). Desktop-managed absolute paths only appear when PATH misses.
|
||||
const pathResolved = deps.resolveCliFromPath("claude", shellEnv) || null;
|
||||
const desktopResolved = pathResolved
|
||||
? null
|
||||
: (deps.resolveDesktopManagedCli("claude") || null);
|
||||
const claudePath = pathResolved || desktopResolved;
|
||||
return {
|
||||
shellEnv,
|
||||
claudePath,
|
||||
commandExecutable: pathResolved ? "claude" : (desktopResolved || "claude"),
|
||||
};
|
||||
}
|
||||
|
||||
async function runClaude(claudePath, shellEnv, args) {
|
||||
return await runBoundedCliCommand(deps, claudePath, args, { env: shellEnv });
|
||||
}
|
||||
|
||||
async function getStatus() {
|
||||
const { shellEnv, claudePath, commandExecutable } = await resolveClaude();
|
||||
if (!claudePath) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "claude_not_found",
|
||||
claudePath: null,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(),
|
||||
existingCommand: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// `claude mcp get` does not accept `-s`; user-scope entries are still
|
||||
// returned by the default get lookup after `mcp add -s user`.
|
||||
const result = await runClaude(claudePath, shellEnv, [
|
||||
"mcp",
|
||||
"get",
|
||||
EXTERNAL_MCP_CLAUDE_NAME,
|
||||
]);
|
||||
const status = classifyClaudeExternalMcpStatus({
|
||||
getResult: result,
|
||||
launcherPath: deps.launcherPath,
|
||||
claudePath,
|
||||
discoveryEnv: deps.discoveryEnv,
|
||||
commandExecutable,
|
||||
});
|
||||
return {
|
||||
...status,
|
||||
command: getManualCommand(commandExecutable),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "error",
|
||||
claudePath,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(commandExecutable),
|
||||
existingCommand: null,
|
||||
error: error?.message || String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function addToClaude() {
|
||||
const status = await getStatus();
|
||||
if (status.state === "claude_not_found" || status.state === "conflict" || status.state === "configured") {
|
||||
return status;
|
||||
}
|
||||
if (status.state === "error") {
|
||||
return status;
|
||||
}
|
||||
|
||||
const { shellEnv, claudePath, commandExecutable } = await resolveClaude();
|
||||
if (!claudePath) {
|
||||
return {
|
||||
...status,
|
||||
state: "claude_not_found",
|
||||
claudePath: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (status.existingCommand) {
|
||||
const scopes = status.existingScope
|
||||
? [status.existingScope]
|
||||
: ["local", "user", "project"];
|
||||
for (const nextScope of scopes) {
|
||||
await runClaude(claudePath, shellEnv, [
|
||||
"mcp",
|
||||
"remove",
|
||||
"-s",
|
||||
nextScope,
|
||||
EXTERNAL_MCP_CLAUDE_NAME,
|
||||
]);
|
||||
}
|
||||
}
|
||||
const addResult = await runClaude(
|
||||
claudePath,
|
||||
shellEnv,
|
||||
buildClaudeAddArgs(deps.launcherPath, deps.discoveryEnv),
|
||||
);
|
||||
if (addResult.exitCode !== 0) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "error",
|
||||
claudePath,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(commandExecutable),
|
||||
existingCommand: null,
|
||||
error: summarizeFailure(addResult, `Claude exited with code ${addResult.exitCode ?? "unknown"}`),
|
||||
};
|
||||
}
|
||||
return await getStatus();
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "error",
|
||||
claudePath,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(commandExecutable),
|
||||
existingCommand: null,
|
||||
error: error?.message || String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getStatus,
|
||||
addToClaude,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EXTERNAL_MCP_CLAUDE_NAME,
|
||||
createExternalMcpClaudeSetup,
|
||||
classifyClaudeExternalMcpStatus,
|
||||
};
|
||||
316
electron/bridges/externalMcp/clientSetup.test.cjs
Normal file
316
electron/bridges/externalMcp/clientSetup.test.cjs
Normal file
@@ -0,0 +1,316 @@
|
||||
"use strict";
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
|
||||
const {
|
||||
EXTERNAL_MCP_CODEX_NAME,
|
||||
classifyCodexExternalMcpStatus,
|
||||
parseCodexMcpList,
|
||||
} = require("./codexSetup.cjs");
|
||||
const {
|
||||
EXTERNAL_MCP_CLAUDE_NAME,
|
||||
classifyClaudeExternalMcpStatus,
|
||||
} = require("./claudeSetup.cjs");
|
||||
const {
|
||||
EXTERNAL_MCP_GROK_NAME,
|
||||
classifyGrokExternalMcpStatus,
|
||||
parseGrokMcpList,
|
||||
} = require("./grokSetup.cjs");
|
||||
|
||||
describe("external MCP client setup classifiers", () => {
|
||||
it("parses Codex MCP list and detects configured launcher", () => {
|
||||
const entries = parseCodexMcpList(JSON.stringify([
|
||||
{
|
||||
name: EXTERNAL_MCP_CODEX_NAME,
|
||||
enabled: true,
|
||||
transport: {
|
||||
type: "stdio",
|
||||
command: "/path/to/netcatty-external-mcp",
|
||||
args: [],
|
||||
env: { NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: "/tmp/discovery.json" },
|
||||
},
|
||||
},
|
||||
]));
|
||||
const status = classifyCodexExternalMcpStatus({
|
||||
entries,
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
codexPath: "/usr/bin/codex",
|
||||
discoveryEnv: { NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: "/tmp/discovery.json" },
|
||||
});
|
||||
assert.equal(status.state, "configured");
|
||||
});
|
||||
|
||||
it("treats Codex launcher without discovery env as not_configured", () => {
|
||||
const status = classifyCodexExternalMcpStatus({
|
||||
entries: [{
|
||||
name: EXTERNAL_MCP_CODEX_NAME,
|
||||
transport: { type: "stdio", command: "/path/to/netcatty-external-mcp", args: [], env: null },
|
||||
}],
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
codexPath: "/usr/bin/codex",
|
||||
discoveryEnv: { NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: "/tmp/discovery.json" },
|
||||
});
|
||||
assert.equal(status.state, "not_configured");
|
||||
assert.equal(status.existingCommand, "/path/to/netcatty-external-mcp");
|
||||
});
|
||||
|
||||
it("treats disabled Codex entries as not_configured with existingCommand", () => {
|
||||
const status = classifyCodexExternalMcpStatus({
|
||||
entries: [{
|
||||
name: EXTERNAL_MCP_CODEX_NAME,
|
||||
enabled: false,
|
||||
transport: { type: "stdio", command: "/path/to/netcatty-external-mcp", args: [] },
|
||||
}],
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
codexPath: "/usr/bin/codex",
|
||||
discoveryEnv: { NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: "/tmp/discovery.json" },
|
||||
});
|
||||
assert.equal(status.state, "not_configured");
|
||||
assert.ok(status.existingCommand);
|
||||
});
|
||||
|
||||
it("flags Codex conflict when command differs", () => {
|
||||
const status = classifyCodexExternalMcpStatus({
|
||||
entries: [{
|
||||
name: EXTERNAL_MCP_CODEX_NAME,
|
||||
transport: { type: "stdio", command: "/other/path", args: [] },
|
||||
}],
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
codexPath: "/usr/bin/codex",
|
||||
});
|
||||
assert.equal(status.state, "conflict");
|
||||
});
|
||||
|
||||
it("embeds desktop-managed Codex path in the copyable setup command", () => {
|
||||
const desktopPath = "/Applications/ChatGPT.app/Contents/Resources/codex";
|
||||
const status = classifyCodexExternalMcpStatus({
|
||||
entries: [],
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
codexPath: desktopPath,
|
||||
commandExecutable: desktopPath,
|
||||
});
|
||||
assert.equal(status.state, "not_configured");
|
||||
assert.ok(status.command.startsWith(`${desktopPath} `));
|
||||
assert.equal(status.command.startsWith("codex "), false);
|
||||
});
|
||||
|
||||
it("keeps bare codex for PATH installs even when codexPath is absolute", () => {
|
||||
const status = classifyCodexExternalMcpStatus({
|
||||
entries: [],
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
codexPath: "C:\\Program Files\\Codex\\codex.exe",
|
||||
commandExecutable: "codex",
|
||||
});
|
||||
assert.equal(status.state, "not_configured");
|
||||
assert.ok(status.command.startsWith("codex "));
|
||||
});
|
||||
|
||||
it("embeds desktop-managed Claude path in the copyable setup command", () => {
|
||||
const desktopPath = "/Users/test/Library/Application Support/Claude/claude-code/2.10.0/claude.app/Contents/MacOS/claude";
|
||||
const status = classifyClaudeExternalMcpStatus({
|
||||
getResult: {
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: 'No MCP server found with name: "netcatty-external"',
|
||||
},
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: desktopPath,
|
||||
commandExecutable: desktopPath,
|
||||
});
|
||||
assert.equal(status.state, "not_configured");
|
||||
assert.ok(status.command.startsWith(`"${desktopPath}" `));
|
||||
assert.equal(status.command.startsWith("claude "), false);
|
||||
});
|
||||
|
||||
it("quotes launcher paths with apostrophes in the copyable setup command", () => {
|
||||
const launcherPath = "/Applications/Bob's/Netcatty.app/Contents/MacOS/netcatty-external-mcp";
|
||||
const status = classifyCodexExternalMcpStatus({
|
||||
entries: [],
|
||||
launcherPath,
|
||||
codexPath: "/usr/bin/codex",
|
||||
commandExecutable: "codex",
|
||||
});
|
||||
assert.equal(status.state, "not_configured");
|
||||
assert.ok(status.command.includes(`"${launcherPath}"`));
|
||||
});
|
||||
|
||||
it("classifies Claude configured and missing states", () => {
|
||||
const configured = classifyClaudeExternalMcpStatus({
|
||||
getResult: { exitCode: 0, stdout: `${EXTERNAL_MCP_CLAUDE_NAME}: /path/to/netcatty-external-mcp - connected`, stderr: "" },
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: "/usr/bin/claude",
|
||||
});
|
||||
assert.equal(configured.state, "configured");
|
||||
|
||||
const quoted = classifyClaudeExternalMcpStatus({
|
||||
getResult: {
|
||||
exitCode: 0,
|
||||
stdout: `Command: "/path/to/netcatty-external-mcp"\nStatus: connected`,
|
||||
stderr: "",
|
||||
},
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: "/usr/bin/claude",
|
||||
});
|
||||
assert.equal(quoted.state, "configured");
|
||||
|
||||
const withEnvHeader = classifyClaudeExternalMcpStatus({
|
||||
getResult: {
|
||||
exitCode: 0,
|
||||
stdout: `Command: /path/to/netcatty-external-mcp\nEnvironment:\n NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE=/tmp/d.json\nScope: User config (available in all your projects)`,
|
||||
stderr: "",
|
||||
},
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: "/usr/bin/claude",
|
||||
discoveryEnv: { NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: "/tmp/d.json" },
|
||||
});
|
||||
assert.equal(withEnvHeader.state, "configured");
|
||||
|
||||
const withEnvFlags = classifyClaudeExternalMcpStatus({
|
||||
getResult: {
|
||||
exitCode: 0,
|
||||
stdout: `Command: -e NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE=/tmp/d.json -- /path/to/netcatty-external-mcp`,
|
||||
stderr: "",
|
||||
},
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: "/usr/bin/claude",
|
||||
discoveryEnv: { NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: "/tmp/d.json" },
|
||||
});
|
||||
assert.equal(withEnvFlags.state, "configured");
|
||||
|
||||
const missingEnv = classifyClaudeExternalMcpStatus({
|
||||
getResult: {
|
||||
exitCode: 0,
|
||||
stdout: `Command: /path/to/netcatty-external-mcp`,
|
||||
stderr: "",
|
||||
},
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: "/usr/bin/claude",
|
||||
discoveryEnv: { NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: "/tmp/d.json" },
|
||||
});
|
||||
assert.equal(missingEnv.state, "not_configured");
|
||||
|
||||
const withExtraArgs = classifyClaudeExternalMcpStatus({
|
||||
getResult: {
|
||||
exitCode: 0,
|
||||
stdout: `Command: /path/to/netcatty-external-mcp --evil`,
|
||||
stderr: "",
|
||||
},
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: "/usr/bin/claude",
|
||||
});
|
||||
assert.equal(withExtraArgs.state, "conflict");
|
||||
|
||||
const withArgsField = classifyClaudeExternalMcpStatus({
|
||||
getResult: {
|
||||
exitCode: 0,
|
||||
stdout: `Command: /path/to/netcatty-external-mcp\nArgs: --evil\nScope: Local config (private to you in this project)`,
|
||||
stderr: "",
|
||||
},
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: "/usr/bin/claude",
|
||||
});
|
||||
assert.equal(withArgsField.state, "conflict");
|
||||
assert.equal(withArgsField.existingScope, "local");
|
||||
|
||||
const userScope = classifyClaudeExternalMcpStatus({
|
||||
getResult: {
|
||||
exitCode: 0,
|
||||
stdout: `Command: -e NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE=/tmp/d.json -- /path/to/netcatty-external-mcp\nScope: User config (available in all your projects)`,
|
||||
stderr: "",
|
||||
},
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: "/usr/bin/claude",
|
||||
discoveryEnv: { NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: "/tmp/d.json" },
|
||||
});
|
||||
assert.equal(userScope.state, "configured");
|
||||
assert.equal(userScope.existingScope, "user");
|
||||
|
||||
const localScopeNeedsUpgrade = classifyClaudeExternalMcpStatus({
|
||||
getResult: {
|
||||
exitCode: 0,
|
||||
stdout: `Command: -e NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE=/tmp/d.json -- /path/to/netcatty-external-mcp\nScope: Local config (private to you in this project)`,
|
||||
stderr: "",
|
||||
},
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: "/usr/bin/claude",
|
||||
discoveryEnv: { NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: "/tmp/d.json" },
|
||||
});
|
||||
assert.equal(localScopeNeedsUpgrade.state, "not_configured");
|
||||
assert.equal(localScopeNeedsUpgrade.existingScope, "local");
|
||||
assert.ok(localScopeNeedsUpgrade.existingCommand);
|
||||
|
||||
const missing = classifyClaudeExternalMcpStatus({
|
||||
getResult: {
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: `No MCP server found with name: "${EXTERNAL_MCP_CLAUDE_NAME}"`,
|
||||
},
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: "/usr/bin/claude",
|
||||
});
|
||||
assert.equal(missing.state, "not_configured");
|
||||
|
||||
const missingNamed = classifyClaudeExternalMcpStatus({
|
||||
getResult: {
|
||||
exitCode: 1,
|
||||
stdout: "",
|
||||
stderr: `No MCP server named ${EXTERNAL_MCP_CLAUDE_NAME}`,
|
||||
},
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
claudePath: "/usr/bin/claude",
|
||||
});
|
||||
assert.equal(missingNamed.state, "not_configured");
|
||||
});
|
||||
|
||||
it("parses Grok MCP list and detects configured launcher", () => {
|
||||
const entries = parseGrokMcpList(JSON.stringify([
|
||||
{
|
||||
name: EXTERNAL_MCP_GROK_NAME,
|
||||
enabled: true,
|
||||
transport: { type: "stdio", command: "/path/to/netcatty-external-mcp", args: [] },
|
||||
env: { NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: "/tmp/discovery.json" },
|
||||
},
|
||||
]));
|
||||
const status = classifyGrokExternalMcpStatus({
|
||||
entries,
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
grokPath: "/usr/bin/grok",
|
||||
discoveryEnv: { NETCATTY_EXTERNAL_MCP_DISCOVERY_FILE: "/tmp/discovery.json" },
|
||||
});
|
||||
assert.equal(status.state, "configured");
|
||||
});
|
||||
|
||||
it("flags Grok conflict when command differs", () => {
|
||||
const status = classifyGrokExternalMcpStatus({
|
||||
entries: [{
|
||||
name: EXTERNAL_MCP_GROK_NAME,
|
||||
transport: { type: "stdio", command: "/other/path", args: [] },
|
||||
}],
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
grokPath: "/usr/bin/grok",
|
||||
});
|
||||
assert.equal(status.state, "conflict");
|
||||
});
|
||||
|
||||
it("flags Grok conflict when launcher has extra args", () => {
|
||||
const status = classifyGrokExternalMcpStatus({
|
||||
entries: [{
|
||||
name: EXTERNAL_MCP_GROK_NAME,
|
||||
transport: { type: "stdio", command: "/path/to/netcatty-external-mcp", args: ["--evil"] },
|
||||
}],
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
grokPath: "/usr/bin/grok",
|
||||
});
|
||||
assert.equal(status.state, "conflict");
|
||||
});
|
||||
|
||||
it("classifies Grok missing when CLI is absent", () => {
|
||||
const status = classifyGrokExternalMcpStatus({
|
||||
entries: [],
|
||||
launcherPath: "/path/to/netcatty-external-mcp",
|
||||
grokPath: null,
|
||||
});
|
||||
assert.equal(status.state, "grok_not_found");
|
||||
});
|
||||
});
|
||||
328
electron/bridges/externalMcp/codexSetup.cjs
Normal file
328
electron/bridges/externalMcp/codexSetup.cjs
Normal file
@@ -0,0 +1,328 @@
|
||||
"use strict";
|
||||
|
||||
const { runBoundedCliCommand } = require("./boundedCliCommand.cjs");
|
||||
|
||||
const EXTERNAL_MCP_CODEX_NAME = "netcatty-external";
|
||||
const {
|
||||
formatDiscoveryEnvCliFlags,
|
||||
} = require("../../cli/externalMcpDiscoveryPath.cjs");
|
||||
|
||||
function loadShellUtils() {
|
||||
return require("../ai/shellUtils.cjs");
|
||||
}
|
||||
|
||||
function loadDesktopCliResolver() {
|
||||
return require("./desktopCliResolver.cjs");
|
||||
}
|
||||
|
||||
function parseCodexMcpList(rawOutput) {
|
||||
const parsed = JSON.parse(String(rawOutput || "[]"));
|
||||
if (!Array.isArray(parsed)) {
|
||||
throw new Error("Codex MCP list returned an unexpected payload.");
|
||||
}
|
||||
return parsed
|
||||
.filter((entry) => entry && typeof entry === "object")
|
||||
.map((entry) => ({
|
||||
name: typeof entry.name === "string" ? entry.name : "",
|
||||
enabled: entry.enabled !== false,
|
||||
transport: entry.transport && typeof entry.transport === "object"
|
||||
? { ...entry.transport }
|
||||
: null,
|
||||
env: entry.env && typeof entry.env === "object" ? entry.env : null,
|
||||
}));
|
||||
}
|
||||
|
||||
function formatCodexCommandText(args, cliPath = "codex") {
|
||||
const executable = typeof cliPath === "string" && cliPath.trim()
|
||||
? cliPath.trim()
|
||||
: "codex";
|
||||
return [quoteCommandArg(executable), ...args.map(quoteCommandArg)].join(" ");
|
||||
}
|
||||
|
||||
function quoteCommandArg(value) {
|
||||
if (typeof value !== "string" || value.length === 0) return '""';
|
||||
// Match ExternalMcpCard quoteShellArg so copyable commands stay shell-safe
|
||||
// for paths with spaces, quotes, apostrophes, or backslashes.
|
||||
if (!/[\s"'\\]/u.test(value)) return value;
|
||||
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
|
||||
}
|
||||
|
||||
function formatExistingCommand(transport) {
|
||||
if (!transport || typeof transport !== "object") return null;
|
||||
if (transport.type === "stdio") {
|
||||
const command = typeof transport.command === "string" ? transport.command.trim() : "";
|
||||
const args = Array.isArray(transport.args)
|
||||
? transport.args.filter((arg) => typeof arg === "string" && arg.trim())
|
||||
: [];
|
||||
return [command, ...args].filter(Boolean).join(" ").trim() || null;
|
||||
}
|
||||
if (typeof transport.url === "string" && transport.url.trim()) {
|
||||
return transport.url.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizePathForCompare(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
let normalized = value.trim().replace(/^["']|["']$/gu, "");
|
||||
if (process.platform === "win32") {
|
||||
normalized = normalized.replace(/\.cmd$/iu, "");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pathsMatch(left, right) {
|
||||
return normalizePathForCompare(left) === normalizePathForCompare(right);
|
||||
}
|
||||
|
||||
function buildCodexAddArgs(launcherPath, discoveryEnv) {
|
||||
return [
|
||||
"mcp",
|
||||
"add",
|
||||
EXTERNAL_MCP_CODEX_NAME,
|
||||
...formatDiscoveryEnvCliFlags(discoveryEnv, "codex"),
|
||||
"--",
|
||||
launcherPath,
|
||||
];
|
||||
}
|
||||
|
||||
function getCodexEntryEnv(entry) {
|
||||
const transportEnv = entry?.transport?.env;
|
||||
if (transportEnv && typeof transportEnv === "object") return transportEnv;
|
||||
if (entry?.env && typeof entry.env === "object") return entry.env;
|
||||
return null;
|
||||
}
|
||||
|
||||
function hasRequiredDiscoveryEnv(entryEnv, discoveryEnv) {
|
||||
const required = discoveryEnv && typeof discoveryEnv === "object" ? discoveryEnv : {};
|
||||
const keys = Object.keys(required).filter((key) => typeof required[key] === "string" && required[key]);
|
||||
if (keys.length === 0) return true;
|
||||
if (!entryEnv || typeof entryEnv !== "object") return false;
|
||||
return keys.every((key) => String(entryEnv[key] || "") === String(required[key]));
|
||||
}
|
||||
|
||||
function classifyCodexExternalMcpStatus({
|
||||
entries,
|
||||
launcherPath,
|
||||
codexPath,
|
||||
discoveryEnv,
|
||||
commandExecutable,
|
||||
}) {
|
||||
const commandArgs = buildCodexAddArgs(launcherPath, discoveryEnv || {});
|
||||
const base = {
|
||||
ok: true,
|
||||
codexPath: codexPath || null,
|
||||
launcherPath: launcherPath || null,
|
||||
command: formatCodexCommandText(commandArgs, commandExecutable || codexPath),
|
||||
existingCommand: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const entry = Array.isArray(entries)
|
||||
? entries.find((item) => item?.name === EXTERNAL_MCP_CODEX_NAME)
|
||||
: null;
|
||||
|
||||
if (!entry) {
|
||||
return {
|
||||
...base,
|
||||
state: codexPath ? "not_configured" : "codex_not_found",
|
||||
};
|
||||
}
|
||||
|
||||
const transport = entry.transport || null;
|
||||
const existingCommand = formatExistingCommand(transport) || launcherPath || EXTERNAL_MCP_CODEX_NAME;
|
||||
if (entry.enabled === false) {
|
||||
return {
|
||||
...base,
|
||||
state: "not_configured",
|
||||
existingCommand,
|
||||
};
|
||||
}
|
||||
|
||||
if (
|
||||
transport?.type === "stdio"
|
||||
&& pathsMatch(transport.command, launcherPath)
|
||||
&& (!Array.isArray(transport.args) || transport.args.length === 0)
|
||||
) {
|
||||
if (!hasRequiredDiscoveryEnv(getCodexEntryEnv(entry), discoveryEnv)) {
|
||||
return {
|
||||
...base,
|
||||
state: "not_configured",
|
||||
existingCommand,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: "configured",
|
||||
existingCommand,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
state: "conflict",
|
||||
existingCommand,
|
||||
};
|
||||
}
|
||||
|
||||
function createExternalMcpCodexSetup(options = {}) {
|
||||
const deps = {
|
||||
launcherPath: options.launcherPath || null,
|
||||
discoveryEnv: options.discoveryEnv && typeof options.discoveryEnv === "object"
|
||||
? options.discoveryEnv
|
||||
: {},
|
||||
getShellEnv: options.getShellEnv || loadShellUtils().getShellEnv,
|
||||
resolveCliFromPath: options.resolveCliFromPath || loadShellUtils().resolveCliFromPath,
|
||||
resolveDesktopManagedCli: options.resolveDesktopManagedCli
|
||||
|| loadDesktopCliResolver().resolveDesktopManagedCli,
|
||||
prepareCommandForSpawn: options.prepareCommandForSpawn || loadShellUtils().prepareCommandForSpawn,
|
||||
spawn: options.spawn || require("node:child_process").spawn,
|
||||
stripAnsi: options.stripAnsi || loadShellUtils().stripAnsi,
|
||||
};
|
||||
|
||||
function getManualCommand(cliPath) {
|
||||
return formatCodexCommandText(
|
||||
buildCodexAddArgs(deps.launcherPath, deps.discoveryEnv),
|
||||
cliPath,
|
||||
);
|
||||
}
|
||||
|
||||
async function resolveCodex() {
|
||||
const shellEnv = await deps.getShellEnv();
|
||||
// PATH installs keep the bare `codex` copyable command (portable across
|
||||
// shells). Desktop-managed absolute paths only appear when PATH misses.
|
||||
const pathResolved = deps.resolveCliFromPath("codex", shellEnv) || null;
|
||||
const desktopResolved = pathResolved
|
||||
? null
|
||||
: (deps.resolveDesktopManagedCli("codex") || null);
|
||||
const codexPath = pathResolved || desktopResolved;
|
||||
return {
|
||||
shellEnv,
|
||||
codexPath,
|
||||
commandExecutable: pathResolved ? "codex" : (desktopResolved || "codex"),
|
||||
};
|
||||
}
|
||||
|
||||
async function runCodex(codexPath, shellEnv, args) {
|
||||
return await runBoundedCliCommand(deps, codexPath, args, { env: shellEnv });
|
||||
}
|
||||
|
||||
function summarizeFailure(result, fallback) {
|
||||
return String(result?.stderr || result?.stdout || fallback || "Codex command failed").trim();
|
||||
}
|
||||
|
||||
async function getStatus() {
|
||||
const { shellEnv, codexPath, commandExecutable } = await resolveCodex();
|
||||
if (!codexPath) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "codex_not_found",
|
||||
codexPath: null,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(),
|
||||
existingCommand: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runCodex(codexPath, shellEnv, ["mcp", "list", "--json"]);
|
||||
if (result.exitCode !== 0) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "error",
|
||||
codexPath,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(commandExecutable),
|
||||
existingCommand: null,
|
||||
error: summarizeFailure(result, `Codex exited with code ${result.exitCode ?? "unknown"}`),
|
||||
};
|
||||
}
|
||||
const status = classifyCodexExternalMcpStatus({
|
||||
entries: parseCodexMcpList(result.stdout),
|
||||
launcherPath: deps.launcherPath,
|
||||
codexPath,
|
||||
discoveryEnv: deps.discoveryEnv,
|
||||
commandExecutable,
|
||||
});
|
||||
return {
|
||||
...status,
|
||||
command: getManualCommand(commandExecutable),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "error",
|
||||
codexPath,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(commandExecutable),
|
||||
existingCommand: null,
|
||||
error: error?.message || String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function addToCodex() {
|
||||
const status = await getStatus();
|
||||
if (status.state === "codex_not_found" || status.state === "conflict" || status.state === "configured") {
|
||||
return status;
|
||||
}
|
||||
if (status.state === "error") {
|
||||
return status;
|
||||
}
|
||||
|
||||
const { shellEnv, codexPath, commandExecutable } = await resolveCodex();
|
||||
if (!codexPath) {
|
||||
return {
|
||||
...status,
|
||||
state: "codex_not_found",
|
||||
codexPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (status.existingCommand) {
|
||||
await runCodex(codexPath, shellEnv, ["mcp", "remove", EXTERNAL_MCP_CODEX_NAME]);
|
||||
}
|
||||
const addResult = await runCodex(
|
||||
codexPath,
|
||||
shellEnv,
|
||||
buildCodexAddArgs(deps.launcherPath, deps.discoveryEnv),
|
||||
);
|
||||
if (addResult.exitCode !== 0) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "error",
|
||||
codexPath,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(commandExecutable),
|
||||
existingCommand: null,
|
||||
error: summarizeFailure(addResult, `Codex exited with code ${addResult.exitCode ?? "unknown"}`),
|
||||
};
|
||||
}
|
||||
return await getStatus();
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "error",
|
||||
codexPath,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(commandExecutable),
|
||||
existingCommand: null,
|
||||
error: error?.message || String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getStatus,
|
||||
addToCodex,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EXTERNAL_MCP_CODEX_NAME,
|
||||
createExternalMcpCodexSetup,
|
||||
parseCodexMcpList,
|
||||
classifyCodexExternalMcpStatus,
|
||||
};
|
||||
108
electron/bridges/externalMcp/desktopCliResolver.cjs
Normal file
108
electron/bridges/externalMcp/desktopCliResolver.cjs
Normal file
@@ -0,0 +1,108 @@
|
||||
"use strict";
|
||||
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
|
||||
function isExecutableFile(filePath, deps) {
|
||||
try {
|
||||
if (!deps.existsSync(filePath)) return false;
|
||||
const stat = deps.statSync(filePath);
|
||||
if (!stat.isFile()) return false;
|
||||
// Prefer runtime access check so we skip candidates the process cannot
|
||||
// execute (mode bits alone miss ACL/ownership cases). Fall back to mode
|
||||
// bits when accessSync is not provided (tests can inject either).
|
||||
if (typeof deps.accessSync === "function") {
|
||||
deps.accessSync(filePath, deps.X_OK);
|
||||
return true;
|
||||
}
|
||||
return (stat.mode & 0o111) !== 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function findFirstExecutable(candidates, deps) {
|
||||
for (const candidate of candidates) {
|
||||
if (isExecutableFile(candidate, deps)) return candidate;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function compareVersionDirectoryNames(left, right) {
|
||||
return String(right).localeCompare(String(left), "en", {
|
||||
numeric: true,
|
||||
sensitivity: "base",
|
||||
});
|
||||
}
|
||||
|
||||
function resolveCodexDesktopCli(homeDir, deps) {
|
||||
const appRoots = [
|
||||
"/Applications/ChatGPT.app",
|
||||
"/Applications/Codex.app",
|
||||
path.join(homeDir, "Applications", "ChatGPT.app"),
|
||||
path.join(homeDir, "Applications", "Codex.app"),
|
||||
];
|
||||
return findFirstExecutable(
|
||||
appRoots.map((appRoot) => path.join(appRoot, "Contents", "Resources", "codex")),
|
||||
deps,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveClaudeDesktopCli(homeDir, deps) {
|
||||
const versionsRoot = path.join(
|
||||
homeDir,
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Claude",
|
||||
"claude-code",
|
||||
);
|
||||
|
||||
let versionDirectories;
|
||||
try {
|
||||
versionDirectories = deps.readdirSync(versionsRoot, { withFileTypes: true })
|
||||
.filter((entry) => entry.isDirectory())
|
||||
.map((entry) => entry.name)
|
||||
.sort(compareVersionDirectoryNames);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Only the native macOS app-bundle CLI. A sibling `<version>/claude` binary
|
||||
// may exist but is a Linux VM helper on current Claude Desktop installs, so
|
||||
// accepting it would break spawn and block falling back to an older good version.
|
||||
return findFirstExecutable(
|
||||
versionDirectories.map((version) => path.join(
|
||||
versionsRoot,
|
||||
version,
|
||||
"claude.app",
|
||||
"Contents",
|
||||
"MacOS",
|
||||
"claude",
|
||||
)),
|
||||
deps,
|
||||
);
|
||||
}
|
||||
|
||||
function resolveDesktopManagedCli(name, options = {}) {
|
||||
const platform = options.platform || process.platform;
|
||||
if (platform !== "darwin") return null;
|
||||
|
||||
const deps = {
|
||||
existsSync: options.existsSync || fs.existsSync,
|
||||
statSync: options.statSync || fs.statSync,
|
||||
readdirSync: options.readdirSync || fs.readdirSync,
|
||||
accessSync: options.accessSync || fs.accessSync,
|
||||
X_OK: options.X_OK != null ? options.X_OK : fs.constants.X_OK,
|
||||
};
|
||||
const homeDir = options.homeDir || os.homedir();
|
||||
|
||||
if (name === "codex") return resolveCodexDesktopCli(homeDir, deps);
|
||||
if (name === "claude") return resolveClaudeDesktopCli(homeDir, deps);
|
||||
return null;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
compareVersionDirectoryNames,
|
||||
resolveDesktopManagedCli,
|
||||
};
|
||||
173
electron/bridges/externalMcp/desktopCliResolver.test.cjs
Normal file
173
electron/bridges/externalMcp/desktopCliResolver.test.cjs
Normal file
@@ -0,0 +1,173 @@
|
||||
"use strict";
|
||||
|
||||
const { describe, it } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const path = require("node:path");
|
||||
|
||||
const {
|
||||
resolveDesktopManagedCli,
|
||||
} = require("./desktopCliResolver.cjs");
|
||||
|
||||
function createFileDeps(files, directories = {}, options = {}) {
|
||||
const fileSet = new Set(files);
|
||||
const nonExecutable = new Set(options.nonExecutable || []);
|
||||
return {
|
||||
existsSync: (filePath) => fileSet.has(filePath),
|
||||
statSync: (filePath) => ({
|
||||
isFile: () => fileSet.has(filePath),
|
||||
// mode is only used when accessSync is omitted
|
||||
mode: nonExecutable.has(filePath) ? 0o100644 : 0o100755,
|
||||
}),
|
||||
readdirSync: (directoryPath) => {
|
||||
if (!(directoryPath in directories)) throw new Error("ENOENT");
|
||||
return directories[directoryPath].map((name) => ({
|
||||
name,
|
||||
isDirectory: () => true,
|
||||
}));
|
||||
},
|
||||
accessSync: (filePath) => {
|
||||
if (!fileSet.has(filePath)) {
|
||||
const err = new Error("ENOENT");
|
||||
err.code = "ENOENT";
|
||||
throw err;
|
||||
}
|
||||
if (nonExecutable.has(filePath)) {
|
||||
const err = new Error("EACCES");
|
||||
err.code = "EACCES";
|
||||
throw err;
|
||||
}
|
||||
},
|
||||
X_OK: 1,
|
||||
};
|
||||
}
|
||||
|
||||
describe("macOS desktop-managed CLI resolution", () => {
|
||||
it("finds the Codex CLI bundled with ChatGPT Desktop", () => {
|
||||
const codexPath = "/Applications/ChatGPT.app/Contents/Resources/codex";
|
||||
assert.equal(resolveDesktopManagedCli("codex", {
|
||||
platform: "darwin",
|
||||
homeDir: "/Users/test",
|
||||
...createFileDeps([codexPath]),
|
||||
}), codexPath);
|
||||
});
|
||||
|
||||
it("finds a user-installed Codex Desktop bundle", () => {
|
||||
const codexPath = "/Users/test/Applications/Codex.app/Contents/Resources/codex";
|
||||
assert.equal(resolveDesktopManagedCli("codex", {
|
||||
platform: "darwin",
|
||||
homeDir: "/Users/test",
|
||||
...createFileDeps([codexPath]),
|
||||
}), codexPath);
|
||||
});
|
||||
|
||||
it("uses the newest valid Claude Code managed by Claude Desktop", () => {
|
||||
const root = path.join(
|
||||
"/Users/test",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Claude",
|
||||
"claude-code",
|
||||
);
|
||||
const newestPath = path.join(root, "2.10.0", "claude.app", "Contents", "MacOS", "claude");
|
||||
const olderPath = path.join(root, "2.9.9", "claude.app", "Contents", "MacOS", "claude");
|
||||
assert.equal(resolveDesktopManagedCli("claude", {
|
||||
platform: "darwin",
|
||||
homeDir: "/Users/test",
|
||||
...createFileDeps([newestPath, olderPath], {
|
||||
[root]: ["2.9.9", "2.10.0"],
|
||||
}),
|
||||
}), newestPath);
|
||||
});
|
||||
|
||||
it("falls back to the newest installed Claude version that has an executable", () => {
|
||||
const root = path.join(
|
||||
"/Users/test",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Claude",
|
||||
"claude-code",
|
||||
);
|
||||
const validPath = path.join(root, "2.9.9", "claude.app", "Contents", "MacOS", "claude");
|
||||
assert.equal(resolveDesktopManagedCli("claude", {
|
||||
platform: "darwin",
|
||||
homeDir: "/Users/test",
|
||||
...createFileDeps([validPath], {
|
||||
[root]: ["2.10.0", "2.9.9"],
|
||||
}),
|
||||
}), validPath);
|
||||
});
|
||||
|
||||
it("skips newer Claude installs that are not executable and uses an older runnable one", () => {
|
||||
const root = path.join(
|
||||
"/Users/test",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Claude",
|
||||
"claude-code",
|
||||
);
|
||||
const newestPath = path.join(root, "2.10.0", "claude.app", "Contents", "MacOS", "claude");
|
||||
const olderPath = path.join(root, "2.9.9", "claude.app", "Contents", "MacOS", "claude");
|
||||
assert.equal(resolveDesktopManagedCli("claude", {
|
||||
platform: "darwin",
|
||||
homeDir: "/Users/test",
|
||||
...createFileDeps([newestPath, olderPath], {
|
||||
[root]: ["2.10.0", "2.9.9"],
|
||||
}, { nonExecutable: [newestPath] }),
|
||||
}), olderPath);
|
||||
});
|
||||
|
||||
it("skips non-executable Codex desktop candidates", () => {
|
||||
const systemPath = "/Applications/ChatGPT.app/Contents/Resources/codex";
|
||||
const userPath = "/Users/test/Applications/Codex.app/Contents/Resources/codex";
|
||||
assert.equal(resolveDesktopManagedCli("codex", {
|
||||
platform: "darwin",
|
||||
homeDir: "/Users/test",
|
||||
...createFileDeps([systemPath, userPath], {}, { nonExecutable: [systemPath] }),
|
||||
}), userPath);
|
||||
});
|
||||
|
||||
it("ignores the plain version/claude helper (Linux VM binary on current installs)", () => {
|
||||
const root = path.join(
|
||||
"/Users/test",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Claude",
|
||||
"claude-code",
|
||||
);
|
||||
const linuxHelper = path.join(root, "2.10.0", "claude");
|
||||
const olderAppPath = path.join(root, "2.9.9", "claude.app", "Contents", "MacOS", "claude");
|
||||
assert.equal(resolveDesktopManagedCli("claude", {
|
||||
platform: "darwin",
|
||||
homeDir: "/Users/test",
|
||||
...createFileDeps([linuxHelper, olderAppPath], {
|
||||
[root]: ["2.10.0", "2.9.9"],
|
||||
}),
|
||||
}), olderAppPath);
|
||||
});
|
||||
|
||||
it("does not select a plain version/claude helper when no app-bundle CLI exists", () => {
|
||||
const root = path.join(
|
||||
"/Users/test",
|
||||
"Library",
|
||||
"Application Support",
|
||||
"Claude",
|
||||
"claude-code",
|
||||
);
|
||||
const linuxHelper = path.join(root, "2.10.0", "claude");
|
||||
assert.equal(resolveDesktopManagedCli("claude", {
|
||||
platform: "darwin",
|
||||
homeDir: "/Users/test",
|
||||
...createFileDeps([linuxHelper], {
|
||||
[root]: ["2.10.0"],
|
||||
}),
|
||||
}), null);
|
||||
});
|
||||
|
||||
it("does not probe desktop locations on other platforms", () => {
|
||||
assert.equal(resolveDesktopManagedCli("codex", {
|
||||
platform: "linux",
|
||||
homeDir: "/home/test",
|
||||
...createFileDeps(["/Applications/ChatGPT.app/Contents/Resources/codex"]),
|
||||
}), null);
|
||||
});
|
||||
});
|
||||
410
electron/bridges/externalMcp/grokSetup.cjs
Normal file
410
electron/bridges/externalMcp/grokSetup.cjs
Normal file
@@ -0,0 +1,410 @@
|
||||
"use strict";
|
||||
|
||||
const { runBoundedCliCommand } = require("./boundedCliCommand.cjs");
|
||||
|
||||
const EXTERNAL_MCP_GROK_NAME = "netcatty-external";
|
||||
const {
|
||||
formatDiscoveryEnvCliFlags,
|
||||
} = require("../../cli/externalMcpDiscoveryPath.cjs");
|
||||
|
||||
function loadShellUtils() {
|
||||
return require("../ai/shellUtils.cjs");
|
||||
}
|
||||
|
||||
function formatGrokCommandText(args) {
|
||||
return ["grok", ...args.map(quoteCommandArg)].join(" ");
|
||||
}
|
||||
|
||||
function quoteCommandArg(value) {
|
||||
if (typeof value !== "string" || value.length === 0) return '""';
|
||||
// Match ExternalMcpCard quoteShellArg so copyable commands stay shell-safe
|
||||
// for paths with spaces, quotes, apostrophes, or backslashes.
|
||||
if (!/[\s"'\\]/u.test(value)) return value;
|
||||
return `"${value.replaceAll("\\", "\\\\").replaceAll("\"", "\\\"")}"`;
|
||||
}
|
||||
|
||||
function formatExistingCommand(entry) {
|
||||
if (!entry || typeof entry !== "object") return null;
|
||||
if (entry.transport && typeof entry.transport === "object") {
|
||||
const transport = entry.transport;
|
||||
if (transport.type === "stdio" || transport.command) {
|
||||
const command = typeof transport.command === "string" ? transport.command.trim() : "";
|
||||
const args = Array.isArray(transport.args)
|
||||
? transport.args.filter((arg) => typeof arg === "string" && arg.trim())
|
||||
: [];
|
||||
return [command, ...args].filter(Boolean).join(" ").trim() || null;
|
||||
}
|
||||
if (typeof transport.url === "string" && transport.url.trim()) {
|
||||
return transport.url.trim();
|
||||
}
|
||||
}
|
||||
if (typeof entry.command === "string" && entry.command.trim()) {
|
||||
const args = Array.isArray(entry.args)
|
||||
? entry.args.filter((arg) => typeof arg === "string" && arg.trim())
|
||||
: [];
|
||||
return [entry.command.trim(), ...args].filter(Boolean).join(" ").trim() || null;
|
||||
}
|
||||
if (typeof entry.url === "string" && entry.url.trim()) {
|
||||
return entry.url.trim();
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function parseGrokMcpList(rawOutput) {
|
||||
const text = String(rawOutput || "").trim();
|
||||
if (!text) return [];
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(text);
|
||||
if (Array.isArray(parsed)) {
|
||||
return parsed
|
||||
.filter((entry) => entry && typeof entry === "object")
|
||||
.map(normalizeGrokListEntry);
|
||||
}
|
||||
if (parsed && typeof parsed === "object") {
|
||||
const servers = parsed.servers || parsed.mcp_servers || parsed.mcpServers || parsed;
|
||||
if (Array.isArray(servers)) {
|
||||
return servers
|
||||
.filter((entry) => entry && typeof entry === "object")
|
||||
.map(normalizeGrokListEntry);
|
||||
}
|
||||
if (servers && typeof servers === "object") {
|
||||
return Object.entries(servers).map(([name, value]) => normalizeGrokListEntry({
|
||||
...(value && typeof value === "object" ? value : {}),
|
||||
name,
|
||||
}));
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Fall through to line-oriented parsing for non-JSON list output.
|
||||
}
|
||||
|
||||
return text
|
||||
.split(/\r?\n/u)
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
const colonIndex = line.indexOf(":");
|
||||
if (colonIndex > 0) {
|
||||
return {
|
||||
name: line.slice(0, colonIndex).trim(),
|
||||
command: line.slice(colonIndex + 1).trim() || null,
|
||||
};
|
||||
}
|
||||
const parts = line.split(/\s+/u);
|
||||
return {
|
||||
name: parts[0] || "",
|
||||
command: parts.slice(1).join(" ").trim() || null,
|
||||
};
|
||||
})
|
||||
.filter((entry) => entry.name);
|
||||
}
|
||||
|
||||
function normalizeGrokListEntry(entry) {
|
||||
const name = typeof entry.name === "string"
|
||||
? entry.name
|
||||
: (typeof entry.id === "string" ? entry.id : "");
|
||||
return {
|
||||
name,
|
||||
enabled: entry.enabled !== false,
|
||||
command: typeof entry.command === "string" ? entry.command : null,
|
||||
args: Array.isArray(entry.args) ? entry.args : null,
|
||||
transport: entry.transport && typeof entry.transport === "object" ? entry.transport : null,
|
||||
url: typeof entry.url === "string" ? entry.url : null,
|
||||
env: entry.env && typeof entry.env === "object" ? entry.env : null,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePathForCompare(value) {
|
||||
if (typeof value !== "string") return "";
|
||||
let normalized = value.trim().replace(/^["']|["']$/gu, "");
|
||||
if (process.platform === "win32") {
|
||||
normalized = normalized.replace(/\.cmd$/iu, "");
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function pathsMatch(left, right) {
|
||||
return normalizePathForCompare(left) === normalizePathForCompare(right);
|
||||
}
|
||||
|
||||
function extractCommandExecutable(commandText) {
|
||||
if (typeof commandText !== "string") return "";
|
||||
const trimmed = commandText.trim();
|
||||
if (!trimmed) return "";
|
||||
const dashDashIndex = trimmed.lastIndexOf(" -- ");
|
||||
const candidate = dashDashIndex >= 0
|
||||
? trimmed.slice(dashDashIndex + 4).trim()
|
||||
: trimmed;
|
||||
const match = candidate.match(/("(?:\\.|[^"])*"|'(?:\\.|[^'])*'|[^\s]+)/u);
|
||||
if (!match) return candidate;
|
||||
const remainder = candidate.slice(match[0].length).trim();
|
||||
if (remainder) return "";
|
||||
return match[1];
|
||||
}
|
||||
|
||||
function getEntryCommand(entry) {
|
||||
if (!entry) return null;
|
||||
if (entry.transport?.type === "stdio" || entry.transport?.command) {
|
||||
const command = String(entry.transport.command || "").trim();
|
||||
const args = Array.isArray(entry.transport.args) ? entry.transport.args : [];
|
||||
if (command && args.length === 0) return command;
|
||||
return null;
|
||||
}
|
||||
if (typeof entry.command === "string" && entry.command.trim()) {
|
||||
const args = Array.isArray(entry.args) ? entry.args : [];
|
||||
if (args.length === 0) return entry.command.trim();
|
||||
return null;
|
||||
}
|
||||
return formatExistingCommand(entry);
|
||||
}
|
||||
|
||||
function hasRequiredDiscoveryEnv(entryEnv, discoveryEnv) {
|
||||
const required = discoveryEnv && typeof discoveryEnv === "object" ? discoveryEnv : {};
|
||||
const keys = Object.keys(required).filter((key) => typeof required[key] === "string" && required[key]);
|
||||
if (keys.length === 0) return true;
|
||||
if (!entryEnv || typeof entryEnv !== "object") return false;
|
||||
return keys.every((key) => String(entryEnv[key] || "") === String(required[key]));
|
||||
}
|
||||
|
||||
function getGrokEntryEnv(entry) {
|
||||
if (entry?.env && typeof entry.env === "object") return entry.env;
|
||||
if (entry?.transport?.env && typeof entry.transport.env === "object") return entry.transport.env;
|
||||
return null;
|
||||
}
|
||||
|
||||
function buildGrokAddArgs(launcherPath, discoveryEnv) {
|
||||
return [
|
||||
"mcp",
|
||||
"add",
|
||||
EXTERNAL_MCP_GROK_NAME,
|
||||
...formatDiscoveryEnvCliFlags(discoveryEnv, "grok"),
|
||||
"--",
|
||||
launcherPath,
|
||||
];
|
||||
}
|
||||
|
||||
function classifyGrokExternalMcpStatus({ entries, launcherPath, grokPath, discoveryEnv }) {
|
||||
const commandArgs = buildGrokAddArgs(launcherPath, discoveryEnv || {});
|
||||
const base = {
|
||||
ok: true,
|
||||
grokPath: grokPath || null,
|
||||
launcherPath: launcherPath || null,
|
||||
command: formatGrokCommandText(commandArgs),
|
||||
existingCommand: null,
|
||||
error: null,
|
||||
};
|
||||
|
||||
const entry = Array.isArray(entries)
|
||||
? entries.find((item) => item?.name === EXTERNAL_MCP_GROK_NAME)
|
||||
: null;
|
||||
|
||||
if (!entry) {
|
||||
return {
|
||||
...base,
|
||||
state: grokPath ? "not_configured" : "grok_not_found",
|
||||
};
|
||||
}
|
||||
|
||||
const existingCommand = getEntryCommand(entry);
|
||||
if (entry.enabled === false) {
|
||||
return {
|
||||
...base,
|
||||
state: "not_configured",
|
||||
existingCommand: existingCommand || launcherPath || EXTERNAL_MCP_GROK_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
if (!existingCommand) {
|
||||
// Present but not a plain launcher command (extra args / non-stdio).
|
||||
return {
|
||||
...base,
|
||||
state: "conflict",
|
||||
existingCommand: formatExistingCommand(entry) || EXTERNAL_MCP_GROK_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
if (pathsMatch(extractCommandExecutable(existingCommand), launcherPath)) {
|
||||
if (!hasRequiredDiscoveryEnv(getGrokEntryEnv(entry), discoveryEnv)) {
|
||||
return {
|
||||
...base,
|
||||
state: "not_configured",
|
||||
existingCommand,
|
||||
};
|
||||
}
|
||||
return {
|
||||
...base,
|
||||
state: "configured",
|
||||
existingCommand,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...base,
|
||||
state: "conflict",
|
||||
existingCommand,
|
||||
};
|
||||
}
|
||||
|
||||
function createExternalMcpGrokSetup(options = {}) {
|
||||
const deps = {
|
||||
launcherPath: options.launcherPath || null,
|
||||
discoveryEnv: options.discoveryEnv && typeof options.discoveryEnv === "object"
|
||||
? options.discoveryEnv
|
||||
: {},
|
||||
getShellEnv: options.getShellEnv || loadShellUtils().getShellEnv,
|
||||
resolveCliFromPath: options.resolveCliFromPath || loadShellUtils().resolveCliFromPath,
|
||||
prepareCommandForSpawn: options.prepareCommandForSpawn || loadShellUtils().prepareCommandForSpawn,
|
||||
spawn: options.spawn || require("node:child_process").spawn,
|
||||
stripAnsi: options.stripAnsi || loadShellUtils().stripAnsi,
|
||||
};
|
||||
|
||||
function getManualCommand() {
|
||||
return formatGrokCommandText(buildGrokAddArgs(deps.launcherPath, deps.discoveryEnv));
|
||||
}
|
||||
|
||||
async function resolveGrok() {
|
||||
const shellEnv = await deps.getShellEnv();
|
||||
const grokPath = deps.resolveCliFromPath("grok", shellEnv);
|
||||
return {
|
||||
shellEnv,
|
||||
grokPath: grokPath || null,
|
||||
};
|
||||
}
|
||||
|
||||
async function runGrok(grokPath, shellEnv, args) {
|
||||
return await runBoundedCliCommand(deps, grokPath, args, { env: shellEnv });
|
||||
}
|
||||
|
||||
function summarizeFailure(result, fallback) {
|
||||
return String(result?.stderr || result?.stdout || fallback || "Grok command failed").trim();
|
||||
}
|
||||
|
||||
async function getStatus() {
|
||||
const { shellEnv, grokPath } = await resolveGrok();
|
||||
if (!grokPath) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "grok_not_found",
|
||||
grokPath: null,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(),
|
||||
existingCommand: null,
|
||||
error: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await runGrok(grokPath, shellEnv, ["mcp", "list", "--json"]);
|
||||
if (result.exitCode !== 0) {
|
||||
// Some builds may not support --json; fall back to plain list.
|
||||
const fallback = await runGrok(grokPath, shellEnv, ["mcp", "list"]);
|
||||
if (fallback.exitCode !== 0) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "error",
|
||||
grokPath,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(),
|
||||
existingCommand: null,
|
||||
error: summarizeFailure(fallback, `Grok exited with code ${fallback.exitCode ?? "unknown"}`),
|
||||
};
|
||||
}
|
||||
const status = classifyGrokExternalMcpStatus({
|
||||
entries: parseGrokMcpList(fallback.stdout),
|
||||
launcherPath: deps.launcherPath,
|
||||
grokPath,
|
||||
discoveryEnv: deps.discoveryEnv,
|
||||
});
|
||||
return {
|
||||
...status,
|
||||
command: getManualCommand(),
|
||||
};
|
||||
}
|
||||
const status = classifyGrokExternalMcpStatus({
|
||||
entries: parseGrokMcpList(result.stdout),
|
||||
launcherPath: deps.launcherPath,
|
||||
grokPath,
|
||||
discoveryEnv: deps.discoveryEnv,
|
||||
});
|
||||
return {
|
||||
...status,
|
||||
command: getManualCommand(),
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "error",
|
||||
grokPath,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(),
|
||||
existingCommand: null,
|
||||
error: error?.message || String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async function addToGrok() {
|
||||
const status = await getStatus();
|
||||
if (status.state === "grok_not_found" || status.state === "conflict" || status.state === "configured") {
|
||||
return status;
|
||||
}
|
||||
if (status.state === "error") {
|
||||
return status;
|
||||
}
|
||||
|
||||
const { shellEnv, grokPath } = await resolveGrok();
|
||||
if (!grokPath) {
|
||||
return {
|
||||
...status,
|
||||
state: "grok_not_found",
|
||||
grokPath: null,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
if (status.existingCommand) {
|
||||
await runGrok(grokPath, shellEnv, ["mcp", "remove", EXTERNAL_MCP_GROK_NAME]);
|
||||
}
|
||||
const addResult = await runGrok(
|
||||
grokPath,
|
||||
shellEnv,
|
||||
buildGrokAddArgs(deps.launcherPath, deps.discoveryEnv),
|
||||
);
|
||||
if (addResult.exitCode !== 0) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "error",
|
||||
grokPath,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(),
|
||||
existingCommand: null,
|
||||
error: summarizeFailure(addResult, `Grok exited with code ${addResult.exitCode ?? "unknown"}`),
|
||||
};
|
||||
}
|
||||
return await getStatus();
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: true,
|
||||
state: "error",
|
||||
grokPath,
|
||||
launcherPath: deps.launcherPath,
|
||||
command: getManualCommand(),
|
||||
existingCommand: null,
|
||||
error: error?.message || String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
getStatus,
|
||||
addToGrok,
|
||||
};
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
EXTERNAL_MCP_GROK_NAME,
|
||||
createExternalMcpGrokSetup,
|
||||
parseGrokMcpList,
|
||||
classifyGrokExternalMcpStatus,
|
||||
};
|
||||
Reference in New Issue
Block a user