pennyrouter 0.2.0 → 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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.2.0",
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": {
@@ -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
@@ -375,9 +375,9 @@ async function install(flags) {
375
375
  console.log("");
376
376
 
377
377
  const manifest = await loadManifest();
378
- const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
379
378
 
380
379
  if (flags.dryRun) {
380
+ const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
381
381
  for (const harness of selected) {
382
382
  const explicit = ["claude-code", "codex"].includes(harness.id) && process.platform !== "win32";
383
383
  const command = harness.id === "claude-code" ? "penny claude" : "penny codex";
@@ -411,6 +411,25 @@ async function install(flags) {
411
411
  console.log("");
412
412
 
413
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);
414
433
 
415
434
  const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
416
435
  let runtimeResult = null;
@@ -423,7 +442,6 @@ async function install(flags) {
423
442
  for (const harness of selected) {
424
443
  await runHarnessJob(failures, harness.id, "install", async () => {
425
444
  const plan = await harness.planInstall();
426
- const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
427
445
  const existingRecord = (manifest.installations || []).find(
428
446
  (candidate) => candidate.harness === harness.id,
429
447
  );
@@ -441,18 +459,6 @@ async function install(flags) {
441
459
  const claudeSubscriptionAuth = harness.id === "claude-code"
442
460
  && Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
443
461
 
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
462
  if (supportsPennyCommand) {
457
463
  await configurePennyHarness(harness.id, {
458
464
  apiKey: claim.api_key,
@@ -505,14 +511,22 @@ async function install(flags) {
505
511
 
506
512
  await saveManifest(manifest);
507
513
  console.log("");
508
- 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")}`);
509
518
  for (const record of records) {
510
519
  const harness = HARNESS_BY_ID[record.harness];
511
520
  if (record.launch_available) {
512
521
  const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
513
522
  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"}`);
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`}`);
516
530
  } else {
517
531
  console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
518
532
  }
@@ -522,7 +536,7 @@ async function install(flags) {
522
536
  } else if (runtimeResult?.changed) {
523
537
  console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
524
538
  }
525
- console.log(`Manifest: ${statePath("installations.json")}`);
539
+ console.log("");
526
540
  reportFailures(failures);
527
541
  }
528
542
 
@@ -552,15 +566,31 @@ async function chooseDefaultRouting(flags, selected, manifest) {
552
566
  }
553
567
  const native = harness.id === "claude-code" ? "claude" : "codex";
554
568
  const change = await confirm(
555
- `PennyRouter currently runs when you type \`${native}\`. Change it to run only with \`penny ${native}\`?`,
569
+ `PennyRouter currently runs when you type ${color(native, "bold")}. Change it to run only with ${color(`penny ${native}`, "bold")}?`,
556
570
  );
557
571
  out.set(harness.id, !change);
558
572
  continue;
559
573
  }
560
574
  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`?");
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
+ }
564
594
  }
565
595
  out.set(harness.id, newInstallChoice);
566
596
  }
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,8 @@ 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 claudeUserCommandPath() {
58
+ return join(homeDir(), ".claude", "commands", "penny.md");
58
59
  }
59
60
 
60
61
  async function privateWrite(path, text) {
@@ -84,12 +85,19 @@ async function copyRuntime() {
84
85
  const source = packageRoot();
85
86
  const destination = pennyRuntimeDir();
86
87
  await mkdir(destination, { recursive: true, mode: 0o700 });
87
- for (const entry of ["bin", "src", "claude-plugin"]) {
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 });
88
93
  await cp(join(source, entry), join(destination, entry), {
89
94
  recursive: true,
90
95
  force: true,
91
96
  });
92
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 });
93
101
  await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
94
102
  }
95
103
 
@@ -172,6 +180,27 @@ async function writeClaudeLaunchSettings() {
172
180
  await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
173
181
  }
174
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
+
175
204
  async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
176
205
  const path = codexLaunchProfilePath();
177
206
  const existing = await readText(path, "");
@@ -194,6 +223,7 @@ export async function configurePennyHarness(harness, {
194
223
  config.api_key = apiKey;
195
224
  config.harnesses ||= {};
196
225
  if (harness === "claude-code") {
226
+ await writeClaudeUserCommand();
197
227
  await writeClaudeLaunchSettings();
198
228
  config.harnesses.claude = {
199
229
  enabled: true,
@@ -228,6 +258,12 @@ export async function removePennyHarness(harness) {
228
258
  await unlink(claudeLaunchSettingsPath()).catch((error) => {
229
259
  if (error.code !== "ENOENT") throw error;
230
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
+ }
231
267
  } else if (harness === "codex") {
232
268
  const existing = await readText(codexLaunchProfilePath(), "");
233
269
  if (existing.startsWith(CODEX_PROFILE_MARKER)) {
@@ -296,7 +332,7 @@ export function buildPennyInvocation(harness, args, config) {
296
332
  if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = models.fable;
297
333
  return {
298
334
  command: "claude",
299
- args: ["--settings", claudeLaunchSettingsPath(), "--plugin-dir", claudePluginPath(), ...args],
335
+ args: ["--settings", claudeLaunchSettingsPath(), ...args],
300
336
  env,
301
337
  };
302
338
  }
@@ -6,7 +6,7 @@ import path from "node:path";
6
6
  import {
7
7
  buildPennyInvocation,
8
8
  claudeLaunchSettingsPath,
9
- claudePluginPath,
9
+ claudeUserCommandPath,
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,19 @@ 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(claudeUserCommandPath(), "utf8");
112
+ assert.ok(command.includes("@penny $ARGUMENTS"));
110
113
 
111
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");
112
124
  assert.ok(fs.existsSync(codexLaunchProfilePath()));
113
125
  assert.ok(fs.existsSync(pennyShimPath()));
114
126
  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