feat: configure CraftTable Agent skill
This commit is contained in:
+154
-26
@@ -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
@@ -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 }) => {
|
||||
|
||||
Reference in New Issue
Block a user