@cad0p/pi-usage 0.11.2 → 0.11.3
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/CHANGELOG.md +20 -0
- package/README.md +16 -6
- package/bin/piquota.js +12 -4
- package/extensions/quota-panel.ts +136 -25
- package/package.json +1 -1
- package/src/cache.js +12 -1
- package/src/engine.js +22 -0
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
## [0.11.3] - 2026-11-02
|
|
6
|
+
|
|
7
|
+
<!-- USER-EDITABLE SECTION START -->
|
|
8
|
+
First fix release of the fork. With Claude Code uninstalled, Claude now reads Pi's own login instead of a dead Claude Code store, and `/usage` shows every OpenCode Go account.
|
|
9
|
+
|
|
10
|
+
- **Claude source:** `auto` passes over an expired Claude Code token when Pi's `anthropic` login is fresh, and adds a warning naming the switch. `PI_QUOTA_CLAUDE_SOURCE=claude-code` still pins the CLI store. The report cache is keyed by the Claude source setting, so a switch refetches.
|
|
11
|
+
- **Multi-account panel:** a provider with several accounts is one header plus one line per account (its fullest window, or `key rejected` / `no Go plan`), rendered as a component so Pi's ten-line widget cap no longer truncates it.
|
|
12
|
+
- **Summary:** `OP-Go: 7/14 ok · lowest 24% (#7)` instead of account #1's number.
|
|
13
|
+
- `piquota --version` reads `package.json`.
|
|
14
|
+
<!-- USER-EDITABLE SECTION END -->
|
|
15
|
+
|
|
16
|
+
### 🐛 Bug Fixes
|
|
17
|
+
|
|
18
|
+
- /usage live bugs — dead Claude store, truncated panel, account-#1 summary (closes #5)
|
|
19
|
+
|
|
20
|
+
|
package/README.md
CHANGED
|
@@ -8,7 +8,8 @@ One source of truth for quota: **the credentials Pi already owns**.
|
|
|
8
8
|
> * **OpenCode Go from the API key**: `GET https://opencode.ai/zen/go/v1/usage` returns the rolling (5h), weekly and monthly windows for the key Pi already stores, so no browser session is needed.
|
|
9
9
|
> * **Numbered accounts**: `auth.json` entries such as `opencode-go-2` … `opencode-go-14` (the [pi-fallback-provider](https://github.com/cad0p/pi-fallback-provider) sibling convention) are read as extra accounts of their base provider.
|
|
10
10
|
> * **One Omarchy tab per provider**: a multi-account provider shows each account's binding window (`#2 · Monthly`) in a single tab, and summarises failed accounts.
|
|
11
|
-
> * **`/usage` on demand**: the Pi command is `/usage` only, and nothing is pinned above the editor or in the footer unless you ask for it.
|
|
11
|
+
> * **`/usage` on demand**: the Pi command is `/usage` only, and nothing is pinned above the editor or in the footer unless you ask for it. A multi-account provider gets one line per account in the panel.
|
|
12
|
+
> * **No dead Claude store**: `auto` passes over an expired Claude Code token when Pi's own `anthropic` login is fresh, and says so in the warnings.
|
|
12
13
|
>
|
|
13
14
|
> Everything below still applies; the CLI is still called `piquota`.
|
|
14
15
|
|
|
@@ -97,9 +98,12 @@ There are two ways to be signed in to Claude, and a user may have either or both
|
|
|
97
98
|
| Claude Code CLI | `~/.claude/.credentials.json` (honours `CLAUDE_CONFIG_DIR`); on macOS, when that file is absent, the `Claude Code-credentials` login Keychain item (read-only, via `security find-generic-password`; macOS may ask once to allow access) | What `npm:pi-claude-code-provider` drives. A subscription with usage credits disabled answers `400` on Pi's own entry for every request, and this is the source that still works. |
|
|
98
99
|
| Pi's own entry | `~/.pi/agent/auth.json` → `anthropic` | `/login anthropic` in Pi, without the plugin installed. |
|
|
99
100
|
|
|
100
|
-
**The Claude Code CLI wins when both are present
|
|
101
|
-
a fallback*: only the selected source is tried, so a
|
|
102
|
-
reported rather than silently masked by the other one.
|
|
101
|
+
**The Claude Code CLI wins when both are present and its token is still valid.** The
|
|
102
|
+
choice is a *preference, not a fallback*: only the selected source is tried, so a
|
|
103
|
+
broken preferred source is reported rather than silently masked by the other one.
|
|
104
|
+
The one exception is an expired Claude Code token next to a fresh Pi login: a store
|
|
105
|
+
left behind by an uninstalled CLI never refreshes again, so `auto` reads Pi's login
|
|
106
|
+
instead and adds a warning naming the switch. Override it with
|
|
103
107
|
`PI_QUOTA_CLAUDE_SOURCE=pi` or `=claude-code`; `--explain` prints which one was used
|
|
104
108
|
and why.
|
|
105
109
|
|
|
@@ -243,6 +247,12 @@ above the editor and leaves the footer alone. Turn a surface on with `/usage box
|
|
|
243
247
|
it fresh with a background refresh (and a gentle refresh after each agent turn).
|
|
244
248
|
`/usage hide` gives the row back.
|
|
245
249
|
|
|
250
|
+
A provider with several accounts is shown as one header (`OP-Go · 7 of 14 accounts
|
|
251
|
+
ok`) plus one line per account: its fullest window, or the reason it failed (`key
|
|
252
|
+
rejected`, `no Go plan`). The panel is rendered as a component, so Pi's ten-line
|
|
253
|
+
cap on plain widgets never truncates it, and the summary names the account with the
|
|
254
|
+
most room: `OP-Go: 7/14 ok · lowest 24% (#7)`.
|
|
255
|
+
|
|
246
256
|
`/usage box` pins the **five-row box above the editor**:
|
|
247
257
|
|
|
248
258
|
```
|
|
@@ -477,7 +487,7 @@ extensions/quota-panel.ts Pi TUI line and /usage
|
|
|
477
487
|
| `no claude credential in the Pi store or from the Claude Code CLI` | Neither Claude source is configured. `/login anthropic` in Pi, or install Claude Code and run `claude` once. |
|
|
478
488
|
| `Claude Code CLI has no readable credentials; looked for …` | The store is missing, empty or corrupt. `piquota --explain` prints every path that was tried. |
|
|
479
489
|
| `Claude Code token expired or rejected; run \`claude\` once` | Claude Code's own access token lapsed. Run any `claude` command; piQuota never refreshes it. |
|
|
480
|
-
| Claude reports `plan pro` but the window looks wrong | The store is chosen by preference, not by success. `PI_QUOTA_CLAUDE_SOURCE=pi piquota` forces the other source. |
|
|
490
|
+
| Claude reports `plan pro` but the window looks wrong | The store is chosen by preference, not by success. `PI_QUOTA_CLAUDE_SOURCE=pi piquota` forces the other source; the cache is keyed by that setting, so the switch refetches. |
|
|
481
491
|
| `rate limited (HTTP 429); retry in Ns` | The vendor throttled the usage endpoint. Wait, or raise `--ttl`. |
|
|
482
492
|
| `Antigravity token rejected` | Refresh failed; `/login antigravity` in Pi. `--no-refresh` disables the attempt. |
|
|
483
493
|
| `no "auth" cookie for opencode.ai in N readable store(s)` | Run `piquota auth opencode`, or paste the cookie. |
|
|
@@ -488,7 +498,7 @@ extensions/quota-panel.ts Pi TUI line and /usage
|
|
|
488
498
|
## Tests
|
|
489
499
|
|
|
490
500
|
```bash
|
|
491
|
-
node --test tests/*.test.mjs #
|
|
501
|
+
node --test tests/*.test.mjs # 265 tests, fake tokens only, no network
|
|
492
502
|
```
|
|
493
503
|
|
|
494
504
|
Modules covered: `auth.json` parsing and de-duplication, the Claude Code store
|
package/bin/piquota.js
CHANGED
|
@@ -12,6 +12,8 @@
|
|
|
12
12
|
* the rail slot gentle-pi needs to paint the quota card
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
|
|
15
17
|
import { clearCache, withCache } from "../src/cache.js";
|
|
16
18
|
import { parseArgs } from "../src/cli/args.js";
|
|
17
19
|
import { runAuthCommand } from "../src/cli/commands/auth.js";
|
|
@@ -19,13 +21,19 @@ import { runGentlePiCommand } from "../src/cli/commands/gentle-pi.js";
|
|
|
19
21
|
import { runOmarchyCommand } from "../src/cli/commands/omarchy.js";
|
|
20
22
|
import { explainLines, resolveFamilies } from "../src/cli/explain.js";
|
|
21
23
|
import { err, out } from "../src/cli/output.js";
|
|
22
|
-
import { collectQuota, enabledFamilies } from "../src/engine.js";
|
|
24
|
+
import { collectQuota, enabledFamilies, normalizeClaudeSourceMode } from "../src/engine.js";
|
|
23
25
|
import { redact } from "../src/http.js";
|
|
24
26
|
import { mergeLastGood } from "../src/sticky.js";
|
|
25
27
|
import { renderBoxWidget, renderCompact, renderPanel, renderStatusLine } from "../src/render/panel.js";
|
|
26
28
|
import { ansiPalette } from "../src/render/theme.js";
|
|
27
29
|
|
|
28
|
-
const VERSION = "
|
|
30
|
+
const VERSION = JSON.parse(readFileSync(new URL("../package.json", import.meta.url), "utf-8")).version;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* The cache answers for the settings it was collected under: a Claude source
|
|
34
|
+
* switch must refetch instead of serving the other source's result.
|
|
35
|
+
*/
|
|
36
|
+
const CACHE_KEY = `claude-source:${normalizeClaudeSourceMode(process.env.PI_QUOTA_CLAUDE_SOURCE)}`;
|
|
29
37
|
|
|
30
38
|
const HELP = `piquota ${VERSION} — read-only quota from Pi's provider credentials
|
|
31
39
|
|
|
@@ -120,7 +128,7 @@ async function main() {
|
|
|
120
128
|
const loadFresh = () => collectQuota({ families: everyFamily, timeoutMs: args.timeoutMs, refresh, force: args.force });
|
|
121
129
|
const fresh = args.noCache
|
|
122
130
|
? await loadFresh()
|
|
123
|
-
: (await withCache({ ttlMs: args.ttlMs, force: args.force, families: everyFamily }, loadFresh)).report;
|
|
131
|
+
: (await withCache({ ttlMs: args.ttlMs, force: args.force, families: everyFamily, key: CACHE_KEY }, loadFresh)).report;
|
|
124
132
|
return mergeLastGood(fresh, {}).report;
|
|
125
133
|
};
|
|
126
134
|
return runOmarchyCommand({ load });
|
|
@@ -149,7 +157,7 @@ async function main() {
|
|
|
149
157
|
if (args.noCache) {
|
|
150
158
|
report = await load();
|
|
151
159
|
} else {
|
|
152
|
-
const result = await withCache({ ttlMs: args.ttlMs, force: args.force, families: selected }, load);
|
|
160
|
+
const result = await withCache({ ttlMs: args.ttlMs, force: args.force, families: selected, key: CACHE_KEY }, load);
|
|
153
161
|
report = result.report;
|
|
154
162
|
cached = result.cached;
|
|
155
163
|
ageMs = result.ageMs;
|
|
@@ -486,26 +486,146 @@ function renderSidebarCard(report: QuotaReport | null, theme: Theme, width: numb
|
|
|
486
486
|
}
|
|
487
487
|
|
|
488
488
|
/** The detailed panel: every account and every window, with a used-fraction bar. */
|
|
489
|
+
/**
|
|
490
|
+
* Clip one line to `width` visible columns, keeping SGR sequences intact. Every
|
|
491
|
+
* glyph this extension paints is one column wide, so code points are columns.
|
|
492
|
+
*/
|
|
493
|
+
function clipToWidth(line: string, width: number): string {
|
|
494
|
+
let visible = 0;
|
|
495
|
+
let out = "";
|
|
496
|
+
let styled = false;
|
|
497
|
+
for (let index = 0; index < line.length; ) {
|
|
498
|
+
const sgr = /^\u001b\[[0-9;]*m/.exec(line.slice(index));
|
|
499
|
+
if (sgr) {
|
|
500
|
+
out += sgr[0];
|
|
501
|
+
styled = true;
|
|
502
|
+
index += sgr[0].length;
|
|
503
|
+
continue;
|
|
504
|
+
}
|
|
505
|
+
const char = String.fromCodePoint(line.codePointAt(index) as number);
|
|
506
|
+
// Close any open style so a cut line cannot bleed colour into the next one.
|
|
507
|
+
if (visible + 1 > width) return styled ? `${out}\u001b[0m` : out;
|
|
508
|
+
out += char;
|
|
509
|
+
visible += 1;
|
|
510
|
+
index += char.length;
|
|
511
|
+
}
|
|
512
|
+
return out;
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
/**
|
|
516
|
+
* The panel as a component rather than a string array: Pi caps string-array
|
|
517
|
+
* widgets at ten lines, and a multi-account family alone can exceed that.
|
|
518
|
+
*/
|
|
519
|
+
function panelComponent(lines: string[]) {
|
|
520
|
+
return () => ({
|
|
521
|
+
render: (width: number) => lines.map((line) => ` ${clipToWidth(line, Math.max(0, width - 1))}`),
|
|
522
|
+
invalidate() {},
|
|
523
|
+
});
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* The one-line notify summary. A multi-account family reports how many accounts
|
|
528
|
+
* answer and the account with the most room, not whichever key happens to be #1.
|
|
529
|
+
*/
|
|
530
|
+
function summarize(report: QuotaReport): string {
|
|
531
|
+
const active = report.providers.filter((provider) => provider.notConfigured !== true);
|
|
532
|
+
return firstPerFamily(active)
|
|
533
|
+
.map((provider) => {
|
|
534
|
+
const name = SHORT_NAME[provider.family] ?? provider.family;
|
|
535
|
+
if (isMultiAccount(active, provider)) {
|
|
536
|
+
const accounts = active.filter((other) => other.family === provider.family);
|
|
537
|
+
const scored = accounts
|
|
538
|
+
.filter((account) => account.ok)
|
|
539
|
+
.map((account) => ({ account, used: usedOf(bindingWindow(account)) }))
|
|
540
|
+
.filter((entry): entry is { account: QuotaProvider; used: number } => entry.used !== null);
|
|
541
|
+
const counted = `${accounts.filter((account) => account.ok).length}/${accounts.length} ok`;
|
|
542
|
+
if (scored.length === 0) return `${name}: ${counted}`;
|
|
543
|
+
const lowest = scored.reduce((best, entry) => (entry.used < best.used ? entry : best));
|
|
544
|
+
return `${name}: ${counted} · lowest ${Math.round(lowest.used)}% (${lowest.account.account})`;
|
|
545
|
+
}
|
|
546
|
+
if (!provider.ok) return `${name}: ${errorCaption(provider.error)}`;
|
|
547
|
+
const used = usedOf(headline(provider));
|
|
548
|
+
return `${name}: ${used === null ? "n/a" : `${Math.round(used)}% used`}`;
|
|
549
|
+
})
|
|
550
|
+
.join(" · ");
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
/** The account's fullest window: the one that stops its next prompt. */
|
|
554
|
+
function bindingWindow(provider: QuotaProvider): QuotaWindow | null {
|
|
555
|
+
let best: QuotaWindow | null = null;
|
|
556
|
+
for (const window of provider.windows) {
|
|
557
|
+
const used = usedOf(window);
|
|
558
|
+
if (used === null) continue;
|
|
559
|
+
if (best === null || used > (usedOf(best) as number)) best = window;
|
|
560
|
+
}
|
|
561
|
+
return best;
|
|
562
|
+
}
|
|
563
|
+
|
|
564
|
+
/** Short per-account failure reason; mirrors src/omarchy/record.js. */
|
|
565
|
+
function accountReason(error: string | null | undefined): string {
|
|
566
|
+
const text = String(error ?? "");
|
|
567
|
+
if (/key rejected|key was rejected/i.test(text)) return "key rejected";
|
|
568
|
+
if (/Go plan not active|Workspace access denied/i.test(text)) return "no Go plan";
|
|
569
|
+
return errorCaption(error);
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
/** `#N` accounts of one family, i.e. a family Pi holds several credentials for. */
|
|
573
|
+
function isMultiAccount(providers: QuotaProvider[], provider: QuotaProvider): boolean {
|
|
574
|
+
return typeof provider.account === "string"
|
|
575
|
+
&& provider.account.startsWith("#")
|
|
576
|
+
&& providers.filter((other) => other.family === provider.family).length > 1;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
/**
|
|
580
|
+
* One family, many accounts: a header plus one line per account with only its
|
|
581
|
+
* binding window, so fourteen keys stay fourteen lines instead of fifty-six.
|
|
582
|
+
*/
|
|
583
|
+
function renderAccountRows(accounts: QuotaProvider[], theme: Theme): string[] {
|
|
584
|
+
const family = accounts[0].family;
|
|
585
|
+
const healthy = accounts.filter((account) => account.ok).length;
|
|
586
|
+
const header = `${paint(BRAND[family] ?? "#8B949E", SHORT_NAME[family] ?? accounts[0].label)} ${theme.fg("dim", `· ${healthy} of ${accounts.length} accounts ok`)}`;
|
|
587
|
+
const lines = [header];
|
|
588
|
+
const width = Math.max(...accounts.map((account) => account.account.length));
|
|
589
|
+
for (const account of accounts) {
|
|
590
|
+
const label = theme.fg("dim", account.account.padEnd(width));
|
|
591
|
+
if (!account.ok) {
|
|
592
|
+
const kind = errorKind(account.error);
|
|
593
|
+
const color = kind === "transient" || kind === "throttle" ? "warning" : kind === "expired" || kind === "auth" ? "error" : "dim";
|
|
594
|
+
lines.push(` ${label} ${theme.fg(color, errorGlyph(account.error))} ${theme.fg("warning", accountReason(account.error))}`);
|
|
595
|
+
continue;
|
|
596
|
+
}
|
|
597
|
+
const window = bindingWindow(account);
|
|
598
|
+
const used = usedOf(window);
|
|
599
|
+
if (window === null || used === null) {
|
|
600
|
+
lines.push(` ${label} ${theme.fg("dim", "no rate-limit windows reported")}`);
|
|
601
|
+
continue;
|
|
602
|
+
}
|
|
603
|
+
const band = bandFor(used);
|
|
604
|
+
const reset = window.resetsInSec === null ? "" : theme.fg("dim", ` · reset in ${humanDuration(window.resetsInSec)}`);
|
|
605
|
+
lines.push(
|
|
606
|
+
` ${label} ${paint(band.color, band.glyph)} ${paint(band.color, bar(used))} ` +
|
|
607
|
+
`${paint(band.color, `${used.toFixed(used < 10 ? 1 : 0)}%`.padStart(5))} ` +
|
|
608
|
+
`${theme.fg("dim", window.id)}${reset}`,
|
|
609
|
+
);
|
|
610
|
+
}
|
|
611
|
+
return lines;
|
|
612
|
+
}
|
|
613
|
+
|
|
489
614
|
function renderPanel(report: QuotaReport, theme: Theme): string[] {
|
|
490
615
|
const lines: string[] = [];
|
|
491
616
|
const active = report.providers.filter((provider) => provider.notConfigured !== true);
|
|
492
617
|
const unconfigured = report.providers.filter((provider) => provider.notConfigured === true);
|
|
493
|
-
|
|
494
|
-
const familyCounts = new Map<string, number>();
|
|
495
|
-
for (const provider of active) familyCounts.set(provider.family, (familyCounts.get(provider.family) ?? 0) + 1);
|
|
618
|
+
const renderedFamilies = new Set<string>();
|
|
496
619
|
|
|
497
620
|
for (const provider of active) {
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
const name = paint(BRAND[provider.family] ?? "#8B949E", title);
|
|
507
|
-
const meta: string[] = [];
|
|
508
|
-
if (!multi) meta.push(provider.account);
|
|
621
|
+
if (isMultiAccount(active, provider)) {
|
|
622
|
+
if (renderedFamilies.has(provider.family)) continue;
|
|
623
|
+
renderedFamilies.add(provider.family);
|
|
624
|
+
lines.push(...renderAccountRows(active.filter((other) => other.family === provider.family), theme));
|
|
625
|
+
continue;
|
|
626
|
+
}
|
|
627
|
+
const name = paint(BRAND[provider.family] ?? "#8B949E", provider.label);
|
|
628
|
+
const meta: string[] = [provider.account];
|
|
509
629
|
if (provider.plan) meta.push(`plan ${provider.plan}`);
|
|
510
630
|
// Two stores can back Claude, and this is the one place that says which.
|
|
511
631
|
if (provider.sourceKind === "claude-code") meta.push("Claude Code CLI");
|
|
@@ -647,7 +767,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
647
767
|
}
|
|
648
768
|
|
|
649
769
|
if (panelVisible && report) {
|
|
650
|
-
ctx.ui.setWidget(WIDGET_KEY, renderPanel(report, theme), { placement: "aboveEditor" });
|
|
770
|
+
ctx.ui.setWidget(WIDGET_KEY, panelComponent(renderPanel(report, theme)), { placement: "aboveEditor" });
|
|
651
771
|
return;
|
|
652
772
|
}
|
|
653
773
|
|
|
@@ -939,16 +1059,7 @@ export default function quotaPanelExtension(pi: ExtensionAPI): void {
|
|
|
939
1059
|
ctx.ui.notify(`Quota unavailable: ${lastError}`, "error");
|
|
940
1060
|
return;
|
|
941
1061
|
}
|
|
942
|
-
|
|
943
|
-
const summary = active
|
|
944
|
-
.map((provider) => {
|
|
945
|
-
const name = SHORT_NAME[provider.family] ?? provider.family;
|
|
946
|
-
if (!provider.ok) return `${name}: ${errorCaption(provider.error)}`;
|
|
947
|
-
const used = usedOf(headline(provider));
|
|
948
|
-
return `${name}: ${used === null ? "n/a" : `${Math.round(used)}% used`}`;
|
|
949
|
-
})
|
|
950
|
-
.join(" · ");
|
|
951
|
-
ctx.ui.notify(summary || "No active providers configured in Pi", "info");
|
|
1062
|
+
ctx.ui.notify(summarize(report) || "No active providers configured in Pi", "info");
|
|
952
1063
|
};
|
|
953
1064
|
|
|
954
1065
|
pi.registerCommand("usage", {
|
package/package.json
CHANGED
package/src/cache.js
CHANGED
|
@@ -69,6 +69,7 @@ export function resolveCachePath(options = {}) {
|
|
|
69
69
|
* now?: number,
|
|
70
70
|
* env?: Record<string, string | undefined>,
|
|
71
71
|
* home?: string,
|
|
72
|
+
* key?: string, Settings fingerprint; a cached report saved under another key is a miss.
|
|
72
73
|
* }} [options]
|
|
73
74
|
* @returns {{ report: import("./engine.js").PiQuotaReport, ageMs: number, path: string } | null}
|
|
74
75
|
*/
|
|
@@ -100,6 +101,9 @@ export function readCache(options = {}) {
|
|
|
100
101
|
if (savedAt === null) return null;
|
|
101
102
|
const ageMs = now - savedAt;
|
|
102
103
|
if (ageMs > ttlMs) return null;
|
|
104
|
+
// A report collected under other settings (e.g. another Claude source) answers a
|
|
105
|
+
// different question, so it is a miss rather than a stale hit.
|
|
106
|
+
if (options.key !== undefined && /** @type {{ key?: unknown }} */ (parsed).key !== options.key) return null;
|
|
103
107
|
|
|
104
108
|
for (const provider of report.providers) {
|
|
105
109
|
for (const win of provider.windows ?? []) {
|
|
@@ -122,6 +126,8 @@ export function readCache(options = {}) {
|
|
|
122
126
|
* now?: number,
|
|
123
127
|
* env?: Record<string, string | undefined>,
|
|
124
128
|
* home?: string,
|
|
129
|
+
* merge?: boolean,
|
|
130
|
+
* key?: string,
|
|
125
131
|
* }} [options]
|
|
126
132
|
* @returns {{ ok: boolean, path: string, error?: string }}
|
|
127
133
|
*/
|
|
@@ -135,7 +141,11 @@ export function writeCache(report, options = {}) {
|
|
|
135
141
|
finalReport = mergeReportIntoCache(existing.report, report);
|
|
136
142
|
}
|
|
137
143
|
}
|
|
138
|
-
const payload = JSON.stringify(
|
|
144
|
+
const payload = JSON.stringify(
|
|
145
|
+
options.key === undefined ? { savedAt: now, report: finalReport } : { savedAt: now, key: options.key, report: finalReport },
|
|
146
|
+
null,
|
|
147
|
+
2,
|
|
148
|
+
);
|
|
139
149
|
|
|
140
150
|
try {
|
|
141
151
|
mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
|
|
@@ -191,6 +201,7 @@ export function describeCache(options = {}) {
|
|
|
191
201
|
* path?: string,
|
|
192
202
|
* families?: string[],
|
|
193
203
|
* merge?: boolean,
|
|
204
|
+
* key?: string,
|
|
194
205
|
* env?: Record<string, string | undefined>,
|
|
195
206
|
* home?: string,
|
|
196
207
|
* }} options
|
package/src/engine.js
CHANGED
|
@@ -100,6 +100,26 @@ export function resolveClaudeCredentials(input) {
|
|
|
100
100
|
now: input.now,
|
|
101
101
|
});
|
|
102
102
|
|
|
103
|
+
// `auto` prefers the Claude Code CLI only while its token is still usable. A
|
|
104
|
+
// leftover store from an uninstalled CLI never refreshes again, so once it has
|
|
105
|
+
// expired Pi's own login is the better source whenever Pi still has a fresh one.
|
|
106
|
+
if (claudeCode.ok && mode === "auto" && !checkFreshness(claudeCode.credential, input.now ?? Date.now()).fresh) {
|
|
107
|
+
const freshPi = fromPi.filter((credential) => checkFreshness(credential, input.now ?? Date.now()).fresh);
|
|
108
|
+
if (freshPi.length > 0) {
|
|
109
|
+
const expiredMin = Math.abs(checkFreshness(claudeCode.credential, input.now ?? Date.now()).expiresInMin ?? 0);
|
|
110
|
+
return {
|
|
111
|
+
credentials: freshPi,
|
|
112
|
+
mode: "pi",
|
|
113
|
+
unavailable: null,
|
|
114
|
+
// Said out loud so the switch is never silent: the report names the source
|
|
115
|
+
// it used and why the preferred one was passed over.
|
|
116
|
+
notice: `claude: the Claude Code token expired ${expiredMin}m ago; using Pi's anthropic login instead`,
|
|
117
|
+
claudeCodePaths: claudeCode.paths,
|
|
118
|
+
recoveredLock: claudeCode.recoveredLock ?? null,
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
103
123
|
if (claudeCode.ok) {
|
|
104
124
|
return {
|
|
105
125
|
credentials: [claudeCode.credential],
|
|
@@ -191,6 +211,7 @@ export async function collectQuota(options = {}) {
|
|
|
191
211
|
platform: options.platform,
|
|
192
212
|
usersRoot: options.usersRoot,
|
|
193
213
|
claudeCodePaths: options.claudeCodePaths,
|
|
214
|
+
now,
|
|
194
215
|
});
|
|
195
216
|
|
|
196
217
|
/**
|
|
@@ -207,6 +228,7 @@ export async function collectQuota(options = {}) {
|
|
|
207
228
|
const byFamily = {};
|
|
208
229
|
/** @type {string[]} */
|
|
209
230
|
const warnings = [...loaded.warnings];
|
|
231
|
+
if (claude.notice) warnings.push(claude.notice);
|
|
210
232
|
if (claude.recoveredLock) {
|
|
211
233
|
warnings.push(
|
|
212
234
|
`claude: removed abandoned OAuth refresh lock (${Math.round((claude.recoveredLock.ageMs ?? 0) / 1000)}s old) at ${claude.recoveredLock.lockPath}`,
|