pennyrouter 0.1.3 → 0.1.4

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,28 @@ 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,cursor,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
+ - Cursor
31
+ - Windsurf
32
+ - Zed
33
+ - JetBrains CodeGPT (manual setup instructions; no XML mutation)
34
+ - Aider
24
35
 
25
36
  ## Agent Flow
26
37
 
@@ -38,6 +49,9 @@ directly.
38
49
  The CLI writes only selected local tool configuration files, saves backups under the local
39
50
  PennyRouter state directory, and records an uninstall manifest.
40
51
 
52
+ `disable` temporarily removes PennyRouter from selected tools while retaining a local
53
+ PennyRouter-managed snapshot for `enable`.
54
+
41
55
  It does not delete PennyRouter accounts, credits, or remote API keys.
42
56
 
43
57
  ## License
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
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,16 @@
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";
10
+ import { cursorHarness } from "./harnesses/cursor.js";
7
11
  import { opencodeHarness } from "./harnesses/opencode.js";
12
+ import { windsurfHarness } from "./harnesses/windsurf.js";
13
+ import { zedHarness } from "./harnesses/zed.js";
8
14
  import {
9
15
  APP_URL,
10
16
  createCliSession,
@@ -13,9 +19,9 @@ import {
13
19
  sleep,
14
20
  } from "./session.js";
15
21
  import {
16
- expandHome,
22
+ backupFile,
17
23
  loadManifest,
18
- readJsonFile,
24
+ restoreFile,
19
25
  saveManifest,
20
26
  statePath,
21
27
  } from "./state.js";
@@ -25,8 +31,15 @@ const HARNESS_BY_ID = {
25
31
  cline: clineHarness,
26
32
  opencode: opencodeHarness,
27
33
  codex: codexHarness,
34
+ cursor: cursorHarness,
35
+ windsurf: windsurfHarness,
36
+ zed: zedHarness,
37
+ codegpt: codeGptHarness,
38
+ aider: aiderHarness,
28
39
  };
29
40
 
41
+ const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
42
+
30
43
  export async function main(argv = process.argv.slice(2)) {
31
44
  const { command, flags } = parseArgs(argv);
32
45
 
@@ -45,6 +58,16 @@ export async function main(argv = process.argv.slice(2)) {
45
58
  return;
46
59
  }
47
60
 
61
+ if (command === "disable") {
62
+ await disable(flags);
63
+ return;
64
+ }
65
+
66
+ if (command === "enable") {
67
+ await enable(flags);
68
+ return;
69
+ }
70
+
48
71
  if (command === "status") {
49
72
  await status();
50
73
  return;
@@ -65,6 +88,7 @@ function parseArgs(argv) {
65
88
  else if (arg === "--all") flags.all = true;
66
89
  else if (arg === "--dry-run") flags.dryRun = true;
67
90
  else if (arg === "--no-browser") flags.noBrowser = true;
91
+ else if (arg === "--existing-account") flags.existingAccount = true;
68
92
  else if (arg === "--harness") flags.harness = rest[++i] || "";
69
93
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
70
94
  else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
@@ -84,14 +108,10 @@ async function install(flags) {
84
108
 
85
109
  console.log("PennyRouter install");
86
110
  console.log("");
87
- console.log("Selected tools:");
88
- for (const harness of selected) console.log(` - ${harness.name}`);
89
- console.log("");
90
111
 
91
112
  if (flags.dryRun) {
92
113
  for (const harness of selected) {
93
- const plan = await harness.planInstall();
94
- printPlan(harness, plan);
114
+ console.log(`Will install on ${harness.name}${installNoteForHarness(harness.id)}.`);
95
115
  }
96
116
  console.log("Dry run only. No files changed.");
97
117
  return;
@@ -100,9 +120,14 @@ async function install(flags) {
100
120
  const session = await createCliSession({
101
121
  appUrl: flags.appUrl || APP_URL,
102
122
  harnesses: selected.map((harness) => harness.id),
123
+ accountMode: flags.existingAccount ? "existing" : "seed",
103
124
  });
104
125
 
105
- console.log("Opening your browser to authorize this machine...");
126
+ console.log(
127
+ flags.existingAccount
128
+ ? "Opening your browser to authorize this machine with your existing PennyRouter account..."
129
+ : "Opening your browser to authorize this machine...",
130
+ );
106
131
  console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
107
132
  if (!flags.noBrowser) openBrowser(session.authorize_url);
108
133
  console.log("");
@@ -116,7 +141,6 @@ async function install(flags) {
116
141
  const records = [];
117
142
  for (const harness of selected) {
118
143
  const plan = await harness.planInstall();
119
- printPlan(harness, plan);
120
144
  const record = await harness.install({
121
145
  apiKey: claim.api_key,
122
146
  gatewayBaseUrl: claim.gateway_base_url,
@@ -129,10 +153,115 @@ async function install(flags) {
129
153
  await saveManifest(manifest);
130
154
  console.log("");
131
155
  console.log("Installed PennyRouter.");
132
- console.log(`Manifest: ${statePath("installations.json")}`);
133
156
  for (const record of records) {
134
- if (record.restart) console.log(`${record.harness}: ${record.restart}`);
157
+ const harness = HARNESS_BY_ID[record.harness];
158
+ console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
135
159
  }
160
+ console.log(`Manifest: ${statePath("installations.json")}`);
161
+ }
162
+
163
+ async function disable(flags) {
164
+ const manifest = await loadManifest();
165
+ const records = manifest.installations || [];
166
+ const selectedIds = resolveRequestedHarnessIds(flags, records.map((record) => record.harness));
167
+ const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
168
+
169
+ if (selectedRecords.length === 0) {
170
+ console.log("No matching PennyRouter installations found.");
171
+ return;
172
+ }
173
+
174
+ console.log("PennyRouter disable");
175
+ console.log("");
176
+ for (const record of selectedRecords) {
177
+ console.log(` - ${record.harness}: ${record.config_path || "manual setup"}`);
178
+ }
179
+ console.log("");
180
+
181
+ if (!flags.yes) {
182
+ const ok = await confirm("Temporarily turn off these PennyRouter local integrations?");
183
+ if (!ok) return;
184
+ }
185
+
186
+ for (const record of selectedRecords) {
187
+ if (record.disabled) {
188
+ console.log(`${record.harness} is already disabled.`);
189
+ continue;
190
+ }
191
+
192
+ const harness = HARNESS_BY_ID[record.harness];
193
+ if (!harness) {
194
+ console.log(`Skipping ${record.harness}: this CLI no longer knows how to disable it.`);
195
+ continue;
196
+ }
197
+
198
+ if (record.config_path) {
199
+ const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
200
+ record.disabled_snapshot_path = snapshotPath;
201
+ }
202
+
203
+ if (typeof harness.disable === "function") await harness.disable(record);
204
+ else await harness.uninstall(record);
205
+
206
+ record.disabled = true;
207
+ record.disabled_at = new Date().toISOString();
208
+ console.log(`Disabled ${record.harness}.`);
209
+ }
210
+
211
+ await saveManifest(manifest);
212
+ }
213
+
214
+ async function enable(flags) {
215
+ const manifest = await loadManifest();
216
+ const records = manifest.installations || [];
217
+ const selectedIds = resolveRequestedHarnessIds(flags, records.map((record) => record.harness));
218
+ const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
219
+
220
+ if (selectedRecords.length === 0) {
221
+ console.log("No matching PennyRouter installations found.");
222
+ return;
223
+ }
224
+
225
+ console.log("PennyRouter enable");
226
+ console.log("");
227
+ for (const record of selectedRecords) {
228
+ console.log(` - ${record.harness}: ${record.config_path || "manual setup"}`);
229
+ }
230
+ console.log("");
231
+
232
+ if (!flags.yes) {
233
+ const ok = await confirm("Turn these PennyRouter local integrations back on?");
234
+ if (!ok) return;
235
+ }
236
+
237
+ for (const record of selectedRecords) {
238
+ if (!record.disabled) {
239
+ console.log(`${record.harness} is already enabled.`);
240
+ continue;
241
+ }
242
+
243
+ const harness = HARNESS_BY_ID[record.harness];
244
+ if (!harness) {
245
+ console.log(`Skipping ${record.harness}: this CLI no longer knows how to enable it.`);
246
+ continue;
247
+ }
248
+
249
+ if (typeof harness.enable === "function") await harness.enable(record);
250
+ else if (record.config_path && record.disabled_snapshot_path) {
251
+ await restoreFile(record.disabled_snapshot_path, record.config_path);
252
+ } else if (record.manual_required) {
253
+ console.log(`${record.harness}: re-enable manually in the tool UI using the status details.`);
254
+ } else {
255
+ console.log(`${record.harness}: no disabled snapshot found; run install again.`);
256
+ continue;
257
+ }
258
+
259
+ delete record.disabled;
260
+ delete record.disabled_at;
261
+ console.log(`Enabled ${record.harness}.`);
262
+ }
263
+
264
+ await saveManifest(manifest);
136
265
  }
137
266
 
138
267
  async function uninstall(flags) {
@@ -192,10 +321,13 @@ async function status() {
192
321
  }
193
322
 
194
323
  for (const record of records) {
195
- console.log(`${record.harness}: ${record.manual_required ? "manual setup shown" : "installed"}`);
324
+ const state = record.disabled ? "disabled" : record.manual_required ? "manual setup shown" : "installed";
325
+ console.log(`${record.harness}: ${state}`);
196
326
  if (record.config_path) console.log(` config: ${record.config_path}`);
197
327
  if (record.backup_path) console.log(` backup: ${record.backup_path}`);
328
+ if (record.disabled_snapshot_path) console.log(` disabled snapshot: ${record.disabled_snapshot_path}`);
198
329
  if (record.installed_at) console.log(` installed: ${record.installed_at}`);
330
+ if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
199
331
  if (record.manual_required) console.log(" note: finish or remove this integration in the tool UI");
200
332
  }
201
333
  }
@@ -211,29 +343,161 @@ async function selectHarnesses(flags) {
211
343
  return requested.map((id) => HARNESS_BY_ID[id]).filter(Boolean);
212
344
  }
213
345
 
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
- }
346
+ if (flags.yes) return detected;
219
347
 
220
- if (flags.yes || detected.length === 1) return detected;
348
+ return promptHarnessCheckboxes(detected);
349
+ }
221
350
 
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("");
351
+ async function promptHarnessCheckboxes(detected) {
352
+ const detectedIds = new Set(detected.map((harness) => harness.id));
353
+ const selectedIds = new Set(detectedIds);
354
+ const harnesses = orderedHarnessesForPrompt(detectedIds);
355
+ if (!input.isTTY || !output.isTTY) return promptHarnessCheckboxesByText(harnesses, selectedIds);
356
+
357
+ return promptHarnessCheckboxesByKeys(harnesses, selectedIds);
358
+ }
359
+
360
+ async function promptHarnessCheckboxesByKeys(harnesses, selectedIds) {
361
+ let cursor = 0;
362
+ const previousRawMode = input.isRaw;
363
+
364
+ emitKeypressEvents(input);
365
+ input.setRawMode(true);
366
+ input.resume();
367
+ output.write("\x1b[?25l");
368
+
369
+ const render = () => {
370
+ output.write("\x1b[2J\x1b[H");
371
+ output.write("\n\n");
372
+ output.write(`${color("Where should PennyRouter be installed?", "bold")}\n\n\n`);
373
+ harnesses.forEach((harness, index) => {
374
+ const pointer = index === cursor ? color(">", "cyan") : " ";
375
+ const checked = selectedIds.has(harness.id) ? color("x", "green") : " ";
376
+ const name = index === cursor ? color(harness.name, "cyan") : harness.name;
377
+ output.write(` ${pointer} [${checked}] ${name}\n`);
378
+ });
379
+ output.write("\nEnter to continue.\n");
380
+ };
381
+
382
+ render();
383
+
384
+ return new Promise((resolve, reject) => {
385
+ const cleanup = () => {
386
+ input.off("keypress", onKeypress);
387
+ input.setRawMode(Boolean(previousRawMode));
388
+ input.pause();
389
+ output.write("\x1b[?25h");
390
+ output.write("\n");
391
+ };
392
+
393
+ const onKeypress = (_str, key = {}) => {
394
+ if (key.ctrl && key.name === "c") {
395
+ cleanup();
396
+ reject(new Error("Cancelled."));
397
+ return;
398
+ }
399
+
400
+ if (key.name === "up" || key.name === "k") {
401
+ cursor = (cursor - 1 + harnesses.length) % harnesses.length;
402
+ render();
403
+ return;
404
+ }
405
+
406
+ if (key.name === "down" || key.name === "j") {
407
+ cursor = (cursor + 1) % harnesses.length;
408
+ render();
409
+ return;
410
+ }
411
+
412
+ if (key.name === "space") {
413
+ const id = harnesses[cursor].id;
414
+ if (selectedIds.has(id)) selectedIds.delete(id);
415
+ else selectedIds.add(id);
416
+ render();
417
+ return;
418
+ }
419
+
420
+ if (key.name === "return" || key.name === "enter") {
421
+ cleanup();
422
+ resolve(harnesses.filter((harness) => selectedIds.has(harness.id)));
423
+ }
424
+ };
425
+
426
+ input.on("keypress", onKeypress);
427
+ });
428
+ }
226
429
 
430
+ async function promptHarnessCheckboxesByText(harnesses, selectedIds) {
227
431
  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
432
 
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]);
433
+ try {
434
+ while (true) {
435
+ console.log("");
436
+ console.log("");
437
+ console.log(color("Where should PennyRouter be installed?", "bold"));
438
+ for (const harness of harnesses) {
439
+ const checked = selectedIds.has(harness.id) ? color("x", "green") : " ";
440
+ console.log(` [${checked}] ${harness.name}`);
441
+ }
442
+ console.log("");
443
+ const answer = await rl.question(
444
+ "Press Enter to continue, or type names to toggle (comma-separated, 'all', or 'none'): ",
445
+ );
446
+ const value = answer.trim().toLowerCase();
447
+ if (!value) return harnesses.filter((harness) => selectedIds.has(harness.id));
448
+ if (value === "all") {
449
+ for (const harness of harnesses) selectedIds.add(harness.id);
450
+ } else if (value === "none") {
451
+ selectedIds.clear();
452
+ } else {
453
+ for (const token of parseHarnessList(value)) {
454
+ const id = resolveHarnessToken(token);
455
+ if (!HARNESS_BY_ID[id]) {
456
+ console.log(`Unknown tool "${token}".`);
457
+ continue;
458
+ }
459
+ if (selectedIds.has(id)) selectedIds.delete(id);
460
+ else selectedIds.add(id);
461
+ }
462
+ }
463
+ console.log("");
464
+ }
465
+ } finally {
466
+ rl.close();
467
+ }
468
+ }
469
+
470
+ function orderedHarnessesForPrompt(detectedIds) {
471
+ return Object.values(HARNESS_BY_ID).sort((left, right) => {
472
+ const leftDetected = detectedIds.has(left.id);
473
+ const rightDetected = detectedIds.has(right.id);
474
+ if (leftDetected !== rightDetected) return leftDetected ? -1 : 1;
475
+ return left.name.localeCompare(right.name);
476
+ });
477
+ }
478
+
479
+ function resolveHarnessToken(token) {
480
+ const normalized = normalizeHarnessToken(token);
481
+ for (const harness of Object.values(HARNESS_BY_ID)) {
482
+ if (normalizeHarnessToken(harness.id) === normalized) return harness.id;
483
+ if (normalizeHarnessToken(harness.name) === normalized) return harness.id;
484
+ }
485
+ return token;
486
+ }
487
+
488
+ function normalizeHarnessToken(value) {
489
+ return String(value).toLowerCase().replace(/[^a-z0-9]/g, "");
490
+ }
491
+
492
+ function color(text, style) {
493
+ if (!output.isTTY || process.env.NO_COLOR) return text;
494
+ const codes = {
495
+ bold: ["\x1b[1m", "\x1b[22m"],
496
+ cyan: ["\x1b[36m", "\x1b[39m"],
497
+ green: ["\x1b[32m", "\x1b[39m"],
498
+ };
499
+ const pair = codes[style];
500
+ return pair ? `${pair[0]}${text}${pair[1]}` : text;
237
501
  }
238
502
 
239
503
  function parseHarnessList(value = "") {
@@ -262,9 +526,15 @@ async function confirm(question) {
262
526
  return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
263
527
  }
264
528
 
265
- function printPlan(harness, plan) {
266
- console.log(`${harness.name}:`);
267
- for (const line of plan.summary) console.log(` - ${line}`);
529
+ function installNoteForRecord(record) {
530
+ if (record.manual_required) return " (manual setup shown)";
531
+ return installNoteForHarness(record.harness);
532
+ }
533
+
534
+ function installNoteForHarness(harnessId) {
535
+ if (harnessId === "cline" || harnessId === "codegpt") return " (manual setup)";
536
+ if (harnessId === "opencode") return " (select PennyRouter Auto from /model)";
537
+ return " (auto-selected)";
268
538
  }
269
539
 
270
540
  function openBrowser(url) {
@@ -279,14 +549,19 @@ function printHelp() {
279
549
  console.log(`PennyRouter CLI
280
550
 
281
551
  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]
552
+ pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--existing-account]
553
+ pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
554
+ pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
555
+ pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
284
556
  pennyrouter status
285
557
 
286
558
  Examples:
287
559
  npx pennyrouter install
560
+ npx pennyrouter install --existing-account
288
561
  npx pennyrouter install --all
289
- npx pennyrouter install --harness claude-code,cline,opencode,codex --yes
562
+ npx pennyrouter install --harness cursor,windsurf,zed,codegpt,aider --yes
563
+ npx pennyrouter disable
564
+ npx pennyrouter enable
290
565
  npx pennyrouter uninstall
291
566
  `);
292
567
  }
@@ -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 PennyRouter Auto 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
+ };
@@ -0,0 +1,75 @@
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");
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
+ },
49
+ };
50
+ },
51
+
52
+ async uninstall() {
53
+ console.log("");
54
+ console.log("JetBrains CodeGPT manual uninstall");
55
+ console.log(" Open CodeGPT settings and remove or replace the PennyRouter provider:");
56
+ console.log(" - Base URL: https://api.pennyrouter.com/v1");
57
+ console.log(" - API Key: pr-...");
58
+ console.log(" - Model: pennyrouter/auto");
59
+ },
60
+ };
61
+
62
+ function copyToClipboard(text) {
63
+ const commands =
64
+ process.platform === "darwin"
65
+ ? [["pbcopy"]]
66
+ : process.platform === "win32"
67
+ ? [["clip"]]
68
+ : [["xclip", "-selection", "clipboard"], ["xsel", "--clipboard", "--input"]];
69
+
70
+ for (const [command, ...args] of commands) {
71
+ const result = spawnSync(command, args, { input: text, stdio: ["pipe", "ignore", "ignore"] });
72
+ if (result.status === 0) return true;
73
+ }
74
+ return false;
75
+ }
@@ -0,0 +1,83 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import { backupFile, compactHome, exists, expandHome } from "../state.js";
3
+ import { PENNYROUTER_MODELS } from "./shared.js";
4
+
5
+ const CONFIG_PATH = process.platform === "darwin"
6
+ ? "~/Library/Application Support/Cursor/User/globalStorage/state.vscdb"
7
+ : process.platform === "win32"
8
+ ? "~/AppData/Roaming/Cursor/User/globalStorage/state.vscdb"
9
+ : "~/.config/Cursor/User/globalStorage/state.vscdb";
10
+
11
+ export const cursorHarness = {
12
+ id: "cursor",
13
+ name: "Cursor",
14
+
15
+ async detect() {
16
+ return exists(CONFIG_PATH);
17
+ },
18
+
19
+ async planInstall() {
20
+ return {
21
+ configPath: expandHome(CONFIG_PATH),
22
+ summary: [
23
+ `write ${CONFIG_PATH}`,
24
+ "set Cursor backend OpenAI base URL and API key",
25
+ "set Cursor custom model list to PennyRouter aliases",
26
+ ],
27
+ };
28
+ },
29
+
30
+ async install({ apiKey, gatewayBaseUrl, plan }) {
31
+ const backupPath = await backupFile(CONFIG_PATH, "cursor");
32
+ runSqlite(plan.configPath, [
33
+ ["backend.openaiBaseUrl", JSON.stringify(`${gatewayBaseUrl}/v1`)],
34
+ ["backend.openaiApiKey", JSON.stringify(apiKey)],
35
+ ["backend.customModels", JSON.stringify(PENNYROUTER_MODELS)],
36
+ ]);
37
+
38
+ return {
39
+ harness: "cursor",
40
+ config_path: compactHome(plan.configPath),
41
+ backup_path: backupPath ? compactHome(backupPath) : null,
42
+ installed_at: new Date().toISOString(),
43
+ restart: "quit and restart Cursor",
44
+ changes: {
45
+ table: "kvptr",
46
+ keys: ["backend.openaiBaseUrl", "backend.openaiApiKey", "backend.customModels"],
47
+ },
48
+ };
49
+ },
50
+
51
+ async uninstall(record) {
52
+ runSqlite(expandHome(record.config_path), null, [
53
+ "backend.openaiBaseUrl",
54
+ "backend.openaiApiKey",
55
+ "backend.customModels",
56
+ ]);
57
+ },
58
+ };
59
+
60
+ function runSqlite(dbPath, upserts = [], deletes = []) {
61
+ const statements = [];
62
+ for (const [key, value] of upserts || []) {
63
+ statements.push(`INSERT OR REPLACE INTO kvptr (key, value) VALUES (${sqlString(key)}, ${sqlString(value)});`);
64
+ }
65
+ for (const key of deletes || []) {
66
+ statements.push(`DELETE FROM kvptr WHERE key = ${sqlString(key)};`);
67
+ }
68
+
69
+ const result = spawnSync("sqlite3", [dbPath], {
70
+ input: `${statements.join("\n")}\n`,
71
+ encoding: "utf8",
72
+ stdio: ["pipe", "pipe", "pipe"],
73
+ });
74
+
75
+ if (result.status !== 0) {
76
+ const detail = result.stderr?.trim() || "sqlite3 command failed";
77
+ throw new Error(`Cursor config update failed: ${detail}. Quit Cursor and ensure sqlite3 is installed.`);
78
+ }
79
+ }
80
+
81
+ function sqlString(value) {
82
+ return `'${String(value).replace(/'/g, "''")}'`;
83
+ }
@@ -15,7 +15,7 @@ const MODEL_STATE_PATH = "~/.local/state/opencode/model.json";
15
15
 
16
16
  export const opencodeHarness = {
17
17
  id: "opencode",
18
- name: "opencode",
18
+ name: "OpenCode",
19
19
 
20
20
  async detect() {
21
21
  return (await exists(JSONC_PATH)) || (await exists(JSON_PATH));
@@ -0,0 +1,38 @@
1
+ export const PENNYROUTER_MODELS = [
2
+ "pennyrouter/auto",
3
+ "pennyrouter/anthropic",
4
+ "pennyrouter/openai",
5
+ "pennyrouter/gemini",
6
+ "pennyrouter/grok",
7
+ "pennyrouter/deepseek",
8
+ "pennyrouter/zai",
9
+ "pennyrouter/qwen",
10
+ "pennyrouter/penny",
11
+ ];
12
+
13
+ export function removeJsonKeys(object, keys) {
14
+ if (!object || typeof object !== "object") return;
15
+ for (const key of keys) delete object[key];
16
+ }
17
+
18
+ export function setYamlScalar(text, key, value) {
19
+ const escaped = String(value).replace(/\\/g, "\\\\").replace(/"/g, '\\"');
20
+ const line = `${key}: "${escaped}"`;
21
+ const pattern = new RegExp(`^\\s*${escapeRegExp(key)}\\s*:.*$`, "m");
22
+ if (pattern.test(text)) return text.replace(pattern, line);
23
+ const normalized = text.trimEnd();
24
+ return `${normalized}${normalized ? "\n" : ""}${line}\n`;
25
+ }
26
+
27
+ export function removeYamlKeys(text, keys) {
28
+ const patterns = keys.map((key) => new RegExp(`^\\s*${escapeRegExp(key)}\\s*:.*$`));
29
+ return text
30
+ .split("\n")
31
+ .filter((line) => !patterns.some((pattern) => pattern.test(line)))
32
+ .join("\n")
33
+ .replace(/\n{3,}/g, "\n\n");
34
+ }
35
+
36
+ function escapeRegExp(value) {
37
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
38
+ }
@@ -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 });