pennyrouter 0.1.20 → 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 +40 -3
- package/bin/penny.js +9 -0
- package/claude-plugin/.claude-plugin/plugin.json +8 -0
- package/claude-plugin/skills/penny/SKILL.md +8 -0
- package/package.json +3 -2
- package/src/cli.js +275 -50
- package/src/harnesses/claude-code.js +1 -1
- package/src/harnesses/codex.js +4 -3
- package/src/harnesses/codex.test.js +37 -0
- package/src/launch.js +339 -0
- package/src/launch.test.js +140 -0
- package/src/session.js +56 -1
- package/src/session.test.js +43 -0
- package/src/statusline.js +3 -4
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 --
|
|
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
|
|
128
|
-
|
|
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
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.
|
|
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/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";
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
forgetAnthropicToken,
|
|
21
22
|
maskKey,
|
|
22
23
|
pollForKey,
|
|
24
|
+
probeClaudeCodeGateway,
|
|
23
25
|
sleep,
|
|
24
26
|
storeAnthropicToken,
|
|
25
27
|
} from "./session.js";
|
|
@@ -31,6 +33,19 @@ import {
|
|
|
31
33
|
saveManifest,
|
|
32
34
|
statePath,
|
|
33
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";
|
|
34
49
|
|
|
35
50
|
const HARNESS_BY_ID = {
|
|
36
51
|
"claude-code": claudeCodeHarness,
|
|
@@ -98,6 +113,11 @@ function parseArgs(argv) {
|
|
|
98
113
|
else if (arg === "--yes" || arg === "-y") flags.yes = true;
|
|
99
114
|
else if (arg === "--all") flags.all = true;
|
|
100
115
|
else if (arg === "--dry-run") flags.dryRun = true;
|
|
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;
|
|
101
121
|
else if (arg === "--no-browser") flags.noBrowser = true;
|
|
102
122
|
else if (arg === "--existing-account") flags.existingAccount = true;
|
|
103
123
|
else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
|
|
@@ -136,7 +156,8 @@ async function authProvider(flags) {
|
|
|
136
156
|
}
|
|
137
157
|
|
|
138
158
|
if (flags.forget) {
|
|
139
|
-
const
|
|
159
|
+
const launchConfig = await loadPennyConfig();
|
|
160
|
+
const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
|
|
140
161
|
if (!pennyKey) {
|
|
141
162
|
throw new Error(
|
|
142
163
|
"No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
|
|
@@ -165,6 +186,10 @@ async function authProvider(flags) {
|
|
|
165
186
|
if (updated) {
|
|
166
187
|
await saveManifest(manifest);
|
|
167
188
|
}
|
|
189
|
+
if (launchConfig.harnesses?.claude) {
|
|
190
|
+
launchConfig.harnesses.claude.subscription_auth = false;
|
|
191
|
+
await savePennyConfig(launchConfig);
|
|
192
|
+
}
|
|
168
193
|
return;
|
|
169
194
|
}
|
|
170
195
|
|
|
@@ -174,7 +199,8 @@ async function authProvider(flags) {
|
|
|
174
199
|
return;
|
|
175
200
|
}
|
|
176
201
|
|
|
177
|
-
const
|
|
202
|
+
const launchConfig = await loadPennyConfig();
|
|
203
|
+
const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
|
|
178
204
|
if (!pennyKey) {
|
|
179
205
|
throw new Error(
|
|
180
206
|
"No PennyRouter key found in Claude Code settings. Run `pennyrouter install --harness claude-code` first, or pass `--penny-key pr-...`.",
|
|
@@ -190,29 +216,58 @@ async function authProvider(flags) {
|
|
|
190
216
|
}
|
|
191
217
|
|
|
192
218
|
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
|
|
193
|
-
// Store the token encrypted server-side, then write a pr-key-only config.
|
|
194
|
-
await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
|
|
195
219
|
const manifest = await loadManifest();
|
|
196
220
|
const existingRecord = (manifest.installations || []).find(
|
|
197
221
|
(candidate) => candidate.harness === "claude-code",
|
|
198
222
|
);
|
|
199
|
-
const
|
|
200
|
-
|
|
201
|
-
|
|
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,
|
|
202
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
|
+
}
|
|
203
255
|
|
|
204
256
|
upsertManifestRecord(manifest, {
|
|
205
257
|
...record,
|
|
258
|
+
launch_available: true,
|
|
259
|
+
launch_config_path: compactHome(claudeLaunchSettingsPath()),
|
|
260
|
+
default_routing: ownsGlobalRouting,
|
|
206
261
|
anthropic_oauth: true,
|
|
207
262
|
anthropic_token_stored: true,
|
|
208
263
|
token_source: tokenResult.source,
|
|
209
264
|
});
|
|
210
265
|
await saveManifest(manifest);
|
|
211
266
|
|
|
212
|
-
console.log("Configured
|
|
267
|
+
console.log("Configured `penny claude` for PennyRouter + your Claude subscription.");
|
|
213
268
|
console.log("Your Claude token is stored encrypted; Claude Code holds only your PennyRouter key.");
|
|
214
269
|
console.log("If Claude auth expires, refresh the token source and run `pennyrouter auth anthropic` again.");
|
|
215
|
-
console.log(`
|
|
270
|
+
console.log(`Penny config: ${pennyConfigPath()}`);
|
|
216
271
|
}
|
|
217
272
|
|
|
218
273
|
function readAnthropicOAuthToken(flags = {}) {
|
|
@@ -307,6 +362,9 @@ function normalizeAnthropicToken(value) {
|
|
|
307
362
|
}
|
|
308
363
|
|
|
309
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
|
+
}
|
|
310
368
|
const selected = await selectHarnesses(flags);
|
|
311
369
|
if (selected.length === 0) {
|
|
312
370
|
console.log("No supported harnesses selected.");
|
|
@@ -316,9 +374,17 @@ async function install(flags) {
|
|
|
316
374
|
console.log("PennyRouter install");
|
|
317
375
|
console.log("");
|
|
318
376
|
|
|
377
|
+
const manifest = await loadManifest();
|
|
378
|
+
const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
|
|
379
|
+
|
|
319
380
|
if (flags.dryRun) {
|
|
320
381
|
for (const harness of selected) {
|
|
321
|
-
|
|
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
|
+
}
|
|
322
388
|
}
|
|
323
389
|
if (flags.gatewayBaseUrl) console.log(`Serving gateway: ${flags.gatewayBaseUrl}`);
|
|
324
390
|
if (selected.some((harness) => harness.id === "claude-code")) {
|
|
@@ -331,14 +397,10 @@ async function install(flags) {
|
|
|
331
397
|
const session = await createCliSession({
|
|
332
398
|
appUrl: flags.appUrl || APP_URL,
|
|
333
399
|
harnesses: selected.map((harness) => harness.id),
|
|
334
|
-
accountMode:
|
|
400
|
+
accountMode: "existing",
|
|
335
401
|
});
|
|
336
402
|
|
|
337
|
-
console.log(
|
|
338
|
-
flags.existingAccount
|
|
339
|
-
? "Opening your browser to authorize this machine with your existing PennyRouter account..."
|
|
340
|
-
: "Opening your browser to authorize this machine...",
|
|
341
|
-
);
|
|
403
|
+
console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
|
|
342
404
|
console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
|
|
343
405
|
if (!flags.noBrowser) openBrowser(session.authorize_url);
|
|
344
406
|
console.log("");
|
|
@@ -350,7 +412,12 @@ async function install(flags) {
|
|
|
350
412
|
|
|
351
413
|
const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(flags, selected);
|
|
352
414
|
|
|
353
|
-
const
|
|
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
|
+
|
|
354
421
|
const records = [];
|
|
355
422
|
const failures = [];
|
|
356
423
|
for (const harness of selected) {
|
|
@@ -360,32 +427,76 @@ async function install(flags) {
|
|
|
360
427
|
const existingRecord = (manifest.installations || []).find(
|
|
361
428
|
(candidate) => candidate.harness === harness.id,
|
|
362
429
|
);
|
|
363
|
-
|
|
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
|
+
|
|
364
444
|
if (harness.id === "claude-code" && claudeAnthropicAuth) {
|
|
365
|
-
// Store the Claude setup-token encrypted server-side, then write a config
|
|
366
|
-
// that carries ONLY the pr- key. Nothing Anthropic-flavored on disk, and no
|
|
367
|
-
// dual-credential warnings from Claude Code.
|
|
368
445
|
await storeAnthropicToken({
|
|
369
446
|
gatewayBaseUrl,
|
|
370
447
|
apiKey: claim.api_key,
|
|
371
448
|
token: claudeAnthropicAuth.token,
|
|
372
449
|
});
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
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
|
+
if (supportsPennyCommand) {
|
|
457
|
+
await configurePennyHarness(harness.id, {
|
|
381
458
|
apiKey: claim.api_key,
|
|
382
|
-
// An explicit --local / --gateway-base-url overrides the URL the server hands back
|
|
383
|
-
// (which points at prod) — so a --local install serves through the local gateway.
|
|
384
459
|
gatewayBaseUrl,
|
|
385
|
-
|
|
386
|
-
|
|
460
|
+
subscriptionAuth: harness.id === "claude-code"
|
|
461
|
+
? claudeSubscriptionAuth
|
|
462
|
+
: codexSubscriptionAuth,
|
|
463
|
+
models: harness.id === "claude-code" ? CLAUDE_CODE_MODELS : null,
|
|
387
464
|
});
|
|
388
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;
|
|
389
500
|
records.push(record);
|
|
390
501
|
upsertManifestRecord(manifest, record);
|
|
391
502
|
await saveManifest(manifest);
|
|
@@ -397,12 +508,65 @@ async function install(flags) {
|
|
|
397
508
|
console.log("Installed PennyRouter.");
|
|
398
509
|
for (const record of records) {
|
|
399
510
|
const harness = HARNESS_BY_ID[record.harness];
|
|
400
|
-
|
|
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.");
|
|
401
524
|
}
|
|
402
525
|
console.log(`Manifest: ${statePath("installations.json")}`);
|
|
403
526
|
reportFailures(failures);
|
|
404
527
|
}
|
|
405
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
|
+
|
|
406
570
|
async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
|
|
407
571
|
if (!selected.some((harness) => harness.id === "claude-code")) return null;
|
|
408
572
|
if (flags.noAnthropicAuth) return null;
|
|
@@ -501,13 +665,17 @@ async function disable(flags) {
|
|
|
501
665
|
}
|
|
502
666
|
|
|
503
667
|
await runHarnessJob(failures, record.harness, "disable", async () => {
|
|
504
|
-
|
|
668
|
+
const ownsGlobalRouting = !record.launch_available || record.default_routing;
|
|
669
|
+
if (record.config_path && ownsGlobalRouting) {
|
|
505
670
|
const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
|
|
506
671
|
record.disabled_snapshot_path = snapshotPath;
|
|
507
672
|
}
|
|
508
673
|
|
|
509
|
-
if (
|
|
510
|
-
|
|
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
|
+
}
|
|
511
679
|
|
|
512
680
|
record.disabled = true;
|
|
513
681
|
record.disabled_at = new Date().toISOString();
|
|
@@ -559,13 +727,17 @@ async function enable(flags) {
|
|
|
559
727
|
}
|
|
560
728
|
|
|
561
729
|
await runHarnessJob(failures, record.harness, "enable", async () => {
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
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
|
+
}
|
|
569
741
|
}
|
|
570
742
|
|
|
571
743
|
delete record.disabled;
|
|
@@ -625,7 +797,8 @@ async function uninstall(flags) {
|
|
|
625
797
|
} else if (record.anthropic_token_stored) {
|
|
626
798
|
anthropicTokenNotice = true;
|
|
627
799
|
}
|
|
628
|
-
await
|
|
800
|
+
if (record.launch_available) await removePennyHarness(record.harness);
|
|
801
|
+
if (!record.launch_available || record.default_routing) await harness.uninstall(record);
|
|
629
802
|
console.log(`Removed ${record.harness}.`);
|
|
630
803
|
});
|
|
631
804
|
if (!ok) kept.push(record);
|
|
@@ -644,7 +817,8 @@ async function uninstall(flags) {
|
|
|
644
817
|
} else if (record.anthropic_token_stored) {
|
|
645
818
|
anthropicTokenNotice = true;
|
|
646
819
|
}
|
|
647
|
-
await
|
|
820
|
+
if (record.launch_available) await removePennyHarness(record.harness);
|
|
821
|
+
if (!record.launch_available || record.default_routing) await harness.uninstall(record);
|
|
648
822
|
console.log(`Removed ${record.harness}.`);
|
|
649
823
|
});
|
|
650
824
|
}
|
|
@@ -657,6 +831,7 @@ async function uninstall(flags) {
|
|
|
657
831
|
|
|
658
832
|
manifest.installations = kept;
|
|
659
833
|
await saveManifest(manifest);
|
|
834
|
+
if (!(await hasConfiguredPennyHarnesses())) await removePennyRuntime();
|
|
660
835
|
reportFailures(failures);
|
|
661
836
|
}
|
|
662
837
|
|
|
@@ -729,6 +904,11 @@ async function status() {
|
|
|
729
904
|
if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
|
|
730
905
|
if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
|
|
731
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
|
+
}
|
|
732
912
|
if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
|
|
733
913
|
}
|
|
734
914
|
}
|
|
@@ -969,7 +1149,10 @@ function formatError(error) {
|
|
|
969
1149
|
async function forgetStoredAnthropicToken(record) {
|
|
970
1150
|
if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
|
|
971
1151
|
try {
|
|
972
|
-
const
|
|
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;
|
|
973
1156
|
if (!gatewayBaseUrl || !apiKey) return;
|
|
974
1157
|
await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
|
|
975
1158
|
} catch {
|
|
@@ -1058,11 +1241,52 @@ function openBrowser(url) {
|
|
|
1058
1241
|
spawnSync(command, args, { stdio: "ignore", detached: true });
|
|
1059
1242
|
}
|
|
1060
1243
|
|
|
1244
|
+
function runningClaudeCodeProcesses() {
|
|
1245
|
+
if (process.platform === "win32") {
|
|
1246
|
+
const result = spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8" });
|
|
1247
|
+
if (result.status !== 0) return [];
|
|
1248
|
+
return String(result.stdout || "").split(/\r?\n/)
|
|
1249
|
+
.filter((line) => /^"claude(?:\.exe)?"/i.test(line.trim()));
|
|
1250
|
+
}
|
|
1251
|
+
const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
|
|
1252
|
+
if (result.status !== 0) return [];
|
|
1253
|
+
return String(result.stdout || "").split(/\r?\n/)
|
|
1254
|
+
.map((line) => line.trim())
|
|
1255
|
+
// Case-sensitive on purpose: match the Claude Code executable, not Claude.app.
|
|
1256
|
+
.filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
|
|
1257
|
+
}
|
|
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
|
+
|
|
1061
1285
|
function printHelp() {
|
|
1062
1286
|
console.log(`PennyRouter CLI
|
|
1063
1287
|
|
|
1064
1288
|
Usage:
|
|
1065
|
-
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--
|
|
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]
|
|
1066
1290
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
1067
1291
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
1068
1292
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
@@ -1071,7 +1295,8 @@ Usage:
|
|
|
1071
1295
|
|
|
1072
1296
|
Examples:
|
|
1073
1297
|
npx pennyrouter install
|
|
1074
|
-
npx pennyrouter install --
|
|
1298
|
+
npx pennyrouter install --make-default
|
|
1299
|
+
npx pennyrouter install --penny-only
|
|
1075
1300
|
npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
|
|
1076
1301
|
npx pennyrouter auth anthropic
|
|
1077
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
|