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