pennyrouter 0.2.8 → 0.2.10
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 -225
- package/bin/penny.js +9 -9
- package/bin/pennyrouter.js +7 -7
- package/package.json +30 -30
- package/src/cli.js +1774 -1727
- package/src/harnesses/aider.js +48 -48
- package/src/harnesses/claude-code.js +322 -318
- 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 +487 -467
- package/src/launch.test.js +170 -153
- package/src/mcp.js +228 -0
- package/src/mcp.test.js +182 -0
- 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/thread-session.js +204 -0
- package/src/thread-session.test.js +140 -0
|
@@ -1,318 +1,322 @@
|
|
|
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
|
|
9
|
-
//
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
//
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
"
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
const
|
|
58
|
-
current
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
current.env.
|
|
69
|
-
current.env.
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
//
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
"
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"
|
|
95
|
-
"
|
|
96
|
-
"
|
|
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
|
-
//
|
|
121
|
-
// context
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
"
|
|
141
|
-
"
|
|
142
|
-
"
|
|
143
|
-
"
|
|
144
|
-
"
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
}
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
const
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
current.env.
|
|
161
|
-
current.env.
|
|
162
|
-
current.env.
|
|
163
|
-
current.env.
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
"
|
|
186
|
-
"
|
|
187
|
-
"
|
|
188
|
-
"
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
};
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
//
|
|
202
|
-
//
|
|
203
|
-
//
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
delete current.
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
if
|
|
234
|
-
|
|
235
|
-
}
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
env.ANTHROPIC_DEFAULT_OPUS_MODEL
|
|
270
|
-
env.ANTHROPIC_DEFAULT_SONNET_MODEL
|
|
271
|
-
env.ANTHROPIC_DEFAULT_HAIKU_MODEL
|
|
272
|
-
env.ANTHROPIC_DEFAULT_FABLE_MODEL
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
);
|
|
278
|
-
}
|
|
279
|
-
|
|
280
|
-
function
|
|
281
|
-
return typeof value === "string" &&
|
|
282
|
-
}
|
|
283
|
-
|
|
284
|
-
function
|
|
285
|
-
return
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
value === CLAUDE_CODE_MODELS.
|
|
292
|
-
value
|
|
293
|
-
value
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
export
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
}
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
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 use genuine Anthropic ids here.
|
|
9
|
+
// That lets Claude Code apply its own per-model context limits: Haiku gets its 200K window;
|
|
10
|
+
// the 1M-capable slots get 1M. The gateway recognizes and pins these native ids, so the user's
|
|
11
|
+
// pick still runs on that exact model (background/utility calls remain cheap). "Penny Custom"
|
|
12
|
+
// remains the dynamic PennyRouter option. Friendly pinned labels from earlier installs remain
|
|
13
|
+
// recognized for uninstall and backwards compatibility (see isPennyRouterModelValue).
|
|
14
|
+
export const CLAUDE_CODE_MODELS = {
|
|
15
|
+
haiku: "claude-haiku-4-5-20251001",
|
|
16
|
+
sonnet: "claude-sonnet-5",
|
|
17
|
+
opus: "claude-opus-5",
|
|
18
|
+
fable: "claude-fable-5",
|
|
19
|
+
custom: "Penny Custom", // -> dynamic (PennyRouter cost-routing)
|
|
20
|
+
};
|
|
21
|
+
const LEGACY_PINNED_MODEL_IDS = {
|
|
22
|
+
"Haiku 4.5 (PennyRouter)": CLAUDE_CODE_MODELS.haiku,
|
|
23
|
+
"Sonnet 5 (PennyRouter)": CLAUDE_CODE_MODELS.sonnet,
|
|
24
|
+
"Opus 5 (PennyRouter)": CLAUDE_CODE_MODELS.opus,
|
|
25
|
+
"Fable 5 (PennyRouter)": CLAUDE_CODE_MODELS.fable,
|
|
26
|
+
};
|
|
27
|
+
const LEGACY_PINNED_MODEL_LABELS = Object.keys(LEGACY_PINNED_MODEL_IDS);
|
|
28
|
+
// Retired dynamic-bundle labels (pre-pinned-trunk) and the pre-rename pinned labels. Not
|
|
29
|
+
// written by a fresh install; matched only so uninstall/detect still recognize and clean up
|
|
30
|
+
// an older install.
|
|
31
|
+
const LEGACY_PENNY_LABELS = [
|
|
32
|
+
"Penny Speed", "Penny Core", "Penny Power",
|
|
33
|
+
"Penny Opus", "Penny Sonnet", "Penny Haiku", "Penny Fable",
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
export const claudeCodeHarness = {
|
|
37
|
+
id: "claude-code",
|
|
38
|
+
name: "Claude Code",
|
|
39
|
+
|
|
40
|
+
async detect() {
|
|
41
|
+
return exists(CONFIG_PATH);
|
|
42
|
+
},
|
|
43
|
+
|
|
44
|
+
async planInstall() {
|
|
45
|
+
return {
|
|
46
|
+
configPath: expandHome(CONFIG_PATH),
|
|
47
|
+
summary: [
|
|
48
|
+
`write ${CONFIG_PATH}`,
|
|
49
|
+
"set Anthropic-compatible base URL to PennyRouter",
|
|
50
|
+
"store the PennyRouter API key without printing it",
|
|
51
|
+
"map Claude Code models to Penny model choices",
|
|
52
|
+
],
|
|
53
|
+
};
|
|
54
|
+
},
|
|
55
|
+
|
|
56
|
+
async install({ apiKey, gatewayBaseUrl, plan, existingRecord }) {
|
|
57
|
+
const backupPath = await backupFile(CONFIG_PATH, "claude-code");
|
|
58
|
+
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
59
|
+
const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
|
|
60
|
+
current.env ||= {};
|
|
61
|
+
current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
|
|
62
|
+
// Custom base URL: Claude Code authenticates with the auth token, not an
|
|
63
|
+
// Anthropic API key. Clear any stale key so it doesn't take precedence.
|
|
64
|
+
current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
|
65
|
+
delete current.env.ANTHROPIC_API_KEY;
|
|
66
|
+
// Claude Code displays these env values directly in its model picker; the
|
|
67
|
+
// gateway decodes the friendly labels into bundle/profile overrides.
|
|
68
|
+
current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
|
|
69
|
+
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
|
|
70
|
+
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
|
|
71
|
+
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
|
|
72
|
+
// This is global, not per-model. Leave it unset so Claude Code uses each native id's
|
|
73
|
+
// real context limit instead of incorrectly giving Haiku a 1M window.
|
|
74
|
+
delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
|
75
|
+
|
|
76
|
+
const statusline = await configureClaudeCodeStatusline(current);
|
|
77
|
+
|
|
78
|
+
await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
|
|
79
|
+
|
|
80
|
+
return {
|
|
81
|
+
harness: "claude-code",
|
|
82
|
+
config_path: compactHome(plan.configPath),
|
|
83
|
+
backup_path: backupPath ? compactHome(backupPath) : null,
|
|
84
|
+
installed_at: new Date().toISOString(),
|
|
85
|
+
restart: "restart Claude Code",
|
|
86
|
+
statusline: statusline.wrapped ? "wrapped-existing" : "installed",
|
|
87
|
+
claude_ai_connectors_before: claudeAiConnectorsBefore,
|
|
88
|
+
changes: {
|
|
89
|
+
settings_keys: [CONNECTOR_SETTING],
|
|
90
|
+
env_keys: [
|
|
91
|
+
"ANTHROPIC_BASE_URL",
|
|
92
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
93
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
94
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
95
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
96
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
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
|
+
// This is global, not per-model. Leave it unset so Claude Code uses each native id's
|
|
121
|
+
// real context limit instead of incorrectly giving Haiku a 1M window.
|
|
122
|
+
delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
|
123
|
+
|
|
124
|
+
const statusline = await configureClaudeCodeStatusline(current);
|
|
125
|
+
|
|
126
|
+
await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
|
|
127
|
+
|
|
128
|
+
return {
|
|
129
|
+
harness: "claude-code",
|
|
130
|
+
config_path: compactHome(plan.configPath),
|
|
131
|
+
backup_path: backupPath ? compactHome(backupPath) : null,
|
|
132
|
+
installed_at: new Date().toISOString(),
|
|
133
|
+
restart: "restart Claude Code",
|
|
134
|
+
statusline: statusline.wrapped ? "wrapped-existing" : "installed",
|
|
135
|
+
claude_ai_connectors_before: claudeAiConnectorsBefore,
|
|
136
|
+
changes: {
|
|
137
|
+
settings_keys: [CONNECTOR_SETTING],
|
|
138
|
+
env_keys: [
|
|
139
|
+
"ANTHROPIC_BASE_URL",
|
|
140
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
141
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
142
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
143
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
144
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
145
|
+
],
|
|
146
|
+
},
|
|
147
|
+
};
|
|
148
|
+
},
|
|
149
|
+
|
|
150
|
+
async installAnthropicOAuth({ pennyKey, anthropicAuthToken, gatewayBaseUrl, existingRecord }) {
|
|
151
|
+
const backupPath = await backupFile(CONFIG_PATH, "claude-code-anthropic-auth");
|
|
152
|
+
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
153
|
+
const claudeAiConnectorsBefore = disableClaudeAiConnectors(current, existingRecord);
|
|
154
|
+
current.env ||= {};
|
|
155
|
+
current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
|
|
156
|
+
// Dual-auth mode: x-api-key authenticates the local user to PennyRouter; the
|
|
157
|
+
// Authorization bearer token is forwarded by the gateway to Anthropic.
|
|
158
|
+
current.env.ANTHROPIC_API_KEY = pennyKey;
|
|
159
|
+
current.env.ANTHROPIC_AUTH_TOKEN = anthropicAuthToken;
|
|
160
|
+
current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.opus;
|
|
161
|
+
current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.sonnet;
|
|
162
|
+
current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.haiku;
|
|
163
|
+
current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.fable;
|
|
164
|
+
// This is global, not per-model. Leave it unset so Claude Code uses each native id's
|
|
165
|
+
// real context limit instead of incorrectly giving Haiku a 1M window.
|
|
166
|
+
delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
|
167
|
+
|
|
168
|
+
const statusline = await configureClaudeCodeStatusline(current);
|
|
169
|
+
|
|
170
|
+
await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
|
|
171
|
+
return {
|
|
172
|
+
harness: "claude-code",
|
|
173
|
+
config_path: compactHome(expandHome(CONFIG_PATH)),
|
|
174
|
+
backup_path: backupPath ? compactHome(backupPath) : null,
|
|
175
|
+
installed_at: new Date().toISOString(),
|
|
176
|
+
restart: "restart Claude Code",
|
|
177
|
+
statusline: statusline.wrapped ? "wrapped-existing" : "installed",
|
|
178
|
+
claude_ai_connectors_before: claudeAiConnectorsBefore,
|
|
179
|
+
changes: {
|
|
180
|
+
settings_keys: [CONNECTOR_SETTING],
|
|
181
|
+
env_keys: [
|
|
182
|
+
"ANTHROPIC_BASE_URL",
|
|
183
|
+
"ANTHROPIC_API_KEY",
|
|
184
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
185
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
186
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
187
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
188
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
189
|
+
],
|
|
190
|
+
},
|
|
191
|
+
};
|
|
192
|
+
},
|
|
193
|
+
|
|
194
|
+
async uninstall(record) {
|
|
195
|
+
const current = (await readJsonFile(record.config_path, null)) || {};
|
|
196
|
+
if (hasPennyRouterEnv(current.env)) {
|
|
197
|
+
// If the base URL points at PennyRouter, every auth value in env was written
|
|
198
|
+
// by us for our gateway (a Penny pr- key and/or an Anthropic OAuth bearer we
|
|
199
|
+
// forward upstream) — clear them all. We can't rely on the token's prefix:
|
|
200
|
+
// in OAuth mode ANTHROPIC_AUTH_TOKEN holds an Anthropic sk-ant-oat... token,
|
|
201
|
+
// not a pr- value, and the manifest's anthropic_oauth flag may be absent on a
|
|
202
|
+
// record synthesized from a detected config. Keying off our base URL is what
|
|
203
|
+
// makes cleanup reliable regardless of which auth mode was installed.
|
|
204
|
+
const ourGateway = isPennyRouterBaseUrl(current.env.ANTHROPIC_BASE_URL);
|
|
205
|
+
delete current.env.ANTHROPIC_BASE_URL;
|
|
206
|
+
const tok = current.env.ANTHROPIC_AUTH_TOKEN;
|
|
207
|
+
if (typeof tok === "string" && (ourGateway || tok.startsWith("pr-") || record.anthropic_oauth)) {
|
|
208
|
+
delete current.env.ANTHROPIC_AUTH_TOKEN;
|
|
209
|
+
}
|
|
210
|
+
// ANTHROPIC_API_KEY carries the Penny key in OAuth dual-auth mode, or a pr-
|
|
211
|
+
// key in legacy installs. Clear it when it's ours by base URL or by prefix.
|
|
212
|
+
if (typeof current.env.ANTHROPIC_API_KEY === "string" && (ourGateway || current.env.ANTHROPIC_API_KEY.startsWith("pr-"))) {
|
|
213
|
+
delete current.env.ANTHROPIC_API_KEY;
|
|
214
|
+
}
|
|
215
|
+
for (const k of [
|
|
216
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
217
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
218
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
219
|
+
"ANTHROPIC_DEFAULT_FABLE_MODEL",
|
|
220
|
+
]) {
|
|
221
|
+
if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
|
|
222
|
+
}
|
|
223
|
+
delete current.env.CLAUDE_CODE_MAX_CONTEXT_TOKENS;
|
|
224
|
+
}
|
|
225
|
+
restoreClaudeAiConnectors(current, record);
|
|
226
|
+
if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
|
|
227
|
+
delete current["anthropic.baseURL"];
|
|
228
|
+
}
|
|
229
|
+
if (typeof current["anthropic.apiKey"] === "string" && current["anthropic.apiKey"].startsWith("pr-")) {
|
|
230
|
+
delete current["anthropic.apiKey"];
|
|
231
|
+
}
|
|
232
|
+
if (current.env && Object.keys(current.env).length === 0) delete current.env;
|
|
233
|
+
// Statusline: restore the user's original command if we wrapped one, else remove ours.
|
|
234
|
+
await restoreClaudeCodeStatusline(current);
|
|
235
|
+
await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
|
|
236
|
+
},
|
|
237
|
+
};
|
|
238
|
+
|
|
239
|
+
export function disableClaudeAiConnectors(settings, existingRecord = null) {
|
|
240
|
+
const saved = existingRecord?.claude_ai_connectors_before;
|
|
241
|
+
const before = saved && typeof saved.present === "boolean"
|
|
242
|
+
? { ...saved }
|
|
243
|
+
: Object.prototype.hasOwnProperty.call(settings, CONNECTOR_SETTING)
|
|
244
|
+
? { present: true, value: settings[CONNECTOR_SETTING] }
|
|
245
|
+
: { present: false };
|
|
246
|
+
settings[CONNECTOR_SETTING] = true;
|
|
247
|
+
return before;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
export function restoreClaudeAiConnectors(settings, record) {
|
|
251
|
+
const before = record?.claude_ai_connectors_before;
|
|
252
|
+
if (!before || typeof before.present !== "boolean") return;
|
|
253
|
+
// Respect a user edit made after installation. We only undo the value we manage.
|
|
254
|
+
if (settings[CONNECTOR_SETTING] !== true) return;
|
|
255
|
+
if (before.present) settings[CONNECTOR_SETTING] = before.value;
|
|
256
|
+
else delete settings[CONNECTOR_SETTING];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function hasPennyRouterEnv(env) {
|
|
260
|
+
if (!env || typeof env !== "object") return false;
|
|
261
|
+
return (
|
|
262
|
+
isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ||
|
|
263
|
+
isPennyRouterValue(env.ANTHROPIC_AUTH_TOKEN) ||
|
|
264
|
+
isPennyRouterValue(env.ANTHROPIC_API_KEY) ||
|
|
265
|
+
env.ANTHROPIC_DEFAULT_OPUS_MODEL === LEGACY_MODEL_LABEL ||
|
|
266
|
+
env.ANTHROPIC_DEFAULT_SONNET_MODEL === LEGACY_MODEL_LABEL ||
|
|
267
|
+
env.ANTHROPIC_DEFAULT_HAIKU_MODEL === LEGACY_MODEL_LABEL ||
|
|
268
|
+
env.ANTHROPIC_DEFAULT_FABLE_MODEL === LEGACY_MODEL_LABEL ||
|
|
269
|
+
isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_OPUS_MODEL) ||
|
|
270
|
+
isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_SONNET_MODEL) ||
|
|
271
|
+
isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_HAIKU_MODEL) ||
|
|
272
|
+
isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_FABLE_MODEL)
|
|
273
|
+
);
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
function isPennyRouterValue(value) {
|
|
277
|
+
return typeof value === "string" && (value.startsWith("pr-") || value.includes("pennyrouter"));
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
function isPennyRouterBaseUrl(value) {
|
|
281
|
+
return typeof value === "string" && value.includes("pennyrouter");
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function isPennyRouterModelValue(value) {
|
|
285
|
+
return (
|
|
286
|
+
isPennyRouterValue(value) ||
|
|
287
|
+
value === CLAUDE_CODE_MODELS.haiku ||
|
|
288
|
+
value === CLAUDE_CODE_MODELS.sonnet ||
|
|
289
|
+
value === CLAUDE_CODE_MODELS.opus ||
|
|
290
|
+
value === CLAUDE_CODE_MODELS.fable ||
|
|
291
|
+
value === CLAUDE_CODE_MODELS.custom ||
|
|
292
|
+
LEGACY_PINNED_MODEL_LABELS.includes(value) ||
|
|
293
|
+
LEGACY_PENNY_LABELS.includes(value)
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
// `penny claude` can launch using a local Penny config written by an earlier CLI
|
|
298
|
+
// version. Translate only our previous friendly labels; a user-supplied model id
|
|
299
|
+
// remains untouched.
|
|
300
|
+
export function upgradeClaudeCodeModelValue(value) {
|
|
301
|
+
return LEGACY_PINNED_MODEL_IDS[value] || value;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
export async function readClaudeCodePennyRouterKey() {
|
|
305
|
+
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
306
|
+
const env = current.env || {};
|
|
307
|
+
for (const value of [env.ANTHROPIC_API_KEY, env.ANTHROPIC_AUTH_TOKEN, current["anthropic.apiKey"]]) {
|
|
308
|
+
if (typeof value === "string" && value.startsWith("pr-")) return value;
|
|
309
|
+
}
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// The PennyRouter gateway URL + pr- key currently in Claude Code's config, used to
|
|
314
|
+
// call the gateway's "forget my Claude token" endpoint on uninstall. Read before
|
|
315
|
+
// uninstall strips these keys. Either field may be null if not present.
|
|
316
|
+
export async function readClaudeCodeGatewayAuth() {
|
|
317
|
+
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
318
|
+
const env = current.env || {};
|
|
319
|
+
const gatewayBaseUrl = isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ? env.ANTHROPIC_BASE_URL : null;
|
|
320
|
+
const apiKey = await readClaudeCodePennyRouterKey();
|
|
321
|
+
return { gatewayBaseUrl, apiKey };
|
|
322
|
+
}
|