pennyrouter 0.3.21 → 0.3.23
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 +80 -24
- package/src/cli.test.js +10 -1
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -209,7 +209,7 @@ async function profiles(flags) {
|
|
|
209
209
|
console.log("Switch with: pennyrouter profiles <name> (or penny claude --penny-profile <name>)");
|
|
210
210
|
}
|
|
211
211
|
|
|
212
|
-
function parseArgs(argv) {
|
|
212
|
+
export function parseArgs(argv) {
|
|
213
213
|
const command = argv[0] && !argv[0].startsWith("-") ? argv[0] : "install";
|
|
214
214
|
const rest = command === argv[0] ? argv.slice(1) : argv;
|
|
215
215
|
const flags = {};
|
|
@@ -260,6 +260,8 @@ function parseArgs(argv) {
|
|
|
260
260
|
// clobbering the cloud credentials that `penny claude` uses by default.
|
|
261
261
|
else if (arg === "--profile") flags.profile = rest[++i] || "";
|
|
262
262
|
else if (arg.startsWith("--profile=")) flags.profile = arg.slice("--profile=".length);
|
|
263
|
+
else if (arg === "--private") flags.profile = "private";
|
|
264
|
+
else if (arg === "--cloud") flags.profile = "cloud";
|
|
263
265
|
else if (arg === "--harness") flags.harness = rest[++i] || "";
|
|
264
266
|
else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
|
|
265
267
|
// --private-gateway [url]: install against a self-hosted gateway. Unlike
|
|
@@ -320,9 +322,12 @@ function parseArgs(argv) {
|
|
|
320
322
|
|
|
321
323
|
if (flags.configPath) applyConfigFile(flags);
|
|
322
324
|
|
|
323
|
-
//
|
|
324
|
-
//
|
|
325
|
-
|
|
325
|
+
// Install chooses a destination profile when none was named: hosted installs go to cloud,
|
|
326
|
+
// while self-hosted installs go to private. Operational commands such as update and auth
|
|
327
|
+
// should instead leave the profile unset so loadPennyConfig() resolves active_profile.
|
|
328
|
+
if (!flags.profile && command === "install") {
|
|
329
|
+
flags.profile = flags.privateGateway ? "private" : DEFAULT_PROFILE;
|
|
330
|
+
}
|
|
326
331
|
|
|
327
332
|
return { command, flags };
|
|
328
333
|
}
|
|
@@ -925,10 +930,11 @@ export async function install(flags = {}) {
|
|
|
925
930
|
record.launch_available = true;
|
|
926
931
|
const launchConfigPath = harness.id === "claude-code"
|
|
927
932
|
? claudeLaunchSettingsPath()
|
|
928
|
-
: harness.id === "codex" ? codexLaunchProfilePath() : null;
|
|
933
|
+
: harness.id === "codex" ? codexLaunchProfilePath(flags.profile) : null;
|
|
929
934
|
record.launch_config_path = launchConfigPath ? compactHome(launchConfigPath) : null;
|
|
930
935
|
record.default_routing = Boolean(makeDefault);
|
|
931
936
|
}
|
|
937
|
+
record.profile = flags.profile;
|
|
932
938
|
if (harness.id === "claude-code" && claudeSubscriptionAuth) {
|
|
933
939
|
record.anthropic_oauth = true;
|
|
934
940
|
record.anthropic_token_stored = true;
|
|
@@ -1256,6 +1262,12 @@ async function update(flags) {
|
|
|
1256
1262
|
if (record.disabled) return false;
|
|
1257
1263
|
if (flags.all || requested.length === 0) return true;
|
|
1258
1264
|
return requested.includes(record.harness);
|
|
1265
|
+
}).filter((record) => {
|
|
1266
|
+
if (!flags.profile) return true;
|
|
1267
|
+
const recordProfile = record.profile || profileFromManifestRecord(record);
|
|
1268
|
+
// Records written before profile ownership was added have no reliable Claude profile
|
|
1269
|
+
// marker, so leave them eligible for an explicit profile as a compatibility fallback.
|
|
1270
|
+
return !recordProfile || recordProfile === flags.profile;
|
|
1259
1271
|
});
|
|
1260
1272
|
|
|
1261
1273
|
if (records.length === 0) {
|
|
@@ -1266,25 +1278,55 @@ async function update(flags) {
|
|
|
1266
1278
|
return;
|
|
1267
1279
|
}
|
|
1268
1280
|
|
|
1269
|
-
const
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
||
|
|
1276
|
-
|
|
1281
|
+
const updateContexts = new Map();
|
|
1282
|
+
for (const record of targets) {
|
|
1283
|
+
const preferredProfile = record.profile || profileFromManifestRecord(record) || flags.profile;
|
|
1284
|
+
let profile = preferredProfile;
|
|
1285
|
+
let launchConfig = await loadPennyConfig({ profile });
|
|
1286
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
1287
|
+
let apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
|
|
1288
|
+
let gatewayBaseUrl = flags.gatewayBaseUrl
|
|
1289
|
+
|| launchConfig.gateway_base_url
|
|
1290
|
+
|| globalAuth.gatewayBaseUrl
|
|
1291
|
+
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
1292
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
1277
1293
|
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1294
|
+
// A pre-profile manifest may say "cloud" simply because that was the old default,
|
|
1295
|
+
// even though the user only has a private profile now. An unqualified update should
|
|
1296
|
+
// use an available credential rather than treating that stale hint as authoritative.
|
|
1297
|
+
if (!apiKey && !flags.profile && !flags.pennyKey) {
|
|
1298
|
+
const candidates = [
|
|
1299
|
+
activePennyProfile(launchConfig),
|
|
1300
|
+
...listPennyProfiles(launchConfig),
|
|
1301
|
+
].filter((name, index, all) => name && all.indexOf(name) === index);
|
|
1302
|
+
for (const candidate of candidates) {
|
|
1303
|
+
const candidateConfig = await loadPennyConfig({ profile: candidate });
|
|
1304
|
+
if (!candidateConfig.api_key) continue;
|
|
1305
|
+
profile = candidate;
|
|
1306
|
+
launchConfig = candidateConfig;
|
|
1307
|
+
apiKey = candidateConfig.api_key;
|
|
1308
|
+
gatewayBaseUrl = flags.gatewayBaseUrl
|
|
1309
|
+
|| candidateConfig.gateway_base_url
|
|
1310
|
+
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
1311
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
1312
|
+
break;
|
|
1313
|
+
}
|
|
1314
|
+
}
|
|
1315
|
+
if (!apiKey) {
|
|
1316
|
+
const suffix = profile ? ` for profile "${profile}"` : "";
|
|
1317
|
+
throw new Error(
|
|
1318
|
+
`Could not find your PennyRouter key in the local config${suffix}. `
|
|
1319
|
+
+ "Pass `--penny-key pr-...`, or run `npx pennyrouter install` to re-authorize.",
|
|
1320
|
+
);
|
|
1321
|
+
}
|
|
1322
|
+
updateContexts.set(record, { profile, apiKey, gatewayBaseUrl });
|
|
1283
1323
|
}
|
|
1284
1324
|
|
|
1285
1325
|
console.log("PennyRouter update");
|
|
1286
1326
|
console.log("");
|
|
1287
|
-
|
|
1327
|
+
for (const { profile, apiKey, gatewayBaseUrl } of updateContexts.values()) {
|
|
1328
|
+
console.log(`${profile ? `Profile ${profile} · ` : ""}Key ${maskKey(apiKey)} · gateway ${gatewayBaseUrl}`);
|
|
1329
|
+
}
|
|
1288
1330
|
console.log("Subscription and API keys are left untouched.");
|
|
1289
1331
|
console.log("");
|
|
1290
1332
|
|
|
@@ -1307,6 +1349,7 @@ async function update(flags) {
|
|
|
1307
1349
|
for (const record of targets) {
|
|
1308
1350
|
const harness = HARNESS_BY_ID[record.harness];
|
|
1309
1351
|
if (!harness) continue;
|
|
1352
|
+
const { profile, apiKey, gatewayBaseUrl } = updateContexts.get(record);
|
|
1310
1353
|
|
|
1311
1354
|
await runHarnessJob(failures, record.harness, "update", async () => {
|
|
1312
1355
|
const supportsPennyCommand = LAUNCHABLE_HARNESS_IDS.includes(record.harness);
|
|
@@ -1333,7 +1376,7 @@ async function update(flags) {
|
|
|
1333
1376
|
? claudeSubscriptionAuth
|
|
1334
1377
|
: codexSubscriptionAuth,
|
|
1335
1378
|
models: record.harness === "claude-code" ? CLAUDE_CODE_MODELS : null,
|
|
1336
|
-
profile
|
|
1379
|
+
profile,
|
|
1337
1380
|
});
|
|
1338
1381
|
}
|
|
1339
1382
|
|
|
@@ -1351,6 +1394,7 @@ async function update(flags) {
|
|
|
1351
1394
|
|
|
1352
1395
|
next.updated_at = new Date().toISOString();
|
|
1353
1396
|
if (record.harness === "codex") next.openai_subscription_auth = codexSubscriptionAuth;
|
|
1397
|
+
next.profile = profile;
|
|
1354
1398
|
upsertManifestRecord(manifest, next);
|
|
1355
1399
|
await saveManifest(manifest);
|
|
1356
1400
|
console.log(`Updated ${record.harness}.`);
|
|
@@ -1368,9 +1412,13 @@ async function update(flags) {
|
|
|
1368
1412
|
// and `pennyrouter mcp uninstall` removes it for good.
|
|
1369
1413
|
if (!flags.dryRun && !flags.noMcp) {
|
|
1370
1414
|
const wanted = targets.filter((record) => MCP_HARNESS_IDS.includes(record.harness));
|
|
1371
|
-
const results =
|
|
1372
|
-
|
|
1373
|
-
|
|
1415
|
+
const results = [];
|
|
1416
|
+
for (const record of wanted) {
|
|
1417
|
+
const context = updateContexts.get(record);
|
|
1418
|
+
results.push(...await registerMcp([record.harness], {
|
|
1419
|
+
gatewayBaseUrl: customGatewayBaseUrl(context.gatewayBaseUrl),
|
|
1420
|
+
}));
|
|
1421
|
+
}
|
|
1374
1422
|
const added = results.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
|
|
1375
1423
|
if (added.length) console.log(`Registered the Threads MCP server on ${added.join(" and ")}.`);
|
|
1376
1424
|
for (const result of results.filter((r) => r.error)) {
|
|
@@ -2277,6 +2325,14 @@ function upsertManifestRecord(manifest, record) {
|
|
|
2277
2325
|
manifest.installations.push(record);
|
|
2278
2326
|
}
|
|
2279
2327
|
|
|
2328
|
+
// Older manifests predate profile ownership. Codex's per-profile launch filename gives us a
|
|
2329
|
+
// reliable migration signal; Claude's launch settings are shared, so it follows active_profile.
|
|
2330
|
+
function profileFromManifestRecord(record) {
|
|
2331
|
+
const launchPath = String(record.launch_config_path || "");
|
|
2332
|
+
const match = launchPath.match(/pennyrouter-([^/\\.]+)\.config\.toml$/);
|
|
2333
|
+
return match ? match[1] : null;
|
|
2334
|
+
}
|
|
2335
|
+
|
|
2280
2336
|
function pruneUnsupportedManifestRecords(manifest) {
|
|
2281
2337
|
manifest.installations ||= [];
|
|
2282
2338
|
const kept = manifest.installations.filter((record) => HARNESS_BY_ID[record.harness]);
|
|
@@ -2442,7 +2498,7 @@ function printHelp() {
|
|
|
2442
2498
|
|
|
2443
2499
|
Usage:
|
|
2444
2500
|
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--no-mcp] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openai-proxy-base-url URL --openai-proxy-api-key KEY] [--openrouter-api-key KEY] [--anthropic-proxy-base-url URL --anthropic-proxy-api-key KEY] [--proxy-base-url URL --proxy-api-key KEY] [--private-gateway|--private-server[=URL] [--email you@example.com]] [--gateway-base-url URL] [--private-key pr-...] [--local[=URL]|--prod] [--config PATH]
|
|
2445
|
-
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
|
|
2501
|
+
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--profile NAME|--private|--cloud] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
|
|
2446
2502
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
2447
2503
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
2448
2504
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
package/src/cli.test.js
CHANGED
|
@@ -4,7 +4,8 @@ import { tmpdir } from "node:os";
|
|
|
4
4
|
import { join } from "node:path";
|
|
5
5
|
import test from "node:test";
|
|
6
6
|
import {
|
|
7
|
-
applyConfigFile, harnessFundingChanged, INSTALL_MANIFEST, main,
|
|
7
|
+
applyConfigFile, harnessFundingChanged, INSTALL_MANIFEST, main, parseArgs,
|
|
8
|
+
SUPPORTED_HARNESS_IDS,
|
|
8
9
|
} from "./cli.js";
|
|
9
10
|
|
|
10
11
|
// --config lets a GUI installer (the macOS DMG) answer install questions up front instead of
|
|
@@ -29,6 +30,14 @@ function captureLogs(run) {
|
|
|
29
30
|
return run().finally(() => { console.log = original; }).then(() => lines);
|
|
30
31
|
}
|
|
31
32
|
|
|
33
|
+
test("update uses the active profile unless one is explicitly selected", () => {
|
|
34
|
+
assert.equal(parseArgs(["update"]).flags.profile, undefined);
|
|
35
|
+
assert.equal(parseArgs(["update", "--profile", "private"]).flags.profile, "private");
|
|
36
|
+
assert.equal(parseArgs(["update", "--cloud"]).flags.profile, "cloud");
|
|
37
|
+
assert.equal(parseArgs(["install"]).flags.profile, "cloud");
|
|
38
|
+
assert.equal(parseArgs(["install", "--private-gateway"]).flags.profile, "private");
|
|
39
|
+
});
|
|
40
|
+
|
|
32
41
|
test("--config selects harnesses without prompting", async () => {
|
|
33
42
|
await withConfigFile({ harnesses: ["claude-code"] }, async (path) => {
|
|
34
43
|
const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
|