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 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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
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
@@ -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 that issues its own
199
- // keys. Flag it so install can require the matching admin-issued key. --local/--prod are
200
- // developer shortcuts against gateways that honor prod keys, so they do not set this.
201
- else if (arg === "--gateway-base-url") { flags.gatewayBaseUrl = rest[++i] || ""; flags.privateGateway = true; }
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
- if (flags[field.config_key] === undefined) {
262
- flags[field.config_key] = config[field.config_key] || "";
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 claims its key and
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) flags.privateGateway = true;
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
- new Set(["subscription"]),
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
- const interactive = !flags.yes && input.isTTY && output.isTTY;
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
- When an interactive install selects an Anthropic or OpenAI-compatible proxy, it asks for the PennyRouter gateway
2193
- that can reach that proxy. Leave it blank for the hosted gateway, or enter a private/self-hosted
2194
- gateway URL. Use --gateway-base-url (or --local for localhost:8400 development) for non-interactive installs.
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
- A private gateway keeps its own keys, so the key from the browser login does not work there. Whenever
2197
- you point install at one — with --gateway-base-url or by entering a URL at that prompt — you must also
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
+ });
@@ -51,11 +51,13 @@ export const clineHarness = {
51
51
  };
52
52
  },
53
53
 
54
- async uninstall() {
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(" - Base URL: https://api.pennyrouter.com/v1");
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
  },
@@ -50,11 +50,13 @@ export const codeGptHarness = {
50
50
  };
51
51
  },
52
52
 
53
- async uninstall() {
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(" - Base URL: https://api.pennyrouter.com/v1");
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 a private PennyRouter gateway if supplied by your IT team.",
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,