pennyrouter 0.1.4 → 0.1.6

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
@@ -11,7 +11,7 @@ status checks, and uninstall metadata.
11
11
  ```bash
12
12
  npx pennyrouter install
13
13
  npx pennyrouter install --existing-account
14
- npx pennyrouter install --harness claude-code,cline,opencode,codex,cursor,windsurf,zed,codegpt,aider
14
+ npx pennyrouter install --harness claude-code,cline,opencode,codex,windsurf,zed,codegpt,aider
15
15
  npx pennyrouter disable
16
16
  npx pennyrouter enable
17
17
  npx pennyrouter uninstall
@@ -27,12 +27,20 @@ detected configs start checked; press Enter to accept or type tool IDs to toggle
27
27
  - Cline (manual setup instructions; no VS Code settings mutation)
28
28
  - Codex
29
29
  - opencode
30
- - Cursor
31
30
  - Windsurf
32
31
  - Zed
33
32
  - JetBrains CodeGPT (manual setup instructions; no XML mutation)
34
33
  - Aider
35
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
43
+
36
44
  ## Agent Flow
37
45
 
38
46
  Users can ask a coding agent to follow:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.1.4",
3
+ "version": "0.1.6",
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
@@ -7,7 +7,6 @@ import { claudeCodeHarness } from "./harnesses/claude-code.js";
7
7
  import { clineHarness } from "./harnesses/cline.js";
8
8
  import { codeGptHarness } from "./harnesses/codegpt.js";
9
9
  import { codexHarness } from "./harnesses/codex.js";
10
- import { cursorHarness } from "./harnesses/cursor.js";
11
10
  import { opencodeHarness } from "./harnesses/opencode.js";
12
11
  import { windsurfHarness } from "./harnesses/windsurf.js";
13
12
  import { zedHarness } from "./harnesses/zed.js";
@@ -20,6 +19,7 @@ import {
20
19
  } from "./session.js";
21
20
  import {
22
21
  backupFile,
22
+ compactHome,
23
23
  loadManifest,
24
24
  restoreFile,
25
25
  saveManifest,
@@ -31,7 +31,6 @@ const HARNESS_BY_ID = {
31
31
  cline: clineHarness,
32
32
  opencode: opencodeHarness,
33
33
  codex: codexHarness,
34
- cursor: cursorHarness,
35
34
  windsurf: windsurfHarness,
36
35
  zed: zedHarness,
37
36
  codegpt: codeGptHarness,
@@ -139,15 +138,19 @@ async function install(flags) {
139
138
 
140
139
  const manifest = await loadManifest();
141
140
  const records = [];
141
+ const failures = [];
142
142
  for (const harness of selected) {
143
- const plan = await harness.planInstall();
144
- const record = await harness.install({
145
- apiKey: claim.api_key,
146
- gatewayBaseUrl: claim.gateway_base_url,
147
- 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);
148
153
  });
149
- records.push(record);
150
- upsertManifestRecord(manifest, record);
151
154
  }
152
155
 
153
156
  await saveManifest(manifest);
@@ -158,13 +161,14 @@ async function install(flags) {
158
161
  console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
159
162
  }
160
163
  console.log(`Manifest: ${statePath("installations.json")}`);
164
+ reportFailures(failures);
161
165
  }
162
166
 
163
167
  async function disable(flags) {
164
168
  const manifest = await loadManifest();
169
+ if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
165
170
  const records = manifest.installations || [];
166
- const selectedIds = resolveRequestedHarnessIds(flags, records.map((record) => record.harness));
167
- const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
171
+ const { selectedIds, selectedRecords } = await resolveLifecycleTargets(flags, records);
168
172
 
169
173
  if (selectedRecords.length === 0) {
170
174
  console.log("No matching PennyRouter installations found.");
@@ -183,6 +187,7 @@ async function disable(flags) {
183
187
  if (!ok) return;
184
188
  }
185
189
 
190
+ const failures = [];
186
191
  for (const record of selectedRecords) {
187
192
  if (record.disabled) {
188
193
  console.log(`${record.harness} is already disabled.`);
@@ -195,24 +200,30 @@ async function disable(flags) {
195
200
  continue;
196
201
  }
197
202
 
198
- if (record.config_path) {
199
- const snapshotPath = await backupFile(record.config_path, `${record.harness}-disabled`);
200
- record.disabled_snapshot_path = snapshotPath;
201
- }
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
+ }
202
208
 
203
- if (typeof harness.disable === "function") await harness.disable(record);
204
- else await harness.uninstall(record);
209
+ if (typeof harness.disable === "function") await harness.disable(record);
210
+ else await harness.uninstall(record);
205
211
 
206
- record.disabled = true;
207
- record.disabled_at = new Date().toISOString();
208
- console.log(`Disabled ${record.harness}.`);
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
+ });
209
218
  }
210
219
 
211
220
  await saveManifest(manifest);
221
+ reportFailures(failures);
212
222
  }
213
223
 
214
224
  async function enable(flags) {
215
225
  const manifest = await loadManifest();
226
+ if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
216
227
  const records = manifest.installations || [];
217
228
  const selectedIds = resolveRequestedHarnessIds(flags, records.map((record) => record.harness));
218
229
  const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
@@ -234,6 +245,7 @@ async function enable(flags) {
234
245
  if (!ok) return;
235
246
  }
236
247
 
248
+ const failures = [];
237
249
  for (const record of selectedRecords) {
238
250
  if (!record.disabled) {
239
251
  console.log(`${record.harness} is already enabled.`);
@@ -246,29 +258,33 @@ async function enable(flags) {
246
258
  continue;
247
259
  }
248
260
 
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
- }
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
+ }
258
270
 
259
- delete record.disabled;
260
- delete record.disabled_at;
261
- console.log(`Enabled ${record.harness}.`);
271
+ delete record.disabled;
272
+ delete record.disabled_at;
273
+ upsertManifestRecord(manifest, record);
274
+ await saveManifest(manifest);
275
+ console.log(`Enabled ${record.harness}.`);
276
+ });
262
277
  }
263
278
 
264
279
  await saveManifest(manifest);
280
+ reportFailures(failures);
265
281
  }
266
282
 
267
283
  async function uninstall(flags) {
268
284
  const manifest = await loadManifest();
285
+ if (pruneUnsupportedManifestRecords(manifest).length > 0) await saveManifest(manifest);
269
286
  const records = manifest.installations || [];
270
- const selectedIds = resolveRequestedHarnessIds(flags, records.map((record) => record.harness));
271
- const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
287
+ const { selectedIds, selectedRecords } = await resolveLifecycleTargets(flags, records);
272
288
 
273
289
  if (selectedRecords.length === 0) {
274
290
  console.log("No matching PennyRouter installations found.");
@@ -288,6 +304,7 @@ async function uninstall(flags) {
288
304
  }
289
305
 
290
306
  const kept = [];
307
+ const failures = [];
291
308
  for (const record of records) {
292
309
  if (!selectedIds.includes(record.harness)) {
293
310
  kept.push(record);
@@ -301,16 +318,81 @@ async function uninstall(flags) {
301
318
  continue;
302
319
  }
303
320
 
304
- await harness.uninstall(record);
305
- 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
+ });
306
339
  }
307
340
 
308
341
  manifest.installations = kept;
309
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;
310
390
  }
311
391
 
312
392
  async function status() {
313
393
  const manifest = await loadManifest();
394
+ const pruned = pruneUnsupportedManifestRecords(manifest);
395
+ if (pruned.length > 0) await saveManifest(manifest);
314
396
  const records = manifest.installations || [];
315
397
  console.log("PennyRouter local setup");
316
398
  console.log("");
@@ -336,7 +418,7 @@ async function selectHarnesses(flags) {
336
418
  const requested = flags.all ? Object.keys(HARNESS_BY_ID) : parseHarnessList(flags.harness);
337
419
  const detected = [];
338
420
  for (const harness of Object.values(HARNESS_BY_ID)) {
339
- if (await harness.detect()) detected.push(harness);
421
+ if (await safeDetect(harness)) detected.push(harness);
340
422
  }
341
423
 
342
424
  if (requested.length > 0) {
@@ -519,6 +601,48 @@ function upsertManifestRecord(manifest, record) {
519
601
  manifest.installations.push(record);
520
602
  }
521
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
+
522
646
  async function confirm(question) {
523
647
  const rl = createInterface({ input, output });
524
648
  const answer = await rl.question(`${question} [y/N] `);
@@ -533,7 +657,7 @@ function installNoteForRecord(record) {
533
657
 
534
658
  function installNoteForHarness(harnessId) {
535
659
  if (harnessId === "cline" || harnessId === "codegpt") return " (manual setup)";
536
- if (harnessId === "opencode") return " (select PennyRouter Auto from /model)";
660
+ if (harnessId === "opencode") return " (select a Penny model from /model)";
537
661
  return " (auto-selected)";
538
662
  }
539
663
 
@@ -559,7 +683,7 @@ Examples:
559
683
  npx pennyrouter install
560
684
  npx pennyrouter install --existing-account
561
685
  npx pennyrouter install --all
562
- npx pennyrouter install --harness cursor,windsurf,zed,codegpt,aider --yes
686
+ npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
563
687
  npx pennyrouter disable
564
688
  npx pennyrouter enable
565
689
  npx pennyrouter uninstall
@@ -17,7 +17,7 @@ export const aiderHarness = {
17
17
  summary: [
18
18
  `write ${CONFIG_PATH}`,
19
19
  "set OpenAI-compatible base URL and API key",
20
- "set default model to PennyRouter Auto through LiteLLM naming",
20
+ "set default model to Penny Custom through LiteLLM naming",
21
21
  ],
22
22
  };
23
23
  },
@@ -1,6 +1,13 @@
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
+ power: "Penny Power",
9
+ custom: "Penny Custom",
10
+ };
4
11
 
5
12
  export const claudeCodeHarness = {
6
13
  id: "claude-code",
@@ -17,7 +24,7 @@ export const claudeCodeHarness = {
17
24
  `write ${CONFIG_PATH}`,
18
25
  "set Anthropic-compatible base URL to PennyRouter",
19
26
  "store the PennyRouter API key without printing it",
20
- 'label every tier "PennyRouter Bundle" (routing is automatic)',
27
+ "map Claude Code models to Penny model choices",
21
28
  ],
22
29
  };
23
30
  },
@@ -31,12 +38,12 @@ export const claudeCodeHarness = {
31
38
  // Anthropic API key. Clear any stale key so it doesn't take precedence.
32
39
  current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
33
40
  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";
41
+ // Claude Code displays these env values directly in its model picker; the
42
+ // gateway decodes the friendly labels into bundle/profile overrides.
43
+ current.env.ANTHROPIC_DEFAULT_OPUS_MODEL = CLAUDE_CODE_MODELS.power;
44
+ current.env.ANTHROPIC_DEFAULT_SONNET_MODEL = CLAUDE_CODE_MODELS.core;
45
+ current.env.ANTHROPIC_DEFAULT_HAIKU_MODEL = CLAUDE_CODE_MODELS.speed;
46
+ current.env.ANTHROPIC_DEFAULT_FABLE_MODEL = CLAUDE_CODE_MODELS.custom;
40
47
 
41
48
  await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
42
49
 
@@ -53,6 +60,7 @@ export const claudeCodeHarness = {
53
60
  "ANTHROPIC_DEFAULT_OPUS_MODEL",
54
61
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
55
62
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
63
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
56
64
  ],
57
65
  },
58
66
  };
@@ -60,7 +68,7 @@ export const claudeCodeHarness = {
60
68
 
61
69
  async uninstall(record) {
62
70
  const current = (await readJsonFile(record.config_path, null)) || {};
63
- if (current.env?.ANTHROPIC_BASE_URL?.includes("pennyrouter.com")) {
71
+ if (hasPennyRouterEnv(current.env)) {
64
72
  delete current.env.ANTHROPIC_BASE_URL;
65
73
  const tok = current.env.ANTHROPIC_AUTH_TOKEN;
66
74
  if (typeof tok === "string" && tok.startsWith("pr-")) {
@@ -74,11 +82,53 @@ export const claudeCodeHarness = {
74
82
  "ANTHROPIC_DEFAULT_OPUS_MODEL",
75
83
  "ANTHROPIC_DEFAULT_SONNET_MODEL",
76
84
  "ANTHROPIC_DEFAULT_HAIKU_MODEL",
85
+ "ANTHROPIC_DEFAULT_FABLE_MODEL",
77
86
  ]) {
78
- if (current.env[k] === "PennyRouter Bundle") delete current.env[k];
87
+ if (current.env[k] === LEGACY_MODEL_LABEL || isPennyRouterModelValue(current.env[k])) delete current.env[k];
79
88
  }
80
89
  }
90
+ if (current["anthropic.baseURL"] && isPennyRouterValue(current["anthropic.baseURL"])) {
91
+ delete current["anthropic.baseURL"];
92
+ }
93
+ if (typeof current["anthropic.apiKey"] === "string" && current["anthropic.apiKey"].startsWith("pr-")) {
94
+ delete current["anthropic.apiKey"];
95
+ }
81
96
  if (current.env && Object.keys(current.env).length === 0) delete current.env;
82
97
  await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
83
98
  },
84
99
  };
100
+
101
+ function hasPennyRouterEnv(env) {
102
+ if (!env || typeof env !== "object") return false;
103
+ return (
104
+ isPennyRouterBaseUrl(env.ANTHROPIC_BASE_URL) ||
105
+ isPennyRouterValue(env.ANTHROPIC_AUTH_TOKEN) ||
106
+ isPennyRouterValue(env.ANTHROPIC_API_KEY) ||
107
+ env.ANTHROPIC_DEFAULT_OPUS_MODEL === LEGACY_MODEL_LABEL ||
108
+ env.ANTHROPIC_DEFAULT_SONNET_MODEL === LEGACY_MODEL_LABEL ||
109
+ env.ANTHROPIC_DEFAULT_HAIKU_MODEL === LEGACY_MODEL_LABEL ||
110
+ env.ANTHROPIC_DEFAULT_FABLE_MODEL === LEGACY_MODEL_LABEL ||
111
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_OPUS_MODEL) ||
112
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_SONNET_MODEL) ||
113
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_HAIKU_MODEL) ||
114
+ isPennyRouterModelValue(env.ANTHROPIC_DEFAULT_FABLE_MODEL)
115
+ );
116
+ }
117
+
118
+ function isPennyRouterValue(value) {
119
+ return typeof value === "string" && (value.startsWith("pr-") || value.includes("pennyrouter"));
120
+ }
121
+
122
+ function isPennyRouterBaseUrl(value) {
123
+ return typeof value === "string" && value.includes("pennyrouter");
124
+ }
125
+
126
+ function isPennyRouterModelValue(value) {
127
+ return (
128
+ isPennyRouterValue(value) ||
129
+ value === CLAUDE_CODE_MODELS.speed ||
130
+ value === CLAUDE_CODE_MODELS.core ||
131
+ value === CLAUDE_CODE_MODELS.power ||
132
+ value === CLAUDE_CODE_MODELS.custom
133
+ );
134
+ }
@@ -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
  },
@@ -27,7 +27,7 @@ export const codeGptHarness = {
27
27
  console.log(" Open CodeGPT settings in your JetBrains IDE and set:");
28
28
  console.log(` - Base URL: ${gatewayBaseUrl}/v1`);
29
29
  console.log(` - API Key: ${copied ? "copied to clipboard" : "copy from your PennyRouter dashboard"}`);
30
- console.log(" - Model: pennyrouter/auto");
30
+ console.log(" - Model: pennyrouter/auto (Penny Custom)");
31
31
  console.log("");
32
32
  console.log("The CLI did not write JetBrains XML settings because CodeGPT stores options under IDE/version-specific paths.");
33
33
  if (!copied) {
@@ -45,6 +45,7 @@ export const codeGptHarness = {
45
45
  provider: "OpenAI Compatible",
46
46
  base_url: `${gatewayBaseUrl}/v1`,
47
47
  model: "pennyrouter/auto",
48
+ model_name: "Penny Custom",
48
49
  },
49
50
  };
50
51
  },
@@ -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";
@@ -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,
@@ -1,15 +1,28 @@
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",
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
+ },
11
18
  ];
12
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
+
13
26
  export function removeJsonKeys(object, keys) {
14
27
  if (!object || typeof object !== "object") return;
15
28
  for (const key of keys) delete object[key];
@@ -1,83 +0,0 @@
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
- }