pennyrouter 0.1.16 → 0.1.18
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 +19 -4
- package/package.json +2 -2
- package/src/cli.js +14 -3
- package/src/harnesses/claude-code.js +37 -6
- package/src/harnesses/claude-code.test.js +57 -0
- package/src/state.js +6 -1
- package/src/statusline.js +3 -2
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
|
|
91
|
-
`
|
|
92
|
-
|
|
93
|
-
|
|
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.
|
|
3
|
+
"version": "0.1.18",
|
|
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({
|
|
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({
|
|
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
|
|
87
|
-
// The gateway pulls the stored token per request. This avoids the
|
|
88
|
-
// on disk
|
|
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");
|
package/src/state.js
CHANGED
|
@@ -8,7 +8,12 @@ export function homeDir() {
|
|
|
8
8
|
|
|
9
9
|
export function expandHome(path) {
|
|
10
10
|
if (path === "~") return homeDir();
|
|
11
|
-
|
|
11
|
+
// Accept both "~/..." and Windows "~\\..." — compactHome preserves whatever
|
|
12
|
+
// separator the platform's absolute path used, so a manifest written on Windows
|
|
13
|
+
// stores config paths as "~\\.claude\\settings.json". Matching only "~/" here
|
|
14
|
+
// let those fall through unexpanded, so read/write hit a literal "~" directory
|
|
15
|
+
// in the cwd instead of the real home. join() normalizes separators.
|
|
16
|
+
if (path.startsWith("~/") || path.startsWith("~\\")) return join(homeDir(), path.slice(2));
|
|
12
17
|
return path;
|
|
13
18
|
}
|
|
14
19
|
|
package/src/statusline.js
CHANGED
|
@@ -187,10 +187,11 @@ if (lines.length === 0) {
|
|
|
187
187
|
lines[0] = lines[0].trimEnd() + " | " + penny;
|
|
188
188
|
}
|
|
189
189
|
// Ambient tip: the gateway rotates cache.tip on a slow clock (see tips.statusline_tip).
|
|
190
|
-
// Rendered as its own
|
|
190
|
+
// Rendered as its own soft light-green line: visible without competing with the brighter
|
|
191
|
+
// model/context status above it. Absent field
|
|
191
192
|
// (tips off, old gateway, bypass) renders nothing.
|
|
192
193
|
if (pennyInstalled && cache && cache.tip && !cache.bypass) {
|
|
193
|
-
lines.push("\\x1b[
|
|
194
|
+
lines.push("\\x1b[38;5;114m" + cache.tip + "\\x1b[0m");
|
|
194
195
|
}
|
|
195
196
|
console.log(lines.join("\\n"));
|
|
196
197
|
`;
|