just-usage 0.0.3 → 0.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -15,8 +15,8 @@ import { mkdirSync } from "node:fs";
15
15
  // package.json
16
16
  var package_default = {
17
17
  name: "just-usage",
18
- version: "0.0.3",
19
- description: "One local page for your coding-CLI subscription quotas: Claude, Codex, Cursor, OpenCode Go.",
18
+ version: "0.0.5",
19
+ description: "One local page for your coding-CLI subscription quotas: Claude, Codex, Cursor, Antigravity, Grok, OpenCode Go.",
20
20
  type: "module",
21
21
  license: "MIT",
22
22
  author: "spheceo",
@@ -31,6 +31,8 @@ var package_default = {
31
31
  "codex",
32
32
  "cursor",
33
33
  "opencode",
34
+ "antigravity",
35
+ "grok",
34
36
  "quota",
35
37
  "usage",
36
38
  "rate-limit",
@@ -49,7 +51,7 @@ var package_default = {
49
51
  node: ">=20"
50
52
  },
51
53
  scripts: {
52
- dev: "bun --watch src/cli.ts",
54
+ dev: "bun run src/cli.ts",
53
55
  build: "bun run scripts/build.ts",
54
56
  test: "bun test",
55
57
  typecheck: "tsc --noEmit",
@@ -86,7 +88,9 @@ var paths = {
86
88
  registry: () => join(configDir(), "accounts.json"),
87
89
  secrets: () => join(configDir(), "secrets.json"),
88
90
  updateCache: () => join(configDir(), "update-check.json"),
89
- profiles: (provider) => join(configDir(), "profiles", provider)
91
+ profiles: (provider) => join(configDir(), "profiles", provider),
92
+ runDir: () => join(configDir(), "run"),
93
+ runRecord: (port) => join(configDir(), "run", `${port}.json`)
90
94
  };
91
95
 
92
96
  // src/format.ts
@@ -828,24 +832,379 @@ async function verifyClaudeToken(token) {
828
832
  // src/accounts.ts
829
833
  import { renameSync, rmSync as rmSync2 } from "node:fs";
830
834
 
831
- // src/adapters/opencode.ts
835
+ // src/adapters/antigravity.ts
836
+ import { createHash as createHash2, randomBytes } from "node:crypto";
832
837
  import { existsSync as existsSync3, readFileSync as readFileSync3 } from "node:fs";
833
838
  import { homedir as homedir3 } from "node:os";
834
839
  import { join as join3 } from "node:path";
840
+ var AGY_CLIENT_ID = "1071006060591-tmhssin2h21lcre235vtolojh4g403ep.apps.googleusercontent.com";
841
+ var AGY_CLIENT_SECRET = "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";
842
+ var AGY_REDIRECT_URI = "https://antigravity.google/oauth-callback";
843
+ var AGY_SCOPES = [
844
+ "openid",
845
+ "https://www.googleapis.com/auth/userinfo.email",
846
+ "https://www.googleapis.com/auth/userinfo.profile",
847
+ "https://www.googleapis.com/auth/cloud-platform",
848
+ "https://www.googleapis.com/auth/cclog",
849
+ "https://www.googleapis.com/auth/experimentsandconfigs"
850
+ ];
851
+ var USAGE_HOST = "https://daily-cloudcode-pa.googleapis.com";
852
+ var KEYCHAIN_SERVICE = "gemini";
853
+ var KEYCHAIN_ACCOUNT = "antigravity";
854
+ var FALLBACK_CLI_VERSION2 = "1.1.26";
855
+ function settingsFile() {
856
+ return join3(homedir3(), ".gemini", "antigravity-cli", "settings.json");
857
+ }
858
+ function usesGeminiApiKey() {
859
+ try {
860
+ if (!existsSync3(settingsFile()))
861
+ return false;
862
+ const parsed = JSON.parse(readFileSync3(settingsFile(), "utf8"));
863
+ return parsed.modelProvider === "gemini" && Boolean(process.env.GEMINI_API_KEY?.trim());
864
+ } catch {
865
+ return false;
866
+ }
867
+ }
868
+ function parseAgyKeyringBlob(raw) {
869
+ const text = raw.replace(/\r?\n$/, "");
870
+ const prefix = "go-keyring-base64:";
871
+ const json = text.startsWith(prefix) ? Buffer.from(text.slice(prefix.length), "base64").toString("utf8") : text;
872
+ try {
873
+ const parsed = JSON.parse(json);
874
+ const token = isObject(parsed.token) ? parsed.token : parsed;
875
+ if (typeof token.access_token !== "string" || !token.access_token)
876
+ return null;
877
+ if (typeof token.refresh_token !== "string" || !token.refresh_token)
878
+ return null;
879
+ const expiry = typeof token.expiry === "string" ? Date.parse(token.expiry) : typeof token.expiry === "number" ? token.expiry : NaN;
880
+ return {
881
+ accessToken: token.access_token,
882
+ refreshToken: token.refresh_token,
883
+ expiry: Number.isFinite(expiry) ? expiry : null
884
+ };
885
+ } catch {
886
+ return null;
887
+ }
888
+ }
889
+ function parseStoredAgySecret(raw) {
890
+ try {
891
+ const parsed = JSON.parse(raw);
892
+ if (typeof parsed.refresh_token !== "string" || !parsed.refresh_token)
893
+ return parseAgyKeyringBlob(raw);
894
+ return {
895
+ accessToken: typeof parsed.access_token === "string" ? parsed.access_token : "",
896
+ refreshToken: parsed.refresh_token,
897
+ expiry: typeof parsed.expiry === "number" ? parsed.expiry : typeof parsed.expiry === "string" ? Date.parse(parsed.expiry) || null : null
898
+ };
899
+ } catch {
900
+ return parseAgyKeyringBlob(raw);
901
+ }
902
+ }
903
+ function serializeAgySecret(token) {
904
+ return JSON.stringify({
905
+ access_token: token.accessToken,
906
+ refresh_token: token.refreshToken,
907
+ expiry: token.expiry
908
+ });
909
+ }
910
+ async function tokenFromKeychain() {
911
+ if (process.platform !== "darwin")
912
+ return null;
913
+ const res = await run("security", ["find-generic-password", "-s", KEYCHAIN_SERVICE, "-a", KEYCHAIN_ACCOUNT, "-w"], { timeoutMs: 20000 });
914
+ if (res.code !== 0)
915
+ return null;
916
+ return parseAgyKeyringBlob(res.stdout);
917
+ }
918
+ async function tokenFromSecretTool() {
919
+ if (process.platform === "darwin" || process.platform === "win32")
920
+ return null;
921
+ const res = await run("secret-tool", ["lookup", "service", KEYCHAIN_SERVICE, "username", KEYCHAIN_ACCOUNT], { timeoutMs: 20000 });
922
+ if (res.code !== 0)
923
+ return null;
924
+ return parseAgyKeyringBlob(res.stdout);
925
+ }
926
+ async function readDefaultAgyToken() {
927
+ return await tokenFromKeychain() ?? await tokenFromSecretTool();
928
+ }
929
+ function createPkce() {
930
+ const verifier = randomBytes(32).toString("base64url");
931
+ const challenge = createHash2("sha256").update(verifier).digest("base64url");
932
+ return { verifier, challenge };
933
+ }
934
+ function antigravityAuthUrl(opts) {
935
+ const url = new URL("https://accounts.google.com/o/oauth2/v2/auth");
936
+ url.searchParams.set("client_id", AGY_CLIENT_ID);
937
+ url.searchParams.set("redirect_uri", AGY_REDIRECT_URI);
938
+ url.searchParams.set("response_type", "code");
939
+ url.searchParams.set("scope", AGY_SCOPES.join(" "));
940
+ url.searchParams.set("access_type", "offline");
941
+ url.searchParams.set("prompt", "consent");
942
+ url.searchParams.set("state", opts.state);
943
+ url.searchParams.set("code_challenge", opts.challenge);
944
+ url.searchParams.set("code_challenge_method", "S256");
945
+ return url.toString();
946
+ }
947
+ function isAntigravityCallbackUrl(raw) {
948
+ let u;
949
+ try {
950
+ u = new URL(raw.trim());
951
+ } catch {
952
+ return false;
953
+ }
954
+ return u.protocol === "https:" && u.hostname === "antigravity.google" && u.pathname === "/oauth-callback" && Boolean(u.searchParams.get("code"));
955
+ }
956
+ function parseAntigravityAuthInput(raw) {
957
+ const text = raw.trim().replace(/^["']|["']$/g, "");
958
+ if (!text)
959
+ return null;
960
+ if (isAntigravityCallbackUrl(text)) {
961
+ const u = new URL(text);
962
+ return { code: u.searchParams.get("code"), state: u.searchParams.get("state") };
963
+ }
964
+ if (/^https?:\/\//i.test(text))
965
+ return null;
966
+ if (/^4\/[A-Za-z0-9_\-/]+$/.test(text) && text.length >= 20)
967
+ return { code: text, state: null };
968
+ return null;
969
+ }
970
+ async function userAgent2() {
971
+ const v = await binVersion("agy") ?? FALLBACK_CLI_VERSION2;
972
+ return `antigravity-cli/${v}`;
973
+ }
974
+ function headers2(token, ua) {
975
+ return {
976
+ Authorization: `Bearer ${token}`,
977
+ "Content-Type": "application/json",
978
+ Accept: "application/json",
979
+ "User-Agent": ua
980
+ };
981
+ }
982
+ async function refreshAgyToken(refreshToken) {
983
+ const res = await fetchJson("https://oauth2.googleapis.com/token", {
984
+ method: "POST",
985
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
986
+ body: new URLSearchParams({
987
+ client_id: AGY_CLIENT_ID,
988
+ client_secret: AGY_CLIENT_SECRET,
989
+ refresh_token: refreshToken,
990
+ grant_type: "refresh_token"
991
+ }).toString()
992
+ });
993
+ if (res.status !== 200 || !isObject(res.body) || typeof res.body.access_token !== "string")
994
+ return null;
995
+ const expiresIn = typeof res.body.expires_in === "number" ? res.body.expires_in : 3600;
996
+ return {
997
+ accessToken: res.body.access_token,
998
+ refreshToken: typeof res.body.refresh_token === "string" && res.body.refresh_token ? res.body.refresh_token : refreshToken,
999
+ expiry: Date.now() + expiresIn * 1000
1000
+ };
1001
+ }
1002
+ async function exchangeAntigravityCode(code, verifier) {
1003
+ const res = await fetchJson("https://oauth2.googleapis.com/token", {
1004
+ method: "POST",
1005
+ headers: { "Content-Type": "application/x-www-form-urlencoded" },
1006
+ body: new URLSearchParams({
1007
+ client_id: AGY_CLIENT_ID,
1008
+ client_secret: AGY_CLIENT_SECRET,
1009
+ code,
1010
+ code_verifier: verifier,
1011
+ grant_type: "authorization_code",
1012
+ redirect_uri: AGY_REDIRECT_URI
1013
+ }).toString()
1014
+ });
1015
+ if (res.status !== 200 || !isObject(res.body) || typeof res.body.access_token !== "string" || typeof res.body.refresh_token !== "string") {
1016
+ throw new Error(isObject(res.body) && typeof res.body.error_description === "string" ? res.body.error_description : `Token exchange failed (HTTP ${res.status}).`);
1017
+ }
1018
+ const expiresIn = typeof res.body.expires_in === "number" ? res.body.expires_in : 3600;
1019
+ return {
1020
+ accessToken: res.body.access_token,
1021
+ refreshToken: res.body.refresh_token,
1022
+ expiry: Date.now() + expiresIn * 1000
1023
+ };
1024
+ }
1025
+ async function fetchAgyEmail(accessToken) {
1026
+ const res = await fetchJson("https://www.googleapis.com/oauth2/v2/userinfo", {
1027
+ headers: { Authorization: `Bearer ${accessToken}` }
1028
+ });
1029
+ if (res.status !== 200 || !isObject(res.body) || typeof res.body.email !== "string")
1030
+ return null;
1031
+ return res.body.email;
1032
+ }
1033
+ var GOOGLE_AI_PLANS = {
1034
+ "g1-plus-tier": "Plus",
1035
+ "g1-pro-tier": "Pro",
1036
+ "g1-ultra-tier": "Ultra"
1037
+ };
1038
+ var PRODUCT_TIER_NAMES = /^(antigravity|gemini code assist)$/i;
1039
+ function planFromTier(tier) {
1040
+ if (!tier)
1041
+ return null;
1042
+ const id = typeof tier.id === "string" ? tier.id.trim().toLowerCase() : "";
1043
+ if (id && GOOGLE_AI_PLANS[id])
1044
+ return GOOGLE_AI_PLANS[id];
1045
+ if (id.includes("ultra"))
1046
+ return "Ultra";
1047
+ if (id.includes("pro"))
1048
+ return "Pro";
1049
+ if (id.includes("plus"))
1050
+ return "Plus";
1051
+ const name = typeof tier.name === "string" ? tier.name.trim() : "";
1052
+ if (name && !PRODUCT_TIER_NAMES.test(name))
1053
+ return name;
1054
+ if (id === "free-tier")
1055
+ return "Free";
1056
+ if (id === "standard-tier")
1057
+ return "Standard";
1058
+ if (id === "legacy-tier")
1059
+ return "Legacy";
1060
+ return name || null;
1061
+ }
1062
+ function planFromCodeAssist(body) {
1063
+ if (!isObject(body))
1064
+ return null;
1065
+ const current = isObject(body.currentTier) ? body.currentTier : null;
1066
+ const paid = isObject(body.paidTier) ? body.paidTier : null;
1067
+ return planFromTier(paid) ?? planFromTier(current);
1068
+ }
1069
+ function windowMinutes(window) {
1070
+ const key = (window ?? "").toLowerCase().replace(/[_-]/g, "");
1071
+ if (key === "5h" || key === "fivehour" || key === "fivehours")
1072
+ return 300;
1073
+ if (key === "weekly" || key === "week")
1074
+ return 10080;
1075
+ return null;
1076
+ }
1077
+ function usageLabel(minutes, fallback) {
1078
+ if (minutes === 300)
1079
+ return "5h Usage";
1080
+ if (minutes === 10080)
1081
+ return "Weekly Usage";
1082
+ return fallback.replace(/\s+remaining$/i, "").trim() || "Usage";
1083
+ }
1084
+ function normalizeAntigravityQuota(body) {
1085
+ if (!isObject(body) || !Array.isArray(body.groups))
1086
+ return [];
1087
+ const out = [];
1088
+ for (const group of body.groups) {
1089
+ if (!isObject(group) || !Array.isArray(group.buckets))
1090
+ continue;
1091
+ const rawGroup = typeof group.displayName === "string" && group.displayName.trim() ? group.displayName.trim() : undefined;
1092
+ const groupName = rawGroup && !/^gemini models$/i.test(rawGroup) ? rawGroup : undefined;
1093
+ for (const bucket of group.buckets) {
1094
+ if (!isObject(bucket) || bucket.disabled === true)
1095
+ continue;
1096
+ const remaining = typeof bucket.remainingFraction === "number" ? bucket.remainingFraction : null;
1097
+ const used = remaining === null ? null : clampPercent((1 - remaining) * 100);
1098
+ if (used === null)
1099
+ continue;
1100
+ const id = typeof bucket.bucketId === "string" && bucket.bucketId ? bucket.bucketId : `${groupName ?? "quota"}:${out.length}`;
1101
+ const minutes = windowMinutes(typeof bucket.window === "string" ? bucket.window : undefined);
1102
+ const fallback = typeof bucket.displayName === "string" ? bucket.displayName : "Usage";
1103
+ out.push({
1104
+ id,
1105
+ label: usageLabel(minutes, fallback),
1106
+ group: groupName,
1107
+ usedPercent: used,
1108
+ resetsAt: isoOrNull(bucket.resetTime),
1109
+ windowMinutes: minutes,
1110
+ kind: "rolling"
1111
+ });
1112
+ }
1113
+ }
1114
+ return out;
1115
+ }
1116
+ async function resolveToken2(account) {
1117
+ if (account.kind === "token") {
1118
+ const raw = await secretStore().get(account.id);
1119
+ if (!raw)
1120
+ return { fail: snapshot(account, "error", { message: "Stored Google session missing. Remove the account and sign in again." }) };
1121
+ const parsed = parseStoredAgySecret(raw);
1122
+ if (!parsed)
1123
+ return { fail: snapshot(account, "error", { message: "Stored Google session is unreadable. Remove the account and sign in again." }) };
1124
+ return { token: parsed };
1125
+ }
1126
+ const token = await readDefaultAgyToken();
1127
+ if (!token) {
1128
+ if (usesGeminiApiKey()) {
1129
+ return { fail: snapshot(account, "unsupported", { message: "This CLI is using a Gemini API key, which has no subscription quota." }) };
1130
+ }
1131
+ return { fail: snapshot(account, "signed_out", { message: "Not signed in. Run `agy` and complete Google sign-in." }) };
1132
+ }
1133
+ return { token };
1134
+ }
1135
+ async function liveToken(account, token) {
1136
+ if (token.expiry && token.expiry > Date.now() + 60000 && token.accessToken)
1137
+ return token;
1138
+ const refreshed = await refreshAgyToken(token.refreshToken);
1139
+ if (!refreshed) {
1140
+ return { fail: snapshot(account, "error", { message: "Google session expired. Sign in again with `agy`." }) };
1141
+ }
1142
+ if (account.kind === "token") {
1143
+ await secretStore().set(account.id, serializeAgySecret(refreshed));
1144
+ }
1145
+ return refreshed;
1146
+ }
1147
+ async function fetchAntigravity(account) {
1148
+ try {
1149
+ const resolved = await resolveToken2(account);
1150
+ if ("fail" in resolved)
1151
+ return resolved.fail;
1152
+ const live = await liveToken(account, resolved.token);
1153
+ if ("fail" in live)
1154
+ return live.fail;
1155
+ const ua = await userAgent2();
1156
+ const h = headers2(live.accessToken, ua);
1157
+ const [usage, assist, email] = await Promise.all([
1158
+ fetchJson(`${USAGE_HOST}/v1internal:retrieveUserQuotaSummary`, { method: "POST", headers: h, body: "{}" }),
1159
+ fetchJson(`${USAGE_HOST}/v1internal:loadCodeAssist`, {
1160
+ method: "POST",
1161
+ headers: h,
1162
+ body: JSON.stringify({ metadata: { ideType: "ANTIGRAVITY" } })
1163
+ }),
1164
+ account.email ? Promise.resolve(account.email) : fetchAgyEmail(live.accessToken)
1165
+ ]);
1166
+ const plan = assist.status === 200 ? planFromCodeAssist(assist.body) : null;
1167
+ if (usage.status === 401) {
1168
+ return snapshot(account, "error", { email, plan, message: "Google session rejected (401). Sign in again." });
1169
+ }
1170
+ if (usage.status === 403) {
1171
+ return snapshot(account, "unsupported", {
1172
+ email,
1173
+ plan,
1174
+ message: "This Google account has no Antigravity quota. A Google AI Pro / Antigravity subscription is required."
1175
+ });
1176
+ }
1177
+ if (usage.status !== 200) {
1178
+ return snapshot(account, "error", { email, plan, message: `Usage endpoint returned HTTP ${usage.status}.` });
1179
+ }
1180
+ const windows = normalizeAntigravityQuota(usage.body);
1181
+ if (windows.length === 0) {
1182
+ return snapshot(account, "unsupported", { email, plan, message: "Usage response had no recognizable windows (schema may have changed)." });
1183
+ }
1184
+ return snapshot(account, "ok", { email, plan, windows });
1185
+ } catch (e) {
1186
+ return snapshot(account, "error", { message: errorMessage(e) });
1187
+ }
1188
+ }
1189
+
1190
+ // src/adapters/opencode.ts
1191
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "node:fs";
1192
+ import { homedir as homedir4 } from "node:os";
1193
+ import { join as join4 } from "node:path";
835
1194
  var USAGE_URL2 = "https://opencode.ai/zen/go/v1/usage";
836
1195
  function openCodeAuthFile() {
837
1196
  if (process.env.OPENCODE_AUTH_FILE)
838
1197
  return process.env.OPENCODE_AUTH_FILE;
839
1198
  const xdg = process.env.XDG_DATA_HOME;
840
- const base = xdg && xdg.trim() ? xdg : join3(homedir3(), ".local", "share");
841
- return join3(base, "opencode", "auth.json");
1199
+ const base = xdg && xdg.trim() ? xdg : join4(homedir4(), ".local", "share");
1200
+ return join4(base, "opencode", "auth.json");
842
1201
  }
843
1202
  function readOpenCodeGoKey() {
844
1203
  const file = openCodeAuthFile();
845
- if (!existsSync3(file))
1204
+ if (!existsSync4(file))
846
1205
  return null;
847
1206
  try {
848
- const parsed = JSON.parse(readFileSync3(file, "utf8"));
1207
+ const parsed = JSON.parse(readFileSync4(file, "utf8"));
849
1208
  const entry = parsed["opencode-go"];
850
1209
  if (isObject(entry) && typeof entry.key === "string" && entry.key)
851
1210
  return entry.key;
@@ -905,14 +1264,14 @@ async function fetchOpenCode(account) {
905
1264
  }
906
1265
 
907
1266
  // 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";
1267
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync2, rmSync } from "node:fs";
1268
+ import { dirname as dirname2, join as join5 } from "node:path";
910
1269
  function readRegistry() {
911
1270
  const file = paths.registry();
912
- if (!existsSync4(file))
1271
+ if (!existsSync5(file))
913
1272
  return { version: 1, accounts: [] };
914
1273
  try {
915
- const parsed = JSON.parse(readFileSync4(file, "utf8"));
1274
+ const parsed = JSON.parse(readFileSync5(file, "utf8"));
916
1275
  return { version: 1, accounts: Array.isArray(parsed.accounts) ? parsed.accounts : [] };
917
1276
  } catch {
918
1277
  return { version: 1, accounts: [] };
@@ -945,7 +1304,7 @@ function newAccountId(provider, hint) {
945
1304
  }
946
1305
  }
947
1306
  function profileDirFor(provider, id) {
948
- return join4(paths.profiles(provider), id.split(":")[1] ?? "account");
1307
+ return join5(paths.profiles(provider), id.split(":")[1] ?? "account");
949
1308
  }
950
1309
  function saveAccount(record) {
951
1310
  const reg = readRegistry();
@@ -1176,50 +1535,139 @@ async function submitCodexCallback(sessionId, url) {
1176
1535
  return;
1177
1536
  await submitLocalCallback(url);
1178
1537
  }
1538
+ var agySessions = new Map;
1539
+ function dropAgySession(id) {
1540
+ const rec = agySessions.get(id);
1541
+ if (!rec)
1542
+ return;
1543
+ clearTimeout(rec.timer);
1544
+ agySessions.delete(id);
1545
+ }
1546
+ function startAgySession(opts) {
1547
+ const pkce = createPkce();
1548
+ const state = crypto.randomUUID();
1549
+ const id = crypto.randomUUID();
1550
+ const authUrl = antigravityAuthUrl({ state, challenge: pkce.challenge });
1551
+ const rec = {
1552
+ id,
1553
+ label: opts.label?.trim() || undefined,
1554
+ accountId: opts.accountId,
1555
+ state,
1556
+ verifier: pkce.verifier,
1557
+ authUrl,
1558
+ status: "waiting",
1559
+ timer: setTimeout(() => dropAgySession(id), SESSION_TTL_MS)
1560
+ };
1561
+ rec.timer.unref?.();
1562
+ agySessions.set(id, rec);
1563
+ return { sessionId: id, authUrl };
1564
+ }
1565
+ async function beginAntigravityAdd(label) {
1566
+ if (!await which("agy"))
1567
+ throw new AccountError("agy is not installed (https://antigravity.google/docs/cli/install).");
1568
+ return startAgySession({ label });
1569
+ }
1570
+ async function beginAntigravityRelogin(accountId) {
1571
+ const existing = getAccount(accountId);
1572
+ if (!existing)
1573
+ throw new AccountError(`Unknown account: ${accountId}`, 404);
1574
+ if (existing.provider !== "antigravity" || existing.kind !== "token") {
1575
+ throw new AccountError(`${accountId} cannot be re-authenticated this way.`);
1576
+ }
1577
+ return startAgySession({ accountId });
1578
+ }
1579
+ function antigravitySessionStatus(sessionId) {
1580
+ const rec = agySessions.get(sessionId);
1581
+ if (!rec)
1582
+ return { status: "error", error: "Login session expired." };
1583
+ return { status: rec.status, authUrl: rec.authUrl, error: rec.error, account: rec.account };
1584
+ }
1585
+ async function submitAntigravityCallback(sessionId, raw) {
1586
+ const parsed = parseAntigravityAuthInput(raw);
1587
+ if (!parsed) {
1588
+ throw new AccountError("Paste the code from the Antigravity page, or the antigravity.google/oauth-callback URL.");
1589
+ }
1590
+ const rec = agySessions.get(sessionId);
1591
+ if (!rec)
1592
+ throw new AccountError("Login session expired.", 404);
1593
+ if (rec.status === "done" && rec.account)
1594
+ return rec.account;
1595
+ const { code, state } = parsed;
1596
+ if (state && state !== rec.state)
1597
+ throw new AccountError("Sign-in state did not match. Start again.");
1598
+ try {
1599
+ const token = await exchangeAntigravityCode(code, rec.verifier);
1600
+ const email = await fetchAgyEmail(token.accessToken);
1601
+ const named = rec.label?.trim() || "";
1602
+ if (rec.accountId) {
1603
+ await secretStore().set(rec.accountId, serializeAgySecret(token));
1604
+ updateAccount(rec.accountId, { email });
1605
+ rec.account = getAccount(rec.accountId);
1606
+ } else {
1607
+ const id = newAccountId("antigravity", named || email || "account");
1608
+ await secretStore().set(id, serializeAgySecret(token));
1609
+ rec.account = {
1610
+ id,
1611
+ provider: "antigravity",
1612
+ label: named,
1613
+ kind: "token",
1614
+ email,
1615
+ createdAt: new Date().toISOString()
1616
+ };
1617
+ saveAccount(rec.account);
1618
+ }
1619
+ rec.status = "done";
1620
+ return rec.account;
1621
+ } catch (e) {
1622
+ rec.status = "error";
1623
+ rec.error = e instanceof Error ? e.message : String(e);
1624
+ throw new AccountError(rec.error);
1625
+ }
1626
+ }
1179
1627
 
1180
1628
  // src/collect.ts
1181
1629
  import { hostname } from "node:os";
1182
1630
 
1183
1631
  // src/adapters/cursor.ts
1184
- import { existsSync as existsSync5, readFileSync as readFileSync5 } from "node:fs";
1185
- import { homedir as homedir4 } from "node:os";
1186
- import { join as join5 } from "node:path";
1632
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "node:fs";
1633
+ import { homedir as homedir5 } from "node:os";
1634
+ import { join as join6 } from "node:path";
1187
1635
  var USAGE_URL3 = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetCurrentPeriodUsage";
1188
1636
  var GROK_BOT_URL = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetSandUsageStatus";
1189
1637
  var PLAN_URL = "https://api2.cursor.sh/aiserver.v1.DashboardService/GetPlanInfo";
1190
- var KEYCHAIN_ACCOUNT = "cursor-user";
1191
- var KEYCHAIN_SERVICE = "cursor-access-token";
1638
+ var KEYCHAIN_ACCOUNT2 = "cursor-user";
1639
+ var KEYCHAIN_SERVICE2 = "cursor-access-token";
1192
1640
  function tokenFromAuthJson(parsed) {
1193
1641
  if (!isObject(parsed))
1194
1642
  return null;
1195
1643
  return typeof parsed.accessToken === "string" && parsed.accessToken ? parsed.accessToken : null;
1196
1644
  }
1197
1645
  function tokenFromAuthFile(file) {
1198
- if (!existsSync5(file))
1646
+ if (!existsSync6(file))
1199
1647
  return null;
1200
1648
  try {
1201
- return tokenFromAuthJson(JSON.parse(readFileSync5(file, "utf8")));
1649
+ return tokenFromAuthJson(JSON.parse(readFileSync6(file, "utf8")));
1202
1650
  } catch {
1203
1651
  return null;
1204
1652
  }
1205
1653
  }
1206
1654
  function authFiles() {
1207
- const home = homedir4();
1655
+ const home = homedir5();
1208
1656
  const out = [];
1209
1657
  if (process.platform === "win32") {
1210
- const roaming = process.env.APPDATA || join5(home, "AppData", "Roaming");
1211
- out.push(join5(roaming, "Cursor", "auth.json"));
1658
+ const roaming = process.env.APPDATA || join6(home, "AppData", "Roaming");
1659
+ out.push(join6(roaming, "Cursor", "auth.json"));
1212
1660
  } else if (process.platform !== "darwin") {
1213
- const xdg = process.env.XDG_CONFIG_HOME || join5(home, ".config");
1214
- out.push(join5(xdg, "cursor", "auth.json"));
1661
+ const xdg = process.env.XDG_CONFIG_HOME || join6(home, ".config");
1662
+ out.push(join6(xdg, "cursor", "auth.json"));
1215
1663
  }
1216
- out.push(join5(home, ".cursor", "auth.json"));
1664
+ out.push(join6(home, ".cursor", "auth.json"));
1217
1665
  return out;
1218
1666
  }
1219
- async function tokenFromKeychain() {
1667
+ async function tokenFromKeychain2() {
1220
1668
  if (process.platform !== "darwin")
1221
1669
  return null;
1222
- const res = await run("security", ["find-generic-password", "-a", KEYCHAIN_ACCOUNT, "-s", KEYCHAIN_SERVICE, "-w"], { timeoutMs: 20000 });
1670
+ const res = await run("security", ["find-generic-password", "-a", KEYCHAIN_ACCOUNT2, "-s", KEYCHAIN_SERVICE2, "-w"], { timeoutMs: 20000 });
1223
1671
  if (res.code !== 0)
1224
1672
  return null;
1225
1673
  const v = res.stdout.replace(/\r?\n$/, "");
@@ -1234,7 +1682,7 @@ async function readCursorAccessToken() {
1234
1682
  if (fromFile)
1235
1683
  return fromFile;
1236
1684
  }
1237
- const fromKeychain = await tokenFromKeychain();
1685
+ const fromKeychain = await tokenFromKeychain2();
1238
1686
  if (fromKeychain)
1239
1687
  return fromKeychain;
1240
1688
  if (process.env.CURSOR_AUTH_FILE)
@@ -1392,6 +1840,236 @@ async function fetchCursor(account) {
1392
1840
  }
1393
1841
  }
1394
1842
 
1843
+ // src/adapters/grok.ts
1844
+ import { existsSync as existsSync7, readFileSync as readFileSync7 } from "node:fs";
1845
+ import { homedir as homedir6 } from "node:os";
1846
+ import { join as join7 } from "node:path";
1847
+ var DEFAULT_PROXY = "https://cli-chat-proxy.grok.com/v1";
1848
+ var TOKEN_URL = "https://auth.x.ai/oauth2/token";
1849
+ var FALLBACK_CLI_VERSION3 = "1.0.13";
1850
+ var TIER_NAMES = {
1851
+ supergrok: "SuperGrok",
1852
+ supergrok_lite: "SuperGrok Lite",
1853
+ supergrok_plus: "SuperGrok Plus",
1854
+ supergrok_heavy: "SuperGrok Heavy",
1855
+ x_premium: "X Premium",
1856
+ x_premium_plus: "X Premium+"
1857
+ };
1858
+ function authFile() {
1859
+ if (process.env.GROK_AUTH_FILE)
1860
+ return process.env.GROK_AUTH_FILE;
1861
+ return join7(homedir6(), ".grok", "auth.json");
1862
+ }
1863
+ function proxyBase() {
1864
+ const raw = process.env.GROK_CLI_CHAT_PROXY_BASE_URL?.trim();
1865
+ if (!raw)
1866
+ return DEFAULT_PROXY;
1867
+ return raw.replace(/\/+$/, "");
1868
+ }
1869
+ function num(v) {
1870
+ if (typeof v === "number" && Number.isFinite(v))
1871
+ return v;
1872
+ if (isObject(v) && typeof v.val === "number" && Number.isFinite(v.val))
1873
+ return v.val;
1874
+ if (typeof v === "string" && v.trim()) {
1875
+ const n = Number(v);
1876
+ return Number.isFinite(n) ? n : null;
1877
+ }
1878
+ return null;
1879
+ }
1880
+ function money2(n) {
1881
+ return `$${n.toFixed(n % 1 ? 2 : 0)}`;
1882
+ }
1883
+ function sessionFromEntry(entry) {
1884
+ const access = typeof entry.key === "string" && entry.key ? entry.key : null;
1885
+ if (!access)
1886
+ return null;
1887
+ const expiry = typeof entry.expires_at === "string" ? Date.parse(entry.expires_at) : NaN;
1888
+ return {
1889
+ accessToken: access,
1890
+ refreshToken: typeof entry.refresh_token === "string" && entry.refresh_token ? entry.refresh_token : null,
1891
+ clientId: typeof entry.oidc_client_id === "string" && entry.oidc_client_id ? entry.oidc_client_id : null,
1892
+ email: typeof entry.email === "string" && entry.email.trim() ? entry.email.trim() : null,
1893
+ expiresAt: Number.isFinite(expiry) ? expiry : null,
1894
+ authMode: typeof entry.auth_mode === "string" ? entry.auth_mode : null
1895
+ };
1896
+ }
1897
+ function sessionFromAuthJson(parsed) {
1898
+ if (!isObject(parsed))
1899
+ return null;
1900
+ if (typeof parsed.key === "string" && parsed.key)
1901
+ return sessionFromEntry(parsed);
1902
+ const entries = Object.values(parsed).filter(isObject);
1903
+ const oidc = entries.find((e) => e.auth_mode === "oidc" && typeof e.key === "string" && e.key);
1904
+ if (oidc)
1905
+ return sessionFromEntry(oidc);
1906
+ const any = entries.find((e) => typeof e.key === "string" && e.key);
1907
+ return any ? sessionFromEntry(any) : null;
1908
+ }
1909
+ function readGrokSession() {
1910
+ const file = authFile();
1911
+ if (!existsSync7(file))
1912
+ return null;
1913
+ try {
1914
+ return sessionFromAuthJson(JSON.parse(readFileSync7(file, "utf8")));
1915
+ } catch {
1916
+ return null;
1917
+ }
1918
+ }
1919
+ function grokUsesApiKey(session) {
1920
+ if (session?.authMode === "oidc" && session.accessToken)
1921
+ return false;
1922
+ return Boolean(process.env.XAI_API_KEY?.trim()) || session?.authMode === "api_key";
1923
+ }
1924
+ function planFromGrokSettings(body) {
1925
+ if (!isObject(body))
1926
+ return null;
1927
+ if (typeof body.subscription_tier_display === "string" && body.subscription_tier_display.trim()) {
1928
+ return body.subscription_tier_display.trim();
1929
+ }
1930
+ const raw = typeof body.subscription_tier === "string" ? body.subscription_tier.trim() : "";
1931
+ if (!raw)
1932
+ return null;
1933
+ return TIER_NAMES[raw] ?? raw.replace(/_/g, " ");
1934
+ }
1935
+ function creditsRoot(body) {
1936
+ if (!isObject(body))
1937
+ return null;
1938
+ return isObject(body.config) ? body.config : body;
1939
+ }
1940
+ function normalizeGrokCredits(body) {
1941
+ const cfg = creditsRoot(body);
1942
+ if (!cfg)
1943
+ return [];
1944
+ const out = [];
1945
+ const period = isObject(cfg.currentPeriod) ? cfg.currentPeriod : null;
1946
+ const periodType = typeof period?.type === "string" ? period.type : "";
1947
+ const weekly = periodType.includes("WEEKLY");
1948
+ const resetsAt = isoOrNull(period?.end) ?? isoOrNull(cfg.billingPeriodEnd);
1949
+ const used = clampPercent(num(cfg.creditUsagePercent));
1950
+ if (used !== null) {
1951
+ out.push({
1952
+ id: "weekly",
1953
+ label: weekly || !periodType ? "Weekly Usage" : "Usage",
1954
+ usedPercent: used,
1955
+ resetsAt,
1956
+ windowMinutes: weekly || !periodType ? 10080 : null,
1957
+ kind: weekly || !periodType ? "rolling" : "cycle"
1958
+ });
1959
+ }
1960
+ const cap = num(cfg.onDemandCap);
1961
+ const spent = num(cfg.onDemandUsed);
1962
+ if (cap && cap > 0 && spent !== null) {
1963
+ out.push({
1964
+ id: "on_demand",
1965
+ label: "On-demand",
1966
+ usedPercent: clampPercent(spent / cap * 100),
1967
+ resetsAt,
1968
+ windowMinutes: weekly ? 10080 : null,
1969
+ kind: weekly ? "rolling" : "cycle",
1970
+ note: `${money2(spent)} of ${money2(cap)}`
1971
+ });
1972
+ }
1973
+ return out;
1974
+ }
1975
+ async function clientVersion() {
1976
+ return await binVersion("grok") ?? FALLBACK_CLI_VERSION3;
1977
+ }
1978
+ function headers3(token, version, email) {
1979
+ const h = {
1980
+ Authorization: `Bearer ${token}`,
1981
+ Accept: "application/json",
1982
+ "User-Agent": "xai-grok-cli",
1983
+ "x-grok-client-version": version,
1984
+ "x-grok-client-mode": "cli"
1985
+ };
1986
+ if (email)
1987
+ h["x-email"] = email;
1988
+ return h;
1989
+ }
1990
+ async function refreshAccessToken(session) {
1991
+ if (!session.refreshToken || !session.clientId)
1992
+ return null;
1993
+ const res = await fetchJson(TOKEN_URL, {
1994
+ method: "POST",
1995
+ headers: { "Content-Type": "application/x-www-form-urlencoded", Accept: "application/json" },
1996
+ body: new URLSearchParams({
1997
+ grant_type: "refresh_token",
1998
+ refresh_token: session.refreshToken,
1999
+ client_id: session.clientId
2000
+ }).toString()
2001
+ });
2002
+ if (res.status !== 200 || !isObject(res.body) || typeof res.body.access_token !== "string" || !res.body.access_token) {
2003
+ return null;
2004
+ }
2005
+ const expiresIn = typeof res.body.expires_in === "number" ? res.body.expires_in : 3600;
2006
+ return {
2007
+ ...session,
2008
+ accessToken: res.body.access_token,
2009
+ refreshToken: typeof res.body.refresh_token === "string" && res.body.refresh_token ? res.body.refresh_token : session.refreshToken,
2010
+ expiresAt: Date.now() + expiresIn * 1000
2011
+ };
2012
+ }
2013
+ async function liveSession(session) {
2014
+ if (session.expiresAt && session.expiresAt > Date.now() + 60000)
2015
+ return session;
2016
+ return await refreshAccessToken(session) ?? (session.expiresAt && session.expiresAt > Date.now() ? session : null);
2017
+ }
2018
+ async function fetchGrok(account) {
2019
+ try {
2020
+ const stored = readGrokSession();
2021
+ if (!stored) {
2022
+ if (grokUsesApiKey(null)) {
2023
+ return snapshot(account, "unsupported", { message: "This CLI is using an xAI API key, which has no SuperGrok quota." });
2024
+ }
2025
+ return snapshot(account, "signed_out", { message: "Not signed in. Run `grok login --oauth`." });
2026
+ }
2027
+ if (stored.authMode && stored.authMode !== "oidc") {
2028
+ return snapshot(account, "unsupported", { email: stored.email, message: "This CLI is using an xAI API key, which has no SuperGrok quota." });
2029
+ }
2030
+ let session = await liveSession(stored);
2031
+ if (!session) {
2032
+ return snapshot(account, "error", { email: stored.email, message: "Grok session expired. Run `grok login --oauth`." });
2033
+ }
2034
+ const version = await clientVersion();
2035
+ const base = proxyBase();
2036
+ const get = (token, path) => fetchJson(`${base}${path}`, { headers: headers3(token, version, session.email) });
2037
+ let [credits, settings] = await Promise.all([
2038
+ get(session.accessToken, "/billing?format=credits"),
2039
+ get(session.accessToken, "/settings").catch(() => ({ status: 0, body: null, text: "" }))
2040
+ ]);
2041
+ if (credits.status === 401 || credits.status === 403) {
2042
+ const refreshed = await refreshAccessToken(session);
2043
+ if (refreshed) {
2044
+ session = refreshed;
2045
+ [credits, settings] = await Promise.all([
2046
+ get(session.accessToken, "/billing?format=credits"),
2047
+ get(session.accessToken, "/settings").catch(() => ({ status: 0, body: null, text: "" }))
2048
+ ]);
2049
+ }
2050
+ }
2051
+ const email = session.email ?? account.email ?? null;
2052
+ const plan = settings.status === 200 ? planFromGrokSettings(settings.body) : null;
2053
+ if (credits.status === 401 || credits.status === 403) {
2054
+ return snapshot(account, "error", { email, plan, message: `Grok rejected the session (${credits.status}). Run \`grok login --oauth\`.` });
2055
+ }
2056
+ if (credits.status !== 200) {
2057
+ return snapshot(account, "error", { email, plan, message: `Usage endpoint returned HTTP ${credits.status}.` });
2058
+ }
2059
+ const windows = normalizeGrokCredits(credits.body);
2060
+ if (windows.length === 0) {
2061
+ return snapshot(account, "unsupported", {
2062
+ email,
2063
+ plan,
2064
+ message: "Signed in, but this account has no SuperGrok / Grok Build allowance."
2065
+ });
2066
+ }
2067
+ return snapshot(account, "ok", { email, plan, windows });
2068
+ } catch (e) {
2069
+ return snapshot(account, "error", { message: errorMessage(e) });
2070
+ }
2071
+ }
2072
+
1395
2073
  // src/adapters/index.ts
1396
2074
  function fetchSnapshot(account) {
1397
2075
  switch (account.provider) {
@@ -1401,6 +2079,10 @@ function fetchSnapshot(account) {
1401
2079
  return fetchCodex(account);
1402
2080
  case "cursor":
1403
2081
  return fetchCursor(account);
2082
+ case "antigravity":
2083
+ return fetchAntigravity(account);
2084
+ case "grok":
2085
+ return fetchGrok(account);
1404
2086
  case "opencode":
1405
2087
  return fetchOpenCode(account);
1406
2088
  }
@@ -1411,6 +2093,8 @@ var PROVIDERS = [
1411
2093
  { id: "claude", name: "Claude", bin: "claude" },
1412
2094
  { id: "codex", name: "Codex", bin: "codex" },
1413
2095
  { id: "cursor", name: "Cursor", bin: "cursor-agent" },
2096
+ { id: "antigravity", name: "Antigravity", bin: "agy" },
2097
+ { id: "grok", name: "Grok", bin: "grok" },
1414
2098
  { id: "opencode", name: "OpenCode Go", bin: "opencode" }
1415
2099
  ];
1416
2100
  function providerName(id) {
@@ -1495,10 +2179,231 @@ class ReportCache {
1495
2179
  }
1496
2180
  }
1497
2181
 
2182
+ // src/instance.ts
2183
+ import { existsSync as existsSync8, readdirSync, readFileSync as readFileSync8, unlinkSync, writeFileSync as writeFileSync3 } from "node:fs";
2184
+ function looksLikeJustUsageCommand(command) {
2185
+ const s = command.replace(/\\/g, "/").toLowerCase();
2186
+ return s.includes("just-usage") || /\/just-usage\/(?:src\/|dist\/)?cli\.(ts|js)\b/.test(s);
2187
+ }
2188
+ function parseRunRecord(raw) {
2189
+ try {
2190
+ const parsed = JSON.parse(raw);
2191
+ if (!isObject(parsed))
2192
+ return null;
2193
+ const pid = typeof parsed.pid === "number" ? parsed.pid : Number(parsed.pid);
2194
+ const port = typeof parsed.port === "number" ? parsed.port : Number(parsed.port);
2195
+ if (!Number.isInteger(pid) || pid <= 0)
2196
+ return null;
2197
+ if (!Number.isInteger(port) || port <= 0 || port > 65535)
2198
+ return null;
2199
+ return {
2200
+ pid,
2201
+ port,
2202
+ host: typeof parsed.host === "string" && parsed.host ? parsed.host : "0.0.0.0",
2203
+ startedAt: typeof parsed.startedAt === "string" ? parsed.startedAt : ""
2204
+ };
2205
+ } catch {
2206
+ return null;
2207
+ }
2208
+ }
2209
+ function writeRunRecord(opts) {
2210
+ ensureDir(paths.runDir());
2211
+ writeFileSync3(paths.runRecord(opts.port), JSON.stringify({
2212
+ pid: process.pid,
2213
+ port: opts.port,
2214
+ host: opts.host,
2215
+ startedAt: new Date().toISOString()
2216
+ }), { encoding: "utf8", mode: 384 });
2217
+ }
2218
+ function readRunRecord(port) {
2219
+ const file = paths.runRecord(port);
2220
+ if (!existsSync8(file))
2221
+ return null;
2222
+ return parseRunRecord(readFileSync8(file, "utf8"));
2223
+ }
2224
+ function removeRunRecord(port) {
2225
+ const file = paths.runRecord(port);
2226
+ try {
2227
+ unlinkSync(file);
2228
+ } catch {}
2229
+ }
2230
+ function listRunPorts() {
2231
+ const dir = paths.runDir();
2232
+ if (!existsSync8(dir))
2233
+ return [];
2234
+ const ports = [];
2235
+ for (const name of readdirSync(dir)) {
2236
+ const m = name.match(/^(\d+)\.json$/);
2237
+ if (!m)
2238
+ continue;
2239
+ const port = Number(m[1]);
2240
+ if (Number.isInteger(port) && port > 0 && port <= 65535)
2241
+ ports.push(port);
2242
+ }
2243
+ return ports.sort((a, b) => a - b);
2244
+ }
2245
+ function processAlive(pid) {
2246
+ try {
2247
+ process.kill(pid, 0);
2248
+ return true;
2249
+ } catch {
2250
+ return false;
2251
+ }
2252
+ }
2253
+ async function processCommand(pid) {
2254
+ if (process.platform === "win32") {
2255
+ const res = await run("wmic", ["process", "where", `ProcessId=${pid}`, "get", "CommandLine", "/value"], { timeoutMs: 5000 });
2256
+ const line = res.stdout.split(/\r?\n/).map((s) => s.trim()).find((s) => s.toLowerCase().startsWith("commandline="));
2257
+ return line ? line.slice(line.indexOf("=") + 1).trim() : "";
2258
+ }
2259
+ const res = await run("ps", ["-p", String(pid), "-www", "-o", "command="], { timeoutMs: 5000 });
2260
+ return res.stdout.split(/\r?\n/).map((s) => s.trim()).find((s) => s && !/^command$/i.test(s)) ?? "";
2261
+ }
2262
+ async function pidsOnPort(port) {
2263
+ if (process.platform === "win32") {
2264
+ const res = await run("netstat", ["-ano"], { timeoutMs: 8000 });
2265
+ const re = new RegExp(`[:\\[]${port}\\]?(?:\\s+\\S+){1,2}\\s+LISTENING\\s+(\\d+)`, "gi");
2266
+ const pids = new Set;
2267
+ for (const m of res.stdout.matchAll(re)) {
2268
+ const pid = Number(m[1]);
2269
+ if (Number.isInteger(pid) && pid > 0)
2270
+ pids.add(pid);
2271
+ }
2272
+ return [...pids];
2273
+ }
2274
+ const res = await run("lsof", ["-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { timeoutMs: 8000 });
2275
+ const pids = new Set;
2276
+ for (const line of res.stdout.split(/\r?\n/)) {
2277
+ const pid = Number(line.trim());
2278
+ if (Number.isInteger(pid) && pid > 0)
2279
+ pids.add(pid);
2280
+ }
2281
+ return [...pids];
2282
+ }
2283
+ function parseHealth(body) {
2284
+ if (!isObject(body) || body.ok !== true)
2285
+ return null;
2286
+ const pid = typeof body.pid === "number" && Number.isInteger(body.pid) && body.pid > 0 ? body.pid : null;
2287
+ return {
2288
+ ok: true,
2289
+ version: typeof body.version === "string" ? body.version : null,
2290
+ name: typeof body.name === "string" ? body.name : null,
2291
+ pid
2292
+ };
2293
+ }
2294
+ function isJustUsageHealth(info) {
2295
+ if (!info?.ok)
2296
+ return false;
2297
+ if (info.name && info.name !== PACKAGE_NAME)
2298
+ return false;
2299
+ return info.name === PACKAGE_NAME || Boolean(info.version);
2300
+ }
2301
+ async function fetchHealth(port) {
2302
+ try {
2303
+ const res = await fetchJson(`http://127.0.0.1:${port}/api/health`, { timeoutMs: 2000 });
2304
+ if (res.status !== 200)
2305
+ return null;
2306
+ return parseHealth(res.body);
2307
+ } catch {
2308
+ return null;
2309
+ }
2310
+ }
2311
+ async function oursOnPort(port) {
2312
+ const record = readRunRecord(port);
2313
+ const listeners = await pidsOnPort(port);
2314
+ const health = await fetchHealth(port);
2315
+ const ours = new Set;
2316
+ const known = new Set(listeners);
2317
+ if (record && processAlive(record.pid))
2318
+ known.add(record.pid);
2319
+ if (health?.pid && processAlive(health.pid))
2320
+ known.add(health.pid);
2321
+ for (const pid of known) {
2322
+ if (pid === process.pid)
2323
+ continue;
2324
+ const command = await processCommand(pid);
2325
+ const listening = listeners.includes(pid);
2326
+ if (looksLikeJustUsageCommand(command) || isJustUsageHealth(health) && (listening || pid === record?.pid || pid === health?.pid)) {
2327
+ ours.add(pid);
2328
+ }
2329
+ }
2330
+ return { pids: [...ours], health, listening: listeners.length > 0 };
2331
+ }
2332
+ async function terminate(pids) {
2333
+ for (const pid of pids) {
2334
+ try {
2335
+ process.kill(pid, "SIGTERM");
2336
+ } catch {}
2337
+ }
2338
+ const deadline = Date.now() + 2500;
2339
+ while (Date.now() < deadline && pids.some(processAlive)) {
2340
+ await new Promise((r) => setTimeout(r, 80));
2341
+ }
2342
+ for (const pid of pids) {
2343
+ if (!processAlive(pid))
2344
+ continue;
2345
+ try {
2346
+ process.kill(pid, "SIGKILL");
2347
+ } catch {}
2348
+ }
2349
+ }
2350
+ async function stopPort(port) {
2351
+ const found = await oursOnPort(port);
2352
+ if (found.pids.length === 0) {
2353
+ if (found.listening && !isJustUsageHealth(found.health))
2354
+ return { status: "busy", port };
2355
+ removeRunRecord(port);
2356
+ return { status: "idle", port };
2357
+ }
2358
+ await terminate(found.pids);
2359
+ removeRunRecord(port);
2360
+ return { status: "stopped", port, pids: found.pids };
2361
+ }
2362
+ async function stopServers(opts) {
2363
+ const ports = opts.all ? [...new Set([DEFAULT_PORT, ...listRunPorts(), ...opts.port ? [opts.port] : []])] : [opts.port ?? DEFAULT_PORT];
2364
+ const out = [];
2365
+ for (const port of ports)
2366
+ out.push(await stopPort(port));
2367
+ return out;
2368
+ }
2369
+ function formatStopResults(results) {
2370
+ const stopped = results.filter((r) => r.status === "stopped");
2371
+ const busy = results.filter((r) => r.status === "busy");
2372
+ const lines = [];
2373
+ for (const r of stopped) {
2374
+ lines.push(`Stopped ${PACKAGE_NAME} on port ${r.port} (pid ${r.pids.join(", ")}).`);
2375
+ }
2376
+ for (const r of busy) {
2377
+ lines.push(`Port ${r.port} is in use, but it is not a ${PACKAGE_NAME} server.`);
2378
+ }
2379
+ if (stopped.length === 0 && busy.length === 0) {
2380
+ if (results.length === 1)
2381
+ lines.push(`No ${PACKAGE_NAME} server is running on port ${results[0].port}.`);
2382
+ else
2383
+ lines.push(`No ${PACKAGE_NAME} server is running.`);
2384
+ }
2385
+ const code = busy.length ? 1 : stopped.length ? 0 : 1;
2386
+ return { text: lines.join(`
2387
+ `), code };
2388
+ }
2389
+
1498
2390
  // src/server.ts
1499
2391
  import { createServer } from "node:http";
1500
2392
  import { hostname as hostname2, networkInterfaces } from "node:os";
1501
2393
 
2394
+ // src/ui/favicon.svg
2395
+ var favicon_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 32 32">
2396
+ <rect width="32" height="32" rx="2.5" fill="#000"/>
2397
+ <defs>
2398
+ <mask id="card">
2399
+ <rect x="6" y="6" width="20" height="20" rx="4.5" fill="#fff"/>
2400
+ <rect x="20.5" y="20.5" width="5.5" height="5.5" fill="#000"/>
2401
+ </mask>
2402
+ </defs>
2403
+ <rect width="32" height="32" fill="#fff" mask="url(#card)"/>
2404
+ </svg>
2405
+ `;
2406
+
1502
2407
  // src/ui/index.html
1503
2408
  var ui_default = `<!doctype html>
1504
2409
  <html lang="en">
@@ -1507,7 +2412,7 @@ var ui_default = `<!doctype html>
1507
2412
  <meta name="viewport" content="width=device-width,initial-scale=1">
1508
2413
  <meta name="color-scheme" content="dark">
1509
2414
  <title>just-usage</title>
1510
- <link rel="icon" type="image/svg+xml" href="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 32 32'%3E%3Crect width='32' height='32' rx='7' fill='%23000'/%3E%3Crect x='7' y='14' width='18' height='4' rx='2' fill='%232a2a2a'/%3E%3Crect x='7' y='14' width='11' height='4' rx='2' fill='%23fff'/%3E%3C/svg%3E">
2415
+ <link rel="icon" type="image/svg+xml" href="/favicon.svg">
1511
2416
  <style>
1512
2417
  :root {
1513
2418
  --bg: #000;
@@ -1613,24 +2518,25 @@ var ui_default = `<!doctype html>
1613
2518
  .credits-avail:hover, .credits-avail:focus-visible { color: var(--fg); }
1614
2519
  .credits-tip {
1615
2520
  display: none; position: absolute; right: 0; bottom: calc(100% + 8px);
1616
- min-width: 240px; padding: 8px 10px;
2521
+ min-width: 0; padding: 8px 10px;
1617
2522
  background: var(--surface); border: 1px solid var(--line-2); border-radius: 8px;
1618
2523
  color: var(--muted); font-size: 11px; line-height: 1.45; white-space: nowrap; z-index: 5;
1619
2524
  }
1620
2525
  .credits-avail:hover .credits-tip, .credits-avail:focus-visible .credits-tip { display: block; }
1621
- .credits-tip-row { display: flex; align-items: baseline; gap: 12px; }
2526
+ .credits-tip-row { display: flex; align-items: baseline; gap: 10px; width: max-content; }
1622
2527
  .credits-tip-row + .credits-tip-row { margin-top: 6px; }
1623
2528
  .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; }
2529
+ .credits-tip-date { color: var(--fg); }
2530
+ .credits-tip-left { color: var(--dim); font-variant-numeric: tabular-nums; }
1626
2531
 
1627
2532
  #dash[hidden], #settings[hidden] { display: none; }
1628
2533
  .settings { display: flex; flex-direction: column; gap: 22px; }
1629
2534
  .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
- }
2535
+ #opts-providers { position: relative; }
2536
+ .opts-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; padding: 6px 0; user-select: none; transition: opacity .22s ease; }
2537
+ .opts-row.dragging { opacity: .62; cursor: grabbing; }
2538
+ .opts-row.dragging .grip { cursor: grabbing; }
2539
+ .opts-email { margin-top: 10px; padding-top: 16px; border-top: 1px solid var(--line); }
1634
2540
  .opts-prov { display: flex; align-items: center; gap: 8px; min-width: 0; }
1635
2541
  .opts-row img.logo { width: 14px; height: 14px; }
1636
2542
  .grip {
@@ -1662,11 +2568,11 @@ var ui_default = `<!doctype html>
1662
2568
  .seg input { appearance: none; position: absolute; }
1663
2569
  .seg label:has(input:checked) { background: #121212; color: var(--fg); }
1664
2570
  .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
2571
  .opts-link { background: none; border: none; color: var(--muted); font: inherit; font-size: 12px; cursor: pointer; padding: 0; }
1667
2572
  .opts-link:hover { color: var(--fg); }
1668
2573
  .opts-link[hidden] { display: none; }
1669
2574
  .acct-group { padding-bottom: 12px; }
2575
+ .acct-group:last-child { padding-bottom: 0; }
1670
2576
  .acct-group + .acct-group { border-top: 1px solid var(--line); padding-top: 12px; }
1671
2577
  .acct-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; font-size: 13px; }
1672
2578
  .acct-head img.logo { width: 14px; height: 14px; }
@@ -1683,6 +2589,7 @@ var ui_default = `<!doctype html>
1683
2589
  border: 1px solid var(--line); border-radius: 12px;
1684
2590
  padding: 0;
1685
2591
  }
2592
+ dialog.dlg form { margin: 0; }
1686
2593
  dialog.dlg::backdrop { background: rgba(0, 0, 0, .9); }
1687
2594
  .dlg-head {
1688
2595
  display: flex; justify-content: space-between; align-items: center;
@@ -1751,19 +2658,18 @@ var ui_default = `<!doctype html>
1751
2658
  <label><input type="radio" name="meter" value="used"> Used</label>
1752
2659
  </div>
1753
2660
  </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
2661
  <div>
1759
2662
  <div class="opts-inline tight">
1760
2663
  <div class="opts-label">Accounts</div>
1761
2664
  <button class="refresh" id="acct-add" type="button">Add account</button>
1762
2665
  </div>
1763
2666
  <div id="opts-accounts"></div>
2667
+ <label class="opts-row opts-email" for="opts-email">
2668
+ <span>Show email on accounts</span>
2669
+ <input class="chk" id="opts-email" type="checkbox">
2670
+ </label>
1764
2671
  </div>
1765
2672
  <div class="opts-actions">
1766
- <button class="opts-link" id="opts-signed-in" type="button">Signed-in only</button>
1767
2673
  <button class="refresh" id="refresh" type="button">Refresh data</button>
1768
2674
  </div>
1769
2675
  </div>
@@ -1774,11 +2680,13 @@ var ui_default = `<!doctype html>
1774
2680
  </div>
1775
2681
 
1776
2682
  <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>
2683
+ <form autocomplete="off" onsubmit="return false">
2684
+ <div class="dlg-head">
2685
+ <strong id="add-acct-title">Add account</strong>
2686
+ <button class="opts-link" id="add-acct-close" type="button">Close</button>
2687
+ </div>
2688
+ <div class="dlg-body" id="add-acct-body"></div>
2689
+ </form>
1782
2690
  </dialog>
1783
2691
 
1784
2692
  <script>
@@ -1787,13 +2695,17 @@ var ui_default = `<!doctype html>
1787
2695
  ["claude", "Claude"],
1788
2696
  ["codex", "Codex"],
1789
2697
  ["cursor", "Cursor"],
2698
+ ["antigravity", "Antigravity"],
2699
+ ["grok", "Grok"],
1790
2700
  ["opencode", "OpenCode Go"],
1791
2701
  ];
1792
2702
  const SIGNIN_HINT = {
1793
2703
  claude: "Run <code>claude</code>, then <code>/login</code>.",
1794
2704
  codex: "Run <code>codex login</code>.",
1795
2705
  cursor: "Run <code>cursor-agent login</code>.",
2706
+ grok: "Run <code>grok login --oauth</code>.",
1796
2707
  opencode: "Run <code>opencode auth login</code> or <code>just-usage add opencode</code>.",
2708
+ antigravity: "Run <code>agy</code> and complete Google sign-in.",
1797
2709
  };
1798
2710
  const $ = (id) => document.getElementById(id);
1799
2711
  let report = null;
@@ -1823,22 +2735,37 @@ var ui_default = `<!doctype html>
1823
2735
  } catch {}
1824
2736
  let dragging = false;
1825
2737
  let showLeft = localStorage.getItem("ju.showLeft") !== "0";
1826
- let order = PROVIDERS.map(([id]) => id);
2738
+ const defaultOrder = PROVIDERS.map(([id]) => id);
2739
+ let order = defaultOrder.slice();
1827
2740
  try {
1828
2741
  const raw = JSON.parse(localStorage.getItem("ju.order"));
1829
2742
  if (Array.isArray(raw)) {
1830
- const known = new Set(order);
2743
+ const known = new Set(defaultOrder);
1831
2744
  const next = raw.filter((id) => known.has(id));
1832
- for (const id of order) if (!next.includes(id)) next.push(id);
2745
+ for (const id of defaultOrder) {
2746
+ if (next.includes(id)) continue;
2747
+ const after = defaultOrder.slice(defaultOrder.indexOf(id) + 1).find((x) => next.includes(x));
2748
+ next.splice(after ? next.indexOf(after) : next.length, 0, id);
2749
+ }
2750
+ if (!localStorage.getItem("ju.orderGen") && next[next.length - 1] === "grok") {
2751
+ const i = next.indexOf("opencode");
2752
+ if (i !== -1) {
2753
+ next.pop();
2754
+ next.splice(i, 0, "grok");
2755
+ }
2756
+ }
1833
2757
  order = next;
1834
2758
  }
1835
2759
  } catch {}
2760
+ localStorage.setItem("ju.orderGen", "2");
2761
+ localStorage.setItem("ju.order", JSON.stringify(order));
1836
2762
 
1837
2763
  const esc = (s) => String(s ?? "").replace(/[&<>"']/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" }[c]));
1838
2764
  const rich = (s) => esc(s).replace(/\`([^\`]+)\`/g, "<code>$1</code>");
1839
2765
  const sev = (p) => (p == null ? null : p >= 85 ? "crit" : p >= 60 ? "warn" : "ok");
1840
2766
  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">\`;
2767
+ const logo = (id) => \`<img class="logo" src="/logos/\${id}.svg?v=5" alt="" width="14" height="14">\`;
2768
+ const noSuggest = \`autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"\`;
1842
2769
  const formatPlan = (p) => String(p).replace(/\\S+/g, (w) => w.charAt(0).toUpperCase() + w.slice(1));
1843
2770
  const GENERIC_LABELS = new Set(["", "default", "token", "go", "pending", "profile", "account", "codex"]);
1844
2771
  function isGenericLabel(label, email) {
@@ -1893,14 +2820,24 @@ var ui_default = `<!doctype html>
1893
2820
  return d === 1 ? "1 day ago" : \`\${d} days ago\`;
1894
2821
  }
1895
2822
 
1896
- function isSignedIn(p) {
1897
- return !!p?.accounts.some((a) => a.status !== "signed_out");
2823
+ function isDefaultVisible(p) {
2824
+ return !!p?.accounts.some((a) => a.status === "ok" || a.status === "error");
2825
+ }
2826
+
2827
+ /** true/false = user chose; missing = auto-show when the CLI is signed in. */
2828
+ function explicitEnabled(id) {
2829
+ if (!enabled || typeof enabled !== "object" || !Object.hasOwn(enabled, id)) return null;
2830
+ if (enabled[id] === true) return true;
2831
+ if (enabled[id] === false) return false;
2832
+ return null;
1898
2833
  }
1899
2834
 
1900
2835
  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)));
2836
+ return order.filter((id) => {
2837
+ const choice = explicitEnabled(id);
2838
+ if (choice !== null) return choice;
2839
+ return isDefaultVisible(report?.providers.find((x) => x.id === id));
2840
+ });
1904
2841
  }
1905
2842
 
1906
2843
  function ensureActive() {
@@ -1915,10 +2852,8 @@ var ui_default = `<!doctype html>
1915
2852
  if (next) localStorage.setItem("ju.providers", JSON.stringify(next));
1916
2853
  else localStorage.removeItem("ju.providers");
1917
2854
  ensureActive();
1918
- if (settingsOpen) {
1919
- renderTabs();
1920
- $("opts-signed-in").hidden = !enabled;
1921
- } else render();
2855
+ if (settingsOpen) renderTabs();
2856
+ else render();
1922
2857
  }
1923
2858
 
1924
2859
  function persistOrder(next) {
@@ -1989,9 +2924,16 @@ var ui_default = `<!doctype html>
1989
2924
  return html;
1990
2925
  }
1991
2926
 
2927
+ function planBadge(provider, plan) {
2928
+ if (!plan) return "";
2929
+ const name = PROVIDERS.find(([id]) => id === provider)?.[1] || provider;
2930
+ if (plan.toLowerCase() === name.toLowerCase() || plan.toLowerCase() === provider) return "";
2931
+ return \`<span class="plan">\${esc(formatPlan(plan))}</span>\`;
2932
+ }
2933
+
1992
2934
  function renderCard(a, provider, index) {
1993
2935
  const title = accountTitle(a, index);
1994
- const plan = a.account.plan ? \`<span class="plan">\${esc(formatPlan(a.account.plan))}</span>\` : "";
2936
+ const plan = planBadge(provider, a.account.plan);
1995
2937
  let badge = "";
1996
2938
  if (a.status === "ok") {
1997
2939
  let worst = null;
@@ -2071,7 +3013,7 @@ var ui_default = `<!doctype html>
2071
3013
  }).join("");
2072
3014
  for (const input of $("opts-providers").querySelectorAll("input")) {
2073
3015
  input.onchange = () => {
2074
- const next = Object.fromEntries(order.map((id) => [id, vis.has(id)]));
3016
+ const next = { ...(enabled || {}) };
2075
3017
  next[input.dataset.id] = input.checked;
2076
3018
  persistEnabled(next);
2077
3019
  };
@@ -2084,22 +3026,48 @@ var ui_default = `<!doctype html>
2084
3026
  const row = grip.closest(".opts-row");
2085
3027
  dragging = true;
2086
3028
  row.classList.add("dragging");
3029
+ document.body.style.cursor = "grabbing";
3030
+ try { grip.setPointerCapture(e.pointerId); } catch {}
3031
+ const ease = "transform .36s cubic-bezier(.4, 0, .2, 1)";
2087
3032
  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
- });
3033
+ const rows = [...list.querySelectorAll(".opts-row")];
3034
+ const others = rows.filter((r) => r !== row);
3035
+ const y = clientY - list.getBoundingClientRect().top + list.scrollTop;
3036
+ const before = others.find((r) => y < r.offsetTop + r.offsetHeight / 2 - 6);
3037
+ if ((before && row.nextElementSibling === before) || (!before && list.lastElementChild === row)) return;
3038
+ const first = new Map(rows.map((r) => {
3039
+ r.style.transition = "none";
3040
+ return [r, r.getBoundingClientRect()];
3041
+ }));
2093
3042
  if (before) list.insertBefore(row, before);
2094
3043
  else list.appendChild(row);
3044
+ for (const r of rows) {
3045
+ r.style.transform = "";
3046
+ const from = first.get(r);
3047
+ const dy = from.top - r.getBoundingClientRect().top;
3048
+ r.style.transform = dy ? \`translateY(\${dy}px)\` : "";
3049
+ }
3050
+ list.getBoundingClientRect();
3051
+ for (const r of rows) {
3052
+ if (!r.style.transform) continue;
3053
+ r.style.transition = ease;
3054
+ r.style.transform = "";
3055
+ }
2095
3056
  };
2096
3057
  const move = (ev) => place(ev.clientY);
2097
3058
  const stop = () => {
2098
3059
  document.removeEventListener("pointermove", move);
2099
3060
  document.removeEventListener("pointerup", stop);
2100
3061
  document.removeEventListener("pointercancel", stop);
3062
+ document.body.style.cursor = "";
2101
3063
  dragging = false;
2102
3064
  row.classList.remove("dragging");
3065
+ window.setTimeout(() => {
3066
+ for (const r of list.querySelectorAll(".opts-row")) {
3067
+ r.style.transition = "";
3068
+ r.style.transform = "";
3069
+ }
3070
+ }, 380);
2103
3071
  const next = [...list.querySelectorAll(".opts-row")].map((r) => r.dataset.id);
2104
3072
  if (next.join() !== order.join()) persistOrder(next);
2105
3073
  };
@@ -2108,7 +3076,6 @@ var ui_default = `<!doctype html>
2108
3076
  document.addEventListener("pointercancel", stop);
2109
3077
  };
2110
3078
  }
2111
- $("opts-signed-in").hidden = !enabled;
2112
3079
  for (const input of $("opts-meter").querySelectorAll("input")) {
2113
3080
  input.checked = (input.value === "left") === showLeft;
2114
3081
  }
@@ -2139,7 +3106,7 @@ var ui_default = `<!doctype html>
2139
3106
  const extra = a.account.kind === "default" ? "from CLI" : a.account.kind;
2140
3107
  if (renaming === a.account.id) {
2141
3108
  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)}">
3109
+ <span class="acct-rename"><input class="field acct-rename-input" data-id="\${esc(a.account.id)}" value="\${esc(title)}" \${noSuggest}>
2143
3110
  <button class="refresh acct-rename-save" type="button" data-id="\${esc(a.account.id)}">Save</button></span>
2144
3111
  <button class="opts-link acct-rename-cancel" type="button">Cancel</button>
2145
3112
  </div>\`;
@@ -2225,25 +3192,30 @@ var ui_default = `<!doctype html>
2225
3192
  body = \`<div class="pick">
2226
3193
  <button class="pick-btn" type="button" data-id="claude">\${logo("claude")} Claude</button>
2227
3194
  <button class="pick-btn" type="button" data-id="codex">\${logo("codex")} Codex</button>
3195
+ <button class="pick-btn" type="button" data-id="antigravity">\${logo("antigravity")} Antigravity</button>
2228
3196
  <button class="pick-btn" type="button" data-id="opencode">\${logo("opencode")} OpenCode Go</button>
2229
3197
  </div>
2230
- <div class="acct-note">One Cursor account for now — whatever <code>cursor-agent</code> is signed in as.</div>\`;
3198
+ <div class="acct-note">One Cursor account for now — whatever <code>cursor-agent</code> is signed in as.</div>
3199
+ <div class="acct-note">One Grok account for now — whatever <code>grok</code> is signed in as.</div>\`;
2231
3200
  } else if (wizard.step === "label") {
2232
3201
  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)}">
3202
+ <input class="field" id="wiz-label" placeholder="Label (optional)" value="\${esc(wizard.label)}" \${noSuggest}>
2234
3203
  <div class="acct-actions"><button class="opts-link" id="wiz-back" type="button">Back</button>
2235
3204
  <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>
3205
+ } else if (wizard.provider === "codex" || wizard.provider === "antigravity") {
3206
+ const oauth = wizard.provider === "codex"
3207
+ ? { note: "Sign in with ChatGPT. If you are not on this machine, paste the localhost callback after the redirect.", action: "Sign in with ChatGPT", ph: "http://localhost:… callback", start: "wiz-codex", submit: "Submit callback" }
3208
+ : { note: "Sign in with Google. Copy the code from the Antigravity page (or paste the callback URL). This extra login does not replace <code>agy</code>'s default account.", action: "Sign in with Google", ph: "4/0A… code from the page", start: "wiz-agy", submit: "Submit code" };
3209
+ body = \`<div class="acct-note">\${oauth.note}</div>
2238
3210
  \${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>\`}
3211
+ <input class="field" id="wiz-callback" placeholder="\${esc(oauth.ph)}" value="\${esc(wizard.callback)}" \${noSuggest}>
3212
+ <button class="refresh" id="wiz-callback-go" type="button">\${oauth.submit || "Submit callback"}</button>\`
3213
+ : \`<button class="refresh" id="\${oauth.start}" type="button">\${oauth.action}</button>\`}
2242
3214
  <button class="opts-link" id="wiz-back" type="button">Back</button>\`;
2243
3215
  } else {
2244
3216
  const ph = wizard.provider === "claude" ? "Setup token from <code>claude setup-token</code>" : "OpenCode Go API key";
2245
3217
  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">
3218
+ <input class="field" id="wiz-secret" type="password" placeholder="\${wizard.provider === "claude" ? "Setup token" : "API key"}" value="\${esc(wizard.secret)}" \${noSuggest}>
2247
3219
  <div class="acct-actions"><button class="opts-link" id="wiz-back" type="button">Back</button>
2248
3220
  <button class="refresh" id="wiz-save" type="button">Add</button></div>\`;
2249
3221
  }
@@ -2271,8 +3243,10 @@ var ui_default = `<!doctype html>
2271
3243
  if (save) save.onclick = addToken;
2272
3244
  const start = $("wiz-codex");
2273
3245
  if (start) start.onclick = startCodex;
3246
+ const startAgy = $("wiz-agy");
3247
+ if (startAgy) startAgy.onclick = startAntigravity;
2274
3248
  const submit = $("wiz-callback-go");
2275
- if (submit) submit.onclick = submitCodexCallback;
3249
+ if (submit) submit.onclick = wizard.provider === "antigravity" ? submitAntigravityCallback : submitCodexCallback;
2276
3250
  }
2277
3251
 
2278
3252
  async function addToken() {
@@ -2329,6 +3303,56 @@ var ui_default = `<!doctype html>
2329
3303
  }
2330
3304
  }
2331
3305
 
3306
+ async function startAntigravity() {
3307
+ if (busy) return;
3308
+ const popup = window.open("about:blank", "_blank");
3309
+ busy = true;
3310
+ wizardMsg("Starting sign-in…", false);
3311
+ renderWizard();
3312
+ try {
3313
+ const body = await api("/api/accounts/antigravity/start", {
3314
+ method: "POST",
3315
+ headers: { "Content-Type": "application/json" },
3316
+ body: JSON.stringify({ label: wizard.label }),
3317
+ });
3318
+ wizard.sessionId = body.sessionId;
3319
+ wizard.authUrl = body.authUrl;
3320
+ if (body.authUrl) {
3321
+ if (popup) popup.location.replace(body.authUrl);
3322
+ else window.location.assign(body.authUrl);
3323
+ } else {
3324
+ popup?.close();
3325
+ }
3326
+ wizardMsg("Copy the code from the Antigravity page and paste it here.", false);
3327
+ } catch (e) {
3328
+ popup?.close();
3329
+ wizardMsg(e.message, true);
3330
+ } finally {
3331
+ busy = false;
3332
+ renderWizard();
3333
+ }
3334
+ }
3335
+
3336
+ async function submitAntigravityCallback() {
3337
+ if (!wizard.sessionId) return;
3338
+ if (busy) return;
3339
+ busy = true;
3340
+ try {
3341
+ await api("/api/accounts/antigravity/callback", {
3342
+ method: "POST",
3343
+ headers: { "Content-Type": "application/json" },
3344
+ body: JSON.stringify({ sessionId: wizard.sessionId, url: wizard.callback }),
3345
+ });
3346
+ closeWizard();
3347
+ await load(true);
3348
+ } catch (e) {
3349
+ wizardMsg(e.message, true);
3350
+ renderWizard();
3351
+ } finally {
3352
+ busy = false;
3353
+ }
3354
+ }
3355
+
2332
3356
  async function submitCodexCallback() {
2333
3357
  if (!wizard.sessionId) return;
2334
3358
  try {
@@ -2422,7 +3446,10 @@ var ui_default = `<!doctype html>
2422
3446
  for (const a of p.accounts || []) {
2423
3447
  const old = prev?.accounts.find((x) => x.account.id === a.account.id);
2424
3448
  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;
3449
+ if (!a.account.plan && old?.account.plan) {
3450
+ const name = PROVIDERS.find(([id]) => id === p.id)?.[1] || p.id;
3451
+ if (old.account.plan.toLowerCase() !== name.toLowerCase()) a.account.plan = old.account.plan;
3452
+ }
2426
3453
  }
2427
3454
  }
2428
3455
  }
@@ -2465,7 +3492,6 @@ var ui_default = `<!doctype html>
2465
3492
 
2466
3493
  $("opts-btn").onclick = () => setView(!settingsOpen);
2467
3494
  $("refresh").onclick = () => load(true, true);
2468
- $("opts-signed-in").onclick = () => persistEnabled(null);
2469
3495
  $("opts-email").onchange = () => {
2470
3496
  showEmail = $("opts-email").checked;
2471
3497
  localStorage.setItem("ju.showEmail", showEmail ? "1" : "0");
@@ -2516,6 +3542,13 @@ var ui_default = `<!doctype html>
2516
3542
  </html>
2517
3543
  `;
2518
3544
 
3545
+ // src/ui/logos/antigravity.svg
3546
+ var antigravity_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ececec" role="img">
3547
+ <title>Antigravity</title>
3548
+ <path d="M21.218 22.023c1.273.954 3.182.318 1.432-1.432C17.4 15.5 18.513 1.5 11.99 1.5 5.468 1.5 6.581 15.5 1.331 20.591c-1.91 1.91.16 2.386 1.432 1.432C7.695 18.682 7.377 12.795 11.99 12.795c4.614 0 4.296 5.887 9.228 9.228Z"/>
3549
+ </svg>
3550
+ `;
3551
+
2519
3552
  // src/ui/logos/claude.svg
2520
3553
  var claude_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ececec" role="img">
2521
3554
  <title>Claude</title>
@@ -2537,6 +3570,14 @@ var cursor_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24
2537
3570
  </svg>
2538
3571
  `;
2539
3572
 
3573
+ // src/ui/logos/grok.svg
3574
+ var grok_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512" fill="#ececec" role="img">
3575
+ <title>Grok</title>
3576
+ <path d="M210.484 312.759 343.465 210.383c6.519-5.019 15.837-3.061 18.943 4.734 16.35 41.114 9.046 90.523-23.483 124.446-32.528 33.924-77.788 41.364-119.157 24.42l-45.191 21.82c64.817 46.205 143.527 34.778 192.712-16.552 39.014-40.687 51.097-96.147 39.799-146.16l.102.107c-16.383-73.472 4.028-102.839 45.84-162.891.99-1.424 1.98-2.848 2.97-4.307L400.978 113.382v-.178L210.45 312.794"/>
3577
+ <path d="M183.042 337.641c-46.523-46.347-38.502-118.074 1.194-159.438 29.354-30.613 77.447-43.107 119.43-24.739l45.089-21.714c-8.123-6.123-18.534-12.708-30.48-17.336-53.998-23.173-118.645-11.64-162.54 34.102-42.222 44.033-55.499 111.738-32.699 169.511 17.033 43.179-10.888 73.721-39.013 104.548C74.056 433.503 64.055 444.431 56 456l127.007-118.323"/>
3578
+ </svg>
3579
+ `;
3580
+
2540
3581
  // src/ui/logos/opencode.svg
2541
3582
  var opencode_default = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="#ececec" fill-rule="evenodd" role="img">
2542
3583
  <title>OpenCode</title>
@@ -2549,7 +3590,9 @@ var LOGOS = {
2549
3590
  claude: claude_default,
2550
3591
  codex: codex_default,
2551
3592
  cursor: cursor_default,
2552
- opencode: opencode_default
3593
+ opencode: opencode_default,
3594
+ antigravity: antigravity_default,
3595
+ grok: grok_default
2553
3596
  };
2554
3597
  function json(res, status, body) {
2555
3598
  const text = JSON.stringify(body);
@@ -2603,6 +3646,11 @@ async function startServer(opts) {
2603
3646
  res.setHeader("X-Content-Type-Options", "nosniff");
2604
3647
  try {
2605
3648
  const method = req.method ?? "GET";
3649
+ if ((url.pathname === "/favicon.svg" || url.pathname === "/favicon.ico") && (method === "GET" || method === "HEAD")) {
3650
+ res.writeHead(200, { "Content-Type": "image/svg+xml; charset=utf-8", "Cache-Control": "public, max-age=86400" });
3651
+ res.end(favicon_default);
3652
+ return;
3653
+ }
2606
3654
  if (url.pathname === "/" && (method === "GET" || method === "HEAD")) {
2607
3655
  res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-store" });
2608
3656
  res.end(ui_default);
@@ -2625,7 +3673,7 @@ async function startServer(opts) {
2625
3673
  return;
2626
3674
  }
2627
3675
  if (url.pathname === "/api/health" && (method === "GET" || method === "HEAD")) {
2628
- json(res, 200, { ok: true, version: VERSION });
3676
+ json(res, 200, { ok: true, name: PACKAGE_NAME, version: VERSION, pid: process.pid });
2629
3677
  return;
2630
3678
  }
2631
3679
  if (url.pathname === "/api/accounts" && method === "POST") {
@@ -2641,7 +3689,23 @@ async function startServer(opts) {
2641
3689
  json(res, 200, await mutated(() => addOpenCodeKey(secret, label)));
2642
3690
  return;
2643
3691
  }
2644
- json(res, 400, { error: "Add Claude with a setup-token, OpenCode with an API key, or start a Codex sign-in." });
3692
+ json(res, 400, { error: "Add Claude with a setup-token, OpenCode with an API key, or start a Codex / Antigravity sign-in." });
3693
+ return;
3694
+ }
3695
+ if (url.pathname === "/api/accounts/antigravity/start" && method === "POST") {
3696
+ const body = await readJson(req);
3697
+ const accountId = str(body.accountId);
3698
+ json(res, 200, accountId ? await beginAntigravityRelogin(accountId) : await beginAntigravityAdd(str(body.label) || undefined));
3699
+ return;
3700
+ }
3701
+ if (url.pathname === "/api/accounts/antigravity/callback" && method === "POST") {
3702
+ const body = await readJson(req);
3703
+ json(res, 200, { ok: true, account: await mutated(() => submitAntigravityCallback(str(body.sessionId), str(body.url))) });
3704
+ return;
3705
+ }
3706
+ const agySession = url.pathname.match(/^\/api\/accounts\/antigravity\/session\/([^/]+)$/);
3707
+ if (agySession && (method === "GET" || method === "HEAD")) {
3708
+ json(res, 200, antigravitySessionStatus(decodeURIComponent(agySession[1])));
2645
3709
  return;
2646
3710
  }
2647
3711
  if (url.pathname === "/api/accounts/codex/start" && method === "POST") {
@@ -2695,7 +3759,14 @@ async function startServer(opts) {
2695
3759
  return new Promise((resolve, reject) => {
2696
3760
  server.once("error", reject);
2697
3761
  server.listen(opts.port, opts.host, () => {
2698
- resolve({ close: () => server.close(), urls: reachableUrls(opts.host, opts.port, tailscaleIp) });
3762
+ writeRunRecord({ port: opts.port, host: opts.host });
3763
+ resolve({
3764
+ close: () => {
3765
+ removeRunRecord(opts.port);
3766
+ server.close();
3767
+ },
3768
+ urls: reachableUrls(opts.host, opts.port, tailscaleIp)
3769
+ });
2699
3770
  });
2700
3771
  });
2701
3772
  }
@@ -2703,7 +3774,6 @@ function isTailscale(ip) {
2703
3774
  const [a, b] = ip.split(".").map(Number);
2704
3775
  return a === 100 && b !== undefined && b >= 64 && b <= 127;
2705
3776
  }
2706
- var TAILSCALE_NOTE = "Tailscale only. Not same-Wi-Fi. Other devices need Tailscale too.";
2707
3777
  async function detectTailscaleIp() {
2708
3778
  const res = await run("tailscale", ["status", "--json"], { timeoutMs: 4000 });
2709
3779
  if (res.code !== 0)
@@ -2744,7 +3814,7 @@ function reachableUrls(host, port, tailscaleIp = null) {
2744
3814
  }
2745
3815
  }
2746
3816
  if (tailscaleIp) {
2747
- urls.push({ kind: "tailscale", url: `http://${tailscaleIp}:${port}`, note: TAILSCALE_NOTE });
3817
+ urls.push({ kind: "tailscale", url: `http://${tailscaleIp}:${port}` });
2748
3818
  }
2749
3819
  return urls;
2750
3820
  }
@@ -2809,15 +3879,15 @@ function renderReport(report, now = Date.now()) {
2809
3879
  }
2810
3880
 
2811
3881
  // src/update.ts
2812
- import { existsSync as existsSync6, readFileSync as readFileSync6, writeFileSync as writeFileSync3 } from "node:fs";
3882
+ import { existsSync as existsSync9, readFileSync as readFileSync9, writeFileSync as writeFileSync4 } from "node:fs";
2813
3883
  import { dirname as dirname3 } from "node:path";
2814
3884
  var CHECK_INTERVAL_MS = 12 * 60 * 60 * 1000;
2815
3885
  function readCache() {
2816
3886
  const file = paths.updateCache();
2817
- if (!existsSync6(file))
3887
+ if (!existsSync9(file))
2818
3888
  return null;
2819
3889
  try {
2820
- const parsed = JSON.parse(readFileSync6(file, "utf8"));
3890
+ const parsed = JSON.parse(readFileSync9(file, "utf8"));
2821
3891
  if (typeof parsed.checkedAt === "string" && typeof parsed.latest === "string")
2822
3892
  return parsed;
2823
3893
  } catch {}
@@ -2826,7 +3896,7 @@ function readCache() {
2826
3896
  function writeCache(c) {
2827
3897
  try {
2828
3898
  ensureDir(dirname3(paths.updateCache()));
2829
- writeFileSync3(paths.updateCache(), JSON.stringify(c) + `
3899
+ writeFileSync4(paths.updateCache(), JSON.stringify(c) + `
2830
3900
  `);
2831
3901
  } catch {}
2832
3902
  }
@@ -2919,18 +3989,69 @@ Usage
2919
3989
  --port <n> Port (default ${DEFAULT_PORT})
2920
3990
  --host <addr> Bind address (default ${DEFAULT_HOST}; use 127.0.0.1 for local only)
2921
3991
  --no-open Don't open a browser
3992
+ just-usage stop [options] Stop a running just-usage server
3993
+ --port <n> Port (default ${DEFAULT_PORT})
3994
+ --all Stop every just-usage server this CLI started
2922
3995
  just-usage status [--json] Print quotas in the terminal
2923
3996
  just-usage accounts List accounts
2924
- just-usage add <provider> Add an account (codex | claude | opencode)
3997
+ just-usage add <provider> Add an account (codex | claude | opencode | antigravity)
2925
3998
  --label <name> Friendly name
2926
3999
  --token Claude only: paste a \`claude setup-token\` instead of a profile login
2927
4000
  just-usage login <account-id> Re-authenticate a profile account
2928
4001
  just-usage remove <account-id> Remove an account and anything we stored for it
2929
4002
  just-usage upgrade [--check] Update to the latest release
2930
4003
  just-usage --version
4004
+ just-usage stop --help More on stopping a running server
2931
4005
 
2932
4006
  Providers: ${PROVIDERS.map((p) => p.name).join(", ")}
2933
4007
  Cursor uses whatever \`cursor-agent\` is logged in as (single account).
4008
+ Grok uses whatever \`grok login --oauth\` stored (single account).
4009
+ Antigravity extras are extra Google logins; they do not replace \`agy\`'s signed-in account.
4010
+
4011
+ Stop
4012
+ \`just-usage stop\` asks the server to exit — the same as Ctrl+C in the terminal
4013
+ that started it. Use this when that terminal is gone or another instance is
4014
+ still holding the port.
4015
+
4016
+ just-usage stop Stop the default server (port ${DEFAULT_PORT})
4017
+ just-usage stop --port 5758 Stop a server you started on another port
4018
+ just-usage stop --all Stop every just-usage server recorded here
4019
+
4020
+ It only stops a process it can confirm is just-usage (pid file, health route,
4021
+ or command line). An unrelated process on the same port is left alone.
4022
+ `;
4023
+ var STOP_HELP = `${PACKAGE_NAME} stop
4024
+ Stop a running just-usage server.
4025
+
4026
+ Usage
4027
+ just-usage stop
4028
+ just-usage stop --port <n>
4029
+ just-usage stop --all
4030
+
4031
+ What it does
4032
+ The server writes a small pid file under ~/.config/just-usage/run/ when it
4033
+ starts. \`stop\` reads that file and asks the process to exit (SIGTERM, then
4034
+ SIGKILL if it ignores the first signal).
4035
+
4036
+ If the pid file is missing or stale — for example after a crash — \`stop\`
4037
+ looks for a listener on the port and checks GET /api/health. Only a confirmed
4038
+ just-usage server is stopped.
4039
+
4040
+ Options
4041
+ --port <n> Port the server is bound to (default ${DEFAULT_PORT})
4042
+ --all Stop every just-usage server this CLI has a pid file for,
4043
+ plus the default port if that is still running
4044
+ --help, -h Show this help
4045
+
4046
+ Examples
4047
+ just-usage stop
4048
+ just-usage serve --port 5758
4049
+ just-usage stop --port 5758
4050
+ just-usage stop --all
4051
+
4052
+ See also
4053
+ just-usage serve Start the server
4054
+ just-usage --help All commands
2934
4055
  `;
2935
4056
  function fail(msg, code = 1) {
2936
4057
  console.error(msg);
@@ -2994,17 +4115,21 @@ Update available: v${u.current} → v${u.latest}. Run: just-usage upgrade
2994
4115
  server = await startServer({ host, port, getUpdate: () => update });
2995
4116
  } catch (e) {
2996
4117
  const code = e.code;
2997
- if (code === "EADDRINUSE")
2998
- fail(`Port ${port} is already in use. Try: just-usage serve --port ${port + 1}`);
4118
+ if (code === "EADDRINUSE") {
4119
+ const stop = port === DEFAULT_PORT ? "just-usage stop" : `just-usage stop --port ${port}`;
4120
+ fail(`Port ${port} is already in use. Stop it with: ${stop}
4121
+ Or start another: just-usage serve --port ${port + 1}`);
4122
+ }
2999
4123
  throw e;
3000
4124
  }
3001
4125
  console.log(`${PACKAGE_NAME} v${VERSION}`);
3002
4126
  for (const u of server.urls) {
3003
4127
  const tag = u.kind === "local" ? "local " : u.kind === "tailscale" ? "tailscale" : "network ";
3004
- console.log(` ${tag} ${u.url}${u.note ? ` — ${u.note}` : ""}`);
4128
+ console.log(` ${tag} ${u.url}`);
3005
4129
  }
4130
+ const stopHint = port === DEFAULT_PORT ? "just-usage stop" : `just-usage stop --port ${port}`;
3006
4131
  console.log(`
3007
- Press Ctrl+C to stop.`);
4132
+ Press Ctrl+C to stop. From another terminal: ${stopHint}`);
3008
4133
  if (shouldOpen)
3009
4134
  openInBrowser(server.urls[0].url);
3010
4135
  const shutdown = () => {
@@ -3026,10 +4151,10 @@ async function cmdStatus(argv) {
3026
4151
  }
3027
4152
  function cmdAccounts() {
3028
4153
  const rows = listAccounts();
3029
- console.log("Default accounts come from each CLI's own login (codex login, claude /login, cursor-agent login, opencode auth login).");
4154
+ console.log("Default accounts come from each CLI's own login (codex login, claude /login, cursor-agent login, grok login --oauth, opencode auth login, agy).");
3030
4155
  if (rows.length === 0) {
3031
4156
  console.log(`
3032
- No extra accounts. Add one with: just-usage add codex | claude | opencode`);
4157
+ No extra accounts. Add one with: just-usage add codex | claude | opencode | antigravity`);
3033
4158
  return;
3034
4159
  }
3035
4160
  console.log("");
@@ -3103,7 +4228,7 @@ async function cmdAdd(argv) {
3103
4228
  });
3104
4229
  const provider = positionals[0];
3105
4230
  if (!isProvider(provider))
3106
- fail(`Usage: just-usage add <codex|claude|opencode> [--label name] [--token]`);
4231
+ fail(`Usage: just-usage add <codex|claude|opencode|antigravity> [--label name] [--token]`);
3107
4232
  switch (provider) {
3108
4233
  case "codex":
3109
4234
  return addCodex(values.label);
@@ -3111,8 +4236,28 @@ async function cmdAdd(argv) {
3111
4236
  return values.token ? addClaudeTokenCli(values.label) : addClaudeProfile(values.label);
3112
4237
  case "opencode":
3113
4238
  return addOpenCodeCli(values.label);
4239
+ case "antigravity":
4240
+ return addAntigravityCli(values.label);
3114
4241
  case "cursor":
3115
4242
  fail("Cursor is single-account: just-usage shows whatever `cursor-agent` is logged in as.");
4243
+ case "grok":
4244
+ fail("Grok is single-account: just-usage shows whatever `grok` is logged in as.");
4245
+ }
4246
+ }
4247
+ async function addAntigravityCli(label) {
4248
+ const { sessionId, authUrl } = await beginAntigravityAdd(label);
4249
+ console.log(`
4250
+ Open this URL to sign in (opening your browser):
4251
+ ${authUrl}
4252
+ `);
4253
+ openInBrowser(authUrl);
4254
+ console.log("After Google sign-in, copy the code from the Antigravity page (or paste the callback URL).");
4255
+ const callback = await prompt("Code: ");
4256
+ try {
4257
+ const account = await submitAntigravityCallback(sessionId, callback);
4258
+ console.log(`Added ${account.id}${account.email ? ` (${account.email})` : ""}.`);
4259
+ } catch (e) {
4260
+ fail(e instanceof Error ? e.message : String(e));
3116
4261
  }
3117
4262
  }
3118
4263
  async function cmdLogin(argv) {
@@ -3155,6 +4300,28 @@ async function cmdRemove(argv) {
3155
4300
  fail(e instanceof Error ? e.message : String(e));
3156
4301
  }
3157
4302
  }
4303
+ async function cmdStop(argv) {
4304
+ if (argv.includes("--help") || argv.includes("-h")) {
4305
+ console.log(STOP_HELP);
4306
+ return;
4307
+ }
4308
+ const { values } = parseArgs({
4309
+ args: argv,
4310
+ options: {
4311
+ port: { type: "string", short: "p" },
4312
+ all: { type: "boolean" }
4313
+ },
4314
+ allowPositionals: true,
4315
+ strict: false
4316
+ });
4317
+ const port = values.port ? Number(values.port) : undefined;
4318
+ if (values.port && (!Number.isInteger(port) || port <= 0 || port > 65535))
4319
+ fail(`invalid port: ${values.port}`);
4320
+ const { text, code } = formatStopResults(await stopServers({ port, all: values.all === true }));
4321
+ console.log(text);
4322
+ if (code !== 0)
4323
+ process.exit(code);
4324
+ }
3158
4325
  async function cmdUpgrade(argv) {
3159
4326
  const { values } = parseArgs({ args: argv, options: { check: { type: "boolean" }, yes: { type: "boolean", short: "y" } }, allowPositionals: true, strict: false });
3160
4327
  const info = await checkForUpdate(true);
@@ -3194,6 +4361,9 @@ async function main() {
3194
4361
  return cmdServe([]);
3195
4362
  case "serve":
3196
4363
  return cmdServe(argv.slice(1));
4364
+ case "stop":
4365
+ case "quit":
4366
+ return cmdStop(argv.slice(1));
3197
4367
  case "status":
3198
4368
  return cmdStatus(argv.slice(1));
3199
4369
  case "accounts":