pennyrouter 0.1.2 → 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 +15 -1
- package/package.json +1 -1
- package/src/cli.js +311 -36
- package/src/harnesses/aider.js +48 -0
- package/src/harnesses/claude-code.js +30 -2
- package/src/harnesses/codegpt.js +75 -0
- package/src/harnesses/cursor.js +83 -0
- package/src/harnesses/opencode.js +1 -1
- package/src/harnesses/shared.js +38 -0
- package/src/harnesses/windsurf.js +57 -0
- package/src/harnesses/zed.js +61 -0
- package/src/session.js +2 -1
- package/src/state.js +7 -0
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 --
|
|
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
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
|
-
|
|
22
|
+
backupFile,
|
|
17
23
|
loadManifest,
|
|
18
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
348
|
+
return promptHarnessCheckboxes(detected);
|
|
349
|
+
}
|
|
221
350
|
|
|
222
|
-
|
|
223
|
-
detected.
|
|
224
|
-
|
|
225
|
-
|
|
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
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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
|
|
266
|
-
|
|
267
|
-
|
|
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
|
|
283
|
-
pennyrouter
|
|
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
|
|
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
|
+
};
|
|
@@ -17,6 +17,7 @@ export const claudeCodeHarness = {
|
|
|
17
17
|
`write ${CONFIG_PATH}`,
|
|
18
18
|
"set Anthropic-compatible base URL to PennyRouter",
|
|
19
19
|
"store the PennyRouter API key without printing it",
|
|
20
|
+
'label every tier "PennyRouter Bundle" (routing is automatic)',
|
|
20
21
|
],
|
|
21
22
|
};
|
|
22
23
|
},
|
|
@@ -26,7 +27,16 @@ export const claudeCodeHarness = {
|
|
|
26
27
|
const current = (await readJsonFile(CONFIG_PATH, null)) || {};
|
|
27
28
|
current.env ||= {};
|
|
28
29
|
current.env.ANTHROPIC_BASE_URL = gatewayBaseUrl;
|
|
29
|
-
|
|
30
|
+
// Custom base URL: Claude Code authenticates with the auth token, not an
|
|
31
|
+
// Anthropic API key. Clear any stale key so it doesn't take precedence.
|
|
32
|
+
current.env.ANTHROPIC_AUTH_TOKEN = apiKey;
|
|
33
|
+
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";
|
|
30
40
|
|
|
31
41
|
await atomicWrite(CONFIG_PATH, `${JSON.stringify(current, null, 2)}\n`);
|
|
32
42
|
|
|
@@ -37,7 +47,13 @@ export const claudeCodeHarness = {
|
|
|
37
47
|
installed_at: new Date().toISOString(),
|
|
38
48
|
restart: "restart Claude Code",
|
|
39
49
|
changes: {
|
|
40
|
-
env_keys: [
|
|
50
|
+
env_keys: [
|
|
51
|
+
"ANTHROPIC_BASE_URL",
|
|
52
|
+
"ANTHROPIC_AUTH_TOKEN",
|
|
53
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
54
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
55
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
56
|
+
],
|
|
41
57
|
},
|
|
42
58
|
};
|
|
43
59
|
},
|
|
@@ -46,9 +62,21 @@ export const claudeCodeHarness = {
|
|
|
46
62
|
const current = (await readJsonFile(record.config_path, null)) || {};
|
|
47
63
|
if (current.env?.ANTHROPIC_BASE_URL?.includes("pennyrouter.com")) {
|
|
48
64
|
delete current.env.ANTHROPIC_BASE_URL;
|
|
65
|
+
const tok = current.env.ANTHROPIC_AUTH_TOKEN;
|
|
66
|
+
if (typeof tok === "string" && tok.startsWith("pr-")) {
|
|
67
|
+
delete current.env.ANTHROPIC_AUTH_TOKEN;
|
|
68
|
+
}
|
|
69
|
+
// Legacy installs stored the key under ANTHROPIC_API_KEY.
|
|
49
70
|
if (typeof current.env.ANTHROPIC_API_KEY === "string" && current.env.ANTHROPIC_API_KEY.startsWith("pr-")) {
|
|
50
71
|
delete current.env.ANTHROPIC_API_KEY;
|
|
51
72
|
}
|
|
73
|
+
for (const k of [
|
|
74
|
+
"ANTHROPIC_DEFAULT_OPUS_MODEL",
|
|
75
|
+
"ANTHROPIC_DEFAULT_SONNET_MODEL",
|
|
76
|
+
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
|
|
77
|
+
]) {
|
|
78
|
+
if (current.env[k] === "PennyRouter Bundle") delete current.env[k];
|
|
79
|
+
}
|
|
52
80
|
}
|
|
53
81
|
if (current.env && Object.keys(current.env).length === 0) delete current.env;
|
|
54
82
|
await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
|
|
@@ -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
|
+
}
|
|
@@ -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 });
|