pennyrouter 0.2.1 → 0.2.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -22,12 +22,14 @@ npx pennyrouter status
22
22
  Installation opens a browser and requires login to the PennyRouter account that should own
23
23
  the new machine key. CLI installs no longer create anonymous ghost accounts.
24
24
 
25
- With no `--harness` flag, the interactive installer shows a checkbox list. Tools with
26
- detected configs start checked; press Enter to accept or type tool IDs to toggle them.
25
+ With no `--harness` flag, the interactive installer shows Claude Code and Codex. Detected
26
+ tools start checked. The additional legacy integrations remain available through an explicit
27
+ `--harness <id>` selection or `--all`.
27
28
 
28
- When Claude Code is selected, the installer also offers an optional Claude Pro/Max
29
- subscription-auth step. Users can skip it during install and enable it later with
30
- `@penny auth` or `npx pennyrouter auth anthropic`.
29
+ When Claude Code is selected, the installer offers Claude subscription authorization. If it is
30
+ declined or unavailable, installation requires an Anthropic API key or OpenRouter key. Codex
31
+ uses an existing ChatGPT login; without one, installation requires an OpenAI API key or
32
+ OpenRouter key. PennyRouter-funded provider keys are never an installed-harness fallback.
31
33
 
32
34
  ## Explicit Penny Commands
33
35
 
@@ -59,7 +61,8 @@ does not currently expose equivalent project-independent custom slash commands.
59
61
  When Codex is already signed in with ChatGPT, the installer preserves that Codex-managed
60
62
  login and configures PennyRouter as an OpenAI-authenticated Responses proxy. Codex continues
61
63
  to own and refresh the OpenAI credentials; PennyRouter stores only its existing `pr-` account
62
- key in the managed provider block. API-key Codex installs continue to use PennyRouter credits.
64
+ key in the managed provider block. API-key Codex installs use the user's encrypted OpenAI or
65
+ OpenRouter key.
63
66
 
64
67
  To point Codex directly at a PennyRouter gateway running on localhost port 8400:
65
68
 
@@ -100,7 +103,7 @@ model you chose. "Penny Custom" is the dynamic option: PennyRouter cost-routes e
100
103
  - Penny Fable: pins `anthropic/claude-fable-5`
101
104
  - Penny Custom: `pennyrouter/auto` — dynamic routing on the bundle/profile saved in the user's account
102
105
 
103
- ## Claude Pro/Max Subscription Auth
106
+ ## Claude Subscription Auth
104
107
 
105
108
  When installing PennyRouter for Claude Code, the interactive installer offers to route
106
109
  Anthropic-native Claude turns through the user's own Claude subscription token. To force this
@@ -108,9 +111,12 @@ in non-interactive installs:
108
111
 
109
112
  ```bash
110
113
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
114
+ npx pennyrouter install --harness claude-code --anthropic-api-key <key>
115
+ npx pennyrouter install --harness codex --openai-api-key <key>
116
+ npx pennyrouter install --openrouter-api-key <key>
111
117
  ```
112
118
 
113
- If Claude Code is already logged in to a Pro/Max subscription, use Claude Code's token setup
119
+ If Claude Code is already logged in to a Claude subscription, use Claude Code's token setup
114
120
  flow:
115
121
 
116
122
  ```bash
@@ -138,6 +144,11 @@ PennyRouter `pr-` bearer in its settings; the gateway retrieves and forwards the
138
144
  with the required OAuth beta header and does not deduct PennyRouter credit for successfully
139
145
  served Anthropic-subscription calls.
140
146
 
147
+ On a transient subscription 429/529, the gateway retries the same subscription once. If it
148
+ still fails, it can fall back only to credentials owned by that user: Anthropic API then
149
+ OpenRouter for Claude, or OpenAI API then OpenRouter for Codex. If no such credential is
150
+ configured, the provider error is returned; PennyRouter never changes the payer to itself.
151
+
141
152
  PennyRouter also sets `disableClaudeAiConnectors: true` while its custom gateway is installed.
142
153
  Claude Code cannot load claude.ai organization connectors when gateway authentication takes
143
154
  precedence, and otherwise displays a persistent warning about that state. Making the disablement
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.1",
3
+ "version": "0.2.4",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
package/src/cli.js CHANGED
@@ -24,6 +24,7 @@ import {
24
24
  probeClaudeCodeGateway,
25
25
  sleep,
26
26
  storeAnthropicToken,
27
+ storeUpstreamCredential,
27
28
  } from "./session.js";
28
29
  import {
29
30
  backupFile,
@@ -59,6 +60,17 @@ const HARNESS_BY_ID = {
59
60
  };
60
61
 
61
62
  const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
63
+ const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex"];
64
+ const CLAUDE_PROVIDER_OPTIONS = [
65
+ { id: "subscription", name: "Claude subscription" },
66
+ { id: "anthropic-api", name: "Anthropic API key" },
67
+ { id: "openrouter", name: "OpenRouter API key" },
68
+ ];
69
+ const CODEX_PROVIDER_OPTIONS = [
70
+ { id: "subscription", name: "ChatGPT subscription" },
71
+ { id: "openai-api", name: "OpenAI API key" },
72
+ { id: "openrouter", name: "OpenRouter API key" },
73
+ ];
62
74
 
63
75
  export async function main(argv = process.argv.slice(2)) {
64
76
  const { command, flags } = parseArgs(argv);
@@ -128,6 +140,12 @@ function parseArgs(argv) {
128
140
  else if (arg.startsWith("--token=")) flags.token = arg.slice("--token=".length);
129
141
  else if (arg === "--token-command") flags.tokenCommand = rest[++i] || "";
130
142
  else if (arg.startsWith("--token-command=")) flags.tokenCommand = arg.slice("--token-command=".length);
143
+ else if (arg === "--anthropic-api-key") flags.anthropicApiKey = rest[++i] || "";
144
+ else if (arg.startsWith("--anthropic-api-key=")) flags.anthropicApiKey = arg.slice("--anthropic-api-key=".length);
145
+ else if (arg === "--openai-api-key") flags.openaiApiKey = rest[++i] || "";
146
+ else if (arg.startsWith("--openai-api-key=")) flags.openaiApiKey = arg.slice("--openai-api-key=".length);
147
+ else if (arg === "--openrouter-api-key") flags.openrouterApiKey = rest[++i] || "";
148
+ else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
131
149
  else if (arg === "--harness") flags.harness = rest[++i] || "";
132
150
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
133
151
  else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
@@ -388,7 +406,7 @@ async function install(flags) {
388
406
  }
389
407
  if (flags.gatewayBaseUrl) console.log(`Serving gateway: ${flags.gatewayBaseUrl}`);
390
408
  if (selected.some((harness) => harness.id === "claude-code")) {
391
- console.log("Claude Code subscription auth prompt would be shown unless skipped.");
409
+ console.log("Claude Code provider picker would be shown unless providers were supplied by flags or environment.");
392
410
  }
393
411
  console.log("Dry run only. No files changed.");
394
412
  return;
@@ -410,17 +428,24 @@ async function install(flags) {
410
428
  console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
411
429
  console.log("");
412
430
 
413
- const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(flags, selected);
431
+ const claudeProviders = await selectClaudeFundingProviders(flags, selected);
432
+ const codexProviders = await selectCodexFundingProviders(flags, selected);
433
+ const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
434
+ flags, selected, claudeProviders.has("subscription"));
435
+ const funding = await configureUserFunding(
436
+ flags, selected, claudeAnthropicAuth, claudeProviders, codexProviders);
414
437
  const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
415
438
 
439
+ for (const credential of funding.credentials) {
440
+ await storeUpstreamCredential({
441
+ gatewayBaseUrl,
442
+ apiKey: claim.api_key,
443
+ provider: credential.provider,
444
+ token: credential.token,
445
+ });
446
+ }
447
+
416
448
  if (selected.some((harness) => harness.id === "claude-code")) {
417
- if (claudeAnthropicAuth) {
418
- await storeAnthropicToken({
419
- gatewayBaseUrl,
420
- apiKey: claim.api_key,
421
- token: claudeAnthropicAuth.token,
422
- });
423
- }
424
449
  console.log("Verifying modern Claude Code compatibility before changing local settings...");
425
450
  await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
426
451
  console.log("");
@@ -454,7 +479,7 @@ async function install(flags) {
454
479
  }
455
480
 
456
481
  const codexSubscriptionAuth = harness.id === "codex"
457
- ? await detectCodexChatgptAuth()
482
+ ? funding.codexSubscriptionAuth
458
483
  : false;
459
484
  const claudeSubscriptionAuth = harness.id === "claude-code"
460
485
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
@@ -502,6 +527,12 @@ async function install(flags) {
502
527
  record.anthropic_token_stored = true;
503
528
  record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
504
529
  }
530
+ if (harness.id === "claude-code" && funding.claudeCredentialProvider) {
531
+ record.user_funded_provider = funding.claudeCredentialProvider;
532
+ }
533
+ if (harness.id === "codex" && funding.codexCredentialProvider) {
534
+ record.user_funded_provider = funding.codexCredentialProvider;
535
+ }
505
536
  if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
506
537
  records.push(record);
507
538
  upsertManifestRecord(manifest, record);
@@ -588,8 +619,13 @@ async function chooseDefaultRouting(flags, selected, manifest) {
588
619
  }
589
620
  console.log("");
590
621
  const bare = launchable.map((item) => item.id === "claude-code" ? "claude" : "codex");
591
- const label = bare.length === 1 ? bare[0] : `${bare.slice(0, -1).join(", ")} and ${bare.at(-1)}`;
592
- newInstallChoice = await confirm(`Also route ${color(label, "bold")} through PennyRouter by default?`);
622
+ const quoted = bare.map((command) => `'${command}'`);
623
+ const label = quoted.length === 1
624
+ ? quoted[0]
625
+ : `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
626
+ newInstallChoice = await confirm(
627
+ `Set ${label} to automatically load PennyRouter with every session?`,
628
+ );
593
629
  }
594
630
  }
595
631
  out.set(harness.id, newInstallChoice);
@@ -597,9 +633,54 @@ async function chooseDefaultRouting(flags, selected, manifest) {
597
633
  return out;
598
634
  }
599
635
 
600
- async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
636
+ async function selectClaudeFundingProviders(flags, selected) {
637
+ if (!selected.some((harness) => harness.id === "claude-code")) return new Set();
638
+
639
+ const supplied = new Set();
640
+ if (!flags.noAnthropicAuth && (flags.anthropicAuth || flags.token || flags.tokenCommand
641
+ || process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
642
+ if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
643
+ if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
644
+
645
+ // Explicit flags are the automation selector. Interactive installs always default to the
646
+ // subscription alone; environment keys are used if the user checks their provider.
647
+ const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
648
+ || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey;
649
+ if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
650
+
651
+ console.log("PennyRouter can use your Claude subscription, an Anthropic API key, or an OpenRouter API key to run Claude Code.");
652
+ console.log("Choose one or more providers. PennyRouter will try them in order when available.");
653
+ return promptFundingProviderCheckboxes(
654
+ CLAUDE_PROVIDER_OPTIONS,
655
+ "Which providers should PennyRouter use for Claude Code?",
656
+ new Set(["subscription"]),
657
+ );
658
+ }
659
+
660
+ async function selectCodexFundingProviders(flags, selected) {
661
+ if (!selected.some((harness) => harness.id === "codex")) return new Set();
662
+
663
+ const supplied = new Set();
664
+ if (flags.openaiApiKey || process.env.OPENAI_API_KEY) supplied.add("openai-api");
665
+ if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
666
+ const hasChatgptLogin = await detectCodexChatgptAuth();
667
+ if (hasChatgptLogin) supplied.add("subscription");
668
+
669
+ const explicit = flags.openaiApiKey || flags.openrouterApiKey;
670
+ if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
671
+
672
+ console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, or an OpenRouter API key to run Codex.");
673
+ console.log("Choose one or more providers. PennyRouter will try them in order when available.");
674
+ return promptFundingProviderCheckboxes(
675
+ CODEX_PROVIDER_OPTIONS,
676
+ "Which providers should PennyRouter use for Codex?",
677
+ new Set(["subscription"]),
678
+ );
679
+ }
680
+
681
+ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true) {
601
682
  if (!selected.some((harness) => harness.id === "claude-code")) return null;
602
- if (flags.noAnthropicAuth) return null;
683
+ if (!enabled || flags.noAnthropicAuth) return null;
603
684
 
604
685
  if (flags.token || flags.tokenCommand || process.env.ANTHROPIC_AUTH_TOKEN) {
605
686
  const tokenResult = readAnthropicOAuthToken(flags);
@@ -615,19 +696,6 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
615
696
  return null;
616
697
  }
617
698
 
618
- if (!flags.anthropicAuth) {
619
- console.log("Claude Code subscription auth");
620
- console.log("PennyRouter can use your Claude Pro/Max subscription for Anthropic Claude calls.");
621
- console.log("PennyRouter still handles routing, optimization, and @penny commands.");
622
- console.log("");
623
- const ok = await confirm("Enable Claude Pro/Max subscription auth for Claude Code?");
624
- if (!ok) {
625
- console.log("Skipped Claude subscription auth. Enable later with `@penny auth` or `pennyrouter auth anthropic`.");
626
- console.log("");
627
- return null;
628
- }
629
- }
630
-
631
699
  const tokenResult = readAnthropicOAuthToken({ tokenCommand: flags.tokenCommand });
632
700
  if (tokenResult.token) return tokenResult;
633
701
 
@@ -658,6 +726,114 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
658
726
  return { token, source: "prompt" };
659
727
  }
660
728
 
729
+ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
730
+ claudeProviders = new Set(), codexProviders = new Set()) {
731
+ const wantsClaude = selected.some((harness) => harness.id === "claude-code");
732
+ const wantsCodex = selected.some((harness) => harness.id === "codex");
733
+ const interactive = !flags.yes && input.isTTY && output.isTTY;
734
+ const credentials = [];
735
+
736
+ let anthropicApiKey = String(
737
+ flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY || "",
738
+ ).trim();
739
+ let openaiApiKey = String(
740
+ flags.openaiApiKey || process.env.OPENAI_API_KEY || "",
741
+ ).trim();
742
+ let openrouterApiKey = String(
743
+ flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
744
+ ).trim();
745
+ const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
746
+ ? await detectCodexChatgptAuth()
747
+ : false;
748
+
749
+ if (wantsClaude && claudeProviders.has("anthropic-api") && !anthropicApiKey) {
750
+ if (!interactive) throw new Error("Anthropic API was selected, but no key was provided.");
751
+ anthropicApiKey = (await promptSecretish("Anthropic API key: ")).trim();
752
+ }
753
+ if (wantsClaude && claudeProviders.has("openrouter") && !openrouterApiKey) {
754
+ if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
755
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
756
+ }
757
+ if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
758
+ throw new Error(
759
+ "ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
760
+ );
761
+ }
762
+ if (wantsCodex && codexProviders.has("openai-api") && !openaiApiKey) {
763
+ if (!interactive) throw new Error("OpenAI API was selected, but no key was provided.");
764
+ openaiApiKey = (await promptSecretish("OpenAI API key: ")).trim();
765
+ }
766
+ if (wantsCodex && codexProviders.has("openrouter") && !openrouterApiKey) {
767
+ if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
768
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
769
+ }
770
+
771
+ if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey) {
772
+ if (!interactive) {
773
+ throw new Error(
774
+ "Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
775
+ + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
776
+ );
777
+ }
778
+ console.log("Claude Code needs a user-funded provider. PennyRouter will never fall back to its own API keys.");
779
+ anthropicApiKey = (await promptSecretish(
780
+ "Anthropic API key (leave blank to use OpenRouter): ",
781
+ )).trim();
782
+ if (!anthropicApiKey) {
783
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
784
+ }
785
+ if (!anthropicApiKey && !openrouterApiKey) {
786
+ throw new Error("Claude Code installation cancelled: no subscription or API key was provided.");
787
+ }
788
+ console.log("");
789
+ }
790
+
791
+ if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openrouterApiKey) {
792
+ if (!interactive) {
793
+ throw new Error(
794
+ "Codex requires ChatGPT login, --openai-api-key (or OPENAI_API_KEY), "
795
+ + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
796
+ );
797
+ }
798
+ console.log("Codex is not logged into ChatGPT and needs a user-funded provider.");
799
+ console.log("PennyRouter will never fall back to its own API keys.");
800
+ openaiApiKey = (await promptSecretish(
801
+ "OpenAI API key (leave blank to use OpenRouter): ",
802
+ )).trim();
803
+ if (!openaiApiKey) {
804
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
805
+ }
806
+ if (!openaiApiKey && !openrouterApiKey) {
807
+ throw new Error("Codex installation cancelled: no subscription or API key was provided.");
808
+ }
809
+ console.log("");
810
+ }
811
+
812
+ if (claudeAnthropicAuth) {
813
+ credentials.push({ provider: "anthropic_oauth", token: claudeAnthropicAuth.token });
814
+ }
815
+ if (wantsClaude && anthropicApiKey) {
816
+ credentials.push({ provider: "anthropic_api", token: anthropicApiKey });
817
+ }
818
+ if (wantsCodex && openaiApiKey) {
819
+ credentials.push({ provider: "openai_api", token: openaiApiKey });
820
+ }
821
+ if ((wantsClaude || wantsCodex) && openrouterApiKey) {
822
+ credentials.push({ provider: "openrouter", token: openrouterApiKey });
823
+ }
824
+
825
+ return {
826
+ credentials,
827
+ codexSubscriptionAuth,
828
+ claudeCredentialProvider: claudeAnthropicAuth
829
+ ? "claude-subscription"
830
+ : anthropicApiKey ? "anthropic-api" : openrouterApiKey ? "openrouter" : null,
831
+ codexCredentialProvider: codexSubscriptionAuth
832
+ ? "chatgpt-subscription"
833
+ : openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
834
+ };
835
+ }
836
+
661
837
  async function disable(flags) {
662
838
  const manifest = await loadManifest();
663
839
  if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
@@ -945,15 +1121,16 @@ async function status() {
945
1121
 
946
1122
  async function selectHarnesses(flags) {
947
1123
  const requested = flags.all ? Object.keys(HARNESS_BY_ID) : parseHarnessList(flags.harness);
948
- const detected = [];
949
- for (const harness of Object.values(HARNESS_BY_ID)) {
950
- if (await safeDetect(harness)) detected.push(harness);
951
- }
952
-
953
1124
  if (requested.length > 0) {
954
1125
  return requested.map((id) => HARNESS_BY_ID[id]).filter(Boolean);
955
1126
  }
956
1127
 
1128
+ const detected = [];
1129
+ for (const id of DEFAULT_INSTALL_HARNESS_IDS) {
1130
+ const harness = HARNESS_BY_ID[id];
1131
+ if (await safeDetect(harness)) detected.push(harness);
1132
+ }
1133
+
957
1134
  if (flags.yes) return detected;
958
1135
 
959
1136
  return promptHarnessCheckboxes(detected);
@@ -1078,8 +1255,74 @@ async function promptHarnessCheckboxesByText(harnesses, selectedIds) {
1078
1255
  }
1079
1256
  }
1080
1257
 
1258
+ async function promptFundingProviderCheckboxes(providerOptions, title,
1259
+ initialIds = new Set(["subscription"])) {
1260
+ const selectedIds = new Set(initialIds);
1261
+ if (!input.isTTY || !output.isTTY) return selectedIds;
1262
+
1263
+ let cursor = 0;
1264
+ let validation = "";
1265
+ const previousRawMode = input.isRaw;
1266
+ emitKeypressEvents(input);
1267
+ input.setRawMode(true);
1268
+ input.resume();
1269
+ output.write("\x1b[?25l");
1270
+
1271
+ const render = () => {
1272
+ output.write("\x1b[2J\x1b[H");
1273
+ output.write("\n\n");
1274
+ output.write(`${color(title, "bold")}\n\n`);
1275
+ providerOptions.forEach((provider, index) => {
1276
+ const pointer = index === cursor ? color(">", "cyan") : " ";
1277
+ const checked = selectedIds.has(provider.id) ? color("x", "green") : " ";
1278
+ const name = index === cursor ? color(provider.name, "cyan") : provider.name;
1279
+ output.write(` ${pointer} [${checked}] ${name}\n`);
1280
+ });
1281
+ output.write("\nSpace to toggle · Enter to continue.\n");
1282
+ if (validation) output.write(`${validation}\n`);
1283
+ };
1284
+ render();
1285
+
1286
+ return new Promise((resolve, reject) => {
1287
+ const cleanup = () => {
1288
+ input.off("keypress", onKeypress);
1289
+ input.setRawMode(Boolean(previousRawMode));
1290
+ input.pause();
1291
+ output.write("\x1b[?25h\n");
1292
+ };
1293
+ const onKeypress = (_str, key = {}) => {
1294
+ if (key.ctrl && key.name === "c") {
1295
+ cleanup();
1296
+ reject(new Error("Cancelled."));
1297
+ } else if (key.name === "up" || key.name === "k") {
1298
+ cursor = (cursor - 1 + providerOptions.length) % providerOptions.length;
1299
+ validation = "";
1300
+ render();
1301
+ } else if (key.name === "down" || key.name === "j") {
1302
+ cursor = (cursor + 1) % providerOptions.length;
1303
+ validation = "";
1304
+ render();
1305
+ } else if (key.name === "space") {
1306
+ const id = providerOptions[cursor].id;
1307
+ if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id);
1308
+ validation = "";
1309
+ render();
1310
+ } else if (key.name === "return" || key.name === "enter") {
1311
+ if (!selectedIds.size) {
1312
+ validation = "Select at least one provider.";
1313
+ render();
1314
+ return;
1315
+ }
1316
+ cleanup();
1317
+ resolve(selectedIds);
1318
+ }
1319
+ };
1320
+ input.on("keypress", onKeypress);
1321
+ });
1322
+ }
1323
+
1081
1324
  function orderedHarnessesForPrompt(detectedIds) {
1082
- return Object.values(HARNESS_BY_ID).sort((left, right) => {
1325
+ return DEFAULT_INSTALL_HARNESS_IDS.map((id) => HARNESS_BY_ID[id]).sort((left, right) => {
1083
1326
  const leftDetected = detectedIds.has(left.id);
1084
1327
  const rightDetected = detectedIds.has(right.id);
1085
1328
  if (leftDetected !== rightDetected) return leftDetected ? -1 : 1;
@@ -1316,7 +1559,7 @@ function printHelp() {
1316
1559
  console.log(`PennyRouter CLI
1317
1560
 
1318
1561
  Usage:
1319
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]] [--allow-running-harnesses]
1562
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--local[=URL]] [--allow-running-harnesses]
1320
1563
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1321
1564
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1322
1565
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -1328,6 +1571,9 @@ Examples:
1328
1571
  npx pennyrouter install --make-default
1329
1572
  npx pennyrouter install --penny-only
1330
1573
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
1574
+ npx pennyrouter install --harness claude-code --anthropic-api-key <key>
1575
+ npx pennyrouter install --harness codex --openai-api-key <key>
1576
+ npx pennyrouter install --openrouter-api-key <key>
1331
1577
  npx pennyrouter auth anthropic
1332
1578
  npx pennyrouter install --all
1333
1579
  npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
package/src/launch.js CHANGED
@@ -54,7 +54,11 @@ export function codexLaunchProfilePath() {
54
54
  return join(codexHome, "pennyrouter.config.toml");
55
55
  }
56
56
 
57
- export function claudeUserCommandPath() {
57
+ export function claudeUserSkillPath() {
58
+ return join(homeDir(), ".claude", "skills", "penny", "SKILL.md");
59
+ }
60
+
61
+ function legacyClaudeUserCommandPath() {
58
62
  return join(homeDir(), ".claude", "commands", "penny.md");
59
63
  }
60
64
 
@@ -180,8 +184,8 @@ async function writeClaudeLaunchSettings() {
180
184
  await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
181
185
  }
182
186
 
183
- async function writeClaudeUserCommand() {
184
- const path = claudeUserCommandPath();
187
+ async function writeClaudeUserSkill() {
188
+ const path = claudeUserSkillPath();
185
189
  const existing = await readText(path, "");
186
190
  if (existing.trim() && !existing.includes(CLAUDE_COMMAND_MARKER)) {
187
191
  throw new Error(
@@ -191,14 +195,23 @@ async function writeClaudeUserCommand() {
191
195
  await mkdir(dirname(path), { recursive: true });
192
196
  await atomicWrite(path, [
193
197
  "---",
198
+ "name: penny",
194
199
  "description: PennyRouter commands and help.",
195
200
  "argument-hint: <command and arguments>",
201
+ "disable-model-invocation: true",
196
202
  "---",
197
203
  CLAUDE_COMMAND_MARKER,
198
204
  "",
199
205
  "@penny $ARGUMENTS",
200
206
  "",
201
207
  ].join("\n"));
208
+ const legacyPath = legacyClaudeUserCommandPath();
209
+ const legacy = await readText(legacyPath, "");
210
+ if (legacy.includes(CLAUDE_COMMAND_MARKER)) {
211
+ await unlink(legacyPath).catch((error) => {
212
+ if (error.code !== "ENOENT") throw error;
213
+ });
214
+ }
202
215
  }
203
216
 
204
217
  async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
@@ -223,7 +236,7 @@ export async function configurePennyHarness(harness, {
223
236
  config.api_key = apiKey;
224
237
  config.harnesses ||= {};
225
238
  if (harness === "claude-code") {
226
- await writeClaudeUserCommand();
239
+ await writeClaudeUserSkill();
227
240
  await writeClaudeLaunchSettings();
228
241
  config.harnesses.claude = {
229
242
  enabled: true,
@@ -258,9 +271,10 @@ export async function removePennyHarness(harness) {
258
271
  await unlink(claudeLaunchSettingsPath()).catch((error) => {
259
272
  if (error.code !== "ENOENT") throw error;
260
273
  });
261
- const command = await readText(claudeUserCommandPath(), "");
262
- if (command.includes(CLAUDE_COMMAND_MARKER)) {
263
- await unlink(claudeUserCommandPath()).catch((error) => {
274
+ for (const path of [claudeUserSkillPath(), legacyClaudeUserCommandPath()]) {
275
+ const command = await readText(path, "");
276
+ if (!command.includes(CLAUDE_COMMAND_MARKER)) continue;
277
+ await unlink(path).catch((error) => {
264
278
  if (error.code !== "ENOENT") throw error;
265
279
  });
266
280
  }
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import {
7
7
  buildPennyInvocation,
8
8
  claudeLaunchSettingsPath,
9
- claudeUserCommandPath,
9
+ claudeUserSkillPath,
10
10
  codexLaunchProfilePath,
11
11
  configurePennyHarness,
12
12
  fileMode,
@@ -108,19 +108,20 @@ async function main() {
108
108
  assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
109
109
  assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
110
110
 
111
- const command = fs.readFileSync(claudeUserCommandPath(), "utf8");
111
+ const command = fs.readFileSync(claudeUserSkillPath(), "utf8");
112
112
  assert.ok(command.includes("@penny $ARGUMENTS"));
113
+ assert.ok(command.includes("name: penny"));
113
114
 
114
115
  await removePennyHarness("claude-code");
115
- assert.ok(!fs.existsSync(claudeUserCommandPath()));
116
- fs.mkdirSync(path.dirname(claudeUserCommandPath()), { recursive: true });
117
- fs.writeFileSync(claudeUserCommandPath(), "user-owned command\n");
116
+ assert.ok(!fs.existsSync(claudeUserSkillPath()));
117
+ fs.mkdirSync(path.dirname(claudeUserSkillPath()), { recursive: true });
118
+ fs.writeFileSync(claudeUserSkillPath(), "user-owned command\n");
118
119
  await assert.rejects(configurePennyHarness("claude-code", {
119
120
  apiKey: "pr-super-secret",
120
121
  gatewayBaseUrl: "https://gateway.example/",
121
122
  models: {},
122
123
  }), /not managed by PennyRouter/);
123
- assert.equal(fs.readFileSync(claudeUserCommandPath(), "utf8"), "user-owned command\n");
124
+ assert.equal(fs.readFileSync(claudeUserSkillPath(), "utf8"), "user-owned command\n");
124
125
  assert.ok(fs.existsSync(codexLaunchProfilePath()));
125
126
  assert.ok(fs.existsSync(pennyShimPath()));
126
127
  await removePennyHarness("codex");
package/src/session.js CHANGED
@@ -46,10 +46,10 @@ export function sleep(ms) {
46
46
  return new Promise((resolve) => setTimeout(resolve, ms));
47
47
  }
48
48
 
49
- // Encrypt-and-store an Anthropic setup-token against the account server-side, so the
50
- // pr- key alone unlocks Claude and no token is written to the harness config. The
51
- // gateway (not the web app) owns this: it holds the pr- key auth and the encryption.
52
- export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
49
+ // Encrypt and store a user-owned upstream credential against the account server-side,
50
+ // so the pr- key alone unlocks the selected harness and the provider secret is never
51
+ // written to harness configuration.
52
+ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider, token }) {
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 storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
57
57
  "content-type": "application/json",
58
58
  authorization: `Bearer ${apiKey}`,
59
59
  },
60
- body: JSON.stringify({ provider: "anthropic", token }),
60
+ body: JSON.stringify({ provider, token }),
61
61
  });
62
62
  if (!response.ok) {
63
63
  let detail = "";
@@ -66,15 +66,22 @@ export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
66
66
  } catch {
67
67
  // non-JSON body; fall through to status-only message
68
68
  }
69
- throw new Error(`Storing Claude token failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
69
+ throw new Error(`Storing ${provider} credential failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
70
70
  }
71
71
  return response.json();
72
72
  }
73
73
 
74
+ export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
75
+ return storeUpstreamCredential({
76
+ gatewayBaseUrl, apiKey, provider: "anthropic_oauth", token,
77
+ });
78
+ }
79
+
74
80
  // Exercise the exact modern Claude Code request feature that has historically broken at
75
81
  // 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.
82
+ // is intentionally sent through /v1/messages rather than a generic /health endpoint, so it
83
+ // proves the gateway can parse the modern beta/tool/context-management envelope. The exact
84
+ // one-token install probe is answered locally by compatible gateways and costs nothing.
78
85
  export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
79
86
  const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/messages?beta=true`;
80
87
  const controller = new AbortController();
@@ -91,7 +98,7 @@ export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
91
98
  },
92
99
  body: JSON.stringify({
93
100
  model: "Penny Sonnet",
94
- max_tokens: 16,
101
+ max_tokens: 1,
95
102
  stream: false,
96
103
  messages: [{ role: "user", content: "Reply OK." }],
97
104
  tools: [{
@@ -28,6 +28,7 @@ try {
28
28
  assert.equal(seen.options.headers.authorization, "Bearer pr-test");
29
29
  assert.equal(seen.options.headers["anthropic-beta"], "compact-2026-01-12");
30
30
  const body = JSON.parse(seen.options.body);
31
+ assert.equal(body.max_tokens, 1);
31
32
  assert.equal(body.context_management.edits[0].type, "compact_20260112");
32
33
  assert.equal(body.tools[0].name, "pennyrouter_install_probe");
33
34