[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:
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,
|
||||
};
|
||||
Reference in New Issue
Block a user