pennyrouter 0.2.0 → 0.2.2

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
@@ -22,8 +22,9 @@ npx pennyrouter status
22
22
  Installation opens a browser and requires login to the PennyRouter account that should own
23
23
  the new machine key. CLI installs no longer create anonymous ghost accounts.
24
24
 
25
- With no `--harness` flag, the interactive installer shows a checkbox list. Tools with
26
- detected configs start checked; press Enter to accept or type tool IDs to toggle them.
25
+ With no `--harness` flag, the interactive installer shows Claude Code and Codex. Detected
26
+ tools start checked. The additional legacy integrations remain available through an explicit
27
+ `--harness <id>` selection or `--all`.
27
28
 
28
29
  When Claude Code is selected, the installer also offers an optional Claude Pro/Max
29
30
  subscription-auth step. Users can skip it during install and enable it later with
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -20,7 +20,6 @@
20
20
  "files": [
21
21
  "bin",
22
22
  "src",
23
- "claude-plugin",
24
23
  "README.md"
25
24
  ],
26
25
  "scripts": {
package/src/cli.js CHANGED
@@ -59,6 +59,7 @@ const HARNESS_BY_ID = {
59
59
  };
60
60
 
61
61
  const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
62
+ const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex"];
62
63
 
63
64
  export async function main(argv = process.argv.slice(2)) {
64
65
  const { command, flags } = parseArgs(argv);
@@ -375,9 +376,9 @@ async function install(flags) {
375
376
  console.log("");
376
377
 
377
378
  const manifest = await loadManifest();
378
- const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
379
379
 
380
380
  if (flags.dryRun) {
381
+ const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
381
382
  for (const harness of selected) {
382
383
  const explicit = ["claude-code", "codex"].includes(harness.id) && process.platform !== "win32";
383
384
  const command = harness.id === "claude-code" ? "penny claude" : "penny codex";
@@ -411,6 +412,25 @@ async function install(flags) {
411
412
  console.log("");
412
413
 
413
414
  const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(flags, selected);
415
+ const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
416
+
417
+ if (selected.some((harness) => harness.id === "claude-code")) {
418
+ if (claudeAnthropicAuth) {
419
+ await storeAnthropicToken({
420
+ gatewayBaseUrl,
421
+ apiKey: claim.api_key,
422
+ token: claudeAnthropicAuth.token,
423
+ });
424
+ }
425
+ console.log("Verifying modern Claude Code compatibility before changing local settings...");
426
+ await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
427
+ console.log("");
428
+ }
429
+
430
+ // Keep this as the final interactive choice: users first authorize their account,
431
+ // optionally connect their subscription, and pass compatibility checks. Then they
432
+ // choose the visible command behavior with all relevant context in front of them.
433
+ const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
414
434
 
415
435
  const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
416
436
  let runtimeResult = null;
@@ -423,7 +443,6 @@ async function install(flags) {
423
443
  for (const harness of selected) {
424
444
  await runHarnessJob(failures, harness.id, "install", async () => {
425
445
  const plan = await harness.planInstall();
426
- const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
427
446
  const existingRecord = (manifest.installations || []).find(
428
447
  (candidate) => candidate.harness === harness.id,
429
448
  );
@@ -441,18 +460,6 @@ async function install(flags) {
441
460
  const claudeSubscriptionAuth = harness.id === "claude-code"
442
461
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
443
462
 
444
- if (harness.id === "claude-code" && claudeAnthropicAuth) {
445
- await storeAnthropicToken({
446
- gatewayBaseUrl,
447
- apiKey: claim.api_key,
448
- token: claudeAnthropicAuth.token,
449
- });
450
- }
451
- if (harness.id === "claude-code") {
452
- console.log("Verifying modern Claude Code compatibility before changing local settings...");
453
- await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
454
- }
455
-
456
463
  if (supportsPennyCommand) {
457
464
  await configurePennyHarness(harness.id, {
458
465
  apiKey: claim.api_key,
@@ -505,14 +512,22 @@ async function install(flags) {
505
512
 
506
513
  await saveManifest(manifest);
507
514
  console.log("");
508
- console.log("Installed PennyRouter.");
515
+ console.log(records.length > 0
516
+ ? "PennyRouter installation complete."
517
+ : "PennyRouter was not installed on any selected tool.");
518
+ console.log(`Manifest: ${statePath("installations.json")}`);
509
519
  for (const record of records) {
510
520
  const harness = HARNESS_BY_ID[record.harness];
511
521
  if (record.launch_available) {
512
522
  const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
513
523
  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"}`);
524
+ const harnessName = record.harness === "claude-code" ? "Claude Code" : "Codex";
525
+ const commandWidth = Math.max(command.length, native.length) + 2;
526
+ console.log("");
527
+ console.log(color(`How to Run ${harnessName}`, "bold"));
528
+ console.log("");
529
+ console.log(` ${color(command.padEnd(commandWidth), "bold")}→ ${harnessName} with PennyRouter`);
530
+ console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName} with PennyRouter (default routing enabled)` : `${harnessName} native`}`);
516
531
  } else {
517
532
  console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
518
533
  }
@@ -522,7 +537,7 @@ async function install(flags) {
522
537
  } else if (runtimeResult?.changed) {
523
538
  console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
524
539
  }
525
- console.log(`Manifest: ${statePath("installations.json")}`);
540
+ console.log("");
526
541
  reportFailures(failures);
527
542
  }
528
543
 
@@ -552,15 +567,31 @@ async function chooseDefaultRouting(flags, selected, manifest) {
552
567
  }
553
568
  const native = harness.id === "claude-code" ? "claude" : "codex";
554
569
  const change = await confirm(
555
- `PennyRouter currently runs when you type \`${native}\`. Change it to run only with \`penny ${native}\`?`,
570
+ `PennyRouter currently runs when you type ${color(native, "bold")}. Change it to run only with ${color(`penny ${native}`, "bold")}?`,
556
571
  );
557
572
  out.set(harness.id, !change);
558
573
  continue;
559
574
  }
560
575
  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`?");
576
+ if (flags.dryRun || flags.yes || !input.isTTY || !output.isTTY) {
577
+ newInstallChoice = false;
578
+ } else {
579
+ for (const item of launchable) {
580
+ const native = item.id === "claude-code" ? "claude" : "codex";
581
+ const harnessName = item.id === "claude-code" ? "Claude Code" : "Codex";
582
+ const command = `penny ${native}`;
583
+ const commandWidth = Math.max(command.length, native.length) + 2;
584
+ console.log("");
585
+ console.log(color(`How to Run ${harnessName}`, "bold"));
586
+ console.log("");
587
+ console.log(` ${color(command.padEnd(commandWidth), "bold")}→ ${harnessName} with PennyRouter`);
588
+ console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${harnessName} native (recommended)`);
589
+ }
590
+ console.log("");
591
+ const bare = launchable.map((item) => item.id === "claude-code" ? "claude" : "codex");
592
+ const label = bare.length === 1 ? bare[0] : `${bare.slice(0, -1).join(", ")} and ${bare.at(-1)}`;
593
+ newInstallChoice = await confirm(`Also route ${color(label, "bold")} through PennyRouter by default?`);
594
+ }
564
595
  }
565
596
  out.set(harness.id, newInstallChoice);
566
597
  }
@@ -915,15 +946,16 @@ async function status() {
915
946
 
916
947
  async function selectHarnesses(flags) {
917
948
  const requested = flags.all ? Object.keys(HARNESS_BY_ID) : parseHarnessList(flags.harness);
918
- const detected = [];
919
- for (const harness of Object.values(HARNESS_BY_ID)) {
920
- if (await safeDetect(harness)) detected.push(harness);
921
- }
922
-
923
949
  if (requested.length > 0) {
924
950
  return requested.map((id) => HARNESS_BY_ID[id]).filter(Boolean);
925
951
  }
926
952
 
953
+ const detected = [];
954
+ for (const id of DEFAULT_INSTALL_HARNESS_IDS) {
955
+ const harness = HARNESS_BY_ID[id];
956
+ if (await safeDetect(harness)) detected.push(harness);
957
+ }
958
+
927
959
  if (flags.yes) return detected;
928
960
 
929
961
  return promptHarnessCheckboxes(detected);
@@ -1049,7 +1081,7 @@ async function promptHarnessCheckboxesByText(harnesses, selectedIds) {
1049
1081
  }
1050
1082
 
1051
1083
  function orderedHarnessesForPrompt(detectedIds) {
1052
- return Object.values(HARNESS_BY_ID).sort((left, right) => {
1084
+ return DEFAULT_INSTALL_HARNESS_IDS.map((id) => HARNESS_BY_ID[id]).sort((left, right) => {
1053
1085
  const leftDetected = detectedIds.has(left.id);
1054
1086
  const rightDetected = detectedIds.has(right.id);
1055
1087
  if (leftDetected !== rightDetected) return leftDetected ? -1 : 1;
package/src/launch.js CHANGED
@@ -19,6 +19,7 @@ const CONFIG_VERSION = 1;
19
19
  const PATH_START = "# >>> pennyrouter PATH >>>";
20
20
  const PATH_END = "# <<< pennyrouter PATH <<<";
21
21
  const CODEX_PROFILE_MARKER = "# PennyRouter per-command profile (managed; no secrets)";
22
+ const CLAUDE_COMMAND_MARKER = "<!-- PennyRouter managed Claude command -->";
22
23
 
23
24
  export function pennyConfigDir() {
24
25
  return join(process.env.XDG_CONFIG_HOME || join(homeDir(), ".config"), "pennyrouter");
@@ -53,8 +54,12 @@ export function codexLaunchProfilePath() {
53
54
  return join(codexHome, "pennyrouter.config.toml");
54
55
  }
55
56
 
56
- export function claudePluginPath() {
57
- return join(pennyRuntimeDir(), "claude-plugin");
57
+ export function claudeUserSkillPath() {
58
+ return join(homeDir(), ".claude", "skills", "penny", "SKILL.md");
59
+ }
60
+
61
+ function legacyClaudeUserCommandPath() {
62
+ return join(homeDir(), ".claude", "commands", "penny.md");
58
63
  }
59
64
 
60
65
  async function privateWrite(path, text) {
@@ -84,12 +89,19 @@ async function copyRuntime() {
84
89
  const source = packageRoot();
85
90
  const destination = pennyRuntimeDir();
86
91
  await mkdir(destination, { recursive: true, mode: 0o700 });
87
- for (const entry of ["bin", "src", "claude-plugin"]) {
92
+ for (const entry of ["bin", "src"]) {
93
+ // Runtime upgrades must not merge with the previous package. In particular,
94
+ // 0.2.1 moved /penny from a skill to a command; leaving the old skill behind
95
+ // makes Claude discover two incompatible definitions.
96
+ await rm(join(destination, entry), { recursive: true, force: true });
88
97
  await cp(join(source, entry), join(destination, entry), {
89
98
  recursive: true,
90
99
  force: true,
91
100
  });
92
101
  }
102
+ // Remove plugin assets used by 0.2.0/0.2.1. /penny is now a managed personal
103
+ // command so it can use the exact unnamespaced name in every Claude session.
104
+ await rm(join(destination, "claude-plugin"), { recursive: true, force: true });
93
105
  await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
94
106
  }
95
107
 
@@ -172,6 +184,36 @@ async function writeClaudeLaunchSettings() {
172
184
  await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
173
185
  }
174
186
 
187
+ async function writeClaudeUserSkill() {
188
+ const path = claudeUserSkillPath();
189
+ const existing = await readText(path, "");
190
+ if (existing.trim() && !existing.includes(CLAUDE_COMMAND_MARKER)) {
191
+ throw new Error(
192
+ `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
193
+ );
194
+ }
195
+ await mkdir(dirname(path), { recursive: true });
196
+ await atomicWrite(path, [
197
+ "---",
198
+ "name: penny",
199
+ "description: PennyRouter commands and help.",
200
+ "argument-hint: <command and arguments>",
201
+ "disable-model-invocation: true",
202
+ "---",
203
+ CLAUDE_COMMAND_MARKER,
204
+ "",
205
+ "@penny $ARGUMENTS",
206
+ "",
207
+ ].join("\n"));
208
+ const legacyPath = legacyClaudeUserCommandPath();
209
+ const legacy = await readText(legacyPath, "");
210
+ if (legacy.includes(CLAUDE_COMMAND_MARKER)) {
211
+ await unlink(legacyPath).catch((error) => {
212
+ if (error.code !== "ENOENT") throw error;
213
+ });
214
+ }
215
+ }
216
+
175
217
  async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
176
218
  const path = codexLaunchProfilePath();
177
219
  const existing = await readText(path, "");
@@ -194,6 +236,7 @@ export async function configurePennyHarness(harness, {
194
236
  config.api_key = apiKey;
195
237
  config.harnesses ||= {};
196
238
  if (harness === "claude-code") {
239
+ await writeClaudeUserSkill();
197
240
  await writeClaudeLaunchSettings();
198
241
  config.harnesses.claude = {
199
242
  enabled: true,
@@ -228,6 +271,13 @@ export async function removePennyHarness(harness) {
228
271
  await unlink(claudeLaunchSettingsPath()).catch((error) => {
229
272
  if (error.code !== "ENOENT") throw error;
230
273
  });
274
+ for (const path of [claudeUserSkillPath(), legacyClaudeUserCommandPath()]) {
275
+ const command = await readText(path, "");
276
+ if (!command.includes(CLAUDE_COMMAND_MARKER)) continue;
277
+ await unlink(path).catch((error) => {
278
+ if (error.code !== "ENOENT") throw error;
279
+ });
280
+ }
231
281
  } else if (harness === "codex") {
232
282
  const existing = await readText(codexLaunchProfilePath(), "");
233
283
  if (existing.startsWith(CODEX_PROFILE_MARKER)) {
@@ -296,7 +346,7 @@ export function buildPennyInvocation(harness, args, config) {
296
346
  if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = models.fable;
297
347
  return {
298
348
  command: "claude",
299
- args: ["--settings", claudeLaunchSettingsPath(), "--plugin-dir", claudePluginPath(), ...args],
349
+ args: ["--settings", claudeLaunchSettingsPath(), ...args],
300
350
  env,
301
351
  };
302
352
  }
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import {
7
7
  buildPennyInvocation,
8
8
  claudeLaunchSettingsPath,
9
- claudePluginPath,
9
+ claudeUserSkillPath,
10
10
  codexLaunchProfilePath,
11
11
  configurePennyHarness,
12
12
  fileMode,
@@ -56,13 +56,16 @@ async function main() {
56
56
 
57
57
  try {
58
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");
59
62
  const second = await installPennyRuntime();
60
63
  assert.equal(first.supported, true);
61
64
  assert.equal(first.changed, true);
62
65
  assert.equal(second.changed, false);
63
66
  assert.equal(await fileMode(pennyShimPath()), 0o755);
64
67
  assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
65
- assert.ok(fs.existsSync(path.join(claudePluginPath(), ".claude-plugin", "plugin.json")));
68
+ assert.ok(!fs.existsSync(staleSkill), "runtime upgrade removes stale plugin files");
66
69
  const rc = fs.readFileSync(path.join(home, ".zshrc"), "utf8");
67
70
  assert.equal((rc.match(/>>> pennyrouter PATH >>>/g) || []).length, 1);
68
71
 
@@ -94,7 +97,7 @@ async function main() {
94
97
  const claude = buildPennyInvocation("claude", ["--resume", "abc"], config);
95
98
  assert.equal(claude.command, "claude");
96
99
  assert.deepEqual(claude.args.slice(-2), ["--resume", "abc"]);
97
- assert.ok(claude.args.includes("--plugin-dir"));
100
+ assert.ok(!claude.args.includes("--plugin-dir"));
98
101
  assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
99
102
  assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
100
103
  assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Penny Opus");
@@ -105,10 +108,20 @@ async function main() {
105
108
  assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
106
109
  assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
107
110
 
108
- const skill = fs.readFileSync(path.join(claudePluginPath(), "skills", "penny", "SKILL.md"), "utf8");
109
- assert.ok(skill.includes("@penny $ARGUMENTS"));
111
+ const command = fs.readFileSync(claudeUserSkillPath(), "utf8");
112
+ assert.ok(command.includes("@penny $ARGUMENTS"));
113
+ assert.ok(command.includes("name: penny"));
110
114
 
111
115
  await removePennyHarness("claude-code");
116
+ assert.ok(!fs.existsSync(claudeUserSkillPath()));
117
+ fs.mkdirSync(path.dirname(claudeUserSkillPath()), { recursive: true });
118
+ fs.writeFileSync(claudeUserSkillPath(), "user-owned command\n");
119
+ await assert.rejects(configurePennyHarness("claude-code", {
120
+ apiKey: "pr-super-secret",
121
+ gatewayBaseUrl: "https://gateway.example/",
122
+ models: {},
123
+ }), /not managed by PennyRouter/);
124
+ assert.equal(fs.readFileSync(claudeUserSkillPath(), "utf8"), "user-owned command\n");
112
125
  assert.ok(fs.existsSync(codexLaunchProfilePath()));
113
126
  assert.ok(fs.existsSync(pennyShimPath()));
114
127
  await removePennyHarness("codex");
@@ -1,8 +0,0 @@
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
- }
@@ -1,8 +0,0 @@
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