pennyrouter 0.1.11 → 0.1.13

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.11",
3
+ "version": "0.1.13",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
package/src/cli.js CHANGED
@@ -1,4 +1,4 @@
1
- import { spawnSync } from "node:child_process";
1
+ import { spawn, spawnSync } from "node:child_process";
2
2
  import { emitKeypressEvents } from "node:readline";
3
3
  import { createInterface } from "node:readline/promises";
4
4
  import { stdin as input, stdout as output } from "node:process";
@@ -234,6 +234,41 @@ function readAnthropicOAuthToken(flags = {}) {
234
234
  return { token: normalizeAnthropicToken(result.stdout), source: "ant" };
235
235
  }
236
236
 
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.
240
+ // eslint-disable-next-line no-control-regex
241
+ const ANSI_ESCAPE_RE = /\x1b\[[0-9;?]*[ -/]*[@-~]/g;
242
+ // The token line printed by `claude setup-token`. Match any sk-ant-* token so we don't
243
+ // depend on the exact variant/version (e.g. sk-ant-oat01-...).
244
+ const ANTHROPIC_SETUP_TOKEN_RE = /sk-ant-[A-Za-z0-9_-]+/;
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.
253
+ function runClaudeSetupToken() {
254
+ return new Promise((resolve) => {
255
+ let captured = "";
256
+ 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
262
+ });
263
+ child.on("error", () => resolve(""));
264
+ child.on("close", (code) => {
265
+ if (code !== 0) return resolve("");
266
+ const match = captured.replace(ANSI_ESCAPE_RE, "").match(ANTHROPIC_SETUP_TOKEN_RE);
267
+ resolve(match ? match[0] : "");
268
+ });
269
+ });
270
+ }
271
+
237
272
  function normalizeAnthropicToken(value) {
238
273
  let text = String(value || "").trim();
239
274
  if (!text) return "";
@@ -373,7 +408,17 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected) {
373
408
 
374
409
  console.log("");
375
410
  if (commandExists("claude")) {
376
- console.log("Run `claude setup-token` in another terminal, then paste the token here.");
411
+ // The user already opted into subscription auth above, so run `claude setup-token`
412
+ // directly and capture the token — no copy-paste, no second confirm. The command
413
+ // 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.");
415
+ const captured = await runClaudeSetupToken();
416
+ if (captured) {
417
+ console.log("");
418
+ return { token: captured, source: "setup-token" };
419
+ }
420
+ console.log("");
421
+ console.log("Couldn't capture a token automatically. Paste it below, or leave blank to skip.");
377
422
  } else {
378
423
  console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
379
424
  }
@@ -1,4 +1,5 @@
1
1
  import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile } from "../state.js";
2
+ import { configureClaudeCodeStatusline, restoreClaudeCodeStatusline } from "../statusline.js";
2
3
 
3
4
  const CONFIG_PATH = "~/.claude/settings.json";
4
5
  const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
@@ -56,6 +57,8 @@ export const claudeCodeHarness = {
56
57
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
57
58
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
58
59
 
60
+ const statusline = await configureClaudeCodeStatusline(current);
61
+
59
62
  await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
60
63
 
61
64
  return {
@@ -64,6 +67,7 @@ export const claudeCodeHarness = {
64
67
  backup_path: backupPath ? compactHome(backupPath) : null,
65
68
  installed_at: new Date().toISOString(),
66
69
  restart: "restart Claude Code",
70
+ statusline: statusline.wrapped ? "wrapped-existing" : "installed",
67
71
  changes: {
68
72
  env_keys: [
69
73
  "ANTHROPIC_BASE_URL",
@@ -95,6 +99,8 @@ export const claudeCodeHarness = {
95
99
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
96
100
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
97
101
 
102
+ const statusline = await configureClaudeCodeStatusline(current);
103
+
98
104
  await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
99
105
 
100
106
  return {
@@ -103,6 +109,7 @@ export const claudeCodeHarness = {
103
109
  backup_path: backupPath ? compactHome(backupPath) : null,
104
110
  installed_at: new Date().toISOString(),
105
111
  restart: "restart Claude Code",
112
+ statusline: statusline.wrapped ? "wrapped-existing" : "installed",
106
113
  changes: {
107
114
  env_keys: [
108
115
  "ANTHROPIC_BASE_URL",
@@ -130,6 +137,8 @@ export const claudeCodeHarness = {
130
137
  current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
131
138
  current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
132
139
 
140
+ const statusline = await configureClaudeCodeStatusline(current);
141
+
133
142
  await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
134
143
  return {
135
144
  harness: "claude-code",
@@ -137,6 +146,7 @@ export const claudeCodeHarness = {
137
146
  backup_path: backupPath ? compactHome(backupPath) : null,
138
147
  installed_at: new Date().toISOString(),
139
148
  restart: "restart Claude Code",
149
+ statusline: statusline.wrapped ? "wrapped-existing" : "installed",
140
150
  changes: {
141
151
  env_keys: [
142
152
  "ANTHROPIC_BASE_URL",
@@ -188,6 +198,8 @@ export const claudeCodeHarness = {
188
198
  delete current["anthropic.apiKey"];
189
199
  }
190
200
  if (current.env && Object.keys(current.env).length === 0) delete current.env;
201
+ // Statusline: restore the user's original command if we wrapped one, else remove ours.
202
+ await restoreClaudeCodeStatusline(current);
191
203
  await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
192
204
  },
193
205
  };
@@ -0,0 +1,178 @@
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
+ const base = env.ANTHROPIC_BASE_URL || "";
98
+ const keyCandidates = [env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN];
99
+ const key = keyCandidates.find((v) => typeof v === "string" && v.startsWith("pr-")) || "";
100
+ return { base, key };
101
+ }
102
+
103
+ // --refresh <sessionId>: fetch gateway status into the cache file, then exit.
104
+ if (process.argv[2] === "--refresh") {
105
+ const sessionId = process.argv[3];
106
+ const { base, key } = claudeEnv();
107
+ if (!sessionId || !base || !key) process.exit(0);
108
+ try {
109
+ const resp = await fetch(base.replace(/\\/$/, "") + "/v1/penny/statusline/" + sessionId, {
110
+ headers: { "x-api-key": key },
111
+ signal: AbortSignal.timeout(4000),
112
+ });
113
+ if (resp.ok) {
114
+ const data = await resp.json();
115
+ mkdirSync(cacheDir, { recursive: true });
116
+ writeFileSync(join(cacheDir, sessionId + ".json"), JSON.stringify(data));
117
+ }
118
+ } catch { /* best-effort: stale cache beats a broken statusline */ }
119
+ process.exit(0);
120
+ }
121
+
122
+ const raw = readFileSync(0, "utf8");
123
+ let input = {};
124
+ try { input = JSON.parse(raw); } catch { /* keep {} */ }
125
+ const sessionId = input.session_id || "";
126
+ const config = readJson(join(stateDir, "claude-statusline.json")) || {};
127
+ const refreshMs = (config.refresh_seconds ?? 15) * 1000;
128
+
129
+ const cachePath = join(cacheDir, sessionId + ".json");
130
+ let cache = sessionId ? readJson(cachePath) : null;
131
+ let cacheAge = Infinity;
132
+ try { cacheAge = Date.now() - statSync(cachePath).mtimeMs; } catch { /* no cache yet */ }
133
+
134
+ const { base, key } = claudeEnv();
135
+ const pennyInstalled = base.includes("pennyrouter") && Boolean(key);
136
+ if (sessionId && pennyInstalled && cacheAge > refreshMs) {
137
+ spawn(process.execPath, [process.argv[1], "--refresh", sessionId], {
138
+ detached: true, stdio: "ignore",
139
+ }).unref();
140
+ }
141
+
142
+ // Auth tier suffix: only "sub"/"byok" are shown (penny-tier and unknown render bare).
143
+ function authSuffix(mode) {
144
+ return mode === "sub" || mode === "byok" ? " (" + mode + ")" : "";
145
+ }
146
+
147
+ let penny;
148
+ if (!pennyInstalled) {
149
+ penny = "🪙 penny off";
150
+ } else if (cache && cache.known && cache.bypass) {
151
+ penny = "🪙 penny off";
152
+ } else {
153
+ penny = "🪙 penny" + authSuffix(cache && cache.auth_mode);
154
+ }
155
+
156
+ // Wrap the underlying statusline (e.g. ccstatusline) and append the penny segment to
157
+ // its FIRST line only, preserving every subsequent line verbatim (the Context bar and
158
+ // any other rows). Taking only line [0] here is what dropped multi-line statuslines.
159
+ let lines;
160
+ if (config.wrap_command) {
161
+ const run = spawnSync(config.wrap_command, {
162
+ shell: true, input: raw, encoding: "utf8", timeout: 1500,
163
+ });
164
+ lines = (run.stdout || "").replace(/\\n$/, "").split("\\n");
165
+ if (lines.length === 1 && lines[0] === "") lines = [];
166
+ } else {
167
+ const model = input.model?.display_name || "";
168
+ const dir = input.workspace?.current_dir ? basename(input.workspace.current_dir) : "";
169
+ lines = [[model, dir].filter(Boolean).join(" · ")].filter(Boolean);
170
+ }
171
+
172
+ if (lines.length === 0) {
173
+ console.log(penny);
174
+ } else {
175
+ lines[0] = lines[0].trimEnd() + " | " + penny;
176
+ console.log(lines.join("\\n"));
177
+ }
178
+ `;