/** * 管理 CraftTable MCP 在 Codex、Claude Code 与 OpenCode 中的注册和 Skill 安装。 * * @packageDocumentation */ import { randomUUID } from "node:crypto"; import { constants } from "node:fs"; import { copyFile, cp, mkdir, readFile, readdir, rename, rm, stat, writeFile } from "node:fs/promises"; import os from "node:os"; import path from "node:path"; import { createInterface } from "node:readline/promises"; import { applyEdits, modify, parse } from "jsonc-parser"; import spawn from "cross-spawn"; import type { ClientOptions } from "./config.js"; import { errorMessage } from "./config.js"; /** CraftTable MCP 服务及随附 Skill 使用的注册名称。 */ export const SERVER_NAME = "crafttable"; /** 支持自动配置的 Agent CLI。 */ export type AgentName = "codex" | "claude" | "opencode"; /** 单个受支持 Agent 或全部受支持 Agent。 */ export type AgentTarget = AgentName | "all"; /** 安装或移除托管 CraftTable Skill 的结果。 */ export type SkillAction = "installed" | "replaced" | "removed" | "unchanged" | "absent" | "preserved" | "would-install" | "would-replace" | "would-remove" | "would-preserve"; /** 外部 Agent CLI 调用的捕获结果。 */ export type CommandResult = { code: number; stdout: string; stderr: string }; /** Agent 配置使用的可注入命令执行边界。 */ export interface CommandRunner { /** 不经过 shell 执行命令并捕获其输出。 */ run(command: string, args: string[]): Promise; } /** 控制 MCP 注册和随附 Skill 安装的输入。 */ export type ConfigureInput = { target: AgentTarget; options: ClientOptions; cliEntry: string; nodePath?: string; dryRun?: boolean; force?: boolean; env?: NodeJS.ProcessEnv; runner?: CommandRunner; confirm?: (message: string) => Promise; opencodePath?: string; skillSource?: string; skillPaths?: Partial>; }; /** 配置或取消配置操作中每个 Agent 的结果。 */ export type ConfigureResult = { agent: AgentName; action: "added" | "replaced" | "removed" | "unchanged" | "absent" | "would-add" | "would-replace" | "would-remove"; skillAction: SkillAction; }; /** * 为选定 Agent 注册 stdio 桥接并安装随附 Skill。 * * 除非现有配置与托管内容一致、用户确认替换或启用 `force`,否则保留现有配置。 * * @param input - 目标 Agent、启动设置与变更策略。 * @returns 每个选定 Agent 的 MCP 与 Skill 操作结果。 * @throws 无法检查配置、无法取得确认或写入/CLI 命令失败时抛出。 */ export async function configureAgents(input: ConfigureInput): Promise { const agents = expandTarget(input.target); const command = launchCommand(input.options, input.cliEntry, input.nodePath ?? process.execPath); const runner = input.runner ?? new SpawnCommandRunner(); const results: ConfigureResult[] = []; for (const agent of agents) { const mcpResult = agent === "opencode" ? await configureOpenCode(input, command) : await configureCliAgent(agent, command, input, runner); const skillAction = await configureAgentSkill(agent, input); results.push({ ...mcpResult, skillAction }); } return results; } /** * 从选定 Agent 中移除托管的 MCP 注册与随附 Skill。 * * 除非启用 `force`,否则保留用户修改过的 Skill。 * * @param input - 目标 Agent、路径与变更策略。 * @returns 每个选定 Agent 的 MCP 与 Skill 操作结果。 * @throws 无法检查配置或写入/CLI 命令失败时抛出。 */ export async function unconfigureAgents(input: ConfigureInput): Promise { const agents = expandTarget(input.target); const runner = input.runner ?? new SpawnCommandRunner(); const results: ConfigureResult[] = []; for (const agent of agents) { let mcpResult: Omit; if (agent === "opencode") { mcpResult = await unconfigureOpenCode(input); } else { mcpResult = await unconfigureCliAgent(agent, input, runner); } const skillAction = await unconfigureAgentSkill(agent, input); results.push({ ...mcpResult, skillAction }); } return results; } async function unconfigureCliAgent( agent: "codex" | "claude", input: ConfigureInput, runner: CommandRunner, ): Promise> { const executable = agent === "codex" ? "codex" : "claude"; const existing = await probeCliAgent(agent, runner); if (!existing.exists) return { agent, action: "absent" }; if (input.dryRun) return { agent, action: "would-remove" }; const args = agent === "codex" ? ["mcp", "remove", SERVER_NAME] : ["mcp", "remove", "--scope", "user", SERVER_NAME]; await requireSuccess(runner.run(executable, args), `${agent} MCP removal`); return { agent, action: "removed" }; } /** * 构造注册到 Agent CLI 的完整可执行程序与参数。 * * @param options - 远端 MCP 与 OAuth 回调设置。 * @param cliEntry - CraftTable MCP CLI 入口模块路径。 * @param nodePath - 用于启动入口模块的 Node.js 可执行文件。 * @returns 可用于 Agent 配置的 argv 风格命令数组。 */ export function launchCommand(options: ClientOptions, cliEntry: string, nodePath: string): string[] { return [ path.resolve(nodePath), path.resolve(cliEntry), "serve", "--url", options.url.toString(), "--client-id", options.clientId, "--callback-port", String(options.callbackPort), ]; } async function configureCliAgent( agent: "codex" | "claude", command: string[], input: ConfigureInput, runner: CommandRunner, ): Promise> { const existing = await probeCliAgent(agent, runner); if (existing.exists && outputMatchesCommand(existing.output, command)) return { agent, action: "unchanged" }; if (existing.exists && !input.force && !input.dryRun) { const confirm = input.confirm ?? terminalConfirm; if (!process.stdin.isTTY && !input.confirm) throw new Error(`${agent} already has a different ${SERVER_NAME} MCP entry; use --force to replace it`); if (!await confirm(`${agent} already has a different ${SERVER_NAME} MCP entry. Replace it?`)) { throw new Error(`${agent} MCP configuration was not changed`); } } const action = existing.exists ? "replaced" : "added"; if (input.dryRun) return { agent, action: existing.exists ? "would-replace" : "would-add" }; const executable = agent === "codex" ? "codex" : "claude"; if (existing.exists) { const removeArgs = agent === "codex" ? ["mcp", "remove", SERVER_NAME] : ["mcp", "remove", "--scope", "user", SERVER_NAME]; await requireSuccess(runner.run(executable, removeArgs), `${agent} MCP replacement cleanup`); } const addArgs = agent === "codex" ? ["mcp", "add", SERVER_NAME, "--", ...command] : ["mcp", "add", "--scope", "user", SERVER_NAME, "--", ...command]; await requireSuccess(runner.run(executable, addArgs), `${agent} MCP registration`); return { agent, action }; } async function probeCliAgent(agent: "codex" | "claude", runner: CommandRunner): Promise<{ exists: boolean; output: string }> { const executable = agent === "codex" ? "codex" : "claude"; const args = agent === "codex" ? ["mcp", "get", SERVER_NAME, "--json"] : ["mcp", "get", SERVER_NAME]; const result = await runner.run(executable, args); if (result.code === 0) return { exists: true, output: result.stdout }; const combined = `${result.stdout}\n${result.stderr}`; if (/not found|does not exist|no mcp server|not configured|unknown server/i.test(combined)) return { exists: false, output: combined }; throw new Error(`Could not inspect ${agent} MCP configuration: ${safeCommandError(result)}`); } function outputMatchesCommand(output: string, command: string[]): boolean { try { const document = JSON.parse(output) as unknown; if (findCommand(document, command)) return true; } catch { // Claude 当前返回便于阅读的文本记录。 } return command.every((part) => output.includes(part)); } function findCommand(value: unknown, command: string[]): boolean { if (!value || typeof value !== "object") return false; if (Array.isArray(value)) return value.some((item) => findCommand(item, command)); const record = value as Record; if (typeof record.command === "string" && Array.isArray(record.args)) { const candidate = [record.command, ...record.args.filter((item): item is string => typeof item === "string")]; if (candidate.length === command.length && candidate.every((item, index) => item === command[index])) return true; } return Object.values(record).some((item) => findCommand(item, command)); } async function configureOpenCode(input: ConfigureInput, command: string[]): Promise> { const filePath = input.opencodePath ?? defaultOpenCodePath(input.env); const original = await readOptionalFile(filePath) ?? "{}\n"; const document = parse(original) as { mcp?: Record } | undefined; const existing = document?.mcp?.[SERVER_NAME] as { type?: unknown; command?: unknown; enabled?: unknown } | undefined; const desired = { type: "local", command, enabled: true }; if (existing && existing.type === desired.type && existing.enabled === true && arraysEqual(existing.command, command)) { return { agent: "opencode", action: "unchanged" }; } if (existing && !input.force && !input.dryRun) { const confirm = input.confirm ?? terminalConfirm; if (!process.stdin.isTTY && !input.confirm) throw new Error(`opencode already has a different ${SERVER_NAME} MCP entry; use --force to replace it`); if (!await confirm(`opencode already has a different ${SERVER_NAME} MCP entry. Replace it?`)) { throw new Error("opencode MCP configuration was not changed"); } } if (input.dryRun) return { agent: "opencode", action: existing ? "would-replace" : "would-add" }; const updated = applyEdits(original, modify(original, ["mcp", SERVER_NAME], desired, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, })); await backupAndAtomicWrite(filePath, updated, await readOptionalFile(filePath) !== undefined); return { agent: "opencode", action: existing ? "replaced" : "added" }; } async function unconfigureOpenCode(input: ConfigureInput): Promise> { const filePath = input.opencodePath ?? defaultOpenCodePath(input.env); const original = await readOptionalFile(filePath); if (original === undefined) return { agent: "opencode", action: "absent" }; const document = parse(original) as { mcp?: Record } | undefined; if (!document?.mcp || !(SERVER_NAME in document.mcp)) return { agent: "opencode", action: "absent" }; if (input.dryRun) return { agent: "opencode", action: "would-remove" }; const updated = applyEdits(original, modify(original, ["mcp", SERVER_NAME], undefined, { formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" }, })); await backupAndAtomicWrite(filePath, updated, true); return { agent: "opencode", action: "removed" }; } /** * @param env - 用于解析用户配置根目录的环境变量。 * @returns 默认 OpenCode JSONC 配置路径。 */ export function defaultOpenCodePath(env: NodeJS.ProcessEnv = process.env): string { const base = env.XDG_CONFIG_HOME || (process.platform === "win32" ? path.join(env.USERPROFILE || os.homedir(), ".config") : path.join(os.homedir(), ".config")); return path.join(base, "opencode", "opencode.json"); } /** * @param cliEntry - 已安装 CLI 入口模块的路径。 * @returns 与入口相邻的随附 CraftTable Skill 目录。 */ export function bundledSkillPath(cliEntry: string): string { return path.resolve(path.dirname(cliEntry), "..", "skills", SERVER_NAME); } /** * @param agent - 需要解析用户级 Skill 目录的 Agent。 * @param env - 用于解析用户配置根目录的环境变量。 * @returns CraftTable Skill 的默认目标目录。 */ export function defaultSkillPath(agent: AgentName, env: NodeJS.ProcessEnv = process.env): string { const home = env.USERPROFILE || env.HOME || os.homedir(); if (agent === "codex") return path.join(env.CODEX_HOME || path.join(home, ".codex"), "skills", SERVER_NAME); if (agent === "claude") return path.join(home, ".claude", "skills", SERVER_NAME); return path.join(path.dirname(defaultOpenCodePath(env)), "skills", SERVER_NAME); } async function configureAgentSkill(agent: AgentName, input: ConfigureInput): Promise { const source = input.skillSource ?? bundledSkillPath(input.cliEntry); const destination = input.skillPaths?.[agent] ?? defaultSkillPath(agent, input.env); const sourceFiles = await readSkillDirectory(source, true); if (!sourceFiles) throw new Error(`Bundled Skill directory is missing: ${source}`); const existingFiles = await readSkillDirectory(destination, false); if (existingFiles && skillFilesEqual(existingFiles, sourceFiles)) return "unchanged"; if (existingFiles && !input.force && !input.dryRun) { const confirm = input.confirm ?? terminalConfirm; if (!process.stdin.isTTY && !input.confirm) throw new Error(`${agent} already has a different ${SERVER_NAME} Skill; use --force to replace it`); if (!await confirm(`${agent} already has a different ${SERVER_NAME} Skill. Replace it?`)) { throw new Error(`${agent} Skill configuration was not changed`); } } if (input.dryRun) return existingFiles ? "would-replace" : "would-install"; if (existingFiles) await backupSkillDirectory(destination); await replaceSkillDirectory(source, destination, Boolean(existingFiles)); return existingFiles ? "replaced" : "installed"; } async function unconfigureAgentSkill(agent: AgentName, input: ConfigureInput): Promise { const source = input.skillSource ?? bundledSkillPath(input.cliEntry); const destination = input.skillPaths?.[agent] ?? defaultSkillPath(agent, input.env); const existingFiles = await readSkillDirectory(destination, false); if (!existingFiles) return "absent"; const sourceFiles = await readSkillDirectory(source, true); if (!sourceFiles) throw new Error(`Bundled Skill directory is missing: ${source}`); const managed = skillFilesEqual(existingFiles, sourceFiles); if (!managed && !input.force) return input.dryRun ? "would-preserve" : "preserved"; if (input.dryRun) return "would-remove"; if (!managed) await backupSkillDirectory(destination); await rm(destination, { recursive: true, force: true }); return "removed"; } async function readSkillDirectory(directory: string, required: boolean): Promise | undefined> { const files = new Map(); const walk = async (current: string, relative: string): Promise => { const entries = await readdir(current, { withFileTypes: true }); entries.sort((left, right) => left.name.localeCompare(right.name)); for (const entry of entries) { const entryPath = path.join(current, entry.name); const entryRelative = relative ? path.join(relative, entry.name) : entry.name; if (entry.isDirectory()) { await walk(entryPath, entryRelative); } else if (entry.isFile()) { files.set(entryRelative, await readFile(entryPath)); } else { throw new Error(`Skill directory contains an unsupported entry: ${entryPath}`); } } }; try { await walk(directory, ""); } catch (error) { if (!required && (error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } if (!files.has("SKILL.md")) throw new Error(`Skill directory is missing SKILL.md: ${directory}`); return files; } function skillFilesEqual(left: Map, right: Map): boolean { if (left.size !== right.size) return false; return [...left].every(([name, value]) => right.get(name)?.equals(value) === true); } async function backupSkillDirectory(directory: string): Promise { const backup = `${directory}.crafttable-mcp.backup`; try { await cp(directory, backup, { recursive: true, force: false, errorOnExist: true }); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } return backup; } async function replaceSkillDirectory(source: string, destination: string, existed: boolean): Promise { await mkdir(path.dirname(destination), { recursive: true }); const temporary = `${destination}.${process.pid}.${randomUUID()}.tmp`; await cp(source, temporary, { recursive: true, force: false, errorOnExist: true }); try { if (existed) await rm(destination, { recursive: true, force: true }); await rename(temporary, destination); } catch (error) { if (existed && !await pathExists(destination)) { const backup = `${destination}.crafttable-mcp.backup`; if (await pathExists(backup)) await cp(backup, destination, { recursive: true }); } throw error; } finally { await rm(temporary, { recursive: true, force: true }); } } async function pathExists(filePath: string): Promise { try { await stat(filePath); return true; } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return false; throw error; } } async function backupAndAtomicWrite(filePath: string, updated: string, existed: boolean): Promise { await mkdir(path.dirname(filePath), { recursive: true }); if (existed) { try { await copyFile(filePath, `${filePath}.crafttable-mcp.backup`, constants.COPYFILE_EXCL); } catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; } } const temporary = `${filePath}.${process.pid}.tmp`; await writeFile(temporary, updated, "utf8"); await rename(temporary, filePath); } async function readOptionalFile(filePath: string): Promise { try { return await readFile(filePath, "utf8"); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; } } function arraysEqual(value: unknown, expected: string[]): boolean { return Array.isArray(value) && value.length === expected.length && value.every((item, index) => item === expected[index]); } function expandTarget(target: AgentTarget): AgentName[] { if (target === "all") return ["codex", "claude", "opencode"]; if (["codex", "claude", "opencode"].includes(target)) return [target as AgentName]; throw new Error("Agent must be one of: codex, claude, opencode, all"); } async function terminalConfirm(message: string): Promise { const readline = createInterface({ input: process.stdin, output: process.stderr }); try { return /^y(es)?$/i.test((await readline.question(`${message} [y/N] `)).trim()); } finally { readline.close(); } } async function requireSuccess(resultPromise: Promise, operation: string): Promise { const result = await resultPromise; if (result.code !== 0) throw new Error(`${operation} failed: ${safeCommandError(result)}`); } function safeCommandError(result: CommandResult): string { return (result.stderr || result.stdout || `exit code ${result.code}`).trim(); } /** 以隐藏且不经过 shell 的子进程运行 Agent CLI 配置命令。 */ export class SpawnCommandRunner implements CommandRunner { /** * @param command - 可执行文件名称或路径。 * @param args - 不经 shell 解释而传递的原始参数列表。 * @returns 捕获的退出码、标准输出与标准错误。 * @throws 子进程无法启动时抛出。 */ run(command: string, args: string[]): Promise { return new Promise((resolve, reject) => { const child = spawn(command, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout?.setEncoding("utf8").on("data", (chunk) => { stdout += String(chunk); }); child.stderr?.setEncoding("utf8").on("data", (chunk) => { stderr += String(chunk); }); child.once("error", (error) => reject(new Error(`Could not run ${command}: ${errorMessage(error)}`))); child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); }); } }