pennyrouter 0.3.11 → 0.3.14

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.11",
3
+ "version": "0.3.14",
4
4
  "description": "Install and manage PennyRouter local coding-agent integrations.",
5
5
  "homepage": "https://pennyrouter.com",
6
6
  "bugs": {
package/src/cli.js CHANGED
@@ -56,6 +56,10 @@ import {
56
56
  removePennyRuntime,
57
57
  savePennyConfig,
58
58
  setPennyHarnessEnabled,
59
+ DEFAULT_PROFILE,
60
+ listPennyProfiles,
61
+ activePennyProfile,
62
+ resolvePennyProfile,
59
63
  } from "./launch.js";
60
64
 
61
65
  const HARNESS_BY_ID = {
@@ -137,6 +141,11 @@ export async function main(argv = process.argv.slice(2)) {
137
141
  return;
138
142
  }
139
143
 
144
+ if (command === "profiles" || command === "profile") {
145
+ await profiles(flags);
146
+ return;
147
+ }
148
+
140
149
  if (command === "mcp") {
141
150
  // Bare `mcp` is the server itself, started by a harness over stdio. The subcommands are for
142
151
  // a person: registering after --no-mcp, retrying after a name conflict, or checking state.
@@ -151,6 +160,34 @@ export async function main(argv = process.argv.slice(2)) {
151
160
  throw new Error(`Unknown command "${command}". Run "pennyrouter help".`);
152
161
  }
153
162
 
163
+ /**
164
+ * Show the stored credential sets, or make one active. The active profile is what
165
+ * `penny claude` uses when no --penny-profile / --private / --cloud flag is given.
166
+ */
167
+ async function profiles(flags) {
168
+ const requested = flags.args[0];
169
+ const config = await loadPennyConfig();
170
+ const names = listPennyProfiles(config);
171
+ if (names.length === 0) {
172
+ console.log("No PennyRouter profiles configured. Run `npx pennyrouter install`.");
173
+ return;
174
+ }
175
+ if (requested) {
176
+ // Resolve before saving so an unknown name fails without changing anything.
177
+ const selected = resolvePennyProfile(config, requested, { requireExisting: true });
178
+ await savePennyConfig({ ...selected, active_profile: requested });
179
+ console.log(`Active profile is now "${requested}" (${selected.gateway_base_url}).`);
180
+ return;
181
+ }
182
+ const active = activePennyProfile(config);
183
+ for (const name of names) {
184
+ const entry = config.profiles[name];
185
+ console.log(`${name === active ? "*" : " "} ${name} ${entry.gateway_base_url} ${maskKey(entry.api_key)}`);
186
+ }
187
+ console.log("");
188
+ console.log("Switch with: pennyrouter profiles <name> (or penny claude --penny-profile <name>)");
189
+ }
190
+
154
191
  function parseArgs(argv) {
155
192
  const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : "install";
156
193
  const rest = command === argv[0] ? argv.slice(1) : argv;
@@ -193,6 +230,11 @@ function parseArgs(argv) {
193
230
  else if (arg === "--proxy-api-key") flags.proxyApiKey = rest[++i] || "";
194
231
  else if (arg.startsWith("--proxy-api-key=")) flags.proxyApiKey = arg.slice("--proxy-api-key=".length);
195
232
  else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
233
+ // --profile names which stored credential set this install writes to. Defaulted below
234
+ // from the install shape, so a private-gateway install lands in "private" without
235
+ // clobbering the cloud credentials that `penny claude` uses by default.
236
+ else if (arg === "--profile") flags.profile = rest[++i] || "";
237
+ else if (arg.startsWith("--profile=")) flags.profile = arg.slice("--profile=".length);
196
238
  else if (arg === "--harness") flags.harness = rest[++i] || "";
197
239
  else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
198
240
  // --private-gateway [url]: install against a self-hosted gateway. Unlike
@@ -253,6 +295,10 @@ function parseArgs(argv) {
253
295
 
254
296
  if (flags.configPath) applyConfigFile(flags);
255
297
 
298
+ // A self-hosted gateway is a distinct identity with its own key store, so give it its own
299
+ // profile unless one was named. Hosted installs keep the default profile.
300
+ if (!flags.profile) flags.profile = flags.privateGateway ? "private" : DEFAULT_PROFILE;
301
+
256
302
  return { command, flags };
257
303
  }
258
304
 
@@ -357,7 +403,7 @@ async function authProvider(flags) {
357
403
  }
358
404
 
359
405
  if (flags.forget) {
360
- const launchConfig = await loadPennyConfig();
406
+ const launchConfig = await loadPennyConfig({ profile: flags.profile });
361
407
  const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
362
408
  if (!pennyKey) {
363
409
  throw new Error(
@@ -410,7 +456,7 @@ async function authProvider(flags) {
410
456
  return;
411
457
  }
412
458
 
413
- const launchConfig = await loadPennyConfig();
459
+ const launchConfig = await loadPennyConfig({ profile: flags.profile });
414
460
  const pennyKey = flags.pennyKey || launchConfig.api_key || await readClaudeCodePennyRouterKey();
415
461
  if (!pennyKey) {
416
462
  throw new Error(
@@ -442,6 +488,7 @@ async function authProvider(flags) {
442
488
  gatewayBaseUrl,
443
489
  subscriptionAuth: true,
444
490
  models: CLAUDE_CODE_MODELS,
491
+ profile: flags.profile,
445
492
  });
446
493
  let record = existingRecord || {
447
494
  harness: "claude-code",
@@ -620,7 +667,7 @@ export async function install(flags = {}) {
620
667
  let gatewayBaseUrl;
621
668
 
622
669
  if (needsAuth.length === 0) {
623
- const launchConfig = await loadPennyConfig();
670
+ const launchConfig = await loadPennyConfig({ profile: flags.profile });
624
671
  const globalAuth = await readClaudeCodeGatewayAuth();
625
672
  const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
626
673
  if (!apiKey) {
@@ -642,7 +689,7 @@ export async function install(flags = {}) {
642
689
  // key against a store this install never talks to. Ask for the gateway, mint there, and
643
690
  // skip the claim entirely.
644
691
  gatewayBaseUrl = await resolvePrivateGatewayUrl(flags);
645
- const launchConfig = await loadPennyConfig();
692
+ const launchConfig = await loadPennyConfig({ profile: flags.profile });
646
693
  const globalAuth = await readClaudeCodeGatewayAuth();
647
694
  const storedGateway = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
648
695
  const storedKey = storedGateway && sameGatewayBaseUrl(gatewayBaseUrl, storedGateway)
@@ -673,7 +720,7 @@ export async function install(flags = {}) {
673
720
  // Re-running the ordinary installer should refresh local harness configuration, not mint
674
721
  // another machine key. Reuse a key already saved for this same gateway; an explicit gateway
675
722
  // URL still wins, and a URL change deliberately falls through to fresh authorization.
676
- const launchConfig = await loadPennyConfig();
723
+ const launchConfig = await loadPennyConfig({ profile: flags.profile });
677
724
  const globalAuth = await readClaudeCodeGatewayAuth();
678
725
  const existingApiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
679
726
  const existingGateway = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl
@@ -798,6 +845,7 @@ export async function install(flags = {}) {
798
845
  ? claudeSubscriptionAuth
799
846
  : codexSubscriptionAuth,
800
847
  models: harness.id === "claude-code" ? CLAUDE_CODE_MODELS : null,
848
+ profile: flags.profile,
801
849
  });
802
850
  }
803
851
 
@@ -1169,7 +1217,7 @@ async function update(flags) {
1169
1217
  return;
1170
1218
  }
1171
1219
 
1172
- const launchConfig = await loadPennyConfig();
1220
+ const launchConfig = await loadPennyConfig({ profile: flags.profile });
1173
1221
  const globalAuth = await readClaudeCodeGatewayAuth();
1174
1222
  const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
1175
1223
  const gatewayBaseUrl = flags.gatewayBaseUrl
@@ -1232,6 +1280,7 @@ async function update(flags) {
1232
1280
  ? claudeSubscriptionAuth
1233
1281
  : codexSubscriptionAuth,
1234
1282
  models: record.harness === "claude-code" ? CLAUDE_CODE_MODELS : null,
1283
+ profile: flags.profile,
1235
1284
  });
1236
1285
  }
1237
1286
 
@@ -2340,6 +2389,7 @@ Usage:
2340
2389
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
2341
2390
  pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
2342
2391
  pennyrouter status
2392
+ pennyrouter profiles [name]
2343
2393
  pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
2344
2394
 
2345
2395
  Installing against a private/self-hosted gateway is its own flow: "pennyrouter install --private-gateway"
@@ -2348,6 +2398,16 @@ login entirely. Pass them up front to run it unattended: --private-gateway URL -
2348
2398
  --gateway-base-url URL takes the same path. If your administrator already issued you a key, pass
2349
2399
  --private-key pr-... and no email is asked for.
2350
2400
 
2401
+ Credentials are stored in named profiles, so one machine can hold both a cloud and a private
2402
+ gateway identity at once. A --private-gateway/--gateway-base-url install writes to the "private"
2403
+ profile and a hosted install to "cloud"; pass --profile NAME to choose another. Nothing is
2404
+ overwritten when you install the second one.
2405
+
2406
+ pennyrouter profiles list them (* marks the active one)
2407
+ pennyrouter profiles private make "private" the default for bare penny claude
2408
+ penny claude --private launch this one session against the private gateway
2409
+ penny codex --penny-profile lab any profile by name; also --cloud
2410
+
2351
2411
  Self-service keys only work where the operator enabled them (PENNYROUTER_OPEN_PROVISIONING on the
2352
2412
  gateway); otherwise install says so and you need an administrator-issued --private-key.
2353
2413
 
@@ -2382,6 +2442,8 @@ Examples:
2382
2442
  npx pennyrouter install --harness claude-code --proxy-base-url https://proxy.example.com --proxy-api-key <key>
2383
2443
  npx pennyrouter install --openrouter-api-key <key>
2384
2444
  npx pennyrouter install --private-gateway
2445
+ npx pennyrouter profiles
2446
+ npx pennyrouter profiles private
2385
2447
  npx pennyrouter install --private-gateway https://gateway.example.com --email you@example.com
2386
2448
  npx pennyrouter install --gateway-base-url https://gateway.internal.example.com --private-key pr-...
2387
2449
  npx pennyrouter auth anthropic
package/src/launch.js CHANGED
@@ -98,9 +98,18 @@ export function claudeDesktopCredentialHelperPath() {
98
98
  );
99
99
  }
100
100
 
101
- export function codexLaunchProfilePath() {
101
+ /**
102
+ * Codex reads its provider settings from a file on disk rather than the environment, so
103
+ * each Penny profile needs its own file and its own Codex profile name. Without that,
104
+ * `penny codex --private` would launch against whichever gateway was written last.
105
+ */
106
+ export function codexLaunchProfilePath(profile = DEFAULT_PROFILE) {
102
107
  const codexHome = process.env.CODEX_HOME || join(homeDir(), ".codex");
103
- return join(codexHome, "pennyrouter.config.toml");
108
+ return join(codexHome, `pennyrouter-${profile}.config.toml`);
109
+ }
110
+
111
+ export function codexProfileName(profile = DEFAULT_PROFILE) {
112
+ return `pennyrouter-${profile}`;
104
113
  }
105
114
 
106
115
  export function claudeUserSkillPath() {
@@ -122,15 +131,80 @@ async function executableWrite(path, text) {
122
131
  if (process.platform !== "win32") await chmod(path, 0o700);
123
132
  }
124
133
 
125
- export async function loadPennyConfig() {
126
- return (await readJsonFile(pennyConfigPath(), null)) || {
134
+ /**
135
+ * Credentials live in named profiles so one machine can hold both a cloud and a
136
+ * private-gateway identity. Each profile owns an api_key/gateway_base_url pair; the rest
137
+ * of the config (harnesses, PATH state, Claude Desktop state) is machine-wide.
138
+ */
139
+ export const DEFAULT_PROFILE = "cloud";
140
+
141
+ export function listPennyProfiles(config) {
142
+ return Object.keys(config.profiles || {});
143
+ }
144
+
145
+ export function activePennyProfile(config) {
146
+ return config.active_profile || DEFAULT_PROFILE;
147
+ }
148
+
149
+ /**
150
+ * Return `config` with the named profile's credentials projected onto `api_key` and
151
+ * `gateway_base_url`. Every consumer reads those two fields, so resolving here keeps the
152
+ * profile lookup in one place rather than threading a profile through each caller.
153
+ *
154
+ * A name that does not exist yet resolves to a profile with no credentials rather than
155
+ * throwing, because `install --profile private` has to read the profile it is about to
156
+ * create. Only an explicit `requireExisting` lookup — selecting a profile to launch or
157
+ * switch to — treats an unknown name as an error.
158
+ */
159
+ export function resolvePennyProfile(config, name = null, { requireExisting = false } = {}) {
160
+ const requested = name || activePennyProfile(config);
161
+ const profiles = config.profiles || {};
162
+ const entry = profiles[requested];
163
+ if (!entry) {
164
+ if (!requireExisting) return { ...config, profile: requested };
165
+ const names = Object.keys(profiles);
166
+ const known = names.length ? names.join(", ") : "none configured";
167
+ throw new Error(`Unknown PennyRouter profile "${requested}". Available: ${known}.`);
168
+ }
169
+ return {
170
+ ...config,
171
+ profile: requested,
172
+ api_key: entry.api_key,
173
+ gateway_base_url: entry.gateway_base_url,
174
+ };
175
+ }
176
+
177
+ /** Write credentials into a named profile and make it active. */
178
+ export function setPennyProfile(config, name, { apiKey, gatewayBaseUrl }) {
179
+ return {
180
+ ...config,
181
+ profiles: {
182
+ ...(config.profiles || {}),
183
+ [name]: { api_key: apiKey, gateway_base_url: gatewayBaseUrl },
184
+ },
185
+ active_profile: name,
186
+ };
187
+ }
188
+
189
+ export async function loadPennyConfig({ profile = null, requireExisting = false } = {}) {
190
+ const config = (await readJsonFile(pennyConfigPath(), null)) || {
127
191
  version: CONFIG_VERSION,
192
+ profiles: {},
128
193
  harnesses: {},
129
194
  };
195
+ return resolvePennyProfile(config, profile, { requireExisting });
130
196
  }
131
197
 
198
+ /**
199
+ * Persist a config. A resolved config carries the active profile's credentials on
200
+ * `api_key`/`gateway_base_url`; strip those before writing so a save after a load does not
201
+ * leave a stale duplicate of one profile's credentials at the top level.
202
+ */
132
203
  export async function savePennyConfig(config) {
133
204
  const next = { version: CONFIG_VERSION, ...config };
205
+ delete next.profile;
206
+ delete next.api_key;
207
+ delete next.gateway_base_url;
134
208
  await privateWrite(pennyConfigPath(), `${JSON.stringify(next, null, 2)}\n`);
135
209
  return next;
136
210
  }
@@ -699,8 +773,8 @@ async function writeClaudeUserSkill() {
699
773
  }
700
774
  }
701
775
 
702
- async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
703
- const path = codexLaunchProfilePath();
776
+ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile }) {
777
+ const path = codexLaunchProfilePath(profile);
704
778
  const existing = await readText(path, "");
705
779
  if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
706
780
  throw new Error(
@@ -715,10 +789,17 @@ export async function configurePennyHarness(harness, {
715
789
  gatewayBaseUrl,
716
790
  subscriptionAuth = false,
717
791
  models = null,
792
+ profile = null,
718
793
  }) {
719
- const config = await loadPennyConfig();
720
- config.gateway_base_url = gatewayBaseUrl;
794
+ const loaded = await loadPennyConfig();
795
+ const config = setPennyProfile(loaded, profile || activePennyProfile(loaded), {
796
+ apiKey,
797
+ gatewayBaseUrl,
798
+ });
799
+ // The rest of this function writes harness files from the credentials just stored, so
800
+ // project them for the local reads below.
721
801
  config.api_key = apiKey;
802
+ config.gateway_base_url = gatewayBaseUrl;
722
803
  config.harnesses ||= {};
723
804
  if (harness === "claude-code") {
724
805
  await writeClaudeUserSkill();
@@ -729,7 +810,11 @@ export async function configurePennyHarness(harness, {
729
810
  models: models || {},
730
811
  };
731
812
  } else if (harness === "codex") {
732
- await writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth });
813
+ await writeCodexLaunchProfile({
814
+ gatewayBaseUrl,
815
+ subscriptionAuth,
816
+ profile: config.active_profile,
817
+ });
733
818
  config.harnesses.codex = {
734
819
  enabled: true,
735
820
  subscription_auth: Boolean(subscriptionAuth),
@@ -769,9 +854,13 @@ export async function removePennyHarness(harness) {
769
854
  });
770
855
  }
771
856
  } else if (harness === "codex") {
772
- const existing = await readText(codexLaunchProfilePath(), "");
773
- if (existing.startsWith(CODEX_PROFILE_MARKER)) {
774
- await unlink(codexLaunchProfilePath()).catch((error) => {
857
+ // Remove the file for every profile: `remove codex` uninstalls the harness, not one
858
+ // credential set.
859
+ for (const name of listPennyProfiles(config)) {
860
+ const path = codexLaunchProfilePath(name);
861
+ const existing = await readText(path, "");
862
+ if (!existing.startsWith(CODEX_PROFILE_MARKER)) continue;
863
+ await unlink(path).catch((error) => {
775
864
  if (error.code !== "ENOENT") throw error;
776
865
  });
777
866
  }
@@ -826,6 +915,38 @@ export async function removePennyRuntime() {
826
915
  });
827
916
  }
828
917
 
918
+ /**
919
+ * Split Penny's own flags out of the harness argument list. `--penny-profile` (and the
920
+ * `--private`/`--cloud` shorthands) select which stored credential to launch with; the
921
+ * harnesses have no such flags, so they must not be forwarded. Everything after a bare
922
+ * `--` belongs to the harness and is left untouched.
923
+ */
924
+ export function extractProfileArgs(args) {
925
+ const rest = [];
926
+ let profile = null;
927
+ for (let i = 0; i < args.length; i += 1) {
928
+ const arg = args[i];
929
+ if (arg === "--") {
930
+ rest.push(...args.slice(i));
931
+ break;
932
+ }
933
+ if (arg === "--penny-profile") {
934
+ profile = args[++i];
935
+ if (!profile) throw new Error("--penny-profile requires a profile name.");
936
+ } else if (arg.startsWith("--penny-profile=")) {
937
+ profile = arg.slice("--penny-profile=".length);
938
+ if (!profile) throw new Error("--penny-profile requires a profile name.");
939
+ } else if (arg === "--private") {
940
+ profile = "private";
941
+ } else if (arg === "--cloud") {
942
+ profile = "cloud";
943
+ } else {
944
+ rest.push(arg);
945
+ }
946
+ }
947
+ return { profile, args: rest };
948
+ }
949
+
829
950
  function assertNoConflictingArgs(harness, args) {
830
951
  if (harness === "claude" && args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
831
952
  throw new Error("penny claude manages --settings; remove that flag or run native claude.");
@@ -889,7 +1010,11 @@ export function buildPennyInvocation(harness, args, config) {
889
1010
  // identity. Give both sides a launch-local join token so title warmth cannot bleed between
890
1011
  // two Codex TUIs using the same Penny key.
891
1012
  env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
892
- return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
1013
+ return {
1014
+ command: "codex",
1015
+ args: ["--profile", codexProfileName(config.profile), ...args],
1016
+ env,
1017
+ };
893
1018
  }
894
1019
  if (harness === "opencode") {
895
1020
  env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
@@ -908,13 +1033,19 @@ async function waitForChild(child) {
908
1033
  export async function runPenny(argv = process.argv.slice(2)) {
909
1034
  const harness = argv[0];
910
1035
  if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
911
- console.log("Usage: penny <claude|codex|opencode> [native arguments...]");
1036
+ console.log("Usage: penny <claude|codex|opencode> [--penny-profile <name>] [native arguments...]");
1037
+ console.log(" --private / --cloud select the like-named profile.");
912
1038
  return 0;
913
1039
  }
914
1040
  if (!["claude", "codex", "opencode"].includes(harness)) {
915
1041
  throw new Error(`Unknown harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
916
1042
  }
917
- const config = await loadPennyConfig();
1043
+ const selection = extractProfileArgs(argv.slice(1));
1044
+ argv = [harness, ...selection.args];
1045
+ const config = await loadPennyConfig({
1046
+ profile: selection.profile,
1047
+ requireExisting: Boolean(selection.profile),
1048
+ });
918
1049
  if (harness === "claude" && argv[1]?.toLowerCase() === "desktop") {
919
1050
  const option = argv[2]?.toLowerCase();
920
1051
  if (option === "--restore" || option === "restore") {
@@ -29,6 +29,12 @@ import {
29
29
  restoreClaudeDesktopProfile,
30
30
  selectClaudeDesktopNativeMode,
31
31
  renderCodexLaunchProfile,
32
+ extractProfileArgs,
33
+ resolvePennyProfile,
34
+ setPennyProfile,
35
+ savePennyConfig,
36
+ listPennyProfiles,
37
+ activePennyProfile,
32
38
  } from "./launch.js";
33
39
  import { claudeCodeHarness } from "./harnesses/claude-code.js";
34
40
 
@@ -216,7 +222,7 @@ async function main() {
216
222
  assert.deepEqual(normalPrompt.args.slice(-2), ["proxy", "please"]);
217
223
 
218
224
  const codex = buildPennyInvocation("codex", ["resume", "--last"], config);
219
- assert.deepEqual(codex.args, ["--profile", "pennyrouter", "resume", "--last"]);
225
+ assert.deepEqual(codex.args, ["--profile", "pennyrouter-cloud", "resume", "--last"]);
220
226
  assert.equal(codex.env.PENNYROUTER_API_KEY, "pr-super-secret");
221
227
  const opencode = buildPennyInvocation("opencode", ["--continue"], config);
222
228
  assert.equal(opencode.env.PENNYROUTER_API_KEY, "pr-super-secret");
@@ -278,6 +284,10 @@ async function main() {
278
284
  });
279
285
  assert.ok(paid.includes('env_key = "PENNYROUTER_API_KEY"'));
280
286
  assert.ok(!paid.includes("requires_openai_auth"));
287
+
288
+ // Inside the fixture: testProfiles persists a config, which must land in the
289
+ // temporary HOME and never in the developer's real ~/.config/pennyrouter.
290
+ await testProfiles();
281
291
  } finally {
282
292
  for (const key of ENV_KEYS) {
283
293
  if (previous[key] === undefined) delete process.env[key];
@@ -289,6 +299,77 @@ async function main() {
289
299
  console.log("launch.test.js: all checks passed");
290
300
  }
291
301
 
302
+ async function testProfiles() {
303
+ // Penny's own flags are consumed here and must never reach the harness; everything
304
+ // after a bare `--` is the harness's to interpret, including a literal --private.
305
+ assert.deepEqual(extractProfileArgs(["--resume", "abc"]), {
306
+ profile: null, args: ["--resume", "abc"],
307
+ });
308
+ assert.deepEqual(extractProfileArgs(["--private", "--resume", "abc"]), {
309
+ profile: "private", args: ["--resume", "abc"],
310
+ });
311
+ assert.deepEqual(extractProfileArgs(["--cloud"]), { profile: "cloud", args: [] });
312
+ assert.deepEqual(extractProfileArgs(["--penny-profile", "lab", "-c"]), {
313
+ profile: "lab", args: ["-c"],
314
+ });
315
+ assert.deepEqual(extractProfileArgs(["--penny-profile=lab"]), {
316
+ profile: "lab", args: [],
317
+ });
318
+ assert.deepEqual(extractProfileArgs(["--", "--private"]), {
319
+ profile: null, args: ["--", "--private"],
320
+ });
321
+ assert.throws(() => extractProfileArgs(["--penny-profile"]), /requires a profile name/);
322
+
323
+ const config = setPennyProfile(
324
+ setPennyProfile({ harnesses: {} }, "cloud", {
325
+ apiKey: "pr-cloud", gatewayBaseUrl: "https://api.pennyrouter.com",
326
+ }),
327
+ "private",
328
+ { apiKey: "pr-private", gatewayBaseUrl: "http://localhost:8400" },
329
+ );
330
+ // The most recent write is active, and each profile keeps its own credentials.
331
+ assert.equal(activePennyProfile(config), "private");
332
+ assert.deepEqual(listPennyProfiles(config).sort(), ["cloud", "private"]);
333
+ assert.equal(resolvePennyProfile(config).api_key, "pr-private");
334
+ assert.equal(resolvePennyProfile(config, "cloud").api_key, "pr-cloud");
335
+ assert.equal(
336
+ resolvePennyProfile(config, "cloud").gateway_base_url,
337
+ "https://api.pennyrouter.com",
338
+ );
339
+ // A not-yet-created profile resolves empty: `install --profile private` must be able to
340
+ // read the profile it is about to write, even when other profiles already exist.
341
+ const pending = resolvePennyProfile(config, "brand-new");
342
+ assert.equal(pending.profile, "brand-new");
343
+ assert.equal(pending.api_key, undefined);
344
+ // Selecting one to launch or switch to is the opposite: an unknown name must fail loudly
345
+ // rather than silently launching with no credentials.
346
+ assert.throws(
347
+ () => resolvePennyProfile(config, "nope", { requireExisting: true }),
348
+ /Unknown PennyRouter profile "nope"\. Available: cloud, private\./,
349
+ );
350
+ await assert.rejects(
351
+ loadPennyConfig({ profile: "nope", requireExisting: true }),
352
+ /Unknown PennyRouter profile/,
353
+ );
354
+
355
+ // A resolved config must not persist one profile's credentials at the top level,
356
+ // where they would shadow whichever profile is selected on the next load.
357
+ const written = await savePennyConfig(resolvePennyProfile(config, "cloud"));
358
+ assert.equal(written.api_key, undefined);
359
+ assert.equal(written.gateway_base_url, undefined);
360
+ assert.equal(written.profiles.private.api_key, "pr-private");
361
+ const reloaded = await loadPennyConfig({ profile: "private" });
362
+ assert.equal(reloaded.api_key, "pr-private");
363
+
364
+ // Each profile launches Codex against its own on-disk provider file.
365
+ const invocation = buildPennyInvocation(
366
+ "codex", [], { ...resolvePennyProfile(config, "private"), harnesses: { codex: { enabled: true } } },
367
+ );
368
+ assert.deepEqual(invocation.args, ["--profile", "pennyrouter-private"]);
369
+ assert.ok(codexLaunchProfilePath("private").endsWith("pennyrouter-private.config.toml"));
370
+ assert.notEqual(codexLaunchProfilePath("private"), codexLaunchProfilePath("cloud"));
371
+ }
372
+
292
373
  main().catch((error) => {
293
374
  console.error(error);
294
375
  process.exit(1);