pi-fancy-footer 1.3.0 → 1.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 CHANGED
@@ -276,9 +276,10 @@ Notes:
276
276
  from `~/.pi/agent/auth.json`, falling back to Codex CLI credentials in
277
277
  `~/.codex/auth.json`. Claude uses pi Anthropic OAuth credentials from
278
278
  `~/.pi/agent/auth.json` and reads Claude.ai usage for the 5-hour and weekly
279
- windows. Status is cached under `~/.cache/pi-fancy-footer/provider-status/`.
280
- The widget is hidden when the active model selection is not backed by the
281
- status provider.
279
+ windows. Status is cached under `~/.cache/pi-fancy-footer/provider-status/`;
280
+ when a refresh fails, cached quota windows keep showing until their reset times
281
+ pass instead of hiding the widget. The widget is hidden when the active model
282
+ selection is not backed by the status provider.
282
283
  - `provider-status` also refreshes from `x-codex-*` provider response headers
283
284
  when pi exposes them, avoiding a separate Codex status request after provider
284
285
  calls. Claude status refreshes from the Claude.ai usage endpoint, not
@@ -289,13 +290,15 @@ Notes:
289
290
  work better in terminals that don't use a Nerd Font.
290
291
  - Per-widget icon overrides only let you hide the icon. The selected
291
292
  `iconFamily` controls which icon each widget uses.
292
- - The PR widgets appear only for open GitHub pull requests and rely on the
293
- GitHub CLI (`gh`) being available and authenticated.
293
+ - The PR widgets appear only for open GitHub and GitHub Enterprise pull
294
+ requests on GitHub-style hosts such as `github.example.com`; they rely on the
295
+ GitHub CLI (`gh`) being available and authenticated for the remote host.
294
296
  - `pull-request-review-threads` counts unresolved GitHub review threads
295
297
  on the current PR.
296
298
  - `pull-request-ci-status` shows GitHub Actions workflow runs for the current
297
299
  PR head commit. It links to the relevant run and switches to failed as soon as
298
300
  one workflow fails, even when other workflows are still running.
301
+
299
302
  ## 🧱 Gauge styles
300
303
 
301
304
  The `gaugeStyle` setting controls the characters used by the `context-bar`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-fancy-footer",
3
- "version": "1.3.0",
3
+ "version": "1.3.2",
4
4
  "description": "A fancy footer extension for pi",
5
5
  "type": "module",
6
6
  "peerDependencies": {
package/src/ci.test.ts CHANGED
@@ -76,6 +76,13 @@ test("buildWorkflowRunsPath creates GitHub Actions runs endpoint", () => {
76
76
  buildWorkflowRunsPath("https://github.com/org/repo/pull/42", "abc123"),
77
77
  "repos/org/repo/actions/runs?head_sha=abc123&per_page=100",
78
78
  );
79
+ assert.equal(
80
+ buildWorkflowRunsPath(
81
+ "https://github.example.com/org/repo/pull/42",
82
+ "abc123",
83
+ ),
84
+ "repos/org/repo/actions/runs?head_sha=abc123&per_page=100",
85
+ );
79
86
  assert.equal(
80
87
  buildWorkflowRunsPath("https://example.com/x/y/pull/1", "abc"),
81
88
  undefined,
package/src/git.test.ts CHANGED
@@ -129,6 +129,7 @@ test("collectPullRequestInfo ignores foreign branch-name matches and falls back
129
129
  assert.deepEqual(result.pullRequest, {
130
130
  number: 7,
131
131
  url: "https://github.com/org/repo/pull/7",
132
+ host: "github.com",
132
133
  });
133
134
  assert.equal(result.pullRequestLookupEnabled, true);
134
135
  assert.notEqual(result.pullRequestLookupAt, 0);
@@ -223,6 +224,7 @@ test("collectPullRequestInfo includes unresolved review thread count", async ()
223
224
  assert.deepEqual(result.pullRequest, {
224
225
  number: 12,
225
226
  url: "https://github.com/me/repo/pull/12",
227
+ host: "github.com",
226
228
  unresolvedReviewThreadCount: 2,
227
229
  });
228
230
  });
@@ -271,7 +273,9 @@ test("collectPullRequestInfo includes PR CI status when requested", async () =>
271
273
  if (
272
274
  command === "gh" &&
273
275
  args[0] === "api" &&
274
- args[1] === "repos/me/repo/actions/runs?head_sha=abc123&per_page=100"
276
+ args[1] === "--hostname" &&
277
+ args[2] === "github.com" &&
278
+ args[3] === "repos/me/repo/actions/runs?head_sha=abc123&per_page=100"
275
279
  ) {
276
280
  return {
277
281
  code: 0,
@@ -306,6 +310,7 @@ test("collectPullRequestInfo includes PR CI status when requested", async () =>
306
310
  assert.deepEqual(result.pullRequest, {
307
311
  number: 12,
308
312
  url: "https://github.com/me/repo/pull/12",
313
+ host: "github.com",
309
314
  headRefOid: "abc123",
310
315
  ciStatus: {
311
316
  state: "failed",
@@ -314,6 +319,128 @@ test("collectPullRequestInfo includes PR CI status when requested", async () =>
314
319
  });
315
320
  });
316
321
 
322
+ test("collectPullRequestInfo uses the GitHub Enterprise host for API calls", async () => {
323
+ const { pi, calls } = createPi(({ command, args }) => {
324
+ if (command === "git" && gitSubcommand(args) === "rev-parse") {
325
+ return { code: 0, stdout: "origin/feature\n", stderr: "" };
326
+ }
327
+
328
+ if (command === "git" && gitSubcommand(args) === "config") {
329
+ return {
330
+ code: 0,
331
+ stdout: "remote.origin.url git@github.example.com:me/repo.git",
332
+ stderr: "",
333
+ };
334
+ }
335
+
336
+ if (
337
+ command === "gh" &&
338
+ args[0] === "api" &&
339
+ args[1] === "graphql" &&
340
+ args[2] === "--hostname" &&
341
+ args[3] === "github.example.com" &&
342
+ args.includes("branch=feature")
343
+ ) {
344
+ return {
345
+ code: 0,
346
+ stdout: JSON.stringify({
347
+ data: {
348
+ repository: {
349
+ pullRequests: {
350
+ nodes: [
351
+ {
352
+ number: 12,
353
+ url: "https://github.example.com/me/repo/pull/12",
354
+ headRefOid: "abc123",
355
+ headRepositoryOwner: { login: "me" },
356
+ },
357
+ ],
358
+ },
359
+ },
360
+ },
361
+ }),
362
+ stderr: "",
363
+ };
364
+ }
365
+
366
+ if (
367
+ command === "gh" &&
368
+ args[0] === "api" &&
369
+ args[1] === "graphql" &&
370
+ args[2] === "--hostname" &&
371
+ args[3] === "github.example.com" &&
372
+ args.includes("number=12")
373
+ ) {
374
+ return {
375
+ code: 0,
376
+ stdout: JSON.stringify({
377
+ data: {
378
+ repository: {
379
+ pullRequest: {
380
+ reviewThreads: {
381
+ pageInfo: {
382
+ hasNextPage: false,
383
+ endCursor: null,
384
+ },
385
+ nodes: [{ isResolved: false }],
386
+ },
387
+ },
388
+ },
389
+ },
390
+ }),
391
+ stderr: "",
392
+ };
393
+ }
394
+
395
+ if (
396
+ command === "gh" &&
397
+ args[0] === "api" &&
398
+ args[1] === "--hostname" &&
399
+ args[2] === "github.example.com" &&
400
+ args[3] === "repos/me/repo/actions/runs?head_sha=abc123&per_page=100"
401
+ ) {
402
+ return {
403
+ code: 0,
404
+ stdout: JSON.stringify({
405
+ workflow_runs: [
406
+ {
407
+ status: "completed",
408
+ conclusion: "success",
409
+ html_url: "https://github.example.com/me/repo/actions/runs/1",
410
+ updated_at: "2026-01-01T10:00:00Z",
411
+ },
412
+ ],
413
+ }),
414
+ stderr: "",
415
+ };
416
+ }
417
+
418
+ throw new Error(`Unexpected command: ${command} ${args.join(" ")}`);
419
+ });
420
+
421
+ const result = await collectPullRequestInfo(pi as never, "/repo", "feature", {
422
+ includeCiStatus: true,
423
+ });
424
+
425
+ assert.deepEqual(result.pullRequest, {
426
+ number: 12,
427
+ url: "https://github.example.com/me/repo/pull/12",
428
+ host: "github.example.com",
429
+ headRefOid: "abc123",
430
+ unresolvedReviewThreadCount: 1,
431
+ ciStatus: {
432
+ state: "okay",
433
+ url: "https://github.example.com/me/repo/actions/runs/1",
434
+ },
435
+ });
436
+ assert.equal(
437
+ calls
438
+ .filter((call) => call.command === "gh" && call.args[0] === "api")
439
+ .every((call) => call.args.includes("github.example.com")),
440
+ true,
441
+ );
442
+ });
443
+
317
444
  test("collectPullRequestInfo skips GitHub CLI lookups when the repository has no GitHub remote", async () => {
318
445
  const { pi, calls } = createPi(({ command, args }) => {
319
446
  if (command === "git" && gitSubcommand(args) === "rev-parse") {
package/src/git.ts CHANGED
@@ -6,10 +6,10 @@ import {
6
6
  parsePullRequest,
7
7
  parsePullRequestReviewThreadsPage,
8
8
  selectPullRequestFromGraphQL,
9
- splitGitHubRepository,
10
9
  } from "./pull-request.ts";
11
10
  import {
12
11
  EMPTY_GIT_INFO,
12
+ type GitHubRepositoryRef,
13
13
  type GitInfo,
14
14
  parseNumstat,
15
15
  toNumber,
@@ -117,12 +117,11 @@ async function execGit(
117
117
  async function collectPullRequestFromBaseRepository(
118
118
  pi: ExtensionAPI,
119
119
  cwd: string,
120
- baseRepository: string,
120
+ baseRepository: GitHubRepositoryRef,
121
121
  branch: string,
122
122
  headOwners: string[],
123
123
  ): Promise<GitInfo["pullRequest"]> {
124
- const repository = splitGitHubRepository(baseRepository);
125
- if (!repository || !branch) return undefined;
124
+ if (!branch) return undefined;
126
125
 
127
126
  const result = await execResult(
128
127
  pi,
@@ -130,12 +129,14 @@ async function collectPullRequestFromBaseRepository(
130
129
  [
131
130
  "api",
132
131
  "graphql",
132
+ "--hostname",
133
+ baseRepository.host,
133
134
  "-f",
134
135
  `query=${PULL_REQUEST_QUERY}`,
135
136
  "-F",
136
- `owner=${repository.owner}`,
137
+ `owner=${baseRepository.owner}`,
137
138
  "-F",
138
- `name=${repository.name}`,
139
+ `name=${baseRepository.name}`,
139
140
  "-F",
140
141
  `branch=${branch}`,
141
142
  ],
@@ -144,7 +145,9 @@ async function collectPullRequestFromBaseRepository(
144
145
  );
145
146
  if (result.code !== 0 || !result.stdout) return undefined;
146
147
 
147
- return selectPullRequestFromGraphQL(result.stdout, headOwners);
148
+ const pullRequest = selectPullRequestFromGraphQL(result.stdout, headOwners);
149
+ if (!pullRequest) return undefined;
150
+ return { ...pullRequest, host: pullRequest.host ?? baseRepository.host };
148
151
  }
149
152
 
150
153
  async function collectCurrentBranchPullRequest(
@@ -178,6 +181,8 @@ async function collectPullRequestReviewThreadCount(
178
181
  const args = [
179
182
  "api",
180
183
  "graphql",
184
+ "--hostname",
185
+ location.host,
181
186
  "-f",
182
187
  `query=${PULL_REQUEST_REVIEW_THREADS_QUERY}`,
183
188
  "-F",
@@ -221,11 +226,14 @@ async function collectPullRequestCiStatus(
221
226
  pullRequest.headRefOid ?? "",
222
227
  );
223
228
  if (!path) return undefined;
229
+ const location = parseGitHubPullRequestUrl(pullRequest.url);
230
+ const host = pullRequest.host ?? location?.host;
231
+ if (!host) return undefined;
224
232
 
225
233
  const result = await execResult(
226
234
  pi,
227
235
  "gh",
228
- ["api", path],
236
+ ["api", "--hostname", host, path],
229
237
  cwd,
230
238
  GITHUB_COMMAND_TIMEOUT_MS,
231
239
  );
@@ -14,10 +14,7 @@ import {
14
14
  providerStatusColor,
15
15
  updateProviderStatusFromHeaders,
16
16
  } from "./provider-status.ts";
17
- import {
18
- getGaugeStyle,
19
- type ProviderStatusConfigSnapshot,
20
- } from "./shared.ts";
17
+ import { getGaugeStyle, type ProviderStatusConfigSnapshot } from "./shared.ts";
21
18
 
22
19
  const now = new Date("2026-05-06T10:00:00Z");
23
20
  const providerStatusConfig: ProviderStatusConfigSnapshot = {
@@ -338,6 +335,295 @@ test("collectProviderStatus does not present expired cache as live status after
338
335
  assert.match(snapshot?.error ?? "", /No usable Codex OAuth credentials/);
339
336
  });
340
337
 
338
+ test("collectProviderStatus keeps cached quota in effect after a failed refresh", async (t) => {
339
+ const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
340
+ t.after(async () => {
341
+ await rm(dir, { recursive: true, force: true });
342
+ });
343
+
344
+ const previousHome = process.env.HOME;
345
+ const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
346
+ const previousFetch = globalThis.fetch;
347
+ process.env.HOME = dir;
348
+ process.env.XDG_CACHE_HOME = join(dir, "cache");
349
+ globalThis.fetch = async () =>
350
+ new Response(JSON.stringify({ error: { message: "rate limited" } }), {
351
+ status: 429,
352
+ });
353
+ t.after(() => {
354
+ if (previousHome === undefined) delete process.env.HOME;
355
+ else process.env.HOME = previousHome;
356
+ if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
357
+ else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
358
+ globalThis.fetch = previousFetch;
359
+ });
360
+
361
+ await mkdir(join(dir, ".pi", "agent"), { recursive: true });
362
+ await writeFile(
363
+ join(dir, ".pi", "agent", "auth.json"),
364
+ JSON.stringify({ anthropic: { access: "test-access-token" } }),
365
+ { mode: 0o600 },
366
+ );
367
+
368
+ const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
369
+ const cacheDir = join(
370
+ process.env.XDG_CACHE_HOME,
371
+ "pi-fancy-footer",
372
+ "provider-status",
373
+ );
374
+ await mkdir(cacheDir, { recursive: true });
375
+ await writeFile(
376
+ join(cacheDir, "anthropic.json"),
377
+ JSON.stringify({
378
+ provider: "anthropic",
379
+ source: "api",
380
+ fetchedAt: "2026-05-06T09:00:00Z",
381
+ state: "ok",
382
+ primary: {
383
+ label: "5h",
384
+ usedPercent: 5,
385
+ leftPercent: 95,
386
+ resetAt: futureResetAt,
387
+ },
388
+ secondary: {
389
+ label: "7d",
390
+ usedPercent: 12,
391
+ leftPercent: 88,
392
+ resetAt: futureResetAt,
393
+ },
394
+ url: "https://claude.ai/settings/usage",
395
+ }),
396
+ { mode: 0o600 },
397
+ );
398
+
399
+ const snapshots = await collectProviderStatus({} as never, {
400
+ ...anthropicProviderStatusConfig,
401
+ cacheTtlMs: 1,
402
+ });
403
+
404
+ assert.equal(snapshots.length, 1);
405
+ const snapshot = snapshots[0];
406
+ assert.equal(snapshot?.source, "cache");
407
+ assert.equal(snapshot?.state, "ok");
408
+ assert.deepEqual(snapshot?.primary, {
409
+ label: "5h",
410
+ usedPercent: 5,
411
+ leftPercent: 95,
412
+ resetAt: futureResetAt,
413
+ });
414
+ assert.deepEqual(snapshot?.secondary, {
415
+ label: "7d",
416
+ usedPercent: 12,
417
+ leftPercent: 88,
418
+ resetAt: futureResetAt,
419
+ });
420
+ assert.match(snapshot?.error ?? "", /429/);
421
+ });
422
+
423
+ test("collectProviderStatus hides cached quota once its windows reset", async (t) => {
424
+ const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
425
+ t.after(async () => {
426
+ await rm(dir, { recursive: true, force: true });
427
+ });
428
+
429
+ const previousHome = process.env.HOME;
430
+ const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
431
+ const previousFetch = globalThis.fetch;
432
+ process.env.HOME = dir;
433
+ process.env.XDG_CACHE_HOME = join(dir, "cache");
434
+ globalThis.fetch = async () => new Response("rate limited", { status: 429 });
435
+ t.after(() => {
436
+ if (previousHome === undefined) delete process.env.HOME;
437
+ else process.env.HOME = previousHome;
438
+ if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
439
+ else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
440
+ globalThis.fetch = previousFetch;
441
+ });
442
+
443
+ await mkdir(join(dir, ".pi", "agent"), { recursive: true });
444
+ await writeFile(
445
+ join(dir, ".pi", "agent", "auth.json"),
446
+ JSON.stringify({ anthropic: { access: "test-access-token" } }),
447
+ { mode: 0o600 },
448
+ );
449
+
450
+ const cacheDir = join(
451
+ process.env.XDG_CACHE_HOME,
452
+ "pi-fancy-footer",
453
+ "provider-status",
454
+ );
455
+ await mkdir(cacheDir, { recursive: true });
456
+ await writeFile(
457
+ join(cacheDir, "anthropic.json"),
458
+ JSON.stringify({
459
+ provider: "anthropic",
460
+ source: "api",
461
+ fetchedAt: "2026-05-06T09:00:00Z",
462
+ state: "ok",
463
+ primary: { label: "5h", usedPercent: 5, leftPercent: 95, resetAt: 1 },
464
+ secondary: {
465
+ label: "7d",
466
+ usedPercent: 12,
467
+ leftPercent: 88,
468
+ resetAt: 1,
469
+ },
470
+ url: "https://claude.ai/settings/usage",
471
+ }),
472
+ { mode: 0o600 },
473
+ );
474
+
475
+ const snapshots = await collectProviderStatus({} as never, {
476
+ ...anthropicProviderStatusConfig,
477
+ cacheTtlMs: 1,
478
+ });
479
+
480
+ assert.equal(snapshots.length, 1);
481
+ const snapshot = snapshots[0];
482
+ assert.equal(snapshot?.source, "api");
483
+ assert.equal(snapshot?.state, "unavailable");
484
+ assert.equal(snapshot?.primary, undefined);
485
+ assert.equal(snapshot?.secondary, undefined);
486
+ assert.match(snapshot?.error ?? "", /429/);
487
+ });
488
+
489
+ test("collectProviderStatus keeps cached quota in effect after a failed auth refresh", async (t) => {
490
+ const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
491
+ t.after(async () => {
492
+ await rm(dir, { recursive: true, force: true });
493
+ });
494
+
495
+ const previousHome = process.env.HOME;
496
+ const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
497
+ const previousFetch = globalThis.fetch;
498
+ process.env.HOME = dir;
499
+ process.env.XDG_CACHE_HOME = join(dir, "cache");
500
+ globalThis.fetch = async () => new Response("rate limited", { status: 429 });
501
+ t.after(() => {
502
+ if (previousHome === undefined) delete process.env.HOME;
503
+ else process.env.HOME = previousHome;
504
+ if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
505
+ else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
506
+ globalThis.fetch = previousFetch;
507
+ });
508
+
509
+ await mkdir(join(dir, ".pi", "agent"), { recursive: true });
510
+ await writeFile(
511
+ join(dir, ".pi", "agent", "auth.json"),
512
+ JSON.stringify({
513
+ anthropic: {
514
+ access: "expired-access-token",
515
+ refresh: "test-refresh-token",
516
+ expires: Date.now() - 60_000,
517
+ },
518
+ }),
519
+ { mode: 0o600 },
520
+ );
521
+
522
+ const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
523
+ const cacheDir = join(
524
+ process.env.XDG_CACHE_HOME,
525
+ "pi-fancy-footer",
526
+ "provider-status",
527
+ );
528
+ await mkdir(cacheDir, { recursive: true });
529
+ await writeFile(
530
+ join(cacheDir, "anthropic.json"),
531
+ JSON.stringify({
532
+ provider: "anthropic",
533
+ source: "api",
534
+ fetchedAt: "2026-05-06T09:00:00Z",
535
+ state: "ok",
536
+ primary: {
537
+ label: "5h",
538
+ usedPercent: 5,
539
+ leftPercent: 95,
540
+ resetAt: futureResetAt,
541
+ },
542
+ url: "https://claude.ai/settings/usage",
543
+ }),
544
+ { mode: 0o600 },
545
+ );
546
+
547
+ const snapshots = await collectProviderStatus({} as never, {
548
+ ...anthropicProviderStatusConfig,
549
+ cacheTtlMs: 1,
550
+ });
551
+
552
+ assert.equal(snapshots.length, 1);
553
+ const snapshot = snapshots[0];
554
+ assert.equal(snapshot?.source, "cache");
555
+ assert.equal(snapshot?.state, "ok");
556
+ assert.equal(snapshot?.primary?.resetAt, futureResetAt);
557
+ assert.match(snapshot?.error ?? "", /429/);
558
+ });
559
+
560
+ test("collectProviderStatus keeps cached quota in effect regardless of the failure cause", async (t) => {
561
+ const dir = await mkdtemp(join(tmpdir(), "pi-fancy-footer-test-"));
562
+ t.after(async () => {
563
+ await rm(dir, { recursive: true, force: true });
564
+ });
565
+
566
+ const previousHome = process.env.HOME;
567
+ const previousXdgCacheHome = process.env.XDG_CACHE_HOME;
568
+ const previousFetch = globalThis.fetch;
569
+ process.env.HOME = dir;
570
+ process.env.XDG_CACHE_HOME = join(dir, "cache");
571
+ // A non-retryable failure (e.g. revoked credentials) must not invalidate a
572
+ // quota window that has not reset yet.
573
+ globalThis.fetch = async () => new Response("forbidden", { status: 403 });
574
+ t.after(() => {
575
+ if (previousHome === undefined) delete process.env.HOME;
576
+ else process.env.HOME = previousHome;
577
+ if (previousXdgCacheHome === undefined) delete process.env.XDG_CACHE_HOME;
578
+ else process.env.XDG_CACHE_HOME = previousXdgCacheHome;
579
+ globalThis.fetch = previousFetch;
580
+ });
581
+
582
+ await mkdir(join(dir, ".pi", "agent"), { recursive: true });
583
+ await writeFile(
584
+ join(dir, ".pi", "agent", "auth.json"),
585
+ JSON.stringify({ anthropic: { access: "test-access-token" } }),
586
+ { mode: 0o600 },
587
+ );
588
+
589
+ const futureResetAt = Math.ceil(Date.now() / 1000) + 3_600;
590
+ const cacheDir = join(
591
+ process.env.XDG_CACHE_HOME,
592
+ "pi-fancy-footer",
593
+ "provider-status",
594
+ );
595
+ await mkdir(cacheDir, { recursive: true });
596
+ await writeFile(
597
+ join(cacheDir, "anthropic.json"),
598
+ JSON.stringify({
599
+ provider: "anthropic",
600
+ source: "api",
601
+ fetchedAt: "2026-05-06T09:00:00Z",
602
+ state: "ok",
603
+ primary: {
604
+ label: "5h",
605
+ usedPercent: 5,
606
+ leftPercent: 95,
607
+ resetAt: futureResetAt,
608
+ },
609
+ url: "https://claude.ai/settings/usage",
610
+ }),
611
+ { mode: 0o600 },
612
+ );
613
+
614
+ const snapshots = await collectProviderStatus({} as never, {
615
+ ...anthropicProviderStatusConfig,
616
+ cacheTtlMs: 1,
617
+ });
618
+
619
+ assert.equal(snapshots.length, 1);
620
+ const snapshot = snapshots[0];
621
+ assert.equal(snapshot?.source, "cache");
622
+ assert.equal(snapshot?.state, "ok");
623
+ assert.equal(snapshot?.primary?.resetAt, futureResetAt);
624
+ assert.match(snapshot?.error ?? "", /403/);
625
+ });
626
+
341
627
  test("updateProviderStatusFromHeaders honors disabled providers", async () => {
342
628
  const updated = await updateProviderStatusFromHeaders(
343
629
  {
@@ -28,6 +28,10 @@ const CLAUDE_SECONDARY_WINDOW_LABEL = "7d";
28
28
 
29
29
  type HeaderLike = Record<string, string | number | boolean | undefined | null>;
30
30
 
31
+ type TokenRefreshResult =
32
+ | { ok: true; stdout: string }
33
+ | { ok: false; error: Error };
34
+
31
35
  export interface ProviderStatusSource {
32
36
  id: string;
33
37
  label: string;
@@ -106,10 +110,7 @@ export function isProviderStatusRelevantToModel(
106
110
  providerId: string,
107
111
  model: ModelLike | string | undefined,
108
112
  ): boolean {
109
- if (
110
- providerId !== CODEX_SOURCE.id &&
111
- providerId !== ANTHROPIC_SOURCE.id
112
- ) {
113
+ if (providerId !== CODEX_SOURCE.id && providerId !== ANTHROPIC_SOURCE.id) {
113
114
  return true;
114
115
  }
115
116
 
@@ -238,20 +239,76 @@ async function collectProviderStatusFromSource(
238
239
  await writeProviderStatusCache(snapshot).catch(() => undefined);
239
240
  return snapshot;
240
241
  } catch (error) {
241
- if (isProviderStatusFresh(cached, config.cacheTtlMs)) {
242
- return { ...cached, source: "cache" };
243
- }
244
- return {
245
- provider: source.id,
246
- source: "api",
247
- fetchedAt: new Date().toISOString(),
248
- state: "unavailable",
249
- url: source.usageUrl,
250
- error: error instanceof Error ? error.message : String(error),
251
- };
242
+ // A refresh failed. The cached quota windows remain valid until they
243
+ // reset, regardless of why the refresh failed, so keep showing whichever
244
+ // windows are still in effect.
245
+ return (
246
+ displayableCachedStatus(cached, error) ??
247
+ unavailableProviderStatus(source, error)
248
+ );
252
249
  }
253
250
  }
254
251
 
252
+ // Projects a cached snapshot onto the windows that are still in effect, i.e.
253
+ // have not reset yet. Returns undefined when nothing is left to display.
254
+ function displayableCachedStatus(
255
+ cached: ProviderStatusSnapshot | undefined,
256
+ error: unknown,
257
+ now = new Date(),
258
+ ): ProviderStatusSnapshot | undefined {
259
+ if (!cached) return undefined;
260
+
261
+ const primary = windowInEffect(cached.primary, now);
262
+ const secondary = windowInEffect(cached.secondary, now);
263
+ if (!primary && !secondary) return undefined;
264
+
265
+ const {
266
+ primary: _expiredPrimary,
267
+ secondary: _expiredSecondary,
268
+ ...rest
269
+ } = cached;
270
+ return {
271
+ ...rest,
272
+ source: "cache",
273
+ state: computeProviderStatusState(primary, secondary),
274
+ ...(primary ? { primary } : {}),
275
+ ...(secondary ? { secondary } : {}),
276
+ error: providerStatusErrorMessage(error),
277
+ };
278
+ }
279
+
280
+ // A window is in effect while its reset time is still in the future. Windows
281
+ // without a reset time have no intrinsic lifetime, so they are not shown once
282
+ // the refresh that would confirm them has failed.
283
+ function windowInEffect(
284
+ window: ProviderStatusWindow | undefined,
285
+ now: Date,
286
+ ): ProviderStatusWindow | undefined {
287
+ if (!window?.resetAt) return undefined;
288
+ const resetAtMs = window.resetAt * 1000;
289
+ return Number.isFinite(resetAtMs) && resetAtMs > now.getTime()
290
+ ? window
291
+ : undefined;
292
+ }
293
+
294
+ function unavailableProviderStatus(
295
+ source: ProviderStatusSource,
296
+ error: unknown,
297
+ ): ProviderStatusSnapshot {
298
+ return {
299
+ provider: source.id,
300
+ source: "api",
301
+ fetchedAt: new Date().toISOString(),
302
+ state: "unavailable",
303
+ url: source.usageUrl,
304
+ error: providerStatusErrorMessage(error),
305
+ };
306
+ }
307
+
308
+ function providerStatusErrorMessage(error: unknown): string {
309
+ return error instanceof Error ? error.message : String(error);
310
+ }
311
+
255
312
  export async function updateProviderStatusFromHeaders(
256
313
  headers: HeaderLike,
257
314
  config?: ProviderStatusConfigSnapshot,
@@ -495,7 +552,9 @@ async function resolveClaudeAuth(): Promise<AuthCredentials> {
495
552
  );
496
553
  }
497
554
 
498
- async function refreshIfNeeded(auth: AuthCredentials): Promise<AuthCredentials> {
555
+ async function refreshIfNeeded(
556
+ auth: AuthCredentials,
557
+ ): Promise<AuthCredentials> {
499
558
  if (!auth.refreshToken || !auth.expiresAtMs) return auth;
500
559
  if (auth.expiresAtMs > Date.now() + 5 * 60 * 1000) return auth;
501
560
  return refreshAuth(auth);
@@ -522,12 +581,15 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
522
581
  client_id: refreshConfig.clientId,
523
582
  }).toString();
524
583
 
525
- const result = await localRefresh(body, refreshConfig.tokenUrl);
584
+ const result = await requestTokenRefresh(
585
+ body,
586
+ refreshConfig.tokenUrl,
587
+ `${refreshConfig.label} auth refresh`,
588
+ );
526
589
 
527
- if (result.code !== 0 || !result.stdout) {
528
- throw new Error(
529
- result.stderr || `Failed to refresh ${refreshConfig.label} auth`,
530
- );
590
+ if (!result.ok) throw result.error;
591
+ if (!result.stdout) {
592
+ throw new Error(`Failed to refresh ${refreshConfig.label} auth`);
531
593
  }
532
594
 
533
595
  const refreshed = JSON.parse(result.stdout) as Record<string, unknown>;
@@ -538,7 +600,8 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
538
600
  );
539
601
  }
540
602
 
541
- const refreshToken = stringValue(refreshed.refresh_token) ?? auth.refreshToken;
603
+ const refreshToken =
604
+ stringValue(refreshed.refresh_token) ?? auth.refreshToken;
542
605
  const expiresIn = numberValue(refreshed.expires_in);
543
606
  const next: AuthCredentials = {
544
607
  ...auth,
@@ -552,10 +615,11 @@ async function refreshAuth(auth: AuthCredentials): Promise<AuthCredentials> {
552
615
  return next;
553
616
  }
554
617
 
555
- async function localRefresh(
618
+ async function requestTokenRefresh(
556
619
  body: string,
557
620
  tokenUrl: string,
558
- ): Promise<{ code: number; stdout: string; stderr: string }> {
621
+ label: string,
622
+ ): Promise<TokenRefreshResult> {
559
623
  try {
560
624
  const response = await fetch(tokenUrl, {
561
625
  method: "POST",
@@ -563,13 +627,19 @@ async function localRefresh(
563
627
  body,
564
628
  });
565
629
  const text = await response.text();
566
- if (!response.ok) return { code: 1, stdout: "", stderr: text };
567
- return { code: 0, stdout: text, stderr: "" };
630
+ if (!response.ok) {
631
+ return {
632
+ ok: false,
633
+ error: new Error(
634
+ `${label} failed (${response.status}): ${text.slice(0, 500)}`,
635
+ ),
636
+ };
637
+ }
638
+ return { ok: true, stdout: text };
568
639
  } catch (error) {
569
640
  return {
570
- code: -1,
571
- stdout: "",
572
- stderr: error instanceof Error ? error.message : String(error),
641
+ ok: false,
642
+ error: error instanceof Error ? error : new Error(String(error)),
573
643
  };
574
644
  }
575
645
  }
@@ -705,7 +775,7 @@ function mergeProviderStatus(
705
775
  ...update,
706
776
  ...(primary ? { primary } : {}),
707
777
  ...(secondary ? { secondary } : {}),
708
- ...(update.credits ?? existing.credits
778
+ ...((update.credits ?? existing.credits)
709
779
  ? { credits: update.credits ?? existing.credits }
710
780
  : {}),
711
781
  state: computeProviderStatusState(primary, secondary),
@@ -762,7 +832,9 @@ function normalizeClaudeUsageWindow(
762
832
  );
763
833
  }
764
834
 
765
- function windowLabelFromSeconds(seconds: number | undefined): string | undefined {
835
+ function windowLabelFromSeconds(
836
+ seconds: number | undefined,
837
+ ): string | undefined {
766
838
  if (seconds === undefined || seconds <= 0) return undefined;
767
839
  if (seconds % 86_400 === 0) return `${seconds / 86_400}d`;
768
840
  if (seconds % 3_600 === 0) return `${seconds / 3_600}h`;
@@ -854,7 +926,9 @@ function stringValue(value: unknown): string | undefined {
854
926
  }
855
927
 
856
928
  function numberValue(value: unknown): number | undefined {
857
- return typeof value === "number" && Number.isFinite(value) ? value : undefined;
929
+ return typeof value === "number" && Number.isFinite(value)
930
+ ? value
931
+ : undefined;
858
932
  }
859
933
 
860
934
  function numberString(value: string | undefined): number | undefined {
@@ -19,16 +19,84 @@ test("createGitHubRepositoryContext derives repository and PR lookup plan from r
19
19
  assert.equal(context.repository, "me/repo");
20
20
  assert.equal(context.pullRequestLookupEnabled, true);
21
21
  assert.deepEqual(context.pullRequestLookupPlan, {
22
- baseRepositories: ["org/repo", "me/repo"],
22
+ baseRepositories: [
23
+ {
24
+ host: "github.com",
25
+ owner: "org",
26
+ name: "repo",
27
+ repository: "org/repo",
28
+ },
29
+ {
30
+ host: "github.com",
31
+ owner: "me",
32
+ name: "repo",
33
+ repository: "me/repo",
34
+ },
35
+ ],
23
36
  headOwners: ["me", "org"],
24
37
  allowCurrentBranchFallback: true,
25
38
  });
26
39
  });
27
40
 
41
+ test("createGitHubRepositoryContext supports GitHub Enterprise hosts", () => {
42
+ const context = createGitHubRepositoryContext(
43
+ "remote.origin.url git@github.example.com:org/repo.git",
44
+ "origin/main",
45
+ );
46
+
47
+ assert.equal(context.repository, "org/repo");
48
+ assert.equal(context.pullRequestLookupEnabled, true);
49
+ assert.deepEqual(context.pullRequestLookupPlan, {
50
+ baseRepositories: [
51
+ {
52
+ host: "github.example.com",
53
+ owner: "org",
54
+ name: "repo",
55
+ repository: "org/repo",
56
+ },
57
+ ],
58
+ headOwners: ["org"],
59
+ allowCurrentBranchFallback: true,
60
+ });
61
+ });
62
+
63
+ test("createGitHubRepositoryContext supports GitHub Enterprise SSH URLs with ports", () => {
64
+ const context = createGitHubRepositoryContext(
65
+ "remote.origin.url ssh://git@github.example.com:2222/org/repo.git",
66
+ "origin/main",
67
+ );
68
+
69
+ assert.equal(context.repository, "org/repo");
70
+ assert.deepEqual(context.pullRequestLookupPlan?.baseRepositories, [
71
+ {
72
+ host: "github.example.com",
73
+ owner: "org",
74
+ name: "repo",
75
+ repository: "org/repo",
76
+ },
77
+ ]);
78
+ });
79
+
80
+ test("createGitHubRepositoryContext excludes non-GitHub hosts", () => {
81
+ const context = createGitHubRepositoryContext(
82
+ [
83
+ "remote.origin.url git@notgithub.com:org/repo.git",
84
+ "remote.upstream.url https://gitlab.githubmirror.com/org/repo.git",
85
+ "remote.fork.url https://gitlab.com/org/repo.git",
86
+ ].join("\n"),
87
+ "origin/main",
88
+ );
89
+
90
+ assert.equal(context.repository, "");
91
+ assert.equal(context.pullRequestLookupEnabled, false);
92
+ assert.equal(context.pullRequestLookupPlan, undefined);
93
+ });
94
+
28
95
  test("parseGitHubPullRequestUrl extracts owner, repository, and PR number", () => {
29
96
  assert.deepEqual(
30
97
  parseGitHubPullRequestUrl("https://github.com/org/repo/pull/42"),
31
98
  {
99
+ host: "github.com",
32
100
  owner: "org",
33
101
  name: "repo",
34
102
  number: 42,
@@ -39,6 +107,16 @@ test("parseGitHubPullRequestUrl extracts owner, repository, and PR number", () =
39
107
  "https://github.com/org/repo/pull/42#discussion_r1",
40
108
  ),
41
109
  {
110
+ host: "github.com",
111
+ owner: "org",
112
+ name: "repo",
113
+ number: 42,
114
+ },
115
+ );
116
+ assert.deepEqual(
117
+ parseGitHubPullRequestUrl("https://github.example.com/org/repo/pull/42"),
118
+ {
119
+ host: "github.example.com",
42
120
  owner: "org",
43
121
  name: "repo",
44
122
  number: 42,
@@ -103,6 +181,7 @@ test("selectPullRequestFromGraphQL accepts only candidates from known head owner
103
181
  assert.deepEqual(selectPullRequestFromGraphQL(output, ["me", "org"]), {
104
182
  number: 7,
105
183
  url: "https://github.com/org/repo/pull/7",
184
+ host: "github.com",
106
185
  });
107
186
  assert.equal(selectPullRequestFromGraphQL(output, ["unknown"]), undefined);
108
187
  });
@@ -1,13 +1,13 @@
1
1
  import {
2
2
  type GitHubPullRequest,
3
+ type GitHubRepositoryRef,
3
4
  parseGitHubRemote,
4
5
  toNumber,
5
6
  } from "./shared.ts";
6
7
 
7
8
  interface GitHubRemote {
8
9
  name: string;
9
- repository: string;
10
- owner: string;
10
+ ref: GitHubRepositoryRef;
11
11
  }
12
12
 
13
13
  interface PullRequestCandidate {
@@ -18,6 +18,7 @@ interface PullRequestCandidate {
18
18
  }
19
19
 
20
20
  export interface GitHubPullRequestLocation {
21
+ host: string;
21
22
  owner: string;
22
23
  name: string;
23
24
  number: number;
@@ -30,7 +31,7 @@ export interface PullRequestReviewThreadsPage {
30
31
  }
31
32
 
32
33
  export interface PullRequestLookupPlan {
33
- baseRepositories: string[];
34
+ baseRepositories: GitHubRepositoryRef[];
34
35
  headOwners: string[];
35
36
  allowCurrentBranchFallback: boolean;
36
37
  }
@@ -61,21 +62,13 @@ function parseRemoteName(ref: string): string {
61
62
  return ref.slice(0, slash);
62
63
  }
63
64
 
64
- function parseRepositoryOwner(repository: string): string {
65
- const slash = repository.indexOf("/");
66
- if (slash <= 0) return "";
67
- return repository.slice(0, slash);
68
- }
69
-
70
65
  function parseGitHubRemotes(remoteUrls: string): GitHubRemote[] {
71
66
  const remotes: GitHubRemote[] = [];
72
67
 
73
68
  for (const [name, url] of parseRemoteUrls(remoteUrls)) {
74
- const repository = parseGitHubRemote(url);
75
- if (!repository) continue;
76
- const owner = parseRepositoryOwner(repository);
77
- if (!owner) continue;
78
- remotes.push({ name, repository, owner });
69
+ const ref = parseGitHubRemote(url);
70
+ if (!ref) continue;
71
+ remotes.push({ name, ref });
79
72
  }
80
73
 
81
74
  return remotes;
@@ -85,6 +78,7 @@ function orderedRemoteValues<T>(
85
78
  remotes: GitHubRemote[],
86
79
  preferredNames: string[],
87
80
  pick: (remote: GitHubRemote) => T,
81
+ keyFor: (value: T) => string = String,
88
82
  ): T[] {
89
83
  const byName = new Map(remotes.map((remote) => [remote.name, remote]));
90
84
  const ordered: T[] = [];
@@ -98,7 +92,7 @@ function orderedRemoteValues<T>(
98
92
  const remote = byName.get(remoteName);
99
93
  if (!remote) continue;
100
94
  const value = pick(remote);
101
- const key = String(value);
95
+ const key = keyFor(value);
102
96
  if (!key || seen.has(key)) continue;
103
97
  seen.add(key);
104
98
  ordered.push(value);
@@ -115,7 +109,7 @@ function selectGitHubRepository(
115
109
  orderedRemoteValues(
116
110
  remotes,
117
111
  [preferredRemote, "origin", "upstream"],
118
- (remote) => remote.repository,
112
+ (remote) => remote.ref.repository,
119
113
  )[0] ?? ""
120
114
  );
121
115
  }
@@ -123,12 +117,13 @@ function selectGitHubRepository(
123
117
  function selectPullRequestBaseRepositories(
124
118
  remotes: GitHubRemote[],
125
119
  preferredRemote: string,
126
- ): string[] {
120
+ ): GitHubRepositoryRef[] {
127
121
  // PRs often live in the upstream repo even when the branch tracks a fork remote.
128
122
  return orderedRemoteValues(
129
123
  remotes,
130
124
  ["upstream", preferredRemote, "origin"],
131
- (remote) => remote.repository,
125
+ (remote) => remote.ref,
126
+ (ref) => `${ref.host}/${ref.repository}`,
132
127
  );
133
128
  }
134
129
 
@@ -139,7 +134,7 @@ function selectPullRequestHeadOwners(
139
134
  return orderedRemoteValues(
140
135
  remotes,
141
136
  [preferredRemote, "origin", "upstream"],
142
- (remote) => remote.owner,
137
+ (remote) => remote.ref.owner,
143
138
  );
144
139
  }
145
140
 
@@ -178,9 +173,12 @@ function selectPullRequest(
178
173
 
179
174
  if (!bestCandidate) return undefined;
180
175
 
176
+ const location = parseGitHubPullRequestUrl(bestCandidate.url);
177
+
181
178
  return {
182
179
  number: bestCandidate.number,
183
180
  url: bestCandidate.url,
181
+ ...(location ? { host: location.host } : {}),
184
182
  ...(bestCandidate.headRefOid
185
183
  ? { headRefOid: bestCandidate.headRefOid }
186
184
  : {}),
@@ -244,17 +242,6 @@ export function createGitHubRepositoryContext(
244
242
  };
245
243
  }
246
244
 
247
- export function splitGitHubRepository(
248
- repository: string,
249
- ): { owner: string; name: string } | undefined {
250
- const slash = repository.indexOf("/");
251
- if (slash <= 0 || slash >= repository.length - 1) return undefined;
252
- return {
253
- owner: repository.slice(0, slash),
254
- name: repository.slice(slash + 1),
255
- };
256
- }
257
-
258
245
  export function parsePullRequest(
259
246
  output: string,
260
247
  ): GitHubPullRequest | undefined {
@@ -269,7 +256,13 @@ export function parsePullRequest(
269
256
  const headRefOid =
270
257
  typeof parsed?.headRefOid === "string" ? parsed.headRefOid : undefined;
271
258
  if (number <= 0 || !url) return undefined;
272
- return { number, url, ...(headRefOid ? { headRefOid } : {}) };
259
+ const location = parseGitHubPullRequestUrl(url);
260
+ return {
261
+ number,
262
+ url,
263
+ ...(location ? { host: location.host } : {}),
264
+ ...(headRefOid ? { headRefOid } : {}),
265
+ };
273
266
  } catch {
274
267
  return undefined;
275
268
  }
@@ -279,14 +272,15 @@ export function parseGitHubPullRequestUrl(
279
272
  url: string,
280
273
  ): GitHubPullRequestLocation | undefined {
281
274
  const match = url.match(
282
- /^https:\/\/github\.com\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/,
275
+ /^https:\/\/([^/]+)\/([^/]+)\/([^/]+)\/pull\/(\d+)(?:[/?#].*)?$/,
283
276
  );
284
277
  if (!match) return undefined;
285
278
 
286
- const [, owner, name, numberText] = match;
279
+ const [, rawHost, owner, name, numberText] = match;
280
+ const hostRef = parseGitHubRemote(`https://${rawHost}/${owner}/${name}.git`);
287
281
  const number = Math.max(0, Math.floor(toNumber(numberText)));
288
- if (!owner || !name || number <= 0) return undefined;
289
- return { owner, name, number };
282
+ if (!hostRef || number <= 0) return undefined;
283
+ return { host: hostRef.host, owner, name, number };
290
284
  }
291
285
 
292
286
  export function parsePullRequestReviewThreadsPage(
package/src/shared.ts CHANGED
@@ -272,6 +272,7 @@ export interface ProviderStatusSnapshot {
272
272
  export interface GitHubPullRequest {
273
273
  number: number;
274
274
  url: string;
275
+ host?: string;
275
276
  headRefOid?: string;
276
277
  unresolvedReviewThreadCount?: number;
277
278
  ciStatus?: {
@@ -280,6 +281,13 @@ export interface GitHubPullRequest {
280
281
  };
281
282
  }
282
283
 
284
+ export interface GitHubRepositoryRef {
285
+ host: string;
286
+ owner: string;
287
+ name: string;
288
+ repository: string;
289
+ }
290
+
283
291
  export interface GitInfo {
284
292
  repository: string;
285
293
  branch: string;
@@ -761,9 +769,40 @@ export function formatThinkingLevel(level: string): string {
761
769
  return normalized;
762
770
  }
763
771
 
764
- export function parseGitHubRemote(url: string): string {
765
- const match = url.match(/github\.com[:/](.+\/.+?)(?:\.git)?$/);
766
- return match?.[1] ?? "";
772
+ function parseGitHubHost(host: string): string {
773
+ const normalized = host.toLowerCase();
774
+ if (normalized === "github.com") return normalized;
775
+ if (/^github(?:[.-][a-z0-9][a-z0-9-]*)+\.[a-z]{2,}$/i.test(normalized)) {
776
+ return normalized;
777
+ }
778
+ return "";
779
+ }
780
+
781
+ export function parseGitHubRemote(url: string): GitHubRepositoryRef | undefined {
782
+ const trimmed = url.trim();
783
+ const scpLike = trimmed.includes("://")
784
+ ? undefined
785
+ : trimmed.match(/^.+@([^:/]+):([^/][^:]*\/[^:]+)$/);
786
+ const urlLike = trimmed.match(
787
+ /^(?:https?:\/\/|ssh:\/\/.+@)([^/:]+)(?::\d+)?\/(.+\/.+)$/,
788
+ );
789
+ const match = scpLike ?? urlLike;
790
+ if (!match) return undefined;
791
+
792
+ const [, rawHost, rawRepository] = match;
793
+ const host = parseGitHubHost(rawHost ?? "");
794
+ if (!host || !rawRepository) return undefined;
795
+
796
+ const repository = rawRepository.replace(/\.git$/i, "");
797
+ const slash = repository.indexOf("/");
798
+ if (slash <= 0 || slash >= repository.length - 1) return undefined;
799
+
800
+ return {
801
+ host,
802
+ owner: repository.slice(0, slash),
803
+ name: repository.slice(slash + 1),
804
+ repository,
805
+ };
767
806
  }
768
807
 
769
808
  export function formatTerminalHyperlink(url: string, text: string): string {