feat: configure CraftTable Agent skill

This commit is contained in:
2026-08-16 11:43:10 +08:00
parent 5ff55c6ea9
commit 6ce4d61c8f
9 changed files with 381 additions and 71 deletions
Vendored
+128 -22
View File
@@ -6,8 +6,9 @@ import { Command } from "commander";
// src/agents.ts
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { copyFile, mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "node:fs/promises";
import { copyFile, cp, mkdir as mkdir2, readFile as readFile2, readdir, rename as rename2, rm, stat, writeFile as writeFile2 } from "node:fs/promises";
import os2 from "node:os";
import path2 from "node:path";
import { createInterface } from "node:readline/promises";
@@ -106,11 +107,9 @@ async function configureAgents(input) {
const runner = input.runner ?? new SpawnCommandRunner();
const results = [];
for (const agent of agents) {
if (agent === "opencode") {
results.push(await configureOpenCode(input, command));
} else {
results.push(await configureCliAgent(agent, command, input, runner));
}
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;
}
@@ -119,26 +118,26 @@ async function unconfigureAgents(input) {
const runner = input.runner ?? new SpawnCommandRunner();
const results = [];
for (const agent of agents) {
let mcpResult;
if (agent === "opencode") {
results.push(await unconfigureOpenCode(input));
continue;
mcpResult = await unconfigureOpenCode(input);
} else {
mcpResult = await unconfigureCliAgent(agent, input, runner);
}
const executable = agent === "codex" ? "codex" : "claude";
const existing = await probeCliAgent(agent, runner);
if (!existing.exists) {
results.push({ agent, action: "absent" });
continue;
}
if (input.dryRun) {
results.push({ agent, action: "would-remove" });
continue;
}
const args = agent === "codex" ? ["mcp", "remove", SERVER_NAME] : ["mcp", "remove", "--scope", "user", SERVER_NAME];
await requireSuccess(runner.run(executable, args), `${agent} MCP removal`);
results.push({ agent, action: "removed" });
const skillAction = await unconfigureAgentSkill(agent, input);
results.push({ ...mcpResult, skillAction });
}
return results;
}
async function unconfigureCliAgent(agent, input, runner) {
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" };
}
function launchCommand(options, cliEntry, nodePath) {
return [
path2.resolve(nodePath),
@@ -241,6 +240,113 @@ function defaultOpenCodePath(env = process.env) {
const base = env.XDG_CONFIG_HOME || (process.platform === "win32" ? path2.join(env.USERPROFILE || os2.homedir(), ".config") : path2.join(os2.homedir(), ".config"));
return path2.join(base, "opencode", "opencode.json");
}
function bundledSkillPath(cliEntry) {
return path2.resolve(path2.dirname(cliEntry), "..", "skills", SERVER_NAME);
}
function defaultSkillPath(agent, env = process.env) {
const home = env.USERPROFILE || env.HOME || os2.homedir();
if (agent === "codex") return path2.join(env.CODEX_HOME || path2.join(home, ".codex"), "skills", SERVER_NAME);
if (agent === "claude") return path2.join(home, ".claude", "skills", SERVER_NAME);
return path2.join(path2.dirname(defaultOpenCodePath(env)), "skills", SERVER_NAME);
}
async function configureAgentSkill(agent, input) {
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, input) {
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, required) {
const files = /* @__PURE__ */ new Map();
const walk = async (current, relative) => {
const entries = await readdir(current, { withFileTypes: true });
entries.sort((left, right) => left.name.localeCompare(right.name));
for (const entry of entries) {
const entryPath = path2.join(current, entry.name);
const entryRelative = relative ? path2.join(relative, entry.name) : entry.name;
if (entry.isDirectory()) {
await walk(entryPath, entryRelative);
} else if (entry.isFile()) {
files.set(entryRelative, await readFile2(entryPath));
} else {
throw new Error(`Skill directory contains an unsupported entry: ${entryPath}`);
}
}
};
try {
await walk(directory, "");
} catch (error) {
if (!required && error.code === "ENOENT") return void 0;
throw error;
}
if (!files.has("SKILL.md")) throw new Error(`Skill directory is missing SKILL.md: ${directory}`);
return files;
}
function skillFilesEqual(left, right) {
if (left.size !== right.size) return false;
return [...left].every(([name, value]) => right.get(name)?.equals(value) === true);
}
async function backupSkillDirectory(directory) {
const backup = `${directory}.crafttable-mcp.backup`;
try {
await cp(directory, backup, { recursive: true, force: false, errorOnExist: true });
} catch (error) {
if (error.code !== "EEXIST") throw error;
}
return backup;
}
async function replaceSkillDirectory(source, destination, existed) {
await mkdir2(path2.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 rename2(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) {
try {
await stat(filePath);
return true;
} catch (error) {
if (error.code === "ENOENT") return false;
throw error;
}
}
async function backupAndAtomicWrite(filePath, updated, existed) {
await mkdir2(path2.dirname(filePath), { recursive: true });
if (existed) {
@@ -722,7 +828,7 @@ withConnection(program.command("serve").description("Run the local stdio bridge"
await serveBridge(connectionOptions(flags), new KeyringTokenStore(), new DiscoveryStore());
});
for (const operation of ["configure", "unconfigure"]) {
withConnection(program.command(`${operation} <agent>`).description(`${operation === "configure" ? "Add" : "Remove"} the stdio bridge in Codex, Claude Code, or OpenCode`)).option("--dry-run", "show the planned changes without writing").option("--force", "replace a conflicting entry without prompting").action(async (agent, flags) => {
withConnection(program.command(`${operation} <agent>`).description(`${operation === "configure" ? "Add" : "Remove"} the stdio bridge and CraftTable Skill in Codex, Claude Code, or OpenCode`)).option("--dry-run", "show the planned changes without writing").option("--force", "replace a conflicting entry without prompting").action(async (agent, flags) => {
const cliEntry = fileURLToPath(import.meta.url);
if (!cliEntry.endsWith(".js")) throw new Error("Agent configuration requires the built CLI; run `npm --prefix apps/mcp-client run build` first");
const input = {