pennyrouter 0.1.16 → 0.1.17

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/README.md CHANGED
@@ -31,6 +31,15 @@ login and configures PennyRouter as an OpenAI-authenticated Responses proxy. Cod
31
31
  to own and refresh the OpenAI credentials; PennyRouter stores only its existing `pr-` account
32
32
  key in the managed provider block. API-key Codex installs continue to use PennyRouter credits.
33
33
 
34
+ To point Codex directly at a PennyRouter gateway running on localhost port 8400:
35
+
36
+ ```bash
37
+ npx pennyrouter@latest install --harness codex --local
38
+ ```
39
+
40
+ Restart Codex after installation. Without `--local`, browser authorization supplies the normal
41
+ hosted gateway URL.
42
+
34
43
  ## Supported Tools
35
44
 
36
45
  - Claude Code
@@ -87,10 +96,16 @@ ant auth login
87
96
  npx pennyrouter auth anthropic
88
97
  ```
89
98
 
90
- The command updates Claude Code so `x-api-key` carries the PennyRouter key and
91
- `Authorization: Bearer ...` carries the Anthropic OAuth token. The gateway forwards that
92
- bearer token to Anthropic with the required OAuth beta header and does not deduct
93
- PennyRouter credit for those Anthropic-native upstream calls.
99
+ The command stores the Anthropic token encrypted at the gateway. Claude Code keeps only the
100
+ PennyRouter `pr-` bearer in its settings; the gateway retrieves and forwards the stored token
101
+ with the required OAuth beta header and does not deduct PennyRouter credit for successfully
102
+ served Anthropic-subscription calls.
103
+
104
+ PennyRouter also sets `disableClaudeAiConnectors: true` while its custom gateway is installed.
105
+ Claude Code cannot load claude.ai organization connectors when gateway authentication takes
106
+ precedence, and otherwise displays a persistent warning about that state. Making the disablement
107
+ explicit suppresses the warning; locally configured MCP servers are unaffected. Reinstall keeps
108
+ the original user value, and disable or uninstall restores it unless the user changed it later.
94
109
 
95
110
  If the stored token expires during a long-running session, refresh the token source and rerun
96
111
  `npx pennyrouter auth anthropic`.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.16",
3
+ "version": "0.1.17",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "start": "node ./bin/pennyrouter.js",
27
- "check": "node --check ./bin/pennyrouter.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/statusline.test.js && node src/harnesses/codex.test.js"
27
+ "check": "node --check ./bin/pennyrouter.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js"
28
28
  },
29
29
  "license": "MIT"
30
30
  }
package/src/cli.js CHANGED
@@ -192,10 +192,15 @@ async function authProvider(flags) {
192
192
  const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
193
193
  // Store the token encrypted server-side, then write a pr-key-only config.
194
194
  await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
195
+ const manifest = await loadManifest();
196
+ const existingRecord = (manifest.installations || []).find(
197
+ (candidate) => candidate.harness === "claude-code",
198
+ );
195
199
  const plan = await claudeCodeHarness.planInstall();
196
- const record = await claudeCodeHarness.installStoredToken({ apiKey: pennyKey, gatewayBaseUrl, plan });
200
+ const record = await claudeCodeHarness.installStoredToken({
201
+ apiKey: pennyKey, gatewayBaseUrl, plan, existingRecord,
202
+ });
197
203
 
198
- const manifest = await loadManifest();
199
204
  upsertManifestRecord(manifest, {
200
205
  ...record,
201
206
  anthropic_oauth: true,
@@ -352,6 +357,9 @@ async function install(flags) {
352
357
  await runHarnessJob(failures, harness.id, "install", async () => {
353
358
  const plan = await harness.planInstall();
354
359
  const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
360
+ const existingRecord = (manifest.installations || []).find(
361
+ (candidate) => candidate.harness === harness.id,
362
+ );
355
363
  let record;
356
364
  if (harness.id === "claude-code" && claudeAnthropicAuth) {
357
365
  // Store the Claude setup-token encrypted server-side, then write a config
@@ -362,7 +370,9 @@ async function install(flags) {
362
370
  apiKey: claim.api_key,
363
371
  token: claudeAnthropicAuth.token,
364
372
  });
365
- record = await harness.installStoredToken({ apiKey: claim.api_key, gatewayBaseUrl, plan });
373
+ record = await harness.installStoredToken({
374
+ apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
375
+ });
366
376
  record.anthropic_oauth = true;
367
377
  record.anthropic_token_stored = true;
368
378
  record.token_source = claudeAnthropicAuth.source;
@@ -373,6 +383,7 @@ async function install(flags) {
373
383
  // (which points at prod) — so a --local install serves through the local gateway.
374
384
  gatewayBaseUrl,
375
385
  plan,
386
+ existingRecord,
376
387
  });
377
388
  }
378
389
  records.push(record);
@@ -3,6 +3,7 @@ import { configureClaudeCodeStatusline, restoreClaudeCodeStatusline } from "../s
3
3
 
4
4
  const CONFIG_PATH = "~/.claude/settings.json";
5
5
  const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
6
+ const CONNECTOR_SETTING = "disableClaudeAiConnectors";
6
7
  // Values written into Claude Code's four model slots. CC displays a slot's VALUE verbatim in
7
8
  // its /model menu AND sends it as the request `model`, so each value is both the menu label
8
9
  // and the gateway's routing key. The three concrete Claude slots carry friendly PINNED labels
@@ -41,9 +42,10 @@ export const claudeCodeHarness = {
41
42
  };
42
43
  },
43
44
 
44
- async install({ apiKey, gatewayBaseUrl, plan }) {
45
+ async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
45
46
  const backupPath = await backupFile(CONFIG_PATH, "claude-code");
46
47
  const current = (await readJsonFile(CONFIG_PATH, null)) || {};
48
+ const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
47
49
  current.env ||= {};
48
50
  current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
49
51
  // Custom base URL: Claude Code authenticates with the auth token, not an
@@ -68,7 +70,9 @@ export const claudeCodeHarness = {
68
70
  installed_at: new Date().toISOString(),
69
71
  restart: "restart Claude Code",
70
72
  statusline: statusline.wrapped ? "wrapped-existing" : "installed",
73
+ claude_ai_connectors_before: claudeAiConnectorsBefore,
71
74
  changes: {
75
+ settings_keys: [CONNECTOR_SETTING],
72
76
  env_keys: [
73
77
  "ANTHROPIC_BASE_URL",
74
78
  "ANTHROPIC_AUTH_TOKEN",
@@ -83,12 +87,13 @@ export const claudeCodeHarness = {
83
87
 
84
88
  // Stored-token mode: the Anthropic setup-token was already POSTed to the gateway
85
89
  // and encrypted against the account (see cli.js storeAnthropicToken). Claude Code's
86
- // config therefore carries ONLY the pr- key + base URL — no ANTHROPIC_AUTH_TOKEN.
87
- // The gateway pulls the stored token per request. This avoids the plaintext token
88
- // on disk AND the dual-credential (api key + auth token) warnings Claude Code emits.
89
- async installStoredToken({ apiKey, gatewayBaseUrl, plan }) {
90
+ // config therefore carries only the pr- bearer + base URL, not the Anthropic token.
91
+ // The gateway pulls the stored token per request. This avoids putting the Anthropic
92
+ // credential on disk and avoids a dual-credential configuration.
93
+ async installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
90
94
  const backupPath = await backupFile(CONFIG_PATH, "claude-code");
91
95
  const current = (await readJsonFile(CONFIG_PATH, null)) || {};
96
+ const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
92
97
  current.env ||= {};
93
98
  current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
94
99
  current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
@@ -110,7 +115,9 @@ export const claudeCodeHarness = {
110
115
  installed_at: new Date().toISOString(),
111
116
  restart: "restart Claude Code",
112
117
  statusline: statusline.wrapped ? "wrapped-existing" : "installed",
118
+ claude_ai_connectors_before: claudeAiConnectorsBefore,
113
119
  changes: {
120
+ settings_keys: [CONNECTOR_SETTING],
114
121
  env_keys: [
115
122
  "ANTHROPIC_BASE_URL",
116
123
  "ANTHROPIC_AUTH_TOKEN",
@@ -123,9 +130,10 @@ export const claudeCodeHarness = {
123
130
  };
124
131
  },
125
132
 
126
- async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl }) {
133
+ async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl, existingRecord }) {
127
134
  const backupPath = await backupFile(CONFIG_PATH, "claude-code-anthropic-auth");
128
135
  const current = (await readJsonFile(CONFIG_PATH, null)) || {};
136
+ const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
129
137
  current.env ||= {};
130
138
  current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
131
139
  // Dual-auth mode: x-api-key authenticates the local user to PennyRouter; the
@@ -147,7 +155,9 @@ export const claudeCodeHarness = {
147
155
  installed_at: new Date().toISOString(),
148
156
  restart: "restart Claude Code",
149
157
  statusline: statusline.wrapped ? "wrapped-existing" : "installed",
158
+ claude_ai_connectors_before: claudeAiConnectorsBefore,
150
159
  changes: {
160
+ settings_keys: [CONNECTOR_SETTING],
151
161
  env_keys: [
152
162
  "ANTHROPIC_BASE_URL",
153
163
  "ANTHROPIC_API_KEY",
@@ -191,6 +201,7 @@ export const claudeCodeHarness = {
191
201
  if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
192
202
  }
193
203
  }
204
+ restoreClaudeAiConnectors(current, record);
194
205
  if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
195
206
  delete current["anthropic.baseURL"];
196
207
  }
@@ -204,6 +215,26 @@ export const claudeCodeHarness = {
204
215
  },
205
216
  };
206
217
 
218
+ export function disableClaudeAiConnectors(settings, existingRecord = null) {
219
+ const saved = existingRecord?.claude_ai_connectors_before;
220
+ const before = saved && typeof saved.present === "boolean"
221
+ ? { ...saved }
222
+ : Object.prototype.hasOwnProperty.call(settings, CONNECTOR_SETTING)
223
+ ? { present: true, value: settings[CONNECTOR_SETTING] }
224
+ : { present: false };
225
+ settings[CONNECTOR_SETTING] = true;
226
+ return before;
227
+ }
228
+
229
+ export function restoreClaudeAiConnectors(settings, record) {
230
+ const before = record?.claude_ai_connectors_before;
231
+ if (!before || typeof before.present !== "boolean") return;
232
+ // Respect a user edit made after installation. We only undo the value we manage.
233
+ if (settings[CONNECTOR_SETTING] !== true) return;
234
+ if (before.present) settings[CONNECTOR_SETTING] = before.value;
235
+ else delete settings[CONNECTOR_SETTING];
236
+ }
237
+
207
238
  function hasPennyRouterEnv(env) {
208
239
  if (!env || typeof env !== "object") return false;
209
240
  return (
@@ -0,0 +1,57 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import {
4
+ disableClaudeAiConnectors,
5
+ restoreClaudeAiConnectors,
6
+ } from "./claude-code.js";
7
+
8
+ function testAbsentSettingIsRemovedOnUninstall() {
9
+ const settings = {};
10
+ const before = disableClaudeAiConnectors(settings);
11
+
12
+ assert.equal(settings.disableClaudeAiConnectors, true);
13
+ assert.deepEqual(before, { present: false });
14
+
15
+ restoreClaudeAiConnectors(settings, { claude_ai_connectors_before: before });
16
+ assert.equal("disableClaudeAiConnectors" in settings, false);
17
+ }
18
+
19
+ function testExistingSettingIsRestored() {
20
+ const settings = { disableClaudeAiConnectors: false };
21
+ const before = disableClaudeAiConnectors(settings);
22
+
23
+ assert.equal(settings.disableClaudeAiConnectors, true);
24
+ assert.deepEqual(before, { present: true, value: false });
25
+
26
+ restoreClaudeAiConnectors(settings, { claude_ai_connectors_before: before });
27
+ assert.equal(settings.disableClaudeAiConnectors, false);
28
+ }
29
+
30
+ function testReinstallKeepsOriginalValue() {
31
+ const settings = {};
32
+ const firstBefore = disableClaudeAiConnectors(settings);
33
+ const secondBefore = disableClaudeAiConnectors(settings, {
34
+ claude_ai_connectors_before: firstBefore,
35
+ });
36
+
37
+ assert.deepEqual(secondBefore, { present: false });
38
+ restoreClaudeAiConnectors(settings, {
39
+ claude_ai_connectors_before: secondBefore,
40
+ });
41
+ assert.equal("disableClaudeAiConnectors" in settings, false);
42
+ }
43
+
44
+ function testUninstallPreservesLaterUserEdit() {
45
+ const settings = {};
46
+ const before = disableClaudeAiConnectors(settings);
47
+ settings.disableClaudeAiConnectors = false;
48
+
49
+ restoreClaudeAiConnectors(settings, { claude_ai_connectors_before: before });
50
+ assert.equal(settings.disableClaudeAiConnectors, false);
51
+ }
52
+
53
+ testAbsentSettingIsRemovedOnUninstall();
54
+ testExistingSettingIsRestored();
55
+ testReinstallKeepsOriginalValue();
56
+ testUninstallPreservesLaterUserEdit();
57
+ console.log("claude-code.test.js: all checks passed");