feat: add CraftTable MCP OAuth CLI

This commit is contained in:
2026-08-15 20:38:50 +08:00
commit 7c033b6118
19 changed files with 4053 additions and 0 deletions
+264
View File
@@ -0,0 +1,264 @@
import { spawn } from "node:child_process";
import { constants } from "node:fs";
import { copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
import os from "node:os";
import path from "node:path";
import { createInterface } from "node:readline/promises";
import { applyEdits, modify, parse } from "jsonc-parser";
import type { ClientOptions } from "./config.js";
import { errorMessage } from "./config.js";
export const SERVER_NAME = "crafttable";
export type AgentName = "codex" | "claude" | "opencode";
export type AgentTarget = AgentName | "all";
export type CommandResult = { code: number; stdout: string; stderr: string };
export interface CommandRunner {
run(command: string, args: string[]): Promise<CommandResult>;
}
export type ConfigureInput = {
target: AgentTarget;
options: ClientOptions;
cliEntry: string;
nodePath?: string;
dryRun?: boolean;
force?: boolean;
env?: NodeJS.ProcessEnv;
runner?: CommandRunner;
confirm?: (message: string) => Promise<boolean>;
opencodePath?: string;
};
export type ConfigureResult = {
agent: AgentName;
action: "added" | "replaced" | "removed" | "unchanged" | "absent" | "would-add" | "would-replace" | "would-remove";
};
export async function configureAgents(input: ConfigureInput): Promise<ConfigureResult[]> {
const agents = expandTarget(input.target);
const command = launchCommand(input.options, input.cliEntry, input.nodePath ?? process.execPath);
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));
}
}
return results;
}
export async function unconfigureAgents(input: ConfigureInput): Promise<ConfigureResult[]> {
const agents = expandTarget(input.target);
const runner = input.runner ?? new SpawnCommandRunner();
const results: ConfigureResult[] = [];
for (const agent of agents) {
if (agent === "opencode") {
results.push(await unconfigureOpenCode(input));
continue;
}
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" });
}
return results;
}
export function launchCommand(options: ClientOptions, cliEntry: string, nodePath: string): string[] {
return [
path.resolve(nodePath),
path.resolve(cliEntry),
"serve",
"--url", options.url.toString(),
"--client-id", options.clientId,
"--callback-port", String(options.callbackPort),
];
}
async function configureCliAgent(
agent: "codex" | "claude",
command: string[],
input: ConfigureInput,
runner: CommandRunner,
): Promise<ConfigureResult> {
const existing = await probeCliAgent(agent, runner);
if (existing.exists && outputMatchesCommand(existing.output, command)) return { agent, action: "unchanged" };
if (existing.exists && !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} MCP entry; use --force to replace it`);
if (!await confirm(`${agent} already has a different ${SERVER_NAME} MCP entry. Replace it?`)) {
throw new Error(`${agent} MCP configuration was not changed`);
}
}
const action = existing.exists ? "replaced" : "added";
if (input.dryRun) return { agent, action: existing.exists ? "would-replace" : "would-add" };
const executable = agent === "codex" ? "codex" : "claude";
if (existing.exists) {
const removeArgs = agent === "codex"
? ["mcp", "remove", SERVER_NAME]
: ["mcp", "remove", "--scope", "user", SERVER_NAME];
await requireSuccess(runner.run(executable, removeArgs), `${agent} MCP replacement cleanup`);
}
const addArgs = agent === "codex"
? ["mcp", "add", SERVER_NAME, "--", ...command]
: ["mcp", "add", "--scope", "user", SERVER_NAME, "--", ...command];
await requireSuccess(runner.run(executable, addArgs), `${agent} MCP registration`);
return { agent, action };
}
async function probeCliAgent(agent: "codex" | "claude", runner: CommandRunner): Promise<{ exists: boolean; output: string }> {
const executable = agent === "codex" ? "codex" : "claude";
const args = agent === "codex"
? ["mcp", "get", SERVER_NAME, "--json"]
: ["mcp", "get", SERVER_NAME];
const result = await runner.run(executable, args);
if (result.code === 0) return { exists: true, output: result.stdout };
const combined = `${result.stdout}\n${result.stderr}`;
if (/not found|does not exist|no mcp server|not configured|unknown server/i.test(combined)) return { exists: false, output: combined };
throw new Error(`Could not inspect ${agent} MCP configuration: ${safeCommandError(result)}`);
}
function outputMatchesCommand(output: string, command: string[]): boolean {
try {
const document = JSON.parse(output) as unknown;
if (findCommand(document, command)) return true;
} catch {
// Claude currently returns a human-readable record.
}
return command.every((part) => output.includes(part));
}
function findCommand(value: unknown, command: string[]): boolean {
if (!value || typeof value !== "object") return false;
if (Array.isArray(value)) return value.some((item) => findCommand(item, command));
const record = value as Record<string, unknown>;
if (typeof record.command === "string" && Array.isArray(record.args)) {
const candidate = [record.command, ...record.args.filter((item): item is string => typeof item === "string")];
if (candidate.length === command.length && candidate.every((item, index) => item === command[index])) return true;
}
return Object.values(record).some((item) => findCommand(item, command));
}
async function configureOpenCode(input: ConfigureInput, command: string[]): Promise<ConfigureResult> {
const filePath = input.opencodePath ?? defaultOpenCodePath(input.env);
const original = await readOptionalFile(filePath) ?? "{}\n";
const document = parse(original) as { mcp?: Record<string, unknown> } | undefined;
const existing = document?.mcp?.[SERVER_NAME] as { type?: unknown; command?: unknown; enabled?: unknown } | undefined;
const desired = { type: "local", command, enabled: true };
if (existing && existing.type === desired.type && existing.enabled === true && arraysEqual(existing.command, command)) {
return { agent: "opencode", action: "unchanged" };
}
if (existing && !input.force && !input.dryRun) {
const confirm = input.confirm ?? terminalConfirm;
if (!process.stdin.isTTY && !input.confirm) throw new Error(`opencode already has a different ${SERVER_NAME} MCP entry; use --force to replace it`);
if (!await confirm(`opencode already has a different ${SERVER_NAME} MCP entry. Replace it?`)) {
throw new Error("opencode MCP configuration was not changed");
}
}
if (input.dryRun) return { agent: "opencode", action: existing ? "would-replace" : "would-add" };
const updated = applyEdits(original, modify(original, ["mcp", SERVER_NAME], desired, {
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
}));
await backupAndAtomicWrite(filePath, updated, await readOptionalFile(filePath) !== undefined);
return { agent: "opencode", action: existing ? "replaced" : "added" };
}
async function unconfigureOpenCode(input: ConfigureInput): Promise<ConfigureResult> {
const filePath = input.opencodePath ?? defaultOpenCodePath(input.env);
const original = await readOptionalFile(filePath);
if (original === undefined) return { agent: "opencode", action: "absent" };
const document = parse(original) as { mcp?: Record<string, unknown> } | undefined;
if (!document?.mcp || !(SERVER_NAME in document.mcp)) return { agent: "opencode", action: "absent" };
if (input.dryRun) return { agent: "opencode", action: "would-remove" };
const updated = applyEdits(original, modify(original, ["mcp", SERVER_NAME], undefined, {
formattingOptions: { insertSpaces: true, tabSize: 2, eol: "\n" },
}));
await backupAndAtomicWrite(filePath, updated, true);
return { agent: "opencode", action: "removed" };
}
export function defaultOpenCodePath(env: NodeJS.ProcessEnv = process.env): string {
const base = env.XDG_CONFIG_HOME || (process.platform === "win32"
? path.join(env.USERPROFILE || os.homedir(), ".config")
: path.join(os.homedir(), ".config"));
return path.join(base, "opencode", "opencode.json");
}
async function backupAndAtomicWrite(filePath: string, updated: string, existed: boolean): Promise<void> {
await mkdir(path.dirname(filePath), { recursive: true });
if (existed) {
try {
await copyFile(filePath, `${filePath}.crafttable-mcp.backup`, constants.COPYFILE_EXCL);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
}
}
const temporary = `${filePath}.${process.pid}.tmp`;
await writeFile(temporary, updated, "utf8");
await rename(temporary, filePath);
}
async function readOptionalFile(filePath: string): Promise<string | undefined> {
try {
return await readFile(filePath, "utf8");
} catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined;
throw error;
}
}
function arraysEqual(value: unknown, expected: string[]): boolean {
return Array.isArray(value) && value.length === expected.length && value.every((item, index) => item === expected[index]);
}
function expandTarget(target: AgentTarget): AgentName[] {
if (target === "all") return ["codex", "claude", "opencode"];
if (["codex", "claude", "opencode"].includes(target)) return [target as AgentName];
throw new Error("Agent must be one of: codex, claude, opencode, all");
}
async function terminalConfirm(message: string): Promise<boolean> {
const readline = createInterface({ input: process.stdin, output: process.stderr });
try {
return /^y(es)?$/i.test((await readline.question(`${message} [y/N] `)).trim());
} finally {
readline.close();
}
}
async function requireSuccess(resultPromise: Promise<CommandResult>, operation: string): Promise<void> {
const result = await resultPromise;
if (result.code !== 0) throw new Error(`${operation} failed: ${safeCommandError(result)}`);
}
function safeCommandError(result: CommandResult): string {
return (result.stderr || result.stdout || `exit code ${result.code}`).trim();
}
export class SpawnCommandRunner implements CommandRunner {
run(command: string, args: string[]): Promise<CommandResult> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, { shell: false, windowsHide: true, stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8").on("data", (chunk) => { stdout += String(chunk); });
child.stderr.setEncoding("utf8").on("data", (chunk) => { stderr += String(chunk); });
child.once("error", (error) => reject(new Error(`Could not run ${command}: ${errorMessage(error)}`)));
child.once("close", (code) => resolve({ code: code ?? 1, stdout, stderr }));
});
}
}