pennyrouter 0.2.25 → 0.2.26
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 +96 -10
- package/src/cli.test.js +73 -0
- package/src/launch.js +5 -0
- package/src/session.js +8 -4
- package/src/statusline.js +53 -4
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
APP_URL,
|
|
27
27
|
createCliSession,
|
|
28
28
|
forgetAnthropicToken,
|
|
29
|
+
forgetUpstreamCredential,
|
|
29
30
|
maskKey,
|
|
30
31
|
pollForKey,
|
|
31
32
|
probeClaudeCodeGateway,
|
|
@@ -72,6 +73,7 @@ const CLAUDE_PROVIDER_OPTIONS = [
|
|
|
72
73
|
{ id: "subscription", name: "Claude subscription" },
|
|
73
74
|
{ id: "anthropic-api", name: "Anthropic API key" },
|
|
74
75
|
{ id: "openrouter", name: "OpenRouter API key" },
|
|
76
|
+
{ id: "anthropic-proxy", name: "Other proxy (custom base URL)" },
|
|
75
77
|
];
|
|
76
78
|
const CODEX_PROVIDER_OPTIONS = [
|
|
77
79
|
{ id: "subscription", name: "ChatGPT subscription" },
|
|
@@ -169,6 +171,10 @@ function parseArgs(argv) {
|
|
|
169
171
|
else if (arg === "--openai-api-key") flags.openaiApiKey = rest[++i] || "";
|
|
170
172
|
else if (arg.startsWith("--openai-api-key=")) flags.openaiApiKey = arg.slice("--openai-api-key=".length);
|
|
171
173
|
else if (arg === "--openrouter-api-key") flags.openrouterApiKey = rest[++i] || "";
|
|
174
|
+
else if (arg === "--proxy-base-url") flags.proxyBaseUrl = rest[++i] || "";
|
|
175
|
+
else if (arg.startsWith("--proxy-base-url=")) flags.proxyBaseUrl = arg.slice("--proxy-base-url=".length);
|
|
176
|
+
else if (arg === "--proxy-api-key") flags.proxyApiKey = rest[++i] || "";
|
|
177
|
+
else if (arg.startsWith("--proxy-api-key=")) flags.proxyApiKey = arg.slice("--proxy-api-key=".length);
|
|
172
178
|
else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
|
|
173
179
|
else if (arg === "--harness") flags.harness = rest[++i] || "";
|
|
174
180
|
else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
|
|
@@ -230,6 +236,10 @@ function applyConfigFile(flags) {
|
|
|
230
236
|
if (codexFunding.has("openai-api") && flags.openaiApiKey === undefined) {
|
|
231
237
|
flags.openaiApiKey = config.openaiApiKey || "";
|
|
232
238
|
}
|
|
239
|
+
if (claudeFunding.has("anthropic-proxy")) {
|
|
240
|
+
if (flags.proxyBaseUrl === undefined) flags.proxyBaseUrl = config.proxyBaseUrl || "";
|
|
241
|
+
if (flags.proxyApiKey === undefined) flags.proxyApiKey = config.proxyApiKey || "";
|
|
242
|
+
}
|
|
233
243
|
|
|
234
244
|
flags.yes = true;
|
|
235
245
|
flags.fromConfig = true;
|
|
@@ -284,11 +294,21 @@ async function authProvider(flags) {
|
|
|
284
294
|
"No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
|
|
285
295
|
);
|
|
286
296
|
}
|
|
287
|
-
|
|
297
|
+
// Resolve against the INSTALLED config before falling back to the prod default.
|
|
298
|
+
// This is a destructive call against whichever gateway it reaches, so defaulting to
|
|
299
|
+
// production while the user is working against a local gateway would silently delete
|
|
300
|
+
// the wrong account's token (and leave the local one in place, looking like a no-op).
|
|
301
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
302
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl
|
|
303
|
+
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
304
|
+
|| launchConfig.gateway_base_url
|
|
305
|
+
|| globalAuth.gatewayBaseUrl
|
|
306
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
288
307
|
if (flags.dryRun) {
|
|
289
308
|
console.log(`Will delete stored Anthropic token from gateway: ${gatewayBaseUrl}`);
|
|
290
309
|
return;
|
|
291
310
|
}
|
|
311
|
+
console.log(`Gateway: ${gatewayBaseUrl}`);
|
|
292
312
|
const ok = await forgetAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey });
|
|
293
313
|
if (ok) {
|
|
294
314
|
console.log("Successfully forgot stored Anthropic token on server.");
|
|
@@ -579,6 +599,7 @@ export async function install(flags = {}) {
|
|
|
579
599
|
apiKey: claim.api_key,
|
|
580
600
|
provider: credential.provider,
|
|
581
601
|
token: credential.token,
|
|
602
|
+
metadata: credential.metadata,
|
|
582
603
|
});
|
|
583
604
|
}
|
|
584
605
|
}
|
|
@@ -669,6 +690,11 @@ export async function install(flags = {}) {
|
|
|
669
690
|
record.user_funded_provider = harnessNeededAuth
|
|
670
691
|
? (funding.claudeCredentialProvider || existingRecord?.user_funded_provider)
|
|
671
692
|
: existingRecord?.user_funded_provider;
|
|
693
|
+
// Host only, never the key — the key lives encrypted on the gateway.
|
|
694
|
+
const proxyBaseUrl = harnessNeededAuth
|
|
695
|
+
? (funding.claudeProxyBaseUrl ?? existingRecord?.proxy_base_url ?? null)
|
|
696
|
+
: (existingRecord?.proxy_base_url ?? null);
|
|
697
|
+
if (proxyBaseUrl) record.proxy_base_url = proxyBaseUrl;
|
|
672
698
|
}
|
|
673
699
|
if (harness.id === "codex") {
|
|
674
700
|
record.user_funded_provider = harnessNeededAuth
|
|
@@ -1055,14 +1081,19 @@ async function selectClaudeFundingProviders(flags, selected) {
|
|
|
1055
1081
|
|| process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
|
|
1056
1082
|
if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
|
|
1057
1083
|
if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
|
|
1084
|
+
if ((flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL)
|
|
1085
|
+
&& (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
|
|
1086
|
+
supplied.add("anthropic-proxy");
|
|
1087
|
+
}
|
|
1058
1088
|
|
|
1059
1089
|
// Explicit flags are the automation selector. Interactive installs always default to the
|
|
1060
1090
|
// subscription alone; environment keys are used if the user checks their provider.
|
|
1061
1091
|
const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
|
|
1062
|
-
|| flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
|
|
1092
|
+
|| flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
|
|
1093
|
+
|| flags.proxyBaseUrl || flags.proxyApiKey;
|
|
1063
1094
|
if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
|
|
1064
1095
|
|
|
1065
|
-
console.log("PennyRouter can use your Claude subscription, an Anthropic API key,
|
|
1096
|
+
console.log("PennyRouter can use your Claude subscription, an Anthropic API key, an OpenRouter API key, or your own Anthropic-compatible proxy to run Claude Code.");
|
|
1066
1097
|
console.log("Choose one or more providers. PennyRouter will try them in order when available.");
|
|
1067
1098
|
return promptFundingProviderCheckboxes(
|
|
1068
1099
|
CLAUDE_PROVIDER_OPTIONS,
|
|
@@ -1165,6 +1196,12 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1165
1196
|
let openrouterApiKey = String(
|
|
1166
1197
|
flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
|
|
1167
1198
|
).trim();
|
|
1199
|
+
let proxyBaseUrl = String(
|
|
1200
|
+
flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL || "",
|
|
1201
|
+
).trim();
|
|
1202
|
+
let proxyApiKey = String(
|
|
1203
|
+
flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY || "",
|
|
1204
|
+
).trim();
|
|
1168
1205
|
const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
|
|
1169
1206
|
? await detectCodexChatgptAuth()
|
|
1170
1207
|
: false;
|
|
@@ -1177,6 +1214,19 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1177
1214
|
if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
|
|
1178
1215
|
openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
|
|
1179
1216
|
}
|
|
1217
|
+
if (wantsClaude && claudeProviders.has("anthropic-proxy") && !(proxyBaseUrl && proxyApiKey)) {
|
|
1218
|
+
if (!interactive) {
|
|
1219
|
+
throw new Error(
|
|
1220
|
+
"Other proxy was selected, but --proxy-base-url and --proxy-api-key were not both provided.",
|
|
1221
|
+
);
|
|
1222
|
+
}
|
|
1223
|
+
// The URL is not a secret and is easy to mistype, so it echoes; the key does not.
|
|
1224
|
+
if (!proxyBaseUrl) proxyBaseUrl = await promptText("Proxy base URL (e.g. https://proxy.example.com): ");
|
|
1225
|
+
if (!proxyApiKey) proxyApiKey = (await promptSecretish("Proxy API key: ")).trim();
|
|
1226
|
+
if (!proxyBaseUrl || !proxyApiKey) {
|
|
1227
|
+
throw new Error("Other proxy requires both a base URL and an API key.");
|
|
1228
|
+
}
|
|
1229
|
+
}
|
|
1180
1230
|
if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
|
|
1181
1231
|
throw new Error(
|
|
1182
1232
|
"ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
|
|
@@ -1191,7 +1241,9 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1191
1241
|
openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
|
|
1192
1242
|
}
|
|
1193
1243
|
|
|
1194
|
-
|
|
1244
|
+
const claudeProxyConfigured = Boolean(proxyBaseUrl && proxyApiKey);
|
|
1245
|
+
if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey
|
|
1246
|
+
&& !claudeProxyConfigured) {
|
|
1195
1247
|
if (!interactive) {
|
|
1196
1248
|
throw new Error(
|
|
1197
1249
|
"Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
|
|
@@ -1244,13 +1296,23 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1244
1296
|
if ((wantsClaude || wantsCodex) && openrouterApiKey) {
|
|
1245
1297
|
credentials.push({ provider: "openrouter", token: openrouterApiKey });
|
|
1246
1298
|
}
|
|
1299
|
+
if (wantsClaude && claudeProxyConfigured) {
|
|
1300
|
+
credentials.push({
|
|
1301
|
+
provider: "anthropic_proxy",
|
|
1302
|
+
token: proxyApiKey,
|
|
1303
|
+
metadata: { base_url: proxyBaseUrl },
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1247
1306
|
|
|
1248
1307
|
return {
|
|
1249
1308
|
credentials,
|
|
1250
1309
|
codexSubscriptionAuth,
|
|
1251
1310
|
claudeCredentialProvider: claudeAnthropicAuth
|
|
1252
1311
|
? "claude-subscription"
|
|
1253
|
-
: anthropicApiKey ? "anthropic-api"
|
|
1312
|
+
: anthropicApiKey ? "anthropic-api"
|
|
1313
|
+
: claudeProxyConfigured ? "anthropic-proxy"
|
|
1314
|
+
: openrouterApiKey ? "openrouter" : null,
|
|
1315
|
+
claudeProxyBaseUrl: claudeProxyConfigured ? proxyBaseUrl : null,
|
|
1254
1316
|
codexCredentialProvider: codexSubscriptionAuth
|
|
1255
1317
|
? "chatgpt-subscription"
|
|
1256
1318
|
: openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
|
|
@@ -1423,7 +1485,7 @@ async function uninstall(flags) {
|
|
|
1423
1485
|
const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
1424
1486
|
if (flags.forgetToken) {
|
|
1425
1487
|
await forgetStoredAnthropicToken(record);
|
|
1426
|
-
} else if (record.anthropic_token_stored) {
|
|
1488
|
+
} else if (record.anthropic_token_stored || record.proxy_base_url) {
|
|
1427
1489
|
anthropicTokenNotice = true;
|
|
1428
1490
|
}
|
|
1429
1491
|
if (record.launch_available) await removePennyHarness(record.harness);
|
|
@@ -1443,7 +1505,7 @@ async function uninstall(flags) {
|
|
|
1443
1505
|
await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
1444
1506
|
if (flags.forgetToken) {
|
|
1445
1507
|
await forgetStoredAnthropicToken(record);
|
|
1446
|
-
} else if (record.anthropic_token_stored) {
|
|
1508
|
+
} else if (record.anthropic_token_stored || record.proxy_base_url) {
|
|
1447
1509
|
anthropicTokenNotice = true;
|
|
1448
1510
|
}
|
|
1449
1511
|
if (record.launch_available) await removePennyHarness(record.harness);
|
|
@@ -1537,6 +1599,10 @@ async function status() {
|
|
|
1537
1599
|
if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
|
|
1538
1600
|
if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
|
|
1539
1601
|
if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
|
|
1602
|
+
if (record.proxy_base_url) {
|
|
1603
|
+
const note = record.anthropic_oauth ? " (subscription takes precedence)" : "";
|
|
1604
|
+
console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
|
|
1605
|
+
}
|
|
1540
1606
|
if (record.launch_available) {
|
|
1541
1607
|
const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
|
|
1542
1608
|
console.log(` command: ${command}`);
|
|
@@ -1847,14 +1913,24 @@ function formatError(error) {
|
|
|
1847
1913
|
// live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
|
|
1848
1914
|
// must not be blocked by a network hiccup; the record is going away regardless.
|
|
1849
1915
|
async function forgetStoredAnthropicToken(record) {
|
|
1850
|
-
if (record.harness !== "claude-code"
|
|
1916
|
+
if (record.harness !== "claude-code") return;
|
|
1917
|
+
if (!record.anthropic_token_stored && !record.proxy_base_url) return;
|
|
1851
1918
|
try {
|
|
1852
1919
|
const launchConfig = await loadPennyConfig();
|
|
1853
1920
|
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
1854
1921
|
const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
|
|
1855
1922
|
const apiKey = launchConfig.api_key || globalAuth.apiKey;
|
|
1856
1923
|
if (!gatewayBaseUrl || !apiKey) return;
|
|
1857
|
-
|
|
1924
|
+
if (record.anthropic_token_stored) {
|
|
1925
|
+
await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
|
|
1926
|
+
}
|
|
1927
|
+
// The proxy key is stored server-side exactly like the subscription token, so the
|
|
1928
|
+
// same "forget my credentials" gesture has to clear it too.
|
|
1929
|
+
if (record.proxy_base_url) {
|
|
1930
|
+
await forgetUpstreamCredential({
|
|
1931
|
+
gatewayBaseUrl, apiKey, provider: "anthropic_proxy",
|
|
1932
|
+
});
|
|
1933
|
+
}
|
|
1858
1934
|
} catch {
|
|
1859
1935
|
// swallow — see note above
|
|
1860
1936
|
}
|
|
@@ -1867,6 +1943,15 @@ async function confirm(question) {
|
|
|
1867
1943
|
return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
|
|
1868
1944
|
}
|
|
1869
1945
|
|
|
1946
|
+
// Visible-echo input, for values that are not secrets. A mistyped proxy URL is a
|
|
1947
|
+
// confusing failure much later, so the user gets to see what they typed.
|
|
1948
|
+
async function promptText(question) {
|
|
1949
|
+
const rl = createInterface({ input, output });
|
|
1950
|
+
const answer = await rl.question(question);
|
|
1951
|
+
rl.close();
|
|
1952
|
+
return answer.trim();
|
|
1953
|
+
}
|
|
1954
|
+
|
|
1870
1955
|
async function promptSecretish(question) {
|
|
1871
1956
|
if (!input.isTTY || !output.isTTY) {
|
|
1872
1957
|
const rl = createInterface({ input, output });
|
|
@@ -1986,7 +2071,7 @@ function printHelp() {
|
|
|
1986
2071
|
console.log(`PennyRouter CLI
|
|
1987
2072
|
|
|
1988
2073
|
Usage:
|
|
1989
|
-
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--local[=URL]|--prod] [--config PATH] [--allow-running-harnesses]
|
|
2074
|
+
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--local[=URL]|--prod] [--config PATH] [--allow-running-harnesses]
|
|
1990
2075
|
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp] [--allow-running-harnesses]
|
|
1991
2076
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
1992
2077
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
@@ -2023,6 +2108,7 @@ Examples:
|
|
|
2023
2108
|
npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
|
|
2024
2109
|
npx pennyrouter install --harness claude-code --anthropic-api-key <key>
|
|
2025
2110
|
npx pennyrouter install --harness codex --openai-api-key <key>
|
|
2111
|
+
npx pennyrouter install --harness claude-code --proxy-base-url https://proxy.example.com --proxy-api-key <key>
|
|
2026
2112
|
npx pennyrouter install --openrouter-api-key <key>
|
|
2027
2113
|
npx pennyrouter auth anthropic
|
|
2028
2114
|
npx pennyrouter install --all
|
package/src/cli.test.js
CHANGED
|
@@ -121,3 +121,76 @@ test("harnessFundingChanged: no requested funding for an existing harness is not
|
|
|
121
121
|
};
|
|
122
122
|
assert.equal(harnessFundingChanged({}, claudeHarness, manifest), false);
|
|
123
123
|
});
|
|
124
|
+
|
|
125
|
+
// --- Other proxy (user-supplied Anthropic-compatible base URL) ----------------
|
|
126
|
+
|
|
127
|
+
test("--proxy-base-url and --proxy-api-key parse in both forms", async () => {
|
|
128
|
+
for (const args of [
|
|
129
|
+
["--proxy-base-url", "https://proxy.example.com", "--proxy-api-key", "k"],
|
|
130
|
+
["--proxy-base-url=https://proxy.example.com", "--proxy-api-key=k"],
|
|
131
|
+
]) {
|
|
132
|
+
const lines = await captureLogs(() => main([
|
|
133
|
+
"install", "--harness", "claude-code", "--dry-run", ...args,
|
|
134
|
+
]));
|
|
135
|
+
assert.ok(lines.length > 0);
|
|
136
|
+
}
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
test("--config maps anthropic-proxy funding to the proxy flags", async () => {
|
|
140
|
+
await withConfigFile(
|
|
141
|
+
{
|
|
142
|
+
harnesses: ["claude-code"],
|
|
143
|
+
claudeFunding: ["anthropic-proxy"],
|
|
144
|
+
proxyBaseUrl: "https://proxy.example.com",
|
|
145
|
+
proxyApiKey: "secret-key",
|
|
146
|
+
},
|
|
147
|
+
async (path) => {
|
|
148
|
+
const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
|
|
149
|
+
assert.ok(lines.some((line) => line.includes("Claude Code")));
|
|
150
|
+
// The key must never be echoed to the terminal.
|
|
151
|
+
assert.ok(!lines.some((line) => line.includes("secret-key")));
|
|
152
|
+
},
|
|
153
|
+
);
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
test("harnessFundingChanged sees a switch to the proxy as a change", () => {
|
|
157
|
+
const manifest = {
|
|
158
|
+
installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
|
|
159
|
+
};
|
|
160
|
+
const changed = harnessFundingChanged(
|
|
161
|
+
{ configClaudeFunding: new Set(["anthropic-proxy"]) },
|
|
162
|
+
{ id: "claude-code" },
|
|
163
|
+
manifest,
|
|
164
|
+
);
|
|
165
|
+
assert.equal(changed, true);
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
test("harnessFundingChanged sees an unchanged proxy install as unchanged", () => {
|
|
169
|
+
const manifest = {
|
|
170
|
+
installations: [{ harness: "claude-code", user_funded_provider: "anthropic-proxy" }],
|
|
171
|
+
};
|
|
172
|
+
const changed = harnessFundingChanged(
|
|
173
|
+
{ configClaudeFunding: new Set(["anthropic-proxy"]) },
|
|
174
|
+
{ id: "claude-code" },
|
|
175
|
+
manifest,
|
|
176
|
+
);
|
|
177
|
+
assert.equal(changed, false);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
test("auth anthropic --forget targets the installed gateway, not prod", async () => {
|
|
181
|
+
// Regression: this defaulted to https://api.pennyrouter.com even when the machine was
|
|
182
|
+
// installed against a local gateway, silently deleting the wrong account's token.
|
|
183
|
+
const lines = await captureLogs(() => main([
|
|
184
|
+
"auth", "anthropic", "--forget", "--dry-run",
|
|
185
|
+
"--penny-key", "pr-test", "--gateway-base-url", "http://localhost:8400",
|
|
186
|
+
]));
|
|
187
|
+
assert.ok(lines.some((line) => line.includes("http://localhost:8400")));
|
|
188
|
+
assert.ok(!lines.some((line) => line.includes("api.pennyrouter.com")));
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
test("auth anthropic --forget honors --local", async () => {
|
|
192
|
+
const lines = await captureLogs(() => main([
|
|
193
|
+
"auth", "anthropic", "--forget", "--dry-run", "--local", "--penny-key", "pr-test",
|
|
194
|
+
]));
|
|
195
|
+
assert.ok(lines.some((line) => line.includes("localhost:8400")));
|
|
196
|
+
});
|
package/src/launch.js
CHANGED
|
@@ -438,6 +438,11 @@ export function buildPennyInvocation(harness, args, config) {
|
|
|
438
438
|
if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = upgradeClaudeCodeModelValue(models.sonnet);
|
|
439
439
|
if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = upgradeClaudeCodeModelValue(models.opus);
|
|
440
440
|
if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = upgradeClaudeCodeModelValue(models.fable);
|
|
441
|
+
if (models.custom) {
|
|
442
|
+
env.ANTHROPIC_CUSTOM_MODEL_OPTION = upgradeClaudeCodeModelValue(models.custom);
|
|
443
|
+
if (models.custom_name) env.ANTHROPIC_CUSTOM_MODEL_OPTION_NAME = models.custom_name;
|
|
444
|
+
if (models.custom_description) env.ANTHROPIC_CUSTOM_MODEL_OPTION_DESCRIPTION = models.custom_description;
|
|
445
|
+
}
|
|
441
446
|
writeClaudeCodeModelPresentation(env);
|
|
442
447
|
// Per-model limits must come from Claude Code's recognized native model ids. This
|
|
443
448
|
// global override would incorrectly give Haiku the 1M window.
|
package/src/session.js
CHANGED
|
@@ -49,7 +49,7 @@ export function sleep(ms) {
|
|
|
49
49
|
// Encrypt and store a user-owned upstream credential against the account server-side,
|
|
50
50
|
// so the pr- key alone unlocks the selected harness and the provider secret is never
|
|
51
51
|
// written to harness configuration.
|
|
52
|
-
export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider, token }) {
|
|
52
|
+
export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider, token, metadata }) {
|
|
53
53
|
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials`;
|
|
54
54
|
const response = await fetch(url, {
|
|
55
55
|
method: "POST",
|
|
@@ -57,7 +57,7 @@ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider
|
|
|
57
57
|
"content-type": "application/json",
|
|
58
58
|
authorization: `Bearer ${apiKey}`,
|
|
59
59
|
},
|
|
60
|
-
body: JSON.stringify({ provider, token }),
|
|
60
|
+
body: JSON.stringify({ provider, token, ...(metadata ? { metadata } : {}) }),
|
|
61
61
|
});
|
|
62
62
|
if (!response.ok) {
|
|
63
63
|
let detail = "";
|
|
@@ -135,10 +135,10 @@ export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
|
|
|
135
135
|
|
|
136
136
|
// Best-effort "forget my Claude token" on uninstall. Never throws — a cleanup that
|
|
137
137
|
// can't reach the network shouldn't block removing the local integration.
|
|
138
|
-
export async function
|
|
138
|
+
export async function forgetUpstreamCredential({ gatewayBaseUrl, apiKey, provider }) {
|
|
139
139
|
if (!gatewayBaseUrl || !apiKey) return false;
|
|
140
140
|
try {
|
|
141
|
-
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials
|
|
141
|
+
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/upstream-credentials/${encodeURIComponent(provider)}`;
|
|
142
142
|
const response = await fetch(url, {
|
|
143
143
|
method: "DELETE",
|
|
144
144
|
headers: { authorization: `Bearer ${apiKey}` },
|
|
@@ -149,6 +149,10 @@ export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
|
|
|
149
149
|
}
|
|
150
150
|
}
|
|
151
151
|
|
|
152
|
+
export async function forgetAnthropicToken({ gatewayBaseUrl, apiKey }) {
|
|
153
|
+
return forgetUpstreamCredential({ gatewayBaseUrl, apiKey, provider: "anthropic" });
|
|
154
|
+
}
|
|
155
|
+
|
|
152
156
|
function platformName() {
|
|
153
157
|
if (process.platform === "darwin") return "Mac";
|
|
154
158
|
if (process.platform === "win32") return "Windows";
|
package/src/statusline.js
CHANGED
|
@@ -11,6 +11,10 @@
|
|
|
11
11
|
// authed with the pr- key already in Claude Code's settings). The gateway keys that
|
|
12
12
|
// endpoint on the CC session uuid it sees in every request's metadata.user_id — the
|
|
13
13
|
// same uuid Claude Code hands the statusline script on stdin.
|
|
14
|
+
//
|
|
15
|
+
// A wrapped user statusline command is cached the same way: it can be slow to spawn
|
|
16
|
+
// (npx especially, and especially on Windows — see WRAP_REFRESH_MS below), so it's never
|
|
17
|
+
// run inline on Claude Code's ~1s render loop.
|
|
14
18
|
|
|
15
19
|
import { statePath, atomicWrite, readJsonFile, writeJsonFile } from "./state.js";
|
|
16
20
|
|
|
@@ -87,6 +91,14 @@ const stateDir = join(
|
|
|
87
91
|
"pennyrouter",
|
|
88
92
|
);
|
|
89
93
|
const cacheDir = join(stateDir, "statusline-cache");
|
|
94
|
+
// A wrapped user statusline command can be slow to spawn (npx especially: on Windows
|
|
95
|
+
// it goes through an extra cmd.exe/npx.cmd hop and can take 2-4s per invocation, vs
|
|
96
|
+
// well under 1s on macOS/Linux). Claude Code re-invokes this script every ~1s
|
|
97
|
+
// (refreshInterval), so running the wrap command inline on every render blocked past
|
|
98
|
+
// that interval on Windows, renders piled up, and the statusline never finished in
|
|
99
|
+
// time to display at all. Cache its output and refresh it out-of-band instead, same
|
|
100
|
+
// as the Penny gateway segment below.
|
|
101
|
+
const WRAP_REFRESH_MS = 2000;
|
|
90
102
|
|
|
91
103
|
function readJson(path) {
|
|
92
104
|
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
|
|
@@ -109,6 +121,8 @@ function claudeEnv() {
|
|
|
109
121
|
return { base, key };
|
|
110
122
|
}
|
|
111
123
|
|
|
124
|
+
const config = readJson(join(stateDir, "claude-statusline.json")) || {};
|
|
125
|
+
|
|
112
126
|
// --refresh <sessionId>: fetch gateway status into the cache file, then exit.
|
|
113
127
|
if (process.argv[2] === "--refresh") {
|
|
114
128
|
const sessionId = process.argv[3];
|
|
@@ -131,11 +145,28 @@ if (process.argv[2] === "--refresh") {
|
|
|
131
145
|
process.exit(0);
|
|
132
146
|
}
|
|
133
147
|
|
|
148
|
+
// --refresh-wrap <sessionId>: run the (possibly slow) wrapped statusline command out of
|
|
149
|
+
// band and cache its output. Reads the stdin JSON the main render last saw, persisted to
|
|
150
|
+
// a per-session file since a detached child has no stdin of its own to read.
|
|
151
|
+
if (process.argv[2] === "--refresh-wrap") {
|
|
152
|
+
const sessionId = process.argv[3];
|
|
153
|
+
if (!sessionId || !config.wrap_command) process.exit(0);
|
|
154
|
+
let wrapInput = "{}";
|
|
155
|
+
try { wrapInput = readFileSync(join(cacheDir, sessionId + ".wrap-input.json"), "utf8"); } catch { /* none yet */ }
|
|
156
|
+
const run = spawnSync(config.wrap_command, {
|
|
157
|
+
shell: true, input: wrapInput, encoding: "utf8", timeout: 20000, windowsHide: true,
|
|
158
|
+
});
|
|
159
|
+
let wrapLines = (run.stdout || "").replace(/\\n$/, "").split("\\n");
|
|
160
|
+
if (wrapLines.length === 1 && wrapLines[0] === "") wrapLines = [];
|
|
161
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
162
|
+
writeFileSync(join(cacheDir, sessionId + ".wrap.json"), JSON.stringify({ lines: wrapLines, at: Date.now() }));
|
|
163
|
+
process.exit(0);
|
|
164
|
+
}
|
|
165
|
+
|
|
134
166
|
const raw = readFileSync(0, "utf8");
|
|
135
167
|
let input = {};
|
|
136
168
|
try { input = JSON.parse(raw); } catch { /* keep {} */ }
|
|
137
169
|
const sessionId = input.session_id || "";
|
|
138
|
-
const config = readJson(join(stateDir, "claude-statusline.json")) || {};
|
|
139
170
|
const refreshMs = (config.refresh_seconds ?? 15) * 1000;
|
|
140
171
|
|
|
141
172
|
const cachePath = join(cacheDir, sessionId + ".json");
|
|
@@ -148,7 +179,7 @@ const { base, key } = claudeEnv();
|
|
|
148
179
|
const pennyInstalled = Boolean(base) && Boolean(key);
|
|
149
180
|
if (sessionId && pennyInstalled && cacheAge > refreshMs) {
|
|
150
181
|
spawn(process.execPath, [process.argv[1], "--refresh", sessionId], {
|
|
151
|
-
detached: true, stdio: "ignore",
|
|
182
|
+
detached: true, stdio: "ignore", windowsHide: true,
|
|
152
183
|
}).unref();
|
|
153
184
|
}
|
|
154
185
|
|
|
@@ -193,9 +224,27 @@ if (!pennyInstalled) {
|
|
|
193
224
|
}
|
|
194
225
|
|
|
195
226
|
let lines;
|
|
196
|
-
if (config.wrap_command) {
|
|
227
|
+
if (config.wrap_command && sessionId) {
|
|
228
|
+
const wrapCachePath = join(cacheDir, sessionId + ".wrap.json");
|
|
229
|
+
const wrapCache = readJson(wrapCachePath);
|
|
230
|
+
const wrapAge = wrapCache && typeof wrapCache.at === "number" ? Date.now() - wrapCache.at : Infinity;
|
|
231
|
+
if (wrapAge > WRAP_REFRESH_MS) {
|
|
232
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
233
|
+
writeFileSync(join(cacheDir, sessionId + ".wrap-input.json"), raw);
|
|
234
|
+
// Bump the cache timestamp before spawning: Claude Code polls every ~1s, so
|
|
235
|
+
// without this every render in the next couple seconds would see a still-stale
|
|
236
|
+
// cache and fire its own overlapping refresh while the first one is still running.
|
|
237
|
+
writeFileSync(wrapCachePath, JSON.stringify({ lines: (wrapCache && wrapCache.lines) || [], at: Date.now() }));
|
|
238
|
+
spawn(process.execPath, [process.argv[1], "--refresh-wrap", sessionId], {
|
|
239
|
+
detached: true, stdio: "ignore", windowsHide: true,
|
|
240
|
+
}).unref();
|
|
241
|
+
}
|
|
242
|
+
lines = (wrapCache && wrapCache.lines) || [];
|
|
243
|
+
} else if (config.wrap_command) {
|
|
244
|
+
// No session id (e.g. a manual/interactive invocation outside Claude Code): there's
|
|
245
|
+
// no cache key to keep state under, so run inline like before caching existed.
|
|
197
246
|
const run = spawnSync(config.wrap_command, {
|
|
198
|
-
shell: true, input: raw, encoding: "utf8", timeout: 5000,
|
|
247
|
+
shell: true, input: raw, encoding: "utf8", timeout: 5000, windowsHide: true,
|
|
199
248
|
});
|
|
200
249
|
lines = (run.stdout || "").replace(/\\n$/, "").split("\\n");
|
|
201
250
|
if (lines.length === 1 && lines[0] === "") lines = [];
|