pennyrouter 0.3.1 → 0.3.3
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/package.json +2 -2
- package/src/cli.js +120 -44
- package/src/cli.test.js +76 -1
- package/src/install-manifest.json +128 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pennyrouter",
|
|
3
|
-
"version": "0.3.
|
|
3
|
+
"version": "0.3.3",
|
|
4
4
|
"description": "Install and manage PennyRouter local coding-agent integrations.",
|
|
5
5
|
"homepage": "https://pennyrouter.com",
|
|
6
6
|
"bugs": {
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
],
|
|
25
25
|
"scripts": {
|
|
26
26
|
"start": "node ./bin/pennyrouter.js",
|
|
27
|
-
"check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/harnesses/opencode.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
|
|
27
|
+
"check": "node --check ./bin/pennyrouter.js && node --check ./bin/penny.js && find src -name '*.js' -print0 | xargs -0 -n1 node --check && node --test src/cli.test.js && node src/session.test.js && node src/statusline.test.js && node src/harnesses/claude-code.test.js && node src/harnesses/codex.test.js && node src/harnesses/opencode.test.js && node src/launch.test.js && node src/thread-session.test.js && node src/thread-session-codex.test.js && node src/mcp-register.test.js && node src/mcp.test.js"
|
|
28
28
|
},
|
|
29
29
|
"license": "MIT"
|
|
30
30
|
}
|
package/src/cli.js
CHANGED
|
@@ -67,20 +67,30 @@ const HARNESS_BY_ID = {
|
|
|
67
67
|
aider: aiderHarness,
|
|
68
68
|
};
|
|
69
69
|
|
|
70
|
-
const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
70
|
+
export const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
|
|
71
|
+
|
|
72
|
+
// The GUI installer (apps/setup/swift) asks the install questions itself and hands the answers
|
|
73
|
+
// back through --config, so it needs to know the same harness ids and funding options this file
|
|
74
|
+
// branches on. It fetches this manifest from the published package over a CDN rather than
|
|
75
|
+
// duplicating the lists in Swift. Reading it here too is what keeps the two honest: there is one
|
|
76
|
+
// definition, and the code that acts on it is the code that reads it.
|
|
77
|
+
export const INSTALL_MANIFEST = JSON.parse(
|
|
78
|
+
readFileSync(new URL("./install-manifest.json", import.meta.url), "utf8"),
|
|
79
|
+
);
|
|
80
|
+
|
|
81
|
+
const DEFAULT_INSTALL_HARNESS_IDS = INSTALL_MANIFEST.default_harness_ids;
|
|
82
|
+
const CLAUDE_PROVIDER_OPTIONS = INSTALL_MANIFEST.funding_groups.claude.options
|
|
83
|
+
.map(({ id, name }) => ({ id, name }));
|
|
84
|
+
const CODEX_PROVIDER_OPTIONS = INSTALL_MANIFEST.funding_groups.codex.options
|
|
85
|
+
.map(({ id, name }) => ({ id, name }));
|
|
86
|
+
|
|
87
|
+
/** Harnesses the manifest gives a `penny <name>` launcher, keyed by id. */
|
|
88
|
+
const PENNY_COMMAND_BY_HARNESS = Object.fromEntries(
|
|
89
|
+
INSTALL_MANIFEST.harnesses
|
|
90
|
+
.filter((harness) => harness.penny_command)
|
|
91
|
+
.map((harness) => [harness.id, harness.penny_command]),
|
|
92
|
+
);
|
|
93
|
+
const LAUNCHABLE_HARNESS_IDS = Object.keys(PENNY_COMMAND_BY_HARNESS);
|
|
84
94
|
|
|
85
95
|
export async function main(argv = process.argv.slice(2)) {
|
|
86
96
|
const { command, flags } = parseArgs(argv);
|
|
@@ -225,7 +235,7 @@ function parseArgs(argv) {
|
|
|
225
235
|
// non-interactive paths unchanged. CLI flags win over the config file where both are given.
|
|
226
236
|
// Funding choices imply --yes (skip remaining prompts) but, unlike bare --yes, do NOT suppress
|
|
227
237
|
// the Claude subscription browser sign-in — see maybeConfigureClaudeAnthropicAuth.
|
|
228
|
-
function applyConfigFile(flags) {
|
|
238
|
+
export function applyConfigFile(flags) {
|
|
229
239
|
let config;
|
|
230
240
|
try {
|
|
231
241
|
config = JSON.parse(readFileSync(flags.configPath, "utf8"));
|
|
@@ -241,20 +251,27 @@ function applyConfigFile(flags) {
|
|
|
241
251
|
if (claudeFunding.has("subscription") && flags.anthropicAuth === undefined) {
|
|
242
252
|
flags.anthropicAuth = true;
|
|
243
253
|
}
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
254
|
+
// Every funding option the manifest describes carries the fields it needs, and their
|
|
255
|
+
// config_key is the flags field they populate. Copying them generically means a new option
|
|
256
|
+
// works here the moment it is added to the manifest, instead of needing a branch per option.
|
|
257
|
+
for (const [group, chosen] of [["claude", claudeFunding], ["codex", codexFunding]]) {
|
|
258
|
+
for (const option of INSTALL_MANIFEST.funding_groups[group].options) {
|
|
259
|
+
if (!chosen.has(option.id)) continue;
|
|
260
|
+
for (const field of option.fields) {
|
|
261
|
+
if (flags[field.config_key] === undefined) {
|
|
262
|
+
flags[field.config_key] = config[field.config_key] || "";
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
}
|
|
253
266
|
}
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
267
|
+
|
|
268
|
+
// A private gateway is independent of funding: it changes where the CLI claims its key and
|
|
269
|
+
// stores credentials, so it applies whatever providers were picked.
|
|
270
|
+
for (const field of INSTALL_MANIFEST.advanced.fields) {
|
|
271
|
+
const value = String(config[field.config_key] || "").trim();
|
|
272
|
+
if (value && flags[field.config_key] === undefined) flags[field.config_key] = value;
|
|
257
273
|
}
|
|
274
|
+
if (flags.gatewayBaseUrl) flags.privateGateway = true;
|
|
258
275
|
|
|
259
276
|
flags.yes = true;
|
|
260
277
|
flags.fromConfig = true;
|
|
@@ -535,9 +552,8 @@ export async function install(flags = {}) {
|
|
|
535
552
|
if (flags.dryRun) {
|
|
536
553
|
const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
|
|
537
554
|
for (const harness of selected) {
|
|
538
|
-
const
|
|
539
|
-
const
|
|
540
|
-
? "penny claude" : harness.id === "codex" ? "penny codex" : "penny opencode";
|
|
555
|
+
const command = PENNY_COMMAND_BY_HARNESS[harness.id];
|
|
556
|
+
const explicit = Boolean(command);
|
|
541
557
|
console.log(`Will install on ${harness.name}${explicit ? ` via ${command}` : installNoteForHarness(harness.id)}.`);
|
|
542
558
|
if (defaultRouting.get(harness.id)) {
|
|
543
559
|
console.log(`Will also route bare ${harness.id === "claude-code" ? "claude" : harness.id} through PennyRouter.`);
|
|
@@ -607,6 +623,16 @@ export async function install(flags = {}) {
|
|
|
607
623
|
funding = await configureUserFunding(
|
|
608
624
|
flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
|
|
609
625
|
gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
|
|
626
|
+
if (flags.gatewayBaseUrl) {
|
|
627
|
+
const normalized = normalizeGatewayBaseUrl(flags.gatewayBaseUrl);
|
|
628
|
+
if (!normalized) {
|
|
629
|
+
throw new Error(
|
|
630
|
+
`--gateway-base-url "${flags.gatewayBaseUrl}" is not a usable URL. Use a host like `
|
|
631
|
+
+ "localhost:8400 or https://gateway.example.com.",
|
|
632
|
+
);
|
|
633
|
+
}
|
|
634
|
+
gatewayBaseUrl = normalized;
|
|
635
|
+
}
|
|
610
636
|
|
|
611
637
|
// A user-supplied proxy may live on a private network that the hosted gateway cannot
|
|
612
638
|
// reach. Let interactive installs choose the gateway after they choose the proxy: blank
|
|
@@ -615,10 +641,20 @@ export async function install(flags = {}) {
|
|
|
615
641
|
// explicit localhost:8400 developer shortcut and skips this prompt.
|
|
616
642
|
if ((claudeProviders.has("anthropic-proxy") || codexProviders.has("openai-proxy")) && !flags.gatewayBaseUrl
|
|
617
643
|
&& !flags.yes && input.isTTY && output.isTTY) {
|
|
618
|
-
const
|
|
644
|
+
const answer = (await promptText(
|
|
619
645
|
"PennyRouter gateway URL for this proxy (blank for hosted gateway): ",
|
|
620
646
|
)).trim();
|
|
621
|
-
if (
|
|
647
|
+
if (answer) {
|
|
648
|
+
const privateGateway = normalizeGatewayBaseUrl(answer);
|
|
649
|
+
if (!privateGateway) {
|
|
650
|
+
throw new Error(
|
|
651
|
+
`"${answer}" is not a usable gateway URL. Use a host like `
|
|
652
|
+
+ "localhost:8400 or https://gateway.example.com.",
|
|
653
|
+
);
|
|
654
|
+
}
|
|
655
|
+
// Show the normalized form: the user typed "localhost:8000" and everything from here on
|
|
656
|
+
// uses "http://localhost:8000", so any later error names a URL they recognize.
|
|
657
|
+
if (privateGateway !== answer) console.log(`Using gateway ${privateGateway}.`);
|
|
622
658
|
gatewayBaseUrl = privateGateway;
|
|
623
659
|
flags.privateGateway = true;
|
|
624
660
|
}
|
|
@@ -633,7 +669,8 @@ export async function install(flags = {}) {
|
|
|
633
669
|
console.log("");
|
|
634
670
|
console.log(`Private gateway: ${gatewayBaseUrl}`);
|
|
635
671
|
console.log("Your PennyRouter administrator issues keys for this gateway.");
|
|
636
|
-
|
|
672
|
+
// No echo: this is an issued credential, and a visible paste lingers in scrollback.
|
|
673
|
+
privateKey = (await promptSecretish("Paste your private gateway key (pr-...): ")).trim();
|
|
637
674
|
}
|
|
638
675
|
if (!privateKey) {
|
|
639
676
|
throw new Error(
|
|
@@ -650,19 +687,20 @@ export async function install(flags = {}) {
|
|
|
650
687
|
}
|
|
651
688
|
|
|
652
689
|
for (const credential of funding.credentials) {
|
|
653
|
-
await storeUpstreamCredential({
|
|
690
|
+
await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
|
|
654
691
|
gatewayBaseUrl,
|
|
655
692
|
apiKey: claim.api_key,
|
|
656
693
|
provider: credential.provider,
|
|
657
694
|
token: credential.token,
|
|
658
695
|
metadata: credential.metadata,
|
|
659
|
-
});
|
|
696
|
+
}));
|
|
660
697
|
}
|
|
661
698
|
}
|
|
662
699
|
|
|
663
700
|
if (needsAuth.some((harness) => harness.id === "claude-code")) {
|
|
664
701
|
console.log("Verifying modern Claude Code compatibility before changing local settings...");
|
|
665
|
-
await
|
|
702
|
+
await withGatewayContext(
|
|
703
|
+
gatewayBaseUrl, () => probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key }));
|
|
666
704
|
console.log("");
|
|
667
705
|
}
|
|
668
706
|
|
|
@@ -671,7 +709,7 @@ export async function install(flags = {}) {
|
|
|
671
709
|
// choose the visible command behavior with all relevant context in front of them.
|
|
672
710
|
const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
|
|
673
711
|
|
|
674
|
-
const launchable = selected.filter((harness) =>
|
|
712
|
+
const launchable = selected.filter((harness) => LAUNCHABLE_HARNESS_IDS.includes(harness.id));
|
|
675
713
|
let runtimeResult = null;
|
|
676
714
|
if (launchable.length > 0) {
|
|
677
715
|
runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
@@ -685,7 +723,7 @@ export async function install(flags = {}) {
|
|
|
685
723
|
const existingRecord = (manifest.installations || []).find(
|
|
686
724
|
(candidate) => candidate.harness === harness.id,
|
|
687
725
|
);
|
|
688
|
-
const supportsPennyCommand =
|
|
726
|
+
const supportsPennyCommand = LAUNCHABLE_HARNESS_IDS.includes(harness.id);
|
|
689
727
|
const makeDefault = defaultRouting.get(harness.id) ?? true;
|
|
690
728
|
const hadGlobalRouting = Boolean(existingRecord && existingRecord.default_routing !== false);
|
|
691
729
|
|
|
@@ -771,8 +809,7 @@ export async function install(flags = {}) {
|
|
|
771
809
|
for (const record of records) {
|
|
772
810
|
const harness = HARNESS_BY_ID[record.harness];
|
|
773
811
|
if (record.launch_available) {
|
|
774
|
-
const command = record.harness
|
|
775
|
-
? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
|
|
812
|
+
const command = PENNY_COMMAND_BY_HARNESS[record.harness];
|
|
776
813
|
const native = record.harness === "claude-code" ? "claude"
|
|
777
814
|
: record.harness === "codex" ? "codex" : "opencode";
|
|
778
815
|
const harnessName = record.harness === "claude-code" ? "Claude Code"
|
|
@@ -844,6 +881,46 @@ function mcpTargets(flags) {
|
|
|
844
881
|
/** The gateway URL worth pinning into the MCP entry, or "" for the default.
|
|
845
882
|
The MCP server already falls back to production on its own, so writing that value would only
|
|
846
883
|
freeze today's default into every user's config. */
|
|
884
|
+
/* Users type gateways the way they say them out loud — "localhost:8000",
|
|
885
|
+
"gateway.example.com". Those are not absolute URLs, so fetch() rejects them with a bare
|
|
886
|
+
"fetch failed" long after the prompt, where the cause is invisible. Supply the obvious scheme
|
|
887
|
+
(plain http for loopback, https elsewhere) and prove the result parses, so a typo fails here
|
|
888
|
+
instead. Returns null when the value cannot be salvaged. */
|
|
889
|
+
function normalizeGatewayBaseUrl(raw) {
|
|
890
|
+
const value = String(raw || "").trim().replace(/\/+$/, "");
|
|
891
|
+
if (!value) return null;
|
|
892
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
|
|
893
|
+
? value
|
|
894
|
+
: `${/^(localhost|127\.0\.0\.1|\[::1\])(:|$)/i.test(value) ? "http" : "https"}://${value}`;
|
|
895
|
+
let parsed;
|
|
896
|
+
try {
|
|
897
|
+
parsed = new URL(withScheme);
|
|
898
|
+
} catch {
|
|
899
|
+
return null;
|
|
900
|
+
}
|
|
901
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
902
|
+
if (!parsed.hostname) return null;
|
|
903
|
+
return withScheme.replace(/\/+$/, "");
|
|
904
|
+
}
|
|
905
|
+
|
|
906
|
+
/* A gateway that is unreachable (wrong host, wrong port, not running) surfaces from fetch() as
|
|
907
|
+
an opaque "fetch failed" with no status. Name the gateway we could not reach so the user knows
|
|
908
|
+
which URL to fix. Real HTTP errors carry their own message and pass through untouched. */
|
|
909
|
+
async function withGatewayContext(gatewayBaseUrl, action) {
|
|
910
|
+
try {
|
|
911
|
+
return await action();
|
|
912
|
+
} catch (error) {
|
|
913
|
+
const message = String(error?.message || error);
|
|
914
|
+
if (/fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ECONNRESET|network/i.test(message)) {
|
|
915
|
+
throw new Error(
|
|
916
|
+
`Could not reach the PennyRouter gateway at ${gatewayBaseUrl} (${message}). `
|
|
917
|
+
+ "Check that the URL is correct and that the gateway is running.",
|
|
918
|
+
);
|
|
919
|
+
}
|
|
920
|
+
throw error;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
|
|
847
924
|
function customGatewayBaseUrl(url) {
|
|
848
925
|
const value = String(url || "").trim().replace(/\/+$/, "");
|
|
849
926
|
return !value || value === DEFAULT_GATEWAY_BASE_URL ? "" : value;
|
|
@@ -985,7 +1062,7 @@ async function update(flags) {
|
|
|
985
1062
|
|
|
986
1063
|
const failures = [];
|
|
987
1064
|
let runtimeResult = null;
|
|
988
|
-
if (targets.some((record) =>
|
|
1065
|
+
if (targets.some((record) => LAUNCHABLE_HARNESS_IDS.includes(record.harness))) {
|
|
989
1066
|
runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
990
1067
|
}
|
|
991
1068
|
|
|
@@ -994,7 +1071,7 @@ async function update(flags) {
|
|
|
994
1071
|
if (!harness) continue;
|
|
995
1072
|
|
|
996
1073
|
await runHarnessJob(failures, record.harness, "update", async () => {
|
|
997
|
-
const supportsPennyCommand =
|
|
1074
|
+
const supportsPennyCommand = LAUNCHABLE_HARNESS_IDS.includes(record.harness);
|
|
998
1075
|
// An update rewrites the harness's own config only when that config is ours to
|
|
999
1076
|
// rewrite — i.e. the bare command still routes through the gateway. In penny-only
|
|
1000
1077
|
// mode the harness config belongs to the user and we touch just the launch profile.
|
|
@@ -1678,8 +1755,7 @@ async function status() {
|
|
|
1678
1755
|
console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
|
|
1679
1756
|
}
|
|
1680
1757
|
if (record.launch_available) {
|
|
1681
|
-
const command = record.harness
|
|
1682
|
-
? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
|
|
1758
|
+
const command = PENNY_COMMAND_BY_HARNESS[record.harness];
|
|
1683
1759
|
console.log(` command: ${command}`);
|
|
1684
1760
|
console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
|
|
1685
1761
|
}
|
package/src/cli.test.js
CHANGED
|
@@ -3,7 +3,9 @@ import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
|
3
3
|
import { tmpdir } from "node:os";
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
applyConfigFile, harnessFundingChanged, INSTALL_MANIFEST, main, SUPPORTED_HARNESS_IDS,
|
|
8
|
+
} from "./cli.js";
|
|
7
9
|
|
|
8
10
|
// --config lets a GUI installer (the macOS DMG) answer install questions up front instead of
|
|
9
11
|
// over a TTY. These tests drive it through `main()` in --dry-run, which prints its plan and
|
|
@@ -194,3 +196,76 @@ test("auth anthropic --forget honors --local", async () => {
|
|
|
194
196
|
]));
|
|
195
197
|
assert.ok(lines.some((line) => line.includes("localhost:8400")));
|
|
196
198
|
});
|
|
199
|
+
|
|
200
|
+
// --- Manifest contract -------------------------------------------------------------------
|
|
201
|
+
// The GUI installer (apps/setup/swift) builds its whole question flow from install-manifest.json,
|
|
202
|
+
// fetched from the published package. These tests pin the parts Swift depends on and, more
|
|
203
|
+
// importantly, prove the manifest still describes what this file actually does — a funding option
|
|
204
|
+
// the GUI can offer but --config cannot apply is the exact drift the manifest exists to prevent.
|
|
205
|
+
|
|
206
|
+
test("every manifest funding field is applied by --config", () => {
|
|
207
|
+
// Asserts on applyConfigFile directly rather than through a --dry-run install: the dry run
|
|
208
|
+
// returns before configureUserFunding, so it cannot observe a field that was never copied.
|
|
209
|
+
for (const [group, configKey] of [["claude", "claudeFunding"], ["codex", "codexFunding"]]) {
|
|
210
|
+
for (const option of INSTALL_MANIFEST.funding_groups[group].options) {
|
|
211
|
+
if (option.fields.length === 0) continue;
|
|
212
|
+
const config = { harnesses: ["claude-code", "codex"], [configKey]: [option.id] };
|
|
213
|
+
for (const field of option.fields) config[field.config_key] = `value-${field.config_key}`;
|
|
214
|
+
withConfigFile(config, (path) => {
|
|
215
|
+
const flags = { configPath: path };
|
|
216
|
+
applyConfigFile(flags);
|
|
217
|
+
for (const field of option.fields) {
|
|
218
|
+
assert.equal(
|
|
219
|
+
flags[field.config_key], `value-${field.config_key}`,
|
|
220
|
+
`${group}/${option.id}: --config ignored ${field.config_key}`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
test("manifest harness ids are all installable", () => {
|
|
229
|
+
const supported = SUPPORTED_HARNESS_IDS.split(",");
|
|
230
|
+
for (const harness of INSTALL_MANIFEST.harnesses) {
|
|
231
|
+
assert.ok(supported.includes(harness.id), `${harness.id} is not a supported harness`);
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
test("manifest config paths match what each harness detects", () => {
|
|
236
|
+
// Swift decides which tools to pre-check by testing these paths itself, so a path that drifts
|
|
237
|
+
// from the harness module makes the GUI silently under-detect an installed tool.
|
|
238
|
+
const expected = {
|
|
239
|
+
"claude-code": ["~/.claude/settings.json"],
|
|
240
|
+
codex: ["~/.codex/config.toml"],
|
|
241
|
+
opencode: ["~/.config/opencode/opencode.jsonc", "~/.config/opencode/opencode.json"],
|
|
242
|
+
};
|
|
243
|
+
for (const harness of INSTALL_MANIFEST.harnesses) {
|
|
244
|
+
assert.deepEqual(harness.config_paths, expected[harness.id], `${harness.id} config paths`);
|
|
245
|
+
}
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
test("--config applies a private gateway and its key", async () => {
|
|
249
|
+
await withConfigFile(
|
|
250
|
+
{
|
|
251
|
+
harnesses: ["claude-code"],
|
|
252
|
+
claudeFunding: ["subscription"],
|
|
253
|
+
gatewayBaseUrl: "http://localhost:8400",
|
|
254
|
+
privateKey: "pr-test-private",
|
|
255
|
+
},
|
|
256
|
+
async (path) => {
|
|
257
|
+
const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
|
|
258
|
+
assert.ok(lines.some((line) => line.includes("localhost:8400")));
|
|
259
|
+
},
|
|
260
|
+
);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
test("--config leaves the hosted gateway alone when no private gateway is given", async () => {
|
|
264
|
+
await withConfigFile(
|
|
265
|
+
{ harnesses: ["claude-code"], claudeFunding: ["subscription"] },
|
|
266
|
+
async (path) => {
|
|
267
|
+
const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
|
|
268
|
+
assert.ok(!lines.some((line) => line.includes("localhost:8400")));
|
|
269
|
+
},
|
|
270
|
+
);
|
|
271
|
+
});
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"default_harness_ids": ["claude-code", "codex", "opencode"],
|
|
4
|
+
"harnesses": [
|
|
5
|
+
{
|
|
6
|
+
"id": "claude-code",
|
|
7
|
+
"name": "Claude Code",
|
|
8
|
+
"config_paths": ["~/.claude/settings.json"],
|
|
9
|
+
"penny_command": "penny claude",
|
|
10
|
+
"funding_group": "claude"
|
|
11
|
+
},
|
|
12
|
+
{
|
|
13
|
+
"id": "codex",
|
|
14
|
+
"name": "Codex",
|
|
15
|
+
"config_paths": ["~/.codex/config.toml"],
|
|
16
|
+
"penny_command": "penny codex",
|
|
17
|
+
"funding_group": "codex"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"id": "opencode",
|
|
21
|
+
"name": "OpenCode",
|
|
22
|
+
"config_paths": [
|
|
23
|
+
"~/.config/opencode/opencode.jsonc",
|
|
24
|
+
"~/.config/opencode/opencode.json"
|
|
25
|
+
],
|
|
26
|
+
"penny_command": "penny opencode",
|
|
27
|
+
"funding_group": null
|
|
28
|
+
}
|
|
29
|
+
],
|
|
30
|
+
"funding_groups": {
|
|
31
|
+
"claude": {
|
|
32
|
+
"config_key": "claudeFunding",
|
|
33
|
+
"title": "Claude Code funding",
|
|
34
|
+
"options": [
|
|
35
|
+
{
|
|
36
|
+
"id": "subscription",
|
|
37
|
+
"name": "Claude subscription",
|
|
38
|
+
"fields": []
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
"id": "anthropic-api",
|
|
42
|
+
"name": "Anthropic API key",
|
|
43
|
+
"fields": [
|
|
44
|
+
{ "config_key": "anthropicApiKey", "label": "Anthropic API key", "secret": true }
|
|
45
|
+
]
|
|
46
|
+
},
|
|
47
|
+
{
|
|
48
|
+
"id": "openrouter",
|
|
49
|
+
"name": "OpenRouter API key",
|
|
50
|
+
"fields": [
|
|
51
|
+
{ "config_key": "openrouterApiKey", "label": "OpenRouter API key", "secret": true }
|
|
52
|
+
]
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
"id": "anthropic-proxy",
|
|
56
|
+
"name": "Other proxy (custom base URL)",
|
|
57
|
+
"fields": [
|
|
58
|
+
{
|
|
59
|
+
"config_key": "proxyBaseUrl",
|
|
60
|
+
"label": "Proxy base URL",
|
|
61
|
+
"secret": false,
|
|
62
|
+
"placeholder": "https://proxy.example.com"
|
|
63
|
+
},
|
|
64
|
+
{ "config_key": "proxyApiKey", "label": "Proxy API key", "secret": true }
|
|
65
|
+
]
|
|
66
|
+
}
|
|
67
|
+
]
|
|
68
|
+
},
|
|
69
|
+
"codex": {
|
|
70
|
+
"config_key": "codexFunding",
|
|
71
|
+
"title": "Codex funding",
|
|
72
|
+
"options": [
|
|
73
|
+
{
|
|
74
|
+
"id": "subscription",
|
|
75
|
+
"name": "ChatGPT subscription",
|
|
76
|
+
"fields": []
|
|
77
|
+
},
|
|
78
|
+
{
|
|
79
|
+
"id": "openai-api",
|
|
80
|
+
"name": "OpenAI API key",
|
|
81
|
+
"fields": [
|
|
82
|
+
{ "config_key": "openaiApiKey", "label": "OpenAI API key", "secret": true }
|
|
83
|
+
]
|
|
84
|
+
},
|
|
85
|
+
{
|
|
86
|
+
"id": "openai-proxy",
|
|
87
|
+
"name": "Other OpenAI-compatible proxy",
|
|
88
|
+
"fields": [
|
|
89
|
+
{
|
|
90
|
+
"config_key": "openaiProxyBaseUrl",
|
|
91
|
+
"label": "OpenAI proxy base URL",
|
|
92
|
+
"secret": false,
|
|
93
|
+
"placeholder": "https://proxy.example.com/v1"
|
|
94
|
+
},
|
|
95
|
+
{ "config_key": "openaiProxyApiKey", "label": "OpenAI proxy API key", "secret": true }
|
|
96
|
+
]
|
|
97
|
+
},
|
|
98
|
+
{
|
|
99
|
+
"id": "openrouter",
|
|
100
|
+
"name": "OpenRouter API key",
|
|
101
|
+
"fields": [
|
|
102
|
+
{ "config_key": "openrouterApiKey", "label": "OpenRouter API key", "secret": true }
|
|
103
|
+
]
|
|
104
|
+
}
|
|
105
|
+
]
|
|
106
|
+
}
|
|
107
|
+
},
|
|
108
|
+
"advanced": {
|
|
109
|
+
"title": "Private gateway",
|
|
110
|
+
"description": "Route through your own PennyRouter gateway instead of the hosted one.",
|
|
111
|
+
"fields": [
|
|
112
|
+
{
|
|
113
|
+
"config_key": "gatewayBaseUrl",
|
|
114
|
+
"label": "Gateway base URL",
|
|
115
|
+
"secret": false,
|
|
116
|
+
"placeholder": "https://gateway.example.com",
|
|
117
|
+
"requires_https": true
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
"config_key": "privateKey",
|
|
121
|
+
"label": "Private gateway key",
|
|
122
|
+
"secret": true,
|
|
123
|
+
"placeholder": "pr-...",
|
|
124
|
+
"prefix": "pr-"
|
|
125
|
+
}
|
|
126
|
+
]
|
|
127
|
+
}
|
|
128
|
+
}
|