@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/model.js ADDED
@@ -0,0 +1,332 @@
1
+ /**
2
+ * Shared quota model.
3
+ *
4
+ * Every provider normalizes its own API response into `QuotaWindow[]` so the
5
+ * terminal renderer, the Pi TUI extension and the Moshi adapter consume exactly
6
+ * one shape.
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} QuotaWindow
11
+ * @property {string} id Stable id, e.g. "5h", "weekly", "monthly".
12
+ * @property {string} label Human label, e.g. "5h window".
13
+ * @property {number | null} usedPercent Percent consumed (0-100).
14
+ * @property {number | null} remainingPercent
15
+ * @property {string | null} resetsAt ISO timestamp.
16
+ * @property {number | null} resetsInSec Seconds until reset.
17
+ * @property {number | null} windowSeconds Length of the window when known.
18
+ * @property {string | null} note Provider-specific extra context.
19
+ */
20
+
21
+ /**
22
+ * @typedef {Object} QuotaResult
23
+ * @property {string} family
24
+ * @property {string} label
25
+ * @property {string} account Display identity (email or short id).
26
+ * @property {string | null} [slot] Numbered sibling account ("2"), or null
27
+ * for the base entry of a family.
28
+ * @property {string | null} plan
29
+ * @property {QuotaWindow[]} windows
30
+ * @property {string | null} error Degradation message; null when healthy.
31
+ * @property {boolean} ok
32
+ * @property {string} updatedAt ISO timestamp.
33
+ * @property {string} source Credential source description.
34
+ * @property {"pi" | "claude-code"} [sourceKind] Which kind of store produced it.
35
+ * @property {string | null} expiresInMin Credential freshness, when known.
36
+ */
37
+
38
+ /**
39
+ * Clamp to the inclusive 0-100 percent range.
40
+ *
41
+ * A missing value stays missing: `Number(null)` is 0, and turning "no data" into
42
+ * "0% used" would render as a full bar.
43
+ *
44
+ * @param {unknown} value
45
+ * @returns {number | null}
46
+ */
47
+ export function clampPercent(value) {
48
+ if (typeof value !== "number" && typeof value !== "string") return null;
49
+ if (typeof value === "string" && value.trim() === "") return null;
50
+ const number = Number(value);
51
+ if (!Number.isFinite(number)) return null;
52
+ return Math.min(100, Math.max(0, number));
53
+ }
54
+
55
+ /**
56
+ * @param {unknown} value
57
+ * @returns {number | null}
58
+ */
59
+ export function finiteNumber(value) {
60
+ if (typeof value !== "number" && typeof value !== "string") return null;
61
+ if (typeof value === "string" && value.trim() === "") return null;
62
+ const number = Number(value);
63
+ return Number.isFinite(number) ? number : null;
64
+ }
65
+
66
+ /**
67
+ * @param {unknown} value
68
+ * @returns {string | null}
69
+ */
70
+ export function stringValue(value) {
71
+ return typeof value === "string" && value.trim() !== "" ? value.trim() : null;
72
+ }
73
+
74
+ /**
75
+ * Epoch values below this are seconds, above are milliseconds. See the matching
76
+ * constant in auth/pi-auth.js.
77
+ */
78
+ const SECONDS_EPOCH_CEILING = 1e11;
79
+
80
+ /**
81
+ * Parse a reset value into an ISO timestamp plus a countdown.
82
+ *
83
+ * Accepts ISO strings, epoch seconds/ms numbers and explicit second counts.
84
+ *
85
+ * @param {unknown} value
86
+ * @param {number} now
87
+ * @returns {{ resetsAt: string | null, resetsInSec: number | null }}
88
+ */
89
+ export function parseReset(value, now = Date.now()) {
90
+ const numeric = finiteNumber(value);
91
+ if (numeric !== null) {
92
+ if (numeric > 1e9) {
93
+ const ms = numeric > SECONDS_EPOCH_CEILING ? numeric : numeric * 1000;
94
+ return { resetsAt: new Date(ms).toISOString(), resetsInSec: Math.max(0, Math.round((ms - now) / 1000)) };
95
+ }
96
+ if (numeric >= 0) {
97
+ return parseResetSeconds(numeric, now);
98
+ }
99
+ return { resetsAt: null, resetsInSec: null };
100
+ }
101
+ const text = stringValue(value);
102
+ if (text) {
103
+ if (/^\d+(\.\d+)?$/.test(text)) {
104
+ const num = Number(text);
105
+ if (num >= 0 && num <= 1e9) {
106
+ return parseResetSeconds(num, now);
107
+ }
108
+ }
109
+ const parsed = Date.parse(text);
110
+ if (!Number.isNaN(parsed)) {
111
+ return { resetsAt: new Date(parsed).toISOString(), resetsInSec: Math.max(0, Math.round((parsed - now) / 1000)) };
112
+ }
113
+ }
114
+ return { resetsAt: null, resetsInSec: null };
115
+ }
116
+
117
+ /**
118
+ * Derive a reset timestamp purely from a remaining-seconds count.
119
+ *
120
+ * @param {unknown} seconds
121
+ * @param {number} now
122
+ * @returns {{ resetsAt: string | null, resetsInSec: number | null }}
123
+ */
124
+ export function parseResetSeconds(seconds, now = Date.now()) {
125
+ const numeric = finiteNumber(seconds);
126
+ if (numeric === null || numeric < 0) return { resetsAt: null, resetsInSec: null };
127
+ const rounded = Math.round(numeric);
128
+ return { resetsAt: new Date(now + rounded * 1000).toISOString(), resetsInSec: rounded };
129
+ }
130
+
131
+ /**
132
+ * Build a window from a used-percent value.
133
+ *
134
+ * @param {{
135
+ * id: string,
136
+ * label: string,
137
+ * usedPercent?: unknown,
138
+ * remainingPercent?: unknown,
139
+ * resetsAt?: unknown,
140
+ * resetsInSec?: unknown,
141
+ * windowSeconds?: unknown,
142
+ * note?: string | null,
143
+ * now?: number,
144
+ * }} input
145
+ * @returns {QuotaWindow}
146
+ */
147
+ export function buildWindow(input) {
148
+ const now = input.now ?? Date.now();
149
+ let used = clampPercent(input.usedPercent);
150
+ let remaining = clampPercent(input.remainingPercent);
151
+ if (used === null && remaining !== null) {
152
+ used = 100 - remaining;
153
+ } else if (remaining === null && used !== null) {
154
+ remaining = 100 - used;
155
+ }
156
+
157
+ let resets = { resetsAt: null, resetsInSec: null };
158
+ if (input.resetsAt !== undefined && input.resetsAt !== null) {
159
+ resets = parseReset(input.resetsAt, now);
160
+ } else if (input.resetsInSec !== undefined && input.resetsInSec !== null) {
161
+ resets = parseResetSeconds(input.resetsInSec, now);
162
+ }
163
+
164
+ return {
165
+ id: input.id,
166
+ label: input.label,
167
+ usedPercent: used,
168
+ remainingPercent: remaining,
169
+ resetsAt: resets.resetsAt,
170
+ resetsInSec: resets.resetsInSec,
171
+ windowSeconds: finiteNumber(input.windowSeconds),
172
+ note: input.note ?? null,
173
+ };
174
+ }
175
+
176
+ /**
177
+ * Map a window length in seconds onto a canonical id/label pair.
178
+ *
179
+ * @param {number | null} seconds
180
+ * @returns {{ id: string, label: string }}
181
+ */
182
+ export function windowFromSeconds(seconds) {
183
+ if (seconds === null) return { id: "window", label: "Window" };
184
+ if (seconds <= 6 * 3600) return { id: "5h", label: "5h window" };
185
+ if (seconds <= 8 * 86400) return { id: "weekly", label: "Weekly window" };
186
+ if (seconds <= 31 * 86400) return { id: "monthly", label: "Monthly window" };
187
+ return { id: "window", label: "Window" };
188
+ }
189
+
190
+ /**
191
+ * Map a provider-supplied window name onto a canonical id/label pair.
192
+ *
193
+ * @param {unknown} name
194
+ * @returns {{ id: string, label: string }}
195
+ */
196
+ export function windowFromName(name) {
197
+ const text = (stringValue(name) ?? "").toLowerCase();
198
+ if (text.includes("week")) return { id: "weekly", label: "Weekly window" };
199
+ if (text.includes("month")) return { id: "monthly", label: "Monthly window" };
200
+ if (text.includes("day") && !text.includes("week")) return { id: "daily", label: "Daily window" };
201
+ if (text.includes("5h") || text.includes("five") || text.includes("session") || text.includes("hour")) {
202
+ return { id: "5h", label: "5h window" };
203
+ }
204
+ return { id: text || "window", label: text || "Window" };
205
+ }
206
+
207
+ /**
208
+ * Format a second count as a short human duration, e.g. "3h 12m".
209
+ *
210
+ * @param {number | null} seconds
211
+ * @returns {string}
212
+ */
213
+ export function humanDuration(seconds) {
214
+ if (seconds === null || !Number.isFinite(seconds)) return "unknown";
215
+ if (seconds <= 0) return "now";
216
+ const total = Math.round(seconds);
217
+ const days = Math.floor(total / 86400);
218
+ const hours = Math.floor((total % 86400) / 3600);
219
+ const minutes = Math.floor((total % 3600) / 60);
220
+ if (days > 0) return `${days}d ${hours}h`;
221
+ if (hours > 0) return `${hours}h ${minutes}m`;
222
+ if (minutes > 0) return `${minutes}m`;
223
+ return `${total}s`;
224
+ }
225
+
226
+ /**
227
+ * Format a reset countdown for a UI string.
228
+ *
229
+ * @param {number | null} seconds
230
+ * @returns {string}
231
+ */
232
+ export function humanReset(seconds) {
233
+ if (seconds === null) return "reset unknown";
234
+ if (seconds <= 0) return "resetting now";
235
+ return `reset in ${humanDuration(seconds)}`;
236
+ }
237
+
238
+ /**
239
+ * Build a complete degraded result for one family.
240
+ *
241
+ * @param {{
242
+ * family: string,
243
+ * label: string,
244
+ * account?: string | null,
245
+ * plan?: string | null,
246
+ * error: string,
247
+ * source: string,
248
+ * now?: number,
249
+ * expiresInMin?: number | null,
250
+ * windows?: QuotaWindow[],
251
+ * }} input
252
+ * @returns {QuotaResult}
253
+ */
254
+ export function degradedResult(input) {
255
+ const notConfigured = Boolean(
256
+ input.notConfigured ??
257
+ (typeof input.error === "string" && input.error.startsWith("no ") && input.error.includes("credential in the Pi store")),
258
+ );
259
+ return {
260
+ family: input.family,
261
+ label: input.label,
262
+ account: input.account ?? "unknown",
263
+ plan: input.plan ?? null,
264
+ windows: input.windows ?? [],
265
+ error: input.error,
266
+ ok: false,
267
+ notConfigured,
268
+ updatedAt: new Date(input.now ?? Date.now()).toISOString(),
269
+ source: input.source,
270
+ sourceKind: input.sourceKind,
271
+ expiresInMin: input.expiresInMin ?? null,
272
+ };
273
+ }
274
+
275
+ /**
276
+ * Pick a display identity without leaking full secrets.
277
+ *
278
+ * @param {{ email?: string | null, accountId?: string | null, projectId?: string | null, identity?: string }} credential
279
+ * @returns {string}
280
+ */
281
+ export function displayIdentity(credential) {
282
+ if (credential.email) return credential.email;
283
+ if (credential.accountId) return `${credential.accountId.slice(0, 8)}…`;
284
+ if (credential.projectId) return `project ${credential.projectId.slice(0, 8)}…`;
285
+ return credential.identity ? `id ${credential.identity.slice(0, 8)}…` : "unknown";
286
+ }
287
+
288
+ /**
289
+ * Rank a window by how soon it constrains you: 0 = shortest (5h/session),
290
+ * then daily, weekly, monthly, and finally anything unrecognized.
291
+ *
292
+ * @param {QuotaWindow} window
293
+ * @returns {number}
294
+ */
295
+ export function windowRank(window) {
296
+ const seconds = finiteNumber(window?.windowSeconds);
297
+ if (seconds !== null && seconds > 0) {
298
+ if (seconds <= 6 * 3600) return 0;
299
+ if (seconds <= 36 * 3600) return 1;
300
+ if (seconds <= 8 * 86400) return 2;
301
+ if (seconds <= 31 * 86400) return 3;
302
+ }
303
+ const id = String(window?.id ?? "").toLowerCase();
304
+ if (id.includes("5h") || id.includes("session") || id.includes("hour") || id.includes("rolling")) return 0;
305
+ if (id.includes("daily") || id.includes("day")) return 1;
306
+ if (id.includes("weekly") || id.includes("week") || id.includes("7d")) return 2;
307
+ if (id.includes("monthly") || id.includes("month") || id.includes("30d")) return 3;
308
+ return 4;
309
+ }
310
+
311
+ /**
312
+ * The window that represents a provider at a glance.
313
+ *
314
+ * Shortest first, because that is the limit you hit first: a 5h window at 3%
315
+ * used tells you far more about "can I keep working" than a weekly window at
316
+ * 69%. Ties inside the same rank go to the tightest one, so Antigravity's two
317
+ * 5h groups resolve to whichever is more consumed.
318
+ *
319
+ * @param {QuotaWindow[]} windows
320
+ * @returns {QuotaWindow | null}
321
+ */
322
+ export function selectPrimaryWindow(windows) {
323
+ const list = Array.isArray(windows) ? windows : [];
324
+ const scored = list.filter((window) => window?.remainingPercent !== null && window?.remainingPercent !== undefined);
325
+ if (scored.length === 0) return list[0] ?? null;
326
+ return scored.reduce((best, window) => {
327
+ const bestRank = windowRank(best);
328
+ const rank = windowRank(window);
329
+ if (rank !== bestRank) return rank < bestRank ? window : best;
330
+ return /** @type {number} */ (window.remainingPercent) < /** @type {number} */ (best.remainingPercent) ? window : best;
331
+ });
332
+ }
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 0 24 24" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M12 0C12 6.627 6.627 12 0 12c6.627 0 12 5.373 12 12 0-6.627 5.373-12 12-12-6.627 0-12-5.373-12-12z" fill="#B877DB"/>
3
+ </svg>
@@ -0,0 +1 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" width="256" height="257" preserveAspectRatio="xMidYMid" viewBox="0 0 256 257"><path fill="#D97757" d="m50.228 170.321 50.357-28.257.843-2.463-.843-1.361h-2.462l-8.426-.518-28.775-.778-24.952-1.037-24.175-1.296-6.092-1.297L0 125.796l.583-3.759 5.12-3.434 7.324.648 16.202 1.101 24.304 1.685 17.629 1.037 26.118 2.722h4.148l.583-1.685-1.426-1.037-1.101-1.037-25.147-17.045-27.22-18.017-14.258-10.37-7.713-5.25-3.888-4.925-1.685-10.758 7-7.713 9.397.649 2.398.648 9.527 7.323 20.35 15.75L94.817 91.9l3.889 3.24 1.555-1.102.195-.777-1.75-2.917-14.453-26.118-15.425-26.572-6.87-11.018-1.814-6.61c-.648-2.723-1.102-4.991-1.102-7.778l7.972-10.823L71.42 0 82.05 1.426l4.472 3.888 6.61 15.101 10.694 23.786 16.591 32.34 4.861 9.592 2.592 8.879.973 2.722h1.685v-1.556l1.36-18.211 2.528-22.36 2.463-28.776.843-8.1 4.018-9.722 7.971-5.25 6.222 2.981 5.12 7.324-.713 4.73-3.046 19.768-5.962 30.98-3.889 20.739h2.268l2.593-2.593 10.499-13.934 17.628-22.036 7.778-8.749 9.073-9.657 5.833-4.601h11.018l8.1 12.055-3.628 12.443-11.342 14.388-9.398 12.184-13.48 18.147-8.426 14.518.778 1.166 2.01-.194 30.46-6.481 16.462-2.982 19.637-3.37 8.88 4.148.971 4.213-3.5 8.62-20.998 5.184-24.628 4.926-36.682 8.685-.454.324.519.648 16.526 1.555 7.065.389h17.304l32.21 2.398 8.426 5.574 5.055 6.805-.843 5.184-12.962 6.611-17.498-4.148-40.83-9.721-14-3.5h-1.944v1.167l11.666 11.406 21.387 19.314 26.767 24.887 1.36 6.157-3.434 4.86-3.63-.518-23.526-17.693-9.073-7.972-20.545-17.304h-1.36v1.814l4.73 6.935 25.017 37.59 1.296 11.536-1.814 3.76-6.481 2.268-7.13-1.297-14.647-20.544-15.1-23.138-12.185-20.739-1.49.843-7.194 77.448-3.37 3.953-7.778 2.981-6.48-4.925-3.436-7.972 3.435-15.749 4.148-20.544 3.37-16.333 3.046-20.285 1.815-6.74-.13-.454-1.49.194-15.295 20.999-23.267 31.433-18.406 19.702-4.407 1.75-7.648-3.954.713-7.064 4.277-6.286 25.47-32.405 15.36-20.092 9.917-11.6-.065-1.686h-.583L44.07 198.125l-12.055 1.555-5.185-4.86.648-7.972 2.463-2.593 20.35-13.999-.064.065Z"/></svg>
@@ -0,0 +1 @@
1
+ <svg fill="#fff" fill-rule="evenodd" height="1em" style="flex:none;line-height:1" viewBox="0 0 24 24" width="1em" xmlns="http://www.w3.org/2000/svg"><title>Codex</title><path clip-rule="evenodd" d="M8.086.457a6.105 6.105 0 013.046-.415c1.333.153 2.521.72 3.564 1.7a.117.117 0 00.107.029c1.408-.346 2.762-.224 4.061.366l.063.03.154.076c1.357.703 2.33 1.77 2.918 3.198.278.679.418 1.388.421 2.126a5.655 5.655 0 01-.18 1.631.167.167 0 00.04.155 5.982 5.982 0 011.578 2.891c.385 1.901-.01 3.615-1.183 5.14l-.182.22a6.063 6.063 0 01-2.934 1.851.162.162 0 00-.108.102c-.255.736-.511 1.364-.987 1.992-1.199 1.582-2.962 2.462-4.948 2.451-1.583-.008-2.986-.587-4.21-1.736a.145.145 0 00-.14-.032c-.518.167-1.04.191-1.604.185a5.924 5.924 0 01-2.595-.622 6.058 6.058 0 01-2.146-1.781c-.203-.269-.404-.522-.551-.821a7.74 7.74 0 01-.495-1.283 6.11 6.11 0 01-.017-3.064.166.166 0 00.008-.074.115.115 0 00-.037-.064 5.958 5.958 0 01-1.38-2.202 5.196 5.196 0 01-.333-1.589 6.915 6.915 0 01.188-2.132c.45-1.484 1.309-2.648 2.577-3.493.282-.188.55-.334.802-.438.286-.12.573-.22.861-.304a.129.129 0 00.087-.087A6.016 6.016 0 015.635 2.31C6.315 1.464 7.132.846 8.086.457zm-.804 7.85a.848.848 0 00-1.473.842l1.694 2.965-1.688 2.848a.849.849 0 001.46.864l1.94-3.272a.849.849 0 00.007-.854l-1.94-3.393zm5.446 6.24a.849.849 0 000 1.695h4.848a.849.849 0 000-1.696h-4.848z"/></svg>
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 0 98 96" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
2
+ <path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#2088FF"/>
3
+ </svg>
@@ -0,0 +1,3 @@
1
+ <svg viewBox="0 0 24 24" width="24" height="24" xmlns="http://www.w3.org/2000/svg">
2
+ <path d="M9.4 16.6L4.8 12l4.6-4.6L8 6l-6 6 6 6 1.4-1.4zm5.2 0l4.6-4.6-4.6-4.6L16 6l6 6-6 6-1.4-1.4z" fill="#22D3EE"/>
3
+ </svg>
@@ -0,0 +1,152 @@
1
+ /**
2
+ * Atomic writer for Omarchy agent usage records.
3
+ *
4
+ * This is the one place piQuota writes outside its own cache and state
5
+ * directories, and it is opt-in (`piquota omarchy`). It only ever creates or
6
+ * removes files named `pi-<family>.json` in Omarchy's usage directory, so
7
+ * Omarchy's own `claude.json` / `codex.json` records are never touched.
8
+ */
9
+
10
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
11
+ import { homedir } from "node:os";
12
+ import { join } from "node:path";
13
+ import { fileURLToPath } from "node:url";
14
+
15
+ import { isPiRecordId, RECORD_PREFIX } from "./record.js";
16
+
17
+ /**
18
+ * `PI_QUOTA_OMARCHY_DIR` wins; otherwise Omarchy's own location under
19
+ * `$XDG_STATE_HOME` (default `~/.local/state`).
20
+ *
21
+ * @param {{ env?: Record<string, string | undefined>, home?: string }} [options]
22
+ * @returns {string}
23
+ */
24
+ export function resolveOmarchyDir(options = {}) {
25
+ const env = options.env ?? process.env;
26
+ if (env.PI_QUOTA_OMARCHY_DIR) return env.PI_QUOTA_OMARCHY_DIR;
27
+ const state = env.XDG_STATE_HOME || join(options.home ?? homedir(), ".local", "state");
28
+ return join(state, "omarchy", "agents", "usage");
29
+ }
30
+
31
+ /**
32
+ * @param {string} dir
33
+ * @param {string} id
34
+ * @param {Record<string, unknown>} record
35
+ * @returns {string} The final path.
36
+ */
37
+ function writeRecord(dir, id, record) {
38
+ const target = join(dir, `${id}.json`);
39
+ const content = `${JSON.stringify(record)}\n`;
40
+ if (existsSync(target)) {
41
+ try {
42
+ if (readFileSync(target, "utf-8") === content) {
43
+ return target;
44
+ }
45
+ } catch {}
46
+ }
47
+ // The temp name does not end in `.json`, so Omarchy's `*.json` watcher never
48
+ // sees a half-written file.
49
+ const temp = join(dir, `.${id}.${process.pid}.tmp`);
50
+ try {
51
+ writeFileSync(temp, content, { mode: 0o644 });
52
+ renameSync(temp, target);
53
+ } catch (error) {
54
+ rmSync(temp, { force: true });
55
+ throw error;
56
+ }
57
+ return target;
58
+ }
59
+
60
+ /**
61
+ * Copy a file only if the target does not exist or its content differs.
62
+ * This is critical when writing to directories watched by inotify, such as
63
+ * Omarchy's plugins directory, where redundant writes trigger full shell reloads.
64
+ *
65
+ * @param {string} srcPath
66
+ * @param {string} dstPath
67
+ * @returns {boolean} Whether the file was written.
68
+ */
69
+ function copyIfChanged(srcPath, dstPath) {
70
+ try {
71
+ if (existsSync(dstPath)) {
72
+ const srcBuf = readFileSync(srcPath);
73
+ const dstBuf = readFileSync(dstPath);
74
+ if (srcBuf.equals(dstBuf)) return false;
75
+ }
76
+ copyFileSync(srcPath, dstPath);
77
+ return true;
78
+ } catch {
79
+ return false;
80
+ }
81
+ }
82
+
83
+ /**
84
+ * Synchronize provider SVG icons into Omarchy plugin assets directories if present.
85
+ *
86
+ * @param {{ env?: Record<string, string | undefined>, home?: string }} [options]
87
+ * @returns {string[]} Paths of written assets.
88
+ */
89
+ export function syncOmarchyAssets(options = {}) {
90
+ const env = options.env ?? process.env;
91
+ const home = options.home ?? (env.HOME || homedir());
92
+ const pluginsDir = join(home, ".config", "omarchy", "plugins");
93
+ if (!existsSync(pluginsDir)) return [];
94
+
95
+ const assetsSrc = fileURLToPath(new URL("assets", import.meta.url));
96
+ if (!existsSync(assetsSrc)) return [];
97
+
98
+ const copied = [];
99
+ try {
100
+ for (const plugin of readdirSync(pluginsDir)) {
101
+ const pluginDir = join(pluginsDir, plugin);
102
+ const manifestPath = join(pluginDir, "manifest.json");
103
+ if (!existsSync(manifestPath)) continue;
104
+ const targetAssetsDir = join(pluginDir, "assets");
105
+ if (!existsSync(targetAssetsDir)) {
106
+ mkdirSync(targetAssetsDir, { recursive: true });
107
+ }
108
+
109
+ for (const file of readdirSync(assetsSrc)) {
110
+ if (!file.endsWith(".svg")) continue;
111
+ const srcPath = join(assetsSrc, file);
112
+ const dstPath = join(targetAssetsDir, file);
113
+ const piDstPath = join(targetAssetsDir, `pi-${file}`);
114
+ if (copyIfChanged(srcPath, dstPath)) copied.push(dstPath);
115
+ if (copyIfChanged(srcPath, piDstPath)) copied.push(piDstPath);
116
+ }
117
+ }
118
+ } catch {}
119
+ return copied;
120
+ }
121
+
122
+ /**
123
+ * Write the given records and remove stale `pi-*.json` files whose provider is
124
+ * no longer present.
125
+ *
126
+ * @param {Array<{ id: string, record: Record<string, unknown> }>} records
127
+ * @param {{ dir?: string, env?: Record<string, string | undefined>, home?: string }} [options]
128
+ * @returns {{ dir: string, written: string[], removed: string[] }}
129
+ */
130
+ export function publishRecords(records, options = {}) {
131
+ const dir = options.dir ?? resolveOmarchyDir(options);
132
+ mkdirSync(dir, { recursive: true });
133
+
134
+ const written = [];
135
+ for (const { id, record } of records) {
136
+ if (!isPiRecordId(id)) continue;
137
+ written.push(writeRecord(dir, id, record));
138
+ }
139
+
140
+ const keep = new Set(records.map(({ id }) => `${id}.json`));
141
+ const removed = [];
142
+ for (const name of readdirSync(dir)) {
143
+ if (!name.startsWith(RECORD_PREFIX) || !name.endsWith(".json")) continue;
144
+ if (!isPiRecordId(name.slice(0, -".json".length)) || keep.has(name)) continue;
145
+ rmSync(join(dir, name), { force: true });
146
+ removed.push(join(dir, name));
147
+ }
148
+
149
+ syncOmarchyAssets(options);
150
+
151
+ return { dir, written, removed };
152
+ }