pennyrouter 0.2.4 → 0.2.8
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/LICENSE +21 -21
- package/README.md +225 -185
- package/bin/penny.js +9 -9
- package/bin/pennyrouter.js +7 -7
- package/package.json +30 -30
- package/src/cli.js +1727 -1584
- package/src/harnesses/aider.js +48 -48
- package/src/harnesses/claude-code.js +318 -295
- package/src/harnesses/claude-code.test.js +57 -57
- package/src/harnesses/cline.js +77 -77
- package/src/harnesses/codegpt.js +76 -76
- package/src/harnesses/codex.js +180 -180
- package/src/harnesses/codex.test.js +119 -119
- package/src/harnesses/opencode.js +151 -151
- package/src/harnesses/shared.js +51 -51
- package/src/harnesses/windsurf.js +57 -57
- package/src/harnesses/zed.js +61 -61
- package/src/launch.js +467 -389
- package/src/launch.test.js +153 -153
- package/src/session.js +157 -157
- package/src/session.test.js +44 -44
- package/src/state.js +103 -103
- package/src/statusline.js +202 -202
- package/src/statusline.test.js +72 -72
package/src/statusline.js
CHANGED
|
@@ -1,202 +1,202 @@
|
|
|
1
|
-
// Claude Code statusline integration.
|
|
2
|
-
//
|
|
3
|
-
// Install drops a standalone node script (no deps) and points Claude Code's
|
|
4
|
-
// `statusLine` setting at it. If the user already had a statusLine command, it is
|
|
5
|
-
// PRESERVED: the original command is stored in the statusline config file and the
|
|
6
|
-
// script runs it first, appending the PennyRouter segment to its output — uninstall
|
|
7
|
-
// restores the original verbatim.
|
|
8
|
-
//
|
|
9
|
-
// The script never blocks on the network: it prints from a per-session cache file and
|
|
10
|
-
// spawns a detached refresh (GET /v1/penny/statusline/<session_id> on the gateway,
|
|
11
|
-
// authed with the pr- key already in Claude Code's settings). The gateway keys that
|
|
12
|
-
// endpoint on the CC session uuid it sees in every request's metadata.user_id — the
|
|
13
|
-
// same uuid Claude Code hands the statusline script on stdin.
|
|
14
|
-
|
|
15
|
-
import { statePath, atomicWrite, readJsonFile, writeJsonFile } from "./state.js";
|
|
16
|
-
|
|
17
|
-
export const STATUSLINE_SCRIPT_BASENAME = "claude-statusline.mjs";
|
|
18
|
-
export const STATUSLINE_CONFIG_BASENAME = "claude-statusline.json";
|
|
19
|
-
|
|
20
|
-
export function statuslineScriptPath() {
|
|
21
|
-
return statePath(STATUSLINE_SCRIPT_BASENAME);
|
|
22
|
-
}
|
|
23
|
-
|
|
24
|
-
function statuslineConfigPath() {
|
|
25
|
-
return statePath(STATUSLINE_CONFIG_BASENAME);
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
function isOurCommand(command) {
|
|
29
|
-
return typeof command === "string" && command.includes(STATUSLINE_SCRIPT_BASENAME);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
// Mutates `current` (the parsed ~/.claude/settings.json object) to point statusLine at
|
|
33
|
-
// our script; writes the script + config files. The caller persists `current`.
|
|
34
|
-
export async function configureClaudeCodeStatusline(current) {
|
|
35
|
-
const existing = current.statusLine;
|
|
36
|
-
const config = (await readJsonFile(statuslineConfigPath(), null)) || {};
|
|
37
|
-
if (existing && existing.type === "command" && existing.command && !isOurCommand(existing.command)) {
|
|
38
|
-
// A user-authored statusline: wrap it, and remember it for uninstall. Never
|
|
39
|
-
// overwrite a previously captured wrap with our own command (re-install case).
|
|
40
|
-
config.wrap_command = existing.command;
|
|
41
|
-
if (existing.padding !== undefined) config.wrap_padding = existing.padding;
|
|
42
|
-
}
|
|
43
|
-
config.refresh_seconds = config.refresh_seconds ?? 15;
|
|
44
|
-
await writeJsonFile(statuslineConfigPath(), config);
|
|
45
|
-
await atomicWrite(statuslineScriptPath(), STATUSLINE_SCRIPT);
|
|
46
|
-
current.statusLine = {
|
|
47
|
-
type: "command",
|
|
48
|
-
command: `node "${statuslineScriptPath()}"`,
|
|
49
|
-
...(existing?.padding !== undefined ? { padding: existing.padding } : {}),
|
|
50
|
-
};
|
|
51
|
-
return { script_path: statuslineScriptPath(), wrapped: Boolean(config.wrap_command) };
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
// Mutates `current` to undo configureClaudeCodeStatusline: restore the user's original
|
|
55
|
-
// statusline command if we wrapped one, otherwise remove the setting entirely. A
|
|
56
|
-
// statusLine we didn't write is left untouched.
|
|
57
|
-
export async function restoreClaudeCodeStatusline(current) {
|
|
58
|
-
if (!current.statusLine || !isOurCommand(current.statusLine.command)) return;
|
|
59
|
-
const config = (await readJsonFile(statuslineConfigPath(), null)) || {};
|
|
60
|
-
if (config.wrap_command) {
|
|
61
|
-
current.statusLine = {
|
|
62
|
-
type: "command",
|
|
63
|
-
command: config.wrap_command,
|
|
64
|
-
...(config.wrap_padding !== undefined ? { padding: config.wrap_padding } : {}),
|
|
65
|
-
};
|
|
66
|
-
} else {
|
|
67
|
-
delete current.statusLine;
|
|
68
|
-
}
|
|
69
|
-
}
|
|
70
|
-
|
|
71
|
-
// The standalone script written to disk. Node >= 18 (global fetch). Keep it dependency-
|
|
72
|
-
// free and fast: read stdin, print from cache, refresh out-of-band.
|
|
73
|
-
const STATUSLINE_SCRIPT = `#!/usr/bin/env node
|
|
74
|
-
// PennyRouter statusline for Claude Code (installed by \`pennyrouter install\`).
|
|
75
|
-
// Prints: [wrapped original statusline (all lines) |] penny segment (penny / penny (sub)
|
|
76
|
-
// / penny (byok) / penny off). The wrapped statusline's first line gets the penny suffix;
|
|
77
|
-
// any further lines (e.g. a context bar) pass through verbatim.
|
|
78
|
-
// Never blocks on the network: reads a per-session cache and refreshes it detached.
|
|
79
|
-
import { readFileSync, mkdirSync, writeFileSync, statSync } from "node:fs";
|
|
80
|
-
import { spawn, spawnSync } from "node:child_process";
|
|
81
|
-
import { join, basename } from "node:path";
|
|
82
|
-
import os from "node:os";
|
|
83
|
-
|
|
84
|
-
const stateDir = join(
|
|
85
|
-
process.env.XDG_STATE_HOME || join(os.homedir(), ".local", "state"),
|
|
86
|
-
"pennyrouter",
|
|
87
|
-
);
|
|
88
|
-
const cacheDir = join(stateDir, "statusline-cache");
|
|
89
|
-
|
|
90
|
-
function readJson(path) {
|
|
91
|
-
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
|
|
92
|
-
}
|
|
93
|
-
|
|
94
|
-
function claudeEnv() {
|
|
95
|
-
const settings = readJson(join(os.homedir(), ".claude", "settings.json")) || {};
|
|
96
|
-
const env = settings.env || {};
|
|
97
|
-
// A normal install writes base + pr- key into settings.json's env block. Fall back to the
|
|
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
|
|
100
|
-
// user's settings.json — still resolves Penny. Real installs never set these as env vars, so
|
|
101
|
-
// this fallback is inert for them.
|
|
102
|
-
const base = env.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || "";
|
|
103
|
-
const keyCandidates = [
|
|
104
|
-
env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN,
|
|
105
|
-
process.env.ANTHROPIC_API_KEY, process.env.ANTHROPIC_AUTH_TOKEN,
|
|
106
|
-
];
|
|
107
|
-
const key = keyCandidates.find((v) => typeof v === "string" && v.startsWith("pr-")) || "";
|
|
108
|
-
return { base, key };
|
|
109
|
-
}
|
|
110
|
-
|
|
111
|
-
// --refresh <sessionId>: fetch gateway status into the cache file, then exit.
|
|
112
|
-
if (process.argv[2] === "--refresh") {
|
|
113
|
-
const sessionId = process.argv[3];
|
|
114
|
-
const { base, key } = claudeEnv();
|
|
115
|
-
if (!sessionId || !base || !key) process.exit(0);
|
|
116
|
-
try {
|
|
117
|
-
const resp = await fetch(base.replace(/\\/$/, "") + "/v1/penny/statusline/" + sessionId, {
|
|
118
|
-
headers: { "x-api-key": key },
|
|
119
|
-
signal: AbortSignal.timeout(4000),
|
|
120
|
-
});
|
|
121
|
-
if (resp.ok) {
|
|
122
|
-
const data = await resp.json();
|
|
123
|
-
mkdirSync(cacheDir, { recursive: true });
|
|
124
|
-
writeFileSync(join(cacheDir, sessionId + ".json"), JSON.stringify(data));
|
|
125
|
-
}
|
|
126
|
-
} catch { /* best-effort: stale cache beats a broken statusline */ }
|
|
127
|
-
process.exit(0);
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
const raw = readFileSync(0, "utf8");
|
|
131
|
-
let input = {};
|
|
132
|
-
try { input = JSON.parse(raw); } catch { /* keep {} */ }
|
|
133
|
-
const sessionId = input.session_id || "";
|
|
134
|
-
const config = readJson(join(stateDir, "claude-statusline.json")) || {};
|
|
135
|
-
const refreshMs = (config.refresh_seconds ?? 15) * 1000;
|
|
136
|
-
|
|
137
|
-
const cachePath = join(cacheDir, sessionId + ".json");
|
|
138
|
-
let cache = sessionId ? readJson(cachePath) : null;
|
|
139
|
-
let cacheAge = Infinity;
|
|
140
|
-
try { cacheAge = Date.now() - statSync(cachePath).mtimeMs; } catch { /* no cache yet */ }
|
|
141
|
-
|
|
142
|
-
const { base, key } = claudeEnv();
|
|
143
|
-
|
|
144
|
-
const pennyInstalled = Boolean(base) && Boolean(key);
|
|
145
|
-
if (sessionId && pennyInstalled && cacheAge > refreshMs) {
|
|
146
|
-
spawn(process.execPath, [process.argv[1], "--refresh", sessionId], {
|
|
147
|
-
detached: true, stdio: "ignore",
|
|
148
|
-
}).unref();
|
|
149
|
-
}
|
|
150
|
-
|
|
151
|
-
// Auth tier suffix: only "sub"/"byok" are shown (penny-tier and unknown render bare).
|
|
152
|
-
function authSuffix(mode) {
|
|
153
|
-
return mode === "sub" || mode === "byok" ? " (" + mode + ")" : "";
|
|
154
|
-
}
|
|
155
|
-
|
|
156
|
-
// Teammate badges (@penny ask): 📨 questions waiting on you, 📬 answers that came back.
|
|
157
|
-
// Read from the structured fields rather than the gateway's prebuilt \`label\`, since this
|
|
158
|
-
// line is composed here. Absent on an older gateway, which renders nothing.
|
|
159
|
-
function askBadges(c) {
|
|
160
|
-
if (!c) return "";
|
|
161
|
-
let out = "";
|
|
162
|
-
if (c.asks_open > 0) out += " · 📨 " + c.asks_open;
|
|
163
|
-
if (c.answers_fresh > 0) out += " · 📬 " + c.answers_fresh;
|
|
164
|
-
return out;
|
|
165
|
-
}
|
|
166
|
-
|
|
167
|
-
let penny;
|
|
168
|
-
if (!pennyInstalled) {
|
|
169
|
-
penny = "🪙 penny off";
|
|
170
|
-
} else if (cache && cache.known && cache.bypass) {
|
|
171
|
-
penny = "🪙 penny off";
|
|
172
|
-
} else {
|
|
173
|
-
penny = "🪙 penny" + authSuffix(cache && cache.auth_mode) + askBadges(cache);
|
|
174
|
-
}
|
|
175
|
-
|
|
176
|
-
let lines;
|
|
177
|
-
if (config.wrap_command) {
|
|
178
|
-
const run = spawnSync(config.wrap_command, {
|
|
179
|
-
shell: true, input: raw, encoding: "utf8", timeout: 5000,
|
|
180
|
-
});
|
|
181
|
-
lines = (run.stdout || "").replace(/\\n$/, "").split("\\n");
|
|
182
|
-
if (lines.length === 1 && lines[0] === "") lines = [];
|
|
183
|
-
} else {
|
|
184
|
-
const model = input.model?.display_name || "";
|
|
185
|
-
const dir = input.workspace?.current_dir ? basename(input.workspace.current_dir) : "";
|
|
186
|
-
lines = [[model, dir].filter(Boolean).join(" · ")].filter(Boolean);
|
|
187
|
-
}
|
|
188
|
-
|
|
189
|
-
if (lines.length === 0) {
|
|
190
|
-
lines = [penny];
|
|
191
|
-
} else {
|
|
192
|
-
lines[0] = lines[0].trimEnd() + " | " + penny;
|
|
193
|
-
}
|
|
194
|
-
// Ambient tip: the gateway rotates cache.tip on a slow clock (see tips.statusline_tip).
|
|
195
|
-
// Rendered as its own soft light-green line: visible without competing with the brighter
|
|
196
|
-
// model/context status above it. Absent field
|
|
197
|
-
// (tips off, old gateway, bypass) renders nothing.
|
|
198
|
-
if (pennyInstalled && cache && cache.tip && !cache.bypass) {
|
|
199
|
-
lines.push("\\x1b[38;5;114m" + cache.tip + "\\x1b[0m");
|
|
200
|
-
}
|
|
201
|
-
console.log(lines.join("\\n"));
|
|
202
|
-
`;
|
|
1
|
+
// Claude Code statusline integration.
|
|
2
|
+
//
|
|
3
|
+
// Install drops a standalone node script (no deps) and points Claude Code's
|
|
4
|
+
// `statusLine` setting at it. If the user already had a statusLine command, it is
|
|
5
|
+
// PRESERVED: the original command is stored in the statusline config file and the
|
|
6
|
+
// script runs it first, appending the PennyRouter segment to its output — uninstall
|
|
7
|
+
// restores the original verbatim.
|
|
8
|
+
//
|
|
9
|
+
// The script never blocks on the network: it prints from a per-session cache file and
|
|
10
|
+
// spawns a detached refresh (GET /v1/penny/statusline/<session_id> on the gateway,
|
|
11
|
+
// authed with the pr- key already in Claude Code's settings). The gateway keys that
|
|
12
|
+
// endpoint on the CC session uuid it sees in every request's metadata.user_id — the
|
|
13
|
+
// same uuid Claude Code hands the statusline script on stdin.
|
|
14
|
+
|
|
15
|
+
import { statePath, atomicWrite, readJsonFile, writeJsonFile } from "./state.js";
|
|
16
|
+
|
|
17
|
+
export const STATUSLINE_SCRIPT_BASENAME = "claude-statusline.mjs";
|
|
18
|
+
export const STATUSLINE_CONFIG_BASENAME = "claude-statusline.json";
|
|
19
|
+
|
|
20
|
+
export function statuslineScriptPath() {
|
|
21
|
+
return statePath(STATUSLINE_SCRIPT_BASENAME);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function statuslineConfigPath() {
|
|
25
|
+
return statePath(STATUSLINE_CONFIG_BASENAME);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function isOurCommand(command) {
|
|
29
|
+
return typeof command === "string" && command.includes(STATUSLINE_SCRIPT_BASENAME);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Mutates `current` (the parsed ~/.claude/settings.json object) to point statusLine at
|
|
33
|
+
// our script; writes the script + config files. The caller persists `current`.
|
|
34
|
+
export async function configureClaudeCodeStatusline(current) {
|
|
35
|
+
const existing = current.statusLine;
|
|
36
|
+
const config = (await readJsonFile(statuslineConfigPath(), null)) || {};
|
|
37
|
+
if (existing && existing.type === "command" && existing.command && !isOurCommand(existing.command)) {
|
|
38
|
+
// A user-authored statusline: wrap it, and remember it for uninstall. Never
|
|
39
|
+
// overwrite a previously captured wrap with our own command (re-install case).
|
|
40
|
+
config.wrap_command = existing.command;
|
|
41
|
+
if (existing.padding !== undefined) config.wrap_padding = existing.padding;
|
|
42
|
+
}
|
|
43
|
+
config.refresh_seconds = config.refresh_seconds ?? 15;
|
|
44
|
+
await writeJsonFile(statuslineConfigPath(), config);
|
|
45
|
+
await atomicWrite(statuslineScriptPath(), STATUSLINE_SCRIPT);
|
|
46
|
+
current.statusLine = {
|
|
47
|
+
type: "command",
|
|
48
|
+
command: `node "${statuslineScriptPath()}"`,
|
|
49
|
+
...(existing?.padding !== undefined ? { padding: existing.padding } : {}),
|
|
50
|
+
};
|
|
51
|
+
return { script_path: statuslineScriptPath(), wrapped: Boolean(config.wrap_command) };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// Mutates `current` to undo configureClaudeCodeStatusline: restore the user's original
|
|
55
|
+
// statusline command if we wrapped one, otherwise remove the setting entirely. A
|
|
56
|
+
// statusLine we didn't write is left untouched.
|
|
57
|
+
export async function restoreClaudeCodeStatusline(current) {
|
|
58
|
+
if (!current.statusLine || !isOurCommand(current.statusLine.command)) return;
|
|
59
|
+
const config = (await readJsonFile(statuslineConfigPath(), null)) || {};
|
|
60
|
+
if (config.wrap_command) {
|
|
61
|
+
current.statusLine = {
|
|
62
|
+
type: "command",
|
|
63
|
+
command: config.wrap_command,
|
|
64
|
+
...(config.wrap_padding !== undefined ? { padding: config.wrap_padding } : {}),
|
|
65
|
+
};
|
|
66
|
+
} else {
|
|
67
|
+
delete current.statusLine;
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// The standalone script written to disk. Node >= 18 (global fetch). Keep it dependency-
|
|
72
|
+
// free and fast: read stdin, print from cache, refresh out-of-band.
|
|
73
|
+
const STATUSLINE_SCRIPT = `#!/usr/bin/env node
|
|
74
|
+
// PennyRouter statusline for Claude Code (installed by \`pennyrouter install\`).
|
|
75
|
+
// Prints: [wrapped original statusline (all lines) |] penny segment (penny / penny (sub)
|
|
76
|
+
// / penny (byok) / penny off). The wrapped statusline's first line gets the penny suffix;
|
|
77
|
+
// any further lines (e.g. a context bar) pass through verbatim.
|
|
78
|
+
// Never blocks on the network: reads a per-session cache and refreshes it detached.
|
|
79
|
+
import { readFileSync, mkdirSync, writeFileSync, statSync } from "node:fs";
|
|
80
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
81
|
+
import { join, basename } from "node:path";
|
|
82
|
+
import os from "node:os";
|
|
83
|
+
|
|
84
|
+
const stateDir = join(
|
|
85
|
+
process.env.XDG_STATE_HOME || join(os.homedir(), ".local", "state"),
|
|
86
|
+
"pennyrouter",
|
|
87
|
+
);
|
|
88
|
+
const cacheDir = join(stateDir, "statusline-cache");
|
|
89
|
+
|
|
90
|
+
function readJson(path) {
|
|
91
|
+
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function claudeEnv() {
|
|
95
|
+
const settings = readJson(join(os.homedir(), ".claude", "settings.json")) || {};
|
|
96
|
+
const env = settings.env || {};
|
|
97
|
+
// A normal install writes base + pr- key into settings.json's env block. Fall back to the
|
|
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
|
|
100
|
+
// user's settings.json — still resolves Penny. Real installs never set these as env vars, so
|
|
101
|
+
// this fallback is inert for them.
|
|
102
|
+
const base = env.ANTHROPIC_BASE_URL || process.env.ANTHROPIC_BASE_URL || "";
|
|
103
|
+
const keyCandidates = [
|
|
104
|
+
env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN,
|
|
105
|
+
process.env.ANTHROPIC_API_KEY, process.env.ANTHROPIC_AUTH_TOKEN,
|
|
106
|
+
];
|
|
107
|
+
const key = keyCandidates.find((v) => typeof v === "string" && v.startsWith("pr-")) || "";
|
|
108
|
+
return { base, key };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// --refresh <sessionId>: fetch gateway status into the cache file, then exit.
|
|
112
|
+
if (process.argv[2] === "--refresh") {
|
|
113
|
+
const sessionId = process.argv[3];
|
|
114
|
+
const { base, key } = claudeEnv();
|
|
115
|
+
if (!sessionId || !base || !key) process.exit(0);
|
|
116
|
+
try {
|
|
117
|
+
const resp = await fetch(base.replace(/\\/$/, "") + "/v1/penny/statusline/" + sessionId, {
|
|
118
|
+
headers: { "x-api-key": key },
|
|
119
|
+
signal: AbortSignal.timeout(4000),
|
|
120
|
+
});
|
|
121
|
+
if (resp.ok) {
|
|
122
|
+
const data = await resp.json();
|
|
123
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
124
|
+
writeFileSync(join(cacheDir, sessionId + ".json"), JSON.stringify(data));
|
|
125
|
+
}
|
|
126
|
+
} catch { /* best-effort: stale cache beats a broken statusline */ }
|
|
127
|
+
process.exit(0);
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const raw = readFileSync(0, "utf8");
|
|
131
|
+
let input = {};
|
|
132
|
+
try { input = JSON.parse(raw); } catch { /* keep {} */ }
|
|
133
|
+
const sessionId = input.session_id || "";
|
|
134
|
+
const config = readJson(join(stateDir, "claude-statusline.json")) || {};
|
|
135
|
+
const refreshMs = (config.refresh_seconds ?? 15) * 1000;
|
|
136
|
+
|
|
137
|
+
const cachePath = join(cacheDir, sessionId + ".json");
|
|
138
|
+
let cache = sessionId ? readJson(cachePath) : null;
|
|
139
|
+
let cacheAge = Infinity;
|
|
140
|
+
try { cacheAge = Date.now() - statSync(cachePath).mtimeMs; } catch { /* no cache yet */ }
|
|
141
|
+
|
|
142
|
+
const { base, key } = claudeEnv();
|
|
143
|
+
|
|
144
|
+
const pennyInstalled = Boolean(base) && Boolean(key);
|
|
145
|
+
if (sessionId && pennyInstalled && cacheAge > refreshMs) {
|
|
146
|
+
spawn(process.execPath, [process.argv[1], "--refresh", sessionId], {
|
|
147
|
+
detached: true, stdio: "ignore",
|
|
148
|
+
}).unref();
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
// Auth tier suffix: only "sub"/"byok" are shown (penny-tier and unknown render bare).
|
|
152
|
+
function authSuffix(mode) {
|
|
153
|
+
return mode === "sub" || mode === "byok" ? " (" + mode + ")" : "";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Teammate badges (@penny ask): 📨 questions waiting on you, 📬 answers that came back.
|
|
157
|
+
// Read from the structured fields rather than the gateway's prebuilt \`label\`, since this
|
|
158
|
+
// line is composed here. Absent on an older gateway, which renders nothing.
|
|
159
|
+
function askBadges(c) {
|
|
160
|
+
if (!c) return "";
|
|
161
|
+
let out = "";
|
|
162
|
+
if (c.asks_open > 0) out += " · 📨 " + c.asks_open;
|
|
163
|
+
if (c.answers_fresh > 0) out += " · 📬 " + c.answers_fresh;
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let penny;
|
|
168
|
+
if (!pennyInstalled) {
|
|
169
|
+
penny = "🪙 penny off";
|
|
170
|
+
} else if (cache && cache.known && cache.bypass) {
|
|
171
|
+
penny = "🪙 penny off";
|
|
172
|
+
} else {
|
|
173
|
+
penny = "🪙 penny" + authSuffix(cache && cache.auth_mode) + askBadges(cache);
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
let lines;
|
|
177
|
+
if (config.wrap_command) {
|
|
178
|
+
const run = spawnSync(config.wrap_command, {
|
|
179
|
+
shell: true, input: raw, encoding: "utf8", timeout: 5000,
|
|
180
|
+
});
|
|
181
|
+
lines = (run.stdout || "").replace(/\\n$/, "").split("\\n");
|
|
182
|
+
if (lines.length === 1 && lines[0] === "") lines = [];
|
|
183
|
+
} else {
|
|
184
|
+
const model = input.model?.display_name || "";
|
|
185
|
+
const dir = input.workspace?.current_dir ? basename(input.workspace.current_dir) : "";
|
|
186
|
+
lines = [[model, dir].filter(Boolean).join(" · ")].filter(Boolean);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (lines.length === 0) {
|
|
190
|
+
lines = [penny];
|
|
191
|
+
} else {
|
|
192
|
+
lines[0] = lines[0].trimEnd() + " | " + penny;
|
|
193
|
+
}
|
|
194
|
+
// Ambient tip: the gateway rotates cache.tip on a slow clock (see tips.statusline_tip).
|
|
195
|
+
// Rendered as its own soft light-green line: visible without competing with the brighter
|
|
196
|
+
// model/context status above it. Absent field
|
|
197
|
+
// (tips off, old gateway, bypass) renders nothing.
|
|
198
|
+
if (pennyInstalled && cache && cache.tip && !cache.bypass) {
|
|
199
|
+
lines.push("\\x1b[38;5;114m" + cache.tip + "\\x1b[0m");
|
|
200
|
+
}
|
|
201
|
+
console.log(lines.join("\\n"));
|
|
202
|
+
`;
|
package/src/statusline.test.js
CHANGED
|
@@ -1,72 +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
|
-
});
|
|
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
|
+
});
|