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 +1 -1
- package/src/cli.js +68 -6
- package/src/launch.js +146 -15
- package/src/launch.test.js +82 -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, { 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
|
-
|
|
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,80 @@ 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
|
+
* 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
|
|
720
|
-
config
|
|
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({
|
|
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
|
-
|
|
773
|
-
|
|
774
|
-
|
|
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 {
|
|
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
|
|
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") {
|
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");
|
|
@@ -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);
|