pennyrouter 0.1.21 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/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
+ });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.21",
3
+ "version": "0.2.1",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "start": "node ./bin/pennyrouter.js",
27
- "check": "node --check ./bin/pennyrouter.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"
27
+ "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
28
  },
29
29
  "license": "MIT"
30
30
  }
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";
@@ -32,6 +33,19 @@ import {
32
33
  saveManifest,
33
34
  statePath,
34
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";
35
49
 
36
50
  const HARNESS_BY_ID = {
37
51
  "claude-code": claudeCodeHarness,
@@ -100,6 +114,10 @@ function parseArgs(argv) {
100
114
  else if (arg === "--all") flags.all = true;
101
115
  else if (arg === "--dry-run") flags.dryRun = true;
102
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;
103
121
  else if (arg === "--no-browser") flags.noBrowser = true;
104
122
  else if (arg === "--existing-account") flags.existingAccount = true;
105
123
  else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
@@ -138,7 +156,8 @@ async function authProvider(flags) {
138
156
  }
139
157
 
140
158
  if (flags.forget) {
141
- const pennyKey = flags.pennyKey || await readClaudeCodePennyRouterKey();
159
+ const launchConfig = await loadPennyConfig();
160
+ const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
142
161
  if (!pennyKey) {
143
162
  throw new Error(
144
163
  "No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
@@ -167,6 +186,10 @@ async function authProvider(flags) {
167
186
  if (updated) {
168
187
  await saveManifest(manifest);
169
188
  }
189
+ if (launchConfig.harnesses?.claude) {
190
+ launchConfig.harnesses.claude.subscription_auth = false;
191
+ await savePennyConfig(launchConfig);
192
+ }
170
193
  return;
171
194
  }
172
195
 
@@ -176,24 +199,8 @@ async function authProvider(flags) {
176
199
  return;
177
200
  }
178
201
 
179
- // Claude Code's env settings are user-global and may be reloaded by already-open
180
- // processes. Switching the base URL underneath an in-flight agent can transplant a
181
- // multi-hundred-thousand-token tool loop into PennyRouter without a fresh human action.
182
- // Make that blast radius opt-in instead of surprising every new installer.
183
- if (selected.some((harness) => harness.id === "claude-code")
184
- && !flags.allowRunningClaude) {
185
- const running = runningClaudeCodeProcesses();
186
- if (running.length > 0) {
187
- throw new Error(
188
- "Claude Code is currently running. Close all Claude Code sessions, then run install again. "
189
- + "PennyRouter changes Claude's user-global API routing, so continuing could redirect "
190
- + "an open session's full transcript. If you intentionally accept that risk, pass "
191
- + "--allow-running-claude.\nDetected:\n " + running.slice(0, 5).join("\n "),
192
- );
193
- }
194
- }
195
-
196
- const pennyKey = flags.pennyKey || await readClaudeCodePennyRouterKey();
202
+ const launchConfig = await loadPennyConfig();
203
+ const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
197
204
  if (!pennyKey) {
198
205
  throw new Error(
199
206
  "No PennyRouter key found in Claude Code settings. Run `pennyrouter install --harness claude-code` first, or pass `--penny-key pr-...`.",
@@ -209,29 +216,58 @@ async function authProvider(flags) {
209
216
  }
210
217
 
211
218
  const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
212
- // Store the token encrypted server-side, then write a pr-key-only config.
213
- await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
214
219
  const manifest = await loadManifest();
215
220
  const existingRecord = (manifest.installations || []).find(
216
221
  (candidate) => candidate.harness === "claude-code",
217
222
  );
218
- const plan = await claudeCodeHarness.planInstall();
219
- const record = await claudeCodeHarness.installStoredToken({
220
- 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,
221
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
+ }
222
255
 
223
256
  upsertManifestRecord(manifest, {
224
257
  ...record,
258
+ launch_available: true,
259
+ launch_config_path: compactHome(claudeLaunchSettingsPath()),
260
+ default_routing: ownsGlobalRouting,
225
261
  anthropic_oauth: true,
226
262
  anthropic_token_stored: true,
227
263
  token_source: tokenResult.source,
228
264
  });
229
265
  await saveManifest(manifest);
230
266
 
231
- console.log("Configured Claude Code for PennyRouter + your Claude subscription.");
267
+ console.log("Configured `penny claude` for PennyRouter + your Claude subscription.");
232
268
  console.log("Your Claude token is stored encrypted; Claude Code holds only your PennyRouter key.");
233
269
  console.log("If Claude auth expires, refresh the token source and run `pennyrouter auth anthropic` again.");
234
- console.log(`Config: ${record.config_path}`);
270
+ console.log(`Penny config: ${pennyConfigPath()}`);
235
271
  }
236
272
 
237
273
  function readAnthropicOAuthToken(flags = {}) {
@@ -326,6 +362,9 @@ function normalizeAnthropicToken(value) {
326
362
  }
327
363
 
328
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
+ }
329
368
  const selected = await selectHarnesses(flags);
330
369
  if (selected.length === 0) {
331
370
  console.log("No supported harnesses selected.");
@@ -335,9 +374,17 @@ async function install(flags) {
335
374
  console.log("PennyRouter install");
336
375
  console.log("");
337
376
 
377
+ const manifest = await loadManifest();
378
+
338
379
  if (flags.dryRun) {
380
+ const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
339
381
  for (const harness of selected) {
340
- 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
+ }
341
388
  }
342
389
  if (flags.gatewayBaseUrl) console.log(`Serving gateway: ${flags.gatewayBaseUrl}`);
343
390
  if (selected.some((harness) => harness.id === "claude-code")) {
@@ -350,14 +397,10 @@ async function install(flags) {
350
397
  const session = await createCliSession({
351
398
  appUrl: flags.appUrl || APP_URL,
352
399
  harnesses: selected.map((harness) => harness.id),
353
- accountMode: flags.existingAccount ? "existing" : "seed",
400
+ accountMode: "existing",
354
401
  });
355
402
 
356
- console.log(
357
- flags.existingAccount
358
- ? "Opening your browser to authorize this machine with your existing PennyRouter account..."
359
- : "Opening your browser to authorize this machine...",
360
- );
403
+ console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
361
404
  console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
362
405
  if (!flags.noBrowser) openBrowser(session.authorize_url);
363
406
  console.log("");
@@ -368,49 +411,98 @@ async function install(flags) {
368
411
  console.log("");
369
412
 
370
413
  const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(flags, selected);
414
+ const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
415
+
416
+ if (selected.some((harness) => harness.id === "claude-code")) {
417
+ if (claudeAnthropicAuth) {
418
+ await storeAnthropicToken({
419
+ gatewayBaseUrl,
420
+ apiKey: claim.api_key,
421
+ token: claudeAnthropicAuth.token,
422
+ });
423
+ }
424
+ console.log("Verifying modern Claude Code compatibility before changing local settings...");
425
+ await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
426
+ console.log("");
427
+ }
428
+
429
+ // Keep this as the final interactive choice: users first authorize their account,
430
+ // optionally connect their subscription, and pass compatibility checks. Then they
431
+ // choose the visible command behavior with all relevant context in front of them.
432
+ const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
433
+
434
+ const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
435
+ let runtimeResult = null;
436
+ if (launchable.length > 0 && process.platform !== "win32") {
437
+ runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
438
+ }
371
439
 
372
- const manifest = await loadManifest();
373
440
  const records = [];
374
441
  const failures = [];
375
442
  for (const harness of selected) {
376
443
  await runHarnessJob(failures, harness.id, "install", async () => {
377
444
  const plan = await harness.planInstall();
378
- const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
379
445
  const existingRecord = (manifest.installations || []).find(
380
446
  (candidate) => candidate.harness === harness.id,
381
447
  );
382
- let record;
383
- if (harness.id === "claude-code" && claudeAnthropicAuth) {
384
- // Store the Claude setup-token encrypted server-side, then write a config
385
- // that carries ONLY the pr- key. Nothing Anthropic-flavored on disk, and no
386
- // dual-credential warnings from Claude Code.
387
- await storeAnthropicToken({
388
- gatewayBaseUrl,
448
+ const supportsPennyCommand = ["claude-code", "codex"].includes(harness.id)
449
+ && process.platform !== "win32";
450
+ const makeDefault = defaultRouting.get(harness.id) ?? true;
451
+ const hadGlobalRouting = Boolean(existingRecord && existingRecord.default_routing !== false);
452
+ if (["claude-code", "codex"].includes(harness.id) && (makeDefault || hadGlobalRouting)) {
453
+ assertNoRunningHarness(harness.id, flags);
454
+ }
455
+
456
+ const codexSubscriptionAuth = harness.id === "codex"
457
+ ? await detectCodexChatgptAuth()
458
+ : false;
459
+ const claudeSubscriptionAuth = harness.id === "claude-code"
460
+ && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
461
+
462
+ if (supportsPennyCommand) {
463
+ await configurePennyHarness(harness.id, {
389
464
  apiKey: claim.api_key,
390
- token: claudeAnthropicAuth.token,
391
- });
392
- console.log("Verifying modern Claude Code compatibility before changing global settings...");
393
- await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
394
- record = await harness.installStoredToken({
395
- apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
465
+ gatewayBaseUrl,
466
+ subscriptionAuth: harness.id === "claude-code"
467
+ ? claudeSubscriptionAuth
468
+ : codexSubscriptionAuth,
469
+ models: harness.id === "claude-code" ? CLAUDE_CODE_MODELS : null,
396
470
  });
397
- record.anthropic_oauth = true;
398
- record.anthropic_token_stored = true;
399
- record.token_source = claudeAnthropicAuth.source;
471
+ }
472
+
473
+ let record;
474
+ if (!supportsPennyCommand || makeDefault) {
475
+ record = harness.id === "claude-code" && claudeSubscriptionAuth
476
+ ? await harness.installStoredToken({
477
+ apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
478
+ })
479
+ : await harness.install({
480
+ apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
481
+ });
400
482
  } else {
401
- if (harness.id === "claude-code") {
402
- console.log("Verifying modern Claude Code compatibility before changing global settings...");
403
- await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
483
+ if (hadGlobalRouting) {
484
+ await harness.uninstall(existingRecord);
404
485
  }
405
- record = await harness.install({
406
- apiKey: claim.api_key,
407
- // An explicit --local / --gateway-base-url overrides the URL the server hands back
408
- // (which points at prod) — so a --local install serves through the local gateway.
409
- gatewayBaseUrl,
410
- plan,
411
- existingRecord,
412
- });
486
+ record = {
487
+ harness: harness.id,
488
+ config_path: compactHome(plan.configPath),
489
+ backup_path: existingRecord?.backup_path || null,
490
+ installed_at: new Date().toISOString(),
491
+ };
492
+ }
493
+ if (supportsPennyCommand) {
494
+ record.launch_available = true;
495
+ record.launch_config_path = compactHome(
496
+ harness.id === "claude-code" ? claudeLaunchSettingsPath() : codexLaunchProfilePath(),
497
+ );
498
+ record.default_routing = Boolean(makeDefault);
499
+ }
500
+ if (harness.id === "claude-code" && claudeSubscriptionAuth) {
501
+ record.anthropic_oauth = true;
502
+ record.anthropic_token_stored = true;
503
+ record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
413
504
  }
505
+ if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
414
506
  records.push(record);
415
507
  upsertManifestRecord(manifest, record);
416
508
  await saveManifest(manifest);
@@ -419,15 +511,92 @@ async function install(flags) {
419
511
 
420
512
  await saveManifest(manifest);
421
513
  console.log("");
422
- console.log("Installed PennyRouter.");
514
+ console.log(records.length > 0
515
+ ? "PennyRouter installation complete."
516
+ : "PennyRouter was not installed on any selected tool.");
517
+ console.log(`Manifest: ${statePath("installations.json")}`);
423
518
  for (const record of records) {
424
519
  const harness = HARNESS_BY_ID[record.harness];
425
- console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
520
+ if (record.launch_available) {
521
+ const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
522
+ const native = record.harness === "claude-code" ? "claude" : "codex";
523
+ const harnessName = record.harness === "claude-code" ? "Claude Code" : "Codex";
524
+ const commandWidth = Math.max(command.length, native.length) + 2;
525
+ console.log("");
526
+ console.log(color(`How to Run ${harnessName}`, "bold"));
527
+ console.log("");
528
+ console.log(` ${color(command.padEnd(commandWidth), "bold")}→ ${harnessName} with PennyRouter`);
529
+ console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName} with PennyRouter (default routing enabled)` : `${harnessName} native`}`);
530
+ } else {
531
+ console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
532
+ }
426
533
  }
427
- console.log(`Manifest: ${statePath("installations.json")}`);
534
+ if (runtimeResult && !runtimeResult.available) {
535
+ console.log("Add ~/.local/bin to PATH before using the `penny` command.");
536
+ } else if (runtimeResult?.changed) {
537
+ console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
538
+ }
539
+ console.log("");
428
540
  reportFailures(failures);
429
541
  }
430
542
 
543
+ async function chooseDefaultRouting(flags, selected, manifest) {
544
+ const out = new Map();
545
+ const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
546
+ if (process.platform === "win32") {
547
+ for (const harness of launchable) out.set(harness.id, true);
548
+ return out;
549
+ }
550
+ let newInstallChoice = null;
551
+ for (const harness of launchable) {
552
+ if (flags.makeDefault) {
553
+ out.set(harness.id, true);
554
+ continue;
555
+ }
556
+ if (flags.pennyOnly) {
557
+ out.set(harness.id, false);
558
+ continue;
559
+ }
560
+ const existing = (manifest.installations || []).find((item) => item.harness === harness.id);
561
+ if (existing) {
562
+ const currentlyDefault = existing.default_routing !== false;
563
+ if (flags.dryRun || !currentlyDefault || flags.yes || !input.isTTY || !output.isTTY) {
564
+ out.set(harness.id, currentlyDefault);
565
+ continue;
566
+ }
567
+ const native = harness.id === "claude-code" ? "claude" : "codex";
568
+ const change = await confirm(
569
+ `PennyRouter currently runs when you type ${color(native, "bold")}. Change it to run only with ${color(`penny ${native}`, "bold")}?`,
570
+ );
571
+ out.set(harness.id, !change);
572
+ continue;
573
+ }
574
+ if (newInstallChoice === null) {
575
+ if (flags.dryRun || flags.yes || !input.isTTY || !output.isTTY) {
576
+ newInstallChoice = false;
577
+ } else {
578
+ for (const item of launchable) {
579
+ const native = item.id === "claude-code" ? "claude" : "codex";
580
+ const harnessName = item.id === "claude-code" ? "Claude Code" : "Codex";
581
+ const command = `penny ${native}`;
582
+ const commandWidth = Math.max(command.length, native.length) + 2;
583
+ console.log("");
584
+ console.log(color(`How to Run ${harnessName}`, "bold"));
585
+ console.log("");
586
+ console.log(` ${color(command.padEnd(commandWidth), "bold")}→ ${harnessName} with PennyRouter`);
587
+ console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${harnessName} native (recommended)`);
588
+ }
589
+ console.log("");
590
+ const bare = launchable.map((item) => item.id === "claude-code" ? "claude" : "codex");
591
+ const label = bare.length === 1 ? bare[0] : `${bare.slice(0, -1).join(", ")} and ${bare.at(-1)}`;
592
+ newInstallChoice = await confirm(`Also route ${color(label, "bold")} through PennyRouter by default?`);
593
+ }
594
+ }
595
+ out.set(harness.id, newInstallChoice);
596
+ }
597
+ return out;
598
+ }
599
+
431
600
  async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
432
601
  if (!selected.some((harness) => harness.id === "claude-code")) return null;
433
602
  if (flags.noAnthropicAuth) return null;
@@ -526,13 +695,17 @@ async function disable(flags) {
526
695
  }
527
696
 
528
697
  await runHarnessJob(failures, record.harness, "disable", async () => {
529
- if (record.config_path) {
698
+ const ownsGlobalRouting = !record.launch_available || record.default_routing;
699
+ if (record.config_path && ownsGlobalRouting) {
530
700
  const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
531
701
  record.disabled_snapshot_path = snapshotPath;
532
702
  }
533
703
 
534
- if (typeof harness.disable === "function") await harness.disable(record);
535
- else await harness.uninstall(record);
704
+ if (record.launch_available) await setPennyHarnessEnabled(record.harness, false);
705
+ if (ownsGlobalRouting) {
706
+ if (typeof harness.disable === "function") await harness.disable(record);
707
+ else await harness.uninstall(record);
708
+ }
536
709
 
537
710
  record.disabled = true;
538
711
  record.disabled_at = new Date().toISOString();
@@ -584,13 +757,17 @@ async function enable(flags) {
584
757
  }
585
758
 
586
759
  await runHarnessJob(failures, record.harness, "enable", async () => {
587
- if (typeof harness.enable === "function") await harness.enable(record);
588
- else if (record.config_path && record.disabled_snapshot_path) {
589
- await restoreFile(record.disabled_snapshot_path, record.config_path);
590
- } else if (record.manual_required) {
591
- console.log(`${record.harness}: re-enable manually in the tool UI using the status details.`);
592
- } else {
593
- throw new Error("no disabled snapshot found; run install again");
760
+ const ownsGlobalRouting = !record.launch_available || record.default_routing;
761
+ if (record.launch_available) await setPennyHarnessEnabled(record.harness, true);
762
+ if (ownsGlobalRouting) {
763
+ if (typeof harness.enable === "function") await harness.enable(record);
764
+ else if (record.config_path && record.disabled_snapshot_path) {
765
+ await restoreFile(record.disabled_snapshot_path, record.config_path);
766
+ } else if (record.manual_required) {
767
+ console.log(`${record.harness}: re-enable manually in the tool UI using the status details.`);
768
+ } else {
769
+ throw new Error("no disabled snapshot found; run install again");
770
+ }
594
771
  }
595
772
 
596
773
  delete record.disabled;
@@ -650,7 +827,8 @@ async function uninstall(flags) {
650
827
  } else if (record.anthropic_token_stored) {
651
828
  anthropicTokenNotice = true;
652
829
  }
653
- await harness.uninstall(record);
830
+ if (record.launch_available) await removePennyHarness(record.harness);
831
+ if (!record.launch_available || record.default_routing) await harness.uninstall(record);
654
832
  console.log(`Removed ${record.harness}.`);
655
833
  });
656
834
  if (!ok) kept.push(record);
@@ -669,7 +847,8 @@ async function uninstall(flags) {
669
847
  } else if (record.anthropic_token_stored) {
670
848
  anthropicTokenNotice = true;
671
849
  }
672
- await harness.uninstall(record);
850
+ if (record.launch_available) await removePennyHarness(record.harness);
851
+ if (!record.launch_available || record.default_routing) await harness.uninstall(record);
673
852
  console.log(`Removed ${record.harness}.`);
674
853
  });
675
854
  }
@@ -682,6 +861,7 @@ async function uninstall(flags) {
682
861
 
683
862
  manifest.installations = kept;
684
863
  await saveManifest(manifest);
864
+ if (!(await hasConfiguredPennyHarnesses())) await removePennyRuntime();
685
865
  reportFailures(failures);
686
866
  }
687
867
 
@@ -754,6 +934,11 @@ async function status() {
754
934
  if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
755
935
  if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
756
936
  if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
937
+ if (record.launch_available) {
938
+ const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
939
+ console.log(` command: ${command}`);
940
+ console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
941
+ }
757
942
  if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
758
943
  }
759
944
  }
@@ -994,7 +1179,10 @@ function formatError(error) {
994
1179
  async function forgetStoredAnthropicToken(record) {
995
1180
  if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
996
1181
  try {
997
- const { gatewayBaseUrl, apiKey } = await readClaudeCodeGatewayAuth();
1182
+ const launchConfig = await loadPennyConfig();
1183
+ const globalAuth = await readClaudeCodeGatewayAuth();
1184
+ const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
1185
+ const apiKey = launchConfig.api_key || globalAuth.apiKey;
998
1186
  if (!gatewayBaseUrl || !apiKey) return;
999
1187
  await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
1000
1188
  } catch {
@@ -1098,11 +1286,37 @@ function runningClaudeCodeProcesses() {
1098
1286
  .filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
1099
1287
  }
1100
1288
 
1289
+ function runningCodexProcesses() {
1290
+ if (process.platform === "win32") return [];
1291
+ const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
1292
+ if (result.status !== 0) return [];
1293
+ return String(result.stdout || "").split(/\r?\n/)
1294
+ .map((line) => line.trim())
1295
+ .filter((line) => /(^|[\/\s])codex(?:\s|$)/.test(line));
1296
+ }
1297
+
1298
+ function assertNoRunningHarness(harnessId, flags) {
1299
+ if (flags.allowRunningHarnesses || (harnessId === "claude-code" && flags.allowRunningClaude)) {
1300
+ return;
1301
+ }
1302
+ const running = harnessId === "claude-code"
1303
+ ? runningClaudeCodeProcesses()
1304
+ : runningCodexProcesses();
1305
+ if (running.length === 0) return;
1306
+ const name = harnessId === "claude-code" ? "Claude Code" : "Codex";
1307
+ throw new Error(
1308
+ `${name} is currently running. Close all ${name} sessions before changing its global routing. `
1309
+ + "Installing only the explicit `penny` command does not require this. If you intentionally "
1310
+ + "accept the risk, pass --allow-running-harnesses.\nDetected:\n "
1311
+ + running.slice(0, 5).join("\n "),
1312
+ );
1313
+ }
1314
+
1101
1315
  function printHelp() {
1102
1316
  console.log(`PennyRouter CLI
1103
1317
 
1104
1318
  Usage:
1105
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]] [--allow-running-claude]
1319
+ 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]
1106
1320
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1107
1321
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1108
1322
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -1111,7 +1325,8 @@ Usage:
1111
1325
 
1112
1326
  Examples:
1113
1327
  npx pennyrouter install
1114
- npx pennyrouter install --existing-account
1328
+ npx pennyrouter install --make-default
1329
+ npx pennyrouter install --penny-only
1115
1330
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
1116
1331
  npx pennyrouter auth anthropic
1117
1332
  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
@@ -27,10 +27,11 @@ export const codexHarness = {
27
27
  };
28
28
  },
29
29
 
30
- async install({ apiKey, gatewayBaseUrl, plan }) {
30
+ async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
31
31
  const current = await readText(CONFIG_PATH, "");
32
32
  const subscriptionAuth = await detectCodexChatgptAuth();
33
- const previousModelProvider = rootAssignment(removeManagedCodexConfig(current), "model_provider");
33
+ const previousModelProvider = existingRecord?.previous_model_provider
34
+ || rootAssignment(removeManagedCodexConfig(current), "model_provider");
34
35
  const backupPath = await backupFile(CONFIG_PATH, "codex");
35
36
  const next = renderCodexConfig(current, {
36
37
  apiKey,
@@ -75,7 +76,7 @@ export function codexUsesChatgptAuth(auth) {
75
76
  return Boolean(auth.tokens && typeof auth.tokens === "object" && auth.tokens.account_id);
76
77
  }
77
78
 
78
- async function detectCodexChatgptAuth() {
79
+ export async function detectCodexChatgptAuth() {
79
80
  const auth = await readJsonFile(AUTH_PATH, null);
80
81
  if (codexUsesChatgptAuth(auth)) return true;
81
82
  // auth.json may be absent when Codex uses the OS keyring. `codex login status` reports
@@ -1,6 +1,10 @@
1
1
  import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
2
5
 
3
6
  import {
7
+ codexHarness,
4
8
  codexUsesChatgptAuth,
5
9
  removeManagedCodexConfig,
6
10
  renderCodexConfig,
@@ -74,9 +78,42 @@ function testUninstallLeavesUserConfig() {
74
78
  assert.ok(!removed.includes("pr-test"));
75
79
  }
76
80
 
81
+ async function testReinstallPreservesRecordedProvider() {
82
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-codex-reinstall-"));
83
+ const previousHome = process.env.HOME;
84
+ const previousState = process.env.XDG_STATE_HOME;
85
+ process.env.HOME = root;
86
+ process.env.XDG_STATE_HOME = path.join(root, ".state");
87
+ fs.mkdirSync(path.join(root, ".codex"), { recursive: true });
88
+ fs.writeFileSync(path.join(root, ".codex", "auth.json"), '{"auth_mode":"apikey"}\n');
89
+ fs.writeFileSync(path.join(root, ".codex", "config.toml"), renderCodexConfig("", {
90
+ apiKey: "pr-old", gatewayBaseUrl: "https://old.example", subscriptionAuth: false,
91
+ }));
92
+ try {
93
+ const plan = await codexHarness.planInstall();
94
+ const record = await codexHarness.install({
95
+ apiKey: "pr-new",
96
+ gatewayBaseUrl: "https://new.example",
97
+ plan,
98
+ existingRecord: { previous_model_provider: 'model_provider = "native"' },
99
+ });
100
+ assert.equal(record.previous_model_provider, 'model_provider = "native"');
101
+ await codexHarness.uninstall(record);
102
+ assert.ok(fs.readFileSync(path.join(root, ".codex", "config.toml"), "utf8")
103
+ .includes('model_provider = "native"'));
104
+ } finally {
105
+ if (previousHome === undefined) delete process.env.HOME;
106
+ else process.env.HOME = previousHome;
107
+ if (previousState === undefined) delete process.env.XDG_STATE_HOME;
108
+ else process.env.XDG_STATE_HOME = previousState;
109
+ fs.rmSync(root, { recursive: true, force: true });
110
+ }
111
+ }
112
+
77
113
  testAuthDetection();
78
114
  testSubscriptionProviderIsRootSafe();
79
115
  testLegacyMigrationAndIdempotence();
80
116
  testProviderCollisionFailsClosed();
81
117
  testUninstallLeavesUserConfig();
118
+ await testReinstallPreservesRecordedProvider();
82
119
  console.log("codex.test.js: all checks passed");
package/src/launch.js ADDED
@@ -0,0 +1,375 @@
1
+ import { spawn } from "node:child_process";
2
+ import {
3
+ chmod,
4
+ cp,
5
+ mkdir,
6
+ rm,
7
+ stat,
8
+ unlink,
9
+ writeFile,
10
+ } from "node:fs/promises";
11
+ import { constants as osConstants } from "node:os";
12
+ import { fileURLToPath } from "node:url";
13
+ import { basename, dirname, join, resolve } from "node:path";
14
+
15
+ import { configureClaudeCodeStatusline } from "./statusline.js";
16
+ import { atomicWrite, homeDir, readJsonFile, readText } from "./state.js";
17
+
18
+ const CONFIG_VERSION = 1;
19
+ const PATH_START = "# >>> pennyrouter PATH >>>";
20
+ const PATH_END = "# <<< pennyrouter PATH <<<";
21
+ const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed; no secrets)";
22
+ const CLAUDE_COMMAND_MARKER = "<!-- PennyRouter managed Claude command -->";
23
+
24
+ export function pennyConfigDir() {
25
+ return join(process.env.XDG_CONFIG_HOME || join(homeDir(), ".config"), "pennyrouter");
26
+ }
27
+
28
+ export function pennyConfigPath() {
29
+ return join(pennyConfigDir(), "config.json");
30
+ }
31
+
32
+ export function pennyDataDir() {
33
+ return join(process.env.XDG_DATA_HOME || join(homeDir(), ".local", "share"), "pennyrouter");
34
+ }
35
+
36
+ export function pennyRuntimeDir() {
37
+ return join(pennyDataDir(), "runtime");
38
+ }
39
+
40
+ export function pennyBinDir() {
41
+ return join(homeDir(), ".local", "bin");
42
+ }
43
+
44
+ export function pennyShimPath() {
45
+ return join(pennyBinDir(), "penny");
46
+ }
47
+
48
+ export function claudeLaunchSettingsPath() {
49
+ return join(pennyConfigDir(), "claude-settings.json");
50
+ }
51
+
52
+ export function codexLaunchProfilePath() {
53
+ const codexHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
54
+ return join(codexHome, "pennyrouter.config.toml");
55
+ }
56
+
57
+ export function claudeUserCommandPath() {
58
+ return join(homeDir(), ".claude", "commands", "penny.md");
59
+ }
60
+
61
+ async function privateWrite(path, text) {
62
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
63
+ await atomicWrite(path, text);
64
+ await chmod(path, 0o600);
65
+ }
66
+
67
+ export async function loadPennyConfig() {
68
+ return (await readJsonFile(pennyConfigPath(), null)) || {
69
+ version: CONFIG_VERSION,
70
+ harnesses: {},
71
+ };
72
+ }
73
+
74
+ export async function savePennyConfig(config) {
75
+ const next = { version: CONFIG_VERSION, ...config };
76
+ await privateWrite(pennyConfigPath(), `${JSON.stringify(next, null, 2)}\n`);
77
+ return next;
78
+ }
79
+
80
+ function packageRoot() {
81
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
82
+ }
83
+
84
+ async function copyRuntime() {
85
+ const source = packageRoot();
86
+ const destination = pennyRuntimeDir();
87
+ await mkdir(destination, { recursive: true, mode: 0o700 });
88
+ for (const entry of ["bin", "src"]) {
89
+ // Runtime upgrades must not merge with the previous package. In particular,
90
+ // 0.2.1 moved /penny from a skill to a command; leaving the old skill behind
91
+ // makes Claude discover two incompatible definitions.
92
+ await rm(join(destination, entry), { recursive: true, force: true });
93
+ await cp(join(source, entry), join(destination, entry), {
94
+ recursive: true,
95
+ force: true,
96
+ });
97
+ }
98
+ // Remove plugin assets used by 0.2.0/0.2.1. /penny is now a managed personal
99
+ // command so it can use the exact unnamespaced name in every Claude session.
100
+ await rm(join(destination, "claude-plugin"), { recursive: true, force: true });
101
+ await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
102
+ }
103
+
104
+ async function writeShim() {
105
+ await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
106
+ const entry = join(pennyRuntimeDir(), "bin", "penny.js").replaceAll('"', '\\"');
107
+ await writeFile(pennyShimPath(), `#!/bin/sh\nexec node "${entry}" "$@"\n`, {
108
+ encoding: "utf8",
109
+ mode: 0o755,
110
+ });
111
+ await chmod(pennyShimPath(), 0o755);
112
+ }
113
+
114
+ function pathContains(dir) {
115
+ return String(process.env.PATH || "").split(":").some((candidate) => resolve(candidate) === resolve(dir));
116
+ }
117
+
118
+ function shellRcPath() {
119
+ const shell = basename(process.env.SHELL || "");
120
+ if (shell === "zsh") return join(homeDir(), ".zshrc");
121
+ if (shell === "bash") return join(homeDir(), ".bashrc");
122
+ return null;
123
+ }
124
+
125
+ export async function ensurePennyOnPath({ updatePath = true } = {}) {
126
+ if (pathContains(pennyBinDir())) return { available: true, changed: false, rc_path: null };
127
+ if (!updatePath) return { available: false, changed: false, rc_path: null };
128
+ const rcPath = shellRcPath();
129
+ if (!rcPath) return { available: false, changed: false, rc_path: null };
130
+ const current = await readText(rcPath, "");
131
+ if (!current.includes(PATH_START)) {
132
+ const block = `${PATH_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_END}`;
133
+ const prefix = current && !current.endsWith("\n") ? "\n" : "";
134
+ await atomicWrite(rcPath, `${current}${prefix}${block}\n`);
135
+ }
136
+ return { available: true, changed: !current.includes(PATH_START), rc_path: rcPath };
137
+ }
138
+
139
+ export async function installPennyRuntime({ updatePath = true } = {}) {
140
+ if (!(["darwin", "linux"].includes(process.platform))) {
141
+ return { supported: false, available: false, changed: false, rc_path: null };
142
+ }
143
+ await copyRuntime();
144
+ await writeShim();
145
+ const path = await ensurePennyOnPath({ updatePath });
146
+ const config = await loadPennyConfig();
147
+ if (path.rc_path) {
148
+ config.path_rc = path.rc_path;
149
+ await savePennyConfig(config);
150
+ }
151
+ return { supported: true, ...path, shim_path: pennyShimPath() };
152
+ }
153
+
154
+ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
155
+ const authLines = subscriptionAuth
156
+ ? [
157
+ "requires_openai_auth = true",
158
+ 'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }',
159
+ ]
160
+ : ['env_key = "PENNYROUTER_API_KEY"'];
161
+ return [
162
+ CODEX_PROFILE_MARKER,
163
+ 'model_provider = "pennyrouter"',
164
+ "",
165
+ "[model_providers.pennyrouter]",
166
+ 'name = "PennyRouter"',
167
+ `base_url = ${JSON.stringify(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`)}`,
168
+ 'wire_api = "responses"',
169
+ "supports_websockets = false",
170
+ ...authLines,
171
+ "",
172
+ ].join("\n");
173
+ }
174
+
175
+ async function writeClaudeLaunchSettings() {
176
+ const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
177
+ const overlay = { disableClaudeAiConnectors: true };
178
+ if (native.statusLine) overlay.statusLine = native.statusLine;
179
+ await configureClaudeCodeStatusline(overlay);
180
+ await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
181
+ }
182
+
183
+ async function writeClaudeUserCommand() {
184
+ const path = claudeUserCommandPath();
185
+ const existing = await readText(path, "");
186
+ if (existing.trim() && !existing.includes(CLAUDE_COMMAND_MARKER)) {
187
+ throw new Error(
188
+ `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
189
+ );
190
+ }
191
+ await mkdir(dirname(path), { recursive: true });
192
+ await atomicWrite(path, [
193
+ "---",
194
+ "description: PennyRouter commands and help.",
195
+ "argument-hint: <command and arguments>",
196
+ "---",
197
+ CLAUDE_COMMAND_MARKER,
198
+ "",
199
+ "@penny $ARGUMENTS",
200
+ "",
201
+ ].join("\n"));
202
+ }
203
+
204
+ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
205
+ const path = codexLaunchProfilePath();
206
+ const existing = await readText(path, "");
207
+ if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
208
+ throw new Error(
209
+ `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
210
+ );
211
+ }
212
+ await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
213
+ }
214
+
215
+ export async function configurePennyHarness(harness, {
216
+ apiKey,
217
+ gatewayBaseUrl,
218
+ subscriptionAuth = false,
219
+ models = null,
220
+ }) {
221
+ const config = await loadPennyConfig();
222
+ config.gateway_base_url = gatewayBaseUrl;
223
+ config.api_key = apiKey;
224
+ config.harnesses ||= {};
225
+ if (harness === "claude-code") {
226
+ await writeClaudeUserCommand();
227
+ await writeClaudeLaunchSettings();
228
+ config.harnesses.claude = {
229
+ enabled: true,
230
+ subscription_auth: Boolean(subscriptionAuth),
231
+ models: models || {},
232
+ };
233
+ } else if (harness === "codex") {
234
+ await writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth });
235
+ config.harnesses.codex = {
236
+ enabled: true,
237
+ subscription_auth: Boolean(subscriptionAuth),
238
+ };
239
+ } else {
240
+ throw new Error(`The penny command does not support ${harness}.`);
241
+ }
242
+ await savePennyConfig(config);
243
+ }
244
+
245
+ export async function setPennyHarnessEnabled(harness, enabled) {
246
+ const config = await loadPennyConfig();
247
+ const key = harness === "claude-code" ? "claude" : harness;
248
+ if (config.harnesses?.[key]) {
249
+ config.harnesses[key].enabled = Boolean(enabled);
250
+ await savePennyConfig(config);
251
+ }
252
+ }
253
+
254
+ export async function removePennyHarness(harness) {
255
+ const config = await loadPennyConfig();
256
+ const key = harness === "claude-code" ? "claude" : harness;
257
+ if (harness === "claude-code") {
258
+ await unlink(claudeLaunchSettingsPath()).catch((error) => {
259
+ if (error.code !== "ENOENT") throw error;
260
+ });
261
+ const command = await readText(claudeUserCommandPath(), "");
262
+ if (command.includes(CLAUDE_COMMAND_MARKER)) {
263
+ await unlink(claudeUserCommandPath()).catch((error) => {
264
+ if (error.code !== "ENOENT") throw error;
265
+ });
266
+ }
267
+ } else if (harness === "codex") {
268
+ const existing = await readText(codexLaunchProfilePath(), "");
269
+ if (existing.startsWith(CODEX_PROFILE_MARKER)) {
270
+ await unlink(codexLaunchProfilePath()).catch((error) => {
271
+ if (error.code !== "ENOENT") throw error;
272
+ });
273
+ }
274
+ }
275
+ if (config.harnesses) delete config.harnesses[key];
276
+ await savePennyConfig(config);
277
+ }
278
+
279
+ export async function hasConfiguredPennyHarnesses() {
280
+ const config = await loadPennyConfig();
281
+ return Object.keys(config.harnesses || {}).length > 0;
282
+ }
283
+
284
+ async function removeManagedPathBlock(rcPath) {
285
+ if (!rcPath) return;
286
+ const current = await readText(rcPath, "");
287
+ if (!current.includes(PATH_START)) return;
288
+ const escapedStart = PATH_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
289
+ const escapedEnd = PATH_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
290
+ const next = current
291
+ .replace(new RegExp(`(?:^|\\n)${escapedStart}\\n[\\s\\S]*?\\n${escapedEnd}\\n?`, "m"), "\n")
292
+ .replace(/^\n+/, "");
293
+ await atomicWrite(rcPath, next);
294
+ }
295
+
296
+ export async function removePennyRuntime() {
297
+ const config = await loadPennyConfig();
298
+ await removeManagedPathBlock(config.path_rc);
299
+ await unlink(pennyShimPath()).catch((error) => {
300
+ if (error.code !== "ENOENT") throw error;
301
+ });
302
+ await rm(pennyRuntimeDir(), { recursive: true, force: true });
303
+ await unlink(pennyConfigPath()).catch((error) => {
304
+ if (error.code !== "ENOENT") throw error;
305
+ });
306
+ }
307
+
308
+ function assertNoConflictingArgs(harness, args) {
309
+ if (harness === "claude" && args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
310
+ throw new Error("penny claude manages --settings; remove that flag or run native claude.");
311
+ }
312
+ if (harness === "codex" && args.some((arg) => arg === "--profile" || arg === "-p" || arg.startsWith("--profile="))) {
313
+ throw new Error("penny codex manages --profile; remove that flag or run native codex.");
314
+ }
315
+ }
316
+
317
+ export function buildPennyInvocation(harness, args, config) {
318
+ const item = config.harnesses?.[harness];
319
+ if (!item || !item.enabled) {
320
+ throw new Error(`PennyRouter is not enabled for ${harness}. Run npx pennyrouter install.`);
321
+ }
322
+ assertNoConflictingArgs(harness, args);
323
+ const env = { ...process.env, PENNYROUTER_API_KEY: config.api_key };
324
+ if (harness === "claude") {
325
+ const models = item.models || {};
326
+ env.ANTHROPIC_BASE_URL = config.gateway_base_url;
327
+ env.ANTHROPIC_AUTH_TOKEN = config.api_key;
328
+ delete env.ANTHROPIC_API_KEY;
329
+ if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haiku;
330
+ if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnet;
331
+ if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opus;
332
+ if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = models.fable;
333
+ return {
334
+ command: "claude",
335
+ args: ["--settings", claudeLaunchSettingsPath(), ...args],
336
+ env,
337
+ };
338
+ }
339
+ if (harness === "codex") {
340
+ return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
341
+ }
342
+ throw new Error(`Unknown Penny harness "${harness}". Try penny claude or penny codex.`);
343
+ }
344
+
345
+ async function waitForChild(child) {
346
+ return new Promise((resolvePromise, reject) => {
347
+ child.once("error", reject);
348
+ child.once("exit", (code, signal) => resolvePromise({ code, signal }));
349
+ });
350
+ }
351
+
352
+ export async function runPenny(argv = process.argv.slice(2)) {
353
+ const harness = argv[0];
354
+ if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
355
+ console.log("Usage: penny <claude|codex> [native arguments...]");
356
+ return 0;
357
+ }
358
+ if (!(["claude", "codex"].includes(harness))) {
359
+ throw new Error(`Unknown harness "${harness}". Try penny claude or penny codex.`);
360
+ }
361
+ const config = await loadPennyConfig();
362
+ const invocation = buildPennyInvocation(harness, argv.slice(1), config);
363
+ const child = spawn(invocation.command, invocation.args, {
364
+ cwd: process.cwd(),
365
+ env: invocation.env,
366
+ stdio: "inherit",
367
+ });
368
+ const result = await waitForChild(child);
369
+ if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
370
+ return result.code ?? 1;
371
+ }
372
+
373
+ export async function fileMode(path) {
374
+ return (await stat(path)).mode & 0o777;
375
+ }
@@ -0,0 +1,152 @@
1
+ import assert from "node:assert/strict";
2
+ import fs from "node:fs";
3
+ import os from "node:os";
4
+ import path from "node:path";
5
+
6
+ import {
7
+ buildPennyInvocation,
8
+ claudeLaunchSettingsPath,
9
+ claudeUserCommandPath,
10
+ codexLaunchProfilePath,
11
+ configurePennyHarness,
12
+ fileMode,
13
+ installPennyRuntime,
14
+ loadPennyConfig,
15
+ pennyConfigPath,
16
+ pennyRuntimeDir,
17
+ pennyShimPath,
18
+ removePennyHarness,
19
+ removePennyRuntime,
20
+ renderCodexLaunchProfile,
21
+ } from "./launch.js";
22
+
23
+ const ENV_KEYS = [
24
+ "HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_STATE_HOME", "CODEX_HOME", "PATH", "SHELL",
25
+ ];
26
+
27
+ async function main() {
28
+ if (!(["darwin", "linux"].includes(process.platform))) {
29
+ console.log("launch.test.js: skipped on unsupported platform");
30
+ return;
31
+ }
32
+
33
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pr-launch-test-"));
34
+ const previous = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
35
+ const home = path.join(root, "home");
36
+ fs.mkdirSync(path.join(home, ".claude"), { recursive: true });
37
+ fs.mkdirSync(path.join(home, ".codex"), { recursive: true });
38
+ const nativeClaude = `${JSON.stringify({
39
+ permissions: { allow: ["Read"] },
40
+ statusLine: { type: "command", command: "native-status" },
41
+ }, null, 2)}\n`;
42
+ const nativeCodex = 'model = "native-model"\n';
43
+ fs.writeFileSync(path.join(home, ".claude", "settings.json"), nativeClaude);
44
+ fs.writeFileSync(path.join(home, ".codex", "config.toml"), nativeCodex);
45
+ fs.writeFileSync(path.join(home, ".zshrc"), "# user setting\n");
46
+
47
+ Object.assign(process.env, {
48
+ HOME: home,
49
+ XDG_CONFIG_HOME: path.join(home, ".config"),
50
+ XDG_DATA_HOME: path.join(home, ".local", "share"),
51
+ XDG_STATE_HOME: path.join(home, ".local", "state"),
52
+ CODEX_HOME: path.join(home, ".codex"),
53
+ PATH: "/usr/bin:/bin",
54
+ SHELL: "/bin/zsh",
55
+ });
56
+
57
+ try {
58
+ const first = await installPennyRuntime();
59
+ const staleSkill = path.join(pennyRuntimeDir(), "claude-plugin", "skills", "penny", "SKILL.md");
60
+ fs.mkdirSync(path.dirname(staleSkill), { recursive: true });
61
+ fs.writeFileSync(staleSkill, "stale plugin file");
62
+ const second = await installPennyRuntime();
63
+ assert.equal(first.supported, true);
64
+ assert.equal(first.changed, true);
65
+ assert.equal(second.changed, false);
66
+ assert.equal(await fileMode(pennyShimPath()), 0o755);
67
+ assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
68
+ assert.ok(!fs.existsSync(staleSkill), "runtime upgrade removes stale plugin files");
69
+ const rc = fs.readFileSync(path.join(home, ".zshrc"), "utf8");
70
+ assert.equal((rc.match(/>>> pennyrouter PATH >>>/g) || []).length, 1);
71
+
72
+ await configurePennyHarness("claude-code", {
73
+ apiKey: "pr-super-secret",
74
+ gatewayBaseUrl: "https://gateway.example/",
75
+ subscriptionAuth: true,
76
+ models: { haiku: "Penny Haiku", sonnet: "Penny Sonnet", opus: "Penny Opus" },
77
+ });
78
+ await configurePennyHarness("codex", {
79
+ apiKey: "pr-super-secret",
80
+ gatewayBaseUrl: "https://gateway.example/",
81
+ subscriptionAuth: true,
82
+ });
83
+
84
+ assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
85
+ assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
86
+ assert.equal(await fileMode(pennyConfigPath()), 0o600);
87
+ assert.equal(await fileMode(claudeLaunchSettingsPath()), 0o600);
88
+ assert.equal(await fileMode(codexLaunchProfilePath()), 0o600);
89
+
90
+ const config = await loadPennyConfig();
91
+ assert.equal(config.api_key, "pr-super-secret");
92
+ const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
93
+ assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
94
+ assert.ok(!codexProfile.includes("pr-super-secret"));
95
+ assert.ok(!rc.includes("pr-super-secret"));
96
+
97
+ const claude = buildPennyInvocation("claude", ["--resume", "abc"], config);
98
+ assert.equal(claude.command, "claude");
99
+ assert.deepEqual(claude.args.slice(-2), ["--resume", "abc"]);
100
+ assert.ok(!claude.args.includes("--plugin-dir"));
101
+ assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
102
+ assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
103
+ assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Penny Opus");
104
+
105
+ const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
106
+ assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
107
+ assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
108
+ assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
109
+ assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
110
+
111
+ const command = fs.readFileSync(claudeUserCommandPath(), "utf8");
112
+ assert.ok(command.includes("@penny $ARGUMENTS"));
113
+
114
+ await removePennyHarness("claude-code");
115
+ assert.ok(!fs.existsSync(claudeUserCommandPath()));
116
+ fs.mkdirSync(path.dirname(claudeUserCommandPath()), { recursive: true });
117
+ fs.writeFileSync(claudeUserCommandPath(), "user-owned command\n");
118
+ await assert.rejects(configurePennyHarness("claude-code", {
119
+ apiKey: "pr-super-secret",
120
+ gatewayBaseUrl: "https://gateway.example/",
121
+ models: {},
122
+ }), /not managed by PennyRouter/);
123
+ assert.equal(fs.readFileSync(claudeUserCommandPath(), "utf8"), "user-owned command\n");
124
+ assert.ok(fs.existsSync(codexLaunchProfilePath()));
125
+ assert.ok(fs.existsSync(pennyShimPath()));
126
+ await removePennyHarness("codex");
127
+ await removePennyRuntime();
128
+ assert.ok(!fs.existsSync(pennyShimPath()));
129
+ assert.ok(!fs.existsSync(pennyRuntimeDir()));
130
+ assert.ok(!fs.existsSync(pennyConfigPath()));
131
+ assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
132
+
133
+ const paid = renderCodexLaunchProfile({
134
+ gatewayBaseUrl: "https://gateway.example/", subscriptionAuth: false,
135
+ });
136
+ assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
137
+ assert.ok(!paid.includes("requires_openai_auth"));
138
+ } finally {
139
+ for (const key of ENV_KEYS) {
140
+ if (previous[key] === undefined) delete process.env[key];
141
+ else process.env[key] = previous[key];
142
+ }
143
+ fs.rmSync(root, { recursive: true, force: true });
144
+ }
145
+
146
+ console.log("launch.test.js: all checks passed");
147
+ }
148
+
149
+ main().catch((error) => {
150
+ console.error(error);
151
+ process.exit(1);
152
+ });
package/src/session.js CHANGED
@@ -5,7 +5,7 @@ export function maskKey(key) {
5
5
  return `${key.slice(0, 3)}...${key.slice(-4)}`;
6
6
  }
7
7
 
8
- export async function createCliSession({ appUrl = APP_URL, harnesses, accountMode = "seed" }) {
8
+ export async function createCliSession({ appUrl = APP_URL, harnesses, accountMode = "existing" }) {
9
9
  const label = `${platformName()} / ${harnesses.join(",") || "PennyRouter"}`;
10
10
  const response = await fetch(`${appUrl}/api/cli/session`, {
11
11
  method: "POST",
@@ -118,7 +118,9 @@ export async function probeClaudeCodeGateway({ gatewayBaseUrl, apiKey }) {
118
118
  // Status alone is enough for a safe failure.
119
119
  }
120
120
  throw new Error(
121
- `Gateway compatibility check failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}. No Claude Code settings were changed.`,
121
+ `Gateway compatibility check failed: HTTP ${response.status}${detail ? ` — ${detail}` : ""}. `
122
+ + "The PennyRouter gateway rejected a modern Claude Code request; deploy/fix the gateway "
123
+ + "before retrying. No Claude Code settings were changed.",
122
124
  );
123
125
  }
124
126
  return true;
@@ -1,9 +1,21 @@
1
1
  import assert from "node:assert/strict";
2
- import { probeClaudeCodeGateway } from "./session.js";
2
+ import { createCliSession, probeClaudeCodeGateway } from "./session.js";
3
3
 
4
4
  const originalFetch = globalThis.fetch;
5
5
 
6
6
  try {
7
+ let sessionRequest;
8
+ globalThis.fetch = async (url, options) => {
9
+ sessionRequest = { url, options };
10
+ return {
11
+ ok: true,
12
+ json: async () => ({ session_id: "session-test" }),
13
+ };
14
+ };
15
+ await createCliSession({ appUrl: "https://app.example", harnesses: ["claude-code"] });
16
+ assert.equal(sessionRequest.url, "https://app.example/api/cli/session");
17
+ assert.equal(JSON.parse(sessionRequest.options.body).account_mode, "existing");
18
+
7
19
  let seen;
8
20
  globalThis.fetch = async (url, options) => {
9
21
  seen = { url, options };
@@ -29,4 +41,3 @@ try {
29
41
  } finally {
30
42
  globalThis.fetch = originalFetch;
31
43
  }
32
-