Files
crafttable-mcp-client/src/agents.ts
T

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