feat: add CraftTable MCP OAuth CLI
This commit is contained in:
+264
@@ -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 }));
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user