pennyrouter 0.1.0
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/LICENSE +21 -0
- package/README.md +15 -0
- package/bin/pennyrouter.js +7 -0
- package/package.json +22 -0
- package/src/cli.js +304 -0
- package/src/harnesses/codex.js +66 -0
- package/src/harnesses/opencode.js +157 -0
- package/src/session.js +53 -0
- package/src/state.js +91 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 PennyRouter
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# PennyRouter CLI
|
|
2
|
+
|
|
3
|
+
Local installer for PennyRouter coding-agent integrations.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
npx pennyrouter install
|
|
7
|
+
npx pennyrouter install --harness opencode,codex
|
|
8
|
+
npx pennyrouter uninstall
|
|
9
|
+
npx pennyrouter status
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
The CLI owns local config changes, backups, browser authorization, and uninstall metadata.
|
|
13
|
+
The machine-readable pages at `https://pennyrouter.com/install` and
|
|
14
|
+
`https://pennyrouter.com/uninstall` should tell coding agents to invoke this CLI instead of
|
|
15
|
+
editing config files directly.
|
package/package.json
ADDED
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "pennyrouter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Install and manage PennyRouter local coding-agent integrations.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"bin": {
|
|
7
|
+
"pennyrouter": "bin/pennyrouter.js"
|
|
8
|
+
},
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"bin",
|
|
14
|
+
"src",
|
|
15
|
+
"README.md"
|
|
16
|
+
],
|
|
17
|
+
"scripts": {
|
|
18
|
+
"start": "node ./bin/pennyrouter.js",
|
|
19
|
+
"check": "node --check ./bin/pennyrouter.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check"
|
|
20
|
+
},
|
|
21
|
+
"license": "MIT"
|
|
22
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,304 @@
|
|
|
1
|
+
import { spawnSync } from "node:child_process";
|
|
2
|
+
import { createInterface } from "node:readline/promises";
|
|
3
|
+
import { stdin as input, stdout as output } from "node:process";
|
|
4
|
+
import { codexHarness } from "./harnesses/codex.js";
|
|
5
|
+
import { opencodeHarness } from "./harnesses/opencode.js";
|
|
6
|
+
import {
|
|
7
|
+
APP_URL,
|
|
8
|
+
createCliSession,
|
|
9
|
+
maskKey,
|
|
10
|
+
pollForKey,
|
|
11
|
+
sleep,
|
|
12
|
+
} from "./session.js";
|
|
13
|
+
import {
|
|
14
|
+
expandHome,
|
|
15
|
+
loadManifest,
|
|
16
|
+
readJsonFile,
|
|
17
|
+
saveManifest,
|
|
18
|
+
statePath,
|
|
19
|
+
} from "./state.js";
|
|
20
|
+
|
|
21
|
+
const HARNESS_BY_ID = {
|
|
22
|
+
opencode: opencodeHarness,
|
|
23
|
+
codex: codexHarness,
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const PLANNED_HARNESSES = [
|
|
27
|
+
{
|
|
28
|
+
id: "claude-code",
|
|
29
|
+
name: "Claude Code",
|
|
30
|
+
reason: "Claude Code support is planned; this preview CLI does not mutate Claude settings yet.",
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
id: "cline",
|
|
34
|
+
name: "Cline",
|
|
35
|
+
reason: "Cline support is planned; this preview CLI does not mutate VS Code settings yet.",
|
|
36
|
+
},
|
|
37
|
+
];
|
|
38
|
+
|
|
39
|
+
export async function main(argv = process.argv.slice(2)) {
|
|
40
|
+
const { command, flags } = parseArgs(argv);
|
|
41
|
+
|
|
42
|
+
if (flags.help || command === "help") {
|
|
43
|
+
printHelp();
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
if (command === "install") {
|
|
48
|
+
await install(flags);
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (command === "uninstall") {
|
|
53
|
+
await uninstall(flags);
|
|
54
|
+
return;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
if (command === "status") {
|
|
58
|
+
await status();
|
|
59
|
+
return;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
throw new Error(`Unknown command "${command}". Run "pennyrouter help".`);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseArgs(argv) {
|
|
66
|
+
const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : "install";
|
|
67
|
+
const rest = command === argv[0] ? argv.slice(1) : argv;
|
|
68
|
+
const flags = {};
|
|
69
|
+
|
|
70
|
+
for (let i = 0; i < rest.length; i += 1) {
|
|
71
|
+
const arg = rest[i];
|
|
72
|
+
if (arg === "--help" || arg === "-h") flags.help = true;
|
|
73
|
+
else if (arg === "--yes" || arg === "-y") flags.yes = true;
|
|
74
|
+
else if (arg === "--all") flags.all = true;
|
|
75
|
+
else if (arg === "--dry-run") flags.dryRun = true;
|
|
76
|
+
else if (arg === "--no-browser") flags.noBrowser = true;
|
|
77
|
+
else if (arg === "--harness") flags.harness = rest[++i] || "";
|
|
78
|
+
else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
|
|
79
|
+
else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
|
|
80
|
+
else if (arg.startsWith("--app-url=")) flags.appUrl = arg.slice("--app-url=".length);
|
|
81
|
+
else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { command, flags };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async function install(flags) {
|
|
88
|
+
const selected = await selectHarnesses(flags);
|
|
89
|
+
if (selected.length === 0) {
|
|
90
|
+
console.log("No supported harnesses selected.");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
console.log("PennyRouter install");
|
|
95
|
+
console.log("");
|
|
96
|
+
console.log("Selected tools:");
|
|
97
|
+
for (const harness of selected) console.log(` - ${harness.name}`);
|
|
98
|
+
console.log("");
|
|
99
|
+
|
|
100
|
+
if (flags.dryRun) {
|
|
101
|
+
for (const harness of selected) {
|
|
102
|
+
const plan = await harness.planInstall();
|
|
103
|
+
printPlan(harness, plan);
|
|
104
|
+
}
|
|
105
|
+
console.log("Dry run only. No files changed.");
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const session = await createCliSession({
|
|
110
|
+
appUrl: flags.appUrl || APP_URL,
|
|
111
|
+
harnesses: selected.map((harness) => harness.id),
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
console.log("Opening your browser to authorize this machine...");
|
|
115
|
+
console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
|
|
116
|
+
if (!flags.noBrowser) openBrowser(session.authorize_url);
|
|
117
|
+
console.log("");
|
|
118
|
+
console.log("Waiting for approval...");
|
|
119
|
+
|
|
120
|
+
const claim = await pollForKey(session);
|
|
121
|
+
console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
|
|
122
|
+
console.log("");
|
|
123
|
+
|
|
124
|
+
const manifest = await loadManifest();
|
|
125
|
+
const records = [];
|
|
126
|
+
for (const harness of selected) {
|
|
127
|
+
const plan = await harness.planInstall();
|
|
128
|
+
printPlan(harness, plan);
|
|
129
|
+
const record = await harness.install({
|
|
130
|
+
apiKey: claim.api_key,
|
|
131
|
+
gatewayBaseUrl: claim.gateway_base_url,
|
|
132
|
+
plan,
|
|
133
|
+
});
|
|
134
|
+
records.push(record);
|
|
135
|
+
upsertManifestRecord(manifest, record);
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
await saveManifest(manifest);
|
|
139
|
+
console.log("");
|
|
140
|
+
console.log("Installed PennyRouter.");
|
|
141
|
+
console.log(`Manifest: ${statePath("installations.json")}`);
|
|
142
|
+
for (const record of records) {
|
|
143
|
+
if (record.restart) console.log(`${record.harness}: ${record.restart}`);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
async function uninstall(flags) {
|
|
148
|
+
const manifest = await loadManifest();
|
|
149
|
+
const records = manifest.installations || [];
|
|
150
|
+
const selectedIds = resolveRequestedHarnessIds(flags, records.map((record) => record.harness));
|
|
151
|
+
const selectedRecords = records.filter((record) => selectedIds.includes(record.harness));
|
|
152
|
+
|
|
153
|
+
if (selectedRecords.length === 0) {
|
|
154
|
+
console.log("No matching PennyRouter installations found.");
|
|
155
|
+
return;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
console.log("PennyRouter uninstall");
|
|
159
|
+
console.log("");
|
|
160
|
+
for (const record of selectedRecords) {
|
|
161
|
+
console.log(` - ${record.harness}: ${record.config_path}`);
|
|
162
|
+
}
|
|
163
|
+
console.log("");
|
|
164
|
+
|
|
165
|
+
if (!flags.yes) {
|
|
166
|
+
const ok = await confirm("Remove these PennyRouter local integrations?");
|
|
167
|
+
if (!ok) return;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
const kept = [];
|
|
171
|
+
for (const record of records) {
|
|
172
|
+
if (!selectedIds.includes(record.harness)) {
|
|
173
|
+
kept.push(record);
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const harness = HARNESS_BY_ID[record.harness];
|
|
178
|
+
if (!harness) {
|
|
179
|
+
console.log(`Skipping ${record.harness}: this CLI no longer knows how to uninstall it.`);
|
|
180
|
+
kept.push(record);
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
await harness.uninstall(record);
|
|
185
|
+
console.log(`Removed ${record.harness}.`);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
manifest.installations = kept;
|
|
189
|
+
await saveManifest(manifest);
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async function status() {
|
|
193
|
+
const manifest = await loadManifest();
|
|
194
|
+
const records = manifest.installations || [];
|
|
195
|
+
console.log("PennyRouter local setup");
|
|
196
|
+
console.log("");
|
|
197
|
+
|
|
198
|
+
if (records.length === 0) {
|
|
199
|
+
console.log("No local PennyRouter integrations recorded.");
|
|
200
|
+
return;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
for (const record of records) {
|
|
204
|
+
console.log(`${record.harness}: installed`);
|
|
205
|
+
console.log(` config: ${record.config_path}`);
|
|
206
|
+
if (record.backup_path) console.log(` backup: ${record.backup_path}`);
|
|
207
|
+
if (record.installed_at) console.log(` installed: ${record.installed_at}`);
|
|
208
|
+
}
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
async function selectHarnesses(flags) {
|
|
212
|
+
const requested = flags.all ? Object.keys(HARNESS_BY_ID) : parseHarnessList(flags.harness);
|
|
213
|
+
const detected = [];
|
|
214
|
+
for (const harness of Object.values(HARNESS_BY_ID)) {
|
|
215
|
+
if (await harness.detect()) detected.push(harness);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
for (const planned of PLANNED_HARNESSES) {
|
|
219
|
+
if (requested.includes(planned.id)) console.log(`${planned.name}: ${planned.reason}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
if (requested.length > 0) {
|
|
223
|
+
return requested.map((id) => HARNESS_BY_ID[id]).filter(Boolean);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
if (detected.length === 0) {
|
|
227
|
+
console.log("No supported harness configs were detected.");
|
|
228
|
+
console.log("Use --harness opencode,codex to create config for specific tools.");
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (flags.yes || detected.length === 1) return detected;
|
|
233
|
+
|
|
234
|
+
console.log("Detected supported tools:");
|
|
235
|
+
detected.forEach((harness, index) => console.log(` ${index + 1}. ${harness.name}`));
|
|
236
|
+
console.log(" a. All detected");
|
|
237
|
+
console.log("");
|
|
238
|
+
|
|
239
|
+
const rl = createInterface({ input, output });
|
|
240
|
+
const answer = await rl.question("Install for which tools? Enter numbers, comma-separated, or 'a': ");
|
|
241
|
+
rl.close();
|
|
242
|
+
|
|
243
|
+
if (answer.trim().toLowerCase() === "a") return detected;
|
|
244
|
+
const indexes = answer
|
|
245
|
+
.split(",")
|
|
246
|
+
.map((part) => Number(part.trim()) - 1)
|
|
247
|
+
.filter((index) => Number.isInteger(index) && detected[index]);
|
|
248
|
+
return [...new Set(indexes)].map((index) => detected[index]);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function parseHarnessList(value = "") {
|
|
252
|
+
return value
|
|
253
|
+
.split(",")
|
|
254
|
+
.map((part) => part.trim())
|
|
255
|
+
.filter(Boolean);
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function resolveRequestedHarnessIds(flags, defaults) {
|
|
259
|
+
if (flags.all) return defaults;
|
|
260
|
+
const requested = parseHarnessList(flags.harness);
|
|
261
|
+
return requested.length > 0 ? requested : defaults;
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function upsertManifestRecord(manifest, record) {
|
|
265
|
+
manifest.installations ||= [];
|
|
266
|
+
manifest.installations = manifest.installations.filter((existing) => existing.harness !== record.harness);
|
|
267
|
+
manifest.installations.push(record);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
async function confirm(question) {
|
|
271
|
+
const rl = createInterface({ input, output });
|
|
272
|
+
const answer = await rl.question(`${question} [y/N] `);
|
|
273
|
+
rl.close();
|
|
274
|
+
return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function printPlan(harness, plan) {
|
|
278
|
+
console.log(`${harness.name}:`);
|
|
279
|
+
for (const line of plan.summary) console.log(` - ${line}`);
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function openBrowser(url) {
|
|
283
|
+
const platform = process.platform;
|
|
284
|
+
const command =
|
|
285
|
+
platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open";
|
|
286
|
+
const args = platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
287
|
+
spawnSync(command, args, { stdio: "ignore", detached: true });
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
function printHelp() {
|
|
291
|
+
console.log(`PennyRouter CLI
|
|
292
|
+
|
|
293
|
+
Usage:
|
|
294
|
+
pennyrouter install [--harness opencode,codex] [--all] [--yes] [--dry-run]
|
|
295
|
+
pennyrouter uninstall [--harness opencode,codex] [--all] [--yes]
|
|
296
|
+
pennyrouter status
|
|
297
|
+
|
|
298
|
+
Examples:
|
|
299
|
+
npx pennyrouter install
|
|
300
|
+
npx pennyrouter install --all
|
|
301
|
+
npx pennyrouter install --harness opencode,codex --yes
|
|
302
|
+
npx pennyrouter uninstall
|
|
303
|
+
`);
|
|
304
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { atomicWrite, backupFile, compactHome, exists, expandHome, readText } from "../state.js";
|
|
2
|
+
|
|
3
|
+
const CONFIG_PATH = "~/.codex/config.toml";
|
|
4
|
+
|
|
5
|
+
export const codexHarness = {
|
|
6
|
+
id: "codex",
|
|
7
|
+
name: "Codex",
|
|
8
|
+
|
|
9
|
+
async detect() {
|
|
10
|
+
return exists(CONFIG_PATH);
|
|
11
|
+
},
|
|
12
|
+
|
|
13
|
+
async planInstall() {
|
|
14
|
+
return {
|
|
15
|
+
configPath: expandHome(CONFIG_PATH),
|
|
16
|
+
summary: [
|
|
17
|
+
`write ${CONFIG_PATH}`,
|
|
18
|
+
"set OpenAI-compatible base URL to PennyRouter",
|
|
19
|
+
"store the PennyRouter API key without printing it",
|
|
20
|
+
],
|
|
21
|
+
};
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async install({ apiKey, gatewayBaseUrl, plan }) {
|
|
25
|
+
const current = await readText(CONFIG_PATH, "");
|
|
26
|
+
const backupPath = await backupFile(CONFIG_PATH, "codex");
|
|
27
|
+
const next = setTomlValue(
|
|
28
|
+
setTomlValue(current, "openai_base_url", `${gatewayBaseUrl}/v1`),
|
|
29
|
+
"openai_api_key",
|
|
30
|
+
apiKey,
|
|
31
|
+
);
|
|
32
|
+
|
|
33
|
+
await atomicWrite(CONFIG_PATH, next);
|
|
34
|
+
|
|
35
|
+
return {
|
|
36
|
+
harness: "codex",
|
|
37
|
+
config_path: compactHome(plan.configPath),
|
|
38
|
+
backup_path: backupPath ? compactHome(backupPath) : null,
|
|
39
|
+
installed_at: new Date().toISOString(),
|
|
40
|
+
restart: "restart Codex",
|
|
41
|
+
changes: {
|
|
42
|
+
keys: ["openai_base_url", "openai_api_key"],
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
},
|
|
46
|
+
|
|
47
|
+
async uninstall(record) {
|
|
48
|
+
const text = await readText(record.config_path, "");
|
|
49
|
+
const next = text
|
|
50
|
+
.split("\n")
|
|
51
|
+
.filter((line) => !/^\s*openai_base_url\s*=/.test(line))
|
|
52
|
+
.filter((line) => !/^\s*openai_api_key\s*=/.test(line))
|
|
53
|
+
.join("\n")
|
|
54
|
+
.replace(/\n{3,}/g, "\n\n");
|
|
55
|
+
await atomicWrite(record.config_path, next.endsWith("\n") ? next : `${next}\n`);
|
|
56
|
+
},
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
function setTomlValue(text, key, value) {
|
|
60
|
+
const escaped = value.replace(/\\/g, "\\\\").replace(/"/g, '\\"');
|
|
61
|
+
const line = `${key} = "${escaped}"`;
|
|
62
|
+
const pattern = new RegExp(`^\\s*${key}\\s*=.*$`, "m");
|
|
63
|
+
if (pattern.test(text)) return text.replace(pattern, line);
|
|
64
|
+
const normalized = text.trimEnd();
|
|
65
|
+
return `${normalized}${normalized ? "\n" : ""}${line}\n`;
|
|
66
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import {
|
|
2
|
+
atomicWrite,
|
|
3
|
+
backupFile,
|
|
4
|
+
compactHome,
|
|
5
|
+
exists,
|
|
6
|
+
expandHome,
|
|
7
|
+
readJsonFile,
|
|
8
|
+
readText,
|
|
9
|
+
writeJsonFile,
|
|
10
|
+
} from "../state.js";
|
|
11
|
+
|
|
12
|
+
const JSONC_PATH = "~/.config/opencode/opencode.jsonc";
|
|
13
|
+
const JSON_PATH = "~/.config/opencode/opencode.json";
|
|
14
|
+
const MODEL_STATE_PATH = "~/.local/state/opencode/model.json";
|
|
15
|
+
|
|
16
|
+
export const opencodeHarness = {
|
|
17
|
+
id: "opencode",
|
|
18
|
+
name: "opencode",
|
|
19
|
+
|
|
20
|
+
async detect() {
|
|
21
|
+
return (await exists(JSONC_PATH)) || (await exists(JSON_PATH));
|
|
22
|
+
},
|
|
23
|
+
|
|
24
|
+
async planInstall() {
|
|
25
|
+
const configPath = (await exists(JSONC_PATH)) ? JSONC_PATH : JSON_PATH;
|
|
26
|
+
return {
|
|
27
|
+
configPath: expandHome(configPath),
|
|
28
|
+
summary: [
|
|
29
|
+
`write ${configPath}`,
|
|
30
|
+
"merge PennyRouter provider without replacing existing providers",
|
|
31
|
+
`reset ${MODEL_STATE_PATH} so opencode rediscovers configured models`,
|
|
32
|
+
],
|
|
33
|
+
};
|
|
34
|
+
},
|
|
35
|
+
|
|
36
|
+
async install({ apiKey, gatewayBaseUrl, plan }) {
|
|
37
|
+
const configPath = compactHome(plan.configPath);
|
|
38
|
+
const backupPath = await backupFile(configPath, "opencode");
|
|
39
|
+
const current = await readConfig(configPath);
|
|
40
|
+
current.provider ||= {};
|
|
41
|
+
current.provider.pennyrouter = {
|
|
42
|
+
npm: "@ai-sdk/openai-compatible",
|
|
43
|
+
name: "PennyRouter",
|
|
44
|
+
options: {
|
|
45
|
+
baseURL: `${gatewayBaseUrl}/v1`,
|
|
46
|
+
apiKey,
|
|
47
|
+
},
|
|
48
|
+
models: {
|
|
49
|
+
"pennyrouter/auto": { name: "PennyRouter Auto" },
|
|
50
|
+
"pennyrouter/anthropic": { name: "PennyRouter Anthropic" },
|
|
51
|
+
"pennyrouter/openai": { name: "PennyRouter OpenAI" },
|
|
52
|
+
"pennyrouter/gemini": { name: "PennyRouter Gemini" },
|
|
53
|
+
"pennyrouter/grok": { name: "PennyRouter Grok" },
|
|
54
|
+
"pennyrouter/deepseek": { name: "PennyRouter DeepSeek" },
|
|
55
|
+
"pennyrouter/zai": { name: "PennyRouter Z.ai" },
|
|
56
|
+
"pennyrouter/qwen": { name: "PennyRouter Qwen" },
|
|
57
|
+
"pennyrouter/penny": { name: "PennyRouter Penny" },
|
|
58
|
+
},
|
|
59
|
+
};
|
|
60
|
+
|
|
61
|
+
await atomicWrite(configPath, `${JSON.stringify(current, null, 2)}\n`);
|
|
62
|
+
|
|
63
|
+
const modelBackupPath = await backupFile(MODEL_STATE_PATH, "opencode-model");
|
|
64
|
+
await writeJsonFile(MODEL_STATE_PATH, { recent: [], favorite: [], variant: {} });
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
harness: "opencode",
|
|
68
|
+
config_path: compactHome(plan.configPath),
|
|
69
|
+
backup_path: backupPath ? compactHome(backupPath) : null,
|
|
70
|
+
installed_at: new Date().toISOString(),
|
|
71
|
+
restart: "type /exit, run opencode again, then pick PennyRouter Auto",
|
|
72
|
+
changes: {
|
|
73
|
+
provider_id: "pennyrouter",
|
|
74
|
+
model_state_path: MODEL_STATE_PATH,
|
|
75
|
+
model_state_backup_path: modelBackupPath ? compactHome(modelBackupPath) : null,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
|
|
80
|
+
async uninstall(record) {
|
|
81
|
+
const current = await readConfig(record.config_path);
|
|
82
|
+
if (current.provider?.pennyrouter) delete current.provider.pennyrouter;
|
|
83
|
+
if (current.provider && Object.keys(current.provider).length === 0) delete current.provider;
|
|
84
|
+
await atomicWrite(record.config_path, `${JSON.stringify(current, null, 2)}\n`);
|
|
85
|
+
|
|
86
|
+
const model = (await readJsonFile(MODEL_STATE_PATH, null)) || {};
|
|
87
|
+
model.recent = removePennyRouterModels(model.recent);
|
|
88
|
+
model.favorite = removePennyRouterModels(model.favorite);
|
|
89
|
+
if (model.variant && typeof model.variant === "object") {
|
|
90
|
+
for (const [key, value] of Object.entries(model.variant)) {
|
|
91
|
+
if (isPennyRouterModel(key) || isPennyRouterModel(value)) delete model.variant[key];
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
await writeJsonFile(MODEL_STATE_PATH, model);
|
|
95
|
+
},
|
|
96
|
+
};
|
|
97
|
+
|
|
98
|
+
async function readConfig(path) {
|
|
99
|
+
const text = await readText(path, "");
|
|
100
|
+
if (!text.trim()) return {};
|
|
101
|
+
return JSON.parse(stripJsonComments(text));
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function stripJsonComments(text) {
|
|
105
|
+
let result = "";
|
|
106
|
+
let inString = false;
|
|
107
|
+
let quote = "";
|
|
108
|
+
let escaped = false;
|
|
109
|
+
|
|
110
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
111
|
+
const char = text[i];
|
|
112
|
+
const next = text[i + 1];
|
|
113
|
+
|
|
114
|
+
if (inString) {
|
|
115
|
+
result += char;
|
|
116
|
+
if (escaped) escaped = false;
|
|
117
|
+
else if (char === "\\") escaped = true;
|
|
118
|
+
else if (char === quote) inString = false;
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (char === '"' || char === "'") {
|
|
123
|
+
inString = true;
|
|
124
|
+
quote = char;
|
|
125
|
+
result += char;
|
|
126
|
+
continue;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (char === "/" && next === "/") {
|
|
130
|
+
while (i < text.length && text[i] !== "\n") i += 1;
|
|
131
|
+
result += "\n";
|
|
132
|
+
continue;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
if (char === "/" && next === "*") {
|
|
136
|
+
i += 2;
|
|
137
|
+
while (i < text.length && !(text[i] === "*" && text[i + 1] === "/")) i += 1;
|
|
138
|
+
i += 1;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
result += char;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
return result;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function removePennyRouterModels(value) {
|
|
149
|
+
if (!Array.isArray(value)) return Array.isArray(value) ? [] : value;
|
|
150
|
+
return value.filter((item) => !isPennyRouterModel(item));
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
function isPennyRouterModel(value) {
|
|
154
|
+
if (typeof value === "string") return value.includes("pennyrouter");
|
|
155
|
+
if (value && typeof value === "object") return JSON.stringify(value).includes("pennyrouter");
|
|
156
|
+
return false;
|
|
157
|
+
}
|
package/src/session.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export const APP_URL = process.env.PENNYROUTER_APP_URL || "https://pennyrouter.com";
|
|
2
|
+
|
|
3
|
+
export function maskKey(key) {
|
|
4
|
+
if (!key || key.length < 8) return "pr-...";
|
|
5
|
+
return `${key.slice(0, 3)}...${key.slice(-4)}`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export async function createCliSession({ appUrl = APP_URL, harnesses }) {
|
|
9
|
+
const label = `${platformName()} / ${harnesses.join(",") || "PennyRouter"}`;
|
|
10
|
+
const response = await fetch(`${appUrl}/api/cli/session`, {
|
|
11
|
+
method: "POST",
|
|
12
|
+
headers: { "content-type": "application/json" },
|
|
13
|
+
body: JSON.stringify({
|
|
14
|
+
harness: harnesses.join(","),
|
|
15
|
+
label,
|
|
16
|
+
}),
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
if (!response.ok) {
|
|
20
|
+
throw new Error(`Failed to create setup session: HTTP ${response.status}`);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
return response.json();
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function pollForKey(session) {
|
|
27
|
+
const interval = session.poll_interval_ms || 2000;
|
|
28
|
+
const deadline = Date.now() + (session.expires_in_s || 900) * 1000;
|
|
29
|
+
|
|
30
|
+
while (Date.now() < deadline) {
|
|
31
|
+
const response = await fetch(session.claim_url);
|
|
32
|
+
if (!response.ok) throw new Error(`Claim failed: HTTP ${response.status}`);
|
|
33
|
+
const body = await response.json();
|
|
34
|
+
|
|
35
|
+
if (body.status === "ready" && body.api_key) return body;
|
|
36
|
+
if (body.status === "expired") throw new Error("Setup session expired. Run install again.");
|
|
37
|
+
if (body.status === "claimed") throw new Error("Setup session was already claimed. Run install again.");
|
|
38
|
+
await sleep(interval);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
throw new Error("Timed out waiting for authorization.");
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function sleep(ms) {
|
|
45
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function platformName() {
|
|
49
|
+
if (process.platform === "darwin") return "Mac";
|
|
50
|
+
if (process.platform === "win32") return "Windows";
|
|
51
|
+
if (process.platform === "linux") return "Linux";
|
|
52
|
+
return "Machine";
|
|
53
|
+
}
|
package/src/state.js
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { constants } from "node:fs";
|
|
2
|
+
import { access, copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
3
|
+
import { dirname, join } from "node:path";
|
|
4
|
+
|
|
5
|
+
export function homeDir() {
|
|
6
|
+
return process.env.HOME || process.env.USERPROFILE || "";
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function expandHome(path) {
|
|
10
|
+
if (path === "~") return homeDir();
|
|
11
|
+
if (path.startsWith("~/")) return join(homeDir(), path.slice(2));
|
|
12
|
+
return path;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function compactHome(path) {
|
|
16
|
+
const home = homeDir();
|
|
17
|
+
return home && path.startsWith(home) ? `~${path.slice(home.length)}` : path;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function statePath(...parts) {
|
|
21
|
+
const base =
|
|
22
|
+
process.env.XDG_STATE_HOME ||
|
|
23
|
+
(process.platform === "darwin"
|
|
24
|
+
? join(homeDir(), ".local", "state")
|
|
25
|
+
: join(homeDir(), ".local", "state"));
|
|
26
|
+
return join(base, "pennyrouter", ...parts);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export async function exists(path) {
|
|
30
|
+
try {
|
|
31
|
+
await access(expandHome(path), constants.F_OK);
|
|
32
|
+
return true;
|
|
33
|
+
} catch {
|
|
34
|
+
return false;
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export async function readText(path, fallback = "") {
|
|
39
|
+
try {
|
|
40
|
+
return await readFile(expandHome(path), "utf8");
|
|
41
|
+
} catch (error) {
|
|
42
|
+
if (error.code === "ENOENT") return fallback;
|
|
43
|
+
throw error;
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export async function writeText(path, text) {
|
|
48
|
+
const expanded = expandHome(path);
|
|
49
|
+
await mkdir(dirname(expanded), { recursive: true });
|
|
50
|
+
await writeFile(expanded, text, "utf8");
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
export async function readJsonFile(path, fallback = null) {
|
|
54
|
+
const text = await readText(path, "");
|
|
55
|
+
if (!text.trim()) return fallback;
|
|
56
|
+
return JSON.parse(text);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function writeJsonFile(path, value) {
|
|
60
|
+
await writeText(path, `${JSON.stringify(value, null, 2)}\n`);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function loadManifest() {
|
|
64
|
+
return (await readJsonFile(statePath("installations.json"), null)) || {
|
|
65
|
+
version: 1,
|
|
66
|
+
installations: [],
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function saveManifest(manifest) {
|
|
71
|
+
await writeJsonFile(statePath("installations.json"), manifest);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export async function backupFile(path, harness) {
|
|
75
|
+
const expanded = expandHome(path);
|
|
76
|
+
if (!(await exists(expanded))) return null;
|
|
77
|
+
const stamp = new Date().toISOString().replace(/[-:]/g, "").replace(/\..+/, "Z");
|
|
78
|
+
const ext = expanded.split(".").pop();
|
|
79
|
+
const backupPath = statePath("backups", `${harness}-${stamp}.${ext || "bak"}`);
|
|
80
|
+
await mkdir(dirname(backupPath), { recursive: true });
|
|
81
|
+
await copyFile(expanded, backupPath);
|
|
82
|
+
return backupPath;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export async function atomicWrite(path, text) {
|
|
86
|
+
const expanded = expandHome(path);
|
|
87
|
+
await mkdir(dirname(expanded), { recursive: true });
|
|
88
|
+
const temp = `${expanded}.pennyrouter-tmp`;
|
|
89
|
+
await writeFile(temp, text, "utf8");
|
|
90
|
+
await rename(temp, expanded);
|
|
91
|
+
}
|