pennyrouter 0.3.20 → 0.3.22
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 +57 -25
- package/src/cli.test.js +10 -1
- package/src/launch.js +28 -3
- package/src/launch.test.js +8 -0
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,32 @@ async function update(flags) {
|
|
|
1266
1278
|
return;
|
|
1267
1279
|
}
|
|
1268
1280
|
|
|
1269
|
-
const
|
|
1270
|
-
const
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|| globalAuth.
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1281
|
+
const updateContexts = new Map();
|
|
1282
|
+
for (const record of targets) {
|
|
1283
|
+
const profile = record.profile || profileFromManifestRecord(record) || flags.profile;
|
|
1284
|
+
const launchConfig = await loadPennyConfig({ profile });
|
|
1285
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
1286
|
+
const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
|
|
1287
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl
|
|
1288
|
+
|| launchConfig.gateway_base_url
|
|
1289
|
+
|| globalAuth.gatewayBaseUrl
|
|
1290
|
+
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
1291
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
1292
|
+
if (!apiKey) {
|
|
1293
|
+
const suffix = profile ? ` for profile "${profile}"` : "";
|
|
1294
|
+
throw new Error(
|
|
1295
|
+
`Could not find your PennyRouter key in the local config${suffix}. `
|
|
1296
|
+
+ "Pass `--penny-key pr-...`, or run `npx pennyrouter install` to re-authorize.",
|
|
1297
|
+
);
|
|
1298
|
+
}
|
|
1299
|
+
updateContexts.set(record, { profile, apiKey, gatewayBaseUrl });
|
|
1283
1300
|
}
|
|
1284
1301
|
|
|
1285
1302
|
console.log("PennyRouter update");
|
|
1286
1303
|
console.log("");
|
|
1287
|
-
|
|
1304
|
+
for (const { profile, apiKey, gatewayBaseUrl } of updateContexts.values()) {
|
|
1305
|
+
console.log(`${profile ? `Profile ${profile} · ` : ""}Key ${maskKey(apiKey)} · gateway ${gatewayBaseUrl}`);
|
|
1306
|
+
}
|
|
1288
1307
|
console.log("Subscription and API keys are left untouched.");
|
|
1289
1308
|
console.log("");
|
|
1290
1309
|
|
|
@@ -1307,6 +1326,7 @@ async function update(flags) {
|
|
|
1307
1326
|
for (const record of targets) {
|
|
1308
1327
|
const harness = HARNESS_BY_ID[record.harness];
|
|
1309
1328
|
if (!harness) continue;
|
|
1329
|
+
const { profile, apiKey, gatewayBaseUrl } = updateContexts.get(record);
|
|
1310
1330
|
|
|
1311
1331
|
await runHarnessJob(failures, record.harness, "update", async () => {
|
|
1312
1332
|
const supportsPennyCommand = LAUNCHABLE_HARNESS_IDS.includes(record.harness);
|
|
@@ -1333,7 +1353,7 @@ async function update(flags) {
|
|
|
1333
1353
|
? claudeSubscriptionAuth
|
|
1334
1354
|
: codexSubscriptionAuth,
|
|
1335
1355
|
models: record.harness === "claude-code" ? CLAUDE_CODE_MODELS : null,
|
|
1336
|
-
profile
|
|
1356
|
+
profile,
|
|
1337
1357
|
});
|
|
1338
1358
|
}
|
|
1339
1359
|
|
|
@@ -1368,9 +1388,13 @@ async function update(flags) {
|
|
|
1368
1388
|
// and `pennyrouter mcp uninstall` removes it for good.
|
|
1369
1389
|
if (!flags.dryRun && !flags.noMcp) {
|
|
1370
1390
|
const wanted = targets.filter((record) => MCP_HARNESS_IDS.includes(record.harness));
|
|
1371
|
-
const results =
|
|
1372
|
-
|
|
1373
|
-
|
|
1391
|
+
const results = [];
|
|
1392
|
+
for (const record of wanted) {
|
|
1393
|
+
const context = updateContexts.get(record);
|
|
1394
|
+
results.push(...await registerMcp([record.harness], {
|
|
1395
|
+
gatewayBaseUrl: customGatewayBaseUrl(context.gatewayBaseUrl),
|
|
1396
|
+
}));
|
|
1397
|
+
}
|
|
1374
1398
|
const added = results.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
|
|
1375
1399
|
if (added.length) console.log(`Registered the Threads MCP server on ${added.join(" and ")}.`);
|
|
1376
1400
|
for (const result of results.filter((r) => r.error)) {
|
|
@@ -2277,6 +2301,14 @@ function upsertManifestRecord(manifest, record) {
|
|
|
2277
2301
|
manifest.installations.push(record);
|
|
2278
2302
|
}
|
|
2279
2303
|
|
|
2304
|
+
// Older manifests predate profile ownership. Codex's per-profile launch filename gives us a
|
|
2305
|
+
// reliable migration signal; Claude's launch settings are shared, so it follows active_profile.
|
|
2306
|
+
function profileFromManifestRecord(record) {
|
|
2307
|
+
const launchPath = String(record.launch_config_path || "");
|
|
2308
|
+
const match = launchPath.match(/pennyrouter-([^/\\.]+)\.config\.toml$/);
|
|
2309
|
+
return match ? match[1] : null;
|
|
2310
|
+
}
|
|
2311
|
+
|
|
2280
2312
|
function pruneUnsupportedManifestRecords(manifest) {
|
|
2281
2313
|
manifest.installations ||= [];
|
|
2282
2314
|
const kept = manifest.installations.filter((record) => HARNESS_BY_ID[record.harness]);
|
|
@@ -2442,7 +2474,7 @@ function printHelp() {
|
|
|
2442
2474
|
|
|
2443
2475
|
Usage:
|
|
2444
2476
|
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]
|
|
2477
|
+
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
2478
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
2447
2479
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
2448
2480
|
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"]));
|
package/src/launch.js
CHANGED
|
@@ -806,9 +806,26 @@ export function startPennyTerminalTitle({ gatewayBaseUrl, apiKey, titleSessionId
|
|
|
806
806
|
};
|
|
807
807
|
}
|
|
808
808
|
|
|
809
|
-
async function writeClaudeLaunchSettings() {
|
|
809
|
+
async function writeClaudeLaunchSettings({ gatewayBaseUrl, apiKey } = {}) {
|
|
810
810
|
const native = (await readJsonFile(join(homeDir(), ".claude", "settings.json"), null)) || {};
|
|
811
|
-
|
|
811
|
+
// Claude Code gives values from --settings precedence over the parent process
|
|
812
|
+
// environment. Keep this overlay authoritative so a separately-managed
|
|
813
|
+
// ~/.claude/settings.json (for example scripts/proxy/claude-proxy.sh) cannot
|
|
814
|
+
// redirect a `penny claude` session away from the selected Penny profile.
|
|
815
|
+
const overlay = {
|
|
816
|
+
disableClaudeAiConnectors: true,
|
|
817
|
+
...(gatewayBaseUrl && apiKey
|
|
818
|
+
? {
|
|
819
|
+
env: {
|
|
820
|
+
ANTHROPIC_BASE_URL: gatewayBaseUrl,
|
|
821
|
+
ANTHROPIC_AUTH_TOKEN: apiKey,
|
|
822
|
+
// An empty value is treated as unset by Claude Code, and prevents a
|
|
823
|
+
// stale API key in the shared settings from creating dual auth.
|
|
824
|
+
ANTHROPIC_API_KEY: "",
|
|
825
|
+
},
|
|
826
|
+
}
|
|
827
|
+
: {}),
|
|
828
|
+
};
|
|
812
829
|
if (native.statusLine) overlay.statusLine = native.statusLine;
|
|
813
830
|
await configureClaudeCodeStatusline(overlay);
|
|
814
831
|
await privateWrite(claudeLaunchSettingsPath(), `${JSON.stringify(overlay, null, 2)}\n`);
|
|
@@ -874,7 +891,7 @@ export async function configurePennyHarness(harness, {
|
|
|
874
891
|
config.harnesses ||= {};
|
|
875
892
|
if (harness === "claude-code") {
|
|
876
893
|
await writeClaudeUserSkill();
|
|
877
|
-
await writeClaudeLaunchSettings();
|
|
894
|
+
await writeClaudeLaunchSettings({ gatewayBaseUrl, apiKey });
|
|
878
895
|
config.harnesses.claude = {
|
|
879
896
|
enabled: true,
|
|
880
897
|
subscription_auth: Boolean(subscriptionAuth),
|
|
@@ -1268,6 +1285,14 @@ export async function runPenny(argv = process.argv.slice(2)) {
|
|
|
1268
1285
|
profile: config.profile,
|
|
1269
1286
|
});
|
|
1270
1287
|
}
|
|
1288
|
+
if (harness === "claude") {
|
|
1289
|
+
// Refresh on every launch so profile selection is honored immediately,
|
|
1290
|
+
// including --private, --cloud, and named profiles.
|
|
1291
|
+
await writeClaudeLaunchSettings({
|
|
1292
|
+
gatewayBaseUrl: config.gateway_base_url,
|
|
1293
|
+
apiKey: config.api_key,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1271
1296
|
const models = harness === "opencode" ? await resolveOpenCodeLaunchModels(config) : null;
|
|
1272
1297
|
const invocation = buildPennyInvocation(harness, argv.slice(1), config, models);
|
|
1273
1298
|
const child = spawn(invocation.command, invocation.args, {
|
package/src/launch.test.js
CHANGED
|
@@ -123,6 +123,14 @@ async function main() {
|
|
|
123
123
|
});
|
|
124
124
|
|
|
125
125
|
assert.equal(fs.readFileSync(path.join(home, ".claude", "settings.json"), "utf8"), nativeClaude);
|
|
126
|
+
const claudeLaunchSettings = JSON.parse(
|
|
127
|
+
fs.readFileSync(claudeLaunchSettingsPath(), "utf8"),
|
|
128
|
+
);
|
|
129
|
+
assert.deepEqual(claudeLaunchSettings.env, {
|
|
130
|
+
ANTHROPIC_BASE_URL: "https://gateway.example/",
|
|
131
|
+
ANTHROPIC_AUTH_TOKEN: "pr-super-secret",
|
|
132
|
+
ANTHROPIC_API_KEY: "",
|
|
133
|
+
});
|
|
126
134
|
assert.equal(fs.readFileSync(path.join(home, ".codex", "config.toml"), "utf8"), nativeCodex);
|
|
127
135
|
assert.equal(await fileMode(pennyConfigPath()), 0o600);
|
|
128
136
|
assert.equal(await fileMode(claudeLaunchSettingsPath()), 0o600);
|