pennyrouter 0.3.0 → 0.3.1

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,40 +551,116 @@ 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...");
431
-
432
- const claim = await pollForKey(session);
433
- console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
434
- console.log("");
563
+ let claim;
564
+ let claudeAnthropicAuth = null;
565
+ let funding = { credentials: [], codexSubscriptionAuth: false, claudeCredentialProvider: null, codexCredentialProvider: null };
566
+ let gatewayBaseUrl;
435
567
 
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
+
611
+ // A user-supplied proxy may live on a private network that the hosted gateway cannot
612
+ // reach. Let interactive installs choose the gateway after they choose the proxy: blank
613
+ // keeps the hosted default, while a supplied URL is used for credential storage,
614
+ // validation, probing, and the installed harness configuration. `--local` remains the
615
+ // explicit localhost:8400 developer shortcut and skips this prompt.
616
+ if ((claudeProviders.has("anthropic-proxy") || codexProviders.has("openai-proxy")) && !flags.gatewayBaseUrl
617
+ && !flags.yes && input.isTTY && output.isTTY) {
618
+ const privateGateway = (await promptText(
619
+ "PennyRouter gateway URL for this proxy (blank for hosted gateway): ",
620
+ )).trim();
621
+ if (privateGateway) {
622
+ gatewayBaseUrl = privateGateway;
623
+ flags.privateGateway = true;
624
+ }
625
+ }
626
+
627
+ // A private gateway has its own key store, so the hosted key from the browser claim is
628
+ // meaningless there. Require the admin-issued key and use it for every subsequent gateway
629
+ // call (credential storage, probing, harness config).
630
+ if (flags.privateGateway) {
631
+ let privateKey = (flags.privateKey || "").trim();
632
+ if (!privateKey && !flags.yes && input.isTTY && output.isTTY) {
633
+ console.log("");
634
+ console.log(`Private gateway: ${gatewayBaseUrl}`);
635
+ console.log("Your PennyRouter administrator issues keys for this gateway.");
636
+ privateKey = (await promptText("Paste your private gateway key (pr-...): ")).trim();
637
+ }
638
+ if (!privateKey) {
639
+ throw new Error(
640
+ `A private gateway (${gatewayBaseUrl}) needs its own key. `
641
+ + "Ask your PennyRouter administrator for one and pass `--private-key pr-...`.",
642
+ );
643
+ }
644
+ if (!privateKey.startsWith("pr-")) {
645
+ throw new Error(`That does not look like a PennyRouter key — expected it to start with "pr-".`);
646
+ }
647
+ claim.api_key = privateKey;
648
+ console.log(`Using private gateway key ${maskKey(privateKey)}.`);
649
+ console.log("");
650
+ }
651
+
652
+ for (const credential of funding.credentials) {
653
+ await storeUpstreamCredential({
654
+ gatewayBaseUrl,
655
+ apiKey: claim.api_key,
656
+ provider: credential.provider,
657
+ token: credential.token,
658
+ metadata: credential.metadata,
659
+ });
660
+ }
451
661
  }
452
662
 
453
- if (selected.some((harness) => harness.id === "claude-code")) {
663
+ if (needsAuth.some((harness) => harness.id === "claude-code")) {
454
664
  console.log("Verifying modern Claude Code compatibility before changing local settings...");
455
665
  await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
456
666
  console.log("");
@@ -461,9 +671,9 @@ async function install(flags) {
461
671
  // choose the visible command behavior with all relevant context in front of them.
462
672
  const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
463
673
 
464
- const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
674
+ const launchable = selected.filter((harness) => ["claude-code", "codex", "opencode"].includes(harness.id));
465
675
  let runtimeResult = null;
466
- if (launchable.length > 0 && process.platform !== "win32") {
676
+ if (launchable.length > 0) {
467
677
  runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
468
678
  }
469
679
 
@@ -475,16 +685,13 @@ async function install(flags) {
475
685
  const existingRecord = (manifest.installations || []).find(
476
686
  (candidate) => candidate.harness === harness.id,
477
687
  );
478
- const supportsPennyCommand = ["claude-code", "codex"].includes(harness.id)
479
- && process.platform !== "win32";
688
+ const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(harness.id);
480
689
  const makeDefault = defaultRouting.get(harness.id) ?? true;
481
690
  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
691
 
692
+ const harnessNeededAuth = needsAuth.includes(harness);
486
693
  const codexSubscriptionAuth = harness.id === "codex"
487
- ? funding.codexSubscriptionAuth
694
+ ? (harnessNeededAuth ? funding.codexSubscriptionAuth : Boolean(existingRecord?.openai_subscription_auth))
488
695
  : false;
489
696
  const claudeSubscriptionAuth = harness.id === "claude-code"
490
697
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
@@ -522,9 +729,10 @@ async function install(flags) {
522
729
  }
523
730
  if (supportsPennyCommand) {
524
731
  record.launch_available = true;
525
- record.launch_config_path = compactHome(
526
- harness.id === "claude-code" ? claudeLaunchSettingsPath() : codexLaunchProfilePath(),
527
- );
732
+ const launchConfigPath = harness.id === "claude-code"
733
+ ? claudeLaunchSettingsPath()
734
+ : harness.id === "codex" ? codexLaunchProfilePath() : null;
735
+ record.launch_config_path = launchConfigPath ? compactHome(launchConfigPath) : null;
528
736
  record.default_routing = Boolean(makeDefault);
529
737
  }
530
738
  if (harness.id === "claude-code" && claudeSubscriptionAuth) {
@@ -532,11 +740,20 @@ async function install(flags) {
532
740
  record.anthropic_token_stored = true;
533
741
  record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
534
742
  }
535
- if (harness.id === "claude-code" && funding.claudeCredentialProvider) {
536
- record.user_funded_provider = funding.claudeCredentialProvider;
743
+ if (harness.id === "claude-code") {
744
+ record.user_funded_provider = harnessNeededAuth
745
+ ? (funding.claudeCredentialProvider || existingRecord?.user_funded_provider)
746
+ : existingRecord?.user_funded_provider;
747
+ // Host only, never the key — the key lives encrypted on the gateway.
748
+ const proxyBaseUrl = harnessNeededAuth
749
+ ? (funding.claudeProxyBaseUrl ?? existingRecord?.proxy_base_url ?? null)
750
+ : (existingRecord?.proxy_base_url ?? null);
751
+ if (proxyBaseUrl) record.proxy_base_url = proxyBaseUrl;
537
752
  }
538
- if (harness.id === "codex" && funding.codexCredentialProvider) {
539
- record.user_funded_provider = funding.codexCredentialProvider;
753
+ if (harness.id === "codex") {
754
+ record.user_funded_provider = harnessNeededAuth
755
+ ? (funding.codexCredentialProvider || existingRecord?.user_funded_provider)
756
+ : existingRecord?.user_funded_provider;
540
757
  }
541
758
  if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
542
759
  records.push(record);
@@ -554,28 +771,149 @@ async function install(flags) {
554
771
  for (const record of records) {
555
772
  const harness = HARNESS_BY_ID[record.harness];
556
773
  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";
774
+ const command = record.harness === "claude-code"
775
+ ? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
776
+ const native = record.harness === "claude-code" ? "claude"
777
+ : record.harness === "codex" ? "codex" : "opencode";
778
+ const harnessName = record.harness === "claude-code" ? "Claude Code"
779
+ : record.harness === "codex" ? "Codex" : "OpenCode";
560
780
  const commandWidth = Math.max(command.length, native.length) + 2;
561
781
  console.log("");
562
782
  console.log(color(`How to Run ${harnessName}`, "bold"));
563
783
  console.log("");
564
784
  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`}`);
785
+ console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName}` : `${harnessName} native`}`);
566
786
  } else {
567
787
  console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
568
788
  }
569
789
  }
570
790
  if (runtimeResult && !runtimeResult.available) {
571
- console.log("Add ~/.local/bin to PATH before using the `penny` command.");
791
+ console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
572
792
  } else if (runtimeResult?.changed) {
573
793
  console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
574
794
  }
795
+
796
+ // Registered after routing, which is the primary job: a naming conflict here reports itself
797
+ // and leaves a working install rather than failing the whole run.
798
+ if (!flags.noMcp && !flags.dryRun) {
799
+ const mcp = await registerMcp(records.map((r) => r.harness), {
800
+ gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
801
+ });
802
+ const registered = mcp.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
803
+ if (registered.length) {
804
+ console.log("");
805
+ console.log(color("Threads MCP", "bold"));
806
+ console.log(` Registered on ${registered.join(" and ")}.`);
807
+ console.log(" Ask your agent to read or resume an AI share link:");
808
+ console.log(' "code based on this chat: <paste a ChatGPT/Claude/Gemini/Perplexity link>"');
809
+ }
810
+ for (const result of mcp.filter((r) => r.error)) {
811
+ console.log(` MCP registration skipped for ${result.harness}: ${result.error}`);
812
+ }
813
+ }
814
+
575
815
  console.log("");
576
816
  reportFailures(failures);
577
817
  }
578
818
 
819
+ // Deliberately small, secret-free preflight for graphical installers. Do not add
820
+ // tokens or config contents here: the renderer only needs to know which setup
821
+ // choices are likely to work on this machine.
822
+ export async function getSetupPreflight() {
823
+ const harnesses = await Promise.all(DEFAULT_INSTALL_HARNESS_IDS.map(async (id) => {
824
+ const harness = HARNESS_BY_ID[id];
825
+ return { id, name: harness.name, detected: await safeDetect(harness) };
826
+ }));
827
+ return {
828
+ harnesses,
829
+ codex_chatgpt_logged_in: await detectCodexChatgptAuth(),
830
+ };
831
+ }
832
+
833
+ /* The `mcp install|uninstall|status` subcommands.
834
+
835
+ Install already registers the server, so these exist for the states install cannot reach:
836
+ someone who passed --no-mcp and changed their mind, someone who hit a name conflict and has
837
+ now renamed the other server, and anyone who just wants to know whether it is wired up. */
838
+
839
+ function mcpTargets(flags) {
840
+ const requested = parseHarnessList(flags.harness).filter((id) => MCP_HARNESS_IDS.includes(id));
841
+ return requested.length ? requested : MCP_HARNESS_IDS;
842
+ }
843
+
844
+ /** The gateway URL worth pinning into the MCP entry, or "" for the default.
845
+ The MCP server already falls back to production on its own, so writing that value would only
846
+ freeze today's default into every user's config. */
847
+ function customGatewayBaseUrl(url) {
848
+ const value = String(url || "").trim().replace(/\/+$/, "");
849
+ return !value || value === DEFAULT_GATEWAY_BASE_URL ? "" : value;
850
+ }
851
+
852
+ async function installMcp(flags) {
853
+ const results = await registerMcp(mcpTargets(flags), {
854
+ gatewayBaseUrl: customGatewayBaseUrl(
855
+ flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL,
856
+ ),
857
+ });
858
+ console.log("PennyRouter Threads MCP");
859
+ console.log("");
860
+ for (const result of results) {
861
+ const name = HARNESS_BY_ID[result.harness]?.name || result.harness;
862
+ if (result.error) console.log(` ${name}: ${result.error}`);
863
+ else if (result.changed) console.log(` ${name}: registered`);
864
+ else if (result.reason) console.log(` ${name}: skipped — ${result.reason}`);
865
+ else console.log(` ${name}: already registered`);
866
+ }
867
+ console.log("");
868
+ console.log("Restart any running session, then ask your agent to read or resume a share link.");
869
+ }
870
+
871
+ async function uninstallMcp(flags) {
872
+ const results = await registerMcp(mcpTargets(flags), { remove: true });
873
+ console.log("PennyRouter Threads MCP");
874
+ console.log("");
875
+ for (const result of results) {
876
+ const name = HARNESS_BY_ID[result.harness]?.name || result.harness;
877
+ console.log(` ${name}: ${result.changed ? "removed" : "nothing to remove"}`);
878
+ }
879
+ }
880
+
881
+ async function statusMcp() {
882
+ const rows = await mcpStatus(MCP_HARNESS_IDS);
883
+ console.log("PennyRouter Threads MCP");
884
+ console.log("");
885
+ for (const row of rows) {
886
+ const name = HARNESS_BY_ID[row.harness]?.name || row.harness;
887
+ if (!row.configFound) console.log(` ${name}: not installed on this machine`);
888
+ else if (row.conflict) {
889
+ console.log(` ${name}: a different server is already named "pennyrouter"`);
890
+ } else console.log(` ${name}: ${row.registered ? "registered" : "not registered"}`);
891
+ }
892
+ if (rows.some((row) => row.configFound && !row.registered)) {
893
+ console.log("");
894
+ console.log("Register with: pennyrouter mcp install");
895
+ }
896
+ }
897
+
898
+ /* Serve the Threads MCP tools over stdio. Started by the harness, not by a person: stdout is the
899
+ JSON-RPC channel, so nothing may be printed to it. Diagnostics go to stderr. */
900
+ async function serveMcp(flags) {
901
+ const launchConfig = await loadPennyConfig();
902
+ const globalAuth = await readClaudeCodeGatewayAuth();
903
+ const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
904
+ const gatewayBaseUrl = flags.gatewayBaseUrl
905
+ || launchConfig.gateway_base_url
906
+ || globalAuth.gatewayBaseUrl
907
+ || process.env.PENNYROUTER_GATEWAY_BASE_URL
908
+ || DEFAULT_GATEWAY_BASE_URL;
909
+
910
+ await runMcpServer({
911
+ gatewayBaseUrl,
912
+ apiKey,
913
+ harness: process.env.PENNYROUTER_MCP_HARNESS,
914
+ });
915
+ }
916
+
579
917
  // Refresh the managed local files to this package version WITHOUT re-running any of the
580
918
  // account/credential flow. Everything an update needs is already on disk: the pr- key and
581
919
  // gateway URL live in the penny config (and, for older installs, in the harness config the
@@ -620,7 +958,7 @@ async function update(flags) {
620
958
  || launchConfig.gateway_base_url
621
959
  || globalAuth.gatewayBaseUrl
622
960
  || process.env.PENNYROUTER_GATEWAY_BASE_URL
623
- || "https://api.pennyrouter.com";
961
+ || DEFAULT_GATEWAY_BASE_URL;
624
962
 
625
963
  if (!apiKey) {
626
964
  throw new Error(
@@ -647,8 +985,7 @@ async function update(flags) {
647
985
 
648
986
  const failures = [];
649
987
  let runtimeResult = null;
650
- if (targets.some((record) => ["claude-code", "codex"].includes(record.harness))
651
- && process.platform !== "win32") {
988
+ if (targets.some((record) => ["claude-code", "codex", "opencode"].includes(record.harness))) {
652
989
  runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
653
990
  }
654
991
 
@@ -657,13 +994,11 @@ async function update(flags) {
657
994
  if (!harness) continue;
658
995
 
659
996
  await runHarnessJob(failures, record.harness, "update", async () => {
660
- const supportsPennyCommand = ["claude-code", "codex"].includes(record.harness)
661
- && process.platform !== "win32";
997
+ const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(record.harness);
662
998
  // An update rewrites the harness's own config only when that config is ours to
663
999
  // rewrite — i.e. the bare command still routes through the gateway. In penny-only
664
1000
  // mode the harness config belongs to the user and we touch just the launch profile.
665
1001
  const ownsGlobalRouting = !record.launch_available || record.default_routing !== false;
666
- if (ownsGlobalRouting) assertNoRunningHarness(record.harness, flags);
667
1002
 
668
1003
  const claudeSubscriptionAuth = record.harness === "claude-code"
669
1004
  && Boolean(record.anthropic_token_stored);
@@ -703,9 +1038,29 @@ async function update(flags) {
703
1038
  }
704
1039
 
705
1040
  await saveManifest(manifest);
1041
+
1042
+ // Pick up MCP registration for installs that predate it, which is every existing user: the
1043
+ // server ships with the package but nothing registers it retroactively.
1044
+ //
1045
+ // Update always registers rather than honoring a stored opt-out. Registration is what makes
1046
+ // the Threads tools exist at all, and a preference remembered across version bumps would
1047
+ // mostly serve to leave people silently without them. `--no-mcp` still skips a single run,
1048
+ // and `pennyrouter mcp uninstall` removes it for good.
1049
+ if (!flags.dryRun && !flags.noMcp) {
1050
+ const wanted = targets.filter((record) => MCP_HARNESS_IDS.includes(record.harness));
1051
+ const results = await registerMcp(wanted.map((record) => record.harness), {
1052
+ gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
1053
+ });
1054
+ const added = results.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
1055
+ if (added.length) console.log(`Registered the Threads MCP server on ${added.join(" and ")}.`);
1056
+ for (const result of results.filter((r) => r.error)) {
1057
+ console.log(`MCP registration skipped for ${result.harness}: ${result.error}`);
1058
+ }
1059
+ }
1060
+
706
1061
  console.log("");
707
1062
  if (runtimeResult && !runtimeResult.available) {
708
- console.log("Add ~/.local/bin to PATH before using the `penny` command.");
1063
+ console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
709
1064
  } else if (runtimeResult?.changed) {
710
1065
  console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
711
1066
  }
@@ -716,10 +1071,6 @@ async function update(flags) {
716
1071
  async function chooseDefaultRouting(flags, selected, manifest) {
717
1072
  const out = new Map();
718
1073
  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
1074
  let newInstallChoice = null;
724
1075
  for (const harness of launchable) {
725
1076
  if (flags.makeDefault) {
@@ -745,7 +1096,10 @@ async function chooseDefaultRouting(flags, selected, manifest) {
745
1096
  continue;
746
1097
  }
747
1098
  if (newInstallChoice === null) {
748
- if (flags.dryRun || flags.yes || !input.isTTY || !output.isTTY) {
1099
+ // Auto-load-by-default prompt is disabled for now — always install as `penny <native>`
1100
+ // only. Flip ENABLE_AUTOLOAD_PROMPT to true to restore asking the user.
1101
+ const ENABLE_AUTOLOAD_PROMPT = false;
1102
+ if (!ENABLE_AUTOLOAD_PROMPT || flags.dryRun || flags.yes || !input.isTTY || !output.isTTY) {
749
1103
  newInstallChoice = false;
750
1104
  } else {
751
1105
  for (const item of launchable) {
@@ -783,14 +1137,19 @@ async function selectClaudeFundingProviders(flags, selected) {
783
1137
  || process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
784
1138
  if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
785
1139
  if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
1140
+ if ((flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL)
1141
+ && (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
1142
+ supplied.add("anthropic-proxy");
1143
+ }
786
1144
 
787
1145
  // Explicit flags are the automation selector. Interactive installs always default to the
788
1146
  // subscription alone; environment keys are used if the user checks their provider.
789
1147
  const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
790
- || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey;
1148
+ || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
1149
+ || flags.proxyBaseUrl || flags.proxyApiKey;
791
1150
  if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
792
1151
 
793
- console.log("PennyRouter can use your Claude subscription, an Anthropic API key, or an OpenRouter API key to run Claude Code.");
1152
+ 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
1153
  console.log("Choose one or more providers. PennyRouter will try them in order when available.");
795
1154
  return promptFundingProviderCheckboxes(
796
1155
  CLAUDE_PROVIDER_OPTIONS,
@@ -804,14 +1163,16 @@ async function selectCodexFundingProviders(flags, selected) {
804
1163
 
805
1164
  const supplied = new Set();
806
1165
  if (flags.openaiApiKey || process.env.OPENAI_API_KEY) supplied.add("openai-api");
1166
+ if ((flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL)
1167
+ && (flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY)) supplied.add("openai-proxy");
807
1168
  if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
808
1169
  const hasChatgptLogin = await detectCodexChatgptAuth();
809
1170
  if (hasChatgptLogin) supplied.add("subscription");
810
1171
 
811
- const explicit = flags.openaiApiKey || flags.openrouterApiKey;
1172
+ const explicit = flags.openaiApiKey || flags.openaiProxyBaseUrl || flags.openaiProxyApiKey || flags.openrouterApiKey;
812
1173
  if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
813
1174
 
814
- console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, or an OpenRouter API key to run Codex.");
1175
+ 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
1176
  console.log("Choose one or more providers. PennyRouter will try them in order when available.");
816
1177
  return promptFundingProviderCheckboxes(
817
1178
  CODEX_PROVIDER_OPTIONS,
@@ -829,7 +1190,10 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
829
1190
  if (tokenResult.token) return tokenResult;
830
1191
  }
831
1192
 
832
- if (flags.yes || !input.isTTY || !output.isTTY) {
1193
+ // --config drives --yes to skip prompts, but it still expects the subscription browser
1194
+ // sign-in to run — the caller (e.g. the macOS installer) attaches a pty for exactly this,
1195
+ // so treat it like the interactive path rather than the plain --yes/non-TTY automation path.
1196
+ if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
833
1197
  if (flags.anthropicAuth) {
834
1198
  throw new Error(
835
1199
  "Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
@@ -845,7 +1209,8 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
845
1209
  if (commandExists("claude")) {
846
1210
  // The user already opted into subscription auth above, so run `claude setup-token`
847
1211
  // 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.
1212
+ // opens the browser for approval inline. On failure we fall through to manual paste,
1213
+ // unless we came from --config, where there's no terminal to paste into.
849
1214
  console.log("Launching Claude sign-in — approve in your browser…");
850
1215
  const captured = await runClaudeSetupToken();
851
1216
  if (captured) {
@@ -853,8 +1218,13 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
853
1218
  console.log("");
854
1219
  return { token: captured, source: "setup-token" };
855
1220
  }
1221
+ if (flags.fromConfig) {
1222
+ throw new Error("Claude sign-in did not complete. Run the installer again.");
1223
+ }
856
1224
  console.log("");
857
1225
  console.log("Couldn't capture a token automatically. Paste it below, or leave blank to skip.");
1226
+ } else if (flags.fromConfig) {
1227
+ throw new Error("Claude Code was not found on PATH, so subscription sign-in could not run.");
858
1228
  } else {
859
1229
  console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
860
1230
  }
@@ -884,6 +1254,18 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
884
1254
  let openrouterApiKey = String(
885
1255
  flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
886
1256
  ).trim();
1257
+ let openaiProxyBaseUrl = String(
1258
+ flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL || "",
1259
+ ).trim();
1260
+ let openaiProxyApiKey = String(
1261
+ flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY || "",
1262
+ ).trim();
1263
+ let proxyBaseUrl = String(
1264
+ flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL || "",
1265
+ ).trim();
1266
+ let proxyApiKey = String(
1267
+ flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY || "",
1268
+ ).trim();
887
1269
  const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
888
1270
  ? await detectCodexChatgptAuth()
889
1271
  : false;
@@ -896,6 +1278,19 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
896
1278
  if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
897
1279
  openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
898
1280
  }
1281
+ if (wantsClaude && claudeProviders.has("anthropic-proxy") && !(proxyBaseUrl && proxyApiKey)) {
1282
+ if (!interactive) {
1283
+ throw new Error(
1284
+ "Other proxy was selected, but --proxy-base-url and --proxy-api-key were not both provided.",
1285
+ );
1286
+ }
1287
+ // The URL is not a secret and is easy to mistype, so it echoes; the key does not.
1288
+ if (!proxyBaseUrl) proxyBaseUrl = await promptText("Proxy base URL (e.g. https://proxy.example.com): ");
1289
+ if (!proxyApiKey) proxyApiKey = (await promptSecretish("Proxy API key: ")).trim();
1290
+ if (!proxyBaseUrl || !proxyApiKey) {
1291
+ throw new Error("Other proxy requires both a base URL and an API key.");
1292
+ }
1293
+ }
899
1294
  if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
900
1295
  throw new Error(
901
1296
  "ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
@@ -905,12 +1300,20 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
905
1300
  if (!interactive) throw new Error("OpenAI API was selected, but no key was provided.");
906
1301
  openaiApiKey = (await promptSecretish("OpenAI API key: ")).trim();
907
1302
  }
1303
+ if (wantsCodex && codexProviders.has("openai-proxy") && !(openaiProxyBaseUrl && openaiProxyApiKey)) {
1304
+ if (!interactive) throw new Error("OpenAI-compatible proxy was selected, but --openai-proxy-base-url and --openai-proxy-api-key were not both provided.");
1305
+ if (!openaiProxyBaseUrl) openaiProxyBaseUrl = await promptText("OpenAI proxy base URL (e.g. https://proxy.example.com/v1): ");
1306
+ if (!openaiProxyApiKey) openaiProxyApiKey = (await promptSecretish("OpenAI proxy API key: ")).trim();
1307
+ if (!openaiProxyBaseUrl || !openaiProxyApiKey) throw new Error("OpenAI-compatible proxy requires both a base URL and an API key.");
1308
+ }
908
1309
  if (wantsCodex && codexProviders.has("openrouter") && !openrouterApiKey) {
909
1310
  if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
910
1311
  openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
911
1312
  }
912
1313
 
913
- if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey) {
1314
+ const claudeProxyConfigured = Boolean(proxyBaseUrl && proxyApiKey);
1315
+ if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey
1316
+ && !claudeProxyConfigured) {
914
1317
  if (!interactive) {
915
1318
  throw new Error(
916
1319
  "Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
@@ -930,11 +1333,11 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
930
1333
  console.log("");
931
1334
  }
932
1335
 
933
- if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openrouterApiKey) {
1336
+ if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openaiProxyApiKey && !openrouterApiKey) {
934
1337
  if (!interactive) {
935
1338
  throw new Error(
936
1339
  "Codex requires ChatGPT login, --openai-api-key (or OPENAI_API_KEY), "
937
- + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
1340
+ + "--openai-proxy-api-key (with --openai-proxy-base-url), or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
938
1341
  );
939
1342
  }
940
1343
  console.log("Codex is not logged into ChatGPT and needs a user-funded provider.");
@@ -960,19 +1363,33 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
960
1363
  if (wantsCodex && openaiApiKey) {
961
1364
  credentials.push({ provider: "openai_api", token: openaiApiKey });
962
1365
  }
1366
+ if (wantsCodex && openaiProxyBaseUrl && openaiProxyApiKey) {
1367
+ credentials.push({ provider: "openai_proxy", token: openaiProxyApiKey,
1368
+ metadata: { base_url: openaiProxyBaseUrl } });
1369
+ }
963
1370
  if ((wantsClaude || wantsCodex) && openrouterApiKey) {
964
1371
  credentials.push({ provider: "openrouter", token: openrouterApiKey });
965
1372
  }
1373
+ if (wantsClaude && claudeProxyConfigured) {
1374
+ credentials.push({
1375
+ provider: "anthropic_proxy",
1376
+ token: proxyApiKey,
1377
+ metadata: { base_url: proxyBaseUrl },
1378
+ });
1379
+ }
966
1380
 
967
1381
  return {
968
1382
  credentials,
969
1383
  codexSubscriptionAuth,
970
1384
  claudeCredentialProvider: claudeAnthropicAuth
971
1385
  ? "claude-subscription"
972
- : anthropicApiKey ? "anthropic-api" : openrouterApiKey ? "openrouter" : null,
1386
+ : anthropicApiKey ? "anthropic-api"
1387
+ : claudeProxyConfigured ? "anthropic-proxy"
1388
+ : openrouterApiKey ? "openrouter" : null,
1389
+ claudeProxyBaseUrl: claudeProxyConfigured ? proxyBaseUrl : null,
973
1390
  codexCredentialProvider: codexSubscriptionAuth
974
1391
  ? "chatgpt-subscription"
975
- : openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
1392
+ : openaiApiKey ? "openai-api" : openaiProxyBaseUrl ? "openai-proxy" : openrouterApiKey ? "openrouter" : null,
976
1393
  };
977
1394
  }
978
1395
 
@@ -1142,7 +1559,7 @@ async function uninstall(flags) {
1142
1559
  const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
1143
1560
  if (flags.forgetToken) {
1144
1561
  await forgetStoredAnthropicToken(record);
1145
- } else if (record.anthropic_token_stored) {
1562
+ } else if (record.anthropic_token_stored || record.proxy_base_url) {
1146
1563
  anthropicTokenNotice = true;
1147
1564
  }
1148
1565
  if (record.launch_available) await removePennyHarness(record.harness);
@@ -1162,7 +1579,7 @@ async function uninstall(flags) {
1162
1579
  await runHarnessJob(failures, record.harness, "uninstall", async () => {
1163
1580
  if (flags.forgetToken) {
1164
1581
  await forgetStoredAnthropicToken(record);
1165
- } else if (record.anthropic_token_stored) {
1582
+ } else if (record.anthropic_token_stored || record.proxy_base_url) {
1166
1583
  anthropicTokenNotice = true;
1167
1584
  }
1168
1585
  if (record.launch_available) await removePennyHarness(record.harness);
@@ -1171,6 +1588,10 @@ async function uninstall(flags) {
1171
1588
  });
1172
1589
  }
1173
1590
 
1591
+ // Only entries PennyRouter wrote are removed, so a server the user re-pointed at their own
1592
+ // build survives an uninstall.
1593
+ await registerMcp(selectedRecords.map((record) => record.harness), { remove: true });
1594
+
1174
1595
  if (anthropicTokenNotice) {
1175
1596
  console.log("");
1176
1597
  console.log("Note: Your stored Anthropic token remains on the server for other machines.");
@@ -1252,8 +1673,13 @@ async function status() {
1252
1673
  if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
1253
1674
  if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
1254
1675
  if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
1676
+ if (record.proxy_base_url) {
1677
+ const note = record.anthropic_oauth ? " (subscription takes precedence)" : "";
1678
+ console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
1679
+ }
1255
1680
  if (record.launch_available) {
1256
- const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
1681
+ const command = record.harness === "claude-code"
1682
+ ? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
1257
1683
  console.log(` command: ${command}`);
1258
1684
  console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
1259
1685
  }
@@ -1562,14 +1988,24 @@ function formatError(error) {
1562
1988
  // live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
1563
1989
  // must not be blocked by a network hiccup; the record is going away regardless.
1564
1990
  async function forgetStoredAnthropicToken(record) {
1565
- if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
1991
+ if (record.harness !== "claude-code") return;
1992
+ if (!record.anthropic_token_stored && !record.proxy_base_url) return;
1566
1993
  try {
1567
1994
  const launchConfig = await loadPennyConfig();
1568
1995
  const globalAuth = await readClaudeCodeGatewayAuth();
1569
1996
  const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
1570
1997
  const apiKey = launchConfig.api_key || globalAuth.apiKey;
1571
1998
  if (!gatewayBaseUrl || !apiKey) return;
1572
- await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
1999
+ if (record.anthropic_token_stored) {
2000
+ await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
2001
+ }
2002
+ // The proxy key is stored server-side exactly like the subscription token, so the
2003
+ // same "forget my credentials" gesture has to clear it too.
2004
+ if (record.proxy_base_url) {
2005
+ await forgetUpstreamCredential({
2006
+ gatewayBaseUrl, apiKey, provider: "anthropic_proxy",
2007
+ });
2008
+ }
1573
2009
  } catch {
1574
2010
  // swallow — see note above
1575
2011
  }
@@ -1582,6 +2018,15 @@ async function confirm(question) {
1582
2018
  return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
1583
2019
  }
1584
2020
 
2021
+ // Visible-echo input, for values that are not secrets. A mistyped proxy URL is a
2022
+ // confusing failure much later, so the user gets to see what they typed.
2023
+ async function promptText(question) {
2024
+ const rl = createInterface({ input, output });
2025
+ const answer = await rl.question(question);
2026
+ rl.close();
2027
+ return answer.trim();
2028
+ }
2029
+
1585
2030
  async function promptSecretish(question) {
1586
2031
  if (!input.isTTY || !output.isTTY) {
1587
2032
  const rl = createInterface({ input, output });
@@ -1656,58 +2101,38 @@ function openBrowser(url) {
1656
2101
  spawnSync(command, args, { stdio: "ignore", detached: true });
1657
2102
  }
1658
2103
 
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
2104
  function printHelp() {
1701
2105
  console.log(`PennyRouter CLI
1702
2106
 
1703
2107
  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]
2108
+ 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]
2109
+ pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
1706
2110
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1707
2111
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1708
2112
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1709
2113
  pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
1710
2114
  pennyrouter status
2115
+ pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
2116
+
2117
+ When an interactive install selects an Anthropic or OpenAI-compatible proxy, it asks for the PennyRouter gateway
2118
+ that can reach that proxy. Leave it blank for the hosted gateway, or enter a private/self-hosted
2119
+ gateway URL. Use --gateway-base-url (or --local for localhost:8400 development) for non-interactive installs.
2120
+
2121
+ A private gateway keeps its own keys, so the key from the browser login does not work there. Whenever
2122
+ you point install at one — with --gateway-base-url or by entering a URL at that prompt — you must also
2123
+ supply the key your PennyRouter administrator issued you: paste it when asked, or pass --private-key pr-...
2124
+
2125
+ Install and update both register the Threads MCP server with Claude Code and Codex (--no-mcp to
2126
+ skip a run). Run "pennyrouter mcp install" if you skipped it or had to rename a conflicting
2127
+ server, "pennyrouter mcp status" to see whether it is wired up, and "pennyrouter mcp uninstall"
2128
+ to remove it. Bare "pennyrouter mcp" is the server itself, run by those harnesses over stdio —
2129
+ you never invoke it by hand.
2130
+
2131
+ Once registered, ask your agent to read or resume an AI share link:
2132
+
2133
+ thread_read <url> pull a ChatGPT/Claude/Gemini/Perplexity conversation into the chat
2134
+ thread_resume <url> write it as a resumable session and print the resume command
2135
+ (harness: claude-code by default, or codex)
1711
2136
 
1712
2137
  Update refreshes the managed local config and the penny runtime to this package version.
1713
2138
  It reuses your existing key and never re-asks for subscription or API credentials.
@@ -1716,16 +2141,22 @@ Examples:
1716
2141
  npx pennyrouter update
1717
2142
  npx pennyrouter update --dry-run
1718
2143
  npx pennyrouter update --harness claude-code
2144
+ npx pennyrouter update --harness codex --local
2145
+ npx pennyrouter update --all --local
2146
+ npx pennyrouter update --all --prod
1719
2147
  npx pennyrouter install
1720
2148
  npx pennyrouter install --make-default
1721
2149
  npx pennyrouter install --penny-only
1722
2150
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
1723
2151
  npx pennyrouter install --harness claude-code --anthropic-api-key <key>
1724
2152
  npx pennyrouter install --harness codex --openai-api-key <key>
2153
+ npx pennyrouter install --harness claude-code --proxy-base-url https://proxy.example.com --proxy-api-key <key>
1725
2154
  npx pennyrouter install --openrouter-api-key <key>
2155
+ npx pennyrouter install --gateway-base-url https://gateway.internal.example.com --private-key pr-...
1726
2156
  npx pennyrouter auth anthropic
1727
2157
  npx pennyrouter install --all
1728
2158
  npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
2159
+ npx pennyrouter install --config ./penny_install.json
1729
2160
  npx pennyrouter disable
1730
2161
  npx pennyrouter enable
1731
2162
  npx pennyrouter uninstall