@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,173 @@
1
+ /**
2
+ * Read-only Firefox history lookup.
3
+ *
4
+ * Needed because opencode.ai serves the workspace app only at
5
+ * `/workspace/<id>/...`: there is no listing page at `/workspace` (it answers
6
+ * 404 even when authenticated), so the id has to be observed. Firefox records
7
+ * the URL the OAuth flow lands on, which makes its history the reliable source.
8
+ *
9
+ * Read-only: `places.sqlite` (with `-wal`/`-shm`) is copied to a private temp
10
+ * directory and opened there. Only URLs are read, and query strings are never
11
+ * returned to callers.
12
+ */
13
+
14
+ import { DatabaseSync } from "node:sqlite";
15
+ import { copyFileSync, existsSync, mkdtempSync, rmSync } from "node:fs";
16
+ import { tmpdir } from "node:os";
17
+ import { join } from "node:path";
18
+
19
+ import { firefoxRoots, parseFirefoxProfiles } from "./cookies.js";
20
+
21
+ /**
22
+ * @param {{ home?: string, usersRoot?: string, platform?: string }} [options]
23
+ * @returns {Array<{ profile: string, path: string }>}
24
+ */
25
+ export function discoverHistoryStores(options = {}) {
26
+ /** @type {Array<{ profile: string, path: string }>} */
27
+ const stores = [];
28
+ for (const root of firefoxRoots(options)) {
29
+ for (const profile of parseFirefoxProfiles(root)) {
30
+ const path = join(profile.dir, "places.sqlite");
31
+ if (!existsSync(path)) continue;
32
+ const platformPrefix = root.includes("Application Support")
33
+ ? "mac"
34
+ : root.endsWith("Firefox")
35
+ ? "windows"
36
+ : "linux";
37
+ stores.push({ profile: `${platformPrefix}:${profile.label}`, path });
38
+ }
39
+ }
40
+ return stores;
41
+ }
42
+
43
+ /**
44
+ * Strip the query string so no token can leak through a URL.
45
+ *
46
+ * @param {string} url
47
+ * @returns {string}
48
+ */
49
+ export function pathOnly(url) {
50
+ try {
51
+ const parsed = new URL(url);
52
+ return parsed.pathname;
53
+ } catch {
54
+ return String(url).split("?")[0];
55
+ }
56
+ }
57
+
58
+ /**
59
+ * Extract workspace ids from a list of URLs, most recent first.
60
+ *
61
+ * @param {string[]} urls
62
+ * @returns {string[]}
63
+ */
64
+ export function extractWorkspaceIds(urls) {
65
+ /** @type {string[]} */
66
+ const ids = [];
67
+ const ignored = new Set(["settings", "usage", "go", "keys", "members", "billing", "new", "login", "auth"]);
68
+ for (const url of urls) {
69
+ const match = pathOnly(url).match(/\/(?:workspace|console)\/([A-Za-z0-9_-]{4,})/);
70
+ if (!match) continue;
71
+ if (ignored.has(match[1])) continue;
72
+ if (!ids.includes(match[1])) ids.push(match[1]);
73
+ }
74
+ return ids;
75
+ }
76
+
77
+ /**
78
+ * Query one history database.
79
+ *
80
+ * @param {string} dbPath
81
+ * @param {{ urlLike: string, limit?: number }} query
82
+ * @returns {string[]}
83
+ */
84
+ const activeHistoryCleanups = new Set();
85
+ if (typeof process !== "undefined" && typeof process.on === "function") {
86
+ const runCleanups = () => {
87
+ for (const fn of activeHistoryCleanups) {
88
+ try { fn(); } catch {}
89
+ }
90
+ activeHistoryCleanups.clear();
91
+ };
92
+ process.once("exit", runCleanups);
93
+ process.once("SIGINT", () => {
94
+ runCleanups();
95
+ process.exit(130);
96
+ });
97
+ process.once("SIGTERM", () => {
98
+ runCleanups();
99
+ process.exit(143);
100
+ });
101
+ }
102
+
103
+ export function readVisitedUrls(dbPath, query) {
104
+ const dir = mkdtempSync(join(tmpdir(), "pi-quota-history-"));
105
+ const target = join(dir, "places.sqlite");
106
+ const cleanupFn = () => {
107
+ activeHistoryCleanups.delete(cleanupFn);
108
+ try {
109
+ rmSync(dir, { recursive: true, force: true });
110
+ } catch {
111
+ // Best effort.
112
+ }
113
+ };
114
+ activeHistoryCleanups.add(cleanupFn);
115
+
116
+ try {
117
+ for (const suffix of ["", "-wal", "-shm"]) {
118
+ const source = `${dbPath}${suffix}`;
119
+ if (existsSync(source)) {
120
+ try {
121
+ copyFileSync(source, `${target}${suffix}`);
122
+ } catch {
123
+ // A locked sidecar is not fatal.
124
+ }
125
+ }
126
+ }
127
+ let database;
128
+ try {
129
+ database = new DatabaseSync(target, { readOnly: true });
130
+ } catch {
131
+ database = new DatabaseSync(target);
132
+ }
133
+ try {
134
+ const rows = database
135
+ .prepare("select url from moz_places where url like ? order by last_visit_date desc limit ?")
136
+ .all(query.urlLike, query.limit ?? 50);
137
+ return /** @type {Array<{ url: string }>} */ (rows).map((row) => row.url).filter(Boolean);
138
+ } finally {
139
+ try {
140
+ database.close();
141
+ } catch {
142
+ // Ignore.
143
+ }
144
+ }
145
+ } catch {
146
+ return [];
147
+ } finally {
148
+ cleanupFn();
149
+ }
150
+ }
151
+
152
+ /**
153
+ * Find recently visited workspace ids, newest first.
154
+ *
155
+ * @param {{
156
+ * stores?: Array<{ profile: string, path: string }>,
157
+ * home?: string,
158
+ * usersRoot?: string,
159
+ * platform?: string,
160
+ * }} [options]
161
+ * @returns {{ ids: string[], profile: string | null, scanned: number }}
162
+ */
163
+ export function findRecentWorkspaceIds(options = {}) {
164
+ const stores = options.stores ?? discoverHistoryStores(options);
165
+ let scanned = 0;
166
+ for (const store of stores) {
167
+ scanned += 1;
168
+ const urls = readVisitedUrls(store.path, { urlLike: "%opencode.ai/%", limit: 100 });
169
+ const ids = extractWorkspaceIds(urls);
170
+ if (ids.length > 0) return { ids, profile: store.profile, scanned };
171
+ }
172
+ return { ids: [], profile: null, scanned };
173
+ }
package/src/cache.js ADDED
@@ -0,0 +1,224 @@
1
+ /**
2
+ * Local response cache.
3
+ *
4
+ * Holds only the already-normalized report (percentages, windows, labels): no
5
+ * access token, refresh token, API key, cookie or account e-mail beyond the
6
+ * display identity the renderers already print.
7
+ */
8
+
9
+ import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
10
+ import { homedir } from "node:os";
11
+ import { dirname, join } from "node:path";
12
+
13
+ export const DEFAULT_TTL_MS = 60_000;
14
+
15
+ /**
16
+ * Merge a partial report into an existing cached report so partial family queries
17
+ * do not clobber other cached providers.
18
+ *
19
+ * @param {import("./engine.js").PiQuotaReport | null | undefined} existingReport
20
+ * @param {import("./engine.js").PiQuotaReport} newReport
21
+ * @returns {import("./engine.js").PiQuotaReport}
22
+ */
23
+ export function mergeReportIntoCache(existingReport, newReport) {
24
+ if (!existingReport || !existingReport.byFamily) return newReport;
25
+ const mergedByFamily = { ...existingReport.byFamily, ...newReport.byFamily };
26
+ const existingFamilies = Object.keys(existingReport.byFamily);
27
+ const newFamilies = Object.keys(newReport.byFamily || {});
28
+ const allFamilies = Array.from(new Set([...existingFamilies, ...newFamilies]));
29
+ const providers = allFamilies.flatMap((family) => mergedByFamily[family] || []);
30
+
31
+ const keptExistingWarnings = (existingReport.warnings || []).filter((w) => {
32
+ const family = String(w).split(":")[0];
33
+ return !newFamilies.includes(family);
34
+ });
35
+ const seenPatterns = new Set();
36
+ const warnings = [];
37
+ for (const w of [...(newReport.warnings || []), ...keptExistingWarnings]) {
38
+ const pattern = w.replace(/\d+m ago/, "Xm ago").replace(/\d+s old/, "Xs old").replace(/\d+s\b/, "Xs");
39
+ if (!seenPatterns.has(pattern)) {
40
+ seenPatterns.add(pattern);
41
+ warnings.push(w);
42
+ }
43
+ }
44
+
45
+ return {
46
+ ...existingReport,
47
+ ...newReport,
48
+ providers,
49
+ byFamily: mergedByFamily,
50
+ warnings,
51
+ };
52
+ }
53
+
54
+ /**
55
+ * @param {{ env?: Record<string, string | undefined>, home?: string, path?: string }} [options]
56
+ * @returns {string}
57
+ */
58
+ export function resolveCachePath(options = {}) {
59
+ if (options.path) return options.path;
60
+ const env = options.env ?? process.env;
61
+ const cacheHome = options.home ? join(options.home, ".cache") : (env.XDG_CACHE_HOME || join(homedir(), ".cache"));
62
+ return join(cacheHome, "pi-quota", "usage.json");
63
+ }
64
+
65
+ /**
66
+ * @param {{
67
+ * path?: string,
68
+ * ttlMs?: number,
69
+ * now?: number,
70
+ * env?: Record<string, string | undefined>,
71
+ * home?: string,
72
+ * }} [options]
73
+ * @returns {{ report: import("./engine.js").PiQuotaReport, ageMs: number, path: string } | null}
74
+ */
75
+ export function readCache(options = {}) {
76
+ const path = options.path ?? resolveCachePath(options);
77
+ const ttlMs = options.ttlMs ?? DEFAULT_TTL_MS;
78
+ const now = options.now ?? Date.now();
79
+
80
+ let raw;
81
+ try {
82
+ raw = readFileSync(path, "utf-8");
83
+ } catch {
84
+ return null;
85
+ }
86
+
87
+ let parsed;
88
+ try {
89
+ parsed = JSON.parse(raw);
90
+ } catch {
91
+ return null;
92
+ }
93
+ if (!parsed || typeof parsed !== "object") return null;
94
+ const report = /** @type {{ report?: import("./engine.js").PiQuotaReport }} */ (parsed).report;
95
+ if (!report || !Array.isArray(report.providers)) return null;
96
+
97
+ const savedAt = typeof /** @type {{ savedAt?: unknown }} */ (parsed).savedAt === "number"
98
+ ? /** @type {number} */ (/** @type {{ savedAt?: number }} */ (parsed).savedAt)
99
+ : null;
100
+ if (savedAt === null) return null;
101
+ const ageMs = now - savedAt;
102
+ if (ageMs > ttlMs) return null;
103
+
104
+ for (const provider of report.providers) {
105
+ for (const win of provider.windows ?? []) {
106
+ if (win.resetsAt) {
107
+ const parsedMs = Date.parse(win.resetsAt);
108
+ if (!Number.isNaN(parsedMs)) {
109
+ win.resetsInSec = Math.max(0, Math.round((parsedMs - now) / 1000));
110
+ }
111
+ }
112
+ }
113
+ }
114
+
115
+ return { report, ageMs, path };
116
+ }
117
+
118
+ /**
119
+ * @param {import("./engine.js").PiQuotaReport} report
120
+ * @param {{
121
+ * path?: string,
122
+ * now?: number,
123
+ * env?: Record<string, string | undefined>,
124
+ * home?: string,
125
+ * }} [options]
126
+ * @returns {{ ok: boolean, path: string, error?: string }}
127
+ */
128
+ export function writeCache(report, options = {}) {
129
+ const path = options.path ?? resolveCachePath(options);
130
+ const now = options.now ?? Date.now();
131
+ let finalReport = report;
132
+ if (options.merge) {
133
+ const existing = readCache({ ...options, path, ttlMs: Number.POSITIVE_INFINITY });
134
+ if (existing?.report) {
135
+ finalReport = mergeReportIntoCache(existing.report, report);
136
+ }
137
+ }
138
+ const payload = JSON.stringify({ savedAt: now, report: finalReport }, null, 2);
139
+
140
+ try {
141
+ mkdirSync(dirname(path), { recursive: true, mode: 0o700 });
142
+ const temporary = `${path}.${process.pid}.tmp`;
143
+ writeFileSync(temporary, payload, { encoding: "utf-8", mode: 0o600 });
144
+ try {
145
+ chmodSync(temporary, 0o600);
146
+ } catch {
147
+ // Best-effort in environments where chmod is not supported.
148
+ }
149
+ renameSync(temporary, path);
150
+ return { ok: true, path };
151
+ } catch (error) {
152
+ return { ok: false, path, error: /** @type {{ message?: string }} */ (error)?.message ?? String(error) };
153
+ }
154
+ }
155
+
156
+ /**
157
+ * @param {{ path?: string, env?: Record<string, string | undefined>, home?: string }} [options]
158
+ * @returns {{ removed: boolean, path: string }}
159
+ */
160
+ export function clearCache(options = {}) {
161
+ const path = options.path ?? resolveCachePath(options);
162
+ try {
163
+ rmSync(path, { force: true });
164
+ return { removed: true, path };
165
+ } catch {
166
+ return { removed: false, path };
167
+ }
168
+ }
169
+
170
+ /**
171
+ * @param {{ path?: string, env?: Record<string, string | undefined>, home?: string }} [options]
172
+ * @returns {{ exists: boolean, path: string, sizeBytes: number | null }}
173
+ */
174
+ export function describeCache(options = {}) {
175
+ const path = options.path ?? resolveCachePath(options);
176
+ try {
177
+ const stats = statSync(path);
178
+ return { exists: true, path, sizeBytes: stats.size };
179
+ } catch {
180
+ return { exists: false, path, sizeBytes: null };
181
+ }
182
+ }
183
+
184
+ /**
185
+ * Read-through helper: return a fresh cached report or refresh it.
186
+ *
187
+ * @param {{
188
+ * ttlMs?: number,
189
+ * now?: number,
190
+ * force?: boolean,
191
+ * path?: string,
192
+ * families?: string[],
193
+ * merge?: boolean,
194
+ * env?: Record<string, string | undefined>,
195
+ * home?: string,
196
+ * }} options
197
+ * @param {() => Promise<import("./engine.js").PiQuotaReport>} loader
198
+ * @returns {Promise<{ report: import("./engine.js").PiQuotaReport, cached: boolean, ageMs: number }>}
199
+ */
200
+ export async function withCache(options, loader) {
201
+ const requested = options.families;
202
+ if (!options.force) {
203
+ const hit = readCache(options);
204
+ if (hit) {
205
+ const allPresent = !requested || requested.every((fam) => fam in hit.report.byFamily);
206
+ if (allPresent) {
207
+ const filteredReport = requested
208
+ ? {
209
+ ...hit.report,
210
+ providers: hit.report.providers.filter((p) => requested.includes(p.family)),
211
+ byFamily: Object.fromEntries(
212
+ Object.entries(hit.report.byFamily).filter(([fam]) => requested.includes(fam)),
213
+ ),
214
+ }
215
+ : hit.report;
216
+ return { report: filteredReport, cached: true, ageMs: hit.ageMs };
217
+ }
218
+ }
219
+ }
220
+ const report = await loader();
221
+ const isPartial = Array.isArray(requested) && requested.length > 0;
222
+ writeCache(report, { ...options, merge: isPartial || options.merge });
223
+ return { report, cached: false, ageMs: 0 };
224
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * Argument parsing shared by both CLIs.
3
+ *
4
+ * Deliberately small: this parser only owns the flags piquota itself
5
+ * implements, and reports anything else as unknown.
6
+ */
7
+
8
+ /**
9
+ * @typedef {Object} ParsedArgs
10
+ * @property {string[]} positionals
11
+ * @property {boolean} json
12
+ * @property {boolean} compact
13
+ * @property {boolean} color
14
+ * @property {boolean} noCache
15
+ * @property {boolean} force
16
+ * @property {boolean} explain
17
+ * @property {boolean} help
18
+ * @property {boolean} version
19
+ * @property {boolean} status
20
+ * @property {boolean} clearCache
21
+ * @property {number} ttlMs
22
+ * @property {number} timeoutMs
23
+ * @property {string[]} unknown Flags this parser does not own.
24
+ * @property {string[]} raw Original argv.
25
+ */
26
+
27
+ const VALUE_FLAGS = new Set(["--ttl", "--timeout"]);
28
+
29
+ /**
30
+ * Flags a subcommand consumes from the raw argv, collected here only so the parser
31
+ * can tell a real typo apart from a flag that belongs to subcommands like `auth`.
32
+ */
33
+ const SUBCOMMAND_VALUE_FLAGS = new Set(["--wait"]);
34
+
35
+ /** Boolean flags owned by a subcommand. */
36
+ const SUBCOMMAND_FLAGS = new Set(["--paste", "--no-browser", "--no-refresh"]);
37
+
38
+ /**
39
+ * What to name in an "unknown flag" report.
40
+ *
41
+ * A bare flag is reported alone when nothing followed it or when what followed was
42
+ * another flag: `--interval --fetch-ttl` must not claim that `--fetch-ttl` was the
43
+ * value of `--interval`.
44
+ *
45
+ * @param {string} flag
46
+ * @param {string | undefined} value
47
+ * @returns {string[]}
48
+ */
49
+ function flagWithValue(flag, value) {
50
+ return value !== undefined && value !== "" && !value.startsWith("-") ? [flag, value] : [flag];
51
+ }
52
+
53
+ /**
54
+ * @param {string[]} argv
55
+ * @param {{ defaultTtlMs?: number, defaultTimeoutMs?: number }} [defaults]
56
+ * @returns {ParsedArgs}
57
+ */
58
+ export function parseArgs(argv, defaults = {}) {
59
+ /** @type {ParsedArgs} */
60
+ const parsed = {
61
+ positionals: [],
62
+ json: false,
63
+ compact: false,
64
+ color: true,
65
+ noCache: false,
66
+ force: false,
67
+ explain: false,
68
+ help: false,
69
+ version: false,
70
+ status: false,
71
+ box: false,
72
+ clearCache: false,
73
+ ttlMs: defaults.defaultTtlMs ?? 60_000,
74
+ timeoutMs: defaults.defaultTimeoutMs ?? 15_000,
75
+ unknown: [],
76
+ raw: [...argv],
77
+ };
78
+
79
+ for (let index = 0; index < argv.length; index += 1) {
80
+ const arg = argv[index];
81
+
82
+ if (VALUE_FLAGS.has(arg)) {
83
+ const value = argv[index + 1];
84
+ index += 1;
85
+ const number = Number(value);
86
+ if (!Number.isFinite(number) || number <= 0) {
87
+ parsed.unknown.push(...flagWithValue(arg, value));
88
+ continue;
89
+ }
90
+ if (arg === "--ttl") parsed.ttlMs = number * 1000;
91
+ if (arg === "--timeout") parsed.timeoutMs = number;
92
+ continue;
93
+ }
94
+
95
+ if (SUBCOMMAND_VALUE_FLAGS.has(arg)) {
96
+ // The value is consumed here only so it cannot be mistaken for a
97
+ // positional; the subcommand reads it from `raw` itself.
98
+ const value = argv[index + 1];
99
+ if (value === undefined || value.startsWith("-") || Number.isNaN(Number(value))) {
100
+ parsed.unknown.push(...flagWithValue(arg, value));
101
+ continue;
102
+ }
103
+ index += 1;
104
+ continue;
105
+ }
106
+
107
+ switch (arg) {
108
+ case "--json":
109
+ parsed.json = true;
110
+ break;
111
+ case "--compact":
112
+ case "-c":
113
+ parsed.compact = true;
114
+ break;
115
+ case "--no-color":
116
+ parsed.color = false;
117
+ break;
118
+ case "--no-cache":
119
+ parsed.noCache = true;
120
+ break;
121
+ case "--force":
122
+ case "--refresh":
123
+ parsed.force = true;
124
+ break;
125
+ case "--explain":
126
+ parsed.explain = true;
127
+ break;
128
+ case "--help":
129
+ case "-h":
130
+ parsed.help = true;
131
+ break;
132
+ case "--version":
133
+ case "-v":
134
+ parsed.version = true;
135
+ break;
136
+ case "--status":
137
+ parsed.status = true;
138
+ break;
139
+ case "--box":
140
+ parsed.box = true;
141
+ break;
142
+ case "--clear-cache":
143
+ parsed.clearCache = true;
144
+ break;
145
+ default:
146
+ // Owned by a subcommand, which reads it from `raw` itself.
147
+ if (SUBCOMMAND_FLAGS.has(arg)) break;
148
+ if (arg.startsWith("-")) parsed.unknown.push(arg);
149
+ else parsed.positionals.push(arg);
150
+ }
151
+ }
152
+
153
+ return parsed;
154
+ }