Files
NetMesh/electron/bridges/externalMcp/claudeSetup.cjs

432 lines
13 KiB
JavaScript
Raw Normal View History

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