pennyrouter 0.3.11 → 0.3.12

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.12",
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);
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,77 @@ 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
+ * An unconfigured machine resolves to a profile with no credentials rather than throwing:
155
+ * `install` has to run against a config that does not exist yet.
156
+ */
157
+ export function resolvePennyProfile(config, name = null) {
158
+ const requested = name || activePennyProfile(config);
159
+ const profiles = config.profiles || {};
160
+ const entry = profiles[requested];
161
+ if (!entry) {
162
+ if (Object.keys(profiles).length === 0) return { ...config, profile: requested };
163
+ const known = Object.keys(profiles).join(", ");
164
+ throw new Error(`Unknown PennyRouter profile "${requested}". Available: ${known}.`);
165
+ }
166
+ return {
167
+ ...config,
168
+ profile: requested,
169
+ api_key: entry.api_key,
170
+ gateway_base_url: entry.gateway_base_url,
171
+ };
172
+ }
173
+
174
+ /** Write credentials into a named profile and make it active. */
175
+ export function setPennyProfile(config, name, { apiKey, gatewayBaseUrl }) {
176
+ return {
177
+ ...config,
178
+ profiles: {
179
+ ...(config.profiles || {}),
180
+ [name]: { api_key: apiKey, gateway_base_url: gatewayBaseUrl },
181
+ },
182
+ active_profile: name,
183
+ };
184
+ }
185
+
186
+ export async function loadPennyConfig({ profile = null } = {}) {
187
+ const config = (await readJsonFile(pennyConfigPath(), null)) || {
127
188
  version: CONFIG_VERSION,
189
+ profiles: {},
128
190
  harnesses: {},
129
191
  };
192
+ return resolvePennyProfile(config, profile);
130
193
  }
131
194
 
195
+ /**
196
+ * Persist a config. A resolved config carries the active profile's credentials on
197
+ * `api_key`/`gateway_base_url`; strip those before writing so a save after a load does not
198
+ * leave a stale duplicate of one profile's credentials at the top level.
199
+ */
132
200
  export async function savePennyConfig(config) {
133
201
  const next = { version: CONFIG_VERSION, ...config };
202
+ delete next.profile;
203
+ delete next.api_key;
204
+ delete next.gateway_base_url;
134
205
  await privateWrite(pennyConfigPath(), `${JSON.stringify(next, null, 2)}\n`);
135
206
  return next;
136
207
  }
@@ -699,8 +770,8 @@ async function writeClaudeUserSkill() {
699
770
  }
700
771
  }
701
772
 
702
- async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth }) {
703
- const path = codexLaunchProfilePath();
773
+ async function writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth, profile }) {
774
+ const path = codexLaunchProfilePath(profile);
704
775
  const existing = await readText(path, "");
705
776
  if (existing.trim() && !existing.startsWith(CODEX_PROFILE_MARKER)) {
706
777
  throw new Error(
@@ -715,10 +786,17 @@ export async function configurePennyHarness(harness, {
715
786
  gatewayBaseUrl,
716
787
  subscriptionAuth = false,
717
788
  models = null,
789
+ profile = null,
718
790
  }) {
719
- const config = await loadPennyConfig();
720
- config.gateway_base_url = gatewayBaseUrl;
791
+ const loaded = await loadPennyConfig();
792
+ const config = setPennyProfile(loaded, profile || activePennyProfile(loaded), {
793
+ apiKey,
794
+ gatewayBaseUrl,
795
+ });
796
+ // The rest of this function writes harness files from the credentials just stored, so
797
+ // project them for the local reads below.
721
798
  config.api_key = apiKey;
799
+ config.gateway_base_url = gatewayBaseUrl;
722
800
  config.harnesses ||= {};
723
801
  if (harness === "claude-code") {
724
802
  await writeClaudeUserSkill();
@@ -729,7 +807,11 @@ export async function configurePennyHarness(harness, {
729
807
  models: models || {},
730
808
  };
731
809
  } else if (harness === "codex") {
732
- await writeCodexLaunchProfile({ gatewayBaseUrl, subscriptionAuth });
810
+ await writeCodexLaunchProfile({
811
+ gatewayBaseUrl,
812
+ subscriptionAuth,
813
+ profile: config.active_profile,
814
+ });
733
815
  config.harnesses.codex = {
734
816
  enabled: true,
735
817
  subscription_auth: Boolean(subscriptionAuth),
@@ -769,9 +851,13 @@ export async function removePennyHarness(harness) {
769
851
  });
770
852
  }
771
853
  } else if (harness === "codex") {
772
- const existing = await readText(codexLaunchProfilePath(), "");
773
- if (existing.startsWith(CODEX_PROFILE_MARKER)) {
774
- await unlink(codexLaunchProfilePath()).catch((error) => {
854
+ // Remove the file for every profile: `remove codex` uninstalls the harness, not one
855
+ // credential set.
856
+ for (const name of listPennyProfiles(config)) {
857
+ const path = codexLaunchProfilePath(name);
858
+ const existing = await readText(path, "");
859
+ if (!existing.startsWith(CODEX_PROFILE_MARKER)) continue;
860
+ await unlink(path).catch((error) => {
775
861
  if (error.code !== "ENOENT") throw error;
776
862
  });
777
863
  }
@@ -826,6 +912,38 @@ export async function removePennyRuntime() {
826
912
  });
827
913
  }
828
914
 
915
+ /**
916
+ * Split Penny's own flags out of the harness argument list. `--penny-profile` (and the
917
+ * `--private`/`--cloud` shorthands) select which stored credential to launch with; the
918
+ * harnesses have no such flags, so they must not be forwarded. Everything after a bare
919
+ * `--` belongs to the harness and is left untouched.
920
+ */
921
+ export function extractProfileArgs(args) {
922
+ const rest = [];
923
+ let profile = null;
924
+ for (let i = 0; i < args.length; i += 1) {
925
+ const arg = args[i];
926
+ if (arg === "--") {
927
+ rest.push(...args.slice(i));
928
+ break;
929
+ }
930
+ if (arg === "--penny-profile") {
931
+ profile = args[++i];
932
+ if (!profile) throw new Error("--penny-profile requires a profile name.");
933
+ } else if (arg.startsWith("--penny-profile=")) {
934
+ profile = arg.slice("--penny-profile=".length);
935
+ if (!profile) throw new Error("--penny-profile requires a profile name.");
936
+ } else if (arg === "--private") {
937
+ profile = "private";
938
+ } else if (arg === "--cloud") {
939
+ profile = "cloud";
940
+ } else {
941
+ rest.push(arg);
942
+ }
943
+ }
944
+ return { profile, args: rest };
945
+ }
946
+
829
947
  function assertNoConflictingArgs(harness, args) {
830
948
  if (harness === "claude" && args.some((arg) => arg === "--settings" || arg.startsWith("--settings="))) {
831
949
  throw new Error("penny claude manages --settings; remove that flag or run native claude.");
@@ -889,7 +1007,11 @@ export function buildPennyInvocation(harness, args, config) {
889
1007
  // identity. Give both sides a launch-local join token so title warmth cannot bleed between
890
1008
  // two Codex TUIs using the same Penny key.
891
1009
  env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
892
- return { command: "codex", args: ["--profile", "pennyrouter", ...args], env };
1010
+ return {
1011
+ command: "codex",
1012
+ args: ["--profile", codexProfileName(config.profile), ...args],
1013
+ env,
1014
+ };
893
1015
  }
894
1016
  if (harness === "opencode") {
895
1017
  env.PENNYROUTER_TITLE_SESSION_ID = randomUUID();
@@ -908,13 +1030,16 @@ async function waitForChild(child) {
908
1030
  export async function runPenny(argv = process.argv.slice(2)) {
909
1031
  const harness = argv[0];
910
1032
  if (!harness || harness === "help" || harness === "--help" || harness === "-h") {
911
- console.log("Usage: penny <claude|codex|opencode> [native arguments...]");
1033
+ console.log("Usage: penny <claude|codex|opencode> [--penny-profile <name>] [native arguments...]");
1034
+ console.log(" --private / --cloud select the like-named profile.");
912
1035
  return 0;
913
1036
  }
914
1037
  if (!["claude", "codex", "opencode"].includes(harness)) {
915
1038
  throw new Error(`Unknown harness "${harness}". Try penny claude, penny codex, or penny opencode.`);
916
1039
  }
917
- const config = await loadPennyConfig();
1040
+ const selection = extractProfileArgs(argv.slice(1));
1041
+ argv = [harness, ...selection.args];
1042
+ const config = await loadPennyConfig({ profile: selection.profile });
918
1043
  if (harness === "claude" && argv[1]?.toLowerCase() === "desktop") {
919
1044
  const option = argv[2]?.toLowerCase();
920
1045
  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");
@@ -286,9 +292,67 @@ async function main() {
286
292
  fs.rmSync(root, { recursive: true, force: true });
287
293
  }
288
294
 
295
+ await testProfiles();
289
296
  console.log("launch.test.js: all checks passed");
290
297
  }
291
298
 
299
+ async function testProfiles() {
300
+ // Penny's own flags are consumed here and must never reach the harness; everything
301
+ // after a bare `--` is the harness's to interpret, including a literal --private.
302
+ assert.deepEqual(extractProfileArgs(["--resume", "abc"]), {
303
+ profile: null, args: ["--resume", "abc"],
304
+ });
305
+ assert.deepEqual(extractProfileArgs(["--private", "--resume", "abc"]), {
306
+ profile: "private", args: ["--resume", "abc"],
307
+ });
308
+ assert.deepEqual(extractProfileArgs(["--cloud"]), { profile: "cloud", args: [] });
309
+ assert.deepEqual(extractProfileArgs(["--penny-profile", "lab", "-c"]), {
310
+ profile: "lab", args: ["-c"],
311
+ });
312
+ assert.deepEqual(extractProfileArgs(["--penny-profile=lab"]), {
313
+ profile: "lab", args: [],
314
+ });
315
+ assert.deepEqual(extractProfileArgs(["--", "--private"]), {
316
+ profile: null, args: ["--", "--private"],
317
+ });
318
+ assert.throws(() => extractProfileArgs(["--penny-profile"]), /requires a profile name/);
319
+
320
+ const config = setPennyProfile(
321
+ setPennyProfile({ harnesses: {} }, "cloud", {
322
+ apiKey: "pr-cloud", gatewayBaseUrl: "https://api.pennyrouter.com",
323
+ }),
324
+ "private",
325
+ { apiKey: "pr-private", gatewayBaseUrl: "http://localhost:8400" },
326
+ );
327
+ // The most recent write is active, and each profile keeps its own credentials.
328
+ assert.equal(activePennyProfile(config), "private");
329
+ assert.deepEqual(listPennyProfiles(config).sort(), ["cloud", "private"]);
330
+ assert.equal(resolvePennyProfile(config).api_key, "pr-private");
331
+ assert.equal(resolvePennyProfile(config, "cloud").api_key, "pr-cloud");
332
+ assert.equal(
333
+ resolvePennyProfile(config, "cloud").gateway_base_url,
334
+ "https://api.pennyrouter.com",
335
+ );
336
+ assert.throws(() => resolvePennyProfile(config, "nope"), /Unknown PennyRouter profile/);
337
+
338
+ // A resolved config must not persist one profile's credentials at the top level,
339
+ // where they would shadow whichever profile is selected on the next load.
340
+ const written = await savePennyConfig(resolvePennyProfile(config, "cloud"));
341
+ assert.equal(written.api_key, undefined);
342
+ assert.equal(written.gateway_base_url, undefined);
343
+ assert.equal(written.profiles.private.api_key, "pr-private");
344
+ const reloaded = await loadPennyConfig({ profile: "private" });
345
+ assert.equal(reloaded.api_key, "pr-private");
346
+
347
+ // Each profile launches Codex against its own on-disk provider file.
348
+ const invocation = buildPennyInvocation(
349
+ "codex", [], { ...resolvePennyProfile(config, "private"), harnesses: { codex: { enabled: true } } },
350
+ );
351
+ assert.deepEqual(invocation.args, ["--profile", "pennyrouter-private"]);
352
+ assert.ok(codexLaunchProfilePath("private").endsWith("pennyrouter-private.config.toml"));
353
+ assert.notEqual(codexLaunchProfilePath("private"), codexLaunchProfilePath("cloud"));
354
+ }
355
+
292
356
  main().catch((error) => {
293
357
  console.error(error);
294
358
  process.exit(1);