[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

This commit is contained in:
2026-09-13 18:24:01 +08:00
commit 3c72efcb7f
3255 changed files with 907009 additions and 0 deletions

View 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,
};

View 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: "文" });
});

View 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,
};

View 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");
});
});

View 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,
};

View 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,
};

View 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);
});
});

View 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,
};