pennyrouter 0.3.0 → 0.3.1
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/README.md +14 -9
- package/package.json +2 -2
- package/src/cli.js +563 -132
- package/src/cli.test.js +196 -0
- package/src/harnesses/claude-code.js +81 -32
- package/src/harnesses/opencode.js +82 -8
- package/src/harnesses/opencode.test.js +75 -0
- package/src/launch.js +260 -28
- package/src/launch.test.js +47 -1
- package/src/mcp-register.js +212 -0
- package/src/mcp-register.test.js +200 -0
- package/src/mcp.js +375 -0
- package/src/mcp.test.js +259 -0
- package/src/session.js +9 -5
- package/src/statusline.js +73 -5
- package/src/thread-session-codex.js +218 -0
- package/src/thread-session-codex.test.js +151 -0
- package/src/thread-session.js +215 -0
- package/src/thread-session.test.js +151 -0
package/src/cli.js
CHANGED
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
import { spawn, spawnSync } from "node:child_process";
|
|
2
|
+
import { readFileSync } from "node:fs";
|
|
2
3
|
import { emitKeypressEvents } from "node:readline";
|
|
3
4
|
import { createInterface } from "node:readline/promises";
|
|
4
5
|
import { stdin as input, stdout as output } from "node:process";
|
|
5
6
|
import { aiderHarness } from "./harnesses/aider.js";
|
|
7
|
+
import { runMcpServer } from "./mcp.js";
|
|
8
|
+
import { mcpStatus, registerMcp } from "./mcp-register.js";
|
|
9
|
+
|
|
10
|
+
/** The harnesses that can run an MCP server. The other supported harnesses cannot. */
|
|
11
|
+
const MCP_HARNESS_IDS = ["claude-code", "codex"];
|
|
12
|
+
const DEFAULT_GATEWAY_BASE_URL = "https://api.pennyrouter.com";
|
|
6
13
|
import {
|
|
7
14
|
CLAUDE_CODE_MODELS,
|
|
8
15
|
claudeCodeHarness,
|
|
@@ -19,6 +26,7 @@ import {
|
|
|
19
26
|
APP_URL,
|
|
20
27
|
createCliSession,
|
|
21
28
|
forgetAnthropicToken,
|
|
29
|
+
forgetUpstreamCredential,
|
|
22
30
|
maskKey,
|
|
23
31
|
pollForKey,
|
|
24
32
|
probeClaudeCodeGateway,
|
|
@@ -60,15 +68,17 @@ const HARNESS_BY_ID = {
|
|
|
60
68
|
};
|
|
61
69
|
|
|
62
70
|
const SUPPORTED_HARNESS_IDS = Object.keys(HARNESS_BY_ID).join(",");
|
|
63
|
-
const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex"];
|
|
71
|
+
const DEFAULT_INSTALL_HARNESS_IDS = ["claude-code", "codex", "opencode"];
|
|
64
72
|
const CLAUDE_PROVIDER_OPTIONS = [
|
|
65
73
|
{ id: "subscription", name: "Claude subscription" },
|
|
66
74
|
{ id: "anthropic-api", name: "Anthropic API key" },
|
|
67
75
|
{ id: "openrouter", name: "OpenRouter API key" },
|
|
76
|
+
{ id: "anthropic-proxy", name: "Other proxy (custom base URL)" },
|
|
68
77
|
];
|
|
69
78
|
const CODEX_PROVIDER_OPTIONS = [
|
|
70
79
|
{ id: "subscription", name: "ChatGPT subscription" },
|
|
71
80
|
{ id: "openai-api", name: "OpenAI API key" },
|
|
81
|
+
{ id: "openai-proxy", name: "Other OpenAI-compatible proxy" },
|
|
72
82
|
{ id: "openrouter", name: "OpenRouter API key" },
|
|
73
83
|
];
|
|
74
84
|
|
|
@@ -115,6 +125,17 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
115
125
|
return;
|
|
116
126
|
}
|
|
117
127
|
|
|
128
|
+
if (command === "mcp") {
|
|
129
|
+
// Bare `mcp` is the server itself, started by a harness over stdio. The subcommands are for
|
|
130
|
+
// a person: registering after --no-mcp, retrying after a name conflict, or checking state.
|
|
131
|
+
const sub = (flags.args[0] || "").toLowerCase();
|
|
132
|
+
if (sub === "install" || sub === "add") await installMcp(flags);
|
|
133
|
+
else if (sub === "uninstall" || sub === "remove") await uninstallMcp(flags);
|
|
134
|
+
else if (sub === "status" || sub === "list") await statusMcp();
|
|
135
|
+
else await serveMcp(flags);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
118
139
|
throw new Error(`Unknown command "${command}". Run "pennyrouter help".`);
|
|
119
140
|
}
|
|
120
141
|
|
|
@@ -135,6 +156,7 @@ function parseArgs(argv) {
|
|
|
135
156
|
else if (arg === "--make-default") flags.makeDefault = true;
|
|
136
157
|
else if (arg === "--penny-only") flags.pennyOnly = true;
|
|
137
158
|
else if (arg === "--no-path-update") flags.noPathUpdate = true;
|
|
159
|
+
else if (arg === "--no-mcp") flags.noMcp = true;
|
|
138
160
|
else if (arg === "--no-browser") flags.noBrowser = true;
|
|
139
161
|
else if (arg === "--existing-account") flags.existingAccount = true;
|
|
140
162
|
else if (arg === "--anthropic-auth") flags.anthropicAuth = true;
|
|
@@ -150,28 +172,129 @@ function parseArgs(argv) {
|
|
|
150
172
|
else if (arg === "--openai-api-key") flags.openaiApiKey = rest[++i] || "";
|
|
151
173
|
else if (arg.startsWith("--openai-api-key=")) flags.openaiApiKey = arg.slice("--openai-api-key=".length);
|
|
152
174
|
else if (arg === "--openrouter-api-key") flags.openrouterApiKey = rest[++i] || "";
|
|
175
|
+
else if (arg === "--openai-proxy-base-url") flags.openaiProxyBaseUrl = rest[++i] || "";
|
|
176
|
+
else if (arg.startsWith("--openai-proxy-base-url=")) flags.openaiProxyBaseUrl = arg.slice("--openai-proxy-base-url=".length);
|
|
177
|
+
else if (arg === "--openai-proxy-api-key") flags.openaiProxyApiKey = rest[++i] || "";
|
|
178
|
+
else if (arg.startsWith("--openai-proxy-api-key=")) flags.openaiProxyApiKey = arg.slice("--openai-proxy-api-key=".length);
|
|
179
|
+
else if (arg === "--proxy-base-url") flags.proxyBaseUrl = rest[++i] || "";
|
|
180
|
+
else if (arg.startsWith("--proxy-base-url=")) flags.proxyBaseUrl = arg.slice("--proxy-base-url=".length);
|
|
181
|
+
else if (arg === "--proxy-api-key") flags.proxyApiKey = rest[++i] || "";
|
|
182
|
+
else if (arg.startsWith("--proxy-api-key=")) flags.proxyApiKey = arg.slice("--proxy-api-key=".length);
|
|
153
183
|
else if (arg.startsWith("--openrouter-api-key=")) flags.openrouterApiKey = arg.slice("--openrouter-api-key=".length);
|
|
154
184
|
else if (arg === "--harness") flags.harness = rest[++i] || "";
|
|
155
185
|
else if (arg.startsWith("--harness=")) flags.harness = arg.slice("--harness=".length);
|
|
156
186
|
else if (arg === "--app-url") flags.appUrl = rest[++i] || "";
|
|
157
187
|
else if (arg.startsWith("--app-url=")) flags.appUrl = arg.slice("--app-url=".length);
|
|
158
|
-
|
|
159
|
-
|
|
188
|
+
// A --gateway-base-url is a *private* gateway: an admin-run deployment that issues its own
|
|
189
|
+
// keys. Flag it so install can require the matching admin-issued key. --local/--prod are
|
|
190
|
+
// developer shortcuts against gateways that honor prod keys, so they do not set this.
|
|
191
|
+
else if (arg === "--gateway-base-url") { flags.gatewayBaseUrl = rest[++i] || ""; flags.privateGateway = true; }
|
|
192
|
+
else if (arg.startsWith("--gateway-base-url=")) {
|
|
193
|
+
flags.gatewayBaseUrl = arg.slice("--gateway-base-url=".length);
|
|
194
|
+
flags.privateGateway = true;
|
|
195
|
+
}
|
|
196
|
+
// Admin-issued key for a private gateway (see scripts/provision_local_key.py). Overrides the
|
|
197
|
+
// key returned by the browser claim, which is only valid against the hosted gateway.
|
|
198
|
+
else if (arg === "--private-key") flags.privateKey = rest[++i] || "";
|
|
199
|
+
else if (arg.startsWith("--private-key=")) flags.privateKey = arg.slice("--private-key=".length);
|
|
160
200
|
// --local: point the installed harness at a locally-running gateway (default
|
|
161
201
|
// http://localhost:8400), for developing/testing against your own gateway. Auth still goes
|
|
162
202
|
// to prod (pennyrouter.com) so you get a real key; only the harness's serving URL is local.
|
|
163
203
|
// `--local=<url>` overrides the port/host.
|
|
164
204
|
else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
|
|
165
205
|
else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
|
|
206
|
+
// --prod: point the installed harness back at the default production gateway
|
|
207
|
+
// (https://api.pennyrouter.com). Undoes --local.
|
|
208
|
+
else if (arg === "--prod") flags.gatewayBaseUrl = DEFAULT_GATEWAY_BASE_URL;
|
|
166
209
|
else if (arg === "--forget") flags.forget = true;
|
|
167
210
|
else if (arg === "--forget-token") flags.forgetToken = true;
|
|
211
|
+
else if (arg === "--config") flags.configPath = rest[++i] || "";
|
|
212
|
+
else if (arg.startsWith("--config=")) flags.configPath = arg.slice("--config=".length);
|
|
168
213
|
else if (!arg.startsWith("-")) flags.args.push(arg);
|
|
169
214
|
else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
|
|
170
215
|
}
|
|
171
216
|
|
|
217
|
+
if (flags.configPath) applyConfigFile(flags);
|
|
218
|
+
|
|
172
219
|
return { command, flags };
|
|
173
220
|
}
|
|
174
221
|
|
|
222
|
+
// Lets a GUI installer (e.g. the macOS DMG) answer the install questions up front instead of
|
|
223
|
+
// over a TTY: pick harnesses, pick funding per harness, optionally supply keys. Populates the
|
|
224
|
+
// same `flags` fields the equivalent CLI flags set, so it flows through the existing
|
|
225
|
+
// non-interactive paths unchanged. CLI flags win over the config file where both are given.
|
|
226
|
+
// Funding choices imply --yes (skip remaining prompts) but, unlike bare --yes, do NOT suppress
|
|
227
|
+
// the Claude subscription browser sign-in — see maybeConfigureClaudeAnthropicAuth.
|
|
228
|
+
function applyConfigFile(flags) {
|
|
229
|
+
let config;
|
|
230
|
+
try {
|
|
231
|
+
config = JSON.parse(readFileSync(flags.configPath, "utf8"));
|
|
232
|
+
} catch (error) {
|
|
233
|
+
throw new Error(`Could not read --config file "${flags.configPath}": ${error.message}`);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (config.harnesses && flags.harness === undefined) {
|
|
237
|
+
flags.harness = config.harnesses.join(",");
|
|
238
|
+
}
|
|
239
|
+
const claudeFunding = new Set(config.claudeFunding || []);
|
|
240
|
+
const codexFunding = new Set(config.codexFunding || []);
|
|
241
|
+
if (claudeFunding.has("subscription") && flags.anthropicAuth === undefined) {
|
|
242
|
+
flags.anthropicAuth = true;
|
|
243
|
+
}
|
|
244
|
+
if (claudeFunding.has("anthropic-api") && flags.anthropicApiKey === undefined) {
|
|
245
|
+
flags.anthropicApiKey = config.anthropicApiKey || "";
|
|
246
|
+
}
|
|
247
|
+
if ((claudeFunding.has("openrouter") || codexFunding.has("openrouter"))
|
|
248
|
+
&& flags.openrouterApiKey === undefined) {
|
|
249
|
+
flags.openrouterApiKey = config.openrouterApiKey || "";
|
|
250
|
+
}
|
|
251
|
+
if (codexFunding.has("openai-api") && flags.openaiApiKey === undefined) {
|
|
252
|
+
flags.openaiApiKey = config.openaiApiKey || "";
|
|
253
|
+
}
|
|
254
|
+
if (claudeFunding.has("anthropic-proxy")) {
|
|
255
|
+
if (flags.proxyBaseUrl === undefined) flags.proxyBaseUrl = config.proxyBaseUrl || "";
|
|
256
|
+
if (flags.proxyApiKey === undefined) flags.proxyApiKey = config.proxyApiKey || "";
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
flags.yes = true;
|
|
260
|
+
flags.fromConfig = true;
|
|
261
|
+
flags.configClaudeFunding = claudeFunding;
|
|
262
|
+
flags.configCodexFunding = codexFunding;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
// A harness needs the account-claim/funding flow only if it's new, or if the funding the
|
|
266
|
+
// config asked for differs from what's already stored on it — an unchanged re-run of
|
|
267
|
+
// --config should behave like `update`, not force a fresh Claude/PennyRouter sign-in.
|
|
268
|
+
// Plain (non--config) installs call every selected harness "changed" by always passing an
|
|
269
|
+
// empty requested set here, so this only takes effect on the --config path (see `needsAuth`
|
|
270
|
+
// in install()).
|
|
271
|
+
export function harnessFundingChanged(flags, harness, manifest) {
|
|
272
|
+
const existingRecord = (manifest.installations || []).find(
|
|
273
|
+
(candidate) => candidate.harness === harness.id,
|
|
274
|
+
);
|
|
275
|
+
if (!existingRecord) return true;
|
|
276
|
+
|
|
277
|
+
if (harness.id === "claude-code") {
|
|
278
|
+
const requested = flags.configClaudeFunding || new Set();
|
|
279
|
+
if (requested.size === 0) return false;
|
|
280
|
+
const current = existingRecord.user_funded_provider === "claude-subscription"
|
|
281
|
+
? "subscription"
|
|
282
|
+
: existingRecord.user_funded_provider;
|
|
283
|
+
return requested.size !== 1 || !requested.has(current);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (harness.id === "codex") {
|
|
287
|
+
const requested = flags.configCodexFunding || new Set();
|
|
288
|
+
if (requested.size === 0) return false;
|
|
289
|
+
const current = existingRecord.openai_subscription_auth
|
|
290
|
+
? "subscription"
|
|
291
|
+
: existingRecord.user_funded_provider;
|
|
292
|
+
return requested.size !== 1 || !requested.has(current);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return false;
|
|
296
|
+
}
|
|
297
|
+
|
|
175
298
|
async function authProvider(flags) {
|
|
176
299
|
const provider = (flags.args[0] || "anthropic").toLowerCase();
|
|
177
300
|
if (provider !== "anthropic" && provider !== "claude") {
|
|
@@ -186,11 +309,21 @@ async function authProvider(flags) {
|
|
|
186
309
|
"No PennyRouter key found in Claude Code settings. Pass `--penny-key pr-...`.",
|
|
187
310
|
);
|
|
188
311
|
}
|
|
189
|
-
|
|
312
|
+
// Resolve against the INSTALLED config before falling back to the prod default.
|
|
313
|
+
// This is a destructive call against whichever gateway it reaches, so defaulting to
|
|
314
|
+
// production while the user is working against a local gateway would silently delete
|
|
315
|
+
// the wrong account's token (and leave the local one in place, looking like a no-op).
|
|
316
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
317
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl
|
|
318
|
+
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
319
|
+
|| launchConfig.gateway_base_url
|
|
320
|
+
|| globalAuth.gatewayBaseUrl
|
|
321
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
190
322
|
if (flags.dryRun) {
|
|
191
323
|
console.log(`Will delete stored Anthropic token from gateway: ${gatewayBaseUrl}`);
|
|
192
324
|
return;
|
|
193
325
|
}
|
|
326
|
+
console.log(`Gateway: ${gatewayBaseUrl}`);
|
|
194
327
|
const ok = await forgetAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey });
|
|
195
328
|
if (ok) {
|
|
196
329
|
console.log("Successfully forgot stored Anthropic token on server.");
|
|
@@ -238,7 +371,7 @@ async function authProvider(flags) {
|
|
|
238
371
|
);
|
|
239
372
|
}
|
|
240
373
|
|
|
241
|
-
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL ||
|
|
374
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL || DEFAULT_GATEWAY_BASE_URL;
|
|
242
375
|
const manifest = await loadManifest();
|
|
243
376
|
const existingRecord = (manifest.installations || []).find(
|
|
244
377
|
(candidate) => candidate.harness === "claude-code",
|
|
@@ -246,12 +379,9 @@ async function authProvider(flags) {
|
|
|
246
379
|
const ownsGlobalRouting = existingRecord
|
|
247
380
|
? existingRecord.default_routing !== false
|
|
248
381
|
: Boolean(await readClaudeCodePennyRouterKey());
|
|
249
|
-
if (ownsGlobalRouting) assertNoRunningHarness("claude-code", flags);
|
|
250
382
|
await storeAnthropicToken({ gatewayBaseUrl, apiKey: pennyKey, token });
|
|
251
383
|
await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: pennyKey });
|
|
252
|
-
|
|
253
|
-
await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
254
|
-
}
|
|
384
|
+
await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
255
385
|
await configurePennyHarness("claude-code", {
|
|
256
386
|
apiKey: pennyKey,
|
|
257
387
|
gatewayBaseUrl,
|
|
@@ -384,7 +514,10 @@ function normalizeAnthropicToken(value) {
|
|
|
384
514
|
return text.replace(/\s+/g, "").trim();
|
|
385
515
|
}
|
|
386
516
|
|
|
387
|
-
|
|
517
|
+
// Public programmatic entry point used by the signed desktop setup app. Keeping this
|
|
518
|
+
// here means every installer surface shares the same authorization, backup, config,
|
|
519
|
+
// update, and uninstall implementation.
|
|
520
|
+
export async function install(flags = {}) {
|
|
388
521
|
if (flags.makeDefault && flags.pennyOnly) {
|
|
389
522
|
throw new Error("--make-default and --penny-only cannot be used together.");
|
|
390
523
|
}
|
|
@@ -402,8 +535,9 @@ async function install(flags) {
|
|
|
402
535
|
if (flags.dryRun) {
|
|
403
536
|
const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
|
|
404
537
|
for (const harness of selected) {
|
|
405
|
-
const explicit = ["claude-code", "codex"].includes(harness.id)
|
|
406
|
-
const command = harness.id === "claude-code"
|
|
538
|
+
const explicit = ["claude-code", "codex", "opencode"].includes(harness.id);
|
|
539
|
+
const command = harness.id === "claude-code"
|
|
540
|
+
? "penny claude" : harness.id === "codex" ? "penny codex" : "penny opencode";
|
|
407
541
|
console.log(`Will install on ${harness.name}${explicit ? ` via ${command}` : installNoteForHarness(harness.id)}.`);
|
|
408
542
|
if (defaultRouting.get(harness.id)) {
|
|
409
543
|
console.log(`Will also route bare ${harness.id === "claude-code" ? "claude" : harness.id} through PennyRouter.`);
|
|
@@ -417,40 +551,116 @@ async function install(flags) {
|
|
|
417
551
|
return;
|
|
418
552
|
}
|
|
419
553
|
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
554
|
+
// A --config re-run against an existing install only needs a fresh account claim and
|
|
555
|
+
// funding popups for harnesses that are actually new or whose requested funding differs
|
|
556
|
+
// from what's already stored — an unchanged harness should behave like `update`, not
|
|
557
|
+
// trigger the Claude/PennyRouter sign-in windows again. Plain (non-config) installs always
|
|
558
|
+
// treat everything selected as needing auth, matching the original behavior.
|
|
559
|
+
const needsAuth = flags.fromConfig
|
|
560
|
+
? selected.filter((harness) => harnessFundingChanged(flags, harness, manifest))
|
|
561
|
+
: selected;
|
|
425
562
|
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
console.log("Waiting for approval...");
|
|
431
|
-
|
|
432
|
-
const claim = await pollForKey(session);
|
|
433
|
-
console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
|
|
434
|
-
console.log("");
|
|
563
|
+
let claim;
|
|
564
|
+
let claudeAnthropicAuth = null;
|
|
565
|
+
let funding = { credentials: [], codexSubscriptionAuth: false, claudeCredentialProvider: null, codexCredentialProvider: null };
|
|
566
|
+
let gatewayBaseUrl;
|
|
435
567
|
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
flags
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
568
|
+
if (needsAuth.length === 0) {
|
|
569
|
+
const launchConfig = await loadPennyConfig();
|
|
570
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
571
|
+
const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
|
|
572
|
+
if (!apiKey) {
|
|
573
|
+
throw new Error(
|
|
574
|
+
"Could not find your PennyRouter key in the local config. Pass `--penny-key pr-...`, "
|
|
575
|
+
+ "or remove --config to re-authorize.",
|
|
576
|
+
);
|
|
577
|
+
}
|
|
578
|
+
console.log("No new tools or funding requested — refreshing your existing setup.");
|
|
579
|
+
console.log("");
|
|
580
|
+
claim = { api_key: apiKey, gateway_base_url: null };
|
|
581
|
+
gatewayBaseUrl = flags.gatewayBaseUrl
|
|
582
|
+
|| launchConfig.gateway_base_url
|
|
583
|
+
|| globalAuth.gatewayBaseUrl
|
|
584
|
+
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
585
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
586
|
+
} else {
|
|
587
|
+
const session = await createCliSession({
|
|
588
|
+
appUrl: flags.appUrl || APP_URL,
|
|
589
|
+
harnesses: selected.map((harness) => harness.id),
|
|
590
|
+
accountMode: "existing",
|
|
450
591
|
});
|
|
592
|
+
|
|
593
|
+
console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
|
|
594
|
+
console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
|
|
595
|
+
if (!flags.noBrowser) openBrowser(session.authorize_url);
|
|
596
|
+
console.log("");
|
|
597
|
+
console.log("Waiting for approval...");
|
|
598
|
+
|
|
599
|
+
claim = await pollForKey(session);
|
|
600
|
+
console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
|
|
601
|
+
console.log("");
|
|
602
|
+
|
|
603
|
+
const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
|
|
604
|
+
const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
|
|
605
|
+
claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
|
|
606
|
+
flags, needsAuth, claudeProviders.has("subscription"));
|
|
607
|
+
funding = await configureUserFunding(
|
|
608
|
+
flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
|
|
609
|
+
gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
|
|
610
|
+
|
|
611
|
+
// A user-supplied proxy may live on a private network that the hosted gateway cannot
|
|
612
|
+
// reach. Let interactive installs choose the gateway after they choose the proxy: blank
|
|
613
|
+
// keeps the hosted default, while a supplied URL is used for credential storage,
|
|
614
|
+
// validation, probing, and the installed harness configuration. `--local` remains the
|
|
615
|
+
// explicit localhost:8400 developer shortcut and skips this prompt.
|
|
616
|
+
if ((claudeProviders.has("anthropic-proxy") || codexProviders.has("openai-proxy")) && !flags.gatewayBaseUrl
|
|
617
|
+
&& !flags.yes && input.isTTY && output.isTTY) {
|
|
618
|
+
const privateGateway = (await promptText(
|
|
619
|
+
"PennyRouter gateway URL for this proxy (blank for hosted gateway): ",
|
|
620
|
+
)).trim();
|
|
621
|
+
if (privateGateway) {
|
|
622
|
+
gatewayBaseUrl = privateGateway;
|
|
623
|
+
flags.privateGateway = true;
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
|
|
627
|
+
// A private gateway has its own key store, so the hosted key from the browser claim is
|
|
628
|
+
// meaningless there. Require the admin-issued key and use it for every subsequent gateway
|
|
629
|
+
// call (credential storage, probing, harness config).
|
|
630
|
+
if (flags.privateGateway) {
|
|
631
|
+
let privateKey = (flags.privateKey || "").trim();
|
|
632
|
+
if (!privateKey && !flags.yes && input.isTTY && output.isTTY) {
|
|
633
|
+
console.log("");
|
|
634
|
+
console.log(`Private gateway: ${gatewayBaseUrl}`);
|
|
635
|
+
console.log("Your PennyRouter administrator issues keys for this gateway.");
|
|
636
|
+
privateKey = (await promptText("Paste your private gateway key (pr-...): ")).trim();
|
|
637
|
+
}
|
|
638
|
+
if (!privateKey) {
|
|
639
|
+
throw new Error(
|
|
640
|
+
`A private gateway (${gatewayBaseUrl}) needs its own key. `
|
|
641
|
+
+ "Ask your PennyRouter administrator for one and pass `--private-key pr-...`.",
|
|
642
|
+
);
|
|
643
|
+
}
|
|
644
|
+
if (!privateKey.startsWith("pr-")) {
|
|
645
|
+
throw new Error(`That does not look like a PennyRouter key — expected it to start with "pr-".`);
|
|
646
|
+
}
|
|
647
|
+
claim.api_key = privateKey;
|
|
648
|
+
console.log(`Using private gateway key ${maskKey(privateKey)}.`);
|
|
649
|
+
console.log("");
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
for (const credential of funding.credentials) {
|
|
653
|
+
await storeUpstreamCredential({
|
|
654
|
+
gatewayBaseUrl,
|
|
655
|
+
apiKey: claim.api_key,
|
|
656
|
+
provider: credential.provider,
|
|
657
|
+
token: credential.token,
|
|
658
|
+
metadata: credential.metadata,
|
|
659
|
+
});
|
|
660
|
+
}
|
|
451
661
|
}
|
|
452
662
|
|
|
453
|
-
if (
|
|
663
|
+
if (needsAuth.some((harness) => harness.id === "claude-code")) {
|
|
454
664
|
console.log("Verifying modern Claude Code compatibility before changing local settings...");
|
|
455
665
|
await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
|
|
456
666
|
console.log("");
|
|
@@ -461,9 +671,9 @@ async function install(flags) {
|
|
|
461
671
|
// choose the visible command behavior with all relevant context in front of them.
|
|
462
672
|
const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
|
|
463
673
|
|
|
464
|
-
const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
|
|
674
|
+
const launchable = selected.filter((harness) => ["claude-code", "codex", "opencode"].includes(harness.id));
|
|
465
675
|
let runtimeResult = null;
|
|
466
|
-
if (launchable.length > 0
|
|
676
|
+
if (launchable.length > 0) {
|
|
467
677
|
runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
468
678
|
}
|
|
469
679
|
|
|
@@ -475,16 +685,13 @@ async function install(flags) {
|
|
|
475
685
|
const existingRecord = (manifest.installations || []).find(
|
|
476
686
|
(candidate) => candidate.harness === harness.id,
|
|
477
687
|
);
|
|
478
|
-
const supportsPennyCommand = ["claude-code", "codex"].includes(harness.id)
|
|
479
|
-
&& process.platform !== "win32";
|
|
688
|
+
const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(harness.id);
|
|
480
689
|
const makeDefault = defaultRouting.get(harness.id) ?? true;
|
|
481
690
|
const hadGlobalRouting = Boolean(existingRecord && existingRecord.default_routing !== false);
|
|
482
|
-
if (["claude-code", "codex"].includes(harness.id) && (makeDefault || hadGlobalRouting)) {
|
|
483
|
-
assertNoRunningHarness(harness.id, flags);
|
|
484
|
-
}
|
|
485
691
|
|
|
692
|
+
const harnessNeededAuth = needsAuth.includes(harness);
|
|
486
693
|
const codexSubscriptionAuth = harness.id === "codex"
|
|
487
|
-
? funding.codexSubscriptionAuth
|
|
694
|
+
? (harnessNeededAuth ? funding.codexSubscriptionAuth : Boolean(existingRecord?.openai_subscription_auth))
|
|
488
695
|
: false;
|
|
489
696
|
const claudeSubscriptionAuth = harness.id === "claude-code"
|
|
490
697
|
&& Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
|
|
@@ -522,9 +729,10 @@ async function install(flags) {
|
|
|
522
729
|
}
|
|
523
730
|
if (supportsPennyCommand) {
|
|
524
731
|
record.launch_available = true;
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
732
|
+
const launchConfigPath = harness.id === "claude-code"
|
|
733
|
+
? claudeLaunchSettingsPath()
|
|
734
|
+
: harness.id === "codex" ? codexLaunchProfilePath() : null;
|
|
735
|
+
record.launch_config_path = launchConfigPath ? compactHome(launchConfigPath) : null;
|
|
528
736
|
record.default_routing = Boolean(makeDefault);
|
|
529
737
|
}
|
|
530
738
|
if (harness.id === "claude-code" && claudeSubscriptionAuth) {
|
|
@@ -532,11 +740,20 @@ async function install(flags) {
|
|
|
532
740
|
record.anthropic_token_stored = true;
|
|
533
741
|
record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
|
|
534
742
|
}
|
|
535
|
-
if (harness.id === "claude-code"
|
|
536
|
-
record.user_funded_provider =
|
|
743
|
+
if (harness.id === "claude-code") {
|
|
744
|
+
record.user_funded_provider = harnessNeededAuth
|
|
745
|
+
? (funding.claudeCredentialProvider || existingRecord?.user_funded_provider)
|
|
746
|
+
: existingRecord?.user_funded_provider;
|
|
747
|
+
// Host only, never the key — the key lives encrypted on the gateway.
|
|
748
|
+
const proxyBaseUrl = harnessNeededAuth
|
|
749
|
+
? (funding.claudeProxyBaseUrl ?? existingRecord?.proxy_base_url ?? null)
|
|
750
|
+
: (existingRecord?.proxy_base_url ?? null);
|
|
751
|
+
if (proxyBaseUrl) record.proxy_base_url = proxyBaseUrl;
|
|
537
752
|
}
|
|
538
|
-
if (harness.id === "codex"
|
|
539
|
-
record.user_funded_provider =
|
|
753
|
+
if (harness.id === "codex") {
|
|
754
|
+
record.user_funded_provider = harnessNeededAuth
|
|
755
|
+
? (funding.codexCredentialProvider || existingRecord?.user_funded_provider)
|
|
756
|
+
: existingRecord?.user_funded_provider;
|
|
540
757
|
}
|
|
541
758
|
if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
|
|
542
759
|
records.push(record);
|
|
@@ -554,28 +771,149 @@ async function install(flags) {
|
|
|
554
771
|
for (const record of records) {
|
|
555
772
|
const harness = HARNESS_BY_ID[record.harness];
|
|
556
773
|
if (record.launch_available) {
|
|
557
|
-
const command = record.harness === "claude-code"
|
|
558
|
-
|
|
559
|
-
const
|
|
774
|
+
const command = record.harness === "claude-code"
|
|
775
|
+
? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
|
|
776
|
+
const native = record.harness === "claude-code" ? "claude"
|
|
777
|
+
: record.harness === "codex" ? "codex" : "opencode";
|
|
778
|
+
const harnessName = record.harness === "claude-code" ? "Claude Code"
|
|
779
|
+
: record.harness === "codex" ? "Codex" : "OpenCode";
|
|
560
780
|
const commandWidth = Math.max(command.length, native.length) + 2;
|
|
561
781
|
console.log("");
|
|
562
782
|
console.log(color(`How to Run ${harnessName}`, "bold"));
|
|
563
783
|
console.log("");
|
|
564
784
|
console.log(` ${color(command.padEnd(commandWidth), "bold")}→ ${harnessName} with PennyRouter`);
|
|
565
|
-
console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName}
|
|
785
|
+
console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName}` : `${harnessName} native`}`);
|
|
566
786
|
} else {
|
|
567
787
|
console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
|
|
568
788
|
}
|
|
569
789
|
}
|
|
570
790
|
if (runtimeResult && !runtimeResult.available) {
|
|
571
|
-
console.log("Add
|
|
791
|
+
console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
|
|
572
792
|
} else if (runtimeResult?.changed) {
|
|
573
793
|
console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
|
|
574
794
|
}
|
|
795
|
+
|
|
796
|
+
// Registered after routing, which is the primary job: a naming conflict here reports itself
|
|
797
|
+
// and leaves a working install rather than failing the whole run.
|
|
798
|
+
if (!flags.noMcp && !flags.dryRun) {
|
|
799
|
+
const mcp = await registerMcp(records.map((r) => r.harness), {
|
|
800
|
+
gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
|
|
801
|
+
});
|
|
802
|
+
const registered = mcp.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
|
|
803
|
+
if (registered.length) {
|
|
804
|
+
console.log("");
|
|
805
|
+
console.log(color("Threads MCP", "bold"));
|
|
806
|
+
console.log(` Registered on ${registered.join(" and ")}.`);
|
|
807
|
+
console.log(" Ask your agent to read or resume an AI share link:");
|
|
808
|
+
console.log(' "code based on this chat: <paste a ChatGPT/Claude/Gemini/Perplexity link>"');
|
|
809
|
+
}
|
|
810
|
+
for (const result of mcp.filter((r) => r.error)) {
|
|
811
|
+
console.log(` MCP registration skipped for ${result.harness}: ${result.error}`);
|
|
812
|
+
}
|
|
813
|
+
}
|
|
814
|
+
|
|
575
815
|
console.log("");
|
|
576
816
|
reportFailures(failures);
|
|
577
817
|
}
|
|
578
818
|
|
|
819
|
+
// Deliberately small, secret-free preflight for graphical installers. Do not add
|
|
820
|
+
// tokens or config contents here: the renderer only needs to know which setup
|
|
821
|
+
// choices are likely to work on this machine.
|
|
822
|
+
export async function getSetupPreflight() {
|
|
823
|
+
const harnesses = await Promise.all(DEFAULT_INSTALL_HARNESS_IDS.map(async (id) => {
|
|
824
|
+
const harness = HARNESS_BY_ID[id];
|
|
825
|
+
return { id, name: harness.name, detected: await safeDetect(harness) };
|
|
826
|
+
}));
|
|
827
|
+
return {
|
|
828
|
+
harnesses,
|
|
829
|
+
codex_chatgpt_logged_in: await detectCodexChatgptAuth(),
|
|
830
|
+
};
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
/* The `mcp install|uninstall|status` subcommands.
|
|
834
|
+
|
|
835
|
+
Install already registers the server, so these exist for the states install cannot reach:
|
|
836
|
+
someone who passed --no-mcp and changed their mind, someone who hit a name conflict and has
|
|
837
|
+
now renamed the other server, and anyone who just wants to know whether it is wired up. */
|
|
838
|
+
|
|
839
|
+
function mcpTargets(flags) {
|
|
840
|
+
const requested = parseHarnessList(flags.harness).filter((id) => MCP_HARNESS_IDS.includes(id));
|
|
841
|
+
return requested.length ? requested : MCP_HARNESS_IDS;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** The gateway URL worth pinning into the MCP entry, or "" for the default.
|
|
845
|
+
The MCP server already falls back to production on its own, so writing that value would only
|
|
846
|
+
freeze today's default into every user's config. */
|
|
847
|
+
function customGatewayBaseUrl(url) {
|
|
848
|
+
const value = String(url || "").trim().replace(/\/+$/, "");
|
|
849
|
+
return !value || value === DEFAULT_GATEWAY_BASE_URL ? "" : value;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async function installMcp(flags) {
|
|
853
|
+
const results = await registerMcp(mcpTargets(flags), {
|
|
854
|
+
gatewayBaseUrl: customGatewayBaseUrl(
|
|
855
|
+
flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL,
|
|
856
|
+
),
|
|
857
|
+
});
|
|
858
|
+
console.log("PennyRouter Threads MCP");
|
|
859
|
+
console.log("");
|
|
860
|
+
for (const result of results) {
|
|
861
|
+
const name = HARNESS_BY_ID[result.harness]?.name || result.harness;
|
|
862
|
+
if (result.error) console.log(` ${name}: ${result.error}`);
|
|
863
|
+
else if (result.changed) console.log(` ${name}: registered`);
|
|
864
|
+
else if (result.reason) console.log(` ${name}: skipped — ${result.reason}`);
|
|
865
|
+
else console.log(` ${name}: already registered`);
|
|
866
|
+
}
|
|
867
|
+
console.log("");
|
|
868
|
+
console.log("Restart any running session, then ask your agent to read or resume a share link.");
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
async function uninstallMcp(flags) {
|
|
872
|
+
const results = await registerMcp(mcpTargets(flags), { remove: true });
|
|
873
|
+
console.log("PennyRouter Threads MCP");
|
|
874
|
+
console.log("");
|
|
875
|
+
for (const result of results) {
|
|
876
|
+
const name = HARNESS_BY_ID[result.harness]?.name || result.harness;
|
|
877
|
+
console.log(` ${name}: ${result.changed ? "removed" : "nothing to remove"}`);
|
|
878
|
+
}
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
async function statusMcp() {
|
|
882
|
+
const rows = await mcpStatus(MCP_HARNESS_IDS);
|
|
883
|
+
console.log("PennyRouter Threads MCP");
|
|
884
|
+
console.log("");
|
|
885
|
+
for (const row of rows) {
|
|
886
|
+
const name = HARNESS_BY_ID[row.harness]?.name || row.harness;
|
|
887
|
+
if (!row.configFound) console.log(` ${name}: not installed on this machine`);
|
|
888
|
+
else if (row.conflict) {
|
|
889
|
+
console.log(` ${name}: a different server is already named "pennyrouter"`);
|
|
890
|
+
} else console.log(` ${name}: ${row.registered ? "registered" : "not registered"}`);
|
|
891
|
+
}
|
|
892
|
+
if (rows.some((row) => row.configFound && !row.registered)) {
|
|
893
|
+
console.log("");
|
|
894
|
+
console.log("Register with: pennyrouter mcp install");
|
|
895
|
+
}
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
/* Serve the Threads MCP tools over stdio. Started by the harness, not by a person: stdout is the
|
|
899
|
+
JSON-RPC channel, so nothing may be printed to it. Diagnostics go to stderr. */
|
|
900
|
+
async function serveMcp(flags) {
|
|
901
|
+
const launchConfig = await loadPennyConfig();
|
|
902
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
903
|
+
const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
|
|
904
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl
|
|
905
|
+
|| launchConfig.gateway_base_url
|
|
906
|
+
|| globalAuth.gatewayBaseUrl
|
|
907
|
+
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
908
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
909
|
+
|
|
910
|
+
await runMcpServer({
|
|
911
|
+
gatewayBaseUrl,
|
|
912
|
+
apiKey,
|
|
913
|
+
harness: process.env.PENNYROUTER_MCP_HARNESS,
|
|
914
|
+
});
|
|
915
|
+
}
|
|
916
|
+
|
|
579
917
|
// Refresh the managed local files to this package version WITHOUT re-running any of the
|
|
580
918
|
// account/credential flow. Everything an update needs is already on disk: the pr- key and
|
|
581
919
|
// gateway URL live in the penny config (and, for older installs, in the harness config the
|
|
@@ -620,7 +958,7 @@ async function update(flags) {
|
|
|
620
958
|
|| launchConfig.gateway_base_url
|
|
621
959
|
|| globalAuth.gatewayBaseUrl
|
|
622
960
|
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
623
|
-
||
|
|
961
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
624
962
|
|
|
625
963
|
if (!apiKey) {
|
|
626
964
|
throw new Error(
|
|
@@ -647,8 +985,7 @@ async function update(flags) {
|
|
|
647
985
|
|
|
648
986
|
const failures = [];
|
|
649
987
|
let runtimeResult = null;
|
|
650
|
-
if (targets.some((record) => ["claude-code", "codex"].includes(record.harness))
|
|
651
|
-
&& process.platform !== "win32") {
|
|
988
|
+
if (targets.some((record) => ["claude-code", "codex", "opencode"].includes(record.harness))) {
|
|
652
989
|
runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
653
990
|
}
|
|
654
991
|
|
|
@@ -657,13 +994,11 @@ async function update(flags) {
|
|
|
657
994
|
if (!harness) continue;
|
|
658
995
|
|
|
659
996
|
await runHarnessJob(failures, record.harness, "update", async () => {
|
|
660
|
-
const supportsPennyCommand = ["claude-code", "codex"].includes(record.harness)
|
|
661
|
-
&& process.platform !== "win32";
|
|
997
|
+
const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(record.harness);
|
|
662
998
|
// An update rewrites the harness's own config only when that config is ours to
|
|
663
999
|
// rewrite — i.e. the bare command still routes through the gateway. In penny-only
|
|
664
1000
|
// mode the harness config belongs to the user and we touch just the launch profile.
|
|
665
1001
|
const ownsGlobalRouting = !record.launch_available || record.default_routing !== false;
|
|
666
|
-
if (ownsGlobalRouting) assertNoRunningHarness(record.harness, flags);
|
|
667
1002
|
|
|
668
1003
|
const claudeSubscriptionAuth = record.harness === "claude-code"
|
|
669
1004
|
&& Boolean(record.anthropic_token_stored);
|
|
@@ -703,9 +1038,29 @@ async function update(flags) {
|
|
|
703
1038
|
}
|
|
704
1039
|
|
|
705
1040
|
await saveManifest(manifest);
|
|
1041
|
+
|
|
1042
|
+
// Pick up MCP registration for installs that predate it, which is every existing user: the
|
|
1043
|
+
// server ships with the package but nothing registers it retroactively.
|
|
1044
|
+
//
|
|
1045
|
+
// Update always registers rather than honoring a stored opt-out. Registration is what makes
|
|
1046
|
+
// the Threads tools exist at all, and a preference remembered across version bumps would
|
|
1047
|
+
// mostly serve to leave people silently without them. `--no-mcp` still skips a single run,
|
|
1048
|
+
// and `pennyrouter mcp uninstall` removes it for good.
|
|
1049
|
+
if (!flags.dryRun && !flags.noMcp) {
|
|
1050
|
+
const wanted = targets.filter((record) => MCP_HARNESS_IDS.includes(record.harness));
|
|
1051
|
+
const results = await registerMcp(wanted.map((record) => record.harness), {
|
|
1052
|
+
gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
|
|
1053
|
+
});
|
|
1054
|
+
const added = results.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
|
|
1055
|
+
if (added.length) console.log(`Registered the Threads MCP server on ${added.join(" and ")}.`);
|
|
1056
|
+
for (const result of results.filter((r) => r.error)) {
|
|
1057
|
+
console.log(`MCP registration skipped for ${result.harness}: ${result.error}`);
|
|
1058
|
+
}
|
|
1059
|
+
}
|
|
1060
|
+
|
|
706
1061
|
console.log("");
|
|
707
1062
|
if (runtimeResult && !runtimeResult.available) {
|
|
708
|
-
console.log("Add
|
|
1063
|
+
console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
|
|
709
1064
|
} else if (runtimeResult?.changed) {
|
|
710
1065
|
console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
|
|
711
1066
|
}
|
|
@@ -716,10 +1071,6 @@ async function update(flags) {
|
|
|
716
1071
|
async function chooseDefaultRouting(flags, selected, manifest) {
|
|
717
1072
|
const out = new Map();
|
|
718
1073
|
const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
|
|
719
|
-
if (process.platform === "win32") {
|
|
720
|
-
for (const harness of launchable) out.set(harness.id, true);
|
|
721
|
-
return out;
|
|
722
|
-
}
|
|
723
1074
|
let newInstallChoice = null;
|
|
724
1075
|
for (const harness of launchable) {
|
|
725
1076
|
if (flags.makeDefault) {
|
|
@@ -745,7 +1096,10 @@ async function chooseDefaultRouting(flags, selected, manifest) {
|
|
|
745
1096
|
continue;
|
|
746
1097
|
}
|
|
747
1098
|
if (newInstallChoice === null) {
|
|
748
|
-
|
|
1099
|
+
// Auto-load-by-default prompt is disabled for now — always install as `penny <native>`
|
|
1100
|
+
// only. Flip ENABLE_AUTOLOAD_PROMPT to true to restore asking the user.
|
|
1101
|
+
const ENABLE_AUTOLOAD_PROMPT = false;
|
|
1102
|
+
if (!ENABLE_AUTOLOAD_PROMPT || flags.dryRun || flags.yes || !input.isTTY || !output.isTTY) {
|
|
749
1103
|
newInstallChoice = false;
|
|
750
1104
|
} else {
|
|
751
1105
|
for (const item of launchable) {
|
|
@@ -783,14 +1137,19 @@ async function selectClaudeFundingProviders(flags, selected) {
|
|
|
783
1137
|
|| process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
|
|
784
1138
|
if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
|
|
785
1139
|
if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
|
|
1140
|
+
if ((flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL)
|
|
1141
|
+
&& (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
|
|
1142
|
+
supplied.add("anthropic-proxy");
|
|
1143
|
+
}
|
|
786
1144
|
|
|
787
1145
|
// Explicit flags are the automation selector. Interactive installs always default to the
|
|
788
1146
|
// subscription alone; environment keys are used if the user checks their provider.
|
|
789
1147
|
const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
|
|
790
|
-
|| flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
|
|
1148
|
+
|| flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
|
|
1149
|
+
|| flags.proxyBaseUrl || flags.proxyApiKey;
|
|
791
1150
|
if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
|
|
792
1151
|
|
|
793
|
-
console.log("PennyRouter can use your Claude subscription, an Anthropic API key,
|
|
1152
|
+
console.log("PennyRouter can use your Claude subscription, an Anthropic API key, an OpenRouter API key, or your own Anthropic-compatible proxy to run Claude Code.");
|
|
794
1153
|
console.log("Choose one or more providers. PennyRouter will try them in order when available.");
|
|
795
1154
|
return promptFundingProviderCheckboxes(
|
|
796
1155
|
CLAUDE_PROVIDER_OPTIONS,
|
|
@@ -804,14 +1163,16 @@ async function selectCodexFundingProviders(flags, selected) {
|
|
|
804
1163
|
|
|
805
1164
|
const supplied = new Set();
|
|
806
1165
|
if (flags.openaiApiKey || process.env.OPENAI_API_KEY) supplied.add("openai-api");
|
|
1166
|
+
if ((flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL)
|
|
1167
|
+
&& (flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY)) supplied.add("openai-proxy");
|
|
807
1168
|
if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
|
|
808
1169
|
const hasChatgptLogin = await detectCodexChatgptAuth();
|
|
809
1170
|
if (hasChatgptLogin) supplied.add("subscription");
|
|
810
1171
|
|
|
811
|
-
const explicit = flags.openaiApiKey || flags.openrouterApiKey;
|
|
1172
|
+
const explicit = flags.openaiApiKey || flags.openaiProxyBaseUrl || flags.openaiProxyApiKey || flags.openrouterApiKey;
|
|
812
1173
|
if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
|
|
813
1174
|
|
|
814
|
-
console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, or an OpenRouter API key to run Codex.");
|
|
1175
|
+
console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, an OpenAI-compatible proxy, or an OpenRouter API key to run Codex.");
|
|
815
1176
|
console.log("Choose one or more providers. PennyRouter will try them in order when available.");
|
|
816
1177
|
return promptFundingProviderCheckboxes(
|
|
817
1178
|
CODEX_PROVIDER_OPTIONS,
|
|
@@ -829,7 +1190,10 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
829
1190
|
if (tokenResult.token) return tokenResult;
|
|
830
1191
|
}
|
|
831
1192
|
|
|
832
|
-
|
|
1193
|
+
// --config drives --yes to skip prompts, but it still expects the subscription browser
|
|
1194
|
+
// sign-in to run — the caller (e.g. the macOS installer) attaches a pty for exactly this,
|
|
1195
|
+
// so treat it like the interactive path rather than the plain --yes/non-TTY automation path.
|
|
1196
|
+
if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
|
|
833
1197
|
if (flags.anthropicAuth) {
|
|
834
1198
|
throw new Error(
|
|
835
1199
|
"Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
|
|
@@ -845,7 +1209,8 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
845
1209
|
if (commandExists("claude")) {
|
|
846
1210
|
// The user already opted into subscription auth above, so run `claude setup-token`
|
|
847
1211
|
// directly and capture the token — no copy-paste, no second confirm. The command
|
|
848
|
-
// opens the browser for approval inline. On failure we fall through to manual paste
|
|
1212
|
+
// opens the browser for approval inline. On failure we fall through to manual paste,
|
|
1213
|
+
// unless we came from --config, where there's no terminal to paste into.
|
|
849
1214
|
console.log("Launching Claude sign-in — approve in your browser…");
|
|
850
1215
|
const captured = await runClaudeSetupToken();
|
|
851
1216
|
if (captured) {
|
|
@@ -853,8 +1218,13 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
853
1218
|
console.log("");
|
|
854
1219
|
return { token: captured, source: "setup-token" };
|
|
855
1220
|
}
|
|
1221
|
+
if (flags.fromConfig) {
|
|
1222
|
+
throw new Error("Claude sign-in did not complete. Run the installer again.");
|
|
1223
|
+
}
|
|
856
1224
|
console.log("");
|
|
857
1225
|
console.log("Couldn't capture a token automatically. Paste it below, or leave blank to skip.");
|
|
1226
|
+
} else if (flags.fromConfig) {
|
|
1227
|
+
throw new Error("Claude Code was not found on PATH, so subscription sign-in could not run.");
|
|
858
1228
|
} else {
|
|
859
1229
|
console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
|
|
860
1230
|
}
|
|
@@ -884,6 +1254,18 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
884
1254
|
let openrouterApiKey = String(
|
|
885
1255
|
flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
|
|
886
1256
|
).trim();
|
|
1257
|
+
let openaiProxyBaseUrl = String(
|
|
1258
|
+
flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL || "",
|
|
1259
|
+
).trim();
|
|
1260
|
+
let openaiProxyApiKey = String(
|
|
1261
|
+
flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY || "",
|
|
1262
|
+
).trim();
|
|
1263
|
+
let proxyBaseUrl = String(
|
|
1264
|
+
flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL || "",
|
|
1265
|
+
).trim();
|
|
1266
|
+
let proxyApiKey = String(
|
|
1267
|
+
flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY || "",
|
|
1268
|
+
).trim();
|
|
887
1269
|
const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
|
|
888
1270
|
? await detectCodexChatgptAuth()
|
|
889
1271
|
: false;
|
|
@@ -896,6 +1278,19 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
896
1278
|
if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
|
|
897
1279
|
openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
|
|
898
1280
|
}
|
|
1281
|
+
if (wantsClaude && claudeProviders.has("anthropic-proxy") && !(proxyBaseUrl && proxyApiKey)) {
|
|
1282
|
+
if (!interactive) {
|
|
1283
|
+
throw new Error(
|
|
1284
|
+
"Other proxy was selected, but --proxy-base-url and --proxy-api-key were not both provided.",
|
|
1285
|
+
);
|
|
1286
|
+
}
|
|
1287
|
+
// The URL is not a secret and is easy to mistype, so it echoes; the key does not.
|
|
1288
|
+
if (!proxyBaseUrl) proxyBaseUrl = await promptText("Proxy base URL (e.g. https://proxy.example.com): ");
|
|
1289
|
+
if (!proxyApiKey) proxyApiKey = (await promptSecretish("Proxy API key: ")).trim();
|
|
1290
|
+
if (!proxyBaseUrl || !proxyApiKey) {
|
|
1291
|
+
throw new Error("Other proxy requires both a base URL and an API key.");
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
899
1294
|
if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
|
|
900
1295
|
throw new Error(
|
|
901
1296
|
"ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
|
|
@@ -905,12 +1300,20 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
905
1300
|
if (!interactive) throw new Error("OpenAI API was selected, but no key was provided.");
|
|
906
1301
|
openaiApiKey = (await promptSecretish("OpenAI API key: ")).trim();
|
|
907
1302
|
}
|
|
1303
|
+
if (wantsCodex && codexProviders.has("openai-proxy") && !(openaiProxyBaseUrl && openaiProxyApiKey)) {
|
|
1304
|
+
if (!interactive) throw new Error("OpenAI-compatible proxy was selected, but --openai-proxy-base-url and --openai-proxy-api-key were not both provided.");
|
|
1305
|
+
if (!openaiProxyBaseUrl) openaiProxyBaseUrl = await promptText("OpenAI proxy base URL (e.g. https://proxy.example.com/v1): ");
|
|
1306
|
+
if (!openaiProxyApiKey) openaiProxyApiKey = (await promptSecretish("OpenAI proxy API key: ")).trim();
|
|
1307
|
+
if (!openaiProxyBaseUrl || !openaiProxyApiKey) throw new Error("OpenAI-compatible proxy requires both a base URL and an API key.");
|
|
1308
|
+
}
|
|
908
1309
|
if (wantsCodex && codexProviders.has("openrouter") && !openrouterApiKey) {
|
|
909
1310
|
if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
|
|
910
1311
|
openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
|
|
911
1312
|
}
|
|
912
1313
|
|
|
913
|
-
|
|
1314
|
+
const claudeProxyConfigured = Boolean(proxyBaseUrl && proxyApiKey);
|
|
1315
|
+
if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey
|
|
1316
|
+
&& !claudeProxyConfigured) {
|
|
914
1317
|
if (!interactive) {
|
|
915
1318
|
throw new Error(
|
|
916
1319
|
"Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
|
|
@@ -930,11 +1333,11 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
930
1333
|
console.log("");
|
|
931
1334
|
}
|
|
932
1335
|
|
|
933
|
-
if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openrouterApiKey) {
|
|
1336
|
+
if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openaiProxyApiKey && !openrouterApiKey) {
|
|
934
1337
|
if (!interactive) {
|
|
935
1338
|
throw new Error(
|
|
936
1339
|
"Codex requires ChatGPT login, --openai-api-key (or OPENAI_API_KEY), "
|
|
937
|
-
+ "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
|
|
1340
|
+
+ "--openai-proxy-api-key (with --openai-proxy-base-url), or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
|
|
938
1341
|
);
|
|
939
1342
|
}
|
|
940
1343
|
console.log("Codex is not logged into ChatGPT and needs a user-funded provider.");
|
|
@@ -960,19 +1363,33 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
960
1363
|
if (wantsCodex && openaiApiKey) {
|
|
961
1364
|
credentials.push({ provider: "openai_api", token: openaiApiKey });
|
|
962
1365
|
}
|
|
1366
|
+
if (wantsCodex && openaiProxyBaseUrl && openaiProxyApiKey) {
|
|
1367
|
+
credentials.push({ provider: "openai_proxy", token: openaiProxyApiKey,
|
|
1368
|
+
metadata: { base_url: openaiProxyBaseUrl } });
|
|
1369
|
+
}
|
|
963
1370
|
if ((wantsClaude || wantsCodex) && openrouterApiKey) {
|
|
964
1371
|
credentials.push({ provider: "openrouter", token: openrouterApiKey });
|
|
965
1372
|
}
|
|
1373
|
+
if (wantsClaude && claudeProxyConfigured) {
|
|
1374
|
+
credentials.push({
|
|
1375
|
+
provider: "anthropic_proxy",
|
|
1376
|
+
token: proxyApiKey,
|
|
1377
|
+
metadata: { base_url: proxyBaseUrl },
|
|
1378
|
+
});
|
|
1379
|
+
}
|
|
966
1380
|
|
|
967
1381
|
return {
|
|
968
1382
|
credentials,
|
|
969
1383
|
codexSubscriptionAuth,
|
|
970
1384
|
claudeCredentialProvider: claudeAnthropicAuth
|
|
971
1385
|
? "claude-subscription"
|
|
972
|
-
: anthropicApiKey ? "anthropic-api"
|
|
1386
|
+
: anthropicApiKey ? "anthropic-api"
|
|
1387
|
+
: claudeProxyConfigured ? "anthropic-proxy"
|
|
1388
|
+
: openrouterApiKey ? "openrouter" : null,
|
|
1389
|
+
claudeProxyBaseUrl: claudeProxyConfigured ? proxyBaseUrl : null,
|
|
973
1390
|
codexCredentialProvider: codexSubscriptionAuth
|
|
974
1391
|
? "chatgpt-subscription"
|
|
975
|
-
: openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
|
|
1392
|
+
: openaiApiKey ? "openai-api" : openaiProxyBaseUrl ? "openai-proxy" : openrouterApiKey ? "openrouter" : null,
|
|
976
1393
|
};
|
|
977
1394
|
}
|
|
978
1395
|
|
|
@@ -1142,7 +1559,7 @@ async function uninstall(flags) {
|
|
|
1142
1559
|
const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
1143
1560
|
if (flags.forgetToken) {
|
|
1144
1561
|
await forgetStoredAnthropicToken(record);
|
|
1145
|
-
} else if (record.anthropic_token_stored) {
|
|
1562
|
+
} else if (record.anthropic_token_stored || record.proxy_base_url) {
|
|
1146
1563
|
anthropicTokenNotice = true;
|
|
1147
1564
|
}
|
|
1148
1565
|
if (record.launch_available) await removePennyHarness(record.harness);
|
|
@@ -1162,7 +1579,7 @@ async function uninstall(flags) {
|
|
|
1162
1579
|
await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
1163
1580
|
if (flags.forgetToken) {
|
|
1164
1581
|
await forgetStoredAnthropicToken(record);
|
|
1165
|
-
} else if (record.anthropic_token_stored) {
|
|
1582
|
+
} else if (record.anthropic_token_stored || record.proxy_base_url) {
|
|
1166
1583
|
anthropicTokenNotice = true;
|
|
1167
1584
|
}
|
|
1168
1585
|
if (record.launch_available) await removePennyHarness(record.harness);
|
|
@@ -1171,6 +1588,10 @@ async function uninstall(flags) {
|
|
|
1171
1588
|
});
|
|
1172
1589
|
}
|
|
1173
1590
|
|
|
1591
|
+
// Only entries PennyRouter wrote are removed, so a server the user re-pointed at their own
|
|
1592
|
+
// build survives an uninstall.
|
|
1593
|
+
await registerMcp(selectedRecords.map((record) => record.harness), { remove: true });
|
|
1594
|
+
|
|
1174
1595
|
if (anthropicTokenNotice) {
|
|
1175
1596
|
console.log("");
|
|
1176
1597
|
console.log("Note: Your stored Anthropic token remains on the server for other machines.");
|
|
@@ -1252,8 +1673,13 @@ async function status() {
|
|
|
1252
1673
|
if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
|
|
1253
1674
|
if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
|
|
1254
1675
|
if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
|
|
1676
|
+
if (record.proxy_base_url) {
|
|
1677
|
+
const note = record.anthropic_oauth ? " (subscription takes precedence)" : "";
|
|
1678
|
+
console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
|
|
1679
|
+
}
|
|
1255
1680
|
if (record.launch_available) {
|
|
1256
|
-
const command = record.harness === "claude-code"
|
|
1681
|
+
const command = record.harness === "claude-code"
|
|
1682
|
+
? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
|
|
1257
1683
|
console.log(` command: ${command}`);
|
|
1258
1684
|
console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
|
|
1259
1685
|
}
|
|
@@ -1562,14 +1988,24 @@ function formatError(error) {
|
|
|
1562
1988
|
// live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
|
|
1563
1989
|
// must not be blocked by a network hiccup; the record is going away regardless.
|
|
1564
1990
|
async function forgetStoredAnthropicToken(record) {
|
|
1565
|
-
if (record.harness !== "claude-code"
|
|
1991
|
+
if (record.harness !== "claude-code") return;
|
|
1992
|
+
if (!record.anthropic_token_stored && !record.proxy_base_url) return;
|
|
1566
1993
|
try {
|
|
1567
1994
|
const launchConfig = await loadPennyConfig();
|
|
1568
1995
|
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
1569
1996
|
const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
|
|
1570
1997
|
const apiKey = launchConfig.api_key || globalAuth.apiKey;
|
|
1571
1998
|
if (!gatewayBaseUrl || !apiKey) return;
|
|
1572
|
-
|
|
1999
|
+
if (record.anthropic_token_stored) {
|
|
2000
|
+
await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
|
|
2001
|
+
}
|
|
2002
|
+
// The proxy key is stored server-side exactly like the subscription token, so the
|
|
2003
|
+
// same "forget my credentials" gesture has to clear it too.
|
|
2004
|
+
if (record.proxy_base_url) {
|
|
2005
|
+
await forgetUpstreamCredential({
|
|
2006
|
+
gatewayBaseUrl, apiKey, provider: "anthropic_proxy",
|
|
2007
|
+
});
|
|
2008
|
+
}
|
|
1573
2009
|
} catch {
|
|
1574
2010
|
// swallow — see note above
|
|
1575
2011
|
}
|
|
@@ -1582,6 +2018,15 @@ async function confirm(question) {
|
|
|
1582
2018
|
return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
|
|
1583
2019
|
}
|
|
1584
2020
|
|
|
2021
|
+
// Visible-echo input, for values that are not secrets. A mistyped proxy URL is a
|
|
2022
|
+
// confusing failure much later, so the user gets to see what they typed.
|
|
2023
|
+
async function promptText(question) {
|
|
2024
|
+
const rl = createInterface({ input, output });
|
|
2025
|
+
const answer = await rl.question(question);
|
|
2026
|
+
rl.close();
|
|
2027
|
+
return answer.trim();
|
|
2028
|
+
}
|
|
2029
|
+
|
|
1585
2030
|
async function promptSecretish(question) {
|
|
1586
2031
|
if (!input.isTTY || !output.isTTY) {
|
|
1587
2032
|
const rl = createInterface({ input, output });
|
|
@@ -1656,58 +2101,38 @@ function openBrowser(url) {
|
|
|
1656
2101
|
spawnSync(command, args, { stdio: "ignore", detached: true });
|
|
1657
2102
|
}
|
|
1658
2103
|
|
|
1659
|
-
function runningClaudeCodeProcesses() {
|
|
1660
|
-
if (process.platform === "win32") {
|
|
1661
|
-
const result = spawnSync("tasklist", ["/FO", "CSV", "/NH"], { encoding: "utf8" });
|
|
1662
|
-
if (result.status !== 0) return [];
|
|
1663
|
-
return String(result.stdout || "").split(/\r?\n/)
|
|
1664
|
-
.filter((line) => /^"claude(?:\.exe)?"/i.test(line.trim()));
|
|
1665
|
-
}
|
|
1666
|
-
const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
|
|
1667
|
-
if (result.status !== 0) return [];
|
|
1668
|
-
return String(result.stdout || "").split(/\r?\n/)
|
|
1669
|
-
.map((line) => line.trim())
|
|
1670
|
-
// Case-sensitive on purpose: match the Claude Code executable, not Claude.app.
|
|
1671
|
-
.filter((line) => /(^|[\/\s])claude(?:\s|$)/.test(line));
|
|
1672
|
-
}
|
|
1673
|
-
|
|
1674
|
-
function runningCodexProcesses() {
|
|
1675
|
-
if (process.platform === "win32") return [];
|
|
1676
|
-
const result = spawnSync("ps", ["-ax", "-o", "pid=,command="], { encoding: "utf8" });
|
|
1677
|
-
if (result.status !== 0) return [];
|
|
1678
|
-
return String(result.stdout || "").split(/\r?\n/)
|
|
1679
|
-
.map((line) => line.trim())
|
|
1680
|
-
.filter((line) => /(^|[\/\s])codex(?:\s|$)/.test(line));
|
|
1681
|
-
}
|
|
1682
|
-
|
|
1683
|
-
function assertNoRunningHarness(harnessId, flags) {
|
|
1684
|
-
if (flags.allowRunningHarnesses || (harnessId === "claude-code" && flags.allowRunningClaude)) {
|
|
1685
|
-
return;
|
|
1686
|
-
}
|
|
1687
|
-
const running = harnessId === "claude-code"
|
|
1688
|
-
? runningClaudeCodeProcesses()
|
|
1689
|
-
: runningCodexProcesses();
|
|
1690
|
-
if (running.length === 0) return;
|
|
1691
|
-
const name = harnessId === "claude-code" ? "Claude Code" : "Codex";
|
|
1692
|
-
throw new Error(
|
|
1693
|
-
`${name} is currently running. Close all ${name} sessions before changing its global routing. `
|
|
1694
|
-
+ "Installing only the explicit `penny` command does not require this. If you intentionally "
|
|
1695
|
-
+ "accept the risk, pass --allow-running-harnesses.\nDetected:\n "
|
|
1696
|
-
+ running.slice(0, 5).join("\n "),
|
|
1697
|
-
);
|
|
1698
|
-
}
|
|
1699
|
-
|
|
1700
2104
|
function printHelp() {
|
|
1701
2105
|
console.log(`PennyRouter CLI
|
|
1702
2106
|
|
|
1703
2107
|
Usage:
|
|
1704
|
-
pennyrouter install [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--dry-run] [--make-default|--penny-only] [--no-path-update] [--anthropic-auth|--no-anthropic-auth] [--anthropic-api-key KEY] [--openai-api-key KEY] [--openrouter-api-key KEY] [--local[=URL]] [--
|
|
1705
|
-
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL] [--
|
|
2108
|
+
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] [--proxy-base-url URL --proxy-api-key KEY] [--gateway-base-url URL --private-key pr-...] [--local[=URL]|--prod] [--config PATH]
|
|
2109
|
+
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
|
|
1706
2110
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
1707
2111
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
1708
2112
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
1709
2113
|
pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
|
|
1710
2114
|
pennyrouter status
|
|
2115
|
+
pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
|
|
2116
|
+
|
|
2117
|
+
When an interactive install selects an Anthropic or OpenAI-compatible proxy, it asks for the PennyRouter gateway
|
|
2118
|
+
that can reach that proxy. Leave it blank for the hosted gateway, or enter a private/self-hosted
|
|
2119
|
+
gateway URL. Use --gateway-base-url (or --local for localhost:8400 development) for non-interactive installs.
|
|
2120
|
+
|
|
2121
|
+
A private gateway keeps its own keys, so the key from the browser login does not work there. Whenever
|
|
2122
|
+
you point install at one — with --gateway-base-url or by entering a URL at that prompt — you must also
|
|
2123
|
+
supply the key your PennyRouter administrator issued you: paste it when asked, or pass --private-key pr-...
|
|
2124
|
+
|
|
2125
|
+
Install and update both register the Threads MCP server with Claude Code and Codex (--no-mcp to
|
|
2126
|
+
skip a run). Run "pennyrouter mcp install" if you skipped it or had to rename a conflicting
|
|
2127
|
+
server, "pennyrouter mcp status" to see whether it is wired up, and "pennyrouter mcp uninstall"
|
|
2128
|
+
to remove it. Bare "pennyrouter mcp" is the server itself, run by those harnesses over stdio —
|
|
2129
|
+
you never invoke it by hand.
|
|
2130
|
+
|
|
2131
|
+
Once registered, ask your agent to read or resume an AI share link:
|
|
2132
|
+
|
|
2133
|
+
thread_read <url> pull a ChatGPT/Claude/Gemini/Perplexity conversation into the chat
|
|
2134
|
+
thread_resume <url> write it as a resumable session and print the resume command
|
|
2135
|
+
(harness: claude-code by default, or codex)
|
|
1711
2136
|
|
|
1712
2137
|
Update refreshes the managed local config and the penny runtime to this package version.
|
|
1713
2138
|
It reuses your existing key and never re-asks for subscription or API credentials.
|
|
@@ -1716,16 +2141,22 @@ Examples:
|
|
|
1716
2141
|
npx pennyrouter update
|
|
1717
2142
|
npx pennyrouter update --dry-run
|
|
1718
2143
|
npx pennyrouter update --harness claude-code
|
|
2144
|
+
npx pennyrouter update --harness codex --local
|
|
2145
|
+
npx pennyrouter update --all --local
|
|
2146
|
+
npx pennyrouter update --all --prod
|
|
1719
2147
|
npx pennyrouter install
|
|
1720
2148
|
npx pennyrouter install --make-default
|
|
1721
2149
|
npx pennyrouter install --penny-only
|
|
1722
2150
|
npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
|
|
1723
2151
|
npx pennyrouter install --harness claude-code --anthropic-api-key <key>
|
|
1724
2152
|
npx pennyrouter install --harness codex --openai-api-key <key>
|
|
2153
|
+
npx pennyrouter install --harness claude-code --proxy-base-url https://proxy.example.com --proxy-api-key <key>
|
|
1725
2154
|
npx pennyrouter install --openrouter-api-key <key>
|
|
2155
|
+
npx pennyrouter install --gateway-base-url https://gateway.internal.example.com --private-key pr-...
|
|
1726
2156
|
npx pennyrouter auth anthropic
|
|
1727
2157
|
npx pennyrouter install --all
|
|
1728
2158
|
npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
|
|
2159
|
+
npx pennyrouter install --config ./penny_install.json
|
|
1729
2160
|
npx pennyrouter disable
|
|
1730
2161
|
npx pennyrouter enable
|
|
1731
2162
|
npx pennyrouter uninstall
|