@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,337 @@
1
+ /**
2
+ * Parser for the authenticated OpenCode Go dashboard.
3
+ *
4
+ * opencode.ai is a SolidJS/SSR app: there is no public REST endpoint for usage
5
+ * (every `/api/*` guess returns 404), so the numbers have to be read out of the
6
+ * rendered page for `/workspace/<id>/go`.
7
+ *
8
+ * Because that page cannot be inspected without a session cookie, the parser is
9
+ * deliberately multi-strategy and never throws:
10
+ * 1. `data-slot="usage-item"` blocks (rendered markup);
11
+ * 2. hydration JSON objects keyed `rollingUsage` / `weeklyUsage` /
12
+ * `monthlyUsage` (SolidJS serialized state);
13
+ * 3. a generic scan pairing `usagePercent`/`usedPercent` with
14
+ * `resetInSec`/`resetSeconds` next to a recognizable window keyword.
15
+ */
16
+
17
+ import { buildWindow, clampPercent, finiteNumber, stringValue } from "../model.js";
18
+
19
+ /**
20
+ * Convert Console API meter values into canonical quota windows.
21
+ *
22
+ * @param {unknown} meters
23
+ * @param {{ now?: number }} [options]
24
+ * @returns {import("../model.js").QuotaWindow[]}
25
+ */
26
+ export function parseGoMeters(meters, options = {}) {
27
+ const source = record(meters);
28
+ if (!source) return [];
29
+
30
+ const definitions = [
31
+ ["fiveHour", "5h", "5h window", 18000],
32
+ ["week", "weekly", "Weekly window", 604800],
33
+ ["month", "monthly", "Monthly window", 2592000],
34
+ ];
35
+
36
+ return definitions.flatMap(([field, id, label, windowSeconds]) => {
37
+ const meter = record(source[field]);
38
+ if (!meter) return [];
39
+ const limit = Number(meter.limitMicroCents);
40
+ const used = Number(meter.usedMicroCents);
41
+ const usedPercent = Number.isFinite(limit) && limit > 0 && Number.isFinite(used)
42
+ ? Math.min(100, Math.max(0, (used / limit) * 100))
43
+ : null;
44
+ return [buildWindow({ id, label, usedPercent, resetsAt: meter.resetsAt, windowSeconds, now: options.now })];
45
+ });
46
+ }
47
+
48
+ /**
49
+ * Canonical window ids in the order the Go plan presents them.
50
+ *
51
+ * The label separators matter: the live page renders "5-hour Usage" (hyphenated),
52
+ * so a `\s*` separator silently dropped the 5h window. The aliases therefore
53
+ * accept a space, a hyphen or nothing at all.
54
+ */
55
+ export const GO_WINDOWS = [
56
+ {
57
+ field: "rollingUsage",
58
+ id: "5h",
59
+ label: "5h window",
60
+ aliases: /\b(?:rolling|session|hourly)\b|\b5[\s-]*(?:h|hours?|hrs?)\b|\bfive[\s-]*hours?\b/i,
61
+ },
62
+ {
63
+ field: "weeklyUsage",
64
+ id: "weekly",
65
+ label: "Weekly window",
66
+ aliases: /\bweekly\b|\bweek\b|\b7[\s-]*d(?:ay)?s?\b|\bseven[\s-]*days?\b/i,
67
+ },
68
+ {
69
+ field: "monthlyUsage",
70
+ id: "monthly",
71
+ label: "Monthly window",
72
+ aliases: /\bmonthly\b|\bmonth\b|\b30[\s-]*d(?:ay)?s?\b/i,
73
+ },
74
+ ];
75
+
76
+ /**
77
+ * @param {unknown} value
78
+ * @returns {Record<string, unknown> | null}
79
+ */
80
+ function record(value) {
81
+ return value && typeof value === "object" && !Array.isArray(value)
82
+ ? /** @type {Record<string, unknown>} */ (value)
83
+ : null;
84
+ }
85
+
86
+ /**
87
+ * Decode the HTML entities and drop tags for a fragment.
88
+ *
89
+ * @param {string} value
90
+ * @returns {string}
91
+ */
92
+ export function htmlToText(value) {
93
+ return value
94
+ .replace(/<!--[\s\S]*?-->/g, "")
95
+ .replace(/<[^>]*>/g, " ")
96
+ .replace(/&(?:#(\d+)|#x([\da-f]+)|(amp|lt|gt|quot|apos|nbsp));/gi, (match, dec, hex, named) => {
97
+ if (dec) return String.fromCodePoint(Number(dec));
98
+ if (hex) return String.fromCodePoint(Number.parseInt(hex, 16));
99
+ const table = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
100
+ return table[String(named).toLowerCase()] ?? match;
101
+ })
102
+ .replace(/\s+/g, " ")
103
+ .trim();
104
+ }
105
+
106
+ /**
107
+ * Parse a percentage out of arbitrary text.
108
+ *
109
+ * @param {unknown} value
110
+ * @returns {number | null}
111
+ */
112
+ export function parsePercent(value) {
113
+ if (typeof value === "number") return clampPercent(value);
114
+ const text = stringValue(value);
115
+ if (!text) return null;
116
+ const match = text.match(/(-?(?:\d+(?:\.\d*)?|\.\d+))\s*%/);
117
+ return match ? clampPercent(Number(match[1])) : null;
118
+ }
119
+
120
+ /**
121
+ * Parse a reset phrase such as "Resets in 5 hours 10 minutes" into seconds.
122
+ *
123
+ * @param {unknown} value
124
+ * @returns {number | null}
125
+ */
126
+ export function parseResetSeconds(value) {
127
+ if (typeof value === "number" && Number.isFinite(value)) return Math.max(0, Math.round(value));
128
+ const text = htmlToText(stringValue(value) ?? "").toLowerCase();
129
+ if (!text) return null;
130
+ if (/\b(?:resets?|resetting)(?:\s+right)?\s+now\b|^now$/.test(text)) return 0;
131
+ const unitSeconds = { d: 86400, day: 86400, days: 86400, h: 3600, hr: 3600, hrs: 3600, hour: 3600, hours: 3600, m: 60, min: 60, mins: 60, minute: 60, minutes: 60, s: 1, sec: 1, secs: 1, second: 1, seconds: 1 };
132
+ const pattern = /(-?(?:\d+(?:\.\d*)?|\.\d+))\s*(days?|d|hours?|hrs?|h|minutes?|mins?|m|seconds?|secs?|s)\b/gi;
133
+ let matched = false;
134
+ let seconds = 0;
135
+ for (const match of text.matchAll(pattern)) {
136
+ matched = true;
137
+ seconds += Number(match[1]) * (unitSeconds[match[2].toLowerCase()] ?? 0);
138
+ }
139
+ return matched ? Math.max(0, Math.round(seconds)) : null;
140
+ }
141
+
142
+ /**
143
+ * Read `data-slot="<name>"` text from a fragment.
144
+ *
145
+ * @param {string} segment
146
+ * @param {string} slot
147
+ * @returns {string}
148
+ */
149
+ function readSlot(segment, slot) {
150
+ const escaped = slot.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
151
+ const pattern = new RegExp(
152
+ `<([a-z][\\w:-]*)\\b[^>]*\\bdata-slot\\s*=\\s*(?:"${escaped}"|'${escaped}'|${escaped}\\b)[^>]*>([\\s\\S]*?)<\\/\\1\\s*>`,
153
+ "i",
154
+ );
155
+ const match = segment.match(pattern);
156
+ return match ? htmlToText(match[2]) : "";
157
+ }
158
+
159
+ /**
160
+ * @param {string} label
161
+ * @returns {{ id: string, label: string } | null}
162
+ */
163
+ function windowForLabel(label) {
164
+ for (const window of GO_WINDOWS) {
165
+ if (window.aliases.test(label)) return { id: window.id, label: window.label };
166
+ }
167
+ return null;
168
+ }
169
+
170
+ /**
171
+ * Extract balanced JSON objects that contain a given key.
172
+ *
173
+ * @param {string} text
174
+ * @param {string} key
175
+ * @returns {string[]}
176
+ */
177
+ function objectsWithKey(text, key) {
178
+ /** @type {string[]} */
179
+ const out = [];
180
+ const pattern = new RegExp(`(?:"|')?${key}(?:"|')?\\s*:\\s*\\{`, "g");
181
+ for (const match of text.matchAll(pattern)) {
182
+ const start = (match.index ?? 0) + match[0].lastIndexOf("{");
183
+ const object = balancedObject(text, start);
184
+ if (object) out.push(object);
185
+ }
186
+ return out;
187
+ }
188
+
189
+ /**
190
+ * @param {string} text
191
+ * @param {number} start
192
+ * @returns {string | null}
193
+ */
194
+ function balancedObject(text, start) {
195
+ let depth = 0;
196
+ let quote = null;
197
+ let escaped = false;
198
+ for (let index = start; index < text.length; index += 1) {
199
+ const character = text[index];
200
+ if (quote) {
201
+ if (escaped) escaped = false;
202
+ else if (character === "\\") escaped = true;
203
+ else if (character === quote) quote = null;
204
+ continue;
205
+ }
206
+ if (character === '"' || character === "'") {
207
+ quote = character;
208
+ continue;
209
+ }
210
+ if (character === "{") depth += 1;
211
+ if (character === "}") {
212
+ depth -= 1;
213
+ if (depth === 0) return text.slice(start, index + 1);
214
+ }
215
+ }
216
+ return null;
217
+ }
218
+
219
+ /**
220
+ * @param {string} object
221
+ * @param {string} key
222
+ * @returns {number | null}
223
+ */
224
+ function numberField(object, key) {
225
+ const pattern = new RegExp(`(?:"|')?${key}(?:"|')?\\s*:\\s*(?:"|')?(-?(?:\\d+(?:\\.\\d*)?|\\.\\d+))`);
226
+ const match = object.match(pattern);
227
+ return match ? Number(match[1]) : null;
228
+ }
229
+
230
+ /**
231
+ * Parse the dashboard HTML into quota windows.
232
+ *
233
+ * @param {unknown} html
234
+ * @param {{ now?: number }} [options]
235
+ * @returns {{ windows: import("../model.js").QuotaWindow[], strategies: string[], planHint: string | null }}
236
+ */
237
+ export function parseGoDashboard(html, options = {}) {
238
+ const now = options.now ?? Date.now();
239
+ const text = typeof html === "string" ? html : "";
240
+ if (text.trim() === "") return { windows: [], strategies: [], planHint: null };
241
+
242
+ /** @type {Map<string, { usagePercent: number | null, resetInSec: number | null }>} */
243
+ const collected = new Map();
244
+ /** @type {string[]} */
245
+ const strategies = [];
246
+
247
+ // Strategy 1: rendered markup.
248
+ const itemPattern = /<[a-z][\w:-]*\b[^>]*\bdata-slot\s*=\s*(?:"usage-item"|'usage-item'|usage-item\b)[^>]*>/gi;
249
+ const starts = [...text.matchAll(itemPattern)].map((match) => match.index ?? 0);
250
+ for (let index = 0; index < starts.length; index += 1) {
251
+ const segment = text.slice(starts[index], starts[index + 1] ?? text.length);
252
+ const label = readSlot(segment, "usage-label");
253
+ const value = readSlot(segment, "usage-value");
254
+ const reset = readSlot(segment, "reset-time");
255
+ const window = windowForLabel(label);
256
+ const usagePercent = parsePercent(value);
257
+ if (!window || usagePercent === null) continue;
258
+ if (!collected.has(window.id)) {
259
+ collected.set(window.id, { usagePercent, resetInSec: parseResetSeconds(reset) });
260
+ if (!strategies.includes("usage-item")) strategies.push("usage-item");
261
+ }
262
+ }
263
+
264
+ // Strategy 2: hydration state. Complementary, so a partial render cannot hide
265
+ // a window that only exists in the serialized state.
266
+ if (collected.size < GO_WINDOWS.length) {
267
+ const decoded = text.replace(/\\u0022/gi, '"').replace(/\\"/g, '"');
268
+ for (const window of GO_WINDOWS) {
269
+ if (collected.has(window.id)) continue;
270
+ for (const object of objectsWithKey(decoded, window.field)) {
271
+ const usagePercent = numberField(object, "usagePercent") ?? numberField(object, "usedPercent");
272
+ if (usagePercent === null) continue;
273
+ const resetInSec = numberField(object, "resetInSec") ?? numberField(object, "resetSeconds");
274
+ collected.set(window.id, { usagePercent, resetInSec });
275
+ if (!strategies.includes("hydration")) strategies.push("hydration");
276
+ break;
277
+ }
278
+ }
279
+ }
280
+
281
+ // Strategy 3: generic scan around recognizable labels. Also complementary.
282
+ if (collected.size < GO_WINDOWS.length) {
283
+ const decoded = text.replace(/\\u0022/gi, '"').replace(/\\"/g, '"');
284
+ for (const window of GO_WINDOWS) {
285
+ if (collected.has(window.id)) continue;
286
+ const labelIndex = decoded.search(window.aliases);
287
+ if (labelIndex < 0) continue;
288
+ const vicinity = decoded.slice(labelIndex, labelIndex + 600);
289
+ const usagePercent =
290
+ numberField(vicinity, "usagePercent") ??
291
+ numberField(vicinity, "usedPercent") ??
292
+ parsePercent(stringValue(vicinity.match(/(-?[\d.]+)\s*%/)?.[0]) ?? "");
293
+ if (usagePercent === null) continue;
294
+ const resetInSec = numberField(vicinity, "resetInSec") ?? parseResetSeconds(vicinity);
295
+ if (!collected.has(window.id)) {
296
+ collected.set(window.id, { usagePercent, resetInSec });
297
+ if (!strategies.includes("generic")) strategies.push("generic");
298
+ }
299
+ }
300
+ }
301
+
302
+ /** @type {import("../model.js").QuotaWindow[]} */
303
+ const windows = [];
304
+ for (const window of GO_WINDOWS) {
305
+ const found = collected.get(window.id);
306
+ if (!found) continue;
307
+ windows.push(
308
+ buildWindow({
309
+ id: window.id,
310
+ label: window.label,
311
+ usedPercent: found.usagePercent,
312
+ resetsInSec: found.resetInSec,
313
+ now,
314
+ }),
315
+ );
316
+ }
317
+
318
+ const planMatch = text.match(/\$\s?(\d+(?:\.\d+)?)\s*(?:\/|per\s*)?\s*month/i);
319
+ return { windows, strategies, planHint: planMatch ? `$${planMatch[1]}/month` : null };
320
+ }
321
+
322
+ /**
323
+ * Find workspace ids referenced by a page.
324
+ *
325
+ * @param {unknown} html
326
+ * @returns {string[]}
327
+ */
328
+ export function findWorkspaceIds(html) {
329
+ const text = typeof html === "string" ? html : "";
330
+ /** @type {Set<string>} */
331
+ const ids = new Set();
332
+ for (const match of text.matchAll(/\/workspace\/([A-Za-z0-9_-]{4,})/g)) ids.add(match[1]);
333
+ for (const match of text.matchAll(/"(?:workspaceId|workspace_id|id)"\s*:\s*"([A-Za-z0-9_-]{4,})"/g)) ids.add(match[1]);
334
+ return [...ids].filter((id) => id !== "settings" && id !== "usage" && id !== "go" && id !== "keys" && id !== "members" && id !== "billing");
335
+ }
336
+
337
+ export { finiteNumber };