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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pennyrouter",
3
- "version": "0.3.21",
3
+ "version": "0.3.23",
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
@@ -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
- // A self-hosted gateway is a distinct identity with its own key store, so give it its own
324
- // profile unless one was named. Hosted installs keep the default profile.
325
- if (!flags.profile) flags.profile = flags.privateGateway ? "private" : DEFAULT_PROFILE;
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 launchConfig = await loadPennyConfig({ profile: flags.profile });
1270
- const globalAuth = await readClaudeCodeGatewayAuth();
1271
- const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
1272
- const gatewayBaseUrl = flags.gatewayBaseUrl
1273
- || launchConfig.gateway_base_url
1274
- || globalAuth.gatewayBaseUrl
1275
- || process.env.PENNYROUTER_GATEWAY_BASE_URL
1276
- || DEFAULT_GATEWAY_BASE_URL;
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
- if (!apiKey) {
1279
- throw new Error(
1280
- "Could not find your PennyRouter key in the local config. Pass `--penny-key pr-...`, "
1281
- + "or run `npx pennyrouter install` to re-authorize.",
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
- console.log(`Key ${maskKey(apiKey)} · gateway ${gatewayBaseUrl}`);
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: flags.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 = await registerMcp(wanted.map((record) => record.harness), {
1372
- gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
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, SUPPORTED_HARNESS_IDS,
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"]));