@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.
- package/LICENSE +21 -0
- package/README.md +509 -2
- package/bin/piquota.js +199 -0
- package/extensions/quota-panel.ts +963 -0
- package/package.json +59 -4
- package/src/auth/claude-code-auth.js +490 -0
- package/src/auth/jwt.js +84 -0
- package/src/auth/pi-auth.js +424 -0
- package/src/browser/cookies.js +557 -0
- package/src/browser/history.js +173 -0
- package/src/cache.js +224 -0
- package/src/cli/args.js +154 -0
- package/src/cli/commands/auth.js +216 -0
- package/src/cli/commands/gentle-pi.js +44 -0
- package/src/cli/commands/omarchy.js +75 -0
- package/src/cli/explain.js +99 -0
- package/src/cli/output.js +34 -0
- package/src/engine.js +404 -0
- package/src/exec.js +31 -0
- package/src/gentle-pi/rail-patch.js +335 -0
- package/src/http.js +182 -0
- package/src/model.js +332 -0
- package/src/omarchy/assets/antigravity.svg +3 -0
- package/src/omarchy/assets/claude.svg +1 -0
- package/src/omarchy/assets/codex.svg +1 -0
- package/src/omarchy/assets/github-actions.svg +3 -0
- package/src/omarchy/assets/opencode-go.svg +3 -0
- package/src/omarchy/publish.js +152 -0
- package/src/omarchy/record.js +462 -0
- package/src/opencode/dashboard.js +337 -0
- package/src/opencode/session.js +485 -0
- package/src/providers/antigravity-oauth.js +173 -0
- package/src/providers/antigravity.js +225 -0
- package/src/providers/backoff.js +146 -0
- package/src/providers/claude.js +194 -0
- package/src/providers/codex.js +151 -0
- package/src/providers/error-kind.js +128 -0
- package/src/providers/github-actions.js +379 -0
- package/src/providers/opencode-go.js +274 -0
- package/src/refresh.js +292 -0
- package/src/render/panel.js +411 -0
- package/src/render/theme.js +102 -0
- 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 };
|