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
+154 -26
View File
@@ -1,6 +1,7 @@
import { spawn } from "node:child_process";
import { randomUUID } from "node:crypto";
import { constants } from "node:fs";
import { copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
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";
@@ -11,6 +12,7 @@ import { errorMessage } from "./config.js";
export const SERVER_NAME = "crafttable";
export type AgentName = "codex" | "claude" | "opencode";
export type AgentTarget = AgentName | "all";
export type SkillAction = "installed" | "replaced" | "removed" | "unchanged" | "absent" | "preserved" | "would-install" | "would-replace" | "would-remove" | "would-preserve";
export type CommandResult = { code: number; stdout: string; stderr: string };
export interface CommandRunner {
@@ -28,11 +30,14 @@ export type ConfigureInput = {
runner?: CommandRunner;
confirm?: (message: string) => Promise<boolean>;
opencodePath?: string;
skillSource?: string;
skillPaths?: Partial<Record<AgentName, string>>;
};
export type ConfigureResult = {
agent: AgentName;
action: "added" | "replaced" | "removed" | "unchanged" | "absent" | "would-add" | "would-replace" | "would-remove";
skillAction: SkillAction;
};
export async function configureAgents(input: ConfigureInput): Promise<ConfigureResult[]> {
@@ -41,11 +46,11 @@ export async function configureAgents(input: ConfigureInput): Promise<ConfigureR
const runner = input.runner ?? new SpawnCommandRunner();
const results: ConfigureResult[] = [];
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;
}
@@ -55,29 +60,34 @@ export async function unconfigureAgents(input: ConfigureInput): Promise<Configur
const runner = input.runner ?? new SpawnCommandRunner();
const results: ConfigureResult[] = [];
for (const agent of agents) {
let mcpResult: Omit<ConfigureResult, "skillAction">;
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: "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" };
}
export function launchCommand(options: ClientOptions, cliEntry: string, nodePath: string): string[] {
return [
path.resolve(nodePath),
@@ -94,7 +104,7 @@ async function configureCliAgent(
command: string[],
input: ConfigureInput,
runner: CommandRunner,
): Promise<ConfigureResult> {
): 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) {
@@ -153,7 +163,7 @@ function findCommand(value: unknown, command: string[]): boolean {
return Object.values(record).some((item) => findCommand(item, command));
}
async function configureOpenCode(input: ConfigureInput, command: string[]): Promise<ConfigureResult> {
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;
@@ -177,7 +187,7 @@ async function configureOpenCode(input: ConfigureInput, command: string[]): Prom
return { agent: "opencode", action: existing ? "replaced" : "added" };
}
async function unconfigureOpenCode(input: ConfigureInput): Promise<ConfigureResult> {
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" };
@@ -198,6 +208,124 @@ export function defaultOpenCodePath(env: NodeJS.ProcessEnv = process.env): strin
return path.join(base, "opencode", "opencode.json");
}
export function bundledSkillPath(cliEntry: string): string {
return path.resolve(path.dirname(cliEntry), "..", "skills", SERVER_NAME);
}
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) {
+1 -1
View File
@@ -75,7 +75,7 @@ withConnection(program.command("serve").description("Run the local stdio bridge"
});
for (const operation of ["configure", "unconfigure"] as const) {
withConnection(program.command(`${operation} <agent>`).description(`${operation === "configure" ? "Add" : "Remove"} the stdio bridge in Codex, Claude Code, or OpenCode`))
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: AgentTarget, flags: ConnectionFlags & { dryRun?: boolean; force?: boolean }) => {