| 1 | import { spawn } from "node:child_process"; |
| 2 | import { appendFile, mkdir, readFile } from "node:fs/promises"; |
| 3 | import { homedir } from "node:os"; |
| 4 | import path from "node:path"; |
| 5 | |
| 6 | /** |
| 7 | * Codex's `diffinery` MCP server, pointed at a self-hosted Diffinery by `diffinery setup`. The Codex plugin's |
| 8 | * server is https://diffinery.com/mcp (Codex has no plugin settings to change it), and a server of the same |
| 9 | * name in Codex's own config replaces it, so for another instance this adds that server to config.toml. |
| 10 | * Not with `codex mcp add`, which starts a browser sign-in for a server that offers OAuth and waits for it. |
| 11 | * Nothing happens for diffinery.com, or where Codex isn't installed. |
| 12 | */ |
| 13 | |
| 14 | const DEFAULT_URL = "https://diffinery.com"; |
| 15 | |
| 16 | function codex(env: NodeJS.ProcessEnv, args: string[]): Promise<{ code: number; stdout: string; stderr: string }> { |
| 17 | return new Promise((resolve) => { |
| 18 | let child; |
| 19 | try { |
| 20 | child = spawn("codex", args, { env, stdio: ["ignore", "pipe", "pipe"] }); |
| 21 | } catch (e) { |
| 22 | return resolve({ code: 127, stdout: "", stderr: (e as Error).message }); |
| 23 | } |
| 24 | let stdout = ""; |
| 25 | let stderr = ""; |
| 26 | child.stdout.on("data", (d: Buffer) => (stdout += d.toString())); |
| 27 | child.stderr.on("data", (d: Buffer) => (stderr += d.toString())); |
| 28 | child.on("error", (e) => resolve({ code: 127, stdout, stderr: e.message })); |
| 29 | child.on("close", (code) => resolve({ code: code ?? 1, stdout, stderr })); |
| 30 | }); |
| 31 | } |
| 32 | |
| 33 | export interface CodexResult { |
| 34 | ok: boolean; |
| 35 | message: string; |
| 36 | } |
| 37 | |
| 38 | /** Null when there's nothing to do: diffinery.com, DIFFINERY_NO_CODEX, or no `codex` on PATH. */ |
| 39 | export async function setupCodexMcp(env: NodeJS.ProcessEnv, url: string): Promise<CodexResult | null> { |
| 40 | if (url === DEFAULT_URL || env.DIFFINERY_NO_CODEX) return null; |
| 41 | const want = `${url}/mcp`; |
| 42 | // The server Codex uses now: the user's own, else the plugin's, else none (exit status 1). |
| 43 | const got = await codex(env, ["mcp", "get", "diffinery", "--json"]); |
| 44 | if (got.code === 127) return null; |
| 45 | let bearer: string | null = null; |
| 46 | if (got.code === 0) { |
| 47 | try { |
| 48 | const t = (JSON.parse(got.stdout) as { transport?: { url?: unknown; bearer_token_env_var?: unknown } }).transport ?? {}; |
| 49 | if (t.url === want) return { ok: true, message: `the diffinery MCP server is ${want}` }; |
| 50 | // A headless setup that sends DIFFINERY_TOKEN keeps doing so. |
| 51 | if (typeof t.bearer_token_env_var === "string") bearer = t.bearer_token_env_var; |
| 52 | } catch { |
| 53 | // An answer we can't read: add the server all the same. |
| 54 | } |
| 55 | } |
| 56 | const home = env.CODEX_HOME && path.isAbsolute(env.CODEX_HOME) ? env.CODEX_HOME : path.join(env.HOME || homedir(), ".codex"); |
| 57 | const file = path.join(home, "config.toml"); |
| 58 | const text = await readFile(file, "utf8").catch(() => ""); |
| 59 | // A server of the user's own goes first (codex removes it however it's written); the plugin's stays. |
| 60 | if (/^\s*\[\s*mcp_servers\s*\.\s*"?diffinery"?\s*\]/m.test(text) || /^\s*mcp_servers\s*\.\s*"?diffinery"?\s*[.=]/m.test(text)) { |
| 61 | const rm = await codex(env, ["mcp", "remove", "diffinery"]); |
| 62 | if (rm.code !== 0) return { ok: false, message: `codex mcp remove diffinery failed: ${(rm.stderr.trim().split("\n")[0] || `exit status ${rm.code}`).slice(0, 200)}` }; |
| 63 | } |
| 64 | const now = await readFile(file, "utf8").catch(() => ""); |
| 65 | const table = `[mcp_servers.diffinery]\nurl = ${JSON.stringify(want)}\n${bearer ? `bearer_token_env_var = ${JSON.stringify(bearer)}\n` : ""}`; |
| 66 | await mkdir(home, { recursive: true }); |
| 67 | await appendFile(file, `${now && !now.endsWith("\n") ? "\n" : ""}${now ? "\n" : ""}${table}`); |
| 68 | const check = await codex(env, ["mcp", "get", "diffinery", "--json"]); |
| 69 | if (!check.stdout.includes(JSON.stringify(want).slice(1, -1))) return { ok: false, message: `Codex doesn't read the diffinery server added to ${file}: check that file` }; |
| 70 | return { ok: true, message: `the diffinery MCP server is now ${want} (sign in with: codex mcp login diffinery)` }; |
| 71 | } |