pennyrouter 0.2.19 → 0.2.21
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/package.json +1 -1
- package/src/cli.js +24 -7
- package/src/mcp-register.js +28 -14
- package/src/mcp-register.test.js +27 -0
- package/src/mcp.test.js +2 -0
- package/src/statusline.js +17 -1
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -8,6 +8,7 @@ import { mcpStatus, registerMcp } from "./mcp-register.js";
|
|
|
8
8
|
|
|
9
9
|
/** The harnesses that can run an MCP server. The other supported harnesses cannot. */
|
|
10
10
|
const MCP_HARNESS_IDS = ["claude-code", "codex"];
|
|
11
|
+
const DEFAULT_GATEWAY_BASE_URL = "https://api.pennyrouter.com";
|
|
11
12
|
import {
|
|
12
13
|
CLAUDE_CODE_MODELS,
|
|
13
14
|
claudeCodeHarness,
|
|
@@ -203,7 +204,7 @@ async function authProvider(flags) {
|
|
|
203
204
|
"No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
|
|
204
205
|
);
|
|
205
206
|
}
|
|
206
|
-
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL ||
|
|
207
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || DEFAULT_GATEWAY_BASE_URL;
|
|
207
208
|
if (flags.dryRun) {
|
|
208
209
|
console.log(`Will delete stored Anthropic token from gateway: ${gatewayBaseUrl}`);
|
|
209
210
|
return;
|
|
@@ -255,7 +256,7 @@ async function authProvider(flags) {
|
|
|
255
256
|
);
|
|
256
257
|
}
|
|
257
258
|
|
|
258
|
-
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL ||
|
|
259
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || DEFAULT_GATEWAY_BASE_URL;
|
|
259
260
|
const manifest = await loadManifest();
|
|
260
261
|
const existingRecord = (manifest.installations || []).find(
|
|
261
262
|
(candidate) => candidate.harness === "claude-code",
|
|
@@ -593,7 +594,9 @@ export async function install(flags = {}) {
|
|
|
593
594
|
// Registered after routing, which is the primary job: a naming conflict here reports itself
|
|
594
595
|
// and leaves a working install rather than failing the whole run.
|
|
595
596
|
if (!flags.noMcp && !flags.dryRun) {
|
|
596
|
-
const mcp = await registerMcp(records.map((r) => r.harness)
|
|
597
|
+
const mcp = await registerMcp(records.map((r) => r.harness), {
|
|
598
|
+
gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
|
|
599
|
+
});
|
|
597
600
|
const registered = mcp.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
|
|
598
601
|
if (registered.length) {
|
|
599
602
|
console.log("");
|
|
@@ -636,8 +639,20 @@ function mcpTargets(flags) {
|
|
|
636
639
|
return requested.length ? requested : MCP_HARNESS_IDS;
|
|
637
640
|
}
|
|
638
641
|
|
|
642
|
+
/** The gateway URL worth pinning into the MCP entry, or "" for the default.
|
|
643
|
+
The MCP server already falls back to production on its own, so writing that value would only
|
|
644
|
+
freeze today's default into every user's config. */
|
|
645
|
+
function customGatewayBaseUrl(url) {
|
|
646
|
+
const value = String(url || "").trim().replace(/\/+$/, "");
|
|
647
|
+
return !value || value === DEFAULT_GATEWAY_BASE_URL ? "" : value;
|
|
648
|
+
}
|
|
649
|
+
|
|
639
650
|
async function installMcp(flags) {
|
|
640
|
-
const results = await registerMcp(mcpTargets(flags)
|
|
651
|
+
const results = await registerMcp(mcpTargets(flags), {
|
|
652
|
+
gatewayBaseUrl: customGatewayBaseUrl(
|
|
653
|
+
flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL,
|
|
654
|
+
),
|
|
655
|
+
});
|
|
641
656
|
console.log("PennyRouter Threads MCP");
|
|
642
657
|
console.log("");
|
|
643
658
|
for (const result of results) {
|
|
@@ -688,7 +703,7 @@ async function serveMcp(flags) {
|
|
|
688
703
|
|| launchConfig.gateway_base_url
|
|
689
704
|
|| globalAuth.gatewayBaseUrl
|
|
690
705
|
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
691
|
-
||
|
|
706
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
692
707
|
|
|
693
708
|
await runMcpServer({
|
|
694
709
|
gatewayBaseUrl,
|
|
@@ -741,7 +756,7 @@ async function update(flags) {
|
|
|
741
756
|
|| launchConfig.gateway_base_url
|
|
742
757
|
|| globalAuth.gatewayBaseUrl
|
|
743
758
|
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
744
|
-
||
|
|
759
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
745
760
|
|
|
746
761
|
if (!apiKey) {
|
|
747
762
|
throw new Error(
|
|
@@ -832,7 +847,9 @@ async function update(flags) {
|
|
|
832
847
|
// and `pennyrouter mcp uninstall` removes it for good.
|
|
833
848
|
if (!flags.dryRun && !flags.noMcp) {
|
|
834
849
|
const wanted = targets.filter((record) => MCP_HARNESS_IDS.includes(record.harness));
|
|
835
|
-
const results = await registerMcp(wanted.map((record) => record.harness)
|
|
850
|
+
const results = await registerMcp(wanted.map((record) => record.harness), {
|
|
851
|
+
gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
|
|
852
|
+
});
|
|
836
853
|
const added = results.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
|
|
837
854
|
if (added.length) console.log(`Registered the Threads MCP server on ${added.join(" and ")}.`);
|
|
838
855
|
for (const result of results.filter((r) => r.error)) {
|
package/src/mcp-register.js
CHANGED
|
@@ -29,12 +29,21 @@ const CODEX_CONFIG = "~/.codex/config.toml";
|
|
|
29
29
|
const MANAGED_START = "# --- PennyRouter managed MCP server ---";
|
|
30
30
|
const MANAGED_END = "# --- end PennyRouter managed MCP server ---";
|
|
31
31
|
|
|
32
|
-
/** What we register. Shared by both harnesses so the two configs cannot drift apart.
|
|
33
|
-
|
|
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();
|
|
34
40
|
return {
|
|
35
41
|
command: "npx",
|
|
36
42
|
args: ["-y", "pennyrouter", "mcp"],
|
|
37
|
-
env: {
|
|
43
|
+
env: {
|
|
44
|
+
PENNYROUTER_MCP_HARNESS: harness,
|
|
45
|
+
...(url ? { PENNYROUTER_GATEWAY_BASE_URL: url } : {}),
|
|
46
|
+
},
|
|
38
47
|
};
|
|
39
48
|
}
|
|
40
49
|
|
|
@@ -48,7 +57,7 @@ export function isManagedEntry(entry) {
|
|
|
48
57
|
|
|
49
58
|
// -- Claude Code ----------------------------------------------------------
|
|
50
59
|
|
|
51
|
-
export function applyClaudeRegistry(config, { remove = false } = {}) {
|
|
60
|
+
export function applyClaudeRegistry(config, { remove = false, gatewayBaseUrl = "" } = {}) {
|
|
52
61
|
const next = { ...(config || {}) };
|
|
53
62
|
const servers = { ...(next.mcpServers || {}) };
|
|
54
63
|
|
|
@@ -63,7 +72,7 @@ export function applyClaudeRegistry(config, { remove = false } = {}) {
|
|
|
63
72
|
"write. Rename it before installing so your configuration is not overwritten.",
|
|
64
73
|
);
|
|
65
74
|
}
|
|
66
|
-
servers[SERVER_NAME] = serverEntry("claude-code");
|
|
75
|
+
servers[SERVER_NAME] = serverEntry("claude-code", gatewayBaseUrl);
|
|
67
76
|
}
|
|
68
77
|
|
|
69
78
|
if (Object.keys(servers).length) next.mcpServers = servers;
|
|
@@ -71,14 +80,14 @@ export function applyClaudeRegistry(config, { remove = false } = {}) {
|
|
|
71
80
|
return next;
|
|
72
81
|
}
|
|
73
82
|
|
|
74
|
-
async function writeClaude({ remove }) {
|
|
83
|
+
async function writeClaude({ remove, gatewayBaseUrl }) {
|
|
75
84
|
if (!(await exists(CLAUDE_REGISTRY))) {
|
|
76
85
|
// Claude Code writes this on first run. Creating it ourselves risks clobbering a shape we
|
|
77
86
|
// do not own, so skip rather than guess.
|
|
78
87
|
return { changed: false, reason: "Claude Code config not found" };
|
|
79
88
|
}
|
|
80
89
|
const current = (await readJsonFile(CLAUDE_REGISTRY, null)) || {};
|
|
81
|
-
const next = applyClaudeRegistry(current, { remove });
|
|
90
|
+
const next = applyClaudeRegistry(current, { remove, gatewayBaseUrl });
|
|
82
91
|
if (JSON.stringify(next) === JSON.stringify(current)) return { changed: false };
|
|
83
92
|
|
|
84
93
|
await backupFile(CLAUDE_REGISTRY, "mcp");
|
|
@@ -88,7 +97,7 @@ async function writeClaude({ remove }) {
|
|
|
88
97
|
|
|
89
98
|
// -- Codex ----------------------------------------------------------------
|
|
90
99
|
|
|
91
|
-
export function renderCodexMcp(text, { remove = false } = {}) {
|
|
100
|
+
export function renderCodexMcp(text, { remove = false, gatewayBaseUrl = "" } = {}) {
|
|
92
101
|
const clean = removeManagedCodexMcp(text).trimEnd();
|
|
93
102
|
if (remove) return clean ? `${clean}\n` : "";
|
|
94
103
|
|
|
@@ -100,13 +109,18 @@ export function renderCodexMcp(text, { remove = false } = {}) {
|
|
|
100
109
|
);
|
|
101
110
|
}
|
|
102
111
|
|
|
103
|
-
const entry = serverEntry("codex");
|
|
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(", ");
|
|
104
118
|
const block = [
|
|
105
119
|
MANAGED_START,
|
|
106
120
|
`[mcp_servers.${SERVER_NAME}]`,
|
|
107
121
|
`command = "${entry.command}"`,
|
|
108
122
|
`args = [${entry.args.map((a) => `"${a}"`).join(", ")}]`,
|
|
109
|
-
`env = {
|
|
123
|
+
`env = { ${env} }`,
|
|
110
124
|
MANAGED_END,
|
|
111
125
|
].join("\n");
|
|
112
126
|
|
|
@@ -130,10 +144,10 @@ function escapeRegex(value) {
|
|
|
130
144
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
131
145
|
}
|
|
132
146
|
|
|
133
|
-
async function writeCodex({ remove }) {
|
|
147
|
+
async function writeCodex({ remove, gatewayBaseUrl }) {
|
|
134
148
|
if (!(await exists(CODEX_CONFIG))) return { changed: false, reason: "Codex config not found" };
|
|
135
149
|
const current = await readFile(expandHome(CODEX_CONFIG), "utf8").catch(() => "");
|
|
136
|
-
const next = renderCodexMcp(current, { remove });
|
|
150
|
+
const next = renderCodexMcp(current, { remove, gatewayBaseUrl });
|
|
137
151
|
if (next === current) return { changed: false };
|
|
138
152
|
|
|
139
153
|
await backupFile(CODEX_CONFIG, "mcp");
|
|
@@ -181,13 +195,13 @@ export async function mcpStatus(harnessIds = Object.keys(WRITERS)) {
|
|
|
181
195
|
* Register (or with `remove`, unregister) the MCP server for `harnessIds`.
|
|
182
196
|
* Returns one result per harness; a harness whose config is absent is skipped, not an error.
|
|
183
197
|
*/
|
|
184
|
-
export async function registerMcp(harnessIds, { remove = false } = {}) {
|
|
198
|
+
export async function registerMcp(harnessIds, { remove = false, gatewayBaseUrl = "" } = {}) {
|
|
185
199
|
const results = [];
|
|
186
200
|
for (const id of harnessIds) {
|
|
187
201
|
const writer = WRITERS[id];
|
|
188
202
|
if (!writer) continue;
|
|
189
203
|
try {
|
|
190
|
-
results.push({ harness: id, ...(await writer({ remove })) });
|
|
204
|
+
results.push({ harness: id, ...(await writer({ remove, gatewayBaseUrl })) });
|
|
191
205
|
} catch (error) {
|
|
192
206
|
// A conflict must not fail the whole install: routing is the primary job and is already
|
|
193
207
|
// done by this point. Report it and let the user resolve the name.
|
package/src/mcp-register.test.js
CHANGED
|
@@ -20,6 +20,33 @@ import {
|
|
|
20
20
|
assert.equal(entry.env.PENNYROUTER_MCP_HARNESS, "claude-code");
|
|
21
21
|
assert.ok(!JSON.stringify(entry).includes("/"), "no absolute paths");
|
|
22
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");
|
|
23
50
|
}
|
|
24
51
|
|
|
25
52
|
// -- Claude Code registry -------------------------------------------------
|
package/src/mcp.test.js
CHANGED
|
@@ -123,7 +123,9 @@ const workspace = mkdtempSync(join(tmpdir(), "pr-mcp-ws-"));
|
|
|
123
123
|
// the model explained the silence as a "size/display limitation" and fell back to fetching
|
|
124
124
|
// the share URL directly — pulling 574KB of JS-shell markup into the context that
|
|
125
125
|
// thread_resume exists to protect. The descriptions have to rule that out explicitly.
|
|
126
|
+
// Only the share-link tools: thread_transfer takes no URL, so there is no URL to fetch instead.
|
|
126
127
|
for (const tool of byId.get(2).result.tools) {
|
|
128
|
+
if (!tool.inputSchema?.properties?.url) continue;
|
|
127
129
|
assert.match(tool.description, /not fetch|Never fall back to fetching/i, tool.name);
|
|
128
130
|
assert.match(tool.description, /blocked/i, tool.name);
|
|
129
131
|
}
|
package/src/statusline.js
CHANGED
|
@@ -46,6 +46,7 @@ export async function configureClaudeCodeStatusline(current) {
|
|
|
46
46
|
current.statusLine = {
|
|
47
47
|
type: "command",
|
|
48
48
|
command: `node "${statuslineScriptPath()}"`,
|
|
49
|
+
refreshInterval: 1,
|
|
49
50
|
...(existing?.padding !== undefined ? { padding: existing.padding } : {}),
|
|
50
51
|
};
|
|
51
52
|
return { script_path: statuslineScriptPath(), wrapped: Boolean(config.wrap_command) };
|
|
@@ -164,13 +165,28 @@ function askBadges(c) {
|
|
|
164
165
|
return out;
|
|
165
166
|
}
|
|
166
167
|
|
|
168
|
+
// Cache warmth countdown: when a prompt cache prefix is live, show time remaining.
|
|
169
|
+
// The gateway sends cache_expires_at (absolute Unix timestamp); we interpolate locally
|
|
170
|
+
// so it ticks down smoothly every statusline render (every ~1s), only "jumping" when
|
|
171
|
+
// the gateway refreshes (~15s) with a fresh timestamp.
|
|
172
|
+
function cacheWarmth(c) {
|
|
173
|
+
if (!c || typeof c.cache_expires_at !== "number" || c.cache_expires_at <= 0) {
|
|
174
|
+
return "";
|
|
175
|
+
}
|
|
176
|
+
const remaining = c.cache_expires_at - Date.now() / 1000;
|
|
177
|
+
if (remaining <= 0) return "";
|
|
178
|
+
const minutes = Math.floor(remaining / 60);
|
|
179
|
+
const seconds = Math.floor(remaining % 60);
|
|
180
|
+
return " · 🔥 warm " + minutes + ":" + String(seconds).padStart(2, "0");
|
|
181
|
+
}
|
|
182
|
+
|
|
167
183
|
let penny;
|
|
168
184
|
if (!pennyInstalled) {
|
|
169
185
|
penny = "🪙 penny off";
|
|
170
186
|
} else if (cache && cache.known && cache.bypass) {
|
|
171
187
|
penny = "🪙 penny off";
|
|
172
188
|
} else {
|
|
173
|
-
penny = "🪙 penny" + authSuffix(cache && cache.auth_mode) + askBadges(cache);
|
|
189
|
+
penny = "🪙 penny" + authSuffix(cache && cache.auth_mode) + askBadges(cache) + cacheWarmth(cache);
|
|
174
190
|
}
|
|
175
191
|
|
|
176
192
|
let lines;
|