pennyrouter 0.2.18 → 0.2.20

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.18",
3
+ "version": "0.2.20",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
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 || "https://api.pennyrouter.com";
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 || "https://api.pennyrouter.com";
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
- || "https://api.pennyrouter.com";
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
- || "https://api.pennyrouter.com";
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)) {
@@ -4,15 +4,19 @@ import { configureClaudeCodeStatusline, restoreClaudeCodeStatusline } from "../s
4
4
  const CONFIG_PATH = "~/.claude/settings.json";
5
5
  const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
6
6
  const CONNECTOR_SETTING = "disableClaudeAiConnectors";
7
- // The slot values remain genuine Anthropic ids so Claude Code recognizes their actual context
8
- // windows (Haiku 200K; the 1M-capable models 1M) and sends the native id on the wire. Claude
9
- // Code separately supports *_MODEL_NAME and *_MODEL_DESCRIPTION for the picker, so Penny can
10
- // present a friendly alias without sacrificing model recognition.
7
+ // The slot values remain genuine Anthropic ids so Claude Code recognizes them and sends the
8
+ // native id on the wire. Claude Code separately supports *_MODEL_NAME and *_MODEL_DESCRIPTION
9
+ // for the picker, so Penny can present a friendly alias without sacrificing model recognition.
10
+ //
11
+ // The 1M-context variant is a `[1m]` suffix on the id — Claude Code does NOT infer a 1M window
12
+ // from a bare id, so a bare `claude-sonnet-5` gets 200K and auto-compacts around 150K. The
13
+ // suffix rides the wire; the gateway strips it for the upstream model and turns it into the
14
+ // `context-1m` beta header. Haiku has no 1M variant, so it stays bare.
11
15
  export const CLAUDE_CODE_MODELS = {
12
16
  haiku: "claude-haiku-4-5-20251001",
13
- sonnet: "claude-sonnet-5",
14
- opus: "claude-opus-5",
15
- fable: "claude-fable-5",
17
+ sonnet: "claude-sonnet-5[1m]",
18
+ opus: "claude-opus-5[1m]",
19
+ fable: "claude-fable-5[1m]",
16
20
  custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
17
21
  };
18
22
  const CLAUDE_CODE_MODEL_PRESENTATION = {
@@ -103,7 +103,10 @@ async function main() {
103
103
  assert.ok(!claude.args.includes("--plugin-dir"));
104
104
  assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
105
105
  assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
106
- assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5");
106
+ // 1M-capable slots carry the `[1m]` suffix; Haiku has no 1M variant and stays bare.
107
+ // Per-slot suffixes, not the global CLAUDE_CODE_MAX_CONTEXT_TOKENS override, which
108
+ // would wrongly hand Haiku a 1M window too.
109
+ assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "claude-opus-5[1m]");
107
110
  assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
108
111
  assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_NAME, "Haiku 4.5 (PennyRouter)");
109
112
  assert.equal(claude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL_DESCRIPTION, "Fast, economical Claude model · 200K context");
@@ -128,7 +131,7 @@ async function main() {
128
131
  });
129
132
  const persistedClaude = JSON.parse(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"));
130
133
  assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_HAIKU_MODEL, "claude-haiku-4-5-20251001");
131
- assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "claude-sonnet-5");
134
+ assert.equal(persistedClaude.env.ANTHROPIC_DEFAULT_SONNET_MODEL, "claude-sonnet-5[1m]");
132
135
  assert.equal("CLAUDE_CODE_MAX_CONTEXT_TOKENS" in persistedClaude.env, false);
133
136
 
134
137
  await removePennyHarness("claude-code");
@@ -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
- export function serverEntry(harness = "claude-code") {
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: { PENNYROUTER_MCP_HARNESS: harness },
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 = { PENNYROUTER_MCP_HARNESS = "${entry.env.PENNYROUTER_MCP_HARNESS}" }`,
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.
@@ -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
  }