pennyrouter 0.3.0 → 0.3.2

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.
@@ -0,0 +1,212 @@
1
+ /* Register the Threads MCP server with the harnesses that can run it.
2
+
3
+ Without this, `pennyrouter install` gives a user routing and nothing else: the MCP server
4
+ exists as a command they would have to discover and register by hand, which almost nobody
5
+ does. Registration is what makes thread_read/thread_resume simply be there.
6
+
7
+ Two harnesses, two config files, neither of them the one the installer already manages:
8
+
9
+ Claude Code ~/.claude.json -> mcpServers.pennyrouter (JSON)
10
+ Codex ~/.codex/config.toml -> [mcp_servers.pennyrouter] (TOML)
11
+
12
+ NOTE the file for Claude Code. Its MCP registry is `~/.claude.json`, NOT the
13
+ `~/.claude/settings.json` that harnesses/claude-code.js writes — different file, different
14
+ shape. Writing the entry into settings.json looks right and does nothing.
15
+
16
+ The command is `npx -y pennyrouter mcp`, deliberately unpinned. An absolute path to the
17
+ current bin breaks the moment the user switches node versions or reinstalls, and the server
18
+ then silently stops appearing rather than failing loudly. A pinned version would freeze the
19
+ session writers, which is wrong for a feature whose whole risk is that Claude Code and Codex
20
+ change their undocumented formats — the writers need to be updatable. */
21
+
22
+ import { atomicWrite, backupFile, exists, expandHome, readJsonFile } from "./state.js";
23
+ import { readFile } from "node:fs/promises";
24
+
25
+ export const SERVER_NAME = "pennyrouter";
26
+ const CLAUDE_REGISTRY = "~/.claude.json";
27
+ const CODEX_CONFIG = "~/.codex/config.toml";
28
+
29
+ const MANAGED_START = "# --- PennyRouter managed MCP server ---";
30
+ const MANAGED_END = "# --- end PennyRouter managed MCP server ---";
31
+
32
+ /** What we register. Shared by both harnesses so the two configs cannot drift apart.
33
+
34
+ `gatewayBaseUrl` is pinned into the entry only when the install targeted a non-default
35
+ gateway. The MCP server is spawned later by the harness, in a process that shares nothing
36
+ with the install run, so a base URL chosen at install time reaches it only by being written
37
+ here. Omitted when unset, which keeps default installs byte-identical to earlier versions. */
38
+ export function serverEntry(harness = "claude-code", gatewayBaseUrl = "") {
39
+ const url = String(gatewayBaseUrl || "").trim();
40
+ return {
41
+ command: "npx",
42
+ args: ["-y", "pennyrouter", "mcp"],
43
+ env: {
44
+ PENNYROUTER_MCP_HARNESS: harness,
45
+ ...(url ? { PENNYROUTER_GATEWAY_BASE_URL: url } : {}),
46
+ },
47
+ };
48
+ }
49
+
50
+ /** True when an entry is one of ours, so re-installing updates rather than duplicating and
51
+ uninstalling never removes a server the user registered themselves. */
52
+ export function isManagedEntry(entry) {
53
+ if (!entry || typeof entry !== "object") return false;
54
+ const args = Array.isArray(entry.args) ? entry.args.join(" ") : "";
55
+ return /\bpennyrouter\b/.test(`${entry.command || ""} ${args}`);
56
+ }
57
+
58
+ // -- Claude Code ----------------------------------------------------------
59
+
60
+ export function applyClaudeRegistry(config, { remove = false, gatewayBaseUrl = "" } = {}) {
61
+ const next = { ...(config || {}) };
62
+ const servers = { ...(next.mcpServers || {}) };
63
+
64
+ if (remove) {
65
+ // Only remove what we wrote. A user who pointed the name at their own build keeps it.
66
+ if (isManagedEntry(servers[SERVER_NAME])) delete servers[SERVER_NAME];
67
+ } else {
68
+ const existing = servers[SERVER_NAME];
69
+ if (existing && !isManagedEntry(existing)) {
70
+ throw new Error(
71
+ `Claude Code already has an MCP server named "${SERVER_NAME}" that PennyRouter did not ` +
72
+ "write. Rename it before installing so your configuration is not overwritten.",
73
+ );
74
+ }
75
+ servers[SERVER_NAME] = serverEntry("claude-code", gatewayBaseUrl);
76
+ }
77
+
78
+ if (Object.keys(servers).length) next.mcpServers = servers;
79
+ else delete next.mcpServers;
80
+ return next;
81
+ }
82
+
83
+ async function writeClaude({ remove, gatewayBaseUrl }) {
84
+ if (!(await exists(CLAUDE_REGISTRY))) {
85
+ // Claude Code writes this on first run. Creating it ourselves risks clobbering a shape we
86
+ // do not own, so skip rather than guess.
87
+ return { changed: false, reason: "Claude Code config not found" };
88
+ }
89
+ const current = (await readJsonFile(CLAUDE_REGISTRY, null)) || {};
90
+ const next = applyClaudeRegistry(current, { remove, gatewayBaseUrl });
91
+ if (JSON.stringify(next) === JSON.stringify(current)) return { changed: false };
92
+
93
+ await backupFile(CLAUDE_REGISTRY, "mcp");
94
+ await atomicWrite(CLAUDE_REGISTRY, `${JSON.stringify(next, null, 2)}\n`);
95
+ return { changed: true, path: expandHome(CLAUDE_REGISTRY) };
96
+ }
97
+
98
+ // -- Codex ----------------------------------------------------------------
99
+
100
+ export function renderCodexMcp(text, { remove = false, gatewayBaseUrl = "" } = {}) {
101
+ const clean = removeManagedCodexMcp(text).trimEnd();
102
+ if (remove) return clean ? `${clean}\n` : "";
103
+
104
+ const pattern = new RegExp(`^\\s*\\[mcp_servers\\.${SERVER_NAME}\\]\\s*$`, "m");
105
+ if (pattern.test(clean)) {
106
+ throw new Error(
107
+ `Codex already defines [mcp_servers.${SERVER_NAME}] outside PennyRouter's managed block. ` +
108
+ "Rename that server before installing so PennyRouter does not overwrite it.",
109
+ );
110
+ }
111
+
112
+ const entry = serverEntry("codex", gatewayBaseUrl);
113
+ // Rendered from entry.env rather than a fixed line, so a key added to serverEntry reaches both
114
+ // harnesses instead of silently appearing only in Claude Code's JSON.
115
+ const env = Object.entries(entry.env)
116
+ .map(([key, value]) => `${key} = "${value}"`)
117
+ .join(", ");
118
+ const block = [
119
+ MANAGED_START,
120
+ `[mcp_servers.${SERVER_NAME}]`,
121
+ `command = "${entry.command}"`,
122
+ `args = [${entry.args.map((a) => `"${a}"`).join(", ")}]`,
123
+ `env = { ${env} }`,
124
+ MANAGED_END,
125
+ ].join("\n");
126
+
127
+ // Appended rather than spliced at the first table: a TOML table header captures every key
128
+ // that follows it, so inserting this block above existing tables would silently reparent
129
+ // their keys into ours.
130
+ return `${clean ? `${clean}\n\n` : ""}${block}\n`;
131
+ }
132
+
133
+ export function removeManagedCodexMcp(text) {
134
+ return String(text || "").replace(
135
+ new RegExp(
136
+ `${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`,
137
+ "g",
138
+ ),
139
+ "",
140
+ );
141
+ }
142
+
143
+ function escapeRegex(value) {
144
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
145
+ }
146
+
147
+ async function writeCodex({ remove, gatewayBaseUrl }) {
148
+ if (!(await exists(CODEX_CONFIG))) return { changed: false, reason: "Codex config not found" };
149
+ const current = await readFile(expandHome(CODEX_CONFIG), "utf8").catch(() => "");
150
+ const next = renderCodexMcp(current, { remove, gatewayBaseUrl });
151
+ if (next === current) return { changed: false };
152
+
153
+ await backupFile(CODEX_CONFIG, "mcp");
154
+ await atomicWrite(CODEX_CONFIG, next);
155
+ return { changed: true, path: expandHome(CODEX_CONFIG) };
156
+ }
157
+
158
+ // -- entry points ---------------------------------------------------------
159
+
160
+ const WRITERS = { "claude-code": writeClaude, codex: writeCodex };
161
+
162
+ /** Whether each harness currently has our server registered, and whether it could. */
163
+ export async function mcpStatus(harnessIds = Object.keys(WRITERS)) {
164
+ const out = [];
165
+ for (const id of harnessIds) {
166
+ if (id === "claude-code") {
167
+ const present = await exists(CLAUDE_REGISTRY);
168
+ const config = present ? (await readJsonFile(CLAUDE_REGISTRY, null)) || {} : {};
169
+ out.push({
170
+ harness: id,
171
+ configFound: present,
172
+ registered: isManagedEntry((config.mcpServers || {})[SERVER_NAME]),
173
+ conflict: Boolean(
174
+ (config.mcpServers || {})[SERVER_NAME] &&
175
+ !isManagedEntry((config.mcpServers || {})[SERVER_NAME]),
176
+ ),
177
+ });
178
+ } else if (id === "codex") {
179
+ const present = await exists(CODEX_CONFIG);
180
+ const text = present ? await readFile(expandHome(CODEX_CONFIG), "utf8").catch(() => "") : "";
181
+ const managed = text.includes(MANAGED_START);
182
+ out.push({
183
+ harness: id,
184
+ configFound: present,
185
+ registered: managed,
186
+ conflict:
187
+ !managed && new RegExp(`^\\s*\\[mcp_servers\\.${SERVER_NAME}\\]`, "m").test(text),
188
+ });
189
+ }
190
+ }
191
+ return out;
192
+ }
193
+
194
+ /**
195
+ * Register (or with `remove`, unregister) the MCP server for `harnessIds`.
196
+ * Returns one result per harness; a harness whose config is absent is skipped, not an error.
197
+ */
198
+ export async function registerMcp(harnessIds, { remove = false, gatewayBaseUrl = "" } = {}) {
199
+ const results = [];
200
+ for (const id of harnessIds) {
201
+ const writer = WRITERS[id];
202
+ if (!writer) continue;
203
+ try {
204
+ results.push({ harness: id, ...(await writer({ remove, gatewayBaseUrl })) });
205
+ } catch (error) {
206
+ // A conflict must not fail the whole install: routing is the primary job and is already
207
+ // done by this point. Report it and let the user resolve the name.
208
+ results.push({ harness: id, changed: false, error: error?.message || String(error) });
209
+ }
210
+ }
211
+ return results;
212
+ }
@@ -0,0 +1,200 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import {
4
+ SERVER_NAME,
5
+ applyClaudeRegistry,
6
+ isManagedEntry,
7
+ removeManagedCodexMcp,
8
+ renderCodexMcp,
9
+ serverEntry,
10
+ } from "./mcp-register.js";
11
+
12
+ // -- what gets written ----------------------------------------------------
13
+ // Unpinned on purpose: an absolute path breaks on a node/nvm switch and the server then stops
14
+ // appearing silently, and a pinned version would freeze the session writers — wrong for a
15
+ // feature whose risk is that the harnesses change their undocumented formats.
16
+ {
17
+ const entry = serverEntry();
18
+ assert.equal(entry.command, "npx");
19
+ assert.deepEqual(entry.args, ["-y", "pennyrouter", "mcp"]);
20
+ assert.equal(entry.env.PENNYROUTER_MCP_HARNESS, "claude-code");
21
+ assert.ok(!JSON.stringify(entry).includes("/"), "no absolute paths");
22
+ assert.ok(!/pennyrouter@/.test(JSON.stringify(entry)), "not version-pinned");
23
+ assert.ok(!("PENNYROUTER_GATEWAY_BASE_URL" in entry.env), "no URL pinned by default");
24
+ }
25
+
26
+ // A non-default gateway must be pinned into the entry. The MCP server is spawned later by the
27
+ // harness, in a process that never sees the install run's flags, so `--local` reaches it only
28
+ // through the written config. Regression: `--local` used to set the URL for the install process
29
+ // alone, leaving the registered server pointed at production and every transfer failing there.
30
+ {
31
+ const local = serverEntry("claude-code", "http://localhost:8400");
32
+ assert.equal(local.env.PENNYROUTER_GATEWAY_BASE_URL, "http://localhost:8400");
33
+ assert.equal(local.env.PENNYROUTER_MCP_HARNESS, "claude-code");
34
+
35
+ // Both harnesses carry it, or the two configs drift apart.
36
+ const toml = renderCodexMcp("", { gatewayBaseUrl: "http://localhost:8400" });
37
+ assert.match(toml, /PENNYROUTER_MCP_HARNESS = "codex"/);
38
+ assert.match(toml, /PENNYROUTER_GATEWAY_BASE_URL = "http:\/\/localhost:8400"/);
39
+
40
+ // Default installs stay byte-identical, so existing entries are not rewritten on update.
41
+ assert.equal(renderCodexMcp(""), renderCodexMcp("", { gatewayBaseUrl: "" }));
42
+ assert.ok(!renderCodexMcp("").includes("PENNYROUTER_GATEWAY_BASE_URL"));
43
+
44
+ const claude = applyClaudeRegistry({}, { gatewayBaseUrl: "http://localhost:8400" });
45
+ assert.equal(
46
+ claude.mcpServers[SERVER_NAME].env.PENNYROUTER_GATEWAY_BASE_URL,
47
+ "http://localhost:8400",
48
+ );
49
+ assert.ok(isManagedEntry(claude.mcpServers[SERVER_NAME]), "still ours after pinning a URL");
50
+ }
51
+
52
+ // -- Claude Code registry -------------------------------------------------
53
+ {
54
+ // Registering into an empty config.
55
+ const fresh = applyClaudeRegistry({});
56
+ assert.deepEqual(fresh.mcpServers[SERVER_NAME], serverEntry());
57
+
58
+ // Existing unrelated servers are preserved.
59
+ const withOthers = applyClaudeRegistry({
60
+ mcpServers: { railway: { command: "railway" } },
61
+ projects: { "/x": {} },
62
+ });
63
+ assert.ok(withOthers.mcpServers.railway, "other servers survive");
64
+ assert.ok(withOthers.projects, "unrelated top-level keys survive");
65
+
66
+ // Re-installing updates in place rather than duplicating.
67
+ const twice = applyClaudeRegistry(applyClaudeRegistry({}));
68
+ assert.equal(Object.keys(twice.mcpServers).length, 1);
69
+
70
+ // A same-named server we did not write is a hard stop, not a silent overwrite.
71
+ assert.throws(
72
+ () => applyClaudeRegistry({ mcpServers: { [SERVER_NAME]: { command: "/my/own/build" } } }),
73
+ /did not write/,
74
+ );
75
+
76
+ // Removal takes ours and leaves everything else.
77
+ const removed = applyClaudeRegistry(
78
+ { mcpServers: { railway: { command: "railway" }, [SERVER_NAME]: serverEntry() } },
79
+ { remove: true },
80
+ );
81
+ assert.ok(!removed.mcpServers[SERVER_NAME]);
82
+ assert.ok(removed.mcpServers.railway);
83
+
84
+ // A user who re-pointed the name at their own build keeps it through an uninstall.
85
+ const custom = { mcpServers: { [SERVER_NAME]: { command: "/my/own/build" } } };
86
+ assert.deepEqual(applyClaudeRegistry(custom, { remove: true }), custom);
87
+
88
+ // Removing the last server drops the key entirely rather than leaving `{}`.
89
+ const emptied = applyClaudeRegistry(
90
+ { mcpServers: { [SERVER_NAME]: serverEntry() } },
91
+ { remove: true },
92
+ );
93
+ assert.ok(!("mcpServers" in emptied));
94
+ }
95
+
96
+ // -- managed-entry detection ----------------------------------------------
97
+ assert.equal(isManagedEntry(serverEntry()), true);
98
+ assert.equal(isManagedEntry({ command: "npx", args: ["-y", "pennyrouter@1.2.3", "mcp"] }), true);
99
+ assert.equal(isManagedEntry({ command: "/my/own/build" }), false);
100
+ assert.equal(isManagedEntry(null), false);
101
+ assert.equal(isManagedEntry({}), false);
102
+
103
+ // -- Codex TOML -----------------------------------------------------------
104
+ {
105
+ const existing = [
106
+ 'model_provider = "pennyrouter"',
107
+ "",
108
+ "[mcp_servers.node_repl]",
109
+ 'command = "/Applications/Codex.app/node_repl"',
110
+ ].join("\n");
111
+
112
+ const written = renderCodexMcp(existing);
113
+ assert.ok(written.includes(`[mcp_servers.${SERVER_NAME}]`));
114
+ assert.ok(written.includes('command = "npx"'));
115
+ assert.ok(written.includes('args = ["-y", "pennyrouter", "mcp"]'));
116
+ assert.ok(written.includes('PENNYROUTER_MCP_HARNESS = "codex"'));
117
+ assert.ok(written.includes("[mcp_servers.node_repl]"), "existing servers survive");
118
+
119
+ // A TOML table header captures every key after it, so our block must be appended — splicing
120
+ // it above an existing table would reparent that table's keys into ours.
121
+ const ourIndex = written.indexOf(`[mcp_servers.${SERVER_NAME}]`);
122
+ assert.ok(ourIndex > written.indexOf("[mcp_servers.node_repl]"), "appended, not spliced");
123
+ assert.ok(
124
+ written.indexOf('model_provider = "pennyrouter"') < written.indexOf("[mcp_servers."),
125
+ "root keys stay above every table",
126
+ );
127
+
128
+ // Re-installing is idempotent: the managed block is replaced, not stacked.
129
+ const again = renderCodexMcp(written);
130
+ assert.equal(again.match(new RegExp(`\\[mcp_servers\\.${SERVER_NAME}\\]`, "g")).length, 1);
131
+
132
+ // Removal restores the original.
133
+ assert.equal(renderCodexMcp(written, { remove: true }).trim(), existing.trim());
134
+ assert.equal(removeManagedCodexMcp(written).trim(), existing.trim());
135
+
136
+ // An unmanaged server of the same name is a hard stop.
137
+ assert.throws(
138
+ () => renderCodexMcp(`[mcp_servers.${SERVER_NAME}]\ncommand = "mine"`),
139
+ /outside PennyRouter's managed block/,
140
+ );
141
+
142
+ // Empty config in, valid config out.
143
+ assert.ok(renderCodexMcp("").includes(`[mcp_servers.${SERVER_NAME}]`));
144
+ assert.equal(renderCodexMcp("", { remove: true }), "");
145
+ }
146
+
147
+ // -- status reporting -----------------------------------------------------
148
+ // `mcp status` is how someone finds out they are in the state install cannot reach: opted out
149
+ // with --no-mcp, or blocked by a name conflict. Both must be distinguishable from "not set up".
150
+ {
151
+ const { mkdtempSync, writeFileSync, mkdirSync } = await import("node:fs");
152
+ const { tmpdir } = await import("node:os");
153
+ const { join } = await import("node:path");
154
+ const { mcpStatus } = await import("./mcp-register.js");
155
+
156
+ const home = mkdtempSync(join(tmpdir(), "pr-mcp-status-"));
157
+ const originalHome = process.env.HOME;
158
+ process.env.HOME = home;
159
+ try {
160
+ // No configs at all: reported as absent, not as "not registered".
161
+ let rows = await mcpStatus(["claude-code", "codex"]);
162
+ assert.deepEqual(rows.map((r) => r.configFound), [false, false]);
163
+
164
+ // Present but not registered — the --no-mcp state.
165
+ writeFileSync(join(home, ".claude.json"), JSON.stringify({ mcpServers: {} }));
166
+ mkdirSync(join(home, ".codex"), { recursive: true });
167
+ writeFileSync(join(home, ".codex", "config.toml"), 'model = "gpt-5"\n');
168
+ rows = await mcpStatus(["claude-code", "codex"]);
169
+ assert.deepEqual(rows.map((r) => r.registered), [false, false]);
170
+ assert.deepEqual(rows.map((r) => r.conflict), [false, false]);
171
+ assert.deepEqual(rows.map((r) => r.configFound), [true, true]);
172
+
173
+ // Someone else's server under our name.
174
+ writeFileSync(
175
+ join(home, ".claude.json"),
176
+ JSON.stringify({ mcpServers: { [SERVER_NAME]: { command: "/my/own/build" } } }),
177
+ );
178
+ writeFileSync(
179
+ join(home, ".codex", "config.toml"),
180
+ `[mcp_servers.${SERVER_NAME}]\ncommand = "mine"\n`,
181
+ );
182
+ rows = await mcpStatus(["claude-code", "codex"]);
183
+ assert.deepEqual(rows.map((r) => r.conflict), [true, true]);
184
+ assert.deepEqual(rows.map((r) => r.registered), [false, false]);
185
+
186
+ // Ours.
187
+ writeFileSync(
188
+ join(home, ".claude.json"),
189
+ JSON.stringify(applyClaudeRegistry({})),
190
+ );
191
+ writeFileSync(join(home, ".codex", "config.toml"), renderCodexMcp(""));
192
+ rows = await mcpStatus(["claude-code", "codex"]);
193
+ assert.deepEqual(rows.map((r) => r.registered), [true, true]);
194
+ assert.deepEqual(rows.map((r) => r.conflict), [false, false]);
195
+ } finally {
196
+ process.env.HOME = originalHome;
197
+ }
198
+ }
199
+
200
+ console.log("mcp-register tests passed");