cookbook-bridge 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +156 -0
- package/bridge.mjs +1914 -0
- package/chat.mjs +0 -0
- package/codex-runner.mjs +241 -0
- package/config.example.json +83 -0
- package/connectors.mjs +157 -0
- package/cookbook.mjs +234 -0
- package/device.mjs +509 -0
- package/harden.mjs +117 -0
- package/local.mjs +388 -0
- package/package.json +57 -0
- package/prompt.mjs +232 -0
- package/robot-runner.mjs +55 -0
- package/thread-runner.mjs +221 -0
- package/update.mjs +151 -0
- package/usage.mjs +147 -0
- package/volunteer.mjs +130 -0
package/chat.mjs
ADDED
|
Binary file
|
package/codex-runner.mjs
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex runner — drives OpenAI's Codex CLI autonomously with FULL Cookbook MCP.
|
|
3
|
+
*
|
|
4
|
+
* Why app-server: `codex exec` auto-cancels every MCP tool call in headless mode
|
|
5
|
+
* (OpenAI bug #16685 — closed stdin reads as "user declined"). The app-server
|
|
6
|
+
* JSON-RPC interface answers approvals explicitly, so tools work.
|
|
7
|
+
*
|
|
8
|
+
* v2 (chat lane, 2026-08-21): the server is now PERSISTENT — one process, one
|
|
9
|
+
* handshake, reused across turns — and Cookbook threads map to Codex threads, so
|
|
10
|
+
* a follow-up turn CONTINUES the same Codex conversation (claude-resume parity).
|
|
11
|
+
* Agent-message deltas stream out via onProgress as live_text. Codex Perplexity
|
|
12
|
+
* lane: boot once, then every turn is model time.
|
|
13
|
+
*
|
|
14
|
+
* SECURITY UNCHANGED from v1 (audit 2026-07-03): elicitations/input requests are
|
|
15
|
+
* accepted (that's what lets MCP tools run), but exec/patch APPROVALS are always
|
|
16
|
+
* DECLINED — with approvalPolicy "never" those only arrive as requests to escalate
|
|
17
|
+
* beyond the sandbox, and the sandbox is the boundary only if we never approve
|
|
18
|
+
* leaving it.
|
|
19
|
+
*
|
|
20
|
+
* Node built-ins only.
|
|
21
|
+
*/
|
|
22
|
+
import os from "node:os";
|
|
23
|
+
import path from "node:path";
|
|
24
|
+
import fs from "node:fs";
|
|
25
|
+
import { spawn } from "node:child_process";
|
|
26
|
+
|
|
27
|
+
const IDLE_MS = 15 * 60_000;
|
|
28
|
+
const LIVE_TEXT_CAP = 1800;
|
|
29
|
+
|
|
30
|
+
let server = null; // one persistent app-server per Bridge
|
|
31
|
+
const codexThreads = new Map(); // cookbook threadKey -> codex threadId
|
|
32
|
+
|
|
33
|
+
/** Does a live Codex conversation exist for this Cookbook thread? (Prompt shaping:
|
|
34
|
+
* resumed follow-ups skip the cold baton.) */
|
|
35
|
+
export function hasCodexThread(threadKey) {
|
|
36
|
+
return !!(server && !server.dead && codexThreads.has(threadKey));
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function killCodexServer() {
|
|
40
|
+
if (server) {
|
|
41
|
+
server.kill();
|
|
42
|
+
server = null;
|
|
43
|
+
}
|
|
44
|
+
codexThreads.clear();
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Reap the server after idleness (call from the Bridge's janitor tick). */
|
|
48
|
+
export function reapCodexServer(log) {
|
|
49
|
+
if (server && !server.dead && !server.turn && Date.now() - server.lastUsedAt > IDLE_MS) {
|
|
50
|
+
log?.(" ↳ codex app-server reaped (idle)");
|
|
51
|
+
killCodexServer();
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
class CodexServer {
|
|
56
|
+
constructor(agent, env, log) {
|
|
57
|
+
const codexBin = (agent.command && agent.command[0]) || "codex";
|
|
58
|
+
const codexHome = agent.codexHome || path.join(os.homedir(), ".codex-bridge");
|
|
59
|
+
this.cwd = path.join(os.tmpdir(), "codex-bridge-work");
|
|
60
|
+
try { fs.mkdirSync(this.cwd, { recursive: true }); } catch { /* best effort */ }
|
|
61
|
+
this.agent = agent;
|
|
62
|
+
this.log = log;
|
|
63
|
+
this.dead = false;
|
|
64
|
+
this.turn = null; // the single in-flight turn's handlers
|
|
65
|
+
this.queue = Promise.resolve(); // turns serialize on this chain
|
|
66
|
+
this.nextId = 10;
|
|
67
|
+
this.pending = new Map(); // request id -> {resolve, reject}
|
|
68
|
+
this.lastUsedAt = Date.now();
|
|
69
|
+
this.buf = "";
|
|
70
|
+
this.child = spawn(codexBin, ["app-server"], { env: { ...env, CODEX_HOME: codexHome }, stdio: ["pipe", "pipe", "pipe"] });
|
|
71
|
+
this.child.stderr.on("data", () => {});
|
|
72
|
+
this.child.on("error", (e) => this.#die(new Error(`could not launch \`${codexBin} app-server\`: ${e.message}`)));
|
|
73
|
+
this.child.on("close", () => this.#die(new Error("codex app-server exited")));
|
|
74
|
+
this.child.stdout.on("data", (d) => this.#onData(String(d)));
|
|
75
|
+
// Handshake once for the process lifetime.
|
|
76
|
+
this.ready = this.request("initialize", {
|
|
77
|
+
clientInfo: { name: "cookbook-bridge", title: "Cookbook Bridge", version: "0.2.0" },
|
|
78
|
+
capabilities: { experimentalApi: true, mcpServerOpenaiFormElicitation: true },
|
|
79
|
+
}).then(() => { this.notify("initialized"); });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
#die(err) {
|
|
83
|
+
if (this.dead) return;
|
|
84
|
+
this.dead = true;
|
|
85
|
+
for (const [, p] of this.pending) p.reject(err);
|
|
86
|
+
this.pending.clear();
|
|
87
|
+
const t = this.turn;
|
|
88
|
+
this.turn = null;
|
|
89
|
+
if (t) t.reject(err);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
kill() {
|
|
93
|
+
this.dead = true;
|
|
94
|
+
try { this.child.kill("SIGTERM"); } catch { /* gone */ }
|
|
95
|
+
setTimeout(() => { try { this.child.kill("SIGKILL"); } catch { /* gone */ } }, 3000);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
send(o) {
|
|
99
|
+
try { this.child.stdin.write(JSON.stringify(o) + "\n"); } catch { /* closed */ }
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
request(method, params) {
|
|
103
|
+
const id = this.nextId++;
|
|
104
|
+
return new Promise((resolve, reject) => {
|
|
105
|
+
this.pending.set(id, { resolve, reject });
|
|
106
|
+
this.send({ id, method, params });
|
|
107
|
+
});
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
notify(method, params) {
|
|
111
|
+
this.send(params === undefined ? { method } : { method, params });
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#onData(chunk) {
|
|
115
|
+
this.buf += chunk;
|
|
116
|
+
let i;
|
|
117
|
+
while ((i = this.buf.indexOf("\n")) >= 0) {
|
|
118
|
+
const line = this.buf.slice(0, i);
|
|
119
|
+
this.buf = this.buf.slice(i + 1);
|
|
120
|
+
if (!line.trim()) continue;
|
|
121
|
+
let m;
|
|
122
|
+
try { m = JSON.parse(line); } catch { continue; }
|
|
123
|
+
const meth = m.method || "";
|
|
124
|
+
|
|
125
|
+
// Server→client REQUESTS (id + method): tool elicitations accepted, sandbox
|
|
126
|
+
// escalation approvals DECLINED — see header; semantics identical to v1.
|
|
127
|
+
if (m.id !== undefined && meth) {
|
|
128
|
+
if (/elicitation\/request$/.test(meth)) this.send({ id: m.id, result: { action: "accept" } });
|
|
129
|
+
else if (/requestUserInput$/.test(meth)) this.send({ id: m.id, result: { answers: [{ value: "accept" }] } });
|
|
130
|
+
else if (/requestApproval$|Approval$/.test(meth)) this.send({ id: m.id, result: "decline" });
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// RESPONSES to our requests.
|
|
135
|
+
if (m.id !== undefined && (m.result !== undefined || m.error)) {
|
|
136
|
+
const p = this.pending.get(m.id);
|
|
137
|
+
if (p) {
|
|
138
|
+
this.pending.delete(m.id);
|
|
139
|
+
if (m.error) p.reject(new Error(`codex app-server error: ${m.error.message || JSON.stringify(m.error).slice(0, 200)}`));
|
|
140
|
+
else p.resolve(m.result);
|
|
141
|
+
}
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// NOTIFICATIONS — routed to the single in-flight turn.
|
|
146
|
+
const t = this.turn;
|
|
147
|
+
if (!t) continue;
|
|
148
|
+
t.lastActivityAt = Date.now();
|
|
149
|
+
if (meth === "item/agentMessage/delta" && m.params && m.params.delta) {
|
|
150
|
+
t.text += m.params.delta;
|
|
151
|
+
t.emit();
|
|
152
|
+
}
|
|
153
|
+
if (m.params) {
|
|
154
|
+
const u = m.params.usage ?? m.params.tokenUsage ?? m.params.token_usage ?? (m.params.turn && m.params.turn.usage);
|
|
155
|
+
if (u && typeof u === "object") t.usage = u;
|
|
156
|
+
}
|
|
157
|
+
if (meth === "turn/completed" || meth === "turn/failed") {
|
|
158
|
+
const status = (m.params && m.params.turn && m.params.turn.status) || meth;
|
|
159
|
+
this.turn = null;
|
|
160
|
+
this.lastUsedAt = Date.now();
|
|
161
|
+
clearInterval(t.watchdog);
|
|
162
|
+
t.resolve({ status, out: t.text.trim(), usage: t.usage });
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/** Run one turn (serialized). threadKey maps to a persistent Codex thread —
|
|
168
|
+
* reused when known, created otherwise. */
|
|
169
|
+
runTurn({ threadKey, prompt, timeoutSeconds, onProgress, cwd }) {
|
|
170
|
+
const exec = async () => {
|
|
171
|
+
if (this.dead) throw new Error("codex app-server is dead");
|
|
172
|
+
await this.ready;
|
|
173
|
+
let threadId = codexThreads.get(threadKey);
|
|
174
|
+
if (!threadId) {
|
|
175
|
+
// Per-THREAD cwd: local-access threads live in the mapped folder (the
|
|
176
|
+
// workspace-write sandbox is the wall); jailed threads use the tmp dir.
|
|
177
|
+
const r = await this.request("thread/start", {
|
|
178
|
+
cwd: cwd || this.cwd,
|
|
179
|
+
sandbox: this.agent.sandbox || "workspace-write",
|
|
180
|
+
approvalPolicy: "never",
|
|
181
|
+
});
|
|
182
|
+
threadId = r && r.thread && r.thread.id;
|
|
183
|
+
if (!threadId) throw new Error("codex app-server: thread/start returned no thread id");
|
|
184
|
+
codexThreads.set(threadKey, threadId);
|
|
185
|
+
} else {
|
|
186
|
+
this.log?.(` ↳ continuing the codex conversation (thread reuse)`);
|
|
187
|
+
}
|
|
188
|
+
return await new Promise((resolve, reject) => {
|
|
189
|
+
const startedAt = Date.now();
|
|
190
|
+
const t = {
|
|
191
|
+
resolve, reject,
|
|
192
|
+
text: "", usage: null,
|
|
193
|
+
lastEmit: 0, lastActivityAt: startedAt,
|
|
194
|
+
emit: () => {
|
|
195
|
+
if (!onProgress || Date.now() - t.lastEmit < 1200) return;
|
|
196
|
+
t.lastEmit = Date.now();
|
|
197
|
+
const tail = t.text.length > LIVE_TEXT_CAP ? "…" + t.text.slice(-LIVE_TEXT_CAP) : t.text;
|
|
198
|
+
try { onProgress({ input_tokens: 0, output_tokens: 0, runner: this.agent.name, ...(tail ? { live_text: tail } : {}) }); } catch { /* best-effort */ }
|
|
199
|
+
},
|
|
200
|
+
watchdog: setInterval(() => {
|
|
201
|
+
if (Date.now() - startedAt < timeoutSeconds * 1000) return;
|
|
202
|
+
clearInterval(t.watchdog);
|
|
203
|
+
if (this.turn === t) this.turn = null;
|
|
204
|
+
reject(new Error(`timed out after ${timeoutSeconds}s`));
|
|
205
|
+
}, 5000),
|
|
206
|
+
};
|
|
207
|
+
this.turn = t;
|
|
208
|
+
this.request("turn/start", {
|
|
209
|
+
threadId,
|
|
210
|
+
input: [{ type: "text", text: prompt, text_elements: [] }],
|
|
211
|
+
}).catch((e) => {
|
|
212
|
+
clearInterval(t.watchdog);
|
|
213
|
+
if (this.turn === t) this.turn = null;
|
|
214
|
+
reject(e);
|
|
215
|
+
});
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
// Serialize turns; a failed turn must not break the chain for the next one.
|
|
219
|
+
const run = this.queue.then(exec, exec);
|
|
220
|
+
this.queue = run.catch(() => {});
|
|
221
|
+
return run;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/**
|
|
226
|
+
* Run a Codex turn against the persistent server (booted on first use, kept warm).
|
|
227
|
+
* Signature kept close to v1's runCodexTask; extra opts carry the thread key and
|
|
228
|
+
* the live-progress sink. Falls back to a fresh server if the old one died.
|
|
229
|
+
*/
|
|
230
|
+
export function runCodexTask(agent, prompt, timeoutSeconds, token, baseEnv, onProgress, opts = {}) {
|
|
231
|
+
const env = { ...(baseEnv ?? process.env) };
|
|
232
|
+
if (token) env.COOKBOOK_CODEX_TOKEN = token;
|
|
233
|
+
if (!server || server.dead) server = new CodexServer(agent, env, opts.log);
|
|
234
|
+
return server.runTurn({
|
|
235
|
+
threadKey: opts.threadKey ?? `oneshot::${Date.now()}`,
|
|
236
|
+
prompt,
|
|
237
|
+
timeoutSeconds,
|
|
238
|
+
onProgress,
|
|
239
|
+
cwd: agent.cwd,
|
|
240
|
+
});
|
|
241
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
{
|
|
2
|
+
"cookbookUrl": "https://cookbook.team",
|
|
3
|
+
"token": "PASTE_YOUR_COOKBOOK_TOKEN_HERE",
|
|
4
|
+
"pollSeconds": 15,
|
|
5
|
+
"maxAttempts": 2,
|
|
6
|
+
"taskTimeoutSeconds": 3600,
|
|
7
|
+
"_timeouts": "taskTimeoutSeconds is the ABSOLUTE ceiling per run (cost backstop). livenessTimeoutSeconds kills a STALLED run \u2014 no output for this many seconds (streaming runs only). Healthy long work runs to the ceiling; silence dies fast.",
|
|
8
|
+
"livenessTimeoutSeconds": 300,
|
|
9
|
+
"_concurrency": "How many task runs may be in flight at once. Runs launch in parallel up to this cap; the atomic pre-claim keeps every task single-runner.",
|
|
10
|
+
"maxConcurrentRuns": 2,
|
|
11
|
+
"_billing": "Agents run on the CLI subscriptions you already pay for. The Bridge hides ANTHROPIC_API_KEY / OPENAI_API_KEY / GEMINI_API_KEY / GOOGLE_API_KEY from agent processes so a task can never silently bill your API account instead. Set allowApiKeyBilling to true ONLY if you explicitly want API-key billing.",
|
|
12
|
+
"allowApiKeyBilling": false,
|
|
13
|
+
"_volunteering": "STIGMERGY (off by default): an agent with volunteer:true watches tasks posted as open GOALS (to:'goal' on the board) and may claim ones matching its capabilities \u2014 decided by one cheap call to the agent's own CLI, gated by your delegation policy (ask parks it in your approvals inbox), claimed atomically, capped per poll. Flip volunteering:false to kill it globally without touching agents.",
|
|
14
|
+
"volunteering": true,
|
|
15
|
+
"_autoUpdate": "The Bridge follows the app: it checks the deploy's file manifest at startup + every 6h, and self-updates (hash-verified, originals kept in bridge.backup/, your config/token never touched) then restarts itself. Set false to pin your version and update manually with `node bridge.mjs update`.",
|
|
16
|
+
"autoUpdate": true,
|
|
17
|
+
"default": "Gemini",
|
|
18
|
+
"_acceptFrom": "Who may auto-run tasks on this Bridge: \"anyone\", or a list of EXACT member names or profile ids (case-insensitive, no partial matching \u2014 consent never guesses).",
|
|
19
|
+
"acceptFrom": "anyone",
|
|
20
|
+
"agents": [
|
|
21
|
+
{
|
|
22
|
+
"name": "Gemini",
|
|
23
|
+
"match": [
|
|
24
|
+
"gemini",
|
|
25
|
+
"antigravity",
|
|
26
|
+
"agy"
|
|
27
|
+
],
|
|
28
|
+
"enabled": true,
|
|
29
|
+
"command": [
|
|
30
|
+
"agy",
|
|
31
|
+
"-p",
|
|
32
|
+
"{prompt}",
|
|
33
|
+
"--sandbox",
|
|
34
|
+
"--print-timeout",
|
|
35
|
+
"3600s"
|
|
36
|
+
],
|
|
37
|
+
"_setup": "Gemini runs via the Antigravity CLI (agy) \u2014 the old `gemini` CLI stopped serving individual accounts June 18 2026. Requires agy >= 1.1.1 (first version whose headless -p can call MCP tools; the Bridge refuses older). Auth: run `agy` once interactively and sign in with Google. MCP: `node bridge/bridge.mjs connect-agents` writes ~/.gemini/config/mcp_config.json (agy has no `mcp add`). --sandbox keeps terminal restrictions on \u2014 Cookbook work flows through MCP tools, not the shell. Keep --print-timeout >= taskTimeoutSeconds (ceiling) so agy doesn't cut off before the Bridge's own timeout.",
|
|
38
|
+
"_usage": "agy emits no token/usage JSON \u2014 the board shows wall-clock duration for its runs (no token count)."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"name": "Claude",
|
|
42
|
+
"match": [
|
|
43
|
+
"claude"
|
|
44
|
+
],
|
|
45
|
+
"enabled": true,
|
|
46
|
+
"volunteer": false,
|
|
47
|
+
"capabilities": "TypeScript/Next.js, Supabase, research & writing. Avoid: iOS/Swift, design.",
|
|
48
|
+
"command": [
|
|
49
|
+
"claude",
|
|
50
|
+
"-p",
|
|
51
|
+
"{prompt}",
|
|
52
|
+
"--allowedTools",
|
|
53
|
+
"mcp__cookbook__*",
|
|
54
|
+
"--output-format",
|
|
55
|
+
"json"
|
|
56
|
+
],
|
|
57
|
+
"_allowedTools": "The prefix matches HOW your Claude is connected to Cookbook: a CLI-added server (claude mcp add ... cookbook ...) exposes mcp__cookbook__*; the claude.ai/desktop CONNECTOR exposes mcp__claude_ai_Cookbook__*. If tasks run but never complete, this mismatch is the usual cause \u2014 `node bridge.mjs doctor` checks it.",
|
|
58
|
+
"_output": "json output lets the Bridge report what each task cost (tokens/$) back to the board \u2014 text works too, you just lose the usage report"
|
|
59
|
+
},
|
|
60
|
+
{
|
|
61
|
+
"name": "Codex",
|
|
62
|
+
"match": [
|
|
63
|
+
"codex",
|
|
64
|
+
"chatgpt"
|
|
65
|
+
],
|
|
66
|
+
"enabled": false,
|
|
67
|
+
"runner": "app-server",
|
|
68
|
+
"command": [
|
|
69
|
+
"/Applications/ChatGPT.app/Contents/Resources/codex"
|
|
70
|
+
],
|
|
71
|
+
"_binary": "Codex merged into the ChatGPT desktop app (July 2026) \u2014 older installs had /Applications/Codex.app/Contents/Resources/codex. If doctor says 'binary not found', check both paths.",
|
|
72
|
+
"sandbox": "workspace-write",
|
|
73
|
+
"_setup": "Codex (ChatGPT) via codex app-server \u2014 its headless `exec` can't call MCP tools (OpenAI #16685), so the Bridge drives the app-server protocol and auto-approves tool elicitations (bridge/codex-runner.mjs). To enable: (1) make a clean CODEX_HOME at ~/.codex-bridge with config.toml [mcp_servers.cookbook] (url=<cookbookUrl>/api/mcp, bearer_token_env_var=COOKBOOK_CODEX_TOKEN) and a copy of ~/.codex/auth.json so it uses your ChatGPT login; (2) add a Cookbook token here as \"token\" (separate from the Bridge token, so Codex's work is attributed to Codex); (3) optionally set \"codexHome\" if not ~/.codex-bridge; (4) set enabled:true."
|
|
74
|
+
}
|
|
75
|
+
],
|
|
76
|
+
"persistentThreads": true,
|
|
77
|
+
"localWorkspaces": {
|
|
78
|
+
"<workspace-id>": {
|
|
79
|
+
"cwd": "/absolute/path/to/project",
|
|
80
|
+
"allowedTools": null
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
package/connectors.mjs
ADDED
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CONNECTOR SYNC — connect a tool once, every one of your agents has it.
|
|
3
|
+
*
|
|
4
|
+
* Today MCP connectors are configured per vendor, in three places, in three
|
|
5
|
+
* different shapes:
|
|
6
|
+
*
|
|
7
|
+
* Claude ~/.claude.json mcpServers{} (+ per-project blocks)
|
|
8
|
+
* Codex ~/.codex/config.toml [mcp_servers.NAME] TOML sections
|
|
9
|
+
* Gemini ~/.gemini/config/mcp_config.json mcpServers{} but `serverUrl` not `url`
|
|
10
|
+
*
|
|
11
|
+
* So a tool you wired into Claude is invisible to Codex, and drift is silent — one
|
|
12
|
+
* vendor keeps pointing at a stale URL for months. This module reads all three,
|
|
13
|
+
* normalizes them to one shape, and can write missing connectors back so every
|
|
14
|
+
* vendor agrees. That is the Cookbook thesis at the tool layer: your agents are
|
|
15
|
+
* interchangeable workers, so their capabilities should be yours, not the vendor's.
|
|
16
|
+
*
|
|
17
|
+
* Safety: every write makes a timestamped .bak first; secrets are never printed
|
|
18
|
+
* (values are carried across as-is, shown as ••••). Node built-ins only.
|
|
19
|
+
*/
|
|
20
|
+
import fs from "node:fs";
|
|
21
|
+
import os from "node:os";
|
|
22
|
+
import path from "node:path";
|
|
23
|
+
|
|
24
|
+
const HOME = os.homedir();
|
|
25
|
+
export const VENDORS = {
|
|
26
|
+
claude: { label: "Claude", file: path.join(HOME, ".claude.json") },
|
|
27
|
+
codex: { label: "Codex", file: path.join(process.env.CODEX_HOME || path.join(HOME, ".codex"), "config.toml") },
|
|
28
|
+
gemini: { label: "Gemini", file: path.join(HOME, ".gemini", "config", "mcp_config.json") },
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
/** Normalized connector: { name, kind: 'stdio'|'http', command, args, env, url, headers } */
|
|
32
|
+
function normalize(name, raw) {
|
|
33
|
+
const url = raw.url || raw.serverUrl || raw.httpUrl;
|
|
34
|
+
if (url) return { name, kind: "http", url, headers: raw.headers || {} };
|
|
35
|
+
return { name, kind: "stdio", command: raw.command, args: raw.args || [], env: raw.env || {} };
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function readJson(file) {
|
|
39
|
+
try { return JSON.parse(fs.readFileSync(file, "utf8")); } catch { return null; }
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** Read one vendor's connectors → Map(name → normalized). */
|
|
43
|
+
export function readVendor(vendor) {
|
|
44
|
+
const out = new Map();
|
|
45
|
+
const { file } = VENDORS[vendor];
|
|
46
|
+
if (vendor === "claude") {
|
|
47
|
+
const j = readJson(file);
|
|
48
|
+
if (!j) return out;
|
|
49
|
+
for (const [n, raw] of Object.entries(j.mcpServers || {})) out.set(n, normalize(n, raw));
|
|
50
|
+
return out;
|
|
51
|
+
}
|
|
52
|
+
if (vendor === "gemini") {
|
|
53
|
+
const j = readJson(file);
|
|
54
|
+
if (!j) return out;
|
|
55
|
+
for (const [n, raw] of Object.entries(j.mcpServers || {})) out.set(n, normalize(n, raw));
|
|
56
|
+
return out;
|
|
57
|
+
}
|
|
58
|
+
// codex: TOML. We only need the [mcp_servers.NAME] sections and their scalars.
|
|
59
|
+
let text = "";
|
|
60
|
+
try { text = fs.readFileSync(file, "utf8"); } catch { return out; }
|
|
61
|
+
const re = /^\[mcp_servers\.([A-Za-z0-9_\-]+)\]([\s\S]*?)(?=^\[|\Z)/gm;
|
|
62
|
+
let m;
|
|
63
|
+
while ((m = re.exec(text))) {
|
|
64
|
+
const [, name, body] = m;
|
|
65
|
+
const get = (k) => {
|
|
66
|
+
const hit = body.match(new RegExp(`^${k}\\s*=\\s*"([^"]*)"`, "m"));
|
|
67
|
+
return hit ? hit[1] : undefined;
|
|
68
|
+
};
|
|
69
|
+
const argsLine = body.match(/^args\s*=\s*\[([^\]]*)\]/m);
|
|
70
|
+
const args = argsLine ? argsLine[1].split(",").map((s) => s.trim().replace(/^"|"$/g, "")).filter(Boolean) : [];
|
|
71
|
+
out.set(name, get("url")
|
|
72
|
+
? { name, kind: "http", url: get("url"), headers: {} }
|
|
73
|
+
: { name, kind: "stdio", command: get("command"), args, env: {} });
|
|
74
|
+
}
|
|
75
|
+
return out;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** One row per connector, with per-vendor presence and drift detection. */
|
|
79
|
+
export function survey() {
|
|
80
|
+
const byVendor = Object.fromEntries(Object.keys(VENDORS).map((v) => [v, readVendor(v)]));
|
|
81
|
+
const names = [...new Set(Object.values(byVendor).flatMap((m) => [...m.keys()]))].sort();
|
|
82
|
+
return names.map((name) => {
|
|
83
|
+
const present = {};
|
|
84
|
+
const targets = new Set();
|
|
85
|
+
for (const v of Object.keys(VENDORS)) {
|
|
86
|
+
const c = byVendor[v].get(name);
|
|
87
|
+
present[v] = !!c;
|
|
88
|
+
if (c) targets.add(c.kind === "http" ? c.url : `${c.command} ${c.args.join(" ")}`.trim());
|
|
89
|
+
}
|
|
90
|
+
return { name, present, drift: targets.size > 1, targets: [...targets], byVendor };
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function backup(file) {
|
|
95
|
+
try {
|
|
96
|
+
if (fs.existsSync(file)) fs.copyFileSync(file, `${file}.bak-${Date.now()}`);
|
|
97
|
+
} catch { /* best effort */ }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/** Write a connector into a vendor's config (idempotent: replaces same-named). */
|
|
101
|
+
export function writeConnector(vendor, conn) {
|
|
102
|
+
const { file } = VENDORS[vendor];
|
|
103
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
104
|
+
backup(file);
|
|
105
|
+
|
|
106
|
+
if (vendor === "claude" || vendor === "gemini") {
|
|
107
|
+
const j = readJson(file) || {};
|
|
108
|
+
j.mcpServers = j.mcpServers || {};
|
|
109
|
+
j.mcpServers[conn.name] = conn.kind === "http"
|
|
110
|
+
// Claude takes `url`; Gemini takes `serverUrl`. Same connector, different key.
|
|
111
|
+
? (vendor === "gemini"
|
|
112
|
+
? { serverUrl: conn.url, ...(Object.keys(conn.headers).length ? { headers: conn.headers } : {}) }
|
|
113
|
+
: { type: "http", url: conn.url, ...(Object.keys(conn.headers).length ? { headers: conn.headers } : {}) })
|
|
114
|
+
: { command: conn.command, args: conn.args, ...(Object.keys(conn.env).length ? { env: conn.env } : {}) };
|
|
115
|
+
fs.writeFileSync(file, JSON.stringify(j, null, 2));
|
|
116
|
+
return;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// codex TOML: drop any existing section for this name, then append a fresh one.
|
|
120
|
+
let text = "";
|
|
121
|
+
try { text = fs.readFileSync(file, "utf8"); } catch { /* new file */ }
|
|
122
|
+
text = text.replace(new RegExp(`^\\[mcp_servers\\.${conn.name}\\][\\s\\S]*?(?=^\\[|\\Z)`, "gm"), "");
|
|
123
|
+
const lines = [`\n[mcp_servers.${conn.name}]`];
|
|
124
|
+
if (conn.kind === "http") {
|
|
125
|
+
lines.push(`url = "${conn.url}"`);
|
|
126
|
+
} else {
|
|
127
|
+
lines.push(`command = "${conn.command}"`);
|
|
128
|
+
lines.push(`args = [${conn.args.map((a) => `"${a}"`).join(", ")}]`);
|
|
129
|
+
if (Object.keys(conn.env).length) {
|
|
130
|
+
lines.push(`\n[mcp_servers.${conn.name}.env]`);
|
|
131
|
+
for (const [k, v] of Object.entries(conn.env)) lines.push(`${k} = "${v}"`);
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
fs.writeFileSync(file, text.replace(/\n{3,}$/, "\n") + lines.join("\n") + "\n");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/** Make every vendor agree. `only` limits to named connectors; source picks whose
|
|
138
|
+
* definition wins when a connector exists in several places (default: claude). */
|
|
139
|
+
export function sync({ only = null, source = "claude", dryRun = false } = {}) {
|
|
140
|
+
const rows = survey();
|
|
141
|
+
const actions = [];
|
|
142
|
+
for (const row of rows) {
|
|
143
|
+
if (only && !only.includes(row.name)) continue;
|
|
144
|
+
const defs = row.byVendor;
|
|
145
|
+
const winner = defs[source]?.get(row.name)
|
|
146
|
+
|| defs.claude.get(row.name) || defs.codex.get(row.name) || defs.gemini.get(row.name);
|
|
147
|
+
if (!winner) continue;
|
|
148
|
+
for (const v of Object.keys(VENDORS)) {
|
|
149
|
+
const existing = defs[v].get(row.name);
|
|
150
|
+
const same = existing && JSON.stringify(existing) === JSON.stringify({ ...existing, ...winner });
|
|
151
|
+
if (existing && same) continue;
|
|
152
|
+
actions.push({ vendor: v, name: row.name, action: existing ? "update" : "add", conn: winner });
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
if (!dryRun) for (const a of actions) writeConnector(a.vendor, a.conn);
|
|
156
|
+
return actions;
|
|
157
|
+
}
|