just-usage 0.0.1 → 0.0.4

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 (3) hide show
  1. package/CHANGELOG.md +25 -0
  2. package/dist/cli.js +1532 -339
  3. package/package.json +3 -2
package/dist/cli.js CHANGED
@@ -1,6 +1,4 @@
1
1
  #!/usr/bin/env node
2
- import { createRequire } from "node:module";
3
- var __require = /* @__PURE__ */ createRequire(import.meta.url);
4
2
 
5
3
  // src/cli.ts
6
4
  import { parseArgs } from "node:util";
@@ -17,7 +15,7 @@ import { mkdirSync } from "node:fs";
17
15
  // package.json
18
16
  var package_default = {
19
17
  name: "just-usage",
20
- version: "0.0.1",
18
+ version: "0.0.4",
21
19
  description: "One local page for your coding-CLI subscription quotas: Claude, Codex, Cursor, OpenCode Go.",
22
20
  type: "module",
23
21
  license: "MIT",
@@ -44,13 +42,14 @@ var package_default = {
44
42
  files: [
45
43
  "dist",
46
44
  "README.md",
45
+ "CHANGELOG.md",
47
46
  "LICENSE"
48
47
  ],
49
48
  engines: {
50
49
  node: ">=20"
51
50
  },
52
51
  scripts: {
53
- dev: "bun run src/cli.ts",
52
+ dev: "bun --watch src/cli.ts",
54
53
  build: "bun run scripts/build.ts",
55
54
  test: "bun test",
56
55
  typecheck: "tsc --noEmit",
@@ -135,20 +134,41 @@ function clampPercent(v) {
135
134
  v = Number(v);
136
135
  if (typeof v !== "number" || !Number.isFinite(v))
137
136
  return null;
138
- return Math.min(100, Math.max(0, Math.round(v * 10) / 10));
137
+ if (v <= 0)
138
+ return 0;
139
+ if (v >= 100)
140
+ return 100;
141
+ const tenth = Math.round(v * 10) / 10;
142
+ if (tenth === 0)
143
+ return 0.1;
144
+ if (tenth === 100)
145
+ return 99.9;
146
+ return tenth;
147
+ }
148
+ function formatPercent(used) {
149
+ if (used === null)
150
+ return "—";
151
+ const n = clampPercent(used) ?? 0;
152
+ const tenth = Math.round(n * 10) / 10;
153
+ return Number.isInteger(tenth) ? `${tenth}%` : `${tenth.toFixed(1)}%`;
154
+ }
155
+ function formatPlan(plan) {
156
+ if (!plan || !plan.trim())
157
+ return null;
158
+ return plan.replace(/\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1));
139
159
  }
140
160
  function windowLabel(minutes) {
141
161
  if (minutes === null)
142
- return "Window";
162
+ return "Window Usage";
143
163
  if (minutes === 300)
144
- return "5h";
164
+ return "5h Usage";
145
165
  if (minutes === 10080)
146
- return "Weekly";
166
+ return "Weekly Usage";
147
167
  if (minutes % 1440 === 0)
148
- return `${minutes / 1440}d`;
168
+ return `${minutes / 1440}d Usage`;
149
169
  if (minutes % 60 === 0)
150
- return `${minutes / 60}h`;
151
- return `${minutes}m`;
170
+ return `${minutes / 60}h Usage`;
171
+ return `${minutes}m Usage`;
152
172
  }
153
173
  function formatDuration(ms) {
154
174
  if (ms <= 0)
@@ -183,9 +203,13 @@ function severity(usedPercent) {
183
203
  return "warn";
184
204
  return "ok";
185
205
  }
206
+ function formatHostname(name) {
207
+ return name.replace(/\.local$/, "").replace(/[-_]+/g, " ").replace(/\s+/g, " ").trim();
208
+ }
186
209
  function slugify(s) {
187
210
  return s.toLowerCase().replace(/@.*$/, "").replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40) || "account";
188
211
  }
212
+ var GENERIC_LABELS = new Set(["", "default", "token", "go", "pending", "profile", "account", "codex"]);
189
213
 
190
214
  // src/adapters/common.ts
191
215
  function snapshot(account, status, patch = {}) {
@@ -196,7 +220,7 @@ function snapshot(account, status, patch = {}) {
196
220
  provider: account.provider,
197
221
  label: account.label,
198
222
  email: email ?? account.email ?? null,
199
- plan: plan ?? null,
223
+ plan: formatPlan(plan) ?? null,
200
224
  kind: account.kind
201
225
  },
202
226
  status,
@@ -398,34 +422,56 @@ async function fetchCodex(account) {
398
422
  client?.close();
399
423
  }
400
424
  }
401
- async function codexLogin(codexHome, onAuthUrl, timeoutMs = 5 * 60000) {
425
+ async function startCodexLogin(codexHome, timeoutMs = 5 * 60000) {
402
426
  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
- });
427
+ let closed = false;
428
+ const close = () => {
429
+ if (closed)
430
+ return;
431
+ closed = true;
432
+ client.close();
433
+ };
434
+ const completed = new Promise((resolve, reject) => {
435
+ const timer = setTimeout(() => reject(new Error("login timed out")), timeoutMs);
436
+ client.onNotification(async (n) => {
437
+ if (n.method !== "account/login/completed")
438
+ return;
439
+ clearTimeout(timer);
440
+ const p = isObject(n.params) ? n.params : {};
441
+ if (!p.success) {
442
+ reject(new Error(typeof p.error === "string" ? p.error : "login failed"));
443
+ return;
444
+ }
445
+ try {
446
+ const acct = await client.request("account/read", {});
447
+ const info = isObject(acct.account) ? acct.account : {};
448
+ resolve({
449
+ email: typeof info.email === "string" ? info.email : null,
450
+ plan: typeof info.planType === "string" ? info.planType : null
451
+ });
452
+ } catch (e) {
453
+ reject(e instanceof Error ? e : new Error(String(e)));
454
+ }
416
455
  });
456
+ }).finally(close);
457
+ try {
417
458
  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();
459
+ if (typeof start.authUrl !== "string" || !start.authUrl)
460
+ throw new Error("Codex did not return an auth URL.");
461
+ return { authUrl: start.authUrl, completed, close };
462
+ } catch (e) {
463
+ close();
464
+ throw e;
465
+ }
466
+ }
467
+ async function codexLogin(codexHome, onAuthUrl, timeoutMs = 5 * 60000) {
468
+ const handle = await startCodexLogin(codexHome, timeoutMs);
469
+ onAuthUrl(handle.authUrl);
470
+ try {
471
+ return await handle.completed;
472
+ } catch (e) {
473
+ handle.close();
474
+ throw e;
429
475
  }
430
476
  }
431
477
 
@@ -517,9 +563,6 @@ function openInBrowser(url) {
517
563
  child.unref();
518
564
  } catch {}
519
565
  }
520
- function stripAnsi(s) {
521
- return s.replace(/\u001b\[[0-9;]*[A-Za-z]/g, "");
522
- }
523
566
  function withTimeout(p, ms, label) {
524
567
  return new Promise((resolve, reject) => {
525
568
  const t = setTimeout(() => reject(new Error(`${label} timed out after ${Math.round(ms / 1000)}s`)), ms);
@@ -603,10 +646,10 @@ function secretStore() {
603
646
  var USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
604
647
  var PROFILE_URL = "https://api.anthropic.com/api/oauth/profile";
605
648
  var FALLBACK_CLI_VERSION = "2.1.259";
606
- function keychainService(configDir2) {
607
- if (!configDir2)
649
+ function keychainService(configDir) {
650
+ if (!configDir)
608
651
  return "Claude Code-credentials";
609
- const hash = createHash("sha256").update(configDir2).digest("hex").slice(0, 8);
652
+ const hash = createHash("sha256").update(configDir).digest("hex").slice(0, 8);
610
653
  return `Claude Code-credentials-${hash}`;
611
654
  }
612
655
  function parseCreds(text) {
@@ -624,24 +667,24 @@ function parseCreds(text) {
624
667
  return null;
625
668
  }
626
669
  }
627
- async function readClaudeCredentials(configDir2) {
670
+ async function readClaudeCredentials(configDir) {
628
671
  if (process.platform === "darwin") {
629
- const res = await run("security", ["find-generic-password", "-s", keychainService(configDir2), "-w"], { timeoutMs: 20000 });
672
+ const res = await run("security", ["find-generic-password", "-s", keychainService(configDir), "-w"], { timeoutMs: 20000 });
630
673
  if (res.code === 0) {
631
674
  const creds = parseCreds(res.stdout.trim());
632
675
  if (creds)
633
676
  return creds;
634
677
  }
635
678
  }
636
- const file = join2(configDir2 ?? join2(homedir2(), ".claude"), ".credentials.json");
679
+ const file = join2(configDir ?? join2(homedir2(), ".claude"), ".credentials.json");
637
680
  if (existsSync2(file))
638
681
  return parseCreds(readFileSync2(file, "utf8"));
639
682
  return null;
640
683
  }
641
- async function claudeAuthStatus(configDir2) {
684
+ async function claudeAuthStatus(configDir) {
642
685
  const env = {};
643
- if (configDir2)
644
- env.CLAUDE_CONFIG_DIR = configDir2;
686
+ if (configDir)
687
+ env.CLAUDE_CONFIG_DIR = configDir;
645
688
  const res = await run("claude", ["auth", "status", "--json"], { env, timeoutMs: 15000 });
646
689
  if (res.code === null)
647
690
  return null;
@@ -681,17 +724,18 @@ function normalizeClaudeUsage(body) {
681
724
  return [];
682
725
  const out = [];
683
726
  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));
727
+ push(bucket(body.five_hour, "five_hour", "5h Usage", 300));
728
+ push(bucket(body.seven_day, "seven_day", "Weekly Usage", 10080));
729
+ push(bucket(body.seven_day_opus, "seven_day_opus", "Weekly · Opus Usage", 10080));
730
+ push(bucket(body.seven_day_sonnet, "seven_day_sonnet", "Weekly · Sonnet Usage", 10080));
731
+ push(bucket(body.seven_day_oauth_apps, "seven_day_oauth_apps", "Weekly · OAuth apps Usage", 10080));
689
732
  if (out.length === 0 && Array.isArray(body.limits)) {
690
733
  for (const [i, item] of body.limits.entries()) {
691
734
  if (!isObject(item))
692
735
  continue;
693
736
  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));
737
+ const label = name.replace(/_/g, " ");
738
+ push(bucket(item, `limits:${name}`, /usage$/i.test(label) ? label : `${label} Usage`, null));
695
739
  }
696
740
  }
697
741
  const extra = body.extra_usage;
@@ -720,14 +764,10 @@ async function resolveToken(account) {
720
764
  return { fail: snapshot(account, "error", { message: "Stored token missing. Run `just-usage remove` and add it again." }) };
721
765
  return { token, plan: null };
722
766
  }
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
767
  const creds = await readClaudeCredentials(account.path);
729
768
  if (!creds) {
730
- return { fail: snapshot(account, "error", { message: "Could not read Claude Code's credential (Keychain access denied or file missing)." }) };
769
+ const hint = account.kind === "default" ? "Run `claude` and `/login`." : `Run \`just-usage login ${account.id}\`.`;
770
+ return { fail: snapshot(account, "signed_out", { message: `Not signed in. ${hint}` }) };
731
771
  }
732
772
  if (creds.expiresAt && creds.expiresAt < Date.now()) {
733
773
  const hint = account.kind === "default" ? "Open `claude` once to refresh it." : `Run \`CLAUDE_CONFIG_DIR=${account.path} claude\` once to refresh it.`;
@@ -785,6 +825,9 @@ async function verifyClaudeToken(token) {
785
825
  return { ok: true, email, message: "ok" };
786
826
  }
787
827
 
828
+ // src/accounts.ts
829
+ import { renameSync, rmSync as rmSync2 } from "node:fs";
830
+
788
831
  // src/adapters/opencode.ts
789
832
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
790
833
  import { homedir as homedir3 } from "node:os";
@@ -812,9 +855,9 @@ function readOpenCodeGoKey() {
812
855
  }
813
856
  }
814
857
  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" }
858
+ { key: "rolling", id: "rolling", label: "5h Usage", minutes: 300, kind: "rolling" },
859
+ { key: "weekly", id: "weekly", label: "Weekly Usage", minutes: 10080, kind: "rolling" },
860
+ { key: "monthly", id: "monthly", label: "Monthly Usage", minutes: null, kind: "cycle" }
818
861
  ];
819
862
  function normalizeOpenCodeUsage(body) {
820
863
  if (!isObject(body))
@@ -848,7 +891,7 @@ async function fetchOpenCode(account) {
848
891
  if (status === 401)
849
892
  return snapshot(account, "error", { message: "Key rejected (401)." });
850
893
  if (status === 403)
851
- return snapshot(account, "unsupported", { message: "Valid key, but no active OpenCode Go subscription (403)." });
894
+ return snapshot(account, "unsupported", { message: "No active OpenCode Go subscription." });
852
895
  if (status === 429)
853
896
  return snapshot(account, "error", { message: "Rate limited (429). Try again shortly." });
854
897
  if (status !== 200)
@@ -861,28 +904,348 @@ async function fetchOpenCode(account) {
861
904
  }
862
905
  }
863
906
 
907
+ // src/registry.ts
908
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync2, rmSync } from "node:fs";
909
+ import { dirname as dirname2, join as join4 } from "node:path";
910
+ function readRegistry() {
911
+ const file = paths.registry();
912
+ if (!existsSync4(file))
913
+ return { version: 1, accounts: [] };
914
+ try {
915
+ const parsed = JSON.parse(readFileSync4(file, "utf8"));
916
+ return { version: 1, accounts: Array.isArray(parsed.accounts) ? parsed.accounts : [] };
917
+ } catch {
918
+ return { version: 1, accounts: [] };
919
+ }
920
+ }
921
+ function writeRegistry(reg) {
922
+ const file = paths.registry();
923
+ ensureDir(dirname2(file));
924
+ writeFileSync2(file, JSON.stringify(reg, null, 2) + `
925
+ `, { mode: 384 });
926
+ }
927
+ function listAccounts(provider) {
928
+ const all = readRegistry().accounts;
929
+ return provider ? all.filter((a) => a.provider === provider) : all;
930
+ }
931
+ function getAccount(id) {
932
+ return readRegistry().accounts.find((a) => a.id === id) ?? null;
933
+ }
934
+ function newAccountId(provider, hint) {
935
+ const existing = new Set(readRegistry().accounts.map((a) => a.id));
936
+ const base = `${provider}:${slugify(hint)}`;
937
+ if (base.endsWith(":default"))
938
+ return newAccountId(provider, `${hint}-2`);
939
+ if (!existing.has(base))
940
+ return base;
941
+ for (let i = 2;; i++) {
942
+ const candidate = `${base}-${i}`;
943
+ if (!existing.has(candidate))
944
+ return candidate;
945
+ }
946
+ }
947
+ function profileDirFor(provider, id) {
948
+ return join4(paths.profiles(provider), id.split(":")[1] ?? "account");
949
+ }
950
+ function saveAccount(record) {
951
+ const reg = readRegistry();
952
+ reg.accounts = reg.accounts.filter((a) => a.id !== record.id);
953
+ reg.accounts.push(record);
954
+ writeRegistry(reg);
955
+ }
956
+ function updateAccount(id, patch) {
957
+ const reg = readRegistry();
958
+ const idx = reg.accounts.findIndex((a) => a.id === id);
959
+ if (idx === -1)
960
+ return;
961
+ reg.accounts[idx] = { ...reg.accounts[idx], ...patch, id };
962
+ writeRegistry(reg);
963
+ }
964
+ function deleteAccount(id) {
965
+ const reg = readRegistry();
966
+ const record = reg.accounts.find((a) => a.id === id) ?? null;
967
+ if (!record)
968
+ return null;
969
+ reg.accounts = reg.accounts.filter((a) => a.id !== id);
970
+ writeRegistry(reg);
971
+ if (record.kind === "profile" && record.path && record.path.startsWith(paths.profiles(record.provider))) {
972
+ rmSync(record.path, { recursive: true, force: true });
973
+ }
974
+ return record;
975
+ }
976
+
977
+ // src/accounts.ts
978
+ class AccountError extends Error {
979
+ status;
980
+ constructor(message, status = 400) {
981
+ super(message);
982
+ this.status = status;
983
+ this.name = "AccountError";
984
+ }
985
+ }
986
+ async function addClaudeToken(token, label) {
987
+ const secret = token.trim();
988
+ if (!secret)
989
+ throw new AccountError("No token given.");
990
+ const check = await verifyClaudeToken(secret);
991
+ if (!check.ok)
992
+ throw new AccountError(`Token check failed: ${check.message}`);
993
+ const named = label?.trim() || "";
994
+ const id = newAccountId("claude", named || check.email || "account");
995
+ await secretStore().set(id, secret);
996
+ const account = {
997
+ id,
998
+ provider: "claude",
999
+ label: named,
1000
+ kind: "token",
1001
+ email: check.email,
1002
+ createdAt: new Date().toISOString()
1003
+ };
1004
+ saveAccount(account);
1005
+ return { account };
1006
+ }
1007
+ async function addOpenCodeKey(key, label) {
1008
+ const secret = key.trim();
1009
+ if (!secret)
1010
+ throw new AccountError("No key given.");
1011
+ const { status } = await fetchOpenCodeUsage(secret);
1012
+ if (status === 401)
1013
+ throw new AccountError("Key rejected (401).");
1014
+ if (status !== 200 && status !== 403)
1015
+ throw new AccountError(`Usage endpoint returned HTTP ${status}.`);
1016
+ const named = label?.trim() || "";
1017
+ const id = newAccountId("opencode", named || "account");
1018
+ await secretStore().set(id, secret);
1019
+ const account = {
1020
+ id,
1021
+ provider: "opencode",
1022
+ label: named,
1023
+ kind: "token",
1024
+ createdAt: new Date().toISOString()
1025
+ };
1026
+ saveAccount(account);
1027
+ return {
1028
+ account,
1029
+ warning: status === 403 ? "Key is valid but has no active OpenCode Go subscription." : undefined
1030
+ };
1031
+ }
1032
+ function renameExtraAccount(id, label) {
1033
+ if (id.endsWith(":default"))
1034
+ throw new AccountError("Default accounts are renamed on the page only.");
1035
+ if (!getAccount(id))
1036
+ throw new AccountError(`Unknown account: ${id}`, 404);
1037
+ updateAccount(id, { label: label.trim() });
1038
+ return getAccount(id);
1039
+ }
1040
+ async function removeExtraAccount(id) {
1041
+ if (id.endsWith(":default"))
1042
+ throw new AccountError("Default accounts belong to the CLI itself; sign out there instead.");
1043
+ const rec = deleteAccount(id);
1044
+ if (!rec)
1045
+ throw new AccountError(`Unknown account: ${id}`, 404);
1046
+ if (rec.kind === "token")
1047
+ await secretStore().delete(id);
1048
+ return rec;
1049
+ }
1050
+ function saveCodexProfile(opts) {
1051
+ const named = opts.label?.trim() || "";
1052
+ const finalLabel = named;
1053
+ const id = named ? opts.tmpId : newAccountId("codex", opts.email || opts.tmpId.split(":")[1] || "account");
1054
+ const path = id === opts.tmpId ? opts.dir : ensureDir(profileDirFor("codex", id));
1055
+ if (path !== opts.dir) {
1056
+ rmSync2(path, { recursive: true, force: true });
1057
+ renameSync(opts.dir, path);
1058
+ }
1059
+ const account = {
1060
+ id,
1061
+ provider: "codex",
1062
+ label: finalLabel,
1063
+ kind: "profile",
1064
+ path,
1065
+ email: opts.email,
1066
+ createdAt: new Date().toISOString()
1067
+ };
1068
+ saveAccount(account);
1069
+ return account;
1070
+ }
1071
+ function isLocalCallbackUrl(raw) {
1072
+ let u;
1073
+ try {
1074
+ u = new URL(raw.trim());
1075
+ } catch {
1076
+ return false;
1077
+ }
1078
+ if (u.protocol !== "http:" && u.protocol !== "https:")
1079
+ return false;
1080
+ const host = u.hostname.replace(/^\[|\]$/g, "").toLowerCase();
1081
+ return host === "localhost" || host === "127.0.0.1" || host === "::1";
1082
+ }
1083
+ async function submitLocalCallback(raw) {
1084
+ if (!isLocalCallbackUrl(raw))
1085
+ throw new AccountError("Paste the localhost callback URL from the sign-in redirect.");
1086
+ try {
1087
+ await fetch(raw.trim(), { redirect: "manual" });
1088
+ } catch (e) {
1089
+ throw new AccountError(`Could not reach the local callback: ${e instanceof Error ? e.message : String(e)}`);
1090
+ }
1091
+ }
1092
+ var SESSION_TTL_MS = 10 * 60000;
1093
+ var codexSessions = new Map;
1094
+ function dropCodexSession(id, removeDir) {
1095
+ const rec = codexSessions.get(id);
1096
+ if (!rec)
1097
+ return;
1098
+ clearTimeout(rec.timer);
1099
+ rec.handle.close();
1100
+ codexSessions.delete(id);
1101
+ if (removeDir && rec.status !== "done") {
1102
+ try {
1103
+ rmSync2(rec.dir, { recursive: true, force: true });
1104
+ } catch {}
1105
+ }
1106
+ }
1107
+ async function beginCodexAdd(label) {
1108
+ if (!await which("codex"))
1109
+ throw new AccountError("codex is not installed (npm i -g @openai/codex).");
1110
+ const tmpId = newAccountId("codex", label?.trim() || "pending");
1111
+ const dir = ensureDir(profileDirFor("codex", tmpId));
1112
+ const handle = await startCodexLogin(dir);
1113
+ const id = crypto.randomUUID();
1114
+ const rec = {
1115
+ id,
1116
+ label: label?.trim() || undefined,
1117
+ tmpId,
1118
+ dir,
1119
+ handle,
1120
+ status: "waiting",
1121
+ timer: setTimeout(() => dropCodexSession(id, true), SESSION_TTL_MS)
1122
+ };
1123
+ rec.timer.unref?.();
1124
+ handle.completed.then((info) => {
1125
+ rec.account = saveCodexProfile({ label: rec.label, tmpId, dir, email: info.email });
1126
+ rec.status = "done";
1127
+ }).catch((e) => {
1128
+ rec.status = "error";
1129
+ rec.error = e instanceof Error ? e.message : String(e);
1130
+ });
1131
+ codexSessions.set(id, rec);
1132
+ return { sessionId: id, authUrl: handle.authUrl };
1133
+ }
1134
+ async function beginCodexRelogin(accountId) {
1135
+ const existing = getAccount(accountId);
1136
+ if (!existing)
1137
+ throw new AccountError(`Unknown account: ${accountId}`, 404);
1138
+ if (existing.provider !== "codex" || existing.kind !== "profile" || !existing.path) {
1139
+ throw new AccountError(`${accountId} cannot be re-authenticated this way.`);
1140
+ }
1141
+ const handle = await startCodexLogin(existing.path);
1142
+ const id = crypto.randomUUID();
1143
+ const rec = {
1144
+ id,
1145
+ tmpId: existing.id,
1146
+ dir: existing.path,
1147
+ handle,
1148
+ status: "waiting",
1149
+ timer: setTimeout(() => dropCodexSession(id, false), SESSION_TTL_MS)
1150
+ };
1151
+ rec.timer.unref?.();
1152
+ handle.completed.then((info) => {
1153
+ updateAccount(existing.id, { email: info.email });
1154
+ rec.account = { ...existing, email: info.email };
1155
+ rec.status = "done";
1156
+ }).catch((e) => {
1157
+ rec.status = "error";
1158
+ rec.error = e instanceof Error ? e.message : String(e);
1159
+ });
1160
+ codexSessions.set(id, rec);
1161
+ return { sessionId: id, authUrl: handle.authUrl };
1162
+ }
1163
+ function codexSessionStatus(sessionId) {
1164
+ const rec = codexSessions.get(sessionId);
1165
+ if (!rec)
1166
+ return { status: "error", error: "Login session expired." };
1167
+ return { status: rec.status, authUrl: rec.handle.authUrl, error: rec.error, account: rec.account };
1168
+ }
1169
+ async function submitCodexCallback(sessionId, url) {
1170
+ if (!isLocalCallbackUrl(url))
1171
+ throw new AccountError("Paste the localhost callback URL from the sign-in redirect.");
1172
+ const rec = codexSessions.get(sessionId);
1173
+ if (!rec)
1174
+ throw new AccountError("Login session expired.", 404);
1175
+ if (rec.status !== "waiting")
1176
+ return;
1177
+ await submitLocalCallback(url);
1178
+ }
1179
+
864
1180
  // src/collect.ts
865
1181
  import { hostname } from "node:os";
866
1182
 
867
1183
  // src/adapters/cursor.ts
868
- import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
1184
+ import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
869
1185
  import { homedir as homedir4 } from "node:os";
870
- import { join as join4 } from "node:path";
1186
+ import { join as join5 } from "node:path";
871
1187
  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");
1188
+ var GROK_BOT_URL = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus";
1189
+ var PLAN_URL = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetPlanInfo";
1190
+ var KEYCHAIN_ACCOUNT = "cursor-user";
1191
+ var KEYCHAIN_SERVICE = "cursor-access-token";
1192
+ function tokenFromAuthJson(parsed) {
1193
+ if (!isObject(parsed))
1194
+ return null;
1195
+ return typeof parsed.accessToken === "string" && parsed.accessToken ? parsed.accessToken : null;
874
1196
  }
875
- function readAccessToken() {
876
- const file = authFile();
877
- if (!existsSync4(file))
1197
+ function tokenFromAuthFile(file) {
1198
+ if (!existsSync5(file))
878
1199
  return null;
879
1200
  try {
880
- const parsed = JSON.parse(readFileSync4(file, "utf8"));
881
- return typeof parsed.accessToken === "string" && parsed.accessToken ? parsed.accessToken : null;
1201
+ return tokenFromAuthJson(JSON.parse(readFileSync5(file, "utf8")));
882
1202
  } catch {
883
1203
  return null;
884
1204
  }
885
1205
  }
1206
+ function authFiles() {
1207
+ const home = homedir4();
1208
+ const out = [];
1209
+ if (process.platform === "win32") {
1210
+ const roaming = process.env.APPDATA || join5(home, "AppData", "Roaming");
1211
+ out.push(join5(roaming, "Cursor", "auth.json"));
1212
+ } else if (process.platform !== "darwin") {
1213
+ const xdg = process.env.XDG_CONFIG_HOME || join5(home, ".config");
1214
+ out.push(join5(xdg, "cursor", "auth.json"));
1215
+ }
1216
+ out.push(join5(home, ".cursor", "auth.json"));
1217
+ return out;
1218
+ }
1219
+ async function tokenFromKeychain() {
1220
+ if (process.platform !== "darwin")
1221
+ return null;
1222
+ const res = await run("security", ["find-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w"], { timeoutMs: 20000 });
1223
+ if (res.code !== 0)
1224
+ return null;
1225
+ const v = res.stdout.replace(/\r?\n$/, "");
1226
+ return v || null;
1227
+ }
1228
+ async function readCursorAccessToken() {
1229
+ const env = process.env.CURSOR_AUTH_TOKEN?.trim();
1230
+ if (env)
1231
+ return env;
1232
+ if (process.env.CURSOR_AUTH_FILE) {
1233
+ const fromFile = tokenFromAuthFile(process.env.CURSOR_AUTH_FILE);
1234
+ if (fromFile)
1235
+ return fromFile;
1236
+ }
1237
+ const fromKeychain = await tokenFromKeychain();
1238
+ if (fromKeychain)
1239
+ return fromKeychain;
1240
+ if (process.env.CURSOR_AUTH_FILE)
1241
+ return null;
1242
+ for (const file of authFiles()) {
1243
+ const fromFile = tokenFromAuthFile(file);
1244
+ if (fromFile)
1245
+ return fromFile;
1246
+ }
1247
+ return null;
1248
+ }
886
1249
  function jwtExpiry(token) {
887
1250
  const part = token.split(".")[1];
888
1251
  if (!part)
@@ -895,13 +1258,6 @@ function jwtExpiry(token) {
895
1258
  return null;
896
1259
  }
897
1260
  }
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
1261
  function cents(v) {
906
1262
  if (typeof v === "string" && v.trim())
907
1263
  v = Number(v);
@@ -917,28 +1273,12 @@ function normalizeCursorUsage(body) {
917
1273
  const out = [];
918
1274
  const plan = isObject(body.planUsage) ? body.planUsage : null;
919
1275
  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
1276
  const auto = clampPercent(plan.autoPercentUsed);
940
1277
  if (auto !== null)
941
- out.push({ id: "auto", label: "Auto models", usedPercent: auto, resetsAt: cycleEnd, windowMinutes: null, kind: "cycle" });
1278
+ out.push({ id: "auto", label: "Cursor Models", usedPercent: auto, resetsAt: cycleEnd, windowMinutes: null, kind: "cycle" });
1279
+ const api = clampPercent(plan.apiPercentUsed);
1280
+ if (api !== null)
1281
+ out.push({ id: "api", label: "Other Models", usedPercent: api, resetsAt: cycleEnd, windowMinutes: null, kind: "cycle" });
942
1282
  }
943
1283
  const spend = isObject(body.spendLimitUsage) ? body.spendLimitUsage : null;
944
1284
  if (spend) {
@@ -971,37 +1311,82 @@ function normalizeCursorUsage(body) {
971
1311
  }
972
1312
  return out.length ? out : null;
973
1313
  }
1314
+ function normalizeGrokBotUsage(body) {
1315
+ if (!isObject(body))
1316
+ return null;
1317
+ const src = isObject(body.usage) ? body.usage : body;
1318
+ if (src.usesPooledEnterpriseAllowance === true)
1319
+ return null;
1320
+ if (src.hasNonZeroIncludedLimit === false)
1321
+ return null;
1322
+ if (src.includedLimitZero === true)
1323
+ return null;
1324
+ const used = clampPercent(src.usagePercent);
1325
+ if (used === null)
1326
+ return null;
1327
+ return {
1328
+ id: "grok_bot",
1329
+ label: "Weekly Usage",
1330
+ group: "Grok Bot",
1331
+ usedPercent: used,
1332
+ resetsAt: isoOrNull(src.nextResetTimestampUtc) ?? epochToIso(src.nextResetTimestampUtc),
1333
+ windowMinutes: 10080,
1334
+ kind: "rolling"
1335
+ };
1336
+ }
1337
+ function insertGrokBot(windows, grok) {
1338
+ const extra = windows.findIndex((w) => w.id === "on_demand" || w.id === "pooled");
1339
+ if (extra === -1)
1340
+ return [...windows, grok];
1341
+ return [...windows.slice(0, extra), grok, ...windows.slice(extra)];
1342
+ }
1343
+ function planFromCursorPlanInfo(body) {
1344
+ if (!isObject(body))
1345
+ return null;
1346
+ const info = isObject(body.planInfo) ? body.planInfo : body;
1347
+ return typeof info.planName === "string" && info.planName.trim() ? info.planName.trim() : null;
1348
+ }
974
1349
  async function fetchCursor(account) {
975
1350
  try {
976
- const token = readAccessToken();
1351
+ const token = await readCursorAccessToken();
977
1352
  if (!token)
978
1353
  return snapshot(account, "signed_out", { message: "Not signed in. Run `cursor-agent login`." });
979
1354
  const exp = jwtExpiry(token);
980
- const emailP = cursorEmail().catch(() => null);
981
1355
  if (exp && exp < Date.now()) {
982
- return snapshot(account, "error", { email: await emailP, message: "Cursor session expired. Run `cursor-agent login`." });
1356
+ return snapshot(account, "error", { email: account.email ?? null, message: "Cursor session expired. Run `cursor-agent login`." });
983
1357
  }
984
- const res = await fetchJson(USAGE_URL3, {
1358
+ const headers = {
1359
+ Authorization: `Bearer ${token}`,
1360
+ "Content-Type": "application/json",
1361
+ "Connect-Protocol-Version": "1"
1362
+ };
1363
+ const extra = {
985
1364
  method: "POST",
986
- headers: {
987
- Authorization: `Bearer ${token}`,
988
- "Content-Type": "application/json",
989
- "Connect-Protocol-Version": "1"
990
- },
1365
+ headers,
991
1366
  body: "{}"
992
- });
993
- const email = await emailP;
1367
+ };
1368
+ const grokReq = fetchJson(GROK_BOT_URL, extra).catch(() => null);
1369
+ const planReq = fetchJson(PLAN_URL, extra).catch(() => null);
1370
+ const res = await fetchJson(USAGE_URL3, extra);
1371
+ const email = account.email ?? null;
994
1372
  if (res.status === 401 || res.status === 403) {
995
1373
  return snapshot(account, "error", { email, message: `Cursor rejected the session (${res.status}). Run \`cursor-agent login\`.` });
996
1374
  }
997
1375
  if (res.status !== 200) {
998
1376
  return snapshot(account, "error", { email, message: `Cursor usage endpoint returned HTTP ${res.status}.` });
999
1377
  }
1000
- const windows = normalizeCursorUsage(res.body);
1378
+ let windows = normalizeCursorUsage(res.body);
1001
1379
  if (!windows) {
1002
1380
  return snapshot(account, "unsupported", { email, message: "Cursor returned no plan usage for this account (team/enterprise plans are not supported yet)." });
1003
1381
  }
1004
- return snapshot(account, "ok", { email, windows });
1382
+ const [grokRes, planRes] = await Promise.all([grokReq, planReq]);
1383
+ if (grokRes?.status === 200) {
1384
+ const grok = normalizeGrokBotUsage(grokRes.body);
1385
+ if (grok)
1386
+ windows = insertGrokBot(windows, grok);
1387
+ }
1388
+ const plan = planRes?.status === 200 ? planFromCursorPlanInfo(planRes.body) : null;
1389
+ return snapshot(account, "ok", { email, plan, windows });
1005
1390
  } catch (e) {
1006
1391
  return snapshot(account, "error", { message: errorMessage(e) });
1007
1392
  }
@@ -1021,76 +1406,6 @@ function fetchSnapshot(account) {
1021
1406
  }
1022
1407
  }
1023
1408
 
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
1409
  // src/types.ts
1095
1410
  var PROVIDERS = [
1096
1411
  { id: "claude", name: "Claude", bin: "claude" },
@@ -1106,8 +1421,7 @@ function providerName(id) {
1106
1421
  async function detectProviders() {
1107
1422
  return Promise.all(PROVIDERS.map(async (p) => {
1108
1423
  const path = await which(p.bin);
1109
- const version = path ? await binVersion(p.bin) : null;
1110
- return { id: p.id, installed: path !== null, version };
1424
+ return { id: p.id, installed: path !== null, version: null };
1111
1425
  }));
1112
1426
  }
1113
1427
  function resolveAccounts(provider, installed) {
@@ -1132,12 +1446,15 @@ async function collectReport(update, only) {
1132
1446
  const providers = await Promise.all(PROVIDERS.filter((p) => !only || only.includes(p.id)).map(async (p) => {
1133
1447
  const pres = presence.find((x) => x.id === p.id);
1134
1448
  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 };
1449
+ const [snapshots, version] = await Promise.all([
1450
+ Promise.all(accounts.map(fetchAccount)),
1451
+ pres.installed ? binVersion(p.bin) : Promise.resolve(null)
1452
+ ]);
1453
+ return { id: p.id, name: p.name, installed: pres.installed, version, accounts: snapshots };
1137
1454
  }));
1138
1455
  return {
1139
1456
  version: VERSION,
1140
- hostname: hostname().replace(/\.local$/, ""),
1457
+ hostname: formatHostname(hostname()),
1141
1458
  fetchedAt: new Date().toISOString(),
1142
1459
  update,
1143
1460
  providers
@@ -1154,22 +1471,28 @@ class ReportCache {
1154
1471
  this.getUpdate = getUpdate;
1155
1472
  }
1156
1473
  get(force = false) {
1474
+ const cached = this.report ? { ...this.report, update: this.getUpdate() } : null;
1475
+ const fresh = cached && Date.now() - Date.parse(cached.fetchedAt) < this.ttlMs;
1476
+ if (!force && fresh)
1477
+ return Promise.resolve(cached);
1157
1478
  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
- }
1479
+ return cached && !force ? Promise.resolve(cached) : this.inflight;
1162
1480
  this.inflight = collectReport(this.getUpdate()).then((r) => {
1163
1481
  this.report = r;
1164
1482
  return r;
1165
1483
  }).finally(() => {
1166
1484
  this.inflight = null;
1167
1485
  });
1486
+ if (!force && cached)
1487
+ return Promise.resolve(cached);
1168
1488
  return this.inflight;
1169
1489
  }
1170
1490
  peek() {
1171
1491
  return this.report ? { ...this.report, update: this.getUpdate() } : null;
1172
1492
  }
1493
+ invalidate() {
1494
+ this.report = null;
1495
+ }
1173
1496
  }
1174
1497
 
1175
1498
  // src/server.ts
@@ -1209,39 +1532,48 @@ var ui_default = `<!doctype html>
1209
1532
  .mono { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; font-variant-numeric: tabular-nums; }
1210
1533
  .wrap { max-width: 820px; margin: 0 auto; padding: 44px 20px 64px; }
1211
1534
 
1212
- header { display: flex; align-items: baseline; justify-content: space-between; margin-bottom: 26px; gap: 16px; }
1535
+ header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 22px; gap: 16px; }
1213
1536
  h1 { font-size: 15px; font-weight: 500; letter-spacing: .01em; margin: 0; }
1214
1537
  h1 span { color: var(--dim); font-weight: 400; margin-left: 8px; }
1215
1538
  .meta { color: var(--muted); font-size: 12px; display: flex; gap: 14px; align-items: center; }
1216
- button.refresh {
1539
+ button.opts-btn {
1540
+ display: inline-flex; align-items: center; gap: 6px;
1217
1541
  background: transparent; color: var(--fg); border: 1px solid var(--line-2); border-radius: 6px;
1218
1542
  padding: 5px 11px; font: inherit; font-size: 12px; cursor: pointer;
1219
1543
  }
1220
- button.refresh:hover { border-color: #3a3a3a; }
1221
- button.refresh[disabled] { opacity: .45; cursor: default; }
1544
+ button.opts-btn:hover { border-color: #3a3a3a; }
1545
+ button.opts-btn svg { width: 13px; height: 13px; display: block; flex: none; }
1546
+ button.opts-btn .icon-back { display: none; }
1547
+ button.opts-btn.back .icon-gear { display: none; }
1548
+ button.opts-btn.back .icon-back { display: block; }
1222
1549
 
1223
- /* Chrome-style tab strip */
1224
- .tabs { display: flex; gap: 2px; padding: 0 8px; position: relative; z-index: 1; }
1550
+ .tabs {
1551
+ display: flex; gap: 4px; flex-wrap: wrap;
1552
+ border-bottom: 1px solid var(--line);
1553
+ margin: 0 0 16px; padding: 0;
1554
+ }
1225
1555
  .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); }
1556
+ background: none; border: none; border-bottom: 2px solid transparent;
1557
+ color: var(--muted); padding: 9px 12px 10px; margin-bottom: -1px; cursor: pointer;
1558
+ font: inherit; font-size: 13px; display: flex; gap: 8px; align-items: center; user-select: none;
1559
+ }
1560
+ .tab:hover { color: var(--fg); }
1561
+ .tab.active { color: var(--fg); border-bottom-color: var(--fg); }
1562
+ .tab img.logo { width: 14px; height: 14px; display: block; flex: none; opacity: .55; }
1563
+ .tab.active img.logo, .tab:hover img.logo { opacity: 1; }
1240
1564
  .tab .n { color: var(--dim); font-size: 11px; }
1241
1565
 
1242
1566
  .panel { background: var(--surface); border: 1px solid var(--line); border-radius: 10px; min-height: 160px; }
1567
+ .panel.cards {
1568
+ display: grid; grid-template-columns: 1fr 1fr; gap: 12px; align-items: stretch;
1569
+ background: transparent; border: none; min-height: 0;
1570
+ }
1243
1571
  .card { padding: 18px 18px 20px; border-bottom: 1px solid var(--line); }
1244
1572
  .card:last-child { border-bottom: none; }
1573
+ .panel.cards .card {
1574
+ background: var(--surface); border: 1px solid var(--line); border-radius: 10px;
1575
+ border-bottom: 1px solid var(--line); min-width: 0; height: 100%;
1576
+ }
1245
1577
  .card-head { display: flex; justify-content: space-between; align-items: baseline; gap: 12px; }
1246
1578
  .label { font-weight: 500; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
1247
1579
  .plan { color: var(--muted); font-size: 12px; text-transform: capitalize; margin-left: 8px; font-weight: 400; }
@@ -1250,28 +1582,147 @@ var ui_default = `<!doctype html>
1250
1582
  .badge.warn { color: var(--warn); border-color: rgba(245,197,66,.35); }
1251
1583
 
1252
1584
  .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; }
1585
+ .win-group {
1586
+ margin: 28px 0 0; font-size: 16px; font-weight: 500; color: var(--fg);
1587
+ }
1588
+ .win-group + .win { margin-top: 14px; }
1589
+ .win-row { display: flex; justify-content: space-between; align-items: baseline; font-size: 12px; color: var(--muted); margin-bottom: 8px; gap: 12px; }
1254
1590
  .win-row b { color: var(--fg); font-weight: 500; }
1255
1591
  .win-row .pct { color: var(--fg); }
1256
1592
  .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); }
1593
+ .track { height: 6px; background: var(--line); border-radius: 3px; overflow: hidden; }
1594
+ .fill { height: 100%; border-radius: 3px; background: var(--ok); width: 0; transition: width .5s cubic-bezier(.2,.7,.2,1); }
1259
1595
  .fill.warn { background: var(--warn); } .fill.crit { background: var(--crit); }
1596
+ .fill.sliver { min-width: 8px; }
1260
1597
  .note { color: var(--dim); font-size: 11px; margin-top: 5px; }
1261
1598
 
1262
1599
  .msg { color: var(--muted); font-size: 13px; margin-top: 6px; }
1263
1600
  .empty { padding: 46px 18px; color: var(--muted); text-align: center; font-size: 13px; }
1264
1601
  .hint { color: var(--dim); font-size: 12px; margin-top: 8px; }
1265
1602
  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); }
1603
+ .credits {
1604
+ display: flex; justify-content: space-between; align-items: baseline; gap: 12px;
1605
+ margin-top: 16px; padding-top: 14px; border-top: 1px dashed var(--line);
1606
+ font-size: 12px;
1607
+ }
1608
+ .credits b { color: var(--fg); font-weight: 500; }
1609
+ .credits-avail {
1610
+ position: relative; color: var(--muted); cursor: help;
1611
+ border-bottom: 1px dotted var(--dim);
1612
+ }
1613
+ .credits-avail:hover, .credits-avail:focus-visible { color: var(--fg); }
1614
+ .credits-tip {
1615
+ display: none; position: absolute; right: 0; bottom: calc(100% + 8px);
1616
+ min-width: 240px; padding: 8px 10px;
1617
+ background: var(--surface); border: 1px solid var(--line-2); border-radius: 8px;
1618
+ color: var(--muted); font-size: 11px; line-height: 1.45; white-space: nowrap; z-index: 5;
1619
+ }
1620
+ .credits-avail:hover .credits-tip, .credits-avail:focus-visible .credits-tip { display: block; }
1621
+ .credits-tip-row { display: flex; align-items: baseline; gap: 12px; }
1622
+ .credits-tip-row + .credits-tip-row { margin-top: 6px; }
1623
+ .credits-tip-n { color: var(--fg); font-variant-numeric: tabular-nums; min-width: 1em; }
1624
+ .credits-tip-date { color: var(--fg); flex: 1; }
1625
+ .credits-tip-left { color: var(--dim); font-variant-numeric: tabular-nums; margin-left: auto; }
1626
+
1627
+ #dash[hidden], #settings[hidden] { display: none; }
1628
+ .settings { display: flex; flex-direction: column; gap: 22px; }
1629
+ .opts-label { font-size: 11px; color: var(--dim); letter-spacing: .04em; text-transform: uppercase; margin-bottom: 8px; }
1630
+ .opts-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; user-select: none; }
1631
+ .opts-row.dragging {
1632
+ background: var(--surface); border: 1px solid var(--line-2); border-radius: 8px; padding: 6px 8px;
1633
+ }
1634
+ .opts-prov { display: flex; align-items: center; gap: 8px; min-width: 0; }
1635
+ .opts-row img.logo { width: 14px; height: 14px; }
1636
+ .grip {
1637
+ width: 10px; height: 14px; flex: none; cursor: grab; touch-action: none;
1638
+ background-image: radial-gradient(circle, var(--dim) 1px, transparent 1.2px);
1639
+ background-size: 5px 5px; background-position: 0 1px;
1640
+ }
1641
+ .grip:active { cursor: grabbing; }
1642
+ .chk {
1643
+ appearance: none; -webkit-appearance: none;
1644
+ width: 15px; height: 15px; margin: 0; padding: 0;
1645
+ border: 1px solid var(--line-2); border-radius: 4px;
1646
+ background: transparent; cursor: pointer; flex: none;
1647
+ display: grid; place-items: center;
1648
+ }
1649
+ .chk:hover { border-color: var(--muted); }
1650
+ .chk:checked { background: var(--fg); border-color: var(--fg); }
1651
+ .chk:checked::after {
1652
+ content: ""; width: 7px; height: 4px;
1653
+ border-left: 1.5px solid var(--bg);
1654
+ border-bottom: 1.5px solid var(--bg);
1655
+ transform: translateY(-1px) rotate(-45deg);
1656
+ }
1657
+ .opts-inline { display: flex; align-items: center; justify-content: space-between; gap: 12px; }
1658
+ .opts-inline .opts-label { margin: 0; }
1659
+ .opts-inline.tight { margin-bottom: 8px; }
1660
+ .seg { display: inline-flex; border: 1px solid var(--line-2); border-radius: 6px; overflow: hidden; width: auto; }
1661
+ .seg label { text-align: center; font-size: 11px; padding: 3px 8px; color: var(--muted); cursor: pointer; }
1662
+ .seg input { appearance: none; position: absolute; }
1663
+ .seg label:has(input:checked) { background: #121212; color: var(--fg); }
1664
+ .opts-actions { display: flex; justify-content: flex-end; align-items: center; gap: 12px; padding-top: 2px; }
1665
+ .opts-actions:has(#opts-signed-in:not([hidden])) { justify-content: space-between; }
1666
+ .opts-link { background: none; border: none; color: var(--muted); font: inherit; font-size: 12px; cursor: pointer; padding: 0; }
1667
+ .opts-link:hover { color: var(--fg); }
1668
+ .opts-link[hidden] { display: none; }
1669
+ .acct-group { padding-bottom: 12px; }
1670
+ .acct-group + .acct-group { border-top: 1px solid var(--line); padding-top: 12px; }
1671
+ .acct-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 13px; }
1672
+ .acct-head img.logo { width: 14px; height: 14px; }
1673
+ .acct-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 5px 0; font-size: 13px; }
1674
+ .acct-actions { display: flex; align-items: center; gap: 10px; flex: none; }
1675
+ .acct-row .acct-actions { opacity: 0; }
1676
+ .acct-row:hover .acct-actions, .acct-row:focus-within .acct-actions { opacity: 1; }
1677
+ .dlg-body .acct-actions { justify-content: space-between; width: 100%; }
1678
+ .acct-meta { color: var(--dim); font-size: 12px; margin-left: 8px; }
1679
+ .acct-rename { display: flex; gap: 8px; flex: 1; min-width: 0; }
1680
+ dialog.dlg {
1681
+ width: min(420px, calc(100vw - 32px));
1682
+ background: var(--surface); color: var(--fg);
1683
+ border: 1px solid var(--line); border-radius: 12px;
1684
+ padding: 0;
1685
+ }
1686
+ dialog.dlg::backdrop { background: rgba(0, 0, 0, .9); }
1687
+ .dlg-head {
1688
+ display: flex; justify-content: space-between; align-items: center;
1689
+ padding: 14px 16px; border-bottom: 1px solid var(--line);
1690
+ }
1691
+ .dlg-head strong { font-weight: 500; font-size: 13px; }
1692
+ .dlg-body { padding: 14px 16px 16px; display: flex; flex-direction: column; gap: 12px; }
1693
+ .pick { display: flex; flex-direction: column; gap: 6px; }
1694
+ .pick-btn {
1695
+ display: flex; align-items: center; gap: 10px;
1696
+ background: transparent; color: var(--fg); border: 1px solid var(--line-2); border-radius: 8px;
1697
+ padding: 8px 10px; font: inherit; font-size: 13px; cursor: pointer; text-align: left;
1698
+ }
1699
+ .pick-btn:hover { border-color: #3a3a3a; }
1700
+ .pick-btn img.logo { width: 14px; height: 14px; }
1701
+ .field {
1702
+ background: #121212; color: var(--fg); border: 1px solid var(--line-2); border-radius: 6px;
1703
+ padding: 6px 10px; font: inherit; font-size: 12px; min-width: 0; flex: 1;
1704
+ }
1705
+ .field:focus { outline: none; border-color: var(--muted); }
1706
+ .acct-note { color: var(--dim); font-size: 12px; }
1707
+ .acct-msg { color: var(--muted); font-size: 12px; }
1708
+ .acct-msg.err { color: var(--crit); }
1709
+ .acct-oauth { display: flex; flex-direction: column; gap: 8px; }
1710
+ .acct-url { font-size: 12px; color: var(--fg); word-break: break-all; }
1711
+ .acct-url a { color: var(--fg); }
1712
+ button.refresh {
1713
+ background: transparent; color: var(--fg); border: 1px solid var(--line-2); border-radius: 6px;
1714
+ padding: 5px 11px; font: inherit; font-size: 12px; cursor: pointer;
1715
+ }
1716
+ button.refresh:hover { border-color: #3a3a3a; }
1717
+ button.refresh[disabled] { opacity: .45; cursor: default; }
1267
1718
 
1268
1719
  footer { margin-top: 26px; color: var(--dim); font-size: 12px; display: flex; justify-content: space-between; gap: 12px; flex-wrap: wrap; }
1269
1720
  footer .update { color: var(--warn); }
1270
- footer kbd { font-family: inherit; color: var(--dim); }
1271
1721
  @media (max-width: 520px) {
1272
1722
  .wrap { padding: 28px 12px 48px; }
1273
- .tab { padding: 8px 11px; font-size: 12px; }
1723
+ .tab { padding: 8px 10px; font-size: 12px; }
1274
1724
  .tab .n { display: none; }
1725
+ .panel.cards { grid-template-columns: 1fr; }
1275
1726
  }
1276
1727
  </style>
1277
1728
  </head>
@@ -1280,18 +1731,56 @@ var ui_default = `<!doctype html>
1280
1731
  <header>
1281
1732
  <h1>just-usage<span id="host"></span></h1>
1282
1733
  <div class="meta">
1283
- <span id="updated" class="mono"></span>
1284
- <button class="refresh" id="refresh" type="button">Refresh</button>
1734
+ <span id="updated"></span>
1735
+ <button class="opts-btn" id="opts-btn" type="button"><svg class="icon-gear" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/></svg><svg class="icon-back" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M15 18 9 12l6-6"/><path d="M9 12h12"/></svg><span id="opts-label">Options</span></button>
1285
1736
  </div>
1286
1737
  </header>
1738
+ <div id="dash">
1287
1739
  <nav class="tabs" id="tabs" role="tablist"></nav>
1288
1740
  <section class="panel" id="panel" role="tabpanel"><div class="empty">Loading…</div></section>
1741
+ </div>
1742
+ <div id="settings" class="settings" hidden>
1743
+ <div>
1744
+ <div class="opts-label">Providers</div>
1745
+ <div id="opts-providers"></div>
1746
+ </div>
1747
+ <div class="opts-inline">
1748
+ <div class="opts-label">Meters</div>
1749
+ <div class="seg" id="opts-meter">
1750
+ <label><input type="radio" name="meter" value="left" checked> Left</label>
1751
+ <label><input type="radio" name="meter" value="used"> Used</label>
1752
+ </div>
1753
+ </div>
1754
+ <label class="opts-row" for="opts-email">
1755
+ <span>Show email on accounts</span>
1756
+ <input class="chk" id="opts-email" type="checkbox">
1757
+ </label>
1758
+ <div>
1759
+ <div class="opts-inline tight">
1760
+ <div class="opts-label">Accounts</div>
1761
+ <button class="refresh" id="acct-add" type="button">Add account</button>
1762
+ </div>
1763
+ <div id="opts-accounts"></div>
1764
+ </div>
1765
+ <div class="opts-actions">
1766
+ <button class="opts-link" id="opts-signed-in" type="button">Signed-in only</button>
1767
+ <button class="refresh" id="refresh" type="button">Refresh data</button>
1768
+ </div>
1769
+ </div>
1289
1770
  <footer>
1290
1771
  <span id="version"></span>
1291
1772
  <span id="update"></span>
1292
1773
  </footer>
1293
1774
  </div>
1294
1775
 
1776
+ <dialog class="dlg" id="add-acct">
1777
+ <div class="dlg-head">
1778
+ <strong id="add-acct-title">Add account</strong>
1779
+ <button class="opts-link" id="add-acct-close" type="button">Close</button>
1780
+ </div>
1781
+ <div class="dlg-body" id="add-acct-body"></div>
1782
+ </dialog>
1783
+
1295
1784
  <script>
1296
1785
  (() => {
1297
1786
  const PROVIDERS = [
@@ -1308,15 +1797,69 @@ var ui_default = `<!doctype html>
1308
1797
  };
1309
1798
  const $ = (id) => document.getElementById(id);
1310
1799
  let report = null;
1311
- const wanted = new URLSearchParams(location.search).get("tab");
1800
+ try {
1801
+ const cached = JSON.parse(localStorage.getItem("ju.report"));
1802
+ if (cached && Array.isArray(cached.providers)) report = cached;
1803
+ } catch {}
1804
+ const params = new URLSearchParams(location.search);
1805
+ const wanted = params.get("tab");
1312
1806
  let active = PROVIDERS.some(([id]) => id === wanted) ? wanted : localStorage.getItem("ju.tab") || "claude";
1807
+ let settingsOpen = params.get("view") === "settings";
1313
1808
  let loading = false;
1809
+ let busy = false;
1810
+ let showEmail = localStorage.getItem("ju.showEmail") === "1";
1811
+ let aliases = {};
1812
+ try {
1813
+ const raw = JSON.parse(localStorage.getItem("ju.aliases"));
1814
+ if (raw && typeof raw === "object") aliases = raw;
1815
+ } catch {}
1816
+ let renaming = null;
1817
+ let wizard = { step: "provider", provider: "", label: "", secret: "", sessionId: "", authUrl: "", callback: "", msg: "", err: false };
1818
+ let codexPoll = 0;
1819
+ let enabled = null;
1820
+ try {
1821
+ const raw = JSON.parse(localStorage.getItem("ju.providers"));
1822
+ if (raw && typeof raw === "object") enabled = raw;
1823
+ } catch {}
1824
+ let dragging = false;
1825
+ let showLeft = localStorage.getItem("ju.showLeft") !== "0";
1826
+ let order = PROVIDERS.map(([id]) => id);
1827
+ try {
1828
+ const raw = JSON.parse(localStorage.getItem("ju.order"));
1829
+ if (Array.isArray(raw)) {
1830
+ const known = new Set(order);
1831
+ const next = raw.filter((id) => known.has(id));
1832
+ for (const id of order) if (!next.includes(id)) next.push(id);
1833
+ order = next;
1834
+ }
1835
+ } catch {}
1314
1836
 
1315
1837
  const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
1316
- // Escape, then turn \`backticked\` spans into <code>.
1317
1838
  const rich = (s) => esc(s).replace(/\`([^\`]+)\`/g, "<code>$1</code>");
1318
1839
  const sev = (p) => (p == null ? null : p >= 85 ? "crit" : p >= 60 ? "warn" : "ok");
1319
1840
  const rank = { ok: 1, warn: 2, crit: 3 };
1841
+ const logo = (id) => \`<img class="logo" src="/logos/\${id}.svg?v=2" alt="" width="14" height="14">\`;
1842
+ const formatPlan = (p) => String(p).replace(/\\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1));
1843
+ const GENERIC_LABELS = new Set(["", "default", "token", "go", "pending", "profile", "account", "codex"]);
1844
+ function isGenericLabel(label, email) {
1845
+ const s = String(label ?? "").trim();
1846
+ if (!s) return true;
1847
+ if (email && s === email) return true;
1848
+ return GENERIC_LABELS.has(s.toLowerCase());
1849
+ }
1850
+ function accountTitle(a, index) {
1851
+ if (showEmail && a.account.email) return a.account.email;
1852
+ const alias = String(aliases[a.account.id] || "").trim();
1853
+ if (alias) return alias;
1854
+ if (a.account.kind === "default" || /^default$/i.test(String(a.account.label || "").trim())) return "Default";
1855
+ if (!isGenericLabel(a.account.label, a.account.email)) return String(a.account.label).trim();
1856
+ return \`Account \${index + 1}\`;
1857
+ }
1858
+ function formatPercent(used) {
1859
+ if (used == null) return "—";
1860
+ const tenth = Math.round(Math.min(100, Math.max(0, used)) * 10) / 10;
1861
+ return Number.isInteger(tenth) ? \`\${tenth}%\` : \`\${tenth.toFixed(1)}%\`;
1862
+ }
1320
1863
 
1321
1864
  function fmtDuration(ms) {
1322
1865
  const min = Math.round(ms / 60000);
@@ -1336,31 +1879,80 @@ var ui_default = `<!doctype html>
1336
1879
  }
1337
1880
  return diff <= 0 ? "resetting" : \`resets in \${fmtDuration(diff)}\`;
1338
1881
  }
1339
- function fmtTime(iso) {
1882
+ function fmtAgo(iso) {
1340
1883
  const t = Date.parse(iso);
1341
- return isFinite(t) ? new Date(t).toLocaleTimeString(undefined, { hour: "2-digit", minute: "2-digit" }) : "";
1884
+ if (!isFinite(t)) return "";
1885
+ const sec = Math.max(0, Math.round((Date.now() - t) / 1000));
1886
+ if (sec < 20) return "just now";
1887
+ if (sec < 60) return \`\${sec} sec ago\`;
1888
+ const min = Math.round(sec / 60);
1889
+ if (min < 60) return min === 1 ? "1 min ago" : \`\${min} min ago\`;
1890
+ const hr = Math.round(min / 60);
1891
+ if (hr < 48) return hr === 1 ? "1 hr ago" : \`\${hr} hr ago\`;
1892
+ const d = Math.round(hr / 24);
1893
+ return d === 1 ? "1 day ago" : \`\${d} days ago\`;
1342
1894
  }
1343
1895
 
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;
1896
+ function isSignedIn(p) {
1897
+ return !!p?.accounts.some((a) => a.status !== "signed_out");
1898
+ }
1899
+
1900
+ function visibleIds() {
1901
+ if (enabled) return order.filter((id) => enabled[id]);
1902
+ if (!report) return [];
1903
+ return order.filter((id) => isSignedIn(report.providers.find((x) => x.id === id)));
1904
+ }
1905
+
1906
+ function ensureActive() {
1907
+ const vis = visibleIds();
1908
+ if (!vis.length || vis.includes(active)) return;
1909
+ active = vis[0];
1910
+ localStorage.setItem("ju.tab", active);
1911
+ }
1912
+
1913
+ function persistEnabled(next) {
1914
+ enabled = next;
1915
+ if (next) localStorage.setItem("ju.providers", JSON.stringify(next));
1916
+ else localStorage.removeItem("ju.providers");
1917
+ ensureActive();
1918
+ if (settingsOpen) {
1919
+ renderTabs();
1920
+ $("opts-signed-in").hidden = !enabled;
1921
+ } else render();
1922
+ }
1923
+
1924
+ function persistOrder(next) {
1925
+ order = next;
1926
+ localStorage.setItem("ju.order", JSON.stringify(next));
1927
+ ensureActive();
1928
+ if (settingsOpen) renderTabs();
1929
+ else render();
1930
+ }
1931
+
1932
+ function setView(open) {
1933
+ settingsOpen = open;
1934
+ $("dash").hidden = open;
1935
+ $("settings").hidden = !open;
1936
+ const btn = $("opts-btn");
1937
+ $("opts-label").textContent = open ? "Back" : "Options";
1938
+ const q = new URLSearchParams();
1939
+ if (open) q.set("view", "settings");
1940
+ else q.set("tab", active);
1941
+ history.replaceState(null, "", q.toString() ? \`?\${q}\` : location.pathname);
1942
+ if (open) {
1943
+ renderSettings();
1944
+ renderChrome();
1945
+ } else render();
1355
1946
  }
1356
1947
 
1357
1948
  function renderTabs() {
1358
- $("tabs").innerHTML = PROVIDERS.map(([id, name]) => {
1949
+ const vis = visibleIds();
1950
+ $("tabs").innerHTML = vis.map((id) => {
1951
+ const name = PROVIDERS.find(([x]) => x === id)?.[1] || id;
1359
1952
  const p = report?.providers.find((x) => x.id === id);
1360
- const s = providerSeverity(p);
1361
1953
  const n = p ? p.accounts.filter((a) => a.status === "ok").length : 0;
1362
1954
  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>\`;
1955
+ \${logo(id)}\${esc(name)}\${n > 1 ? \`<span class="n">\${n}</span>\` : ""}</button>\`;
1364
1956
  }).join("");
1365
1957
  for (const el of $("tabs").querySelectorAll(".tab")) {
1366
1958
  el.onclick = () => { active = el.dataset.id; localStorage.setItem("ju.tab", active); render(); };
@@ -1368,21 +1960,38 @@ var ui_default = `<!doctype html>
1368
1960
  }
1369
1961
 
1370
1962
  function renderWindow(w) {
1371
- const s = sev(w.usedPercent);
1372
- const pct = w.usedPercent == null ? "—" : \`\${Math.round(w.usedPercent)}%\`;
1963
+ const used = w.usedPercent;
1964
+ const shown = showLeft && used != null ? Math.max(0, 100 - used) : used;
1965
+ const s = sev(used);
1966
+ const pct = formatPercent(shown);
1967
+ const word = showLeft ? "left" : "used";
1373
1968
  const reset = fmtReset(w);
1969
+ const bar = shown == null ? 0 : shown;
1970
+ const sliver = shown > 0 && shown < 100;
1374
1971
  return \`<div class="win">
1375
1972
  <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>
1973
+ <span class="mono"><span class="pct\${s ? " " + s : ""}">\${pct} \${word}</span>\${reset ? \` · \${esc(reset)}\` : ""}</span></div>
1974
+ <div class="track"><div class="fill\${s ? " " + s : ""}\${sliver ? " sliver" : ""}" style="width:\${bar}%"></div></div>
1378
1975
  \${w.note ? \`<div class="note">\${esc(w.note)}</div>\` : ""}
1379
1976
  </div>\`;
1380
1977
  }
1381
1978
 
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>\` : "";
1979
+ function renderWindows(windows) {
1980
+ let html = "";
1981
+ let lastGroup = null;
1982
+ for (const w of windows) {
1983
+ if (w.group && w.group !== lastGroup) {
1984
+ html += \`<div class="win-group">\${esc(w.group)}</div>\`;
1985
+ lastGroup = w.group;
1986
+ }
1987
+ html += renderWindow(w);
1988
+ }
1989
+ return html;
1990
+ }
1991
+
1992
+ function renderCard(a, provider, index) {
1993
+ const title = accountTitle(a, index);
1994
+ const plan = a.account.plan ? \`<span class="plan">\${esc(formatPlan(a.account.plan))}</span>\` : "";
1386
1995
  let badge = "";
1387
1996
  if (a.status === "ok") {
1388
1997
  let worst = null;
@@ -1390,78 +1999,516 @@ var ui_default = `<!doctype html>
1390
1999
  if (worst === "crit") badge = \`<span class="badge crit">critical</span>\`;
1391
2000
  else if (worst === "warn") badge = \`<span class="badge warn">running low</span>\`;
1392
2001
  } else {
1393
- const text = { signed_out: "signed out", unsupported: "unsupported", error: "error" }[a.status] || a.status;
2002
+ const text = { signed_out: "signed out", unsupported: "Unsupported", error: "error" }[a.status] || a.status;
1394
2003
  badge = \`<span class="badge">\${esc(text)}</span>\`;
1395
2004
  }
1396
2005
  let body = "";
1397
2006
  if (a.status === "ok") {
1398
- body = a.windows.map(renderWindow).join("");
2007
+ body = renderWindows(a.windows);
1399
2008
  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
2009
  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>\`;
2010
+ const rows = (a.resetCredits.credits || [])
2011
+ .filter((c) => !c.status || c.status === "available")
2012
+ .map((c, i) => {
2013
+ const t = c.expiresAt ? Date.parse(c.expiresAt) : NaN;
2014
+ const day = Number.isFinite(t)
2015
+ ? \`\${new Date(t).getDate()} \${new Date(t).toLocaleDateString(undefined, { month: "short" })}\`
2016
+ : "—";
2017
+ const left = Number.isFinite(t) && t > Date.now() ? fmtDuration(t - Date.now()) : "now";
2018
+ return \`<div class="credits-tip-row"><span class="credits-tip-n">\${i + 1}</span><span class="credits-tip-date">\${esc(day)}</span><span class="credits-tip-left">\${esc(left)}</span></div>\`;
2019
+ });
2020
+ const tip = rows.length ? \`<span class="credits-tip">\${rows.join("")}</span>\` : "";
2021
+ body += \`<div class="credits"><b>Rate Limit Reset</b>
2022
+ <span class="credits-avail" tabindex="0">\${n} available\${tip}</span></div>\`;
1404
2023
  }
1405
2024
  } else {
1406
2025
  body = a.message ? \`<div class="msg">\${rich(a.message)}</div>\` : \`<div class="hint">\${SIGNIN_HINT[provider] || ""}</div>\`;
1407
2026
  }
1408
2027
  return \`<article class="card">
1409
- <div class="card-head"><div class="label">\${esc(title)}\${sub}\${plan}</div>\${badge}</div>
2028
+ <div class="card-head"><div class="label">\${esc(title)}\${plan}</div>\${badge}</div>
1410
2029
  \${body}
1411
2030
  </article>\`;
1412
2031
  }
1413
2032
 
2033
+ function formatHost(name) {
2034
+ return String(name ?? "").replace(/\\.local$/, "").replace(/[-_]+/g, " ").replace(/\\s+/g, " ").trim();
2035
+ }
2036
+
1414
2037
  function renderPanel() {
2038
+ $("panel").classList.remove("cards");
2039
+ if (!report) {
2040
+ $("panel").innerHTML = \`<div class="empty">Loading…</div>\`;
2041
+ return;
2042
+ }
2043
+ const vis = visibleIds();
2044
+ if (vis.length === 0) {
2045
+ $("panel").innerHTML = \`<div class="empty">No providers enabled.<div class="hint">Open Options to show one, or add an account there.</div></div>\`;
2046
+ return;
2047
+ }
1415
2048
  const p = report?.providers.find((x) => x.id === active);
1416
2049
  const name = PROVIDERS.find(([id]) => id === active)?.[1] || active;
1417
2050
  if (!p) { $("panel").innerHTML = \`<div class="empty">Loading…</div>\`; return; }
1418
2051
  if (!p.installed && p.accounts.length === 0) {
1419
- $("panel").innerHTML = \`<div class="empty">\${esc(name)} isn't installed on \${esc(report.hostname)}.</div>\`;
2052
+ $("panel").innerHTML = \`<div class="empty">\${esc(name)} isn't installed on \${esc(formatHost(report.hostname))}.</div>\`;
1420
2053
  return;
1421
2054
  }
1422
2055
  if (p.accounts.length === 0) {
1423
2056
  $("panel").innerHTML = \`<div class="empty">No \${esc(name)} accounts.<div class="hint">\${SIGNIN_HINT[active] || ""}</div></div>\`;
1424
2057
  return;
1425
2058
  }
1426
- $("panel").innerHTML = p.accounts.map((a) => renderCard(a, active)).join("");
2059
+ $("panel").classList.toggle("cards", p.accounts.length > 1);
2060
+ $("panel").innerHTML = p.accounts.map((a, i) => renderCard(a, active, i)).join("");
1427
2061
  }
1428
2062
 
1429
- function render() {
1430
- renderTabs();
1431
- renderPanel();
2063
+ function renderOptions() {
2064
+ const vis = new Set(visibleIds());
2065
+ $("opts-providers").innerHTML = order.map((id) => {
2066
+ const name = PROVIDERS.find(([x]) => x === id)?.[1] || id;
2067
+ return \`<div class="opts-row" data-id="\${id}">
2068
+ <span class="opts-prov"><span class="grip" title="Drag to reorder"></span>\${logo(id)}\${esc(name)}</span>
2069
+ <input class="chk" type="checkbox" data-id="\${id}" \${vis.has(id) ? "checked" : ""}>
2070
+ </div>\`;
2071
+ }).join("");
2072
+ for (const input of $("opts-providers").querySelectorAll("input")) {
2073
+ input.onchange = () => {
2074
+ const next = Object.fromEntries(order.map((id) => [id, vis.has(id)]));
2075
+ next[input.dataset.id] = input.checked;
2076
+ persistEnabled(next);
2077
+ };
2078
+ }
2079
+ const list = $("opts-providers");
2080
+ for (const grip of list.querySelectorAll(".grip")) {
2081
+ grip.onpointerdown = (e) => {
2082
+ if (e.button !== 0) return;
2083
+ e.preventDefault();
2084
+ const row = grip.closest(".opts-row");
2085
+ dragging = true;
2086
+ row.classList.add("dragging");
2087
+ const place = (clientY) => {
2088
+ const others = [...list.querySelectorAll(".opts-row")].filter((r) => r !== row);
2089
+ const before = others.find((r) => {
2090
+ const b = r.getBoundingClientRect();
2091
+ return clientY < b.top + b.height / 2;
2092
+ });
2093
+ if (before) list.insertBefore(row, before);
2094
+ else list.appendChild(row);
2095
+ };
2096
+ const move = (ev) => place(ev.clientY);
2097
+ const stop = () => {
2098
+ document.removeEventListener("pointermove", move);
2099
+ document.removeEventListener("pointerup", stop);
2100
+ document.removeEventListener("pointercancel", stop);
2101
+ dragging = false;
2102
+ row.classList.remove("dragging");
2103
+ const next = [...list.querySelectorAll(".opts-row")].map((r) => r.dataset.id);
2104
+ if (next.join() !== order.join()) persistOrder(next);
2105
+ };
2106
+ document.addEventListener("pointermove", move);
2107
+ document.addEventListener("pointerup", stop);
2108
+ document.addEventListener("pointercancel", stop);
2109
+ };
2110
+ }
2111
+ $("opts-signed-in").hidden = !enabled;
2112
+ for (const input of $("opts-meter").querySelectorAll("input")) {
2113
+ input.checked = (input.value === "left") === showLeft;
2114
+ }
2115
+ $("opts-email").checked = showEmail;
2116
+ renderAccounts();
2117
+ }
2118
+
2119
+ function wizardMsg(text, err) {
2120
+ wizard.msg = text || "";
2121
+ wizard.err = !!err;
2122
+ }
2123
+
2124
+ async function api(path, opts) {
2125
+ const res = await fetch(path, { cache: "no-store", ...opts });
2126
+ const body = await res.json().catch(() => ({}));
2127
+ if (!res.ok) throw new Error(body.error || \`HTTP \${res.status}\`);
2128
+ return body;
2129
+ }
2130
+
2131
+ function renderAccounts() {
2132
+ const host = $("opts-accounts");
2133
+ if (!host) return;
2134
+ host.innerHTML = order.map((id) => {
2135
+ const name = PROVIDERS.find(([x]) => x === id)?.[1] || id;
2136
+ const p = report?.providers.find((x) => x.id === id);
2137
+ const rows = (p?.accounts || []).map((a, i) => {
2138
+ const title = accountTitle(a, i);
2139
+ const extra = a.account.kind === "default" ? "from CLI" : a.account.kind;
2140
+ if (renaming === a.account.id) {
2141
+ return \`<div class="acct-row">
2142
+ <span class="acct-rename"><input class="field acct-rename-input" data-id="\${esc(a.account.id)}" value="\${esc(title)}">
2143
+ <button class="refresh acct-rename-save" type="button" data-id="\${esc(a.account.id)}">Save</button></span>
2144
+ <button class="opts-link acct-rename-cancel" type="button">Cancel</button>
2145
+ </div>\`;
2146
+ }
2147
+ const rm = a.account.kind !== "default"
2148
+ ? \`<button class="opts-link acct-remove" type="button" data-id="\${esc(a.account.id)}">Remove</button>\`
2149
+ : "";
2150
+ return \`<div class="acct-row"><span>\${esc(title)}<span class="acct-meta">\${esc(extra)}</span></span>
2151
+ <span class="acct-actions"><button class="opts-link acct-rename-btn" type="button" data-id="\${esc(a.account.id)}">Rename</button>\${rm}</span></div>\`;
2152
+ }).join("") || \`<div class="acct-note">No accounts yet.</div>\`;
2153
+ return \`<div class="acct-group"><div class="acct-head">\${logo(id)}\${esc(name)}</div>\${rows}</div>\`;
2154
+ }).join("");
2155
+ for (const btn of host.querySelectorAll(".acct-rename-btn")) {
2156
+ btn.onclick = () => { renaming = btn.dataset.id; renderAccounts(); };
2157
+ }
2158
+ const cancel = host.querySelector(".acct-rename-cancel");
2159
+ if (cancel) cancel.onclick = () => { renaming = null; renderAccounts(); };
2160
+ const save = host.querySelector(".acct-rename-save");
2161
+ const input = host.querySelector(".acct-rename-input");
2162
+ if (save && input) save.onclick = () => renameAccount(save.dataset.id, input.value);
2163
+ for (const btn of host.querySelectorAll(".acct-remove")) {
2164
+ btn.onclick = () => removeAccount(btn.dataset.id);
2165
+ }
2166
+ }
2167
+
2168
+ function persistAliases() {
2169
+ localStorage.setItem("ju.aliases", JSON.stringify(aliases));
2170
+ }
2171
+
2172
+ async function renameAccount(id, label) {
2173
+ const next = String(label ?? "").trim();
2174
+ renaming = null;
2175
+ if (id.endsWith(":default")) {
2176
+ if (next && next !== "Default") aliases[id] = next;
2177
+ else delete aliases[id];
2178
+ persistAliases();
2179
+ render();
2180
+ return;
2181
+ }
2182
+ try {
2183
+ const body = await api(\`/api/accounts/\${encodeURIComponent(id)}\`, {
2184
+ method: "PATCH",
2185
+ headers: { "Content-Type": "application/json" },
2186
+ body: JSON.stringify({ label: next }),
2187
+ });
2188
+ if (body.account && report) {
2189
+ for (const p of report.providers) {
2190
+ const a = p.accounts.find((x) => x.account.id === id);
2191
+ if (a) a.account.label = body.account.label;
2192
+ }
2193
+ }
2194
+ delete aliases[id];
2195
+ persistAliases();
2196
+ } catch (e) {
2197
+ wizardMsg(e.message, true);
2198
+ }
2199
+ render();
2200
+ }
2201
+
2202
+ function resetWizard() {
2203
+ clearInterval(codexPoll);
2204
+ wizard = { step: "provider", provider: "", label: "", secret: "", sessionId: "", authUrl: "", callback: "", msg: "", err: false };
2205
+ }
2206
+
2207
+ function openWizard() {
2208
+ resetWizard();
2209
+ $("add-acct").showModal();
2210
+ renderWizard();
2211
+ }
2212
+
2213
+ function closeWizard() {
2214
+ $("add-acct").close();
2215
+ resetWizard();
2216
+ }
2217
+
2218
+ function renderWizard() {
2219
+ const dlg = $("add-acct");
2220
+ if (!dlg.open) return;
2221
+ const name = PROVIDERS.find(([id]) => id === wizard.provider)?.[1] || "account";
2222
+ $("add-acct-title").textContent = wizard.step === "provider" ? "Add account" : \`Add \${name}\`;
2223
+ let body = "";
2224
+ if (wizard.step === "provider") {
2225
+ body = \`<div class="pick">
2226
+ <button class="pick-btn" type="button" data-id="claude">\${logo("claude")} Claude</button>
2227
+ <button class="pick-btn" type="button" data-id="codex">\${logo("codex")} Codex</button>
2228
+ <button class="pick-btn" type="button" data-id="opencode">\${logo("opencode")} OpenCode Go</button>
2229
+ </div>
2230
+ <div class="acct-note">One Cursor account for now — whatever <code>cursor-agent</code> is signed in as.</div>\`;
2231
+ } else if (wizard.step === "label") {
2232
+ body = \`<div class="acct-note">Optional. Leave blank to show as Account 2, Account 3, …</div>
2233
+ <input class="field" id="wiz-label" placeholder="Label (optional)" value="\${esc(wizard.label)}">
2234
+ <div class="acct-actions"><button class="opts-link" id="wiz-back" type="button">Back</button>
2235
+ <button class="refresh" id="wiz-next" type="button">Continue</button></div>\`;
2236
+ } else if (wizard.provider === "codex") {
2237
+ body = \`<div class="acct-note">Sign in with ChatGPT. If you are not on this machine, paste the localhost callback after the redirect.</div>
2238
+ \${wizard.authUrl ? \`<div class="acct-url">Open <a href="\${esc(wizard.authUrl)}" target="_blank" rel="noreferrer">this sign-in URL</a></div>
2239
+ <input class="field" id="wiz-callback" placeholder="http://localhost:… callback" value="\${esc(wizard.callback)}">
2240
+ <button class="refresh" id="wiz-callback-go" type="button">Submit callback</button>\`
2241
+ : \`<button class="refresh" id="wiz-codex" type="button">Sign in with ChatGPT</button>\`}
2242
+ <button class="opts-link" id="wiz-back" type="button">Back</button>\`;
2243
+ } else {
2244
+ const ph = wizard.provider === "claude" ? "Setup token from <code>claude setup-token</code>" : "OpenCode Go API key";
2245
+ body = \`<div class="acct-note">\${ph}.</div>
2246
+ <input class="field" id="wiz-secret" type="password" placeholder="\${wizard.provider === "claude" ? "Setup token" : "API key"}" value="\${esc(wizard.secret)}" autocomplete="off">
2247
+ <div class="acct-actions"><button class="opts-link" id="wiz-back" type="button">Back</button>
2248
+ <button class="refresh" id="wiz-save" type="button">Add</button></div>\`;
2249
+ }
2250
+ if (wizard.msg) body += \`<div class="acct-msg\${wizard.err ? " err" : ""}">\${esc(wizard.msg)}</div>\`;
2251
+ $("add-acct-body").innerHTML = body;
2252
+ for (const btn of dlg.querySelectorAll(".pick-btn")) {
2253
+ btn.onclick = () => { wizard.provider = btn.dataset.id; wizard.step = "label"; renderWizard(); };
2254
+ }
2255
+ const label = $("wiz-label");
2256
+ if (label) label.oninput = () => { wizard.label = label.value; };
2257
+ const secret = $("wiz-secret");
2258
+ if (secret) secret.oninput = () => { wizard.secret = secret.value; };
2259
+ const cb = $("wiz-callback");
2260
+ if (cb) cb.oninput = () => { wizard.callback = cb.value; };
2261
+ const back = $("wiz-back");
2262
+ if (back) back.onclick = () => {
2263
+ wizard.msg = "";
2264
+ wizard.step = wizard.step === "auth" ? "label" : "provider";
2265
+ if (wizard.step === "provider") wizard.provider = "";
2266
+ renderWizard();
2267
+ };
2268
+ const next = $("wiz-next");
2269
+ if (next) next.onclick = () => { wizard.step = "auth"; wizard.msg = ""; renderWizard(); };
2270
+ const save = $("wiz-save");
2271
+ if (save) save.onclick = addToken;
2272
+ const start = $("wiz-codex");
2273
+ if (start) start.onclick = startCodex;
2274
+ const submit = $("wiz-callback-go");
2275
+ if (submit) submit.onclick = submitCodexCallback;
2276
+ }
2277
+
2278
+ async function addToken() {
2279
+ if (busy) return;
2280
+ if (!wizard.secret.trim()) { wizardMsg("Paste a token or key first.", true); renderWizard(); return; }
2281
+ busy = true;
2282
+ wizardMsg("Adding…", false);
2283
+ renderWizard();
2284
+ try {
2285
+ const body = await api("/api/accounts", {
2286
+ method: "POST",
2287
+ headers: { "Content-Type": "application/json" },
2288
+ body: JSON.stringify({ provider: wizard.provider, label: wizard.label, secret: wizard.secret }),
2289
+ });
2290
+ closeWizard();
2291
+ if (body.warning) wizardMsg(body.warning, false);
2292
+ await load(true);
2293
+ } catch (e) {
2294
+ wizardMsg(e.message, true);
2295
+ } finally {
2296
+ busy = false;
2297
+ if ($("add-acct").open) renderWizard();
2298
+ }
2299
+ }
2300
+
2301
+ async function startCodex() {
2302
+ if (busy) return;
2303
+ const popup = window.open("about:blank", "_blank");
2304
+ busy = true;
2305
+ wizardMsg("Starting sign-in…", false);
2306
+ renderWizard();
2307
+ try {
2308
+ const body = await api("/api/accounts/codex/start", {
2309
+ method: "POST",
2310
+ headers: { "Content-Type": "application/json" },
2311
+ body: JSON.stringify({ label: wizard.label }),
2312
+ });
2313
+ wizard.sessionId = body.sessionId;
2314
+ wizard.authUrl = body.authUrl;
2315
+ if (body.authUrl) {
2316
+ if (popup) popup.location.replace(body.authUrl);
2317
+ else window.location.assign(body.authUrl);
2318
+ } else {
2319
+ popup?.close();
2320
+ }
2321
+ wizardMsg("Waiting for sign-in…", false);
2322
+ pollCodex();
2323
+ } catch (e) {
2324
+ popup?.close();
2325
+ wizardMsg(e.message, true);
2326
+ } finally {
2327
+ busy = false;
2328
+ renderWizard();
2329
+ }
2330
+ }
2331
+
2332
+ async function submitCodexCallback() {
2333
+ if (!wizard.sessionId) return;
2334
+ try {
2335
+ await api("/api/accounts/codex/callback", {
2336
+ method: "POST",
2337
+ headers: { "Content-Type": "application/json" },
2338
+ body: JSON.stringify({ sessionId: wizard.sessionId, url: wizard.callback }),
2339
+ });
2340
+ wizardMsg("Callback submitted. Waiting for Codex…", false);
2341
+ renderWizard();
2342
+ pollCodex();
2343
+ } catch (e) {
2344
+ wizardMsg(e.message, true);
2345
+ renderWizard();
2346
+ }
2347
+ }
2348
+
2349
+ function pollCodex() {
2350
+ clearInterval(codexPoll);
2351
+ if (!wizard.sessionId) return;
2352
+ const tick = async () => {
2353
+ try {
2354
+ const body = await api(\`/api/accounts/codex/session/\${encodeURIComponent(wizard.sessionId)}\`);
2355
+ if (body.status === "done") {
2356
+ clearInterval(codexPoll);
2357
+ closeWizard();
2358
+ await load(true);
2359
+ } else if (body.status === "error") {
2360
+ clearInterval(codexPoll);
2361
+ wizardMsg(body.error || "Sign-in failed.", true);
2362
+ wizard.sessionId = "";
2363
+ renderWizard();
2364
+ }
2365
+ } catch {}
2366
+ };
2367
+ tick();
2368
+ codexPoll = setInterval(tick, 2000);
2369
+ }
2370
+
2371
+ async function removeAccount(id) {
2372
+ if (busy) return;
2373
+ busy = true;
2374
+ try {
2375
+ await api(\`/api/accounts/\${encodeURIComponent(id)}\`, { method: "DELETE" });
2376
+ await load(true);
2377
+ } catch (e) {
2378
+ wizardMsg(e.message, true);
2379
+ } finally {
2380
+ busy = false;
2381
+ renderAccounts();
2382
+ }
2383
+ }
2384
+
2385
+ function renderSettings() {
2386
+ if (!dragging) renderOptions();
2387
+ }
2388
+
2389
+ function renderChrome() {
1432
2390
  if (report) {
1433
- $("host").textContent = report.hostname;
1434
- $("updated").textContent = loading ? "refreshing…" : \`updated \${fmtTime(report.fetchedAt)}\`;
2391
+ $("host").textContent = formatHost(report.hostname);
2392
+ $("updated").textContent = \`updated \${fmtAgo(report.fetchedAt)}\`;
1435
2393
  $("version").textContent = \`v\${report.version}\`;
1436
2394
  const u = report.update;
1437
2395
  $("update").innerHTML = u && u.available
1438
2396
  ? \`<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\`;
2397
+ : "";
2398
+ }
2399
+ const locked = refreshLocked();
2400
+ $("refresh").disabled = loading || locked;
2401
+ $("refresh").textContent = loading ? "Refreshing…" : locked ? refreshLabel() : "Refresh data";
2402
+ $("opts-label").textContent = settingsOpen ? "Back" : "Options";
2403
+ $("opts-btn").classList.toggle("back", settingsOpen);
2404
+ }
2405
+
2406
+ function render() {
2407
+ ensureActive();
2408
+ $("dash").hidden = settingsOpen;
2409
+ $("settings").hidden = !settingsOpen;
2410
+ if (settingsOpen) renderSettings();
2411
+ else {
2412
+ renderTabs();
2413
+ renderPanel();
2414
+ }
2415
+ renderChrome();
2416
+ }
2417
+
2418
+ function adoptReport(next) {
2419
+ if (report) {
2420
+ for (const p of next.providers || []) {
2421
+ const prev = report.providers.find((x) => x.id === p.id);
2422
+ for (const a of p.accounts || []) {
2423
+ const old = prev?.accounts.find((x) => x.account.id === a.account.id);
2424
+ if (!a.account.email && old?.account.email) a.account.email = old.account.email;
2425
+ if (!a.account.plan && old?.account.plan) a.account.plan = old.account.plan;
2426
+ }
2427
+ }
1440
2428
  }
1441
- $("refresh").disabled = loading;
2429
+ report = next;
2430
+ try { localStorage.setItem("ju.report", JSON.stringify(next)); } catch {}
2431
+ }
2432
+
2433
+ const COOLDOWN_MS = 5 * 60 * 1000;
2434
+ function refreshUntil() {
2435
+ return Number(localStorage.getItem("ju.refreshAt") || 0) + COOLDOWN_MS;
2436
+ }
2437
+ function refreshLocked() {
2438
+ return Date.now() < refreshUntil();
2439
+ }
2440
+ function refreshLabel() {
2441
+ const left = refreshUntil() - Date.now();
2442
+ if (left <= 0) return "Refresh data";
2443
+ const min = Math.max(1, Math.ceil(left / 60000));
2444
+ return \`Refresh in \${min}m\`;
2445
+ }
2446
+ function markRefreshed() {
2447
+ localStorage.setItem("ju.refreshAt", String(Date.now()));
1442
2448
  }
1443
2449
 
1444
- async function load(force) {
2450
+ async function load(force, manual) {
2451
+ if (manual && refreshLocked()) return;
2452
+ if (manual) markRefreshed();
1445
2453
  loading = true;
1446
2454
  render();
1447
2455
  try {
1448
2456
  const res = await fetch(\`/api/quotas\${force ? "?refresh=1" : ""}\`, { cache: "no-store" });
1449
- report = await res.json();
2457
+ adoptReport(await res.json());
1450
2458
  } catch (e) {
1451
- $("panel").innerHTML = \`<div class="empty">Couldn't reach just-usage. Is the server still running?</div>\`;
2459
+ if (!report) $("panel").innerHTML = \`<div class="empty">Couldn't reach just-usage. Is the server still running?</div>\`;
1452
2460
  } finally {
1453
2461
  loading = false;
1454
2462
  render();
1455
2463
  }
1456
2464
  }
1457
2465
 
1458
- $("refresh").onclick = () => load(true);
2466
+ $("opts-btn").onclick = () => setView(!settingsOpen);
2467
+ $("refresh").onclick = () => load(true, true);
2468
+ $("opts-signed-in").onclick = () => persistEnabled(null);
2469
+ $("opts-email").onchange = () => {
2470
+ showEmail = $("opts-email").checked;
2471
+ localStorage.setItem("ju.showEmail", showEmail ? "1" : "0");
2472
+ if (settingsOpen) renderAccounts();
2473
+ else render();
2474
+ };
2475
+ $("acct-add").onclick = openWizard;
2476
+ $("add-acct-close").onclick = closeWizard;
2477
+ $("add-acct").addEventListener("close", resetWizard);
2478
+ $("add-acct").addEventListener("click", (e) => { if (e.target === $("add-acct")) closeWizard(); });
2479
+ for (const input of $("opts-meter").querySelectorAll("input")) {
2480
+ input.onchange = () => {
2481
+ showLeft = input.value === "left";
2482
+ localStorage.setItem("ju.showLeft", showLeft ? "1" : "0");
2483
+ if (!settingsOpen) render();
2484
+ };
2485
+ }
1459
2486
  document.addEventListener("keydown", (e) => {
1460
2487
  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);
2488
+ if (settingsOpen) return;
2489
+ const vis = visibleIds();
2490
+ if (e.key >= "1" && e.key <= String(vis.length)) {
2491
+ active = vis[Number(e.key) - 1];
2492
+ localStorage.setItem("ju.tab", active);
2493
+ render();
2494
+ } else if (e.key === "r") load(true, true);
1463
2495
  });
1464
- setInterval(() => { if (report && !loading) render(); }, 30000);
2496
+ const STALE_MS = 5 * 60 * 1000;
2497
+ function maybeAutoRefresh() {
2498
+ if (!report || loading || settingsOpen) return;
2499
+ const t = Date.parse(report.fetchedAt);
2500
+ if (Number.isFinite(t) && Date.now() - t >= STALE_MS) load(true);
2501
+ }
2502
+ setInterval(() => {
2503
+ maybeAutoRefresh();
2504
+ if (!report || loading) return;
2505
+ if (settingsOpen) renderChrome();
2506
+ else render();
2507
+ }, 15000);
2508
+ document.addEventListener("visibilitychange", () => {
2509
+ if (document.visibilityState === "visible") maybeAutoRefresh();
2510
+ });
2511
+ if (report) render();
1465
2512
  load(false);
1466
2513
  })();
1467
2514
  </script>
@@ -1469,7 +2516,41 @@ var ui_default = `<!doctype html>
1469
2516
  </html>
1470
2517
  `;
1471
2518
 
2519
+ // src/ui/logos/claude.svg
2520
+ var claude_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ececec" role="img">
2521
+ <title>Claude</title>
2522
+ <path d="m4.7144 15.9555 4.7174-2.6471.079-.2307-.079-.1275h-.2307l-.7893-.0486-2.6956-.0729-2.3375-.0971-2.2646-.1214-.5707-.1215-.5343-.7042.0546-.3522.4797-.3218.686.0608 1.5179.1032 2.2767.1578 1.6514.0972 2.4468.255h.3886l.0546-.1579-.1336-.0971-.1032-.0972L6.973 9.8356l-2.55-1.6879-1.3356-.9714-.7225-.4918-.3643-.4614-.1578-1.0078.6557-.7225.8803.0607.2246.0607.8925.686 1.9064 1.4754 2.4893 1.8336.3643.3035.1457-.1032.0182-.0728-.164-.2733-1.3539-2.4467-1.445-2.4893-.6435-1.032-.17-.6194c-.0607-.255-.1032-.4674-.1032-.7285L6.287.1335 6.6997 0l.9957.1336.419.3642.6192 1.4147 1.0018 2.2282 1.5543 3.0296.4553.8985.2429.8318.091.255h.1579v-.1457l.1275-1.706.2368-2.0947.2307-2.6957.0789-.7589.3764-.9107.7468-.4918.5828.2793.4797.686-.0668.4433-.2853 1.8517-.5586 2.9021-.3643 1.9429h.2125l.2429-.2429.9835-1.3053 1.6514-2.0643.7286-.8196.85-.9046.5464-.4311h1.0321l.759 1.1293-.34 1.1657-1.0625 1.3478-.8804 1.1414-1.2628 1.7-.7893 1.36.0729.1093.1882-.0183 2.8535-.607 1.5421-.2794 1.8396-.3157.8318.3886.091.3946-.3278.8075-1.967.4857-2.3072.4614-3.4364.8136-.0425.0304.0486.0607 1.5482.1457.6618.0364h1.621l3.0175.2247.7892.522.4736.6376-.079.4857-1.2142.6193-1.6393-.3886-3.825-.9107-1.3113-.3279h-.1822v.1093l1.0929 1.0686 2.0035 1.8092 2.5075 2.3314.1275.5768-.3218.4554-.34-.0486-2.2039-1.6575-.85-.7468-1.9246-1.621h-.1275v.17l.4432.6496 2.3436 3.5214.1214 1.0807-.17.3521-.6071.2125-.6679-.1214-1.3721-1.9246L14.38 17.959l-1.1414-1.9428-.1397.079-.674 7.2552-.3156.3703-.7286.2793-.6071-.4614-.3218-.7468.3218-1.4753.3886-1.9246.3157-1.53.2853-1.9004.17-.6314-.0121-.0425-.1397.0182-1.4328 1.9672-2.1796 2.9446-1.7243 1.8456-.4128.164-.7164-.3704.0667-.6618.4008-.5889 2.386-3.0357 1.4389-1.882.929-1.0868-.0062-.1579h-.0546l-6.3385 4.1164-1.1293.1457-.4857-.4554.0608-.7467.2307-.2429 1.9064-1.3114Z"/>
2523
+ </svg>
2524
+ `;
2525
+
2526
+ // src/ui/logos/codex.svg
2527
+ var codex_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ececec" role="img">
2528
+ <title>Codex</title>
2529
+ <path d="M22.2819 9.8211a5.9847 5.9847 0 0 0-.5157-4.9108 6.0462 6.0462 0 0 0-6.5098-2.9A6.0651 6.0651 0 0 0 4.9807 4.1818a5.9847 5.9847 0 0 0-3.9977 2.9 6.0462 6.0462 0 0 0 .7427 7.0966 5.98 5.98 0 0 0 .511 4.9107 6.051 6.051 0 0 0 6.5146 2.9001A5.9847 5.9847 0 0 0 13.2599 24a6.0557 6.0557 0 0 0 5.7718-4.2058 5.9894 5.9894 0 0 0 3.9977-2.9001 6.0557 6.0557 0 0 0-.7475-7.0729zm-9.022 12.6081a4.4755 4.4755 0 0 1-2.8764-1.0408l.1419-.0804 4.7783-2.7582a.7948.7948 0 0 0 .3927-.6813v-6.7369l2.02 1.1686a.071.071 0 0 1 .038.052v5.5826a4.504 4.504 0 0 1-4.4945 4.4944zm-9.6607-4.1254a4.4708 4.4708 0 0 1-.5346-3.0137l.142.0852 4.783 2.7582a.7712.7712 0 0 0 .7806 0l5.8428-3.3685v2.3324a.0804.0804 0 0 1-.0332.0615L9.74 19.9502a4.4992 4.4992 0 0 1-6.1408-1.6464zM2.3408 7.8956a4.485 4.485 0 0 1 2.3655-1.9728V11.6a.7664.7664 0 0 0 .3879.6765l5.8144 3.3543-2.0201 1.1685a.0757.0757 0 0 1-.071 0l-4.8303-2.7865A4.504 4.504 0 0 1 2.3408 7.872zm16.5963 3.8558L13.1038 8.364 15.1192 7.2a.0757.0757 0 0 1 .071 0l4.8303 2.7913a4.4944 4.4944 0 0 1-.6765 8.1042v-5.6772a.79.79 0 0 0-.407-.667zm2.0107-3.0231l-.142-.0852-4.7735-2.7818a.7759.7759 0 0 0-.7854 0L9.409 9.2297V6.8974a.0662.0662 0 0 1 .0284-.0615l4.8303-2.7866a4.4992 4.4992 0 0 1 6.6802 4.66zM8.3065 12.863l-2.02-1.1638a.0804.0804 0 0 1-.038-.0567V6.0742a4.4992 4.4992 0 0 1 7.3757-3.4537l-.142.0805L8.704 5.459a.7948.7948 0 0 0-.3927.6813zm1.0976-2.3654l2.602-1.4998 2.6069 1.4998v2.9994l-2.5974 1.4997-2.6067-1.4997Z"/>
2530
+ </svg>
2531
+ `;
2532
+
2533
+ // src/ui/logos/cursor.svg
2534
+ var cursor_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ececec" role="img">
2535
+ <title>Cursor</title>
2536
+ <path d="M11.503.131 1.891 5.678a.84.84 0 0 0-.42.726v11.188c0 .3.162.575.42.724l9.609 5.55a1 1 0 0 0 .998 0l9.61-5.55a.84.84 0 0 0 .42-.724V6.404a.84.84 0 0 0-.42-.726L12.497.131a1.01 1.01 0 0 0-.996 0M2.657 6.338h18.55c.263 0 .43.287.297.515L12.23 22.918c-.062.107-.229.064-.229-.06V12.335a.59.59 0 0 0-.295-.51l-9.11-5.257c-.109-.063-.064-.23.061-.23"/>
2537
+ </svg>
2538
+ `;
2539
+
2540
+ // src/ui/logos/opencode.svg
2541
+ var opencode_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ececec" fill-rule="evenodd" role="img">
2542
+ <title>OpenCode</title>
2543
+ <path d="M22 24H2V0h20zM17 4.8H7v14.4h10z"/>
2544
+ </svg>
2545
+ `;
2546
+
1472
2547
  // src/server.ts
2548
+ var LOGOS = {
2549
+ claude: claude_default,
2550
+ codex: codex_default,
2551
+ cursor: cursor_default,
2552
+ opencode: opencode_default
2553
+ };
1473
2554
  function json(res, status, body) {
1474
2555
  const text = JSON.stringify(body);
1475
2556
  res.writeHead(status, {
@@ -1479,33 +2560,134 @@ function json(res, status, body) {
1479
2560
  });
1480
2561
  res.end(text);
1481
2562
  }
1482
- function startServer(opts) {
2563
+ function readBody(req, limit = 64000) {
2564
+ return new Promise((resolve, reject) => {
2565
+ const chunks = [];
2566
+ let n = 0;
2567
+ req.on("data", (c) => {
2568
+ n += c.length;
2569
+ if (n > limit) {
2570
+ req.destroy();
2571
+ reject(new AccountError("Request body too large."));
2572
+ return;
2573
+ }
2574
+ chunks.push(c);
2575
+ });
2576
+ req.on("end", () => resolve(Buffer.concat(chunks).toString("utf8")));
2577
+ req.on("error", reject);
2578
+ });
2579
+ }
2580
+ async function readJson(req) {
2581
+ const text = await readBody(req);
2582
+ if (!text.trim())
2583
+ return {};
2584
+ const parsed = JSON.parse(text);
2585
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed))
2586
+ throw new AccountError("Expected a JSON object.");
2587
+ return parsed;
2588
+ }
2589
+ function str(v) {
2590
+ return typeof v === "string" ? v : "";
2591
+ }
2592
+ async function startServer(opts) {
1483
2593
  const cache = new ReportCache(CACHE_TTL_MS, opts.getUpdate);
1484
2594
  cache.get(true).catch(() => {});
2595
+ const tailscaleIp = await detectTailscaleIp();
2596
+ const mutated = async (work) => {
2597
+ const out = await work();
2598
+ cache.invalidate();
2599
+ return out;
2600
+ };
1485
2601
  const handler = async (req, res) => {
1486
2602
  const url = new URL(req.url ?? "/", "http://localhost");
1487
2603
  res.setHeader("X-Content-Type-Options", "nosniff");
1488
2604
  try {
1489
- if (req.method !== "GET" && req.method !== "HEAD") {
1490
- json(res, 405, { error: "method not allowed" });
1491
- return;
1492
- }
1493
- if (url.pathname === "/") {
2605
+ const method = req.method ?? "GET";
2606
+ if (url.pathname === "/" && (method === "GET" || method === "HEAD")) {
1494
2607
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
1495
2608
  res.end(ui_default);
1496
2609
  return;
1497
2610
  }
1498
- if (url.pathname === "/api/quotas") {
2611
+ const logo = url.pathname.match(/^\/logos\/([a-z]+)\.svg$/);
2612
+ if (logo && (method === "GET" || method === "HEAD")) {
2613
+ const svg = LOGOS[logo[1]];
2614
+ if (!svg) {
2615
+ json(res, 404, { error: "not found" });
2616
+ return;
2617
+ }
2618
+ res.writeHead(200, { "Content-Type": "image/svg+xml; charset=utf-8", "Cache-Control": "public, max-age=86400" });
2619
+ res.end(svg);
2620
+ return;
2621
+ }
2622
+ if (url.pathname === "/api/quotas" && (method === "GET" || method === "HEAD")) {
1499
2623
  const force = url.searchParams.get("refresh") === "1";
1500
2624
  json(res, 200, await cache.get(force));
1501
2625
  return;
1502
2626
  }
1503
- if (url.pathname === "/api/health") {
2627
+ if (url.pathname === "/api/health" && (method === "GET" || method === "HEAD")) {
1504
2628
  json(res, 200, { ok: true, version: VERSION });
1505
2629
  return;
1506
2630
  }
2631
+ if (url.pathname === "/api/accounts" && method === "POST") {
2632
+ const body = await readJson(req);
2633
+ const provider = str(body.provider);
2634
+ const label = str(body.label) || undefined;
2635
+ const secret = str(body.secret);
2636
+ if (provider === "claude") {
2637
+ json(res, 200, await mutated(() => addClaudeToken(secret, label)));
2638
+ return;
2639
+ }
2640
+ if (provider === "opencode") {
2641
+ json(res, 200, await mutated(() => addOpenCodeKey(secret, label)));
2642
+ return;
2643
+ }
2644
+ json(res, 400, { error: "Add Claude with a setup-token, OpenCode with an API key, or start a Codex sign-in." });
2645
+ return;
2646
+ }
2647
+ if (url.pathname === "/api/accounts/codex/start" && method === "POST") {
2648
+ const body = await readJson(req);
2649
+ const accountId = str(body.accountId);
2650
+ json(res, 200, accountId ? await beginCodexRelogin(accountId) : await beginCodexAdd(str(body.label) || undefined));
2651
+ return;
2652
+ }
2653
+ if (url.pathname === "/api/accounts/codex/callback" && method === "POST") {
2654
+ const body = await readJson(req);
2655
+ await submitCodexCallback(str(body.sessionId), str(body.url));
2656
+ json(res, 200, { ok: true });
2657
+ return;
2658
+ }
2659
+ const session = url.pathname.match(/^\/api\/accounts\/codex\/session\/([^/]+)$/);
2660
+ if (session && (method === "GET" || method === "HEAD")) {
2661
+ const state = codexSessionStatus(decodeURIComponent(session[1]));
2662
+ if (state.status === "done")
2663
+ cache.invalidate();
2664
+ json(res, 200, state);
2665
+ return;
2666
+ }
2667
+ const remove = url.pathname.match(/^\/api\/accounts\/([^/]+)$/);
2668
+ if (remove && method === "PATCH") {
2669
+ const body = await readJson(req);
2670
+ json(res, 200, { account: await mutated(async () => renameExtraAccount(decodeURIComponent(remove[1]), str(body.label))) });
2671
+ return;
2672
+ }
2673
+ if (remove && method === "DELETE") {
2674
+ json(res, 200, { account: await mutated(() => removeExtraAccount(decodeURIComponent(remove[1]))) });
2675
+ return;
2676
+ }
2677
+ if (method !== "GET" && method !== "HEAD" && method !== "POST" && method !== "DELETE" && method !== "PATCH") {
2678
+ json(res, 405, { error: "method not allowed" });
2679
+ return;
2680
+ }
1507
2681
  json(res, 404, { error: "not found" });
1508
2682
  } catch (e) {
2683
+ if (e instanceof AccountError) {
2684
+ json(res, e.status, { error: e.message });
2685
+ return;
2686
+ }
2687
+ if (e instanceof SyntaxError) {
2688
+ json(res, 400, { error: "Invalid JSON." });
2689
+ return;
2690
+ }
1509
2691
  json(res, 500, { error: e instanceof Error ? e.message : String(e) });
1510
2692
  }
1511
2693
  };
@@ -1513,7 +2695,7 @@ function startServer(opts) {
1513
2695
  return new Promise((resolve, reject) => {
1514
2696
  server.once("error", reject);
1515
2697
  server.listen(opts.port, opts.host, () => {
1516
- resolve({ close: () => server.close(), urls: reachableUrls(opts.host, opts.port) });
2698
+ resolve({ close: () => server.close(), urls: reachableUrls(opts.host, opts.port, tailscaleIp) });
1517
2699
  });
1518
2700
  });
1519
2701
  }
@@ -1521,22 +2703,49 @@ function isTailscale(ip) {
1521
2703
  const [a, b] = ip.split(".").map(Number);
1522
2704
  return a === 100 && b !== undefined && b >= 64 && b <= 127;
1523
2705
  }
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}`];
2706
+ var TAILSCALE_NOTE = "Tailscale only. Not same-Wi-Fi. Other devices need Tailscale too.";
2707
+ async function detectTailscaleIp() {
2708
+ const res = await run("tailscale", ["status", "--json"], { timeoutMs: 4000 });
2709
+ if (res.code !== 0)
2710
+ return null;
2711
+ try {
2712
+ const body = JSON.parse(res.stdout);
2713
+ if (body.BackendState !== "Running")
2714
+ return null;
2715
+ const ips = body.Self?.TailscaleIPs ?? body.TailscaleIPs;
2716
+ if (!Array.isArray(ips))
2717
+ return null;
2718
+ for (const ip of ips) {
2719
+ if (typeof ip === "string" && isTailscale(ip))
2720
+ return ip;
2721
+ }
2722
+ return null;
2723
+ } catch {
2724
+ return null;
2725
+ }
2726
+ }
2727
+ function reachableUrls(host, port, tailscaleIp = null) {
2728
+ if (host !== "0.0.0.0" && host !== "::") {
2729
+ return [{ kind: "local", url: `http://${host === "::1" ? "localhost" : host}:${port}` }];
2730
+ }
2731
+ const urls = [{ kind: "local", url: `http://127.0.0.1:${port}` }];
1528
2732
  const shortHost = hostname2().replace(/\.local$/, "").toLowerCase();
1529
2733
  if (shortHost)
1530
- urls.push(`http://${shortHost}:${port}`);
1531
- const seen = new Set;
2734
+ urls.push({ kind: "network", url: `http://${shortHost}:${port}` });
2735
+ const seen = new Set(tailscaleIp ? [tailscaleIp] : []);
1532
2736
  for (const list of Object.values(networkInterfaces())) {
1533
2737
  for (const iface of list ?? []) {
1534
2738
  if (iface.family !== "IPv4" || iface.internal || seen.has(iface.address))
1535
2739
  continue;
2740
+ if (isTailscale(iface.address))
2741
+ continue;
1536
2742
  seen.add(iface.address);
1537
- urls.push(`http://${iface.address}:${port}${isTailscale(iface.address) ? " (tailscale)" : ""}`);
2743
+ urls.push({ kind: "network", url: `http://${iface.address}:${port}` });
1538
2744
  }
1539
2745
  }
2746
+ if (tailscaleIp) {
2747
+ urls.push({ kind: "tailscale", url: `http://${tailscaleIp}:${port}`, note: TAILSCALE_NOTE });
2748
+ }
1540
2749
  return urls;
1541
2750
  }
1542
2751
 
@@ -1559,7 +2768,7 @@ function bar(used, width = 20) {
1559
2768
  function pct(used) {
1560
2769
  if (used === null)
1561
2770
  return " — ";
1562
- const s = `${String(Math.round(used)).padStart(3)}%`;
2771
+ const s = formatPercent(used).padStart(5);
1563
2772
  const sev = severity(used);
1564
2773
  return sev === "crit" ? c.red(s) : sev === "warn" ? c.yellow(s) : s;
1565
2774
  }
@@ -1614,10 +2823,10 @@ function readCache() {
1614
2823
  } catch {}
1615
2824
  return null;
1616
2825
  }
1617
- function writeCache(c2) {
2826
+ function writeCache(c) {
1618
2827
  try {
1619
2828
  ensureDir(dirname3(paths.updateCache()));
1620
- writeFileSync3(paths.updateCache(), JSON.stringify(c2) + `
2829
+ writeFileSync3(paths.updateCache(), JSON.stringify(c) + `
1621
2830
  `);
1622
2831
  } catch {}
1623
2832
  }
@@ -1668,8 +2877,8 @@ async function checkForUpdate(force = false) {
1668
2877
  writeCache(entry);
1669
2878
  return toInfo(entry);
1670
2879
  }
1671
- function toInfo(c2) {
1672
- return { current: VERSION, latest: c2.latest, available: semverGt(c2.latest, VERSION), checkedAt: c2.checkedAt };
2880
+ function toInfo(c) {
2881
+ return { current: VERSION, latest: c.latest, available: semverGt(c.latest, VERSION), checkedAt: c.checkedAt };
1673
2882
  }
1674
2883
  function detectPackageManager(argv1 = process.argv[1] ?? "", execPath = process.execPath) {
1675
2884
  const p = argv1.replace(/\\/g, "/");
@@ -1790,12 +2999,14 @@ Update available: v${u.current} → v${u.latest}. Run: just-usage upgrade
1790
2999
  throw e;
1791
3000
  }
1792
3001
  console.log(`${PACKAGE_NAME} v${VERSION}`);
1793
- for (const [i, url] of server.urls.entries())
1794
- console.log(` ${i === 0 ? "local " : "network "} ${url}`);
3002
+ for (const u of server.urls) {
3003
+ const tag = u.kind === "local" ? "local " : u.kind === "tailscale" ? "tailscale" : "network ";
3004
+ console.log(` ${tag} ${u.url}${u.note ? ` — ${u.note}` : ""}`);
3005
+ }
1795
3006
  console.log(`
1796
3007
  Press Ctrl+C to stop.`);
1797
3008
  if (shouldOpen)
1798
- openInBrowser(server.urls[0]);
3009
+ openInBrowser(server.urls[0].url);
1799
3010
  const shutdown = () => {
1800
3011
  server.close();
1801
3012
  process.exit(0);
@@ -1841,30 +3052,18 @@ ${url}
1841
3052
  `);
1842
3053
  openInBrowser(url);
1843
3054
  });
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}` : ""})` : ""}.`);
3055
+ const rec = saveCodexProfile({ label, tmpId, dir, email: info.email });
3056
+ console.log(`Added ${rec.id}${info.email ? ` (${info.email}${info.plan ? `, ${info.plan}` : ""})` : ""}.`);
1854
3057
  }
1855
- async function addClaudeToken(label) {
3058
+ async function addClaudeTokenCli(label) {
1856
3059
  console.log("Run `claude setup-token` in the account you want to add, then paste the token here.");
1857
3060
  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}.`);
3061
+ try {
3062
+ const { account } = await addClaudeToken(token, label);
3063
+ console.log(`Added ${account.id}${account.email ? ` (${account.email})` : ""}.`);
3064
+ } catch (e) {
3065
+ fail(e instanceof Error ? e.message : String(e));
3066
+ }
1868
3067
  }
1869
3068
  async function addClaudeProfile(label) {
1870
3069
  if (!await which("claude"))
@@ -1876,31 +3075,25 @@ async function addClaudeProfile(label) {
1876
3075
  await runInteractive("claude", ["auth", "login"], { CLAUDE_CONFIG_DIR: dir });
1877
3076
  const status = await claudeAuthStatus(dir);
1878
3077
  if (!status?.loggedIn) {
1879
- const { rmSync: rmSync2 } = await import("node:fs");
1880
- rmSync2(dir, { recursive: true, force: true });
3078
+ const { rmSync } = await import("node:fs");
3079
+ rmSync(dir, { recursive: true, force: true });
1881
3080
  fail("Login did not complete; nothing was saved.");
1882
3081
  }
1883
3082
  saveAccount({ id, provider: "claude", label: label ?? id.split(":")[1], kind: "profile", path: dir, email: null, createdAt: new Date().toISOString() });
1884
3083
  console.log(`
1885
3084
  Added ${id}. Note: on macOS the first read may trigger a Keychain prompt — choose "Always Allow".`);
1886
3085
  }
1887
- async function addOpenCode(label) {
3086
+ async function addOpenCodeCli(label) {
1888
3087
  console.log("Paste an OpenCode Go API key (from https://opencode.ai/ → Go → API keys).");
1889
3088
  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}.`);
3089
+ try {
3090
+ const { account, warning } = await addOpenCodeKey(key, label);
3091
+ if (warning)
3092
+ console.log(`Warning: ${warning} Saving anyway.`);
3093
+ console.log(`Added ${account.id}.`);
3094
+ } catch (e) {
3095
+ fail(e instanceof Error ? e.message : String(e));
3096
+ }
1904
3097
  }
1905
3098
  async function cmdAdd(argv) {
1906
3099
  const { values, positionals } = parseArgs({
@@ -1915,9 +3108,9 @@ async function cmdAdd(argv) {
1915
3108
  case "codex":
1916
3109
  return addCodex(values.label);
1917
3110
  case "claude":
1918
- return values.token ? addClaudeToken(values.label) : addClaudeProfile(values.label);
3111
+ return values.token ? addClaudeTokenCli(values.label) : addClaudeProfile(values.label);
1919
3112
  case "opencode":
1920
- return addOpenCode(values.label);
3113
+ return addOpenCodeCli(values.label);
1921
3114
  case "cursor":
1922
3115
  fail("Cursor is single-account: just-usage shows whatever `cursor-agent` is logged in as.");
1923
3116
  }
@@ -1955,12 +3148,12 @@ async function cmdRemove(argv) {
1955
3148
  fail("Usage: just-usage remove <account-id>");
1956
3149
  if (id.endsWith(":default"))
1957
3150
  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}.`);
3151
+ try {
3152
+ const rec = await removeExtraAccount(id);
3153
+ console.log(`Removed ${rec.id}.`);
3154
+ } catch (e) {
3155
+ fail(e instanceof Error ? e.message : String(e));
3156
+ }
1964
3157
  }
1965
3158
  async function cmdUpgrade(argv) {
1966
3159
  const { values } = parseArgs({ args: argv, options: { check: { type: "boolean" }, yes: { type: "boolean", short: "y" } }, allowPositionals: true, strict: false });