just-usage 0.0.1

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 (4) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +17 -0
  3. package/dist/cli.js +2029 -0
  4. package/package.json +48 -0
package/dist/cli.js ADDED
@@ -0,0 +1,2029 @@
1
+ #!/usr/bin/env node
2
+ import { createRequire } from "node:module";
3
+ var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
+
5
+ // src/cli.ts
6
+ import { parseArgs } from "node:util";
7
+ import { createInterface as createInterface2 } from "node:readline";
8
+
9
+ // src/adapters/codex.ts
10
+ import { spawn } from "node:child_process";
11
+ import { createInterface } from "node:readline";
12
+
13
+ // src/config.ts
14
+ import { homedir } from "node:os";
15
+ import { join } from "node:path";
16
+ import { mkdirSync } from "node:fs";
17
+ // package.json
18
+ var package_default = {
19
+ name: "just-usage",
20
+ version: "0.0.1",
21
+ description: "One local page for your coding-CLI subscription quotas: Claude, Codex, Cursor, OpenCode Go.",
22
+ type: "module",
23
+ license: "MIT",
24
+ author: "spheceo",
25
+ repository: {
26
+ type: "git",
27
+ url: "git+https://github.com/spheceo/just-usage.git"
28
+ },
29
+ homepage: "https://github.com/spheceo/just-usage#readme",
30
+ bugs: "https://github.com/spheceo/just-usage/issues",
31
+ keywords: [
32
+ "claude",
33
+ "codex",
34
+ "cursor",
35
+ "opencode",
36
+ "quota",
37
+ "usage",
38
+ "rate-limit",
39
+ "cli"
40
+ ],
41
+ bin: {
42
+ "just-usage": "dist/cli.js"
43
+ },
44
+ files: [
45
+ "dist",
46
+ "README.md",
47
+ "LICENSE"
48
+ ],
49
+ engines: {
50
+ node: ">=20"
51
+ },
52
+ scripts: {
53
+ dev: "bun run src/cli.ts",
54
+ build: "bun run scripts/build.ts",
55
+ test: "bun test",
56
+ typecheck: "tsc --noEmit",
57
+ release: "bun run scripts/release.ts",
58
+ prepublishOnly: "bun run build"
59
+ },
60
+ devDependencies: {
61
+ "@types/bun": "^1.2.0",
62
+ "@types/node": "^22.0.0",
63
+ typescript: "^5.6.0"
64
+ }
65
+ };
66
+
67
+ // src/config.ts
68
+ var VERSION = package_default.version;
69
+ var PACKAGE_NAME = "just-usage";
70
+ var GITHUB_REPO = "spheceo/just-usage";
71
+ var DEFAULT_PORT = 5757;
72
+ var DEFAULT_HOST = "0.0.0.0";
73
+ var CACHE_TTL_MS = 120000;
74
+ var FETCH_TIMEOUT_MS = 20000;
75
+ function configDir() {
76
+ const override = process.env.JUST_USAGE_HOME;
77
+ if (override)
78
+ return override;
79
+ const xdg = process.env.XDG_CONFIG_HOME;
80
+ return join(xdg && xdg.trim() ? xdg : join(homedir(), ".config"), PACKAGE_NAME);
81
+ }
82
+ function ensureDir(dir) {
83
+ mkdirSync(dir, { recursive: true, mode: 448 });
84
+ return dir;
85
+ }
86
+ var paths = {
87
+ registry: () => join(configDir(), "accounts.json"),
88
+ secrets: () => join(configDir(), "secrets.json"),
89
+ updateCache: () => join(configDir(), "update-check.json"),
90
+ profiles: (provider) => join(configDir(), "profiles", provider)
91
+ };
92
+
93
+ // src/format.ts
94
+ function parseSemver(v) {
95
+ const m = v.trim().replace(/^v/, "").match(/^(\d+)\.(\d+)\.(\d+)(?:-([0-9A-Za-z.-]+))?/);
96
+ if (!m)
97
+ return null;
98
+ return { nums: [Number(m[1]), Number(m[2]), Number(m[3])], pre: m[4] ?? null };
99
+ }
100
+ function semverGt(a, b) {
101
+ const pa = parseSemver(a);
102
+ const pb = parseSemver(b);
103
+ if (!pa || !pb)
104
+ return false;
105
+ for (let i = 0;i < 3; i++) {
106
+ const x = pa.nums[i] ?? 0;
107
+ const y = pb.nums[i] ?? 0;
108
+ if (x !== y)
109
+ return x > y;
110
+ }
111
+ if (pa.pre === pb.pre)
112
+ return false;
113
+ if (pa.pre === null)
114
+ return true;
115
+ if (pb.pre === null)
116
+ return false;
117
+ return pa.pre > pb.pre;
118
+ }
119
+ function epochToIso(v) {
120
+ if (typeof v === "string" && /^\d+$/.test(v))
121
+ v = Number(v);
122
+ if (typeof v !== "number" || !Number.isFinite(v) || v <= 0)
123
+ return null;
124
+ const ms = v < 1000000000000 ? v * 1000 : v;
125
+ return new Date(ms).toISOString();
126
+ }
127
+ function isoOrNull(v) {
128
+ if (typeof v !== "string" || !v.trim())
129
+ return null;
130
+ const t = Date.parse(v);
131
+ return Number.isFinite(t) ? new Date(t).toISOString() : null;
132
+ }
133
+ function clampPercent(v) {
134
+ if (typeof v === "string" && v.trim() !== "")
135
+ v = Number(v);
136
+ if (typeof v !== "number" || !Number.isFinite(v))
137
+ return null;
138
+ return Math.min(100, Math.max(0, Math.round(v * 10) / 10));
139
+ }
140
+ function windowLabel(minutes) {
141
+ if (minutes === null)
142
+ return "Window";
143
+ if (minutes === 300)
144
+ return "5h";
145
+ if (minutes === 10080)
146
+ return "Weekly";
147
+ if (minutes % 1440 === 0)
148
+ return `${minutes / 1440}d`;
149
+ if (minutes % 60 === 0)
150
+ return `${minutes / 60}h`;
151
+ return `${minutes}m`;
152
+ }
153
+ function formatDuration(ms) {
154
+ if (ms <= 0)
155
+ return "now";
156
+ const totalMin = Math.round(ms / 60000);
157
+ const d = Math.floor(totalMin / 1440);
158
+ const h = Math.floor(totalMin % 1440 / 60);
159
+ const m = totalMin % 60;
160
+ if (d > 0)
161
+ return h > 0 ? `${d}d ${h}h` : `${d}d`;
162
+ if (h > 0)
163
+ return m > 0 ? `${h}h ${m}m` : `${h}h`;
164
+ return `${Math.max(1, m)}m`;
165
+ }
166
+ function formatResetIn(resetsAt, now = Date.now(), kind = "rolling") {
167
+ if (!resetsAt)
168
+ return null;
169
+ const t = Date.parse(resetsAt);
170
+ if (!Number.isFinite(t))
171
+ return null;
172
+ const diff = t - now;
173
+ if (kind === "cycle")
174
+ return diff <= 0 ? "renewing" : `renews in ${formatDuration(diff)}`;
175
+ return diff <= 0 ? "resetting" : `resets in ${formatDuration(diff)}`;
176
+ }
177
+ function severity(usedPercent) {
178
+ if (usedPercent === null)
179
+ return null;
180
+ if (usedPercent >= 85)
181
+ return "crit";
182
+ if (usedPercent >= 60)
183
+ return "warn";
184
+ return "ok";
185
+ }
186
+ function slugify(s) {
187
+ return s.toLowerCase().replace(/@.*$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "account";
188
+ }
189
+
190
+ // src/adapters/common.ts
191
+ function snapshot(account, status, patch = {}) {
192
+ const { email, plan, ...rest } = patch;
193
+ return {
194
+ account: {
195
+ id: account.id,
196
+ provider: account.provider,
197
+ label: account.label,
198
+ email: email ?? account.email ?? null,
199
+ plan: plan ?? null,
200
+ kind: account.kind
201
+ },
202
+ status,
203
+ message: null,
204
+ windows: [],
205
+ resetCredits: null,
206
+ fetchedAt: new Date().toISOString(),
207
+ ...rest
208
+ };
209
+ }
210
+ function errorMessage(e) {
211
+ if (e instanceof Error)
212
+ return e.message;
213
+ return String(e);
214
+ }
215
+ function isObject(v) {
216
+ return typeof v === "object" && v !== null && !Array.isArray(v);
217
+ }
218
+ async function fetchJson(url, init = {}) {
219
+ const { timeoutMs = 15000, ...rest } = init;
220
+ const res = await fetch(url, { ...rest, signal: AbortSignal.timeout(timeoutMs) });
221
+ const text = await res.text();
222
+ let body = null;
223
+ try {
224
+ body = text ? JSON.parse(text) : null;
225
+ } catch {
226
+ body = null;
227
+ }
228
+ return { status: res.status, body, text };
229
+ }
230
+
231
+ // src/adapters/codex.ts
232
+ class AppServerClient {
233
+ proc;
234
+ nextId = 1;
235
+ pending = new Map;
236
+ listeners = new Set;
237
+ exited = false;
238
+ constructor(proc) {
239
+ this.proc = proc;
240
+ createInterface({ input: proc.stdout }).on("line", (line) => this.onLine(line));
241
+ proc.stderr.on("data", () => {});
242
+ proc.on("exit", () => {
243
+ this.exited = true;
244
+ for (const p of this.pending.values()) {
245
+ clearTimeout(p.timer);
246
+ p.reject(new Error("codex app-server exited"));
247
+ }
248
+ this.pending.clear();
249
+ });
250
+ }
251
+ static async start(codexHome) {
252
+ const env = { ...process.env };
253
+ if (codexHome)
254
+ env.CODEX_HOME = codexHome;
255
+ const proc = spawn("codex", ["app-server"], { env, stdio: ["pipe", "pipe", "pipe"] });
256
+ await new Promise((resolve, reject) => {
257
+ proc.once("spawn", () => resolve());
258
+ proc.once("error", (e) => reject(new Error(`could not start codex app-server: ${e.message}`)));
259
+ });
260
+ const client = new AppServerClient(proc);
261
+ await client.request("initialize", {
262
+ clientInfo: { name: "just-usage", title: "just-usage", version: VERSION },
263
+ capabilities: {}
264
+ });
265
+ client.notify("initialized", {});
266
+ return client;
267
+ }
268
+ onLine(line) {
269
+ let msg;
270
+ try {
271
+ msg = JSON.parse(line);
272
+ } catch {
273
+ return;
274
+ }
275
+ if (typeof msg.id === "number" && (("result" in msg) || ("error" in msg))) {
276
+ const p = this.pending.get(msg.id);
277
+ if (!p)
278
+ return;
279
+ this.pending.delete(msg.id);
280
+ clearTimeout(p.timer);
281
+ if (msg.error) {
282
+ const err = msg.error;
283
+ p.reject(new Error(String(err.message ?? "app-server error")));
284
+ } else {
285
+ p.resolve(msg.result);
286
+ }
287
+ return;
288
+ }
289
+ if (typeof msg.method === "string" && !("id" in msg)) {
290
+ for (const l of this.listeners)
291
+ l({ method: msg.method, params: msg.params });
292
+ }
293
+ }
294
+ request(method, params, timeoutMs = 20000) {
295
+ if (this.exited)
296
+ return Promise.reject(new Error("codex app-server exited"));
297
+ const id = this.nextId++;
298
+ return new Promise((resolve, reject) => {
299
+ const timer = setTimeout(() => {
300
+ this.pending.delete(id);
301
+ reject(new Error(`${method} timed out`));
302
+ }, timeoutMs);
303
+ this.pending.set(id, { resolve, reject, timer });
304
+ this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", id, method, params }) + `
305
+ `);
306
+ });
307
+ }
308
+ notify(method, params) {
309
+ this.proc.stdin.write(JSON.stringify({ jsonrpc: "2.0", method, params }) + `
310
+ `);
311
+ }
312
+ onNotification(cb) {
313
+ this.listeners.add(cb);
314
+ return () => this.listeners.delete(cb);
315
+ }
316
+ close() {
317
+ if (!this.exited)
318
+ this.proc.kill();
319
+ }
320
+ }
321
+ function windowsFromLimit(limit, prefix, labelPrefix) {
322
+ const out = [];
323
+ for (const key of ["primary", "secondary"]) {
324
+ const w = limit[key];
325
+ if (!isObject(w))
326
+ continue;
327
+ const minutes = typeof w.windowDurationMins === "number" ? w.windowDurationMins : null;
328
+ out.push({
329
+ id: `${prefix}${key}`,
330
+ label: `${labelPrefix}${windowLabel(minutes)}`,
331
+ usedPercent: clampPercent(w.usedPercent),
332
+ resetsAt: epochToIso(w.resetsAt),
333
+ windowMinutes: minutes,
334
+ kind: "rolling"
335
+ });
336
+ }
337
+ return out;
338
+ }
339
+ function normalizeCodexRateLimits(result) {
340
+ const empty = { windows: [], plan: null, resetCredits: null };
341
+ if (!isObject(result))
342
+ return empty;
343
+ const byId = isObject(result.rateLimitsByLimitId) ? result.rateLimitsByLimitId : null;
344
+ const limits = byId ? Object.values(byId).filter(isObject) : isObject(result.rateLimits) ? [result.rateLimits] : [];
345
+ const windows = [];
346
+ let plan = null;
347
+ const multi = limits.length > 1;
348
+ for (const limit of limits) {
349
+ const limitId = typeof limit.limitId === "string" ? limit.limitId : "codex";
350
+ const limitName = typeof limit.limitName === "string" && limit.limitName ? limit.limitName : null;
351
+ const labelPrefix = multi && limitName ? `${limitName} · ` : multi && limitId !== "codex" ? `${limitId} · ` : "";
352
+ windows.push(...windowsFromLimit(limit, `${limitId}:`, labelPrefix));
353
+ if (!plan && typeof limit.planType === "string")
354
+ plan = limit.planType;
355
+ }
356
+ windows.sort((a, b) => Number(!a.id.startsWith("codex:")) - Number(!b.id.startsWith("codex:")));
357
+ let resetCredits = null;
358
+ const rc = result.rateLimitResetCredits;
359
+ if (isObject(rc)) {
360
+ const credits = Array.isArray(rc.credits) ? rc.credits.filter(isObject).map((c) => ({
361
+ id: String(c.id ?? ""),
362
+ title: typeof c.title === "string" ? c.title : null,
363
+ status: typeof c.status === "string" ? c.status : null,
364
+ expiresAt: epochToIso(c.expiresAt)
365
+ })) : [];
366
+ const availableCount = typeof rc.availableCount === "number" ? rc.availableCount : credits.filter((c) => c.status === "available").length;
367
+ resetCredits = { availableCount, credits };
368
+ }
369
+ return { windows, plan, resetCredits };
370
+ }
371
+ async function fetchCodex(account) {
372
+ let client = null;
373
+ try {
374
+ client = await AppServerClient.start(account.path);
375
+ const acct = await client.request("account/read", { refreshToken: false });
376
+ const info = isObject(acct.account) ? acct.account : null;
377
+ if (!info) {
378
+ return snapshot(account, "signed_out", {
379
+ message: account.kind === "default" ? "Not signed in. Run `codex login`." : `Not signed in. Run \`just-usage login ${account.id}\`.`
380
+ });
381
+ }
382
+ const email = typeof info.email === "string" ? info.email : null;
383
+ const planFromAccount = typeof info.planType === "string" ? info.planType : null;
384
+ if (info.type === "apiKey") {
385
+ return snapshot(account, "unsupported", { email, plan: "api key", message: "API-key logins have no subscription windows." });
386
+ }
387
+ const raw = await client.request("account/rateLimits/read", {});
388
+ const norm = normalizeCodexRateLimits(raw);
389
+ return snapshot(account, "ok", {
390
+ email,
391
+ plan: norm.plan ?? planFromAccount,
392
+ windows: norm.windows,
393
+ resetCredits: norm.resetCredits
394
+ });
395
+ } catch (e) {
396
+ return snapshot(account, "error", { message: errorMessage(e) });
397
+ } finally {
398
+ client?.close();
399
+ }
400
+ }
401
+ async function codexLogin(codexHome, onAuthUrl, timeoutMs = 5 * 60000) {
402
+ const client = await AppServerClient.start(codexHome);
403
+ try {
404
+ const completed = new Promise((resolve, reject) => {
405
+ const timer = setTimeout(() => reject(new Error("login timed out")), timeoutMs);
406
+ client.onNotification((n) => {
407
+ if (n.method !== "account/login/completed")
408
+ return;
409
+ clearTimeout(timer);
410
+ const p = isObject(n.params) ? n.params : {};
411
+ if (p.success)
412
+ resolve();
413
+ else
414
+ reject(new Error(typeof p.error === "string" ? p.error : "login failed"));
415
+ });
416
+ });
417
+ const start = await client.request("account/login/start", { type: "chatgpt" });
418
+ if (typeof start.authUrl === "string")
419
+ onAuthUrl(start.authUrl);
420
+ await completed;
421
+ const acct = await client.request("account/read", {});
422
+ const info = isObject(acct.account) ? acct.account : {};
423
+ return {
424
+ email: typeof info.email === "string" ? info.email : null,
425
+ plan: typeof info.planType === "string" ? info.planType : null
426
+ };
427
+ } finally {
428
+ client.close();
429
+ }
430
+ }
431
+
432
+ // src/adapters/claude.ts
433
+ import { createHash } from "node:crypto";
434
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
435
+ import { homedir as homedir2 } from "node:os";
436
+ import { join as join2 } from "node:path";
437
+
438
+ // src/proc.ts
439
+ import { spawn as spawn2 } from "node:child_process";
440
+ function run(cmd, args, opts = {}) {
441
+ return new Promise((resolve) => {
442
+ let stdout = "";
443
+ let stderr = "";
444
+ let settled = false;
445
+ const finish = (code) => {
446
+ if (settled)
447
+ return;
448
+ settled = true;
449
+ clearTimeout(timer);
450
+ resolve({ code, stdout, stderr });
451
+ };
452
+ const child = spawn2(cmd, args, {
453
+ env: { ...process.env, ...opts.env },
454
+ stdio: ["pipe", "pipe", "pipe"]
455
+ });
456
+ const timer = setTimeout(() => {
457
+ child.kill("SIGKILL");
458
+ stderr += `
459
+ [just-usage] timed out`;
460
+ finish(null);
461
+ }, opts.timeoutMs ?? 15000);
462
+ child.stdout.on("data", (d) => stdout += d);
463
+ child.stderr.on("data", (d) => stderr += d);
464
+ child.on("error", (err) => {
465
+ stderr += String(err);
466
+ finish(null);
467
+ });
468
+ child.on("close", (code) => finish(code));
469
+ if (opts.input !== undefined)
470
+ child.stdin.write(opts.input);
471
+ child.stdin.end();
472
+ });
473
+ }
474
+ function runInteractive(cmd, args, env) {
475
+ return new Promise((resolve, reject) => {
476
+ const child = spawn2(cmd, args, { env: { ...process.env, ...env }, stdio: "inherit" });
477
+ child.on("error", reject);
478
+ child.on("close", (code) => resolve(code));
479
+ });
480
+ }
481
+ var whichCache = new Map;
482
+ function which(bin) {
483
+ let p = whichCache.get(bin);
484
+ if (!p) {
485
+ p = (async () => {
486
+ const finder = process.platform === "win32" ? "where" : "which";
487
+ const res = await run(finder, [bin], { timeoutMs: 5000 });
488
+ if (res.code !== 0)
489
+ return null;
490
+ const first = res.stdout.split(/\r?\n/).map((s) => s.trim()).find(Boolean);
491
+ return first ?? null;
492
+ })();
493
+ whichCache.set(bin, p);
494
+ }
495
+ return p;
496
+ }
497
+ var versionCache = new Map;
498
+ function binVersion(bin) {
499
+ let p = versionCache.get(bin);
500
+ if (!p) {
501
+ p = (async () => {
502
+ const res = await run(bin, ["--version"], { timeoutMs: 8000 });
503
+ const text = `${res.stdout}
504
+ ${res.stderr}`;
505
+ const m = text.match(/\d+\.\d+\.\d+(?:[-.][0-9A-Za-z.-]+)?/);
506
+ return m ? m[0] : null;
507
+ })();
508
+ versionCache.set(bin, p);
509
+ }
510
+ return p;
511
+ }
512
+ function openInBrowser(url) {
513
+ const [cmd, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", "", url]] : ["xdg-open", [url]];
514
+ try {
515
+ const child = spawn2(cmd, args, { stdio: "ignore", detached: true });
516
+ child.on("error", () => {});
517
+ child.unref();
518
+ } catch {}
519
+ }
520
+ function stripAnsi(s) {
521
+ return s.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
522
+ }
523
+ function withTimeout(p, ms, label) {
524
+ return new Promise((resolve, reject) => {
525
+ const t = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`)), ms);
526
+ p.then((v) => {
527
+ clearTimeout(t);
528
+ resolve(v);
529
+ }, (e) => {
530
+ clearTimeout(t);
531
+ reject(e);
532
+ });
533
+ });
534
+ }
535
+
536
+ // src/secrets.ts
537
+ import { existsSync, readFileSync, writeFileSync, chmodSync } from "node:fs";
538
+ import { dirname } from "node:path";
539
+ var SERVICE = "just-usage";
540
+
541
+ class KeychainStore {
542
+ kind = "keychain";
543
+ async get(id) {
544
+ const res = await run("security", ["find-generic-password", "-a", id, "-s", SERVICE, "-w"], { timeoutMs: 1e4 });
545
+ if (res.code !== 0)
546
+ return null;
547
+ const v = res.stdout.replace(/\r?\n$/, "");
548
+ return v || null;
549
+ }
550
+ async set(id, value) {
551
+ const res = await run("security", ["add-generic-password", "-U", "-a", id, "-s", SERVICE, "-w", value], { timeoutMs: 1e4 });
552
+ if (res.code !== 0)
553
+ throw new Error(`keychain write failed: ${res.stderr.trim() || res.code}`);
554
+ }
555
+ async delete(id) {
556
+ await run("security", ["delete-generic-password", "-a", id, "-s", SERVICE], { timeoutMs: 1e4 });
557
+ }
558
+ }
559
+
560
+ class FileStore {
561
+ kind = "file";
562
+ read() {
563
+ const file = paths.secrets();
564
+ if (!existsSync(file))
565
+ return {};
566
+ try {
567
+ return JSON.parse(readFileSync(file, "utf8"));
568
+ } catch {
569
+ return {};
570
+ }
571
+ }
572
+ write(data) {
573
+ const file = paths.secrets();
574
+ ensureDir(dirname(file));
575
+ writeFileSync(file, JSON.stringify(data, null, 2) + `
576
+ `, { mode: 384 });
577
+ chmodSync(file, 384);
578
+ }
579
+ async get(id) {
580
+ return this.read()[id] ?? null;
581
+ }
582
+ async set(id, value) {
583
+ const data = this.read();
584
+ data[id] = value;
585
+ this.write(data);
586
+ }
587
+ async delete(id) {
588
+ const data = this.read();
589
+ delete data[id];
590
+ this.write(data);
591
+ }
592
+ }
593
+ var store = null;
594
+ function secretStore() {
595
+ if (!store) {
596
+ const forced = process.env.JUST_USAGE_SECRET_STORE;
597
+ store = process.platform === "darwin" && forced !== "file" ? new KeychainStore : new FileStore;
598
+ }
599
+ return store;
600
+ }
601
+
602
+ // src/adapters/claude.ts
603
+ var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
604
+ var PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
605
+ var FALLBACK_CLI_VERSION = "2.1.259";
606
+ function keychainService(configDir2) {
607
+ if (!configDir2)
608
+ return "Claude Code-credentials";
609
+ const hash = createHash("sha256").update(configDir2).digest("hex").slice(0, 8);
610
+ return `Claude Code-credentials-${hash}`;
611
+ }
612
+ function parseCreds(text) {
613
+ try {
614
+ const parsed = JSON.parse(text);
615
+ const o = isObject(parsed.claudeAiOauth) ? parsed.claudeAiOauth : null;
616
+ if (!o || typeof o.accessToken !== "string" || !o.accessToken)
617
+ return null;
618
+ return {
619
+ accessToken: o.accessToken,
620
+ expiresAt: typeof o.expiresAt === "number" ? o.expiresAt : null,
621
+ subscriptionType: typeof o.subscriptionType === "string" ? o.subscriptionType : null
622
+ };
623
+ } catch {
624
+ return null;
625
+ }
626
+ }
627
+ async function readClaudeCredentials(configDir2) {
628
+ if (process.platform === "darwin") {
629
+ const res = await run("security", ["find-generic-password", "-s", keychainService(configDir2), "-w"], { timeoutMs: 20000 });
630
+ if (res.code === 0) {
631
+ const creds = parseCreds(res.stdout.trim());
632
+ if (creds)
633
+ return creds;
634
+ }
635
+ }
636
+ const file = join2(configDir2 ?? join2(homedir2(), ".claude"), ".credentials.json");
637
+ if (existsSync2(file))
638
+ return parseCreds(readFileSync2(file, "utf8"));
639
+ return null;
640
+ }
641
+ async function claudeAuthStatus(configDir2) {
642
+ const env = {};
643
+ if (configDir2)
644
+ env.CLAUDE_CONFIG_DIR = configDir2;
645
+ const res = await run("claude", ["auth", "status", "--json"], { env, timeoutMs: 15000 });
646
+ if (res.code === null)
647
+ return null;
648
+ const m = res.stdout.match(/\{[\s\S]*\}/);
649
+ if (!m)
650
+ return null;
651
+ try {
652
+ const parsed = JSON.parse(m[0]);
653
+ return { loggedIn: parsed.loggedIn === true };
654
+ } catch {
655
+ return null;
656
+ }
657
+ }
658
+ async function userAgent() {
659
+ const v = await binVersion("claude") ?? FALLBACK_CLI_VERSION;
660
+ return `claude-code/${v}`;
661
+ }
662
+ function headers(token, ua) {
663
+ return {
664
+ Authorization: `Bearer ${token}`,
665
+ "anthropic-beta": "oauth-2025-04-20",
666
+ "User-Agent": ua,
667
+ Accept: "application/json, text/plain, */*",
668
+ "Content-Type": "application/json"
669
+ };
670
+ }
671
+ function bucket(v, id, label, minutes, kind = "rolling") {
672
+ if (!isObject(v))
673
+ return null;
674
+ const used = clampPercent(v.utilization);
675
+ if (used === null)
676
+ return null;
677
+ return { id, label, usedPercent: used, resetsAt: isoOrNull(v.resets_at), windowMinutes: minutes, kind };
678
+ }
679
+ function normalizeClaudeUsage(body) {
680
+ if (!isObject(body))
681
+ return [];
682
+ const out = [];
683
+ const push = (w) => w && out.push(w);
684
+ push(bucket(body.five_hour, "five_hour", "5h", 300));
685
+ push(bucket(body.seven_day, "seven_day", "Weekly", 10080));
686
+ push(bucket(body.seven_day_opus, "seven_day_opus", "Weekly · Opus", 10080));
687
+ push(bucket(body.seven_day_sonnet, "seven_day_sonnet", "Weekly · Sonnet", 10080));
688
+ push(bucket(body.seven_day_oauth_apps, "seven_day_oauth_apps", "Weekly · OAuth apps", 10080));
689
+ if (out.length === 0 && Array.isArray(body.limits)) {
690
+ for (const [i, item] of body.limits.entries()) {
691
+ if (!isObject(item))
692
+ continue;
693
+ const name = [item.name, item.type, item.id].find((x) => typeof x === "string" && x.length > 0) ?? `limit ${i + 1}`;
694
+ push(bucket(item, `limits:${name}`, name.replace(/_/g, " "), null));
695
+ }
696
+ }
697
+ const extra = body.extra_usage;
698
+ if (isObject(extra) && extra.is_enabled === true) {
699
+ const used = clampPercent(extra.utilization);
700
+ if (used !== null) {
701
+ const limit = typeof extra.monthly_limit === "number" ? extra.monthly_limit : null;
702
+ const spent = typeof extra.used_credits === "number" ? extra.used_credits : null;
703
+ out.push({
704
+ id: "extra_usage",
705
+ label: "Extra usage",
706
+ usedPercent: used,
707
+ resetsAt: null,
708
+ windowMinutes: null,
709
+ kind: "cycle",
710
+ note: limit !== null && spent !== null ? `${spent.toFixed(2)} of ${limit.toFixed(0)} credits` : undefined
711
+ });
712
+ }
713
+ }
714
+ return out;
715
+ }
716
+ async function resolveToken(account) {
717
+ if (account.kind === "token") {
718
+ const token = await secretStore().get(account.id);
719
+ if (!token)
720
+ return { fail: snapshot(account, "error", { message: "Stored token missing. Run `just-usage remove` and add it again." }) };
721
+ return { token, plan: null };
722
+ }
723
+ const status = await claudeAuthStatus(account.path);
724
+ if (status && !status.loggedIn) {
725
+ const hint = account.kind === "default" ? "Run `claude` and `/login`." : `Run \`just-usage login ${account.id}\`.`;
726
+ return { fail: snapshot(account, "signed_out", { message: `Not signed in. ${hint}` }) };
727
+ }
728
+ const creds = await readClaudeCredentials(account.path);
729
+ if (!creds) {
730
+ return { fail: snapshot(account, "error", { message: "Could not read Claude Code's credential (Keychain access denied or file missing)." }) };
731
+ }
732
+ if (creds.expiresAt && creds.expiresAt < Date.now()) {
733
+ const hint = account.kind === "default" ? "Open `claude` once to refresh it." : `Run \`CLAUDE_CONFIG_DIR=${account.path} claude\` once to refresh it.`;
734
+ return { fail: snapshot(account, "error", { plan: creds.subscriptionType, message: `Access token expired. ${hint}` }) };
735
+ }
736
+ return { token: creds.accessToken, plan: creds.subscriptionType };
737
+ }
738
+ async function fetchClaude(account) {
739
+ try {
740
+ const resolved = await resolveToken(account);
741
+ if ("fail" in resolved)
742
+ return resolved.fail;
743
+ const ua = await userAgent();
744
+ const h = headers(resolved.token, ua);
745
+ const [usage, profile] = await Promise.all([
746
+ fetchJson(USAGE_URL, { headers: h }),
747
+ account.email ? Promise.resolve(null) : fetchJson(PROFILE_URL, { headers: h }).catch(() => null)
748
+ ]);
749
+ let email = account.email ?? null;
750
+ if (profile && profile.status === 200 && isObject(profile.body) && isObject(profile.body.account)) {
751
+ const e = profile.body.account.email;
752
+ if (typeof e === "string")
753
+ email = e;
754
+ }
755
+ if (usage.status === 401) {
756
+ return snapshot(account, "error", { email, plan: resolved.plan, message: "Token rejected (401). Sign in again." });
757
+ }
758
+ if (usage.status === 429) {
759
+ return snapshot(account, "error", { email, plan: resolved.plan, message: "Rate limited by Anthropic (429). Try again in a minute." });
760
+ }
761
+ if (usage.status !== 200) {
762
+ return snapshot(account, "error", { email, plan: resolved.plan, message: `Usage endpoint returned HTTP ${usage.status}.` });
763
+ }
764
+ const windows = normalizeClaudeUsage(usage.body);
765
+ if (windows.length === 0) {
766
+ return snapshot(account, "unsupported", { email, plan: resolved.plan, message: "Usage response had no recognizable windows (schema may have changed)." });
767
+ }
768
+ return snapshot(account, "ok", { email, plan: resolved.plan, windows });
769
+ } catch (e) {
770
+ return snapshot(account, "error", { message: errorMessage(e) });
771
+ }
772
+ }
773
+ async function verifyClaudeToken(token) {
774
+ const h = headers(token, await userAgent());
775
+ const res = await fetchJson(USAGE_URL, { headers: h });
776
+ if (res.status === 401)
777
+ return { ok: false, email: null, message: "Token rejected (401)." };
778
+ if (res.status !== 200)
779
+ return { ok: false, email: null, message: `HTTP ${res.status} from usage endpoint.` };
780
+ let email = null;
781
+ const profile = await fetchJson(PROFILE_URL, { headers: h }).catch(() => null);
782
+ if (profile && profile.status === 200 && isObject(profile.body) && isObject(profile.body.account) && typeof profile.body.account.email === "string") {
783
+ email = profile.body.account.email;
784
+ }
785
+ return { ok: true, email, message: "ok" };
786
+ }
787
+
788
+ // src/adapters/opencode.ts
789
+ import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
790
+ import { homedir as homedir3 } from "node:os";
791
+ import { join as join3 } from "node:path";
792
+ var USAGE_URL2 = "https://opencode.ai/zen/go/v1/usage";
793
+ function openCodeAuthFile() {
794
+ if (process.env.OPENCODE_AUTH_FILE)
795
+ return process.env.OPENCODE_AUTH_FILE;
796
+ const xdg = process.env.XDG_DATA_HOME;
797
+ const base = xdg && xdg.trim() ? xdg : join3(homedir3(), ".local", "share");
798
+ return join3(base, "opencode", "auth.json");
799
+ }
800
+ function readOpenCodeGoKey() {
801
+ const file = openCodeAuthFile();
802
+ if (!existsSync3(file))
803
+ return null;
804
+ try {
805
+ const parsed = JSON.parse(readFileSync3(file, "utf8"));
806
+ const entry = parsed["opencode-go"];
807
+ if (isObject(entry) && typeof entry.key === "string" && entry.key)
808
+ return entry.key;
809
+ return null;
810
+ } catch {
811
+ return null;
812
+ }
813
+ }
814
+ var WINDOWS = [
815
+ { key: "rolling", id: "rolling", label: "5h", minutes: 300, kind: "rolling" },
816
+ { key: "weekly", id: "weekly", label: "Weekly", minutes: 10080, kind: "rolling" },
817
+ { key: "monthly", id: "monthly", label: "Monthly", minutes: null, kind: "cycle" }
818
+ ];
819
+ function normalizeOpenCodeUsage(body) {
820
+ if (!isObject(body))
821
+ return [];
822
+ const usage = isObject(body.usage) ? body.usage : body;
823
+ const out = [];
824
+ for (const def of WINDOWS) {
825
+ const w = usage[def.key];
826
+ if (!isObject(w))
827
+ continue;
828
+ let used = clampPercent(w.percent);
829
+ if (w.status === "rate-limited")
830
+ used = 100;
831
+ if (used === null)
832
+ continue;
833
+ out.push({ id: def.id, label: def.label, usedPercent: used, resetsAt: isoOrNull(w.resetsAt), windowMinutes: def.minutes, kind: def.kind });
834
+ }
835
+ return out;
836
+ }
837
+ async function fetchOpenCodeUsage(key) {
838
+ const res = await fetchJson(USAGE_URL2, { headers: { Authorization: `Bearer ${key}`, Accept: "application/json" } });
839
+ return { status: res.status, windows: res.status === 200 ? normalizeOpenCodeUsage(res.body) : [] };
840
+ }
841
+ async function fetchOpenCode(account) {
842
+ try {
843
+ const key = account.kind === "token" ? await secretStore().get(account.id) : readOpenCodeGoKey();
844
+ if (!key) {
845
+ return account.kind === "token" ? snapshot(account, "error", { message: "Stored key missing. Remove and re-add this account." }) : snapshot(account, "signed_out", { message: "No OpenCode Go key found. Run `opencode auth login` or `just-usage add opencode`." });
846
+ }
847
+ const { status, windows } = await fetchOpenCodeUsage(key);
848
+ if (status === 401)
849
+ return snapshot(account, "error", { message: "Key rejected (401)." });
850
+ if (status === 403)
851
+ return snapshot(account, "unsupported", { message: "Valid key, but no active OpenCode Go subscription (403)." });
852
+ if (status === 429)
853
+ return snapshot(account, "error", { message: "Rate limited (429). Try again shortly." });
854
+ if (status !== 200)
855
+ return snapshot(account, "error", { message: `Usage endpoint returned HTTP ${status}.` });
856
+ if (windows.length === 0)
857
+ return snapshot(account, "unsupported", { message: "Usage response had no recognizable windows." });
858
+ return snapshot(account, "ok", { plan: "go", windows });
859
+ } catch (e) {
860
+ return snapshot(account, "error", { message: errorMessage(e) });
861
+ }
862
+ }
863
+
864
+ // src/collect.ts
865
+ import { hostname } from "node:os";
866
+
867
+ // src/adapters/cursor.ts
868
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
869
+ import { homedir as homedir4 } from "node:os";
870
+ import { join as join4 } from "node:path";
871
+ var USAGE_URL3 = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage";
872
+ function authFile() {
873
+ return process.env.CURSOR_AUTH_FILE ?? join4(homedir4(), ".cursor", "auth.json");
874
+ }
875
+ function readAccessToken() {
876
+ const file = authFile();
877
+ if (!existsSync4(file))
878
+ return null;
879
+ try {
880
+ const parsed = JSON.parse(readFileSync4(file, "utf8"));
881
+ return typeof parsed.accessToken === "string" && parsed.accessToken ? parsed.accessToken : null;
882
+ } catch {
883
+ return null;
884
+ }
885
+ }
886
+ function jwtExpiry(token) {
887
+ const part = token.split(".")[1];
888
+ if (!part)
889
+ return null;
890
+ try {
891
+ const json = Buffer.from(part.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
892
+ const claims = JSON.parse(json);
893
+ return typeof claims.exp === "number" ? claims.exp * 1000 : null;
894
+ } catch {
895
+ return null;
896
+ }
897
+ }
898
+ async function cursorEmail() {
899
+ const res = await run("cursor-agent", ["status"], { timeoutMs: 15000 });
900
+ const text = stripAnsi(res.stdout + `
901
+ ` + res.stderr);
902
+ const m = text.match(/Logged in as\s+(\S+)/i);
903
+ return m?.[1] ?? null;
904
+ }
905
+ function cents(v) {
906
+ if (typeof v === "string" && v.trim())
907
+ v = Number(v);
908
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
909
+ }
910
+ function money(c) {
911
+ return `$${(c / 100).toFixed(2)}`;
912
+ }
913
+ function normalizeCursorUsage(body) {
914
+ if (!isObject(body))
915
+ return null;
916
+ const cycleEnd = epochToIso(body.billingCycleEnd);
917
+ const out = [];
918
+ const plan = isObject(body.planUsage) ? body.planUsage : null;
919
+ if (plan) {
920
+ const limit = cents(plan.limit);
921
+ const included = cents(plan.includedSpend);
922
+ const bonus = cents(plan.bonusSpend);
923
+ let used = limit && included !== null ? clampPercent(included / limit * 100) : null;
924
+ if (used === null)
925
+ used = clampPercent(plan.totalPercentUsed);
926
+ if (used !== null) {
927
+ const parts = [];
928
+ if (limit !== null && included !== null)
929
+ parts.push(`${money(included)} of ${money(limit)} included`);
930
+ if (bonus)
931
+ parts.push(`${money(bonus)} bonus usage`);
932
+ if (typeof body.displayMessage === "string" && body.displayMessage.trim())
933
+ parts.push(body.displayMessage.trim());
934
+ out.push({ id: "included", label: "Included · billing cycle", usedPercent: used, resetsAt: cycleEnd, windowMinutes: null, kind: "cycle", note: parts.join(" · ") || undefined });
935
+ }
936
+ const api = clampPercent(plan.apiPercentUsed);
937
+ if (api !== null)
938
+ out.push({ id: "api", label: "Named models", usedPercent: api, resetsAt: cycleEnd, windowMinutes: null, kind: "cycle" });
939
+ const auto = clampPercent(plan.autoPercentUsed);
940
+ if (auto !== null)
941
+ out.push({ id: "auto", label: "Auto models", usedPercent: auto, resetsAt: cycleEnd, windowMinutes: null, kind: "cycle" });
942
+ }
943
+ const spend = isObject(body.spendLimitUsage) ? body.spendLimitUsage : null;
944
+ if (spend) {
945
+ const indLimit = cents(spend.individualLimit);
946
+ const indUsed = cents(spend.individualUsed);
947
+ if (indLimit && indUsed !== null) {
948
+ out.push({
949
+ id: "on_demand",
950
+ label: "On-demand",
951
+ usedPercent: clampPercent(indUsed / indLimit * 100),
952
+ resetsAt: cycleEnd,
953
+ windowMinutes: null,
954
+ kind: "cycle",
955
+ note: `${money(indUsed)} of ${money(indLimit)}`
956
+ });
957
+ }
958
+ const poolLimit = cents(spend.pooledLimit);
959
+ const poolUsed = cents(spend.pooledUsed);
960
+ if (poolLimit && poolUsed !== null) {
961
+ out.push({
962
+ id: "pooled",
963
+ label: "Team pool",
964
+ usedPercent: clampPercent(poolUsed / poolLimit * 100),
965
+ resetsAt: cycleEnd,
966
+ windowMinutes: null,
967
+ kind: "cycle",
968
+ note: `${money(poolUsed)} of ${money(poolLimit)}`
969
+ });
970
+ }
971
+ }
972
+ return out.length ? out : null;
973
+ }
974
+ async function fetchCursor(account) {
975
+ try {
976
+ const token = readAccessToken();
977
+ if (!token)
978
+ return snapshot(account, "signed_out", { message: "Not signed in. Run `cursor-agent login`." });
979
+ const exp = jwtExpiry(token);
980
+ const emailP = cursorEmail().catch(() => null);
981
+ if (exp && exp < Date.now()) {
982
+ return snapshot(account, "error", { email: await emailP, message: "Cursor session expired. Run `cursor-agent login`." });
983
+ }
984
+ const res = await fetchJson(USAGE_URL3, {
985
+ method: "POST",
986
+ headers: {
987
+ Authorization: `Bearer ${token}`,
988
+ "Content-Type": "application/json",
989
+ "Connect-Protocol-Version": "1"
990
+ },
991
+ body: "{}"
992
+ });
993
+ const email = await emailP;
994
+ if (res.status === 401 || res.status === 403) {
995
+ return snapshot(account, "error", { email, message: `Cursor rejected the session (${res.status}). Run \`cursor-agent login\`.` });
996
+ }
997
+ if (res.status !== 200) {
998
+ return snapshot(account, "error", { email, message: `Cursor usage endpoint returned HTTP ${res.status}.` });
999
+ }
1000
+ const windows = normalizeCursorUsage(res.body);
1001
+ if (!windows) {
1002
+ return snapshot(account, "unsupported", { email, message: "Cursor returned no plan usage for this account (team/enterprise plans are not supported yet)." });
1003
+ }
1004
+ return snapshot(account, "ok", { email, windows });
1005
+ } catch (e) {
1006
+ return snapshot(account, "error", { message: errorMessage(e) });
1007
+ }
1008
+ }
1009
+
1010
+ // src/adapters/index.ts
1011
+ function fetchSnapshot(account) {
1012
+ switch (account.provider) {
1013
+ case "claude":
1014
+ return fetchClaude(account);
1015
+ case "codex":
1016
+ return fetchCodex(account);
1017
+ case "cursor":
1018
+ return fetchCursor(account);
1019
+ case "opencode":
1020
+ return fetchOpenCode(account);
1021
+ }
1022
+ }
1023
+
1024
+ // src/registry.ts
1025
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync2, rmSync } from "node:fs";
1026
+ import { dirname as dirname2, join as join5 } from "node:path";
1027
+ function readRegistry() {
1028
+ const file = paths.registry();
1029
+ if (!existsSync5(file))
1030
+ return { version: 1, accounts: [] };
1031
+ try {
1032
+ const parsed = JSON.parse(readFileSync5(file, "utf8"));
1033
+ return { version: 1, accounts: Array.isArray(parsed.accounts) ? parsed.accounts : [] };
1034
+ } catch {
1035
+ return { version: 1, accounts: [] };
1036
+ }
1037
+ }
1038
+ function writeRegistry(reg) {
1039
+ const file = paths.registry();
1040
+ ensureDir(dirname2(file));
1041
+ writeFileSync2(file, JSON.stringify(reg, null, 2) + `
1042
+ `, { mode: 384 });
1043
+ }
1044
+ function listAccounts(provider) {
1045
+ const all = readRegistry().accounts;
1046
+ return provider ? all.filter((a) => a.provider === provider) : all;
1047
+ }
1048
+ function getAccount(id) {
1049
+ return readRegistry().accounts.find((a) => a.id === id) ?? null;
1050
+ }
1051
+ function newAccountId(provider, hint) {
1052
+ const existing = new Set(readRegistry().accounts.map((a) => a.id));
1053
+ const base = `${provider}:${slugify(hint)}`;
1054
+ if (base.endsWith(":default"))
1055
+ return newAccountId(provider, `${hint}-2`);
1056
+ if (!existing.has(base))
1057
+ return base;
1058
+ for (let i = 2;; i++) {
1059
+ const candidate = `${base}-${i}`;
1060
+ if (!existing.has(candidate))
1061
+ return candidate;
1062
+ }
1063
+ }
1064
+ function profileDirFor(provider, id) {
1065
+ return join5(paths.profiles(provider), id.split(":")[1] ?? "account");
1066
+ }
1067
+ function saveAccount(record) {
1068
+ const reg = readRegistry();
1069
+ reg.accounts = reg.accounts.filter((a) => a.id !== record.id);
1070
+ reg.accounts.push(record);
1071
+ writeRegistry(reg);
1072
+ }
1073
+ function updateAccount(id, patch) {
1074
+ const reg = readRegistry();
1075
+ const idx = reg.accounts.findIndex((a) => a.id === id);
1076
+ if (idx === -1)
1077
+ return;
1078
+ reg.accounts[idx] = { ...reg.accounts[idx], ...patch, id };
1079
+ writeRegistry(reg);
1080
+ }
1081
+ function deleteAccount(id) {
1082
+ const reg = readRegistry();
1083
+ const record = reg.accounts.find((a) => a.id === id) ?? null;
1084
+ if (!record)
1085
+ return null;
1086
+ reg.accounts = reg.accounts.filter((a) => a.id !== id);
1087
+ writeRegistry(reg);
1088
+ if (record.kind === "profile" && record.path && record.path.startsWith(paths.profiles(record.provider))) {
1089
+ rmSync(record.path, { recursive: true, force: true });
1090
+ }
1091
+ return record;
1092
+ }
1093
+
1094
+ // src/types.ts
1095
+ var PROVIDERS = [
1096
+ { id: "claude", name: "Claude", bin: "claude" },
1097
+ { id: "codex", name: "Codex", bin: "codex" },
1098
+ { id: "cursor", name: "Cursor", bin: "cursor-agent" },
1099
+ { id: "opencode", name: "OpenCode Go", bin: "opencode" }
1100
+ ];
1101
+ function providerName(id) {
1102
+ return PROVIDERS.find((p) => p.id === id)?.name ?? id;
1103
+ }
1104
+
1105
+ // src/collect.ts
1106
+ async function detectProviders() {
1107
+ return Promise.all(PROVIDERS.map(async (p) => {
1108
+ const path = await which(p.bin);
1109
+ const version = path ? await binVersion(p.bin) : null;
1110
+ return { id: p.id, installed: path !== null, version };
1111
+ }));
1112
+ }
1113
+ function resolveAccounts(provider, installed) {
1114
+ const out = [];
1115
+ if (installed || provider === "opencode") {
1116
+ out.push({ id: `${provider}:default`, provider, label: "Default", kind: "default" });
1117
+ }
1118
+ for (const rec of listAccounts(provider)) {
1119
+ out.push({ id: rec.id, provider, label: rec.label, kind: rec.kind, path: rec.path, email: rec.email ?? null });
1120
+ }
1121
+ return out;
1122
+ }
1123
+ async function fetchAccount(account) {
1124
+ try {
1125
+ return await withTimeout(fetchSnapshot(account), FETCH_TIMEOUT_MS, `${account.provider} fetch`);
1126
+ } catch (e) {
1127
+ return snapshot(account, "error", { message: e instanceof Error ? e.message : String(e) });
1128
+ }
1129
+ }
1130
+ async function collectReport(update, only) {
1131
+ const presence = await detectProviders();
1132
+ const providers = await Promise.all(PROVIDERS.filter((p) => !only || only.includes(p.id)).map(async (p) => {
1133
+ const pres = presence.find((x) => x.id === p.id);
1134
+ const accounts = resolveAccounts(p.id, pres.installed);
1135
+ const snapshots = await Promise.all(accounts.map(fetchAccount));
1136
+ return { id: p.id, name: p.name, installed: pres.installed, version: pres.version, accounts: snapshots };
1137
+ }));
1138
+ return {
1139
+ version: VERSION,
1140
+ hostname: hostname().replace(/\.local$/, ""),
1141
+ fetchedAt: new Date().toISOString(),
1142
+ update,
1143
+ providers
1144
+ };
1145
+ }
1146
+
1147
+ class ReportCache {
1148
+ ttlMs;
1149
+ getUpdate;
1150
+ report = null;
1151
+ inflight = null;
1152
+ constructor(ttlMs, getUpdate) {
1153
+ this.ttlMs = ttlMs;
1154
+ this.getUpdate = getUpdate;
1155
+ }
1156
+ get(force = false) {
1157
+ if (this.inflight)
1158
+ return this.inflight;
1159
+ if (!force && this.report && Date.now() - Date.parse(this.report.fetchedAt) < this.ttlMs) {
1160
+ return Promise.resolve({ ...this.report, update: this.getUpdate() });
1161
+ }
1162
+ this.inflight = collectReport(this.getUpdate()).then((r) => {
1163
+ this.report = r;
1164
+ return r;
1165
+ }).finally(() => {
1166
+ this.inflight = null;
1167
+ });
1168
+ return this.inflight;
1169
+ }
1170
+ peek() {
1171
+ return this.report ? { ...this.report, update: this.getUpdate() } : null;
1172
+ }
1173
+ }
1174
+
1175
+ // src/server.ts
1176
+ import { createServer } from "node:http";
1177
+ import { hostname as hostname2, networkInterfaces } from "node:os";
1178
+
1179
+ // src/ui/index.html
1180
+ var ui_default = `<!doctype html>
1181
+ <html lang="en">
1182
+ <head>
1183
+ <meta charset="utf-8">
1184
+ <meta name="viewport" content="width=device-width,initial-scale=1">
1185
+ <meta name="color-scheme" content="dark">
1186
+ <title>just-usage</title>
1187
+ <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23000'/%3E%3Crect x='7' y='14' width='18' height='4' rx='2' fill='%232a2a2a'/%3E%3Crect x='7' y='14' width='11' height='4' rx='2' fill='%23fff'/%3E%3C/svg%3E">
1188
+ <style>
1189
+ :root {
1190
+ --bg: #000;
1191
+ --surface: #0b0b0b;
1192
+ --line: #1c1c1c;
1193
+ --line-2: #2a2a2a;
1194
+ --fg: #ececec;
1195
+ --muted: #8b8b8b;
1196
+ --dim: #4d4d4d;
1197
+ --ok: #ffffff;
1198
+ --warn: #f5c542;
1199
+ --crit: #ff4a4a;
1200
+ }
1201
+ * { box-sizing: border-box; }
1202
+ html, body {
1203
+ margin: 0;
1204
+ background: var(--bg);
1205
+ color: var(--fg);
1206
+ font: 14px/1.45 -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
1207
+ -webkit-font-smoothing: antialiased;
1208
+ }
1209
+ .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
1210
+ .wrap { max-width: 820px; margin: 0 auto; padding: 44px 20px 64px; }
1211
+
1212
+ header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 26px; gap: 16px; }
1213
+ h1 { font-size: 15px; font-weight: 500; letter-spacing: .01em; margin: 0; }
1214
+ h1 span { color: var(--dim); font-weight: 400; margin-left: 8px; }
1215
+ .meta { color: var(--muted); font-size: 12px; display: flex; gap: 14px; align-items: center; }
1216
+ button.refresh {
1217
+ background: transparent; color: var(--fg); border: 1px solid var(--line-2); border-radius: 6px;
1218
+ padding: 5px 11px; font: inherit; font-size: 12px; cursor: pointer;
1219
+ }
1220
+ button.refresh:hover { border-color: #3a3a3a; }
1221
+ button.refresh[disabled] { opacity: .45; cursor: default; }
1222
+
1223
+ /* Chrome-style tab strip */
1224
+ .tabs { display: flex; gap: 2px; padding: 0 8px; position: relative; z-index: 1; }
1225
+ .tab {
1226
+ position: relative; background: transparent; border: 1px solid transparent; border-bottom: none;
1227
+ border-radius: 10px 10px 0 0; color: var(--muted); padding: 9px 16px 9px 14px; cursor: pointer;
1228
+ font: inherit; font-size: 13px; display: flex; gap: 9px; align-items: center; user-select: none;
1229
+ transition: color .12s;
1230
+ }
1231
+ .tab:hover { color: var(--fg); background: #060606; }
1232
+ .tab.active { background: var(--surface); border-color: var(--line); color: var(--fg); margin-bottom: -1px; padding-bottom: 10px; }
1233
+ .tab.active::before, .tab.active::after {
1234
+ content: ""; position: absolute; bottom: 0; width: 10px; height: 10px; background: var(--surface);
1235
+ }
1236
+ .tab.active::before { left: -10px; -webkit-mask: radial-gradient(circle at 0 0, transparent 10px, #000 10.5px); mask: radial-gradient(circle at 0 0, transparent 10px, #000 10.5px); }
1237
+ .tab.active::after { right: -10px; -webkit-mask: radial-gradient(circle at 100% 0, transparent 10px, #000 10.5px); mask: radial-gradient(circle at 100% 0, transparent 10px, #000 10.5px); }
1238
+ .dot { width: 6px; height: 6px; border-radius: 50%; background: var(--dim); flex: none; }
1239
+ .dot.ok { background: var(--ok); } .dot.warn { background: var(--warn); } .dot.crit { background: var(--crit); }
1240
+ .tab .n { color: var(--dim); font-size: 11px; }
1241
+
1242
+ .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; min-height: 160px; }
1243
+ .card { padding: 18px 18px 20px; border-bottom: 1px solid var(--line); }
1244
+ .card:last-child { border-bottom: none; }
1245
+ .card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
1246
+ .label { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1247
+ .plan { color: var(--muted); font-size: 12px; text-transform: capitalize; margin-left: 8px; font-weight: 400; }
1248
+ .badge { font-size: 11px; color: var(--muted); border: 1px solid var(--line-2); border-radius: 999px; padding: 1px 8px; white-space: nowrap; }
1249
+ .badge.crit { color: var(--crit); border-color: rgba(255,74,74,.35); }
1250
+ .badge.warn { color: var(--warn); border-color: rgba(245,197,66,.35); }
1251
+
1252
+ .win { margin-top: 14px; }
1253
+ .win-row { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; color: var(--muted); margin-bottom: 7px; gap: 12px; }
1254
+ .win-row b { color: var(--fg); font-weight: 500; }
1255
+ .win-row .pct { color: var(--fg); }
1256
+ .win-row .pct.warn { color: var(--warn); } .win-row .pct.crit { color: var(--crit); }
1257
+ .track { height: 4px; background: var(--line); border-radius: 2px; overflow: hidden; }
1258
+ .fill { height: 100%; border-radius: 2px; background: var(--ok); width: 0; transition: width .5s cubic-bezier(.2,.7,.2,1); }
1259
+ .fill.warn { background: var(--warn); } .fill.crit { background: var(--crit); }
1260
+ .note { color: var(--dim); font-size: 11px; margin-top: 5px; }
1261
+
1262
+ .msg { color: var(--muted); font-size: 13px; margin-top: 6px; }
1263
+ .empty { padding: 46px 18px; color: var(--muted); text-align: center; font-size: 13px; }
1264
+ .hint { color: var(--dim); font-size: 12px; margin-top: 8px; }
1265
+ code { font-family: ui-monospace, Menlo, Consolas, monospace; background: #121212; padding: 1px 6px; border-radius: 4px; font-size: 12px; color: var(--fg); }
1266
+ .credits { font-size: 12px; color: var(--muted); margin-top: 16px; padding-top: 12px; border-top: 1px dashed var(--line); }
1267
+
1268
+ footer { margin-top: 26px; color: var(--dim); font-size: 12px; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1269
+ footer .update { color: var(--warn); }
1270
+ footer kbd { font-family: inherit; color: var(--dim); }
1271
+ @media (max-width: 520px) {
1272
+ .wrap { padding: 28px 12px 48px; }
1273
+ .tab { padding: 8px 11px; font-size: 12px; }
1274
+ .tab .n { display: none; }
1275
+ }
1276
+ </style>
1277
+ </head>
1278
+ <body>
1279
+ <div class="wrap">
1280
+ <header>
1281
+ <h1>just-usage<span id="host"></span></h1>
1282
+ <div class="meta">
1283
+ <span id="updated" class="mono"></span>
1284
+ <button class="refresh" id="refresh" type="button">Refresh</button>
1285
+ </div>
1286
+ </header>
1287
+ <nav class="tabs" id="tabs" role="tablist"></nav>
1288
+ <section class="panel" id="panel" role="tabpanel"><div class="empty">Loading…</div></section>
1289
+ <footer>
1290
+ <span id="version"></span>
1291
+ <span id="update"></span>
1292
+ </footer>
1293
+ </div>
1294
+
1295
+ <script>
1296
+ (() => {
1297
+ const PROVIDERS = [
1298
+ ["claude", "Claude"],
1299
+ ["codex", "Codex"],
1300
+ ["cursor", "Cursor"],
1301
+ ["opencode", "OpenCode Go"],
1302
+ ];
1303
+ const SIGNIN_HINT = {
1304
+ claude: "Run <code>claude</code>, then <code>/login</code>.",
1305
+ codex: "Run <code>codex login</code>.",
1306
+ cursor: "Run <code>cursor-agent login</code>.",
1307
+ opencode: "Run <code>opencode auth login</code> or <code>just-usage add opencode</code>.",
1308
+ };
1309
+ const $ = (id) => document.getElementById(id);
1310
+ let report = null;
1311
+ const wanted = new URLSearchParams(location.search).get("tab");
1312
+ let active = PROVIDERS.some(([id]) => id === wanted) ? wanted : localStorage.getItem("ju.tab") || "claude";
1313
+ let loading = false;
1314
+
1315
+ const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
1316
+ // Escape, then turn \`backticked\` spans into <code>.
1317
+ const rich = (s) => esc(s).replace(/\`([^\`]+)\`/g, "<code>$1</code>");
1318
+ const sev = (p) => (p == null ? null : p >= 85 ? "crit" : p >= 60 ? "warn" : "ok");
1319
+ const rank = { ok: 1, warn: 2, crit: 3 };
1320
+
1321
+ function fmtDuration(ms) {
1322
+ const min = Math.round(ms / 60000);
1323
+ const d = Math.floor(min / 1440), h = Math.floor((min % 1440) / 60), m = min % 60;
1324
+ if (d > 0) return h ? \`\${d}d \${h}h\` : \`\${d}d\`;
1325
+ if (h > 0) return m ? \`\${h}h \${m}m\` : \`\${h}h\`;
1326
+ return \`\${Math.max(1, m)}m\`;
1327
+ }
1328
+ function fmtReset(w) {
1329
+ if (!w.resetsAt) return "";
1330
+ const t = Date.parse(w.resetsAt);
1331
+ if (!isFinite(t)) return "";
1332
+ const diff = t - Date.now();
1333
+ if (w.kind === "cycle") {
1334
+ const date = new Date(t).toLocaleDateString(undefined, { month: "short", day: "numeric" });
1335
+ return diff <= 0 ? "renewing" : \`renews \${date}\`;
1336
+ }
1337
+ return diff <= 0 ? "resetting" : \`resets in \${fmtDuration(diff)}\`;
1338
+ }
1339
+ function fmtTime(iso) {
1340
+ const t = Date.parse(iso);
1341
+ return isFinite(t) ? new Date(t).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) : "";
1342
+ }
1343
+
1344
+ function providerSeverity(p) {
1345
+ if (!p) return null;
1346
+ let worst = null;
1347
+ for (const a of p.accounts) {
1348
+ if (a.status !== "ok") continue;
1349
+ for (const w of a.windows) {
1350
+ const s = sev(w.usedPercent);
1351
+ if (s && (!worst || rank[s] > rank[worst])) worst = s;
1352
+ }
1353
+ }
1354
+ return worst;
1355
+ }
1356
+
1357
+ function renderTabs() {
1358
+ $("tabs").innerHTML = PROVIDERS.map(([id, name]) => {
1359
+ const p = report?.providers.find((x) => x.id === id);
1360
+ const s = providerSeverity(p);
1361
+ const n = p ? p.accounts.filter((a) => a.status === "ok").length : 0;
1362
+ return \`<button class="tab\${id === active ? " active" : ""}" role="tab" data-id="\${id}" aria-selected="\${id === active}">
1363
+ <span class="dot\${s ? " " + s : ""}"></span>\${esc(name)}\${n > 1 ? \`<span class="n">\${n}</span>\` : ""}</button>\`;
1364
+ }).join("");
1365
+ for (const el of $("tabs").querySelectorAll(".tab")) {
1366
+ el.onclick = () => { active = el.dataset.id; localStorage.setItem("ju.tab", active); render(); };
1367
+ }
1368
+ }
1369
+
1370
+ function renderWindow(w) {
1371
+ const s = sev(w.usedPercent);
1372
+ const pct = w.usedPercent == null ? "—" : \`\${Math.round(w.usedPercent)}%\`;
1373
+ const reset = fmtReset(w);
1374
+ return \`<div class="win">
1375
+ <div class="win-row"><b>\${esc(w.label)}</b>
1376
+ <span class="mono"><span class="pct\${s ? " " + s : ""}">\${pct} used</span>\${reset ? \` · \${esc(reset)}\` : ""}</span></div>
1377
+ <div class="track"><div class="fill\${s ? " " + s : ""}" style="width:\${w.usedPercent ?? 0}%"></div></div>
1378
+ \${w.note ? \`<div class="note">\${esc(w.note)}</div>\` : ""}
1379
+ </div>\`;
1380
+ }
1381
+
1382
+ function renderCard(a, provider) {
1383
+ const title = a.account.email || a.account.label;
1384
+ const sub = a.account.email && a.account.label !== "Default" && a.account.label !== a.account.email ? \` <span class="plan">\${esc(a.account.label)}</span>\` : "";
1385
+ const plan = a.account.plan ? \`<span class="plan">\${esc(a.account.plan)}</span>\` : "";
1386
+ let badge = "";
1387
+ if (a.status === "ok") {
1388
+ let worst = null;
1389
+ for (const w of a.windows) { const s = sev(w.usedPercent); if (s && (!worst || rank[s] > rank[worst])) worst = s; }
1390
+ if (worst === "crit") badge = \`<span class="badge crit">critical</span>\`;
1391
+ else if (worst === "warn") badge = \`<span class="badge warn">running low</span>\`;
1392
+ } else {
1393
+ const text = { signed_out: "signed out", unsupported: "unsupported", error: "error" }[a.status] || a.status;
1394
+ badge = \`<span class="badge">\${esc(text)}</span>\`;
1395
+ }
1396
+ let body = "";
1397
+ if (a.status === "ok") {
1398
+ body = a.windows.map(renderWindow).join("");
1399
+ if (a.resetCredits && a.resetCredits.availableCount > 0) {
1400
+ const c = a.resetCredits.credits.find((x) => x.status === "available") || a.resetCredits.credits[0];
1401
+ const exp = c && c.expiresAt ? \` · expires \${new Date(c.expiresAt).toLocaleDateString(undefined, { month: "short", day: "numeric" })}\` : "";
1402
+ const n = a.resetCredits.availableCount;
1403
+ body += \`<div class="credits">\${n} rate-limit reset\${n === 1 ? "" : "s"} banked\${c && c.title ? \` — \${esc(c.title)}\` : ""}\${exp}</div>\`;
1404
+ }
1405
+ } else {
1406
+ body = a.message ? \`<div class="msg">\${rich(a.message)}</div>\` : \`<div class="hint">\${SIGNIN_HINT[provider] || ""}</div>\`;
1407
+ }
1408
+ return \`<article class="card">
1409
+ <div class="card-head"><div class="label">\${esc(title)}\${sub}\${plan}</div>\${badge}</div>
1410
+ \${body}
1411
+ </article>\`;
1412
+ }
1413
+
1414
+ function renderPanel() {
1415
+ const p = report?.providers.find((x) => x.id === active);
1416
+ const name = PROVIDERS.find(([id]) => id === active)?.[1] || active;
1417
+ if (!p) { $("panel").innerHTML = \`<div class="empty">Loading…</div>\`; return; }
1418
+ if (!p.installed && p.accounts.length === 0) {
1419
+ $("panel").innerHTML = \`<div class="empty">\${esc(name)} isn't installed on \${esc(report.hostname)}.</div>\`;
1420
+ return;
1421
+ }
1422
+ if (p.accounts.length === 0) {
1423
+ $("panel").innerHTML = \`<div class="empty">No \${esc(name)} accounts.<div class="hint">\${SIGNIN_HINT[active] || ""}</div></div>\`;
1424
+ return;
1425
+ }
1426
+ $("panel").innerHTML = p.accounts.map((a) => renderCard(a, active)).join("");
1427
+ }
1428
+
1429
+ function render() {
1430
+ renderTabs();
1431
+ renderPanel();
1432
+ if (report) {
1433
+ $("host").textContent = report.hostname;
1434
+ $("updated").textContent = loading ? "refreshing…" : \`updated \${fmtTime(report.fetchedAt)}\`;
1435
+ $("version").textContent = \`v\${report.version}\`;
1436
+ const u = report.update;
1437
+ $("update").innerHTML = u && u.available
1438
+ ? \`<span class="update">v\${esc(u.latest)} available — run <code>just-usage upgrade</code></span>\`
1439
+ : \`<kbd>1</kbd>–<kbd>4</kbd> switch tabs · <kbd>r</kbd> refresh\`;
1440
+ }
1441
+ $("refresh").disabled = loading;
1442
+ }
1443
+
1444
+ async function load(force) {
1445
+ loading = true;
1446
+ render();
1447
+ try {
1448
+ const res = await fetch(\`/api/quotas\${force ? "?refresh=1" : ""}\`, { cache: "no-store" });
1449
+ report = await res.json();
1450
+ } catch (e) {
1451
+ $("panel").innerHTML = \`<div class="empty">Couldn't reach just-usage. Is the server still running?</div>\`;
1452
+ } finally {
1453
+ loading = false;
1454
+ render();
1455
+ }
1456
+ }
1457
+
1458
+ $("refresh").onclick = () => load(true);
1459
+ document.addEventListener("keydown", (e) => {
1460
+ if (e.metaKey || e.ctrlKey || e.altKey) return;
1461
+ if (e.key >= "1" && e.key <= "4") { active = PROVIDERS[Number(e.key) - 1][0]; localStorage.setItem("ju.tab", active); render(); }
1462
+ else if (e.key === "r") load(true);
1463
+ });
1464
+ setInterval(() => { if (report && !loading) render(); }, 30000);
1465
+ load(false);
1466
+ })();
1467
+ </script>
1468
+ </body>
1469
+ </html>
1470
+ `;
1471
+
1472
+ // src/server.ts
1473
+ function json(res, status, body) {
1474
+ const text = JSON.stringify(body);
1475
+ res.writeHead(status, {
1476
+ "Content-Type": "application/json; charset=utf-8",
1477
+ "Cache-Control": "no-store",
1478
+ "Content-Length": Buffer.byteLength(text)
1479
+ });
1480
+ res.end(text);
1481
+ }
1482
+ function startServer(opts) {
1483
+ const cache = new ReportCache(CACHE_TTL_MS, opts.getUpdate);
1484
+ cache.get(true).catch(() => {});
1485
+ const handler = async (req, res) => {
1486
+ const url = new URL(req.url ?? "/", "http://localhost");
1487
+ res.setHeader("X-Content-Type-Options", "nosniff");
1488
+ try {
1489
+ if (req.method !== "GET" && req.method !== "HEAD") {
1490
+ json(res, 405, { error: "method not allowed" });
1491
+ return;
1492
+ }
1493
+ if (url.pathname === "/") {
1494
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
1495
+ res.end(ui_default);
1496
+ return;
1497
+ }
1498
+ if (url.pathname === "/api/quotas") {
1499
+ const force = url.searchParams.get("refresh") === "1";
1500
+ json(res, 200, await cache.get(force));
1501
+ return;
1502
+ }
1503
+ if (url.pathname === "/api/health") {
1504
+ json(res, 200, { ok: true, version: VERSION });
1505
+ return;
1506
+ }
1507
+ json(res, 404, { error: "not found" });
1508
+ } catch (e) {
1509
+ json(res, 500, { error: e instanceof Error ? e.message : String(e) });
1510
+ }
1511
+ };
1512
+ const server = createServer((req, res) => void handler(req, res));
1513
+ return new Promise((resolve, reject) => {
1514
+ server.once("error", reject);
1515
+ server.listen(opts.port, opts.host, () => {
1516
+ resolve({ close: () => server.close(), urls: reachableUrls(opts.host, opts.port) });
1517
+ });
1518
+ });
1519
+ }
1520
+ function isTailscale(ip) {
1521
+ const [a, b] = ip.split(".").map(Number);
1522
+ return a === 100 && b !== undefined && b >= 64 && b <= 127;
1523
+ }
1524
+ function reachableUrls(host, port) {
1525
+ if (host !== "0.0.0.0" && host !== "::")
1526
+ return [`http://${host === "::1" ? "localhost" : host}:${port}`];
1527
+ const urls = [`http://127.0.0.1:${port}`];
1528
+ const shortHost = hostname2().replace(/\.local$/, "").toLowerCase();
1529
+ if (shortHost)
1530
+ urls.push(`http://${shortHost}:${port}`);
1531
+ const seen = new Set;
1532
+ for (const list of Object.values(networkInterfaces())) {
1533
+ for (const iface of list ?? []) {
1534
+ if (iface.family !== "IPv4" || iface.internal || seen.has(iface.address))
1535
+ continue;
1536
+ seen.add(iface.address);
1537
+ urls.push(`http://${iface.address}:${port}${isTailscale(iface.address) ? " (tailscale)" : ""}`);
1538
+ }
1539
+ }
1540
+ return urls;
1541
+ }
1542
+
1543
+ // src/terminal.ts
1544
+ var useColor = process.stdout.isTTY && !process.env.NO_COLOR;
1545
+ var c = {
1546
+ dim: (s) => useColor ? `\x1B[2m${s}\x1B[0m` : s,
1547
+ bold: (s) => useColor ? `\x1B[1m${s}\x1B[0m` : s,
1548
+ yellow: (s) => useColor ? `\x1B[33m${s}\x1B[0m` : s,
1549
+ red: (s) => useColor ? `\x1B[31m${s}\x1B[0m` : s
1550
+ };
1551
+ function bar(used, width = 20) {
1552
+ if (used === null)
1553
+ return c.dim("░".repeat(width));
1554
+ const filled = Math.round(used / 100 * width);
1555
+ const s = "█".repeat(filled) + c.dim("░".repeat(width - filled));
1556
+ const sev = severity(used);
1557
+ return sev === "crit" ? c.red(s) : sev === "warn" ? c.yellow(s) : s;
1558
+ }
1559
+ function pct(used) {
1560
+ if (used === null)
1561
+ return " — ";
1562
+ const s = `${String(Math.round(used)).padStart(3)}%`;
1563
+ const sev = severity(used);
1564
+ return sev === "crit" ? c.red(s) : sev === "warn" ? c.yellow(s) : s;
1565
+ }
1566
+ function renderAccount(a, now) {
1567
+ const lines = [];
1568
+ const title = a.account.email ?? a.account.label;
1569
+ const extras = [a.account.label !== "Default" && a.account.label !== title ? a.account.label : null, a.account.plan].filter(Boolean).join(" · ");
1570
+ lines.push(` ${c.bold(title)}${extras ? c.dim(` ${extras}`) : ""}`);
1571
+ if (a.status !== "ok") {
1572
+ lines.push(` ${c.dim(a.status.replace("_", " "))} ${a.message ?? ""}`);
1573
+ return lines;
1574
+ }
1575
+ const labelWidth = Math.max(6, ...a.windows.map((w) => w.label.length));
1576
+ for (const w of a.windows) {
1577
+ const reset = formatResetIn(w.resetsAt, now, w.kind);
1578
+ lines.push(` ${w.label.padEnd(labelWidth)} ${bar(w.usedPercent)} ${pct(w.usedPercent)} used${reset ? c.dim(` ${reset}`) : ""}`);
1579
+ }
1580
+ if (a.resetCredits && a.resetCredits.availableCount > 0) {
1581
+ lines.push(` ${c.dim(`${a.resetCredits.availableCount} rate-limit reset(s) banked`)}`);
1582
+ }
1583
+ return lines;
1584
+ }
1585
+ function renderReport(report, now = Date.now()) {
1586
+ const out = [];
1587
+ for (const p of report.providers) {
1588
+ out.push(`${c.bold(p.name)}${p.version ? c.dim(` v${p.version}`) : ""}${p.installed ? "" : c.dim(" not installed")}`);
1589
+ if (p.accounts.length === 0)
1590
+ out.push(c.dim(" no accounts"));
1591
+ for (const a of p.accounts)
1592
+ out.push(...renderAccount(a, now));
1593
+ out.push("");
1594
+ }
1595
+ if (report.update?.available) {
1596
+ out.push(c.yellow(`Update available: v${report.update.current} → v${report.update.latest}. Run: just-usage upgrade`));
1597
+ }
1598
+ return out.join(`
1599
+ `);
1600
+ }
1601
+
1602
+ // src/update.ts
1603
+ import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
1604
+ import { dirname as dirname3 } from "node:path";
1605
+ var CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
1606
+ function readCache() {
1607
+ const file = paths.updateCache();
1608
+ if (!existsSync6(file))
1609
+ return null;
1610
+ try {
1611
+ const parsed = JSON.parse(readFileSync6(file, "utf8"));
1612
+ if (typeof parsed.checkedAt === "string" && typeof parsed.latest === "string")
1613
+ return parsed;
1614
+ } catch {}
1615
+ return null;
1616
+ }
1617
+ function writeCache(c2) {
1618
+ try {
1619
+ ensureDir(dirname3(paths.updateCache()));
1620
+ writeFileSync3(paths.updateCache(), JSON.stringify(c2) + `
1621
+ `);
1622
+ } catch {}
1623
+ }
1624
+ async function latestFromGitHub() {
1625
+ const res = await fetch(`https://api.github.com/repos/${GITHUB_REPO}/releases/latest`, {
1626
+ headers: { Accept: "application/vnd.github+json", "User-Agent": `${PACKAGE_NAME}/${VERSION}` },
1627
+ signal: AbortSignal.timeout(6000)
1628
+ });
1629
+ if (!res.ok)
1630
+ return null;
1631
+ const body = await res.json();
1632
+ const tag = body.tag_name?.replace(/^v/, "");
1633
+ return tag && /^\d+\.\d+\.\d+/.test(tag) ? tag : null;
1634
+ }
1635
+ async function latestFromNpm() {
1636
+ const res = await fetch(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
1637
+ headers: { Accept: "application/json", "User-Agent": `${PACKAGE_NAME}/${VERSION}` },
1638
+ signal: AbortSignal.timeout(6000)
1639
+ });
1640
+ if (!res.ok)
1641
+ return null;
1642
+ const body = await res.json();
1643
+ return body.version && /^\d+\.\d+\.\d+/.test(body.version) ? body.version : null;
1644
+ }
1645
+ async function checkForUpdate(force = false) {
1646
+ if (process.env.JUST_USAGE_NO_UPDATE_CHECK === "1" && !force)
1647
+ return null;
1648
+ const cached = readCache();
1649
+ if (!force && cached && Date.now() - Date.parse(cached.checkedAt) < CHECK_INTERVAL_MS) {
1650
+ return toInfo(cached);
1651
+ }
1652
+ let latest = null;
1653
+ try {
1654
+ latest = await latestFromGitHub();
1655
+ } catch {
1656
+ latest = null;
1657
+ }
1658
+ if (!latest) {
1659
+ try {
1660
+ latest = await latestFromNpm();
1661
+ } catch {
1662
+ latest = null;
1663
+ }
1664
+ }
1665
+ if (!latest)
1666
+ return cached ? toInfo(cached) : null;
1667
+ const entry = { checkedAt: new Date().toISOString(), latest };
1668
+ writeCache(entry);
1669
+ return toInfo(entry);
1670
+ }
1671
+ function toInfo(c2) {
1672
+ return { current: VERSION, latest: c2.latest, available: semverGt(c2.latest, VERSION), checkedAt: c2.checkedAt };
1673
+ }
1674
+ function detectPackageManager(argv1 = process.argv[1] ?? "", execPath = process.execPath) {
1675
+ const p = argv1.replace(/\\/g, "/");
1676
+ if (/\/\.bun\//.test(p) || /bun/.test(execPath.replace(/\\/g, "/").split("/").pop() ?? ""))
1677
+ return "bun";
1678
+ if (/\/pnpm\//.test(p) || /\/\.pnpm\//.test(p))
1679
+ return "pnpm";
1680
+ if (/\/\.yarn\//.test(p) || /\/yarn\//.test(p))
1681
+ return "yarn";
1682
+ return "npm";
1683
+ }
1684
+ function upgradeCommand(pm, version = "latest") {
1685
+ const spec = `${PACKAGE_NAME}@${version}`;
1686
+ switch (pm) {
1687
+ case "bun":
1688
+ return ["bun", ["add", "-g", spec]];
1689
+ case "pnpm":
1690
+ return ["pnpm", ["add", "-g", spec]];
1691
+ case "yarn":
1692
+ return ["yarn", ["global", "add", spec]];
1693
+ default:
1694
+ return ["npm", ["install", "-g", spec]];
1695
+ }
1696
+ }
1697
+ async function runUpgrade(pm, version = "latest") {
1698
+ const [cmd, args] = upgradeCommand(pm, version);
1699
+ console.log(`$ ${cmd} ${args.join(" ")}`);
1700
+ return runInteractive(cmd, args);
1701
+ }
1702
+
1703
+ // src/cli.ts
1704
+ var HELP = `${PACKAGE_NAME} v${VERSION}
1705
+ One local page for your coding-CLI subscription quotas.
1706
+
1707
+ Usage
1708
+ just-usage Start the server and open the page
1709
+ just-usage serve [options] Start the server
1710
+ --port <n> Port (default ${DEFAULT_PORT})
1711
+ --host <addr> Bind address (default ${DEFAULT_HOST}; use 127.0.0.1 for local only)
1712
+ --no-open Don't open a browser
1713
+ just-usage status [--json] Print quotas in the terminal
1714
+ just-usage accounts List accounts
1715
+ just-usage add <provider> Add an account (codex | claude | opencode)
1716
+ --label <name> Friendly name
1717
+ --token Claude only: paste a \`claude setup-token\` instead of a profile login
1718
+ just-usage login <account-id> Re-authenticate a profile account
1719
+ just-usage remove <account-id> Remove an account and anything we stored for it
1720
+ just-usage upgrade [--check] Update to the latest release
1721
+ just-usage --version
1722
+
1723
+ Providers: ${PROVIDERS.map((p) => p.name).join(", ")}
1724
+ Cursor uses whatever \`cursor-agent\` is logged in as (single account).
1725
+ `;
1726
+ function fail(msg, code = 1) {
1727
+ console.error(msg);
1728
+ process.exit(code);
1729
+ }
1730
+ function isProvider(v) {
1731
+ return PROVIDERS.some((p) => p.id === v);
1732
+ }
1733
+ async function prompt(question, opts = {}) {
1734
+ const rl = createInterface2({ input: process.stdin, output: process.stdout, terminal: process.stdin.isTTY });
1735
+ const muted = opts.secret && process.stdin.isTTY;
1736
+ return new Promise((resolve) => {
1737
+ if (muted) {
1738
+ process.stdout.write(question);
1739
+ const anyRl = rl;
1740
+ anyRl._writeToOutput = (s) => {
1741
+ if (s.includes(`
1742
+ `))
1743
+ process.stdout.write(`
1744
+ `);
1745
+ };
1746
+ rl.question("", (answer) => {
1747
+ rl.close();
1748
+ resolve(answer.trim());
1749
+ });
1750
+ } else {
1751
+ rl.question(question, (answer) => {
1752
+ rl.close();
1753
+ resolve(answer.trim());
1754
+ });
1755
+ }
1756
+ });
1757
+ }
1758
+ async function cmdServe(argv) {
1759
+ const { values } = parseArgs({
1760
+ args: argv,
1761
+ options: {
1762
+ port: { type: "string", short: "p" },
1763
+ host: { type: "string" },
1764
+ "no-open": { type: "boolean" },
1765
+ open: { type: "boolean" }
1766
+ },
1767
+ allowPositionals: true,
1768
+ strict: false
1769
+ });
1770
+ const port = values.port ? Number(values.port) : DEFAULT_PORT;
1771
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
1772
+ fail(`invalid port: ${values.port}`);
1773
+ const host = typeof values.host === "string" && values.host ? values.host : DEFAULT_HOST;
1774
+ const shouldOpen = values["no-open"] !== true && values.open !== false;
1775
+ let update = null;
1776
+ checkForUpdate().then((u) => {
1777
+ update = u;
1778
+ if (u?.available)
1779
+ console.log(`
1780
+ Update available: v${u.current} → v${u.latest}. Run: just-usage upgrade
1781
+ `);
1782
+ });
1783
+ let server;
1784
+ try {
1785
+ server = await startServer({ host, port, getUpdate: () => update });
1786
+ } catch (e) {
1787
+ const code = e.code;
1788
+ if (code === "EADDRINUSE")
1789
+ fail(`Port ${port} is already in use. Try: just-usage serve --port ${port + 1}`);
1790
+ throw e;
1791
+ }
1792
+ console.log(`${PACKAGE_NAME} v${VERSION}`);
1793
+ for (const [i, url] of server.urls.entries())
1794
+ console.log(` ${i === 0 ? "local " : "network "} ${url}`);
1795
+ console.log(`
1796
+ Press Ctrl+C to stop.`);
1797
+ if (shouldOpen)
1798
+ openInBrowser(server.urls[0]);
1799
+ const shutdown = () => {
1800
+ server.close();
1801
+ process.exit(0);
1802
+ };
1803
+ process.on("SIGINT", shutdown);
1804
+ process.on("SIGTERM", shutdown);
1805
+ }
1806
+ async function cmdStatus(argv) {
1807
+ const { values } = parseArgs({ args: argv, options: { json: { type: "boolean" } }, allowPositionals: true, strict: false });
1808
+ const [report, update] = await Promise.all([collectReport(null), checkForUpdate().catch(() => null)]);
1809
+ report.update = update;
1810
+ if (values.json) {
1811
+ console.log(JSON.stringify(report, null, 2));
1812
+ return;
1813
+ }
1814
+ console.log(renderReport(report));
1815
+ }
1816
+ function cmdAccounts() {
1817
+ const rows = listAccounts();
1818
+ console.log("Default accounts come from each CLI's own login (codex login, claude /login, cursor-agent login, opencode auth login).");
1819
+ if (rows.length === 0) {
1820
+ console.log(`
1821
+ No extra accounts. Add one with: just-usage add codex | claude | opencode`);
1822
+ return;
1823
+ }
1824
+ console.log("");
1825
+ const w = Math.max(...rows.map((r) => r.id.length));
1826
+ for (const r of rows) {
1827
+ const extra = [r.email, r.kind, r.path].filter(Boolean).join(" ");
1828
+ console.log(` ${r.id.padEnd(w)} ${r.label} ${extra}`);
1829
+ }
1830
+ }
1831
+ async function addCodex(label) {
1832
+ if (!await which("codex"))
1833
+ fail("codex is not installed (npm i -g @openai/codex).");
1834
+ const tmpId = newAccountId("codex", label ?? "pending");
1835
+ const dir = ensureDir(profileDirFor("codex", tmpId));
1836
+ console.log("Starting Codex login in an isolated profile…");
1837
+ const info = await codexLogin(dir, (url) => {
1838
+ console.log(`
1839
+ Open this URL to sign in (opening your browser):
1840
+ ${url}
1841
+ `);
1842
+ openInBrowser(url);
1843
+ });
1844
+ const finalLabel = label ?? info.email ?? tmpId.split(":")[1];
1845
+ const id = label ? tmpId : newAccountId("codex", finalLabel);
1846
+ const path = id === tmpId ? dir : ensureDir(profileDirFor("codex", id));
1847
+ if (path !== dir) {
1848
+ const { renameSync, rmSync: rmSync2 } = await import("node:fs");
1849
+ rmSync2(path, { recursive: true, force: true });
1850
+ renameSync(dir, path);
1851
+ }
1852
+ saveAccount({ id, provider: "codex", label: finalLabel, kind: "profile", path, email: info.email, createdAt: new Date().toISOString() });
1853
+ console.log(`Added ${id}${info.email ? ` (${info.email}${info.plan ? `, ${info.plan}` : ""})` : ""}.`);
1854
+ }
1855
+ async function addClaudeToken(label) {
1856
+ console.log("Run `claude setup-token` in the account you want to add, then paste the token here.");
1857
+ const token = await prompt("Token: ", { secret: true });
1858
+ if (!token)
1859
+ fail("No token given.");
1860
+ const check = await verifyClaudeToken(token);
1861
+ if (!check.ok)
1862
+ fail(`Token check failed: ${check.message}`);
1863
+ const finalLabel = label ?? check.email ?? "token";
1864
+ const id = newAccountId("claude", finalLabel);
1865
+ await secretStore().set(id, token);
1866
+ saveAccount({ id, provider: "claude", label: finalLabel, kind: "token", email: check.email, createdAt: new Date().toISOString() });
1867
+ console.log(`Added ${id}${check.email ? ` (${check.email})` : ""}. Token stored in ${secretStore().kind}.`);
1868
+ }
1869
+ async function addClaudeProfile(label) {
1870
+ if (!await which("claude"))
1871
+ fail("claude is not installed (npm i -g @anthropic-ai/claude-code).");
1872
+ const id = newAccountId("claude", label ?? "profile");
1873
+ const dir = ensureDir(profileDirFor("claude", id));
1874
+ console.log(`Starting Claude Code login in an isolated profile (CLAUDE_CONFIG_DIR=${dir})…
1875
+ `);
1876
+ await runInteractive("claude", ["auth", "login"], { CLAUDE_CONFIG_DIR: dir });
1877
+ const status = await claudeAuthStatus(dir);
1878
+ if (!status?.loggedIn) {
1879
+ const { rmSync: rmSync2 } = await import("node:fs");
1880
+ rmSync2(dir, { recursive: true, force: true });
1881
+ fail("Login did not complete; nothing was saved.");
1882
+ }
1883
+ saveAccount({ id, provider: "claude", label: label ?? id.split(":")[1], kind: "profile", path: dir, email: null, createdAt: new Date().toISOString() });
1884
+ console.log(`
1885
+ Added ${id}. Note: on macOS the first read may trigger a Keychain prompt — choose "Always Allow".`);
1886
+ }
1887
+ async function addOpenCode(label) {
1888
+ console.log("Paste an OpenCode Go API key (from https://opencode.ai/ → Go → API keys).");
1889
+ const key = await prompt("Key: ", { secret: true });
1890
+ if (!key)
1891
+ fail("No key given.");
1892
+ const { status, windows } = await fetchOpenCodeUsage(key);
1893
+ if (status === 401)
1894
+ fail("Key rejected (401).");
1895
+ if (status === 403)
1896
+ console.log("Warning: key is valid but has no active OpenCode Go subscription (403). Saving anyway.");
1897
+ else if (status !== 200)
1898
+ fail(`Usage endpoint returned HTTP ${status}.`);
1899
+ const finalLabel = label ?? "go";
1900
+ const id = newAccountId("opencode", finalLabel);
1901
+ await secretStore().set(id, key);
1902
+ saveAccount({ id, provider: "opencode", label: finalLabel, kind: "token", createdAt: new Date().toISOString() });
1903
+ console.log(`Added ${id}${windows.length ? ` (${windows.map((w) => `${w.label} ${w.usedPercent}%`).join(", ")})` : ""}. Key stored in ${secretStore().kind}.`);
1904
+ }
1905
+ async function cmdAdd(argv) {
1906
+ const { values, positionals } = parseArgs({
1907
+ args: argv,
1908
+ options: { label: { type: "string", short: "l" }, token: { type: "boolean" } },
1909
+ allowPositionals: true
1910
+ });
1911
+ const provider = positionals[0];
1912
+ if (!isProvider(provider))
1913
+ fail(`Usage: just-usage add <codex|claude|opencode> [--label name] [--token]`);
1914
+ switch (provider) {
1915
+ case "codex":
1916
+ return addCodex(values.label);
1917
+ case "claude":
1918
+ return values.token ? addClaudeToken(values.label) : addClaudeProfile(values.label);
1919
+ case "opencode":
1920
+ return addOpenCode(values.label);
1921
+ case "cursor":
1922
+ fail("Cursor is single-account: just-usage shows whatever `cursor-agent` is logged in as.");
1923
+ }
1924
+ }
1925
+ async function cmdLogin(argv) {
1926
+ const id = argv[0];
1927
+ if (!id)
1928
+ fail("Usage: just-usage login <account-id>");
1929
+ const rec = getAccount(id);
1930
+ if (!rec)
1931
+ fail(`Unknown account: ${id}`);
1932
+ if (rec.kind !== "profile" || !rec.path)
1933
+ fail(`${id} is a ${rec.kind} account; remove and re-add it instead.`);
1934
+ if (rec.provider === "codex") {
1935
+ const info = await codexLogin(rec.path, (url) => {
1936
+ console.log(`
1937
+ Open this URL to sign in (opening your browser):
1938
+ ${url}
1939
+ `);
1940
+ openInBrowser(url);
1941
+ });
1942
+ updateAccount(id, { email: info.email });
1943
+ console.log(`Re-authenticated ${id}${info.email ? ` (${info.email})` : ""}.`);
1944
+ } else if (rec.provider === "claude") {
1945
+ await runInteractive("claude", ["auth", "login"], { CLAUDE_CONFIG_DIR: rec.path });
1946
+ const status = await claudeAuthStatus(rec.path);
1947
+ console.log(status?.loggedIn ? `Re-authenticated ${id}.` : "Login did not complete.");
1948
+ } else {
1949
+ fail(`${providerName(rec.provider)} accounts cannot be re-authenticated this way.`);
1950
+ }
1951
+ }
1952
+ async function cmdRemove(argv) {
1953
+ const id = argv[0];
1954
+ if (!id)
1955
+ fail("Usage: just-usage remove <account-id>");
1956
+ if (id.endsWith(":default"))
1957
+ fail("Default accounts belong to the CLI itself; sign out there instead.");
1958
+ const rec = deleteAccount(id);
1959
+ if (!rec)
1960
+ fail(`Unknown account: ${id}`);
1961
+ if (rec.kind === "token")
1962
+ await secretStore().delete(id);
1963
+ console.log(`Removed ${id}.`);
1964
+ }
1965
+ async function cmdUpgrade(argv) {
1966
+ const { values } = parseArgs({ args: argv, options: { check: { type: "boolean" }, yes: { type: "boolean", short: "y" } }, allowPositionals: true, strict: false });
1967
+ const info = await checkForUpdate(true);
1968
+ if (!info)
1969
+ fail("Could not determine the latest release (GitHub/npm unreachable, or nothing published yet).");
1970
+ if (!info.available) {
1971
+ console.log(`just-usage v${VERSION} is up to date.`);
1972
+ return;
1973
+ }
1974
+ console.log(`Update available: v${info.current} → v${info.latest}`);
1975
+ if (values.check)
1976
+ return;
1977
+ const pm = detectPackageManager();
1978
+ if (!values.yes && process.stdin.isTTY) {
1979
+ const answer = await prompt(`Upgrade now with ${pm}? [Y/n] `);
1980
+ if (answer && !/^y(es)?$/i.test(answer))
1981
+ return;
1982
+ }
1983
+ const code = await runUpgrade(pm, info.latest);
1984
+ if (code !== 0)
1985
+ fail(`Upgrade command exited with ${code}.`);
1986
+ console.log(`Upgraded to v${info.latest}.`);
1987
+ }
1988
+ async function main() {
1989
+ const argv = process.argv.slice(2);
1990
+ const cmd = argv[0];
1991
+ if (cmd === "--version" || cmd === "-v" || cmd === "version") {
1992
+ console.log(VERSION);
1993
+ return;
1994
+ }
1995
+ if (cmd === "--help" || cmd === "-h" || cmd === "help") {
1996
+ console.log(HELP);
1997
+ return;
1998
+ }
1999
+ switch (cmd) {
2000
+ case undefined:
2001
+ return cmdServe([]);
2002
+ case "serve":
2003
+ return cmdServe(argv.slice(1));
2004
+ case "status":
2005
+ return cmdStatus(argv.slice(1));
2006
+ case "accounts":
2007
+ return cmdAccounts();
2008
+ case "add":
2009
+ return cmdAdd(argv.slice(1));
2010
+ case "login":
2011
+ return cmdLogin(argv.slice(1));
2012
+ case "remove":
2013
+ case "rm":
2014
+ return cmdRemove(argv.slice(1));
2015
+ case "upgrade":
2016
+ case "update":
2017
+ return cmdUpgrade(argv.slice(1));
2018
+ default:
2019
+ if (cmd.startsWith("-"))
2020
+ return cmdServe(argv);
2021
+ fail(`Unknown command: ${cmd}
2022
+
2023
+ ${HELP}`);
2024
+ }
2025
+ }
2026
+ main().catch((e) => {
2027
+ console.error(e instanceof Error ? e.message : String(e));
2028
+ process.exit(1);
2029
+ });