pennyrouter 0.1.21 → 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.21",
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/session.test.js && 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";
@@ -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
+ const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
379
+
338
380
  if (flags.dryRun) {
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("");
@@ -369,7 +412,12 @@ async function install(flags) {
369
412
 
370
413
  const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(flags, selected);
371
414
 
372
- 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
+
373
421
  const records = [];
374
422
  const failures = [];
375
423
  for (const harness of selected) {
@@ -379,38 +427,76 @@ async function install(flags) {
379
427
  const existingRecord = (manifest.installations || []).find(
380
428
  (candidate) => candidate.harness === harness.id,
381
429
  );
382
- 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
+
383
444
  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
445
  await storeAnthropicToken({
388
446
  gatewayBaseUrl,
389
447
  apiKey: claim.api_key,
390
448
  token: claudeAnthropicAuth.token,
391
449
  });
392
- console.log("Verifying modern Claude Code compatibility before changing global settings...");
450
+ }
451
+ if (harness.id === "claude-code") {
452
+ console.log("Verifying modern Claude Code compatibility before changing local settings...");
393
453
  await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
394
- record = await harness.installStoredToken({
395
- apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
396
- });
397
- record.anthropic_oauth = true;
398
- record.anthropic_token_stored = true;
399
- record.token_source = claudeAnthropicAuth.source;
400
- } 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 });
404
- }
405
- record = await harness.install({
454
+ }
455
+
456
+ if (supportsPennyCommand) {
457
+ await configurePennyHarness(harness.id, {
406
458
  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
459
  gatewayBaseUrl,
410
- plan,
411
- existingRecord,
460
+ subscriptionAuth: harness.id === "claude-code"
461
+ ? claudeSubscriptionAuth
462
+ : codexSubscriptionAuth,
463
+ models: harness.id === "claude-code" ? CLAUDE_CODE_MODELS : null,
412
464
  });
413
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;
414
500
  records.push(record);
415
501
  upsertManifestRecord(manifest, record);
416
502
  await saveManifest(manifest);
@@ -422,12 +508,65 @@ async function install(flags) {
422
508
  console.log("Installed PennyRouter.");
423
509
  for (const record of records) {
424
510
  const harness = HARNESS_BY_ID[record.harness];
425
- 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.");
426
524
  }
427
525
  console.log(`Manifest: ${statePath("installations.json")}`);
428
526
  reportFailures(failures);
429
527
  }
430
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
+
431
570
  async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
432
571
  if (!selected.some((harness) => harness.id === "claude-code")) return null;
433
572
  if (flags.noAnthropicAuth) return null;
@@ -526,13 +665,17 @@ async function disable(flags) {
526
665
  }
527
666
 
528
667
  await runHarnessJob(failures, record.harness, "disable", async () => {
529
- if (record.config_path) {
668
+ const ownsGlobalRouting = !record.launch_available || record.default_routing;
669
+ if (record.config_path && ownsGlobalRouting) {
530
670
  const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
531
671
  record.disabled_snapshot_path = snapshotPath;
532
672
  }
533
673
 
534
- if (typeof harness.disable === "function") await harness.disable(record);
535
- 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
+ }
536
679
 
537
680
  record.disabled = true;
538
681
  record.disabled_at = new Date().toISOString();
@@ -584,13 +727,17 @@ async function enable(flags) {
584
727
  }
585
728
 
586
729
  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");
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
+ }
594
741
  }
595
742
 
596
743
  delete record.disabled;
@@ -650,7 +797,8 @@ async function uninstall(flags) {
650
797
  } else if (record.anthropic_token_stored) {
651
798
  anthropicTokenNotice = true;
652
799
  }
653
- 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);
654
802
  console.log(`Removed ${record.harness}.`);
655
803
  });
656
804
  if (!ok) kept.push(record);
@@ -669,7 +817,8 @@ async function uninstall(flags) {
669
817
  } else if (record.anthropic_token_stored) {
670
818
  anthropicTokenNotice = true;
671
819
  }
672
- 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);
673
822
  console.log(`Removed ${record.harness}.`);
674
823
  });
675
824
  }
@@ -682,6 +831,7 @@ async function uninstall(flags) {
682
831
 
683
832
  manifest.installations = kept;
684
833
  await saveManifest(manifest);
834
+ if (!(await hasConfiguredPennyHarnesses())) await removePennyRuntime();
685
835
  reportFailures(failures);
686
836
  }
687
837
 
@@ -754,6 +904,11 @@ async function status() {
754
904
  if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
755
905
  if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
756
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
+ }
757
912
  if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
758
913
  }
759
914
  }
@@ -994,7 +1149,10 @@ function formatError(error) {
994
1149
  async function forgetStoredAnthropicToken(record) {
995
1150
  if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
996
1151
  try {
997
- 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;
998
1156
  if (!gatewayBaseUrl || !apiKey) return;
999
1157
  await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
1000
1158
  } catch {
@@ -1098,11 +1256,37 @@ function runningClaudeCodeProcesses() {
1098
1256
  .filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
1099
1257
  }
1100
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
+
1101
1285
  function printHelp() {
1102
1286
  console.log(`PennyRouter CLI
1103
1287
 
1104
1288
  Usage:
1105
- pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]] [--allow-running-claude]
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]
1106
1290
  pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
1107
1291
  pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
1108
1292
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
@@ -1111,7 +1295,8 @@ Usage:
1111
1295
 
1112
1296
  Examples:
1113
1297
  npx pennyrouter install
1114
- npx pennyrouter install --existing-account
1298
+ npx pennyrouter install --make-default
1299
+ npx pennyrouter install --penny-only
1115
1300
  npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
1116
1301
  npx pennyrouter auth anthropic
1117
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
@@ -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,339 @@
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
+
23
+ export function pennyConfigDir() {
24
+ return join(process.env.XDG_CONFIG_HOME || join(homeDir(), ".config"), "pennyrouter");
25
+ }
26
+
27
+ export function pennyConfigPath() {
28
+ return join(pennyConfigDir(), "config.json");
29
+ }
30
+
31
+ export function pennyDataDir() {
32
+ return join(process.env.XDG_DATA_HOME || join(homeDir(), ".local", "share"), "pennyrouter");
33
+ }
34
+
35
+ export function pennyRuntimeDir() {
36
+ return join(pennyDataDir(), "runtime");
37
+ }
38
+
39
+ export function pennyBinDir() {
40
+ return join(homeDir(), ".local", "bin");
41
+ }
42
+
43
+ export function pennyShimPath() {
44
+ return join(pennyBinDir(), "penny");
45
+ }
46
+
47
+ export function claudeLaunchSettingsPath() {
48
+ return join(pennyConfigDir(), "claude-settings.json");
49
+ }
50
+
51
+ export function codexLaunchProfilePath() {
52
+ const codexHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
53
+ return join(codexHome, "pennyrouter.config.toml");
54
+ }
55
+
56
+ export function claudePluginPath() {
57
+ return join(pennyRuntimeDir(), "claude-plugin");
58
+ }
59
+
60
+ async function privateWrite(path, text) {
61
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
62
+ await atomicWrite(path, text);
63
+ await chmod(path, 0o600);
64
+ }
65
+
66
+ export async function loadPennyConfig() {
67
+ return (await readJsonFile(pennyConfigPath(), null)) || {
68
+ version: CONFIG_VERSION,
69
+ harnesses: {},
70
+ };
71
+ }
72
+
73
+ export async function savePennyConfig(config) {
74
+ const next = { version: CONFIG_VERSION, ...config };
75
+ await privateWrite(pennyConfigPath(), `${JSON.stringify(next, null, 2)}\n`);
76
+ return next;
77
+ }
78
+
79
+ function packageRoot() {
80
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
81
+ }
82
+
83
+ async function copyRuntime() {
84
+ const source = packageRoot();
85
+ const destination = pennyRuntimeDir();
86
+ await mkdir(destination, { recursive: true, mode: 0o700 });
87
+ for (const entry of ["bin", "src", "claude-plugin"]) {
88
+ await cp(join(source, entry), join(destination, entry), {
89
+ recursive: true,
90
+ force: true,
91
+ });
92
+ }
93
+ await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
94
+ }
95
+
96
+ async function writeShim() {
97
+ await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
98
+ const entry = join(pennyRuntimeDir(), "bin", "penny.js").replaceAll('"', '\\"');
99
+ await writeFile(pennyShimPath(), `#!/bin/sh\nexec node "${entry}" "$@"\n`, {
100
+ encoding: "utf8",
101
+ mode: 0o755,
102
+ });
103
+ await chmod(pennyShimPath(), 0o755);
104
+ }
105
+
106
+ function pathContains(dir) {
107
+ return String(process.env.PATH || "").split(":").some((candidate) => resolve(candidate) === resolve(dir));
108
+ }
109
+
110
+ function shellRcPath() {
111
+ const shell = basename(process.env.SHELL || "");
112
+ if (shell === "zsh") return join(homeDir(), ".zshrc");
113
+ if (shell === "bash") return join(homeDir(), ".bashrc");
114
+ return null;
115
+ }
116
+
117
+ export async function ensurePennyOnPath({ updatePath = true } = {}) {
118
+ if (pathContains(pennyBinDir())) return { available: true, changed: false, rc_path: null };
119
+ if (!updatePath) return { available: false, changed: false, rc_path: null };
120
+ const rcPath = shellRcPath();
121
+ if (!rcPath) return { available: false, changed: false, rc_path: null };
122
+ const current = await readText(rcPath, "");
123
+ if (!current.includes(PATH_START)) {
124
+ const block = `${PATH_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_END}`;
125
+ const prefix = current && !current.endsWith("\n") ? "\n" : "";
126
+ await atomicWrite(rcPath, `${current}${prefix}${block}\n`);
127
+ }
128
+ return { available: true, changed: !current.includes(PATH_START), rc_path: rcPath };
129
+ }
130
+
131
+ export async function installPennyRuntime({ updatePath = true } = {}) {
132
+ if (!(["darwin", "linux"].includes(process.platform))) {
133
+ return { supported: false, available: false, changed: false, rc_path: null };
134
+ }
135
+ await copyRuntime();
136
+ await writeShim();
137
+ const path = await ensurePennyOnPath({ updatePath });
138
+ const config = await loadPennyConfig();
139
+ if (path.rc_path) {
140
+ config.path_rc = path.rc_path;
141
+ await savePennyConfig(config);
142
+ }
143
+ return { supported: true, ...path, shim_path: pennyShimPath() };
144
+ }
145
+
146
+ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
147
+ const authLines = subscriptionAuth
148
+ ? [
149
+ "requires_openai_auth = true",
150
+ 'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }',
151
+ ]
152
+ : ['env_key = "PENNYROUTER_API_KEY"'];
153
+ return [
154
+ CODEX_PROFILE_MARKER,
155
+ 'model_provider = "pennyrouter"',
156
+ "",
157
+ "[model_providers.pennyrouter]",
158
+ 'name = "PennyRouter"',
159
+ `base_url = ${JSON.stringify(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`)}`,
160
+ 'wire_api = "responses"',
161
+ "supports_websockets = false",
162
+ ...authLines,
163
+ "",
164
+ ].join("\n");
165
+ }
166
+
167
+ async function writeClaudeLaunchSettings() {
168
+ const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
169
+ const overlay = { disableClaudeAiConnectors: true };
170
+ if (native.statusLine) overlay.statusLine = native.statusLine;
171
+ await configureClaudeCodeStatusline(overlay);
172
+ await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
173
+ }
174
+
175
+ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
176
+ const path = codexLaunchProfilePath();
177
+ const existing = await readText(path, "");
178
+ if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
179
+ throw new Error(
180
+ `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
181
+ );
182
+ }
183
+ await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
184
+ }
185
+
186
+ export async function configurePennyHarness(harness, {
187
+ apiKey,
188
+ gatewayBaseUrl,
189
+ subscriptionAuth = false,
190
+ models = null,
191
+ }) {
192
+ const config = await loadPennyConfig();
193
+ config.gateway_base_url = gatewayBaseUrl;
194
+ config.api_key = apiKey;
195
+ config.harnesses ||= {};
196
+ if (harness === "claude-code") {
197
+ await writeClaudeLaunchSettings();
198
+ config.harnesses.claude = {
199
+ enabled: true,
200
+ subscription_auth: Boolean(subscriptionAuth),
201
+ models: models || {},
202
+ };
203
+ } else if (harness === "codex") {
204
+ await writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth });
205
+ config.harnesses.codex = {
206
+ enabled: true,
207
+ subscription_auth: Boolean(subscriptionAuth),
208
+ };
209
+ } else {
210
+ throw new Error(`The penny command does not support ${harness}.`);
211
+ }
212
+ await savePennyConfig(config);
213
+ }
214
+
215
+ export async function setPennyHarnessEnabled(harness, enabled) {
216
+ const config = await loadPennyConfig();
217
+ const key = harness === "claude-code" ? "claude" : harness;
218
+ if (config.harnesses?.[key]) {
219
+ config.harnesses[key].enabled = Boolean(enabled);
220
+ await savePennyConfig(config);
221
+ }
222
+ }
223
+
224
+ export async function removePennyHarness(harness) {
225
+ const config = await loadPennyConfig();
226
+ const key = harness === "claude-code" ? "claude" : harness;
227
+ if (harness === "claude-code") {
228
+ await unlink(claudeLaunchSettingsPath()).catch((error) => {
229
+ if (error.code !== "ENOENT") throw error;
230
+ });
231
+ } else if (harness === "codex") {
232
+ const existing = await readText(codexLaunchProfilePath(), "");
233
+ if (existing.startsWith(CODEX_PROFILE_MARKER)) {
234
+ await unlink(codexLaunchProfilePath()).catch((error) => {
235
+ if (error.code !== "ENOENT") throw error;
236
+ });
237
+ }
238
+ }
239
+ if (config.harnesses) delete config.harnesses[key];
240
+ await savePennyConfig(config);
241
+ }
242
+
243
+ export async function hasConfiguredPennyHarnesses() {
244
+ const config = await loadPennyConfig();
245
+ return Object.keys(config.harnesses || {}).length > 0;
246
+ }
247
+
248
+ async function removeManagedPathBlock(rcPath) {
249
+ if (!rcPath) return;
250
+ const current = await readText(rcPath, "");
251
+ if (!current.includes(PATH_START)) return;
252
+ const escapedStart = PATH_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
253
+ const escapedEnd = PATH_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
254
+ const next = current
255
+ .replace(new RegExp(`(?:^|\\n)${escapedStart}\\n[\\s\\S]*?\\n${escapedEnd}\\n?`, "m"), "\n")
256
+ .replace(/^\n+/, "");
257
+ await atomicWrite(rcPath, next);
258
+ }
259
+
260
+ export async function removePennyRuntime() {
261
+ const config = await loadPennyConfig();
262
+ await removeManagedPathBlock(config.path_rc);
263
+ await unlink(pennyShimPath()).catch((error) => {
264
+ if (error.code !== "ENOENT") throw error;
265
+ });
266
+ await rm(pennyRuntimeDir(), { recursive: true, force: true });
267
+ await unlink(pennyConfigPath()).catch((error) => {
268
+ if (error.code !== "ENOENT") throw error;
269
+ });
270
+ }
271
+
272
+ function assertNoConflictingArgs(harness, args) {
273
+ if (harness === "claude" && args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
274
+ throw new Error("penny claude manages --settings; remove that flag or run native claude.");
275
+ }
276
+ if (harness === "codex" && args.some((arg) => arg === "--profile" || arg === "-p" || arg.startsWith("--profile="))) {
277
+ throw new Error("penny codex manages --profile; remove that flag or run native codex.");
278
+ }
279
+ }
280
+
281
+ export function buildPennyInvocation(harness, args, config) {
282
+ const item = config.harnesses?.[harness];
283
+ if (!item || !item.enabled) {
284
+ throw new Error(`PennyRouter is not enabled for ${harness}. Run npx pennyrouter install.`);
285
+ }
286
+ assertNoConflictingArgs(harness, args);
287
+ const env = { ...process.env, PENNYROUTER_API_KEY: config.api_key };
288
+ if (harness === "claude") {
289
+ const models = item.models || {};
290
+ env.ANTHROPIC_BASE_URL = config.gateway_base_url;
291
+ env.ANTHROPIC_AUTH_TOKEN = config.api_key;
292
+ delete env.ANTHROPIC_API_KEY;
293
+ if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haiku;
294
+ if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnet;
295
+ if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opus;
296
+ if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = models.fable;
297
+ return {
298
+ command: "claude",
299
+ args: ["--settings", claudeLaunchSettingsPath(), "--plugin-dir", claudePluginPath(), ...args],
300
+ env,
301
+ };
302
+ }
303
+ if (harness === "codex") {
304
+ return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
305
+ }
306
+ throw new Error(`Unknown Penny harness "${harness}". Try penny claude or penny codex.`);
307
+ }
308
+
309
+ async function waitForChild(child) {
310
+ return new Promise((resolvePromise, reject) => {
311
+ child.once("error", reject);
312
+ child.once("exit", (code, signal) => resolvePromise({ code, signal }));
313
+ });
314
+ }
315
+
316
+ export async function runPenny(argv = process.argv.slice(2)) {
317
+ const harness = argv[0];
318
+ if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
319
+ console.log("Usage: penny <claude|codex> [native arguments...]");
320
+ return 0;
321
+ }
322
+ if (!(["claude", "codex"].includes(harness))) {
323
+ throw new Error(`Unknown harness "${harness}". Try penny claude or penny codex.`);
324
+ }
325
+ const config = await loadPennyConfig();
326
+ const invocation = buildPennyInvocation(harness, argv.slice(1), config);
327
+ const child = spawn(invocation.command, invocation.args, {
328
+ cwd: process.cwd(),
329
+ env: invocation.env,
330
+ stdio: "inherit",
331
+ });
332
+ const result = await waitForChild(child);
333
+ if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
334
+ return result.code ?? 1;
335
+ }
336
+
337
+ export async function fileMode(path) {
338
+ return (await stat(path)).mode & 0o777;
339
+ }
@@ -0,0 +1,140 @@
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
+ claudePluginPath,
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 second = await installPennyRuntime();
60
+ assert.equal(first.supported, true);
61
+ assert.equal(first.changed, true);
62
+ assert.equal(second.changed, false);
63
+ assert.equal(await fileMode(pennyShimPath()), 0o755);
64
+ assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
65
+ assert.ok(fs.existsSync(path.join(claudePluginPath(), ".claude-plugin", "plugin.json")));
66
+ const rc = fs.readFileSync(path.join(home, ".zshrc"), "utf8");
67
+ assert.equal((rc.match(/>>> pennyrouter PATH >>>/g) || []).length, 1);
68
+
69
+ await configurePennyHarness("claude-code", {
70
+ apiKey: "pr-super-secret",
71
+ gatewayBaseUrl: "https://gateway.example/",
72
+ subscriptionAuth: true,
73
+ models: { haiku: "Penny Haiku", sonnet: "Penny Sonnet", opus: "Penny Opus" },
74
+ });
75
+ await configurePennyHarness("codex", {
76
+ apiKey: "pr-super-secret",
77
+ gatewayBaseUrl: "https://gateway.example/",
78
+ subscriptionAuth: true,
79
+ });
80
+
81
+ assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
82
+ assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
83
+ assert.equal(await fileMode(pennyConfigPath()), 0o600);
84
+ assert.equal(await fileMode(claudeLaunchSettingsPath()), 0o600);
85
+ assert.equal(await fileMode(codexLaunchProfilePath()), 0o600);
86
+
87
+ const config = await loadPennyConfig();
88
+ assert.equal(config.api_key, "pr-super-secret");
89
+ const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
90
+ assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
91
+ assert.ok(!codexProfile.includes("pr-super-secret"));
92
+ assert.ok(!rc.includes("pr-super-secret"));
93
+
94
+ const claude = buildPennyInvocation("claude", ["--resume", "abc"], config);
95
+ assert.equal(claude.command, "claude");
96
+ assert.deepEqual(claude.args.slice(-2), ["--resume", "abc"]);
97
+ assert.ok(claude.args.includes("--plugin-dir"));
98
+ assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
99
+ assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
100
+ assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Penny Opus");
101
+
102
+ const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
103
+ assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
104
+ assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
105
+ assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
106
+ assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
107
+
108
+ const skill = fs.readFileSync(path.join(claudePluginPath(), "skills", "penny", "SKILL.md"), "utf8");
109
+ assert.ok(skill.includes("@penny $ARGUMENTS"));
110
+
111
+ await removePennyHarness("claude-code");
112
+ assert.ok(fs.existsSync(codexLaunchProfilePath()));
113
+ assert.ok(fs.existsSync(pennyShimPath()));
114
+ await removePennyHarness("codex");
115
+ await removePennyRuntime();
116
+ assert.ok(!fs.existsSync(pennyShimPath()));
117
+ assert.ok(!fs.existsSync(pennyRuntimeDir()));
118
+ assert.ok(!fs.existsSync(pennyConfigPath()));
119
+ assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
120
+
121
+ const paid = renderCodexLaunchProfile({
122
+ gatewayBaseUrl: "https://gateway.example/", subscriptionAuth: false,
123
+ });
124
+ assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
125
+ assert.ok(!paid.includes("requires_openai_auth"));
126
+ } finally {
127
+ for (const key of ENV_KEYS) {
128
+ if (previous[key] === undefined) delete process.env[key];
129
+ else process.env[key] = previous[key];
130
+ }
131
+ fs.rmSync(root, { recursive: true, force: true });
132
+ }
133
+
134
+ console.log("launch.test.js: all checks passed");
135
+ }
136
+
137
+ main().catch((error) => {
138
+ console.error(error);
139
+ process.exit(1);
140
+ });
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
-