pennyrouter 0.1.21 → 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/README.md +40 -3
- package/bin/penny.js +9 -0
- package/package.json +2 -2
- package/src/cli.js +296 -81
- 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 +375 -0
- package/src/launch.test.js +152 -0
- package/src/session.js +4 -2
- package/src/session.test.js +13 -2
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.1
|
|
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": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"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"
|
|
27
|
+
"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
28
|
},
|
|
29
29
|
"license": "MIT"
|
|
30
30
|
}
|
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
|
|
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
|
-
|
|
180
|
-
|
|
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
|
|
219
|
-
|
|
220
|
-
|
|
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
|
|
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(`
|
|
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
|
+
|
|
338
379
|
if (flags.dryRun) {
|
|
380
|
+
const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
|
|
339
381
|
for (const harness of selected) {
|
|
340
|
-
|
|
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:
|
|
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("");
|
|
@@ -368,49 +411,98 @@ async function install(flags) {
|
|
|
368
411
|
console.log("");
|
|
369
412
|
|
|
370
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);
|
|
433
|
+
|
|
434
|
+
const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
|
|
435
|
+
let runtimeResult = null;
|
|
436
|
+
if (launchable.length > 0 && process.platform !== "win32") {
|
|
437
|
+
runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
438
|
+
}
|
|
371
439
|
|
|
372
|
-
const manifest = await loadManifest();
|
|
373
440
|
const records = [];
|
|
374
441
|
const failures = [];
|
|
375
442
|
for (const harness of selected) {
|
|
376
443
|
await runHarnessJob(failures, harness.id, "install", async () => {
|
|
377
444
|
const plan = await harness.planInstall();
|
|
378
|
-
const gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
|
|
379
445
|
const existingRecord = (manifest.installations || []).find(
|
|
380
446
|
(candidate) => candidate.harness === harness.id,
|
|
381
447
|
);
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
448
|
+
const supportsPennyCommand = ["claude-code", "codex"].includes(harness.id)
|
|
449
|
+
&& process.platform !== "win32";
|
|
450
|
+
const makeDefault = defaultRouting.get(harness.id) ?? true;
|
|
451
|
+
const hadGlobalRouting = Boolean(existingRecord && existingRecord.default_routing !== false);
|
|
452
|
+
if (["claude-code", "codex"].includes(harness.id) && (makeDefault || hadGlobalRouting)) {
|
|
453
|
+
assertNoRunningHarness(harness.id, flags);
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
const codexSubscriptionAuth = harness.id === "codex"
|
|
457
|
+
? await detectCodexChatgptAuth()
|
|
458
|
+
: false;
|
|
459
|
+
const claudeSubscriptionAuth = harness.id === "claude-code"
|
|
460
|
+
&& Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
|
|
461
|
+
|
|
462
|
+
if (supportsPennyCommand) {
|
|
463
|
+
await configurePennyHarness(harness.id, {
|
|
389
464
|
apiKey: claim.api_key,
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
465
|
+
gatewayBaseUrl,
|
|
466
|
+
subscriptionAuth: harness.id === "claude-code"
|
|
467
|
+
? claudeSubscriptionAuth
|
|
468
|
+
: codexSubscriptionAuth,
|
|
469
|
+
models: harness.id === "claude-code" ? CLAUDE_CODE_MODELS : null,
|
|
396
470
|
});
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
let record;
|
|
474
|
+
if (!supportsPennyCommand || makeDefault) {
|
|
475
|
+
record = harness.id === "claude-code" && claudeSubscriptionAuth
|
|
476
|
+
? await harness.installStoredToken({
|
|
477
|
+
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
478
|
+
})
|
|
479
|
+
: await harness.install({
|
|
480
|
+
apiKey: claim.api_key, gatewayBaseUrl, plan, existingRecord,
|
|
481
|
+
});
|
|
400
482
|
} else {
|
|
401
|
-
if (
|
|
402
|
-
|
|
403
|
-
await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
|
|
483
|
+
if (hadGlobalRouting) {
|
|
484
|
+
await harness.uninstall(existingRecord);
|
|
404
485
|
}
|
|
405
|
-
record =
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
486
|
+
record = {
|
|
487
|
+
harness: harness.id,
|
|
488
|
+
config_path: compactHome(plan.configPath),
|
|
489
|
+
backup_path: existingRecord?.backup_path || null,
|
|
490
|
+
installed_at: new Date().toISOString(),
|
|
491
|
+
};
|
|
492
|
+
}
|
|
493
|
+
if (supportsPennyCommand) {
|
|
494
|
+
record.launch_available = true;
|
|
495
|
+
record.launch_config_path = compactHome(
|
|
496
|
+
harness.id === "claude-code" ? claudeLaunchSettingsPath() : codexLaunchProfilePath(),
|
|
497
|
+
);
|
|
498
|
+
record.default_routing = Boolean(makeDefault);
|
|
499
|
+
}
|
|
500
|
+
if (harness.id === "claude-code" && claudeSubscriptionAuth) {
|
|
501
|
+
record.anthropic_oauth = true;
|
|
502
|
+
record.anthropic_token_stored = true;
|
|
503
|
+
record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
|
|
413
504
|
}
|
|
505
|
+
if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
|
|
414
506
|
records.push(record);
|
|
415
507
|
upsertManifestRecord(manifest, record);
|
|
416
508
|
await saveManifest(manifest);
|
|
@@ -419,15 +511,92 @@ async function install(flags) {
|
|
|
419
511
|
|
|
420
512
|
await saveManifest(manifest);
|
|
421
513
|
console.log("");
|
|
422
|
-
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")}`);
|
|
423
518
|
for (const record of records) {
|
|
424
519
|
const harness = HARNESS_BY_ID[record.harness];
|
|
425
|
-
|
|
520
|
+
if (record.launch_available) {
|
|
521
|
+
const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
|
|
522
|
+
const native = record.harness === "claude-code" ? "claude" : "codex";
|
|
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`}`);
|
|
530
|
+
} else {
|
|
531
|
+
console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
|
|
532
|
+
}
|
|
426
533
|
}
|
|
427
|
-
|
|
534
|
+
if (runtimeResult && !runtimeResult.available) {
|
|
535
|
+
console.log("Add ~/.local/bin to PATH before using the `penny` command.");
|
|
536
|
+
} else if (runtimeResult?.changed) {
|
|
537
|
+
console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
|
|
538
|
+
}
|
|
539
|
+
console.log("");
|
|
428
540
|
reportFailures(failures);
|
|
429
541
|
}
|
|
430
542
|
|
|
543
|
+
async function chooseDefaultRouting(flags, selected, manifest) {
|
|
544
|
+
const out = new Map();
|
|
545
|
+
const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
|
|
546
|
+
if (process.platform === "win32") {
|
|
547
|
+
for (const harness of launchable) out.set(harness.id, true);
|
|
548
|
+
return out;
|
|
549
|
+
}
|
|
550
|
+
let newInstallChoice = null;
|
|
551
|
+
for (const harness of launchable) {
|
|
552
|
+
if (flags.makeDefault) {
|
|
553
|
+
out.set(harness.id, true);
|
|
554
|
+
continue;
|
|
555
|
+
}
|
|
556
|
+
if (flags.pennyOnly) {
|
|
557
|
+
out.set(harness.id, false);
|
|
558
|
+
continue;
|
|
559
|
+
}
|
|
560
|
+
const existing = (manifest.installations || []).find((item) => item.harness === harness.id);
|
|
561
|
+
if (existing) {
|
|
562
|
+
const currentlyDefault = existing.default_routing !== false;
|
|
563
|
+
if (flags.dryRun || !currentlyDefault || flags.yes || !input.isTTY || !output.isTTY) {
|
|
564
|
+
out.set(harness.id, currentlyDefault);
|
|
565
|
+
continue;
|
|
566
|
+
}
|
|
567
|
+
const native = harness.id === "claude-code" ? "claude" : "codex";
|
|
568
|
+
const change = await confirm(
|
|
569
|
+
`PennyRouter currently runs when you type ${color(native, "bold")}. Change it to run only with ${color(`penny ${native}`, "bold")}?`,
|
|
570
|
+
);
|
|
571
|
+
out.set(harness.id, !change);
|
|
572
|
+
continue;
|
|
573
|
+
}
|
|
574
|
+
if (newInstallChoice === null) {
|
|
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
|
+
}
|
|
594
|
+
}
|
|
595
|
+
out.set(harness.id, newInstallChoice);
|
|
596
|
+
}
|
|
597
|
+
return out;
|
|
598
|
+
}
|
|
599
|
+
|
|
431
600
|
async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
|
|
432
601
|
if (!selected.some((harness) => harness.id === "claude-code")) return null;
|
|
433
602
|
if (flags.noAnthropicAuth) return null;
|
|
@@ -526,13 +695,17 @@ async function disable(flags) {
|
|
|
526
695
|
}
|
|
527
696
|
|
|
528
697
|
await runHarnessJob(failures, record.harness, "disable", async () => {
|
|
529
|
-
|
|
698
|
+
const ownsGlobalRouting = !record.launch_available || record.default_routing;
|
|
699
|
+
if (record.config_path && ownsGlobalRouting) {
|
|
530
700
|
const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
|
|
531
701
|
record.disabled_snapshot_path = snapshotPath;
|
|
532
702
|
}
|
|
533
703
|
|
|
534
|
-
if (
|
|
535
|
-
|
|
704
|
+
if (record.launch_available) await setPennyHarnessEnabled(record.harness, false);
|
|
705
|
+
if (ownsGlobalRouting) {
|
|
706
|
+
if (typeof harness.disable === "function") await harness.disable(record);
|
|
707
|
+
else await harness.uninstall(record);
|
|
708
|
+
}
|
|
536
709
|
|
|
537
710
|
record.disabled = true;
|
|
538
711
|
record.disabled_at = new Date().toISOString();
|
|
@@ -584,13 +757,17 @@ async function enable(flags) {
|
|
|
584
757
|
}
|
|
585
758
|
|
|
586
759
|
await runHarnessJob(failures, record.harness, "enable", async () => {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
760
|
+
const ownsGlobalRouting = !record.launch_available || record.default_routing;
|
|
761
|
+
if (record.launch_available) await setPennyHarnessEnabled(record.harness, true);
|
|
762
|
+
if (ownsGlobalRouting) {
|
|
763
|
+
if (typeof harness.enable === "function") await harness.enable(record);
|
|
764
|
+
else if (record.config_path && record.disabled_snapshot_path) {
|
|
765
|
+
await restoreFile(record.disabled_snapshot_path, record.config_path);
|
|
766
|
+
} else if (record.manual_required) {
|
|
767
|
+
console.log(`${record.harness}: re-enable manually in the tool UI using the status details.`);
|
|
768
|
+
} else {
|
|
769
|
+
throw new Error("no disabled snapshot found; run install again");
|
|
770
|
+
}
|
|
594
771
|
}
|
|
595
772
|
|
|
596
773
|
delete record.disabled;
|
|
@@ -650,7 +827,8 @@ async function uninstall(flags) {
|
|
|
650
827
|
} else if (record.anthropic_token_stored) {
|
|
651
828
|
anthropicTokenNotice = true;
|
|
652
829
|
}
|
|
653
|
-
await
|
|
830
|
+
if (record.launch_available) await removePennyHarness(record.harness);
|
|
831
|
+
if (!record.launch_available || record.default_routing) await harness.uninstall(record);
|
|
654
832
|
console.log(`Removed ${record.harness}.`);
|
|
655
833
|
});
|
|
656
834
|
if (!ok) kept.push(record);
|
|
@@ -669,7 +847,8 @@ async function uninstall(flags) {
|
|
|
669
847
|
} else if (record.anthropic_token_stored) {
|
|
670
848
|
anthropicTokenNotice = true;
|
|
671
849
|
}
|
|
672
|
-
await
|
|
850
|
+
if (record.launch_available) await removePennyHarness(record.harness);
|
|
851
|
+
if (!record.launch_available || record.default_routing) await harness.uninstall(record);
|
|
673
852
|
console.log(`Removed ${record.harness}.`);
|
|
674
853
|
});
|
|
675
854
|
}
|
|
@@ -682,6 +861,7 @@ async function uninstall(flags) {
|
|
|
682
861
|
|
|
683
862
|
manifest.installations = kept;
|
|
684
863
|
await saveManifest(manifest);
|
|
864
|
+
if (!(await hasConfiguredPennyHarnesses())) await removePennyRuntime();
|
|
685
865
|
reportFailures(failures);
|
|
686
866
|
}
|
|
687
867
|
|
|
@@ -754,6 +934,11 @@ async function status() {
|
|
|
754
934
|
if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
|
|
755
935
|
if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
|
|
756
936
|
if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
|
|
937
|
+
if (record.launch_available) {
|
|
938
|
+
const command = record.harness === "claude-code" ? "penny claude" : "penny codex";
|
|
939
|
+
console.log(` command: ${command}`);
|
|
940
|
+
console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
|
|
941
|
+
}
|
|
757
942
|
if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
|
|
758
943
|
}
|
|
759
944
|
}
|
|
@@ -994,7 +1179,10 @@ function formatError(error) {
|
|
|
994
1179
|
async function forgetStoredAnthropicToken(record) {
|
|
995
1180
|
if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
|
|
996
1181
|
try {
|
|
997
|
-
const
|
|
1182
|
+
const launchConfig = await loadPennyConfig();
|
|
1183
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
1184
|
+
const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
|
|
1185
|
+
const apiKey = launchConfig.api_key || globalAuth.apiKey;
|
|
998
1186
|
if (!gatewayBaseUrl || !apiKey) return;
|
|
999
1187
|
await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
|
|
1000
1188
|
} catch {
|
|
@@ -1098,11 +1286,37 @@ function runningClaudeCodeProcesses() {
|
|
|
1098
1286
|
.filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
|
|
1099
1287
|
}
|
|
1100
1288
|
|
|
1289
|
+
function runningCodexProcesses() {
|
|
1290
|
+
if (process.platform === "win32") return [];
|
|
1291
|
+
const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
|
|
1292
|
+
if (result.status !== 0) return [];
|
|
1293
|
+
return String(result.stdout || "").split(/\r?\n/)
|
|
1294
|
+
.map((line) => line.trim())
|
|
1295
|
+
.filter((line) => /(^|[\/\s])codex(?:\s|$)/.test(line));
|
|
1296
|
+
}
|
|
1297
|
+
|
|
1298
|
+
function assertNoRunningHarness(harnessId, flags) {
|
|
1299
|
+
if (flags.allowRunningHarnesses || (harnessId === "claude-code" && flags.allowRunningClaude)) {
|
|
1300
|
+
return;
|
|
1301
|
+
}
|
|
1302
|
+
const running = harnessId === "claude-code"
|
|
1303
|
+
? runningClaudeCodeProcesses()
|
|
1304
|
+
: runningCodexProcesses();
|
|
1305
|
+
if (running.length === 0) return;
|
|
1306
|
+
const name = harnessId === "claude-code" ? "Claude Code" : "Codex";
|
|
1307
|
+
throw new Error(
|
|
1308
|
+
`${name} is currently running. Close all ${name} sessions before changing its global routing. `
|
|
1309
|
+
+ "Installing only the explicit `penny` command does not require this. If you intentionally "
|
|
1310
|
+
+ "accept the risk, pass --allow-running-harnesses.\nDetected:\n "
|
|
1311
|
+
+ running.slice(0, 5).join("\n "),
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1101
1315
|
function printHelp() {
|
|
1102
1316
|
console.log(`PennyRouter CLI
|
|
1103
1317
|
|
|
1104
1318
|
Usage:
|
|
1105
|
-
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--
|
|
1319
|
+
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
1320
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
1107
1321
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
1108
1322
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
@@ -1111,7 +1325,8 @@ Usage:
|
|
|
1111
1325
|
|
|
1112
1326
|
Examples:
|
|
1113
1327
|
npx pennyrouter install
|
|
1114
|
-
npx pennyrouter install --
|
|
1328
|
+
npx pennyrouter install --make-default
|
|
1329
|
+
npx pennyrouter install --penny-only
|
|
1115
1330
|
npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
|
|
1116
1331
|
npx pennyrouter auth anthropic
|
|
1117
1332
|
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
|
package/src/harnesses/codex.js
CHANGED
|
@@ -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 =
|
|
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,375 @@
|
|
|
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
|
+
const CLAUDE_COMMAND_MARKER = "<!-- PennyRouter managed Claude command -->";
|
|
23
|
+
|
|
24
|
+
export function pennyConfigDir() {
|
|
25
|
+
return join(process.env.XDG_CONFIG_HOME || join(homeDir(), ".config"), "pennyrouter");
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function pennyConfigPath() {
|
|
29
|
+
return join(pennyConfigDir(), "config.json");
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function pennyDataDir() {
|
|
33
|
+
return join(process.env.XDG_DATA_HOME || join(homeDir(), ".local", "share"), "pennyrouter");
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function pennyRuntimeDir() {
|
|
37
|
+
return join(pennyDataDir(), "runtime");
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function pennyBinDir() {
|
|
41
|
+
return join(homeDir(), ".local", "bin");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function pennyShimPath() {
|
|
45
|
+
return join(pennyBinDir(), "penny");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function claudeLaunchSettingsPath() {
|
|
49
|
+
return join(pennyConfigDir(), "claude-settings.json");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export function codexLaunchProfilePath() {
|
|
53
|
+
const codexHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
|
|
54
|
+
return join(codexHome, "pennyrouter.config.toml");
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function claudeUserCommandPath() {
|
|
58
|
+
return join(homeDir(), ".claude", "commands", "penny.md");
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
async function privateWrite(path, text) {
|
|
62
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
63
|
+
await atomicWrite(path, text);
|
|
64
|
+
await chmod(path, 0o600);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function loadPennyConfig() {
|
|
68
|
+
return (await readJsonFile(pennyConfigPath(), null)) || {
|
|
69
|
+
version: CONFIG_VERSION,
|
|
70
|
+
harnesses: {},
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function savePennyConfig(config) {
|
|
75
|
+
const next = { version: CONFIG_VERSION, ...config };
|
|
76
|
+
await privateWrite(pennyConfigPath(), `${JSON.stringify(next, null, 2)}\n`);
|
|
77
|
+
return next;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
function packageRoot() {
|
|
81
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
async function copyRuntime() {
|
|
85
|
+
const source = packageRoot();
|
|
86
|
+
const destination = pennyRuntimeDir();
|
|
87
|
+
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
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 });
|
|
93
|
+
await cp(join(source, entry), join(destination, entry), {
|
|
94
|
+
recursive: true,
|
|
95
|
+
force: true,
|
|
96
|
+
});
|
|
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 });
|
|
101
|
+
await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
async function writeShim() {
|
|
105
|
+
await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
|
|
106
|
+
const entry = join(pennyRuntimeDir(), "bin", "penny.js").replaceAll('"', '\\"');
|
|
107
|
+
await writeFile(pennyShimPath(), `#!/bin/sh\nexec node "${entry}" "$@"\n`, {
|
|
108
|
+
encoding: "utf8",
|
|
109
|
+
mode: 0o755,
|
|
110
|
+
});
|
|
111
|
+
await chmod(pennyShimPath(), 0o755);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function pathContains(dir) {
|
|
115
|
+
return String(process.env.PATH || "").split(":").some((candidate) => resolve(candidate) === resolve(dir));
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function shellRcPath() {
|
|
119
|
+
const shell = basename(process.env.SHELL || "");
|
|
120
|
+
if (shell === "zsh") return join(homeDir(), ".zshrc");
|
|
121
|
+
if (shell === "bash") return join(homeDir(), ".bashrc");
|
|
122
|
+
return null;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export async function ensurePennyOnPath({ updatePath = true } = {}) {
|
|
126
|
+
if (pathContains(pennyBinDir())) return { available: true, changed: false, rc_path: null };
|
|
127
|
+
if (!updatePath) return { available: false, changed: false, rc_path: null };
|
|
128
|
+
const rcPath = shellRcPath();
|
|
129
|
+
if (!rcPath) return { available: false, changed: false, rc_path: null };
|
|
130
|
+
const current = await readText(rcPath, "");
|
|
131
|
+
if (!current.includes(PATH_START)) {
|
|
132
|
+
const block = `${PATH_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_END}`;
|
|
133
|
+
const prefix = current && !current.endsWith("\n") ? "\n" : "";
|
|
134
|
+
await atomicWrite(rcPath, `${current}${prefix}${block}\n`);
|
|
135
|
+
}
|
|
136
|
+
return { available: true, changed: !current.includes(PATH_START), rc_path: rcPath };
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export async function installPennyRuntime({ updatePath = true } = {}) {
|
|
140
|
+
if (!(["darwin", "linux"].includes(process.platform))) {
|
|
141
|
+
return { supported: false, available: false, changed: false, rc_path: null };
|
|
142
|
+
}
|
|
143
|
+
await copyRuntime();
|
|
144
|
+
await writeShim();
|
|
145
|
+
const path = await ensurePennyOnPath({ updatePath });
|
|
146
|
+
const config = await loadPennyConfig();
|
|
147
|
+
if (path.rc_path) {
|
|
148
|
+
config.path_rc = path.rc_path;
|
|
149
|
+
await savePennyConfig(config);
|
|
150
|
+
}
|
|
151
|
+
return { supported: true, ...path, shim_path: pennyShimPath() };
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
155
|
+
const authLines = subscriptionAuth
|
|
156
|
+
? [
|
|
157
|
+
"requires_openai_auth = true",
|
|
158
|
+
'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }',
|
|
159
|
+
]
|
|
160
|
+
: ['env_key = "PENNYROUTER_API_KEY"'];
|
|
161
|
+
return [
|
|
162
|
+
CODEX_PROFILE_MARKER,
|
|
163
|
+
'model_provider = "pennyrouter"',
|
|
164
|
+
"",
|
|
165
|
+
"[model_providers.pennyrouter]",
|
|
166
|
+
'name = "PennyRouter"',
|
|
167
|
+
`base_url = ${JSON.stringify(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`)}`,
|
|
168
|
+
'wire_api = "responses"',
|
|
169
|
+
"supports_websockets = false",
|
|
170
|
+
...authLines,
|
|
171
|
+
"",
|
|
172
|
+
].join("\n");
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function writeClaudeLaunchSettings() {
|
|
176
|
+
const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
|
|
177
|
+
const overlay = { disableClaudeAiConnectors: true };
|
|
178
|
+
if (native.statusLine) overlay.statusLine = native.statusLine;
|
|
179
|
+
await configureClaudeCodeStatusline(overlay);
|
|
180
|
+
await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
|
|
181
|
+
}
|
|
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
|
+
|
|
204
|
+
async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
205
|
+
const path = codexLaunchProfilePath();
|
|
206
|
+
const existing = await readText(path, "");
|
|
207
|
+
if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
|
|
210
|
+
);
|
|
211
|
+
}
|
|
212
|
+
await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function configurePennyHarness(harness, {
|
|
216
|
+
apiKey,
|
|
217
|
+
gatewayBaseUrl,
|
|
218
|
+
subscriptionAuth = false,
|
|
219
|
+
models = null,
|
|
220
|
+
}) {
|
|
221
|
+
const config = await loadPennyConfig();
|
|
222
|
+
config.gateway_base_url = gatewayBaseUrl;
|
|
223
|
+
config.api_key = apiKey;
|
|
224
|
+
config.harnesses ||= {};
|
|
225
|
+
if (harness === "claude-code") {
|
|
226
|
+
await writeClaudeUserCommand();
|
|
227
|
+
await writeClaudeLaunchSettings();
|
|
228
|
+
config.harnesses.claude = {
|
|
229
|
+
enabled: true,
|
|
230
|
+
subscription_auth: Boolean(subscriptionAuth),
|
|
231
|
+
models: models || {},
|
|
232
|
+
};
|
|
233
|
+
} else if (harness === "codex") {
|
|
234
|
+
await writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth });
|
|
235
|
+
config.harnesses.codex = {
|
|
236
|
+
enabled: true,
|
|
237
|
+
subscription_auth: Boolean(subscriptionAuth),
|
|
238
|
+
};
|
|
239
|
+
} else {
|
|
240
|
+
throw new Error(`The penny command does not support ${harness}.`);
|
|
241
|
+
}
|
|
242
|
+
await savePennyConfig(config);
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
export async function setPennyHarnessEnabled(harness, enabled) {
|
|
246
|
+
const config = await loadPennyConfig();
|
|
247
|
+
const key = harness === "claude-code" ? "claude" : harness;
|
|
248
|
+
if (config.harnesses?.[key]) {
|
|
249
|
+
config.harnesses[key].enabled = Boolean(enabled);
|
|
250
|
+
await savePennyConfig(config);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
export async function removePennyHarness(harness) {
|
|
255
|
+
const config = await loadPennyConfig();
|
|
256
|
+
const key = harness === "claude-code" ? "claude" : harness;
|
|
257
|
+
if (harness === "claude-code") {
|
|
258
|
+
await unlink(claudeLaunchSettingsPath()).catch((error) => {
|
|
259
|
+
if (error.code !== "ENOENT") throw error;
|
|
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
|
+
}
|
|
267
|
+
} else if (harness === "codex") {
|
|
268
|
+
const existing = await readText(codexLaunchProfilePath(), "");
|
|
269
|
+
if (existing.startsWith(CODEX_PROFILE_MARKER)) {
|
|
270
|
+
await unlink(codexLaunchProfilePath()).catch((error) => {
|
|
271
|
+
if (error.code !== "ENOENT") throw error;
|
|
272
|
+
});
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
if (config.harnesses) delete config.harnesses[key];
|
|
276
|
+
await savePennyConfig(config);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
export async function hasConfiguredPennyHarnesses() {
|
|
280
|
+
const config = await loadPennyConfig();
|
|
281
|
+
return Object.keys(config.harnesses || {}).length > 0;
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
async function removeManagedPathBlock(rcPath) {
|
|
285
|
+
if (!rcPath) return;
|
|
286
|
+
const current = await readText(rcPath, "");
|
|
287
|
+
if (!current.includes(PATH_START)) return;
|
|
288
|
+
const escapedStart = PATH_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
289
|
+
const escapedEnd = PATH_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
290
|
+
const next = current
|
|
291
|
+
.replace(new RegExp(`(?:^|\\n)${escapedStart}\\n[\\s\\S]*?\\n${escapedEnd}\\n?`, "m"), "\n")
|
|
292
|
+
.replace(/^\n+/, "");
|
|
293
|
+
await atomicWrite(rcPath, next);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export async function removePennyRuntime() {
|
|
297
|
+
const config = await loadPennyConfig();
|
|
298
|
+
await removeManagedPathBlock(config.path_rc);
|
|
299
|
+
await unlink(pennyShimPath()).catch((error) => {
|
|
300
|
+
if (error.code !== "ENOENT") throw error;
|
|
301
|
+
});
|
|
302
|
+
await rm(pennyRuntimeDir(), { recursive: true, force: true });
|
|
303
|
+
await unlink(pennyConfigPath()).catch((error) => {
|
|
304
|
+
if (error.code !== "ENOENT") throw error;
|
|
305
|
+
});
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
function assertNoConflictingArgs(harness, args) {
|
|
309
|
+
if (harness === "claude" && args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
|
|
310
|
+
throw new Error("penny claude manages --settings; remove that flag or run native claude.");
|
|
311
|
+
}
|
|
312
|
+
if (harness === "codex" && args.some((arg) => arg === "--profile" || arg === "-p" || arg.startsWith("--profile="))) {
|
|
313
|
+
throw new Error("penny codex manages --profile; remove that flag or run native codex.");
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export function buildPennyInvocation(harness, args, config) {
|
|
318
|
+
const item = config.harnesses?.[harness];
|
|
319
|
+
if (!item || !item.enabled) {
|
|
320
|
+
throw new Error(`PennyRouter is not enabled for ${harness}. Run npx pennyrouter install.`);
|
|
321
|
+
}
|
|
322
|
+
assertNoConflictingArgs(harness, args);
|
|
323
|
+
const env = { ...process.env, PENNYROUTER_API_KEY: config.api_key };
|
|
324
|
+
if (harness === "claude") {
|
|
325
|
+
const models = item.models || {};
|
|
326
|
+
env.ANTHROPIC_BASE_URL = config.gateway_base_url;
|
|
327
|
+
env.ANTHROPIC_AUTH_TOKEN = config.api_key;
|
|
328
|
+
delete env.ANTHROPIC_API_KEY;
|
|
329
|
+
if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haiku;
|
|
330
|
+
if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnet;
|
|
331
|
+
if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opus;
|
|
332
|
+
if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = models.fable;
|
|
333
|
+
return {
|
|
334
|
+
command: "claude",
|
|
335
|
+
args: ["--settings", claudeLaunchSettingsPath(), ...args],
|
|
336
|
+
env,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
if (harness === "codex") {
|
|
340
|
+
return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
|
|
341
|
+
}
|
|
342
|
+
throw new Error(`Unknown Penny harness "${harness}". Try penny claude or penny codex.`);
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
async function waitForChild(child) {
|
|
346
|
+
return new Promise((resolvePromise, reject) => {
|
|
347
|
+
child.once("error", reject);
|
|
348
|
+
child.once("exit", (code, signal) => resolvePromise({ code, signal }));
|
|
349
|
+
});
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
export async function runPenny(argv = process.argv.slice(2)) {
|
|
353
|
+
const harness = argv[0];
|
|
354
|
+
if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
|
|
355
|
+
console.log("Usage: penny <claude|codex> [native arguments...]");
|
|
356
|
+
return 0;
|
|
357
|
+
}
|
|
358
|
+
if (!(["claude", "codex"].includes(harness))) {
|
|
359
|
+
throw new Error(`Unknown harness "${harness}". Try penny claude or penny codex.`);
|
|
360
|
+
}
|
|
361
|
+
const config = await loadPennyConfig();
|
|
362
|
+
const invocation = buildPennyInvocation(harness, argv.slice(1), config);
|
|
363
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
364
|
+
cwd: process.cwd(),
|
|
365
|
+
env: invocation.env,
|
|
366
|
+
stdio: "inherit",
|
|
367
|
+
});
|
|
368
|
+
const result = await waitForChild(child);
|
|
369
|
+
if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
|
|
370
|
+
return result.code ?? 1;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
export async function fileMode(path) {
|
|
374
|
+
return (await stat(path)).mode & 0o777;
|
|
375
|
+
}
|
|
@@ -0,0 +1,152 @@
|
|
|
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
|
+
claudeUserCommandPath,
|
|
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 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");
|
|
62
|
+
const second = await installPennyRuntime();
|
|
63
|
+
assert.equal(first.supported, true);
|
|
64
|
+
assert.equal(first.changed, true);
|
|
65
|
+
assert.equal(second.changed, false);
|
|
66
|
+
assert.equal(await fileMode(pennyShimPath()), 0o755);
|
|
67
|
+
assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
|
|
68
|
+
assert.ok(!fs.existsSync(staleSkill), "runtime upgrade removes stale plugin files");
|
|
69
|
+
const rc = fs.readFileSync(path.join(home, ".zshrc"), "utf8");
|
|
70
|
+
assert.equal((rc.match(/>>> pennyrouter PATH >>>/g) || []).length, 1);
|
|
71
|
+
|
|
72
|
+
await configurePennyHarness("claude-code", {
|
|
73
|
+
apiKey: "pr-super-secret",
|
|
74
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
75
|
+
subscriptionAuth: true,
|
|
76
|
+
models: { haiku: "Penny Haiku", sonnet: "Penny Sonnet", opus: "Penny Opus" },
|
|
77
|
+
});
|
|
78
|
+
await configurePennyHarness("codex", {
|
|
79
|
+
apiKey: "pr-super-secret",
|
|
80
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
81
|
+
subscriptionAuth: true,
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
|
|
85
|
+
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
86
|
+
assert.equal(await fileMode(pennyConfigPath()), 0o600);
|
|
87
|
+
assert.equal(await fileMode(claudeLaunchSettingsPath()), 0o600);
|
|
88
|
+
assert.equal(await fileMode(codexLaunchProfilePath()), 0o600);
|
|
89
|
+
|
|
90
|
+
const config = await loadPennyConfig();
|
|
91
|
+
assert.equal(config.api_key, "pr-super-secret");
|
|
92
|
+
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
93
|
+
assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
|
|
94
|
+
assert.ok(!codexProfile.includes("pr-super-secret"));
|
|
95
|
+
assert.ok(!rc.includes("pr-super-secret"));
|
|
96
|
+
|
|
97
|
+
const claude = buildPennyInvocation("claude", ["--resume", "abc"], config);
|
|
98
|
+
assert.equal(claude.command, "claude");
|
|
99
|
+
assert.deepEqual(claude.args.slice(-2), ["--resume", "abc"]);
|
|
100
|
+
assert.ok(!claude.args.includes("--plugin-dir"));
|
|
101
|
+
assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
|
|
102
|
+
assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
|
|
103
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Penny Opus");
|
|
104
|
+
|
|
105
|
+
const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
|
|
106
|
+
assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
|
|
107
|
+
assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
|
|
108
|
+
assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
|
|
109
|
+
assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
|
|
110
|
+
|
|
111
|
+
const command = fs.readFileSync(claudeUserCommandPath(), "utf8");
|
|
112
|
+
assert.ok(command.includes("@penny $ARGUMENTS"));
|
|
113
|
+
|
|
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");
|
|
124
|
+
assert.ok(fs.existsSync(codexLaunchProfilePath()));
|
|
125
|
+
assert.ok(fs.existsSync(pennyShimPath()));
|
|
126
|
+
await removePennyHarness("codex");
|
|
127
|
+
await removePennyRuntime();
|
|
128
|
+
assert.ok(!fs.existsSync(pennyShimPath()));
|
|
129
|
+
assert.ok(!fs.existsSync(pennyRuntimeDir()));
|
|
130
|
+
assert.ok(!fs.existsSync(pennyConfigPath()));
|
|
131
|
+
assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
|
|
132
|
+
|
|
133
|
+
const paid = renderCodexLaunchProfile({
|
|
134
|
+
gatewayBaseUrl: "https://gateway.example/", subscriptionAuth: false,
|
|
135
|
+
});
|
|
136
|
+
assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
|
|
137
|
+
assert.ok(!paid.includes("requires_openai_auth"));
|
|
138
|
+
} finally {
|
|
139
|
+
for (const key of ENV_KEYS) {
|
|
140
|
+
if (previous[key] === undefined) delete process.env[key];
|
|
141
|
+
else process.env[key] = previous[key];
|
|
142
|
+
}
|
|
143
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
console.log("launch.test.js: all checks passed");
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
main().catch((error) => {
|
|
150
|
+
console.error(error);
|
|
151
|
+
process.exit(1);
|
|
152
|
+
});
|
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 = "
|
|
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}` : ""}.
|
|
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;
|
package/src/session.test.js
CHANGED
|
@@ -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
|
-
|