@cad0p/pi-usage 0.0.0-stage → 0.11.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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +509 -2
  3. package/bin/piquota.js +199 -0
  4. package/extensions/quota-panel.ts +963 -0
  5. package/package.json +59 -4
  6. package/src/auth/claude-code-auth.js +490 -0
  7. package/src/auth/jwt.js +84 -0
  8. package/src/auth/pi-auth.js +424 -0
  9. package/src/browser/cookies.js +557 -0
  10. package/src/browser/history.js +173 -0
  11. package/src/cache.js +224 -0
  12. package/src/cli/args.js +154 -0
  13. package/src/cli/commands/auth.js +216 -0
  14. package/src/cli/commands/gentle-pi.js +44 -0
  15. package/src/cli/commands/omarchy.js +75 -0
  16. package/src/cli/explain.js +99 -0
  17. package/src/cli/output.js +34 -0
  18. package/src/engine.js +404 -0
  19. package/src/exec.js +31 -0
  20. package/src/gentle-pi/rail-patch.js +335 -0
  21. package/src/http.js +182 -0
  22. package/src/model.js +332 -0
  23. package/src/omarchy/assets/antigravity.svg +3 -0
  24. package/src/omarchy/assets/claude.svg +1 -0
  25. package/src/omarchy/assets/codex.svg +1 -0
  26. package/src/omarchy/assets/github-actions.svg +3 -0
  27. package/src/omarchy/assets/opencode-go.svg +3 -0
  28. package/src/omarchy/publish.js +152 -0
  29. package/src/omarchy/record.js +462 -0
  30. package/src/opencode/dashboard.js +337 -0
  31. package/src/opencode/session.js +485 -0
  32. package/src/providers/antigravity-oauth.js +173 -0
  33. package/src/providers/antigravity.js +225 -0
  34. package/src/providers/backoff.js +146 -0
  35. package/src/providers/claude.js +194 -0
  36. package/src/providers/codex.js +151 -0
  37. package/src/providers/error-kind.js +128 -0
  38. package/src/providers/github-actions.js +379 -0
  39. package/src/providers/opencode-go.js +274 -0
  40. package/src/refresh.js +292 -0
  41. package/src/render/panel.js +411 -0
  42. package/src/render/theme.js +102 -0
  43. package/src/sticky.js +319 -0
package/src/engine.js ADDED
@@ -0,0 +1,404 @@
1
+ /**
2
+ * Quota collection engine.
3
+ *
4
+ * Loads Pi credentials (read-only), asks each provider for its windows and
5
+ * returns one normalized report. Providers never throw: a failure becomes a
6
+ * degraded result with an actionable message.
7
+ */
8
+
9
+ import { checkFreshness, LABEL_BY_FAMILY, loadPiCredentials, resolveAuthPaths } from "./auth/pi-auth.js";
10
+ import { loadClaudeCodeCredential } from "./auth/claude-code-auth.js";
11
+ import { degradedResult, displayIdentity, selectPrimaryWindow } from "./model.js";
12
+ import { backoffState, clearBackoff, isAuthFailure, isThrottled, recordBackoff, retryAfterFromError } from "./providers/backoff.js";
13
+ import { fetchQuota as fetchClaude } from "./providers/claude.js";
14
+ import { fetchQuota as fetchCodex } from "./providers/codex.js";
15
+ import { fetchQuota as fetchAntigravity } from "./providers/antigravity.js";
16
+ import { fetchQuota as fetchOpenCodeGo } from "./providers/opencode-go.js";
17
+ import { fetchQuota as fetchGithubActions } from "./providers/github-actions.js";
18
+
19
+ /** Canonical family order used by every surface. */
20
+ export const FAMILIES = ["claude", "codex", "antigravity", "opencode-go", "github-actions"];
21
+
22
+ /**
23
+ * Return the ordered families enabled for a run.
24
+ *
25
+ * GitHub Actions joins a default family list only when the env flag is truthy.
26
+ * A positional family selection sets `explicit` to record user consent directly,
27
+ * independent of whether the requested array has been copied or transformed.
28
+ *
29
+ * @param {{ env?: Record<string, string | undefined>, requested?: string[], explicit?: boolean }} [input]
30
+ * @returns {string[]}
31
+ */
32
+ export function enabledFamilies(input = {}) {
33
+ const env = input.env ?? process.env;
34
+ const requested = input.requested;
35
+ const families = requested ?? FAMILIES;
36
+ const explicit = input.explicit ?? false;
37
+ const includeGithubActions = isTruthyFlag(env.PI_QUOTA_GITHUB_ACTIONS) || explicit;
38
+ return families.filter((family) => family !== "github-actions" || includeGithubActions);
39
+ }
40
+ const GH_CLI_CREDENTIAL = {
41
+ family: "github-actions",
42
+ label: "GitHub Actions",
43
+ source: "gh CLI",
44
+ identity: "GitHub CLI",
45
+ expiresAtMs: null,
46
+ };
47
+
48
+ /** @param {string | undefined} value */
49
+ function isTruthyFlag(value) {
50
+ return typeof value === "string" && /^(1|true|yes|on)$/i.test(value.trim());
51
+ }
52
+
53
+ /**
54
+ * Claude has two possible sources, and a user may have either or both.
55
+ *
56
+ * `auto` prefers the Claude Code CLI, because the plugin that drives it is what a
57
+ * user reaches for when Pi's own `anthropic` token cannot serve requests. The
58
+ * choice is a preference, never a fallback: only the selected source is tried, so
59
+ * a broken preferred source is reported instead of silently masked by the other.
60
+ */
61
+ export const CLAUDE_SOURCE_MODES = ["auto", "claude-code", "pi"];
62
+
63
+ /**
64
+ * @param {unknown} value
65
+ * @returns {"auto" | "claude-code" | "pi"}
66
+ */
67
+ export function normalizeClaudeSourceMode(value) {
68
+ const text = typeof value === "string" ? value.trim().toLowerCase() : "";
69
+ if (text === "claude-code" || text === "claude" || text === "cli") return "claude-code";
70
+ if (text === "pi" || text === "anthropic" || text === "oauth") return "pi";
71
+ return "auto";
72
+ }
73
+
74
+ /**
75
+ * Pick the Claude credential set for this run.
76
+ *
77
+ * @param {{
78
+ * credentials: import("./auth/pi-auth.js").PiCredential[],
79
+ * env: Record<string, string | undefined>,
80
+ * home?: string,
81
+ * platform?: string,
82
+ * usersRoot?: string,
83
+ * claudeCodePaths?: string[] | null,
84
+ * }} input
85
+ * @returns {{ credentials: import("./auth/pi-auth.js").PiCredential[], mode: string, unavailable: string | null, claudeCodePaths: string[] }}
86
+ */
87
+ export function resolveClaudeCredentials(input) {
88
+ const mode = normalizeClaudeSourceMode(input.env.PI_QUOTA_CLAUDE_SOURCE);
89
+ const fromPi = input.credentials.filter((credential) => credential.family === "claude");
90
+ const claudeCodePaths = input.claudeCodePaths === undefined ? undefined : input.claudeCodePaths;
91
+
92
+ if (mode === "pi") return { credentials: fromPi, mode, unavailable: null, claudeCodePaths: claudeCodePaths ?? [], recoveredLock: null };
93
+
94
+ const claudeCode = loadClaudeCodeCredential({
95
+ env: input.env,
96
+ home: input.home,
97
+ platform: input.platform,
98
+ usersRoot: input.usersRoot,
99
+ paths: claudeCodePaths ?? undefined,
100
+ now: input.now,
101
+ });
102
+
103
+ if (claudeCode.ok) {
104
+ return {
105
+ credentials: [claudeCode.credential],
106
+ mode: "claude-code",
107
+ unavailable: null,
108
+ claudeCodePaths: claudeCode.paths,
109
+ recoveredLock: claudeCode.recoveredLock ?? null,
110
+ };
111
+ }
112
+ if (mode === "claude-code") {
113
+ return {
114
+ credentials: [],
115
+ mode,
116
+ unavailable: claudeCode.error,
117
+ claudeCodePaths: claudeCode.paths,
118
+ recoveredLock: claudeCode.recoveredLock ?? null,
119
+ };
120
+ }
121
+ return {
122
+ credentials: fromPi,
123
+ mode: "pi",
124
+ unavailable: null,
125
+ claudeCodePaths: claudeCode.paths,
126
+ recoveredLock: claudeCode.recoveredLock ?? null,
127
+ };
128
+ }
129
+
130
+ /** @type {Record<string, (credential: any, options?: any) => Promise<import("./model.js").QuotaResult>>} */
131
+ const PROVIDERS = {
132
+ claude: fetchClaude,
133
+ codex: fetchCodex,
134
+ antigravity: fetchAntigravity,
135
+ "opencode-go": fetchOpenCodeGo,
136
+ "github-actions": fetchGithubActions,
137
+ };
138
+
139
+ /**
140
+ * @typedef {Object} PiQuotaReport
141
+ * @property {string} engine
142
+ * @property {number} schemaVersion
143
+ * @property {true} readOnly
144
+ * @property {string} generatedAt
145
+ * @property {string[]} sources Pi auth stores that were read.
146
+ * @property {string[]} warnings
147
+ * @property {import("./model.js").QuotaResult[]} providers Flattened, family-ordered.
148
+ * @property {Record<string, import("./model.js").QuotaResult[]>} byFamily
149
+ */
150
+
151
+ /**
152
+ * @param {{
153
+ * families?: string[],
154
+ * explicit?: boolean,
155
+ * env?: Record<string, string | undefined>,
156
+ * home?: string,
157
+ * platform?: string,
158
+ * usersRoot?: string,
159
+ * paths?: string[],
160
+ * now?: number,
161
+ * fetchFn?: typeof fetch,
162
+ * runCommand?: typeof import("./exec.js").runCommand,
163
+ * timeoutMs?: number,
164
+ * refresh?: boolean,
165
+ * force?: boolean,
166
+ * stores?: import("./browser/cookies.js").CookieStore[],
167
+ * allowBrowser?: boolean,
168
+ * claudeCodePaths?: string[] | null,
169
+ * }} [options]
170
+ * @returns {Promise<PiQuotaReport>}
171
+ */
172
+ export async function collectQuota(options = {}) {
173
+ const env = options.env ?? process.env;
174
+ const now = options.now ?? Date.now();
175
+ const families = enabledFamilies({ env, requested: options.families, explicit: options.explicit });
176
+
177
+ const loaded = loadPiCredentials({
178
+ env,
179
+ home: options.home,
180
+ platform: options.platform,
181
+ usersRoot: options.usersRoot,
182
+ paths: options.paths,
183
+ });
184
+
185
+ // Claude is the one family with a second, non-Pi source, so it is resolved
186
+ // before the per-family loop and then treated exactly like any other credential.
187
+ const claude = resolveClaudeCredentials({
188
+ credentials: loaded.credentials,
189
+ env,
190
+ home: options.home,
191
+ platform: options.platform,
192
+ usersRoot: options.usersRoot,
193
+ claudeCodePaths: options.claudeCodePaths,
194
+ });
195
+
196
+ /**
197
+ * @param {string} family
198
+ * @returns {import("./auth/pi-auth.js").PiCredential[]}
199
+ */
200
+ const credentialsFor = (family) => {
201
+ if (family === "claude") return claude.credentials;
202
+ if (family === "github-actions") return [GH_CLI_CREDENTIAL];
203
+ return loaded.credentials.filter((credential) => credential.family === family);
204
+ };
205
+
206
+ /** @type {Record<string, import("./model.js").QuotaResult[]>} */
207
+ const byFamily = {};
208
+ /** @type {string[]} */
209
+ const warnings = [...loaded.warnings];
210
+ if (claude.recoveredLock) {
211
+ warnings.push(
212
+ `claude: removed abandoned OAuth refresh lock (${Math.round((claude.recoveredLock.ageMs ?? 0) / 1000)}s old) at ${claude.recoveredLock.lockPath}`,
213
+ );
214
+ }
215
+ /** @type {Record<string, string[]>} */
216
+ const familyWarnings = {};
217
+
218
+ const hasOpenAi = loaded.rawProviders?.includes("openai") || Boolean(env.OPENAI_API_KEY);
219
+
220
+ await Promise.all(
221
+ families.map(async (family) => {
222
+ const credentials = credentialsFor(family);
223
+ if (credentials.length === 0) {
224
+ // `notConfigured` is stated rather than inferred: the message now names a
225
+ // second source, and every renderer keys off this flag to avoid painting a
226
+ // failure icon for a provider the user simply does not use.
227
+ byFamily[family] = [
228
+ degradedResult({
229
+ family,
230
+ label: LABEL_BY_FAMILY[family] ?? family,
231
+ error: claude.unavailable && family === "claude" ? claude.unavailable : missingCredentialMessage(family, { hasOpenAi }),
232
+ notConfigured: true,
233
+ source: loaded.paths[0] ?? resolveAuthPaths({ env, home: options.home })[0] ?? "~/.pi/agent/auth.json",
234
+ now,
235
+ }),
236
+ ];
237
+ return;
238
+ }
239
+
240
+ // A family that is currently throttled is skipped entirely, so the
241
+ // caller's sticky layer can republish the previous good snapshot instead
242
+ // of adding more pressure to a rate-limited endpoint.
243
+ //
244
+ // Authentication failures supersede throttling: if any credential for this
245
+ // family is already known to be expired, waiting out a rate limit is useless
246
+ // and misleading. We clear backoff immediately so the auth failure surfaces.
247
+ const hasExpiredToken = credentials.some((credential) => {
248
+ const freshness = checkFreshness(credential, now);
249
+ return !freshness.fresh;
250
+ });
251
+ if (hasExpiredToken || options.force) {
252
+ clearBackoff(family, { env, home: options.home });
253
+ }
254
+
255
+ const throttle = backoffState(family, { now, env, home: options.home });
256
+ if (throttle.active && !options.force) {
257
+ byFamily[family] = [
258
+ degradedResult({
259
+ family,
260
+ label: LABEL_BY_FAMILY[family] ?? family,
261
+ // Keep the identity visible while paused: "unknown" on screen is
262
+ // worse than a stale-but-labelled account.
263
+ account: displayIdentity(credentials[0]),
264
+ error: `backing off after a throttle: next attempt in ${throttle.secondsLeft}s`,
265
+ source: credentials[0].source,
266
+ now,
267
+ }),
268
+ ];
269
+ return;
270
+ }
271
+
272
+ /** @type {import("./model.js").QuotaResult[]} */
273
+ const results = [];
274
+ const familyWarnList = [];
275
+ for (const credential of credentials) {
276
+ const freshness = checkFreshness(credential, now);
277
+ if (!freshness.fresh) {
278
+ familyWarnList.push(
279
+ `${family}: the ${expiredTokenName(credential)} expired ${Math.abs(freshness.expiresInMin ?? 0)}m ago; ${refreshHint(credential)}`,
280
+ );
281
+ }
282
+ const fetchQuota = PROVIDERS[family];
283
+ try {
284
+ const result = await fetchQuota(credential, {
285
+ now,
286
+ fetchFn: options.fetchFn,
287
+ runCommand: options.runCommand,
288
+ timeoutMs: options.timeoutMs,
289
+ expiresInMin: freshness.expiresInMin,
290
+ env,
291
+ home: options.home,
292
+ refresh: options.refresh,
293
+ stores: options.stores,
294
+ allowBrowser: options.allowBrowser,
295
+ });
296
+ // The renderers group siblings by family; the record builder needs the
297
+ // slot to label each account without re-parsing its display string.
298
+ result.slot = credential.slot ?? null;
299
+ if (isThrottled(result.error)) {
300
+ const seconds = recordBackoff(family, {
301
+ retryAfterSec: retryAfterFromError(result.error),
302
+ now,
303
+ env,
304
+ home: options.home,
305
+ }).seconds;
306
+ familyWarnList.push(`${family}: throttled upstream; pausing that family for ${seconds}s`);
307
+ } else if (result.ok || isAuthFailure(result.error)) {
308
+ clearBackoff(family, { env, home: options.home });
309
+ }
310
+ results.push(result);
311
+ } catch (error) {
312
+ // A provider must never take the whole report down.
313
+ const crashed = degradedResult({
314
+ family,
315
+ label: credential.label,
316
+ account: credential.email ?? credential.accountId ?? "unknown",
317
+ error: `provider crashed: ${/** @type {{ message?: string }} */ (error)?.message ?? String(error)}`,
318
+ source: credential.source,
319
+ now,
320
+ expiresInMin: freshness.expiresInMin,
321
+ });
322
+ crashed.slot = credential.slot ?? null;
323
+ results.push(crashed);
324
+ }
325
+ }
326
+ byFamily[family] = results;
327
+ familyWarnings[family] = familyWarnList;
328
+ }),
329
+ );
330
+
331
+ for (const family of families) {
332
+ if (familyWarnings[family]) {
333
+ warnings.push(...familyWarnings[family]);
334
+ }
335
+ }
336
+
337
+ const providers = families.flatMap((family) => byFamily[family] ?? []);
338
+
339
+ // Stamp the primary window so the terminal, the Pi TUI and the Moshi adapter
340
+ // all agree on which window represents a provider, with no duplicated rules.
341
+ for (const provider of providers) {
342
+ provider.primaryWindowId = selectPrimaryWindow(provider.windows ?? [])?.id ?? null;
343
+ }
344
+
345
+ return {
346
+ engine: "pi-quota",
347
+ schemaVersion: 1,
348
+ readOnly: true,
349
+ generatedAt: new Date(now).toISOString(),
350
+ sources: loaded.paths,
351
+ // Named so `piquota --explain` can state which Claude source was chosen and
352
+ // every surface agrees, instead of each one guessing.
353
+ claudeSource: { mode: claude.mode, paths: claude.claudeCodePaths, unavailable: claude.unavailable },
354
+ warnings,
355
+ providers,
356
+ byFamily,
357
+ };
358
+ }
359
+
360
+ /**
361
+ * @param {string} family
362
+ * @param {{ hasOpenAi?: boolean }} [context]
363
+ * @returns {string}
364
+ */
365
+ function missingCredentialMessage(family, context = {}) {
366
+ if (family === "claude") {
367
+ return "no claude credential in the Pi store or from the Claude Code CLI";
368
+ }
369
+ if (family === "github-actions") {
370
+ return "GitHub Actions uses the authenticated gh CLI; no Pi-store credential is required";
371
+ }
372
+ if (family === "codex" && context.hasOpenAi) {
373
+ return "no codex credential in the Pi store (found 'openai' API; run /login openai-codex in Pi for subscription quota)";
374
+ }
375
+ return `no ${family} credential in the Pi store`;
376
+ }
377
+
378
+ /**
379
+ * @param {{ sourceKind?: string }} credential
380
+ * @returns {string}
381
+ */
382
+ function expiredTokenName(credential) {
383
+ return credential.sourceKind === "claude-code" ? "Claude Code token" : "Pi token";
384
+ }
385
+
386
+ /**
387
+ * @param {{ sourceKind?: string }} credential
388
+ * @returns {string}
389
+ */
390
+ function refreshHint(credential) {
391
+ return credential.sourceKind === "claude-code"
392
+ ? "run `claude` once to refresh it"
393
+ : "use that provider in Pi to refresh it";
394
+ }
395
+
396
+ /**
397
+ * Convenience accessor used by the renderers.
398
+ *
399
+ * @param {PiQuotaReport} report
400
+ * @returns {import("./model.js").QuotaResult | null}
401
+ */
402
+ export function firstForFamily(report, family) {
403
+ return report.byFamily[family]?.[0] ?? null;
404
+ }
package/src/exec.js ADDED
@@ -0,0 +1,31 @@
1
+ /**
2
+ * The one place that spawns foreign binaries.
3
+ *
4
+ * piQuota delegates GitHub API requests to the user's authenticated `gh` CLI.
5
+ * Callers must degrade on missing binaries, non-zero exits and timeouts;
6
+ * external commands must never make quota collection throw.
7
+ */
8
+
9
+ import { spawnSync } from "node:child_process";
10
+
11
+ export const DEFAULT_TIMEOUT_MS = 15_000;
12
+
13
+ /**
14
+ * @param {string} command
15
+ * @param {string[]} [args]
16
+ * @param {{ timeoutMs?: number, env?: Record<string, string | undefined> }} [options]
17
+ * @returns {{ status: number | null, stdout: string, stderr: string, error: string | null }}
18
+ */
19
+ export function runCommand(command, args = [], options = {}) {
20
+ const result = spawnSync(command, args, {
21
+ encoding: "utf-8",
22
+ timeout: options.timeoutMs ?? DEFAULT_TIMEOUT_MS,
23
+ env: options.env ?? process.env,
24
+ });
25
+ return {
26
+ status: typeof result.status === "number" ? result.status : null,
27
+ stdout: typeof result.stdout === "string" ? result.stdout : "",
28
+ stderr: typeof result.stderr === "string" ? result.stderr : "",
29
+ error: result.error ? result.error.message : null,
30
+ };
31
+ }