pennyrouter 0.2.7 → 0.2.9

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/src/launch.js CHANGED
@@ -1,393 +1,487 @@
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 claudeUserSkillPath() {
58
- return join(homeDir(), ".claude", "skills", "penny", "SKILL.md");
59
- }
60
-
61
- function legacyClaudeUserCommandPath() {
62
- return join(homeDir(), ".claude", "commands", "penny.md");
63
- }
64
-
65
- async function privateWrite(path, text) {
66
- await mkdir(dirname(path), { recursive: true, mode: 0o700 });
67
- await atomicWrite(path, text);
68
- await chmod(path, 0o600);
69
- }
70
-
71
- export async function loadPennyConfig() {
72
- return (await readJsonFile(pennyConfigPath(), null)) || {
73
- version: CONFIG_VERSION,
74
- harnesses: {},
75
- };
76
- }
77
-
78
- export async function savePennyConfig(config) {
79
- const next = { version: CONFIG_VERSION, ...config };
80
- await privateWrite(pennyConfigPath(), `${JSON.stringify(next, null, 2)}\n`);
81
- return next;
82
- }
83
-
84
- function packageRoot() {
85
- return resolve(dirname(fileURLToPath(import.meta.url)), "..");
86
- }
87
-
88
- async function copyRuntime() {
89
- const source = packageRoot();
90
- const destination = pennyRuntimeDir();
91
- await mkdir(destination, { recursive: true, mode: 0o700 });
92
- for (const entry of ["bin", "src"]) {
93
- // Runtime upgrades must not merge with the previous package. In particular,
94
- // 0.2.1 moved /penny from a skill to a command; leaving the old skill behind
95
- // makes Claude discover two incompatible definitions.
96
- await rm(join(destination, entry), { recursive: true, force: true });
97
- await cp(join(source, entry), join(destination, entry), {
98
- recursive: true,
99
- force: true,
100
- });
101
- }
102
- // Remove plugin assets used by 0.2.0/0.2.1. /penny is now a managed personal
103
- // command so it can use the exact unnamespaced name in every Claude session.
104
- await rm(join(destination, "claude-plugin"), { recursive: true, force: true });
105
- await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
106
- }
107
-
108
- async function writeShim() {
109
- await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
110
- const entry = join(pennyRuntimeDir(), "bin", "penny.js").replaceAll('"', '\\"');
111
- await writeFile(pennyShimPath(), `#!/bin/sh\nexec node "${entry}" "$@"\n`, {
112
- encoding: "utf8",
113
- mode: 0o755,
114
- });
115
- await chmod(pennyShimPath(), 0o755);
116
- }
117
-
118
- function pathContains(dir) {
119
- return String(process.env.PATH || "").split(":").some((candidate) => resolve(candidate) === resolve(dir));
120
- }
121
-
122
- function shellRcPath() {
123
- const shell = basename(process.env.SHELL || "");
124
- if (shell === "zsh") return join(homeDir(), ".zshrc");
125
- if (shell === "bash") return join(homeDir(), ".bashrc");
126
- return null;
127
- }
128
-
129
- export async function ensurePennyOnPath({ updatePath = true } = {}) {
130
- if (pathContains(pennyBinDir())) return { available: true, changed: false, rc_path: null };
131
- if (!updatePath) return { available: false, changed: false, rc_path: null };
132
- const rcPath = shellRcPath();
133
- if (!rcPath) return { available: false, changed: false, rc_path: null };
134
- const current = await readText(rcPath, "");
135
- if (!current.includes(PATH_START)) {
136
- const block = `${PATH_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_END}`;
137
- const prefix = current && !current.endsWith("\n") ? "\n" : "";
138
- await atomicWrite(rcPath, `${current}${prefix}${block}\n`);
139
- }
140
- return { available: true, changed: !current.includes(PATH_START), rc_path: rcPath };
141
- }
142
-
143
- export async function installPennyRuntime({ updatePath = true } = {}) {
144
- if (!(["darwin", "linux"].includes(process.platform))) {
145
- return { supported: false, available: false, changed: false, rc_path: null };
146
- }
147
- await copyRuntime();
148
- await writeShim();
149
- const path = await ensurePennyOnPath({ updatePath });
150
- const config = await loadPennyConfig();
151
- if (path.rc_path) {
152
- config.path_rc = path.rc_path;
153
- await savePennyConfig(config);
154
- }
155
- return { supported: true, ...path, shim_path: pennyShimPath() };
156
- }
157
-
158
- export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
159
- const authLines = subscriptionAuth
160
- ? [
161
- "requires_openai_auth = true",
162
- 'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }',
163
- ]
164
- : ['env_key = "PENNYROUTER_API_KEY"'];
165
- return [
166
- CODEX_PROFILE_MARKER,
167
- 'model_provider = "pennyrouter"',
168
- "",
169
- "[model_providers.pennyrouter]",
170
- 'name = "PennyRouter"',
171
- `base_url = ${JSON.stringify(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`)}`,
172
- 'wire_api = "responses"',
173
- "supports_websockets = false",
174
- ...authLines,
175
- "",
176
- ].join("\n");
177
- }
178
-
179
- async function writeClaudeLaunchSettings() {
180
- const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
181
- const overlay = { disableClaudeAiConnectors: true };
182
- if (native.statusLine) overlay.statusLine = native.statusLine;
183
- await configureClaudeCodeStatusline(overlay);
184
- await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
185
- }
186
-
187
- async function writeClaudeUserSkill() {
188
- const path = claudeUserSkillPath();
189
- const existing = await readText(path, "");
190
- if (existing.trim() && !existing.includes(CLAUDE_COMMAND_MARKER)) {
191
- throw new Error(
192
- `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
193
- );
194
- }
195
- await mkdir(dirname(path), { recursive: true });
196
- await atomicWrite(path, [
197
- "---",
198
- "name: penny",
199
- "description: PennyRouter commands and help.",
200
- "argument-hint: <command and arguments>",
201
- "disable-model-invocation: true",
202
- "---",
203
- CLAUDE_COMMAND_MARKER,
204
- "",
205
- "@penny $ARGUMENTS",
206
- "",
207
- ].join("\n"));
208
- const legacyPath = legacyClaudeUserCommandPath();
209
- const legacy = await readText(legacyPath, "");
210
- if (legacy.includes(CLAUDE_COMMAND_MARKER)) {
211
- await unlink(legacyPath).catch((error) => {
212
- if (error.code !== "ENOENT") throw error;
213
- });
214
- }
215
- }
216
-
217
- async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
218
- const path = codexLaunchProfilePath();
219
- const existing = await readText(path, "");
220
- if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
221
- throw new Error(
222
- `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
223
- );
224
- }
225
- await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
226
- }
227
-
228
- export async function configurePennyHarness(harness, {
229
- apiKey,
230
- gatewayBaseUrl,
231
- subscriptionAuth = false,
232
- models = null,
233
- }) {
234
- const config = await loadPennyConfig();
235
- config.gateway_base_url = gatewayBaseUrl;
236
- config.api_key = apiKey;
237
- config.harnesses ||= {};
238
- if (harness === "claude-code") {
239
- await writeClaudeUserSkill();
240
- await writeClaudeLaunchSettings();
241
- config.harnesses.claude = {
242
- enabled: true,
243
- subscription_auth: Boolean(subscriptionAuth),
244
- models: models || {},
245
- };
246
- } else if (harness === "codex") {
247
- await writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth });
248
- config.harnesses.codex = {
249
- enabled: true,
250
- subscription_auth: Boolean(subscriptionAuth),
251
- };
252
- } else {
253
- throw new Error(`The penny command does not support ${harness}.`);
254
- }
255
- await savePennyConfig(config);
256
- }
257
-
258
- export async function setPennyHarnessEnabled(harness, enabled) {
259
- const config = await loadPennyConfig();
260
- const key = harness === "claude-code" ? "claude" : harness;
261
- if (config.harnesses?.[key]) {
262
- config.harnesses[key].enabled = Boolean(enabled);
263
- await savePennyConfig(config);
264
- }
265
- }
266
-
267
- export async function removePennyHarness(harness) {
268
- const config = await loadPennyConfig();
269
- const key = harness === "claude-code" ? "claude" : harness;
270
- if (harness === "claude-code") {
271
- await unlink(claudeLaunchSettingsPath()).catch((error) => {
272
- if (error.code !== "ENOENT") throw error;
273
- });
274
- for (const path of [claudeUserSkillPath(), legacyClaudeUserCommandPath()]) {
275
- const command = await readText(path, "");
276
- if (!command.includes(CLAUDE_COMMAND_MARKER)) continue;
277
- await unlink(path).catch((error) => {
278
- if (error.code !== "ENOENT") throw error;
279
- });
280
- }
281
- } else if (harness === "codex") {
282
- const existing = await readText(codexLaunchProfilePath(), "");
283
- if (existing.startsWith(CODEX_PROFILE_MARKER)) {
284
- await unlink(codexLaunchProfilePath()).catch((error) => {
285
- if (error.code !== "ENOENT") throw error;
286
- });
287
- }
288
- }
289
- if (config.harnesses) delete config.harnesses[key];
290
- await savePennyConfig(config);
291
- }
292
-
293
- export async function hasConfiguredPennyHarnesses() {
294
- const config = await loadPennyConfig();
295
- return Object.keys(config.harnesses || {}).length > 0;
296
- }
297
-
298
- async function removeManagedPathBlock(rcPath) {
299
- if (!rcPath) return;
300
- const current = await readText(rcPath, "");
301
- if (!current.includes(PATH_START)) return;
302
- const escapedStart = PATH_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
303
- const escapedEnd = PATH_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
304
- const next = current
305
- .replace(new RegExp(`(?:^|\\n)${escapedStart}\\n[\\s\\S]*?\\n${escapedEnd}\\n?`, "m"), "\n")
306
- .replace(/^\n+/, "");
307
- await atomicWrite(rcPath, next);
308
- }
309
-
310
- export async function removePennyRuntime() {
311
- const config = await loadPennyConfig();
312
- await removeManagedPathBlock(config.path_rc);
313
- await unlink(pennyShimPath()).catch((error) => {
314
- if (error.code !== "ENOENT") throw error;
315
- });
316
- await rm(pennyRuntimeDir(), { recursive: true, force: true });
317
- await unlink(pennyConfigPath()).catch((error) => {
318
- if (error.code !== "ENOENT") throw error;
319
- });
320
- }
321
-
322
- function assertNoConflictingArgs(harness, args) {
323
- if (harness === "claude" && args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
324
- throw new Error("penny claude manages --settings; remove that flag or run native claude.");
325
- }
326
- if (harness === "codex" && args.some((arg) => arg === "--profile" || arg === "-p" || arg.startsWith("--profile="))) {
327
- throw new Error("penny codex manages --profile; remove that flag or run native codex.");
328
- }
329
- }
330
-
331
- export function buildPennyInvocation(harness, args, config) {
332
- const item = config.harnesses?.[harness];
333
- if (!item || !item.enabled) {
334
- throw new Error(`PennyRouter is not enabled for ${harness}. Run npx pennyrouter install.`);
335
- }
336
- assertNoConflictingArgs(harness, args);
337
- const env = { ...process.env, PENNYROUTER_API_KEY: config.api_key };
338
- if (harness === "claude") {
339
- const models = item.models || {};
340
- env.ANTHROPIC_BASE_URL = config.gateway_base_url;
341
- env.ANTHROPIC_AUTH_TOKEN = config.api_key;
342
- delete env.ANTHROPIC_API_KEY;
343
- if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haiku;
344
- if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnet;
345
- if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opus;
346
- if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = models.fable;
347
- // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
348
- // context window from the name and falls back to a conservative 200K default. Tell it
349
- // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
350
- env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
351
- return {
352
- command: "claude",
353
- args: ["--settings", claudeLaunchSettingsPath(), ...args],
354
- env,
355
- };
356
- }
357
- if (harness === "codex") {
358
- return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
359
- }
360
- throw new Error(`Unknown Penny harness "${harness}". Try penny claude or penny codex.`);
361
- }
362
-
363
- async function waitForChild(child) {
364
- return new Promise((resolvePromise, reject) => {
365
- child.once("error", reject);
366
- child.once("exit", (code, signal) => resolvePromise({ code, signal }));
367
- });
368
- }
369
-
370
- export async function runPenny(argv = process.argv.slice(2)) {
371
- const harness = argv[0];
372
- if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
373
- console.log("Usage: penny <claude|codex> [native arguments...]");
374
- return 0;
375
- }
376
- if (!(["claude", "codex"].includes(harness))) {
377
- throw new Error(`Unknown harness "${harness}". Try penny claude or penny codex.`);
378
- }
379
- const config = await loadPennyConfig();
380
- const invocation = buildPennyInvocation(harness, argv.slice(1), config);
381
- const child = spawn(invocation.command, invocation.args, {
382
- cwd: process.cwd(),
383
- env: invocation.env,
384
- stdio: "inherit",
385
- });
386
- const result = await waitForChild(child);
387
- if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
388
- return result.code ?? 1;
389
- }
390
-
391
- export async function fileMode(path) {
392
- return (await stat(path)).mode & 0o777;
393
- }
1
+ import { spawn, spawnSync } 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
+ if (process.platform === "win32") {
26
+ return join(process.env.APPDATA || process.env.LOCALAPPDATA || homeDir(), "PennyRouter");
27
+ }
28
+ return join(process.env.XDG_CONFIG_HOME || join(homeDir(), ".config"), "pennyrouter");
29
+ }
30
+
31
+ export function pennyConfigPath() {
32
+ return join(pennyConfigDir(), "config.json");
33
+ }
34
+
35
+ export function pennyDataDir() {
36
+ if (process.platform === "win32") {
37
+ return join(process.env.LOCALAPPDATA || process.env.APPDATA || homeDir(), "PennyRouter");
38
+ }
39
+ return join(process.env.XDG_DATA_HOME || join(homeDir(), ".local", "share"), "pennyrouter");
40
+ }
41
+
42
+ export function pennyRuntimeDir() {
43
+ return join(pennyDataDir(), "runtime");
44
+ }
45
+
46
+ export function pennyBinDir() {
47
+ if (process.platform === "win32") return join(pennyDataDir(), "bin");
48
+ return join(homeDir(), ".local", "bin");
49
+ }
50
+
51
+ export function pennyShimPath() {
52
+ return join(pennyBinDir(), process.platform === "win32" ? "penny.cmd" : "penny");
53
+ }
54
+
55
+ export function claudeLaunchSettingsPath() {
56
+ return join(pennyConfigDir(), "claude-settings.json");
57
+ }
58
+
59
+ export function codexLaunchProfilePath() {
60
+ const codexHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
61
+ return join(codexHome, "pennyrouter.config.toml");
62
+ }
63
+
64
+ export function claudeUserSkillPath() {
65
+ return join(homeDir(), ".claude", "skills", "penny", "SKILL.md");
66
+ }
67
+
68
+ function legacyClaudeUserCommandPath() {
69
+ return join(homeDir(), ".claude", "commands", "penny.md");
70
+ }
71
+
72
+ async function privateWrite(path, text) {
73
+ await mkdir(dirname(path), { recursive: true, mode: 0o700 });
74
+ await atomicWrite(path, text);
75
+ await chmod(path, 0o600);
76
+ }
77
+
78
+ export async function loadPennyConfig() {
79
+ return (await readJsonFile(pennyConfigPath(), null)) || {
80
+ version: CONFIG_VERSION,
81
+ harnesses: {},
82
+ };
83
+ }
84
+
85
+ export async function savePennyConfig(config) {
86
+ const next = { version: CONFIG_VERSION, ...config };
87
+ await privateWrite(pennyConfigPath(), `${JSON.stringify(next, null, 2)}\n`);
88
+ return next;
89
+ }
90
+
91
+ function packageRoot() {
92
+ return resolve(dirname(fileURLToPath(import.meta.url)), "..");
93
+ }
94
+
95
+ async function copyRuntime() {
96
+ const source = packageRoot();
97
+ const destination = pennyRuntimeDir();
98
+ await mkdir(destination, { recursive: true, mode: 0o700 });
99
+ for (const entry of ["bin", "src"]) {
100
+ // Runtime upgrades must not merge with the previous package. In particular,
101
+ // 0.2.1 moved /penny from a skill to a command; leaving the old skill behind
102
+ // makes Claude discover two incompatible definitions.
103
+ await rm(join(destination, entry), { recursive: true, force: true });
104
+ await cp(join(source, entry), join(destination, entry), {
105
+ recursive: true,
106
+ force: true,
107
+ });
108
+ }
109
+ // Remove plugin assets used by 0.2.0/0.2.1. /penny is now a managed personal
110
+ // command so it can use the exact unnamespaced name in every Claude session.
111
+ await rm(join(destination, "claude-plugin"), { recursive: true, force: true });
112
+ await cp(join(source, "package.json"), join(destination, "package.json"), { force: true });
113
+ }
114
+
115
+ async function writeShim() {
116
+ await mkdir(pennyBinDir(), { recursive: true, mode: 0o755 });
117
+ const entry = join(pennyRuntimeDir(), "bin", "penny.js");
118
+ const body = process.platform === "win32"
119
+ // `node` and the native harnesses are normally npm .cmd shims on Windows. cmd.exe
120
+ // resolves those through PATHEXT, while Node's direct spawn does not.
121
+ ? `@echo off\r\nnode "${entry.replaceAll('"', '""')}" %*\r\n`
122
+ : `#!/bin/sh\nexec node "${entry.replaceAll('"', '\\"')}" "$@"\n`;
123
+ await writeFile(pennyShimPath(), body, {
124
+ encoding: "utf8",
125
+ mode: 0o755,
126
+ });
127
+ if (process.platform !== "win32") await chmod(pennyShimPath(), 0o755);
128
+ }
129
+
130
+ function pathContains(dir) {
131
+ const delimiter = process.platform === "win32" ? ";" : ":";
132
+ const normalized = process.platform === "win32" ? String(dir).toLowerCase() : resolve(dir);
133
+ return String(process.env.PATH || "").split(delimiter).some((candidate) => {
134
+ const value = candidate.trim().replace(/^"|"$/g, "");
135
+ return process.platform === "win32"
136
+ ? value.toLowerCase() === normalized
137
+ : resolve(value) === normalized;
138
+ });
139
+ }
140
+
141
+ function readWindowsUserPath() {
142
+ const result = spawnSync("reg", ["query", "HKCU\\Environment", "/v", "Path"], {
143
+ encoding: "utf8", windowsHide: true,
144
+ });
145
+ if (result.status !== 0) return "";
146
+ const line = String(result.stdout || "").split(/\r?\n/).find((item) => /\bPath\s+REG_/i.test(item));
147
+ return line ? line.replace(/^\s*Path\s+REG_\S+\s*/i, "").trim() : "";
148
+ }
149
+
150
+ function writeWindowsUserPath(value) {
151
+ const result = spawnSync("reg", [
152
+ "add", "HKCU\\Environment", "/v", "Path", "/t", "REG_EXPAND_SZ", "/d", value, "/f",
153
+ ], { stdio: "ignore", windowsHide: true });
154
+ if (result.status !== 0) throw new Error("Could not add PennyRouter to the Windows user PATH.");
155
+ broadcastEnvironmentChange();
156
+ }
157
+
158
+ // user32.dll,UpdatePerUserSystemParameters refreshes desktop/profile parameters (wallpaper,
159
+ // etc.) — it is NOT the env var broadcast and does nothing for PATH. The actual mechanism
160
+ // (what setx.exe and the System Properties dialog use) is a WM_SETTINGCHANGE broadcast with
161
+ // lParam "Environment", which only Win32 SendMessageTimeout can send. Shell out to a tiny
162
+ // inline PowerShell snippet to make that call. New processes started by an already-running
163
+ // shell host (Explorer, Windows Terminal) still won't see the change until that host itself
164
+ // receives and acts on the broadcast, but this is what makes a freshly-opened terminal work.
165
+ function broadcastEnvironmentChange() {
166
+ const script = [
167
+ "$sig = '[DllImport(\"user32.dll\", SetLastError = true, CharSet = CharSet.Auto)]",
168
+ "public static extern IntPtr SendMessageTimeout(IntPtr hWnd, uint Msg, UIntPtr wParam,",
169
+ "string lParam, uint fuFlags, uint uTimeout, out UIntPtr lpdwResult);'",
170
+ "$type = Add-Type -MemberDefinition $sig -Name NativeMethods -Namespace Win32 -PassThru",
171
+ "$result = [UIntPtr]::Zero",
172
+ "$HWND_BROADCAST = [IntPtr]0xffff",
173
+ "$WM_SETTINGCHANGE = 0x1a",
174
+ "$SMTO_ABORTIFHUNG = 0x2",
175
+ "$type::SendMessageTimeout($HWND_BROADCAST, $WM_SETTINGCHANGE, [UIntPtr]::Zero, 'Environment', $SMTO_ABORTIFHUNG, 5000, [ref]$result) | Out-Null",
176
+ ].join("\n");
177
+ spawnSync("powershell.exe", ["-NoProfile", "-NonInteractive", "-Command", script], {
178
+ stdio: "ignore", windowsHide: true,
179
+ });
180
+ }
181
+
182
+ function windowsPathContains(pathValue, dir) {
183
+ const wanted = String(dir).toLowerCase();
184
+ return String(pathValue || "").split(";").some((item) => item.trim().replace(/^"|"$/g, "").toLowerCase() === wanted);
185
+ }
186
+
187
+ async function ensurePennyOnWindowsPath({ updatePath }) {
188
+ if (pathContains(pennyBinDir())) return { available: true, changed: false, path_managed: false };
189
+ const current = readWindowsUserPath();
190
+ if (windowsPathContains(current, pennyBinDir())) return { available: true, changed: false, path_managed: false };
191
+ if (!updatePath) return { available: false, changed: false, path_managed: false };
192
+ const next = current ? `${current};${pennyBinDir()}` : pennyBinDir();
193
+ writeWindowsUserPath(next);
194
+ return { available: true, changed: true, path_managed: true };
195
+ }
196
+
197
+ function shellRcPath() {
198
+ const shell = basename(process.env.SHELL || "");
199
+ if (shell === "zsh") return join(homeDir(), ".zshrc");
200
+ if (shell === "bash") return join(homeDir(), ".bashrc");
201
+ return null;
202
+ }
203
+
204
+ export async function ensurePennyOnPath({ updatePath = true } = {}) {
205
+ if (process.platform === "win32") return ensurePennyOnWindowsPath({ updatePath });
206
+ if (pathContains(pennyBinDir())) return { available: true, changed: false, rc_path: null };
207
+ if (!updatePath) return { available: false, changed: false, rc_path: null };
208
+ const rcPath = shellRcPath();
209
+ if (!rcPath) return { available: false, changed: false, rc_path: null };
210
+ const current = await readText(rcPath, "");
211
+ if (!current.includes(PATH_START)) {
212
+ const block = `${PATH_START}\nexport PATH="$HOME/.local/bin:$PATH"\n${PATH_END}`;
213
+ const prefix = current && !current.endsWith("\n") ? "\n" : "";
214
+ await atomicWrite(rcPath, `${current}${prefix}${block}\n`);
215
+ }
216
+ return { available: true, changed: !current.includes(PATH_START), rc_path: rcPath };
217
+ }
218
+
219
+ export async function installPennyRuntime({ updatePath = true } = {}) {
220
+ if (!(["darwin", "linux", "win32"].includes(process.platform))) {
221
+ return { supported: false, available: false, changed: false, rc_path: null };
222
+ }
223
+ await copyRuntime();
224
+ await writeShim();
225
+ const path = await ensurePennyOnPath({ updatePath });
226
+ const config = await loadPennyConfig();
227
+ if (path.rc_path) {
228
+ config.path_rc = path.rc_path;
229
+ await savePennyConfig(config);
230
+ }
231
+ if (process.platform === "win32" && path.path_managed) {
232
+ config.windows_path_managed = true;
233
+ await savePennyConfig(config);
234
+ }
235
+ return { supported: true, ...path, shim_path: pennyShimPath() };
236
+ }
237
+
238
+ export function renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
239
+ const authLines = subscriptionAuth
240
+ ? [
241
+ "requires_openai_auth = true",
242
+ 'env_http_headers = { "x-pennyrouter-key" = "PENNYROUTER_API_KEY" }',
243
+ ]
244
+ : ['env_key = "PENNYROUTER_API_KEY"'];
245
+ return [
246
+ CODEX_PROFILE_MARKER,
247
+ 'model_provider = "pennyrouter"',
248
+ "",
249
+ "[model_providers.pennyrouter]",
250
+ 'name = "PennyRouter"',
251
+ `base_url = ${JSON.stringify(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`)}`,
252
+ 'wire_api = "responses"',
253
+ "supports_websockets = false",
254
+ ...authLines,
255
+ "",
256
+ ].join("\n");
257
+ }
258
+
259
+ async function writeClaudeLaunchSettings() {
260
+ const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
261
+ const overlay = { disableClaudeAiConnectors: true };
262
+ if (native.statusLine) overlay.statusLine = native.statusLine;
263
+ await configureClaudeCodeStatusline(overlay);
264
+ await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
265
+ }
266
+
267
+ async function writeClaudeUserSkill() {
268
+ const path = claudeUserSkillPath();
269
+ const existing = await readText(path, "");
270
+ if (existing.trim() && !existing.includes(CLAUDE_COMMAND_MARKER)) {
271
+ throw new Error(
272
+ `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
273
+ );
274
+ }
275
+ await mkdir(dirname(path), { recursive: true });
276
+ await atomicWrite(path, [
277
+ "---",
278
+ "name: penny",
279
+ "description: PennyRouter commands and help.",
280
+ "argument-hint: <command and arguments>",
281
+ "disable-model-invocation: true",
282
+ "---",
283
+ CLAUDE_COMMAND_MARKER,
284
+ "",
285
+ "@penny $ARGUMENTS",
286
+ "",
287
+ ].join("\n"));
288
+ const legacyPath = legacyClaudeUserCommandPath();
289
+ const legacy = await readText(legacyPath, "");
290
+ if (legacy.includes(CLAUDE_COMMAND_MARKER)) {
291
+ await unlink(legacyPath).catch((error) => {
292
+ if (error.code !== "ENOENT") throw error;
293
+ });
294
+ }
295
+ }
296
+
297
+ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
298
+ const path = codexLaunchProfilePath();
299
+ const existing = await readText(path, "");
300
+ if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
301
+ throw new Error(
302
+ `${path} already exists and is not managed by PennyRouter. Rename it before installing.`,
303
+ );
304
+ }
305
+ await privateWrite(path, renderCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }));
306
+ }
307
+
308
+ export async function configurePennyHarness(harness, {
309
+ apiKey,
310
+ gatewayBaseUrl,
311
+ subscriptionAuth = false,
312
+ models = null,
313
+ }) {
314
+ const config = await loadPennyConfig();
315
+ config.gateway_base_url = gatewayBaseUrl;
316
+ config.api_key = apiKey;
317
+ config.harnesses ||= {};
318
+ if (harness === "claude-code") {
319
+ await writeClaudeUserSkill();
320
+ await writeClaudeLaunchSettings();
321
+ config.harnesses.claude = {
322
+ enabled: true,
323
+ subscription_auth: Boolean(subscriptionAuth),
324
+ models: models || {},
325
+ };
326
+ } else if (harness === "codex") {
327
+ await writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth });
328
+ config.harnesses.codex = {
329
+ enabled: true,
330
+ subscription_auth: Boolean(subscriptionAuth),
331
+ };
332
+ } else {
333
+ throw new Error(`The penny command does not support ${harness}.`);
334
+ }
335
+ await savePennyConfig(config);
336
+ }
337
+
338
+ export async function setPennyHarnessEnabled(harness, enabled) {
339
+ const config = await loadPennyConfig();
340
+ const key = harness === "claude-code" ? "claude" : harness;
341
+ if (config.harnesses?.[key]) {
342
+ config.harnesses[key].enabled = Boolean(enabled);
343
+ await savePennyConfig(config);
344
+ }
345
+ }
346
+
347
+ export async function removePennyHarness(harness) {
348
+ const config = await loadPennyConfig();
349
+ const key = harness === "claude-code" ? "claude" : harness;
350
+ if (harness === "claude-code") {
351
+ await unlink(claudeLaunchSettingsPath()).catch((error) => {
352
+ if (error.code !== "ENOENT") throw error;
353
+ });
354
+ for (const path of [claudeUserSkillPath(), legacyClaudeUserCommandPath()]) {
355
+ const command = await readText(path, "");
356
+ if (!command.includes(CLAUDE_COMMAND_MARKER)) continue;
357
+ await unlink(path).catch((error) => {
358
+ if (error.code !== "ENOENT") throw error;
359
+ });
360
+ }
361
+ } else if (harness === "codex") {
362
+ const existing = await readText(codexLaunchProfilePath(), "");
363
+ if (existing.startsWith(CODEX_PROFILE_MARKER)) {
364
+ await unlink(codexLaunchProfilePath()).catch((error) => {
365
+ if (error.code !== "ENOENT") throw error;
366
+ });
367
+ }
368
+ }
369
+ if (config.harnesses) delete config.harnesses[key];
370
+ await savePennyConfig(config);
371
+ }
372
+
373
+ export async function hasConfiguredPennyHarnesses() {
374
+ const config = await loadPennyConfig();
375
+ return Object.keys(config.harnesses || {}).length > 0;
376
+ }
377
+
378
+ async function removeManagedPathBlock(rcPath) {
379
+ if (!rcPath) return;
380
+ const current = await readText(rcPath, "");
381
+ if (!current.includes(PATH_START)) return;
382
+ const escapedStart = PATH_START.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
383
+ const escapedEnd = PATH_END.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
384
+ const next = current
385
+ .replace(new RegExp(`(?:^|\\n)${escapedStart}\\n[\\s\\S]*?\\n${escapedEnd}\\n?`, "m"), "\n")
386
+ .replace(/^\n+/, "");
387
+ await atomicWrite(rcPath, next);
388
+ }
389
+
390
+ function removeWindowsPathEntry() {
391
+ const current = readWindowsUserPath();
392
+ if (!current) return;
393
+ const next = current.split(";")
394
+ .filter((item) => item.trim().replace(/^"|"$/g, "").toLowerCase() !== pennyBinDir().toLowerCase())
395
+ .join(";");
396
+ if (next !== current) writeWindowsUserPath(next);
397
+ }
398
+
399
+ export async function removePennyRuntime() {
400
+ const config = await loadPennyConfig();
401
+ if (process.platform === "win32") {
402
+ if (config.windows_path_managed) removeWindowsPathEntry();
403
+ } else {
404
+ await removeManagedPathBlock(config.path_rc);
405
+ }
406
+ await unlink(pennyShimPath()).catch((error) => {
407
+ if (error.code !== "ENOENT") throw error;
408
+ });
409
+ await rm(pennyRuntimeDir(), { recursive: true, force: true });
410
+ await unlink(pennyConfigPath()).catch((error) => {
411
+ if (error.code !== "ENOENT") throw error;
412
+ });
413
+ }
414
+
415
+ function assertNoConflictingArgs(harness, args) {
416
+ if (harness === "claude" && args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
417
+ throw new Error("penny claude manages --settings; remove that flag or run native claude.");
418
+ }
419
+ if (harness === "codex" && args.some((arg) => arg === "--profile" || arg === "-p" || arg.startsWith("--profile="))) {
420
+ throw new Error("penny codex manages --profile; remove that flag or run native codex.");
421
+ }
422
+ }
423
+
424
+ export function buildPennyInvocation(harness, args, config) {
425
+ const item = config.harnesses?.[harness];
426
+ if (!item || !item.enabled) {
427
+ throw new Error(`PennyRouter is not enabled for ${harness}. Run npx pennyrouter install.`);
428
+ }
429
+ assertNoConflictingArgs(harness, args);
430
+ const env = { ...process.env, PENNYROUTER_API_KEY: config.api_key };
431
+ if (harness === "claude") {
432
+ const models = item.models || {};
433
+ env.ANTHROPIC_BASE_URL = config.gateway_base_url;
434
+ env.ANTHROPIC_AUTH_TOKEN = config.api_key;
435
+ delete env.ANTHROPIC_API_KEY;
436
+ if (models.haiku) env.ANTHROPIC_DEFAULT_HAIKU_MODEL = models.haiku;
437
+ if (models.sonnet) env.ANTHROPIC_DEFAULT_SONNET_MODEL = models.sonnet;
438
+ if (models.opus) env.ANTHROPIC_DEFAULT_OPUS_MODEL = models.opus;
439
+ if (models.fable) env.ANTHROPIC_DEFAULT_FABLE_MODEL = models.fable;
440
+ // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
441
+ // context window from the name and falls back to a conservative 200K default. Tell it
442
+ // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
443
+ env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
444
+ return {
445
+ command: "claude",
446
+ args: ["--settings", claudeLaunchSettingsPath(), ...args],
447
+ env,
448
+ };
449
+ }
450
+ if (harness === "codex") {
451
+ return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
452
+ }
453
+ throw new Error(`Unknown Penny harness "${harness}". Try penny claude or penny codex.`);
454
+ }
455
+
456
+ async function waitForChild(child) {
457
+ return new Promise((resolvePromise, reject) => {
458
+ child.once("error", reject);
459
+ child.once("exit", (code, signal) => resolvePromise({ code, signal }));
460
+ });
461
+ }
462
+
463
+ export async function runPenny(argv = process.argv.slice(2)) {
464
+ const harness = argv[0];
465
+ if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
466
+ console.log("Usage: penny <claude|codex> [native arguments...]");
467
+ return 0;
468
+ }
469
+ if (!(["claude", "codex"].includes(harness))) {
470
+ throw new Error(`Unknown harness "${harness}". Try penny claude or penny codex.`);
471
+ }
472
+ const config = await loadPennyConfig();
473
+ const invocation = buildPennyInvocation(harness, argv.slice(1), config);
474
+ const child = spawn(invocation.command, invocation.args, {
475
+ cwd: process.cwd(),
476
+ env: invocation.env,
477
+ stdio: "inherit",
478
+ shell: process.platform === "win32",
479
+ });
480
+ const result = await waitForChild(child);
481
+ if (result.signal) return 128 + (osConstants.signals[result.signal] || 0);
482
+ return result.code ?? 1;
483
+ }
484
+
485
+ export async function fileMode(path) {
486
+ return (await stat(path)).mode & 0o777;
487
+ }