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.
@@ -1,295 +1,318 @@
1
- import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile } from "../state.js";
2
- import { configureClaudeCodeStatusline, restoreClaudeCodeStatusline } from "../statusline.js";
3
-
4
- const CONFIG_PATH = "~/.claude/settings.json";
5
- const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
6
- const CONNECTOR_SETTING = "disableClaudeAiConnectors";
7
- // Values written into Claude Code's four model slots. CC displays a slot's VALUE verbatim in
8
- // its /model menu AND sends it as the request `model`, so each value is both the menu label
9
- // and the gateway's routing key. The three concrete Claude slots carry friendly PINNED labels
10
- // ("Penny Opus/Sonnet/Haiku/Fable") that the gateway maps to that exact model — the user's pick
11
- // runs on the model they chose, no dynamic tier switching on the trunk (background/utility
12
- // calls still route cheap, which is where the savings come from). The separate Fable slot is
13
- // pinned to Fable; the DYNAMIC "Penny Custom" option lets PennyRouter cost-route. Legacy
14
- // "Penny Speed/Core/Power"
15
- // installs (dynamic bundles) are still recognized for uninstall (see isPennyRouterModelValue).
16
- export const CLAUDE_CODE_MODELS = {
17
- haiku: "Penny Haiku", // -> pinned anthropic/claude-haiku-4-5-20251001
18
- sonnet: "Penny Sonnet", // -> pinned anthropic/claude-sonnet-4-6
19
- opus: "Penny Opus", // -> pinned anthropic/claude-opus-4-8
20
- fable: "Penny Fable", // -> pinned anthropic/claude-fable-5
21
- custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
22
- };
23
- // Retired dynamic-bundle labels (pre-pinned-trunk). Not written by a fresh install; matched
24
- // only so uninstall/detect still recognize and clean up an older install.
25
- const LEGACY_PENNY_LABELS = ["Penny Speed", "Penny Core", "Penny Power"];
26
-
27
- export const claudeCodeHarness = {
28
- id: "claude-code",
29
- name: "Claude Code",
30
-
31
- async detect() {
32
- return exists(CONFIG_PATH);
33
- },
34
-
35
- async planInstall() {
36
- return {
37
- configPath: expandHome(CONFIG_PATH),
38
- summary: [
39
- `write ${CONFIG_PATH}`,
40
- "set Anthropic-compatible base URL to PennyRouter",
41
- "store the PennyRouter API key without printing it",
42
- "map Claude Code models to Penny model choices",
43
- ],
44
- };
45
- },
46
-
47
- async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
48
- const backupPath = await backupFile(CONFIG_PATH, "claude-code");
49
- const current = (await readJsonFile(CONFIG_PATH, null)) || {};
50
- const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
51
- current.env ||= {};
52
- current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
53
- // Custom base URL: Claude Code authenticates with the auth token, not an
54
- // Anthropic API key. Clear any stale key so it doesn't take precedence.
55
- current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
56
- delete current.env.ANTHROPIC_API_KEY;
57
- // Claude Code displays these env values directly in its model picker; the
58
- // gateway decodes the friendly labels into bundle/profile overrides.
59
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
60
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
61
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
62
- current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
63
-
64
- const statusline = await configureClaudeCodeStatusline(current);
65
-
66
- await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
67
-
68
- return {
69
- harness: "claude-code",
70
- config_path: compactHome(plan.configPath),
71
- backup_path: backupPath ? compactHome(backupPath) : null,
72
- installed_at: new Date().toISOString(),
73
- restart: "restart Claude Code",
74
- statusline: statusline.wrapped ? "wrapped-existing" : "installed",
75
- claude_ai_connectors_before: claudeAiConnectorsBefore,
76
- changes: {
77
- settings_keys: [CONNECTOR_SETTING],
78
- env_keys: [
79
- "ANTHROPIC_BASE_URL",
80
- "ANTHROPIC_AUTH_TOKEN",
81
- "ANTHROPIC_DEFAULT_OPUS_MODEL",
82
- "ANTHROPIC_DEFAULT_SONNET_MODEL",
83
- "ANTHROPIC_DEFAULT_HAIKU_MODEL",
84
- "ANTHROPIC_DEFAULT_FABLE_MODEL",
85
- ],
86
- },
87
- };
88
- },
89
-
90
- // Stored-token mode: the Anthropic setup-token was already POSTed to the gateway
91
- // and encrypted against the account (see cli.js storeAnthropicToken). Claude Code's
92
- // config therefore carries only the pr- bearer + base URL, not the Anthropic token.
93
- // The gateway pulls the stored token per request. This avoids putting the Anthropic
94
- // credential on disk and avoids a dual-credential configuration.
95
- async installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
96
- const backupPath = await backupFile(CONFIG_PATH, "claude-code");
97
- const current = (await readJsonFile(CONFIG_PATH, null)) || {};
98
- const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
99
- current.env ||= {};
100
- current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
101
- current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
102
- // Single credential only: no stale api key, no separate Anthropic bearer.
103
- delete current.env.ANTHROPIC_API_KEY;
104
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
105
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
106
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
107
- current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
108
-
109
- const statusline = await configureClaudeCodeStatusline(current);
110
-
111
- await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
112
-
113
- return {
114
- harness: "claude-code",
115
- config_path: compactHome(plan.configPath),
116
- backup_path: backupPath ? compactHome(backupPath) : null,
117
- installed_at: new Date().toISOString(),
118
- restart: "restart Claude Code",
119
- statusline: statusline.wrapped ? "wrapped-existing" : "installed",
120
- claude_ai_connectors_before: claudeAiConnectorsBefore,
121
- changes: {
122
- settings_keys: [CONNECTOR_SETTING],
123
- env_keys: [
124
- "ANTHROPIC_BASE_URL",
125
- "ANTHROPIC_AUTH_TOKEN",
126
- "ANTHROPIC_DEFAULT_OPUS_MODEL",
127
- "ANTHROPIC_DEFAULT_SONNET_MODEL",
128
- "ANTHROPIC_DEFAULT_HAIKU_MODEL",
129
- "ANTHROPIC_DEFAULT_FABLE_MODEL",
130
- ],
131
- },
132
- };
133
- },
134
-
135
- async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl, existingRecord }) {
136
- const backupPath = await backupFile(CONFIG_PATH, "claude-code-anthropic-auth");
137
- const current = (await readJsonFile(CONFIG_PATH, null)) || {};
138
- const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
139
- current.env ||= {};
140
- current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
141
- // Dual-auth mode: x-api-key authenticates the local user to PennyRouter; the
142
- // Authorization bearer token is forwarded by the gateway to Anthropic.
143
- current.env.ANTHROPIC_API_KEY = pennyKey;
144
- current.env.ANTHROPIC_AUTH_TOKEN = anthropicAuthToken;
145
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
146
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
147
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
148
- current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
149
-
150
- const statusline = await configureClaudeCodeStatusline(current);
151
-
152
- await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
153
- return {
154
- harness: "claude-code",
155
- config_path: compactHome(expandHome(CONFIG_PATH)),
156
- backup_path: backupPath ? compactHome(backupPath) : null,
157
- installed_at: new Date().toISOString(),
158
- restart: "restart Claude Code",
159
- statusline: statusline.wrapped ? "wrapped-existing" : "installed",
160
- claude_ai_connectors_before: claudeAiConnectorsBefore,
161
- changes: {
162
- settings_keys: [CONNECTOR_SETTING],
163
- env_keys: [
164
- "ANTHROPIC_BASE_URL",
165
- "ANTHROPIC_API_KEY",
166
- "ANTHROPIC_AUTH_TOKEN",
167
- "ANTHROPIC_DEFAULT_OPUS_MODEL",
168
- "ANTHROPIC_DEFAULT_SONNET_MODEL",
169
- "ANTHROPIC_DEFAULT_HAIKU_MODEL",
170
- "ANTHROPIC_DEFAULT_FABLE_MODEL",
171
- ],
172
- },
173
- };
174
- },
175
-
176
- async uninstall(record) {
177
- const current = (await readJsonFile(record.config_path, null)) || {};
178
- if (hasPennyRouterEnv(current.env)) {
179
- // If the base URL points at PennyRouter, every auth value in env was written
180
- // by us for our gateway (a Penny pr- key and/or an Anthropic OAuth bearer we
181
- // forward upstream) — clear them all. We can't rely on the token's prefix:
182
- // in OAuth mode ANTHROPIC_AUTH_TOKEN holds an Anthropic sk-ant-oat... token,
183
- // not a pr- value, and the manifest's anthropic_oauth flag may be absent on a
184
- // record synthesized from a detected config. Keying off our base URL is what
185
- // makes cleanup reliable regardless of which auth mode was installed.
186
- const ourGateway = isPennyRouterBaseUrl(current.env.ANTHROPIC_BASE_URL);
187
- delete current.env.ANTHROPIC_BASE_URL;
188
- const tok = current.env.ANTHROPIC_AUTH_TOKEN;
189
- if (typeof tok === "string" && (ourGateway || tok.startsWith("pr-") || record.anthropic_oauth)) {
190
- delete current.env.ANTHROPIC_AUTH_TOKEN;
191
- }
192
- // ANTHROPIC_API_KEY carries the Penny key in OAuth dual-auth mode, or a pr-
193
- // key in legacy installs. Clear it when it's ours by base URL or by prefix.
194
- if (typeof current.env.ANTHROPIC_API_KEY === "string" && (ourGateway || current.env.ANTHROPIC_API_KEY.startsWith("pr-"))) {
195
- delete current.env.ANTHROPIC_API_KEY;
196
- }
197
- for (const k of [
198
- "ANTHROPIC_DEFAULT_OPUS_MODEL",
199
- "ANTHROPIC_DEFAULT_SONNET_MODEL",
200
- "ANTHROPIC_DEFAULT_HAIKU_MODEL",
201
- "ANTHROPIC_DEFAULT_FABLE_MODEL",
202
- ]) {
203
- if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
204
- }
205
- }
206
- restoreClaudeAiConnectors(current, record);
207
- if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
208
- delete current["anthropic.baseURL"];
209
- }
210
- if (typeof current["anthropic.apiKey"] === "string" && current["anthropic.apiKey"].startsWith("pr-")) {
211
- delete current["anthropic.apiKey"];
212
- }
213
- if (current.env && Object.keys(current.env).length === 0) delete current.env;
214
- // Statusline: restore the user's original command if we wrapped one, else remove ours.
215
- await restoreClaudeCodeStatusline(current);
216
- await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
217
- },
218
- };
219
-
220
- export function disableClaudeAiConnectors(settings, existingRecord = null) {
221
- const saved = existingRecord?.claude_ai_connectors_before;
222
- const before = saved && typeof saved.present === "boolean"
223
- ? { ...saved }
224
- : Object.prototype.hasOwnProperty.call(settings, CONNECTOR_SETTING)
225
- ? { present: true, value: settings[CONNECTOR_SETTING] }
226
- : { present: false };
227
- settings[CONNECTOR_SETTING] = true;
228
- return before;
229
- }
230
-
231
- export function restoreClaudeAiConnectors(settings, record) {
232
- const before = record?.claude_ai_connectors_before;
233
- if (!before || typeof before.present !== "boolean") return;
234
- // Respect a user edit made after installation. We only undo the value we manage.
235
- if (settings[CONNECTOR_SETTING] !== true) return;
236
- if (before.present) settings[CONNECTOR_SETTING] = before.value;
237
- else delete settings[CONNECTOR_SETTING];
238
- }
239
-
240
- function hasPennyRouterEnv(env) {
241
- if (!env || typeof env !== "object") return false;
242
- return (
243
- isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ||
244
- isPennyRouterValue(env.ANTHROPIC_AUTH_TOKEN) ||
245
- isPennyRouterValue(env.ANTHROPIC_API_KEY) ||
246
- env.ANTHROPIC_DEFAULT_OPUS_MODEL === LEGACY_MODEL_LABEL ||
247
- env.ANTHROPIC_DEFAULT_SONNET_MODEL === LEGACY_MODEL_LABEL ||
248
- env.ANTHROPIC_DEFAULT_HAIKU_MODEL === LEGACY_MODEL_LABEL ||
249
- env.ANTHROPIC_DEFAULT_FABLE_MODEL === LEGACY_MODEL_LABEL ||
250
- isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_OPUS_MODEL) ||
251
- isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_SONNET_MODEL) ||
252
- isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_HAIKU_MODEL) ||
253
- isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_FABLE_MODEL)
254
- );
255
- }
256
-
257
- function isPennyRouterValue(value) {
258
- return typeof value === "string" && (value.startsWith("pr-") || value.includes("pennyrouter"));
259
- }
260
-
261
- function isPennyRouterBaseUrl(value) {
262
- return typeof value === "string" && value.includes("pennyrouter");
263
- }
264
-
265
- function isPennyRouterModelValue(value) {
266
- return (
267
- isPennyRouterValue(value) ||
268
- value === CLAUDE_CODE_MODELS.haiku ||
269
- value === CLAUDE_CODE_MODELS.sonnet ||
270
- value === CLAUDE_CODE_MODELS.opus ||
271
- value === CLAUDE_CODE_MODELS.fable ||
272
- value === CLAUDE_CODE_MODELS.custom ||
273
- LEGACY_PENNY_LABELS.includes(value)
274
- );
275
- }
276
-
277
- export async function readClaudeCodePennyRouterKey() {
278
- const current = (await readJsonFile(CONFIG_PATH, null)) || {};
279
- const env = current.env || {};
280
- for (const value of [env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN, current["anthropic.apiKey"]]) {
281
- if (typeof value === "string" && value.startsWith("pr-")) return value;
282
- }
283
- return null;
284
- }
285
-
286
- // The PennyRouter gateway URL + pr- key currently in Claude Code's config, used to
287
- // call the gateway's "forget my Claude token" endpoint on uninstall. Read before
288
- // uninstall strips these keys. Either field may be null if not present.
289
- export async function readClaudeCodeGatewayAuth() {
290
- const current = (await readJsonFile(CONFIG_PATH, null)) || {};
291
- const env = current.env || {};
292
- const gatewayBaseUrl = isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ? env.ANTHROPIC_BASE_URL : null;
293
- const apiKey = await readClaudeCodePennyRouterKey();
294
- return { gatewayBaseUrl, apiKey };
295
- }
1
+ import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile } from "../state.js";
2
+ import { configureClaudeCodeStatusline, restoreClaudeCodeStatusline } from "../statusline.js";
3
+
4
+ const CONFIG_PATH = "~/.claude/settings.json";
5
+ const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
6
+ const CONNECTOR_SETTING = "disableClaudeAiConnectors";
7
+ // Values written into Claude Code's four model slots. CC displays a slot's VALUE verbatim in
8
+ // its /model menu AND sends it as the request `model`, so each value is both the menu label
9
+ // and the gateway's routing key. The three concrete Claude slots carry friendly PINNED labels
10
+ // ("Opus 5/Sonnet 5/Haiku 4.5/Fable 5 (PennyRouter)") that the gateway maps to that exact
11
+ // model — the user's pick runs on the model they chose, no dynamic tier switching on the
12
+ // trunk (background/utility calls still route cheap, which is where the savings come from).
13
+ // Named "<Model> (PennyRouter)" rather than "Penny <Model>" so CC's own generated "Custom ...
14
+ // model" subtext still reads as a real Claude model routed through Penny, not a distinct
15
+ // custom model. The separate Fable slot is pinned to Fable; the DYNAMIC "Penny Custom" option
16
+ // lets PennyRouter cost-route. Legacy "Penny Speed/Core/Power/Opus/Sonnet/Haiku/Fable"
17
+ // installs (dynamic bundles, or the pre-rename pinned labels) are still recognized for
18
+ // uninstall (see isPennyRouterModelValue).
19
+ export const CLAUDE_CODE_MODELS = {
20
+ haiku: "Haiku 4.5 (PennyRouter)", // -> pinned anthropic/claude-haiku-4-5-20251001
21
+ sonnet: "Sonnet 5 (PennyRouter)", // -> pinned anthropic/claude-sonnet-5
22
+ opus: "Opus 5 (PennyRouter)", // -> pinned anthropic/claude-opus-5
23
+ fable: "Fable 5 (PennyRouter)", // -> pinned anthropic/claude-fable-5
24
+ custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
25
+ };
26
+ // Retired dynamic-bundle labels (pre-pinned-trunk) and the pre-rename pinned labels. Not
27
+ // written by a fresh install; matched only so uninstall/detect still recognize and clean up
28
+ // an older install.
29
+ const LEGACY_PENNY_LABELS = [
30
+ "Penny Speed", "Penny Core", "Penny Power",
31
+ "Penny Opus", "Penny Sonnet", "Penny Haiku", "Penny Fable",
32
+ ];
33
+
34
+ export const claudeCodeHarness = {
35
+ id: "claude-code",
36
+ name: "Claude Code",
37
+
38
+ async detect() {
39
+ return exists(CONFIG_PATH);
40
+ },
41
+
42
+ async planInstall() {
43
+ return {
44
+ configPath: expandHome(CONFIG_PATH),
45
+ summary: [
46
+ `write ${CONFIG_PATH}`,
47
+ "set Anthropic-compatible base URL to PennyRouter",
48
+ "store the PennyRouter API key without printing it",
49
+ "map Claude Code models to Penny model choices",
50
+ ],
51
+ };
52
+ },
53
+
54
+ async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
55
+ const backupPath = await backupFile(CONFIG_PATH, "claude-code");
56
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
57
+ const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
58
+ current.env ||= {};
59
+ current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
60
+ // Custom base URL: Claude Code authenticates with the auth token, not an
61
+ // Anthropic API key. Clear any stale key so it doesn't take precedence.
62
+ current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
63
+ delete current.env.ANTHROPIC_API_KEY;
64
+ // Claude Code displays these env values directly in its model picker; the
65
+ // gateway decodes the friendly labels into bundle/profile overrides.
66
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
67
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
68
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
69
+ current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
70
+ // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
71
+ // context window from the name and falls back to a conservative 200K default. Tell it
72
+ // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
73
+ current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
74
+
75
+ const statusline = await configureClaudeCodeStatusline(current);
76
+
77
+ await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
78
+
79
+ return {
80
+ harness: "claude-code",
81
+ config_path: compactHome(plan.configPath),
82
+ backup_path: backupPath ? compactHome(backupPath) : null,
83
+ installed_at: new Date().toISOString(),
84
+ restart: "restart Claude Code",
85
+ statusline: statusline.wrapped ? "wrapped-existing" : "installed",
86
+ claude_ai_connectors_before: claudeAiConnectorsBefore,
87
+ changes: {
88
+ settings_keys: [CONNECTOR_SETTING],
89
+ env_keys: [
90
+ "ANTHROPIC_BASE_URL",
91
+ "ANTHROPIC_AUTH_TOKEN",
92
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
93
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
94
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
95
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
96
+ "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
97
+ ],
98
+ },
99
+ };
100
+ },
101
+
102
+ // Stored-token mode: the Anthropic setup-token was already POSTed to the gateway
103
+ // and encrypted against the account (see cli.js storeAnthropicToken). Claude Code's
104
+ // config therefore carries only the pr- bearer + base URL, not the Anthropic token.
105
+ // The gateway pulls the stored token per request. This avoids putting the Anthropic
106
+ // credential on disk and avoids a dual-credential configuration.
107
+ async installStoredToken({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
108
+ const backupPath = await backupFile(CONFIG_PATH, "claude-code");
109
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
110
+ const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
111
+ current.env ||= {};
112
+ current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
113
+ current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
114
+ // Single credential only: no stale api key, no separate Anthropic bearer.
115
+ delete current.env.ANTHROPIC_API_KEY;
116
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
117
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
118
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
119
+ current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
120
+ // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
121
+ // context window from the name and falls back to a conservative 200K default. Tell it
122
+ // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
123
+ current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
124
+
125
+ const statusline = await configureClaudeCodeStatusline(current);
126
+
127
+ await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
128
+
129
+ return {
130
+ harness: "claude-code",
131
+ config_path: compactHome(plan.configPath),
132
+ backup_path: backupPath ? compactHome(backupPath) : null,
133
+ installed_at: new Date().toISOString(),
134
+ restart: "restart Claude Code",
135
+ statusline: statusline.wrapped ? "wrapped-existing" : "installed",
136
+ claude_ai_connectors_before: claudeAiConnectorsBefore,
137
+ changes: {
138
+ settings_keys: [CONNECTOR_SETTING],
139
+ env_keys: [
140
+ "ANTHROPIC_BASE_URL",
141
+ "ANTHROPIC_AUTH_TOKEN",
142
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
143
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
144
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
145
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
146
+ "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
147
+ ],
148
+ },
149
+ };
150
+ },
151
+
152
+ async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl, existingRecord }) {
153
+ const backupPath = await backupFile(CONFIG_PATH, "claude-code-anthropic-auth");
154
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
155
+ const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
156
+ current.env ||= {};
157
+ current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
158
+ // Dual-auth mode: x-api-key authenticates the local user to PennyRouter; the
159
+ // Authorization bearer token is forwarded by the gateway to Anthropic.
160
+ current.env.ANTHROPIC_API_KEY = pennyKey;
161
+ current.env.ANTHROPIC_AUTH_TOKEN = anthropicAuthToken;
162
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
163
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
164
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
165
+ current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
166
+ // The pinned labels above aren't real Anthropic model ids, so CC can't infer their
167
+ // context window from the name and falls back to a conservative 200K default. Tell it
168
+ // directly: every pinned slot is a Claude model with the 1M-context beta enabled upstream.
169
+ current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS = "1000000";
170
+
171
+ const statusline = await configureClaudeCodeStatusline(current);
172
+
173
+ await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
174
+ return {
175
+ harness: "claude-code",
176
+ config_path: compactHome(expandHome(CONFIG_PATH)),
177
+ backup_path: backupPath ? compactHome(backupPath) : null,
178
+ installed_at: new Date().toISOString(),
179
+ restart: "restart Claude Code",
180
+ statusline: statusline.wrapped ? "wrapped-existing" : "installed",
181
+ claude_ai_connectors_before: claudeAiConnectorsBefore,
182
+ changes: {
183
+ settings_keys: [CONNECTOR_SETTING],
184
+ env_keys: [
185
+ "ANTHROPIC_BASE_URL",
186
+ "ANTHROPIC_API_KEY",
187
+ "ANTHROPIC_AUTH_TOKEN",
188
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
189
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
190
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
191
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
192
+ "CLAUDE_CODE_MAX_CONTEXT_TOKENS",
193
+ ],
194
+ },
195
+ };
196
+ },
197
+
198
+ async uninstall(record) {
199
+ const current = (await readJsonFile(record.config_path, null)) || {};
200
+ if (hasPennyRouterEnv(current.env)) {
201
+ // If the base URL points at PennyRouter, every auth value in env was written
202
+ // by us for our gateway (a Penny pr- key and/or an Anthropic OAuth bearer we
203
+ // forward upstream) — clear them all. We can't rely on the token's prefix:
204
+ // in OAuth mode ANTHROPIC_AUTH_TOKEN holds an Anthropic sk-ant-oat... token,
205
+ // not a pr- value, and the manifest's anthropic_oauth flag may be absent on a
206
+ // record synthesized from a detected config. Keying off our base URL is what
207
+ // makes cleanup reliable regardless of which auth mode was installed.
208
+ const ourGateway = isPennyRouterBaseUrl(current.env.ANTHROPIC_BASE_URL);
209
+ delete current.env.ANTHROPIC_BASE_URL;
210
+ const tok = current.env.ANTHROPIC_AUTH_TOKEN;
211
+ if (typeof tok === "string" && (ourGateway || tok.startsWith("pr-") || record.anthropic_oauth)) {
212
+ delete current.env.ANTHROPIC_AUTH_TOKEN;
213
+ }
214
+ // ANTHROPIC_API_KEY carries the Penny key in OAuth dual-auth mode, or a pr-
215
+ // key in legacy installs. Clear it when it's ours by base URL or by prefix.
216
+ if (typeof current.env.ANTHROPIC_API_KEY === "string" && (ourGateway || current.env.ANTHROPIC_API_KEY.startsWith("pr-"))) {
217
+ delete current.env.ANTHROPIC_API_KEY;
218
+ }
219
+ for (const k of [
220
+ "ANTHROPIC_DEFAULT_OPUS_MODEL",
221
+ "ANTHROPIC_DEFAULT_SONNET_MODEL",
222
+ "ANTHROPIC_DEFAULT_HAIKU_MODEL",
223
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
224
+ ]) {
225
+ if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
226
+ }
227
+ delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
228
+ }
229
+ restoreClaudeAiConnectors(current, record);
230
+ if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
231
+ delete current["anthropic.baseURL"];
232
+ }
233
+ if (typeof current["anthropic.apiKey"] === "string" && current["anthropic.apiKey"].startsWith("pr-")) {
234
+ delete current["anthropic.apiKey"];
235
+ }
236
+ if (current.env && Object.keys(current.env).length === 0) delete current.env;
237
+ // Statusline: restore the user's original command if we wrapped one, else remove ours.
238
+ await restoreClaudeCodeStatusline(current);
239
+ await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
240
+ },
241
+ };
242
+
243
+ export function disableClaudeAiConnectors(settings, existingRecord = null) {
244
+ const saved = existingRecord?.claude_ai_connectors_before;
245
+ const before = saved && typeof saved.present === "boolean"
246
+ ? { ...saved }
247
+ : Object.prototype.hasOwnProperty.call(settings, CONNECTOR_SETTING)
248
+ ? { present: true, value: settings[CONNECTOR_SETTING] }
249
+ : { present: false };
250
+ settings[CONNECTOR_SETTING] = true;
251
+ return before;
252
+ }
253
+
254
+ export function restoreClaudeAiConnectors(settings, record) {
255
+ const before = record?.claude_ai_connectors_before;
256
+ if (!before || typeof before.present !== "boolean") return;
257
+ // Respect a user edit made after installation. We only undo the value we manage.
258
+ if (settings[CONNECTOR_SETTING] !== true) return;
259
+ if (before.present) settings[CONNECTOR_SETTING] = before.value;
260
+ else delete settings[CONNECTOR_SETTING];
261
+ }
262
+
263
+ function hasPennyRouterEnv(env) {
264
+ if (!env || typeof env !== "object") return false;
265
+ return (
266
+ isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ||
267
+ isPennyRouterValue(env.ANTHROPIC_AUTH_TOKEN) ||
268
+ isPennyRouterValue(env.ANTHROPIC_API_KEY) ||
269
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL === LEGACY_MODEL_LABEL ||
270
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL === LEGACY_MODEL_LABEL ||
271
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL === LEGACY_MODEL_LABEL ||
272
+ env.ANTHROPIC_DEFAULT_FABLE_MODEL === LEGACY_MODEL_LABEL ||
273
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_OPUS_MODEL) ||
274
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_SONNET_MODEL) ||
275
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_HAIKU_MODEL) ||
276
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_FABLE_MODEL)
277
+ );
278
+ }
279
+
280
+ function isPennyRouterValue(value) {
281
+ return typeof value === "string" && (value.startsWith("pr-") || value.includes("pennyrouter"));
282
+ }
283
+
284
+ function isPennyRouterBaseUrl(value) {
285
+ return typeof value === "string" && value.includes("pennyrouter");
286
+ }
287
+
288
+ function isPennyRouterModelValue(value) {
289
+ return (
290
+ isPennyRouterValue(value) ||
291
+ value === CLAUDE_CODE_MODELS.haiku ||
292
+ value === CLAUDE_CODE_MODELS.sonnet ||
293
+ value === CLAUDE_CODE_MODELS.opus ||
294
+ value === CLAUDE_CODE_MODELS.fable ||
295
+ value === CLAUDE_CODE_MODELS.custom ||
296
+ LEGACY_PENNY_LABELS.includes(value)
297
+ );
298
+ }
299
+
300
+ export async function readClaudeCodePennyRouterKey() {
301
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
302
+ const env = current.env || {};
303
+ for (const value of [env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN, current["anthropic.apiKey"]]) {
304
+ if (typeof value === "string" && value.startsWith("pr-")) return value;
305
+ }
306
+ return null;
307
+ }
308
+
309
+ // The PennyRouter gateway URL + pr- key currently in Claude Code's config, used to
310
+ // call the gateway's "forget my Claude token" endpoint on uninstall. Read before
311
+ // uninstall strips these keys. Either field may be null if not present.
312
+ export async function readClaudeCodeGatewayAuth() {
313
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
314
+ const env = current.env || {};
315
+ const gatewayBaseUrl = isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ? env.ANTHROPIC_BASE_URL : null;
316
+ const apiKey = await readClaudeCodePennyRouterKey();
317
+ return { gatewayBaseUrl, apiKey };
318
+ }