pennyrouter 0.1.14 → 0.1.16

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 CHANGED
@@ -26,6 +26,11 @@ When Claude Code is selected, the installer also offers an optional Claude Pro/M
26
26
  subscription-auth step. Users can skip it during install and enable it later with
27
27
  `@penny auth` or `npx pennyrouter auth anthropic`.
28
28
 
29
+ When Codex is already signed in with ChatGPT, the installer preserves that Codex-managed
30
+ login and configures PennyRouter as an OpenAI-authenticated Responses proxy. Codex continues
31
+ to own and refresh the OpenAI credentials; PennyRouter stores only its existing `pr-` account
32
+ key in the managed provider block. API-key Codex installs continue to use PennyRouter credits.
33
+
29
34
  ## Supported Tools
30
35
 
31
36
  - Claude Code
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
@@ -24,7 +24,7 @@
24
24
  ],
25
25
  "scripts": {
26
26
  "start": "node ./bin/pennyrouter.js",
27
- "check": "node --check ./bin/pennyrouter.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check"
27
+ "check": "node --check ./bin/pennyrouter.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/statusline.test.js && node src/harnesses/codex.test.js"
28
28
  },
29
29
  "license": "MIT"
30
30
  }
package/src/cli.js CHANGED
@@ -235,31 +235,51 @@ function readAnthropicOAuthToken(flags = {}) {
235
235
  }
236
236
 
237
237
  // ANSI/CSI escape sequences that `claude setup-token`'s TUI emits (colors, cursor
238
- // moves, bracketed-paste). We strip these before scanning for the token so a match
239
- // isn't split by control bytes. Kept deliberately broad.
238
+ // moves, bracketed-paste). We strip these before scanning the captured stream for the
239
+ // token so a match isn't split by control bytes. Kept deliberately broad.
240
240
  // eslint-disable-next-line no-control-regex
241
241
  const ANSI_ESCAPE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
242
242
  // The token line printed by `claude setup-token`. Match any sk-ant-* token so we don't
243
243
  // depend on the exact variant/version (e.g. sk-ant-oat01-...).
244
244
  const ANTHROPIC_SETUP_TOKEN_RE = /sk-ant-[A-Za-z0-9_-]+/;
245
245
 
246
- // Run `claude setup-token` inline and extract the token it prints. The command is a
247
- // full-screen TUI (ANSI art + banner) that also prints the real token on a line like
248
- // "sk-ant-oat01-...". We pipe stdout so we can read it, but tee every chunk straight
249
- // back to the terminal so the user still sees the whole flow (including "you won't be
250
- // able to see it again"). stdin/stderr inherit the TTY for the interactive parts. After
251
- // exit we ANSI-strip the captured stdout and regex out the token. Returns "" on
252
- // non-zero exit or if no token line was found, so the caller falls back to paste.
246
+ // Run `claude setup-token` inline and extract the token it prints, WITHOUT echoing any
247
+ // of the command's own output to the terminal. `claude setup-token` is a full-screen
248
+ // Ink TUI: it redraws with carriage returns and cursor-move escapes (not newlines), so
249
+ // its banner ("Welcome to Claude Code…"), spinner frames, the OAuth URL, the real token
250
+ // ("sk-ant-oat01-…") and the "Store this / export" chrome all arrive interleaved with
251
+ // no reliable line boundaries. There's no safe way to tee that stream line-by-line, and
252
+ // none of it needs to reach the user: the browser auto-opens for approval, and the
253
+ // token goes server-side (encrypted). So we fully suppress the child's output — we pipe
254
+ // BOTH stdout and stderr only to capture them in memory for token extraction, and print
255
+ // our own clean status lines around the call instead. The Ink TUI renders to stdout (the
256
+ // token line arrives there — that's what we scan), but we also silence stderr so no
257
+ // spinner/banner frame can leak from the other stream. Only stdin inherits the TTY, which
258
+ // is all that's needed for the interactive approval handoff; the browser is opened by the
259
+ // OS, not the terminal. After exit we ANSI-strip the captured output and regex out the
260
+ // token. Returns "" on non-zero exit or if no token appeared, so the caller falls back to
261
+ // manual paste.
253
262
  function runClaudeSetupToken() {
254
263
  return new Promise((resolve) => {
255
264
  let captured = "";
265
+ // On Windows, npm installs `claude` as a `claude.cmd` shim which Node's spawn will
266
+ // not resolve without a shell (it looks for a bare executable and throws ENOENT).
267
+ // Route through the shell there so the shim resolves; the args are fixed literals
268
+ // (no user input), so there's no shell-injection surface. POSIX spawns `claude`
269
+ // directly. Works from both PowerShell and cmd.exe host terminals since Node invokes
270
+ // cmd.exe itself for the shell.
256
271
  const child = spawn("claude", ["setup-token"], {
257
- stdio: ["inherit", "pipe", "inherit"],
258
- });
259
- child.stdout.on("data", (chunk) => {
260
- captured += chunk.toString("utf8");
261
- output.write(chunk); // tee: keep the TUI visible to the user
272
+ // stdin keeps the TTY; stdout+stderr are piped (captured, never echoed).
273
+ stdio: ["inherit", "pipe", "pipe"],
274
+ shell: process.platform === "win32",
262
275
  });
276
+
277
+ const capture = (chunk) => {
278
+ captured += chunk.toString("utf8"); // in-memory only — nothing is written to the terminal
279
+ };
280
+ child.stdout.on("data", capture);
281
+ child.stderr.on("data", capture);
282
+
263
283
  child.on("error", () => resolve(""));
264
284
  child.on("close", (code) => {
265
285
  if (code !== 0) return resolve("");
@@ -411,9 +431,10 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
411
431
  // The user already opted into subscription auth above, so run `claude setup-token`
412
432
  // directly and capture the token — no copy-paste, no second confirm. The command
413
433
  // opens the browser for approval inline. On failure we fall through to manual paste.
414
- console.log("Launching `claude setup-token` — approve in your browser when prompted.");
434
+ console.log("Launching Claude sign-in — approve in your browser…");
415
435
  const captured = await runClaudeSetupToken();
416
436
  if (captured) {
437
+ console.log("✓ Claude subscription auth enabled.");
417
438
  console.log("");
418
439
  return { token: captured, source: "setup-token" };
419
440
  }
@@ -696,6 +717,7 @@ async function status() {
696
717
  if (record.installed_at) console.log(` installed: ${record.installed_at}`);
697
718
  if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
698
719
  if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
720
+ if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
699
721
  if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
700
722
  }
701
723
  }
@@ -995,7 +1017,13 @@ async function promptSecretish(question) {
995
1017
  }
996
1018
 
997
1019
  function commandExists(command) {
998
- const result = spawnSync(command, ["--version"], { stdio: "ignore" });
1020
+ // Windows installs CLIs as `.cmd`/`.ps1` shims that Node won't resolve without a
1021
+ // shell, so probe through the shell there (else this false-negatives on `claude` and
1022
+ // the auto setup-token path gets skipped). `command` is an internal literal.
1023
+ const result = spawnSync(command, ["--version"], {
1024
+ stdio: "ignore",
1025
+ shell: process.platform === "win32",
1026
+ });
999
1027
  return result.status === 0;
1000
1028
  }
1001
1029
 
@@ -1,6 +1,11 @@
1
- import { atomicWrite, backupFile, compactHome, exists, expandHome, readText } from "../state.js";
1
+ import { spawnSync } from "node:child_process";
2
+ import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile, readText } from "../state.js";
2
3
 
3
4
  const CONFIG_PATH = "~/.codex/config.toml";
5
+ const AUTH_PATH = "~/.codex/auth.json";
6
+ const PROVIDER_ID = "pennyrouter";
7
+ const MANAGED_START = "# --- PennyRouter managed Codex provider ---";
8
+ const MANAGED_END = "# --- end PennyRouter managed Codex provider ---";
4
9
 
5
10
  export const codexHarness = {
6
11
  id: "codex",
@@ -15,7 +20,8 @@ export const codexHarness = {
15
20
  configPath: expandHome(CONFIG_PATH),
16
21
  summary: [
17
22
  `write ${CONFIG_PATH}`,
18
- "set OpenAI-compatible base URL to PennyRouter",
23
+ "install a Responses-native PennyRouter model provider",
24
+ "preserve Codex-managed ChatGPT login when available",
19
25
  "store the PennyRouter API key without printing it",
20
26
  ],
21
27
  };
@@ -23,12 +29,14 @@ export const codexHarness = {
23
29
 
24
30
  async install({ apiKey, gatewayBaseUrl, plan }) {
25
31
  const current = await readText(CONFIG_PATH, "");
32
+ const subscriptionAuth = await detectCodexChatgptAuth();
33
+ const previousModelProvider = rootAssignment(removeManagedCodexConfig(current), "model_provider");
26
34
  const backupPath = await backupFile(CONFIG_PATH, "codex");
27
- const next = setTomlValue(
28
- setTomlValue(current, "openai_base_url", `${gatewayBaseUrl}/v1`),
29
- "openai_api_key",
35
+ const next = renderCodexConfig(current, {
30
36
  apiKey,
31
- );
37
+ gatewayBaseUrl,
38
+ subscriptionAuth,
39
+ });
32
40
 
33
41
  await atomicWrite(CONFIG_PATH, next);
34
42
 
@@ -38,29 +46,134 @@ export const codexHarness = {
38
46
  backup_path: backupPath ? compactHome(backupPath) : null,
39
47
  installed_at: new Date().toISOString(),
40
48
  restart: "restart Codex",
49
+ openai_subscription_auth: subscriptionAuth,
50
+ previous_model_provider: previousModelProvider,
41
51
  changes: {
42
- keys: ["openai_base_url", "openai_api_key"],
52
+ provider: PROVIDER_ID,
53
+ auth: subscriptionAuth ? "openai-subscription+penny-header" : "penny-bearer",
43
54
  },
44
55
  };
45
56
  },
46
57
 
47
58
  async uninstall(record) {
48
59
  const text = await readText(record.config_path, "");
49
- const next = text
50
- .split("\n")
51
- .filter((line) => !/^\s*openai_base_url\s*=/.test(line))
52
- .filter((line) => !/^\s*openai_api_key\s*=/.test(line))
53
- .join("\n")
54
- .replace(/\n{3,}/g, "\n\n");
60
+ const clean = removeManagedCodexConfig(text);
61
+ const next = record.previous_model_provider
62
+ ? restoreRootAssignment(clean, record.previous_model_provider)
63
+ : clean;
55
64
  await atomicWrite(record.config_path, next.endsWith("\n") ? next : `${next}\n`);
56
65
  },
57
66
  };
58
67
 
59
- function setTomlValue(text, key, value) {
60
- const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
61
- const line = `${key} = "${escaped}"`;
62
- const pattern = new RegExp(`^\\s*${key}\\s*=.*$`, "m");
63
- if (pattern.test(text)) return text.replace(pattern, line);
64
- const normalized = text.trimEnd();
65
- return `${normalized}${normalized ? "\n" : ""}${line}\n`;
68
+ /**
69
+ * ChatGPT login remains owned by Codex. We inspect only the non-secret auth mode/account
70
+ * presence and never copy, print, or persist the access/refresh tokens from auth.json.
71
+ */
72
+ export function codexUsesChatgptAuth(auth) {
73
+ if (!auth || typeof auth !== "object") return false;
74
+ if (String(auth.auth_mode || "").toLowerCase() === "chatgpt") return true;
75
+ return Boolean(auth.tokens && typeof auth.tokens === "object" && auth.tokens.account_id);
76
+ }
77
+
78
+ async function detectCodexChatgptAuth() {
79
+ const auth = await readJsonFile(AUTH_PATH, null);
80
+ if (codexUsesChatgptAuth(auth)) return true;
81
+ // auth.json may be absent when Codex uses the OS keyring. `codex login status` reports
82
+ // only the login mode and does not expose credentials, so it is the safe fallback.
83
+ const status = spawnSync("codex", ["login", "status"], {
84
+ encoding: "utf8",
85
+ stdio: ["ignore", "pipe", "pipe"],
86
+ });
87
+ return status.status === 0 && /logged in using chatgpt/i.test(`${status.stdout}\n${status.stderr}`);
88
+ }
89
+
90
+ /**
91
+ * Install one marker-fenced provider block immediately before the first TOML table.
92
+ * Root keys appended after a table are scoped into that table by TOML, so model_provider
93
+ * must live here rather than at EOF. This also migrates the old undocumented root-key
94
+ * install (openai_base_url/openai_api_key).
95
+ *
96
+ * In subscription mode Codex owns and refreshes the OpenAI bearer. The independent
97
+ * x-pennyrouter-key header authenticates the account to our gateway. In Penny-only mode
98
+ * the pr- key is the provider bearer, matching the isolated probe's existing behavior.
99
+ */
100
+ export function renderCodexConfig(text, { apiKey, gatewayBaseUrl, subscriptionAuth }) {
101
+ const clean = removeManagedCodexConfig(text, { removeRootProvider: true }).trimEnd();
102
+ if (new RegExp(`^\\s*\\[model_providers\\.${escapeRegex(PROVIDER_ID)}\\]\\s*$`, "m").test(clean)) {
103
+ throw new Error(
104
+ `Codex already defines [model_providers.${PROVIDER_ID}] outside PennyRouter's managed block. ` +
105
+ "Rename that provider before installing so PennyRouter does not overwrite user configuration.",
106
+ );
107
+ }
108
+ const escapedKey = tomlString(apiKey);
109
+ const escapedUrl = tomlString(`${String(gatewayBaseUrl).replace(/\/+$/, "")}/v1`);
110
+ const authLines = subscriptionAuth
111
+ ? [
112
+ "requires_openai_auth = true",
113
+ `http_headers = { "x-pennyrouter-key" = ${escapedKey} }`,
114
+ ]
115
+ : [`experimental_bearer_token = ${escapedKey}`];
116
+ const block = [
117
+ MANAGED_START,
118
+ `model_provider = "${PROVIDER_ID}"`,
119
+ "",
120
+ `[model_providers.${PROVIDER_ID}]`,
121
+ 'name = "PennyRouter"',
122
+ `base_url = ${escapedUrl}`,
123
+ 'wire_api = "responses"',
124
+ "supports_websockets = false",
125
+ ...authLines,
126
+ MANAGED_END,
127
+ ].join("\n");
128
+
129
+ const lines = clean ? clean.split("\n") : [];
130
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
131
+ const at = firstTable < 0 ? lines.length : firstTable;
132
+ lines.splice(at, 0, ...(at > 0 && lines[at - 1].trim() ? [""] : []), block,
133
+ ...(at < lines.length && lines[at + 1]?.trim() ? [""] : []));
134
+ return `${lines.join("\n").replace(/\n{3,}/g, "\n\n").trim()}\n`;
135
+ }
136
+
137
+ export function removeManagedCodexConfig(text, { removeRootProvider = false } = {}) {
138
+ let out = String(text || "").replace(
139
+ new RegExp(`${escapeRegex(MANAGED_START)}[\\s\\S]*?${escapeRegex(MANAGED_END)}\\n?`, "g"),
140
+ "",
141
+ );
142
+ // Migrate/uninstall the legacy PennyRouter Codex writer. These keys are not part of the
143
+ // documented custom-provider contract and were written only by older PennyRouter releases.
144
+ out = out
145
+ .split("\n")
146
+ .filter((line) => !/^\s*openai_base_url\s*=/.test(line))
147
+ .filter((line) => !/^\s*openai_api_key\s*=/.test(line))
148
+ .join("\n");
149
+ if (removeRootProvider) {
150
+ const lines = out.split("\n");
151
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
152
+ const rootEnd = firstTable < 0 ? lines.length : firstTable;
153
+ out = lines.filter((line, i) => i >= rootEnd || !/^\s*model_provider\s*=/.test(line)).join("\n");
154
+ }
155
+ return out.replace(/\n{3,}/g, "\n\n").trim();
156
+ }
157
+
158
+ function rootAssignment(text, key) {
159
+ const lines = String(text || "").split("\n");
160
+ const firstTable = lines.findIndex((line) => /^\s*\[/.test(line));
161
+ const rootEnd = firstTable < 0 ? lines.length : firstTable;
162
+ return lines.slice(0, rootEnd).find((line) => new RegExp(`^\\s*${escapeRegex(key)}\\s*=`).test(line)) || null;
163
+ }
164
+
165
+ function restoreRootAssignment(text, line) {
166
+ const lines = String(text || "").trim().split("\n");
167
+ const firstTable = lines.findIndex((item) => /^\s*\[/.test(item));
168
+ const at = firstTable < 0 ? lines.length : firstTable;
169
+ lines.splice(at, 0, ...(at > 0 && lines[at - 1].trim() ? [""] : []), line);
170
+ return lines.join("\n").replace(/\n{3,}/g, "\n\n").trim();
171
+ }
172
+
173
+ function tomlString(value) {
174
+ return JSON.stringify(String(value));
175
+ }
176
+
177
+ function escapeRegex(value) {
178
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
66
179
  }
@@ -0,0 +1,82 @@
1
+ import assert from "node:assert/strict";
2
+
3
+ import {
4
+ codexUsesChatgptAuth,
5
+ removeManagedCodexConfig,
6
+ renderCodexConfig,
7
+ } from "./codex.js";
8
+
9
+ function testAuthDetection() {
10
+ assert.equal(codexUsesChatgptAuth(null), false);
11
+ assert.equal(codexUsesChatgptAuth({ auth_mode: "apikey" }), false);
12
+ assert.equal(codexUsesChatgptAuth({ auth_mode: "chatgpt", tokens: {} }), true);
13
+ assert.equal(codexUsesChatgptAuth({ tokens: { account_id: "acct_test" } }), true);
14
+ }
15
+
16
+ function testSubscriptionProviderIsRootSafe() {
17
+ const original = `model = "gpt-test"\n\n[features]\nplugins = false\n`;
18
+ const next = renderCodexConfig(original, {
19
+ apiKey: "pr-test-secret",
20
+ gatewayBaseUrl: "http://localhost:8400/",
21
+ subscriptionAuth: true,
22
+ });
23
+
24
+ assert.ok(next.indexOf('model_provider = "pennyrouter"') < next.indexOf("[features]"));
25
+ assert.ok(next.includes('[model_providers.pennyrouter]'));
26
+ assert.ok(next.includes('base_url = "http://localhost:8400/v1"'));
27
+ assert.ok(next.includes("requires_openai_auth = true"));
28
+ assert.ok(next.includes('http_headers = { "x-pennyrouter-key" = "pr-test-secret" }'));
29
+ assert.ok(!next.includes("experimental_bearer_token"));
30
+ assert.equal((next.match(/# --- PennyRouter managed Codex provider ---/g) || []).length, 1);
31
+ }
32
+
33
+ function testLegacyMigrationAndIdempotence() {
34
+ const legacy = `[features]\nplugins = false\nopenai_base_url = "http://old/v1"\nopenai_api_key = "pr-old"\n`;
35
+ const once = renderCodexConfig(legacy, {
36
+ apiKey: "pr-new",
37
+ gatewayBaseUrl: "https://api.pennyrouter.com",
38
+ subscriptionAuth: false,
39
+ });
40
+ const twice = renderCodexConfig(once, {
41
+ apiKey: "pr-new",
42
+ gatewayBaseUrl: "https://api.pennyrouter.com",
43
+ subscriptionAuth: false,
44
+ });
45
+
46
+ assert.equal(twice, once);
47
+ assert.ok(!once.includes("openai_base_url"));
48
+ assert.ok(!once.includes("openai_api_key"));
49
+ assert.ok(once.includes('experimental_bearer_token = "pr-new"'));
50
+ assert.ok(!once.includes("requires_openai_auth"));
51
+ }
52
+
53
+ function testProviderCollisionFailsClosed() {
54
+ assert.throws(
55
+ () => renderCodexConfig('[model_providers.pennyrouter]\nname = "User provider"\n', {
56
+ apiKey: "pr-new",
57
+ gatewayBaseUrl: "https://api.pennyrouter.com",
58
+ subscriptionAuth: true,
59
+ }),
60
+ /already defines/,
61
+ );
62
+ }
63
+
64
+ function testUninstallLeavesUserConfig() {
65
+ const original = `model = "gpt-test"\n\n[features]\nplugins = false\n`;
66
+ const installed = renderCodexConfig(original, {
67
+ apiKey: "pr-test",
68
+ gatewayBaseUrl: "http://localhost:8400",
69
+ subscriptionAuth: true,
70
+ });
71
+ const removed = removeManagedCodexConfig(installed);
72
+
73
+ assert.equal(removed, original.trim());
74
+ assert.ok(!removed.includes("pr-test"));
75
+ }
76
+
77
+ testAuthDetection();
78
+ testSubscriptionProviderIsRootSafe();
79
+ testLegacyMigrationAndIdempotence();
80
+ testProviderCollisionFailsClosed();
81
+ testUninstallLeavesUserConfig();
82
+ console.log("codex.test.js: all checks passed");
package/src/statusline.js CHANGED
@@ -96,7 +96,7 @@ function claudeEnv() {
96
96
  const env = settings.env || {};
97
97
  // A normal install writes base + pr- key into settings.json's env block. Fall back to the
98
98
  // process environment so an out-of-band launch that supplies them there instead — e.g. the
99
- // interactive probe, which passes creds via `--settings <file>` + child env rather than the
99
+ // interactive probe, which passes creds via --settings <file> + child env rather than the
100
100
  // user's settings.json — still resolves Penny. Real installs never set these as env vars, so
101
101
  // this fallback is inert for them.
102
102
  const base = env.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || "";
@@ -140,7 +140,11 @@ let cacheAge = Infinity;
140
140
  try { cacheAge = Date.now() - statSync(cachePath).mtimeMs; } catch { /* no cache yet */ }
141
141
 
142
142
  const { base, key } = claudeEnv();
143
- const pennyInstalled = base.includes("pennyrouter") && Boolean(key);
143
+ // Installed = a base URL plus a pr- key (claudeEnv only returns pr- keys, and the installer
144
+ // writes both together). Do NOT sniff the base URL for "pennyrouter": that substring check
145
+ // false-negatived every dev/localhost gateway (e.g. localhost:8400), rendering a hard
146
+ // "penny off" while the gateway was serving the session fine (observed 2026-07-18).
147
+ const pennyInstalled = Boolean(base) && Boolean(key);
144
148
  if (sessionId && pennyInstalled && cacheAge > refreshMs) {
145
149
  spawn(process.execPath, [process.argv[1], "--refresh", sessionId], {
146
150
  detached: true, stdio: "ignore",
@@ -0,0 +1,72 @@
1
+ // Guards against the class of bug found 2026-07-18: an unescaped backtick inside a
2
+ // comment on line 99 of statusline.js terminated the STATUSLINE_SCRIPT template
3
+ // literal early, so every `pennyrouter install` since shipped a syntactically broken
4
+ // generated script — installs succeeded silently, the crash only surfaced later, at
5
+ // Claude Code's next statusline render, in a completely different process.
6
+ //
7
+ // `npm run check` (node --check on every source file) happened to catch this ONE
8
+ // instance because the break also broke the OUTER file's parse — but that's
9
+ // incidental: a subtler bug (backticks that still balance, or valid-looking JS that's
10
+ // semantically wrong) would pass --check on the outer file yet still crash at
11
+ // runtime, because --check never looks at the STRING CONTENT of a template literal.
12
+ // This test extracts the ACTUAL constant (not a source-text regex) and validates the
13
+ // real generated script: syntax-checks it, then executes it end-to-end with a fake
14
+ // stdin payload the way Claude Code actually invokes it, so a broken script fails
15
+ // `npm run check` locally instead of a user's install.
16
+ //
17
+ // No test framework in this package yet — plain assert + a process exit code, run via
18
+ // `node src/statusline.test.js` (wired into `npm run check`).
19
+ import assert from "node:assert/strict";
20
+ import { execFileSync } from "node:child_process";
21
+ import fs from "node:fs";
22
+ import os from "node:os";
23
+ import path from "node:path";
24
+
25
+ import { configureClaudeCodeStatusline, statuslineScriptPath } from "./statusline.js";
26
+
27
+ async function main() {
28
+ const tmpState = fs.mkdtempSync(path.join(os.tmpdir(), "pr-statusline-test-"));
29
+ const prevXdg = process.env.XDG_STATE_HOME;
30
+ process.env.XDG_STATE_HOME = tmpState;
31
+ try {
32
+ const current = {};
33
+ await configureClaudeCodeStatusline(current);
34
+ const scriptPath = statuslineScriptPath();
35
+ assert.ok(fs.existsSync(scriptPath), "script file was written");
36
+
37
+ // 1. Syntax: node --check on the ACTUAL written file (not a regex-extracted copy).
38
+ execFileSync(process.execPath, ["--check", scriptPath], { stdio: "pipe" });
39
+
40
+ // 2. Behavior: run it the way Claude Code does — JSON on stdin, plain stdout.
41
+ // The generated script may discover a real Claude config from the invoking user's
42
+ // home, but must render a valid Penny segment either way. XDG_STATE_HOME is isolated
43
+ // above, so no cached session state or wrapped command can influence this check.
44
+ const payload = JSON.stringify({
45
+ session_id: "test-session",
46
+ model: { display_name: "Test Model" },
47
+ workspace: { current_dir: "/tmp" },
48
+ });
49
+ const out = execFileSync(process.execPath, [scriptPath], {
50
+ input: payload, encoding: "utf8", timeout: 5000,
51
+ });
52
+ assert.ok(out.includes("🪙 penny"), `expected Penny status segment in output, got: ${out}`);
53
+ assert.ok(out.includes("Test Model"), `expected model name passed through, got: ${out}`);
54
+
55
+ // 3. Malformed stdin must not crash the script (best-effort JSON.parse fallback).
56
+ const outBadInput = execFileSync(process.execPath, [scriptPath], {
57
+ input: "not json", encoding: "utf8", timeout: 5000,
58
+ });
59
+ assert.ok(outBadInput.length > 0, "script still prints something on bad stdin");
60
+ } finally {
61
+ if (prevXdg === undefined) delete process.env.XDG_STATE_HOME;
62
+ else process.env.XDG_STATE_HOME = prevXdg;
63
+ fs.rmSync(tmpState, { recursive: true, force: true });
64
+ }
65
+
66
+ console.log("statusline.test.js: all checks passed");
67
+ }
68
+
69
+ main().catch((err) => {
70
+ console.error(err);
71
+ process.exit(1);
72
+ });