454 lines
20 KiB
TypeScript
454 lines
20 KiB
TypeScript
/**
|
|
* 管理 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<CommandResult>;
|
|
}
|
|
|
|
/** 控制 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<boolean>;
|
|
opencodePath?: string;
|
|
skillSource?: string;
|
|
skillPaths?: Partial<Record<AgentName, string>>;
|
|
};
|
|
|
|
/** 配置或取消配置操作中每个 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<ConfigureResult[]> {
|
|
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<ConfigureResult[]> {
|
|
const agents = expandTarget(input.target);
|
|
const runner = input.runner ?? new SpawnCommandRunner();
|
|
const results: ConfigureResult[] = [];
|
|
for (const agent of agents) {
|
|
let mcpResult: Omit<ConfigureResult, "skillAction">;
|
|
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<Omit<ConfigureResult, "skillAction">> {
|
|
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<Omit<ConfigureResult, "skillAction">> {
|
|
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<string, unknown>;
|
|
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<Omit<ConfigureResult, "skillAction">> {
|
|
const filePath = input.opencodePath ?? defaultOpenCodePath(input.env);
|
|
const original = await readOptionalFile(filePath) ?? "{}\n";
|
|
const document = parse(original) as { mcp?: Record<string, unknown> } | 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<Omit<ConfigureResult, "skillAction">> {
|
|
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<string, unknown> } | 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<SkillAction> {
|
|
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<SkillAction> {
|
|
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<Map<string, Buffer> | undefined> {
|
|
const files = new Map<string, Buffer>();
|
|
const walk = async (current: string, relative: string): Promise<void> => {
|
|
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<string, Buffer>, right: Map<string, Buffer>): 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<string> {
|
|
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<void> {
|
|
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<boolean> {
|
|
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<void> {
|
|
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<string | undefined> {
|
|
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<boolean> {
|
|
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<CommandResult>, operation: string): Promise<void> {
|
|
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<CommandResult> {
|
|
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 }));
|
|
});
|
|
}
|
|
}
|