pennyrouter 0.3.6 → 0.3.8
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 +38 -0
- package/package.json +1 -1
- package/src/cli.js +164 -70
- package/src/cli.test.js +82 -0
- package/src/harnesses/cline.js +4 -2
- package/src/harnesses/codegpt.js +4 -2
- package/src/install-manifest.json +8 -2
- package/src/session.js +29 -0
package/README.md
CHANGED
|
@@ -23,6 +23,44 @@ npx pennyrouter status
|
|
|
23
23
|
Installation opens a browser and requires login to the PennyRouter account that should own
|
|
24
24
|
the new machine key. CLI installs no longer create anonymous ghost accounts.
|
|
25
25
|
|
|
26
|
+
## Private and enterprise gateways
|
|
27
|
+
|
|
28
|
+
Installing against a self-hosted gateway is its own flow, with no browser login: the gateway
|
|
29
|
+
keeps its own accounts, so the key is minted there.
|
|
30
|
+
|
|
31
|
+
```bash
|
|
32
|
+
npx pennyrouter install --private-gateway https://gateway.example.internal
|
|
33
|
+
```
|
|
34
|
+
|
|
35
|
+
It asks for the gateway URL (unless given) and your email, and mints a key against that
|
|
36
|
+
gateway. Installing on a second machine with the same email adds a key to the same account.
|
|
37
|
+
Where an administrator issues keys instead, pass `--private-key pr-...` and no email is
|
|
38
|
+
asked for. Self-service minting works only where the operator enabled it
|
|
39
|
+
(`PENNYROUTER_OPEN_PROVISIONING`); otherwise install says so.
|
|
40
|
+
|
|
41
|
+
To hand a whole team one file, put the shared answers in JSON and leave out the per-person
|
|
42
|
+
ones:
|
|
43
|
+
|
|
44
|
+
```json
|
|
45
|
+
{
|
|
46
|
+
"harnesses": ["claude-code", "codex"],
|
|
47
|
+
"gatewayBaseUrl": "https://gateway.example.internal",
|
|
48
|
+
"claudeFunding": ["anthropic-proxy"],
|
|
49
|
+
"proxyBaseUrl": "https://litellm.example.internal",
|
|
50
|
+
"codexFunding": ["openai-proxy"],
|
|
51
|
+
"openaiProxyBaseUrl": "https://litellm.example.internal"
|
|
52
|
+
}
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
npx pennyrouter install --config team-install.json
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Each person is prompted for their email and their proxy key; everything else is prefilled.
|
|
60
|
+
Any field left out of the file is prompted for, and an unattended run that cannot prompt
|
|
61
|
+
refuses by name rather than installing a half-configured harness. Add `email`, `proxyApiKey`
|
|
62
|
+
and `openaiProxyApiKey` to the file to skip the prompts entirely.
|
|
63
|
+
|
|
26
64
|
With no `--harness` flag, the interactive installer shows Claude Code, Codex, and OpenCode. Detected
|
|
27
65
|
tools start checked. The additional legacy integrations remain available through an explicit
|
|
28
66
|
`--harness <id>` selection or `--all`.
|
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
2
|
import { readFileSync } from "node:fs";
|
|
3
|
+
import { hostname } from "node:os";
|
|
3
4
|
import { emitKeypressEvents } from "node:readline";
|
|
4
5
|
import { createInterface } from "node:readline/promises";
|
|
5
6
|
import { stdin as input, stdout as output } from "node:process";
|
|
@@ -30,6 +31,7 @@ import {
|
|
|
30
31
|
maskKey,
|
|
31
32
|
pollForKey,
|
|
32
33
|
probeClaudeCodeGateway,
|
|
34
|
+
provisionPrivateGatewayKey,
|
|
33
35
|
sleep,
|
|
34
36
|
storeAnthropicToken,
|
|
35
37
|
storeUpstreamCredential,
|
|
@@ -193,15 +195,40 @@ function parseArgs(argv) {
|
|
|
193
195
|
else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
|
|
194
196
|
else if (arg === "--harness") flags.harness = rest[++i] || "";
|
|
195
197
|
else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
|
|
198
|
+
// --private-gateway [url]: install against a self-hosted gateway. Unlike
|
|
199
|
+
// --gateway-base-url, which expects the URL and an admin-issued key up front, this asks
|
|
200
|
+
// for what it needs (gateway URL, then email) and mints the key from the gateway itself,
|
|
201
|
+
// so a user on a VPN can install without an administrator handing them a key first.
|
|
202
|
+
else if (arg === "--private-gateway") {
|
|
203
|
+
flags.privateGatewayFlow = true;
|
|
204
|
+
flags.privateGateway = true;
|
|
205
|
+
// Optional inline URL: `--private-gateway https://...` rather than a prompt. Only
|
|
206
|
+
// consume the next argument when it is not itself a flag.
|
|
207
|
+
if (rest[i + 1] && !rest[i + 1].startsWith("-")) flags.gatewayBaseUrl = rest[++i];
|
|
208
|
+
}
|
|
209
|
+
else if (arg.startsWith("--private-gateway=")) {
|
|
210
|
+
flags.privateGatewayFlow = true;
|
|
211
|
+
flags.privateGateway = true;
|
|
212
|
+
flags.gatewayBaseUrl = arg.slice("--private-gateway=".length);
|
|
213
|
+
}
|
|
214
|
+
// Email the private gateway mints the key against; prompted for when absent.
|
|
215
|
+
else if (arg === "--email") flags.email = rest[++i] || "";
|
|
216
|
+
else if (arg.startsWith("--email=")) flags.email = arg.slice("--email=".length);
|
|
196
217
|
else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
|
|
197
218
|
else if (arg.startsWith("--app-url=")) flags.appUrl = arg.slice("--app-url=".length);
|
|
198
|
-
// A --gateway-base-url is a *private* gateway: an admin-run deployment
|
|
199
|
-
//
|
|
200
|
-
// developer shortcuts against gateways that
|
|
201
|
-
|
|
219
|
+
// A --gateway-base-url is a *private* gateway: an admin-run deployment with its own key
|
|
220
|
+
// store, so it takes the same install path as --private-gateway (which supplies the URL
|
|
221
|
+
// interactively instead). --local/--prod are developer shortcuts against gateways that
|
|
222
|
+
// honor prod keys, so they do not set this.
|
|
223
|
+
else if (arg === "--gateway-base-url") {
|
|
224
|
+
flags.gatewayBaseUrl = rest[++i] || "";
|
|
225
|
+
flags.privateGateway = true;
|
|
226
|
+
flags.privateGatewayFlow = true;
|
|
227
|
+
}
|
|
202
228
|
else if (arg.startsWith("--gateway-base-url=")) {
|
|
203
229
|
flags.gatewayBaseUrl = arg.slice("--gateway-base-url=".length);
|
|
204
230
|
flags.privateGateway = true;
|
|
231
|
+
flags.privateGatewayFlow = true;
|
|
205
232
|
}
|
|
206
233
|
// Admin-issued key for a private gateway (see scripts/provision_local_key.py). Overrides the
|
|
207
234
|
// key returned by the browser claim, which is only valid against the hosted gateway.
|
|
@@ -258,20 +285,31 @@ export function applyConfigFile(flags) {
|
|
|
258
285
|
for (const option of INSTALL_MANIFEST.funding_groups[group].options) {
|
|
259
286
|
if (!chosen.has(option.id)) continue;
|
|
260
287
|
for (const field of option.fields) {
|
|
261
|
-
|
|
262
|
-
|
|
288
|
+
// Only copy what the file actually carries. Writing "" for an absent field would
|
|
289
|
+
// make it look supplied-but-empty, which reads as "this option was configured" to
|
|
290
|
+
// the provider selection below and skips the prompt that would have filled it --
|
|
291
|
+
// exactly what a shared file with the secrets left out depends on.
|
|
292
|
+
const value = String(config[field.config_key] ?? "").trim();
|
|
293
|
+
if (value && flags[field.config_key] === undefined) {
|
|
294
|
+
flags[field.config_key] = value;
|
|
263
295
|
}
|
|
264
296
|
}
|
|
265
297
|
}
|
|
266
298
|
}
|
|
267
299
|
|
|
268
|
-
// A private gateway is independent of funding: it changes where the CLI
|
|
300
|
+
// A private gateway is independent of funding: it changes where the CLI gets its key and
|
|
269
301
|
// stores credentials, so it applies whatever providers were picked.
|
|
270
302
|
for (const field of INSTALL_MANIFEST.advanced.fields) {
|
|
271
303
|
const value = String(config[field.config_key] || "").trim();
|
|
272
304
|
if (value && flags[field.config_key] === undefined) flags[field.config_key] = value;
|
|
273
305
|
}
|
|
274
|
-
if (flags.gatewayBaseUrl)
|
|
306
|
+
if (flags.gatewayBaseUrl) {
|
|
307
|
+
flags.privateGateway = true;
|
|
308
|
+
// Take the self-hosted install path rather than the hosted browser claim, whose key is
|
|
309
|
+
// meaningless against a gateway with its own account store. The key comes from
|
|
310
|
+
// --private-key when the config carried one, and is otherwise minted from the email.
|
|
311
|
+
flags.privateGatewayFlow = true;
|
|
312
|
+
}
|
|
275
313
|
|
|
276
314
|
flags.yes = true;
|
|
277
315
|
flags.fromConfig = true;
|
|
@@ -599,6 +637,29 @@ export async function install(flags = {}) {
|
|
|
599
637
|
|| globalAuth.gatewayBaseUrl
|
|
600
638
|
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
601
639
|
|| DEFAULT_GATEWAY_BASE_URL;
|
|
640
|
+
} else if (flags.privateGatewayFlow) {
|
|
641
|
+
// A self-hosted gateway keeps its own accounts, so the hosted browser claim would mint a
|
|
642
|
+
// key against a store this install never talks to. Ask for the gateway, mint there, and
|
|
643
|
+
// skip the claim entirely.
|
|
644
|
+
gatewayBaseUrl = await resolvePrivateGatewayUrl(flags);
|
|
645
|
+
claim = { api_key: await obtainPrivateGatewayKey(flags, gatewayBaseUrl), gateway_base_url: gatewayBaseUrl };
|
|
646
|
+
|
|
647
|
+
const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
|
|
648
|
+
const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
|
|
649
|
+
claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
|
|
650
|
+
flags, needsAuth, claudeProviders.has("subscription"));
|
|
651
|
+
funding = await configureUserFunding(
|
|
652
|
+
flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
|
|
653
|
+
|
|
654
|
+
for (const credential of funding.credentials) {
|
|
655
|
+
await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
|
|
656
|
+
gatewayBaseUrl,
|
|
657
|
+
apiKey: claim.api_key,
|
|
658
|
+
provider: credential.provider,
|
|
659
|
+
token: credential.token,
|
|
660
|
+
metadata: credential.metadata,
|
|
661
|
+
}));
|
|
662
|
+
}
|
|
602
663
|
} else {
|
|
603
664
|
const session = await createCliSession({
|
|
604
665
|
appUrl: flags.appUrl || APP_URL,
|
|
@@ -634,58 +695,6 @@ export async function install(flags = {}) {
|
|
|
634
695
|
gatewayBaseUrl = normalized;
|
|
635
696
|
}
|
|
636
697
|
|
|
637
|
-
// A user-supplied proxy may live on a private network that the hosted gateway cannot
|
|
638
|
-
// reach. Let interactive installs choose the gateway after they choose the proxy: blank
|
|
639
|
-
// keeps the hosted default, while a supplied URL is used for credential storage,
|
|
640
|
-
// validation, probing, and the installed harness configuration. `--local` remains the
|
|
641
|
-
// explicit localhost:8400 developer shortcut and skips this prompt.
|
|
642
|
-
if ((claudeProviders.has("anthropic-proxy") || codexProviders.has("openai-proxy")) && !flags.gatewayBaseUrl
|
|
643
|
-
&& !flags.yes && input.isTTY && output.isTTY) {
|
|
644
|
-
const answer = (await promptText(
|
|
645
|
-
"PennyRouter gateway URL for this proxy (blank for hosted gateway): ",
|
|
646
|
-
)).trim();
|
|
647
|
-
if (answer) {
|
|
648
|
-
const privateGateway = normalizeGatewayBaseUrl(answer);
|
|
649
|
-
if (!privateGateway) {
|
|
650
|
-
throw new Error(
|
|
651
|
-
`"${answer}" is not a usable gateway URL. Use a host like `
|
|
652
|
-
+ "localhost:8400 or https://gateway.example.com.",
|
|
653
|
-
);
|
|
654
|
-
}
|
|
655
|
-
// Show the normalized form: the user typed "localhost:8000" and everything from here on
|
|
656
|
-
// uses "http://localhost:8000", so any later error names a URL they recognize.
|
|
657
|
-
if (privateGateway !== answer) console.log(`Using gateway ${privateGateway}.`);
|
|
658
|
-
gatewayBaseUrl = privateGateway;
|
|
659
|
-
flags.privateGateway = true;
|
|
660
|
-
}
|
|
661
|
-
}
|
|
662
|
-
|
|
663
|
-
// A private gateway has its own key store, so the hosted key from the browser claim is
|
|
664
|
-
// meaningless there. Require the admin-issued key and use it for every subsequent gateway
|
|
665
|
-
// call (credential storage, probing, harness config).
|
|
666
|
-
if (flags.privateGateway) {
|
|
667
|
-
let privateKey = (flags.privateKey || "").trim();
|
|
668
|
-
if (!privateKey && !flags.yes && input.isTTY && output.isTTY) {
|
|
669
|
-
console.log("");
|
|
670
|
-
console.log(`Private gateway: ${gatewayBaseUrl}`);
|
|
671
|
-
console.log("Your PennyRouter administrator issues keys for this gateway.");
|
|
672
|
-
// No echo: this is an issued credential, and a visible paste lingers in scrollback.
|
|
673
|
-
privateKey = (await promptSecretish("Paste your private gateway key (pr-...): ")).trim();
|
|
674
|
-
}
|
|
675
|
-
if (!privateKey) {
|
|
676
|
-
throw new Error(
|
|
677
|
-
`A private gateway (${gatewayBaseUrl}) needs its own key. `
|
|
678
|
-
+ "Ask your PennyRouter administrator for one and pass `--private-key pr-...`.",
|
|
679
|
-
);
|
|
680
|
-
}
|
|
681
|
-
if (!privateKey.startsWith("pr-")) {
|
|
682
|
-
throw new Error(`That does not look like a PennyRouter key — expected it to start with "pr-".`);
|
|
683
|
-
}
|
|
684
|
-
claim.api_key = privateKey;
|
|
685
|
-
console.log(`Using private gateway key ${maskKey(privateKey)}.`);
|
|
686
|
-
console.log("");
|
|
687
|
-
}
|
|
688
|
-
|
|
689
698
|
for (const credential of funding.credentials) {
|
|
690
699
|
await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
|
|
691
700
|
gatewayBaseUrl,
|
|
@@ -886,6 +895,76 @@ function mcpTargets(flags) {
|
|
|
886
895
|
"fetch failed" long after the prompt, where the cause is invisible. Supply the obvious scheme
|
|
887
896
|
(plain http for loopback, https elsewhere) and prove the result parses, so a typo fails here
|
|
888
897
|
instead. Returns null when the value cannot be salvaged. */
|
|
898
|
+
/* Resolve the private gateway's URL, prompting when it was not given inline. Normalizing
|
|
899
|
+
here means every later message (credential storage, probe, harness config) names the same
|
|
900
|
+
URL the user will recognize. */
|
|
901
|
+
async function resolvePrivateGatewayUrl(flags) {
|
|
902
|
+
let raw = (flags.gatewayBaseUrl || "").trim();
|
|
903
|
+
if (!raw) {
|
|
904
|
+
if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
|
|
905
|
+
throw new Error(
|
|
906
|
+
"--private-gateway needs the gateway URL in a non-interactive install: "
|
|
907
|
+
+ "`--private-gateway https://gateway.example.com`.",
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
raw = await promptText("PennyRouter gateway URL: ");
|
|
911
|
+
}
|
|
912
|
+
const normalized = normalizeGatewayBaseUrl(raw);
|
|
913
|
+
if (!normalized) {
|
|
914
|
+
throw new Error(
|
|
915
|
+
`"${raw}" is not a usable gateway URL. Use a host like `
|
|
916
|
+
+ "localhost:8400 or https://gateway.example.com.",
|
|
917
|
+
);
|
|
918
|
+
}
|
|
919
|
+
if (normalized !== raw.replace(/\/+$/, "")) console.log(`Using gateway ${normalized}.`);
|
|
920
|
+
return normalized;
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/* The key for a private gateway: an admin-issued one if the user has it, otherwise minted
|
|
924
|
+
from the gateway itself against their email. --private-key wins so an administrator can
|
|
925
|
+
still hand out keys on a gateway that also allows self-service. */
|
|
926
|
+
async function obtainPrivateGatewayKey(flags, gatewayBaseUrl) {
|
|
927
|
+
const issued = (flags.privateKey || "").trim();
|
|
928
|
+
if (issued) {
|
|
929
|
+
if (!issued.startsWith("pr-")) {
|
|
930
|
+
throw new Error(`That does not look like a PennyRouter key — expected it to start with "pr-".`);
|
|
931
|
+
}
|
|
932
|
+
console.log(`Using private gateway key ${maskKey(issued)}.`);
|
|
933
|
+
console.log("");
|
|
934
|
+
return issued;
|
|
935
|
+
}
|
|
936
|
+
|
|
937
|
+
let email = (flags.email || "").trim();
|
|
938
|
+
if (!email) {
|
|
939
|
+
// A shared --config file names the gateway but not who is installing, so the email is
|
|
940
|
+
// still asked for even though --config implies --yes.
|
|
941
|
+
if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
|
|
942
|
+
throw new Error(
|
|
943
|
+
"--private-gateway needs an email in a non-interactive install: `--email you@example.com` "
|
|
944
|
+
+ "(or pass an admin-issued `--private-key pr-...`).",
|
|
945
|
+
);
|
|
946
|
+
}
|
|
947
|
+
console.log("");
|
|
948
|
+
console.log(`Private gateway: ${gatewayBaseUrl}`);
|
|
949
|
+
email = await promptText("Your email (identifies your account on this gateway): ");
|
|
950
|
+
}
|
|
951
|
+
if (!email.includes("@")) throw new Error(`"${email}" is not a valid email address.`);
|
|
952
|
+
|
|
953
|
+
// Names the key after this machine so a user with several installs can tell their keys
|
|
954
|
+
// apart in the account's key list.
|
|
955
|
+
const machine = hostname().split(".")[0] || "default";
|
|
956
|
+
const result = await withGatewayContext(gatewayBaseUrl, () => provisionPrivateGatewayKey({
|
|
957
|
+
gatewayBaseUrl, email, name: machine,
|
|
958
|
+
}));
|
|
959
|
+
if (!result?.api_key) throw new Error("The gateway did not return a key.");
|
|
960
|
+
console.log(result.created_account
|
|
961
|
+
? `Created your account on ${gatewayBaseUrl}.`
|
|
962
|
+
: `Added this machine to your existing account on ${gatewayBaseUrl}.`);
|
|
963
|
+
console.log(`Received key ${maskKey(result.api_key)}.`);
|
|
964
|
+
console.log("");
|
|
965
|
+
return result.api_key;
|
|
966
|
+
}
|
|
967
|
+
|
|
889
968
|
function normalizeGatewayBaseUrl(raw) {
|
|
890
969
|
const value = String(raw || "").trim().replace(/\/+$/, "");
|
|
891
970
|
if (!value) return null;
|
|
@@ -1218,6 +1297,12 @@ async function selectClaudeFundingProviders(flags, selected) {
|
|
|
1218
1297
|
&& (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
|
|
1219
1298
|
supplied.add("anthropic-proxy");
|
|
1220
1299
|
}
|
|
1300
|
+
// A --config file states its providers outright, and that choice stands whether or not the
|
|
1301
|
+
// file also carried their secrets: a file shared with a team deliberately omits those, and
|
|
1302
|
+
// configureUserFunding prompts for what is missing (or refuses, with no TTY to prompt on).
|
|
1303
|
+
// Without this, an option whose secret was left out is silently dropped and the install
|
|
1304
|
+
// completes having configured nothing.
|
|
1305
|
+
for (const id of flags.configClaudeFunding || []) supplied.add(id);
|
|
1221
1306
|
|
|
1222
1307
|
// Explicit flags are the automation selector. Interactive installs always default to the
|
|
1223
1308
|
// subscription alone; environment keys are used if the user checks their provider.
|
|
@@ -1231,7 +1316,9 @@ async function selectClaudeFundingProviders(flags, selected) {
|
|
|
1231
1316
|
return promptFundingProviderCheckboxes(
|
|
1232
1317
|
CLAUDE_PROVIDER_OPTIONS,
|
|
1233
1318
|
"Which providers should PennyRouter use for Claude Code?",
|
|
1234
|
-
|
|
1319
|
+
// An enterprise gateway usually fronts the model provider itself, so the proxy is the
|
|
1320
|
+
// expected answer there and leads; elsewhere the subscription does.
|
|
1321
|
+
new Set([flags.privateGatewayFlow ? "anthropic-proxy" : "subscription"]),
|
|
1235
1322
|
);
|
|
1236
1323
|
}
|
|
1237
1324
|
|
|
@@ -1243,6 +1330,7 @@ async function selectCodexFundingProviders(flags, selected) {
|
|
|
1243
1330
|
if ((flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL)
|
|
1244
1331
|
&& (flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY)) supplied.add("openai-proxy");
|
|
1245
1332
|
if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
|
|
1333
|
+
for (const id of flags.configCodexFunding || []) supplied.add(id);
|
|
1246
1334
|
const hasChatgptLogin = await detectCodexChatgptAuth();
|
|
1247
1335
|
if (hasChatgptLogin) supplied.add("subscription");
|
|
1248
1336
|
|
|
@@ -1254,7 +1342,7 @@ async function selectCodexFundingProviders(flags, selected) {
|
|
|
1254
1342
|
return promptFundingProviderCheckboxes(
|
|
1255
1343
|
CODEX_PROVIDER_OPTIONS,
|
|
1256
1344
|
"Which providers should PennyRouter use for Codex?",
|
|
1257
|
-
new Set(["subscription"]),
|
|
1345
|
+
new Set([flags.privateGatewayFlow ? "openai-proxy" : "subscription"]),
|
|
1258
1346
|
);
|
|
1259
1347
|
}
|
|
1260
1348
|
|
|
@@ -1319,7 +1407,10 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
1319
1407
|
claudeProviders = new Set(), codexProviders = new Set()) {
|
|
1320
1408
|
const wantsClaude = selected.some((harness) => harness.id === "claude-code");
|
|
1321
1409
|
const wantsCodex = selected.some((harness) => harness.id === "codex");
|
|
1322
|
-
|
|
1410
|
+
// --config drives --yes to skip the question screens, but a file meant to be shared with a
|
|
1411
|
+
// team deliberately carries no secrets: the fields it leaves out are still asked for here,
|
|
1412
|
+
// the same way the subscription sign-in below stays interactive under --config.
|
|
1413
|
+
const interactive = (!flags.yes || flags.fromConfig) && input.isTTY && output.isTTY;
|
|
1323
1414
|
const credentials = [];
|
|
1324
1415
|
|
|
1325
1416
|
let anthropicApiKey = String(
|
|
@@ -2180,7 +2271,7 @@ function printHelp() {
|
|
|
2180
2271
|
console.log(`PennyRouter CLI
|
|
2181
2272
|
|
|
2182
2273
|
Usage:
|
|
2183
|
-
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] [--openai-proxy-base-url URL --openai-proxy-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--gateway-base-url URL --private-key pr-...] [--local[=URL]|--prod] [--config PATH]
|
|
2274
|
+
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] [--openai-proxy-base-url URL --openai-proxy-api-key KEY] [--openrouter-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--private-gateway[=URL] [--email you@example.com]] [--gateway-base-url URL] [--private-key pr-...] [--local[=URL]|--prod] [--config PATH]
|
|
2184
2275
|
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
|
|
2185
2276
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
2186
2277
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
@@ -2189,13 +2280,14 @@ Usage:
|
|
|
2189
2280
|
pennyrouter status
|
|
2190
2281
|
pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
|
|
2191
2282
|
|
|
2192
|
-
|
|
2193
|
-
|
|
2194
|
-
|
|
2283
|
+
Installing against a private/self-hosted gateway is its own flow: "pennyrouter install --private-gateway"
|
|
2284
|
+
asks for the gateway URL and your email, mints your key on that gateway, and skips the hosted browser
|
|
2285
|
+
login entirely. Pass them up front to run it unattended: --private-gateway URL --email you@example.com.
|
|
2286
|
+
--gateway-base-url URL takes the same path. If your administrator already issued you a key, pass
|
|
2287
|
+
--private-key pr-... and no email is asked for.
|
|
2195
2288
|
|
|
2196
|
-
|
|
2197
|
-
|
|
2198
|
-
supply the key your PennyRouter administrator issued you: paste it when asked, or pass --private-key pr-...
|
|
2289
|
+
Self-service keys only work where the operator enabled them (PENNYROUTER_OPEN_PROVISIONING on the
|
|
2290
|
+
gateway); otherwise install says so and you need an administrator-issued --private-key.
|
|
2199
2291
|
|
|
2200
2292
|
Install and update both register the Threads MCP server with Claude Code and Codex (--no-mcp to
|
|
2201
2293
|
skip a run). Run "pennyrouter mcp install" if you skipped it or had to rename a conflicting
|
|
@@ -2227,6 +2319,8 @@ Examples:
|
|
|
2227
2319
|
npx pennyrouter install --harness codex --openai-api-key <key>
|
|
2228
2320
|
npx pennyrouter install --harness claude-code --proxy-base-url https://proxy.example.com --proxy-api-key <key>
|
|
2229
2321
|
npx pennyrouter install --openrouter-api-key <key>
|
|
2322
|
+
npx pennyrouter install --private-gateway
|
|
2323
|
+
npx pennyrouter install --private-gateway https://gateway.example.com --email you@example.com
|
|
2230
2324
|
npx pennyrouter install --gateway-base-url https://gateway.internal.example.com --private-key pr-...
|
|
2231
2325
|
npx pennyrouter auth anthropic
|
|
2232
2326
|
npx pennyrouter install --all
|
package/src/cli.test.js
CHANGED
|
@@ -269,3 +269,85 @@ test("--config leaves the hosted gateway alone when no private gateway is given"
|
|
|
269
269
|
},
|
|
270
270
|
);
|
|
271
271
|
});
|
|
272
|
+
|
|
273
|
+
// --private-gateway is its own install path: it never opens the hosted browser claim, so the
|
|
274
|
+
// URL and email it needs must either be supplied or prompted for. Non-interactive runs can do
|
|
275
|
+
// neither, and must say which piece is missing rather than failing later at the gateway.
|
|
276
|
+
test("--private-gateway requires a gateway URL when it cannot prompt", async () => {
|
|
277
|
+
await assert.rejects(
|
|
278
|
+
() => captureLogs(() => main(["install", "--private-gateway", "--yes", "--harness", "claude-code"])),
|
|
279
|
+
/needs the gateway URL/,
|
|
280
|
+
);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test("--private-gateway requires an email when it cannot prompt", async () => {
|
|
284
|
+
await assert.rejects(
|
|
285
|
+
() => captureLogs(() => main([
|
|
286
|
+
"install", "--private-gateway", "https://gateway.example.com", "--yes", "--harness", "claude-code",
|
|
287
|
+
])),
|
|
288
|
+
/needs an email/,
|
|
289
|
+
);
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// An admin-issued key stands in for the email, so this must not ask for one.
|
|
293
|
+
test("--private-key satisfies --private-gateway without an email", async () => {
|
|
294
|
+
const logs = await captureLogs(() => main([
|
|
295
|
+
"install", "--private-gateway=https://gateway.example.com", "--private-key", "pr-test",
|
|
296
|
+
"--yes", "--harness", "claude-code", "--dry-run",
|
|
297
|
+
]));
|
|
298
|
+
assert.match(logs.join("\n"), /Serving gateway: https:\/\/gateway\.example\.com/);
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
// A bare --private-gateway must not swallow the following flag as its optional URL.
|
|
302
|
+
test("--private-gateway does not consume a following flag as its URL", async () => {
|
|
303
|
+
await assert.rejects(
|
|
304
|
+
() => captureLogs(() => main(["install", "--private-gateway", "--yes", "--harness", "claude-code"])),
|
|
305
|
+
/needs the gateway URL/,
|
|
306
|
+
);
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
// A --config file meant to be shared with a team carries no secrets and does not know who is
|
|
310
|
+
// installing. --config implies --yes, so the omitted fields are prompted for when there is a
|
|
311
|
+
// TTY; with no TTY the install must REFUSE. It previously completed here having configured no
|
|
312
|
+
// credential at all, because a funding option whose secret was absent was silently dropped.
|
|
313
|
+
test("a shared --config without its secret refuses rather than installing nothing", async () => {
|
|
314
|
+
// Isolate the install state: against a real one where this harness is already installed
|
|
315
|
+
// with this funding, install takes its "nothing changed" refresh path and never reaches
|
|
316
|
+
// credential collection.
|
|
317
|
+
const stateDir = mkdtempSync(join(tmpdir(), "pennyrouter-state-"));
|
|
318
|
+
const priorState = process.env.XDG_STATE_HOME;
|
|
319
|
+
process.env.XDG_STATE_HOME = stateDir;
|
|
320
|
+
try {
|
|
321
|
+
await assert.rejects(
|
|
322
|
+
() => withConfigFile({
|
|
323
|
+
harnesses: ["claude-code"],
|
|
324
|
+
gatewayBaseUrl: "https://gateway.example.com",
|
|
325
|
+
claudeFunding: ["anthropic-proxy"],
|
|
326
|
+
proxyBaseUrl: "https://proxy.example.com",
|
|
327
|
+
privateKey: "pr-test",
|
|
328
|
+
}, (path) => captureLogs(() => main(["install", "--config", path]))),
|
|
329
|
+
/--proxy-base-url and --proxy-api-key were not both provided/,
|
|
330
|
+
);
|
|
331
|
+
} finally {
|
|
332
|
+
if (priorState === undefined) delete process.env.XDG_STATE_HOME;
|
|
333
|
+
else process.env.XDG_STATE_HOME = priorState;
|
|
334
|
+
rmSync(stateDir, { recursive: true, force: true });
|
|
335
|
+
}
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
// The config names its providers; that choice stands even when the file omits their secrets.
|
|
339
|
+
test("--config funding choices survive a missing secret", async () => {
|
|
340
|
+
await withConfigFile({
|
|
341
|
+
harnesses: ["claude-code"],
|
|
342
|
+
claudeFunding: ["anthropic-proxy"],
|
|
343
|
+
proxyBaseUrl: "https://proxy.example.com",
|
|
344
|
+
}, (path) => {
|
|
345
|
+
const flags = { configPath: path, args: [] };
|
|
346
|
+
applyConfigFile(flags);
|
|
347
|
+
assert.deepEqual([...flags.configClaudeFunding], ["anthropic-proxy"]);
|
|
348
|
+
// Absent fields stay undefined so the prompt path can fill them; "" would read as
|
|
349
|
+
// supplied-but-empty and skip it.
|
|
350
|
+
assert.equal(flags.proxyApiKey, undefined);
|
|
351
|
+
assert.equal(flags.proxyBaseUrl, "https://proxy.example.com");
|
|
352
|
+
});
|
|
353
|
+
});
|
package/src/harnesses/cline.js
CHANGED
|
@@ -51,11 +51,13 @@ export const clineHarness = {
|
|
|
51
51
|
};
|
|
52
52
|
},
|
|
53
53
|
|
|
54
|
-
|
|
54
|
+
// The record carries the base URL this install actually wrote, so a private/self-hosted
|
|
55
|
+
// gateway is named here instead of the hosted default the user never configured.
|
|
56
|
+
async uninstall(record) {
|
|
55
57
|
console.log("");
|
|
56
58
|
console.log("Cline manual uninstall");
|
|
57
59
|
console.log(" Open Cline settings in VS Code and remove or replace the PennyRouter OpenAI Compatible provider:");
|
|
58
|
-
console.log(
|
|
60
|
+
console.log(` - Base URL: ${record?.changes?.base_url || "https://api.pennyrouter.com/v1"}`);
|
|
59
61
|
console.log(" - API Key: pr-...");
|
|
60
62
|
console.log(" - Model ID: pennyrouter/auto");
|
|
61
63
|
},
|
package/src/harnesses/codegpt.js
CHANGED
|
@@ -50,11 +50,13 @@ export const codeGptHarness = {
|
|
|
50
50
|
};
|
|
51
51
|
},
|
|
52
52
|
|
|
53
|
-
|
|
53
|
+
// The record carries the base URL this install actually wrote, so a private/self-hosted
|
|
54
|
+
// gateway is named here instead of the hosted default the user never configured.
|
|
55
|
+
async uninstall(record) {
|
|
54
56
|
console.log("");
|
|
55
57
|
console.log("JetBrains CodeGPT manual uninstall");
|
|
56
58
|
console.log(" Open CodeGPT settings and remove or replace the PennyRouter provider:");
|
|
57
|
-
console.log(
|
|
59
|
+
console.log(` - Base URL: ${record?.changes?.base_url || "https://api.pennyrouter.com/v1"}`);
|
|
58
60
|
console.log(" - API Key: pr-...");
|
|
59
61
|
console.log(" - Model: pennyrouter/auto");
|
|
60
62
|
},
|
|
@@ -107,7 +107,7 @@
|
|
|
107
107
|
},
|
|
108
108
|
"advanced": {
|
|
109
109
|
"title": "Private PennyRouter gateway",
|
|
110
|
-
"description": "Route through
|
|
110
|
+
"description": "Route through your organization's PennyRouter gateway. Enter your email to get a key from it, or paste one your IT team issued you.",
|
|
111
111
|
"fields": [
|
|
112
112
|
{
|
|
113
113
|
"config_key": "gatewayBaseUrl",
|
|
@@ -116,9 +116,15 @@
|
|
|
116
116
|
"placeholder": "https://gateway.example.com",
|
|
117
117
|
"requires_https": true
|
|
118
118
|
},
|
|
119
|
+
{
|
|
120
|
+
"config_key": "email",
|
|
121
|
+
"label": "Your email",
|
|
122
|
+
"secret": false,
|
|
123
|
+
"placeholder": "you@example.com"
|
|
124
|
+
},
|
|
119
125
|
{
|
|
120
126
|
"config_key": "privateKey",
|
|
121
|
-
"label": "Private gateway key",
|
|
127
|
+
"label": "Private gateway key (if issued)",
|
|
122
128
|
"secret": true,
|
|
123
129
|
"placeholder": "pr-...",
|
|
124
130
|
"prefix": "pr-"
|
package/src/session.js
CHANGED
|
@@ -71,6 +71,35 @@ export async function storeUpstreamCredential({ gatewayBaseUrl, apiKey, provider
|
|
|
71
71
|
return response.json();
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
+
// Mint a key on a private gateway that has self-service provisioning enabled. The hosted
|
|
75
|
+
// browser claim mints against pennyrouter.com's key store, which a private gateway does not
|
|
76
|
+
// share, so this is the equivalent step for a self-hosted install. A gateway that has not
|
|
77
|
+
// opted in answers 404, same as one running a build without the endpoint.
|
|
78
|
+
export async function provisionPrivateGatewayKey({ gatewayBaseUrl, email, name }) {
|
|
79
|
+
const url = `${gatewayBaseUrl.replace(/\/+$/, "")}/v1/provision-key`;
|
|
80
|
+
const response = await fetch(url, {
|
|
81
|
+
method: "POST",
|
|
82
|
+
headers: { "content-type": "application/json" },
|
|
83
|
+
body: JSON.stringify({ email, ...(name ? { name } : {}) }),
|
|
84
|
+
});
|
|
85
|
+
if (!response.ok) {
|
|
86
|
+
let detail = "";
|
|
87
|
+
try {
|
|
88
|
+
detail = (await response.json())?.detail || "";
|
|
89
|
+
} catch {
|
|
90
|
+
// non-JSON body; fall through to status-only message
|
|
91
|
+
}
|
|
92
|
+
if (response.status === 404) {
|
|
93
|
+
throw new Error(
|
|
94
|
+
`${gatewayBaseUrl} does not offer self-service keys. Ask your PennyRouter `
|
|
95
|
+
+ "administrator for one and pass `--private-key pr-...`.",
|
|
96
|
+
);
|
|
97
|
+
}
|
|
98
|
+
throw new Error(`Provisioning a key failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}`);
|
|
99
|
+
}
|
|
100
|
+
return response.json();
|
|
101
|
+
}
|
|
102
|
+
|
|
74
103
|
export async function storeAnthropicToken({ gatewayBaseUrl, apiKey, token }) {
|
|
75
104
|
return storeUpstreamCredential({
|
|
76
105
|
gatewayBaseUrl, apiKey, provider: "anthropic_oauth", token,
|