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 +19 -8
- package/package.json +1 -1
- package/src/cli.js +280 -34
- package/src/launch.js +21 -7
- package/src/launch.test.js +7 -6
- package/src/session.js +16 -9
- package/src/session.test.js +1 -0
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
|
|
26
|
-
|
|
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
|
|
29
|
-
|
|
30
|
-
|
|
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
|
|
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
|
|
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
|
|
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
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
|
|
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
|
|
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
|
-
?
|
|
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
|
|
592
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
184
|
-
const path =
|
|
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
|
|
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
|
|
262
|
-
|
|
263
|
-
|
|
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
|
}
|
package/src/launch.test.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import {
|
|
7
7
|
buildPennyInvocation,
|
|
8
8
|
claudeLaunchSettingsPath,
|
|
9
|
-
|
|
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(
|
|
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(
|
|
116
|
-
fs.mkdirSync(path.dirname(
|
|
117
|
-
fs.writeFileSync(
|
|
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(
|
|
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
|
|
50
|
-
// pr- key alone unlocks
|
|
51
|
-
//
|
|
52
|
-
export async function
|
|
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
|
|
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
|
|
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
|
|
77
|
-
//
|
|
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:
|
|
101
|
+
max_tokens: 1,
|
|
95
102
|
stream: false,
|
|
96
103
|
messages: [{ role: "user", content: "Reply OK." }],
|
|
97
104
|
tools: [{
|
package/src/session.test.js
CHANGED
|
@@ -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
|
|