pennyrouter 0.2.7 → 0.2.8

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.js CHANGED
@@ -1,1733 +1,1727 @@
1
- import { spawn, spawnSync } from "node:child_process";
2
- import { emitKeypressEvents } from "node:readline";
3
- import { createInterface } from "node:readline/promises";
4
- import { stdin as input, stdout as output } from "node:process";
5
- import { aiderHarness } from "./harnesses/aider.js";
6
- import {
7
- CLAUDE_CODE_MODELS,
8
- claudeCodeHarness,
9
- readClaudeCodeGatewayAuth,
10
- readClaudeCodePennyRouterKey,
11
- } from "./harnesses/claude-code.js";
12
- import { clineHarness } from "./harnesses/cline.js";
13
- import { codeGptHarness } from "./harnesses/codegpt.js";
14
- import { codexHarness, detectCodexChatgptAuth } from "./harnesses/codex.js";
15
- import { opencodeHarness } from "./harnesses/opencode.js";
16
- import { windsurfHarness } from "./harnesses/windsurf.js";
17
- import { zedHarness } from "./harnesses/zed.js";
18
- import {
19
- APP_URL,
20
- createCliSession,
21
- forgetAnthropicToken,
22
- maskKey,
23
- pollForKey,
24
- probeClaudeCodeGateway,
25
- sleep,
26
- storeAnthropicToken,
27
- storeUpstreamCredential,
28
- } from "./session.js";
29
- import {
30
- backupFile,
31
- compactHome,
32
- loadManifest,
33
- restoreFile,
34
- saveManifest,
35
- statePath,
36
- } from "./state.js";
37
- import {
38
- claudeLaunchSettingsPath,
39
- codexLaunchProfilePath,
40
- configurePennyHarness,
41
- hasConfiguredPennyHarnesses,
42
- installPennyRuntime,
43
- loadPennyConfig,
44
- pennyConfigPath,
45
- removePennyHarness,
46
- removePennyRuntime,
47
- savePennyConfig,
48
- setPennyHarnessEnabled,
49
- } from "./launch.js";
50
-
51
- const HARNESS_BY_ID = {
52
- "claude-code": claudeCodeHarness,
53
- cline: clineHarness,
54
- opencode: opencodeHarness,
55
- codex: codexHarness,
56
- windsurf: windsurfHarness,
57
- zed: zedHarness,
58
- codegpt: codeGptHarness,
59
- aider: aiderHarness,
60
- };
61
-
62
- const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
63
- const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex"];
64
- const CLAUDE_PROVIDER_OPTIONS = [
65
- { id: "subscription", name: "Claude subscription" },
66
- { id: "anthropic-api", name: "Anthropic API key" },
67
- { id: "openrouter", name: "OpenRouter API key" },
68
- ];
69
- const CODEX_PROVIDER_OPTIONS = [
70
- { id: "subscription", name: "ChatGPT subscription" },
71
- { id: "openai-api", name: "OpenAI API key" },
72
- { id: "openrouter", name: "OpenRouter API key" },
73
- ];
74
-
75
- export async function main(argv = process.argv.slice(2)) {
76
- const { command, flags } = parseArgs(argv);
77
-
78
- if (flags.help || command === "help") {
79
- printHelp();
80
- return;
81
- }
82
-
83
- if (command === "install") {
84
- await install(flags);
85
- return;
86
- }
87
-
88
- if (command === "update") {
89
- await update(flags);
90
- return;
91
- }
92
-
93
- if (command === "auth") {
94
- await authProvider(flags);
95
- return;
96
- }
97
-
98
- if (command === "uninstall") {
99
- await uninstall(flags);
100
- return;
101
- }
102
-
103
- if (command === "disable") {
104
- await disable(flags);
105
- return;
106
- }
107
-
108
- if (command === "enable") {
109
- await enable(flags);
110
- return;
111
- }
112
-
113
- if (command === "status") {
114
- await status();
115
- return;
116
- }
117
-
118
- throw new Error(`Unknown command "${command}". Run "pennyrouter help".`);
119
- }
120
-
121
- function parseArgs(argv) {
122
- const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : "install";
123
- const rest = command === argv[0] ? argv.slice(1) : argv;
124
- const flags = {};
125
- flags.args = [];
126
-
127
- for (let i = 0; i < rest.length; i += 1) {
128
- const arg = rest[i];
129
- if (arg === "--help" || arg === "-h") flags.help = true;
130
- else if (arg === "--yes" || arg === "-y") flags.yes = true;
131
- else if (arg === "--all") flags.all = true;
132
- else if (arg === "--dry-run") flags.dryRun = true;
133
- else if (arg === "--allow-running-claude") flags.allowRunningClaude = true;
134
- else if (arg === "--allow-running-harnesses") flags.allowRunningHarnesses = true;
135
- else if (arg === "--make-default") flags.makeDefault = true;
136
- else if (arg === "--penny-only") flags.pennyOnly = true;
137
- else if (arg === "--no-path-update") flags.noPathUpdate = true;
138
- else if (arg === "--no-browser") flags.noBrowser = true;
139
- else if (arg === "--existing-account") flags.existingAccount = true;
140
- else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
141
- else if (arg === "--no-anthropic-auth") flags.noAnthropicAuth = true;
142
- else if (arg === "--penny-key") flags.pennyKey = rest[++i] || "";
143
- else if (arg.startsWith("--penny-key=")) flags.pennyKey = arg.slice("--penny-key=".length);
144
- else if (arg === "--token") flags.token = rest[++i] || "";
145
- else if (arg.startsWith("--token=")) flags.token = arg.slice("--token=".length);
146
- else if (arg === "--token-command") flags.tokenCommand = rest[++i] || "";
147
- else if (arg.startsWith("--token-command=")) flags.tokenCommand = arg.slice("--token-command=".length);
148
- else if (arg === "--anthropic-api-key") flags.anthropicApiKey = rest[++i] || "";
149
- else if (arg.startsWith("--anthropic-api-key=")) flags.anthropicApiKey = arg.slice("--anthropic-api-key=".length);
150
- else if (arg === "--openai-api-key") flags.openaiApiKey = rest[++i] || "";
151
- else if (arg.startsWith("--openai-api-key=")) flags.openaiApiKey = arg.slice("--openai-api-key=".length);
152
- else if (arg === "--openrouter-api-key") flags.openrouterApiKey = rest[++i] || "";
153
- else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
154
- else if (arg === "--harness") flags.harness = rest[++i] || "";
155
- else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
156
- else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
157
- 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);
160
- // --local: point the installed harness at a locally-running gateway (default
161
- // http://localhost:8400), for developing/testing against your own gateway. Auth still goes
162
- // to prod (pennyrouter.com) so you get a real key; only the harness's serving URL is local.
163
- // `--local=<url>` overrides the port/host.
164
- else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
165
- else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
166
- else if (arg === "--forget") flags.forget = true;
167
- else if (arg === "--forget-token") flags.forgetToken = true;
168
- else if (!arg.startsWith("-")) flags.args.push(arg);
169
- else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
170
- }
171
-
172
- return { command, flags };
173
- }
174
-
175
- async function authProvider(flags) {
176
- const provider = (flags.args[0] || "anthropic").toLowerCase();
177
- if (provider !== "anthropic" && provider !== "claude") {
178
- throw new Error(`Unknown auth provider "${provider}". Try "pennyrouter auth anthropic".`);
179
- }
180
-
181
- if (flags.forget) {
182
- const launchConfig = await loadPennyConfig();
183
- const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
184
- if (!pennyKey) {
185
- throw new Error(
186
- "No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
187
- );
188
- }
189
- const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
190
- if (flags.dryRun) {
191
- console.log(`Will delete stored Anthropic token from gateway: ${gatewayBaseUrl}`);
192
- return;
193
- }
194
- const ok = await forgetAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey });
195
- if (ok) {
196
- console.log("Successfully forgot stored Anthropic token on server.");
197
- } else {
198
- console.log("Failed to forget stored Anthropic token on server.");
199
- }
200
- const manifest = await loadManifest();
201
- let updated = false;
202
- for (const record of (manifest.installations || [])) {
203
- if (record.harness === "claude-code" && record.anthropic_token_stored) {
204
- record.anthropic_token_stored = false;
205
- record.anthropic_oauth = false;
206
- updated = true;
207
- }
208
- }
209
- if (updated) {
210
- await saveManifest(manifest);
211
- }
212
- if (launchConfig.harnesses?.claude) {
213
- launchConfig.harnesses.claude.subscription_auth = false;
214
- await savePennyConfig(launchConfig);
215
- }
216
- return;
217
- }
218
-
219
- if (flags.dryRun) {
220
- console.log("Will configure Claude Code for PennyRouter + Anthropic OAuth dual auth.");
221
- console.log("Dry run only. No files changed.");
222
- return;
223
- }
224
-
225
- const launchConfig = await loadPennyConfig();
226
- const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
227
- if (!pennyKey) {
228
- throw new Error(
229
- "No PennyRouter key found in Claude Code settings. Run `pennyrouter install --harness claude-code` first, or pass `--penny-key pr-...`.",
230
- );
231
- }
232
-
233
- const tokenResult = readAnthropicOAuthToken(flags);
234
- const token = tokenResult.token;
235
- if (!token) {
236
- throw new Error(
237
- "No Anthropic OAuth token found. Run `claude setup-token` and pass it with `--token`, set ANTHROPIC_AUTH_TOKEN, or pass `--token-command <cmd>`.",
238
- );
239
- }
240
-
241
- const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
242
- const manifest = await loadManifest();
243
- const existingRecord = (manifest.installations || []).find(
244
- (candidate) => candidate.harness === "claude-code",
245
- );
246
- const ownsGlobalRouting = existingRecord
247
- ? existingRecord.default_routing !== false
248
- : Boolean(await readClaudeCodePennyRouterKey());
249
- if (ownsGlobalRouting) assertNoRunningHarness("claude-code", flags);
250
- await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
251
- await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: pennyKey });
252
- if (process.platform !== "win32") {
253
- await installPennyRuntime({ updatePath: !flags.noPathUpdate });
254
- }
255
- await configurePennyHarness("claude-code", {
256
- apiKey: pennyKey,
257
- gatewayBaseUrl,
258
- subscriptionAuth: true,
259
- models: CLAUDE_CODE_MODELS,
260
- });
261
- let record = existingRecord || {
262
- harness: "claude-code",
263
- config_path: compactHome((await claudeCodeHarness.planInstall()).configPath),
264
- installed_at: new Date().toISOString(),
265
- launch_available: true,
266
- launch_config_path: compactHome(claudeLaunchSettingsPath()),
267
- default_routing: ownsGlobalRouting,
268
- };
269
- if (ownsGlobalRouting) {
270
- const plan = await claudeCodeHarness.planInstall();
271
- record = {
272
- ...record,
273
- ...(await claudeCodeHarness.installStoredToken({
274
- apiKey: pennyKey, gatewayBaseUrl, plan, existingRecord,
275
- })),
276
- };
277
- }
278
-
279
- upsertManifestRecord(manifest, {
280
- ...record,
281
- launch_available: true,
282
- launch_config_path: compactHome(claudeLaunchSettingsPath()),
283
- default_routing: ownsGlobalRouting,
284
- anthropic_oauth: true,
285
- anthropic_token_stored: true,
286
- token_source: tokenResult.source,
287
- });
288
- await saveManifest(manifest);
289
-
290
- console.log("Configured `penny claude` for PennyRouter + your Claude subscription.");
291
- console.log("Your Claude token is stored encrypted; Claude Code holds only your PennyRouter key.");
292
- console.log("If Claude auth expires, refresh the token source and run `pennyrouter auth anthropic` again.");
293
- console.log(`Penny config: ${pennyConfigPath()}`);
294
- }
295
-
296
- function readAnthropicOAuthToken(flags = {}) {
297
- if (flags.token) return { token: normalizeAnthropicToken(flags.token), source: "flag" };
298
- if (flags.tokenCommand) {
299
- const result = spawnSync(flags.tokenCommand, {
300
- encoding: "utf8",
301
- shell: true,
302
- stdio: ["ignore", "pipe", "pipe"],
303
- });
304
- if (result.status === 0) {
305
- return { token: normalizeAnthropicToken(result.stdout), source: "token-command" };
306
- }
307
- return { token: "", source: "token-command" };
308
- }
309
- if (process.env.ANTHROPIC_AUTH_TOKEN) {
310
- return { token: normalizeAnthropicToken(process.env.ANTHROPIC_AUTH_TOKEN), source: "env" };
311
- }
312
- const result = spawnSync("ant", ["auth", "print-credentials", "--access-token"], {
313
- encoding: "utf8",
314
- stdio: ["ignore", "pipe", "pipe"],
315
- });
316
- if (result.status !== 0) return { token: "", source: "none" };
317
- return { token: normalizeAnthropicToken(result.stdout), source: "ant" };
318
- }
319
-
320
- // ANSI/CSI escape sequences that `claude setup-token`'s TUI emits (colors, cursor
321
- // moves, bracketed-paste). We strip these before scanning the captured stream for the
322
- // token so a match isn't split by control bytes. Kept deliberately broad.
323
- // eslint-disable-next-line no-control-regex
324
- const ANSI_ESCAPE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
325
- // The token line printed by `claude setup-token`. Match any sk-ant-* token so we don't
326
- // depend on the exact variant/version (e.g. sk-ant-oat01-...).
327
- const ANTHROPIC_SETUP_TOKEN_RE = /sk-ant-[A-Za-z0-9_-]+/;
328
-
329
- // Run `claude setup-token` inline and extract the token it prints, WITHOUT echoing any
330
- // of the command's own output to the terminal. `claude setup-token` is a full-screen
331
- // Ink TUI: it redraws with carriage returns and cursor-move escapes (not newlines), so
332
- // its banner ("Welcome to Claude Code…"), spinner frames, the OAuth URL, the real token
333
- // ("sk-ant-oat01-…") and the "Store this / export" chrome all arrive interleaved with
334
- // no reliable line boundaries. There's no safe way to tee that stream line-by-line, and
335
- // none of it needs to reach the user: the browser auto-opens for approval, and the
336
- // token goes server-side (encrypted). So we fully suppress the child's output — we pipe
337
- // BOTH stdout and stderr only to capture them in memory for token extraction, and print
338
- // our own clean status lines around the call instead. The Ink TUI renders to stdout (the
339
- // token line arrives there — that's what we scan), but we also silence stderr so no
340
- // spinner/banner frame can leak from the other stream. Only stdin inherits the TTY, which
341
- // is all that's needed for the interactive approval handoff; the browser is opened by the
342
- // OS, not the terminal. After exit we ANSI-strip the captured output and regex out the
343
- // token. Returns "" on non-zero exit or if no token appeared, so the caller falls back to
344
- // manual paste.
345
- function runClaudeSetupToken() {
346
- return new Promise((resolve) => {
347
- let captured = "";
348
- // On Windows, npm installs `claude` as a `claude.cmd` shim which Node's spawn will
349
- // not resolve without a shell (it looks for a bare executable and throws ENOENT).
350
- // Route through the shell there so the shim resolves; the args are fixed literals
351
- // (no user input), so there's no shell-injection surface. POSIX spawns `claude`
352
- // directly. Works from both PowerShell and cmd.exe host terminals since Node invokes
353
- // cmd.exe itself for the shell.
354
- const child = spawn("claude", ["setup-token"], {
355
- // stdin keeps the TTY; stdout+stderr are piped (captured, never echoed).
356
- stdio: ["inherit", "pipe", "pipe"],
357
- shell: process.platform === "win32",
358
- });
359
-
360
- const capture = (chunk) => {
361
- captured += chunk.toString("utf8"); // in-memory only — nothing is written to the terminal
362
- };
363
- child.stdout.on("data", capture);
364
- child.stderr.on("data", capture);
365
-
366
- child.on("error", () => resolve(""));
367
- child.on("close", (code) => {
368
- if (code !== 0) return resolve("");
369
- const match = captured.replace(ANSI_ESCAPE_RE, "").match(ANTHROPIC_SETUP_TOKEN_RE);
370
- resolve(match ? match[0] : "");
371
- });
372
- });
373
- }
374
-
375
- function normalizeAnthropicToken(value) {
376
- let text = String(value || "").trim();
377
- if (!text) return "";
378
- text = text.replace(/^export\s+/, "");
379
- const match = text.match(/^ANTHROPIC_AUTH_TOKEN=(.*)$/);
380
- if (match) text = match[1].trim();
381
- if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
382
- text = text.slice(1, -1);
383
- }
384
- return text.replace(/\s+/g, "").trim();
385
- }
386
-
387
- async function install(flags) {
388
- if (flags.makeDefault && flags.pennyOnly) {
389
- throw new Error("--make-default and --penny-only cannot be used together.");
390
- }
391
- const selected = await selectHarnesses(flags);
392
- if (selected.length === 0) {
393
- console.log("No supported harnesses selected.");
394
- return;
395
- }
396
-
397
- console.log("PennyRouter install");
398
- console.log("");
399
-
400
- const manifest = await loadManifest();
401
-
402
- if (flags.dryRun) {
403
- const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
404
- 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";
407
- console.log(`Will install on ${harness.name}${explicit ? ` via ${command}` : installNoteForHarness(harness.id)}.`);
408
- if (defaultRouting.get(harness.id)) {
409
- console.log(`Will also route bare ${harness.id === "claude-code" ? "claude" : harness.id} through PennyRouter.`);
410
- }
411
- }
412
- if (flags.gatewayBaseUrl) console.log(`Serving gateway: ${flags.gatewayBaseUrl}`);
413
- if (selected.some((harness) => harness.id === "claude-code")) {
414
- console.log("Claude Code provider picker would be shown unless providers were supplied by flags or environment.");
415
- }
416
- console.log("Dry run only. No files changed.");
417
- return;
418
- }
419
-
420
- const session = await createCliSession({
421
- appUrl: flags.appUrl || APP_URL,
422
- harnesses: selected.map((harness) => harness.id),
423
- accountMode: "existing",
424
- });
425
-
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("");
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,
450
- });
451
- }
452
-
453
- if (selected.some((harness) => harness.id === "claude-code")) {
454
- console.log("Verifying modern Claude Code compatibility before changing local settings...");
455
- await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
456
- console.log("");
457
- }
458
-
459
- // Keep this as the final interactive choice: users first authorize their account,
460
- // optionally connect their subscription, and pass compatibility checks. Then they
461
- // choose the visible command behavior with all relevant context in front of them.
462
- const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
463
-
464
- const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
465
- let runtimeResult = null;
466
- if (launchable.length > 0 && process.platform !== "win32") {
467
- runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
468
- }
469
-
470
- const records = [];
471
- const failures = [];
472
- for (const harness of selected) {
473
- await runHarnessJob(failures, harness.id, "install", async () => {
474
- const plan = await harness.planInstall();
475
- const existingRecord = (manifest.installations || []).find(
476
- (candidate) => candidate.harness === harness.id,
477
- );
478
- const supportsPennyCommand = ["claude-code", "codex"].includes(harness.id)
479
- && process.platform !== "win32";
480
- const makeDefault = defaultRouting.get(harness.id) ?? true;
481
- 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
-
486
- const codexSubscriptionAuth = harness.id === "codex"
487
- ? funding.codexSubscriptionAuth
488
- : false;
489
- const claudeSubscriptionAuth = harness.id === "claude-code"
490
- && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
491
-
492
- if (supportsPennyCommand) {
493
- await configurePennyHarness(harness.id, {
494
- apiKey: claim.api_key,
495
- gatewayBaseUrl,
496
- subscriptionAuth: harness.id === "claude-code"
497
- ? claudeSubscriptionAuth
498
- : codexSubscriptionAuth,
499
- models: harness.id === "claude-code" ? CLAUDE_CODE_MODELS : null,
500
- });
501
- }
502
-
503
- let record;
504
- if (!supportsPennyCommand || makeDefault) {
505
- record = harness.id === "claude-code" && claudeSubscriptionAuth
506
- ? await harness.installStoredToken({
507
- apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
508
- })
509
- : await harness.install({
510
- apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
511
- });
512
- } else {
513
- if (hadGlobalRouting) {
514
- await harness.uninstall(existingRecord);
515
- }
516
- record = {
517
- harness: harness.id,
518
- config_path: compactHome(plan.configPath),
519
- backup_path: existingRecord?.backup_path || null,
520
- installed_at: new Date().toISOString(),
521
- };
522
- }
523
- if (supportsPennyCommand) {
524
- record.launch_available = true;
525
- record.launch_config_path = compactHome(
526
- harness.id === "claude-code" ? claudeLaunchSettingsPath() : codexLaunchProfilePath(),
527
- );
528
- record.default_routing = Boolean(makeDefault);
529
- }
530
- if (harness.id === "claude-code" && claudeSubscriptionAuth) {
531
- record.anthropic_oauth = true;
532
- record.anthropic_token_stored = true;
533
- record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
534
- }
535
- if (harness.id === "claude-code" && funding.claudeCredentialProvider) {
536
- record.user_funded_provider = funding.claudeCredentialProvider;
537
- }
538
- if (harness.id === "codex" && funding.codexCredentialProvider) {
539
- record.user_funded_provider = funding.codexCredentialProvider;
540
- }
541
- if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
542
- records.push(record);
543
- upsertManifestRecord(manifest, record);
544
- await saveManifest(manifest);
545
- });
546
- }
547
-
548
- await saveManifest(manifest);
549
- console.log("");
550
- console.log(records.length > 0
551
- ? "PennyRouter installation complete."
552
- : "PennyRouter was not installed on any selected tool.");
553
- console.log(`Manifest: ${statePath("installations.json")}`);
554
- for (const record of records) {
555
- const harness = HARNESS_BY_ID[record.harness];
556
- 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";
560
- const commandWidth = Math.max(command.length, native.length) + 2;
561
- console.log("");
562
- console.log(color(`How to Run ${harnessName}`, "bold"));
563
- console.log("");
564
- 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`}`);
566
- } else {
567
- console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
568
- }
569
- }
570
- if (runtimeResult && !runtimeResult.available) {
571
- console.log("Add ~/.local/bin to PATH before using the `penny` command.");
572
- } else if (runtimeResult?.changed) {
573
- console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
574
- }
575
- console.log("");
576
- reportFailures(failures);
577
- }
578
-
579
- // Refresh the managed local files to this package version WITHOUT re-running any of the
580
- // account/credential flow. Everything an update needs is already on disk: the pr- key and
581
- // gateway URL live in the penny config (and, for older installs, in the harness config the
582
- // installer wrote), and the upstream provider tokens live server-side, encrypted against the
583
- // account. So no browser claim, no provider checkboxes, no token prompts — this command is
584
- // non-interactive by construction and never touches a credential.
585
- //
586
- // What it DOES rewrite is everything regenerated from code constants, which is exactly what
587
- // goes stale on a version bump: the copied runtime + `penny` shim, the /penny skill, the
588
- // Claude launch settings overlay, the Codex provider profile, and the model labels +
589
- // context-window env in the harness configs.
590
- //
591
- // Per-user choices are read back from the manifest, never re-asked: `default_routing` decides
592
- // whether the bare `claude`/`codex` command keeps pointing at the gateway, and
593
- // `anthropic_token_stored`/`openai_subscription_auth` decide which auth shape gets rewritten.
594
- // Re-deriving either from prompt defaults would silently flip a --penny-only user into
595
- // default routing, or drop subscription auth from a working install.
596
- async function update(flags) {
597
- const manifest = await loadManifest();
598
- if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
599
- const records = manifest.installations || [];
600
-
601
- const requested = parseHarnessList(flags.harness);
602
- const targets = records.filter((record) => {
603
- if (record.disabled) return false;
604
- if (flags.all || requested.length === 0) return true;
605
- return requested.includes(record.harness);
606
- });
607
-
608
- if (records.length === 0) {
609
- throw new Error("No PennyRouter installation found. Run `npx pennyrouter install` first.");
610
- }
611
- if (targets.length === 0) {
612
- console.log("No enabled PennyRouter installations matched. Nothing to update.");
613
- return;
614
- }
615
-
616
- const launchConfig = await loadPennyConfig();
617
- const globalAuth = await readClaudeCodeGatewayAuth();
618
- const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
619
- const gatewayBaseUrl = flags.gatewayBaseUrl
620
- || launchConfig.gateway_base_url
621
- || globalAuth.gatewayBaseUrl
622
- || process.env.PENNYROUTER_GATEWAY_BASE_URL
623
- || "https://api.pennyrouter.com";
624
-
625
- if (!apiKey) {
626
- throw new Error(
627
- "Could not find your PennyRouter key in the local config. Pass `--penny-key pr-...`, "
628
- + "or run `npx pennyrouter install` to re-authorize.",
629
- );
630
- }
631
-
632
- console.log("PennyRouter update");
633
- console.log("");
634
- console.log(`Key ${maskKey(apiKey)} · gateway ${gatewayBaseUrl}`);
635
- console.log("Subscription and API keys are left untouched.");
636
- console.log("");
637
-
638
- if (flags.dryRun) {
639
- for (const record of targets) {
640
- const routing = record.default_routing === false ? "penny command only" : "default routing";
641
- console.log(`Will refresh ${record.harness} (${routing}).`);
642
- }
643
- console.log("Will refresh the penny runtime and shim.");
644
- console.log("Dry run only. No files changed.");
645
- return;
646
- }
647
-
648
- const failures = [];
649
- let runtimeResult = null;
650
- if (targets.some((record) => ["claude-code", "codex"].includes(record.harness))
651
- && process.platform !== "win32") {
652
- runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
653
- }
654
-
655
- for (const record of targets) {
656
- const harness = HARNESS_BY_ID[record.harness];
657
- if (!harness) continue;
658
-
659
- await runHarnessJob(failures, record.harness, "update", async () => {
660
- const supportsPennyCommand = ["claude-code", "codex"].includes(record.harness)
661
- && process.platform !== "win32";
662
- // An update rewrites the harness's own config only when that config is ours to
663
- // rewrite — i.e. the bare command still routes through the gateway. In penny-only
664
- // mode the harness config belongs to the user and we touch just the launch profile.
665
- const ownsGlobalRouting = !record.launch_available || record.default_routing !== false;
666
- if (ownsGlobalRouting) assertNoRunningHarness(record.harness, flags);
667
-
668
- const claudeSubscriptionAuth = record.harness === "claude-code"
669
- && Boolean(record.anthropic_token_stored);
670
- const codexSubscriptionAuth = record.harness === "codex"
671
- ? await detectCodexChatgptAuth()
672
- : false;
673
-
674
- if (supportsPennyCommand) {
675
- await configurePennyHarness(record.harness, {
676
- apiKey,
677
- gatewayBaseUrl,
678
- subscriptionAuth: record.harness === "claude-code"
679
- ? claudeSubscriptionAuth
680
- : codexSubscriptionAuth,
681
- models: record.harness === "claude-code" ? CLAUDE_CODE_MODELS : null,
682
- });
683
- }
684
-
685
- let next = record;
686
- if (ownsGlobalRouting) {
687
- const plan = await harness.planInstall();
688
- // Pass the existing record through so install-time state the harness stashed
689
- // (previous_model_provider, claude_ai_connectors_before) survives the rewrite
690
- // and a later uninstall still restores the user's original values.
691
- const installed = record.harness === "claude-code" && claudeSubscriptionAuth
692
- ? await harness.installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord: record })
693
- : await harness.install({ apiKey, gatewayBaseUrl, plan, existingRecord: record });
694
- next = { ...record, ...installed, installed_at: record.installed_at };
695
- }
696
-
697
- next.updated_at = new Date().toISOString();
698
- if (record.harness === "codex") next.openai_subscription_auth = codexSubscriptionAuth;
699
- upsertManifestRecord(manifest, next);
700
- await saveManifest(manifest);
701
- console.log(`Updated ${record.harness}.`);
702
- });
703
- }
704
-
705
- await saveManifest(manifest);
706
- console.log("");
707
- if (runtimeResult && !runtimeResult.available) {
708
- console.log("Add ~/.local/bin to PATH before using the `penny` command.");
709
- } else if (runtimeResult?.changed) {
710
- console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
711
- }
712
- console.log("Restart any running Claude Code or Codex sessions to pick up the update.");
713
- reportFailures(failures);
714
- }
715
-
716
- async function chooseDefaultRouting(flags, selected, manifest) {
717
- const out = new Map();
718
- 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
- let newInstallChoice = null;
724
- for (const harness of launchable) {
725
- if (flags.makeDefault) {
726
- out.set(harness.id, true);
727
- continue;
728
- }
729
- if (flags.pennyOnly) {
730
- out.set(harness.id, false);
731
- continue;
732
- }
733
- const existing = (manifest.installations || []).find((item) => item.harness === harness.id);
734
- if (existing) {
735
- const currentlyDefault = existing.default_routing !== false;
736
- if (flags.dryRun || !currentlyDefault || flags.yes || !input.isTTY || !output.isTTY) {
737
- out.set(harness.id, currentlyDefault);
738
- continue;
739
- }
740
- const native = harness.id === "claude-code" ? "claude" : "codex";
741
- const change = await confirm(
742
- `PennyRouter currently runs when you type ${color(native, "bold")}. Change it to run only with ${color(`penny ${native}`, "bold")}?`,
743
- );
744
- out.set(harness.id, !change);
745
- continue;
746
- }
747
- if (newInstallChoice === null) {
748
- if (flags.dryRun || flags.yes || !input.isTTY || !output.isTTY) {
749
- newInstallChoice = false;
750
- } else {
751
- for (const item of launchable) {
752
- const native = item.id === "claude-code" ? "claude" : "codex";
753
- const harnessName = item.id === "claude-code" ? "Claude Code" : "Codex";
754
- const command = `penny ${native}`;
755
- const commandWidth = Math.max(command.length, native.length) + 2;
756
- console.log("");
757
- console.log(color(`How to Run ${harnessName}`, "bold"));
758
- console.log("");
759
- console.log(` ${color(command.padEnd(commandWidth), "bold")}→ ${harnessName} with PennyRouter`);
760
- console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${harnessName} native (recommended)`);
761
- }
762
- console.log("");
763
- const bare = launchable.map((item) => item.id === "claude-code" ? "claude" : "codex");
764
- const quoted = bare.map((command) => `'${command}'`);
765
- const label = quoted.length === 1
766
- ? quoted[0]
767
- : `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
768
- newInstallChoice = await confirm(
769
- `Set ${label} to automatically load PennyRouter with every session?`,
770
- );
771
- }
772
- }
773
- out.set(harness.id, newInstallChoice);
774
- }
775
- return out;
776
- }
777
-
778
- async function selectClaudeFundingProviders(flags, selected) {
779
- if (!selected.some((harness) => harness.id === "claude-code")) return new Set();
780
-
781
- const supplied = new Set();
782
- if (!flags.noAnthropicAuth && (flags.anthropicAuth || flags.token || flags.tokenCommand
783
- || process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
784
- if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
785
- if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
786
-
787
- // Explicit flags are the automation selector. Interactive installs always default to the
788
- // subscription alone; environment keys are used if the user checks their provider.
789
- const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
790
- || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey;
791
- if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
792
-
793
- console.log("PennyRouter can use your Claude subscription, an Anthropic API key, or an OpenRouter API key to run Claude Code.");
794
- console.log("Choose one or more providers. PennyRouter will try them in order when available.");
795
- return promptFundingProviderCheckboxes(
796
- CLAUDE_PROVIDER_OPTIONS,
797
- "Which providers should PennyRouter use for Claude Code?",
798
- new Set(["subscription"]),
799
- );
800
- }
801
-
802
- async function selectCodexFundingProviders(flags, selected) {
803
- if (!selected.some((harness) => harness.id === "codex")) return new Set();
804
-
805
- const supplied = new Set();
806
- if (flags.openaiApiKey || process.env.OPENAI_API_KEY) supplied.add("openai-api");
807
- if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
808
- const hasChatgptLogin = await detectCodexChatgptAuth();
809
- if (hasChatgptLogin) supplied.add("subscription");
810
-
811
- const explicit = flags.openaiApiKey || flags.openrouterApiKey;
812
- if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
813
-
814
- console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, or an OpenRouter API key to run Codex.");
815
- console.log("Choose one or more providers. PennyRouter will try them in order when available.");
816
- return promptFundingProviderCheckboxes(
817
- CODEX_PROVIDER_OPTIONS,
818
- "Which providers should PennyRouter use for Codex?",
819
- new Set(["subscription"]),
820
- );
821
- }
822
-
823
- async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true) {
824
- if (!selected.some((harness) => harness.id === "claude-code")) return null;
825
- if (!enabled || flags.noAnthropicAuth) return null;
826
-
827
- if (flags.token || flags.tokenCommand || process.env.ANTHROPIC_AUTH_TOKEN) {
828
- const tokenResult = readAnthropicOAuthToken(flags);
829
- if (tokenResult.token) return tokenResult;
830
- }
831
-
832
- if (flags.yes || !input.isTTY || !output.isTTY) {
833
- if (flags.anthropicAuth) {
834
- throw new Error(
835
- "Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
836
- );
837
- }
838
- return null;
839
- }
840
-
841
- const tokenResult = readAnthropicOAuthToken({ tokenCommand: flags.tokenCommand });
842
- if (tokenResult.token) return tokenResult;
843
-
844
- console.log("");
845
- if (commandExists("claude")) {
846
- // The user already opted into subscription auth above, so run `claude setup-token`
847
- // 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.
849
- console.log("Launching Claude sign-in — approve in your browser…");
850
- const captured = await runClaudeSetupToken();
851
- if (captured) {
852
- console.log("✓ Claude subscription auth enabled.");
853
- console.log("");
854
- return { token: captured, source: "setup-token" };
855
- }
856
- console.log("");
857
- console.log("Couldn't capture a token automatically. Paste it below, or leave blank to skip.");
858
- } else {
859
- console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
860
- }
861
- const token = normalizeAnthropicToken(await promptSecretish("Anthropic token (blank to skip): "));
862
- if (!token) {
863
- console.log("Skipped Claude subscription auth. Enable later with `@penny auth` or `pennyrouter auth anthropic`.");
864
- console.log("");
865
- return null;
866
- }
867
- console.log("");
868
- return { token, source: "prompt" };
869
- }
870
-
871
- async function configureUserFunding(flags, selected, claudeAnthropicAuth,
872
- claudeProviders = new Set(), codexProviders = new Set()) {
873
- const wantsClaude = selected.some((harness) => harness.id === "claude-code");
874
- const wantsCodex = selected.some((harness) => harness.id === "codex");
875
- const interactive = !flags.yes && input.isTTY && output.isTTY;
876
- const credentials = [];
877
-
878
- let anthropicApiKey = String(
879
- flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY || "",
880
- ).trim();
881
- let openaiApiKey = String(
882
- flags.openaiApiKey || process.env.OPENAI_API_KEY || "",
883
- ).trim();
884
- let openrouterApiKey = String(
885
- flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
886
- ).trim();
887
- const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
888
- ? await detectCodexChatgptAuth()
889
- : false;
890
-
891
- if (wantsClaude && claudeProviders.has("anthropic-api") && !anthropicApiKey) {
892
- if (!interactive) throw new Error("Anthropic API was selected, but no key was provided.");
893
- anthropicApiKey = (await promptSecretish("Anthropic API key: ")).trim();
894
- }
895
- if (wantsClaude && claudeProviders.has("openrouter") && !openrouterApiKey) {
896
- if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
897
- openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
898
- }
899
- if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
900
- throw new Error(
901
- "ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
902
- );
903
- }
904
- if (wantsCodex && codexProviders.has("openai-api") && !openaiApiKey) {
905
- if (!interactive) throw new Error("OpenAI API was selected, but no key was provided.");
906
- openaiApiKey = (await promptSecretish("OpenAI API key: ")).trim();
907
- }
908
- if (wantsCodex && codexProviders.has("openrouter") && !openrouterApiKey) {
909
- if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
910
- openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
911
- }
912
-
913
- if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey) {
914
- if (!interactive) {
915
- throw new Error(
916
- "Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
917
- + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
918
- );
919
- }
920
- console.log("Claude Code needs a user-funded provider. PennyRouter will never fall back to its own API keys.");
921
- anthropicApiKey = (await promptSecretish(
922
- "Anthropic API key (leave blank to use OpenRouter): ",
923
- )).trim();
924
- if (!anthropicApiKey) {
925
- openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
926
- }
927
- if (!anthropicApiKey && !openrouterApiKey) {
928
- throw new Error("Claude Code installation cancelled: no subscription or API key was provided.");
929
- }
930
- console.log("");
931
- }
932
-
933
- if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openrouterApiKey) {
934
- if (!interactive) {
935
- throw new Error(
936
- "Codex requires ChatGPT login, --openai-api-key (or OPENAI_API_KEY), "
937
- + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
938
- );
939
- }
940
- console.log("Codex is not logged into ChatGPT and needs a user-funded provider.");
941
- console.log("PennyRouter will never fall back to its own API keys.");
942
- openaiApiKey = (await promptSecretish(
943
- "OpenAI API key (leave blank to use OpenRouter): ",
944
- )).trim();
945
- if (!openaiApiKey) {
946
- openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
947
- }
948
- if (!openaiApiKey && !openrouterApiKey) {
949
- throw new Error("Codex installation cancelled: no subscription or API key was provided.");
950
- }
951
- console.log("");
952
- }
953
-
954
- if (claudeAnthropicAuth) {
955
- credentials.push({ provider: "anthropic_oauth", token: claudeAnthropicAuth.token });
956
- }
957
- if (wantsClaude && anthropicApiKey) {
958
- credentials.push({ provider: "anthropic_api", token: anthropicApiKey });
959
- }
960
- if (wantsCodex && openaiApiKey) {
961
- credentials.push({ provider: "openai_api", token: openaiApiKey });
962
- }
963
- if ((wantsClaude || wantsCodex) && openrouterApiKey) {
964
- credentials.push({ provider: "openrouter", token: openrouterApiKey });
965
- }
966
-
967
- return {
968
- credentials,
969
- codexSubscriptionAuth,
970
- claudeCredentialProvider: claudeAnthropicAuth
971
- ? "claude-subscription"
972
- : anthropicApiKey ? "anthropic-api" : openrouterApiKey ? "openrouter" : null,
973
- codexCredentialProvider: codexSubscriptionAuth
974
- ? "chatgpt-subscription"
975
- : openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
976
- };
977
- }
978
-
979
- async function disable(flags) {
980
- const manifest = await loadManifest();
981
- if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
982
- const records = manifest.installations || [];
983
- const { selectedIds, selectedRecords } = await resolveLifecycleTargets(flags, records);
984
-
985
- if (selectedRecords.length === 0) {
986
- console.log("No matching PennyRouter installations found.");
987
- return;
988
- }
989
-
990
- console.log("PennyRouter disable");
991
- console.log("");
992
- for (const record of selectedRecords) {
993
- console.log(` - ${record.harness}: ${record.config_path || "manual setup"}`);
994
- }
995
- console.log("");
996
-
997
- if (!flags.yes) {
998
- const ok = await confirm("Temporarily turn off these PennyRouter local integrations?");
999
- if (!ok) return;
1000
- }
1001
-
1002
- const failures = [];
1003
- for (const record of selectedRecords) {
1004
- if (record.disabled) {
1005
- console.log(`${record.harness} is already disabled.`);
1006
- continue;
1007
- }
1008
-
1009
- const harness = HARNESS_BY_ID[record.harness];
1010
- if (!harness) {
1011
- console.log(`Skipping ${record.harness}: this CLI no longer knows how to disable it.`);
1012
- continue;
1013
- }
1014
-
1015
- await runHarnessJob(failures, record.harness, "disable", async () => {
1016
- const ownsGlobalRouting = !record.launch_available || record.default_routing;
1017
- if (record.config_path && ownsGlobalRouting) {
1018
- const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
1019
- record.disabled_snapshot_path = snapshotPath;
1020
- }
1021
-
1022
- if (record.launch_available) await setPennyHarnessEnabled(record.harness, false);
1023
- if (ownsGlobalRouting) {
1024
- if (typeof harness.disable === "function") await harness.disable(record);
1025
- else await harness.uninstall(record);
1026
- }
1027
-
1028
- record.disabled = true;
1029
- record.disabled_at = new Date().toISOString();
1030
- upsertManifestRecord(manifest, record);
1031
- await saveManifest(manifest);
1032
- console.log(`Disabled ${record.harness}.`);
1033
- });
1034
- }
1035
-
1036
- await saveManifest(manifest);
1037
- reportFailures(failures);
1038
- }
1039
-
1040
- async function enable(flags) {
1041
- const manifest = await loadManifest();
1042
- if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
1043
- const records = manifest.installations || [];
1044
- const selectedIds = resolveRequestedHarnessIds(flags, records.map((record) => record.harness));
1045
- const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
1046
-
1047
- if (selectedRecords.length === 0) {
1048
- console.log("No matching PennyRouter installations found.");
1049
- return;
1050
- }
1051
-
1052
- console.log("PennyRouter enable");
1053
- console.log("");
1054
- for (const record of selectedRecords) {
1055
- console.log(` - ${record.harness}: ${record.config_path || "manual setup"}`);
1056
- }
1057
- console.log("");
1058
-
1059
- if (!flags.yes) {
1060
- const ok = await confirm("Turn these PennyRouter local integrations back on?");
1061
- if (!ok) return;
1062
- }
1063
-
1064
- const failures = [];
1065
- for (const record of selectedRecords) {
1066
- if (!record.disabled) {
1067
- console.log(`${record.harness} is already enabled.`);
1068
- continue;
1069
- }
1070
-
1071
- const harness = HARNESS_BY_ID[record.harness];
1072
- if (!harness) {
1073
- console.log(`Skipping ${record.harness}: this CLI no longer knows how to enable it.`);
1074
- continue;
1075
- }
1076
-
1077
- await runHarnessJob(failures, record.harness, "enable", async () => {
1078
- const ownsGlobalRouting = !record.launch_available || record.default_routing;
1079
- if (record.launch_available) await setPennyHarnessEnabled(record.harness, true);
1080
- if (ownsGlobalRouting) {
1081
- if (typeof harness.enable === "function") await harness.enable(record);
1082
- else if (record.config_path && record.disabled_snapshot_path) {
1083
- await restoreFile(record.disabled_snapshot_path, record.config_path);
1084
- } else if (record.manual_required) {
1085
- console.log(`${record.harness}: re-enable manually in the tool UI using the status details.`);
1086
- } else {
1087
- throw new Error("no disabled snapshot found; run install again");
1088
- }
1089
- }
1090
-
1091
- delete record.disabled;
1092
- delete record.disabled_at;
1093
- upsertManifestRecord(manifest, record);
1094
- await saveManifest(manifest);
1095
- console.log(`Enabled ${record.harness}.`);
1096
- });
1097
- }
1098
-
1099
- await saveManifest(manifest);
1100
- reportFailures(failures);
1101
- }
1102
-
1103
- async function uninstall(flags) {
1104
- const manifest = await loadManifest();
1105
- if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
1106
- const records = manifest.installations || [];
1107
- const { selectedIds, selectedRecords } = await resolveLifecycleTargets(flags, records);
1108
-
1109
- if (selectedRecords.length === 0) {
1110
- console.log("No matching PennyRouter installations found.");
1111
- return;
1112
- }
1113
-
1114
- console.log("PennyRouter uninstall");
1115
- console.log("");
1116
- for (const record of selectedRecords) {
1117
- console.log(` - ${record.harness}: ${record.config_path}`);
1118
- }
1119
- console.log("");
1120
-
1121
- if (!flags.yes) {
1122
- const ok = await confirm("Remove these PennyRouter local integrations?");
1123
- if (!ok) return;
1124
- }
1125
-
1126
- const kept = [];
1127
- const failures = [];
1128
- let anthropicTokenNotice = false;
1129
- for (const record of records) {
1130
- if (!selectedIds.includes(record.harness)) {
1131
- kept.push(record);
1132
- continue;
1133
- }
1134
-
1135
- const harness = HARNESS_BY_ID[record.harness];
1136
- if (!harness) {
1137
- console.log(`Skipping ${record.harness}: this CLI no longer knows how to uninstall it.`);
1138
- kept.push(record);
1139
- continue;
1140
- }
1141
-
1142
- const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
1143
- if (flags.forgetToken) {
1144
- await forgetStoredAnthropicToken(record);
1145
- } else if (record.anthropic_token_stored) {
1146
- anthropicTokenNotice = true;
1147
- }
1148
- if (record.launch_available) await removePennyHarness(record.harness);
1149
- if (!record.launch_available || record.default_routing) await harness.uninstall(record);
1150
- console.log(`Removed ${record.harness}.`);
1151
- });
1152
- if (!ok) kept.push(record);
1153
- }
1154
-
1155
- for (const record of selectedRecords) {
1156
- if (records.some((existing) => existing.harness === record.harness)) continue;
1157
- const harness = HARNESS_BY_ID[record.harness];
1158
- if (!harness) {
1159
- console.log(`Skipping ${record.harness}: this CLI does not know how to uninstall it.`);
1160
- continue;
1161
- }
1162
- await runHarnessJob(failures, record.harness, "uninstall", async () => {
1163
- if (flags.forgetToken) {
1164
- await forgetStoredAnthropicToken(record);
1165
- } else if (record.anthropic_token_stored) {
1166
- anthropicTokenNotice = true;
1167
- }
1168
- if (record.launch_available) await removePennyHarness(record.harness);
1169
- if (!record.launch_available || record.default_routing) await harness.uninstall(record);
1170
- console.log(`Removed ${record.harness}.`);
1171
- });
1172
- }
1173
-
1174
- if (anthropicTokenNotice) {
1175
- console.log("");
1176
- console.log("Note: Your stored Anthropic token remains on the server for other machines.");
1177
- console.log("To delete it from the server, run: pennyrouter auth anthropic --forget");
1178
- }
1179
-
1180
- manifest.installations = kept;
1181
- await saveManifest(manifest);
1182
- if (!(await hasConfiguredPennyHarnesses())) await removePennyRuntime();
1183
- reportFailures(failures);
1184
- }
1185
-
1186
- async function resolveLifecycleTargets(flags, records) {
1187
- const requested = parseHarnessList(flags.harness);
1188
- const ids = flags.all
1189
- ? Object.keys(HARNESS_BY_ID)
1190
- : requested.length > 0
1191
- ? requested
1192
- : records.length > 0
1193
- ? records.map((record) => record.harness)
1194
- : await detectHarnessIds();
1195
-
1196
- const selectedIds = [...new Set(ids)];
1197
- const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
1198
- const manifestIds = new Set(selectedRecords.map((record) => record.harness));
1199
-
1200
- for (const id of selectedIds) {
1201
- if (manifestIds.has(id)) continue;
1202
- const harness = HARNESS_BY_ID[id];
1203
- if (!harness) continue;
1204
- if (!(await safeDetect(harness))) continue;
1205
- let plan;
1206
- try {
1207
- plan = await harness.planInstall();
1208
- } catch (error) {
1209
- console.log(`Skipping ${id}: ${formatError(error)}`);
1210
- continue;
1211
- }
1212
- selectedRecords.push({
1213
- harness: id,
1214
- config_path: plan.configPath ? compactHome(plan.configPath) : null,
1215
- backup_path: null,
1216
- installed_at: null,
1217
- recovered_from_detected_config: true,
1218
- });
1219
- }
1220
-
1221
- return { selectedIds, selectedRecords };
1222
- }
1223
-
1224
- async function detectHarnessIds() {
1225
- const ids = [];
1226
- for (const harness of Object.values(HARNESS_BY_ID)) {
1227
- if (await safeDetect(harness)) ids.push(harness.id);
1228
- }
1229
- return ids;
1230
- }
1231
-
1232
- async function status() {
1233
- const manifest = await loadManifest();
1234
- const pruned = pruneUnsupportedManifestRecords(manifest);
1235
- if (pruned.length > 0) await saveManifest(manifest);
1236
- const records = manifest.installations || [];
1237
- console.log("PennyRouter local setup");
1238
- console.log("");
1239
-
1240
- if (records.length === 0) {
1241
- console.log("No local PennyRouter integrations recorded.");
1242
- return;
1243
- }
1244
-
1245
- for (const record of records) {
1246
- const state = record.disabled ? "disabled" : record.manual_required ? "manual setup shown" : "installed";
1247
- console.log(`${record.harness}: ${state}`);
1248
- if (record.config_path) console.log(` config: ${record.config_path}`);
1249
- if (record.backup_path) console.log(` backup: ${record.backup_path}`);
1250
- if (record.disabled_snapshot_path) console.log(` disabled snapshot: ${record.disabled_snapshot_path}`);
1251
- if (record.installed_at) console.log(` installed: ${record.installed_at}`);
1252
- if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
1253
- if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
1254
- if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
1255
- if (record.launch_available) {
1256
- const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
1257
- console.log(` command: ${command}`);
1258
- console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
1259
- }
1260
- if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
1261
- }
1262
- }
1263
-
1264
- async function selectHarnesses(flags) {
1265
- const requested = flags.all ? Object.keys(HARNESS_BY_ID) : parseHarnessList(flags.harness);
1266
- if (requested.length > 0) {
1267
- return requested.map((id) => HARNESS_BY_ID[id]).filter(Boolean);
1268
- }
1269
-
1270
- const detected = [];
1271
- for (const id of DEFAULT_INSTALL_HARNESS_IDS) {
1272
- const harness = HARNESS_BY_ID[id];
1273
- if (await safeDetect(harness)) detected.push(harness);
1274
- }
1275
-
1276
- if (flags.yes) return detected;
1277
-
1278
- return promptHarnessCheckboxes(detected);
1279
- }
1280
-
1281
- async function promptHarnessCheckboxes(detected) {
1282
- const detectedIds = new Set(detected.map((harness) => harness.id));
1283
- const selectedIds = new Set(detectedIds);
1284
- const harnesses = orderedHarnessesForPrompt(detectedIds);
1285
- if (!input.isTTY || !output.isTTY) return promptHarnessCheckboxesByText(harnesses, selectedIds);
1286
-
1287
- return promptHarnessCheckboxesByKeys(harnesses, selectedIds);
1288
- }
1289
-
1290
- async function promptHarnessCheckboxesByKeys(harnesses, selectedIds) {
1291
- let cursor = 0;
1292
- const previousRawMode = input.isRaw;
1293
-
1294
- emitKeypressEvents(input);
1295
- input.setRawMode(true);
1296
- input.resume();
1297
- output.write("\x1b[?25l");
1298
-
1299
- const render = () => {
1300
- output.write("\x1b[2J\x1b[H");
1301
- output.write("\n\n");
1302
- output.write(`${color("Where should PennyRouter be installed?", "bold")}\n\n\n`);
1303
- harnesses.forEach((harness, index) => {
1304
- const pointer = index === cursor ? color(">", "cyan") : " ";
1305
- const checked = selectedIds.has(harness.id) ? color("x", "green") : " ";
1306
- const name = index === cursor ? color(harness.name, "cyan") : harness.name;
1307
- output.write(` ${pointer} [${checked}] ${name}\n`);
1308
- });
1309
- output.write("\nEnter to continue.\n");
1310
- };
1311
-
1312
- render();
1313
-
1314
- return new Promise((resolve, reject) => {
1315
- const cleanup = () => {
1316
- input.off("keypress", onKeypress);
1317
- input.setRawMode(Boolean(previousRawMode));
1318
- input.pause();
1319
- output.write("\x1b[?25h");
1320
- output.write("\n");
1321
- };
1322
-
1323
- const onKeypress = (_str, key = {}) => {
1324
- if (key.ctrl && key.name === "c") {
1325
- cleanup();
1326
- reject(new Error("Cancelled."));
1327
- return;
1328
- }
1329
-
1330
- if (key.name === "up" || key.name === "k") {
1331
- cursor = (cursor - 1 + harnesses.length) % harnesses.length;
1332
- render();
1333
- return;
1334
- }
1335
-
1336
- if (key.name === "down" || key.name === "j") {
1337
- cursor = (cursor + 1) % harnesses.length;
1338
- render();
1339
- return;
1340
- }
1341
-
1342
- if (key.name === "space") {
1343
- const id = harnesses[cursor].id;
1344
- if (selectedIds.has(id)) selectedIds.delete(id);
1345
- else selectedIds.add(id);
1346
- render();
1347
- return;
1348
- }
1349
-
1350
- if (key.name === "return" || key.name === "enter") {
1351
- cleanup();
1352
- resolve(harnesses.filter((harness) => selectedIds.has(harness.id)));
1353
- }
1354
- };
1355
-
1356
- input.on("keypress", onKeypress);
1357
- });
1358
- }
1359
-
1360
- async function promptHarnessCheckboxesByText(harnesses, selectedIds) {
1361
- const rl = createInterface({ input, output });
1362
-
1363
- try {
1364
- while (true) {
1365
- console.log("");
1366
- console.log("");
1367
- console.log(color("Where should PennyRouter be installed?", "bold"));
1368
- for (const harness of harnesses) {
1369
- const checked = selectedIds.has(harness.id) ? color("x", "green") : " ";
1370
- console.log(` [${checked}] ${harness.name}`);
1371
- }
1372
- console.log("");
1373
- const answer = await rl.question(
1374
- "Press Enter to continue, or type names to toggle (comma-separated, 'all', or 'none'): ",
1375
- );
1376
- const value = answer.trim().toLowerCase();
1377
- if (!value) return harnesses.filter((harness) => selectedIds.has(harness.id));
1378
- if (value === "all") {
1379
- for (const harness of harnesses) selectedIds.add(harness.id);
1380
- } else if (value === "none") {
1381
- selectedIds.clear();
1382
- } else {
1383
- for (const token of parseHarnessList(value)) {
1384
- const id = resolveHarnessToken(token);
1385
- if (!HARNESS_BY_ID[id]) {
1386
- console.log(`Unknown tool "${token}".`);
1387
- continue;
1388
- }
1389
- if (selectedIds.has(id)) selectedIds.delete(id);
1390
- else selectedIds.add(id);
1391
- }
1392
- }
1393
- console.log("");
1394
- }
1395
- } finally {
1396
- rl.close();
1397
- }
1398
- }
1399
-
1400
- async function promptFundingProviderCheckboxes(providerOptions, title,
1401
- initialIds = new Set(["subscription"])) {
1402
- const selectedIds = new Set(initialIds);
1403
- if (!input.isTTY || !output.isTTY) return selectedIds;
1404
-
1405
- let cursor = 0;
1406
- let validation = "";
1407
- const previousRawMode = input.isRaw;
1408
- emitKeypressEvents(input);
1409
- input.setRawMode(true);
1410
- input.resume();
1411
- output.write("\x1b[?25l");
1412
-
1413
- const render = () => {
1414
- output.write("\x1b[2J\x1b[H");
1415
- output.write("\n\n");
1416
- output.write(`${color(title, "bold")}\n\n`);
1417
- providerOptions.forEach((provider, index) => {
1418
- const pointer = index === cursor ? color(">", "cyan") : " ";
1419
- const checked = selectedIds.has(provider.id) ? color("x", "green") : " ";
1420
- const name = index === cursor ? color(provider.name, "cyan") : provider.name;
1421
- output.write(` ${pointer} [${checked}] ${name}\n`);
1422
- });
1423
- output.write("\nSpace to toggle · Enter to continue.\n");
1424
- if (validation) output.write(`${validation}\n`);
1425
- };
1426
- render();
1427
-
1428
- return new Promise((resolve, reject) => {
1429
- const cleanup = () => {
1430
- input.off("keypress", onKeypress);
1431
- input.setRawMode(Boolean(previousRawMode));
1432
- input.pause();
1433
- output.write("\x1b[?25h\n");
1434
- };
1435
- const onKeypress = (_str, key = {}) => {
1436
- if (key.ctrl && key.name === "c") {
1437
- cleanup();
1438
- reject(new Error("Cancelled."));
1439
- } else if (key.name === "up" || key.name === "k") {
1440
- cursor = (cursor - 1 + providerOptions.length) % providerOptions.length;
1441
- validation = "";
1442
- render();
1443
- } else if (key.name === "down" || key.name === "j") {
1444
- cursor = (cursor + 1) % providerOptions.length;
1445
- validation = "";
1446
- render();
1447
- } else if (key.name === "space") {
1448
- const id = providerOptions[cursor].id;
1449
- if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id);
1450
- validation = "";
1451
- render();
1452
- } else if (key.name === "return" || key.name === "enter") {
1453
- if (!selectedIds.size) {
1454
- validation = "Select at least one provider.";
1455
- render();
1456
- return;
1457
- }
1458
- cleanup();
1459
- resolve(selectedIds);
1460
- }
1461
- };
1462
- input.on("keypress", onKeypress);
1463
- });
1464
- }
1465
-
1466
- function orderedHarnessesForPrompt(detectedIds) {
1467
- return DEFAULT_INSTALL_HARNESS_IDS.map((id) => HARNESS_BY_ID[id]).sort((left, right) => {
1468
- const leftDetected = detectedIds.has(left.id);
1469
- const rightDetected = detectedIds.has(right.id);
1470
- if (leftDetected !== rightDetected) return leftDetected ? -1 : 1;
1471
- return left.name.localeCompare(right.name);
1472
- });
1473
- }
1474
-
1475
- function resolveHarnessToken(token) {
1476
- const normalized = normalizeHarnessToken(token);
1477
- for (const harness of Object.values(HARNESS_BY_ID)) {
1478
- if (normalizeHarnessToken(harness.id) === normalized) return harness.id;
1479
- if (normalizeHarnessToken(harness.name) === normalized) return harness.id;
1480
- }
1481
- return token;
1482
- }
1483
-
1484
- function normalizeHarnessToken(value) {
1485
- return String(value).toLowerCase().replace(/[^a-z0-9]/g, "");
1486
- }
1487
-
1488
- function color(text, style) {
1489
- if (!output.isTTY || process.env.NO_COLOR) return text;
1490
- const codes = {
1491
- bold: ["\x1b[1m", "\x1b[22m"],
1492
- cyan: ["\x1b[36m", "\x1b[39m"],
1493
- green: ["\x1b[32m", "\x1b[39m"],
1494
- };
1495
- const pair = codes[style];
1496
- return pair ? `${pair[0]}${text}${pair[1]}` : text;
1497
- }
1498
-
1499
- function parseHarnessList(value = "") {
1500
- return value
1501
- .split(",")
1502
- .map((part) => part.trim())
1503
- .filter(Boolean);
1504
- }
1505
-
1506
- function resolveRequestedHarnessIds(flags, defaults) {
1507
- if (flags.all) return defaults;
1508
- const requested = parseHarnessList(flags.harness);
1509
- return requested.length > 0 ? requested : defaults;
1510
- }
1511
-
1512
- function upsertManifestRecord(manifest, record) {
1513
- manifest.installations ||= [];
1514
- manifest.installations = manifest.installations.filter((existing) => existing.harness !== record.harness);
1515
- manifest.installations.push(record);
1516
- }
1517
-
1518
- function pruneUnsupportedManifestRecords(manifest) {
1519
- manifest.installations ||= [];
1520
- const kept = manifest.installations.filter((record) => HARNESS_BY_ID[record.harness]);
1521
- const pruned = manifest.installations.filter((record) => !HARNESS_BY_ID[record.harness]);
1522
- manifest.installations = kept;
1523
- return pruned;
1524
- }
1525
-
1526
- async function safeDetect(harness) {
1527
- try {
1528
- return await harness.detect();
1529
- } catch (error) {
1530
- console.log(`Skipping ${harness.id}: detect failed: ${formatError(error)}`);
1531
- return false;
1532
- }
1533
- }
1534
-
1535
- async function runHarnessJob(failures, harnessId, action, job) {
1536
- try {
1537
- await job();
1538
- return true;
1539
- } catch (error) {
1540
- failures.push({ harness: harnessId, action, error });
1541
- console.error(`${harnessId} ${action} failed: ${formatError(error)}`);
1542
- return false;
1543
- }
1544
- }
1545
-
1546
- function reportFailures(failures) {
1547
- if (failures.length === 0) return;
1548
- console.error("");
1549
- console.error("Some PennyRouter integrations failed:");
1550
- for (const failure of failures) {
1551
- console.error(` - ${failure.harness} ${failure.action}: ${formatError(failure.error)}`);
1552
- }
1553
- process.exitCode = 1;
1554
- }
1555
-
1556
- function formatError(error) {
1557
- return error?.message || String(error);
1558
- }
1559
-
1560
- // Best-effort: tell the gateway to forget a stored Claude token when uninstalling a
1561
- // claude-code integration that stored one. Reads the gateway URL + pr- key from the
1562
- // live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
1563
- // must not be blocked by a network hiccup; the record is going away regardless.
1564
- async function forgetStoredAnthropicToken(record) {
1565
- if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
1566
- try {
1567
- const launchConfig = await loadPennyConfig();
1568
- const globalAuth = await readClaudeCodeGatewayAuth();
1569
- const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
1570
- const apiKey = launchConfig.api_key || globalAuth.apiKey;
1571
- if (!gatewayBaseUrl || !apiKey) return;
1572
- await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
1573
- } catch {
1574
- // swallow — see note above
1575
- }
1576
- }
1577
-
1578
- async function confirm(question) {
1579
- const rl = createInterface({ input, output });
1580
- const answer = await rl.question(`${question} [y/N] `);
1581
- rl.close();
1582
- return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
1583
- }
1584
-
1585
- async function promptSecretish(question) {
1586
- if (!input.isTTY || !output.isTTY) {
1587
- const rl = createInterface({ input, output });
1588
- const answer = await rl.question(question);
1589
- rl.close();
1590
- return answer.trim();
1591
- }
1592
-
1593
- const previousRawMode = input.isRaw;
1594
- emitKeypressEvents(input);
1595
- input.setRawMode(true);
1596
- input.resume();
1597
- output.write(question);
1598
-
1599
- return new Promise((resolve, reject) => {
1600
- let value = "";
1601
- const cleanup = () => {
1602
- input.off("keypress", onKeypress);
1603
- input.setRawMode(Boolean(previousRawMode));
1604
- input.pause();
1605
- output.write("\n");
1606
- };
1607
- const onKeypress = (str, key = {}) => {
1608
- if (key.ctrl && key.name === "c") {
1609
- cleanup();
1610
- reject(new Error("Cancelled."));
1611
- return;
1612
- }
1613
- if (key.name === "return" || key.name === "enter") {
1614
- cleanup();
1615
- resolve(value.trim());
1616
- return;
1617
- }
1618
- if (key.name === "backspace") {
1619
- value = value.slice(0, -1);
1620
- return;
1621
- }
1622
- if (str) value += str;
1623
- };
1624
- input.on("keypress", onKeypress);
1625
- });
1626
- }
1627
-
1628
- function commandExists(command) {
1629
- // Windows installs CLIs as `.cmd`/`.ps1` shims that Node won't resolve without a
1630
- // shell, so probe through the shell there (else this false-negatives on `claude` and
1631
- // the auto setup-token path gets skipped). `command` is an internal literal.
1632
- const result = spawnSync(command, ["--version"], {
1633
- stdio: "ignore",
1634
- shell: process.platform === "win32",
1635
- });
1636
- return result.status === 0;
1637
- }
1638
-
1639
- function installNoteForRecord(record) {
1640
- if (record.manual_required) return " (manual setup shown)";
1641
- if (record.anthropic_oauth) return " (Claude subscription auth)";
1642
- return installNoteForHarness(record.harness);
1643
- }
1644
-
1645
- function installNoteForHarness(harnessId) {
1646
- if (harnessId === "cline" || harnessId === "codegpt") return " (manual setup)";
1647
- if (harnessId === "opencode") return " (select a Penny model from /model)";
1648
- return " (auto-selected)";
1649
- }
1650
-
1651
- function openBrowser(url) {
1652
- const platform = process.platform;
1653
- const command =
1654
- platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
1655
- const args = platform === "win32" ? ["/c", "start", "", url] : [url];
1656
- spawnSync(command, args, { stdio: "ignore", detached: true });
1657
- }
1658
-
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
- function printHelp() {
1701
- console.log(`PennyRouter CLI
1702
-
1703
- 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]
1706
- pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1707
- pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1708
- pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1709
- pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
1710
- pennyrouter status
1711
-
1712
- Update refreshes the managed local config and the penny runtime to this package version.
1713
- It reuses your existing key and never re-asks for subscription or API credentials.
1714
-
1715
- Examples:
1716
- npx pennyrouter update
1717
- npx pennyrouter update --dry-run
1718
- npx pennyrouter update --harness claude-code
1719
- npx pennyrouter install
1720
- npx pennyrouter install --make-default
1721
- npx pennyrouter install --penny-only
1722
- npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
1723
- npx pennyrouter install --harness claude-code --anthropic-api-key <key>
1724
- npx pennyrouter install --harness codex --openai-api-key <key>
1725
- npx pennyrouter install --openrouter-api-key <key>
1726
- npx pennyrouter auth anthropic
1727
- npx pennyrouter install --all
1728
- npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
1729
- npx pennyrouter disable
1730
- npx pennyrouter enable
1731
- npx pennyrouter uninstall
1732
- `);
1733
- }
1
+ import { spawn, spawnSync } from "node:child_process";
2
+ import { emitKeypressEvents } from "node:readline";
3
+ import { createInterface } from "node:readline/promises";
4
+ import { stdin as input, stdout as output } from "node:process";
5
+ import { aiderHarness } from "./harnesses/aider.js";
6
+ import {
7
+ CLAUDE_CODE_MODELS,
8
+ claudeCodeHarness,
9
+ readClaudeCodeGatewayAuth,
10
+ readClaudeCodePennyRouterKey,
11
+ } from "./harnesses/claude-code.js";
12
+ import { clineHarness } from "./harnesses/cline.js";
13
+ import { codeGptHarness } from "./harnesses/codegpt.js";
14
+ import { codexHarness, detectCodexChatgptAuth } from "./harnesses/codex.js";
15
+ import { opencodeHarness } from "./harnesses/opencode.js";
16
+ import { windsurfHarness } from "./harnesses/windsurf.js";
17
+ import { zedHarness } from "./harnesses/zed.js";
18
+ import {
19
+ APP_URL,
20
+ createCliSession,
21
+ forgetAnthropicToken,
22
+ maskKey,
23
+ pollForKey,
24
+ probeClaudeCodeGateway,
25
+ sleep,
26
+ storeAnthropicToken,
27
+ storeUpstreamCredential,
28
+ } from "./session.js";
29
+ import {
30
+ backupFile,
31
+ compactHome,
32
+ loadManifest,
33
+ restoreFile,
34
+ saveManifest,
35
+ statePath,
36
+ } from "./state.js";
37
+ import {
38
+ claudeLaunchSettingsPath,
39
+ codexLaunchProfilePath,
40
+ configurePennyHarness,
41
+ hasConfiguredPennyHarnesses,
42
+ installPennyRuntime,
43
+ loadPennyConfig,
44
+ pennyConfigPath,
45
+ removePennyHarness,
46
+ removePennyRuntime,
47
+ savePennyConfig,
48
+ setPennyHarnessEnabled,
49
+ } from "./launch.js";
50
+
51
+ const HARNESS_BY_ID = {
52
+ "claude-code": claudeCodeHarness,
53
+ cline: clineHarness,
54
+ opencode: opencodeHarness,
55
+ codex: codexHarness,
56
+ windsurf: windsurfHarness,
57
+ zed: zedHarness,
58
+ codegpt: codeGptHarness,
59
+ aider: aiderHarness,
60
+ };
61
+
62
+ const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
63
+ const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex"];
64
+ const CLAUDE_PROVIDER_OPTIONS = [
65
+ { id: "subscription", name: "Claude subscription" },
66
+ { id: "anthropic-api", name: "Anthropic API key" },
67
+ { id: "openrouter", name: "OpenRouter API key" },
68
+ ];
69
+ const CODEX_PROVIDER_OPTIONS = [
70
+ { id: "subscription", name: "ChatGPT subscription" },
71
+ { id: "openai-api", name: "OpenAI API key" },
72
+ { id: "openrouter", name: "OpenRouter API key" },
73
+ ];
74
+
75
+ export async function main(argv = process.argv.slice(2)) {
76
+ const { command, flags } = parseArgs(argv);
77
+
78
+ if (flags.help || command === "help") {
79
+ printHelp();
80
+ return;
81
+ }
82
+
83
+ if (command === "install") {
84
+ await install(flags);
85
+ return;
86
+ }
87
+
88
+ if (command === "update") {
89
+ await update(flags);
90
+ return;
91
+ }
92
+
93
+ if (command === "auth") {
94
+ await authProvider(flags);
95
+ return;
96
+ }
97
+
98
+ if (command === "uninstall") {
99
+ await uninstall(flags);
100
+ return;
101
+ }
102
+
103
+ if (command === "disable") {
104
+ await disable(flags);
105
+ return;
106
+ }
107
+
108
+ if (command === "enable") {
109
+ await enable(flags);
110
+ return;
111
+ }
112
+
113
+ if (command === "status") {
114
+ await status();
115
+ return;
116
+ }
117
+
118
+ throw new Error(`Unknown command "${command}". Run "pennyrouter help".`);
119
+ }
120
+
121
+ function parseArgs(argv) {
122
+ const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : "install";
123
+ const rest = command === argv[0] ? argv.slice(1) : argv;
124
+ const flags = {};
125
+ flags.args = [];
126
+
127
+ for (let i = 0; i < rest.length; i += 1) {
128
+ const arg = rest[i];
129
+ if (arg === "--help" || arg === "-h") flags.help = true;
130
+ else if (arg === "--yes" || arg === "-y") flags.yes = true;
131
+ else if (arg === "--all") flags.all = true;
132
+ else if (arg === "--dry-run") flags.dryRun = true;
133
+ else if (arg === "--allow-running-claude") flags.allowRunningClaude = true;
134
+ else if (arg === "--allow-running-harnesses") flags.allowRunningHarnesses = true;
135
+ else if (arg === "--make-default") flags.makeDefault = true;
136
+ else if (arg === "--penny-only") flags.pennyOnly = true;
137
+ else if (arg === "--no-path-update") flags.noPathUpdate = true;
138
+ else if (arg === "--no-browser") flags.noBrowser = true;
139
+ else if (arg === "--existing-account") flags.existingAccount = true;
140
+ else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
141
+ else if (arg === "--no-anthropic-auth") flags.noAnthropicAuth = true;
142
+ else if (arg === "--penny-key") flags.pennyKey = rest[++i] || "";
143
+ else if (arg.startsWith("--penny-key=")) flags.pennyKey = arg.slice("--penny-key=".length);
144
+ else if (arg === "--token") flags.token = rest[++i] || "";
145
+ else if (arg.startsWith("--token=")) flags.token = arg.slice("--token=".length);
146
+ else if (arg === "--token-command") flags.tokenCommand = rest[++i] || "";
147
+ else if (arg.startsWith("--token-command=")) flags.tokenCommand = arg.slice("--token-command=".length);
148
+ else if (arg === "--anthropic-api-key") flags.anthropicApiKey = rest[++i] || "";
149
+ else if (arg.startsWith("--anthropic-api-key=")) flags.anthropicApiKey = arg.slice("--anthropic-api-key=".length);
150
+ else if (arg === "--openai-api-key") flags.openaiApiKey = rest[++i] || "";
151
+ else if (arg.startsWith("--openai-api-key=")) flags.openaiApiKey = arg.slice("--openai-api-key=".length);
152
+ else if (arg === "--openrouter-api-key") flags.openrouterApiKey = rest[++i] || "";
153
+ else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
154
+ else if (arg === "--harness") flags.harness = rest[++i] || "";
155
+ else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
156
+ else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
157
+ 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);
160
+ // --local: point the installed harness at a locally-running gateway (default
161
+ // http://localhost:8400), for developing/testing against your own gateway. Auth still goes
162
+ // to prod (pennyrouter.com) so you get a real key; only the harness's serving URL is local.
163
+ // `--local=<url>` overrides the port/host.
164
+ else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
165
+ else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
166
+ else if (arg === "--forget") flags.forget = true;
167
+ else if (arg === "--forget-token") flags.forgetToken = true;
168
+ else if (!arg.startsWith("-")) flags.args.push(arg);
169
+ else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
170
+ }
171
+
172
+ return { command, flags };
173
+ }
174
+
175
+ async function authProvider(flags) {
176
+ const provider = (flags.args[0] || "anthropic").toLowerCase();
177
+ if (provider !== "anthropic" && provider !== "claude") {
178
+ throw new Error(`Unknown auth provider "${provider}". Try "pennyrouter auth anthropic".`);
179
+ }
180
+
181
+ if (flags.forget) {
182
+ const launchConfig = await loadPennyConfig();
183
+ const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
184
+ if (!pennyKey) {
185
+ throw new Error(
186
+ "No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
187
+ );
188
+ }
189
+ const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
190
+ if (flags.dryRun) {
191
+ console.log(`Will delete stored Anthropic token from gateway: ${gatewayBaseUrl}`);
192
+ return;
193
+ }
194
+ const ok = await forgetAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey });
195
+ if (ok) {
196
+ console.log("Successfully forgot stored Anthropic token on server.");
197
+ } else {
198
+ console.log("Failed to forget stored Anthropic token on server.");
199
+ }
200
+ const manifest = await loadManifest();
201
+ let updated = false;
202
+ for (const record of (manifest.installations || [])) {
203
+ if (record.harness === "claude-code" && record.anthropic_token_stored) {
204
+ record.anthropic_token_stored = false;
205
+ record.anthropic_oauth = false;
206
+ updated = true;
207
+ }
208
+ }
209
+ if (updated) {
210
+ await saveManifest(manifest);
211
+ }
212
+ if (launchConfig.harnesses?.claude) {
213
+ launchConfig.harnesses.claude.subscription_auth = false;
214
+ await savePennyConfig(launchConfig);
215
+ }
216
+ return;
217
+ }
218
+
219
+ if (flags.dryRun) {
220
+ console.log("Will configure Claude Code for PennyRouter + Anthropic OAuth dual auth.");
221
+ console.log("Dry run only. No files changed.");
222
+ return;
223
+ }
224
+
225
+ const launchConfig = await loadPennyConfig();
226
+ const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
227
+ if (!pennyKey) {
228
+ throw new Error(
229
+ "No PennyRouter key found in Claude Code settings. Run `pennyrouter install --harness claude-code` first, or pass `--penny-key pr-...`.",
230
+ );
231
+ }
232
+
233
+ const tokenResult = readAnthropicOAuthToken(flags);
234
+ const token = tokenResult.token;
235
+ if (!token) {
236
+ throw new Error(
237
+ "No Anthropic OAuth token found. Run `claude setup-token` and pass it with `--token`, set ANTHROPIC_AUTH_TOKEN, or pass `--token-command <cmd>`.",
238
+ );
239
+ }
240
+
241
+ const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
242
+ const manifest = await loadManifest();
243
+ const existingRecord = (manifest.installations || []).find(
244
+ (candidate) => candidate.harness === "claude-code",
245
+ );
246
+ const ownsGlobalRouting = existingRecord
247
+ ? existingRecord.default_routing !== false
248
+ : Boolean(await readClaudeCodePennyRouterKey());
249
+ if (ownsGlobalRouting) assertNoRunningHarness("claude-code", flags);
250
+ await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
251
+ await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: pennyKey });
252
+ await installPennyRuntime({ updatePath: !flags.noPathUpdate });
253
+ await configurePennyHarness("claude-code", {
254
+ apiKey: pennyKey,
255
+ gatewayBaseUrl,
256
+ subscriptionAuth: true,
257
+ models: CLAUDE_CODE_MODELS,
258
+ });
259
+ let record = existingRecord || {
260
+ harness: "claude-code",
261
+ config_path: compactHome((await claudeCodeHarness.planInstall()).configPath),
262
+ installed_at: new Date().toISOString(),
263
+ launch_available: true,
264
+ launch_config_path: compactHome(claudeLaunchSettingsPath()),
265
+ default_routing: ownsGlobalRouting,
266
+ };
267
+ if (ownsGlobalRouting) {
268
+ const plan = await claudeCodeHarness.planInstall();
269
+ record = {
270
+ ...record,
271
+ ...(await claudeCodeHarness.installStoredToken({
272
+ apiKey: pennyKey, gatewayBaseUrl, plan, existingRecord,
273
+ })),
274
+ };
275
+ }
276
+
277
+ upsertManifestRecord(manifest, {
278
+ ...record,
279
+ launch_available: true,
280
+ launch_config_path: compactHome(claudeLaunchSettingsPath()),
281
+ default_routing: ownsGlobalRouting,
282
+ anthropic_oauth: true,
283
+ anthropic_token_stored: true,
284
+ token_source: tokenResult.source,
285
+ });
286
+ await saveManifest(manifest);
287
+
288
+ console.log("Configured `penny claude` for PennyRouter + your Claude subscription.");
289
+ console.log("Your Claude token is stored encrypted; Claude Code holds only your PennyRouter key.");
290
+ console.log("If Claude auth expires, refresh the token source and run `pennyrouter auth anthropic` again.");
291
+ console.log(`Penny config: ${pennyConfigPath()}`);
292
+ }
293
+
294
+ function readAnthropicOAuthToken(flags = {}) {
295
+ if (flags.token) return { token: normalizeAnthropicToken(flags.token), source: "flag" };
296
+ if (flags.tokenCommand) {
297
+ const result = spawnSync(flags.tokenCommand, {
298
+ encoding: "utf8",
299
+ shell: true,
300
+ stdio: ["ignore", "pipe", "pipe"],
301
+ });
302
+ if (result.status === 0) {
303
+ return { token: normalizeAnthropicToken(result.stdout), source: "token-command" };
304
+ }
305
+ return { token: "", source: "token-command" };
306
+ }
307
+ if (process.env.ANTHROPIC_AUTH_TOKEN) {
308
+ return { token: normalizeAnthropicToken(process.env.ANTHROPIC_AUTH_TOKEN), source: "env" };
309
+ }
310
+ const result = spawnSync("ant", ["auth", "print-credentials", "--access-token"], {
311
+ encoding: "utf8",
312
+ stdio: ["ignore", "pipe", "pipe"],
313
+ });
314
+ if (result.status !== 0) return { token: "", source: "none" };
315
+ return { token: normalizeAnthropicToken(result.stdout), source: "ant" };
316
+ }
317
+
318
+ // ANSI/CSI escape sequences that `claude setup-token`'s TUI emits (colors, cursor
319
+ // moves, bracketed-paste). We strip these before scanning the captured stream for the
320
+ // token so a match isn't split by control bytes. Kept deliberately broad.
321
+ // eslint-disable-next-line no-control-regex
322
+ const ANSI_ESCAPE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
323
+ // The token line printed by `claude setup-token`. Match any sk-ant-* token so we don't
324
+ // depend on the exact variant/version (e.g. sk-ant-oat01-...).
325
+ const ANTHROPIC_SETUP_TOKEN_RE = /sk-ant-[A-Za-z0-9_-]+/;
326
+
327
+ // Run `claude setup-token` inline and extract the token it prints, WITHOUT echoing any
328
+ // of the command's own output to the terminal. `claude setup-token` is a full-screen
329
+ // Ink TUI: it redraws with carriage returns and cursor-move escapes (not newlines), so
330
+ // its banner ("Welcome to Claude Code…"), spinner frames, the OAuth URL, the real token
331
+ // ("sk-ant-oat01-…") and the "Store this / export" chrome all arrive interleaved with
332
+ // no reliable line boundaries. There's no safe way to tee that stream line-by-line, and
333
+ // none of it needs to reach the user: the browser auto-opens for approval, and the
334
+ // token goes server-side (encrypted). So we fully suppress the child's output — we pipe
335
+ // BOTH stdout and stderr only to capture them in memory for token extraction, and print
336
+ // our own clean status lines around the call instead. The Ink TUI renders to stdout (the
337
+ // token line arrives there — that's what we scan), but we also silence stderr so no
338
+ // spinner/banner frame can leak from the other stream. Only stdin inherits the TTY, which
339
+ // is all that's needed for the interactive approval handoff; the browser is opened by the
340
+ // OS, not the terminal. After exit we ANSI-strip the captured output and regex out the
341
+ // token. Returns "" on non-zero exit or if no token appeared, so the caller falls back to
342
+ // manual paste.
343
+ function runClaudeSetupToken() {
344
+ return new Promise((resolve) => {
345
+ let captured = "";
346
+ // On Windows, npm installs `claude` as a `claude.cmd` shim which Node's spawn will
347
+ // not resolve without a shell (it looks for a bare executable and throws ENOENT).
348
+ // Route through the shell there so the shim resolves; the args are fixed literals
349
+ // (no user input), so there's no shell-injection surface. POSIX spawns `claude`
350
+ // directly. Works from both PowerShell and cmd.exe host terminals since Node invokes
351
+ // cmd.exe itself for the shell.
352
+ const child = spawn("claude", ["setup-token"], {
353
+ // stdin keeps the TTY; stdout+stderr are piped (captured, never echoed).
354
+ stdio: ["inherit", "pipe", "pipe"],
355
+ shell: process.platform === "win32",
356
+ });
357
+
358
+ const capture = (chunk) => {
359
+ captured += chunk.toString("utf8"); // in-memory only — nothing is written to the terminal
360
+ };
361
+ child.stdout.on("data", capture);
362
+ child.stderr.on("data", capture);
363
+
364
+ child.on("error", () => resolve(""));
365
+ child.on("close", (code) => {
366
+ if (code !== 0) return resolve("");
367
+ const match = captured.replace(ANSI_ESCAPE_RE, "").match(ANTHROPIC_SETUP_TOKEN_RE);
368
+ resolve(match ? match[0] : "");
369
+ });
370
+ });
371
+ }
372
+
373
+ function normalizeAnthropicToken(value) {
374
+ let text = String(value || "").trim();
375
+ if (!text) return "";
376
+ text = text.replace(/^export\s+/, "");
377
+ const match = text.match(/^ANTHROPIC_AUTH_TOKEN=(.*)$/);
378
+ if (match) text = match[1].trim();
379
+ if ((text.startsWith('"') && text.endsWith('"')) || (text.startsWith("'") && text.endsWith("'"))) {
380
+ text = text.slice(1, -1);
381
+ }
382
+ return text.replace(/\s+/g, "").trim();
383
+ }
384
+
385
+ async function install(flags) {
386
+ if (flags.makeDefault && flags.pennyOnly) {
387
+ throw new Error("--make-default and --penny-only cannot be used together.");
388
+ }
389
+ const selected = await selectHarnesses(flags);
390
+ if (selected.length === 0) {
391
+ console.log("No supported harnesses selected.");
392
+ return;
393
+ }
394
+
395
+ console.log("PennyRouter install");
396
+ console.log("");
397
+
398
+ const manifest = await loadManifest();
399
+
400
+ if (flags.dryRun) {
401
+ const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
402
+ for (const harness of selected) {
403
+ const explicit = ["claude-code", "codex"].includes(harness.id);
404
+ const command = harness.id === "claude-code" ? "penny claude" : "penny codex";
405
+ console.log(`Will install on ${harness.name}${explicit ? ` via ${command}` : installNoteForHarness(harness.id)}.`);
406
+ if (defaultRouting.get(harness.id)) {
407
+ console.log(`Will also route bare ${harness.id === "claude-code" ? "claude" : harness.id} through PennyRouter.`);
408
+ }
409
+ }
410
+ if (flags.gatewayBaseUrl) console.log(`Serving gateway: ${flags.gatewayBaseUrl}`);
411
+ if (selected.some((harness) => harness.id === "claude-code")) {
412
+ console.log("Claude Code provider picker would be shown unless providers were supplied by flags or environment.");
413
+ }
414
+ console.log("Dry run only. No files changed.");
415
+ return;
416
+ }
417
+
418
+ const session = await createCliSession({
419
+ appUrl: flags.appUrl || APP_URL,
420
+ harnesses: selected.map((harness) => harness.id),
421
+ accountMode: "existing",
422
+ });
423
+
424
+ console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
425
+ console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
426
+ if (!flags.noBrowser) openBrowser(session.authorize_url);
427
+ console.log("");
428
+ console.log("Waiting for approval...");
429
+
430
+ const claim = await pollForKey(session);
431
+ console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
432
+ console.log("");
433
+
434
+ const claudeProviders = await selectClaudeFundingProviders(flags, selected);
435
+ const codexProviders = await selectCodexFundingProviders(flags, selected);
436
+ const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
437
+ flags, selected, claudeProviders.has("subscription"));
438
+ const funding = await configureUserFunding(
439
+ flags, selected, claudeAnthropicAuth, claudeProviders, codexProviders);
440
+ const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
441
+
442
+ for (const credential of funding.credentials) {
443
+ await storeUpstreamCredential({
444
+ gatewayBaseUrl,
445
+ apiKey: claim.api_key,
446
+ provider: credential.provider,
447
+ token: credential.token,
448
+ });
449
+ }
450
+
451
+ if (selected.some((harness) => harness.id === "claude-code")) {
452
+ console.log("Verifying modern Claude Code compatibility before changing local settings...");
453
+ await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
454
+ console.log("");
455
+ }
456
+
457
+ // Keep this as the final interactive choice: users first authorize their account,
458
+ // optionally connect their subscription, and pass compatibility checks. Then they
459
+ // choose the visible command behavior with all relevant context in front of them.
460
+ const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
461
+
462
+ const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
463
+ let runtimeResult = null;
464
+ if (launchable.length > 0) {
465
+ runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
466
+ }
467
+
468
+ const records = [];
469
+ const failures = [];
470
+ for (const harness of selected) {
471
+ await runHarnessJob(failures, harness.id, "install", async () => {
472
+ const plan = await harness.planInstall();
473
+ const existingRecord = (manifest.installations || []).find(
474
+ (candidate) => candidate.harness === harness.id,
475
+ );
476
+ const supportsPennyCommand = ["claude-code", "codex"].includes(harness.id);
477
+ const makeDefault = defaultRouting.get(harness.id) ?? true;
478
+ const hadGlobalRouting = Boolean(existingRecord && existingRecord.default_routing !== false);
479
+ if (["claude-code", "codex"].includes(harness.id) && (makeDefault || hadGlobalRouting)) {
480
+ assertNoRunningHarness(harness.id, flags);
481
+ }
482
+
483
+ const codexSubscriptionAuth = harness.id === "codex"
484
+ ? funding.codexSubscriptionAuth
485
+ : false;
486
+ const claudeSubscriptionAuth = harness.id === "claude-code"
487
+ && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
488
+
489
+ if (supportsPennyCommand) {
490
+ await configurePennyHarness(harness.id, {
491
+ apiKey: claim.api_key,
492
+ gatewayBaseUrl,
493
+ subscriptionAuth: harness.id === "claude-code"
494
+ ? claudeSubscriptionAuth
495
+ : codexSubscriptionAuth,
496
+ models: harness.id === "claude-code" ? CLAUDE_CODE_MODELS : null,
497
+ });
498
+ }
499
+
500
+ let record;
501
+ if (!supportsPennyCommand || makeDefault) {
502
+ record = harness.id === "claude-code" && claudeSubscriptionAuth
503
+ ? await harness.installStoredToken({
504
+ apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
505
+ })
506
+ : await harness.install({
507
+ apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
508
+ });
509
+ } else {
510
+ if (hadGlobalRouting) {
511
+ await harness.uninstall(existingRecord);
512
+ }
513
+ record = {
514
+ harness: harness.id,
515
+ config_path: compactHome(plan.configPath),
516
+ backup_path: existingRecord?.backup_path || null,
517
+ installed_at: new Date().toISOString(),
518
+ };
519
+ }
520
+ if (supportsPennyCommand) {
521
+ record.launch_available = true;
522
+ record.launch_config_path = compactHome(
523
+ harness.id === "claude-code" ? claudeLaunchSettingsPath() : codexLaunchProfilePath(),
524
+ );
525
+ record.default_routing = Boolean(makeDefault);
526
+ }
527
+ if (harness.id === "claude-code" && claudeSubscriptionAuth) {
528
+ record.anthropic_oauth = true;
529
+ record.anthropic_token_stored = true;
530
+ record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
531
+ }
532
+ if (harness.id === "claude-code" && funding.claudeCredentialProvider) {
533
+ record.user_funded_provider = funding.claudeCredentialProvider;
534
+ }
535
+ if (harness.id === "codex" && funding.codexCredentialProvider) {
536
+ record.user_funded_provider = funding.codexCredentialProvider;
537
+ }
538
+ if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
539
+ records.push(record);
540
+ upsertManifestRecord(manifest, record);
541
+ await saveManifest(manifest);
542
+ });
543
+ }
544
+
545
+ await saveManifest(manifest);
546
+ console.log("");
547
+ console.log(records.length > 0
548
+ ? "PennyRouter installation complete."
549
+ : "PennyRouter was not installed on any selected tool.");
550
+ console.log(`Manifest: ${statePath("installations.json")}`);
551
+ for (const record of records) {
552
+ const harness = HARNESS_BY_ID[record.harness];
553
+ if (record.launch_available) {
554
+ const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
555
+ const native = record.harness === "claude-code" ? "claude" : "codex";
556
+ const harnessName = record.harness === "claude-code" ? "Claude Code" : "Codex";
557
+ const commandWidth = Math.max(command.length, native.length) + 2;
558
+ console.log("");
559
+ console.log(color(`How to Run ${harnessName}`, "bold"));
560
+ console.log("");
561
+ console.log(` ${color(command.padEnd(commandWidth), "bold")}→ ${harnessName} with PennyRouter`);
562
+ console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName} with PennyRouter (default routing enabled)` : `${harnessName} native`}`);
563
+ } else {
564
+ console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
565
+ }
566
+ }
567
+ if (runtimeResult && !runtimeResult.available) {
568
+ console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
569
+ } else if (runtimeResult?.changed) {
570
+ console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
571
+ }
572
+ console.log("");
573
+ reportFailures(failures);
574
+ }
575
+
576
+ // Refresh the managed local files to this package version WITHOUT re-running any of the
577
+ // account/credential flow. Everything an update needs is already on disk: the pr- key and
578
+ // gateway URL live in the penny config (and, for older installs, in the harness config the
579
+ // installer wrote), and the upstream provider tokens live server-side, encrypted against the
580
+ // account. So no browser claim, no provider checkboxes, no token prompts — this command is
581
+ // non-interactive by construction and never touches a credential.
582
+ //
583
+ // What it DOES rewrite is everything regenerated from code constants, which is exactly what
584
+ // goes stale on a version bump: the copied runtime + `penny` shim, the /penny skill, the
585
+ // Claude launch settings overlay, the Codex provider profile, and the model labels +
586
+ // context-window env in the harness configs.
587
+ //
588
+ // Per-user choices are read back from the manifest, never re-asked: `default_routing` decides
589
+ // whether the bare `claude`/`codex` command keeps pointing at the gateway, and
590
+ // `anthropic_token_stored`/`openai_subscription_auth` decide which auth shape gets rewritten.
591
+ // Re-deriving either from prompt defaults would silently flip a --penny-only user into
592
+ // default routing, or drop subscription auth from a working install.
593
+ async function update(flags) {
594
+ const manifest = await loadManifest();
595
+ if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
596
+ const records = manifest.installations || [];
597
+
598
+ const requested = parseHarnessList(flags.harness);
599
+ const targets = records.filter((record) => {
600
+ if (record.disabled) return false;
601
+ if (flags.all || requested.length === 0) return true;
602
+ return requested.includes(record.harness);
603
+ });
604
+
605
+ if (records.length === 0) {
606
+ throw new Error("No PennyRouter installation found. Run `npx pennyrouter install` first.");
607
+ }
608
+ if (targets.length === 0) {
609
+ console.log("No enabled PennyRouter installations matched. Nothing to update.");
610
+ return;
611
+ }
612
+
613
+ const launchConfig = await loadPennyConfig();
614
+ const globalAuth = await readClaudeCodeGatewayAuth();
615
+ const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
616
+ const gatewayBaseUrl = flags.gatewayBaseUrl
617
+ || launchConfig.gateway_base_url
618
+ || globalAuth.gatewayBaseUrl
619
+ || process.env.PENNYROUTER_GATEWAY_BASE_URL
620
+ || "https://api.pennyrouter.com";
621
+
622
+ if (!apiKey) {
623
+ throw new Error(
624
+ "Could not find your PennyRouter key in the local config. Pass `--penny-key pr-...`, "
625
+ + "or run `npx pennyrouter install` to re-authorize.",
626
+ );
627
+ }
628
+
629
+ console.log("PennyRouter update");
630
+ console.log("");
631
+ console.log(`Key ${maskKey(apiKey)} · gateway ${gatewayBaseUrl}`);
632
+ console.log("Subscription and API keys are left untouched.");
633
+ console.log("");
634
+
635
+ if (flags.dryRun) {
636
+ for (const record of targets) {
637
+ const routing = record.default_routing === false ? "penny command only" : "default routing";
638
+ console.log(`Will refresh ${record.harness} (${routing}).`);
639
+ }
640
+ console.log("Will refresh the penny runtime and shim.");
641
+ console.log("Dry run only. No files changed.");
642
+ return;
643
+ }
644
+
645
+ const failures = [];
646
+ let runtimeResult = null;
647
+ if (targets.some((record) => ["claude-code", "codex"].includes(record.harness))) {
648
+ runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
649
+ }
650
+
651
+ for (const record of targets) {
652
+ const harness = HARNESS_BY_ID[record.harness];
653
+ if (!harness) continue;
654
+
655
+ await runHarnessJob(failures, record.harness, "update", async () => {
656
+ const supportsPennyCommand = ["claude-code", "codex"].includes(record.harness);
657
+ // An update rewrites the harness's own config only when that config is ours to
658
+ // rewrite — i.e. the bare command still routes through the gateway. In penny-only
659
+ // mode the harness config belongs to the user and we touch just the launch profile.
660
+ const ownsGlobalRouting = !record.launch_available || record.default_routing !== false;
661
+ if (ownsGlobalRouting) assertNoRunningHarness(record.harness, flags);
662
+
663
+ const claudeSubscriptionAuth = record.harness === "claude-code"
664
+ && Boolean(record.anthropic_token_stored);
665
+ const codexSubscriptionAuth = record.harness === "codex"
666
+ ? await detectCodexChatgptAuth()
667
+ : false;
668
+
669
+ if (supportsPennyCommand) {
670
+ await configurePennyHarness(record.harness, {
671
+ apiKey,
672
+ gatewayBaseUrl,
673
+ subscriptionAuth: record.harness === "claude-code"
674
+ ? claudeSubscriptionAuth
675
+ : codexSubscriptionAuth,
676
+ models: record.harness === "claude-code" ? CLAUDE_CODE_MODELS : null,
677
+ });
678
+ }
679
+
680
+ let next = record;
681
+ if (ownsGlobalRouting) {
682
+ const plan = await harness.planInstall();
683
+ // Pass the existing record through so install-time state the harness stashed
684
+ // (previous_model_provider, claude_ai_connectors_before) survives the rewrite
685
+ // and a later uninstall still restores the user's original values.
686
+ const installed = record.harness === "claude-code" && claudeSubscriptionAuth
687
+ ? await harness.installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord: record })
688
+ : await harness.install({ apiKey, gatewayBaseUrl, plan, existingRecord: record });
689
+ next = { ...record, ...installed, installed_at: record.installed_at };
690
+ }
691
+
692
+ next.updated_at = new Date().toISOString();
693
+ if (record.harness === "codex") next.openai_subscription_auth = codexSubscriptionAuth;
694
+ upsertManifestRecord(manifest, next);
695
+ await saveManifest(manifest);
696
+ console.log(`Updated ${record.harness}.`);
697
+ });
698
+ }
699
+
700
+ await saveManifest(manifest);
701
+ console.log("");
702
+ if (runtimeResult && !runtimeResult.available) {
703
+ console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
704
+ } else if (runtimeResult?.changed) {
705
+ console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
706
+ }
707
+ console.log("Restart any running Claude Code or Codex sessions to pick up the update.");
708
+ reportFailures(failures);
709
+ }
710
+
711
+ async function chooseDefaultRouting(flags, selected, manifest) {
712
+ const out = new Map();
713
+ const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
714
+ let newInstallChoice = null;
715
+ for (const harness of launchable) {
716
+ if (flags.makeDefault) {
717
+ out.set(harness.id, true);
718
+ continue;
719
+ }
720
+ if (flags.pennyOnly) {
721
+ out.set(harness.id, false);
722
+ continue;
723
+ }
724
+ const existing = (manifest.installations || []).find((item) => item.harness === harness.id);
725
+ if (existing) {
726
+ const currentlyDefault = existing.default_routing !== false;
727
+ if (flags.dryRun || !currentlyDefault || flags.yes || !input.isTTY || !output.isTTY) {
728
+ out.set(harness.id, currentlyDefault);
729
+ continue;
730
+ }
731
+ const native = harness.id === "claude-code" ? "claude" : "codex";
732
+ const change = await confirm(
733
+ `PennyRouter currently runs when you type ${color(native, "bold")}. Change it to run only with ${color(`penny ${native}`, "bold")}?`,
734
+ );
735
+ out.set(harness.id, !change);
736
+ continue;
737
+ }
738
+ if (newInstallChoice === null) {
739
+ // Auto-load-by-default prompt is disabled for now — always install as `penny <native>`
740
+ // only. Flip ENABLE_AUTOLOAD_PROMPT to true to restore asking the user.
741
+ const ENABLE_AUTOLOAD_PROMPT = false;
742
+ if (!ENABLE_AUTOLOAD_PROMPT || flags.dryRun || flags.yes || !input.isTTY || !output.isTTY) {
743
+ newInstallChoice = false;
744
+ } else {
745
+ for (const item of launchable) {
746
+ const native = item.id === "claude-code" ? "claude" : "codex";
747
+ const harnessName = item.id === "claude-code" ? "Claude Code" : "Codex";
748
+ const command = `penny ${native}`;
749
+ const commandWidth = Math.max(command.length, native.length) + 2;
750
+ console.log("");
751
+ console.log(color(`How to Run ${harnessName}`, "bold"));
752
+ console.log("");
753
+ console.log(` ${color(command.padEnd(commandWidth), "bold")}→ ${harnessName} with PennyRouter`);
754
+ console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${harnessName} native (recommended)`);
755
+ }
756
+ console.log("");
757
+ const bare = launchable.map((item) => item.id === "claude-code" ? "claude" : "codex");
758
+ const quoted = bare.map((command) => `'${command}'`);
759
+ const label = quoted.length === 1
760
+ ? quoted[0]
761
+ : `${quoted.slice(0, -1).join(", ")} and ${quoted.at(-1)}`;
762
+ newInstallChoice = await confirm(
763
+ `Set ${label} to automatically load PennyRouter with every session?`,
764
+ );
765
+ }
766
+ }
767
+ out.set(harness.id, newInstallChoice);
768
+ }
769
+ return out;
770
+ }
771
+
772
+ async function selectClaudeFundingProviders(flags, selected) {
773
+ if (!selected.some((harness) => harness.id === "claude-code")) return new Set();
774
+
775
+ const supplied = new Set();
776
+ if (!flags.noAnthropicAuth && (flags.anthropicAuth || flags.token || flags.tokenCommand
777
+ || process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
778
+ if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
779
+ if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
780
+
781
+ // Explicit flags are the automation selector. Interactive installs always default to the
782
+ // subscription alone; environment keys are used if the user checks their provider.
783
+ const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
784
+ || flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey;
785
+ if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
786
+
787
+ console.log("PennyRouter can use your Claude subscription, an Anthropic API key, or an OpenRouter API key to run Claude Code.");
788
+ console.log("Choose one or more providers. PennyRouter will try them in order when available.");
789
+ return promptFundingProviderCheckboxes(
790
+ CLAUDE_PROVIDER_OPTIONS,
791
+ "Which providers should PennyRouter use for Claude Code?",
792
+ new Set(["subscription"]),
793
+ );
794
+ }
795
+
796
+ async function selectCodexFundingProviders(flags, selected) {
797
+ if (!selected.some((harness) => harness.id === "codex")) return new Set();
798
+
799
+ const supplied = new Set();
800
+ if (flags.openaiApiKey || process.env.OPENAI_API_KEY) supplied.add("openai-api");
801
+ if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
802
+ const hasChatgptLogin = await detectCodexChatgptAuth();
803
+ if (hasChatgptLogin) supplied.add("subscription");
804
+
805
+ const explicit = flags.openaiApiKey || flags.openrouterApiKey;
806
+ if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
807
+
808
+ console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, or an OpenRouter API key to run Codex.");
809
+ console.log("Choose one or more providers. PennyRouter will try them in order when available.");
810
+ return promptFundingProviderCheckboxes(
811
+ CODEX_PROVIDER_OPTIONS,
812
+ "Which providers should PennyRouter use for Codex?",
813
+ new Set(["subscription"]),
814
+ );
815
+ }
816
+
817
+ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true) {
818
+ if (!selected.some((harness) => harness.id === "claude-code")) return null;
819
+ if (!enabled || flags.noAnthropicAuth) return null;
820
+
821
+ if (flags.token || flags.tokenCommand || process.env.ANTHROPIC_AUTH_TOKEN) {
822
+ const tokenResult = readAnthropicOAuthToken(flags);
823
+ if (tokenResult.token) return tokenResult;
824
+ }
825
+
826
+ if (flags.yes || !input.isTTY || !output.isTTY) {
827
+ if (flags.anthropicAuth) {
828
+ throw new Error(
829
+ "Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
830
+ );
831
+ }
832
+ return null;
833
+ }
834
+
835
+ const tokenResult = readAnthropicOAuthToken({ tokenCommand: flags.tokenCommand });
836
+ if (tokenResult.token) return tokenResult;
837
+
838
+ console.log("");
839
+ if (commandExists("claude")) {
840
+ // The user already opted into subscription auth above, so run `claude setup-token`
841
+ // directly and capture the token — no copy-paste, no second confirm. The command
842
+ // opens the browser for approval inline. On failure we fall through to manual paste.
843
+ console.log("Launching Claude sign-in — approve in your browser…");
844
+ const captured = await runClaudeSetupToken();
845
+ if (captured) {
846
+ console.log("✓ Claude subscription auth enabled.");
847
+ console.log("");
848
+ return { token: captured, source: "setup-token" };
849
+ }
850
+ console.log("");
851
+ console.log("Couldn't capture a token automatically. Paste it below, or leave blank to skip.");
852
+ } else {
853
+ console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
854
+ }
855
+ const token = normalizeAnthropicToken(await promptSecretish("Anthropic token (blank to skip): "));
856
+ if (!token) {
857
+ console.log("Skipped Claude subscription auth. Enable later with `@penny auth` or `pennyrouter auth anthropic`.");
858
+ console.log("");
859
+ return null;
860
+ }
861
+ console.log("");
862
+ return { token, source: "prompt" };
863
+ }
864
+
865
+ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
866
+ claudeProviders = new Set(), codexProviders = new Set()) {
867
+ const wantsClaude = selected.some((harness) => harness.id === "claude-code");
868
+ const wantsCodex = selected.some((harness) => harness.id === "codex");
869
+ const interactive = !flags.yes && input.isTTY && output.isTTY;
870
+ const credentials = [];
871
+
872
+ let anthropicApiKey = String(
873
+ flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY || "",
874
+ ).trim();
875
+ let openaiApiKey = String(
876
+ flags.openaiApiKey || process.env.OPENAI_API_KEY || "",
877
+ ).trim();
878
+ let openrouterApiKey = String(
879
+ flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
880
+ ).trim();
881
+ const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
882
+ ? await detectCodexChatgptAuth()
883
+ : false;
884
+
885
+ if (wantsClaude && claudeProviders.has("anthropic-api") && !anthropicApiKey) {
886
+ if (!interactive) throw new Error("Anthropic API was selected, but no key was provided.");
887
+ anthropicApiKey = (await promptSecretish("Anthropic API key: ")).trim();
888
+ }
889
+ if (wantsClaude && claudeProviders.has("openrouter") && !openrouterApiKey) {
890
+ if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
891
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
892
+ }
893
+ if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
894
+ throw new Error(
895
+ "ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
896
+ );
897
+ }
898
+ if (wantsCodex && codexProviders.has("openai-api") && !openaiApiKey) {
899
+ if (!interactive) throw new Error("OpenAI API was selected, but no key was provided.");
900
+ openaiApiKey = (await promptSecretish("OpenAI API key: ")).trim();
901
+ }
902
+ if (wantsCodex && codexProviders.has("openrouter") && !openrouterApiKey) {
903
+ if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
904
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
905
+ }
906
+
907
+ if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey) {
908
+ if (!interactive) {
909
+ throw new Error(
910
+ "Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
911
+ + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
912
+ );
913
+ }
914
+ console.log("Claude Code needs a user-funded provider. PennyRouter will never fall back to its own API keys.");
915
+ anthropicApiKey = (await promptSecretish(
916
+ "Anthropic API key (leave blank to use OpenRouter): ",
917
+ )).trim();
918
+ if (!anthropicApiKey) {
919
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
920
+ }
921
+ if (!anthropicApiKey && !openrouterApiKey) {
922
+ throw new Error("Claude Code installation cancelled: no subscription or API key was provided.");
923
+ }
924
+ console.log("");
925
+ }
926
+
927
+ if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openrouterApiKey) {
928
+ if (!interactive) {
929
+ throw new Error(
930
+ "Codex requires ChatGPT login, --openai-api-key (or OPENAI_API_KEY), "
931
+ + "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
932
+ );
933
+ }
934
+ console.log("Codex is not logged into ChatGPT and needs a user-funded provider.");
935
+ console.log("PennyRouter will never fall back to its own API keys.");
936
+ openaiApiKey = (await promptSecretish(
937
+ "OpenAI API key (leave blank to use OpenRouter): ",
938
+ )).trim();
939
+ if (!openaiApiKey) {
940
+ openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
941
+ }
942
+ if (!openaiApiKey && !openrouterApiKey) {
943
+ throw new Error("Codex installation cancelled: no subscription or API key was provided.");
944
+ }
945
+ console.log("");
946
+ }
947
+
948
+ if (claudeAnthropicAuth) {
949
+ credentials.push({ provider: "anthropic_oauth", token: claudeAnthropicAuth.token });
950
+ }
951
+ if (wantsClaude && anthropicApiKey) {
952
+ credentials.push({ provider: "anthropic_api", token: anthropicApiKey });
953
+ }
954
+ if (wantsCodex && openaiApiKey) {
955
+ credentials.push({ provider: "openai_api", token: openaiApiKey });
956
+ }
957
+ if ((wantsClaude || wantsCodex) && openrouterApiKey) {
958
+ credentials.push({ provider: "openrouter", token: openrouterApiKey });
959
+ }
960
+
961
+ return {
962
+ credentials,
963
+ codexSubscriptionAuth,
964
+ claudeCredentialProvider: claudeAnthropicAuth
965
+ ? "claude-subscription"
966
+ : anthropicApiKey ? "anthropic-api" : openrouterApiKey ? "openrouter" : null,
967
+ codexCredentialProvider: codexSubscriptionAuth
968
+ ? "chatgpt-subscription"
969
+ : openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
970
+ };
971
+ }
972
+
973
+ async function disable(flags) {
974
+ const manifest = await loadManifest();
975
+ if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
976
+ const records = manifest.installations || [];
977
+ const { selectedIds, selectedRecords } = await resolveLifecycleTargets(flags, records);
978
+
979
+ if (selectedRecords.length === 0) {
980
+ console.log("No matching PennyRouter installations found.");
981
+ return;
982
+ }
983
+
984
+ console.log("PennyRouter disable");
985
+ console.log("");
986
+ for (const record of selectedRecords) {
987
+ console.log(` - ${record.harness}: ${record.config_path || "manual setup"}`);
988
+ }
989
+ console.log("");
990
+
991
+ if (!flags.yes) {
992
+ const ok = await confirm("Temporarily turn off these PennyRouter local integrations?");
993
+ if (!ok) return;
994
+ }
995
+
996
+ const failures = [];
997
+ for (const record of selectedRecords) {
998
+ if (record.disabled) {
999
+ console.log(`${record.harness} is already disabled.`);
1000
+ continue;
1001
+ }
1002
+
1003
+ const harness = HARNESS_BY_ID[record.harness];
1004
+ if (!harness) {
1005
+ console.log(`Skipping ${record.harness}: this CLI no longer knows how to disable it.`);
1006
+ continue;
1007
+ }
1008
+
1009
+ await runHarnessJob(failures, record.harness, "disable", async () => {
1010
+ const ownsGlobalRouting = !record.launch_available || record.default_routing;
1011
+ if (record.config_path && ownsGlobalRouting) {
1012
+ const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
1013
+ record.disabled_snapshot_path = snapshotPath;
1014
+ }
1015
+
1016
+ if (record.launch_available) await setPennyHarnessEnabled(record.harness, false);
1017
+ if (ownsGlobalRouting) {
1018
+ if (typeof harness.disable === "function") await harness.disable(record);
1019
+ else await harness.uninstall(record);
1020
+ }
1021
+
1022
+ record.disabled = true;
1023
+ record.disabled_at = new Date().toISOString();
1024
+ upsertManifestRecord(manifest, record);
1025
+ await saveManifest(manifest);
1026
+ console.log(`Disabled ${record.harness}.`);
1027
+ });
1028
+ }
1029
+
1030
+ await saveManifest(manifest);
1031
+ reportFailures(failures);
1032
+ }
1033
+
1034
+ async function enable(flags) {
1035
+ const manifest = await loadManifest();
1036
+ if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
1037
+ const records = manifest.installations || [];
1038
+ const selectedIds = resolveRequestedHarnessIds(flags, records.map((record) => record.harness));
1039
+ const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
1040
+
1041
+ if (selectedRecords.length === 0) {
1042
+ console.log("No matching PennyRouter installations found.");
1043
+ return;
1044
+ }
1045
+
1046
+ console.log("PennyRouter enable");
1047
+ console.log("");
1048
+ for (const record of selectedRecords) {
1049
+ console.log(` - ${record.harness}: ${record.config_path || "manual setup"}`);
1050
+ }
1051
+ console.log("");
1052
+
1053
+ if (!flags.yes) {
1054
+ const ok = await confirm("Turn these PennyRouter local integrations back on?");
1055
+ if (!ok) return;
1056
+ }
1057
+
1058
+ const failures = [];
1059
+ for (const record of selectedRecords) {
1060
+ if (!record.disabled) {
1061
+ console.log(`${record.harness} is already enabled.`);
1062
+ continue;
1063
+ }
1064
+
1065
+ const harness = HARNESS_BY_ID[record.harness];
1066
+ if (!harness) {
1067
+ console.log(`Skipping ${record.harness}: this CLI no longer knows how to enable it.`);
1068
+ continue;
1069
+ }
1070
+
1071
+ await runHarnessJob(failures, record.harness, "enable", async () => {
1072
+ const ownsGlobalRouting = !record.launch_available || record.default_routing;
1073
+ if (record.launch_available) await setPennyHarnessEnabled(record.harness, true);
1074
+ if (ownsGlobalRouting) {
1075
+ if (typeof harness.enable === "function") await harness.enable(record);
1076
+ else if (record.config_path && record.disabled_snapshot_path) {
1077
+ await restoreFile(record.disabled_snapshot_path, record.config_path);
1078
+ } else if (record.manual_required) {
1079
+ console.log(`${record.harness}: re-enable manually in the tool UI using the status details.`);
1080
+ } else {
1081
+ throw new Error("no disabled snapshot found; run install again");
1082
+ }
1083
+ }
1084
+
1085
+ delete record.disabled;
1086
+ delete record.disabled_at;
1087
+ upsertManifestRecord(manifest, record);
1088
+ await saveManifest(manifest);
1089
+ console.log(`Enabled ${record.harness}.`);
1090
+ });
1091
+ }
1092
+
1093
+ await saveManifest(manifest);
1094
+ reportFailures(failures);
1095
+ }
1096
+
1097
+ async function uninstall(flags) {
1098
+ const manifest = await loadManifest();
1099
+ if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
1100
+ const records = manifest.installations || [];
1101
+ const { selectedIds, selectedRecords } = await resolveLifecycleTargets(flags, records);
1102
+
1103
+ if (selectedRecords.length === 0) {
1104
+ console.log("No matching PennyRouter installations found.");
1105
+ return;
1106
+ }
1107
+
1108
+ console.log("PennyRouter uninstall");
1109
+ console.log("");
1110
+ for (const record of selectedRecords) {
1111
+ console.log(` - ${record.harness}: ${record.config_path}`);
1112
+ }
1113
+ console.log("");
1114
+
1115
+ if (!flags.yes) {
1116
+ const ok = await confirm("Remove these PennyRouter local integrations?");
1117
+ if (!ok) return;
1118
+ }
1119
+
1120
+ const kept = [];
1121
+ const failures = [];
1122
+ let anthropicTokenNotice = false;
1123
+ for (const record of records) {
1124
+ if (!selectedIds.includes(record.harness)) {
1125
+ kept.push(record);
1126
+ continue;
1127
+ }
1128
+
1129
+ const harness = HARNESS_BY_ID[record.harness];
1130
+ if (!harness) {
1131
+ console.log(`Skipping ${record.harness}: this CLI no longer knows how to uninstall it.`);
1132
+ kept.push(record);
1133
+ continue;
1134
+ }
1135
+
1136
+ const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
1137
+ if (flags.forgetToken) {
1138
+ await forgetStoredAnthropicToken(record);
1139
+ } else if (record.anthropic_token_stored) {
1140
+ anthropicTokenNotice = true;
1141
+ }
1142
+ if (record.launch_available) await removePennyHarness(record.harness);
1143
+ if (!record.launch_available || record.default_routing) await harness.uninstall(record);
1144
+ console.log(`Removed ${record.harness}.`);
1145
+ });
1146
+ if (!ok) kept.push(record);
1147
+ }
1148
+
1149
+ for (const record of selectedRecords) {
1150
+ if (records.some((existing) => existing.harness === record.harness)) continue;
1151
+ const harness = HARNESS_BY_ID[record.harness];
1152
+ if (!harness) {
1153
+ console.log(`Skipping ${record.harness}: this CLI does not know how to uninstall it.`);
1154
+ continue;
1155
+ }
1156
+ await runHarnessJob(failures, record.harness, "uninstall", async () => {
1157
+ if (flags.forgetToken) {
1158
+ await forgetStoredAnthropicToken(record);
1159
+ } else if (record.anthropic_token_stored) {
1160
+ anthropicTokenNotice = true;
1161
+ }
1162
+ if (record.launch_available) await removePennyHarness(record.harness);
1163
+ if (!record.launch_available || record.default_routing) await harness.uninstall(record);
1164
+ console.log(`Removed ${record.harness}.`);
1165
+ });
1166
+ }
1167
+
1168
+ if (anthropicTokenNotice) {
1169
+ console.log("");
1170
+ console.log("Note: Your stored Anthropic token remains on the server for other machines.");
1171
+ console.log("To delete it from the server, run: pennyrouter auth anthropic --forget");
1172
+ }
1173
+
1174
+ manifest.installations = kept;
1175
+ await saveManifest(manifest);
1176
+ if (!(await hasConfiguredPennyHarnesses())) await removePennyRuntime();
1177
+ reportFailures(failures);
1178
+ }
1179
+
1180
+ async function resolveLifecycleTargets(flags, records) {
1181
+ const requested = parseHarnessList(flags.harness);
1182
+ const ids = flags.all
1183
+ ? Object.keys(HARNESS_BY_ID)
1184
+ : requested.length > 0
1185
+ ? requested
1186
+ : records.length > 0
1187
+ ? records.map((record) => record.harness)
1188
+ : await detectHarnessIds();
1189
+
1190
+ const selectedIds = [...new Set(ids)];
1191
+ const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
1192
+ const manifestIds = new Set(selectedRecords.map((record) => record.harness));
1193
+
1194
+ for (const id of selectedIds) {
1195
+ if (manifestIds.has(id)) continue;
1196
+ const harness = HARNESS_BY_ID[id];
1197
+ if (!harness) continue;
1198
+ if (!(await safeDetect(harness))) continue;
1199
+ let plan;
1200
+ try {
1201
+ plan = await harness.planInstall();
1202
+ } catch (error) {
1203
+ console.log(`Skipping ${id}: ${formatError(error)}`);
1204
+ continue;
1205
+ }
1206
+ selectedRecords.push({
1207
+ harness: id,
1208
+ config_path: plan.configPath ? compactHome(plan.configPath) : null,
1209
+ backup_path: null,
1210
+ installed_at: null,
1211
+ recovered_from_detected_config: true,
1212
+ });
1213
+ }
1214
+
1215
+ return { selectedIds, selectedRecords };
1216
+ }
1217
+
1218
+ async function detectHarnessIds() {
1219
+ const ids = [];
1220
+ for (const harness of Object.values(HARNESS_BY_ID)) {
1221
+ if (await safeDetect(harness)) ids.push(harness.id);
1222
+ }
1223
+ return ids;
1224
+ }
1225
+
1226
+ async function status() {
1227
+ const manifest = await loadManifest();
1228
+ const pruned = pruneUnsupportedManifestRecords(manifest);
1229
+ if (pruned.length > 0) await saveManifest(manifest);
1230
+ const records = manifest.installations || [];
1231
+ console.log("PennyRouter local setup");
1232
+ console.log("");
1233
+
1234
+ if (records.length === 0) {
1235
+ console.log("No local PennyRouter integrations recorded.");
1236
+ return;
1237
+ }
1238
+
1239
+ for (const record of records) {
1240
+ const state = record.disabled ? "disabled" : record.manual_required ? "manual setup shown" : "installed";
1241
+ console.log(`${record.harness}: ${state}`);
1242
+ if (record.config_path) console.log(` config: ${record.config_path}`);
1243
+ if (record.backup_path) console.log(` backup: ${record.backup_path}`);
1244
+ if (record.disabled_snapshot_path) console.log(` disabled snapshot: ${record.disabled_snapshot_path}`);
1245
+ if (record.installed_at) console.log(` installed: ${record.installed_at}`);
1246
+ if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
1247
+ if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
1248
+ if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
1249
+ if (record.launch_available) {
1250
+ const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
1251
+ console.log(` command: ${command}`);
1252
+ console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
1253
+ }
1254
+ if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
1255
+ }
1256
+ }
1257
+
1258
+ async function selectHarnesses(flags) {
1259
+ const requested = flags.all ? Object.keys(HARNESS_BY_ID) : parseHarnessList(flags.harness);
1260
+ if (requested.length > 0) {
1261
+ return requested.map((id) => HARNESS_BY_ID[id]).filter(Boolean);
1262
+ }
1263
+
1264
+ const detected = [];
1265
+ for (const id of DEFAULT_INSTALL_HARNESS_IDS) {
1266
+ const harness = HARNESS_BY_ID[id];
1267
+ if (await safeDetect(harness)) detected.push(harness);
1268
+ }
1269
+
1270
+ if (flags.yes) return detected;
1271
+
1272
+ return promptHarnessCheckboxes(detected);
1273
+ }
1274
+
1275
+ async function promptHarnessCheckboxes(detected) {
1276
+ const detectedIds = new Set(detected.map((harness) => harness.id));
1277
+ const selectedIds = new Set(detectedIds);
1278
+ const harnesses = orderedHarnessesForPrompt(detectedIds);
1279
+ if (!input.isTTY || !output.isTTY) return promptHarnessCheckboxesByText(harnesses, selectedIds);
1280
+
1281
+ return promptHarnessCheckboxesByKeys(harnesses, selectedIds);
1282
+ }
1283
+
1284
+ async function promptHarnessCheckboxesByKeys(harnesses, selectedIds) {
1285
+ let cursor = 0;
1286
+ const previousRawMode = input.isRaw;
1287
+
1288
+ emitKeypressEvents(input);
1289
+ input.setRawMode(true);
1290
+ input.resume();
1291
+ output.write("\x1b[?25l");
1292
+
1293
+ const render = () => {
1294
+ output.write("\x1b[2J\x1b[H");
1295
+ output.write("\n\n");
1296
+ output.write(`${color("Where should PennyRouter be installed?", "bold")}\n\n\n`);
1297
+ harnesses.forEach((harness, index) => {
1298
+ const pointer = index === cursor ? color(">", "cyan") : " ";
1299
+ const checked = selectedIds.has(harness.id) ? color("x", "green") : " ";
1300
+ const name = index === cursor ? color(harness.name, "cyan") : harness.name;
1301
+ output.write(` ${pointer} [${checked}] ${name}\n`);
1302
+ });
1303
+ output.write("\nEnter to continue.\n");
1304
+ };
1305
+
1306
+ render();
1307
+
1308
+ return new Promise((resolve, reject) => {
1309
+ const cleanup = () => {
1310
+ input.off("keypress", onKeypress);
1311
+ input.setRawMode(Boolean(previousRawMode));
1312
+ input.pause();
1313
+ output.write("\x1b[?25h");
1314
+ output.write("\n");
1315
+ };
1316
+
1317
+ const onKeypress = (_str, key = {}) => {
1318
+ if (key.ctrl && key.name === "c") {
1319
+ cleanup();
1320
+ reject(new Error("Cancelled."));
1321
+ return;
1322
+ }
1323
+
1324
+ if (key.name === "up" || key.name === "k") {
1325
+ cursor = (cursor - 1 + harnesses.length) % harnesses.length;
1326
+ render();
1327
+ return;
1328
+ }
1329
+
1330
+ if (key.name === "down" || key.name === "j") {
1331
+ cursor = (cursor + 1) % harnesses.length;
1332
+ render();
1333
+ return;
1334
+ }
1335
+
1336
+ if (key.name === "space") {
1337
+ const id = harnesses[cursor].id;
1338
+ if (selectedIds.has(id)) selectedIds.delete(id);
1339
+ else selectedIds.add(id);
1340
+ render();
1341
+ return;
1342
+ }
1343
+
1344
+ if (key.name === "return" || key.name === "enter") {
1345
+ cleanup();
1346
+ resolve(harnesses.filter((harness) => selectedIds.has(harness.id)));
1347
+ }
1348
+ };
1349
+
1350
+ input.on("keypress", onKeypress);
1351
+ });
1352
+ }
1353
+
1354
+ async function promptHarnessCheckboxesByText(harnesses, selectedIds) {
1355
+ const rl = createInterface({ input, output });
1356
+
1357
+ try {
1358
+ while (true) {
1359
+ console.log("");
1360
+ console.log("");
1361
+ console.log(color("Where should PennyRouter be installed?", "bold"));
1362
+ for (const harness of harnesses) {
1363
+ const checked = selectedIds.has(harness.id) ? color("x", "green") : " ";
1364
+ console.log(` [${checked}] ${harness.name}`);
1365
+ }
1366
+ console.log("");
1367
+ const answer = await rl.question(
1368
+ "Press Enter to continue, or type names to toggle (comma-separated, 'all', or 'none'): ",
1369
+ );
1370
+ const value = answer.trim().toLowerCase();
1371
+ if (!value) return harnesses.filter((harness) => selectedIds.has(harness.id));
1372
+ if (value === "all") {
1373
+ for (const harness of harnesses) selectedIds.add(harness.id);
1374
+ } else if (value === "none") {
1375
+ selectedIds.clear();
1376
+ } else {
1377
+ for (const token of parseHarnessList(value)) {
1378
+ const id = resolveHarnessToken(token);
1379
+ if (!HARNESS_BY_ID[id]) {
1380
+ console.log(`Unknown tool "${token}".`);
1381
+ continue;
1382
+ }
1383
+ if (selectedIds.has(id)) selectedIds.delete(id);
1384
+ else selectedIds.add(id);
1385
+ }
1386
+ }
1387
+ console.log("");
1388
+ }
1389
+ } finally {
1390
+ rl.close();
1391
+ }
1392
+ }
1393
+
1394
+ async function promptFundingProviderCheckboxes(providerOptions, title,
1395
+ initialIds = new Set(["subscription"])) {
1396
+ const selectedIds = new Set(initialIds);
1397
+ if (!input.isTTY || !output.isTTY) return selectedIds;
1398
+
1399
+ let cursor = 0;
1400
+ let validation = "";
1401
+ const previousRawMode = input.isRaw;
1402
+ emitKeypressEvents(input);
1403
+ input.setRawMode(true);
1404
+ input.resume();
1405
+ output.write("\x1b[?25l");
1406
+
1407
+ const render = () => {
1408
+ output.write("\x1b[2J\x1b[H");
1409
+ output.write("\n\n");
1410
+ output.write(`${color(title, "bold")}\n\n`);
1411
+ providerOptions.forEach((provider, index) => {
1412
+ const pointer = index === cursor ? color(">", "cyan") : " ";
1413
+ const checked = selectedIds.has(provider.id) ? color("x", "green") : " ";
1414
+ const name = index === cursor ? color(provider.name, "cyan") : provider.name;
1415
+ output.write(` ${pointer} [${checked}] ${name}\n`);
1416
+ });
1417
+ output.write("\nSpace to toggle · Enter to continue.\n");
1418
+ if (validation) output.write(`${validation}\n`);
1419
+ };
1420
+ render();
1421
+
1422
+ return new Promise((resolve, reject) => {
1423
+ const cleanup = () => {
1424
+ input.off("keypress", onKeypress);
1425
+ input.setRawMode(Boolean(previousRawMode));
1426
+ input.pause();
1427
+ output.write("\x1b[?25h\n");
1428
+ };
1429
+ const onKeypress = (_str, key = {}) => {
1430
+ if (key.ctrl && key.name === "c") {
1431
+ cleanup();
1432
+ reject(new Error("Cancelled."));
1433
+ } else if (key.name === "up" || key.name === "k") {
1434
+ cursor = (cursor - 1 + providerOptions.length) % providerOptions.length;
1435
+ validation = "";
1436
+ render();
1437
+ } else if (key.name === "down" || key.name === "j") {
1438
+ cursor = (cursor + 1) % providerOptions.length;
1439
+ validation = "";
1440
+ render();
1441
+ } else if (key.name === "space") {
1442
+ const id = providerOptions[cursor].id;
1443
+ if (selectedIds.has(id)) selectedIds.delete(id); else selectedIds.add(id);
1444
+ validation = "";
1445
+ render();
1446
+ } else if (key.name === "return" || key.name === "enter") {
1447
+ if (!selectedIds.size) {
1448
+ validation = "Select at least one provider.";
1449
+ render();
1450
+ return;
1451
+ }
1452
+ cleanup();
1453
+ resolve(selectedIds);
1454
+ }
1455
+ };
1456
+ input.on("keypress", onKeypress);
1457
+ });
1458
+ }
1459
+
1460
+ function orderedHarnessesForPrompt(detectedIds) {
1461
+ return DEFAULT_INSTALL_HARNESS_IDS.map((id) => HARNESS_BY_ID[id]).sort((left, right) => {
1462
+ const leftDetected = detectedIds.has(left.id);
1463
+ const rightDetected = detectedIds.has(right.id);
1464
+ if (leftDetected !== rightDetected) return leftDetected ? -1 : 1;
1465
+ return left.name.localeCompare(right.name);
1466
+ });
1467
+ }
1468
+
1469
+ function resolveHarnessToken(token) {
1470
+ const normalized = normalizeHarnessToken(token);
1471
+ for (const harness of Object.values(HARNESS_BY_ID)) {
1472
+ if (normalizeHarnessToken(harness.id) === normalized) return harness.id;
1473
+ if (normalizeHarnessToken(harness.name) === normalized) return harness.id;
1474
+ }
1475
+ return token;
1476
+ }
1477
+
1478
+ function normalizeHarnessToken(value) {
1479
+ return String(value).toLowerCase().replace(/[^a-z0-9]/g, "");
1480
+ }
1481
+
1482
+ function color(text, style) {
1483
+ if (!output.isTTY || process.env.NO_COLOR) return text;
1484
+ const codes = {
1485
+ bold: ["\x1b[1m", "\x1b[22m"],
1486
+ cyan: ["\x1b[36m", "\x1b[39m"],
1487
+ green: ["\x1b[32m", "\x1b[39m"],
1488
+ };
1489
+ const pair = codes[style];
1490
+ return pair ? `${pair[0]}${text}${pair[1]}` : text;
1491
+ }
1492
+
1493
+ function parseHarnessList(value = "") {
1494
+ return value
1495
+ .split(",")
1496
+ .map((part) => part.trim())
1497
+ .filter(Boolean);
1498
+ }
1499
+
1500
+ function resolveRequestedHarnessIds(flags, defaults) {
1501
+ if (flags.all) return defaults;
1502
+ const requested = parseHarnessList(flags.harness);
1503
+ return requested.length > 0 ? requested : defaults;
1504
+ }
1505
+
1506
+ function upsertManifestRecord(manifest, record) {
1507
+ manifest.installations ||= [];
1508
+ manifest.installations = manifest.installations.filter((existing) => existing.harness !== record.harness);
1509
+ manifest.installations.push(record);
1510
+ }
1511
+
1512
+ function pruneUnsupportedManifestRecords(manifest) {
1513
+ manifest.installations ||= [];
1514
+ const kept = manifest.installations.filter((record) => HARNESS_BY_ID[record.harness]);
1515
+ const pruned = manifest.installations.filter((record) => !HARNESS_BY_ID[record.harness]);
1516
+ manifest.installations = kept;
1517
+ return pruned;
1518
+ }
1519
+
1520
+ async function safeDetect(harness) {
1521
+ try {
1522
+ return await harness.detect();
1523
+ } catch (error) {
1524
+ console.log(`Skipping ${harness.id}: detect failed: ${formatError(error)}`);
1525
+ return false;
1526
+ }
1527
+ }
1528
+
1529
+ async function runHarnessJob(failures, harnessId, action, job) {
1530
+ try {
1531
+ await job();
1532
+ return true;
1533
+ } catch (error) {
1534
+ failures.push({ harness: harnessId, action, error });
1535
+ console.error(`${harnessId} ${action} failed: ${formatError(error)}`);
1536
+ return false;
1537
+ }
1538
+ }
1539
+
1540
+ function reportFailures(failures) {
1541
+ if (failures.length === 0) return;
1542
+ console.error("");
1543
+ console.error("Some PennyRouter integrations failed:");
1544
+ for (const failure of failures) {
1545
+ console.error(` - ${failure.harness} ${failure.action}: ${formatError(failure.error)}`);
1546
+ }
1547
+ process.exitCode = 1;
1548
+ }
1549
+
1550
+ function formatError(error) {
1551
+ return error?.message || String(error);
1552
+ }
1553
+
1554
+ // Best-effort: tell the gateway to forget a stored Claude token when uninstalling a
1555
+ // claude-code integration that stored one. Reads the gateway URL + pr- key from the
1556
+ // live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
1557
+ // must not be blocked by a network hiccup; the record is going away regardless.
1558
+ async function forgetStoredAnthropicToken(record) {
1559
+ if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
1560
+ try {
1561
+ const launchConfig = await loadPennyConfig();
1562
+ const globalAuth = await readClaudeCodeGatewayAuth();
1563
+ const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
1564
+ const apiKey = launchConfig.api_key || globalAuth.apiKey;
1565
+ if (!gatewayBaseUrl || !apiKey) return;
1566
+ await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
1567
+ } catch {
1568
+ // swallow — see note above
1569
+ }
1570
+ }
1571
+
1572
+ async function confirm(question) {
1573
+ const rl = createInterface({ input, output });
1574
+ const answer = await rl.question(`${question} [y/N] `);
1575
+ rl.close();
1576
+ return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
1577
+ }
1578
+
1579
+ async function promptSecretish(question) {
1580
+ if (!input.isTTY || !output.isTTY) {
1581
+ const rl = createInterface({ input, output });
1582
+ const answer = await rl.question(question);
1583
+ rl.close();
1584
+ return answer.trim();
1585
+ }
1586
+
1587
+ const previousRawMode = input.isRaw;
1588
+ emitKeypressEvents(input);
1589
+ input.setRawMode(true);
1590
+ input.resume();
1591
+ output.write(question);
1592
+
1593
+ return new Promise((resolve, reject) => {
1594
+ let value = "";
1595
+ const cleanup = () => {
1596
+ input.off("keypress", onKeypress);
1597
+ input.setRawMode(Boolean(previousRawMode));
1598
+ input.pause();
1599
+ output.write("\n");
1600
+ };
1601
+ const onKeypress = (str, key = {}) => {
1602
+ if (key.ctrl && key.name === "c") {
1603
+ cleanup();
1604
+ reject(new Error("Cancelled."));
1605
+ return;
1606
+ }
1607
+ if (key.name === "return" || key.name === "enter") {
1608
+ cleanup();
1609
+ resolve(value.trim());
1610
+ return;
1611
+ }
1612
+ if (key.name === "backspace") {
1613
+ value = value.slice(0, -1);
1614
+ return;
1615
+ }
1616
+ if (str) value += str;
1617
+ };
1618
+ input.on("keypress", onKeypress);
1619
+ });
1620
+ }
1621
+
1622
+ function commandExists(command) {
1623
+ // Windows installs CLIs as `.cmd`/`.ps1` shims that Node won't resolve without a
1624
+ // shell, so probe through the shell there (else this false-negatives on `claude` and
1625
+ // the auto setup-token path gets skipped). `command` is an internal literal.
1626
+ const result = spawnSync(command, ["--version"], {
1627
+ stdio: "ignore",
1628
+ shell: process.platform === "win32",
1629
+ });
1630
+ return result.status === 0;
1631
+ }
1632
+
1633
+ function installNoteForRecord(record) {
1634
+ if (record.manual_required) return " (manual setup shown)";
1635
+ if (record.anthropic_oauth) return " (Claude subscription auth)";
1636
+ return installNoteForHarness(record.harness);
1637
+ }
1638
+
1639
+ function installNoteForHarness(harnessId) {
1640
+ if (harnessId === "cline" || harnessId === "codegpt") return " (manual setup)";
1641
+ if (harnessId === "opencode") return " (select a Penny model from /model)";
1642
+ return " (auto-selected)";
1643
+ }
1644
+
1645
+ function openBrowser(url) {
1646
+ const platform = process.platform;
1647
+ const command =
1648
+ platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
1649
+ const args = platform === "win32" ? ["/c", "start", "", url] : [url];
1650
+ spawnSync(command, args, { stdio: "ignore", detached: true });
1651
+ }
1652
+
1653
+ function runningClaudeCodeProcesses() {
1654
+ if (process.platform === "win32") {
1655
+ const result = spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8" });
1656
+ if (result.status !== 0) return [];
1657
+ return String(result.stdout || "").split(/\r?\n/)
1658
+ .filter((line) => /^"claude(?:\.exe)?"/i.test(line.trim()));
1659
+ }
1660
+ const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
1661
+ if (result.status !== 0) return [];
1662
+ return String(result.stdout || "").split(/\r?\n/)
1663
+ .map((line) => line.trim())
1664
+ // Case-sensitive on purpose: match the Claude Code executable, not Claude.app.
1665
+ .filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
1666
+ }
1667
+
1668
+ function runningCodexProcesses() {
1669
+ if (process.platform === "win32") return [];
1670
+ const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
1671
+ if (result.status !== 0) return [];
1672
+ return String(result.stdout || "").split(/\r?\n/)
1673
+ .map((line) => line.trim())
1674
+ .filter((line) => /(^|[\/\s])codex(?:\s|$)/.test(line));
1675
+ }
1676
+
1677
+ function assertNoRunningHarness(harnessId, flags) {
1678
+ if (flags.allowRunningHarnesses || (harnessId === "claude-code" && flags.allowRunningClaude)) {
1679
+ return;
1680
+ }
1681
+ const running = harnessId === "claude-code"
1682
+ ? runningClaudeCodeProcesses()
1683
+ : runningCodexProcesses();
1684
+ if (running.length === 0) return;
1685
+ const name = harnessId === "claude-code" ? "Claude Code" : "Codex";
1686
+ throw new Error(
1687
+ `${name} is currently running. Close all ${name} sessions before changing its global routing. `
1688
+ + "Installing only the explicit `penny` command does not require this. If you intentionally "
1689
+ + "accept the risk, pass --allow-running-harnesses.\nDetected:\n "
1690
+ + running.slice(0, 5).join("\n "),
1691
+ );
1692
+ }
1693
+
1694
+ function printHelp() {
1695
+ console.log(`PennyRouter CLI
1696
+
1697
+ Usage:
1698
+ 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]
1699
+ pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL] [--allow-running-harnesses]
1700
+ pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1701
+ pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1702
+ pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1703
+ pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
1704
+ pennyrouter status
1705
+
1706
+ Update refreshes the managed local config and the penny runtime to this package version.
1707
+ It reuses your existing key and never re-asks for subscription or API credentials.
1708
+
1709
+ Examples:
1710
+ npx pennyrouter update
1711
+ npx pennyrouter update --dry-run
1712
+ npx pennyrouter update --harness claude-code
1713
+ npx pennyrouter install
1714
+ npx pennyrouter install --make-default
1715
+ npx pennyrouter install --penny-only
1716
+ npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
1717
+ npx pennyrouter install --harness claude-code --anthropic-api-key <key>
1718
+ npx pennyrouter install --harness codex --openai-api-key <key>
1719
+ npx pennyrouter install --openrouter-api-key <key>
1720
+ npx pennyrouter auth anthropic
1721
+ npx pennyrouter install --all
1722
+ npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
1723
+ npx pennyrouter disable
1724
+ npx pennyrouter enable
1725
+ npx pennyrouter uninstall
1726
+ `);
1727
+ }