pennyrouter 0.1.20 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -10,7 +10,7 @@ status checks, and uninstall metadata.
10
10
 
11
11
  ```bash
12
12
  npx pennyrouter install
13
- npx pennyrouter install --existing-account
13
+ npx pennyrouter install --make-default
14
14
  npx pennyrouter auth anthropic
15
15
  npx pennyrouter install --harness claude-code,cline,opencode,codex,windsurf,zed,codegpt,aider
16
16
  npx pennyrouter disable
@@ -19,6 +19,9 @@ npx pennyrouter uninstall
19
19
  npx pennyrouter status
20
20
  ```
21
21
 
22
+ Installation opens a browser and requires login to the PennyRouter account that should own
23
+ the new machine key. CLI installs no longer create anonymous ghost accounts.
24
+
22
25
  With no `--harness` flag, the interactive installer shows a checkbox list. Tools with
23
26
  detected configs start checked; press Enter to accept or type tool IDs to toggle them.
24
27
 
@@ -26,6 +29,33 @@ When Claude Code is selected, the installer also offers an optional Claude Pro/M
26
29
  subscription-auth step. Users can skip it during install and enable it later with
27
30
  `@penny auth` or `npx pennyrouter auth anthropic`.
28
31
 
32
+ ## Explicit Penny Commands
33
+
34
+ On macOS and Linux, a normal install adds a small `penny` launcher under `~/.local/bin`:
35
+
36
+ ```bash
37
+ penny claude
38
+ penny codex
39
+ ```
40
+
41
+ Arguments are passed through to the native harness, so commands such as `penny claude --resume`
42
+ and `penny codex resume --last` work normally. By default, bare `claude` and `codex` remain
43
+ completely native; their normal configuration files are not redirected. Choose the installer's
44
+ "make PennyRouter the default" option, or pass `--make-default`, to retain the original global
45
+ routing behavior.
46
+
47
+ Existing PennyRouter installs preserve their current bare-command behavior during upgrade unless
48
+ the user explicitly changes it. Close running Claude Code or Codex sessions only when adding or
49
+ removing global bare-command routing. A Penny-only install does not require closing native sessions.
50
+
51
+ The launcher uses a private config at `~/.config/pennyrouter/config.json`, a Claude settings overlay,
52
+ and a secret-free Codex profile. The installer adds one marked PATH block to zsh or bash startup
53
+ files when needed; `--no-path-update` skips that edit.
54
+
55
+ In Claude Code sessions launched with `penny claude`, `/penny help` is an alias for the existing
56
+ `@penny help` syntax. Both forms remain supported. Codex continues to use `@penny` because Codex
57
+ does not currently expose equivalent project-independent custom slash commands.
58
+
29
59
  When Codex is already signed in with ChatGPT, the installer preserves that Codex-managed
30
60
  login and configures PennyRouter as an OpenAI-authenticated Responses proxy. Codex continues
31
61
  to own and refresh the OpenAI credentials; PennyRouter stores only its existing `pr-` account
@@ -40,6 +70,12 @@ npx pennyrouter@latest install --harness codex --local
40
70
  Restart Codex after installation. Without `--local`, browser authorization supplies the normal
41
71
  hosted gateway URL.
42
72
 
73
+ Before configuring Claude Code, the installer sends a small compatibility request exercising the
74
+ modern beta/context-management request shape. If that check fails, Claude routing is not enabled;
75
+ the gateway must be redeployed or fixed before retrying. This check is about PennyRouter's gateway
76
+ compatibility, not a demand that the user upgrade a Claude Code version that already emits a
77
+ supported request shape.
78
+
43
79
  ## Supported Tools
44
80
 
45
81
  - Claude Code
@@ -124,8 +160,9 @@ directly.
124
160
 
125
161
  ## What It Changes
126
162
 
127
- The CLI writes only selected local tool configuration files, saves backups under the local
128
- PennyRouter state directory, and records an uninstall manifest.
163
+ The CLI installs its managed launcher under `~/.local`, writes only selected local tool
164
+ configuration or per-command overlay files, saves backups under the local PennyRouter state
165
+ directory, and records an uninstall manifest.
129
166
 
130
167
  `disable` temporarily removes PennyRouter from selected tools while retaining a local
131
168
  PennyRouter-managed snapshot for `enable`.
package/bin/penny.js ADDED
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ import { runPenny } from "../src/launch.js";
3
+
4
+ runPenny().then((code) => {
5
+ process.exitCode = code;
6
+ }).catch((error) => {
7
+ console.error(error?.message || String(error));
8
+ process.exitCode = 1;
9
+ });
@@ -0,0 +1,8 @@
1
+ {
2
+ "name": "pennyrouter",
3
+ "description": "PennyRouter in-session command alias for Claude Code",
4
+ "version": "1.0.0",
5
+ "author": {
6
+ "name": "PennyRouter"
7
+ }
8
+ }
@@ -0,0 +1,8 @@
1
+ ---
2
+ name: penny
3
+ description: Forward a PennyRouter command to the gateway. Use when the user invokes /penny.
4
+ argument-hint: <command and arguments>
5
+ disable-model-invocation: true
6
+ ---
7
+
8
+ @penny $ARGUMENTS
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.20",
3
+ "version": "0.2.0",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -20,11 +20,12 @@
20
20
  "files": [
21
21
  "bin",
22
22
  "src",
23
+ "claude-plugin",
23
24
  "README.md"
24
25
  ],
25
26
  "scripts": {
26
27
  "start": "node ./bin/pennyrouter.js",
27
- "check": "node --check ./bin/pennyrouter.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js"
28
+ "check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/launch.test.js"
28
29
  },
29
30
  "license": "MIT"
30
31
  }
package/src/cli.js CHANGED
@@ -4,13 +4,14 @@ import { createInterface } from "node:readline/promises";
4
4
  import { stdin as input, stdout as output } from "node:process";
5
5
  import { aiderHarness } from "./harnesses/aider.js";
6
6
  import {
7
+ CLAUDE_CODE_MODELS,
7
8
  claudeCodeHarness,
8
9
  readClaudeCodeGatewayAuth,
9
10
  readClaudeCodePennyRouterKey,
10
11
  } from "./harnesses/claude-code.js";
11
12
  import { clineHarness } from "./harnesses/cline.js";
12
13
  import { codeGptHarness } from "./harnesses/codegpt.js";
13
- import { codexHarness } from "./harnesses/codex.js";
14
+ import { codexHarness, detectCodexChatgptAuth } from "./harnesses/codex.js";
14
15
  import { opencodeHarness } from "./harnesses/opencode.js";
15
16
  import { windsurfHarness } from "./harnesses/windsurf.js";
16
17
  import { zedHarness } from "./harnesses/zed.js";
@@ -20,6 +21,7 @@ import {
20
21
  forgetAnthropicToken,
21
22
  maskKey,
22
23
  pollForKey,
24
+ probeClaudeCodeGateway,
23
25
  sleep,
24
26
  storeAnthropicToken,
25
27
  } from "./session.js";
@@ -31,6 +33,19 @@ import {
31
33
  saveManifest,
32
34
  statePath,
33
35
  } from "./state.js";
36
+ import {
37
+ claudeLaunchSettingsPath,
38
+ codexLaunchProfilePath,
39
+ configurePennyHarness,
40
+ hasConfiguredPennyHarnesses,
41
+ installPennyRuntime,
42
+ loadPennyConfig,
43
+ pennyConfigPath,
44
+ removePennyHarness,
45
+ removePennyRuntime,
46
+ savePennyConfig,
47
+ setPennyHarnessEnabled,
48
+ } from "./launch.js";
34
49
 
35
50
  const HARNESS_BY_ID = {
36
51
  "claude-code": claudeCodeHarness,
@@ -98,6 +113,11 @@ function parseArgs(argv) {
98
113
  else if (arg === "--yes" || arg === "-y") flags.yes = true;
99
114
  else if (arg === "--all") flags.all = true;
100
115
  else if (arg === "--dry-run") flags.dryRun = true;
116
+ else if (arg === "--allow-running-claude") flags.allowRunningClaude = true;
117
+ else if (arg === "--allow-running-harnesses") flags.allowRunningHarnesses = true;
118
+ else if (arg === "--make-default") flags.makeDefault = true;
119
+ else if (arg === "--penny-only") flags.pennyOnly = true;
120
+ else if (arg === "--no-path-update") flags.noPathUpdate = true;
101
121
  else if (arg === "--no-browser") flags.noBrowser = true;
102
122
  else if (arg === "--existing-account") flags.existingAccount = true;
103
123
  else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
@@ -136,7 +156,8 @@ async function authProvider(flags) {
136
156
  }
137
157
 
138
158
  if (flags.forget) {
139
- const pennyKey = flags.pennyKey || await readClaudeCodePennyRouterKey();
159
+ const launchConfig = await loadPennyConfig();
160
+ const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
140
161
  if (!pennyKey) {
141
162
  throw new Error(
142
163
  "No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
@@ -165,6 +186,10 @@ async function authProvider(flags) {
165
186
  if (updated) {
166
187
  await saveManifest(manifest);
167
188
  }
189
+ if (launchConfig.harnesses?.claude) {
190
+ launchConfig.harnesses.claude.subscription_auth = false;
191
+ await savePennyConfig(launchConfig);
192
+ }
168
193
  return;
169
194
  }
170
195
 
@@ -174,7 +199,8 @@ async function authProvider(flags) {
174
199
  return;
175
200
  }
176
201
 
177
- const pennyKey = flags.pennyKey || await readClaudeCodePennyRouterKey();
202
+ const launchConfig = await loadPennyConfig();
203
+ const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
178
204
  if (!pennyKey) {
179
205
  throw new Error(
180
206
  "No PennyRouter key found in Claude Code settings. Run `pennyrouter install --harness claude-code` first, or pass `--penny-key pr-...`.",
@@ -190,29 +216,58 @@ async function authProvider(flags) {
190
216
  }
191
217
 
192
218
  const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
193
- // Store the token encrypted server-side, then write a pr-key-only config.
194
- await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
195
219
  const manifest = await loadManifest();
196
220
  const existingRecord = (manifest.installations || []).find(
197
221
  (candidate) => candidate.harness === "claude-code",
198
222
  );
199
- const plan = await claudeCodeHarness.planInstall();
200
- const record = await claudeCodeHarness.installStoredToken({
201
- apiKey: pennyKey, gatewayBaseUrl, plan, existingRecord,
223
+ const ownsGlobalRouting = existingRecord
224
+ ? existingRecord.default_routing !== false
225
+ : Boolean(await readClaudeCodePennyRouterKey());
226
+ if (ownsGlobalRouting) assertNoRunningHarness("claude-code", flags);
227
+ await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
228
+ await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: pennyKey });
229
+ if (process.platform !== "win32") {
230
+ await installPennyRuntime({ updatePath: !flags.noPathUpdate });
231
+ }
232
+ await configurePennyHarness("claude-code", {
233
+ apiKey: pennyKey,
234
+ gatewayBaseUrl,
235
+ subscriptionAuth: true,
236
+ models: CLAUDE_CODE_MODELS,
202
237
  });
238
+ let record = existingRecord || {
239
+ harness: "claude-code",
240
+ config_path: compactHome((await claudeCodeHarness.planInstall()).configPath),
241
+ installed_at: new Date().toISOString(),
242
+ launch_available: true,
243
+ launch_config_path: compactHome(claudeLaunchSettingsPath()),
244
+ default_routing: ownsGlobalRouting,
245
+ };
246
+ if (ownsGlobalRouting) {
247
+ const plan = await claudeCodeHarness.planInstall();
248
+ record = {
249
+ ...record,
250
+ ...(await claudeCodeHarness.installStoredToken({
251
+ apiKey: pennyKey, gatewayBaseUrl, plan, existingRecord,
252
+ })),
253
+ };
254
+ }
203
255
 
204
256
  upsertManifestRecord(manifest, {
205
257
  ...record,
258
+ launch_available: true,
259
+ launch_config_path: compactHome(claudeLaunchSettingsPath()),
260
+ default_routing: ownsGlobalRouting,
206
261
  anthropic_oauth: true,
207
262
  anthropic_token_stored: true,
208
263
  token_source: tokenResult.source,
209
264
  });
210
265
  await saveManifest(manifest);
211
266
 
212
- console.log("Configured Claude Code for PennyRouter + your Claude subscription.");
267
+ console.log("Configured `penny claude` for PennyRouter + your Claude subscription.");
213
268
  console.log("Your Claude token is stored encrypted; Claude Code holds only your PennyRouter key.");
214
269
  console.log("If Claude auth expires, refresh the token source and run `pennyrouter auth anthropic` again.");
215
- console.log(`Config: ${record.config_path}`);
270
+ console.log(`Penny config: ${pennyConfigPath()}`);
216
271
  }
217
272
 
218
273
  function readAnthropicOAuthToken(flags = {}) {
@@ -307,6 +362,9 @@ function normalizeAnthropicToken(value) {
307
362
  }
308
363
 
309
364
  async function install(flags) {
365
+ if (flags.makeDefault && flags.pennyOnly) {
366
+ throw new Error("--make-default and --penny-only cannot be used together.");
367
+ }
310
368
  const selected = await selectHarnesses(flags);
311
369
  if (selected.length === 0) {
312
370
  console.log("No supported harnesses selected.");
@@ -316,9 +374,17 @@ async function install(flags) {
316
374
  console.log("PennyRouter install");
317
375
  console.log("");
318
376
 
377
+ const manifest = await loadManifest();
378
+ const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
379
+
319
380
  if (flags.dryRun) {
320
381
  for (const harness of selected) {
321
- console.log(`Will install on ${harness.name}${installNoteForHarness(harness.id)}.`);
382
+ const explicit = ["claude-code", "codex"].includes(harness.id) && process.platform !== "win32";
383
+ const command = harness.id === "claude-code" ? "penny claude" : "penny codex";
384
+ console.log(`Will install on ${harness.name}${explicit ? ` via ${command}` : installNoteForHarness(harness.id)}.`);
385
+ if (defaultRouting.get(harness.id)) {
386
+ console.log(`Will also route bare ${harness.id === "claude-code" ? "claude" : harness.id} through PennyRouter.`);
387
+ }
322
388
  }
323
389
  if (flags.gatewayBaseUrl) console.log(`Serving gateway: ${flags.gatewayBaseUrl}`);
324
390
  if (selected.some((harness) => harness.id === "claude-code")) {
@@ -331,14 +397,10 @@ async function install(flags) {
331
397
  const session = await createCliSession({
332
398
  appUrl: flags.appUrl || APP_URL,
333
399
  harnesses: selected.map((harness) => harness.id),
334
- accountMode: flags.existingAccount ? "existing" : "seed",
400
+ accountMode: "existing",
335
401
  });
336
402
 
337
- console.log(
338
- flags.existingAccount
339
- ? "Opening your browser to authorize this machine with your existing PennyRouter account..."
340
- : "Opening your browser to authorize this machine...",
341
- );
403
+ console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
342
404
  console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
343
405
  if (!flags.noBrowser) openBrowser(session.authorize_url);
344
406
  console.log("");
@@ -350,7 +412,12 @@ async function install(flags) {
350
412
 
351
413
  const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(flags, selected);
352
414
 
353
- const manifest = await loadManifest();
415
+ const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
416
+ let runtimeResult = null;
417
+ if (launchable.length > 0 && process.platform !== "win32") {
418
+ runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
419
+ }
420
+
354
421
  const records = [];
355
422
  const failures = [];
356
423
  for (const harness of selected) {
@@ -360,32 +427,76 @@ async function install(flags) {
360
427
  const existingRecord = (manifest.installations || []).find(
361
428
  (candidate) => candidate.harness === harness.id,
362
429
  );
363
- let record;
430
+ const supportsPennyCommand = ["claude-code", "codex"].includes(harness.id)
431
+ && process.platform !== "win32";
432
+ const makeDefault = defaultRouting.get(harness.id) ?? true;
433
+ const hadGlobalRouting = Boolean(existingRecord && existingRecord.default_routing !== false);
434
+ if (["claude-code", "codex"].includes(harness.id) && (makeDefault || hadGlobalRouting)) {
435
+ assertNoRunningHarness(harness.id, flags);
436
+ }
437
+
438
+ const codexSubscriptionAuth = harness.id === "codex"
439
+ ? await detectCodexChatgptAuth()
440
+ : false;
441
+ const claudeSubscriptionAuth = harness.id === "claude-code"
442
+ && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
443
+
364
444
  if (harness.id === "claude-code" && claudeAnthropicAuth) {
365
- // Store the Claude setup-token encrypted server-side, then write a config
366
- // that carries ONLY the pr- key. Nothing Anthropic-flavored on disk, and no
367
- // dual-credential warnings from Claude Code.
368
445
  await storeAnthropicToken({
369
446
  gatewayBaseUrl,
370
447
  apiKey: claim.api_key,
371
448
  token: claudeAnthropicAuth.token,
372
449
  });
373
- record = await harness.installStoredToken({
374
- apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
375
- });
376
- record.anthropic_oauth = true;
377
- record.anthropic_token_stored = true;
378
- record.token_source = claudeAnthropicAuth.source;
379
- } else {
380
- record = await harness.install({
450
+ }
451
+ if (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
+ }
455
+
456
+ if (supportsPennyCommand) {
457
+ await configurePennyHarness(harness.id, {
381
458
  apiKey: claim.api_key,
382
- // An explicit --local / --gateway-base-url overrides the URL the server hands back
383
- // (which points at prod) — so a --local install serves through the local gateway.
384
459
  gatewayBaseUrl,
385
- plan,
386
- existingRecord,
460
+ subscriptionAuth: harness.id === "claude-code"
461
+ ? claudeSubscriptionAuth
462
+ : codexSubscriptionAuth,
463
+ models: harness.id === "claude-code" ? CLAUDE_CODE_MODELS : null,
387
464
  });
388
465
  }
466
+
467
+ let record;
468
+ if (!supportsPennyCommand || makeDefault) {
469
+ record = harness.id === "claude-code" && claudeSubscriptionAuth
470
+ ? await harness.installStoredToken({
471
+ apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
472
+ })
473
+ : await harness.install({
474
+ apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
475
+ });
476
+ } else {
477
+ if (hadGlobalRouting) {
478
+ await harness.uninstall(existingRecord);
479
+ }
480
+ record = {
481
+ harness: harness.id,
482
+ config_path: compactHome(plan.configPath),
483
+ backup_path: existingRecord?.backup_path || null,
484
+ installed_at: new Date().toISOString(),
485
+ };
486
+ }
487
+ if (supportsPennyCommand) {
488
+ record.launch_available = true;
489
+ record.launch_config_path = compactHome(
490
+ harness.id === "claude-code" ? claudeLaunchSettingsPath() : codexLaunchProfilePath(),
491
+ );
492
+ record.default_routing = Boolean(makeDefault);
493
+ }
494
+ if (harness.id === "claude-code" && claudeSubscriptionAuth) {
495
+ record.anthropic_oauth = true;
496
+ record.anthropic_token_stored = true;
497
+ record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
498
+ }
499
+ if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
389
500
  records.push(record);
390
501
  upsertManifestRecord(manifest, record);
391
502
  await saveManifest(manifest);
@@ -397,12 +508,65 @@ async function install(flags) {
397
508
  console.log("Installed PennyRouter.");
398
509
  for (const record of records) {
399
510
  const harness = HARNESS_BY_ID[record.harness];
400
- console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
511
+ if (record.launch_available) {
512
+ const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
513
+ const native = record.harness === "claude-code" ? "claude" : "codex";
514
+ console.log(`Installed on ${harness?.name || record.harness}: use \`${command}\`.`);
515
+ console.log(` Bare \`${native}\`: ${record.default_routing ? "PennyRouter" : "native"}`);
516
+ } else {
517
+ console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
518
+ }
519
+ }
520
+ if (runtimeResult && !runtimeResult.available) {
521
+ console.log("Add ~/.local/bin to PATH before using the `penny` command.");
522
+ } else if (runtimeResult?.changed) {
523
+ console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
401
524
  }
402
525
  console.log(`Manifest: ${statePath("installations.json")}`);
403
526
  reportFailures(failures);
404
527
  }
405
528
 
529
+ async function chooseDefaultRouting(flags, selected, manifest) {
530
+ const out = new Map();
531
+ const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
532
+ if (process.platform === "win32") {
533
+ for (const harness of launchable) out.set(harness.id, true);
534
+ return out;
535
+ }
536
+ let newInstallChoice = null;
537
+ for (const harness of launchable) {
538
+ if (flags.makeDefault) {
539
+ out.set(harness.id, true);
540
+ continue;
541
+ }
542
+ if (flags.pennyOnly) {
543
+ out.set(harness.id, false);
544
+ continue;
545
+ }
546
+ const existing = (manifest.installations || []).find((item) => item.harness === harness.id);
547
+ if (existing) {
548
+ const currentlyDefault = existing.default_routing !== false;
549
+ if (flags.dryRun || !currentlyDefault || flags.yes || !input.isTTY || !output.isTTY) {
550
+ out.set(harness.id, currentlyDefault);
551
+ continue;
552
+ }
553
+ const native = harness.id === "claude-code" ? "claude" : "codex";
554
+ const change = await confirm(
555
+ `PennyRouter currently runs when you type \`${native}\`. Change it to run only with \`penny ${native}\`?`,
556
+ );
557
+ out.set(harness.id, !change);
558
+ continue;
559
+ }
560
+ if (newInstallChoice === null) {
561
+ newInstallChoice = flags.dryRun || flags.yes || !input.isTTY || !output.isTTY
562
+ ? false
563
+ : await confirm("Use PennyRouter by default when you type `claude` or `codex`?");
564
+ }
565
+ out.set(harness.id, newInstallChoice);
566
+ }
567
+ return out;
568
+ }
569
+
406
570
  async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
407
571
  if (!selected.some((harness) => harness.id === "claude-code")) return null;
408
572
  if (flags.noAnthropicAuth) return null;
@@ -501,13 +665,17 @@ async function disable(flags) {
501
665
  }
502
666
 
503
667
  await runHarnessJob(failures, record.harness, "disable", async () => {
504
- if (record.config_path) {
668
+ const ownsGlobalRouting = !record.launch_available || record.default_routing;
669
+ if (record.config_path && ownsGlobalRouting) {
505
670
  const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
506
671
  record.disabled_snapshot_path = snapshotPath;
507
672
  }
508
673
 
509
- if (typeof harness.disable === "function") await harness.disable(record);
510
- else await harness.uninstall(record);
674
+ if (record.launch_available) await setPennyHarnessEnabled(record.harness, false);
675
+ if (ownsGlobalRouting) {
676
+ if (typeof harness.disable === "function") await harness.disable(record);
677
+ else await harness.uninstall(record);
678
+ }
511
679
 
512
680
  record.disabled = true;
513
681
  record.disabled_at = new Date().toISOString();
@@ -559,13 +727,17 @@ async function enable(flags) {
559
727
  }
560
728
 
561
729
  await runHarnessJob(failures, record.harness, "enable", async () => {
562
- if (typeof harness.enable === "function") await harness.enable(record);
563
- else if (record.config_path && record.disabled_snapshot_path) {
564
- await restoreFile(record.disabled_snapshot_path, record.config_path);
565
- } else if (record.manual_required) {
566
- console.log(`${record.harness}: re-enable manually in the tool UI using the status details.`);
567
- } else {
568
- throw new Error("no disabled snapshot found; run install again");
730
+ const ownsGlobalRouting = !record.launch_available || record.default_routing;
731
+ if (record.launch_available) await setPennyHarnessEnabled(record.harness, true);
732
+ if (ownsGlobalRouting) {
733
+ if (typeof harness.enable === "function") await harness.enable(record);
734
+ else if (record.config_path && record.disabled_snapshot_path) {
735
+ await restoreFile(record.disabled_snapshot_path, record.config_path);
736
+ } else if (record.manual_required) {
737
+ console.log(`${record.harness}: re-enable manually in the tool UI using the status details.`);
738
+ } else {
739
+ throw new Error("no disabled snapshot found; run install again");
740
+ }
569
741
  }
570
742
 
571
743
  delete record.disabled;
@@ -625,7 +797,8 @@ async function uninstall(flags) {
625
797
  } else if (record.anthropic_token_stored) {
626
798
  anthropicTokenNotice = true;
627
799
  }
628
- await harness.uninstall(record);
800
+ if (record.launch_available) await removePennyHarness(record.harness);
801
+ if (!record.launch_available || record.default_routing) await harness.uninstall(record);
629
802
  console.log(`Removed ${record.harness}.`);
630
803
  });
631
804
  if (!ok) kept.push(record);
@@ -644,7 +817,8 @@ async function uninstall(flags) {
644
817
  } else if (record.anthropic_token_stored) {
645
818
  anthropicTokenNotice = true;
646
819
  }
647
- await harness.uninstall(record);
820
+ if (record.launch_available) await removePennyHarness(record.harness);
821
+ if (!record.launch_available || record.default_routing) await harness.uninstall(record);
648
822
  console.log(`Removed ${record.harness}.`);
649
823
  });
650
824
  }
@@ -657,6 +831,7 @@ async function uninstall(flags) {
657
831
 
658
832
  manifest.installations = kept;
659
833
  await saveManifest(manifest);
834
+ if (!(await hasConfiguredPennyHarnesses())) await removePennyRuntime();
660
835
  reportFailures(failures);
661
836
  }
662
837
 
@@ -729,6 +904,11 @@ async function status() {
729
904
  if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
730
905
  if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
731
906
  if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
907
+ if (record.launch_available) {
908
+ const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
909
+ console.log(` command: ${command}`);
910
+ console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
911
+ }
732
912
  if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
733
913
  }
734
914
  }
@@ -969,7 +1149,10 @@ function formatError(error) {
969
1149
  async function forgetStoredAnthropicToken(record) {
970
1150
  if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
971
1151
  try {
972
- const { gatewayBaseUrl, apiKey } = await readClaudeCodeGatewayAuth();
1152
+ const launchConfig = await loadPennyConfig();
1153
+ const globalAuth = await readClaudeCodeGatewayAuth();
1154
+ const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
1155
+ const apiKey = launchConfig.api_key || globalAuth.apiKey;
973
1156
  if (!gatewayBaseUrl || !apiKey) return;
974
1157
  await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
975
1158
  } catch {
@@ -1058,11 +1241,52 @@ function openBrowser(url) {
1058
1241
  spawnSync(command, args, { stdio: "ignore", detached: true });
1059
1242
  }
1060
1243
 
1244
+ function runningClaudeCodeProcesses() {
1245
+ if (process.platform === "win32") {
1246
+ const result = spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8" });
1247
+ if (result.status !== 0) return [];
1248
+ return String(result.stdout || "").split(/\r?\n/)
1249
+ .filter((line) => /^"claude(?:\.exe)?"/i.test(line.trim()));
1250
+ }
1251
+ const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
1252
+ if (result.status !== 0) return [];
1253
+ return String(result.stdout || "").split(/\r?\n/)
1254
+ .map((line) => line.trim())
1255
+ // Case-sensitive on purpose: match the Claude Code executable, not Claude.app.
1256
+ .filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
1257
+ }
1258
+
1259
+ function runningCodexProcesses() {
1260
+ if (process.platform === "win32") return [];
1261
+ const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
1262
+ if (result.status !== 0) return [];
1263
+ return String(result.stdout || "").split(/\r?\n/)
1264
+ .map((line) => line.trim())
1265
+ .filter((line) => /(^|[\/\s])codex(?:\s|$)/.test(line));
1266
+ }
1267
+
1268
+ function assertNoRunningHarness(harnessId, flags) {
1269
+ if (flags.allowRunningHarnesses || (harnessId === "claude-code" && flags.allowRunningClaude)) {
1270
+ return;
1271
+ }
1272
+ const running = harnessId === "claude-code"
1273
+ ? runningClaudeCodeProcesses()
1274
+ : runningCodexProcesses();
1275
+ if (running.length === 0) return;
1276
+ const name = harnessId === "claude-code" ? "Claude Code" : "Codex";
1277
+ throw new Error(
1278
+ `${name} is currently running. Close all ${name} sessions before changing its global routing. `
1279
+ + "Installing only the explicit `penny` command does not require this. If you intentionally "
1280
+ + "accept the risk, pass --allow-running-harnesses.\nDetected:\n "
1281
+ + running.slice(0, 5).join("\n "),
1282
+ );
1283
+ }
1284
+
1061
1285
  function printHelp() {
1062
1286
  console.log(`PennyRouter CLI
1063
1287
 
1064
1288
  Usage:
1065
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]]
1289
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]] [--allow-running-harnesses]
1066
1290
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1067
1291
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1068
1292
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -1071,7 +1295,8 @@ Usage:
1071
1295
 
1072
1296
  Examples:
1073
1297
  npx pennyrouter install
1074
- npx pennyrouter install --existing-account
1298
+ npx pennyrouter install --make-default
1299
+ npx pennyrouter install --penny-only
1075
1300
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
1076
1301
  npx pennyrouter auth anthropic
1077
1302
  npx pennyrouter install --all
@@ -13,7 +13,7 @@ const CONNECTOR_SETTING = "disableClaudeAiConnectors";
13
13
  // pinned to Fable; the DYNAMIC "Penny Custom" option lets PennyRouter cost-route. Legacy
14
14
  // "Penny Speed/Core/Power"
15
15
  // installs (dynamic bundles) are still recognized for uninstall (see isPennyRouterModelValue).
16
- const CLAUDE_CODE_MODELS = {
16
+ export const CLAUDE_CODE_MODELS = {
17
17
  haiku: "Penny Haiku", // -> pinned anthropic/claude-haiku-4-5-20251001
18
18
  sonnet: "Penny Sonnet", // -> pinned anthropic/claude-sonnet-4-6
19
19
  opus: "Penny Opus", // -> pinned anthropic/claude-opus-4-8