pennyrouter 0.1.20 → 0.1.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 +2 -2
- package/src/cli.js +41 -1
- package/src/session.js +53 -0
- package/src/session.test.js +32 -0
- package/src/statusline.js +3 -4
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.21",
|
|
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/claude-code.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/session.test.js && 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
|
@@ -20,6 +20,7 @@ import {
|
|
|
20
20
|
forgetAnthropicToken,
|
|
21
21
|
maskKey,
|
|
22
22
|
pollForKey,
|
|
23
|
+
probeClaudeCodeGateway,
|
|
23
24
|
sleep,
|
|
24
25
|
storeAnthropicToken,
|
|
25
26
|
} from "./session.js";
|
|
@@ -98,6 +99,7 @@ function parseArgs(argv) {
|
|
|
98
99
|
else if (arg === "--yes" || arg === "-y") flags.yes = true;
|
|
99
100
|
else if (arg === "--all") flags.all = true;
|
|
100
101
|
else if (arg === "--dry-run") flags.dryRun = true;
|
|
102
|
+
else if (arg === "--allow-running-claude") flags.allowRunningClaude = true;
|
|
101
103
|
else if (arg === "--no-browser") flags.noBrowser = true;
|
|
102
104
|
else if (arg === "--existing-account") flags.existingAccount = true;
|
|
103
105
|
else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
|
|
@@ -174,6 +176,23 @@ async function authProvider(flags) {
|
|
|
174
176
|
return;
|
|
175
177
|
}
|
|
176
178
|
|
|
179
|
+
// Claude Code's env settings are user-global and may be reloaded by already-open
|
|
180
|
+
// processes. Switching the base URL underneath an in-flight agent can transplant a
|
|
181
|
+
// multi-hundred-thousand-token tool loop into PennyRouter without a fresh human action.
|
|
182
|
+
// Make that blast radius opt-in instead of surprising every new installer.
|
|
183
|
+
if (selected.some((harness) => harness.id === "claude-code")
|
|
184
|
+
&& !flags.allowRunningClaude) {
|
|
185
|
+
const running = runningClaudeCodeProcesses();
|
|
186
|
+
if (running.length > 0) {
|
|
187
|
+
throw new Error(
|
|
188
|
+
"Claude Code is currently running. Close all Claude Code sessions, then run install again. "
|
|
189
|
+
+ "PennyRouter changes Claude's user-global API routing, so continuing could redirect "
|
|
190
|
+
+ "an open session's full transcript. If you intentionally accept that risk, pass "
|
|
191
|
+
+ "--allow-running-claude.\nDetected:\n " + running.slice(0, 5).join("\n "),
|
|
192
|
+
);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
177
196
|
const pennyKey = flags.pennyKey || await readClaudeCodePennyRouterKey();
|
|
178
197
|
if (!pennyKey) {
|
|
179
198
|
throw new Error(
|
|
@@ -370,6 +389,8 @@ async function install(flags) {
|
|
|
370
389
|
apiKey: claim.api_key,
|
|
371
390
|
token: claudeAnthropicAuth.token,
|
|
372
391
|
});
|
|
392
|
+
console.log("Verifying modern Claude Code compatibility before changing global settings...");
|
|
393
|
+
await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
|
|
373
394
|
record = await harness.installStoredToken({
|
|
374
395
|
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
375
396
|
});
|
|
@@ -377,6 +398,10 @@ async function install(flags) {
|
|
|
377
398
|
record.anthropic_token_stored = true;
|
|
378
399
|
record.token_source = claudeAnthropicAuth.source;
|
|
379
400
|
} else {
|
|
401
|
+
if (harness.id === "claude-code") {
|
|
402
|
+
console.log("Verifying modern Claude Code compatibility before changing global settings...");
|
|
403
|
+
await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
|
|
404
|
+
}
|
|
380
405
|
record = await harness.install({
|
|
381
406
|
apiKey: claim.api_key,
|
|
382
407
|
// An explicit --local / --gateway-base-url overrides the URL the server hands back
|
|
@@ -1058,11 +1083,26 @@ function openBrowser(url) {
|
|
|
1058
1083
|
spawnSync(command, args, { stdio: "ignore", detached: true });
|
|
1059
1084
|
}
|
|
1060
1085
|
|
|
1086
|
+
function runningClaudeCodeProcesses() {
|
|
1087
|
+
if (process.platform === "win32") {
|
|
1088
|
+
const result = spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8" });
|
|
1089
|
+
if (result.status !== 0) return [];
|
|
1090
|
+
return String(result.stdout || "").split(/\r?\n/)
|
|
1091
|
+
.filter((line) => /^"claude(?:\.exe)?"/i.test(line.trim()));
|
|
1092
|
+
}
|
|
1093
|
+
const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
|
|
1094
|
+
if (result.status !== 0) return [];
|
|
1095
|
+
return String(result.stdout || "").split(/\r?\n/)
|
|
1096
|
+
.map((line) => line.trim())
|
|
1097
|
+
// Case-sensitive on purpose: match the Claude Code executable, not Claude.app.
|
|
1098
|
+
.filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1061
1101
|
function printHelp() {
|
|
1062
1102
|
console.log(`PennyRouter CLI
|
|
1063
1103
|
|
|
1064
1104
|
Usage:
|
|
1065
|
-
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]]
|
|
1105
|
+
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]] [--allow-running-claude]
|
|
1066
1106
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
1067
1107
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
1068
1108
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
package/src/session.js
CHANGED
|
@@ -71,6 +71,59 @@ export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
|
|
|
71
71
|
return response.json();
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Exercise the exact modern Claude Code request feature that has historically broken at
|
|
75
|
+
// proxy boundaries BEFORE changing ~/.claude/settings.json (a user-global redirect). This
|
|
76
|
+
// is intentionally a real, tiny Messages call: a generic /health check cannot prove that
|
|
77
|
+
// Anthropic beta headers survive the gateway's subscription/native relay.
|
|
78
|
+
export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
|
|
79
|
+
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/messages?beta=true`;
|
|
80
|
+
const controller = new AbortController();
|
|
81
|
+
const timer = setTimeout(() => controller.abort(), 20_000);
|
|
82
|
+
let response;
|
|
83
|
+
try {
|
|
84
|
+
response = await fetch(url, {
|
|
85
|
+
method: "POST",
|
|
86
|
+
signal: controller.signal,
|
|
87
|
+
headers: {
|
|
88
|
+
"content-type": "application/json",
|
|
89
|
+
authorization: `Bearer ${apiKey}`,
|
|
90
|
+
"anthropic-beta": "compact-2026-01-12",
|
|
91
|
+
},
|
|
92
|
+
body: JSON.stringify({
|
|
93
|
+
model: "Penny Sonnet",
|
|
94
|
+
max_tokens: 16,
|
|
95
|
+
stream: false,
|
|
96
|
+
messages: [{ role: "user", content: "Reply OK." }],
|
|
97
|
+
tools: [{
|
|
98
|
+
name: "pennyrouter_install_probe",
|
|
99
|
+
description: "No-op compatibility probe; do not call it.",
|
|
100
|
+
input_schema: { type: "object", properties: {} },
|
|
101
|
+
}],
|
|
102
|
+
context_management: { edits: [{ type: "compact_20260112" }] },
|
|
103
|
+
}),
|
|
104
|
+
});
|
|
105
|
+
} catch (error) {
|
|
106
|
+
if (error?.name === "AbortError") {
|
|
107
|
+
throw new Error("Gateway compatibility check timed out; no Claude Code settings were changed.");
|
|
108
|
+
}
|
|
109
|
+
throw new Error(`Gateway compatibility check failed; no Claude Code settings were changed: ${error?.message || error}`);
|
|
110
|
+
} finally {
|
|
111
|
+
clearTimeout(timer);
|
|
112
|
+
}
|
|
113
|
+
if (!response.ok) {
|
|
114
|
+
let detail = "";
|
|
115
|
+
try {
|
|
116
|
+
detail = (await response.text()).slice(0, 500).replace(/\s+/g, " ").trim();
|
|
117
|
+
} catch {
|
|
118
|
+
// Status alone is enough for a safe failure.
|
|
119
|
+
}
|
|
120
|
+
throw new Error(
|
|
121
|
+
`Gateway compatibility check failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}. No Claude Code settings were changed.`,
|
|
122
|
+
);
|
|
123
|
+
}
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
|
|
74
127
|
// Best-effort "forget my Claude token" on uninstall. Never throws — a cleanup that
|
|
75
128
|
// can't reach the network shouldn't block removing the local integration.
|
|
76
129
|
export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { probeClaudeCodeGateway } from "./session.js";
|
|
3
|
+
|
|
4
|
+
const originalFetch = globalThis.fetch;
|
|
5
|
+
|
|
6
|
+
try {
|
|
7
|
+
let seen;
|
|
8
|
+
globalThis.fetch = async (url, options) => {
|
|
9
|
+
seen = { url, options };
|
|
10
|
+
return { ok: true, status: 200 };
|
|
11
|
+
};
|
|
12
|
+
assert.equal(await probeClaudeCodeGateway({
|
|
13
|
+
gatewayBaseUrl: "https://gateway.example/", apiKey: "pr-test",
|
|
14
|
+
}), true);
|
|
15
|
+
assert.equal(seen.url, "https://gateway.example/v1/messages?beta=true");
|
|
16
|
+
assert.equal(seen.options.headers.authorization, "Bearer pr-test");
|
|
17
|
+
assert.equal(seen.options.headers["anthropic-beta"], "compact-2026-01-12");
|
|
18
|
+
const body = JSON.parse(seen.options.body);
|
|
19
|
+
assert.equal(body.context_management.edits[0].type, "compact_20260112");
|
|
20
|
+
assert.equal(body.tools[0].name, "pennyrouter_install_probe");
|
|
21
|
+
|
|
22
|
+
globalThis.fetch = async () => ({
|
|
23
|
+
ok: false, status: 502, text: async () => "context_management rejected",
|
|
24
|
+
});
|
|
25
|
+
await assert.rejects(
|
|
26
|
+
probeClaudeCodeGateway({ gatewayBaseUrl: "https://gateway.example", apiKey: "pr-test" }),
|
|
27
|
+
/HTTP 502.*No Claude Code settings were changed/,
|
|
28
|
+
);
|
|
29
|
+
} finally {
|
|
30
|
+
globalThis.fetch = originalFetch;
|
|
31
|
+
}
|
|
32
|
+
|
package/src/statusline.js
CHANGED
|
@@ -153,10 +153,9 @@ function authSuffix(mode) {
|
|
|
153
153
|
return mode === "sub" || mode === "byok" ? " (" + mode + ")" : "";
|
|
154
154
|
}
|
|
155
155
|
|
|
156
|
-
// Teammate badges (@penny ask)
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
// back to you. Both absent on an older gateway, which renders nothing.
|
|
156
|
+
// Teammate badges (@penny ask): 📨 questions waiting on you, 📬 answers that came back.
|
|
157
|
+
// Read from the structured fields rather than the gateway's prebuilt \`label\`, since this
|
|
158
|
+
// line is composed here. Absent on an older gateway, which renders nothing.
|
|
160
159
|
function askBadges(c) {
|
|
161
160
|
if (!c) return "";
|
|
162
161
|
let out = "";
|