pennyrouter 0.1.10 โ 0.1.12
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 +1 -1
- package/src/cli.js +54 -4
- package/src/harnesses/claude-code.js +12 -0
- package/src/statusline.js +169 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -120,6 +120,8 @@ function parseArgs(argv) {
|
|
|
120
120
|
// `--local=<url>` overrides the port/host.
|
|
121
121
|
else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
|
|
122
122
|
else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
|
|
123
|
+
else if (arg === "--forget") flags.forget = true;
|
|
124
|
+
else if (arg === "--forget-token") flags.forgetToken = true;
|
|
123
125
|
else if (!arg.startsWith("-")) flags.args.push(arg);
|
|
124
126
|
else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
|
|
125
127
|
}
|
|
@@ -133,6 +135,39 @@ async function authProvider(flags) {
|
|
|
133
135
|
throw new Error(`Unknown auth provider "${provider}". Try "pennyrouter auth anthropic".`);
|
|
134
136
|
}
|
|
135
137
|
|
|
138
|
+
if (flags.forget) {
|
|
139
|
+
const pennyKey = flags.pennyKey || await readClaudeCodePennyRouterKey();
|
|
140
|
+
if (!pennyKey) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
"No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || "https://api.pennyrouter.com";
|
|
146
|
+
if (flags.dryRun) {
|
|
147
|
+
console.log(`Will delete stored Anthropic token from gateway: ${gatewayBaseUrl}`);
|
|
148
|
+
return;
|
|
149
|
+
}
|
|
150
|
+
const ok = await forgetAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey });
|
|
151
|
+
if (ok) {
|
|
152
|
+
console.log("Successfully forgot stored Anthropic token on server.");
|
|
153
|
+
} else {
|
|
154
|
+
console.log("Failed to forget stored Anthropic token on server.");
|
|
155
|
+
}
|
|
156
|
+
const manifest = await loadManifest();
|
|
157
|
+
let updated = false;
|
|
158
|
+
for (const record of (manifest.installations || [])) {
|
|
159
|
+
if (record.harness === "claude-code" && record.anthropic_token_stored) {
|
|
160
|
+
record.anthropic_token_stored = false;
|
|
161
|
+
record.anthropic_oauth = false;
|
|
162
|
+
updated = true;
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
if (updated) {
|
|
166
|
+
await saveManifest(manifest);
|
|
167
|
+
}
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
|
|
136
171
|
if (flags.dryRun) {
|
|
137
172
|
console.log("Will configure Claude Code for PennyRouter + Anthropic OAuth dual auth.");
|
|
138
173
|
console.log("Dry run only. No files changed.");
|
|
@@ -493,6 +528,7 @@ async function uninstall(flags) {
|
|
|
493
528
|
|
|
494
529
|
const kept = [];
|
|
495
530
|
const failures = [];
|
|
531
|
+
let anthropicTokenNotice = false;
|
|
496
532
|
for (const record of records) {
|
|
497
533
|
if (!selectedIds.includes(record.harness)) {
|
|
498
534
|
kept.push(record);
|
|
@@ -507,7 +543,11 @@ async function uninstall(flags) {
|
|
|
507
543
|
}
|
|
508
544
|
|
|
509
545
|
const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
510
|
-
|
|
546
|
+
if (flags.forgetToken) {
|
|
547
|
+
await forgetStoredAnthropicToken(record);
|
|
548
|
+
} else if (record.anthropic_token_stored) {
|
|
549
|
+
anthropicTokenNotice = true;
|
|
550
|
+
}
|
|
511
551
|
await harness.uninstall(record);
|
|
512
552
|
console.log(`Removed ${record.harness}.`);
|
|
513
553
|
});
|
|
@@ -522,12 +562,22 @@ async function uninstall(flags) {
|
|
|
522
562
|
continue;
|
|
523
563
|
}
|
|
524
564
|
await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
525
|
-
|
|
565
|
+
if (flags.forgetToken) {
|
|
566
|
+
await forgetStoredAnthropicToken(record);
|
|
567
|
+
} else if (record.anthropic_token_stored) {
|
|
568
|
+
anthropicTokenNotice = true;
|
|
569
|
+
}
|
|
526
570
|
await harness.uninstall(record);
|
|
527
571
|
console.log(`Removed ${record.harness}.`);
|
|
528
572
|
});
|
|
529
573
|
}
|
|
530
574
|
|
|
575
|
+
if (anthropicTokenNotice) {
|
|
576
|
+
console.log("");
|
|
577
|
+
console.log("Note: Your stored Anthropic token remains on the server for other machines.");
|
|
578
|
+
console.log("To delete it from the server, run: pennyrouter auth anthropic --forget");
|
|
579
|
+
}
|
|
580
|
+
|
|
531
581
|
manifest.installations = kept;
|
|
532
582
|
await saveManifest(manifest);
|
|
533
583
|
reportFailures(failures);
|
|
@@ -929,10 +979,10 @@ function printHelp() {
|
|
|
929
979
|
|
|
930
980
|
Usage:
|
|
931
981
|
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account] [--anthropic-auth|--no-anthropic-auth] [--local[=URL]]
|
|
932
|
-
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL]
|
|
982
|
+
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
933
983
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
934
984
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
935
|
-
pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
985
|
+
pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
|
|
936
986
|
pennyrouter status
|
|
937
987
|
|
|
938
988
|
Examples:
|
|
@@ -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,169 @@
|
|
|
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 |] penny segment (managed model / BYPASS / off).
|
|
76
|
+
// Never blocks on the network: reads a per-session cache and refreshes it detached.
|
|
77
|
+
import { readFileSync, mkdirSync, writeFileSync, statSync } from "node:fs";
|
|
78
|
+
import { spawn, spawnSync } from "node:child_process";
|
|
79
|
+
import { join, basename } from "node:path";
|
|
80
|
+
import os from "node:os";
|
|
81
|
+
|
|
82
|
+
const stateDir = join(
|
|
83
|
+
process.env.XDG_STATE_HOME || join(os.homedir(), ".local", "state"),
|
|
84
|
+
"pennyrouter",
|
|
85
|
+
);
|
|
86
|
+
const cacheDir = join(stateDir, "statusline-cache");
|
|
87
|
+
|
|
88
|
+
function readJson(path) {
|
|
89
|
+
try { return JSON.parse(readFileSync(path, "utf8")); } catch { return null; }
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function claudeEnv() {
|
|
93
|
+
const settings = readJson(join(os.homedir(), ".claude", "settings.json")) || {};
|
|
94
|
+
const env = settings.env || {};
|
|
95
|
+
const base = env.ANTHROPIC_BASE_URL || "";
|
|
96
|
+
const keyCandidates = [env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN];
|
|
97
|
+
const key = keyCandidates.find((v) => typeof v === "string" && v.startsWith("pr-")) || "";
|
|
98
|
+
return { base, key };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// --refresh <sessionId>: fetch gateway status into the cache file, then exit.
|
|
102
|
+
if (process.argv[2] === "--refresh") {
|
|
103
|
+
const sessionId = process.argv[3];
|
|
104
|
+
const { base, key } = claudeEnv();
|
|
105
|
+
if (!sessionId || !base || !key) process.exit(0);
|
|
106
|
+
try {
|
|
107
|
+
const resp = await fetch(base.replace(/\\/$/, "") + "/v1/penny/statusline/" + sessionId, {
|
|
108
|
+
headers: { "x-api-key": key },
|
|
109
|
+
signal: AbortSignal.timeout(4000),
|
|
110
|
+
});
|
|
111
|
+
if (resp.ok) {
|
|
112
|
+
const data = await resp.json();
|
|
113
|
+
mkdirSync(cacheDir, { recursive: true });
|
|
114
|
+
writeFileSync(join(cacheDir, sessionId + ".json"), JSON.stringify(data));
|
|
115
|
+
}
|
|
116
|
+
} catch { /* best-effort: stale cache beats a broken statusline */ }
|
|
117
|
+
process.exit(0);
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const raw = readFileSync(0, "utf8");
|
|
121
|
+
let input = {};
|
|
122
|
+
try { input = JSON.parse(raw); } catch { /* keep {} */ }
|
|
123
|
+
const sessionId = input.session_id || "";
|
|
124
|
+
const config = readJson(join(stateDir, "claude-statusline.json")) || {};
|
|
125
|
+
const refreshMs = (config.refresh_seconds ?? 15) * 1000;
|
|
126
|
+
|
|
127
|
+
const cachePath = join(cacheDir, sessionId + ".json");
|
|
128
|
+
let cache = sessionId ? readJson(cachePath) : null;
|
|
129
|
+
let cacheAge = Infinity;
|
|
130
|
+
try { cacheAge = Date.now() - statSync(cachePath).mtimeMs; } catch { /* no cache yet */ }
|
|
131
|
+
|
|
132
|
+
const { base, key } = claudeEnv();
|
|
133
|
+
const pennyInstalled = base.includes("pennyrouter") && Boolean(key);
|
|
134
|
+
if (sessionId && pennyInstalled && cacheAge > refreshMs) {
|
|
135
|
+
spawn(process.execPath, [process.argv[1], "--refresh", sessionId], {
|
|
136
|
+
detached: true, stdio: "ignore",
|
|
137
|
+
}).unref();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function shortModel(m) {
|
|
141
|
+
if (typeof m !== "string" || !m) return "";
|
|
142
|
+
return m.split("/").pop().replace(/^claude-/, "");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
let penny;
|
|
146
|
+
if (!pennyInstalled) {
|
|
147
|
+
penny = "๐ช penny off";
|
|
148
|
+
} else if (cache && cache.known && cache.bypass) {
|
|
149
|
+
penny = "๐ช penny BYPASS";
|
|
150
|
+
} else if (cache && cache.known && cache.model) {
|
|
151
|
+
penny = "๐ช penny ยท " + shortModel(cache.model);
|
|
152
|
+
} else {
|
|
153
|
+
penny = "๐ช penny on";
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
let prefix = "";
|
|
157
|
+
if (config.wrap_command) {
|
|
158
|
+
const run = spawnSync(config.wrap_command, {
|
|
159
|
+
shell: true, input: raw, encoding: "utf8", timeout: 1500,
|
|
160
|
+
});
|
|
161
|
+
prefix = (run.stdout || "").split("\\n")[0].trim();
|
|
162
|
+
} else {
|
|
163
|
+
const model = input.model?.display_name || "";
|
|
164
|
+
const dir = input.workspace?.current_dir ? basename(input.workspace.current_dir) : "";
|
|
165
|
+
prefix = [model, dir].filter(Boolean).join(" ยท ");
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
console.log(prefix ? prefix + " | " + penny : penny);
|
|
169
|
+
`;
|