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