pennyrouter 0.3.0 → 0.3.2

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/src/cli.js CHANGED
@@ -1,8 +1,15 @@
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";
5
6
  import { aiderHarness } from "./harnesses/aider.js";
7
+ import { runMcpServer } from "./mcp.js";
8
+ import { mcpStatus, registerMcp } from "./mcp-register.js";
9
+
10
+ /** The harnesses that can run an MCP server. The other supported harnesses cannot. */
11
+ const MCP_HARNESS_IDS = ["claude-code", "codex"];
12
+ const DEFAULT_GATEWAY_BASE_URL = "https://api.pennyrouter.com";
6
13
  import {
7
14
  CLAUDE_CODE_MODELS,
8
15
  claudeCodeHarness,
@@ -19,6 +26,7 @@ import {
19
26
  APP_URL,
20
27
  createCliSession,
21
28
  forgetAnthropicToken,
29
+ forgetUpstreamCredential,
22
30
  maskKey,
23
31
  pollForKey,
24
32
  probeClaudeCodeGateway,
@@ -60,15 +68,17 @@ const HARNESS_BY_ID = {
60
68
  };
61
69
 
62
70
  const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
63
- const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex"];
71
+ const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex", "opencode"];
64
72
  const CLAUDE_PROVIDER_OPTIONS = [
65
73
  { id: "subscription", name: "Claude subscription" },
66
74
  { id: "anthropic-api", name: "Anthropic API key" },
67
75
  { id: "openrouter", name: "OpenRouter API key" },
76
+ { id: "anthropic-proxy", name: "Other proxy (custom base URL)" },
68
77
  ];
69
78
  const CODEX_PROVIDER_OPTIONS = [
70
79
  { id: "subscription", name: "ChatGPT subscription" },
71
80
  { id: "openai-api", name: "OpenAI API key" },
81
+ { id: "openai-proxy", name: "Other OpenAI-compatible proxy" },
72
82
  { id: "openrouter", name: "OpenRouter API key" },
73
83
  ];
74
84
 
@@ -115,6 +125,17 @@ export async function main(argv = process.argv.slice(2)) {
115
125
  return;
116
126
  }
117
127
 
128
+ if (command === "mcp") {
129
+ // Bare `mcp` is the server itself, started by a harness over stdio. The subcommands are for
130
+ // a person: registering after --no-mcp, retrying after a name conflict, or checking state.
131
+ const sub = (flags.args[0] || "").toLowerCase();
132
+ if (sub === "install" || sub === "add") await installMcp(flags);
133
+ else if (sub === "uninstall" || sub === "remove") await uninstallMcp(flags);
134
+ else if (sub === "status" || sub === "list") await statusMcp();
135
+ else await serveMcp(flags);
136
+ return;
137
+ }
138
+
118
139
  throw new Error(`Unknown command "${command}". Run "pennyrouter help".`);
119
140
  }
120
141
 
@@ -135,6 +156,7 @@ function parseArgs(argv) {
135
156
  else if (arg === "--make-default") flags.makeDefault = true;
136
157
  else if (arg === "--penny-only") flags.pennyOnly = true;
137
158
  else if (arg === "--no-path-update") flags.noPathUpdate = true;
159
+ else if (arg === "--no-mcp") flags.noMcp = true;
138
160
  else if (arg === "--no-browser") flags.noBrowser = true;
139
161
  else if (arg === "--existing-account") flags.existingAccount = true;
140
162
  else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
@@ -150,28 +172,129 @@ function parseArgs(argv) {
150
172
  else if (arg === "--openai-api-key") flags.openaiApiKey = rest[++i] || "";
151
173
  else if (arg.startsWith("--openai-api-key=")) flags.openaiApiKey = arg.slice("--openai-api-key=".length);
152
174
  else if (arg === "--openrouter-api-key") flags.openrouterApiKey = rest[++i] || "";
175
+ else if (arg === "--openai-proxy-base-url") flags.openaiProxyBaseUrl = rest[++i] || "";
176
+ else if (arg.startsWith("--openai-proxy-base-url=")) flags.openaiProxyBaseUrl = arg.slice("--openai-proxy-base-url=".length);
177
+ else if (arg === "--openai-proxy-api-key") flags.openaiProxyApiKey = rest[++i] || "";
178
+ else if (arg.startsWith("--openai-proxy-api-key=")) flags.openaiProxyApiKey = arg.slice("--openai-proxy-api-key=".length);
179
+ else if (arg === "--proxy-base-url") flags.proxyBaseUrl = rest[++i] || "";
180
+ else if (arg.startsWith("--proxy-base-url=")) flags.proxyBaseUrl = arg.slice("--proxy-base-url=".length);
181
+ else if (arg === "--proxy-api-key") flags.proxyApiKey = rest[++i] || "";
182
+ else if (arg.startsWith("--proxy-api-key=")) flags.proxyApiKey = arg.slice("--proxy-api-key=".length);
153
183
  else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
154
184
  else if (arg === "--harness") flags.harness = rest[++i] || "";
155
185
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
156
186
  else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
157
187
  else if (arg.startsWith("--app-url=")) flags.appUrl = arg.slice("--app-url=".length);
158
- else if (arg === "--gateway-base-url") flags.gatewayBaseUrl = rest[++i] || "";
159
- else if (arg.startsWith("--gateway-base-url=")) flags.gatewayBaseUrl = arg.slice("--gateway-base-url=".length);
188
+ // A --gateway-base-url is a *private* gateway: an admin-run deployment that issues its own
189
+ // keys. Flag it so install can require the matching admin-issued key. --local/--prod are
190
+ // developer shortcuts against gateways that honor prod keys, so they do not set this.
191
+ else if (arg === "--gateway-base-url") { flags.gatewayBaseUrl = rest[++i] || ""; flags.privateGateway = true; }
192
+ else if (arg.startsWith("--gateway-base-url=")) {
193
+ flags.gatewayBaseUrl = arg.slice("--gateway-base-url=".length);
194
+ flags.privateGateway = true;
195
+ }
196
+ // Admin-issued key for a private gateway (see scripts/provision_local_key.py). Overrides the
197
+ // key returned by the browser claim, which is only valid against the hosted gateway.
198
+ else if (arg === "--private-key") flags.privateKey = rest[++i] || "";
199
+ else if (arg.startsWith("--private-key=")) flags.privateKey = arg.slice("--private-key=".length);
160
200
  // --local: point the installed harness at a locally-running gateway (default
161
201
  // http://localhost:8400), for developing/testing against your own gateway. Auth still goes
162
202
  // to prod (pennyrouter.com) so you get a real key; only the harness's serving URL is local.
163
203
  // `--local=<url>` overrides the port/host.
164
204
  else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
165
205
  else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
206
+ // --prod: point the installed harness back at the default production gateway
207
+ // (https://api.pennyrouter.com). Undoes --local.
208
+ else if (arg === "--prod") flags.gatewayBaseUrl = DEFAULT_GATEWAY_BASE_URL;
166
209
  else if (arg === "--forget") flags.forget = true;
167
210
  else if (arg === "--forget-token") flags.forgetToken = true;
211
+ else if (arg === "--config") flags.configPath = rest[++i] || "";
212
+ else if (arg.startsWith("--config=")) flags.configPath = arg.slice("--config=".length);
168
213
  else if (!arg.startsWith("-")) flags.args.push(arg);
169
214
  else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
170
215
  }
171
216
 
217
+ if (flags.configPath) applyConfigFile(flags);
218
+
172
219
  return { command, flags };
173
220
  }
174
221
 
222
+ // Lets a GUI installer (e.g. the macOS DMG) answer the install questions up front instead of
223
+ // over a TTY: pick harnesses, pick funding per harness, optionally supply keys. Populates the
224
+ // same `flags` fields the equivalent CLI flags set, so it flows through the existing
225
+ // non-interactive paths unchanged. CLI flags win over the config file where both are given.
226
+ // Funding choices imply --yes (skip remaining prompts) but, unlike bare --yes, do NOT suppress
227
+ // the Claude subscription browser sign-in — see maybeConfigureClaudeAnthropicAuth.
228
+ function applyConfigFile(flags) {
229
+ let config;
230
+ try {
231
+ config = JSON.parse(readFileSync(flags.configPath, "utf8"));
232
+ } catch (error) {
233
+ throw new Error(`Could not read --config file "${flags.configPath}": ${error.message}`);
234
+ }
235
+
236
+ if (config.harnesses && flags.harness === undefined) {
237
+ flags.harness = config.harnesses.join(",");
238
+ }
239
+ const claudeFunding = new Set(config.claudeFunding || []);
240
+ const codexFunding = new Set(config.codexFunding || []);
241
+ if (claudeFunding.has("subscription") && flags.anthropicAuth === undefined) {
242
+ flags.anthropicAuth = true;
243
+ }
244
+ if (claudeFunding.has("anthropic-api") && flags.anthropicApiKey === undefined) {
245
+ flags.anthropicApiKey = config.anthropicApiKey || "";
246
+ }
247
+ if ((claudeFunding.has("openrouter") || codexFunding.has("openrouter"))
248
+ && flags.openrouterApiKey === undefined) {
249
+ flags.openrouterApiKey = config.openrouterApiKey || "";
250
+ }
251
+ if (codexFunding.has("openai-api") && flags.openaiApiKey === undefined) {
252
+ flags.openaiApiKey = config.openaiApiKey || "";
253
+ }
254
+ if (claudeFunding.has("anthropic-proxy")) {
255
+ if (flags.proxyBaseUrl === undefined) flags.proxyBaseUrl = config.proxyBaseUrl || "";
256
+ if (flags.proxyApiKey === undefined) flags.proxyApiKey = config.proxyApiKey || "";
257
+ }
258
+
259
+ flags.yes = true;
260
+ flags.fromConfig = true;
261
+ flags.configClaudeFunding = claudeFunding;
262
+ flags.configCodexFunding = codexFunding;
263
+ }
264
+
265
+ // A harness needs the account-claim/funding flow only if it's new, or if the funding the
266
+ // config asked for differs from what's already stored on it — an unchanged re-run of
267
+ // --config should behave like `update`, not force a fresh Claude/PennyRouter sign-in.
268
+ // Plain (non--config) installs call every selected harness "changed" by always passing an
269
+ // empty requested set here, so this only takes effect on the --config path (see `needsAuth`
270
+ // in install()).
271
+ export function harnessFundingChanged(flags, harness, manifest) {
272
+ const existingRecord = (manifest.installations || []).find(
273
+ (candidate) => candidate.harness === harness.id,
274
+ );
275
+ if (!existingRecord) return true;
276
+
277
+ if (harness.id === "claude-code") {
278
+ const requested = flags.configClaudeFunding || new Set();
279
+ if (requested.size === 0) return false;
280
+ const current = existingRecord.user_funded_provider === "claude-subscription"
281
+ ? "subscription"
282
+ : existingRecord.user_funded_provider;
283
+ return requested.size !== 1 || !requested.has(current);
284
+ }
285
+
286
+ if (harness.id === "codex") {
287
+ const requested = flags.configCodexFunding || new Set();
288
+ if (requested.size === 0) return false;
289
+ const current = existingRecord.openai_subscription_auth
290
+ ? "subscription"
291
+ : existingRecord.user_funded_provider;
292
+ return requested.size !== 1 || !requested.has(current);
293
+ }
294
+
295
+ return false;
296
+ }
297
+
175
298
  async function authProvider(flags) {
176
299
  const provider = (flags.args[0] || "anthropic").toLowerCase();
177
300
  if (provider !== "anthropic" && provider !== "claude") {
@@ -186,11 +309,21 @@ async function authProvider(flags) {
186
309
  "No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
187
310
  );
188
311
  }
189
- const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
312
+ // Resolve against the INSTALLED config before falling back to the prod default.
313
+ // This is a destructive call against whichever gateway it reaches, so defaulting to
314
+ // production while the user is working against a local gateway would silently delete
315
+ // the wrong account's token (and leave the local one in place, looking like a no-op).
316
+ const globalAuth = await readClaudeCodeGatewayAuth();
317
+ const gatewayBaseUrl = flags.gatewayBaseUrl
318
+ || process.env.PENNYROUTER_GATEWAY_BASE_URL
319
+ || launchConfig.gateway_base_url
320
+ || globalAuth.gatewayBaseUrl
321
+ || DEFAULT_GATEWAY_BASE_URL;
190
322
  if (flags.dryRun) {
191
323
  console.log(`Will delete stored Anthropic token from gateway: ${gatewayBaseUrl}`);
192
324
  return;
193
325
  }
326
+ console.log(`Gateway: ${gatewayBaseUrl}`);
194
327
  const ok = await forgetAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey });
195
328
  if (ok) {
196
329
  console.log("Successfully forgot stored Anthropic token on server.");
@@ -238,7 +371,7 @@ async function authProvider(flags) {
238
371
  );
239
372
  }
240
373
 
241
- const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
374
+ const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || DEFAULT_GATEWAY_BASE_URL;
242
375
  const manifest = await loadManifest();
243
376
  const existingRecord = (manifest.installations || []).find(
244
377
  (candidate) => candidate.harness === "claude-code",
@@ -246,12 +379,9 @@ async function authProvider(flags) {
246
379
  const ownsGlobalRouting = existingRecord
247
380
  ? existingRecord.default_routing !== false
248
381
  : Boolean(await readClaudeCodePennyRouterKey());
249
- if (ownsGlobalRouting) assertNoRunningHarness("claude-code", flags);
250
382
  await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
251
383
  await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: pennyKey });
252
- if (process.platform !== "win32") {
253
- await installPennyRuntime({ updatePath: !flags.noPathUpdate });
254
- }
384
+ await installPennyRuntime({ updatePath: !flags.noPathUpdate });
255
385
  await configurePennyHarness("claude-code", {
256
386
  apiKey: pennyKey,
257
387
  gatewayBaseUrl,
@@ -384,7 +514,10 @@ function normalizeAnthropicToken(value) {
384
514
  return text.replace(/\s+/g, "").trim();
385
515
  }
386
516
 
387
- async function install(flags) {
517
+ // Public programmatic entry point used by the signed desktop setup app. Keeping this
518
+ // here means every installer surface shares the same authorization, backup, config,
519
+ // update, and uninstall implementation.
520
+ export async function install(flags = {}) {
388
521
  if (flags.makeDefault && flags.pennyOnly) {
389
522
  throw new Error("--make-default and --penny-only cannot be used together.");
390
523
  }
@@ -402,8 +535,9 @@ async function install(flags) {
402
535
  if (flags.dryRun) {
403
536
  const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
404
537
  for (const harness of selected) {
405
- const explicit = ["claude-code", "codex"].includes(harness.id) && process.platform !== "win32";
406
- const command = harness.id === "claude-code" ? "penny claude" : "penny codex";
538
+ const explicit = ["claude-code", "codex", "opencode"].includes(harness.id);
539
+ const command = harness.id === "claude-code"
540
+ ? "penny claude" : harness.id === "codex" ? "penny codex" : "penny opencode";
407
541
  console.log(`Will install on ${harness.name}${explicit ? ` via ${command}` : installNoteForHarness(harness.id)}.`);
408
542
  if (defaultRouting.get(harness.id)) {
409
543
  console.log(`Will also route bare ${harness.id === "claude-code" ? "claude" : harness.id} through PennyRouter.`);
@@ -417,42 +551,140 @@ async function install(flags) {
417
551
  return;
418
552
  }
419
553
 
420
- const session = await createCliSession({
421
- appUrl: flags.appUrl || APP_URL,
422
- harnesses: selected.map((harness) => harness.id),
423
- accountMode: "existing",
424
- });
554
+ // A --config re-run against an existing install only needs a fresh account claim and
555
+ // funding popups for harnesses that are actually new or whose requested funding differs
556
+ // from what's already stored — an unchanged harness should behave like `update`, not
557
+ // trigger the Claude/PennyRouter sign-in windows again. Plain (non-config) installs always
558
+ // treat everything selected as needing auth, matching the original behavior.
559
+ const needsAuth = flags.fromConfig
560
+ ? selected.filter((harness) => harnessFundingChanged(flags, harness, manifest))
561
+ : selected;
425
562
 
426
- console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
427
- console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
428
- if (!flags.noBrowser) openBrowser(session.authorize_url);
429
- console.log("");
430
- console.log("Waiting for approval...");
563
+ let claim;
564
+ let claudeAnthropicAuth = null;
565
+ let funding = { credentials: [], codexSubscriptionAuth: false, claudeCredentialProvider: null, codexCredentialProvider: null };
566
+ let gatewayBaseUrl;
431
567
 
432
- const claim = await pollForKey(session);
433
- console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
434
- console.log("");
435
-
436
- const claudeProviders = await selectClaudeFundingProviders(flags, selected);
437
- const codexProviders = await selectCodexFundingProviders(flags, selected);
438
- const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
439
- flags, selected, claudeProviders.has("subscription"));
440
- const funding = await configureUserFunding(
441
- flags, selected, claudeAnthropicAuth, claudeProviders, codexProviders);
442
- const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
443
-
444
- for (const credential of funding.credentials) {
445
- await storeUpstreamCredential({
446
- gatewayBaseUrl,
447
- apiKey: claim.api_key,
448
- provider: credential.provider,
449
- token: credential.token,
568
+ if (needsAuth.length === 0) {
569
+ const launchConfig = await loadPennyConfig();
570
+ const globalAuth = await readClaudeCodeGatewayAuth();
571
+ const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
572
+ if (!apiKey) {
573
+ throw new Error(
574
+ "Could not find your PennyRouter key in the local config. Pass `--penny-key pr-...`, "
575
+ + "or remove --config to re-authorize.",
576
+ );
577
+ }
578
+ console.log("No new tools or funding requested — refreshing your existing setup.");
579
+ console.log("");
580
+ claim = { api_key: apiKey, gateway_base_url: null };
581
+ gatewayBaseUrl = flags.gatewayBaseUrl
582
+ || launchConfig.gateway_base_url
583
+ || globalAuth.gatewayBaseUrl
584
+ || process.env.PENNYROUTER_GATEWAY_BASE_URL
585
+ || DEFAULT_GATEWAY_BASE_URL;
586
+ } else {
587
+ const session = await createCliSession({
588
+ appUrl: flags.appUrl || APP_URL,
589
+ harnesses: selected.map((harness) => harness.id),
590
+ accountMode: "existing",
450
591
  });
592
+
593
+ console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
594
+ console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
595
+ if (!flags.noBrowser) openBrowser(session.authorize_url);
596
+ console.log("");
597
+ console.log("Waiting for approval...");
598
+
599
+ claim = await pollForKey(session);
600
+ console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
601
+ console.log("");
602
+
603
+ const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
604
+ const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
605
+ claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
606
+ flags, needsAuth, claudeProviders.has("subscription"));
607
+ funding = await configureUserFunding(
608
+ flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
609
+ gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
610
+ if (flags.gatewayBaseUrl) {
611
+ const normalized = normalizeGatewayBaseUrl(flags.gatewayBaseUrl);
612
+ if (!normalized) {
613
+ throw new Error(
614
+ `--gateway-base-url "${flags.gatewayBaseUrl}" is not a usable URL. Use a host like `
615
+ + "localhost:8400 or https://gateway.example.com.",
616
+ );
617
+ }
618
+ gatewayBaseUrl = normalized;
619
+ }
620
+
621
+ // A user-supplied proxy may live on a private network that the hosted gateway cannot
622
+ // reach. Let interactive installs choose the gateway after they choose the proxy: blank
623
+ // keeps the hosted default, while a supplied URL is used for credential storage,
624
+ // validation, probing, and the installed harness configuration. `--local` remains the
625
+ // explicit localhost:8400 developer shortcut and skips this prompt.
626
+ if ((claudeProviders.has("anthropic-proxy") || codexProviders.has("openai-proxy")) && !flags.gatewayBaseUrl
627
+ && !flags.yes && input.isTTY && output.isTTY) {
628
+ const answer = (await promptText(
629
+ "PennyRouter gateway URL for this proxy (blank for hosted gateway): ",
630
+ )).trim();
631
+ if (answer) {
632
+ const privateGateway = normalizeGatewayBaseUrl(answer);
633
+ if (!privateGateway) {
634
+ throw new Error(
635
+ `"${answer}" is not a usable gateway URL. Use a host like `
636
+ + "localhost:8400 or https://gateway.example.com.",
637
+ );
638
+ }
639
+ // Show the normalized form: the user typed "localhost:8000" and everything from here on
640
+ // uses "http://localhost:8000", so any later error names a URL they recognize.
641
+ if (privateGateway !== answer) console.log(`Using gateway ${privateGateway}.`);
642
+ gatewayBaseUrl = privateGateway;
643
+ flags.privateGateway = true;
644
+ }
645
+ }
646
+
647
+ // A private gateway has its own key store, so the hosted key from the browser claim is
648
+ // meaningless there. Require the admin-issued key and use it for every subsequent gateway
649
+ // call (credential storage, probing, harness config).
650
+ if (flags.privateGateway) {
651
+ let privateKey = (flags.privateKey || "").trim();
652
+ if (!privateKey && !flags.yes && input.isTTY && output.isTTY) {
653
+ console.log("");
654
+ console.log(`Private gateway: ${gatewayBaseUrl}`);
655
+ console.log("Your PennyRouter administrator issues keys for this gateway.");
656
+ // No echo: this is an issued credential, and a visible paste lingers in scrollback.
657
+ privateKey = (await promptSecretish("Paste your private gateway key (pr-...): ")).trim();
658
+ }
659
+ if (!privateKey) {
660
+ throw new Error(
661
+ `A private gateway (${gatewayBaseUrl}) needs its own key. `
662
+ + "Ask your PennyRouter administrator for one and pass `--private-key pr-...`.",
663
+ );
664
+ }
665
+ if (!privateKey.startsWith("pr-")) {
666
+ throw new Error(`That does not look like a PennyRouter key — expected it to start with "pr-".`);
667
+ }
668
+ claim.api_key = privateKey;
669
+ console.log(`Using private gateway key ${maskKey(privateKey)}.`);
670
+ console.log("");
671
+ }
672
+
673
+ for (const credential of funding.credentials) {
674
+ await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
675
+ gatewayBaseUrl,
676
+ apiKey: claim.api_key,
677
+ provider: credential.provider,
678
+ token: credential.token,
679
+ metadata: credential.metadata,
680
+ }));
681
+ }
451
682
  }
452
683
 
453
- if (selected.some((harness) => harness.id === "claude-code")) {
684
+ if (needsAuth.some((harness) => harness.id === "claude-code")) {
454
685
  console.log("Verifying modern Claude Code compatibility before changing local settings...");
455
- await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
686
+ await withGatewayContext(
687
+ gatewayBaseUrl, () => probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key }));
456
688
  console.log("");
457
689
  }
458
690
 
@@ -461,9 +693,9 @@ async function install(flags) {
461
693
  // choose the visible command behavior with all relevant context in front of them.
462
694
  const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
463
695
 
464
- const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
696
+ const launchable = selected.filter((harness) => ["claude-code", "codex", "opencode"].includes(harness.id));
465
697
  let runtimeResult = null;
466
- if (launchable.length > 0 && process.platform !== "win32") {
698
+ if (launchable.length > 0) {
467
699
  runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
468
700
  }
469
701
 
@@ -475,16 +707,13 @@ async function install(flags) {
475
707
  const existingRecord = (manifest.installations || []).find(
476
708
  (candidate) => candidate.harness === harness.id,
477
709
  );
478
- const supportsPennyCommand = ["claude-code", "codex"].includes(harness.id)
479
- && process.platform !== "win32";
710
+ const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(harness.id);
480
711
  const makeDefault = defaultRouting.get(harness.id) ?? true;
481
712
  const hadGlobalRouting = Boolean(existingRecord && existingRecord.default_routing !== false);
482
- if (["claude-code", "codex"].includes(harness.id) && (makeDefault || hadGlobalRouting)) {
483
- assertNoRunningHarness(harness.id, flags);
484
- }
485
713
 
714
+ const harnessNeededAuth = needsAuth.includes(harness);
486
715
  const codexSubscriptionAuth = harness.id === "codex"
487
- ? funding.codexSubscriptionAuth
716
+ ? (harnessNeededAuth ? funding.codexSubscriptionAuth : Boolean(existingRecord?.openai_subscription_auth))
488
717
  : false;
489
718
  const claudeSubscriptionAuth = harness.id === "claude-code"
490
719
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
@@ -522,9 +751,10 @@ async function install(flags) {
522
751
  }
523
752
  if (supportsPennyCommand) {
524
753
  record.launch_available = true;
525
- record.launch_config_path = compactHome(
526
- harness.id === "claude-code" ? claudeLaunchSettingsPath() : codexLaunchProfilePath(),
527
- );
754
+ const launchConfigPath = harness.id === "claude-code"
755
+ ? claudeLaunchSettingsPath()
756
+ : harness.id === "codex" ? codexLaunchProfilePath() : null;
757
+ record.launch_config_path = launchConfigPath ? compactHome(launchConfigPath) : null;
528
758
  record.default_routing = Boolean(makeDefault);
529
759
  }
530
760
  if (harness.id === "claude-code" && claudeSubscriptionAuth) {
@@ -532,11 +762,20 @@ async function install(flags) {
532
762
  record.anthropic_token_stored = true;
533
763
  record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
534
764
  }
535
- if (harness.id === "claude-code" && funding.claudeCredentialProvider) {
536
- record.user_funded_provider = funding.claudeCredentialProvider;
765
+ if (harness.id === "claude-code") {
766
+ record.user_funded_provider = harnessNeededAuth
767
+ ? (funding.claudeCredentialProvider || existingRecord?.user_funded_provider)
768
+ : existingRecord?.user_funded_provider;
769
+ // Host only, never the key — the key lives encrypted on the gateway.
770
+ const proxyBaseUrl = harnessNeededAuth
771
+ ? (funding.claudeProxyBaseUrl ?? existingRecord?.proxy_base_url ?? null)
772
+ : (existingRecord?.proxy_base_url ?? null);
773
+ if (proxyBaseUrl) record.proxy_base_url = proxyBaseUrl;
537
774
  }
538
- if (harness.id === "codex" && funding.codexCredentialProvider) {
539
- record.user_funded_provider = funding.codexCredentialProvider;
775
+ if (harness.id === "codex") {
776
+ record.user_funded_provider = harnessNeededAuth
777
+ ? (funding.codexCredentialProvider || existingRecord?.user_funded_provider)
778
+ : existingRecord?.user_funded_provider;
540
779
  }
541
780
  if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
542
781
  records.push(record);
@@ -554,28 +793,189 @@ async function install(flags) {
554
793
  for (const record of records) {
555
794
  const harness = HARNESS_BY_ID[record.harness];
556
795
  if (record.launch_available) {
557
- const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
558
- const native = record.harness === "claude-code" ? "claude" : "codex";
559
- const harnessName = record.harness === "claude-code" ? "Claude Code" : "Codex";
796
+ const command = record.harness === "claude-code"
797
+ ? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
798
+ const native = record.harness === "claude-code" ? "claude"
799
+ : record.harness === "codex" ? "codex" : "opencode";
800
+ const harnessName = record.harness === "claude-code" ? "Claude Code"
801
+ : record.harness === "codex" ? "Codex" : "OpenCode";
560
802
  const commandWidth = Math.max(command.length, native.length) + 2;
561
803
  console.log("");
562
804
  console.log(color(`How to Run ${harnessName}`, "bold"));
563
805
  console.log("");
564
806
  console.log(` ${color(command.padEnd(commandWidth), "bold")}→ ${harnessName} with PennyRouter`);
565
- console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName} with PennyRouter (default routing enabled)` : `${harnessName} native`}`);
807
+ console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName}` : `${harnessName} native`}`);
566
808
  } else {
567
809
  console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
568
810
  }
569
811
  }
570
812
  if (runtimeResult && !runtimeResult.available) {
571
- console.log("Add ~/.local/bin to PATH before using the `penny` command.");
813
+ console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
572
814
  } else if (runtimeResult?.changed) {
573
815
  console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
574
816
  }
817
+
818
+ // Registered after routing, which is the primary job: a naming conflict here reports itself
819
+ // and leaves a working install rather than failing the whole run.
820
+ if (!flags.noMcp && !flags.dryRun) {
821
+ const mcp = await registerMcp(records.map((r) => r.harness), {
822
+ gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
823
+ });
824
+ const registered = mcp.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
825
+ if (registered.length) {
826
+ console.log("");
827
+ console.log(color("Threads MCP", "bold"));
828
+ console.log(` Registered on ${registered.join(" and ")}.`);
829
+ console.log(" Ask your agent to read or resume an AI share link:");
830
+ console.log(' "code based on this chat: <paste a ChatGPT/Claude/Gemini/Perplexity link>"');
831
+ }
832
+ for (const result of mcp.filter((r) => r.error)) {
833
+ console.log(` MCP registration skipped for ${result.harness}: ${result.error}`);
834
+ }
835
+ }
836
+
575
837
  console.log("");
576
838
  reportFailures(failures);
577
839
  }
578
840
 
841
+ // Deliberately small, secret-free preflight for graphical installers. Do not add
842
+ // tokens or config contents here: the renderer only needs to know which setup
843
+ // choices are likely to work on this machine.
844
+ export async function getSetupPreflight() {
845
+ const harnesses = await Promise.all(DEFAULT_INSTALL_HARNESS_IDS.map(async (id) => {
846
+ const harness = HARNESS_BY_ID[id];
847
+ return { id, name: harness.name, detected: await safeDetect(harness) };
848
+ }));
849
+ return {
850
+ harnesses,
851
+ codex_chatgpt_logged_in: await detectCodexChatgptAuth(),
852
+ };
853
+ }
854
+
855
+ /* The `mcp install|uninstall|status` subcommands.
856
+
857
+ Install already registers the server, so these exist for the states install cannot reach:
858
+ someone who passed --no-mcp and changed their mind, someone who hit a name conflict and has
859
+ now renamed the other server, and anyone who just wants to know whether it is wired up. */
860
+
861
+ function mcpTargets(flags) {
862
+ const requested = parseHarnessList(flags.harness).filter((id) => MCP_HARNESS_IDS.includes(id));
863
+ return requested.length ? requested : MCP_HARNESS_IDS;
864
+ }
865
+
866
+ /** The gateway URL worth pinning into the MCP entry, or "" for the default.
867
+ The MCP server already falls back to production on its own, so writing that value would only
868
+ freeze today's default into every user's config. */
869
+ /* Users type gateways the way they say them out loud — "localhost:8000",
870
+ "gateway.example.com". Those are not absolute URLs, so fetch() rejects them with a bare
871
+ "fetch failed" long after the prompt, where the cause is invisible. Supply the obvious scheme
872
+ (plain http for loopback, https elsewhere) and prove the result parses, so a typo fails here
873
+ instead. Returns null when the value cannot be salvaged. */
874
+ function normalizeGatewayBaseUrl(raw) {
875
+ const value = String(raw || "").trim().replace(/\/+$/, "");
876
+ if (!value) return null;
877
+ const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
878
+ ? value
879
+ : `${/^(localhost|127\.0\.0\.1|\[::1\])(:|$)/i.test(value) ? "http" : "https"}://${value}`;
880
+ let parsed;
881
+ try {
882
+ parsed = new URL(withScheme);
883
+ } catch {
884
+ return null;
885
+ }
886
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
887
+ if (!parsed.hostname) return null;
888
+ return withScheme.replace(/\/+$/, "");
889
+ }
890
+
891
+ /* A gateway that is unreachable (wrong host, wrong port, not running) surfaces from fetch() as
892
+ an opaque "fetch failed" with no status. Name the gateway we could not reach so the user knows
893
+ which URL to fix. Real HTTP errors carry their own message and pass through untouched. */
894
+ async function withGatewayContext(gatewayBaseUrl, action) {
895
+ try {
896
+ return await action();
897
+ } catch (error) {
898
+ const message = String(error?.message || error);
899
+ if (/fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ECONNRESET|network/i.test(message)) {
900
+ throw new Error(
901
+ `Could not reach the PennyRouter gateway at ${gatewayBaseUrl} (${message}). `
902
+ + "Check that the URL is correct and that the gateway is running.",
903
+ );
904
+ }
905
+ throw error;
906
+ }
907
+ }
908
+
909
+ function customGatewayBaseUrl(url) {
910
+ const value = String(url || "").trim().replace(/\/+$/, "");
911
+ return !value || value === DEFAULT_GATEWAY_BASE_URL ? "" : value;
912
+ }
913
+
914
+ async function installMcp(flags) {
915
+ const results = await registerMcp(mcpTargets(flags), {
916
+ gatewayBaseUrl: customGatewayBaseUrl(
917
+ flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL,
918
+ ),
919
+ });
920
+ console.log("PennyRouter Threads MCP");
921
+ console.log("");
922
+ for (const result of results) {
923
+ const name = HARNESS_BY_ID[result.harness]?.name || result.harness;
924
+ if (result.error) console.log(` ${name}: ${result.error}`);
925
+ else if (result.changed) console.log(` ${name}: registered`);
926
+ else if (result.reason) console.log(` ${name}: skipped — ${result.reason}`);
927
+ else console.log(` ${name}: already registered`);
928
+ }
929
+ console.log("");
930
+ console.log("Restart any running session, then ask your agent to read or resume a share link.");
931
+ }
932
+
933
+ async function uninstallMcp(flags) {
934
+ const results = await registerMcp(mcpTargets(flags), { remove: true });
935
+ console.log("PennyRouter Threads MCP");
936
+ console.log("");
937
+ for (const result of results) {
938
+ const name = HARNESS_BY_ID[result.harness]?.name || result.harness;
939
+ console.log(` ${name}: ${result.changed ? "removed" : "nothing to remove"}`);
940
+ }
941
+ }
942
+
943
+ async function statusMcp() {
944
+ const rows = await mcpStatus(MCP_HARNESS_IDS);
945
+ console.log("PennyRouter Threads MCP");
946
+ console.log("");
947
+ for (const row of rows) {
948
+ const name = HARNESS_BY_ID[row.harness]?.name || row.harness;
949
+ if (!row.configFound) console.log(` ${name}: not installed on this machine`);
950
+ else if (row.conflict) {
951
+ console.log(` ${name}: a different server is already named "pennyrouter"`);
952
+ } else console.log(` ${name}: ${row.registered ? "registered" : "not registered"}`);
953
+ }
954
+ if (rows.some((row) => row.configFound && !row.registered)) {
955
+ console.log("");
956
+ console.log("Register with: pennyrouter mcp install");
957
+ }
958
+ }
959
+
960
+ /* Serve the Threads MCP tools over stdio. Started by the harness, not by a person: stdout is the
961
+ JSON-RPC channel, so nothing may be printed to it. Diagnostics go to stderr. */
962
+ async function serveMcp(flags) {
963
+ const launchConfig = await loadPennyConfig();
964
+ const globalAuth = await readClaudeCodeGatewayAuth();
965
+ const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
966
+ const gatewayBaseUrl = flags.gatewayBaseUrl
967
+ || launchConfig.gateway_base_url
968
+ || globalAuth.gatewayBaseUrl
969
+ || process.env.PENNYROUTER_GATEWAY_BASE_URL
970
+ || DEFAULT_GATEWAY_BASE_URL;
971
+
972
+ await runMcpServer({
973
+ gatewayBaseUrl,
974
+ apiKey,
975
+ harness: process.env.PENNYROUTER_MCP_HARNESS,
976
+ });
977
+ }
978
+
579
979
  // Refresh the managed local files to this package version WITHOUT re-running any of the
580
980
  // account/credential flow. Everything an update needs is already on disk: the pr- key and
581
981
  // gateway URL live in the penny config (and, for older installs, in the harness config the
@@ -620,7 +1020,7 @@ async function update(flags) {
620
1020
  || launchConfig.gateway_base_url
621
1021
  || globalAuth.gatewayBaseUrl
622
1022
  || process.env.PENNYROUTER_GATEWAY_BASE_URL
623
- || "https://api.pennyrouter.com";
1023
+ || DEFAULT_GATEWAY_BASE_URL;
624
1024
 
625
1025
  if (!apiKey) {
626
1026
  throw new Error(
@@ -647,8 +1047,7 @@ async function update(flags) {
647
1047
 
648
1048
  const failures = [];
649
1049
  let runtimeResult = null;
650
- if (targets.some((record) => ["claude-code", "codex"].includes(record.harness))
651
- && process.platform !== "win32") {
1050
+ if (targets.some((record) => ["claude-code", "codex", "opencode"].includes(record.harness))) {
652
1051
  runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
653
1052
  }
654
1053
 
@@ -657,13 +1056,11 @@ async function update(flags) {
657
1056
  if (!harness) continue;
658
1057
 
659
1058
  await runHarnessJob(failures, record.harness, "update", async () => {
660
- const supportsPennyCommand = ["claude-code", "codex"].includes(record.harness)
661
- && process.platform !== "win32";
1059
+ const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(record.harness);
662
1060
  // An update rewrites the harness's own config only when that config is ours to
663
1061
  // rewrite — i.e. the bare command still routes through the gateway. In penny-only
664
1062
  // mode the harness config belongs to the user and we touch just the launch profile.
665
1063
  const ownsGlobalRouting = !record.launch_available || record.default_routing !== false;
666
- if (ownsGlobalRouting) assertNoRunningHarness(record.harness, flags);
667
1064
 
668
1065
  const claudeSubscriptionAuth = record.harness === "claude-code"
669
1066
  && Boolean(record.anthropic_token_stored);
@@ -703,9 +1100,29 @@ async function update(flags) {
703
1100
  }
704
1101
 
705
1102
  await saveManifest(manifest);
1103
+
1104
+ // Pick up MCP registration for installs that predate it, which is every existing user: the
1105
+ // server ships with the package but nothing registers it retroactively.
1106
+ //
1107
+ // Update always registers rather than honoring a stored opt-out. Registration is what makes
1108
+ // the Threads tools exist at all, and a preference remembered across version bumps would
1109
+ // mostly serve to leave people silently without them. `--no-mcp` still skips a single run,
1110
+ // and `pennyrouter mcp uninstall` removes it for good.
1111
+ if (!flags.dryRun && !flags.noMcp) {
1112
+ const wanted = targets.filter((record) => MCP_HARNESS_IDS.includes(record.harness));
1113
+ const results = await registerMcp(wanted.map((record) => record.harness), {
1114
+ gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
1115
+ });
1116
+ const added = results.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
1117
+ if (added.length) console.log(`Registered the Threads MCP server on ${added.join(" and ")}.`);
1118
+ for (const result of results.filter((r) => r.error)) {
1119
+ console.log(`MCP registration skipped for ${result.harness}: ${result.error}`);
1120
+ }
1121
+ }
1122
+
706
1123
  console.log("");
707
1124
  if (runtimeResult && !runtimeResult.available) {
708
- console.log("Add ~/.local/bin to PATH before using the `penny` command.");
1125
+ console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
709
1126
  } else if (runtimeResult?.changed) {
710
1127
  console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
711
1128
  }
@@ -716,10 +1133,6 @@ async function update(flags) {
716
1133
  async function chooseDefaultRouting(flags, selected, manifest) {
717
1134
  const out = new Map();
718
1135
  const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
719
- if (process.platform === "win32") {
720
- for (const harness of launchable) out.set(harness.id, true);
721
- return out;
722
- }
723
1136
  let newInstallChoice = null;
724
1137
  for (const harness of launchable) {
725
1138
  if (flags.makeDefault) {
@@ -745,7 +1158,10 @@ async function chooseDefaultRouting(flags, selected, manifest) {
745
1158
  continue;
746
1159
  }
747
1160
  if (newInstallChoice === null) {
748
- if (flags.dryRun || flags.yes || !input.isTTY || !output.isTTY) {
1161
+ // Auto-load-by-default prompt is disabled for now — always install as `penny <native>`
1162
+ // only. Flip ENABLE_AUTOLOAD_PROMPT to true to restore asking the user.
1163
+ const ENABLE_AUTOLOAD_PROMPT = false;
1164
+ if (!ENABLE_AUTOLOAD_PROMPT || flags.dryRun || flags.yes || !input.isTTY || !output.isTTY) {
749
1165
  newInstallChoice = false;
750
1166
  } else {
751
1167
  for (const item of launchable) {
@@ -783,14 +1199,19 @@ async function selectClaudeFundingProviders(flags, selected) {
783
1199
  || process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
784
1200
  if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
785
1201
  if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
1202
+ if ((flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL)
1203
+ && (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
1204
+ supplied.add("anthropic-proxy");
1205
+ }
786
1206
 
787
1207
  // Explicit flags are the automation selector. Interactive installs always default to the
788
1208
  // subscription alone; environment keys are used if the user checks their provider.
789
1209
  const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
790
- || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey;
1210
+ || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
1211
+ || flags.proxyBaseUrl || flags.proxyApiKey;
791
1212
  if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
792
1213
 
793
- console.log("PennyRouter can use your Claude subscription, an Anthropic API key, or an OpenRouter API key to run Claude Code.");
1214
+ console.log("PennyRouter can use your Claude subscription, an Anthropic API key, an OpenRouter API key, or your own Anthropic-compatible proxy to run Claude Code.");
794
1215
  console.log("Choose one or more providers. PennyRouter will try them in order when available.");
795
1216
  return promptFundingProviderCheckboxes(
796
1217
  CLAUDE_PROVIDER_OPTIONS,
@@ -804,14 +1225,16 @@ async function selectCodexFundingProviders(flags, selected) {
804
1225
 
805
1226
  const supplied = new Set();
806
1227
  if (flags.openaiApiKey || process.env.OPENAI_API_KEY) supplied.add("openai-api");
1228
+ if ((flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL)
1229
+ && (flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY)) supplied.add("openai-proxy");
807
1230
  if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
808
1231
  const hasChatgptLogin = await detectCodexChatgptAuth();
809
1232
  if (hasChatgptLogin) supplied.add("subscription");
810
1233
 
811
- const explicit = flags.openaiApiKey || flags.openrouterApiKey;
1234
+ const explicit = flags.openaiApiKey || flags.openaiProxyBaseUrl || flags.openaiProxyApiKey || flags.openrouterApiKey;
812
1235
  if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
813
1236
 
814
- console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, or an OpenRouter API key to run Codex.");
1237
+ console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, an OpenAI-compatible proxy, or an OpenRouter API key to run Codex.");
815
1238
  console.log("Choose one or more providers. PennyRouter will try them in order when available.");
816
1239
  return promptFundingProviderCheckboxes(
817
1240
  CODEX_PROVIDER_OPTIONS,
@@ -829,7 +1252,10 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
829
1252
  if (tokenResult.token) return tokenResult;
830
1253
  }
831
1254
 
832
- if (flags.yes || !input.isTTY || !output.isTTY) {
1255
+ // --config drives --yes to skip prompts, but it still expects the subscription browser
1256
+ // sign-in to run — the caller (e.g. the macOS installer) attaches a pty for exactly this,
1257
+ // so treat it like the interactive path rather than the plain --yes/non-TTY automation path.
1258
+ if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
833
1259
  if (flags.anthropicAuth) {
834
1260
  throw new Error(
835
1261
  "Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
@@ -845,7 +1271,8 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
845
1271
  if (commandExists("claude")) {
846
1272
  // The user already opted into subscription auth above, so run `claude setup-token`
847
1273
  // directly and capture the token — no copy-paste, no second confirm. The command
848
- // opens the browser for approval inline. On failure we fall through to manual paste.
1274
+ // opens the browser for approval inline. On failure we fall through to manual paste,
1275
+ // unless we came from --config, where there's no terminal to paste into.
849
1276
  console.log("Launching Claude sign-in — approve in your browser…");
850
1277
  const captured = await runClaudeSetupToken();
851
1278
  if (captured) {
@@ -853,8 +1280,13 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
853
1280
  console.log("");
854
1281
  return { token: captured, source: "setup-token" };
855
1282
  }
1283
+ if (flags.fromConfig) {
1284
+ throw new Error("Claude sign-in did not complete. Run the installer again.");
1285
+ }
856
1286
  console.log("");
857
1287
  console.log("Couldn't capture a token automatically. Paste it below, or leave blank to skip.");
1288
+ } else if (flags.fromConfig) {
1289
+ throw new Error("Claude Code was not found on PATH, so subscription sign-in could not run.");
858
1290
  } else {
859
1291
  console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
860
1292
  }
@@ -884,6 +1316,18 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
884
1316
  let openrouterApiKey = String(
885
1317
  flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
886
1318
  ).trim();
1319
+ let openaiProxyBaseUrl = String(
1320
+ flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL || "",
1321
+ ).trim();
1322
+ let openaiProxyApiKey = String(
1323
+ flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY || "",
1324
+ ).trim();
1325
+ let proxyBaseUrl = String(
1326
+ flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL || "",
1327
+ ).trim();
1328
+ let proxyApiKey = String(
1329
+ flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY || "",
1330
+ ).trim();
887
1331
  const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
888
1332
  ? await detectCodexChatgptAuth()
889
1333
  : false;
@@ -896,6 +1340,19 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
896
1340
  if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
897
1341
  openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
898
1342
  }
1343
+ if (wantsClaude && claudeProviders.has("anthropic-proxy") && !(proxyBaseUrl && proxyApiKey)) {
1344
+ if (!interactive) {
1345
+ throw new Error(
1346
+ "Other proxy was selected, but --proxy-base-url and --proxy-api-key were not both provided.",
1347
+ );
1348
+ }
1349
+ // The URL is not a secret and is easy to mistype, so it echoes; the key does not.
1350
+ if (!proxyBaseUrl) proxyBaseUrl = await promptText("Proxy base URL (e.g. https://proxy.example.com): ");
1351
+ if (!proxyApiKey) proxyApiKey = (await promptSecretish("Proxy API key: ")).trim();
1352
+ if (!proxyBaseUrl || !proxyApiKey) {
1353
+ throw new Error("Other proxy requires both a base URL and an API key.");
1354
+ }
1355
+ }
899
1356
  if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
900
1357
  throw new Error(
901
1358
  "ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
@@ -905,12 +1362,20 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
905
1362
  if (!interactive) throw new Error("OpenAI API was selected, but no key was provided.");
906
1363
  openaiApiKey = (await promptSecretish("OpenAI API key: ")).trim();
907
1364
  }
1365
+ if (wantsCodex && codexProviders.has("openai-proxy") && !(openaiProxyBaseUrl && openaiProxyApiKey)) {
1366
+ if (!interactive) throw new Error("OpenAI-compatible proxy was selected, but --openai-proxy-base-url and --openai-proxy-api-key were not both provided.");
1367
+ if (!openaiProxyBaseUrl) openaiProxyBaseUrl = await promptText("OpenAI proxy base URL (e.g. https://proxy.example.com/v1): ");
1368
+ if (!openaiProxyApiKey) openaiProxyApiKey = (await promptSecretish("OpenAI proxy API key: ")).trim();
1369
+ if (!openaiProxyBaseUrl || !openaiProxyApiKey) throw new Error("OpenAI-compatible proxy requires both a base URL and an API key.");
1370
+ }
908
1371
  if (wantsCodex && codexProviders.has("openrouter") && !openrouterApiKey) {
909
1372
  if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
910
1373
  openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
911
1374
  }
912
1375
 
913
- if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey) {
1376
+ const claudeProxyConfigured = Boolean(proxyBaseUrl && proxyApiKey);
1377
+ if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey
1378
+ && !claudeProxyConfigured) {
914
1379
  if (!interactive) {
915
1380
  throw new Error(
916
1381
  "Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
@@ -930,11 +1395,11 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
930
1395
  console.log("");
931
1396
  }
932
1397
 
933
- if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openrouterApiKey) {
1398
+ if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openaiProxyApiKey && !openrouterApiKey) {
934
1399
  if (!interactive) {
935
1400
  throw new Error(
936
1401
  "Codex requires ChatGPT login, --openai-api-key (or OPENAI_API_KEY), "
937
- + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
1402
+ + "--openai-proxy-api-key (with --openai-proxy-base-url), or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
938
1403
  );
939
1404
  }
940
1405
  console.log("Codex is not logged into ChatGPT and needs a user-funded provider.");
@@ -960,19 +1425,33 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
960
1425
  if (wantsCodex && openaiApiKey) {
961
1426
  credentials.push({ provider: "openai_api", token: openaiApiKey });
962
1427
  }
1428
+ if (wantsCodex && openaiProxyBaseUrl && openaiProxyApiKey) {
1429
+ credentials.push({ provider: "openai_proxy", token: openaiProxyApiKey,
1430
+ metadata: { base_url: openaiProxyBaseUrl } });
1431
+ }
963
1432
  if ((wantsClaude || wantsCodex) && openrouterApiKey) {
964
1433
  credentials.push({ provider: "openrouter", token: openrouterApiKey });
965
1434
  }
1435
+ if (wantsClaude && claudeProxyConfigured) {
1436
+ credentials.push({
1437
+ provider: "anthropic_proxy",
1438
+ token: proxyApiKey,
1439
+ metadata: { base_url: proxyBaseUrl },
1440
+ });
1441
+ }
966
1442
 
967
1443
  return {
968
1444
  credentials,
969
1445
  codexSubscriptionAuth,
970
1446
  claudeCredentialProvider: claudeAnthropicAuth
971
1447
  ? "claude-subscription"
972
- : anthropicApiKey ? "anthropic-api" : openrouterApiKey ? "openrouter" : null,
1448
+ : anthropicApiKey ? "anthropic-api"
1449
+ : claudeProxyConfigured ? "anthropic-proxy"
1450
+ : openrouterApiKey ? "openrouter" : null,
1451
+ claudeProxyBaseUrl: claudeProxyConfigured ? proxyBaseUrl : null,
973
1452
  codexCredentialProvider: codexSubscriptionAuth
974
1453
  ? "chatgpt-subscription"
975
- : openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
1454
+ : openaiApiKey ? "openai-api" : openaiProxyBaseUrl ? "openai-proxy" : openrouterApiKey ? "openrouter" : null,
976
1455
  };
977
1456
  }
978
1457
 
@@ -1142,7 +1621,7 @@ async function uninstall(flags) {
1142
1621
  const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
1143
1622
  if (flags.forgetToken) {
1144
1623
  await forgetStoredAnthropicToken(record);
1145
- } else if (record.anthropic_token_stored) {
1624
+ } else if (record.anthropic_token_stored || record.proxy_base_url) {
1146
1625
  anthropicTokenNotice = true;
1147
1626
  }
1148
1627
  if (record.launch_available) await removePennyHarness(record.harness);
@@ -1162,7 +1641,7 @@ async function uninstall(flags) {
1162
1641
  await runHarnessJob(failures, record.harness, "uninstall", async () => {
1163
1642
  if (flags.forgetToken) {
1164
1643
  await forgetStoredAnthropicToken(record);
1165
- } else if (record.anthropic_token_stored) {
1644
+ } else if (record.anthropic_token_stored || record.proxy_base_url) {
1166
1645
  anthropicTokenNotice = true;
1167
1646
  }
1168
1647
  if (record.launch_available) await removePennyHarness(record.harness);
@@ -1171,6 +1650,10 @@ async function uninstall(flags) {
1171
1650
  });
1172
1651
  }
1173
1652
 
1653
+ // Only entries PennyRouter wrote are removed, so a server the user re-pointed at their own
1654
+ // build survives an uninstall.
1655
+ await registerMcp(selectedRecords.map((record) => record.harness), { remove: true });
1656
+
1174
1657
  if (anthropicTokenNotice) {
1175
1658
  console.log("");
1176
1659
  console.log("Note: Your stored Anthropic token remains on the server for other machines.");
@@ -1252,8 +1735,13 @@ async function status() {
1252
1735
  if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
1253
1736
  if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
1254
1737
  if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
1738
+ if (record.proxy_base_url) {
1739
+ const note = record.anthropic_oauth ? " (subscription takes precedence)" : "";
1740
+ console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
1741
+ }
1255
1742
  if (record.launch_available) {
1256
- const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
1743
+ const command = record.harness === "claude-code"
1744
+ ? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
1257
1745
  console.log(` command: ${command}`);
1258
1746
  console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
1259
1747
  }
@@ -1562,14 +2050,24 @@ function formatError(error) {
1562
2050
  // live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
1563
2051
  // must not be blocked by a network hiccup; the record is going away regardless.
1564
2052
  async function forgetStoredAnthropicToken(record) {
1565
- if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
2053
+ if (record.harness !== "claude-code") return;
2054
+ if (!record.anthropic_token_stored && !record.proxy_base_url) return;
1566
2055
  try {
1567
2056
  const launchConfig = await loadPennyConfig();
1568
2057
  const globalAuth = await readClaudeCodeGatewayAuth();
1569
2058
  const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
1570
2059
  const apiKey = launchConfig.api_key || globalAuth.apiKey;
1571
2060
  if (!gatewayBaseUrl || !apiKey) return;
1572
- await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
2061
+ if (record.anthropic_token_stored) {
2062
+ await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
2063
+ }
2064
+ // The proxy key is stored server-side exactly like the subscription token, so the
2065
+ // same "forget my credentials" gesture has to clear it too.
2066
+ if (record.proxy_base_url) {
2067
+ await forgetUpstreamCredential({
2068
+ gatewayBaseUrl, apiKey, provider: "anthropic_proxy",
2069
+ });
2070
+ }
1573
2071
  } catch {
1574
2072
  // swallow — see note above
1575
2073
  }
@@ -1582,6 +2080,15 @@ async function confirm(question) {
1582
2080
  return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
1583
2081
  }
1584
2082
 
2083
+ // Visible-echo input, for values that are not secrets. A mistyped proxy URL is a
2084
+ // confusing failure much later, so the user gets to see what they typed.
2085
+ async function promptText(question) {
2086
+ const rl = createInterface({ input, output });
2087
+ const answer = await rl.question(question);
2088
+ rl.close();
2089
+ return answer.trim();
2090
+ }
2091
+
1585
2092
  async function promptSecretish(question) {
1586
2093
  if (!input.isTTY || !output.isTTY) {
1587
2094
  const rl = createInterface({ input, output });
@@ -1656,58 +2163,38 @@ function openBrowser(url) {
1656
2163
  spawnSync(command, args, { stdio: "ignore", detached: true });
1657
2164
  }
1658
2165
 
1659
- function runningClaudeCodeProcesses() {
1660
- if (process.platform === "win32") {
1661
- const result = spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8" });
1662
- if (result.status !== 0) return [];
1663
- return String(result.stdout || "").split(/\r?\n/)
1664
- .filter((line) => /^"claude(?:\.exe)?"/i.test(line.trim()));
1665
- }
1666
- const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
1667
- if (result.status !== 0) return [];
1668
- return String(result.stdout || "").split(/\r?\n/)
1669
- .map((line) => line.trim())
1670
- // Case-sensitive on purpose: match the Claude Code executable, not Claude.app.
1671
- .filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
1672
- }
1673
-
1674
- function runningCodexProcesses() {
1675
- if (process.platform === "win32") return [];
1676
- const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
1677
- if (result.status !== 0) return [];
1678
- return String(result.stdout || "").split(/\r?\n/)
1679
- .map((line) => line.trim())
1680
- .filter((line) => /(^|[\/\s])codex(?:\s|$)/.test(line));
1681
- }
1682
-
1683
- function assertNoRunningHarness(harnessId, flags) {
1684
- if (flags.allowRunningHarnesses || (harnessId === "claude-code" && flags.allowRunningClaude)) {
1685
- return;
1686
- }
1687
- const running = harnessId === "claude-code"
1688
- ? runningClaudeCodeProcesses()
1689
- : runningCodexProcesses();
1690
- if (running.length === 0) return;
1691
- const name = harnessId === "claude-code" ? "Claude Code" : "Codex";
1692
- throw new Error(
1693
- `${name} is currently running. Close all ${name} sessions before changing its global routing. `
1694
- + "Installing only the explicit `penny` command does not require this. If you intentionally "
1695
- + "accept the risk, pass --allow-running-harnesses.\nDetected:\n "
1696
- + running.slice(0, 5).join("\n "),
1697
- );
1698
- }
1699
-
1700
2166
  function printHelp() {
1701
2167
  console.log(`PennyRouter CLI
1702
2168
 
1703
2169
  Usage:
1704
- 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]
1705
- pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL] [--allow-running-harnesses]
2170
+ 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]
2171
+ pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
1706
2172
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1707
2173
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1708
2174
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1709
2175
  pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
1710
2176
  pennyrouter status
2177
+ pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
2178
+
2179
+ When an interactive install selects an Anthropic or OpenAI-compatible proxy, it asks for the PennyRouter gateway
2180
+ that can reach that proxy. Leave it blank for the hosted gateway, or enter a private/self-hosted
2181
+ gateway URL. Use --gateway-base-url (or --local for localhost:8400 development) for non-interactive installs.
2182
+
2183
+ A private gateway keeps its own keys, so the key from the browser login does not work there. Whenever
2184
+ you point install at one — with --gateway-base-url or by entering a URL at that prompt — you must also
2185
+ supply the key your PennyRouter administrator issued you: paste it when asked, or pass --private-key pr-...
2186
+
2187
+ Install and update both register the Threads MCP server with Claude Code and Codex (--no-mcp to
2188
+ skip a run). Run "pennyrouter mcp install" if you skipped it or had to rename a conflicting
2189
+ server, "pennyrouter mcp status" to see whether it is wired up, and "pennyrouter mcp uninstall"
2190
+ to remove it. Bare "pennyrouter mcp" is the server itself, run by those harnesses over stdio —
2191
+ you never invoke it by hand.
2192
+
2193
+ Once registered, ask your agent to read or resume an AI share link:
2194
+
2195
+ thread_read <url> pull a ChatGPT/Claude/Gemini/Perplexity conversation into the chat
2196
+ thread_resume <url> write it as a resumable session and print the resume command
2197
+ (harness: claude-code by default, or codex)
1711
2198
 
1712
2199
  Update refreshes the managed local config and the penny runtime to this package version.
1713
2200
  It reuses your existing key and never re-asks for subscription or API credentials.
@@ -1716,16 +2203,22 @@ Examples:
1716
2203
  npx pennyrouter update
1717
2204
  npx pennyrouter update --dry-run
1718
2205
  npx pennyrouter update --harness claude-code
2206
+ npx pennyrouter update --harness codex --local
2207
+ npx pennyrouter update --all --local
2208
+ npx pennyrouter update --all --prod
1719
2209
  npx pennyrouter install
1720
2210
  npx pennyrouter install --make-default
1721
2211
  npx pennyrouter install --penny-only
1722
2212
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
1723
2213
  npx pennyrouter install --harness claude-code --anthropic-api-key <key>
1724
2214
  npx pennyrouter install --harness codex --openai-api-key <key>
2215
+ npx pennyrouter install --harness claude-code --proxy-base-url https://proxy.example.com --proxy-api-key <key>
1725
2216
  npx pennyrouter install --openrouter-api-key <key>
2217
+ npx pennyrouter install --gateway-base-url https://gateway.internal.example.com --private-key pr-...
1726
2218
  npx pennyrouter auth anthropic
1727
2219
  npx pennyrouter install --all
1728
2220
  npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
2221
+ npx pennyrouter install --config ./penny_install.json
1729
2222
  npx pennyrouter disable
1730
2223
  npx pennyrouter enable
1731
2224
  npx pennyrouter uninstall