pennyrouter 0.1.21 → 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 +257 -72
- 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 +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.
|
|
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/session.test.js && 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";
|
|
@@ -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
|
+
const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
|
|
379
|
+
|
|
338
380
|
if (flags.dryRun) {
|
|
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("");
|
|
@@ -369,7 +412,12 @@ async function install(flags) {
|
|
|
369
412
|
|
|
370
413
|
const claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(flags, selected);
|
|
371
414
|
|
|
372
|
-
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
|
+
|
|
373
421
|
const records = [];
|
|
374
422
|
const failures = [];
|
|
375
423
|
for (const harness of selected) {
|
|
@@ -379,38 +427,76 @@ async function install(flags) {
|
|
|
379
427
|
const existingRecord = (manifest.installations || []).find(
|
|
380
428
|
(candidate) => candidate.harness === harness.id,
|
|
381
429
|
);
|
|
382
|
-
|
|
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
|
+
|
|
383
444
|
if (harness.id === "claude-code" && claudeAnthropicAuth) {
|
|
384
|
-
// Store the Claude setup-token encrypted server-side, then write a config
|
|
385
|
-
// that carries ONLY the pr- key. Nothing Anthropic-flavored on disk, and no
|
|
386
|
-
// dual-credential warnings from Claude Code.
|
|
387
445
|
await storeAnthropicToken({
|
|
388
446
|
gatewayBaseUrl,
|
|
389
447
|
apiKey: claim.api_key,
|
|
390
448
|
token: claudeAnthropicAuth.token,
|
|
391
449
|
});
|
|
392
|
-
|
|
450
|
+
}
|
|
451
|
+
if (harness.id === "claude-code") {
|
|
452
|
+
console.log("Verifying modern Claude Code compatibility before changing local settings...");
|
|
393
453
|
await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
record.anthropic_token_stored = true;
|
|
399
|
-
record.token_source = claudeAnthropicAuth.source;
|
|
400
|
-
} else {
|
|
401
|
-
if (harness.id === "claude-code") {
|
|
402
|
-
console.log("Verifying modern Claude Code compatibility before changing global settings...");
|
|
403
|
-
await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
|
|
404
|
-
}
|
|
405
|
-
record = await harness.install({
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
if (supportsPennyCommand) {
|
|
457
|
+
await configurePennyHarness(harness.id, {
|
|
406
458
|
apiKey: claim.api_key,
|
|
407
|
-
// An explicit --local / --gateway-base-url overrides the URL the server hands back
|
|
408
|
-
// (which points at prod) — so a --local install serves through the local gateway.
|
|
409
459
|
gatewayBaseUrl,
|
|
410
|
-
|
|
411
|
-
|
|
460
|
+
subscriptionAuth: harness.id === "claude-code"
|
|
461
|
+
? claudeSubscriptionAuth
|
|
462
|
+
: codexSubscriptionAuth,
|
|
463
|
+
models: harness.id === "claude-code" ? CLAUDE_CODE_MODELS : null,
|
|
412
464
|
});
|
|
413
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;
|
|
414
500
|
records.push(record);
|
|
415
501
|
upsertManifestRecord(manifest, record);
|
|
416
502
|
await saveManifest(manifest);
|
|
@@ -422,12 +508,65 @@ async function install(flags) {
|
|
|
422
508
|
console.log("Installed PennyRouter.");
|
|
423
509
|
for (const record of records) {
|
|
424
510
|
const harness = HARNESS_BY_ID[record.harness];
|
|
425
|
-
|
|
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.");
|
|
426
524
|
}
|
|
427
525
|
console.log(`Manifest: ${statePath("installations.json")}`);
|
|
428
526
|
reportFailures(failures);
|
|
429
527
|
}
|
|
430
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
|
+
|
|
431
570
|
async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
|
|
432
571
|
if (!selected.some((harness) => harness.id === "claude-code")) return null;
|
|
433
572
|
if (flags.noAnthropicAuth) return null;
|
|
@@ -526,13 +665,17 @@ async function disable(flags) {
|
|
|
526
665
|
}
|
|
527
666
|
|
|
528
667
|
await runHarnessJob(failures, record.harness, "disable", async () => {
|
|
529
|
-
|
|
668
|
+
const ownsGlobalRouting = !record.launch_available || record.default_routing;
|
|
669
|
+
if (record.config_path && ownsGlobalRouting) {
|
|
530
670
|
const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
|
|
531
671
|
record.disabled_snapshot_path = snapshotPath;
|
|
532
672
|
}
|
|
533
673
|
|
|
534
|
-
if (
|
|
535
|
-
|
|
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
|
+
}
|
|
536
679
|
|
|
537
680
|
record.disabled = true;
|
|
538
681
|
record.disabled_at = new Date().toISOString();
|
|
@@ -584,13 +727,17 @@ async function enable(flags) {
|
|
|
584
727
|
}
|
|
585
728
|
|
|
586
729
|
await runHarnessJob(failures, record.harness, "enable", async () => {
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
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
|
+
}
|
|
594
741
|
}
|
|
595
742
|
|
|
596
743
|
delete record.disabled;
|
|
@@ -650,7 +797,8 @@ async function uninstall(flags) {
|
|
|
650
797
|
} else if (record.anthropic_token_stored) {
|
|
651
798
|
anthropicTokenNotice = true;
|
|
652
799
|
}
|
|
653
|
-
await
|
|
800
|
+
if (record.launch_available) await removePennyHarness(record.harness);
|
|
801
|
+
if (!record.launch_available || record.default_routing) await harness.uninstall(record);
|
|
654
802
|
console.log(`Removed ${record.harness}.`);
|
|
655
803
|
});
|
|
656
804
|
if (!ok) kept.push(record);
|
|
@@ -669,7 +817,8 @@ async function uninstall(flags) {
|
|
|
669
817
|
} else if (record.anthropic_token_stored) {
|
|
670
818
|
anthropicTokenNotice = true;
|
|
671
819
|
}
|
|
672
|
-
await
|
|
820
|
+
if (record.launch_available) await removePennyHarness(record.harness);
|
|
821
|
+
if (!record.launch_available || record.default_routing) await harness.uninstall(record);
|
|
673
822
|
console.log(`Removed ${record.harness}.`);
|
|
674
823
|
});
|
|
675
824
|
}
|
|
@@ -682,6 +831,7 @@ async function uninstall(flags) {
|
|
|
682
831
|
|
|
683
832
|
manifest.installations = kept;
|
|
684
833
|
await saveManifest(manifest);
|
|
834
|
+
if (!(await hasConfiguredPennyHarnesses())) await removePennyRuntime();
|
|
685
835
|
reportFailures(failures);
|
|
686
836
|
}
|
|
687
837
|
|
|
@@ -754,6 +904,11 @@ async function status() {
|
|
|
754
904
|
if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
|
|
755
905
|
if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
|
|
756
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
|
+
}
|
|
757
912
|
if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
|
|
758
913
|
}
|
|
759
914
|
}
|
|
@@ -994,7 +1149,10 @@ function formatError(error) {
|
|
|
994
1149
|
async function forgetStoredAnthropicToken(record) {
|
|
995
1150
|
if (record.harness !== "claude-code" || !record.anthropic_token_stored) return;
|
|
996
1151
|
try {
|
|
997
|
-
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;
|
|
998
1156
|
if (!gatewayBaseUrl || !apiKey) return;
|
|
999
1157
|
await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
|
|
1000
1158
|
} catch {
|
|
@@ -1098,11 +1256,37 @@ function runningClaudeCodeProcesses() {
|
|
|
1098
1256
|
.filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
|
|
1099
1257
|
}
|
|
1100
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
|
+
|
|
1101
1285
|
function printHelp() {
|
|
1102
1286
|
console.log(`PennyRouter CLI
|
|
1103
1287
|
|
|
1104
1288
|
Usage:
|
|
1105
|
-
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]
|
|
1106
1290
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
1107
1291
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
1108
1292
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
@@ -1111,7 +1295,8 @@ Usage:
|
|
|
1111
1295
|
|
|
1112
1296
|
Examples:
|
|
1113
1297
|
npx pennyrouter install
|
|
1114
|
-
npx pennyrouter install --
|
|
1298
|
+
npx pennyrouter install --make-default
|
|
1299
|
+
npx pennyrouter install --penny-only
|
|
1115
1300
|
npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
|
|
1116
1301
|
npx pennyrouter auth anthropic
|
|
1117
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
|
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,339 @@
|
|
|
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
|
+
|
|
23
|
+
export function pennyConfigDir() {
|
|
24
|
+
return join(process.env.XDG_CONFIG_HOME || join(homeDir(), ".config"), "pennyrouter");
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function pennyConfigPath() {
|
|
28
|
+
return join(pennyConfigDir(), "config.json");
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export function pennyDataDir() {
|
|
32
|
+
return join(process.env.XDG_DATA_HOME || join(homeDir(), ".local", "share"), "pennyrouter");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function pennyRuntimeDir() {
|
|
36
|
+
return join(pennyDataDir(), "runtime");
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function pennyBinDir() {
|
|
40
|
+
return join(homeDir(), ".local", "bin");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function pennyShimPath() {
|
|
44
|
+
return join(pennyBinDir(), "penny");
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function claudeLaunchSettingsPath() {
|
|
48
|
+
return join(pennyConfigDir(), "claude-settings.json");
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function codexLaunchProfilePath() {
|
|
52
|
+
const codexHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
|
|
53
|
+
return join(codexHome, "pennyrouter.config.toml");
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function claudePluginPath() {
|
|
57
|
+
return join(pennyRuntimeDir(), "claude-plugin");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function privateWrite(path, text) {
|
|
61
|
+
await mkdir(dirname(path), { recursive: true, mode: 0o700 });
|
|
62
|
+
await atomicWrite(path, text);
|
|
63
|
+
await chmod(path, 0o600);
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export async function loadPennyConfig() {
|
|
67
|
+
return (await readJsonFile(pennyConfigPath(), null)) || {
|
|
68
|
+
version: CONFIG_VERSION,
|
|
69
|
+
harnesses: {},
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function savePennyConfig(config) {
|
|
74
|
+
const next = { version: CONFIG_VERSION, ...config };
|
|
75
|
+
await privateWrite(pennyConfigPath(), `${JSON.stringify(next, null, 2)}\n`);
|
|
76
|
+
return next;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function packageRoot() {
|
|
80
|
+
return resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
async function copyRuntime() {
|
|
84
|
+
const source = packageRoot();
|
|
85
|
+
const destination = pennyRuntimeDir();
|
|
86
|
+
await mkdir(destination, { recursive: true, mode: 0o700 });
|
|
87
|
+
for (const entry of ["bin", "src", "claude-plugin"]) {
|
|
88
|
+
await cp(join(source, entry), join(destination, entry), {
|
|
89
|
+
recursive: true,
|
|
90
|
+
force: true,
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
async function writeShim() {
|
|
97
|
+
await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
|
|
98
|
+
const entry = join(pennyRuntimeDir(), "bin", "penny.js").replaceAll('"', '\\"');
|
|
99
|
+
await writeFile(pennyShimPath(), `#!/bin/sh\nexec node "${entry}" "$@"\n`, {
|
|
100
|
+
encoding: "utf8",
|
|
101
|
+
mode: 0o755,
|
|
102
|
+
});
|
|
103
|
+
await chmod(pennyShimPath(), 0o755);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function pathContains(dir) {
|
|
107
|
+
return String(process.env.PATH || "").split(":").some((candidate) => resolve(candidate) === resolve(dir));
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function shellRcPath() {
|
|
111
|
+
const shell = basename(process.env.SHELL || "");
|
|
112
|
+
if (shell === "zsh") return join(homeDir(), ".zshrc");
|
|
113
|
+
if (shell === "bash") return join(homeDir(), ".bashrc");
|
|
114
|
+
return null;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export async function ensurePennyOnPath({ updatePath = true } = {}) {
|
|
118
|
+
if (pathContains(pennyBinDir())) return { available: true, changed: false, rc_path: null };
|
|
119
|
+
if (!updatePath) return { available: false, changed: false, rc_path: null };
|
|
120
|
+
const rcPath = shellRcPath();
|
|
121
|
+
if (!rcPath) return { available: false, changed: false, rc_path: null };
|
|
122
|
+
const current = await readText(rcPath, "");
|
|
123
|
+
if (!current.includes(PATH_START)) {
|
|
124
|
+
const block = `${PATH_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_END}`;
|
|
125
|
+
const prefix = current && !current.endsWith("\n") ? "\n" : "";
|
|
126
|
+
await atomicWrite(rcPath, `${current}${prefix}${block}\n`);
|
|
127
|
+
}
|
|
128
|
+
return { available: true, changed: !current.includes(PATH_START), rc_path: rcPath };
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
export async function installPennyRuntime({ updatePath = true } = {}) {
|
|
132
|
+
if (!(["darwin", "linux"].includes(process.platform))) {
|
|
133
|
+
return { supported: false, available: false, changed: false, rc_path: null };
|
|
134
|
+
}
|
|
135
|
+
await copyRuntime();
|
|
136
|
+
await writeShim();
|
|
137
|
+
const path = await ensurePennyOnPath({ updatePath });
|
|
138
|
+
const config = await loadPennyConfig();
|
|
139
|
+
if (path.rc_path) {
|
|
140
|
+
config.path_rc = path.rc_path;
|
|
141
|
+
await savePennyConfig(config);
|
|
142
|
+
}
|
|
143
|
+
return { supported: true, ...path, shim_path: pennyShimPath() };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
147
|
+
const authLines = subscriptionAuth
|
|
148
|
+
? [
|
|
149
|
+
"requires_openai_auth = true",
|
|
150
|
+
'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }',
|
|
151
|
+
]
|
|
152
|
+
: ['env_key = "PENNYROUTER_API_KEY"'];
|
|
153
|
+
return [
|
|
154
|
+
CODEX_PROFILE_MARKER,
|
|
155
|
+
'model_provider = "pennyrouter"',
|
|
156
|
+
"",
|
|
157
|
+
"[model_providers.pennyrouter]",
|
|
158
|
+
'name = "PennyRouter"',
|
|
159
|
+
`base_url = ${JSON.stringify(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`)}`,
|
|
160
|
+
'wire_api = "responses"',
|
|
161
|
+
"supports_websockets = false",
|
|
162
|
+
...authLines,
|
|
163
|
+
"",
|
|
164
|
+
].join("\n");
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
async function writeClaudeLaunchSettings() {
|
|
168
|
+
const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
|
|
169
|
+
const overlay = { disableClaudeAiConnectors: true };
|
|
170
|
+
if (native.statusLine) overlay.statusLine = native.statusLine;
|
|
171
|
+
await configureClaudeCodeStatusline(overlay);
|
|
172
|
+
await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
|
|
176
|
+
const path = codexLaunchProfilePath();
|
|
177
|
+
const existing = await readText(path, "");
|
|
178
|
+
if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
|
|
179
|
+
throw new Error(
|
|
180
|
+
`${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
|
|
181
|
+
);
|
|
182
|
+
}
|
|
183
|
+
await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
export async function configurePennyHarness(harness, {
|
|
187
|
+
apiKey,
|
|
188
|
+
gatewayBaseUrl,
|
|
189
|
+
subscriptionAuth = false,
|
|
190
|
+
models = null,
|
|
191
|
+
}) {
|
|
192
|
+
const config = await loadPennyConfig();
|
|
193
|
+
config.gateway_base_url = gatewayBaseUrl;
|
|
194
|
+
config.api_key = apiKey;
|
|
195
|
+
config.harnesses ||= {};
|
|
196
|
+
if (harness === "claude-code") {
|
|
197
|
+
await writeClaudeLaunchSettings();
|
|
198
|
+
config.harnesses.claude = {
|
|
199
|
+
enabled: true,
|
|
200
|
+
subscription_auth: Boolean(subscriptionAuth),
|
|
201
|
+
models: models || {},
|
|
202
|
+
};
|
|
203
|
+
} else if (harness === "codex") {
|
|
204
|
+
await writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth });
|
|
205
|
+
config.harnesses.codex = {
|
|
206
|
+
enabled: true,
|
|
207
|
+
subscription_auth: Boolean(subscriptionAuth),
|
|
208
|
+
};
|
|
209
|
+
} else {
|
|
210
|
+
throw new Error(`The penny command does not support ${harness}.`);
|
|
211
|
+
}
|
|
212
|
+
await savePennyConfig(config);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
export async function setPennyHarnessEnabled(harness, enabled) {
|
|
216
|
+
const config = await loadPennyConfig();
|
|
217
|
+
const key = harness === "claude-code" ? "claude" : harness;
|
|
218
|
+
if (config.harnesses?.[key]) {
|
|
219
|
+
config.harnesses[key].enabled = Boolean(enabled);
|
|
220
|
+
await savePennyConfig(config);
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
export async function removePennyHarness(harness) {
|
|
225
|
+
const config = await loadPennyConfig();
|
|
226
|
+
const key = harness === "claude-code" ? "claude" : harness;
|
|
227
|
+
if (harness === "claude-code") {
|
|
228
|
+
await unlink(claudeLaunchSettingsPath()).catch((error) => {
|
|
229
|
+
if (error.code !== "ENOENT") throw error;
|
|
230
|
+
});
|
|
231
|
+
} else if (harness === "codex") {
|
|
232
|
+
const existing = await readText(codexLaunchProfilePath(), "");
|
|
233
|
+
if (existing.startsWith(CODEX_PROFILE_MARKER)) {
|
|
234
|
+
await unlink(codexLaunchProfilePath()).catch((error) => {
|
|
235
|
+
if (error.code !== "ENOENT") throw error;
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
if (config.harnesses) delete config.harnesses[key];
|
|
240
|
+
await savePennyConfig(config);
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
export async function hasConfiguredPennyHarnesses() {
|
|
244
|
+
const config = await loadPennyConfig();
|
|
245
|
+
return Object.keys(config.harnesses || {}).length > 0;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async function removeManagedPathBlock(rcPath) {
|
|
249
|
+
if (!rcPath) return;
|
|
250
|
+
const current = await readText(rcPath, "");
|
|
251
|
+
if (!current.includes(PATH_START)) return;
|
|
252
|
+
const escapedStart = PATH_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
253
|
+
const escapedEnd = PATH_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
254
|
+
const next = current
|
|
255
|
+
.replace(new RegExp(`(?:^|\\n)${escapedStart}\\n[\\s\\S]*?\\n${escapedEnd}\\n?`, "m"), "\n")
|
|
256
|
+
.replace(/^\n+/, "");
|
|
257
|
+
await atomicWrite(rcPath, next);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
export async function removePennyRuntime() {
|
|
261
|
+
const config = await loadPennyConfig();
|
|
262
|
+
await removeManagedPathBlock(config.path_rc);
|
|
263
|
+
await unlink(pennyShimPath()).catch((error) => {
|
|
264
|
+
if (error.code !== "ENOENT") throw error;
|
|
265
|
+
});
|
|
266
|
+
await rm(pennyRuntimeDir(), { recursive: true, force: true });
|
|
267
|
+
await unlink(pennyConfigPath()).catch((error) => {
|
|
268
|
+
if (error.code !== "ENOENT") throw error;
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
function assertNoConflictingArgs(harness, args) {
|
|
273
|
+
if (harness === "claude" && args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
|
|
274
|
+
throw new Error("penny claude manages --settings; remove that flag or run native claude.");
|
|
275
|
+
}
|
|
276
|
+
if (harness === "codex" && args.some((arg) => arg === "--profile" || arg === "-p" || arg.startsWith("--profile="))) {
|
|
277
|
+
throw new Error("penny codex manages --profile; remove that flag or run native codex.");
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
export function buildPennyInvocation(harness, args, config) {
|
|
282
|
+
const item = config.harnesses?.[harness];
|
|
283
|
+
if (!item || !item.enabled) {
|
|
284
|
+
throw new Error(`PennyRouter is not enabled for ${harness}. Run npx pennyrouter install.`);
|
|
285
|
+
}
|
|
286
|
+
assertNoConflictingArgs(harness, args);
|
|
287
|
+
const env = { ...process.env, PENNYROUTER_API_KEY: config.api_key };
|
|
288
|
+
if (harness === "claude") {
|
|
289
|
+
const models = item.models || {};
|
|
290
|
+
env.ANTHROPIC_BASE_URL = config.gateway_base_url;
|
|
291
|
+
env.ANTHROPIC_AUTH_TOKEN = config.api_key;
|
|
292
|
+
delete env.ANTHROPIC_API_KEY;
|
|
293
|
+
if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haiku;
|
|
294
|
+
if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnet;
|
|
295
|
+
if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opus;
|
|
296
|
+
if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = models.fable;
|
|
297
|
+
return {
|
|
298
|
+
command: "claude",
|
|
299
|
+
args: ["--settings", claudeLaunchSettingsPath(), "--plugin-dir", claudePluginPath(), ...args],
|
|
300
|
+
env,
|
|
301
|
+
};
|
|
302
|
+
}
|
|
303
|
+
if (harness === "codex") {
|
|
304
|
+
return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
|
|
305
|
+
}
|
|
306
|
+
throw new Error(`Unknown Penny harness "${harness}". Try penny claude or penny codex.`);
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
async function waitForChild(child) {
|
|
310
|
+
return new Promise((resolvePromise, reject) => {
|
|
311
|
+
child.once("error", reject);
|
|
312
|
+
child.once("exit", (code, signal) => resolvePromise({ code, signal }));
|
|
313
|
+
});
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
export async function runPenny(argv = process.argv.slice(2)) {
|
|
317
|
+
const harness = argv[0];
|
|
318
|
+
if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
|
|
319
|
+
console.log("Usage: penny <claude|codex> [native arguments...]");
|
|
320
|
+
return 0;
|
|
321
|
+
}
|
|
322
|
+
if (!(["claude", "codex"].includes(harness))) {
|
|
323
|
+
throw new Error(`Unknown harness "${harness}". Try penny claude or penny codex.`);
|
|
324
|
+
}
|
|
325
|
+
const config = await loadPennyConfig();
|
|
326
|
+
const invocation = buildPennyInvocation(harness, argv.slice(1), config);
|
|
327
|
+
const child = spawn(invocation.command, invocation.args, {
|
|
328
|
+
cwd: process.cwd(),
|
|
329
|
+
env: invocation.env,
|
|
330
|
+
stdio: "inherit",
|
|
331
|
+
});
|
|
332
|
+
const result = await waitForChild(child);
|
|
333
|
+
if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
|
|
334
|
+
return result.code ?? 1;
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export async function fileMode(path) {
|
|
338
|
+
return (await stat(path)).mode & 0o777;
|
|
339
|
+
}
|
|
@@ -0,0 +1,140 @@
|
|
|
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
|
+
claudePluginPath,
|
|
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 second = await installPennyRuntime();
|
|
60
|
+
assert.equal(first.supported, true);
|
|
61
|
+
assert.equal(first.changed, true);
|
|
62
|
+
assert.equal(second.changed, false);
|
|
63
|
+
assert.equal(await fileMode(pennyShimPath()), 0o755);
|
|
64
|
+
assert.ok(fs.existsSync(path.join(pennyRuntimeDir(), "bin", "penny.js")));
|
|
65
|
+
assert.ok(fs.existsSync(path.join(claudePluginPath(), ".claude-plugin", "plugin.json")));
|
|
66
|
+
const rc = fs.readFileSync(path.join(home, ".zshrc"), "utf8");
|
|
67
|
+
assert.equal((rc.match(/>>> pennyrouter PATH >>>/g) || []).length, 1);
|
|
68
|
+
|
|
69
|
+
await configurePennyHarness("claude-code", {
|
|
70
|
+
apiKey: "pr-super-secret",
|
|
71
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
72
|
+
subscriptionAuth: true,
|
|
73
|
+
models: { haiku: "Penny Haiku", sonnet: "Penny Sonnet", opus: "Penny Opus" },
|
|
74
|
+
});
|
|
75
|
+
await configurePennyHarness("codex", {
|
|
76
|
+
apiKey: "pr-super-secret",
|
|
77
|
+
gatewayBaseUrl: "https://gateway.example/",
|
|
78
|
+
subscriptionAuth: true,
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
|
|
82
|
+
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
83
|
+
assert.equal(await fileMode(pennyConfigPath()), 0o600);
|
|
84
|
+
assert.equal(await fileMode(claudeLaunchSettingsPath()), 0o600);
|
|
85
|
+
assert.equal(await fileMode(codexLaunchProfilePath()), 0o600);
|
|
86
|
+
|
|
87
|
+
const config = await loadPennyConfig();
|
|
88
|
+
assert.equal(config.api_key, "pr-super-secret");
|
|
89
|
+
const codexProfile = fs.readFileSync(codexLaunchProfilePath(), "utf8");
|
|
90
|
+
assert.ok(codexProfile.includes('env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }'));
|
|
91
|
+
assert.ok(!codexProfile.includes("pr-super-secret"));
|
|
92
|
+
assert.ok(!rc.includes("pr-super-secret"));
|
|
93
|
+
|
|
94
|
+
const claude = buildPennyInvocation("claude", ["--resume", "abc"], config);
|
|
95
|
+
assert.equal(claude.command, "claude");
|
|
96
|
+
assert.deepEqual(claude.args.slice(-2), ["--resume", "abc"]);
|
|
97
|
+
assert.ok(claude.args.includes("--plugin-dir"));
|
|
98
|
+
assert.equal(claude.env.ANTHROPIC_AUTH_TOKEN, "pr-super-secret");
|
|
99
|
+
assert.equal(claude.env.ANTHROPIC_BASE_URL, "https://gateway.example/");
|
|
100
|
+
assert.equal(claude.env.ANTHROPIC_DEFAULT_OPUS_MODEL, "Penny Opus");
|
|
101
|
+
|
|
102
|
+
const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
|
|
103
|
+
assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
|
|
104
|
+
assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
|
|
105
|
+
assert.throws(() => buildPennyInvocation("claude", ["--settings=x"], config), /manages --settings/);
|
|
106
|
+
assert.throws(() => buildPennyInvocation("codex", ["-p", "other"], config), /manages --profile/);
|
|
107
|
+
|
|
108
|
+
const skill = fs.readFileSync(path.join(claudePluginPath(), "skills", "penny", "SKILL.md"), "utf8");
|
|
109
|
+
assert.ok(skill.includes("@penny $ARGUMENTS"));
|
|
110
|
+
|
|
111
|
+
await removePennyHarness("claude-code");
|
|
112
|
+
assert.ok(fs.existsSync(codexLaunchProfilePath()));
|
|
113
|
+
assert.ok(fs.existsSync(pennyShimPath()));
|
|
114
|
+
await removePennyHarness("codex");
|
|
115
|
+
await removePennyRuntime();
|
|
116
|
+
assert.ok(!fs.existsSync(pennyShimPath()));
|
|
117
|
+
assert.ok(!fs.existsSync(pennyRuntimeDir()));
|
|
118
|
+
assert.ok(!fs.existsSync(pennyConfigPath()));
|
|
119
|
+
assert.equal(fs.readFileSync(path.join(home, ".zshrc"), "utf8"), "# user setting\n");
|
|
120
|
+
|
|
121
|
+
const paid = renderCodexLaunchProfile({
|
|
122
|
+
gatewayBaseUrl: "https://gateway.example/", subscriptionAuth: false,
|
|
123
|
+
});
|
|
124
|
+
assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
|
|
125
|
+
assert.ok(!paid.includes("requires_openai_auth"));
|
|
126
|
+
} finally {
|
|
127
|
+
for (const key of ENV_KEYS) {
|
|
128
|
+
if (previous[key] === undefined) delete process.env[key];
|
|
129
|
+
else process.env[key] = previous[key];
|
|
130
|
+
}
|
|
131
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
console.log("launch.test.js: all checks passed");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
main().catch((error) => {
|
|
138
|
+
console.error(error);
|
|
139
|
+
process.exit(1);
|
|
140
|
+
});
|
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
|
-
|