pennyrouter 0.3.10 → 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.10",
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,16 @@ 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
- claim = { api_key: await obtainPrivateGatewayKey(flags, gatewayBaseUrl), gateway_base_url: gatewayBaseUrl };
692
+ const launchConfig = await loadPennyConfig({ profile: flags.profile });
693
+ const globalAuth = await readClaudeCodeGatewayAuth();
694
+ const storedGateway = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
695
+ const storedKey = storedGateway && sameGatewayBaseUrl(gatewayBaseUrl, storedGateway)
696
+ ? (launchConfig.api_key || globalAuth.apiKey)
697
+ : null;
698
+ claim = {
699
+ api_key: await obtainPrivateGatewayKey(flags, gatewayBaseUrl, storedKey),
700
+ gateway_base_url: gatewayBaseUrl,
701
+ };
646
702
 
647
703
  const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
648
704
  const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
@@ -661,48 +717,86 @@ export async function install(flags = {}) {
661
717
  }));
662
718
  }
663
719
  } else {
664
- const session = await createCliSession({
665
- appUrl: flags.appUrl || APP_URL,
666
- harnesses: selected.map((harness) => harness.id),
667
- accountMode: "existing",
668
- });
720
+ // Re-running the ordinary installer should refresh local harness configuration, not mint
721
+ // another machine key. Reuse a key already saved for this same gateway; an explicit gateway
722
+ // URL still wins, and a URL change deliberately falls through to fresh authorization.
723
+ const launchConfig = await loadPennyConfig({ profile: flags.profile });
724
+ const globalAuth = await readClaudeCodeGatewayAuth();
725
+ const existingApiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
726
+ const existingGateway = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl
727
+ || DEFAULT_GATEWAY_BASE_URL;
728
+ const requestedGateway = flags.gatewayBaseUrl || existingGateway;
729
+ const reusableKey = existingApiKey
730
+ && sameGatewayBaseUrl(requestedGateway, existingGateway)
731
+ ? existingApiKey
732
+ : null;
733
+
734
+ if (reusableKey) {
735
+ gatewayBaseUrl = existingGateway;
736
+ claim = { api_key: reusableKey, gateway_base_url: gatewayBaseUrl };
737
+ console.log(`Using existing PennyRouter key for ${gatewayBaseUrl}.`);
738
+ console.log("");
669
739
 
670
- console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
671
- console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
672
- if (!flags.noBrowser) openBrowser(session.authorize_url);
673
- console.log("");
674
- console.log("Waiting for approval...");
740
+ const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
741
+ const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
742
+ claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
743
+ flags, needsAuth, claudeProviders.has("subscription"));
744
+ funding = await configureUserFunding(
745
+ flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
675
746
 
676
- claim = await pollForKey(session);
677
- console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
678
- console.log("");
747
+ for (const credential of funding.credentials) {
748
+ await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
749
+ gatewayBaseUrl,
750
+ apiKey: claim.api_key,
751
+ provider: credential.provider,
752
+ token: credential.token,
753
+ metadata: credential.metadata,
754
+ }));
755
+ }
756
+ } else {
757
+ const session = await createCliSession({
758
+ appUrl: flags.appUrl || APP_URL,
759
+ harnesses: selected.map((harness) => harness.id),
760
+ accountMode: "existing",
761
+ });
679
762
 
680
- const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
681
- const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
682
- claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
683
- flags, needsAuth, claudeProviders.has("subscription"));
684
- funding = await configureUserFunding(
685
- flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
686
- gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
687
- if (flags.gatewayBaseUrl) {
688
- const normalized = normalizeGatewayBaseUrl(flags.gatewayBaseUrl);
689
- if (!normalized) {
690
- throw new Error(
691
- `--gateway-base-url "${flags.gatewayBaseUrl}" is not a usable URL. Use a host like `
692
- + "localhost:8400 or https://gateway.example.com.",
693
- );
763
+ console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
764
+ console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
765
+ if (!flags.noBrowser) openBrowser(session.authorize_url);
766
+ console.log("");
767
+ console.log("Waiting for approval...");
768
+
769
+ claim = await pollForKey(session);
770
+ console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
771
+ console.log("");
772
+
773
+ const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
774
+ const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
775
+ claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
776
+ flags, needsAuth, claudeProviders.has("subscription"));
777
+ funding = await configureUserFunding(
778
+ flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
779
+ gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
780
+ if (flags.gatewayBaseUrl) {
781
+ const normalized = normalizeGatewayBaseUrl(flags.gatewayBaseUrl);
782
+ if (!normalized) {
783
+ throw new Error(
784
+ `--gateway-base-url "${flags.gatewayBaseUrl}" is not a usable URL. Use a host like `
785
+ + "localhost:8000 or https://gateway.example.com.",
786
+ );
787
+ }
788
+ gatewayBaseUrl = normalized;
694
789
  }
695
- gatewayBaseUrl = normalized;
696
- }
697
790
 
698
- for (const credential of funding.credentials) {
699
- await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
700
- gatewayBaseUrl,
701
- apiKey: claim.api_key,
702
- provider: credential.provider,
703
- token: credential.token,
704
- metadata: credential.metadata,
705
- }));
791
+ for (const credential of funding.credentials) {
792
+ await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
793
+ gatewayBaseUrl,
794
+ apiKey: claim.api_key,
795
+ provider: credential.provider,
796
+ token: credential.token,
797
+ metadata: credential.metadata,
798
+ }));
799
+ }
706
800
  }
707
801
  }
708
802
 
@@ -751,6 +845,7 @@ export async function install(flags = {}) {
751
845
  ? claudeSubscriptionAuth
752
846
  : codexSubscriptionAuth,
753
847
  models: harness.id === "claude-code" ? CLAUDE_CODE_MODELS : null,
848
+ profile: flags.profile,
754
849
  });
755
850
  }
756
851
 
@@ -926,7 +1021,7 @@ async function resolvePrivateGatewayUrl(flags) {
926
1021
  /* The key for a private gateway: an admin-issued one if the user has it, otherwise minted
927
1022
  from the gateway itself against their email. --private-key wins so an administrator can
928
1023
  still hand out keys on a gateway that also allows self-service. */
929
- async function obtainPrivateGatewayKey(flags, gatewayBaseUrl) {
1024
+ async function obtainPrivateGatewayKey(flags, gatewayBaseUrl, existingApiKey = null) {
930
1025
  const issued = (flags.privateKey || "").trim();
931
1026
  if (issued) {
932
1027
  if (!issued.startsWith("pr-")) {
@@ -937,6 +1032,12 @@ async function obtainPrivateGatewayKey(flags, gatewayBaseUrl) {
937
1032
  return issued;
938
1033
  }
939
1034
 
1035
+ if (existingApiKey) {
1036
+ console.log(`Using existing PennyRouter key for ${gatewayBaseUrl}.`);
1037
+ console.log("");
1038
+ return existingApiKey;
1039
+ }
1040
+
940
1041
  let email = (flags.email || "").trim();
941
1042
  if (!email) {
942
1043
  // A shared --config file names the gateway but not who is installing, so the email is
@@ -985,6 +1086,12 @@ function normalizeGatewayBaseUrl(raw) {
985
1086
  return withScheme.replace(/\/+$/, "");
986
1087
  }
987
1088
 
1089
+ function sameGatewayBaseUrl(left, right) {
1090
+ const normalizedLeft = normalizeGatewayBaseUrl(left);
1091
+ const normalizedRight = normalizeGatewayBaseUrl(right);
1092
+ return Boolean(normalizedLeft && normalizedRight && normalizedLeft === normalizedRight);
1093
+ }
1094
+
988
1095
  /* A gateway that is unreachable (wrong host, wrong port, not running) surfaces from fetch() as
989
1096
  an opaque "fetch failed" with no status. Name the gateway we could not reach so the user knows
990
1097
  which URL to fix. Real HTTP errors carry their own message and pass through untouched. */
@@ -1110,7 +1217,7 @@ async function update(flags) {
1110
1217
  return;
1111
1218
  }
1112
1219
 
1113
- const launchConfig = await loadPennyConfig();
1220
+ const launchConfig = await loadPennyConfig({ profile: flags.profile });
1114
1221
  const globalAuth = await readClaudeCodeGatewayAuth();
1115
1222
  const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
1116
1223
  const gatewayBaseUrl = flags.gatewayBaseUrl
@@ -1173,6 +1280,7 @@ async function update(flags) {
1173
1280
  ? claudeSubscriptionAuth
1174
1281
  : codexSubscriptionAuth,
1175
1282
  models: record.harness === "claude-code" ? CLAUDE_CODE_MODELS : null,
1283
+ profile: flags.profile,
1176
1284
  });
1177
1285
  }
1178
1286
 
@@ -2281,6 +2389,7 @@ Usage:
2281
2389
  pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
2282
2390
  pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
2283
2391
  pennyrouter status
2392
+ pennyrouter profiles [name]
2284
2393
  pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
2285
2394
 
2286
2395
  Installing against a private/self-hosted gateway is its own flow: "pennyrouter install --private-gateway"
@@ -2289,6 +2398,16 @@ login entirely. Pass them up front to run it unattended: --private-gateway URL -
2289
2398
  --gateway-base-url URL takes the same path. If your administrator already issued you a key, pass
2290
2399
  --private-key pr-... and no email is asked for.
2291
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
+
2292
2411
  Self-service keys only work where the operator enabled them (PENNYROUTER_OPEN_PROVISIONING on the
2293
2412
  gateway); otherwise install says so and you need an administrator-issued --private-key.
2294
2413
 
@@ -2323,6 +2442,8 @@ Examples:
2323
2442
  npx pennyrouter install --harness claude-code --proxy-base-url https://proxy.example.com --proxy-api-key <key>
2324
2443
  npx pennyrouter install --openrouter-api-key <key>
2325
2444
  npx pennyrouter install --private-gateway
2445
+ npx pennyrouter profiles
2446
+ npx pennyrouter profiles private
2326
2447
  npx pennyrouter install --private-gateway https://gateway.example.com --email you@example.com
2327
2448
  npx pennyrouter install --gateway-base-url https://gateway.internal.example.com --private-key pr-...
2328
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);