@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
@@ -0,0 +1,424 @@
1
+ /**
2
+ * Read-only reader for Pi's provider credential store (`auth.json`).
3
+ *
4
+ * Hard rules enforced by this module:
5
+ * - open the file with read-only flags and never write, move or chmod it;
6
+ * - never import, refresh or "sync" tokens back into it;
7
+ * - never emit token material through logs, errors or JSON output.
8
+ *
9
+ * On WSL the same Pi installation may also exist on the Windows drive. We read
10
+ * both when present and de-duplicate by provider identity so a shared account is
11
+ * never shown twice.
12
+ */
13
+
14
+ import { createHash } from "node:crypto";
15
+ import { existsSync, readFileSync, readdirSync, realpathSync, statSync } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import { extractChatGptAccountId, extractChatGptPlanType, extractEmail, extractExpiresAtMs } from "./jwt.js";
20
+ import { redact } from "../http.js";
21
+
22
+ /** Pi provider key -> normalized quota family. */
23
+ export const FAMILY_BY_PROVIDER = {
24
+ "openai-codex": "codex",
25
+ anthropic: "claude",
26
+ antigravity: "antigravity",
27
+ "opencode-go": "opencode-go",
28
+ };
29
+
30
+ /** Display label used by every surface (terminal, Pi TUI, Moshi cards). */
31
+ export const LABEL_BY_FAMILY = {
32
+ codex: "Codex (Pi)",
33
+ claude: "Claude (Pi)",
34
+ antigravity: "Antigravity (Pi)",
35
+ "opencode-go": "OpenCode Go (Pi)",
36
+ "github-actions": "GitHub Actions",
37
+ };
38
+
39
+ const WINDOWS_USERS_ROOT = "/mnt/c/Users";
40
+ const MAX_WINDOWS_PROFILES = 40;
41
+
42
+ /**
43
+ * @typedef {Object} PiCredential
44
+ * @property {string} provider Raw Pi provider key, e.g. "openai-codex".
45
+ * @property {string} family Normalized family, e.g. "codex".
46
+ * @property {string | null} slot Numbered sibling account, e.g. "2" for
47
+ * "opencode-go-2"; null for the base entry.
48
+ * @property {string} label Display label, e.g. "Codex (Pi)".
49
+ * @property {string} source Absolute path the credential came from.
50
+ * @property {"pi" | "claude-code"} [sourceKind] Which kind of store produced it.
51
+ * @property {string} identity Stable per-provider identity (never a token).
52
+ * @property {string} [access] OAuth access token (memory only).
53
+ * @property {string} [refresh] OAuth refresh token (memory only).
54
+ * @property {string} [key] API key (memory only).
55
+ * @property {number | null} expiresAtMs Epoch milliseconds when known.
56
+ * @property {string | null} accountId
57
+ * @property {string | null} projectId
58
+ * @property {string | null} email
59
+ * @property {string | null} planType
60
+ * @property {"oauth" | "api_key"} kind
61
+ */
62
+
63
+ /**
64
+ * @typedef {Object} PiAuthLoadResult
65
+ * @property {PiCredential[]} credentials
66
+ * @property {string[]} paths Store paths that were read.
67
+ * @property {string[]} warnings Non-fatal issues (already redacted).
68
+ */
69
+
70
+ /**
71
+ * @param {Record<string, string | undefined>} env
72
+ * @returns {string[]}
73
+ */
74
+ function explicitAuthPaths(env) {
75
+ const paths = [];
76
+ for (const key of ["PI_QUOTA_AUTH_PATH", "PI_AUTH_PATH"]) {
77
+ const value = env[key];
78
+ if (value) paths.push(value);
79
+ }
80
+ return paths;
81
+ }
82
+
83
+ /**
84
+ * Windows-side Pi stores reachable from WSL, discovered without spawning cmd.exe.
85
+ *
86
+ * @param {{ platform?: string, usersRoot?: string }} [options]
87
+ * @returns {string[]}
88
+ */
89
+ export function discoverWindowsAuthPaths(options = {}) {
90
+ const platform = options.platform ?? process.platform;
91
+ const usersRoot = options.usersRoot ?? WINDOWS_USERS_ROOT;
92
+ if (platform === "win32") return [];
93
+ if (!existsSync(usersRoot)) return [];
94
+
95
+ /** @type {string[]} */
96
+ const found = [];
97
+ let entries = [];
98
+ try {
99
+ entries = readdirSync(usersRoot, { withFileTypes: true });
100
+ } catch {
101
+ return [];
102
+ }
103
+
104
+ let scanned = 0;
105
+ for (const entry of entries) {
106
+ if (scanned >= MAX_WINDOWS_PROFILES) break;
107
+ if (!entry.isDirectory()) continue;
108
+ const name = entry.name;
109
+ if (name === "Default" || name === "Default User" || name === "All Users" || name === "Public") continue;
110
+ scanned += 1;
111
+ const candidate = join(usersRoot, name, ".pi", "agent", "auth.json");
112
+ if (existsSync(candidate)) found.push(candidate);
113
+ }
114
+ return found;
115
+ }
116
+
117
+ /**
118
+ * Candidate store paths, in priority order, without duplicates.
119
+ *
120
+ * @param {{
121
+ * env?: Record<string, string | undefined>,
122
+ * home?: string,
123
+ * platform?: string,
124
+ * usersRoot?: string,
125
+ * }} [options]
126
+ * @returns {string[]}
127
+ */
128
+ export function resolveAuthPaths(options = {}) {
129
+ const env = options.env ?? process.env;
130
+ const home = options.home ?? homedir();
131
+ const explicit = explicitAuthPaths(env);
132
+ if (explicit.length > 0) return dedupePaths(explicit);
133
+
134
+ const candidates = [
135
+ join(home, ".pi", "agent", "auth.json"),
136
+ ...discoverWindowsAuthPaths({ platform: options.platform, usersRoot: options.usersRoot }),
137
+ ];
138
+ return dedupePaths(candidates.filter((path) => existsSync(path)));
139
+ }
140
+
141
+ /**
142
+ * @param {string[]} paths
143
+ * @returns {string[]}
144
+ */
145
+ function dedupePaths(paths) {
146
+ const seen = new Set();
147
+ const out = [];
148
+ for (const path of paths) {
149
+ let key = path;
150
+ try {
151
+ key = realpathSync(path);
152
+ } catch {
153
+ // Keep the unresolved path when realpath fails; existsSync already ran.
154
+ }
155
+ if (seen.has(key)) continue;
156
+ seen.add(key);
157
+ out.push(path);
158
+ }
159
+ return out;
160
+ }
161
+
162
+ /**
163
+ * @param {unknown} value
164
+ * @returns {string | null}
165
+ */
166
+ function text(value) {
167
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
168
+ }
169
+
170
+ /**
171
+ * Milliseconds since epoch below which we treat a stored timestamp as SECONDS.
172
+ * Current second-epochs are ~1.8e9 and millisecond-epochs ~1.8e12, so 1e11
173
+ * separates them with a wide margin (1e11 ms is 1973, 1e11 s is year 5138).
174
+ */
175
+ const SECONDS_EPOCH_CEILING = 1e11;
176
+
177
+ /**
178
+ * @param {unknown} value
179
+ * @returns {number | null} Epoch milliseconds.
180
+ */
181
+ function epochMs(value) {
182
+ if (typeof value === "number" && Number.isFinite(value) && value > 0) {
183
+ return value > SECONDS_EPOCH_CEILING ? value : value * 1000;
184
+ }
185
+ if (typeof value === "string" && value.trim() !== "") {
186
+ const asNumber = Number(value);
187
+ if (Number.isFinite(asNumber) && asNumber > 0) return epochMs(asNumber);
188
+ const parsed = Date.parse(value);
189
+ if (!Number.isNaN(parsed)) return parsed;
190
+ }
191
+ return null;
192
+ }
193
+
194
+ /**
195
+ * Non-reversible identity fingerprint. Used only for de-duplication.
196
+ *
197
+ * @param {string[]} parts
198
+ * @returns {string}
199
+ */
200
+ function fingerprint(parts) {
201
+ return createHash("sha256").update(parts.join("\u0000")).digest("hex").slice(0, 16);
202
+ }
203
+
204
+ /**
205
+ * Resolve a raw auth.json key onto a family and, when it is a numbered sibling,
206
+ * its slot.
207
+ *
208
+ * Pi's own identifiers are the only base providers. Everything else follows the
209
+ * convention used by multi-account providers: `<base>-<account>`, where the
210
+ * account starts at the FIRST `-<digits>` boundary. That boundary is what makes
211
+ * "opencode-go-2" a slot 2 of "opencode-go" and "opencode-2-work" a slot
212
+ * "2-work" of "opencode" (an unknown base, therefore ignored) rather than a
213
+ * slot "work" of "opencode-2".
214
+ *
215
+ * @param {string} provider
216
+ * @returns {{ base: string, family: string, slot: string | null } | null}
217
+ */
218
+ export function resolveProviderSlot(provider) {
219
+ if (typeof provider !== "string" || provider === "") return null;
220
+ const family = FAMILY_BY_PROVIDER[provider];
221
+ if (family) return { base: provider, family, slot: null };
222
+
223
+ const match = /^(.*?)-(\d.*)$/.exec(provider);
224
+ if (!match) return null;
225
+ const [, base, account] = match;
226
+ const siblingFamily = FAMILY_BY_PROVIDER[base];
227
+ if (!siblingFamily || account === "") return null;
228
+ return { base, family: siblingFamily, slot: account };
229
+ }
230
+
231
+ /**
232
+ * Normalize one raw auth.json entry into a credential.
233
+ *
234
+ * @param {string} provider
235
+ * @param {Record<string, unknown>} raw
236
+ * @param {string} source
237
+ * @returns {PiCredential | null}
238
+ */
239
+ export function normalizeCredential(provider, raw, source) {
240
+ const resolved = resolveProviderSlot(provider);
241
+ if (!resolved || !raw || typeof raw !== "object") return null;
242
+ const { base, family, slot } = resolved;
243
+
244
+ const kind = raw.type === "api_key" ? "api_key" : "oauth";
245
+ const access = text(raw.access);
246
+ const refresh = text(raw.refresh);
247
+ const key = text(raw.key);
248
+ if (!access && !refresh && !key) return null;
249
+
250
+ const accountId = text(raw.accountId) ?? (access ? extractChatGptAccountId(access) : null);
251
+ const email = text(raw.email) ?? (access ? extractEmail(access) : null);
252
+ const projectId = text(raw.projectId) ?? text(raw.project_id);
253
+ const planType = text(raw.planType) ?? (access ? extractChatGptPlanType(access) : null);
254
+ const expiresAtMs = epochMs(raw.expires) ?? (access ? extractExpiresAtMs(access) : null);
255
+
256
+ // Fingerprint on the BASE provider, not the raw slot id: the same secret in
257
+ // "opencode-go" and "opencode-go-2" must still collapse to one credential,
258
+ // while different secrets in two slots stay distinct.
259
+ const secretMaterial = access ?? key ?? refresh ?? "";
260
+ const identity = accountId ?? email ?? projectId ?? fingerprint([base, secretMaterial]);
261
+
262
+ return {
263
+ provider,
264
+ family,
265
+ slot,
266
+ label: LABEL_BY_FAMILY[family],
267
+ source,
268
+ sourceKind: "pi",
269
+ identity,
270
+ access: access ?? undefined,
271
+ refresh: refresh ?? undefined,
272
+ key: key ?? undefined,
273
+ expiresAtMs,
274
+ accountId,
275
+ projectId,
276
+ email,
277
+ planType,
278
+ kind,
279
+ };
280
+ }
281
+
282
+ /**
283
+ * Parse one store file. Never throws.
284
+ *
285
+ * @param {string} path
286
+ * @returns {{ credentials: PiCredential[], warnings: string[] }}
287
+ */
288
+ export function readAuthStore(path) {
289
+ /** @type {PiCredential[]} */
290
+ const credentials = [];
291
+ /** @type {string[]} */
292
+ const warnings = [];
293
+
294
+ let raw;
295
+ try {
296
+ raw = readFileSync(path, { encoding: "utf-8", flag: "r" });
297
+ } catch (error) {
298
+ warnings.push(`cannot read ${path}: ${redact(/** @type {{ message?: string }} */ (error)?.message)}`);
299
+ return { credentials, warnings };
300
+ }
301
+
302
+ let parsed;
303
+ try {
304
+ parsed = JSON.parse(raw);
305
+ } catch {
306
+ warnings.push(`cannot parse ${path}: invalid JSON`);
307
+ return { credentials, warnings };
308
+ }
309
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
310
+ warnings.push(`cannot use ${path}: expected a JSON object`);
311
+ return { credentials, warnings };
312
+ }
313
+
314
+ for (const [provider, entry] of Object.entries(parsed)) {
315
+ if (!resolveProviderSlot(provider)) continue;
316
+ if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
317
+ warnings.push(`${path}: ${provider} entry is not an object`);
318
+ continue;
319
+ }
320
+ const credential = normalizeCredential(provider, /** @type {Record<string, unknown>} */ (entry), path);
321
+ if (credential) credentials.push(credential);
322
+ }
323
+ return { credentials, warnings, rawProviders: Object.keys(parsed) };
324
+ }
325
+
326
+ /**
327
+ * Load every Pi credential from every resolvable store, de-duplicated.
328
+ *
329
+ * @param {{
330
+ * env?: Record<string, string | undefined>,
331
+ * home?: string,
332
+ * platform?: string,
333
+ * usersRoot?: string,
334
+ * paths?: string[],
335
+ * }} [options]
336
+ * @returns {PiAuthLoadResult}
337
+ */
338
+ export function loadPiCredentials(options = {}) {
339
+ const paths = options.paths ?? resolveAuthPaths(options);
340
+ /** @type {PiCredential[]} */
341
+ const credentials = [];
342
+ /** @type {string[]} */
343
+ const warnings = [];
344
+ /** @type {string[]} */
345
+ const usedPaths = [];
346
+ /** @type {Set<string>} */
347
+ const seen = new Set();
348
+ /** @type {Set<string>} */
349
+ const rawProviders = new Set();
350
+
351
+ for (const path of paths) {
352
+ const result = readAuthStore(path);
353
+ warnings.push(...result.warnings);
354
+ if (result.credentials.length > 0) usedPaths.push(path);
355
+ for (const p of result.rawProviders ?? []) rawProviders.add(p);
356
+ for (const credential of result.credentials) {
357
+ const key = `${credential.family}:${credential.identity}`;
358
+ if (seen.has(key)) continue;
359
+ seen.add(key);
360
+ credentials.push(credential);
361
+ }
362
+ }
363
+
364
+ credentials.sort(compareCredentials);
365
+ return { credentials, paths: usedPaths, warnings, rawProviders: Array.from(rawProviders) };
366
+ }
367
+
368
+ /**
369
+ * Slot order inside a family: the base entry first, then siblings by their
370
+ * leading number. Plain string comparison would put "#10" before "#2", which
371
+ * reads as a shuffled account list in every renderer.
372
+ *
373
+ * @param {string | null} slot
374
+ * @returns {number}
375
+ */
376
+ function slotOrder(slot) {
377
+ if (slot === null) return -1;
378
+ const numeric = Number.parseInt(slot, 10);
379
+ return Number.isFinite(numeric) ? numeric : Number.MAX_SAFE_INTEGER;
380
+ }
381
+
382
+ /**
383
+ * @param {PiCredential} a
384
+ * @param {PiCredential} b
385
+ * @returns {number}
386
+ */
387
+ function compareCredentials(a, b) {
388
+ const byFamily = a.family.localeCompare(b.family);
389
+ if (byFamily !== 0) return byFamily;
390
+ const bySlot = slotOrder(a.slot) - slotOrder(b.slot);
391
+ if (bySlot !== 0) return bySlot;
392
+ return String(a.slot ?? "").localeCompare(String(b.slot ?? ""));
393
+ }
394
+
395
+ /**
396
+ * Freshness check driven by the stored expiry. Read-only: this module never
397
+ * refreshes, because a rotated refresh token would invalidate Pi's stored copy.
398
+ *
399
+ * @param {PiCredential} credential
400
+ * @param {number} [now]
401
+ * @returns {{ fresh: boolean, expiresAtMs: number | null, expiresInMin: number | null }}
402
+ */
403
+ export function checkFreshness(credential, now = Date.now()) {
404
+ const expiresAtMs = credential.expiresAtMs ?? null;
405
+ if (expiresAtMs === null) return { fresh: true, expiresAtMs: null, expiresInMin: null };
406
+ const expiresInMin = Math.round((expiresAtMs - now) / 60000);
407
+ return { fresh: expiresAtMs > now, expiresAtMs, expiresInMin };
408
+ }
409
+
410
+ /**
411
+ * Sanity check used by diagnostics: confirm the store exists and is a plain file.
412
+ *
413
+ * @param {string} path
414
+ * @returns {{ exists: boolean, isFile: boolean, sizeBytes: number | null }}
415
+ */
416
+ export function describeStore(path) {
417
+ if (!existsSync(path)) return { exists: false, isFile: false, sizeBytes: null };
418
+ try {
419
+ const stats = statSync(path);
420
+ return { exists: true, isFile: stats.isFile(), sizeBytes: stats.size };
421
+ } catch {
422
+ return { exists: true, isFile: false, sizeBytes: null };
423
+ }
424
+ }