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 +1 -1
- package/src/cli.js +164 -43
- package/src/launch.js +140 -15
- package/src/launch.test.js +65 -1
package/package.json
CHANGED
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
|
-
|
|
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
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
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
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
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
|
-
|
|
677
|
-
|
|
678
|
-
|
|
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
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
126
|
-
|
|
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
|
|
720
|
-
config
|
|
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({
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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 {
|
|
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
|
|
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") {
|
package/src/launch.test.js
CHANGED
|
@@ -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);
|