feat: configure CraftTable Agent skill
This commit is contained in:
@@ -10,7 +10,7 @@ The repository is private, so first make sure Git Credential Manager can access
|
||||
npm install --global --allow-git=all --ignore-scripts git+https://git.crash.work/cneicy/crafttable-mcp-client.git; crafttable-mcp login; crafttable-mcp configure all
|
||||
```
|
||||
|
||||
This installs the CLI, opens the browser for OAuth login, and configures the same local stdio bridge for Codex, Claude Code, and OpenCode at user scope.
|
||||
This installs the CLI, opens the browser for OAuth login, and configures the same local stdio bridge plus the `crafttable` Agent Skill for Codex, Claude Code, and OpenCode at user scope. The Skill lets an Agent discover and call CraftTable proactively when workspace data is relevant.
|
||||
|
||||
The explicit `--allow-git=all` is required by npm 12, whose default policy rejects Git-based package dependencies. The repository includes the built CLI bundle, so `--ignore-scripts` keeps installation reproducible without running package lifecycle scripts.
|
||||
|
||||
@@ -24,6 +24,8 @@ crafttable-mcp call list_spaces '{}'
|
||||
crafttable-mcp configure all
|
||||
```
|
||||
|
||||
`configure` installs both the MCP entry and the bundled Skill. `unconfigure` removes both when the Skill is still managed by this CLI; a locally modified Skill is preserved unless `--force` is supplied. Existing different entries are never overwritten silently, and `--dry-run` previews both changes.
|
||||
|
||||
`login` uses Authorization Code with PKCE and saves OAuth tokens in the operating-system credential store. Tokens are isolated by MCP server URL and OAuth client ID. The CLI never falls back to a plaintext token file.
|
||||
|
||||
`serve` is a stdio MCP bridge for Codex, Claude Code, and OpenCode. It writes MCP JSON-RPC only to stdout. If no OAuth credential exists, it can use the legacy `CRAFTTABLE_MCP_TOKEN` environment variable and reports that path only on stderr.
|
||||
|
||||
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 = {
|
||||
|
||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
+2
-1
@@ -13,7 +13,8 @@
|
||||
},
|
||||
"files": [
|
||||
"dist",
|
||||
"README.md"
|
||||
"README.md",
|
||||
"skills"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
---
|
||||
name: crafttable
|
||||
description: Use the configured CraftTable MCP when a task involves project spaces, members, documents, document folders, Markdown content, Kanban work items, workspace knowledge, or AI suggestion cards. Invoke proactively when CraftTable may contain the authoritative project context needed to answer or act, even if the user does not explicitly ask to use CraftTable. Do not invoke for unrelated local-code work.
|
||||
---
|
||||
|
||||
# CraftTable
|
||||
|
||||
Use the MCP server named `crafttable`; do not substitute direct HTTP calls or expose OAuth credentials.
|
||||
|
||||
## Workflow
|
||||
|
||||
1. Call `list_spaces` when the target space ID is unknown. Match by name and ask only when multiple spaces remain plausible.
|
||||
2. Read the smallest useful surface before answering or changing data. Prefer overview and list tools, then fetch a specific work item or document.
|
||||
3. Treat CraftTable as the source of truth for workspace state. Do not infer current members, tasks, document text, or project knowledge from stale conversation context when MCP reads are available.
|
||||
4. Perform writes only when the user's request authorizes that state change. Keep changes within the named space and object.
|
||||
5. Before updating Markdown, call `get_document` and pass its `currentRevisionId` as `baseRevisionId` to `update_markdown_document`.
|
||||
6. Pass `confirm: true` to deletion tools only after the user has explicitly confirmed the specific deletion.
|
||||
|
||||
Respect membership roles, enabled-plugin checks, and MCP errors. Never try to bypass a denied operation. If the MCP server is unavailable because the user is not logged in, ask them to run `crafttable-mcp login`; never open a login page from an Agent stdio session.
|
||||
@@ -0,0 +1,14 @@
|
||||
interface:
|
||||
display_name: "CraftTable"
|
||||
short_description: "Use CraftTable workspace data through MCP"
|
||||
default_prompt: "Use $crafttable to inspect the relevant CraftTable workspace context."
|
||||
|
||||
dependencies:
|
||||
tools:
|
||||
- type: "mcp"
|
||||
value: "crafttable"
|
||||
description: "CraftTable workspace MCP server"
|
||||
transport: "stdio"
|
||||
|
||||
policy:
|
||||
allow_implicit_invocation: true
|
||||
+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 }) => {
|
||||
|
||||
+57
-17
@@ -1,10 +1,10 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { mkdtemp, readFile, rm } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import test from "node:test";
|
||||
import { parse } from "jsonc-parser";
|
||||
import { configureAgents, type CommandResult, type CommandRunner, launchCommand, unconfigureAgents } from "../src/agents.js";
|
||||
import { configureAgents, type CommandResult, type CommandRunner, defaultSkillPath, launchCommand, unconfigureAgents } from "../src/agents.js";
|
||||
import { resolveClientOptions } from "../src/config.js";
|
||||
|
||||
class FakeRunner implements CommandRunner {
|
||||
@@ -38,26 +38,54 @@ const options = resolveClientOptions({ url: "https://example.test/mcp", clientId
|
||||
const cliEntry = path.resolve("dist", "cli.js");
|
||||
const nodePath = path.resolve("bin", "node.exe");
|
||||
|
||||
test("Codex and Claude adapters add, detect idempotency, replace conflicts, remove, and dry-run", async () => {
|
||||
const runner = new FakeRunner();
|
||||
assert.deepEqual(await configureAgents({ target: "codex", options, cliEntry, nodePath, runner }), [{ agent: "codex", action: "added" }]);
|
||||
assert.deepEqual(await configureAgents({ target: "codex", options, cliEntry, nodePath, runner }), [{ agent: "codex", action: "unchanged" }]);
|
||||
runner.existing.codex = JSON.stringify({ transport: { command: "other", args: [] } });
|
||||
await assert.rejects(configureAgents({ target: "codex", options, cliEntry, nodePath, runner, confirm: async () => false }), /not changed/);
|
||||
assert.deepEqual(await configureAgents({ target: "codex", options, cliEntry, nodePath, runner, force: true }), [{ agent: "codex", action: "replaced" }]);
|
||||
assert.deepEqual(await unconfigureAgents({ target: "codex", options, cliEntry, nodePath, runner, dryRun: true }), [{ agent: "codex", action: "would-remove" }]);
|
||||
assert.deepEqual(await unconfigureAgents({ target: "codex", options, cliEntry, nodePath, runner }), [{ agent: "codex", action: "removed" }]);
|
||||
test("Agent Skill paths use each client's user-level discovery directory", () => {
|
||||
const env = { USERPROFILE: "C:\\Users\\tester", CODEX_HOME: "C:\\CodexHome", XDG_CONFIG_HOME: "C:\\Config" };
|
||||
assert.equal(defaultSkillPath("codex", env), path.join("C:\\CodexHome", "skills", "crafttable"));
|
||||
assert.equal(defaultSkillPath("claude", env), path.join("C:\\Users\\tester", ".claude", "skills", "crafttable"));
|
||||
assert.equal(defaultSkillPath("opencode", env), path.join("C:\\Config", "opencode", "skills", "crafttable"));
|
||||
});
|
||||
|
||||
assert.deepEqual(await configureAgents({ target: "claude", options, cliEntry, nodePath, runner, dryRun: true }), [{ agent: "claude", action: "would-add" }]);
|
||||
assert.equal(runner.existing.claude, undefined);
|
||||
test("Codex and Claude adapters add, detect idempotency, replace conflicts, remove, and dry-run", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "crafttable-mcp-cli-agent-test-"));
|
||||
const skillSource = await createSkillFixture(directory);
|
||||
const codexSkill = path.join(directory, "codex", "crafttable");
|
||||
const claudeSkill = path.join(directory, "claude", "crafttable");
|
||||
const runner = new FakeRunner();
|
||||
const input = { options, cliEntry, nodePath, runner, skillSource, skillPaths: { codex: codexSkill, claude: claudeSkill } };
|
||||
try {
|
||||
assert.deepEqual(await configureAgents({ ...input, target: "codex" }), [{ agent: "codex", action: "added", skillAction: "installed" }]);
|
||||
assert.match(await readFile(path.join(codexSkill, "SKILL.md"), "utf8"), /name: crafttable/);
|
||||
assert.deepEqual(await configureAgents({ ...input, target: "codex" }), [{ agent: "codex", action: "unchanged", skillAction: "unchanged" }]);
|
||||
|
||||
runner.existing.codex = JSON.stringify({ transport: { command: "other", args: [] } });
|
||||
await assert.rejects(configureAgents({ ...input, target: "codex", confirm: async () => false }), /not changed/);
|
||||
assert.deepEqual(await configureAgents({ ...input, target: "codex", force: true }), [{ agent: "codex", action: "replaced", skillAction: "unchanged" }]);
|
||||
|
||||
await writeFile(path.join(codexSkill, "SKILL.md"), "locally modified\n", "utf8");
|
||||
await assert.rejects(configureAgents({ ...input, target: "codex", confirm: async () => false }), /Skill configuration was not changed/);
|
||||
assert.deepEqual(await configureAgents({ ...input, target: "codex", force: true }), [{ agent: "codex", action: "unchanged", skillAction: "replaced" }]);
|
||||
assert.equal(await readFile(path.join(codexSkill, "SKILL.md"), "utf8"), await readFile(path.join(skillSource, "SKILL.md"), "utf8"));
|
||||
assert.equal(await readFile(`${codexSkill}.crafttable-mcp.backup/SKILL.md`, "utf8"), "locally modified\n");
|
||||
|
||||
assert.deepEqual(await unconfigureAgents({ ...input, target: "codex", dryRun: true }), [{ agent: "codex", action: "would-remove", skillAction: "would-remove" }]);
|
||||
assert.deepEqual(await unconfigureAgents({ ...input, target: "codex" }), [{ agent: "codex", action: "removed", skillAction: "removed" }]);
|
||||
|
||||
assert.deepEqual(await configureAgents({ ...input, target: "claude", dryRun: true }), [{ agent: "claude", action: "would-add", skillAction: "would-install" }]);
|
||||
assert.equal(runner.existing.claude, undefined);
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode adapter preserves JSONC, creates one backup, is idempotent, and unconfigures", async () => {
|
||||
const directory = await mkdtemp(path.join(os.tmpdir(), "crafttable-mcp-agent-test-"));
|
||||
const filePath = path.join(directory, "opencode.json");
|
||||
await import("node:fs/promises").then(({ writeFile }) => writeFile(filePath, "{\n // keep this comment\n \"theme\": \"dark\"\n}\n", "utf8"));
|
||||
const skillSource = await createSkillFixture(directory);
|
||||
const opencodeSkill = path.join(directory, "opencode", "skills", "crafttable");
|
||||
const input = { target: "opencode" as const, options, cliEntry, nodePath, opencodePath: filePath, skillSource, skillPaths: { opencode: opencodeSkill } };
|
||||
await writeFile(filePath, "{\n // keep this comment\n \"theme\": \"dark\"\n}\n", "utf8");
|
||||
try {
|
||||
assert.deepEqual(await configureAgents({ target: "opencode", options, cliEntry, nodePath, opencodePath: filePath }), [{ agent: "opencode", action: "added" }]);
|
||||
assert.deepEqual(await configureAgents(input), [{ agent: "opencode", action: "added", skillAction: "installed" }]);
|
||||
const configuredText = await readFile(filePath, "utf8");
|
||||
assert.match(configuredText, /keep this comment/);
|
||||
const configured = parse(configuredText) as { theme: string; mcp: Record<string, { type: string; command: string[] }> };
|
||||
@@ -65,11 +93,23 @@ test("OpenCode adapter preserves JSONC, creates one backup, is idempotent, and u
|
||||
assert.equal(configured.mcp.crafttable?.type, "local");
|
||||
assert.deepEqual(configured.mcp.crafttable?.command, launchCommand(options, cliEntry, nodePath));
|
||||
assert.match(await readFile(`${filePath}.crafttable-mcp.backup`, "utf8"), /keep this comment/);
|
||||
assert.deepEqual(await configureAgents({ target: "opencode", options, cliEntry, nodePath, opencodePath: filePath }), [{ agent: "opencode", action: "unchanged" }]);
|
||||
assert.deepEqual(await unconfigureAgents({ target: "opencode", options, cliEntry, nodePath, opencodePath: filePath }), [{ agent: "opencode", action: "removed" }]);
|
||||
assert.deepEqual(await configureAgents(input), [{ agent: "opencode", action: "unchanged", skillAction: "unchanged" }]);
|
||||
await writeFile(path.join(opencodeSkill, "SKILL.md"), "user customization\n", "utf8");
|
||||
assert.deepEqual(await unconfigureAgents(input), [{ agent: "opencode", action: "removed", skillAction: "preserved" }]);
|
||||
const removed = parse(await readFile(filePath, "utf8")) as { mcp?: Record<string, unknown> };
|
||||
assert.equal(removed.mcp?.crafttable, undefined);
|
||||
assert.equal(await readFile(path.join(opencodeSkill, "SKILL.md"), "utf8"), "user customization\n");
|
||||
assert.deepEqual(await unconfigureAgents({ ...input, force: true }), [{ agent: "opencode", action: "absent", skillAction: "removed" }]);
|
||||
assert.equal(await readFile(`${opencodeSkill}.crafttable-mcp.backup/SKILL.md`, "utf8"), "user customization\n");
|
||||
} finally {
|
||||
await rm(directory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
async function createSkillFixture(directory: string): Promise<string> {
|
||||
const skillSource = path.join(directory, "bundled-skill");
|
||||
await mkdir(path.join(skillSource, "agents"), { recursive: true });
|
||||
await writeFile(path.join(skillSource, "SKILL.md"), "---\nname: crafttable\ndescription: Use CraftTable\n---\n\nUse the crafttable MCP.\n", "utf8");
|
||||
await writeFile(path.join(skillSource, "agents", "openai.yaml"), "policy:\n allow_implicit_invocation: true\n", "utf8");
|
||||
return skillSource;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user