pennyrouter 0.1.3 → 0.1.5

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/README.md CHANGED
@@ -10,17 +10,36 @@ status checks, and uninstall metadata.
10
10
 
11
11
  ```bash
12
12
  npx pennyrouter install
13
- npx pennyrouter install --harness claude-code,cline,opencode,codex
13
+ npx pennyrouter install --existing-account
14
+ npx pennyrouter install --harness claude-code,cline,opencode,codex,windsurf,zed,codegpt,aider
15
+ npx pennyrouter disable
16
+ npx pennyrouter enable
14
17
  npx pennyrouter uninstall
15
18
  npx pennyrouter status
16
19
  ```
17
20
 
21
+ With no `--harness` flag, the interactive installer shows a checkbox list. Tools with
22
+ detected configs start checked; press Enter to accept or type tool IDs to toggle them.
23
+
18
24
  ## Supported Tools
19
25
 
20
26
  - Claude Code
21
27
  - Cline (manual setup instructions; no VS Code settings mutation)
22
28
  - Codex
23
29
  - opencode
30
+ - Windsurf
31
+ - Zed
32
+ - JetBrains CodeGPT (manual setup instructions; no XML mutation)
33
+ - Aider
34
+
35
+ ## Penny Models
36
+
37
+ Tools that expose a model picker get four curated choices:
38
+
39
+ - Penny Core: `pennyrouter/penny-core:premium`
40
+ - Penny Speed: `pennyrouter/penny-speed:standard`
41
+ - Penny Power: `pennyrouter/penny-power:premium`
42
+ - Penny Custom: `pennyrouter/auto`, using the bundle/profile saved in the user's PennyRouter account
24
43
 
25
44
  ## Agent Flow
26
45
 
@@ -38,6 +57,9 @@ directly.
38
57
  The CLI writes only selected local tool configuration files, saves backups under the local
39
58
  PennyRouter state directory, and records an uninstall manifest.
40
59
 
60
+ `disable` temporarily removes PennyRouter from selected tools while retaining a local
61
+ PennyRouter-managed snapshot for `enable`.
62
+
41
63
  It does not delete PennyRouter accounts, credits, or remote API keys.
42
64
 
43
65
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.3",
3
+ "version": "0.1.5",
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,10 +1,15 @@
1
1
  import { spawnSync } from "node:child_process";
2
+ import { emitKeypressEvents } from "node:readline";
2
3
  import { createInterface } from "node:readline/promises";
3
4
  import { stdin as input, stdout as output } from "node:process";
5
+ import { aiderHarness } from "./harnesses/aider.js";
4
6
  import { claudeCodeHarness } from "./harnesses/claude-code.js";
5
7
  import { clineHarness } from "./harnesses/cline.js";
8
+ import { codeGptHarness } from "./harnesses/codegpt.js";
6
9
  import { codexHarness } from "./harnesses/codex.js";
7
10
  import { opencodeHarness } from "./harnesses/opencode.js";
11
+ import { windsurfHarness } from "./harnesses/windsurf.js";
12
+ import { zedHarness } from "./harnesses/zed.js";
8
13
  import {
9
14
  APP_URL,
10
15
  createCliSession,
@@ -13,9 +18,10 @@ import {
13
18
  sleep,
14
19
  } from "./session.js";
15
20
  import {
16
- expandHome,
21
+ backupFile,
22
+ compactHome,
17
23
  loadManifest,
18
- readJsonFile,
24
+ restoreFile,
19
25
  saveManifest,
20
26
  statePath,
21
27
  } from "./state.js";
@@ -25,8 +31,14 @@ const HARNESS_BY_ID = {
25
31
  cline: clineHarness,
26
32
  opencode: opencodeHarness,
27
33
  codex: codexHarness,
34
+ windsurf: windsurfHarness,
35
+ zed: zedHarness,
36
+ codegpt: codeGptHarness,
37
+ aider: aiderHarness,
28
38
  };
29
39
 
40
+ const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
41
+
30
42
  export async function main(argv = process.argv.slice(2)) {
31
43
  const { command, flags } = parseArgs(argv);
32
44
 
@@ -45,6 +57,16 @@ export async function main(argv = process.argv.slice(2)) {
45
57
  return;
46
58
  }
47
59
 
60
+ if (command === "disable") {
61
+ await disable(flags);
62
+ return;
63
+ }
64
+
65
+ if (command === "enable") {
66
+ await enable(flags);
67
+ return;
68
+ }
69
+
48
70
  if (command === "status") {
49
71
  await status();
50
72
  return;
@@ -65,6 +87,7 @@ function parseArgs(argv) {
65
87
  else if (arg === "--all") flags.all = true;
66
88
  else if (arg === "--dry-run") flags.dryRun = true;
67
89
  else if (arg === "--no-browser") flags.noBrowser = true;
90
+ else if (arg === "--existing-account") flags.existingAccount = true;
68
91
  else if (arg === "--harness") flags.harness = rest[++i] || "";
69
92
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
70
93
  else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
@@ -84,14 +107,10 @@ async function install(flags) {
84
107
 
85
108
  console.log("PennyRouter install");
86
109
  console.log("");
87
- console.log("Selected tools:");
88
- for (const harness of selected) console.log(` - ${harness.name}`);
89
- console.log("");
90
110
 
91
111
  if (flags.dryRun) {
92
112
  for (const harness of selected) {
93
- const plan = await harness.planInstall();
94
- printPlan(harness, plan);
113
+ console.log(`Will install on ${harness.name}${installNoteForHarness(harness.id)}.`);
95
114
  }
96
115
  console.log("Dry run only. No files changed.");
97
116
  return;
@@ -100,9 +119,14 @@ async function install(flags) {
100
119
  const session = await createCliSession({
101
120
  appUrl: flags.appUrl || APP_URL,
102
121
  harnesses: selected.map((harness) => harness.id),
122
+ accountMode: flags.existingAccount ? "existing" : "seed",
103
123
  });
104
124
 
105
- console.log("Opening your browser to authorize this machine...");
125
+ console.log(
126
+ flags.existingAccount
127
+ ? "Opening your browser to authorize this machine with your existing PennyRouter account..."
128
+ : "Opening your browser to authorize this machine...",
129
+ );
106
130
  console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
107
131
  if (!flags.noBrowser) openBrowser(session.authorize_url);
108
132
  console.log("");
@@ -114,29 +138,92 @@ async function install(flags) {
114
138
 
115
139
  const manifest = await loadManifest();
116
140
  const records = [];
141
+ const failures = [];
117
142
  for (const harness of selected) {
118
- const plan = await harness.planInstall();
119
- printPlan(harness, plan);
120
- const record = await harness.install({
121
- apiKey: claim.api_key,
122
- gatewayBaseUrl: claim.gateway_base_url,
123
- plan,
143
+ await runHarnessJob(failures, harness.id, "install", async () => {
144
+ const plan = await harness.planInstall();
145
+ const record = await harness.install({
146
+ apiKey: claim.api_key,
147
+ gatewayBaseUrl: claim.gateway_base_url,
148
+ plan,
149
+ });
150
+ records.push(record);
151
+ upsertManifestRecord(manifest, record);
152
+ await saveManifest(manifest);
124
153
  });
125
- records.push(record);
126
- upsertManifestRecord(manifest, record);
127
154
  }
128
155
 
129
156
  await saveManifest(manifest);
130
157
  console.log("");
131
158
  console.log("Installed PennyRouter.");
132
- console.log(`Manifest: ${statePath("installations.json")}`);
133
159
  for (const record of records) {
134
- if (record.restart) console.log(`${record.harness}: ${record.restart}`);
160
+ const harness = HARNESS_BY_ID[record.harness];
161
+ console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
135
162
  }
163
+ console.log(`Manifest: ${statePath("installations.json")}`);
164
+ reportFailures(failures);
136
165
  }
137
166
 
138
- async function uninstall(flags) {
167
+ async function disable(flags) {
168
+ const manifest = await loadManifest();
169
+ if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
170
+ const records = manifest.installations || [];
171
+ const { selectedIds, selectedRecords } = await resolveLifecycleTargets(flags, records);
172
+
173
+ if (selectedRecords.length === 0) {
174
+ console.log("No matching PennyRouter installations found.");
175
+ return;
176
+ }
177
+
178
+ console.log("PennyRouter disable");
179
+ console.log("");
180
+ for (const record of selectedRecords) {
181
+ console.log(` - ${record.harness}: ${record.config_path || "manual setup"}`);
182
+ }
183
+ console.log("");
184
+
185
+ if (!flags.yes) {
186
+ const ok = await confirm("Temporarily turn off these PennyRouter local integrations?");
187
+ if (!ok) return;
188
+ }
189
+
190
+ const failures = [];
191
+ for (const record of selectedRecords) {
192
+ if (record.disabled) {
193
+ console.log(`${record.harness} is already disabled.`);
194
+ continue;
195
+ }
196
+
197
+ const harness = HARNESS_BY_ID[record.harness];
198
+ if (!harness) {
199
+ console.log(`Skipping ${record.harness}: this CLI no longer knows how to disable it.`);
200
+ continue;
201
+ }
202
+
203
+ await runHarnessJob(failures, record.harness, "disable", async () => {
204
+ if (record.config_path) {
205
+ const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
206
+ record.disabled_snapshot_path = snapshotPath;
207
+ }
208
+
209
+ if (typeof harness.disable === "function") await harness.disable(record);
210
+ else await harness.uninstall(record);
211
+
212
+ record.disabled = true;
213
+ record.disabled_at = new Date().toISOString();
214
+ upsertManifestRecord(manifest, record);
215
+ await saveManifest(manifest);
216
+ console.log(`Disabled ${record.harness}.`);
217
+ });
218
+ }
219
+
220
+ await saveManifest(manifest);
221
+ reportFailures(failures);
222
+ }
223
+
224
+ async function enable(flags) {
139
225
  const manifest = await loadManifest();
226
+ if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
140
227
  const records = manifest.installations || [];
141
228
  const selectedIds = resolveRequestedHarnessIds(flags, records.map((record) => record.harness));
142
229
  const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
@@ -146,6 +233,64 @@ async function uninstall(flags) {
146
233
  return;
147
234
  }
148
235
 
236
+ console.log("PennyRouter enable");
237
+ console.log("");
238
+ for (const record of selectedRecords) {
239
+ console.log(` - ${record.harness}: ${record.config_path || "manual setup"}`);
240
+ }
241
+ console.log("");
242
+
243
+ if (!flags.yes) {
244
+ const ok = await confirm("Turn these PennyRouter local integrations back on?");
245
+ if (!ok) return;
246
+ }
247
+
248
+ const failures = [];
249
+ for (const record of selectedRecords) {
250
+ if (!record.disabled) {
251
+ console.log(`${record.harness} is already enabled.`);
252
+ continue;
253
+ }
254
+
255
+ const harness = HARNESS_BY_ID[record.harness];
256
+ if (!harness) {
257
+ console.log(`Skipping ${record.harness}: this CLI no longer knows how to enable it.`);
258
+ continue;
259
+ }
260
+
261
+ await runHarnessJob(failures, record.harness, "enable", async () => {
262
+ if (typeof harness.enable === "function") await harness.enable(record);
263
+ else if (record.config_path && record.disabled_snapshot_path) {
264
+ await restoreFile(record.disabled_snapshot_path, record.config_path);
265
+ } else if (record.manual_required) {
266
+ console.log(`${record.harness}: re-enable manually in the tool UI using the status details.`);
267
+ } else {
268
+ throw new Error("no disabled snapshot found; run install again");
269
+ }
270
+
271
+ delete record.disabled;
272
+ delete record.disabled_at;
273
+ upsertManifestRecord(manifest, record);
274
+ await saveManifest(manifest);
275
+ console.log(`Enabled ${record.harness}.`);
276
+ });
277
+ }
278
+
279
+ await saveManifest(manifest);
280
+ reportFailures(failures);
281
+ }
282
+
283
+ async function uninstall(flags) {
284
+ const manifest = await loadManifest();
285
+ if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
286
+ const records = manifest.installations || [];
287
+ const { selectedIds, selectedRecords } = await resolveLifecycleTargets(flags, records);
288
+
289
+ if (selectedRecords.length === 0) {
290
+ console.log("No matching PennyRouter installations found.");
291
+ return;
292
+ }
293
+
149
294
  console.log("PennyRouter uninstall");
150
295
  console.log("");
151
296
  for (const record of selectedRecords) {
@@ -159,6 +304,7 @@ async function uninstall(flags) {
159
304
  }
160
305
 
161
306
  const kept = [];
307
+ const failures = [];
162
308
  for (const record of records) {
163
309
  if (!selectedIds.includes(record.harness)) {
164
310
  kept.push(record);
@@ -172,16 +318,81 @@ async function uninstall(flags) {
172
318
  continue;
173
319
  }
174
320
 
175
- await harness.uninstall(record);
176
- console.log(`Removed ${record.harness}.`);
321
+ const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
322
+ await harness.uninstall(record);
323
+ console.log(`Removed ${record.harness}.`);
324
+ });
325
+ if (!ok) kept.push(record);
326
+ }
327
+
328
+ for (const record of selectedRecords) {
329
+ if (records.some((existing) => existing.harness === record.harness)) continue;
330
+ const harness = HARNESS_BY_ID[record.harness];
331
+ if (!harness) {
332
+ console.log(`Skipping ${record.harness}: this CLI does not know how to uninstall it.`);
333
+ continue;
334
+ }
335
+ await runHarnessJob(failures, record.harness, "uninstall", async () => {
336
+ await harness.uninstall(record);
337
+ console.log(`Removed ${record.harness}.`);
338
+ });
177
339
  }
178
340
 
179
341
  manifest.installations = kept;
180
342
  await saveManifest(manifest);
343
+ reportFailures(failures);
344
+ }
345
+
346
+ async function resolveLifecycleTargets(flags, records) {
347
+ const requested = parseHarnessList(flags.harness);
348
+ const ids = flags.all
349
+ ? Object.keys(HARNESS_BY_ID)
350
+ : requested.length > 0
351
+ ? requested
352
+ : records.length > 0
353
+ ? records.map((record) => record.harness)
354
+ : await detectHarnessIds();
355
+
356
+ const selectedIds = [...new Set(ids)];
357
+ const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
358
+ const manifestIds = new Set(selectedRecords.map((record) => record.harness));
359
+
360
+ for (const id of selectedIds) {
361
+ if (manifestIds.has(id)) continue;
362
+ const harness = HARNESS_BY_ID[id];
363
+ if (!harness) continue;
364
+ if (!(await safeDetect(harness))) continue;
365
+ let plan;
366
+ try {
367
+ plan = await harness.planInstall();
368
+ } catch (error) {
369
+ console.log(`Skipping ${id}: ${formatError(error)}`);
370
+ continue;
371
+ }
372
+ selectedRecords.push({
373
+ harness: id,
374
+ config_path: plan.configPath ? compactHome(plan.configPath) : null,
375
+ backup_path: null,
376
+ installed_at: null,
377
+ recovered_from_detected_config: true,
378
+ });
379
+ }
380
+
381
+ return { selectedIds, selectedRecords };
382
+ }
383
+
384
+ async function detectHarnessIds() {
385
+ const ids = [];
386
+ for (const harness of Object.values(HARNESS_BY_ID)) {
387
+ if (await safeDetect(harness)) ids.push(harness.id);
388
+ }
389
+ return ids;
181
390
  }
182
391
 
183
392
  async function status() {
184
393
  const manifest = await loadManifest();
394
+ const pruned = pruneUnsupportedManifestRecords(manifest);
395
+ if (pruned.length > 0) await saveManifest(manifest);
185
396
  const records = manifest.installations || [];
186
397
  console.log("PennyRouter local setup");
187
398
  console.log("");
@@ -192,10 +403,13 @@ async function status() {
192
403
  }
193
404
 
194
405
  for (const record of records) {
195
- console.log(`${record.harness}: ${record.manual_required ? "manual setup shown" : "installed"}`);
406
+ const state = record.disabled ? "disabled" : record.manual_required ? "manual setup shown" : "installed";
407
+ console.log(`${record.harness}: ${state}`);
196
408
  if (record.config_path) console.log(` config: ${record.config_path}`);
197
409
  if (record.backup_path) console.log(` backup: ${record.backup_path}`);
410
+ if (record.disabled_snapshot_path) console.log(` disabled snapshot: ${record.disabled_snapshot_path}`);
198
411
  if (record.installed_at) console.log(` installed: ${record.installed_at}`);
412
+ if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
199
413
  if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
200
414
  }
201
415
  }
@@ -204,36 +418,168 @@ async function selectHarnesses(flags) {
204
418
  const requested = flags.all ? Object.keys(HARNESS_BY_ID) : parseHarnessList(flags.harness);
205
419
  const detected = [];
206
420
  for (const harness of Object.values(HARNESS_BY_ID)) {
207
- if (await harness.detect()) detected.push(harness);
421
+ if (await safeDetect(harness)) detected.push(harness);
208
422
  }
209
423
 
210
424
  if (requested.length > 0) {
211
425
  return requested.map((id) => HARNESS_BY_ID[id]).filter(Boolean);
212
426
  }
213
427
 
214
- if (detected.length === 0) {
215
- console.log("No supported harness configs were detected.");
216
- console.log("Use --harness claude-code,cline,opencode,codex to create config for specific tools.");
217
- return [];
218
- }
428
+ if (flags.yes) return detected;
219
429
 
220
- if (flags.yes || detected.length === 1) return detected;
430
+ return promptHarnessCheckboxes(detected);
431
+ }
221
432
 
222
- console.log("Detected supported tools:");
223
- detected.forEach((harness, index) => console.log(` ${index + 1}. ${harness.name}`));
224
- console.log(" a. All detected");
225
- console.log("");
433
+ async function promptHarnessCheckboxes(detected) {
434
+ const detectedIds = new Set(detected.map((harness) => harness.id));
435
+ const selectedIds = new Set(detectedIds);
436
+ const harnesses = orderedHarnessesForPrompt(detectedIds);
437
+ if (!input.isTTY || !output.isTTY) return promptHarnessCheckboxesByText(harnesses, selectedIds);
438
+
439
+ return promptHarnessCheckboxesByKeys(harnesses, selectedIds);
440
+ }
226
441
 
442
+ async function promptHarnessCheckboxesByKeys(harnesses, selectedIds) {
443
+ let cursor = 0;
444
+ const previousRawMode = input.isRaw;
445
+
446
+ emitKeypressEvents(input);
447
+ input.setRawMode(true);
448
+ input.resume();
449
+ output.write("\x1b[?25l");
450
+
451
+ const render = () => {
452
+ output.write("\x1b[2J\x1b[H");
453
+ output.write("\n\n");
454
+ output.write(`${color("Where should PennyRouter be installed?", "bold")}\n\n\n`);
455
+ harnesses.forEach((harness, index) => {
456
+ const pointer = index === cursor ? color(">", "cyan") : " ";
457
+ const checked = selectedIds.has(harness.id) ? color("x", "green") : " ";
458
+ const name = index === cursor ? color(harness.name, "cyan") : harness.name;
459
+ output.write(` ${pointer} [${checked}] ${name}\n`);
460
+ });
461
+ output.write("\nEnter to continue.\n");
462
+ };
463
+
464
+ render();
465
+
466
+ return new Promise((resolve, reject) => {
467
+ const cleanup = () => {
468
+ input.off("keypress", onKeypress);
469
+ input.setRawMode(Boolean(previousRawMode));
470
+ input.pause();
471
+ output.write("\x1b[?25h");
472
+ output.write("\n");
473
+ };
474
+
475
+ const onKeypress = (_str, key = {}) => {
476
+ if (key.ctrl && key.name === "c") {
477
+ cleanup();
478
+ reject(new Error("Cancelled."));
479
+ return;
480
+ }
481
+
482
+ if (key.name === "up" || key.name === "k") {
483
+ cursor = (cursor - 1 + harnesses.length) % harnesses.length;
484
+ render();
485
+ return;
486
+ }
487
+
488
+ if (key.name === "down" || key.name === "j") {
489
+ cursor = (cursor + 1) % harnesses.length;
490
+ render();
491
+ return;
492
+ }
493
+
494
+ if (key.name === "space") {
495
+ const id = harnesses[cursor].id;
496
+ if (selectedIds.has(id)) selectedIds.delete(id);
497
+ else selectedIds.add(id);
498
+ render();
499
+ return;
500
+ }
501
+
502
+ if (key.name === "return" || key.name === "enter") {
503
+ cleanup();
504
+ resolve(harnesses.filter((harness) => selectedIds.has(harness.id)));
505
+ }
506
+ };
507
+
508
+ input.on("keypress", onKeypress);
509
+ });
510
+ }
511
+
512
+ async function promptHarnessCheckboxesByText(harnesses, selectedIds) {
227
513
  const rl = createInterface({ input, output });
228
- const answer = await rl.question("Install for which tools? Enter numbers, comma-separated, or 'a': ");
229
- rl.close();
230
514
 
231
- if (answer.trim().toLowerCase() === "a") return detected;
232
- const indexes = answer
233
- .split(",")
234
- .map((part) => Number(part.trim()) - 1)
235
- .filter((index) => Number.isInteger(index) && detected[index]);
236
- return [...new Set(indexes)].map((index) => detected[index]);
515
+ try {
516
+ while (true) {
517
+ console.log("");
518
+ console.log("");
519
+ console.log(color("Where should PennyRouter be installed?", "bold"));
520
+ for (const harness of harnesses) {
521
+ const checked = selectedIds.has(harness.id) ? color("x", "green") : " ";
522
+ console.log(` [${checked}] ${harness.name}`);
523
+ }
524
+ console.log("");
525
+ const answer = await rl.question(
526
+ "Press Enter to continue, or type names to toggle (comma-separated, 'all', or 'none'): ",
527
+ );
528
+ const value = answer.trim().toLowerCase();
529
+ if (!value) return harnesses.filter((harness) => selectedIds.has(harness.id));
530
+ if (value === "all") {
531
+ for (const harness of harnesses) selectedIds.add(harness.id);
532
+ } else if (value === "none") {
533
+ selectedIds.clear();
534
+ } else {
535
+ for (const token of parseHarnessList(value)) {
536
+ const id = resolveHarnessToken(token);
537
+ if (!HARNESS_BY_ID[id]) {
538
+ console.log(`Unknown tool "${token}".`);
539
+ continue;
540
+ }
541
+ if (selectedIds.has(id)) selectedIds.delete(id);
542
+ else selectedIds.add(id);
543
+ }
544
+ }
545
+ console.log("");
546
+ }
547
+ } finally {
548
+ rl.close();
549
+ }
550
+ }
551
+
552
+ function orderedHarnessesForPrompt(detectedIds) {
553
+ return Object.values(HARNESS_BY_ID).sort((left, right) => {
554
+ const leftDetected = detectedIds.has(left.id);
555
+ const rightDetected = detectedIds.has(right.id);
556
+ if (leftDetected !== rightDetected) return leftDetected ? -1 : 1;
557
+ return left.name.localeCompare(right.name);
558
+ });
559
+ }
560
+
561
+ function resolveHarnessToken(token) {
562
+ const normalized = normalizeHarnessToken(token);
563
+ for (const harness of Object.values(HARNESS_BY_ID)) {
564
+ if (normalizeHarnessToken(harness.id) === normalized) return harness.id;
565
+ if (normalizeHarnessToken(harness.name) === normalized) return harness.id;
566
+ }
567
+ return token;
568
+ }
569
+
570
+ function normalizeHarnessToken(value) {
571
+ return String(value).toLowerCase().replace(/[^a-z0-9]/g, "");
572
+ }
573
+
574
+ function color(text, style) {
575
+ if (!output.isTTY || process.env.NO_COLOR) return text;
576
+ const codes = {
577
+ bold: ["\x1b[1m", "\x1b[22m"],
578
+ cyan: ["\x1b[36m", "\x1b[39m"],
579
+ green: ["\x1b[32m", "\x1b[39m"],
580
+ };
581
+ const pair = codes[style];
582
+ return pair ? `${pair[0]}${text}${pair[1]}` : text;
237
583
  }
238
584
 
239
585
  function parseHarnessList(value = "") {
@@ -255,6 +601,48 @@ function upsertManifestRecord(manifest, record) {
255
601
  manifest.installations.push(record);
256
602
  }
257
603
 
604
+ function pruneUnsupportedManifestRecords(manifest) {
605
+ manifest.installations ||= [];
606
+ const kept = manifest.installations.filter((record) => HARNESS_BY_ID[record.harness]);
607
+ const pruned = manifest.installations.filter((record) => !HARNESS_BY_ID[record.harness]);
608
+ manifest.installations = kept;
609
+ return pruned;
610
+ }
611
+
612
+ async function safeDetect(harness) {
613
+ try {
614
+ return await harness.detect();
615
+ } catch (error) {
616
+ console.log(`Skipping ${harness.id}: detect failed: ${formatError(error)}`);
617
+ return false;
618
+ }
619
+ }
620
+
621
+ async function runHarnessJob(failures, harnessId, action, job) {
622
+ try {
623
+ await job();
624
+ return true;
625
+ } catch (error) {
626
+ failures.push({ harness: harnessId, action, error });
627
+ console.error(`${harnessId} ${action} failed: ${formatError(error)}`);
628
+ return false;
629
+ }
630
+ }
631
+
632
+ function reportFailures(failures) {
633
+ if (failures.length === 0) return;
634
+ console.error("");
635
+ console.error("Some PennyRouter integrations failed:");
636
+ for (const failure of failures) {
637
+ console.error(` - ${failure.harness} ${failure.action}: ${formatError(failure.error)}`);
638
+ }
639
+ process.exitCode = 1;
640
+ }
641
+
642
+ function formatError(error) {
643
+ return error?.message || String(error);
644
+ }
645
+
258
646
  async function confirm(question) {
259
647
  const rl = createInterface({ input, output });
260
648
  const answer = await rl.question(`${question} [y/N] `);
@@ -262,9 +650,15 @@ async function confirm(question) {
262
650
  return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
263
651
  }
264
652
 
265
- function printPlan(harness, plan) {
266
- console.log(`${harness.name}:`);
267
- for (const line of plan.summary) console.log(` - ${line}`);
653
+ function installNoteForRecord(record) {
654
+ if (record.manual_required) return " (manual setup shown)";
655
+ return installNoteForHarness(record.harness);
656
+ }
657
+
658
+ function installNoteForHarness(harnessId) {
659
+ if (harnessId === "cline" || harnessId === "codegpt") return " (manual setup)";
660
+ if (harnessId === "opencode") return " (select a Penny model from /model)";
661
+ return " (auto-selected)";
268
662
  }
269
663
 
270
664
  function openBrowser(url) {
@@ -279,14 +673,19 @@ function printHelp() {
279
673
  console.log(`PennyRouter CLI
280
674
 
281
675
  Usage:
282
- pennyrouter install [--harness claude-code,cline,opencode,codex] [--all] [--yes] [--dry-run]
283
- pennyrouter uninstall [--harness claude-code,cline,opencode,codex] [--all] [--yes]
676
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account]
677
+ pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
678
+ pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
679
+ pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
284
680
  pennyrouter status
285
681
 
286
682
  Examples:
287
683
  npx pennyrouter install
684
+ npx pennyrouter install --existing-account
288
685
  npx pennyrouter install --all
289
- npx pennyrouter install --harness claude-code,cline,opencode,codex --yes
686
+ npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
687
+ npx pennyrouter disable
688
+ npx pennyrouter enable
290
689
  npx pennyrouter uninstall
291
690
  `);
292
691
  }
@@ -0,0 +1,48 @@
1
+ import { atomicWrite, backupFile, compactHome, exists, expandHome, readText } from "../state.js";
2
+ import { removeYamlKeys, setYamlScalar } from "./shared.js";
3
+
4
+ const CONFIG_PATH = "~/.aider.conf.yml";
5
+
6
+ export const aiderHarness = {
7
+ id: "aider",
8
+ name: "Aider",
9
+
10
+ async detect() {
11
+ return exists(CONFIG_PATH);
12
+ },
13
+
14
+ async planInstall() {
15
+ return {
16
+ configPath: expandHome(CONFIG_PATH),
17
+ summary: [
18
+ `write ${CONFIG_PATH}`,
19
+ "set OpenAI-compatible base URL and API key",
20
+ "set default model to Penny Custom through LiteLLM naming",
21
+ ],
22
+ };
23
+ },
24
+
25
+ async install({ apiKey, gatewayBaseUrl, plan }) {
26
+ const backupPath = await backupFile(CONFIG_PATH, "aider");
27
+ let next = await readText(CONFIG_PATH, "");
28
+ next = setYamlScalar(next, "openai-api-base", `${gatewayBaseUrl}/v1`);
29
+ next = setYamlScalar(next, "openai-api-key", apiKey);
30
+ next = setYamlScalar(next, "model", "litellm/pennyrouter/auto");
31
+ await atomicWrite(CONFIG_PATH, next.endsWith("\n") ? next : `${next}\n`);
32
+
33
+ return {
34
+ harness: "aider",
35
+ config_path: compactHome(plan.configPath),
36
+ backup_path: backupPath ? compactHome(backupPath) : null,
37
+ installed_at: new Date().toISOString(),
38
+ restart: "restart Aider",
39
+ changes: { keys: ["openai-api-base", "openai-api-key", "model"] },
40
+ };
41
+ },
42
+
43
+ async uninstall(record) {
44
+ const text = await readText(record.config_path, "");
45
+ const next = removeYamlKeys(text, ["openai-api-base", "openai-api-key", "model"]);
46
+ await atomicWrite(record.config_path, next.endsWith("\n") ? next : `${next}\n`);
47
+ },
48
+ };
@@ -1,6 +1,21 @@
1
1
  import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile } from "../state.js";
2
2
 
3
3
  const CONFIG_PATH = "~/.claude/settings.json";
4
+ const LEGACY_MODEL_LABEL = "PennyRouter Bundle";
5
+ const CLAUDE_CODE_MODELS = {
6
+ speed: "Penny Speed",
7
+ core: "Penny Core",
8
+ coreMax: "Penny Core Max",
9
+ power: "Penny Power",
10
+ custom: "Penny Custom",
11
+ };
12
+ const MODEL_OVERRIDE_KEYS = {
13
+ "claude-sonnet-4-6": CLAUDE_CODE_MODELS.core,
14
+ "claude-sonnet-4-6-1m": CLAUDE_CODE_MODELS.coreMax,
15
+ "claude-opus-4-8": CLAUDE_CODE_MODELS.power,
16
+ "claude-haiku-4-5": CLAUDE_CODE_MODELS.speed,
17
+ "claude-fable-5": CLAUDE_CODE_MODELS.custom,
18
+ };
4
19
 
5
20
  export const claudeCodeHarness = {
6
21
  id: "claude-code",
@@ -17,7 +32,7 @@ export const claudeCodeHarness = {
17
32
  `write ${CONFIG_PATH}`,
18
33
  "set Anthropic-compatible base URL to PennyRouter",
19
34
  "store the PennyRouter API key without printing it",
20
- 'label every tier "PennyRouter Bundle" (routing is automatic)',
35
+ "map Claude Code models to Penny model choices",
21
36
  ],
22
37
  };
23
38
  },
@@ -31,12 +46,14 @@ export const claudeCodeHarness = {
31
46
  // Anthropic API key. Clear any stale key so it doesn't take precedence.
32
47
  current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
33
48
  delete current.env.ANTHROPIC_API_KEY;
34
- // The gateway ignores the requested model and routes by bundle + triage, so the
35
- // per-tier names are cosmetic. Brand all three so the Claude Code footer shows
36
- // "PennyRouter Bundle" instead of Opus/Sonnet/Haiku.
37
- current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = "PennyRouter Bundle";
38
- current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = "PennyRouter Bundle";
39
- current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = "PennyRouter Bundle";
49
+ // Claude Code displays these env values directly in its model picker; the
50
+ // gateway decodes the friendly labels into bundle/profile overrides.
51
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.power;
52
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.speed;
53
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.core;
54
+ current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
55
+ current.modelOverrides ||= {};
56
+ Object.assign(current.modelOverrides, MODEL_OVERRIDE_KEYS);
40
57
 
41
58
  await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
42
59
 
@@ -53,14 +70,16 @@ export const claudeCodeHarness = {
53
70
  "ANTHROPIC_DEFAULT_OPUS_MODEL",
54
71
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
55
72
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
73
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
56
74
  ],
75
+ model_overrides: Object.keys(MODEL_OVERRIDE_KEYS),
57
76
  },
58
77
  };
59
78
  },
60
79
 
61
80
  async uninstall(record) {
62
81
  const current = (await readJsonFile(record.config_path, null)) || {};
63
- if (current.env?.ANTHROPIC_BASE_URL?.includes("pennyrouter.com")) {
82
+ if (hasPennyRouterEnv(current.env)) {
64
83
  delete current.env.ANTHROPIC_BASE_URL;
65
84
  const tok = current.env.ANTHROPIC_AUTH_TOKEN;
66
85
  if (typeof tok === "string" && tok.startsWith("pr-")) {
@@ -74,11 +93,60 @@ export const claudeCodeHarness = {
74
93
  "ANTHROPIC_DEFAULT_OPUS_MODEL",
75
94
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
76
95
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
96
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
77
97
  ]) {
78
- if (current.env[k] === "PennyRouter Bundle") delete current.env[k];
98
+ if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
79
99
  }
80
100
  }
101
+ if (current.modelOverrides && typeof current.modelOverrides === "object") {
102
+ for (const [key, value] of Object.entries(MODEL_OVERRIDE_KEYS)) {
103
+ if (current.modelOverrides[key] === value) delete current.modelOverrides[key];
104
+ }
105
+ if (Object.keys(current.modelOverrides).length === 0) delete current.modelOverrides;
106
+ }
107
+ if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
108
+ delete current["anthropic.baseURL"];
109
+ }
110
+ if (typeof current["anthropic.apiKey"] === "string" && current["anthropic.apiKey"].startsWith("pr-")) {
111
+ delete current["anthropic.apiKey"];
112
+ }
81
113
  if (current.env && Object.keys(current.env).length === 0) delete current.env;
82
114
  await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
83
115
  },
84
116
  };
117
+
118
+ function hasPennyRouterEnv(env) {
119
+ if (!env || typeof env !== "object") return false;
120
+ return (
121
+ isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ||
122
+ isPennyRouterValue(env.ANTHROPIC_AUTH_TOKEN) ||
123
+ isPennyRouterValue(env.ANTHROPIC_API_KEY) ||
124
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL === LEGACY_MODEL_LABEL ||
125
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL === LEGACY_MODEL_LABEL ||
126
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL === LEGACY_MODEL_LABEL ||
127
+ env.ANTHROPIC_DEFAULT_FABLE_MODEL === LEGACY_MODEL_LABEL ||
128
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_OPUS_MODEL) ||
129
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_SONNET_MODEL) ||
130
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_HAIKU_MODEL) ||
131
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_FABLE_MODEL)
132
+ );
133
+ }
134
+
135
+ function isPennyRouterValue(value) {
136
+ return typeof value === "string" && (value.startsWith("pr-") || value.includes("pennyrouter"));
137
+ }
138
+
139
+ function isPennyRouterBaseUrl(value) {
140
+ return typeof value === "string" && value.includes("pennyrouter");
141
+ }
142
+
143
+ function isPennyRouterModelValue(value) {
144
+ return (
145
+ isPennyRouterValue(value) ||
146
+ value === CLAUDE_CODE_MODELS.speed ||
147
+ value === CLAUDE_CODE_MODELS.core ||
148
+ value === CLAUDE_CODE_MODELS.coreMax ||
149
+ value === CLAUDE_CODE_MODELS.power ||
150
+ value === CLAUDE_CODE_MODELS.custom
151
+ );
152
+ }
@@ -28,7 +28,7 @@ export const clineHarness = {
28
28
  console.log(" - API Provider: OpenAI Compatible");
29
29
  console.log(` - Base URL: ${gatewayBaseUrl}/v1`);
30
30
  console.log(` - API Key: ${copied ? "copied to clipboard" : "copy from your PennyRouter dashboard"}`);
31
- console.log(" - Model ID: pennyrouter/auto");
31
+ console.log(" - Model ID: pennyrouter/auto (Penny Custom)");
32
32
  console.log("");
33
33
  console.log("The CLI did not write VS Code settings because Cline stores provider config differently across versions.");
34
34
  if (!copied) {
@@ -46,6 +46,7 @@ export const clineHarness = {
46
46
  provider: "OpenAI Compatible",
47
47
  base_url: `${gatewayBaseUrl}/v1`,
48
48
  model: "pennyrouter/auto",
49
+ model_name: "Penny Custom",
49
50
  },
50
51
  };
51
52
  },
@@ -0,0 +1,76 @@
1
+ import { spawnSync } from "node:child_process";
2
+
3
+ export const codeGptHarness = {
4
+ id: "codegpt",
5
+ name: "JetBrains CodeGPT",
6
+
7
+ async detect() {
8
+ return false;
9
+ },
10
+
11
+ async planInstall() {
12
+ return {
13
+ configPath: null,
14
+ summary: [
15
+ "show manual CodeGPT OpenAI-compatible setup instructions",
16
+ "do not mutate JetBrains XML settings until the exact IDE/version schema is confirmed",
17
+ "record that manual CodeGPT setup instructions were shown",
18
+ ],
19
+ };
20
+ },
21
+
22
+ async install({ apiKey, gatewayBaseUrl }) {
23
+ const copied = copyToClipboard(apiKey);
24
+
25
+ console.log("");
26
+ console.log("JetBrains CodeGPT manual setup");
27
+ console.log(" Open CodeGPT settings in your JetBrains IDE and set:");
28
+ console.log(` - Base URL: ${gatewayBaseUrl}/v1`);
29
+ console.log(` - API Key: ${copied ? "copied to clipboard" : "copy from your PennyRouter dashboard"}`);
30
+ console.log(" - Model: pennyrouter/auto (Penny Custom)");
31
+ console.log("");
32
+ console.log("The CLI did not write JetBrains XML settings because CodeGPT stores options under IDE/version-specific paths.");
33
+ if (!copied) {
34
+ console.log("Clipboard copy failed, so the key was not printed. Open PennyRouter and create or reveal a key manually for CodeGPT.");
35
+ }
36
+
37
+ return {
38
+ harness: "codegpt",
39
+ config_path: null,
40
+ backup_path: null,
41
+ installed_at: new Date().toISOString(),
42
+ manual_required: true,
43
+ restart: "restart the JetBrains IDE after saving CodeGPT settings",
44
+ changes: {
45
+ provider: "OpenAI Compatible",
46
+ base_url: `${gatewayBaseUrl}/v1`,
47
+ model: "pennyrouter/auto",
48
+ model_name: "Penny Custom",
49
+ },
50
+ };
51
+ },
52
+
53
+ async uninstall() {
54
+ console.log("");
55
+ console.log("JetBrains CodeGPT manual uninstall");
56
+ console.log(" Open CodeGPT settings and remove or replace the PennyRouter provider:");
57
+ console.log(" - Base URL: https://api.pennyrouter.com/v1");
58
+ console.log(" - API Key: pr-...");
59
+ console.log(" - Model: pennyrouter/auto");
60
+ },
61
+ };
62
+
63
+ function copyToClipboard(text) {
64
+ const commands =
65
+ process.platform === "darwin"
66
+ ? [["pbcopy"]]
67
+ : process.platform === "win32"
68
+ ? [["clip"]]
69
+ : [["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
70
+
71
+ for (const [command, ...args] of commands) {
72
+ const result = spawnSync(command, args, { input: text, stdio: ["pipe", "ignore", "ignore"] });
73
+ if (result.status === 0) return true;
74
+ }
75
+ return false;
76
+ }
@@ -8,6 +8,7 @@ import {
8
8
  readText,
9
9
  writeJsonFile,
10
10
  } from "../state.js";
11
+ import { PENNYROUTER_MODEL_CHOICES } from "./shared.js";
11
12
 
12
13
  const JSONC_PATH = "~/.config/opencode/opencode.jsonc";
13
14
  const JSON_PATH = "~/.config/opencode/opencode.json";
@@ -15,7 +16,7 @@ const MODEL_STATE_PATH = "~/.local/state/opencode/model.json";
15
16
 
16
17
  export const opencodeHarness = {
17
18
  id: "opencode",
18
- name: "opencode",
19
+ name: "OpenCode",
19
20
 
20
21
  async detect() {
21
22
  return (await exists(JSONC_PATH)) || (await exists(JSON_PATH));
@@ -28,6 +29,7 @@ export const opencodeHarness = {
28
29
  summary: [
29
30
  `write ${configPath}`,
30
31
  "merge PennyRouter provider without replacing existing providers",
32
+ "publish the four Penny model choices",
31
33
  `reset ${MODEL_STATE_PATH} so opencode rediscovers configured models`,
32
34
  ],
33
35
  };
@@ -45,17 +47,9 @@ export const opencodeHarness = {
45
47
  baseURL: `${gatewayBaseUrl}/v1`,
46
48
  apiKey,
47
49
  },
48
- models: {
49
- "pennyrouter/auto": { name: "PennyRouter Auto" },
50
- "pennyrouter/anthropic": { name: "PennyRouter Anthropic" },
51
- "pennyrouter/openai": { name: "PennyRouter OpenAI" },
52
- "pennyrouter/gemini": { name: "PennyRouter Gemini" },
53
- "pennyrouter/grok": { name: "PennyRouter Grok" },
54
- "pennyrouter/deepseek": { name: "PennyRouter DeepSeek" },
55
- "pennyrouter/zai": { name: "PennyRouter Z.ai" },
56
- "pennyrouter/qwen": { name: "PennyRouter Qwen" },
57
- "pennyrouter/penny": { name: "PennyRouter Penny" },
58
- },
50
+ models: Object.fromEntries(
51
+ PENNYROUTER_MODEL_CHOICES.map((model) => [model.id, { name: model.name }]),
52
+ ),
59
53
  };
60
54
 
61
55
  await atomicWrite(configPath, `${JSON.stringify(current, null, 2)}\n`);
@@ -68,7 +62,7 @@ export const opencodeHarness = {
68
62
  config_path: compactHome(plan.configPath),
69
63
  backup_path: backupPath ? compactHome(backupPath) : null,
70
64
  installed_at: new Date().toISOString(),
71
- restart: "type /exit, run opencode again, then pick PennyRouter Auto",
65
+ restart: "type /exit, run opencode again, then pick a Penny model",
72
66
  changes: {
73
67
  provider_id: "pennyrouter",
74
68
  model_state_path: MODEL_STATE_PATH,
@@ -0,0 +1,51 @@
1
+ export const PENNYROUTER_MODEL_CHOICES = [
2
+ {
3
+ id: "pennyrouter/penny-core:premium",
4
+ name: "Penny Core",
5
+ },
6
+ {
7
+ id: "pennyrouter/penny-speed:standard",
8
+ name: "Penny Speed",
9
+ },
10
+ {
11
+ id: "pennyrouter/penny-power:premium",
12
+ name: "Penny Power",
13
+ },
14
+ {
15
+ id: "pennyrouter/auto",
16
+ name: "Penny Custom",
17
+ },
18
+ ];
19
+
20
+ export const PENNYROUTER_MODELS = PENNYROUTER_MODEL_CHOICES.map((model) => model.id);
21
+
22
+ export const PENNYROUTER_MODEL_NAMES = Object.fromEntries(
23
+ PENNYROUTER_MODEL_CHOICES.map((model) => [model.id, model.name]),
24
+ );
25
+
26
+ export function removeJsonKeys(object, keys) {
27
+ if (!object || typeof object !== "object") return;
28
+ for (const key of keys) delete object[key];
29
+ }
30
+
31
+ export function setYamlScalar(text, key, value) {
32
+ const escaped = String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
33
+ const line = `${key}: "${escaped}"`;
34
+ const pattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:.*$`, "m");
35
+ if (pattern.test(text)) return text.replace(pattern, line);
36
+ const normalized = text.trimEnd();
37
+ return `${normalized}${normalized ? "\n" : ""}${line}\n`;
38
+ }
39
+
40
+ export function removeYamlKeys(text, keys) {
41
+ const patterns = keys.map((key) => new RegExp(`^\\s*${escapeRegExp(key)}\\s*:.*$`));
42
+ return text
43
+ .split("\n")
44
+ .filter((line) => !patterns.some((pattern) => pattern.test(line)))
45
+ .join("\n")
46
+ .replace(/\n{3,}/g, "\n\n");
47
+ }
48
+
49
+ function escapeRegExp(value) {
50
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
51
+ }
@@ -0,0 +1,57 @@
1
+ import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile } from "../state.js";
2
+ import { PENNYROUTER_MODELS } from "./shared.js";
3
+
4
+ const CONFIG_PATH = process.platform === "win32"
5
+ ? "~/\\.codeium\\windsurf\\config.json"
6
+ : "~/.codeium/windsurf/config.json";
7
+
8
+ export const windsurfHarness = {
9
+ id: "windsurf",
10
+ name: "Windsurf",
11
+
12
+ async detect() {
13
+ return exists(CONFIG_PATH);
14
+ },
15
+
16
+ async planInstall() {
17
+ return {
18
+ configPath: expandHome(CONFIG_PATH),
19
+ summary: [
20
+ `write ${CONFIG_PATH}`,
21
+ "set OpenAI-compatible base URL and API key",
22
+ "enable PennyRouter model aliases",
23
+ ],
24
+ };
25
+ },
26
+
27
+ async install({ apiKey, gatewayBaseUrl, plan }) {
28
+ const backupPath = await backupFile(CONFIG_PATH, "windsurf");
29
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
30
+ current.api_base_url = `${gatewayBaseUrl}/v1`;
31
+ current.api_key = apiKey;
32
+ current.enabled_models = PENNYROUTER_MODELS;
33
+ await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
34
+
35
+ return {
36
+ harness: "windsurf",
37
+ config_path: compactHome(plan.configPath),
38
+ backup_path: backupPath ? compactHome(backupPath) : null,
39
+ installed_at: new Date().toISOString(),
40
+ restart: "restart Windsurf",
41
+ changes: { keys: ["api_base_url", "api_key", "enabled_models"] },
42
+ };
43
+ },
44
+
45
+ async uninstall(record) {
46
+ const current = (await readJsonFile(record.config_path, null)) || {};
47
+ if (typeof current.api_base_url === "string" && current.api_base_url.includes("pennyrouter")) {
48
+ delete current.api_base_url;
49
+ if (typeof current.api_key === "string" && current.api_key.startsWith("pr-")) delete current.api_key;
50
+ }
51
+ if (Array.isArray(current.enabled_models)) {
52
+ current.enabled_models = current.enabled_models.filter((model) => !String(model).includes("pennyrouter"));
53
+ if (current.enabled_models.length === 0) delete current.enabled_models;
54
+ }
55
+ await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
56
+ },
57
+ };
@@ -0,0 +1,61 @@
1
+ import { atomicWrite, backupFile, compactHome, exists, expandHome, readJsonFile } from "../state.js";
2
+ import { PENNYROUTER_MODELS } from "./shared.js";
3
+
4
+ const CONFIG_PATH = process.platform === "win32"
5
+ ? "~/AppData/Roaming/Zed/settings.json"
6
+ : "~/.config/zed/settings.json";
7
+
8
+ export const zedHarness = {
9
+ id: "zed",
10
+ name: "Zed",
11
+
12
+ async detect() {
13
+ return exists(CONFIG_PATH);
14
+ },
15
+
16
+ async planInstall() {
17
+ return {
18
+ configPath: expandHome(CONFIG_PATH),
19
+ summary: [
20
+ `write ${CONFIG_PATH}`,
21
+ "configure Zed custom language model provider for PennyRouter",
22
+ "publish PennyRouter model aliases",
23
+ ],
24
+ };
25
+ },
26
+
27
+ async install({ apiKey, gatewayBaseUrl, plan }) {
28
+ const backupPath = await backupFile(CONFIG_PATH, "zed");
29
+ const current = (await readJsonFile(CONFIG_PATH, null)) || {};
30
+ current.language_models ||= {};
31
+ current.language_models.providers ||= {};
32
+ current.language_models.providers.custom = {
33
+ api_url: `${gatewayBaseUrl}/v1`,
34
+ api_key: apiKey,
35
+ available_models: PENNYROUTER_MODELS,
36
+ };
37
+ await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
38
+
39
+ return {
40
+ harness: "zed",
41
+ config_path: compactHome(plan.configPath),
42
+ backup_path: backupPath ? compactHome(backupPath) : null,
43
+ installed_at: new Date().toISOString(),
44
+ restart: "restart Zed",
45
+ changes: { provider: "language_models.providers.custom" },
46
+ };
47
+ },
48
+
49
+ async uninstall(record) {
50
+ const current = (await readJsonFile(record.config_path, null)) || {};
51
+ const provider = current.language_models?.providers?.custom;
52
+ if (provider && JSON.stringify(provider).includes("pennyrouter")) {
53
+ delete current.language_models.providers.custom;
54
+ }
55
+ if (current.language_models?.providers && Object.keys(current.language_models.providers).length === 0) {
56
+ delete current.language_models.providers;
57
+ }
58
+ if (current.language_models && Object.keys(current.language_models).length === 0) delete current.language_models;
59
+ await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
60
+ },
61
+ };
package/src/session.js CHANGED
@@ -5,7 +5,7 @@ export function maskKey(key) {
5
5
  return `${key.slice(0, 3)}...${key.slice(-4)}`;
6
6
  }
7
7
 
8
- export async function createCliSession({ appUrl = APP_URL, harnesses }) {
8
+ export async function createCliSession({ appUrl = APP_URL, harnesses, accountMode = "seed" }) {
9
9
  const label = `${platformName()} / ${harnesses.join(",") || "PennyRouter"}`;
10
10
  const response = await fetch(`${appUrl}/api/cli/session`, {
11
11
  method: "POST",
@@ -13,6 +13,7 @@ export async function createCliSession({ appUrl = APP_URL, harnesses }) {
13
13
  body: JSON.stringify({
14
14
  harness: harnesses.join(","),
15
15
  label,
16
+ account_mode: accountMode,
16
17
  }),
17
18
  });
18
19
 
package/src/state.js CHANGED
@@ -82,6 +82,13 @@ export async function backupFile(path, harness) {
82
82
  return backupPath;
83
83
  }
84
84
 
85
+ export async function restoreFile(sourcePath, destinationPath) {
86
+ const source = expandHome(sourcePath);
87
+ const destination = expandHome(destinationPath);
88
+ await mkdir(dirname(destination), { recursive: true });
89
+ await copyFile(source, destination);
90
+ }
91
+
85
92
  export async function atomicWrite(path, text) {
86
93
  const expanded = expandHome(path);
87
94
  await mkdir(dirname(expanded), { recursive: true });