pennyrouter 0.2.24 → 0.2.25

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.
Files changed (3) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +168 -38
  3. package/src/cli.test.js +123 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.24",
3
+ "version": "0.2.25",
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,4 +1,5 @@
1
1
  import { spawn, spawnSync } from "node:child_process";
2
+ import { readFileSync } from "node:fs";
2
3
  import { emitKeypressEvents } from "node:readline";
3
4
  import { createInterface } from "node:readline/promises";
4
5
  import { stdin as input, stdout as output } from "node:process";
@@ -181,15 +182,94 @@ function parseArgs(argv) {
181
182
  // `--local=<url>` overrides the port/host.
182
183
  else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
183
184
  else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
185
+ // --prod: point the installed harness back at the default production gateway
186
+ // (https://api.pennyrouter.com). Undoes --local.
187
+ else if (arg === "--prod") flags.gatewayBaseUrl = DEFAULT_GATEWAY_BASE_URL;
184
188
  else if (arg === "--forget") flags.forget = true;
185
189
  else if (arg === "--forget-token") flags.forgetToken = true;
190
+ else if (arg === "--config") flags.configPath = rest[++i] || "";
191
+ else if (arg.startsWith("--config=")) flags.configPath = arg.slice("--config=".length);
186
192
  else if (!arg.startsWith("-")) flags.args.push(arg);
187
193
  else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
188
194
  }
189
195
 
196
+ if (flags.configPath) applyConfigFile(flags);
197
+
190
198
  return { command, flags };
191
199
  }
192
200
 
201
+ // Lets a GUI installer (e.g. the macOS DMG) answer the install questions up front instead of
202
+ // over a TTY: pick harnesses, pick funding per harness, optionally supply keys. Populates the
203
+ // same `flags` fields the equivalent CLI flags set, so it flows through the existing
204
+ // non-interactive paths unchanged. CLI flags win over the config file where both are given.
205
+ // Funding choices imply --yes (skip remaining prompts) but, unlike bare --yes, do NOT suppress
206
+ // the Claude subscription browser sign-in — see maybeConfigureClaudeAnthropicAuth.
207
+ function applyConfigFile(flags) {
208
+ let config;
209
+ try {
210
+ config = JSON.parse(readFileSync(flags.configPath, "utf8"));
211
+ } catch (error) {
212
+ throw new Error(`Could not read --config file "${flags.configPath}": ${error.message}`);
213
+ }
214
+
215
+ if (config.harnesses && flags.harness === undefined) {
216
+ flags.harness = config.harnesses.join(",");
217
+ }
218
+ const claudeFunding = new Set(config.claudeFunding || []);
219
+ const codexFunding = new Set(config.codexFunding || []);
220
+ if (claudeFunding.has("subscription") && flags.anthropicAuth === undefined) {
221
+ flags.anthropicAuth = true;
222
+ }
223
+ if (claudeFunding.has("anthropic-api") && flags.anthropicApiKey === undefined) {
224
+ flags.anthropicApiKey = config.anthropicApiKey || "";
225
+ }
226
+ if ((claudeFunding.has("openrouter") || codexFunding.has("openrouter"))
227
+ && flags.openrouterApiKey === undefined) {
228
+ flags.openrouterApiKey = config.openrouterApiKey || "";
229
+ }
230
+ if (codexFunding.has("openai-api") && flags.openaiApiKey === undefined) {
231
+ flags.openaiApiKey = config.openaiApiKey || "";
232
+ }
233
+
234
+ flags.yes = true;
235
+ flags.fromConfig = true;
236
+ flags.configClaudeFunding = claudeFunding;
237
+ flags.configCodexFunding = codexFunding;
238
+ }
239
+
240
+ // A harness needs the account-claim/funding flow only if it's new, or if the funding the
241
+ // config asked for differs from what's already stored on it — an unchanged re-run of
242
+ // --config should behave like `update`, not force a fresh Claude/PennyRouter sign-in.
243
+ // Plain (non--config) installs call every selected harness "changed" by always passing an
244
+ // empty requested set here, so this only takes effect on the --config path (see `needsAuth`
245
+ // in install()).
246
+ export function harnessFundingChanged(flags, harness, manifest) {
247
+ const existingRecord = (manifest.installations || []).find(
248
+ (candidate) => candidate.harness === harness.id,
249
+ );
250
+ if (!existingRecord) return true;
251
+
252
+ if (harness.id === "claude-code") {
253
+ const requested = flags.configClaudeFunding || new Set();
254
+ if (requested.size === 0) return false;
255
+ const current = existingRecord.user_funded_provider === "claude-subscription"
256
+ ? "subscription"
257
+ : existingRecord.user_funded_provider;
258
+ return requested.size !== 1 || !requested.has(current);
259
+ }
260
+
261
+ if (harness.id === "codex") {
262
+ const requested = flags.configCodexFunding || new Set();
263
+ if (requested.size === 0) return false;
264
+ const current = existingRecord.openai_subscription_auth
265
+ ? "subscription"
266
+ : existingRecord.user_funded_provider;
267
+ return requested.size !== 1 || !requested.has(current);
268
+ }
269
+
270
+ return false;
271
+ }
272
+
193
273
  async function authProvider(flags) {
194
274
  const provider = (flags.args[0] || "anthropic").toLowerCase();
195
275
  if (provider !== "anthropic" && provider !== "claude") {
@@ -436,40 +516,74 @@ export async function install(flags = {}) {
436
516
  return;
437
517
  }
438
518
 
439
- const session = await createCliSession({
440
- appUrl: flags.appUrl || APP_URL,
441
- harnesses: selected.map((harness) => harness.id),
442
- accountMode: "existing",
443
- });
444
-
445
- console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
446
- console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
447
- if (!flags.noBrowser) openBrowser(session.authorize_url);
448
- console.log("");
449
- console.log("Waiting for approval...");
519
+ // A --config re-run against an existing install only needs a fresh account claim and
520
+ // funding popups for harnesses that are actually new or whose requested funding differs
521
+ // from what's already stored — an unchanged harness should behave like `update`, not
522
+ // trigger the Claude/PennyRouter sign-in windows again. Plain (non-config) installs always
523
+ // treat everything selected as needing auth, matching the original behavior.
524
+ const needsAuth = flags.fromConfig
525
+ ? selected.filter((harness) => harnessFundingChanged(flags, harness, manifest))
526
+ : selected;
450
527
 
451
- const claim = await pollForKey(session);
452
- console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
453
- console.log("");
528
+ let claim;
529
+ let claudeAnthropicAuth = null;
530
+ let funding = { credentials: [], codexSubscriptionAuth: false, claudeCredentialProvider: null, codexCredentialProvider: null };
531
+ let gatewayBaseUrl;
454
532
 
455
- const claudeProviders = await selectClaudeFundingProviders(flags, selected);
456
- const codexProviders = await selectCodexFundingProviders(flags, selected);
457
- const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
458
- flags, selected, claudeProviders.has("subscription"));
459
- const funding = await configureUserFunding(
460
- flags, selected, claudeAnthropicAuth, claudeProviders, codexProviders);
461
- const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
462
-
463
- for (const credential of funding.credentials) {
464
- await storeUpstreamCredential({
465
- gatewayBaseUrl,
466
- apiKey: claim.api_key,
467
- provider: credential.provider,
468
- token: credential.token,
533
+ if (needsAuth.length === 0) {
534
+ const launchConfig = await loadPennyConfig();
535
+ const globalAuth = await readClaudeCodeGatewayAuth();
536
+ const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
537
+ if (!apiKey) {
538
+ throw new Error(
539
+ "Could not find your PennyRouter key in the local config. Pass `--penny-key pr-...`, "
540
+ + "or remove --config to re-authorize.",
541
+ );
542
+ }
543
+ console.log("No new tools or funding requested — refreshing your existing setup.");
544
+ console.log("");
545
+ claim = { api_key: apiKey, gateway_base_url: null };
546
+ gatewayBaseUrl = flags.gatewayBaseUrl
547
+ || launchConfig.gateway_base_url
548
+ || globalAuth.gatewayBaseUrl
549
+ || process.env.PENNYROUTER_GATEWAY_BASE_URL
550
+ || DEFAULT_GATEWAY_BASE_URL;
551
+ } else {
552
+ const session = await createCliSession({
553
+ appUrl: flags.appUrl || APP_URL,
554
+ harnesses: selected.map((harness) => harness.id),
555
+ accountMode: "existing",
469
556
  });
557
+
558
+ console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
559
+ console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
560
+ if (!flags.noBrowser) openBrowser(session.authorize_url);
561
+ console.log("");
562
+ console.log("Waiting for approval...");
563
+
564
+ claim = await pollForKey(session);
565
+ console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
566
+ console.log("");
567
+
568
+ const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
569
+ const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
570
+ claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
571
+ flags, needsAuth, claudeProviders.has("subscription"));
572
+ funding = await configureUserFunding(
573
+ flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
574
+ gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
575
+
576
+ for (const credential of funding.credentials) {
577
+ await storeUpstreamCredential({
578
+ gatewayBaseUrl,
579
+ apiKey: claim.api_key,
580
+ provider: credential.provider,
581
+ token: credential.token,
582
+ });
583
+ }
470
584
  }
471
585
 
472
- if (selected.some((harness) => harness.id === "claude-code")) {
586
+ if (needsAuth.some((harness) => harness.id === "claude-code")) {
473
587
  console.log("Verifying modern Claude Code compatibility before changing local settings...");
474
588
  await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
475
589
  console.log("");
@@ -501,8 +615,9 @@ export async function install(flags = {}) {
501
615
  assertNoRunningHarness(harness.id, flags);
502
616
  }
503
617
 
618
+ const harnessNeededAuth = needsAuth.includes(harness);
504
619
  const codexSubscriptionAuth = harness.id === "codex"
505
- ? funding.codexSubscriptionAuth
620
+ ? (harnessNeededAuth ? funding.codexSubscriptionAuth : Boolean(existingRecord?.openai_subscription_auth))
506
621
  : false;
507
622
  const claudeSubscriptionAuth = harness.id === "claude-code"
508
623
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
@@ -550,11 +665,15 @@ export async function install(flags = {}) {
550
665
  record.anthropic_token_stored = true;
551
666
  record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
552
667
  }
553
- if (harness.id === "claude-code" && funding.claudeCredentialProvider) {
554
- record.user_funded_provider = funding.claudeCredentialProvider;
668
+ if (harness.id === "claude-code") {
669
+ record.user_funded_provider = harnessNeededAuth
670
+ ? (funding.claudeCredentialProvider || existingRecord?.user_funded_provider)
671
+ : existingRecord?.user_funded_provider;
555
672
  }
556
- if (harness.id === "codex" && funding.codexCredentialProvider) {
557
- record.user_funded_provider = funding.codexCredentialProvider;
673
+ if (harness.id === "codex") {
674
+ record.user_funded_provider = harnessNeededAuth
675
+ ? (funding.codexCredentialProvider || existingRecord?.user_funded_provider)
676
+ : existingRecord?.user_funded_provider;
558
677
  }
559
678
  if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
560
679
  records.push(record);
@@ -982,7 +1101,10 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
982
1101
  if (tokenResult.token) return tokenResult;
983
1102
  }
984
1103
 
985
- if (flags.yes || !input.isTTY || !output.isTTY) {
1104
+ // --config drives --yes to skip prompts, but it still expects the subscription browser
1105
+ // sign-in to run — the caller (e.g. the macOS installer) attaches a pty for exactly this,
1106
+ // so treat it like the interactive path rather than the plain --yes/non-TTY automation path.
1107
+ if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
986
1108
  if (flags.anthropicAuth) {
987
1109
  throw new Error(
988
1110
  "Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
@@ -998,7 +1120,8 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
998
1120
  if (commandExists("claude")) {
999
1121
  // The user already opted into subscription auth above, so run `claude setup-token`
1000
1122
  // directly and capture the token — no copy-paste, no second confirm. The command
1001
- // opens the browser for approval inline. On failure we fall through to manual paste.
1123
+ // opens the browser for approval inline. On failure we fall through to manual paste,
1124
+ // unless we came from --config, where there's no terminal to paste into.
1002
1125
  console.log("Launching Claude sign-in — approve in your browser…");
1003
1126
  const captured = await runClaudeSetupToken();
1004
1127
  if (captured) {
@@ -1006,8 +1129,13 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
1006
1129
  console.log("");
1007
1130
  return { token: captured, source: "setup-token" };
1008
1131
  }
1132
+ if (flags.fromConfig) {
1133
+ throw new Error("Claude sign-in did not complete. Run the installer again.");
1134
+ }
1009
1135
  console.log("");
1010
1136
  console.log("Couldn't capture a token automatically. Paste it below, or leave blank to skip.");
1137
+ } else if (flags.fromConfig) {
1138
+ throw new Error("Claude Code was not found on PATH, so subscription sign-in could not run.");
1011
1139
  } else {
1012
1140
  console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
1013
1141
  }
@@ -1858,8 +1986,8 @@ function printHelp() {
1858
1986
  console.log(`PennyRouter CLI
1859
1987
 
1860
1988
  Usage:
1861
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--local[=URL]] [--allow-running-harnesses]
1862
- pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]] [--no-mcp] [--allow-running-harnesses]
1989
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--local[=URL]|--prod] [--config PATH] [--allow-running-harnesses]
1990
+ pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp] [--allow-running-harnesses]
1863
1991
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1864
1992
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1865
1993
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -1888,6 +2016,7 @@ Examples:
1888
2016
  npx pennyrouter update --harness claude-code
1889
2017
  npx pennyrouter update --harness codex --local
1890
2018
  npx pennyrouter update --all --local
2019
+ npx pennyrouter update --all --prod
1891
2020
  npx pennyrouter install
1892
2021
  npx pennyrouter install --make-default
1893
2022
  npx pennyrouter install --penny-only
@@ -1898,6 +2027,7 @@ Examples:
1898
2027
  npx pennyrouter auth anthropic
1899
2028
  npx pennyrouter install --all
1900
2029
  npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
2030
+ npx pennyrouter install --config ./penny_install.json
1901
2031
  npx pennyrouter disable
1902
2032
  npx pennyrouter enable
1903
2033
  npx pennyrouter uninstall
@@ -0,0 +1,123 @@
1
+ import assert from "node:assert/strict";
2
+ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
3
+ import { tmpdir } from "node:os";
4
+ import { join } from "node:path";
5
+ import test from "node:test";
6
+ import { harnessFundingChanged, main } from "./cli.js";
7
+
8
+ // --config lets a GUI installer (the macOS DMG) answer install questions up front instead of
9
+ // over a TTY. These tests drive it through `main()` in --dry-run, which prints its plan and
10
+ // returns before any network call or filesystem write, so no mocking is needed.
11
+
12
+ function withConfigFile(config, run) {
13
+ const dir = mkdtempSync(join(tmpdir(), "pennyrouter-config-"));
14
+ const path = join(dir, "penny_install.json");
15
+ writeFileSync(path, JSON.stringify(config));
16
+ try {
17
+ return run(path);
18
+ } finally {
19
+ rmSync(dir, { recursive: true, force: true });
20
+ }
21
+ }
22
+
23
+ function captureLogs(run) {
24
+ const original = console.log;
25
+ const lines = [];
26
+ console.log = (...args) => lines.push(args.join(" "));
27
+ return run().finally(() => { console.log = original; }).then(() => lines);
28
+ }
29
+
30
+ test("--config selects harnesses without prompting", async () => {
31
+ await withConfigFile({ harnesses: ["claude-code"] }, async (path) => {
32
+ const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
33
+ assert.ok(lines.some((line) => line.includes("Claude Code")));
34
+ assert.ok(!lines.some((line) => line.includes("Codex")));
35
+ });
36
+ });
37
+
38
+ test("--config with claudeFunding subscription sets --anthropic-auth", async () => {
39
+ await withConfigFile(
40
+ { harnesses: ["claude-code"], claudeFunding: ["subscription"] },
41
+ async (path) => {
42
+ const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
43
+ assert.ok(lines.some((line) => line.includes("Claude Code provider picker")));
44
+ },
45
+ );
46
+ });
47
+
48
+ test("--config rejects an unreadable file", async () => {
49
+ await assert.rejects(
50
+ () => main(["install", "--config", "/nonexistent/penny_install.json", "--dry-run"]),
51
+ /Could not read --config file/,
52
+ );
53
+ });
54
+
55
+ test("explicit --harness overrides --config harnesses", async () => {
56
+ await withConfigFile({ harnesses: ["codex"] }, async (path) => {
57
+ const lines = await captureLogs(
58
+ () => main(["install", "--harness", "claude-code", "--config", path, "--dry-run"]),
59
+ );
60
+ assert.ok(lines.some((line) => line.includes("Claude Code")));
61
+ assert.ok(!lines.some((line) => line.includes("Codex")));
62
+ });
63
+ });
64
+
65
+ // harnessFundingChanged decides whether a --config re-run needs to re-open the account-claim
66
+ // and subscription-auth popups for a harness, or can treat it like `update` (skip both). This
67
+ // only kicks in on --config re-runs against an existing manifest; a live end-to-end run against
68
+ // a real account confirmed the same three cases these unit tests cover.
69
+
70
+ const claudeHarness = { id: "claude-code" };
71
+ const codexHarness = { id: "codex" };
72
+
73
+ test("harnessFundingChanged: brand new harness always needs auth", () => {
74
+ const manifest = { installations: [] };
75
+ assert.equal(harnessFundingChanged({}, claudeHarness, manifest), true);
76
+ });
77
+
78
+ test("harnessFundingChanged: claude subscription unchanged", () => {
79
+ const manifest = {
80
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
81
+ };
82
+ const flags = { configClaudeFunding: new Set(["subscription"]) };
83
+ assert.equal(harnessFundingChanged(flags, claudeHarness, manifest), false);
84
+ });
85
+
86
+ test("harnessFundingChanged: claude subscription to anthropic-api is a change", () => {
87
+ const manifest = {
88
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
89
+ };
90
+ const flags = { configClaudeFunding: new Set(["anthropic-api"]) };
91
+ assert.equal(harnessFundingChanged(flags, claudeHarness, manifest), true);
92
+ });
93
+
94
+ test("harnessFundingChanged: codex subscription unchanged (openai_subscription_auth flag)", () => {
95
+ const manifest = {
96
+ installations: [{
97
+ harness: "codex",
98
+ user_funded_provider: "chatgpt-subscription",
99
+ openai_subscription_auth: true,
100
+ }],
101
+ };
102
+ const flags = { configCodexFunding: new Set(["subscription"]) };
103
+ assert.equal(harnessFundingChanged(flags, codexHarness, manifest), false);
104
+ });
105
+
106
+ test("harnessFundingChanged: codex openrouter unchanged", () => {
107
+ const manifest = {
108
+ installations: [{
109
+ harness: "codex",
110
+ user_funded_provider: "openrouter",
111
+ openai_subscription_auth: false,
112
+ }],
113
+ };
114
+ const flags = { configCodexFunding: new Set(["openrouter"]) };
115
+ assert.equal(harnessFundingChanged(flags, codexHarness, manifest), false);
116
+ });
117
+
118
+ test("harnessFundingChanged: no requested funding for an existing harness is not a change", () => {
119
+ const manifest = {
120
+ installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
121
+ };
122
+ assert.equal(harnessFundingChanged({}, claudeHarness, manifest), false);
123
+ });