pennyrouter 0.2.24 → 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 +263 -47
- package/src/cli.test.js +196 -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
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { emitKeypressEvents } from "node:readline";
|
|
3
4
|
import { createInterface } from "node:readline/promises";
|
|
4
5
|
import { stdin as input, stdout as output } from "node:process";
|
|
@@ -25,6 +26,7 @@ import {
|
|
|
25
26
|
APP_URL,
|
|
26
27
|
createCliSession,
|
|
27
28
|
forgetAnthropicToken,
|
|
29
|
+
forgetUpstreamCredential,
|
|
28
30
|
maskKey,
|
|
29
31
|
pollForKey,
|
|
30
32
|
probeClaudeCodeGateway,
|
|
@@ -71,6 +73,7 @@ const CLAUDE_PROVIDER_OPTIONS = [
|
|
|
71
73
|
{ id: "subscription", name: "Claude subscription" },
|
|
72
74
|
{ id: "anthropic-api", name: "Anthropic API key" },
|
|
73
75
|
{ id: "openrouter", name: "OpenRouter API key" },
|
|
76
|
+
{ id: "anthropic-proxy", name: "Other proxy (custom base URL)" },
|
|
74
77
|
];
|
|
75
78
|
const CODEX_PROVIDER_OPTIONS = [
|
|
76
79
|
{ id: "subscription", name: "ChatGPT subscription" },
|
|
@@ -168,6 +171,10 @@ function parseArgs(argv) {
|
|
|
168
171
|
else if (arg === "--openai-api-key") flags.openaiApiKey = rest[++i] || "";
|
|
169
172
|
else if (arg.startsWith("--openai-api-key=")) flags.openaiApiKey = arg.slice("--openai-api-key=".length);
|
|
170
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);
|
|
171
178
|
else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
|
|
172
179
|
else if (arg === "--harness") flags.harness = rest[++i] || "";
|
|
173
180
|
else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
|
|
@@ -181,15 +188,98 @@ function parseArgs(argv) {
|
|
|
181
188
|
// `--local=<url>` overrides the port/host.
|
|
182
189
|
else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
|
|
183
190
|
else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
|
|
191
|
+
// --prod: point the installed harness back at the default production gateway
|
|
192
|
+
// (https://api.pennyrouter.com). Undoes --local.
|
|
193
|
+
else if (arg === "--prod") flags.gatewayBaseUrl = DEFAULT_GATEWAY_BASE_URL;
|
|
184
194
|
else if (arg === "--forget") flags.forget = true;
|
|
185
195
|
else if (arg === "--forget-token") flags.forgetToken = true;
|
|
196
|
+
else if (arg === "--config") flags.configPath = rest[++i] || "";
|
|
197
|
+
else if (arg.startsWith("--config=")) flags.configPath = arg.slice("--config=".length);
|
|
186
198
|
else if (!arg.startsWith("-")) flags.args.push(arg);
|
|
187
199
|
else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
|
|
188
200
|
}
|
|
189
201
|
|
|
202
|
+
if (flags.configPath) applyConfigFile(flags);
|
|
203
|
+
|
|
190
204
|
return { command, flags };
|
|
191
205
|
}
|
|
192
206
|
|
|
207
|
+
// Lets a GUI installer (e.g. the macOS DMG) answer the install questions up front instead of
|
|
208
|
+
// over a TTY: pick harnesses, pick funding per harness, optionally supply keys. Populates the
|
|
209
|
+
// same `flags` fields the equivalent CLI flags set, so it flows through the existing
|
|
210
|
+
// non-interactive paths unchanged. CLI flags win over the config file where both are given.
|
|
211
|
+
// Funding choices imply --yes (skip remaining prompts) but, unlike bare --yes, do NOT suppress
|
|
212
|
+
// the Claude subscription browser sign-in — see maybeConfigureClaudeAnthropicAuth.
|
|
213
|
+
function applyConfigFile(flags) {
|
|
214
|
+
let config;
|
|
215
|
+
try {
|
|
216
|
+
config = JSON.parse(readFileSync(flags.configPath, "utf8"));
|
|
217
|
+
} catch (error) {
|
|
218
|
+
throw new Error(`Could not read --config file "${flags.configPath}": ${error.message}`);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
if (config.harnesses && flags.harness === undefined) {
|
|
222
|
+
flags.harness = config.harnesses.join(",");
|
|
223
|
+
}
|
|
224
|
+
const claudeFunding = new Set(config.claudeFunding || []);
|
|
225
|
+
const codexFunding = new Set(config.codexFunding || []);
|
|
226
|
+
if (claudeFunding.has("subscription") && flags.anthropicAuth === undefined) {
|
|
227
|
+
flags.anthropicAuth = true;
|
|
228
|
+
}
|
|
229
|
+
if (claudeFunding.has("anthropic-api") && flags.anthropicApiKey === undefined) {
|
|
230
|
+
flags.anthropicApiKey = config.anthropicApiKey || "";
|
|
231
|
+
}
|
|
232
|
+
if ((claudeFunding.has("openrouter") || codexFunding.has("openrouter"))
|
|
233
|
+
&& flags.openrouterApiKey === undefined) {
|
|
234
|
+
flags.openrouterApiKey = config.openrouterApiKey || "";
|
|
235
|
+
}
|
|
236
|
+
if (codexFunding.has("openai-api") && flags.openaiApiKey === undefined) {
|
|
237
|
+
flags.openaiApiKey = config.openaiApiKey || "";
|
|
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
|
+
}
|
|
243
|
+
|
|
244
|
+
flags.yes = true;
|
|
245
|
+
flags.fromConfig = true;
|
|
246
|
+
flags.configClaudeFunding = claudeFunding;
|
|
247
|
+
flags.configCodexFunding = codexFunding;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// A harness needs the account-claim/funding flow only if it's new, or if the funding the
|
|
251
|
+
// config asked for differs from what's already stored on it — an unchanged re-run of
|
|
252
|
+
// --config should behave like `update`, not force a fresh Claude/PennyRouter sign-in.
|
|
253
|
+
// Plain (non--config) installs call every selected harness "changed" by always passing an
|
|
254
|
+
// empty requested set here, so this only takes effect on the --config path (see `needsAuth`
|
|
255
|
+
// in install()).
|
|
256
|
+
export function harnessFundingChanged(flags, harness, manifest) {
|
|
257
|
+
const existingRecord = (manifest.installations || []).find(
|
|
258
|
+
(candidate) => candidate.harness === harness.id,
|
|
259
|
+
);
|
|
260
|
+
if (!existingRecord) return true;
|
|
261
|
+
|
|
262
|
+
if (harness.id === "claude-code") {
|
|
263
|
+
const requested = flags.configClaudeFunding || new Set();
|
|
264
|
+
if (requested.size === 0) return false;
|
|
265
|
+
const current = existingRecord.user_funded_provider === "claude-subscription"
|
|
266
|
+
? "subscription"
|
|
267
|
+
: existingRecord.user_funded_provider;
|
|
268
|
+
return requested.size !== 1 || !requested.has(current);
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
if (harness.id === "codex") {
|
|
272
|
+
const requested = flags.configCodexFunding || new Set();
|
|
273
|
+
if (requested.size === 0) return false;
|
|
274
|
+
const current = existingRecord.openai_subscription_auth
|
|
275
|
+
? "subscription"
|
|
276
|
+
: existingRecord.user_funded_provider;
|
|
277
|
+
return requested.size !== 1 || !requested.has(current);
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
return false;
|
|
281
|
+
}
|
|
282
|
+
|
|
193
283
|
async function authProvider(flags) {
|
|
194
284
|
const provider = (flags.args[0] || "anthropic").toLowerCase();
|
|
195
285
|
if (provider !== "anthropic" && provider !== "claude") {
|
|
@@ -204,11 +294,21 @@ async function authProvider(flags) {
|
|
|
204
294
|
"No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
|
|
205
295
|
);
|
|
206
296
|
}
|
|
207
|
-
|
|
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;
|
|
208
307
|
if (flags.dryRun) {
|
|
209
308
|
console.log(`Will delete stored Anthropic token from gateway: ${gatewayBaseUrl}`);
|
|
210
309
|
return;
|
|
211
310
|
}
|
|
311
|
+
console.log(`Gateway: ${gatewayBaseUrl}`);
|
|
212
312
|
const ok = await forgetAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey });
|
|
213
313
|
if (ok) {
|
|
214
314
|
console.log("Successfully forgot stored Anthropic token on server.");
|
|
@@ -436,40 +536,75 @@ export async function install(flags = {}) {
|
|
|
436
536
|
return;
|
|
437
537
|
}
|
|
438
538
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
539
|
+
// A --config re-run against an existing install only needs a fresh account claim and
|
|
540
|
+
// funding popups for harnesses that are actually new or whose requested funding differs
|
|
541
|
+
// from what's already stored — an unchanged harness should behave like `update`, not
|
|
542
|
+
// trigger the Claude/PennyRouter sign-in windows again. Plain (non-config) installs always
|
|
543
|
+
// treat everything selected as needing auth, matching the original behavior.
|
|
544
|
+
const needsAuth = flags.fromConfig
|
|
545
|
+
? selected.filter((harness) => harnessFundingChanged(flags, harness, manifest))
|
|
546
|
+
: selected;
|
|
444
547
|
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
console.log("Waiting for approval...");
|
|
548
|
+
let claim;
|
|
549
|
+
let claudeAnthropicAuth = null;
|
|
550
|
+
let funding = { credentials: [], codexSubscriptionAuth: false, claudeCredentialProvider: null, codexCredentialProvider: null };
|
|
551
|
+
let gatewayBaseUrl;
|
|
450
552
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
553
|
+
if (needsAuth.length === 0) {
|
|
554
|
+
const launchConfig = await loadPennyConfig();
|
|
555
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
556
|
+
const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
|
|
557
|
+
if (!apiKey) {
|
|
558
|
+
throw new Error(
|
|
559
|
+
"Could not find your PennyRouter key in the local config. Pass `--penny-key pr-...`, "
|
|
560
|
+
+ "or remove --config to re-authorize.",
|
|
561
|
+
);
|
|
562
|
+
}
|
|
563
|
+
console.log("No new tools or funding requested — refreshing your existing setup.");
|
|
564
|
+
console.log("");
|
|
565
|
+
claim = { api_key: apiKey, gateway_base_url: null };
|
|
566
|
+
gatewayBaseUrl = flags.gatewayBaseUrl
|
|
567
|
+
|| launchConfig.gateway_base_url
|
|
568
|
+
|| globalAuth.gatewayBaseUrl
|
|
569
|
+
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
570
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
571
|
+
} else {
|
|
572
|
+
const session = await createCliSession({
|
|
573
|
+
appUrl: flags.appUrl || APP_URL,
|
|
574
|
+
harnesses: selected.map((harness) => harness.id),
|
|
575
|
+
accountMode: "existing",
|
|
469
576
|
});
|
|
577
|
+
|
|
578
|
+
console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
|
|
579
|
+
console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
|
|
580
|
+
if (!flags.noBrowser) openBrowser(session.authorize_url);
|
|
581
|
+
console.log("");
|
|
582
|
+
console.log("Waiting for approval...");
|
|
583
|
+
|
|
584
|
+
claim = await pollForKey(session);
|
|
585
|
+
console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
|
|
586
|
+
console.log("");
|
|
587
|
+
|
|
588
|
+
const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
|
|
589
|
+
const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
|
|
590
|
+
claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
|
|
591
|
+
flags, needsAuth, claudeProviders.has("subscription"));
|
|
592
|
+
funding = await configureUserFunding(
|
|
593
|
+
flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
|
|
594
|
+
gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
|
|
595
|
+
|
|
596
|
+
for (const credential of funding.credentials) {
|
|
597
|
+
await storeUpstreamCredential({
|
|
598
|
+
gatewayBaseUrl,
|
|
599
|
+
apiKey: claim.api_key,
|
|
600
|
+
provider: credential.provider,
|
|
601
|
+
token: credential.token,
|
|
602
|
+
metadata: credential.metadata,
|
|
603
|
+
});
|
|
604
|
+
}
|
|
470
605
|
}
|
|
471
606
|
|
|
472
|
-
if (
|
|
607
|
+
if (needsAuth.some((harness) => harness.id === "claude-code")) {
|
|
473
608
|
console.log("Verifying modern Claude Code compatibility before changing local settings...");
|
|
474
609
|
await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
|
|
475
610
|
console.log("");
|
|
@@ -501,8 +636,9 @@ export async function install(flags = {}) {
|
|
|
501
636
|
assertNoRunningHarness(harness.id, flags);
|
|
502
637
|
}
|
|
503
638
|
|
|
639
|
+
const harnessNeededAuth = needsAuth.includes(harness);
|
|
504
640
|
const codexSubscriptionAuth = harness.id === "codex"
|
|
505
|
-
? funding.codexSubscriptionAuth
|
|
641
|
+
? (harnessNeededAuth ? funding.codexSubscriptionAuth : Boolean(existingRecord?.openai_subscription_auth))
|
|
506
642
|
: false;
|
|
507
643
|
const claudeSubscriptionAuth = harness.id === "claude-code"
|
|
508
644
|
&& Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
|
|
@@ -550,11 +686,20 @@ export async function install(flags = {}) {
|
|
|
550
686
|
record.anthropic_token_stored = true;
|
|
551
687
|
record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
|
|
552
688
|
}
|
|
553
|
-
if (harness.id === "claude-code"
|
|
554
|
-
record.user_funded_provider =
|
|
689
|
+
if (harness.id === "claude-code") {
|
|
690
|
+
record.user_funded_provider = harnessNeededAuth
|
|
691
|
+
? (funding.claudeCredentialProvider || existingRecord?.user_funded_provider)
|
|
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;
|
|
555
698
|
}
|
|
556
|
-
if (harness.id === "codex"
|
|
557
|
-
record.user_funded_provider =
|
|
699
|
+
if (harness.id === "codex") {
|
|
700
|
+
record.user_funded_provider = harnessNeededAuth
|
|
701
|
+
? (funding.codexCredentialProvider || existingRecord?.user_funded_provider)
|
|
702
|
+
: existingRecord?.user_funded_provider;
|
|
558
703
|
}
|
|
559
704
|
if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
|
|
560
705
|
records.push(record);
|
|
@@ -936,14 +1081,19 @@ async function selectClaudeFundingProviders(flags, selected) {
|
|
|
936
1081
|
|| process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
|
|
937
1082
|
if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
|
|
938
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
|
+
}
|
|
939
1088
|
|
|
940
1089
|
// Explicit flags are the automation selector. Interactive installs always default to the
|
|
941
1090
|
// subscription alone; environment keys are used if the user checks their provider.
|
|
942
1091
|
const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
|
|
943
|
-
|| flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
|
|
1092
|
+
|| flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
|
|
1093
|
+
|| flags.proxyBaseUrl || flags.proxyApiKey;
|
|
944
1094
|
if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
|
|
945
1095
|
|
|
946
|
-
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.");
|
|
947
1097
|
console.log("Choose one or more providers. PennyRouter will try them in order when available.");
|
|
948
1098
|
return promptFundingProviderCheckboxes(
|
|
949
1099
|
CLAUDE_PROVIDER_OPTIONS,
|
|
@@ -982,7 +1132,10 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
982
1132
|
if (tokenResult.token) return tokenResult;
|
|
983
1133
|
}
|
|
984
1134
|
|
|
985
|
-
|
|
1135
|
+
// --config drives --yes to skip prompts, but it still expects the subscription browser
|
|
1136
|
+
// sign-in to run — the caller (e.g. the macOS installer) attaches a pty for exactly this,
|
|
1137
|
+
// so treat it like the interactive path rather than the plain --yes/non-TTY automation path.
|
|
1138
|
+
if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
|
|
986
1139
|
if (flags.anthropicAuth) {
|
|
987
1140
|
throw new Error(
|
|
988
1141
|
"Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
|
|
@@ -998,7 +1151,8 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
998
1151
|
if (commandExists("claude")) {
|
|
999
1152
|
// The user already opted into subscription auth above, so run `claude setup-token`
|
|
1000
1153
|
// directly and capture the token — no copy-paste, no second confirm. The command
|
|
1001
|
-
// opens the browser for approval inline. On failure we fall through to manual paste
|
|
1154
|
+
// opens the browser for approval inline. On failure we fall through to manual paste,
|
|
1155
|
+
// unless we came from --config, where there's no terminal to paste into.
|
|
1002
1156
|
console.log("Launching Claude sign-in — approve in your browser…");
|
|
1003
1157
|
const captured = await runClaudeSetupToken();
|
|
1004
1158
|
if (captured) {
|
|
@@ -1006,8 +1160,13 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
1006
1160
|
console.log("");
|
|
1007
1161
|
return { token: captured, source: "setup-token" };
|
|
1008
1162
|
}
|
|
1163
|
+
if (flags.fromConfig) {
|
|
1164
|
+
throw new Error("Claude sign-in did not complete. Run the installer again.");
|
|
1165
|
+
}
|
|
1009
1166
|
console.log("");
|
|
1010
1167
|
console.log("Couldn't capture a token automatically. Paste it below, or leave blank to skip.");
|
|
1168
|
+
} else if (flags.fromConfig) {
|
|
1169
|
+
throw new Error("Claude Code was not found on PATH, so subscription sign-in could not run.");
|
|
1011
1170
|
} else {
|
|
1012
1171
|
console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
|
|
1013
1172
|
}
|
|
@@ -1037,6 +1196,12 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1037
1196
|
let openrouterApiKey = String(
|
|
1038
1197
|
flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
|
|
1039
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();
|
|
1040
1205
|
const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
|
|
1041
1206
|
? await detectCodexChatgptAuth()
|
|
1042
1207
|
: false;
|
|
@@ -1049,6 +1214,19 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1049
1214
|
if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
|
|
1050
1215
|
openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
|
|
1051
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
|
+
}
|
|
1052
1230
|
if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
|
|
1053
1231
|
throw new Error(
|
|
1054
1232
|
"ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
|
|
@@ -1063,7 +1241,9 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1063
1241
|
openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
|
|
1064
1242
|
}
|
|
1065
1243
|
|
|
1066
|
-
|
|
1244
|
+
const claudeProxyConfigured = Boolean(proxyBaseUrl && proxyApiKey);
|
|
1245
|
+
if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey
|
|
1246
|
+
&& !claudeProxyConfigured) {
|
|
1067
1247
|
if (!interactive) {
|
|
1068
1248
|
throw new Error(
|
|
1069
1249
|
"Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
|
|
@@ -1116,13 +1296,23 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1116
1296
|
if ((wantsClaude || wantsCodex) && openrouterApiKey) {
|
|
1117
1297
|
credentials.push({ provider: "openrouter", token: openrouterApiKey });
|
|
1118
1298
|
}
|
|
1299
|
+
if (wantsClaude && claudeProxyConfigured) {
|
|
1300
|
+
credentials.push({
|
|
1301
|
+
provider: "anthropic_proxy",
|
|
1302
|
+
token: proxyApiKey,
|
|
1303
|
+
metadata: { base_url: proxyBaseUrl },
|
|
1304
|
+
});
|
|
1305
|
+
}
|
|
1119
1306
|
|
|
1120
1307
|
return {
|
|
1121
1308
|
credentials,
|
|
1122
1309
|
codexSubscriptionAuth,
|
|
1123
1310
|
claudeCredentialProvider: claudeAnthropicAuth
|
|
1124
1311
|
? "claude-subscription"
|
|
1125
|
-
: anthropicApiKey ? "anthropic-api"
|
|
1312
|
+
: anthropicApiKey ? "anthropic-api"
|
|
1313
|
+
: claudeProxyConfigured ? "anthropic-proxy"
|
|
1314
|
+
: openrouterApiKey ? "openrouter" : null,
|
|
1315
|
+
claudeProxyBaseUrl: claudeProxyConfigured ? proxyBaseUrl : null,
|
|
1126
1316
|
codexCredentialProvider: codexSubscriptionAuth
|
|
1127
1317
|
? "chatgpt-subscription"
|
|
1128
1318
|
: openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
|
|
@@ -1295,7 +1485,7 @@ async function uninstall(flags) {
|
|
|
1295
1485
|
const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
1296
1486
|
if (flags.forgetToken) {
|
|
1297
1487
|
await forgetStoredAnthropicToken(record);
|
|
1298
|
-
} else if (record.anthropic_token_stored) {
|
|
1488
|
+
} else if (record.anthropic_token_stored || record.proxy_base_url) {
|
|
1299
1489
|
anthropicTokenNotice = true;
|
|
1300
1490
|
}
|
|
1301
1491
|
if (record.launch_available) await removePennyHarness(record.harness);
|
|
@@ -1315,7 +1505,7 @@ async function uninstall(flags) {
|
|
|
1315
1505
|
await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
1316
1506
|
if (flags.forgetToken) {
|
|
1317
1507
|
await forgetStoredAnthropicToken(record);
|
|
1318
|
-
} else if (record.anthropic_token_stored) {
|
|
1508
|
+
} else if (record.anthropic_token_stored || record.proxy_base_url) {
|
|
1319
1509
|
anthropicTokenNotice = true;
|
|
1320
1510
|
}
|
|
1321
1511
|
if (record.launch_available) await removePennyHarness(record.harness);
|
|
@@ -1409,6 +1599,10 @@ async function status() {
|
|
|
1409
1599
|
if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
|
|
1410
1600
|
if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
|
|
1411
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
|
+
}
|
|
1412
1606
|
if (record.launch_available) {
|
|
1413
1607
|
const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
|
|
1414
1608
|
console.log(` command: ${command}`);
|
|
@@ -1719,14 +1913,24 @@ function formatError(error) {
|
|
|
1719
1913
|
// live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
|
|
1720
1914
|
// must not be blocked by a network hiccup; the record is going away regardless.
|
|
1721
1915
|
async function forgetStoredAnthropicToken(record) {
|
|
1722
|
-
if (record.harness !== "claude-code"
|
|
1916
|
+
if (record.harness !== "claude-code") return;
|
|
1917
|
+
if (!record.anthropic_token_stored && !record.proxy_base_url) return;
|
|
1723
1918
|
try {
|
|
1724
1919
|
const launchConfig = await loadPennyConfig();
|
|
1725
1920
|
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
1726
1921
|
const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
|
|
1727
1922
|
const apiKey = launchConfig.api_key || globalAuth.apiKey;
|
|
1728
1923
|
if (!gatewayBaseUrl || !apiKey) return;
|
|
1729
|
-
|
|
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
|
+
}
|
|
1730
1934
|
} catch {
|
|
1731
1935
|
// swallow — see note above
|
|
1732
1936
|
}
|
|
@@ -1739,6 +1943,15 @@ async function confirm(question) {
|
|
|
1739
1943
|
return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
|
|
1740
1944
|
}
|
|
1741
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
|
+
|
|
1742
1955
|
async function promptSecretish(question) {
|
|
1743
1956
|
if (!input.isTTY || !output.isTTY) {
|
|
1744
1957
|
const rl = createInterface({ input, output });
|
|
@@ -1858,8 +2071,8 @@ function printHelp() {
|
|
|
1858
2071
|
console.log(`PennyRouter CLI
|
|
1859
2072
|
|
|
1860
2073
|
Usage:
|
|
1861
|
-
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]] [--allow-running-harnesses]
|
|
1862
|
-
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]] [--no-mcp] [--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]
|
|
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]
|
|
1863
2076
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
1864
2077
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
1865
2078
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
@@ -1888,16 +2101,19 @@ Examples:
|
|
|
1888
2101
|
npx pennyrouter update --harness claude-code
|
|
1889
2102
|
npx pennyrouter update --harness codex --local
|
|
1890
2103
|
npx pennyrouter update --all --local
|
|
2104
|
+
npx pennyrouter update --all --prod
|
|
1891
2105
|
npx pennyrouter install
|
|
1892
2106
|
npx pennyrouter install --make-default
|
|
1893
2107
|
npx pennyrouter install --penny-only
|
|
1894
2108
|
npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
|
|
1895
2109
|
npx pennyrouter install --harness claude-code --anthropic-api-key <key>
|
|
1896
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>
|
|
1897
2112
|
npx pennyrouter install --openrouter-api-key <key>
|
|
1898
2113
|
npx pennyrouter auth anthropic
|
|
1899
2114
|
npx pennyrouter install --all
|
|
1900
2115
|
npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
|
|
2116
|
+
npx pennyrouter install --config ./penny_install.json
|
|
1901
2117
|
npx pennyrouter disable
|
|
1902
2118
|
npx pennyrouter enable
|
|
1903
2119
|
npx pennyrouter uninstall
|
package/src/cli.test.js
ADDED
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { harnessFundingChanged, main } from "./cli.js";
|
|
7
|
+
|
|
8
|
+
// --config lets a GUI installer (the macOS DMG) answer install questions up front instead of
|
|
9
|
+
// over a TTY. These tests drive it through `main()` in --dry-run, which prints its plan and
|
|
10
|
+
// returns before any network call or filesystem write, so no mocking is needed.
|
|
11
|
+
|
|
12
|
+
function withConfigFile(config, run) {
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), "pennyrouter-config-"));
|
|
14
|
+
const path = join(dir, "penny_install.json");
|
|
15
|
+
writeFileSync(path, JSON.stringify(config));
|
|
16
|
+
try {
|
|
17
|
+
return run(path);
|
|
18
|
+
} finally {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function captureLogs(run) {
|
|
24
|
+
const original = console.log;
|
|
25
|
+
const lines = [];
|
|
26
|
+
console.log = (...args) => lines.push(args.join(" "));
|
|
27
|
+
return run().finally(() => { console.log = original; }).then(() => lines);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("--config selects harnesses without prompting", async () => {
|
|
31
|
+
await withConfigFile({ harnesses: ["claude-code"] }, async (path) => {
|
|
32
|
+
const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
|
|
33
|
+
assert.ok(lines.some((line) => line.includes("Claude Code")));
|
|
34
|
+
assert.ok(!lines.some((line) => line.includes("Codex")));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("--config with claudeFunding subscription sets --anthropic-auth", async () => {
|
|
39
|
+
await withConfigFile(
|
|
40
|
+
{ harnesses: ["claude-code"], claudeFunding: ["subscription"] },
|
|
41
|
+
async (path) => {
|
|
42
|
+
const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
|
|
43
|
+
assert.ok(lines.some((line) => line.includes("Claude Code provider picker")));
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("--config rejects an unreadable file", async () => {
|
|
49
|
+
await assert.rejects(
|
|
50
|
+
() => main(["install", "--config", "/nonexistent/penny_install.json", "--dry-run"]),
|
|
51
|
+
/Could not read --config file/,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("explicit --harness overrides --config harnesses", async () => {
|
|
56
|
+
await withConfigFile({ harnesses: ["codex"] }, async (path) => {
|
|
57
|
+
const lines = await captureLogs(
|
|
58
|
+
() => main(["install", "--harness", "claude-code", "--config", path, "--dry-run"]),
|
|
59
|
+
);
|
|
60
|
+
assert.ok(lines.some((line) => line.includes("Claude Code")));
|
|
61
|
+
assert.ok(!lines.some((line) => line.includes("Codex")));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// harnessFundingChanged decides whether a --config re-run needs to re-open the account-claim
|
|
66
|
+
// and subscription-auth popups for a harness, or can treat it like `update` (skip both). This
|
|
67
|
+
// only kicks in on --config re-runs against an existing manifest; a live end-to-end run against
|
|
68
|
+
// a real account confirmed the same three cases these unit tests cover.
|
|
69
|
+
|
|
70
|
+
const claudeHarness = { id: "claude-code" };
|
|
71
|
+
const codexHarness = { id: "codex" };
|
|
72
|
+
|
|
73
|
+
test("harnessFundingChanged: brand new harness always needs auth", () => {
|
|
74
|
+
const manifest = { installations: [] };
|
|
75
|
+
assert.equal(harnessFundingChanged({}, claudeHarness, manifest), true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("harnessFundingChanged: claude subscription unchanged", () => {
|
|
79
|
+
const manifest = {
|
|
80
|
+
installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
|
|
81
|
+
};
|
|
82
|
+
const flags = { configClaudeFunding: new Set(["subscription"]) };
|
|
83
|
+
assert.equal(harnessFundingChanged(flags, claudeHarness, manifest), false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("harnessFundingChanged: claude subscription to anthropic-api is a change", () => {
|
|
87
|
+
const manifest = {
|
|
88
|
+
installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
|
|
89
|
+
};
|
|
90
|
+
const flags = { configClaudeFunding: new Set(["anthropic-api"]) };
|
|
91
|
+
assert.equal(harnessFundingChanged(flags, claudeHarness, manifest), true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("harnessFundingChanged: codex subscription unchanged (openai_subscription_auth flag)", () => {
|
|
95
|
+
const manifest = {
|
|
96
|
+
installations: [{
|
|
97
|
+
harness: "codex",
|
|
98
|
+
user_funded_provider: "chatgpt-subscription",
|
|
99
|
+
openai_subscription_auth: true,
|
|
100
|
+
}],
|
|
101
|
+
};
|
|
102
|
+
const flags = { configCodexFunding: new Set(["subscription"]) };
|
|
103
|
+
assert.equal(harnessFundingChanged(flags, codexHarness, manifest), false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("harnessFundingChanged: codex openrouter unchanged", () => {
|
|
107
|
+
const manifest = {
|
|
108
|
+
installations: [{
|
|
109
|
+
harness: "codex",
|
|
110
|
+
user_funded_provider: "openrouter",
|
|
111
|
+
openai_subscription_auth: false,
|
|
112
|
+
}],
|
|
113
|
+
};
|
|
114
|
+
const flags = { configCodexFunding: new Set(["openrouter"]) };
|
|
115
|
+
assert.equal(harnessFundingChanged(flags, codexHarness, manifest), false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("harnessFundingChanged: no requested funding for an existing harness is not a change", () => {
|
|
119
|
+
const manifest = {
|
|
120
|
+
installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
|
|
121
|
+
};
|
|
122
|
+
assert.equal(harnessFundingChanged({}, claudeHarness, manifest), false);
|
|
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 = [];
|