pennyrouter 0.3.2 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.2",
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
- const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex", "opencode"];
72
- const CLAUDE_PROVIDER_OPTIONS = [
73
- { id: "subscription", name: "Claude subscription" },
74
- { id: "anthropic-api", name: "Anthropic API key" },
75
- { id: "openrouter", name: "OpenRouter API key" },
76
- { id: "anthropic-proxy", name: "Other proxy (custom base URL)" },
77
- ];
78
- const CODEX_PROVIDER_OPTIONS = [
79
- { id: "subscription", name: "ChatGPT subscription" },
80
- { id: "openai-api", name: "OpenAI API key" },
81
- { id: "openai-proxy", name: "Other OpenAI-compatible proxy" },
82
- { id: "openrouter", name: "OpenRouter API key" },
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
- if (claudeFunding.has("anthropic-api") && flags.anthropicApiKey === undefined) {
245
- flags.anthropicApiKey = config.anthropicApiKey || "";
246
- }
247
- if ((claudeFunding.has("openrouter") || codexFunding.has("openrouter"))
248
- && flags.openrouterApiKey === undefined) {
249
- flags.openrouterApiKey = config.openrouterApiKey || "";
250
- }
251
- if (codexFunding.has("openai-api") && flags.openaiApiKey === undefined) {
252
- flags.openaiApiKey = config.openaiApiKey || "";
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
- if (claudeFunding.has("anthropic-proxy")) {
255
- if (flags.proxyBaseUrl === undefined) flags.proxyBaseUrl = config.proxyBaseUrl || "";
256
- if (flags.proxyApiKey === undefined) flags.proxyApiKey = config.proxyApiKey || "";
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 explicit = ["claude-code", "codex", "opencode"].includes(harness.id);
539
- const command = harness.id === "claude-code"
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.`);
@@ -693,7 +709,7 @@ export async function install(flags = {}) {
693
709
  // choose the visible command behavior with all relevant context in front of them.
694
710
  const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
695
711
 
696
- const launchable = selected.filter((harness) => ["claude-code", "codex", "opencode"].includes(harness.id));
712
+ const launchable = selected.filter((harness) => LAUNCHABLE_HARNESS_IDS.includes(harness.id));
697
713
  let runtimeResult = null;
698
714
  if (launchable.length > 0) {
699
715
  runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
@@ -707,7 +723,7 @@ export async function install(flags = {}) {
707
723
  const existingRecord = (manifest.installations || []).find(
708
724
  (candidate) => candidate.harness === harness.id,
709
725
  );
710
- const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(harness.id);
726
+ const supportsPennyCommand = LAUNCHABLE_HARNESS_IDS.includes(harness.id);
711
727
  const makeDefault = defaultRouting.get(harness.id) ?? true;
712
728
  const hadGlobalRouting = Boolean(existingRecord && existingRecord.default_routing !== false);
713
729
 
@@ -793,8 +809,7 @@ export async function install(flags = {}) {
793
809
  for (const record of records) {
794
810
  const harness = HARNESS_BY_ID[record.harness];
795
811
  if (record.launch_available) {
796
- const command = record.harness === "claude-code"
797
- ? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
812
+ const command = PENNY_COMMAND_BY_HARNESS[record.harness];
798
813
  const native = record.harness === "claude-code" ? "claude"
799
814
  : record.harness === "codex" ? "codex" : "opencode";
800
815
  const harnessName = record.harness === "claude-code" ? "Claude Code"
@@ -1047,7 +1062,7 @@ async function update(flags) {
1047
1062
 
1048
1063
  const failures = [];
1049
1064
  let runtimeResult = null;
1050
- if (targets.some((record) => ["claude-code", "codex", "opencode"].includes(record.harness))) {
1065
+ if (targets.some((record) => LAUNCHABLE_HARNESS_IDS.includes(record.harness))) {
1051
1066
  runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
1052
1067
  }
1053
1068
 
@@ -1056,7 +1071,7 @@ async function update(flags) {
1056
1071
  if (!harness) continue;
1057
1072
 
1058
1073
  await runHarnessJob(failures, record.harness, "update", async () => {
1059
- const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(record.harness);
1074
+ const supportsPennyCommand = LAUNCHABLE_HARNESS_IDS.includes(record.harness);
1060
1075
  // An update rewrites the harness's own config only when that config is ours to
1061
1076
  // rewrite — i.e. the bare command still routes through the gateway. In penny-only
1062
1077
  // mode the harness config belongs to the user and we touch just the launch profile.
@@ -1740,8 +1755,7 @@ async function status() {
1740
1755
  console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
1741
1756
  }
1742
1757
  if (record.launch_available) {
1743
- const command = record.harness === "claude-code"
1744
- ? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
1758
+ const command = PENNY_COMMAND_BY_HARNESS[record.harness];
1745
1759
  console.log(` command: ${command}`);
1746
1760
  console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
1747
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 { harnessFundingChanged, main } from "./cli.js";
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
+ }