pennyrouter 0.3.0 → 0.3.2
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 +626 -133
- 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,42 +551,140 @@ 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...");
|
|
563
|
+
let claim;
|
|
564
|
+
let claudeAnthropicAuth = null;
|
|
565
|
+
let funding = { credentials: [], codexSubscriptionAuth: false, claudeCredentialProvider: null, codexCredentialProvider: null };
|
|
566
|
+
let gatewayBaseUrl;
|
|
431
567
|
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
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
|
+
if (flags.gatewayBaseUrl) {
|
|
611
|
+
const normalized = normalizeGatewayBaseUrl(flags.gatewayBaseUrl);
|
|
612
|
+
if (!normalized) {
|
|
613
|
+
throw new Error(
|
|
614
|
+
`--gateway-base-url "${flags.gatewayBaseUrl}" is not a usable URL. Use a host like `
|
|
615
|
+
+ "localhost:8400 or https://gateway.example.com.",
|
|
616
|
+
);
|
|
617
|
+
}
|
|
618
|
+
gatewayBaseUrl = normalized;
|
|
619
|
+
}
|
|
620
|
+
|
|
621
|
+
// A user-supplied proxy may live on a private network that the hosted gateway cannot
|
|
622
|
+
// reach. Let interactive installs choose the gateway after they choose the proxy: blank
|
|
623
|
+
// keeps the hosted default, while a supplied URL is used for credential storage,
|
|
624
|
+
// validation, probing, and the installed harness configuration. `--local` remains the
|
|
625
|
+
// explicit localhost:8400 developer shortcut and skips this prompt.
|
|
626
|
+
if ((claudeProviders.has("anthropic-proxy") || codexProviders.has("openai-proxy")) && !flags.gatewayBaseUrl
|
|
627
|
+
&& !flags.yes && input.isTTY && output.isTTY) {
|
|
628
|
+
const answer = (await promptText(
|
|
629
|
+
"PennyRouter gateway URL for this proxy (blank for hosted gateway): ",
|
|
630
|
+
)).trim();
|
|
631
|
+
if (answer) {
|
|
632
|
+
const privateGateway = normalizeGatewayBaseUrl(answer);
|
|
633
|
+
if (!privateGateway) {
|
|
634
|
+
throw new Error(
|
|
635
|
+
`"${answer}" is not a usable gateway URL. Use a host like `
|
|
636
|
+
+ "localhost:8400 or https://gateway.example.com.",
|
|
637
|
+
);
|
|
638
|
+
}
|
|
639
|
+
// Show the normalized form: the user typed "localhost:8000" and everything from here on
|
|
640
|
+
// uses "http://localhost:8000", so any later error names a URL they recognize.
|
|
641
|
+
if (privateGateway !== answer) console.log(`Using gateway ${privateGateway}.`);
|
|
642
|
+
gatewayBaseUrl = privateGateway;
|
|
643
|
+
flags.privateGateway = true;
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
// A private gateway has its own key store, so the hosted key from the browser claim is
|
|
648
|
+
// meaningless there. Require the admin-issued key and use it for every subsequent gateway
|
|
649
|
+
// call (credential storage, probing, harness config).
|
|
650
|
+
if (flags.privateGateway) {
|
|
651
|
+
let privateKey = (flags.privateKey || "").trim();
|
|
652
|
+
if (!privateKey && !flags.yes && input.isTTY && output.isTTY) {
|
|
653
|
+
console.log("");
|
|
654
|
+
console.log(`Private gateway: ${gatewayBaseUrl}`);
|
|
655
|
+
console.log("Your PennyRouter administrator issues keys for this gateway.");
|
|
656
|
+
// No echo: this is an issued credential, and a visible paste lingers in scrollback.
|
|
657
|
+
privateKey = (await promptSecretish("Paste your private gateway key (pr-...): ")).trim();
|
|
658
|
+
}
|
|
659
|
+
if (!privateKey) {
|
|
660
|
+
throw new Error(
|
|
661
|
+
`A private gateway (${gatewayBaseUrl}) needs its own key. `
|
|
662
|
+
+ "Ask your PennyRouter administrator for one and pass `--private-key pr-...`.",
|
|
663
|
+
);
|
|
664
|
+
}
|
|
665
|
+
if (!privateKey.startsWith("pr-")) {
|
|
666
|
+
throw new Error(`That does not look like a PennyRouter key — expected it to start with "pr-".`);
|
|
667
|
+
}
|
|
668
|
+
claim.api_key = privateKey;
|
|
669
|
+
console.log(`Using private gateway key ${maskKey(privateKey)}.`);
|
|
670
|
+
console.log("");
|
|
671
|
+
}
|
|
672
|
+
|
|
673
|
+
for (const credential of funding.credentials) {
|
|
674
|
+
await withGatewayContext(gatewayBaseUrl, () => storeUpstreamCredential({
|
|
675
|
+
gatewayBaseUrl,
|
|
676
|
+
apiKey: claim.api_key,
|
|
677
|
+
provider: credential.provider,
|
|
678
|
+
token: credential.token,
|
|
679
|
+
metadata: credential.metadata,
|
|
680
|
+
}));
|
|
681
|
+
}
|
|
451
682
|
}
|
|
452
683
|
|
|
453
|
-
if (
|
|
684
|
+
if (needsAuth.some((harness) => harness.id === "claude-code")) {
|
|
454
685
|
console.log("Verifying modern Claude Code compatibility before changing local settings...");
|
|
455
|
-
await
|
|
686
|
+
await withGatewayContext(
|
|
687
|
+
gatewayBaseUrl, () => probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key }));
|
|
456
688
|
console.log("");
|
|
457
689
|
}
|
|
458
690
|
|
|
@@ -461,9 +693,9 @@ async function install(flags) {
|
|
|
461
693
|
// choose the visible command behavior with all relevant context in front of them.
|
|
462
694
|
const defaultRouting = await chooseDefaultRouting(flags, selected, manifest);
|
|
463
695
|
|
|
464
|
-
const launchable = selected.filter((harness) => ["claude-code", "codex"].includes(harness.id));
|
|
696
|
+
const launchable = selected.filter((harness) => ["claude-code", "codex", "opencode"].includes(harness.id));
|
|
465
697
|
let runtimeResult = null;
|
|
466
|
-
if (launchable.length > 0
|
|
698
|
+
if (launchable.length > 0) {
|
|
467
699
|
runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
468
700
|
}
|
|
469
701
|
|
|
@@ -475,16 +707,13 @@ async function install(flags) {
|
|
|
475
707
|
const existingRecord = (manifest.installations || []).find(
|
|
476
708
|
(candidate) => candidate.harness === harness.id,
|
|
477
709
|
);
|
|
478
|
-
const supportsPennyCommand = ["claude-code", "codex"].includes(harness.id)
|
|
479
|
-
&& process.platform !== "win32";
|
|
710
|
+
const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(harness.id);
|
|
480
711
|
const makeDefault = defaultRouting.get(harness.id) ?? true;
|
|
481
712
|
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
713
|
|
|
714
|
+
const harnessNeededAuth = needsAuth.includes(harness);
|
|
486
715
|
const codexSubscriptionAuth = harness.id === "codex"
|
|
487
|
-
? funding.codexSubscriptionAuth
|
|
716
|
+
? (harnessNeededAuth ? funding.codexSubscriptionAuth : Boolean(existingRecord?.openai_subscription_auth))
|
|
488
717
|
: false;
|
|
489
718
|
const claudeSubscriptionAuth = harness.id === "claude-code"
|
|
490
719
|
&& Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
|
|
@@ -522,9 +751,10 @@ async function install(flags) {
|
|
|
522
751
|
}
|
|
523
752
|
if (supportsPennyCommand) {
|
|
524
753
|
record.launch_available = true;
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
754
|
+
const launchConfigPath = harness.id === "claude-code"
|
|
755
|
+
? claudeLaunchSettingsPath()
|
|
756
|
+
: harness.id === "codex" ? codexLaunchProfilePath() : null;
|
|
757
|
+
record.launch_config_path = launchConfigPath ? compactHome(launchConfigPath) : null;
|
|
528
758
|
record.default_routing = Boolean(makeDefault);
|
|
529
759
|
}
|
|
530
760
|
if (harness.id === "claude-code" && claudeSubscriptionAuth) {
|
|
@@ -532,11 +762,20 @@ async function install(flags) {
|
|
|
532
762
|
record.anthropic_token_stored = true;
|
|
533
763
|
record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
|
|
534
764
|
}
|
|
535
|
-
if (harness.id === "claude-code"
|
|
536
|
-
record.user_funded_provider =
|
|
765
|
+
if (harness.id === "claude-code") {
|
|
766
|
+
record.user_funded_provider = harnessNeededAuth
|
|
767
|
+
? (funding.claudeCredentialProvider || existingRecord?.user_funded_provider)
|
|
768
|
+
: existingRecord?.user_funded_provider;
|
|
769
|
+
// Host only, never the key — the key lives encrypted on the gateway.
|
|
770
|
+
const proxyBaseUrl = harnessNeededAuth
|
|
771
|
+
? (funding.claudeProxyBaseUrl ?? existingRecord?.proxy_base_url ?? null)
|
|
772
|
+
: (existingRecord?.proxy_base_url ?? null);
|
|
773
|
+
if (proxyBaseUrl) record.proxy_base_url = proxyBaseUrl;
|
|
537
774
|
}
|
|
538
|
-
if (harness.id === "codex"
|
|
539
|
-
record.user_funded_provider =
|
|
775
|
+
if (harness.id === "codex") {
|
|
776
|
+
record.user_funded_provider = harnessNeededAuth
|
|
777
|
+
? (funding.codexCredentialProvider || existingRecord?.user_funded_provider)
|
|
778
|
+
: existingRecord?.user_funded_provider;
|
|
540
779
|
}
|
|
541
780
|
if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
|
|
542
781
|
records.push(record);
|
|
@@ -554,28 +793,189 @@ async function install(flags) {
|
|
|
554
793
|
for (const record of records) {
|
|
555
794
|
const harness = HARNESS_BY_ID[record.harness];
|
|
556
795
|
if (record.launch_available) {
|
|
557
|
-
const command = record.harness === "claude-code"
|
|
558
|
-
|
|
559
|
-
const
|
|
796
|
+
const command = record.harness === "claude-code"
|
|
797
|
+
? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
|
|
798
|
+
const native = record.harness === "claude-code" ? "claude"
|
|
799
|
+
: record.harness === "codex" ? "codex" : "opencode";
|
|
800
|
+
const harnessName = record.harness === "claude-code" ? "Claude Code"
|
|
801
|
+
: record.harness === "codex" ? "Codex" : "OpenCode";
|
|
560
802
|
const commandWidth = Math.max(command.length, native.length) + 2;
|
|
561
803
|
console.log("");
|
|
562
804
|
console.log(color(`How to Run ${harnessName}`, "bold"));
|
|
563
805
|
console.log("");
|
|
564
806
|
console.log(` ${color(command.padEnd(commandWidth), "bold")}→ ${harnessName} with PennyRouter`);
|
|
565
|
-
console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName}
|
|
807
|
+
console.log(` ${color(native.padEnd(commandWidth), "bold")}→ ${record.default_routing ? `${harnessName}` : `${harnessName} native`}`);
|
|
566
808
|
} else {
|
|
567
809
|
console.log(`Installed on ${harness?.name || record.harness}${installNoteForRecord(record)}.`);
|
|
568
810
|
}
|
|
569
811
|
}
|
|
570
812
|
if (runtimeResult && !runtimeResult.available) {
|
|
571
|
-
console.log("Add
|
|
813
|
+
console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
|
|
572
814
|
} else if (runtimeResult?.changed) {
|
|
573
815
|
console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
|
|
574
816
|
}
|
|
817
|
+
|
|
818
|
+
// Registered after routing, which is the primary job: a naming conflict here reports itself
|
|
819
|
+
// and leaves a working install rather than failing the whole run.
|
|
820
|
+
if (!flags.noMcp && !flags.dryRun) {
|
|
821
|
+
const mcp = await registerMcp(records.map((r) => r.harness), {
|
|
822
|
+
gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
|
|
823
|
+
});
|
|
824
|
+
const registered = mcp.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
|
|
825
|
+
if (registered.length) {
|
|
826
|
+
console.log("");
|
|
827
|
+
console.log(color("Threads MCP", "bold"));
|
|
828
|
+
console.log(` Registered on ${registered.join(" and ")}.`);
|
|
829
|
+
console.log(" Ask your agent to read or resume an AI share link:");
|
|
830
|
+
console.log(' "code based on this chat: <paste a ChatGPT/Claude/Gemini/Perplexity link>"');
|
|
831
|
+
}
|
|
832
|
+
for (const result of mcp.filter((r) => r.error)) {
|
|
833
|
+
console.log(` MCP registration skipped for ${result.harness}: ${result.error}`);
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
575
837
|
console.log("");
|
|
576
838
|
reportFailures(failures);
|
|
577
839
|
}
|
|
578
840
|
|
|
841
|
+
// Deliberately small, secret-free preflight for graphical installers. Do not add
|
|
842
|
+
// tokens or config contents here: the renderer only needs to know which setup
|
|
843
|
+
// choices are likely to work on this machine.
|
|
844
|
+
export async function getSetupPreflight() {
|
|
845
|
+
const harnesses = await Promise.all(DEFAULT_INSTALL_HARNESS_IDS.map(async (id) => {
|
|
846
|
+
const harness = HARNESS_BY_ID[id];
|
|
847
|
+
return { id, name: harness.name, detected: await safeDetect(harness) };
|
|
848
|
+
}));
|
|
849
|
+
return {
|
|
850
|
+
harnesses,
|
|
851
|
+
codex_chatgpt_logged_in: await detectCodexChatgptAuth(),
|
|
852
|
+
};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/* The `mcp install|uninstall|status` subcommands.
|
|
856
|
+
|
|
857
|
+
Install already registers the server, so these exist for the states install cannot reach:
|
|
858
|
+
someone who passed --no-mcp and changed their mind, someone who hit a name conflict and has
|
|
859
|
+
now renamed the other server, and anyone who just wants to know whether it is wired up. */
|
|
860
|
+
|
|
861
|
+
function mcpTargets(flags) {
|
|
862
|
+
const requested = parseHarnessList(flags.harness).filter((id) => MCP_HARNESS_IDS.includes(id));
|
|
863
|
+
return requested.length ? requested : MCP_HARNESS_IDS;
|
|
864
|
+
}
|
|
865
|
+
|
|
866
|
+
/** The gateway URL worth pinning into the MCP entry, or "" for the default.
|
|
867
|
+
The MCP server already falls back to production on its own, so writing that value would only
|
|
868
|
+
freeze today's default into every user's config. */
|
|
869
|
+
/* Users type gateways the way they say them out loud — "localhost:8000",
|
|
870
|
+
"gateway.example.com". Those are not absolute URLs, so fetch() rejects them with a bare
|
|
871
|
+
"fetch failed" long after the prompt, where the cause is invisible. Supply the obvious scheme
|
|
872
|
+
(plain http for loopback, https elsewhere) and prove the result parses, so a typo fails here
|
|
873
|
+
instead. Returns null when the value cannot be salvaged. */
|
|
874
|
+
function normalizeGatewayBaseUrl(raw) {
|
|
875
|
+
const value = String(raw || "").trim().replace(/\/+$/, "");
|
|
876
|
+
if (!value) return null;
|
|
877
|
+
const withScheme = /^[a-z][a-z0-9+.-]*:\/\//i.test(value)
|
|
878
|
+
? value
|
|
879
|
+
: `${/^(localhost|127\.0\.0\.1|\[::1\])(:|$)/i.test(value) ? "http" : "https"}://${value}`;
|
|
880
|
+
let parsed;
|
|
881
|
+
try {
|
|
882
|
+
parsed = new URL(withScheme);
|
|
883
|
+
} catch {
|
|
884
|
+
return null;
|
|
885
|
+
}
|
|
886
|
+
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") return null;
|
|
887
|
+
if (!parsed.hostname) return null;
|
|
888
|
+
return withScheme.replace(/\/+$/, "");
|
|
889
|
+
}
|
|
890
|
+
|
|
891
|
+
/* A gateway that is unreachable (wrong host, wrong port, not running) surfaces from fetch() as
|
|
892
|
+
an opaque "fetch failed" with no status. Name the gateway we could not reach so the user knows
|
|
893
|
+
which URL to fix. Real HTTP errors carry their own message and pass through untouched. */
|
|
894
|
+
async function withGatewayContext(gatewayBaseUrl, action) {
|
|
895
|
+
try {
|
|
896
|
+
return await action();
|
|
897
|
+
} catch (error) {
|
|
898
|
+
const message = String(error?.message || error);
|
|
899
|
+
if (/fetch failed|ECONNREFUSED|ENOTFOUND|EAI_AGAIN|ECONNRESET|network/i.test(message)) {
|
|
900
|
+
throw new Error(
|
|
901
|
+
`Could not reach the PennyRouter gateway at ${gatewayBaseUrl} (${message}). `
|
|
902
|
+
+ "Check that the URL is correct and that the gateway is running.",
|
|
903
|
+
);
|
|
904
|
+
}
|
|
905
|
+
throw error;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
function customGatewayBaseUrl(url) {
|
|
910
|
+
const value = String(url || "").trim().replace(/\/+$/, "");
|
|
911
|
+
return !value || value === DEFAULT_GATEWAY_BASE_URL ? "" : value;
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
async function installMcp(flags) {
|
|
915
|
+
const results = await registerMcp(mcpTargets(flags), {
|
|
916
|
+
gatewayBaseUrl: customGatewayBaseUrl(
|
|
917
|
+
flags.gatewayBaseUrl || process.env.PENNYROUTER_GATEWAY_BASE_URL,
|
|
918
|
+
),
|
|
919
|
+
});
|
|
920
|
+
console.log("PennyRouter Threads MCP");
|
|
921
|
+
console.log("");
|
|
922
|
+
for (const result of results) {
|
|
923
|
+
const name = HARNESS_BY_ID[result.harness]?.name || result.harness;
|
|
924
|
+
if (result.error) console.log(` ${name}: ${result.error}`);
|
|
925
|
+
else if (result.changed) console.log(` ${name}: registered`);
|
|
926
|
+
else if (result.reason) console.log(` ${name}: skipped — ${result.reason}`);
|
|
927
|
+
else console.log(` ${name}: already registered`);
|
|
928
|
+
}
|
|
929
|
+
console.log("");
|
|
930
|
+
console.log("Restart any running session, then ask your agent to read or resume a share link.");
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
async function uninstallMcp(flags) {
|
|
934
|
+
const results = await registerMcp(mcpTargets(flags), { remove: true });
|
|
935
|
+
console.log("PennyRouter Threads MCP");
|
|
936
|
+
console.log("");
|
|
937
|
+
for (const result of results) {
|
|
938
|
+
const name = HARNESS_BY_ID[result.harness]?.name || result.harness;
|
|
939
|
+
console.log(` ${name}: ${result.changed ? "removed" : "nothing to remove"}`);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
async function statusMcp() {
|
|
944
|
+
const rows = await mcpStatus(MCP_HARNESS_IDS);
|
|
945
|
+
console.log("PennyRouter Threads MCP");
|
|
946
|
+
console.log("");
|
|
947
|
+
for (const row of rows) {
|
|
948
|
+
const name = HARNESS_BY_ID[row.harness]?.name || row.harness;
|
|
949
|
+
if (!row.configFound) console.log(` ${name}: not installed on this machine`);
|
|
950
|
+
else if (row.conflict) {
|
|
951
|
+
console.log(` ${name}: a different server is already named "pennyrouter"`);
|
|
952
|
+
} else console.log(` ${name}: ${row.registered ? "registered" : "not registered"}`);
|
|
953
|
+
}
|
|
954
|
+
if (rows.some((row) => row.configFound && !row.registered)) {
|
|
955
|
+
console.log("");
|
|
956
|
+
console.log("Register with: pennyrouter mcp install");
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
/* Serve the Threads MCP tools over stdio. Started by the harness, not by a person: stdout is the
|
|
961
|
+
JSON-RPC channel, so nothing may be printed to it. Diagnostics go to stderr. */
|
|
962
|
+
async function serveMcp(flags) {
|
|
963
|
+
const launchConfig = await loadPennyConfig();
|
|
964
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
965
|
+
const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
|
|
966
|
+
const gatewayBaseUrl = flags.gatewayBaseUrl
|
|
967
|
+
|| launchConfig.gateway_base_url
|
|
968
|
+
|| globalAuth.gatewayBaseUrl
|
|
969
|
+
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
970
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
971
|
+
|
|
972
|
+
await runMcpServer({
|
|
973
|
+
gatewayBaseUrl,
|
|
974
|
+
apiKey,
|
|
975
|
+
harness: process.env.PENNYROUTER_MCP_HARNESS,
|
|
976
|
+
});
|
|
977
|
+
}
|
|
978
|
+
|
|
579
979
|
// Refresh the managed local files to this package version WITHOUT re-running any of the
|
|
580
980
|
// account/credential flow. Everything an update needs is already on disk: the pr- key and
|
|
581
981
|
// gateway URL live in the penny config (and, for older installs, in the harness config the
|
|
@@ -620,7 +1020,7 @@ async function update(flags) {
|
|
|
620
1020
|
|| launchConfig.gateway_base_url
|
|
621
1021
|
|| globalAuth.gatewayBaseUrl
|
|
622
1022
|
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
623
|
-
||
|
|
1023
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
624
1024
|
|
|
625
1025
|
if (!apiKey) {
|
|
626
1026
|
throw new Error(
|
|
@@ -647,8 +1047,7 @@ async function update(flags) {
|
|
|
647
1047
|
|
|
648
1048
|
const failures = [];
|
|
649
1049
|
let runtimeResult = null;
|
|
650
|
-
if (targets.some((record) => ["claude-code", "codex"].includes(record.harness))
|
|
651
|
-
&& process.platform !== "win32") {
|
|
1050
|
+
if (targets.some((record) => ["claude-code", "codex", "opencode"].includes(record.harness))) {
|
|
652
1051
|
runtimeResult = await installPennyRuntime({ updatePath: !flags.noPathUpdate });
|
|
653
1052
|
}
|
|
654
1053
|
|
|
@@ -657,13 +1056,11 @@ async function update(flags) {
|
|
|
657
1056
|
if (!harness) continue;
|
|
658
1057
|
|
|
659
1058
|
await runHarnessJob(failures, record.harness, "update", async () => {
|
|
660
|
-
const supportsPennyCommand = ["claude-code", "codex"].includes(record.harness)
|
|
661
|
-
&& process.platform !== "win32";
|
|
1059
|
+
const supportsPennyCommand = ["claude-code", "codex", "opencode"].includes(record.harness);
|
|
662
1060
|
// An update rewrites the harness's own config only when that config is ours to
|
|
663
1061
|
// rewrite — i.e. the bare command still routes through the gateway. In penny-only
|
|
664
1062
|
// mode the harness config belongs to the user and we touch just the launch profile.
|
|
665
1063
|
const ownsGlobalRouting = !record.launch_available || record.default_routing !== false;
|
|
666
|
-
if (ownsGlobalRouting) assertNoRunningHarness(record.harness, flags);
|
|
667
1064
|
|
|
668
1065
|
const claudeSubscriptionAuth = record.harness === "claude-code"
|
|
669
1066
|
&& Boolean(record.anthropic_token_stored);
|
|
@@ -703,9 +1100,29 @@ async function update(flags) {
|
|
|
703
1100
|
}
|
|
704
1101
|
|
|
705
1102
|
await saveManifest(manifest);
|
|
1103
|
+
|
|
1104
|
+
// Pick up MCP registration for installs that predate it, which is every existing user: the
|
|
1105
|
+
// server ships with the package but nothing registers it retroactively.
|
|
1106
|
+
//
|
|
1107
|
+
// Update always registers rather than honoring a stored opt-out. Registration is what makes
|
|
1108
|
+
// the Threads tools exist at all, and a preference remembered across version bumps would
|
|
1109
|
+
// mostly serve to leave people silently without them. `--no-mcp` still skips a single run,
|
|
1110
|
+
// and `pennyrouter mcp uninstall` removes it for good.
|
|
1111
|
+
if (!flags.dryRun && !flags.noMcp) {
|
|
1112
|
+
const wanted = targets.filter((record) => MCP_HARNESS_IDS.includes(record.harness));
|
|
1113
|
+
const results = await registerMcp(wanted.map((record) => record.harness), {
|
|
1114
|
+
gatewayBaseUrl: customGatewayBaseUrl(gatewayBaseUrl),
|
|
1115
|
+
});
|
|
1116
|
+
const added = results.filter((r) => r.changed).map((r) => HARNESS_BY_ID[r.harness]?.name);
|
|
1117
|
+
if (added.length) console.log(`Registered the Threads MCP server on ${added.join(" and ")}.`);
|
|
1118
|
+
for (const result of results.filter((r) => r.error)) {
|
|
1119
|
+
console.log(`MCP registration skipped for ${result.harness}: ${result.error}`);
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
|
|
706
1123
|
console.log("");
|
|
707
1124
|
if (runtimeResult && !runtimeResult.available) {
|
|
708
|
-
console.log("Add
|
|
1125
|
+
console.log("Add the PennyRouter launcher directory to PATH before using the `penny` command.");
|
|
709
1126
|
} else if (runtimeResult?.changed) {
|
|
710
1127
|
console.log("Open a new terminal (or reload your shell) before using the `penny` command.");
|
|
711
1128
|
}
|
|
@@ -716,10 +1133,6 @@ async function update(flags) {
|
|
|
716
1133
|
async function chooseDefaultRouting(flags, selected, manifest) {
|
|
717
1134
|
const out = new Map();
|
|
718
1135
|
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
1136
|
let newInstallChoice = null;
|
|
724
1137
|
for (const harness of launchable) {
|
|
725
1138
|
if (flags.makeDefault) {
|
|
@@ -745,7 +1158,10 @@ async function chooseDefaultRouting(flags, selected, manifest) {
|
|
|
745
1158
|
continue;
|
|
746
1159
|
}
|
|
747
1160
|
if (newInstallChoice === null) {
|
|
748
|
-
|
|
1161
|
+
// Auto-load-by-default prompt is disabled for now — always install as `penny <native>`
|
|
1162
|
+
// only. Flip ENABLE_AUTOLOAD_PROMPT to true to restore asking the user.
|
|
1163
|
+
const ENABLE_AUTOLOAD_PROMPT = false;
|
|
1164
|
+
if (!ENABLE_AUTOLOAD_PROMPT || flags.dryRun || flags.yes || !input.isTTY || !output.isTTY) {
|
|
749
1165
|
newInstallChoice = false;
|
|
750
1166
|
} else {
|
|
751
1167
|
for (const item of launchable) {
|
|
@@ -783,14 +1199,19 @@ async function selectClaudeFundingProviders(flags, selected) {
|
|
|
783
1199
|
|| process.env.ANTHROPIC_AUTH_TOKEN)) supplied.add("subscription");
|
|
784
1200
|
if (flags.anthropicApiKey || process.env.ANTHROPIC_API_KEY) supplied.add("anthropic-api");
|
|
785
1201
|
if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
|
|
1202
|
+
if ((flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL)
|
|
1203
|
+
&& (flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY)) {
|
|
1204
|
+
supplied.add("anthropic-proxy");
|
|
1205
|
+
}
|
|
786
1206
|
|
|
787
1207
|
// Explicit flags are the automation selector. Interactive installs always default to the
|
|
788
1208
|
// subscription alone; environment keys are used if the user checks their provider.
|
|
789
1209
|
const explicit = flags.anthropicAuth || flags.noAnthropicAuth || flags.token
|
|
790
|
-
|| flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
|
|
1210
|
+
|| flags.tokenCommand || flags.anthropicApiKey || flags.openrouterApiKey
|
|
1211
|
+
|| flags.proxyBaseUrl || flags.proxyApiKey;
|
|
791
1212
|
if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
|
|
792
1213
|
|
|
793
|
-
console.log("PennyRouter can use your Claude subscription, an Anthropic API key,
|
|
1214
|
+
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
1215
|
console.log("Choose one or more providers. PennyRouter will try them in order when available.");
|
|
795
1216
|
return promptFundingProviderCheckboxes(
|
|
796
1217
|
CLAUDE_PROVIDER_OPTIONS,
|
|
@@ -804,14 +1225,16 @@ async function selectCodexFundingProviders(flags, selected) {
|
|
|
804
1225
|
|
|
805
1226
|
const supplied = new Set();
|
|
806
1227
|
if (flags.openaiApiKey || process.env.OPENAI_API_KEY) supplied.add("openai-api");
|
|
1228
|
+
if ((flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL)
|
|
1229
|
+
&& (flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY)) supplied.add("openai-proxy");
|
|
807
1230
|
if (flags.openrouterApiKey || process.env.OPENROUTER_API_KEY) supplied.add("openrouter");
|
|
808
1231
|
const hasChatgptLogin = await detectCodexChatgptAuth();
|
|
809
1232
|
if (hasChatgptLogin) supplied.add("subscription");
|
|
810
1233
|
|
|
811
|
-
const explicit = flags.openaiApiKey || flags.openrouterApiKey;
|
|
1234
|
+
const explicit = flags.openaiApiKey || flags.openaiProxyBaseUrl || flags.openaiProxyApiKey || flags.openrouterApiKey;
|
|
812
1235
|
if (explicit || flags.yes || !input.isTTY || !output.isTTY) return supplied;
|
|
813
1236
|
|
|
814
|
-
console.log("PennyRouter can use your ChatGPT subscription, an OpenAI API key, or an OpenRouter API key to run Codex.");
|
|
1237
|
+
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
1238
|
console.log("Choose one or more providers. PennyRouter will try them in order when available.");
|
|
816
1239
|
return promptFundingProviderCheckboxes(
|
|
817
1240
|
CODEX_PROVIDER_OPTIONS,
|
|
@@ -829,7 +1252,10 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
829
1252
|
if (tokenResult.token) return tokenResult;
|
|
830
1253
|
}
|
|
831
1254
|
|
|
832
|
-
|
|
1255
|
+
// --config drives --yes to skip prompts, but it still expects the subscription browser
|
|
1256
|
+
// sign-in to run — the caller (e.g. the macOS installer) attaches a pty for exactly this,
|
|
1257
|
+
// so treat it like the interactive path rather than the plain --yes/non-TTY automation path.
|
|
1258
|
+
if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
|
|
833
1259
|
if (flags.anthropicAuth) {
|
|
834
1260
|
throw new Error(
|
|
835
1261
|
"Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
|
|
@@ -845,7 +1271,8 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
845
1271
|
if (commandExists("claude")) {
|
|
846
1272
|
// The user already opted into subscription auth above, so run `claude setup-token`
|
|
847
1273
|
// 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
|
|
1274
|
+
// opens the browser for approval inline. On failure we fall through to manual paste,
|
|
1275
|
+
// unless we came from --config, where there's no terminal to paste into.
|
|
849
1276
|
console.log("Launching Claude sign-in — approve in your browser…");
|
|
850
1277
|
const captured = await runClaudeSetupToken();
|
|
851
1278
|
if (captured) {
|
|
@@ -853,8 +1280,13 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
853
1280
|
console.log("");
|
|
854
1281
|
return { token: captured, source: "setup-token" };
|
|
855
1282
|
}
|
|
1283
|
+
if (flags.fromConfig) {
|
|
1284
|
+
throw new Error("Claude sign-in did not complete. Run the installer again.");
|
|
1285
|
+
}
|
|
856
1286
|
console.log("");
|
|
857
1287
|
console.log("Couldn't capture a token automatically. Paste it below, or leave blank to skip.");
|
|
1288
|
+
} else if (flags.fromConfig) {
|
|
1289
|
+
throw new Error("Claude Code was not found on PATH, so subscription sign-in could not run.");
|
|
858
1290
|
} else {
|
|
859
1291
|
console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
|
|
860
1292
|
}
|
|
@@ -884,6 +1316,18 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
884
1316
|
let openrouterApiKey = String(
|
|
885
1317
|
flags.openrouterApiKey || process.env.OPENROUTER_API_KEY || "",
|
|
886
1318
|
).trim();
|
|
1319
|
+
let openaiProxyBaseUrl = String(
|
|
1320
|
+
flags.openaiProxyBaseUrl || process.env.PENNYROUTER_OPENAI_PROXY_BASE_URL || "",
|
|
1321
|
+
).trim();
|
|
1322
|
+
let openaiProxyApiKey = String(
|
|
1323
|
+
flags.openaiProxyApiKey || process.env.PENNYROUTER_OPENAI_PROXY_API_KEY || "",
|
|
1324
|
+
).trim();
|
|
1325
|
+
let proxyBaseUrl = String(
|
|
1326
|
+
flags.proxyBaseUrl || process.env.PENNYROUTER_PROXY_BASE_URL || "",
|
|
1327
|
+
).trim();
|
|
1328
|
+
let proxyApiKey = String(
|
|
1329
|
+
flags.proxyApiKey || process.env.PENNYROUTER_PROXY_API_KEY || "",
|
|
1330
|
+
).trim();
|
|
887
1331
|
const codexSubscriptionAuth = wantsCodex && codexProviders.has("subscription")
|
|
888
1332
|
? await detectCodexChatgptAuth()
|
|
889
1333
|
: false;
|
|
@@ -896,6 +1340,19 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
896
1340
|
if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
|
|
897
1341
|
openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
|
|
898
1342
|
}
|
|
1343
|
+
if (wantsClaude && claudeProviders.has("anthropic-proxy") && !(proxyBaseUrl && proxyApiKey)) {
|
|
1344
|
+
if (!interactive) {
|
|
1345
|
+
throw new Error(
|
|
1346
|
+
"Other proxy was selected, but --proxy-base-url and --proxy-api-key were not both provided.",
|
|
1347
|
+
);
|
|
1348
|
+
}
|
|
1349
|
+
// The URL is not a secret and is easy to mistype, so it echoes; the key does not.
|
|
1350
|
+
if (!proxyBaseUrl) proxyBaseUrl = await promptText("Proxy base URL (e.g. https://proxy.example.com): ");
|
|
1351
|
+
if (!proxyApiKey) proxyApiKey = (await promptSecretish("Proxy API key: ")).trim();
|
|
1352
|
+
if (!proxyBaseUrl || !proxyApiKey) {
|
|
1353
|
+
throw new Error("Other proxy requires both a base URL and an API key.");
|
|
1354
|
+
}
|
|
1355
|
+
}
|
|
899
1356
|
if (wantsCodex && codexProviders.has("subscription") && !codexSubscriptionAuth) {
|
|
900
1357
|
throw new Error(
|
|
901
1358
|
"ChatGPT subscription was selected, but Codex is not logged in. Run `codex login`, or select an API-key provider.",
|
|
@@ -905,12 +1362,20 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
905
1362
|
if (!interactive) throw new Error("OpenAI API was selected, but no key was provided.");
|
|
906
1363
|
openaiApiKey = (await promptSecretish("OpenAI API key: ")).trim();
|
|
907
1364
|
}
|
|
1365
|
+
if (wantsCodex && codexProviders.has("openai-proxy") && !(openaiProxyBaseUrl && openaiProxyApiKey)) {
|
|
1366
|
+
if (!interactive) throw new Error("OpenAI-compatible proxy was selected, but --openai-proxy-base-url and --openai-proxy-api-key were not both provided.");
|
|
1367
|
+
if (!openaiProxyBaseUrl) openaiProxyBaseUrl = await promptText("OpenAI proxy base URL (e.g. https://proxy.example.com/v1): ");
|
|
1368
|
+
if (!openaiProxyApiKey) openaiProxyApiKey = (await promptSecretish("OpenAI proxy API key: ")).trim();
|
|
1369
|
+
if (!openaiProxyBaseUrl || !openaiProxyApiKey) throw new Error("OpenAI-compatible proxy requires both a base URL and an API key.");
|
|
1370
|
+
}
|
|
908
1371
|
if (wantsCodex && codexProviders.has("openrouter") && !openrouterApiKey) {
|
|
909
1372
|
if (!interactive) throw new Error("OpenRouter was selected, but no key was provided.");
|
|
910
1373
|
openrouterApiKey = (await promptSecretish("OpenRouter API key: ")).trim();
|
|
911
1374
|
}
|
|
912
1375
|
|
|
913
|
-
|
|
1376
|
+
const claudeProxyConfigured = Boolean(proxyBaseUrl && proxyApiKey);
|
|
1377
|
+
if (wantsClaude && !claudeAnthropicAuth && !anthropicApiKey && !openrouterApiKey
|
|
1378
|
+
&& !claudeProxyConfigured) {
|
|
914
1379
|
if (!interactive) {
|
|
915
1380
|
throw new Error(
|
|
916
1381
|
"Claude Code requires a Claude subscription, --anthropic-api-key (or ANTHROPIC_API_KEY), "
|
|
@@ -930,11 +1395,11 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
930
1395
|
console.log("");
|
|
931
1396
|
}
|
|
932
1397
|
|
|
933
|
-
if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openrouterApiKey) {
|
|
1398
|
+
if (wantsCodex && !codexSubscriptionAuth && !openaiApiKey && !openaiProxyApiKey && !openrouterApiKey) {
|
|
934
1399
|
if (!interactive) {
|
|
935
1400
|
throw new Error(
|
|
936
1401
|
"Codex requires ChatGPT login, --openai-api-key (or OPENAI_API_KEY), "
|
|
937
|
-
+ "or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
|
|
1402
|
+
+ "--openai-proxy-api-key (with --openai-proxy-base-url), or --openrouter-api-key (or OPENROUTER_API_KEY). Nothing was installed.",
|
|
938
1403
|
);
|
|
939
1404
|
}
|
|
940
1405
|
console.log("Codex is not logged into ChatGPT and needs a user-funded provider.");
|
|
@@ -960,19 +1425,33 @@ async function configureUserFunding(flags, selected, claudeAnthropicAuth,
|
|
|
960
1425
|
if (wantsCodex && openaiApiKey) {
|
|
961
1426
|
credentials.push({ provider: "openai_api", token: openaiApiKey });
|
|
962
1427
|
}
|
|
1428
|
+
if (wantsCodex && openaiProxyBaseUrl && openaiProxyApiKey) {
|
|
1429
|
+
credentials.push({ provider: "openai_proxy", token: openaiProxyApiKey,
|
|
1430
|
+
metadata: { base_url: openaiProxyBaseUrl } });
|
|
1431
|
+
}
|
|
963
1432
|
if ((wantsClaude || wantsCodex) && openrouterApiKey) {
|
|
964
1433
|
credentials.push({ provider: "openrouter", token: openrouterApiKey });
|
|
965
1434
|
}
|
|
1435
|
+
if (wantsClaude && claudeProxyConfigured) {
|
|
1436
|
+
credentials.push({
|
|
1437
|
+
provider: "anthropic_proxy",
|
|
1438
|
+
token: proxyApiKey,
|
|
1439
|
+
metadata: { base_url: proxyBaseUrl },
|
|
1440
|
+
});
|
|
1441
|
+
}
|
|
966
1442
|
|
|
967
1443
|
return {
|
|
968
1444
|
credentials,
|
|
969
1445
|
codexSubscriptionAuth,
|
|
970
1446
|
claudeCredentialProvider: claudeAnthropicAuth
|
|
971
1447
|
? "claude-subscription"
|
|
972
|
-
: anthropicApiKey ? "anthropic-api"
|
|
1448
|
+
: anthropicApiKey ? "anthropic-api"
|
|
1449
|
+
: claudeProxyConfigured ? "anthropic-proxy"
|
|
1450
|
+
: openrouterApiKey ? "openrouter" : null,
|
|
1451
|
+
claudeProxyBaseUrl: claudeProxyConfigured ? proxyBaseUrl : null,
|
|
973
1452
|
codexCredentialProvider: codexSubscriptionAuth
|
|
974
1453
|
? "chatgpt-subscription"
|
|
975
|
-
: openaiApiKey ? "openai-api" : openrouterApiKey ? "openrouter" : null,
|
|
1454
|
+
: openaiApiKey ? "openai-api" : openaiProxyBaseUrl ? "openai-proxy" : openrouterApiKey ? "openrouter" : null,
|
|
976
1455
|
};
|
|
977
1456
|
}
|
|
978
1457
|
|
|
@@ -1142,7 +1621,7 @@ async function uninstall(flags) {
|
|
|
1142
1621
|
const ok = await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
1143
1622
|
if (flags.forgetToken) {
|
|
1144
1623
|
await forgetStoredAnthropicToken(record);
|
|
1145
|
-
} else if (record.anthropic_token_stored) {
|
|
1624
|
+
} else if (record.anthropic_token_stored || record.proxy_base_url) {
|
|
1146
1625
|
anthropicTokenNotice = true;
|
|
1147
1626
|
}
|
|
1148
1627
|
if (record.launch_available) await removePennyHarness(record.harness);
|
|
@@ -1162,7 +1641,7 @@ async function uninstall(flags) {
|
|
|
1162
1641
|
await runHarnessJob(failures, record.harness, "uninstall", async () => {
|
|
1163
1642
|
if (flags.forgetToken) {
|
|
1164
1643
|
await forgetStoredAnthropicToken(record);
|
|
1165
|
-
} else if (record.anthropic_token_stored) {
|
|
1644
|
+
} else if (record.anthropic_token_stored || record.proxy_base_url) {
|
|
1166
1645
|
anthropicTokenNotice = true;
|
|
1167
1646
|
}
|
|
1168
1647
|
if (record.launch_available) await removePennyHarness(record.harness);
|
|
@@ -1171,6 +1650,10 @@ async function uninstall(flags) {
|
|
|
1171
1650
|
});
|
|
1172
1651
|
}
|
|
1173
1652
|
|
|
1653
|
+
// Only entries PennyRouter wrote are removed, so a server the user re-pointed at their own
|
|
1654
|
+
// build survives an uninstall.
|
|
1655
|
+
await registerMcp(selectedRecords.map((record) => record.harness), { remove: true });
|
|
1656
|
+
|
|
1174
1657
|
if (anthropicTokenNotice) {
|
|
1175
1658
|
console.log("");
|
|
1176
1659
|
console.log("Note: Your stored Anthropic token remains on the server for other machines.");
|
|
@@ -1252,8 +1735,13 @@ async function status() {
|
|
|
1252
1735
|
if (record.disabled_at) console.log(` disabled: ${record.disabled_at}`);
|
|
1253
1736
|
if (record.anthropic_oauth) console.log(` Claude subscription auth: enabled (${record.token_source || "unknown"} token)`);
|
|
1254
1737
|
if (record.openai_subscription_auth) console.log(" OpenAI subscription auth: enabled (Codex-managed login)");
|
|
1738
|
+
if (record.proxy_base_url) {
|
|
1739
|
+
const note = record.anthropic_oauth ? " (subscription takes precedence)" : "";
|
|
1740
|
+
console.log(` Upstream proxy: ${record.proxy_base_url}${note}`);
|
|
1741
|
+
}
|
|
1255
1742
|
if (record.launch_available) {
|
|
1256
|
-
const command = record.harness === "claude-code"
|
|
1743
|
+
const command = record.harness === "claude-code"
|
|
1744
|
+
? "penny claude" : record.harness === "codex" ? "penny codex" : "penny opencode";
|
|
1257
1745
|
console.log(` command: ${command}`);
|
|
1258
1746
|
console.log(` bare command: ${record.default_routing ? "PennyRouter" : "native"}`);
|
|
1259
1747
|
}
|
|
@@ -1562,14 +2050,24 @@ function formatError(error) {
|
|
|
1562
2050
|
// live config BEFORE harness.uninstall strips them. Never throws — a local uninstall
|
|
1563
2051
|
// must not be blocked by a network hiccup; the record is going away regardless.
|
|
1564
2052
|
async function forgetStoredAnthropicToken(record) {
|
|
1565
|
-
if (record.harness !== "claude-code"
|
|
2053
|
+
if (record.harness !== "claude-code") return;
|
|
2054
|
+
if (!record.anthropic_token_stored && !record.proxy_base_url) return;
|
|
1566
2055
|
try {
|
|
1567
2056
|
const launchConfig = await loadPennyConfig();
|
|
1568
2057
|
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
1569
2058
|
const gatewayBaseUrl = launchConfig.gateway_base_url || globalAuth.gatewayBaseUrl;
|
|
1570
2059
|
const apiKey = launchConfig.api_key || globalAuth.apiKey;
|
|
1571
2060
|
if (!gatewayBaseUrl || !apiKey) return;
|
|
1572
|
-
|
|
2061
|
+
if (record.anthropic_token_stored) {
|
|
2062
|
+
await forgetAnthropicToken({ gatewayBaseUrl, apiKey });
|
|
2063
|
+
}
|
|
2064
|
+
// The proxy key is stored server-side exactly like the subscription token, so the
|
|
2065
|
+
// same "forget my credentials" gesture has to clear it too.
|
|
2066
|
+
if (record.proxy_base_url) {
|
|
2067
|
+
await forgetUpstreamCredential({
|
|
2068
|
+
gatewayBaseUrl, apiKey, provider: "anthropic_proxy",
|
|
2069
|
+
});
|
|
2070
|
+
}
|
|
1573
2071
|
} catch {
|
|
1574
2072
|
// swallow — see note above
|
|
1575
2073
|
}
|
|
@@ -1582,6 +2080,15 @@ async function confirm(question) {
|
|
|
1582
2080
|
return answer.trim().toLowerCase() === "y" || answer.trim().toLowerCase() === "yes";
|
|
1583
2081
|
}
|
|
1584
2082
|
|
|
2083
|
+
// Visible-echo input, for values that are not secrets. A mistyped proxy URL is a
|
|
2084
|
+
// confusing failure much later, so the user gets to see what they typed.
|
|
2085
|
+
async function promptText(question) {
|
|
2086
|
+
const rl = createInterface({ input, output });
|
|
2087
|
+
const answer = await rl.question(question);
|
|
2088
|
+
rl.close();
|
|
2089
|
+
return answer.trim();
|
|
2090
|
+
}
|
|
2091
|
+
|
|
1585
2092
|
async function promptSecretish(question) {
|
|
1586
2093
|
if (!input.isTTY || !output.isTTY) {
|
|
1587
2094
|
const rl = createInterface({ input, output });
|
|
@@ -1656,58 +2163,38 @@ function openBrowser(url) {
|
|
|
1656
2163
|
spawnSync(command, args, { stdio: "ignore", detached: true });
|
|
1657
2164
|
}
|
|
1658
2165
|
|
|
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
2166
|
function printHelp() {
|
|
1701
2167
|
console.log(`PennyRouter CLI
|
|
1702
2168
|
|
|
1703
2169
|
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] [--
|
|
2170
|
+
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]
|
|
2171
|
+
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp]
|
|
1706
2172
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
1707
2173
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
1708
2174
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
1709
2175
|
pennyrouter uninstall [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes] [--forget-token]
|
|
1710
2176
|
pennyrouter status
|
|
2177
|
+
pennyrouter mcp install|uninstall|status [--harness claude-code,codex]
|
|
2178
|
+
|
|
2179
|
+
When an interactive install selects an Anthropic or OpenAI-compatible proxy, it asks for the PennyRouter gateway
|
|
2180
|
+
that can reach that proxy. Leave it blank for the hosted gateway, or enter a private/self-hosted
|
|
2181
|
+
gateway URL. Use --gateway-base-url (or --local for localhost:8400 development) for non-interactive installs.
|
|
2182
|
+
|
|
2183
|
+
A private gateway keeps its own keys, so the key from the browser login does not work there. Whenever
|
|
2184
|
+
you point install at one — with --gateway-base-url or by entering a URL at that prompt — you must also
|
|
2185
|
+
supply the key your PennyRouter administrator issued you: paste it when asked, or pass --private-key pr-...
|
|
2186
|
+
|
|
2187
|
+
Install and update both register the Threads MCP server with Claude Code and Codex (--no-mcp to
|
|
2188
|
+
skip a run). Run "pennyrouter mcp install" if you skipped it or had to rename a conflicting
|
|
2189
|
+
server, "pennyrouter mcp status" to see whether it is wired up, and "pennyrouter mcp uninstall"
|
|
2190
|
+
to remove it. Bare "pennyrouter mcp" is the server itself, run by those harnesses over stdio —
|
|
2191
|
+
you never invoke it by hand.
|
|
2192
|
+
|
|
2193
|
+
Once registered, ask your agent to read or resume an AI share link:
|
|
2194
|
+
|
|
2195
|
+
thread_read <url> pull a ChatGPT/Claude/Gemini/Perplexity conversation into the chat
|
|
2196
|
+
thread_resume <url> write it as a resumable session and print the resume command
|
|
2197
|
+
(harness: claude-code by default, or codex)
|
|
1711
2198
|
|
|
1712
2199
|
Update refreshes the managed local config and the penny runtime to this package version.
|
|
1713
2200
|
It reuses your existing key and never re-asks for subscription or API credentials.
|
|
@@ -1716,16 +2203,22 @@ Examples:
|
|
|
1716
2203
|
npx pennyrouter update
|
|
1717
2204
|
npx pennyrouter update --dry-run
|
|
1718
2205
|
npx pennyrouter update --harness claude-code
|
|
2206
|
+
npx pennyrouter update --harness codex --local
|
|
2207
|
+
npx pennyrouter update --all --local
|
|
2208
|
+
npx pennyrouter update --all --prod
|
|
1719
2209
|
npx pennyrouter install
|
|
1720
2210
|
npx pennyrouter install --make-default
|
|
1721
2211
|
npx pennyrouter install --penny-only
|
|
1722
2212
|
npx pennyrouter install --harness claude-code --anthropic-auth --token <token>
|
|
1723
2213
|
npx pennyrouter install --harness claude-code --anthropic-api-key <key>
|
|
1724
2214
|
npx pennyrouter install --harness codex --openai-api-key <key>
|
|
2215
|
+
npx pennyrouter install --harness claude-code --proxy-base-url https://proxy.example.com --proxy-api-key <key>
|
|
1725
2216
|
npx pennyrouter install --openrouter-api-key <key>
|
|
2217
|
+
npx pennyrouter install --gateway-base-url https://gateway.internal.example.com --private-key pr-...
|
|
1726
2218
|
npx pennyrouter auth anthropic
|
|
1727
2219
|
npx pennyrouter install --all
|
|
1728
2220
|
npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
|
|
2221
|
+
npx pennyrouter install --config ./penny_install.json
|
|
1729
2222
|
npx pennyrouter disable
|
|
1730
2223
|
npx pennyrouter enable
|
|
1731
2224
|
npx pennyrouter uninstall
|