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 +1 -2
- package/src/cli.js +52 -22
- package/src/launch.js +40 -4
- package/src/launch.test.js +17 -5
- package/claude-plugin/.claude-plugin/plugin.json +0 -8
- package/claude-plugin/skills/penny/SKILL.md +0 -8
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.2.
|
|
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(
|
|
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
|
-
|
|
515
|
-
|
|
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(
|
|
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
|
|
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
|
-
|
|
562
|
-
|
|
563
|
-
|
|
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
|
|
57
|
-
return join(
|
|
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"
|
|
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(),
|
|
335
|
+
args: ["--settings", claudeLaunchSettingsPath(), ...args],
|
|
300
336
|
env,
|
|
301
337
|
};
|
|
302
338
|
}
|
package/src/launch.test.js
CHANGED
|
@@ -6,7 +6,7 @@ import path from "node:path";
|
|
|
6
6
|
import {
|
|
7
7
|
buildPennyInvocation,
|
|
8
8
|
claudeLaunchSettingsPath,
|
|
9
|
-
|
|
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(
|
|
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
|
|
109
|
-
assert.ok(
|
|
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");
|