tokentracker-cli 0.87.0 → 0.87.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (103) hide show
  1. package/dashboard/dist/assets/{AchievementBadge-CU9m5POo.js → AchievementBadge-C468-Rsw.js} +1 -1
  2. package/dashboard/dist/assets/{AchievementsPage-ESzayVR5.js → AchievementsPage-CZWzSdiH.js} +2 -2
  3. package/dashboard/dist/assets/{AchievementsSection-DUVRqw19.js → AchievementsSection-DXt6VbwH.js} +1 -1
  4. package/dashboard/dist/assets/{BadgeDetailModal-CxNQcN6_.js → BadgeDetailModal-BtGvpqQm.js} +1 -1
  5. package/dashboard/dist/assets/{Card-DXIoSOtO.js → Card-DZ-_fXkw.js} +1 -1
  6. package/dashboard/dist/assets/{ClawdAnimated-BsuxMkNd.js → ClawdAnimated-9rXaHo1-.js} +1 -1
  7. package/dashboard/dist/assets/{CommandPalette-C-amBp21.js → CommandPalette-sqIvZNsH.js} +1 -1
  8. package/dashboard/dist/assets/{CommunityStatsModal-ByFOQube.js → CommunityStatsModal-c8VohIqQ.js} +1 -1
  9. package/dashboard/dist/assets/{ConfirmModal-BnUyidbn.js → ConfirmModal-Ct2HLdds.js} +1 -1
  10. package/dashboard/dist/assets/Controls-DPtXGUGi.js +1 -0
  11. package/dashboard/dist/assets/DashboardPage-C65hbl8o.js +1 -0
  12. package/dashboard/dist/assets/{DevicePage-B_o-jhku.js → DevicePage-Ds4GEuqY.js} +1 -1
  13. package/dashboard/dist/assets/{DialogClose-BSd7Jiag.js → DialogClose-B2IAbJG4.js} +1 -1
  14. package/dashboard/dist/assets/DialogDescription-DeRza-LI.js +1 -0
  15. package/dashboard/dist/assets/{DialogTitle-XBpneDtQ.js → DialogTitle-BIwLH6gA.js} +1 -1
  16. package/dashboard/dist/assets/{FadeIn-Dg0v4V9p.js → FadeIn-DPS7_DOB.js} +1 -1
  17. package/dashboard/dist/assets/{HeaderGithubStar-BIsQD1OK.js → HeaderGithubStar-CsSgr9Hd.js} +1 -1
  18. package/dashboard/dist/assets/{HoverTooltip-CtmRa8ne.js → HoverTooltip-CLGZW2d-.js} +1 -1
  19. package/dashboard/dist/assets/{IpCheckPage-BUoLz5sL.js → IpCheckPage-DEM_uk69.js} +1 -1
  20. package/dashboard/dist/assets/{LandingPage-BheA-lw8.js → LandingPage-BQ3YzZ68.js} +2 -2
  21. package/dashboard/dist/assets/{LeaderboardAvatar-DAy9SOdL.js → LeaderboardAvatar-ySznr4Ml.js} +1 -1
  22. package/dashboard/dist/assets/{LeaderboardPage-CwLqtEUN.js → LeaderboardPage-Br4JCoVE.js} +2 -2
  23. package/dashboard/dist/assets/{LeaderboardProfileModal-BZ9yGyjB.js → LeaderboardProfileModal-Drvsb5xF.js} +3 -3
  24. package/dashboard/dist/assets/{LeaderboardProfilePage-Cs-LTfvS.js → LeaderboardProfilePage-BD0P-zZF.js} +1 -1
  25. package/dashboard/dist/assets/{LimitsPage-BQle8e8N.js → LimitsPage-Mbn2mj6S.js} +1 -1
  26. package/dashboard/dist/assets/{LocalOnlyNotice-Bp4CqObQ.js → LocalOnlyNotice-BsKgtoVa.js} +1 -1
  27. package/dashboard/dist/assets/{LoginCard-BIXfWkR2.js → LoginCard-CTQDdHcx.js} +1 -1
  28. package/dashboard/dist/assets/{LoginModal-B-ubPXdr.js → LoginModal-BxCyLDEW.js} +1 -1
  29. package/dashboard/dist/assets/{LoginPage-9arjWT-s.js → LoginPage-ftxnX8g1.js} +1 -1
  30. package/dashboard/dist/assets/PetPage-CQsfqsm8.js +1 -0
  31. package/dashboard/dist/assets/{PopoverPopup-DMg7XE1h.js → PopoverPopup-C16r8jQC.js} +1 -1
  32. package/dashboard/dist/assets/{ProviderIcon-BG3oqeST.js → ProviderIcon-7Tkq9_m6.js} +1 -1
  33. package/dashboard/dist/assets/{ResetPasswordPage-LQ9rfcRE.js → ResetPasswordPage-D-MPpQd1.js} +1 -1
  34. package/dashboard/dist/assets/{SegmentedControl-GY2tRk1u.js → SegmentedControl-CYFrzu9w.js} +1 -1
  35. package/dashboard/dist/assets/{Select-CCngFwmz.js → Select-Dp4V9p6t.js} +1 -1
  36. package/dashboard/dist/assets/{SelectItemText-B9Zm8WTR.js → SelectItemText-CxhBeoxx.js} +1 -1
  37. package/dashboard/dist/assets/{ServiceStatusPage-BxPvk1-W.js → ServiceStatusPage-BT6hvvLW.js} +1 -1
  38. package/dashboard/dist/assets/{SessionsPage-DGo6yAwP.js → SessionsPage-BopeZBiH.js} +1 -1
  39. package/dashboard/dist/assets/SettingsPage-D03GJJBd.js +1 -0
  40. package/dashboard/dist/assets/{SkillsPage-D0PB3WR5.js → SkillsPage-Cr8O6eOH.js} +1 -1
  41. package/dashboard/dist/assets/{TokenGalaxy-Chh_fPIv.js → TokenGalaxy-BoUsrUEs.js} +1 -1
  42. package/dashboard/dist/assets/{ToolbarRootContext-BXacVvrF.js → ToolbarRootContext-CwRaHhMN.js} +1 -1
  43. package/dashboard/dist/assets/{TrendMonitor-CjiCwNmO.js → TrendMonitor-BV2ugEsw.js} +1 -1
  44. package/dashboard/dist/assets/WidgetsPage-B7r4mvrw.js +1 -0
  45. package/dashboard/dist/assets/{WrappedPage-CEpBAMZR.js → WrappedPage-CFU9l22i.js} +1 -1
  46. package/dashboard/dist/assets/__vite-browser-external-CzQk3FUc.css +1 -0
  47. package/dashboard/dist/assets/{__vite-browser-external-Dyag-P6u.js → __vite-browser-external-Db9onipF.js} +10 -3
  48. package/dashboard/dist/assets/{agent-logos-CLQGE0AM.js → agent-logos-DSSn_SPi.js} +1 -1
  49. package/dashboard/dist/assets/{arrow-up-right-CU2HDjoG.js → arrow-up-right-COvrArNz.js} +1 -1
  50. package/dashboard/dist/assets/{check-CucGM5lh.js → check-CasSqiev.js} +1 -1
  51. package/dashboard/dist/assets/{chevron-right-DYzUvcID.js → chevron-right-B1PMeNoD.js} +1 -1
  52. package/dashboard/dist/assets/{copy-BFeQ31Tr.js → copy-D67vycfp.js} +1 -1
  53. package/dashboard/dist/assets/{download-Coq_llCX.js → download-adOCFO2v.js} +1 -1
  54. package/dashboard/dist/assets/{external-link-BJiDBW3d.js → external-link-DVanKEuy.js} +1 -1
  55. package/dashboard/dist/assets/{flame-5stsnhc0.js → flame-RbiHZM9b.js} +1 -1
  56. package/dashboard/dist/assets/{format-tokens-CpTJbo-2.js → format-tokens-DhMp1e1L.js} +1 -1
  57. package/dashboard/dist/assets/{icons-jsuvmnnO.js → icons-BC0p_aGr.js} +1 -1
  58. package/dashboard/dist/assets/{index-DSD3RLQ4.js → index-A41xjc83.js} +1 -1
  59. package/dashboard/dist/assets/{index-BU6GsTbx.js → index-CaRyTJ6K.js} +1 -1
  60. package/dashboard/dist/assets/{index-Bs97feNM.js → index-DrgBqR3o.js} +1 -1
  61. package/dashboard/dist/assets/{info-DNNUzeV_.js → info-D9LwqlnC.js} +1 -1
  62. package/dashboard/dist/assets/{limits-providers-DKTqNO55.js → limits-providers-CRHpXhmW.js} +1 -1
  63. package/dashboard/dist/assets/{link-2-DC1hnQcE.js → link-2-B5f4qFR0.js} +1 -1
  64. package/dashboard/dist/assets/{loader-circle-DtInM4Io.js → loader-circle-DCmwZEdS.js} +1 -1
  65. package/dashboard/dist/assets/{maximize-2-DxpsaYXp.js → maximize-2-BMalEDCZ.js} +1 -1
  66. package/dashboard/dist/assets/{provider-display-hkyvfEJF.js → provider-display-qMFRUZHm.js} +1 -1
  67. package/dashboard/dist/assets/{react-TbJoDSP4.js → react-BnaPNGj8.js} +1 -1
  68. package/dashboard/dist/assets/{search-C7bnqB9b.js → search-BrqBkLs2.js} +1 -1
  69. package/dashboard/dist/assets/{skills-api-D7glOWg7.js → skills-api-vo3iM6yD.js} +1 -1
  70. package/dashboard/dist/assets/{store-CCVlzuqv.js → store-BwMVXuz3.js} +1 -1
  71. package/dashboard/dist/assets/{terminal-uQlsmzGi.js → terminal-BiyUh_8k.js} +1 -1
  72. package/dashboard/dist/assets/{trash-2-B1yuX-oD.js → trash-2-DAr4sqkX.js} +1 -1
  73. package/dashboard/dist/assets/{use-community-stats-0Fs5nY1G.js → use-community-stats-Cg48Zphy.js} +1 -1
  74. package/dashboard/dist/assets/{use-limits-display-prefs-CrftAn62.js → use-limits-display-prefs-DfKtH1Zn.js} +1 -1
  75. package/dashboard/dist/assets/{use-ordered-list-DAdPjLb5.js → use-ordered-list-CnXfrWeF.js} +1 -1
  76. package/dashboard/dist/assets/use-reduced-motion-BIgYrbON.js +1 -0
  77. package/dashboard/dist/assets/{use-session-efficiency-pref-Cc52HiK0.js → use-session-efficiency-pref-CMDTCAAd.js} +1 -1
  78. package/dashboard/dist/assets/{use-transform-D76wHhX0.js → use-transform-B2d9Txt5.js} +1 -1
  79. package/dashboard/dist/assets/{use-usage-limits-B7Prb8rk.js → use-usage-limits-CJbTUl9W.js} +1 -1
  80. package/dashboard/dist/assets/{useCurrency-DWoM_A3s.js → useCurrency-B0hJte5f.js} +1 -1
  81. package/dashboard/dist/assets/{useScrollLock-Bp6ddiQo.js → useScrollLock-DuosKdSB.js} +1 -1
  82. package/dashboard/dist/assets/{useTokenFormat-DZOYz5HT.js → useTokenFormat-DB8pKLFM.js} +1 -1
  83. package/dashboard/dist/assets/{zap-DyeAeO8l.js → zap-rbzC4XgP.js} +1 -1
  84. package/dashboard/dist/index.html +2 -2
  85. package/dashboard/dist/ip-check.html +2 -2
  86. package/dashboard/dist/leaderboard.html +2 -2
  87. package/dashboard/dist/share.html +2 -2
  88. package/package.json +1 -1
  89. package/src/commands/device-login.js +12 -7
  90. package/src/commands/init.js +3 -0
  91. package/src/commands/sync.js +115 -20
  92. package/src/lib/grok-context-breakdown.js +124 -19
  93. package/src/lib/grok-limits.js +374 -9
  94. package/src/lib/pricing/seed-snapshot.json +1 -1
  95. package/src/lib/runtime-config.js +23 -3
  96. package/dashboard/dist/assets/DashboardPage-GyXkKYW0.js +0 -1
  97. package/dashboard/dist/assets/DialogDescription-BFBVGbUT.js +0 -1
  98. package/dashboard/dist/assets/PetPage-BzAVsvh6.js +0 -1
  99. package/dashboard/dist/assets/SettingsPage-ConPEiwO.js +0 -1
  100. package/dashboard/dist/assets/WidgetsPage-CBw9uBCO.js +0 -1
  101. package/dashboard/dist/assets/__vite-browser-external-hXV_ZYTG.css +0 -1
  102. package/dashboard/dist/assets/use-native-settings-CVqB9eoO.js +0 -1
  103. package/dashboard/dist/assets/use-reduced-motion-DVGYs3-k.js +0 -1
@@ -4,6 +4,10 @@ const path = require("node:path");
4
4
 
5
5
  const DEFAULT_BILLING_BASE_URL = "https://cli-chat-proxy.grok.com";
6
6
  const DEFAULT_BILLING_TIMEOUT_MS = 15_000;
7
+ const DEFAULT_OIDC_ISSUER = "https://auth.x.ai";
8
+ const DEFAULT_TOKEN_ENDPOINT = "https://auth.x.ai/oauth2/token";
9
+ // Refresh slightly before wall-clock expiry so Limits doesn't race a just-expired JWT.
10
+ const ACCESS_TOKEN_EXPIRY_SKEW_MS = 60_000;
7
11
 
8
12
  function grokAuthError() {
9
13
  const error = new Error("Not logged in to Grok Build. Run `grok login` in Terminal to authenticate.");
@@ -11,6 +15,14 @@ function grokAuthError() {
11
15
  return error;
12
16
  }
13
17
 
18
+ function grokReauthError() {
19
+ const error = new Error(
20
+ "Grok session expired. Run `grok login` in Terminal to re-authenticate.",
21
+ );
22
+ error.code = "GROK_REAUTH_REQUIRED";
23
+ return error;
24
+ }
25
+
14
26
  function grokBillingTimeoutError() {
15
27
  const error = new Error("Grok billing request timed out.");
16
28
  error.code = "GROK_BILLING_TIMEOUT";
@@ -174,18 +186,29 @@ function isGrokInstalled({ home, env } = {}) {
174
186
  function loadGrokAuthEntry({ home, env } = {}) {
175
187
  const authPath = path.join(resolveGrokHome({ home, env }), "auth.json");
176
188
  if (!fs.existsSync(authPath)) return null;
189
+ let fallback = null;
177
190
  try {
178
191
  const parsed = JSON.parse(fs.readFileSync(authPath, "utf8"));
179
- if (!parsed || typeof parsed !== "object") return null;
180
- for (const value of Object.values(parsed)) {
181
- if (!value || typeof value !== "object") continue;
192
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
193
+ for (const [scopeKey, value] of Object.entries(parsed)) {
194
+ if (!value || typeof value !== "object" || Array.isArray(value)) continue;
182
195
  const key = typeof value.key === "string" ? value.key.trim() : "";
183
- if (key) return { entry: value, authPath };
196
+ const refreshToken =
197
+ typeof value.refresh_token === "string" ? value.refresh_token.trim() : "";
198
+ // Entries with an access token win outright. A refresh-token-only entry
199
+ // is only usable when a client id is resolvable, and must not shadow a
200
+ // later keyed entry — keep it as a fallback instead.
201
+ if (key) {
202
+ return { entry: value, authPath, scopeKey, authFile: parsed };
203
+ }
204
+ if (refreshToken && !fallback && resolveGrokOidcClientId(value, scopeKey)) {
205
+ fallback = { entry: value, authPath, scopeKey, authFile: parsed };
206
+ }
184
207
  }
185
208
  } catch (_error) {
186
209
  return null;
187
210
  }
188
- return null;
211
+ return fallback;
189
212
  }
190
213
 
191
214
  function readGrokAccessToken({ home, env } = {}) {
@@ -194,6 +217,284 @@ function readGrokAccessToken({ home, env } = {}) {
194
217
  return key || null;
195
218
  }
196
219
 
220
+ function isGrokAccessTokenExpired(expiresAt, nowMs = Date.now(), skewMs = ACCESS_TOKEN_EXPIRY_SKEW_MS) {
221
+ if (expiresAt == null || expiresAt === "") return false;
222
+ const ts = typeof expiresAt === "number" ? expiresAt : Date.parse(String(expiresAt));
223
+ if (!Number.isFinite(ts)) return false;
224
+ const skew = Number.isFinite(skewMs) && skewMs >= 0 ? skewMs : ACCESS_TOKEN_EXPIRY_SKEW_MS;
225
+ return ts <= nowMs + skew;
226
+ }
227
+
228
+ function resolveGrokOidcClientId(entry, scopeKey) {
229
+ if (entry && typeof entry.oidc_client_id === "string" && entry.oidc_client_id.trim()) {
230
+ return entry.oidc_client_id.trim();
231
+ }
232
+ if (typeof scopeKey === "string" && scopeKey.includes("::")) {
233
+ const suffix = scopeKey.slice(scopeKey.lastIndexOf("::") + 2).trim();
234
+ if (suffix) return suffix;
235
+ }
236
+ return null;
237
+ }
238
+
239
+ function resolveGrokOidcIssuer(entry) {
240
+ if (entry && typeof entry.oidc_issuer === "string" && entry.oidc_issuer.trim()) {
241
+ return entry.oidc_issuer.trim().replace(/\/$/, "");
242
+ }
243
+ return DEFAULT_OIDC_ISSUER;
244
+ }
245
+
246
+ function resolveGrokTokenEndpoint(entry, env = process.env) {
247
+ if (typeof env.TOKENTRACKER_GROK_TOKEN_ENDPOINT === "string" && env.TOKENTRACKER_GROK_TOKEN_ENDPOINT.trim()) {
248
+ return env.TOKENTRACKER_GROK_TOKEN_ENDPOINT.trim();
249
+ }
250
+ if (typeof env.GROK_OIDC_TOKEN_ENDPOINT === "string" && env.GROK_OIDC_TOKEN_ENDPOINT.trim()) {
251
+ return env.GROK_OIDC_TOKEN_ENDPOINT.trim();
252
+ }
253
+ const issuer = resolveGrokOidcIssuer(entry);
254
+ if (issuer === DEFAULT_OIDC_ISSUER) return DEFAULT_TOKEN_ENDPOINT;
255
+ return `${issuer}/oauth2/token`;
256
+ }
257
+
258
+ function grokEntryRefreshToken(entry) {
259
+ return entry && typeof entry.refresh_token === "string" ? entry.refresh_token.trim() : "";
260
+ }
261
+
262
+ function grokEntryAccessToken(entry) {
263
+ return entry && typeof entry.key === "string" ? entry.key.trim() : "";
264
+ }
265
+
266
+ /**
267
+ * Public xAI OIDC client refresh (auth method "none").
268
+ * Matches Grok Build CLI's refresh against https://auth.x.ai/oauth2/token.
269
+ */
270
+ async function refreshGrokTokens({
271
+ refreshToken,
272
+ clientId,
273
+ tokenEndpoint = DEFAULT_TOKEN_ENDPOINT,
274
+ fetchImpl = fetch,
275
+ timeoutMs = DEFAULT_BILLING_TIMEOUT_MS,
276
+ } = {}) {
277
+ if (typeof refreshToken !== "string" || !refreshToken.trim()) {
278
+ const err = new Error("Grok refresh skipped: no refresh_token in auth.json");
279
+ err.code = "NO_REFRESH_TOKEN";
280
+ throw err;
281
+ }
282
+ if (typeof clientId !== "string" || !clientId.trim()) {
283
+ const err = new Error("Grok refresh skipped: missing oidc_client_id");
284
+ err.code = "NO_CLIENT_ID";
285
+ throw err;
286
+ }
287
+
288
+ const body = new URLSearchParams({
289
+ grant_type: "refresh_token",
290
+ refresh_token: refreshToken.trim(),
291
+ client_id: clientId.trim(),
292
+ });
293
+
294
+ // Same abort deadline discipline as fetchGrokBilling — a stalled token
295
+ // endpoint must not block fetchGrokLimits (and its poller) indefinitely.
296
+ // The timer stays armed through the body reads below, not just the headers.
297
+ const normalizedTimeoutMs =
298
+ Number.isFinite(timeoutMs) && timeoutMs > 0 ? timeoutMs : DEFAULT_BILLING_TIMEOUT_MS;
299
+ const controller = new AbortController();
300
+ const timeoutError = new Error("Grok token refresh timed out.");
301
+ timeoutError.code = "GROK_REFRESH_TIMEOUT";
302
+ const timer = setTimeout(() => controller.abort(timeoutError), normalizedTimeoutMs);
303
+
304
+ try {
305
+ const res = await fetchImpl(tokenEndpoint, {
306
+ method: "POST",
307
+ headers: {
308
+ "Content-Type": "application/x-www-form-urlencoded",
309
+ Accept: "application/json",
310
+ },
311
+ body: body.toString(),
312
+ signal: controller.signal,
313
+ });
314
+
315
+ if (res.status === 400 || res.status === 401) {
316
+ let oauthError = null;
317
+ try {
318
+ const payload = await res.json();
319
+ oauthError =
320
+ (typeof payload?.error === "string" && payload.error) ||
321
+ (payload?.error && typeof payload.error === "object" && payload.error.code) ||
322
+ null;
323
+ } catch (_error) {
324
+ // Status remains authoritative.
325
+ }
326
+ const err = grokReauthError();
327
+ err.oauthError = oauthError;
328
+ err.status = res.status;
329
+ throw err;
330
+ }
331
+
332
+ if (!res.ok) {
333
+ const err = new Error(`Grok token refresh failed: HTTP ${res.status}`);
334
+ err.code = "REFRESH_HTTP_ERROR";
335
+ err.status = res.status;
336
+ throw err;
337
+ }
338
+
339
+ const payload = await res.json();
340
+ const accessToken =
341
+ typeof payload?.access_token === "string" ? payload.access_token.trim() : "";
342
+ if (!accessToken) {
343
+ const err = new Error("Grok token refresh response missing access_token");
344
+ err.code = "REFRESH_INVALID_RESPONSE";
345
+ throw err;
346
+ }
347
+
348
+ const nextRefresh =
349
+ typeof payload?.refresh_token === "string" && payload.refresh_token.trim()
350
+ ? payload.refresh_token.trim()
351
+ : refreshToken.trim();
352
+
353
+ let expiresAt = null;
354
+ const expiresIn = Number(payload?.expires_in);
355
+ if (Number.isFinite(expiresIn) && expiresIn > 0) {
356
+ expiresAt = new Date(Date.now() + expiresIn * 1000).toISOString();
357
+ } else if (typeof payload?.expires_at === "string" && payload.expires_at.trim()) {
358
+ const parsed = Date.parse(payload.expires_at.trim());
359
+ if (Number.isFinite(parsed)) expiresAt = new Date(parsed).toISOString();
360
+ }
361
+
362
+ return {
363
+ access_token: accessToken,
364
+ refresh_token: nextRefresh,
365
+ expires_at: expiresAt,
366
+ token_type: typeof payload?.token_type === "string" ? payload.token_type : null,
367
+ };
368
+ } catch (error) {
369
+ if (controller.signal.aborted && controller.signal.reason === timeoutError) {
370
+ throw timeoutError;
371
+ }
372
+ throw error;
373
+ } finally {
374
+ clearTimeout(timer);
375
+ }
376
+ }
377
+
378
+ async function persistGrokRefreshedAuth(authPath, authFile, scopeKey, entry, newTokens) {
379
+ if (!authPath || !scopeKey || !entry || !newTokens?.access_token) {
380
+ throw new Error("Grok auth persist requires auth path, scope, entry, and access_token");
381
+ }
382
+ const nextEntry = {
383
+ ...entry,
384
+ key: newTokens.access_token,
385
+ refresh_token: newTokens.refresh_token || entry.refresh_token,
386
+ };
387
+ if (newTokens.expires_at) {
388
+ nextEntry.expires_at = newTokens.expires_at;
389
+ } else {
390
+ // Unknown new lifetime: drop the stale timestamp, otherwise the next poll
391
+ // sees an already-expired expires_at next to a fresh key and refreshes
392
+ // again on every cycle (burning a rotation each time).
393
+ delete nextEntry.expires_at;
394
+ }
395
+ const merged = {
396
+ ...authFile,
397
+ [scopeKey]: nextEntry,
398
+ };
399
+ const tmp = `${authPath}.tmp.${process.pid}.${Date.now()}`;
400
+ try {
401
+ await fs.promises.writeFile(tmp, `${JSON.stringify(merged, null, 2)}\n`, { mode: 0o600 });
402
+ await fs.promises.rename(tmp, authPath);
403
+ } catch (error) {
404
+ // Never leave a tmp file holding fresh tokens behind.
405
+ await fs.promises.rm(tmp, { force: true }).catch(() => {});
406
+ throw error;
407
+ }
408
+ try {
409
+ await fs.promises.chmod(authPath, 0o600);
410
+ } catch (_error) {
411
+ // Best-effort; some filesystems reject chmod on rename targets.
412
+ }
413
+ return { entry: nextEntry, authFile: merged, authPath, scopeKey };
414
+ }
415
+
416
+ /**
417
+ * Return a usable access token, refreshing via OIDC when expired or forced.
418
+ * Does not throw for missing install; throws GROK_REAUTH_REQUIRED when refresh is rejected.
419
+ */
420
+ async function resolveGrokAccessToken({
421
+ home,
422
+ env = process.env,
423
+ fetchImpl = fetch,
424
+ forceRefresh = false,
425
+ nowMs = Date.now(),
426
+ } = {}) {
427
+ const loaded = loadGrokAuthEntry({ home, env });
428
+ if (!loaded) {
429
+ return {
430
+ accessToken: null,
431
+ configured: false,
432
+ canRefresh: false,
433
+ refreshed: false,
434
+ };
435
+ }
436
+
437
+ const accessToken = grokEntryAccessToken(loaded.entry);
438
+ const refreshToken = grokEntryRefreshToken(loaded.entry);
439
+ const expired = isGrokAccessTokenExpired(loaded.entry.expires_at, nowMs);
440
+ const canRefresh = Boolean(refreshToken && resolveGrokOidcClientId(loaded.entry, loaded.scopeKey));
441
+ const needsRefresh = forceRefresh || !accessToken || expired;
442
+
443
+ if (!needsRefresh) {
444
+ return {
445
+ accessToken,
446
+ configured: true,
447
+ canRefresh,
448
+ refreshed: false,
449
+ loaded,
450
+ };
451
+ }
452
+
453
+ if (!canRefresh) {
454
+ if (accessToken) {
455
+ // Stale key still present — let billing decide (may 401).
456
+ return {
457
+ accessToken,
458
+ configured: true,
459
+ canRefresh: false,
460
+ refreshed: false,
461
+ loaded,
462
+ };
463
+ }
464
+ return {
465
+ accessToken: null,
466
+ configured: true,
467
+ canRefresh: false,
468
+ refreshed: false,
469
+ loaded,
470
+ error: grokAuthError(),
471
+ };
472
+ }
473
+
474
+ const clientId = resolveGrokOidcClientId(loaded.entry, loaded.scopeKey);
475
+ const tokens = await refreshGrokTokens({
476
+ refreshToken,
477
+ clientId,
478
+ tokenEndpoint: resolveGrokTokenEndpoint(loaded.entry, env),
479
+ fetchImpl,
480
+ });
481
+ const persisted = await persistGrokRefreshedAuth(
482
+ loaded.authPath,
483
+ loaded.authFile,
484
+ loaded.scopeKey,
485
+ loaded.entry,
486
+ tokens,
487
+ );
488
+
489
+ return {
490
+ accessToken: tokens.access_token,
491
+ configured: true,
492
+ canRefresh: true,
493
+ refreshed: true,
494
+ loaded: persisted,
495
+ };
496
+ }
497
+
197
498
  /**
198
499
  * Parse either:
199
500
  * - Unified billing (`?format=credits`): weekly/monthly period + creditUsagePercent
@@ -234,6 +535,20 @@ function normalizeGrokBillingResponse(body) {
234
535
  if (!periodType) periodType = "monthly";
235
536
  }
236
537
 
538
+ // Unified billing omits creditUsagePercent (and productUsage) entirely when
539
+ // nothing was used this period. A valid currentPeriod window with the usage
540
+ // fields absent means 0%, not an unparseable response. Present-but-malformed
541
+ // fields still fall through to the parse error below.
542
+ if (
543
+ usedPercent === null &&
544
+ currentPeriod &&
545
+ (periodStart || resetAt) &&
546
+ config.creditUsagePercent === undefined &&
547
+ config.productUsage === undefined
548
+ ) {
549
+ usedPercent = 0;
550
+ }
551
+
237
552
  const onDemandCap = grokValNumber(config.onDemandCap);
238
553
  const onDemandUsed = grokValNumber(config.onDemandUsed);
239
554
 
@@ -324,16 +639,57 @@ async function fetchGrokBilling(
324
639
  return legacyResult.body;
325
640
  }
326
641
 
327
- async function fetchGrokLimits({ home, env, fetchImpl = fetch, timeoutMs } = {}) {
642
+ async function fetchGrokLimits({ home, env, fetchImpl = fetch, timeoutMs, nowMs } = {}) {
328
643
  if (!isGrokInstalled({ home, env })) {
329
644
  return { configured: false };
330
645
  }
331
- const accessToken = readGrokAccessToken({ home, env });
332
- if (!accessToken) {
646
+
647
+ let resolved;
648
+ try {
649
+ resolved = await resolveGrokAccessToken({ home, env, fetchImpl, nowMs });
650
+ } catch (error) {
651
+ return {
652
+ configured: true,
653
+ error: error?.message || "Unknown error",
654
+ };
655
+ }
656
+
657
+ if (!resolved.configured) {
333
658
  return { configured: false };
334
659
  }
660
+ if (!resolved.accessToken) {
661
+ return {
662
+ configured: true,
663
+ error: resolved.error?.message || grokAuthError().message,
664
+ };
665
+ }
666
+
335
667
  try {
336
- const body = await fetchGrokBilling(accessToken, { fetchImpl, env, timeoutMs });
668
+ let body;
669
+ try {
670
+ body = await fetchGrokBilling(resolved.accessToken, { fetchImpl, env, timeoutMs });
671
+ } catch (error) {
672
+ // Access token rejected — refresh once when a refresh_token is available.
673
+ if (
674
+ error?.code === "GROK_AUTH_REQUIRED" &&
675
+ resolved.canRefresh &&
676
+ !resolved.refreshed
677
+ ) {
678
+ const retry = await resolveGrokAccessToken({
679
+ home,
680
+ env,
681
+ fetchImpl,
682
+ forceRefresh: true,
683
+ nowMs,
684
+ });
685
+ if (!retry.accessToken) {
686
+ throw retry.error || grokReauthError();
687
+ }
688
+ body = await fetchGrokBilling(retry.accessToken, { fetchImpl, env, timeoutMs });
689
+ } else {
690
+ throw error;
691
+ }
692
+ }
337
693
  return {
338
694
  configured: true,
339
695
  error: null,
@@ -350,9 +706,18 @@ async function fetchGrokLimits({ home, env, fetchImpl = fetch, timeoutMs } = {})
350
706
  module.exports = {
351
707
  resolveGrokHome,
352
708
  resolveGrokBillingBaseUrl,
709
+ DEFAULT_TOKEN_ENDPOINT,
710
+ ACCESS_TOKEN_EXPIRY_SKEW_MS,
353
711
  isGrokInstalled,
354
712
  loadGrokAuthEntry,
355
713
  readGrokAccessToken,
714
+ isGrokAccessTokenExpired,
715
+ resolveGrokOidcClientId,
716
+ resolveGrokOidcIssuer,
717
+ resolveGrokTokenEndpoint,
718
+ refreshGrokTokens,
719
+ persistGrokRefreshedAuth,
720
+ resolveGrokAccessToken,
356
721
  normalizeGrokPeriodType,
357
722
  inferGrokPeriodTypeFromDates,
358
723
  sumProductUsagePercent,