pennyrouter 0.2.23 → 0.2.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/cli.js +168 -38
- package/src/cli.test.js +123 -0
- package/src/statusline.js +7 -6
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
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";
|
|
@@ -181,15 +182,94 @@ function parseArgs(argv) {
|
|
|
181
182
|
// `--local=<url>` overrides the port/host.
|
|
182
183
|
else if (arg === "--local") flags.gatewayBaseUrl = "http://localhost:8400";
|
|
183
184
|
else if (arg.startsWith("--local=")) flags.gatewayBaseUrl = arg.slice("--local=".length);
|
|
185
|
+
// --prod: point the installed harness back at the default production gateway
|
|
186
|
+
// (https://api.pennyrouter.com). Undoes --local.
|
|
187
|
+
else if (arg === "--prod") flags.gatewayBaseUrl = DEFAULT_GATEWAY_BASE_URL;
|
|
184
188
|
else if (arg === "--forget") flags.forget = true;
|
|
185
189
|
else if (arg === "--forget-token") flags.forgetToken = true;
|
|
190
|
+
else if (arg === "--config") flags.configPath = rest[++i] || "";
|
|
191
|
+
else if (arg.startsWith("--config=")) flags.configPath = arg.slice("--config=".length);
|
|
186
192
|
else if (!arg.startsWith("-")) flags.args.push(arg);
|
|
187
193
|
else throw new Error(`Unknown option "${arg}". Run "pennyrouter help".`);
|
|
188
194
|
}
|
|
189
195
|
|
|
196
|
+
if (flags.configPath) applyConfigFile(flags);
|
|
197
|
+
|
|
190
198
|
return { command, flags };
|
|
191
199
|
}
|
|
192
200
|
|
|
201
|
+
// Lets a GUI installer (e.g. the macOS DMG) answer the install questions up front instead of
|
|
202
|
+
// over a TTY: pick harnesses, pick funding per harness, optionally supply keys. Populates the
|
|
203
|
+
// same `flags` fields the equivalent CLI flags set, so it flows through the existing
|
|
204
|
+
// non-interactive paths unchanged. CLI flags win over the config file where both are given.
|
|
205
|
+
// Funding choices imply --yes (skip remaining prompts) but, unlike bare --yes, do NOT suppress
|
|
206
|
+
// the Claude subscription browser sign-in — see maybeConfigureClaudeAnthropicAuth.
|
|
207
|
+
function applyConfigFile(flags) {
|
|
208
|
+
let config;
|
|
209
|
+
try {
|
|
210
|
+
config = JSON.parse(readFileSync(flags.configPath, "utf8"));
|
|
211
|
+
} catch (error) {
|
|
212
|
+
throw new Error(`Could not read --config file "${flags.configPath}": ${error.message}`);
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
if (config.harnesses && flags.harness === undefined) {
|
|
216
|
+
flags.harness = config.harnesses.join(",");
|
|
217
|
+
}
|
|
218
|
+
const claudeFunding = new Set(config.claudeFunding || []);
|
|
219
|
+
const codexFunding = new Set(config.codexFunding || []);
|
|
220
|
+
if (claudeFunding.has("subscription") && flags.anthropicAuth === undefined) {
|
|
221
|
+
flags.anthropicAuth = true;
|
|
222
|
+
}
|
|
223
|
+
if (claudeFunding.has("anthropic-api") && flags.anthropicApiKey === undefined) {
|
|
224
|
+
flags.anthropicApiKey = config.anthropicApiKey || "";
|
|
225
|
+
}
|
|
226
|
+
if ((claudeFunding.has("openrouter") || codexFunding.has("openrouter"))
|
|
227
|
+
&& flags.openrouterApiKey === undefined) {
|
|
228
|
+
flags.openrouterApiKey = config.openrouterApiKey || "";
|
|
229
|
+
}
|
|
230
|
+
if (codexFunding.has("openai-api") && flags.openaiApiKey === undefined) {
|
|
231
|
+
flags.openaiApiKey = config.openaiApiKey || "";
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
flags.yes = true;
|
|
235
|
+
flags.fromConfig = true;
|
|
236
|
+
flags.configClaudeFunding = claudeFunding;
|
|
237
|
+
flags.configCodexFunding = codexFunding;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// A harness needs the account-claim/funding flow only if it's new, or if the funding the
|
|
241
|
+
// config asked for differs from what's already stored on it — an unchanged re-run of
|
|
242
|
+
// --config should behave like `update`, not force a fresh Claude/PennyRouter sign-in.
|
|
243
|
+
// Plain (non--config) installs call every selected harness "changed" by always passing an
|
|
244
|
+
// empty requested set here, so this only takes effect on the --config path (see `needsAuth`
|
|
245
|
+
// in install()).
|
|
246
|
+
export function harnessFundingChanged(flags, harness, manifest) {
|
|
247
|
+
const existingRecord = (manifest.installations || []).find(
|
|
248
|
+
(candidate) => candidate.harness === harness.id,
|
|
249
|
+
);
|
|
250
|
+
if (!existingRecord) return true;
|
|
251
|
+
|
|
252
|
+
if (harness.id === "claude-code") {
|
|
253
|
+
const requested = flags.configClaudeFunding || new Set();
|
|
254
|
+
if (requested.size === 0) return false;
|
|
255
|
+
const current = existingRecord.user_funded_provider === "claude-subscription"
|
|
256
|
+
? "subscription"
|
|
257
|
+
: existingRecord.user_funded_provider;
|
|
258
|
+
return requested.size !== 1 || !requested.has(current);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
if (harness.id === "codex") {
|
|
262
|
+
const requested = flags.configCodexFunding || new Set();
|
|
263
|
+
if (requested.size === 0) return false;
|
|
264
|
+
const current = existingRecord.openai_subscription_auth
|
|
265
|
+
? "subscription"
|
|
266
|
+
: existingRecord.user_funded_provider;
|
|
267
|
+
return requested.size !== 1 || !requested.has(current);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
return false;
|
|
271
|
+
}
|
|
272
|
+
|
|
193
273
|
async function authProvider(flags) {
|
|
194
274
|
const provider = (flags.args[0] || "anthropic").toLowerCase();
|
|
195
275
|
if (provider !== "anthropic" && provider !== "claude") {
|
|
@@ -436,40 +516,74 @@ export async function install(flags = {}) {
|
|
|
436
516
|
return;
|
|
437
517
|
}
|
|
438
518
|
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
if (!flags.noBrowser) openBrowser(session.authorize_url);
|
|
448
|
-
console.log("");
|
|
449
|
-
console.log("Waiting for approval...");
|
|
519
|
+
// A --config re-run against an existing install only needs a fresh account claim and
|
|
520
|
+
// funding popups for harnesses that are actually new or whose requested funding differs
|
|
521
|
+
// from what's already stored — an unchanged harness should behave like `update`, not
|
|
522
|
+
// trigger the Claude/PennyRouter sign-in windows again. Plain (non-config) installs always
|
|
523
|
+
// treat everything selected as needing auth, matching the original behavior.
|
|
524
|
+
const needsAuth = flags.fromConfig
|
|
525
|
+
? selected.filter((harness) => harnessFundingChanged(flags, harness, manifest))
|
|
526
|
+
: selected;
|
|
450
527
|
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
528
|
+
let claim;
|
|
529
|
+
let claudeAnthropicAuth = null;
|
|
530
|
+
let funding = { credentials: [], codexSubscriptionAuth: false, claudeCredentialProvider: null, codexCredentialProvider: null };
|
|
531
|
+
let gatewayBaseUrl;
|
|
454
532
|
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
flags
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
533
|
+
if (needsAuth.length === 0) {
|
|
534
|
+
const launchConfig = await loadPennyConfig();
|
|
535
|
+
const globalAuth = await readClaudeCodeGatewayAuth();
|
|
536
|
+
const apiKey = flags.pennyKey || launchConfig.api_key || globalAuth.apiKey;
|
|
537
|
+
if (!apiKey) {
|
|
538
|
+
throw new Error(
|
|
539
|
+
"Could not find your PennyRouter key in the local config. Pass `--penny-key pr-...`, "
|
|
540
|
+
+ "or remove --config to re-authorize.",
|
|
541
|
+
);
|
|
542
|
+
}
|
|
543
|
+
console.log("No new tools or funding requested — refreshing your existing setup.");
|
|
544
|
+
console.log("");
|
|
545
|
+
claim = { api_key: apiKey, gateway_base_url: null };
|
|
546
|
+
gatewayBaseUrl = flags.gatewayBaseUrl
|
|
547
|
+
|| launchConfig.gateway_base_url
|
|
548
|
+
|| globalAuth.gatewayBaseUrl
|
|
549
|
+
|| process.env.PENNYROUTER_GATEWAY_BASE_URL
|
|
550
|
+
|| DEFAULT_GATEWAY_BASE_URL;
|
|
551
|
+
} else {
|
|
552
|
+
const session = await createCliSession({
|
|
553
|
+
appUrl: flags.appUrl || APP_URL,
|
|
554
|
+
harnesses: selected.map((harness) => harness.id),
|
|
555
|
+
accountMode: "existing",
|
|
469
556
|
});
|
|
557
|
+
|
|
558
|
+
console.log("Opening your browser to log in and authorize this machine with your PennyRouter account...");
|
|
559
|
+
console.log(`If your browser did not open, visit:\n${session.authorize_url}`);
|
|
560
|
+
if (!flags.noBrowser) openBrowser(session.authorize_url);
|
|
561
|
+
console.log("");
|
|
562
|
+
console.log("Waiting for approval...");
|
|
563
|
+
|
|
564
|
+
claim = await pollForKey(session);
|
|
565
|
+
console.log(`Authorized. Received key ${maskKey(claim.api_key)}.`);
|
|
566
|
+
console.log("");
|
|
567
|
+
|
|
568
|
+
const claudeProviders = await selectClaudeFundingProviders(flags, needsAuth);
|
|
569
|
+
const codexProviders = await selectCodexFundingProviders(flags, needsAuth);
|
|
570
|
+
claudeAnthropicAuth = await maybeConfigureClaudeAnthropicAuth(
|
|
571
|
+
flags, needsAuth, claudeProviders.has("subscription"));
|
|
572
|
+
funding = await configureUserFunding(
|
|
573
|
+
flags, needsAuth, claudeAnthropicAuth, claudeProviders, codexProviders);
|
|
574
|
+
gatewayBaseUrl = flags.gatewayBaseUrl || claim.gateway_base_url;
|
|
575
|
+
|
|
576
|
+
for (const credential of funding.credentials) {
|
|
577
|
+
await storeUpstreamCredential({
|
|
578
|
+
gatewayBaseUrl,
|
|
579
|
+
apiKey: claim.api_key,
|
|
580
|
+
provider: credential.provider,
|
|
581
|
+
token: credential.token,
|
|
582
|
+
});
|
|
583
|
+
}
|
|
470
584
|
}
|
|
471
585
|
|
|
472
|
-
if (
|
|
586
|
+
if (needsAuth.some((harness) => harness.id === "claude-code")) {
|
|
473
587
|
console.log("Verifying modern Claude Code compatibility before changing local settings...");
|
|
474
588
|
await probeClaudeCodeGateway({ gatewayBaseUrl, apiKey: claim.api_key });
|
|
475
589
|
console.log("");
|
|
@@ -501,8 +615,9 @@ export async function install(flags = {}) {
|
|
|
501
615
|
assertNoRunningHarness(harness.id, flags);
|
|
502
616
|
}
|
|
503
617
|
|
|
618
|
+
const harnessNeededAuth = needsAuth.includes(harness);
|
|
504
619
|
const codexSubscriptionAuth = harness.id === "codex"
|
|
505
|
-
? funding.codexSubscriptionAuth
|
|
620
|
+
? (harnessNeededAuth ? funding.codexSubscriptionAuth : Boolean(existingRecord?.openai_subscription_auth))
|
|
506
621
|
: false;
|
|
507
622
|
const claudeSubscriptionAuth = harness.id === "claude-code"
|
|
508
623
|
&& Boolean(claudeAnthropicAuth || existingRecord?.anthropic_token_stored);
|
|
@@ -550,11 +665,15 @@ export async function install(flags = {}) {
|
|
|
550
665
|
record.anthropic_token_stored = true;
|
|
551
666
|
record.token_source = claudeAnthropicAuth?.source || existingRecord?.token_source || "stored";
|
|
552
667
|
}
|
|
553
|
-
if (harness.id === "claude-code"
|
|
554
|
-
record.user_funded_provider =
|
|
668
|
+
if (harness.id === "claude-code") {
|
|
669
|
+
record.user_funded_provider = harnessNeededAuth
|
|
670
|
+
? (funding.claudeCredentialProvider || existingRecord?.user_funded_provider)
|
|
671
|
+
: existingRecord?.user_funded_provider;
|
|
555
672
|
}
|
|
556
|
-
if (harness.id === "codex"
|
|
557
|
-
record.user_funded_provider =
|
|
673
|
+
if (harness.id === "codex") {
|
|
674
|
+
record.user_funded_provider = harnessNeededAuth
|
|
675
|
+
? (funding.codexCredentialProvider || existingRecord?.user_funded_provider)
|
|
676
|
+
: existingRecord?.user_funded_provider;
|
|
558
677
|
}
|
|
559
678
|
if (harness.id === "codex") record.openai_subscription_auth = codexSubscriptionAuth;
|
|
560
679
|
records.push(record);
|
|
@@ -982,7 +1101,10 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
982
1101
|
if (tokenResult.token) return tokenResult;
|
|
983
1102
|
}
|
|
984
1103
|
|
|
985
|
-
|
|
1104
|
+
// --config drives --yes to skip prompts, but it still expects the subscription browser
|
|
1105
|
+
// sign-in to run — the caller (e.g. the macOS installer) attaches a pty for exactly this,
|
|
1106
|
+
// so treat it like the interactive path rather than the plain --yes/non-TTY automation path.
|
|
1107
|
+
if ((flags.yes && !flags.fromConfig) || !input.isTTY || !output.isTTY) {
|
|
986
1108
|
if (flags.anthropicAuth) {
|
|
987
1109
|
throw new Error(
|
|
988
1110
|
"Claude subscription auth was requested, but no Anthropic bearer token was found. Pass --token, --token-command, or ANTHROPIC_AUTH_TOKEN.",
|
|
@@ -998,7 +1120,8 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
998
1120
|
if (commandExists("claude")) {
|
|
999
1121
|
// The user already opted into subscription auth above, so run `claude setup-token`
|
|
1000
1122
|
// directly and capture the token — no copy-paste, no second confirm. The command
|
|
1001
|
-
// opens the browser for approval inline. On failure we fall through to manual paste
|
|
1123
|
+
// opens the browser for approval inline. On failure we fall through to manual paste,
|
|
1124
|
+
// unless we came from --config, where there's no terminal to paste into.
|
|
1002
1125
|
console.log("Launching Claude sign-in — approve in your browser…");
|
|
1003
1126
|
const captured = await runClaudeSetupToken();
|
|
1004
1127
|
if (captured) {
|
|
@@ -1006,8 +1129,13 @@ async function maybeConfigureClaudeAnthropicAuth(flags, selected, enabled = true
|
|
|
1006
1129
|
console.log("");
|
|
1007
1130
|
return { token: captured, source: "setup-token" };
|
|
1008
1131
|
}
|
|
1132
|
+
if (flags.fromConfig) {
|
|
1133
|
+
throw new Error("Claude sign-in did not complete. Run the installer again.");
|
|
1134
|
+
}
|
|
1009
1135
|
console.log("");
|
|
1010
1136
|
console.log("Couldn't capture a token automatically. Paste it below, or leave blank to skip.");
|
|
1137
|
+
} else if (flags.fromConfig) {
|
|
1138
|
+
throw new Error("Claude Code was not found on PATH, so subscription sign-in could not run.");
|
|
1011
1139
|
} else {
|
|
1012
1140
|
console.log("Claude Code was not found on PATH. Paste an Anthropic bearer token, or leave blank to skip.");
|
|
1013
1141
|
}
|
|
@@ -1858,8 +1986,8 @@ function printHelp() {
|
|
|
1858
1986
|
console.log(`PennyRouter CLI
|
|
1859
1987
|
|
|
1860
1988
|
Usage:
|
|
1861
|
-
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] [--openrouter-api-key KEY] [--local[=URL]] [--allow-running-harnesses]
|
|
1862
|
-
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]] [--no-mcp] [--allow-running-harnesses]
|
|
1989
|
+
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] [--openrouter-api-key KEY] [--local[=URL]|--prod] [--config PATH] [--allow-running-harnesses]
|
|
1990
|
+
pennyrouter update [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--dry-run] [--penny-key pr-...] [--gateway-base-url URL|--local[=URL]|--prod] [--no-mcp] [--allow-running-harnesses]
|
|
1863
1991
|
pennyrouter auth anthropic [--penny-key pr-...] [--token oauth-token] [--token-command CMD] [--gateway-base-url URL] [--forget]
|
|
1864
1992
|
pennyrouter disable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
1865
1993
|
pennyrouter enable [--harness ${SUPPORTED_HARNESS_IDS}] [--all] [--yes]
|
|
@@ -1888,6 +2016,7 @@ Examples:
|
|
|
1888
2016
|
npx pennyrouter update --harness claude-code
|
|
1889
2017
|
npx pennyrouter update --harness codex --local
|
|
1890
2018
|
npx pennyrouter update --all --local
|
|
2019
|
+
npx pennyrouter update --all --prod
|
|
1891
2020
|
npx pennyrouter install
|
|
1892
2021
|
npx pennyrouter install --make-default
|
|
1893
2022
|
npx pennyrouter install --penny-only
|
|
@@ -1898,6 +2027,7 @@ Examples:
|
|
|
1898
2027
|
npx pennyrouter auth anthropic
|
|
1899
2028
|
npx pennyrouter install --all
|
|
1900
2029
|
npx pennyrouter install --harness windsurf,zed,codegpt,aider --yes
|
|
2030
|
+
npx pennyrouter install --config ./penny_install.json
|
|
1901
2031
|
npx pennyrouter disable
|
|
1902
2032
|
npx pennyrouter enable
|
|
1903
2033
|
npx pennyrouter uninstall
|
package/src/cli.test.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import assert from "node:assert/strict";
|
|
2
|
+
import { mkdtempSync, writeFileSync, rmSync } from "node:fs";
|
|
3
|
+
import { tmpdir } from "node:os";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import test from "node:test";
|
|
6
|
+
import { harnessFundingChanged, main } from "./cli.js";
|
|
7
|
+
|
|
8
|
+
// --config lets a GUI installer (the macOS DMG) answer install questions up front instead of
|
|
9
|
+
// over a TTY. These tests drive it through `main()` in --dry-run, which prints its plan and
|
|
10
|
+
// returns before any network call or filesystem write, so no mocking is needed.
|
|
11
|
+
|
|
12
|
+
function withConfigFile(config, run) {
|
|
13
|
+
const dir = mkdtempSync(join(tmpdir(), "pennyrouter-config-"));
|
|
14
|
+
const path = join(dir, "penny_install.json");
|
|
15
|
+
writeFileSync(path, JSON.stringify(config));
|
|
16
|
+
try {
|
|
17
|
+
return run(path);
|
|
18
|
+
} finally {
|
|
19
|
+
rmSync(dir, { recursive: true, force: true });
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function captureLogs(run) {
|
|
24
|
+
const original = console.log;
|
|
25
|
+
const lines = [];
|
|
26
|
+
console.log = (...args) => lines.push(args.join(" "));
|
|
27
|
+
return run().finally(() => { console.log = original; }).then(() => lines);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
test("--config selects harnesses without prompting", async () => {
|
|
31
|
+
await withConfigFile({ harnesses: ["claude-code"] }, async (path) => {
|
|
32
|
+
const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
|
|
33
|
+
assert.ok(lines.some((line) => line.includes("Claude Code")));
|
|
34
|
+
assert.ok(!lines.some((line) => line.includes("Codex")));
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("--config with claudeFunding subscription sets --anthropic-auth", async () => {
|
|
39
|
+
await withConfigFile(
|
|
40
|
+
{ harnesses: ["claude-code"], claudeFunding: ["subscription"] },
|
|
41
|
+
async (path) => {
|
|
42
|
+
const lines = await captureLogs(() => main(["install", "--config", path, "--dry-run"]));
|
|
43
|
+
assert.ok(lines.some((line) => line.includes("Claude Code provider picker")));
|
|
44
|
+
},
|
|
45
|
+
);
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
test("--config rejects an unreadable file", async () => {
|
|
49
|
+
await assert.rejects(
|
|
50
|
+
() => main(["install", "--config", "/nonexistent/penny_install.json", "--dry-run"]),
|
|
51
|
+
/Could not read --config file/,
|
|
52
|
+
);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
test("explicit --harness overrides --config harnesses", async () => {
|
|
56
|
+
await withConfigFile({ harnesses: ["codex"] }, async (path) => {
|
|
57
|
+
const lines = await captureLogs(
|
|
58
|
+
() => main(["install", "--harness", "claude-code", "--config", path, "--dry-run"]),
|
|
59
|
+
);
|
|
60
|
+
assert.ok(lines.some((line) => line.includes("Claude Code")));
|
|
61
|
+
assert.ok(!lines.some((line) => line.includes("Codex")));
|
|
62
|
+
});
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
// harnessFundingChanged decides whether a --config re-run needs to re-open the account-claim
|
|
66
|
+
// and subscription-auth popups for a harness, or can treat it like `update` (skip both). This
|
|
67
|
+
// only kicks in on --config re-runs against an existing manifest; a live end-to-end run against
|
|
68
|
+
// a real account confirmed the same three cases these unit tests cover.
|
|
69
|
+
|
|
70
|
+
const claudeHarness = { id: "claude-code" };
|
|
71
|
+
const codexHarness = { id: "codex" };
|
|
72
|
+
|
|
73
|
+
test("harnessFundingChanged: brand new harness always needs auth", () => {
|
|
74
|
+
const manifest = { installations: [] };
|
|
75
|
+
assert.equal(harnessFundingChanged({}, claudeHarness, manifest), true);
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
test("harnessFundingChanged: claude subscription unchanged", () => {
|
|
79
|
+
const manifest = {
|
|
80
|
+
installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
|
|
81
|
+
};
|
|
82
|
+
const flags = { configClaudeFunding: new Set(["subscription"]) };
|
|
83
|
+
assert.equal(harnessFundingChanged(flags, claudeHarness, manifest), false);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test("harnessFundingChanged: claude subscription to anthropic-api is a change", () => {
|
|
87
|
+
const manifest = {
|
|
88
|
+
installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
|
|
89
|
+
};
|
|
90
|
+
const flags = { configClaudeFunding: new Set(["anthropic-api"]) };
|
|
91
|
+
assert.equal(harnessFundingChanged(flags, claudeHarness, manifest), true);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
test("harnessFundingChanged: codex subscription unchanged (openai_subscription_auth flag)", () => {
|
|
95
|
+
const manifest = {
|
|
96
|
+
installations: [{
|
|
97
|
+
harness: "codex",
|
|
98
|
+
user_funded_provider: "chatgpt-subscription",
|
|
99
|
+
openai_subscription_auth: true,
|
|
100
|
+
}],
|
|
101
|
+
};
|
|
102
|
+
const flags = { configCodexFunding: new Set(["subscription"]) };
|
|
103
|
+
assert.equal(harnessFundingChanged(flags, codexHarness, manifest), false);
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("harnessFundingChanged: codex openrouter unchanged", () => {
|
|
107
|
+
const manifest = {
|
|
108
|
+
installations: [{
|
|
109
|
+
harness: "codex",
|
|
110
|
+
user_funded_provider: "openrouter",
|
|
111
|
+
openai_subscription_auth: false,
|
|
112
|
+
}],
|
|
113
|
+
};
|
|
114
|
+
const flags = { configCodexFunding: new Set(["openrouter"]) };
|
|
115
|
+
assert.equal(harnessFundingChanged(flags, codexHarness, manifest), false);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
test("harnessFundingChanged: no requested funding for an existing harness is not a change", () => {
|
|
119
|
+
const manifest = {
|
|
120
|
+
installations: [{ harness: "claude-code", user_funded_provider: "claude-subscription" }],
|
|
121
|
+
};
|
|
122
|
+
assert.equal(harnessFundingChanged({}, claudeHarness, manifest), false);
|
|
123
|
+
});
|
package/src/statusline.js
CHANGED
|
@@ -46,7 +46,7 @@ export async function configureClaudeCodeStatusline(current) {
|
|
|
46
46
|
current.statusLine = {
|
|
47
47
|
type: "command",
|
|
48
48
|
command: `node "${statuslineScriptPath()}"`,
|
|
49
|
-
refreshInterval:
|
|
49
|
+
refreshInterval: 1,
|
|
50
50
|
...(existing?.padding !== undefined ? { padding: existing.padding } : {}),
|
|
51
51
|
};
|
|
52
52
|
return { script_path: statuslineScriptPath(), wrapped: Boolean(config.wrap_command) };
|
|
@@ -121,6 +121,9 @@ if (process.argv[2] === "--refresh") {
|
|
|
121
121
|
});
|
|
122
122
|
if (resp.ok) {
|
|
123
123
|
const data = await resp.json();
|
|
124
|
+
if (data.cache_expires_at && typeof data.cache_expires_at === "number") {
|
|
125
|
+
data.cache_expires_at = Math.round(data.cache_expires_at / 10) * 10;
|
|
126
|
+
}
|
|
124
127
|
mkdirSync(cacheDir, { recursive: true });
|
|
125
128
|
writeFileSync(join(cacheDir, sessionId + ".json"), JSON.stringify(data));
|
|
126
129
|
}
|
|
@@ -168,17 +171,15 @@ function askBadges(c) {
|
|
|
168
171
|
// Cache warmth countdown: when a prompt cache prefix is live, show time remaining.
|
|
169
172
|
// The gateway sends cache_expires_at (absolute Unix timestamp); we interpolate locally
|
|
170
173
|
// so it ticks down smoothly every statusline render (every ~1s), only "jumping" when
|
|
171
|
-
// the gateway refreshes (~
|
|
172
|
-
// to avoid annoying flicker; show ❄️ cold when expired.
|
|
174
|
+
// the gateway refreshes (~15s) with a fresh timestamp. Show ❄️ cold when expired.
|
|
173
175
|
function cacheWarmth(c) {
|
|
174
176
|
if (!c || typeof c.cache_expires_at !== "number" || c.cache_expires_at <= 0) {
|
|
175
177
|
return "";
|
|
176
178
|
}
|
|
177
179
|
const remaining = c.cache_expires_at - Date.now() / 1000;
|
|
178
180
|
if (remaining <= 0) return " · ❄️ cold";
|
|
179
|
-
const
|
|
180
|
-
const
|
|
181
|
-
const seconds = Math.floor(roundedRemaining % 60);
|
|
181
|
+
const minutes = Math.floor(remaining / 60);
|
|
182
|
+
const seconds = Math.floor(remaining % 60);
|
|
182
183
|
return " · 🔥 warm " + minutes + ":" + String(seconds).padStart(2, "0");
|
|
183
184
|
}
|
|
184
185
|
|