harnery 0.6.0 → 0.7.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (137) hide show
  1. package/README.md +16 -6
  2. package/dist/commander.d.ts +19 -0
  3. package/dist/commander.d.ts.map +1 -1
  4. package/dist/commander.js +2 -0
  5. package/dist/commands/agents.d.ts.map +1 -1
  6. package/dist/commands/agents.js +51 -4
  7. package/dist/commands/deinit.d.ts.map +1 -1
  8. package/dist/commands/deinit.js +4 -0
  9. package/dist/commands/devtools.d.ts +4 -0
  10. package/dist/commands/devtools.d.ts.map +1 -0
  11. package/dist/commands/devtools.js +239 -0
  12. package/dist/commands/docs.d.ts.map +1 -1
  13. package/dist/commands/docs.js +69 -1
  14. package/dist/commands/doctor.js +12 -4
  15. package/dist/commands/env.d.ts.map +1 -1
  16. package/dist/commands/env.js +3 -63
  17. package/dist/commands/init.d.ts +1 -0
  18. package/dist/commands/init.d.ts.map +1 -1
  19. package/dist/commands/init.js +54 -14
  20. package/dist/commands/scratch.js +1 -1
  21. package/dist/commands/tunnel.d.ts.map +1 -1
  22. package/dist/commands/tunnel.js +273 -62
  23. package/dist/commands/web-fetch.js +1 -1
  24. package/dist/core/agents/cli.js +48 -0
  25. package/dist/core/agents/coord-client.d.ts.map +1 -1
  26. package/dist/core/agents/coord-client.js +32 -8
  27. package/dist/core/agents/events/emit.d.ts.map +1 -1
  28. package/dist/core/agents/events/emit.js +4 -0
  29. package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
  30. package/dist/core/agents/rules/claim-conflict.js +16 -5
  31. package/dist/core/agents/state/heartbeat-projector.d.ts.map +1 -1
  32. package/dist/core/agents/state/heartbeat-projector.js +10 -3
  33. package/dist/core/config.d.ts +10 -0
  34. package/dist/core/config.d.ts.map +1 -1
  35. package/dist/core/config.js +13 -0
  36. package/dist/core/hooks/cli.js +3 -3
  37. package/dist/core/hooks/effects/index.d.ts +11 -7
  38. package/dist/core/hooks/effects/index.d.ts.map +1 -1
  39. package/dist/core/hooks/effects/index.js +15 -18
  40. package/dist/core/hooks/events/emit.d.ts.map +1 -1
  41. package/dist/core/hooks/events/emit.js +4 -0
  42. package/dist/core/hooks/events/rotate.d.ts +43 -0
  43. package/dist/core/hooks/events/rotate.d.ts.map +1 -0
  44. package/dist/core/hooks/events/rotate.js +142 -0
  45. package/dist/core/hooks/harness/events.d.ts +11 -1
  46. package/dist/core/hooks/harness/events.d.ts.map +1 -1
  47. package/dist/core/hooks/harness/events.js +22 -3
  48. package/dist/core/hooks/harness/wiring.d.ts +8 -0
  49. package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
  50. package/dist/core/hooks/harness/wiring.js +34 -5
  51. package/dist/core/scratch/index.d.ts.map +1 -0
  52. package/dist/{lib → core}/scratch/index.js +2 -2
  53. package/dist/lib/devtools.d.ts +178 -0
  54. package/dist/lib/devtools.d.ts.map +1 -0
  55. package/dist/lib/devtools.js +1328 -0
  56. package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
  57. package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
  58. package/dist/lib/docs-frontmatter-migrate.js +364 -0
  59. package/dist/lib/docs-frontmatter.d.ts +33 -0
  60. package/dist/lib/docs-frontmatter.d.ts.map +1 -0
  61. package/dist/lib/docs-frontmatter.js +130 -0
  62. package/dist/lib/docs-index.d.ts +1 -0
  63. package/dist/lib/docs-index.d.ts.map +1 -1
  64. package/dist/lib/docs-index.js +4 -5
  65. package/dist/lib/docs-lint.d.ts +2 -0
  66. package/dist/lib/docs-lint.d.ts.map +1 -1
  67. package/dist/lib/docs-lint.js +18 -12
  68. package/dist/lib/docs-meta.d.ts +14 -0
  69. package/dist/lib/docs-meta.d.ts.map +1 -0
  70. package/dist/lib/docs-meta.js +34 -0
  71. package/dist/lib/docs-sweep.d.ts +12 -0
  72. package/dist/lib/docs-sweep.d.ts.map +1 -1
  73. package/dist/lib/docs-sweep.js +98 -103
  74. package/dist/lib/format.js +2 -2
  75. package/dist/lib/http/index.d.ts +1 -0
  76. package/dist/lib/http/index.d.ts.map +1 -1
  77. package/dist/lib/http/index.js +1 -0
  78. package/dist/lib/http/request.d.ts +77 -0
  79. package/dist/lib/http/request.d.ts.map +1 -0
  80. package/dist/lib/http/request.js +105 -0
  81. package/dist/lib/instructions/apply.d.ts +63 -0
  82. package/dist/lib/instructions/apply.d.ts.map +1 -0
  83. package/dist/lib/instructions/apply.js +255 -0
  84. package/dist/lib/instructions/splice.d.ts +73 -0
  85. package/dist/lib/instructions/splice.d.ts.map +1 -0
  86. package/dist/lib/instructions/splice.js +118 -0
  87. package/dist/lib/instructions/templates.d.ts +45 -0
  88. package/dist/lib/instructions/templates.d.ts.map +1 -0
  89. package/dist/lib/instructions/templates.js +258 -0
  90. package/dist/lib/tunnel/gate.d.ts +1 -0
  91. package/dist/lib/tunnel/gate.d.ts.map +1 -1
  92. package/dist/lib/tunnel/gate.js +14 -9
  93. package/dist/lib/tunnel/state.d.ts +11 -1
  94. package/dist/lib/tunnel/state.d.ts.map +1 -1
  95. package/dist/lib/tunnel/state.js +8 -3
  96. package/package.json +7 -6
  97. package/src/commander.ts +23 -0
  98. package/src/commands/agents.ts +50 -3
  99. package/src/commands/deinit.ts +5 -0
  100. package/src/commands/devtools.ts +284 -0
  101. package/src/commands/docs.ts +81 -1
  102. package/src/commands/doctor.ts +13 -4
  103. package/src/commands/env.ts +11 -77
  104. package/src/commands/init.ts +66 -15
  105. package/src/commands/scratch.ts +1 -1
  106. package/src/commands/tunnel.ts +316 -65
  107. package/src/commands/web-fetch.ts +1 -1
  108. package/src/core/agents/cli.ts +55 -0
  109. package/src/core/agents/coord-client.ts +34 -7
  110. package/src/core/agents/events/emit.ts +5 -0
  111. package/src/core/agents/rules/claim-conflict.ts +17 -6
  112. package/src/core/agents/state/heartbeat-projector.ts +11 -3
  113. package/src/core/config.ts +14 -0
  114. package/src/core/hooks/cli.ts +3 -3
  115. package/src/core/hooks/effects/index.ts +23 -17
  116. package/src/core/hooks/events/emit.ts +5 -0
  117. package/src/core/hooks/events/rotate.ts +151 -0
  118. package/src/core/hooks/harness/events.ts +30 -3
  119. package/src/core/hooks/harness/wiring.ts +46 -5
  120. package/src/{lib → core}/scratch/index.ts +2 -2
  121. package/src/lib/devtools.ts +1653 -0
  122. package/src/lib/docs-frontmatter-migrate.ts +427 -0
  123. package/src/lib/docs-frontmatter.ts +151 -0
  124. package/src/lib/docs-index.ts +4 -5
  125. package/src/lib/docs-lint.ts +17 -11
  126. package/src/lib/docs-meta.ts +44 -0
  127. package/src/lib/docs-sweep.ts +104 -102
  128. package/src/lib/format.ts +2 -2
  129. package/src/lib/http/index.ts +1 -0
  130. package/src/lib/http/request.ts +154 -0
  131. package/src/lib/instructions/apply.ts +318 -0
  132. package/src/lib/instructions/splice.ts +148 -0
  133. package/src/lib/instructions/templates.ts +295 -0
  134. package/src/lib/tunnel/gate.ts +14 -9
  135. package/src/lib/tunnel/state.ts +19 -4
  136. package/dist/lib/scratch/index.d.ts.map +0 -1
  137. /package/dist/{lib → core}/scratch/index.d.ts +0 -0
@@ -0,0 +1,1328 @@
1
+ import { createHash } from "node:crypto";
2
+ import { closeSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, openSync, readdirSync, readFileSync, readSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
+ import { homedir, tmpdir } from "node:os";
4
+ import { dirname, join } from "node:path";
5
+ const ALL_TOOLS = ["claude-code", "codex", "cursor"];
6
+ // ---------------------------------------------------------------------------
7
+ // Network enrichment (opt-in)
8
+ //
9
+ // `readDevtools` stays pure-local. `enrichFromApi` adds live signals over the
10
+ // network, each authenticating with the credential the tool already stores on
11
+ // disk — the user's own token reading the user's own data:
12
+ //
13
+ // • Claude Code — reads the OAuth token from ~/.claude/.credentials.json and
14
+ // calls api.anthropic.com/api/oauth/usage (the endpoint `/usage` uses) for
15
+ // the 5h + weekly rate-limit windows and extra-usage spend.
16
+ // • Cursor usage + billing (NO key needed) — reads the IDE's session token
17
+ // from state.vscdb and calls cursor.com's dashboard API (the Spending page
18
+ // request) for the billing cycle + total/API/first-party percent-used.
19
+ // • Cursor Cloud Agents (needs an API key) — the public /v0 API. Individual
20
+ // Cursor plans expose no usage/billing there (Team-only), so the key path
21
+ // only adds Cloud Agent runs.
22
+ // ---------------------------------------------------------------------------
23
+ /** Path of the machine-local Cursor API key file (honors XDG_CONFIG_HOME). */
24
+ export function cursorApiKeyPath() {
25
+ const home = process.env.HOME ?? process.env.USERPROFILE ?? homedir();
26
+ const configHome = process.env.XDG_CONFIG_HOME?.trim() || join(home, ".config");
27
+ return join(configHome, "harnery", "cursor-api-key");
28
+ }
29
+ /** Resolve a Cursor API key: env `CURSOR_API_KEY` first, then the key file. */
30
+ export function resolveCursorApiKey() {
31
+ const fromEnv = process.env.CURSOR_API_KEY?.trim();
32
+ if (fromEnv)
33
+ return fromEnv;
34
+ try {
35
+ const v = readFileSync(cursorApiKeyPath(), "utf8").trim();
36
+ return v || null;
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ }
42
+ /** Statuses that mean a cloud agent is still doing work (everything else is done/gone). */
43
+ const CURSOR_AGENT_INACTIVE = new Set(["EXPIRED", "FINISHED", "DELETED", "FAILED", "CANCELLED"]);
44
+ // So our requests blend in with the tool's own traffic rather than looking like
45
+ // a scraper (a bare fetch would carry a "Bun/…" or "node" User-Agent, which is
46
+ // the anomaly). We send the same first-party client identity the tool sends for
47
+ // the same call — the user's own machine reaching the user's own account. Claude
48
+ // uses a claude-cli UA (built below); Cursor uses its Electron UA (see
49
+ // cursorUserAgent), both embedding the live local version.
50
+ /** Last-resort Claude Code version when no live source is readable. */
51
+ const CLAUDE_CLI_FALLBACK_VERSION = "2.1.0";
52
+ /**
53
+ * The Claude Code version actually running, most-authoritative source first:
54
+ * 1. the `version` field the running client stamps into its newest session
55
+ * transcript (reflects auto-updates immediately),
56
+ * 2. the updater's last-result marker,
57
+ * 3. a constant.
58
+ * Claude Code auto-updates into a versioned install, so the transcript can be
59
+ * ahead of both the bootstrap `package.json` and the updater marker — which is
60
+ * why we read what the client itself reports, not what's on the install path.
61
+ */
62
+ function claudeCodeVersion(home) {
63
+ const fromTranscript = newestTranscriptVersion(home);
64
+ if (fromTranscript)
65
+ return fromTranscript;
66
+ const marker = readJson(join(home, ".claude", ".last-update-result.json"));
67
+ return strOr(marker?.version_to) ?? CLAUDE_CLI_FALLBACK_VERSION;
68
+ }
69
+ /** Pull the `version` field from the head of the newest session transcript. */
70
+ function newestTranscriptVersion(home) {
71
+ const newest = newestFile(listFilesRecursive(join(home, ".claude", "projects"), ".jsonl"));
72
+ if (!newest)
73
+ return null;
74
+ // The version rides every event, so the head of the file is plenty — avoids
75
+ // reading a multi-MB transcript in full just to read one field.
76
+ for (const line of readHead(newest, 65_536).split("\n")) {
77
+ if (!line.trim())
78
+ continue;
79
+ try {
80
+ const v = strOr(JSON.parse(line).version);
81
+ if (v)
82
+ return v;
83
+ }
84
+ catch {
85
+ // partial line at the buffer edge — skip
86
+ }
87
+ }
88
+ return null;
89
+ }
90
+ /** Read up to `maxBytes` from the start of a file as UTF-8, "" on any error. */
91
+ function readHead(path, maxBytes) {
92
+ try {
93
+ const fd = openSync(path, "r");
94
+ try {
95
+ const buf = Buffer.alloc(maxBytes);
96
+ const n = readSync(fd, buf, 0, maxBytes, 0);
97
+ return buf.toString("utf8", 0, n);
98
+ }
99
+ finally {
100
+ closeSync(fd);
101
+ }
102
+ }
103
+ catch {
104
+ return "";
105
+ }
106
+ }
107
+ /** Read up to the last `maxBytes` of a file (for a bounded tail scan). */
108
+ function readTail(path, maxBytes) {
109
+ try {
110
+ const size = statSync(path).size;
111
+ const start = Math.max(0, size - maxBytes);
112
+ const len = size - start;
113
+ const fd = openSync(path, "r");
114
+ try {
115
+ const buf = Buffer.alloc(len);
116
+ const n = readSync(fd, buf, 0, len, start);
117
+ return buf.toString("utf8", 0, n);
118
+ }
119
+ finally {
120
+ closeSync(fd);
121
+ }
122
+ }
123
+ catch {
124
+ return "";
125
+ }
126
+ }
127
+ /** Short, non-reversible fingerprint of a token, for per-account cache keys. */
128
+ function tokenFingerprint(token) {
129
+ return createHash("sha256").update(token).digest("hex").slice(0, 12);
130
+ }
131
+ /**
132
+ * Enrich a report's entries over the network with live usage each tool keeps
133
+ * server-side, authenticating with the credential already on disk. Best-effort
134
+ * and network-guarded: every failure degrades to a note, never throws. No-op
135
+ * for a tool that isn't installed / present in the report.
136
+ */
137
+ export async function enrichFromApi(report, opts = {}) {
138
+ const home = opts.home ?? homedir();
139
+ const timeoutMs = opts.timeoutMs ?? 12_000;
140
+ // 5-minute cache. The usage windows move slowly, and this is the hard cap on
141
+ // how often we touch a rate-limited endpoint no matter how often the dashboard
142
+ // re-renders — the page can refresh every couple seconds and still hit the
143
+ // network at most once per tool per 5 minutes.
144
+ const cacheTtlMs = opts.cacheTtlMs ?? 300_000;
145
+ // Claude Code — 5h + weekly windows and extra-usage spend from oauth/usage.
146
+ // Cached per-account, because that endpoint is aggressively rate-limited and
147
+ // Claude Code's OWN "Account & Usage" panel hits it too — a chatty dashboard
148
+ // would starve it. Keying by token fingerprint means switching accounts shows
149
+ // the new account's numbers at once instead of the previous account's cache.
150
+ const claude = report.tools.find((t) => t.tool === "claude-code");
151
+ if (claude?.installed) {
152
+ const token = readClaudeOauthToken(home);
153
+ if (token) {
154
+ const live = await cachedUsage(home, `claude-usage-${tokenFingerprint(token)}`, cacheTtlMs, () => fetchClaudeUsage(token, claudeCodeVersion(home), timeoutMs));
155
+ if (live) {
156
+ claude.quota = live.quota;
157
+ claude.spend = live.spend;
158
+ // Drop the pure-local "quota is server-side" caveat now that it's filled.
159
+ claude.notes = claude.notes.filter((n) => !n.includes("server-side via /usage"));
160
+ }
161
+ else {
162
+ claude.notes.push("live usage unavailable (rate-limited or token stale; retries shortly)");
163
+ }
164
+ }
165
+ }
166
+ const cursor = report.tools.find((t) => t.tool === "cursor");
167
+ if (!cursor?.installed)
168
+ return report;
169
+ // Cursor usage + billing from the IDE's own session token (no API key needed).
170
+ const auth = readCursorSessionAuth(home);
171
+ if (auth) {
172
+ const usage = await cachedUsage(home, `cursor-usage-${tokenFingerprint(auth.token)}`, cacheTtlMs, () => fetchCursorUsage(auth, timeoutMs));
173
+ if (usage) {
174
+ cursor.usage = usage.usage;
175
+ cursor.spend = usage.spend;
176
+ }
177
+ else {
178
+ cursor.notes.push("Cursor usage unavailable (session token may be stale — reopen Cursor)");
179
+ }
180
+ }
181
+ // Cursor Cloud Agent activity from a configured API key (optional, separate auth).
182
+ const key = opts.cursorKey !== undefined ? opts.cursorKey : resolveCursorApiKey();
183
+ if (key) {
184
+ cursor.api = await fetchCursorApi(key, cursorUserAgent(auth?.version ?? null), timeoutMs);
185
+ // On success the structured `api` fields carry the signal; only note a break.
186
+ if (!cursor.api.ok) {
187
+ cursor.notes.unshift(`API key configured but not usable: ${cursor.api.error ?? "unknown"}`);
188
+ }
189
+ }
190
+ return report;
191
+ }
192
+ /**
193
+ * One live call per usage endpoint (cache-bypassing) to check the integration
194
+ * still works — the occasional "did a client change its headers?" test. It
195
+ * exercises the EXACT request builders production uses, so an `auth_rejected`
196
+ * result means our headers/token stopped being accepted, and `shape_changed`
197
+ * means the response schema drifted. Rate-limited results are reported as such,
198
+ * not as failures. Makes at most one request per tool; run it by hand (it is not
199
+ * scheduled) so it can't itself cause a rate limit.
200
+ */
201
+ export async function probeEndpoints(opts = {}) {
202
+ const home = opts.home ?? homedir();
203
+ const timeoutMs = opts.timeoutMs ?? 12_000;
204
+ const only = new Set(opts.only ?? ALL_TOOLS);
205
+ const out = [];
206
+ if (only.has("claude-code")) {
207
+ const version = claudeCodeVersion(home);
208
+ const token = readClaudeOauthToken(home);
209
+ if (!token) {
210
+ out.push(noCred("claude-code", CLAUDE_USAGE_URL, version, "no usable OAuth token in ~/.claude"));
211
+ }
212
+ else {
213
+ const { url, headers } = claudeUsageRequest(token, version);
214
+ out.push(await probeOne("claude-code", url, { headers }, version, timeoutMs, (j) => {
215
+ const o = j;
216
+ return Array.isArray(o.limits) || o.five_hour != null;
217
+ }));
218
+ }
219
+ }
220
+ if (only.has("cursor")) {
221
+ const auth = readCursorSessionAuth(home);
222
+ if (!auth) {
223
+ out.push(noCred("cursor", CURSOR_USAGE_URL, null, "no usable session token in state.vscdb"));
224
+ }
225
+ else {
226
+ const { url, init } = cursorUsageRequest(auth);
227
+ out.push(await probeOne("cursor", url, init, auth.version, timeoutMs, (j) => {
228
+ const o = j;
229
+ return o.planUsage != null || o.billingCycleEnd != null;
230
+ }));
231
+ }
232
+ }
233
+ // Codex is read from local files only (no endpoint to probe), so it's omitted.
234
+ return out;
235
+ }
236
+ function noCred(tool, endpoint, clientVersion, detail) {
237
+ return { tool, endpoint, clientVersion, status: null, outcome: "no_credential", detail };
238
+ }
239
+ async function probeOne(tool, url, init, clientVersion, timeoutMs, shapeOk) {
240
+ const controller = new AbortController();
241
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
242
+ const base = { tool, endpoint: url, clientVersion };
243
+ try {
244
+ const res = await fetch(url, { ...init, signal: controller.signal });
245
+ const status = res.status;
246
+ if (status === 429) {
247
+ return {
248
+ ...base,
249
+ status,
250
+ outcome: "rate_limited",
251
+ detail: `429; retry after ${Math.round(retryAfterMs(res) / 1000)}s`,
252
+ };
253
+ }
254
+ if (status === 401 || status === 403) {
255
+ return {
256
+ ...base,
257
+ status,
258
+ outcome: "auth_rejected",
259
+ detail: `HTTP ${status} — auth or required headers may have changed`,
260
+ };
261
+ }
262
+ if (!res.ok)
263
+ return { ...base, status, outcome: "unreachable", detail: `HTTP ${status}` };
264
+ let json;
265
+ try {
266
+ json = await res.json();
267
+ }
268
+ catch {
269
+ return { ...base, status, outcome: "shape_changed", detail: "200 but body was not JSON" };
270
+ }
271
+ if (!shapeOk(json)) {
272
+ return {
273
+ ...base,
274
+ status,
275
+ outcome: "shape_changed",
276
+ detail: "200 but expected fields absent — schema may have changed",
277
+ };
278
+ }
279
+ return { ...base, status, outcome: "ok", detail: "200, expected shape present" };
280
+ }
281
+ catch (err) {
282
+ return {
283
+ ...base,
284
+ status: null,
285
+ outcome: "unreachable",
286
+ detail: err instanceof Error ? err.message : String(err),
287
+ };
288
+ }
289
+ finally {
290
+ clearTimeout(timer);
291
+ }
292
+ }
293
+ /**
294
+ * Wrap a usage fetch in an on-disk cache under `<home>/.cache/harnery/devtools/
295
+ * <name>.json`, protecting rate-limited endpoints on three fronts:
296
+ *
297
+ * • Fresh success (`< ttlMs` old) short-circuits the network entirely.
298
+ * • A 429 records a `cooldownUntil` from the server's retry-after, and no call
299
+ * is made until it passes — so a rate limit can't cascade into a hammer loop
300
+ * (the failure mode that starved Claude Code's own usage panel). During the
301
+ * cooldown the last-known-good value is served, so the card stays populated.
302
+ * • Any other failure serves last-known-good and retries next time.
303
+ *
304
+ * Best-effort: read/write errors fall back to a live fetch. `ttlMs <= 0` fetches
305
+ * every call (still honoring a cooldown) and skips writes.
306
+ */
307
+ async function cachedUsage(home, name, ttlMs, fetcher) {
308
+ const now = Date.now();
309
+ const dir = join(home, ".cache", "harnery", "devtools");
310
+ const file = join(dir, `${name}.json`);
311
+ let entry = null;
312
+ try {
313
+ entry = JSON.parse(readFileSync(file, "utf8"));
314
+ }
315
+ catch {
316
+ // no cache / unreadable
317
+ }
318
+ // Fresh cached success.
319
+ if (entry?.data != null && ttlMs > 0 && now - entry.at < ttlMs)
320
+ return entry.data;
321
+ // In a rate-limit cooldown — do not call; serve last-known-good (may be null).
322
+ if (entry?.cooldownUntil != null && now < entry.cooldownUntil)
323
+ return entry.data ?? null;
324
+ const outcome = await fetcher();
325
+ const write = (e) => {
326
+ try {
327
+ mkdirSync(dir, { recursive: true });
328
+ writeFileSync(file, JSON.stringify(e));
329
+ }
330
+ catch {
331
+ // cache write is best-effort
332
+ }
333
+ };
334
+ if (outcome.kind === "ok") {
335
+ if (ttlMs > 0)
336
+ write({ at: now, data: outcome.data });
337
+ return outcome.data;
338
+ }
339
+ if (outcome.kind === "cooldown") {
340
+ // Record the backoff, preserving any last-known-good data to keep serving it.
341
+ write({
342
+ at: entry?.at ?? now,
343
+ data: entry?.data ?? null,
344
+ cooldownUntil: now + outcome.retryAfterMs,
345
+ });
346
+ return entry?.data ?? null;
347
+ }
348
+ // Plain failure: leave the cache as-is (retry next call), serve any stale data.
349
+ return entry?.data ?? null;
350
+ }
351
+ /**
352
+ * Read Claude Code's OAuth access token from ~/.claude/.credentials.json.
353
+ * Returns null when absent or expired (both the access and refresh windows are
354
+ * past, so a call would 401). The token is used only to build the request and
355
+ * is never stored on the report.
356
+ */
357
+ function readClaudeOauthToken(home) {
358
+ const oauth = readJson(join(home, ".claude", ".credentials.json"))
359
+ ?.claudeAiOauth;
360
+ const token = strOr(oauth?.accessToken);
361
+ if (!token)
362
+ return null;
363
+ const accessExp = numOr(oauth?.expiresAt);
364
+ const refreshExp = numOr(oauth?.refreshTokenExpiresAt);
365
+ // If both windows are past, the token is dead — skip the doomed call.
366
+ if ((refreshExp ?? accessExp ?? Number.POSITIVE_INFINITY) < Date.now())
367
+ return null;
368
+ return token;
369
+ }
370
+ /** Statuses in the oauth/usage `limits[]` array, mapped to quota-window labels. */
371
+ const CLAUDE_LIMIT_LABELS = {
372
+ session: "5h",
373
+ weekly_all: "weekly",
374
+ weekly_scoped: "weekly",
375
+ };
376
+ const CLAUDE_USAGE_URL = "https://api.anthropic.com/api/oauth/usage";
377
+ /**
378
+ * The request we send to Claude's usage endpoint — the same URL + headers the
379
+ * `/usage` command sends, so the call is indistinguishable from Claude Code's
380
+ * own (Bearer token, the oauth beta, and the `claude-cli/<version> (external,
381
+ * cli)` UA + `x-app: cli`). Extracted so the doctor probe exercises the exact
382
+ * headers we use in production.
383
+ */
384
+ function claudeUsageRequest(token, version) {
385
+ return {
386
+ url: CLAUDE_USAGE_URL,
387
+ headers: {
388
+ Authorization: `Bearer ${token}`,
389
+ "anthropic-beta": "oauth-2025-04-20",
390
+ "anthropic-version": "2023-06-01",
391
+ "User-Agent": `claude-cli/${version} (external, cli)`,
392
+ "x-app": "cli",
393
+ },
394
+ };
395
+ }
396
+ /**
397
+ * Fetch Claude Code's live usage from api.anthropic.com/api/oauth/usage with the
398
+ * local OAuth token. Returns a `FetchOutcome`: `ok` with quota + spend,
399
+ * `cooldown` on a 429 (with the server's retry-after), or `fail`.
400
+ */
401
+ async function fetchClaudeUsage(token, version, timeoutMs) {
402
+ const controller = new AbortController();
403
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
404
+ try {
405
+ const { url, headers } = claudeUsageRequest(token, version);
406
+ const res = await fetch(url, { headers, signal: controller.signal });
407
+ if (res.status === 429)
408
+ return { kind: "cooldown", retryAfterMs: retryAfterMs(res) };
409
+ if (!res.ok)
410
+ return { kind: "fail" };
411
+ const raw = (await res.json());
412
+ // Prefer the structured limits[] array (carries model-scoped windows); fall
413
+ // back to the flat five_hour / seven_day pair.
414
+ let quota = [];
415
+ if (Array.isArray(raw.limits) && raw.limits.length) {
416
+ quota = raw.limits.map((l) => {
417
+ const model = strOr(l.scope?.model?.display_name);
418
+ const base = l.kind ? (CLAUDE_LIMIT_LABELS[l.kind] ?? l.kind) : "quota";
419
+ return {
420
+ window: model ? `${base} · ${model}` : base,
421
+ usedPercent: roundPct(l.percent),
422
+ resetsAt: isoOrNull(l.resets_at),
423
+ };
424
+ });
425
+ }
426
+ else {
427
+ const flat = [
428
+ ["5h", raw.five_hour],
429
+ ["weekly", raw.seven_day],
430
+ ];
431
+ quota = flat
432
+ .filter(([, w]) => w?.utilization != null)
433
+ .map(([label, w]) => ({
434
+ window: label,
435
+ usedPercent: roundPct(w?.utilization),
436
+ resetsAt: isoOrNull(w?.resets_at),
437
+ }));
438
+ }
439
+ let spend = null;
440
+ const sp = raw.spend;
441
+ if (sp?.enabled && (minorToCents(sp.used) != null || minorToCents(sp.limit) != null)) {
442
+ spend = {
443
+ label: "Extra usage",
444
+ usedCents: minorToCents(sp.used),
445
+ limitCents: minorToCents(sp.limit),
446
+ };
447
+ }
448
+ return { kind: "ok", data: { quota, spend } };
449
+ }
450
+ catch {
451
+ return { kind: "fail" };
452
+ }
453
+ finally {
454
+ clearTimeout(timer);
455
+ }
456
+ }
457
+ /** Parse a `Retry-After` header (seconds) to ms; default 60s when absent/unparseable. */
458
+ function retryAfterMs(res) {
459
+ const raw = res.headers.get("retry-after");
460
+ const secs = raw != null ? Number.parseInt(raw, 10) : Number.NaN;
461
+ return (Number.isFinite(secs) && secs > 0 ? secs : 60) * 1000;
462
+ }
463
+ /** Convert an {amount_minor, exponent} money value to cents (exponent 2 = already cents). */
464
+ function minorToCents(m) {
465
+ const minor = numOr(m?.amount_minor);
466
+ if (minor == null)
467
+ return null;
468
+ const exp = numOr(m?.exponent) ?? 2;
469
+ return Math.round(minor * 10 ** (2 - exp));
470
+ }
471
+ /** Normalize an ISO-ish timestamp (may carry a +00:00 offset) to a Z-suffixed ISO, or null. */
472
+ function isoOrNull(s) {
473
+ if (!s)
474
+ return null;
475
+ const t = Date.parse(s);
476
+ return Number.isNaN(t) ? null : new Date(t).toISOString();
477
+ }
478
+ /**
479
+ * Read Cursor's locally-stored session token from state.vscdb and derive the
480
+ * WorkOS user id from its JWT `sub` claim, plus the local Cursor version for the
481
+ * client User-Agent. Returns null when the DB is unreadable, no token is
482
+ * present, or the token has expired (so we skip a doomed call). The token
483
+ * string is used only to build the request and is never stored on the report.
484
+ */
485
+ function readCursorSessionAuth(home) {
486
+ const vscdb = cursorGlobalVscdb(home);
487
+ if (!vscdb)
488
+ return null;
489
+ const items = readVscdbItems(vscdb, [
490
+ "cursorAuth/accessToken",
491
+ "cursor.startupMetrics.lastVersion",
492
+ ]);
493
+ const token = strOr(items?.["cursorAuth/accessToken"]);
494
+ if (!token)
495
+ return null;
496
+ const claims = decodeJwtClaims(token);
497
+ const sub = strOr(claims?.sub);
498
+ if (!sub)
499
+ return null;
500
+ // Skip an obviously-expired token (exp is seconds since epoch).
501
+ const exp = numOr(claims?.exp);
502
+ if (exp != null && exp * 1000 < Date.now())
503
+ return null;
504
+ // sub looks like "auth0|user_01J…"; the WorkOS cookie wants the id after the "|".
505
+ const userId = sub.includes("|") ? (sub.split("|").pop() ?? sub) : sub;
506
+ return { token, userId, version: strOr(items?.["cursor.startupMetrics.lastVersion"]) };
507
+ }
508
+ // Cursor is an Electron app; its embedded browser presents a VS Code-style UA
509
+ // (`… <App>/<ver> Chrome/<chromium> Electron/<electron> Safari/537.36`). We
510
+ // mirror that, injecting the LIVE Cursor version read from state.vscdb. The
511
+ // Chromium/Electron pair is cosmetic (tracks Cursor's VS Code base) — bump when
512
+ // Cursor's base does; the identifying `Cursor/<version>` part is always live.
513
+ const CURSOR_CHROMIUM = "132.0.6834.210";
514
+ const CURSOR_ELECTRON = "34.5.8";
515
+ /** Cursor Electron client User-Agent, embedding the live app version. */
516
+ function cursorUserAgent(version) {
517
+ const ver = version ?? "0.0.0";
518
+ return `Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Cursor/${ver} Chrome/${CURSOR_CHROMIUM} Electron/${CURSOR_ELECTRON} Safari/537.36`;
519
+ }
520
+ const CURSOR_USAGE_URL = "https://cursor.com/api/dashboard/get-current-period-usage";
521
+ /**
522
+ * The request we send to Cursor's usage endpoint — the WorkOS session cookie
523
+ * (`<userId>::<token>`) plus the browser-consistent Origin/Referer cursor.com
524
+ * requires, and Cursor's own Electron client UA. Extracted so the doctor probe
525
+ * exercises the exact request we use in production.
526
+ */
527
+ function cursorUsageRequest(auth) {
528
+ const cookie = `WorkosCursorSessionToken=${encodeURIComponent(`${auth.userId}::${auth.token}`)}`;
529
+ return {
530
+ url: CURSOR_USAGE_URL,
531
+ init: {
532
+ method: "POST",
533
+ headers: {
534
+ Cookie: cookie,
535
+ "Content-Type": "application/json",
536
+ Origin: "https://cursor.com",
537
+ Referer: "https://cursor.com/dashboard/spending",
538
+ "User-Agent": cursorUserAgent(auth.version),
539
+ },
540
+ body: "{}",
541
+ },
542
+ };
543
+ }
544
+ /**
545
+ * Fetch the current-period usage the Cursor UI shows on its Spending page.
546
+ * Returns a `FetchOutcome`: `ok`, `cooldown` on a 429, or `fail`.
547
+ */
548
+ async function fetchCursorUsage(auth, timeoutMs) {
549
+ const controller = new AbortController();
550
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
551
+ try {
552
+ const { url, init } = cursorUsageRequest(auth);
553
+ const res = await fetch(url, { ...init, signal: controller.signal });
554
+ if (res.status === 429)
555
+ return { kind: "cooldown", retryAfterMs: retryAfterMs(res) };
556
+ if (!res.ok)
557
+ return { kind: "fail" };
558
+ const raw = (await res.json());
559
+ const pu = raw.planUsage ?? {};
560
+ const sl = raw.spendLimitUsage ?? {};
561
+ const spendLimit = numOr(sl.individualLimit);
562
+ const spendRemaining = numOr(sl.individualRemaining);
563
+ const usage = {
564
+ cycleStart: msToIso(raw.billingCycleStart),
565
+ cycleEnd: msToIso(raw.billingCycleEnd),
566
+ totalPercentUsed: roundPct(pu.totalPercentUsed),
567
+ apiPercentUsed: roundPct(pu.apiPercentUsed),
568
+ firstPartyPercentUsed: roundPct(pu.autoPercentUsed),
569
+ includedLimitCents: numOr(pu.limit),
570
+ };
571
+ const spend = spendLimit != null
572
+ ? {
573
+ label: "On-demand",
574
+ usedCents: spendRemaining != null ? spendLimit - spendRemaining : null,
575
+ limitCents: spendLimit,
576
+ }
577
+ : null;
578
+ return { kind: "ok", data: { usage, spend } };
579
+ }
580
+ catch {
581
+ return { kind: "fail" };
582
+ }
583
+ finally {
584
+ clearTimeout(timer);
585
+ }
586
+ }
587
+ /** Parse a ms-epoch value (string or number) to ISO, or null. */
588
+ function msToIso(v) {
589
+ const n = typeof v === "string" ? Number.parseInt(v, 10) : v;
590
+ if (n == null || !Number.isFinite(n) || n <= 0)
591
+ return null;
592
+ return new Date(n).toISOString();
593
+ }
594
+ /** Clamp a percentage to one decimal place, or null. */
595
+ function roundPct(v) {
596
+ if (v == null || !Number.isFinite(v))
597
+ return null;
598
+ return Math.round(v * 10) / 10;
599
+ }
600
+ async function fetchCursorApi(key, userAgent, timeoutMs) {
601
+ const out = { ok: false, keyName: null, cloudAgents: null, error: null };
602
+ const controller = new AbortController();
603
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
604
+ const get = async (path) => {
605
+ const res = await fetch(`https://api.cursor.com${path}`, {
606
+ headers: { Authorization: `Bearer ${key}`, "User-Agent": userAgent },
607
+ signal: controller.signal,
608
+ });
609
+ if (!res.ok)
610
+ throw new Error(`${path} → HTTP ${res.status}`);
611
+ return res.json();
612
+ };
613
+ try {
614
+ const me = (await get("/v0/me"));
615
+ out.ok = true;
616
+ out.keyName = strOr(me.apiKeyName);
617
+ try {
618
+ const data = (await get("/v0/agents"));
619
+ const list = Array.isArray(data.agents) ? data.agents : [];
620
+ out.cloudAgents = {
621
+ total: list.length,
622
+ active: list.filter((a) => a.status && !CURSOR_AGENT_INACTIVE.has(a.status)).length,
623
+ };
624
+ }
625
+ catch {
626
+ // /v0/me worked but agents listing failed; key is still valid
627
+ }
628
+ }
629
+ catch (err) {
630
+ out.ok = false;
631
+ out.error = err instanceof Error ? err.message : String(err);
632
+ }
633
+ finally {
634
+ clearTimeout(timer);
635
+ }
636
+ return out;
637
+ }
638
+ export function readDevtools(opts = {}) {
639
+ const home = opts.home ?? homedir();
640
+ const now = opts.now ?? Date.now();
641
+ const usage = opts.usage ?? false;
642
+ const windowDays = opts.windowDays ?? 7;
643
+ const only = new Set(opts.only ?? ALL_TOOLS);
644
+ const tools = [];
645
+ if (only.has("claude-code"))
646
+ tools.push(readClaudeCode(home, now, usage, windowDays));
647
+ if (only.has("codex"))
648
+ tools.push(readCodex(home, now, windowDays));
649
+ if (only.has("cursor"))
650
+ tools.push(readCursor(home));
651
+ return {
652
+ generatedAt: new Date(now).toISOString(),
653
+ // The window that any shown token total is measured over. Codex always
654
+ // reports a windowed total; Claude's transcript scan is `--usage`-gated.
655
+ windowDays,
656
+ tools,
657
+ };
658
+ }
659
+ // ---------------------------------------------------------------------------
660
+ // Claude Code (~/.claude)
661
+ // ---------------------------------------------------------------------------
662
+ function readClaudeCode(home, now, usage, windowDays) {
663
+ const dir = join(home, ".claude");
664
+ const status = base("claude-code");
665
+ status.installed = existsSync(dir);
666
+ if (!status.installed) {
667
+ status.notes.push("~/.claude not found");
668
+ return status;
669
+ }
670
+ // Auth: ~/.claude/.credentials.json -> claudeAiOauth (non-secret fields only).
671
+ const oauth = readJson(join(dir, ".credentials.json"))?.claudeAiOauth;
672
+ if (oauth) {
673
+ const accessExp = numOr(oauth.expiresAt);
674
+ const refreshExp = numOr(oauth.refreshTokenExpiresAt);
675
+ status.loggedIn = (refreshExp ?? accessExp ?? 0) > now;
676
+ status.authExpiresAt = accessExp ? new Date(accessExp).toISOString() : null;
677
+ status.plan = strOr(oauth.subscriptionType);
678
+ status.rateLimitTier = strOr(oauth.rateLimitTier);
679
+ if (accessExp && accessExp <= now && refreshExp && refreshExp > now) {
680
+ status.notes.push("access token expired; refreshes on next use");
681
+ }
682
+ }
683
+ else {
684
+ status.loggedIn = false;
685
+ status.notes.push("no credential found (not logged in)");
686
+ }
687
+ // Richer account/plan metadata lives in ~/.claude.json (sibling of the dir).
688
+ const cfg = readJson(join(home, ".claude.json"));
689
+ const acct = cfg?.oauthAccount;
690
+ if (acct) {
691
+ status.account = strOr(acct.emailAddress);
692
+ // Prefer the seat tier as the human "plan" when present.
693
+ status.plan = strOr(acct.seatTier) ?? status.plan;
694
+ status.rateLimitTier = strOr(acct.userRateLimitTier) ?? status.rateLimitTier;
695
+ }
696
+ // Sessions: one .jsonl transcript per session under projects/<slug>/.
697
+ const projects = join(dir, "projects");
698
+ const files = listFilesRecursive(projects, ".jsonl");
699
+ status.sessions = files.length || null;
700
+ status.lastActivity = latestMtimeIso(files);
701
+ // Claude Code does not persist its rate-limit windows locally; the live
702
+ // 5-hour / weekly figures come from the `/usage` server call, which the
703
+ // enrichment step fetches with the local OAuth token. Without it (--no-api),
704
+ // quota stays blank.
705
+ status.quota = null;
706
+ status.notes.push("live quota (5h/weekly resets) is server-side via /usage");
707
+ // Windowed token total, memoized per transcript so it's card-cheap after the
708
+ // first scan. `--usage` (fresh=true) forces an exact, cache-bypassing recount.
709
+ status.tokensUsed = sumClaudeTokensCached(home, files, now, windowDays, usage);
710
+ return status;
711
+ }
712
+ /** Sum the four token fields across every assistant message in one transcript. */
713
+ function sumFileTokens(file) {
714
+ let total = 0;
715
+ for (const line of readJsonlSync(file)) {
716
+ const usage = line.message?.usage;
717
+ if (!usage)
718
+ continue;
719
+ total +=
720
+ (usage.input_tokens ?? 0) +
721
+ (usage.output_tokens ?? 0) +
722
+ (usage.cache_creation_input_tokens ?? 0) +
723
+ (usage.cache_read_input_tokens ?? 0);
724
+ }
725
+ return total;
726
+ }
727
+ /**
728
+ * Windowed Claude token total, memoized per transcript. Unlike Codex, a Claude
729
+ * transcript has no per-session cumulative field, so the total is the sum of
730
+ * every message — a full read of each in-window file (hundreds of MB in a busy
731
+ * week). To keep that off the hot render path, each file's sum is cached by
732
+ * (path, mtime, size) at `~/.cache/harnery/devtools/claude-tokens.json`: an
733
+ * unchanged transcript is never re-read, so after warm-up only the live session
734
+ * (whose size/mtime moved) is rescanned. `fresh` (from `--usage`) bypasses the
735
+ * cache for an exact recompute. The cache holds only in-window files, so mixing
736
+ * a larger `--window-days` re-reads the older span once.
737
+ */
738
+ function sumClaudeTokensCached(home, files, now, windowDays, fresh) {
739
+ const cutoff = now - windowDays * 86_400_000;
740
+ const inWindow = files.filter((f) => safeMtime(f) >= cutoff);
741
+ const cacheFile = join(home, ".cache", "harnery", "devtools", "claude-tokens.json");
742
+ let cache = {};
743
+ if (!fresh) {
744
+ try {
745
+ cache = JSON.parse(readFileSync(cacheFile, "utf8"));
746
+ }
747
+ catch {
748
+ // no cache / unreadable — cold start
749
+ }
750
+ }
751
+ const next = {};
752
+ let total = 0;
753
+ let changed = false;
754
+ for (const f of inWindow) {
755
+ let mtime;
756
+ let size;
757
+ try {
758
+ const st = statSync(f);
759
+ mtime = st.mtimeMs;
760
+ size = st.size;
761
+ }
762
+ catch {
763
+ continue; // vanished mid-scan
764
+ }
765
+ const hit = cache[f];
766
+ const tokens = hit && hit.mtime === mtime && hit.size === size ? hit.tokens : sumFileTokens(f);
767
+ if (!hit || hit.mtime !== mtime || hit.size !== size)
768
+ changed = true;
769
+ next[f] = { mtime, size, tokens };
770
+ total += tokens;
771
+ }
772
+ // Persist when anything moved (new/changed file) or entries aged out of window.
773
+ if (!fresh && (changed || Object.keys(next).length !== Object.keys(cache).length)) {
774
+ try {
775
+ mkdirSync(dirname(cacheFile), { recursive: true });
776
+ const tmp = `${cacheFile}.${process.pid}.tmp`;
777
+ writeFileSync(tmp, JSON.stringify(next));
778
+ renameSync(tmp, cacheFile); // atomic swap — no torn reads for a concurrent reader
779
+ }
780
+ catch {
781
+ // cache write is best-effort
782
+ }
783
+ }
784
+ return total;
785
+ }
786
+ // ---------------------------------------------------------------------------
787
+ // Codex (~/.codex)
788
+ // ---------------------------------------------------------------------------
789
+ function readCodex(home, now, windowDays) {
790
+ const dir = join(home, ".codex");
791
+ const status = base("codex");
792
+ const globbed = codexRollouts(home);
793
+ status.installed = existsSync(dir) || globbed.length > 0;
794
+ if (!status.installed) {
795
+ status.notes.push("~/.codex not found");
796
+ return status;
797
+ }
798
+ // Codex can have more than one install on a machine (e.g. a WSL CLI and the
799
+ // Windows desktop app, each its own account). They must not be mixed: read
800
+ // auth AND rate limits from the SAME install — the one actually in use, which
801
+ // is whichever owns the most-recently-written rollout.
802
+ const newestRollout = newestFile(globbed);
803
+ const activeRoot = newestRollout ? codexRootOf(newestRollout) : dir;
804
+ // Auth: <activeRoot>/auth.json. The id_token carries the non-secret account +
805
+ // plan claims; the access_token carries the meaningful expiry (it outlives the
806
+ // id_token and is what refreshes), so we report that, not the id_token's.
807
+ const auth = readJson(join(activeRoot, "auth.json"));
808
+ if (auth) {
809
+ const tokens = auth.tokens;
810
+ const idToken = strOr(tokens?.id_token);
811
+ const accessToken = strOr(tokens?.access_token);
812
+ const idClaims = idToken ? decodeJwtClaims(idToken) : null;
813
+ const accessExp = numOr(accessToken ? decodeJwtClaims(accessToken)?.exp : undefined);
814
+ if (idClaims) {
815
+ status.account = strOr(idClaims.email);
816
+ const authClaim = idClaims["https://api.openai.com/auth"];
817
+ status.plan = strOr(authClaim?.chatgpt_plan_type);
818
+ }
819
+ // Prefer the access token's expiry; fall back to the id_token's.
820
+ const exp = accessExp ?? numOr(idClaims?.exp);
821
+ status.authExpiresAt = exp ? new Date(exp * 1000).toISOString() : null;
822
+ status.loggedIn = Boolean(accessToken || idToken) && (exp == null || exp * 1000 > now);
823
+ if (exp && exp * 1000 <= now) {
824
+ status.notes.push("token expired; refreshes via stored refresh token");
825
+ }
826
+ }
827
+ else {
828
+ status.loggedIn = false;
829
+ status.notes.push("no auth.json found (not logged in)");
830
+ }
831
+ // Activity: prefer the active install's state_5.sqlite (`threads`) for exact
832
+ // counts, but only when it's actually the active install's DB — otherwise the
833
+ // rollouts are the cross-install-consistent source.
834
+ const state = readCodexState(join(activeRoot, "sqlite", "state_5.sqlite"));
835
+ const activeRollouts = globbed.filter((f) => codexRootOf(f) === activeRoot);
836
+ if (state) {
837
+ status.sessions = state.sessions;
838
+ if (state.lastMs)
839
+ status.lastActivity = new Date(state.lastMs).toISOString();
840
+ }
841
+ else {
842
+ // No DB for the active install (e.g. the Windows desktop app uses a
843
+ // different store): fall back to that install's rollout files.
844
+ status.sessions = activeRollouts.length || null;
845
+ status.lastActivity = latestMtimeIso(activeRollouts);
846
+ }
847
+ // Windowed token total: always shown (the tail scan is card-cheap), summed
848
+ // from the rollouts since they're the fresh cross-install source — the state
849
+ // DB can lag the live sessions.
850
+ status.tokensUsed = sumCodexTokens(activeRollouts, now, windowDays);
851
+ // Freshest rollout → current rate-limit windows + live plan_type.
852
+ if (newestRollout) {
853
+ const rl = lastRateLimits(newestRollout);
854
+ if (rl) {
855
+ status.quota = [rl.primary, rl.secondary].filter((q) => q !== null);
856
+ if (rl.planType)
857
+ status.plan = rl.planType; // live plan wins over the id_token
858
+ if (rl.reachedType)
859
+ status.notes.push(`rate limit reached (${rl.reachedType} window)`);
860
+ }
861
+ }
862
+ if (!status.quota?.length)
863
+ status.notes.push("no local rate-limit snapshot in latest session");
864
+ return status;
865
+ }
866
+ /** The Codex install root that owns a rollout path (the part before `/sessions/`). */
867
+ function codexRootOf(rolloutPath) {
868
+ const parts = rolloutPath.split(/[/\\]sessions[/\\]/);
869
+ return parts.length > 1 ? parts[0] : rolloutPath;
870
+ }
871
+ /** Read Codex's `state_5.sqlite` threads table for session count, recency, newest rollout.
872
+ * (Token totals come from the rollouts, not here — the DB can lag live sessions.) */
873
+ function readCodexState(dbPath) {
874
+ if (!existsSync(dbPath))
875
+ return null;
876
+ return withSqlite(dbPath, (db) => {
877
+ const agg = db.query("SELECT count(*) c, max(updated_at_ms) mx FROM threads").get();
878
+ const newest = db
879
+ .query("SELECT rollout_path FROM threads ORDER BY updated_at_ms DESC LIMIT 1")
880
+ .get();
881
+ return {
882
+ sessions: agg?.c ?? 0,
883
+ lastMs: numOr(agg?.mx),
884
+ newestRollout: strOr(newest?.rollout_path),
885
+ };
886
+ });
887
+ }
888
+ /** All Codex rollout files across the WSL home and (real-home only) the Windows-side home. */
889
+ function codexRollouts(home) {
890
+ const roots = [join(home, ".codex", "sessions")];
891
+ if (home === homedir()) {
892
+ try {
893
+ if (existsSync("/mnt/c/Users")) {
894
+ for (const u of readdirSync("/mnt/c/Users"))
895
+ roots.push(`/mnt/c/Users/${u}/.codex/sessions`);
896
+ }
897
+ }
898
+ catch {
899
+ // not WSL
900
+ }
901
+ }
902
+ return roots
903
+ .flatMap((r) => listFilesRecursive(r, ".jsonl"))
904
+ .filter((f) => f.includes("rollout-"));
905
+ }
906
+ function lastRateLimits(file) {
907
+ let found = null;
908
+ for (const line of readJsonlSync(file)) {
909
+ const payload = line.payload;
910
+ if (!payload)
911
+ continue;
912
+ if (payload.type !== "token_count")
913
+ continue;
914
+ const rl = payload.rate_limits;
915
+ if (!rl)
916
+ continue;
917
+ found = {
918
+ primary: quotaFromWindow(rl.primary),
919
+ secondary: quotaFromWindow(rl.secondary),
920
+ planType: strOr(rl.plan_type),
921
+ reachedType: strOr(rl.rate_limit_reached_type),
922
+ };
923
+ }
924
+ return found;
925
+ }
926
+ function quotaFromWindow(w) {
927
+ if (!w)
928
+ return null;
929
+ const resets = numOr(w.resets_at);
930
+ return {
931
+ window: windowLabel(numOr(w.window_minutes)),
932
+ usedPercent: numOr(w.used_percent),
933
+ resetsAt: resets ? new Date(resets * 1000).toISOString() : null,
934
+ };
935
+ }
936
+ function windowLabel(minutes) {
937
+ if (minutes == null)
938
+ return "unknown";
939
+ if (minutes === 300)
940
+ return "5h";
941
+ if (minutes === 10080)
942
+ return "weekly";
943
+ if (minutes % 1440 === 0)
944
+ return `${minutes / 1440}d`;
945
+ if (minutes % 60 === 0)
946
+ return `${minutes / 60}h`;
947
+ return `${minutes}m`;
948
+ }
949
+ /** Bytes tail-read per rollout when summing tokens — enough to hold the last
950
+ * `token_count` event of any real session while staying cheap on a networked
951
+ * (WSL → /mnt/c) filesystem. */
952
+ const CODEX_TAIL_BYTES = 131_072;
953
+ /**
954
+ * Windowed token total across the in-window rollouts, light enough to run on
955
+ * every card render. Each `token_count` event carries a *cumulative*
956
+ * `info.total_token_usage`, so the session total is the last such event — found
957
+ * by scanning only the file's tail rather than every line. A session whose final
958
+ * `token_count` sits beyond the tail (rare: a huge trailing non-token event) is
959
+ * undercounted; acceptable for a card estimate.
960
+ */
961
+ function sumCodexTokens(files, now, windowDays) {
962
+ const cutoff = now - windowDays * 86_400_000;
963
+ let total = 0;
964
+ for (const f of files) {
965
+ if (safeMtime(f) < cutoff)
966
+ continue;
967
+ total += lastCumulativeTokens(readTail(f, CODEX_TAIL_BYTES));
968
+ }
969
+ return total;
970
+ }
971
+ /** Last cumulative `total_token_usage.total_tokens` in a rollout tail, or 0. */
972
+ function lastCumulativeTokens(tail) {
973
+ let last = 0;
974
+ for (const line of tail.split("\n")) {
975
+ if (!line.includes('"token_count"'))
976
+ continue;
977
+ let parsed;
978
+ try {
979
+ parsed = JSON.parse(line);
980
+ }
981
+ catch {
982
+ continue; // a partial first line from the tail cut — skip it
983
+ }
984
+ const payload = parsed.payload;
985
+ if (!payload)
986
+ continue;
987
+ if (payload.type !== "token_count")
988
+ continue;
989
+ const info = payload.info;
990
+ const totals = info?.total_token_usage;
991
+ const t = numOr(totals?.total_tokens);
992
+ if (t)
993
+ last = t; // cumulative — the last reading wins
994
+ }
995
+ return last;
996
+ }
997
+ // ---------------------------------------------------------------------------
998
+ // Cursor (~/.cursor) — most usage/billing is server-side.
999
+ // ---------------------------------------------------------------------------
1000
+ function readCursor(home) {
1001
+ const dir = join(home, ".cursor");
1002
+ // Cursor's IDE state DB (state.vscdb) holds the account email, Stripe
1003
+ // membership + subscription status, and per-chat activity — all token-free
1004
+ // and the richest local signal. It lives under the OS app-support dir
1005
+ // (Linux ~/.config, macOS ~/Library, WSL the Windows-side path), separate
1006
+ // from ~/.cursor (the agent-CLI config). Treat Cursor as installed if either
1007
+ // exists, so a macOS GUI user who never ran the CLI still resolves.
1008
+ const vscdb = cursorGlobalVscdb(home);
1009
+ const status = base("cursor");
1010
+ status.installed = existsSync(dir) || vscdb != null;
1011
+ if (!status.installed) {
1012
+ status.notes.push("Cursor not found (no ~/.cursor and no state.vscdb)");
1013
+ return status;
1014
+ }
1015
+ // Read the state DB when a SQLite engine is available (bun:sqlite); otherwise
1016
+ // fall back to login-presence signals from the remote-server tokens / statsig id.
1017
+ const items = vscdb
1018
+ ? readVscdbItems(vscdb, [
1019
+ "cursorAuth/cachedEmail",
1020
+ "cursorAuth/stripeMembershipType",
1021
+ "cursorAuth/stripeSubscriptionStatus",
1022
+ "composer.composerHeaders",
1023
+ ])
1024
+ : null;
1025
+ if (items) {
1026
+ const email = strOr(items["cursorAuth/cachedEmail"]);
1027
+ const membership = strOr(items["cursorAuth/stripeMembershipType"]);
1028
+ const subStatus = strOr(items["cursorAuth/stripeSubscriptionStatus"]);
1029
+ status.account = email;
1030
+ status.plan = membership;
1031
+ status.loggedIn = Boolean(email) || subStatus === "active";
1032
+ if (subStatus)
1033
+ status.notes.push(`subscription ${subStatus}`);
1034
+ // Per-chat activity from composer headers (session count + last active).
1035
+ const headers = safeJson(items["composer.composerHeaders"]);
1036
+ const composers = headers?.allComposers ?? [];
1037
+ if (composers.length) {
1038
+ status.sessions = composers.length;
1039
+ let last = 0;
1040
+ for (const c of composers)
1041
+ last = Math.max(last, c.lastUpdatedAt ?? c.createdAt ?? 0);
1042
+ if (last > 0)
1043
+ status.lastActivity = new Date(last).toISOString();
1044
+ }
1045
+ }
1046
+ else {
1047
+ // Fallback: remote-server tokens + statsig id only prove a login exists.
1048
+ const serverDir = join(home, ".cursor-server");
1049
+ const tokenFiles = existsSync(serverDir)
1050
+ ? readdirSync(serverDir).filter((f) => f.endsWith(".token"))
1051
+ : [];
1052
+ const statsig = readJson(join(dir, "statsig-cache.json"));
1053
+ status.account = strOr(statsig?.userID); // opaque statsig id, not an email
1054
+ status.loggedIn = tokenFiles.length > 0 || status.account ? true : null;
1055
+ status.notes.push("state.vscdb unreadable (needs a SQLite engine); login-presence only");
1056
+ }
1057
+ // Session count fallback: per-project workspace dirs.
1058
+ if (status.sessions == null) {
1059
+ const projects = join(dir, "projects");
1060
+ const entries = existsSync(projects)
1061
+ ? readdirSync(projects, { withFileTypes: true }).filter((d) => d.isDirectory())
1062
+ : [];
1063
+ status.sessions = entries.length || null;
1064
+ if (!status.lastActivity)
1065
+ status.lastActivity = latestMtimeIso(entries.map((d) => join(projects, d.name)));
1066
+ }
1067
+ // Usage + billing live on cursor.com, not on disk. The enrichment step fetches
1068
+ // them with the IDE's session token; without it (--no-api / non-Bun), the card
1069
+ // shows local signals only.
1070
+ status.quota = null;
1071
+ status.tokensUsed = null;
1072
+ status.notes.push("usage + billing come from cursor.com (fetched during the enrichment step)");
1073
+ return status;
1074
+ }
1075
+ /** First existing Cursor global `state.vscdb` across native / macOS / WSL-Windows roots. */
1076
+ function cursorGlobalVscdb(home) {
1077
+ const candidates = [
1078
+ join(home, ".config", "Cursor", "User", "globalStorage", "state.vscdb"),
1079
+ join(home, "Library", "Application Support", "Cursor", "User", "globalStorage", "state.vscdb"),
1080
+ ];
1081
+ // WSL: Cursor's GUI runs on Windows, so the live DB is under /mnt/c/Users/<u>/.
1082
+ // Gated on the real home so a test with a synthetic `home` never reaches out
1083
+ // to the host's actual Windows-side Cursor install.
1084
+ if (home === homedir()) {
1085
+ try {
1086
+ if (existsSync("/mnt/c/Users")) {
1087
+ for (const u of readdirSync("/mnt/c/Users")) {
1088
+ candidates.push(`/mnt/c/Users/${u}/AppData/Roaming/Cursor/User/globalStorage/state.vscdb`);
1089
+ }
1090
+ }
1091
+ }
1092
+ catch {
1093
+ // no /mnt/c — not WSL
1094
+ }
1095
+ }
1096
+ return candidates.find((p) => existsSync(p)) ?? null;
1097
+ }
1098
+ /** vscdb copy fallback is skipped above this size (bytes). Cursor's global DB can be 3GB+. */
1099
+ const VSCDB_COPY_MAX_BYTES = 256 * 1024 * 1024;
1100
+ /**
1101
+ * Open a SQLite DB read-only and run `fn` against it, returning fn's result or
1102
+ * null. Uses `bun:sqlite`, lazily required so non-Bun runtimes degrade to null
1103
+ * rather than crashing at import.
1104
+ *
1105
+ * These DBs (Cursor's `state.vscdb`, Codex's `state_5.sqlite`) are usually
1106
+ * WAL-locked because their app is running, and over the WSL 9p mount a plain
1107
+ * open throws "disk I/O error". The fast path is SQLite immutable mode
1108
+ * (`file:...?immutable=1` with the URI open flag): it ignores the lock and the
1109
+ * WAL and reads only the btree pages the query touches, so lookups cost ~10ms
1110
+ * even on a multi-GB file. The tradeoff is it reads the committed main DB and
1111
+ * skips the newest uncommitted WAL frames, which is fine for a status snapshot.
1112
+ * Only if immutable fails AND the file is small do we snapshot-copy it (copying
1113
+ * a 3GB DB is never worth it).
1114
+ */
1115
+ function withSqlite(dbPath, fn) {
1116
+ let Database;
1117
+ let constants;
1118
+ try {
1119
+ const mod = require("bun:sqlite");
1120
+ Database = mod.Database;
1121
+ constants = mod.constants;
1122
+ }
1123
+ catch {
1124
+ return null; // no SQLite engine (plain Node runtime)
1125
+ }
1126
+ // Fast path: open immutable + read-only, no copy. Encode the path into a file:
1127
+ // URI (leaves "/" intact, escapes spaces in Windows paths).
1128
+ if (constants) {
1129
+ try {
1130
+ const flags = constants.SQLITE_OPEN_READONLY | constants.SQLITE_OPEN_URI;
1131
+ const uri = `file:${encodeURI(dbPath)}?immutable=1`;
1132
+ const db = new Database(uri, flags);
1133
+ const out = fn(db);
1134
+ db.close();
1135
+ return out;
1136
+ }
1137
+ catch {
1138
+ // fall through to the size-guarded snapshot fallback
1139
+ }
1140
+ }
1141
+ // Fallback: snapshot the DB (+ sidecars) and read the copy, only when small.
1142
+ let size = Number.POSITIVE_INFINITY;
1143
+ try {
1144
+ size = statSync(dbPath).size;
1145
+ }
1146
+ catch {
1147
+ // stat failed; treat as too-large and skip the copy
1148
+ }
1149
+ if (size > VSCDB_COPY_MAX_BYTES)
1150
+ return null;
1151
+ let tmp = null;
1152
+ try {
1153
+ tmp = mkdtempSync(join(tmpdir(), "harn-sqlite-"));
1154
+ const dst = join(tmp, "copy.sqlite");
1155
+ copyFileSync(dbPath, dst);
1156
+ for (const ext of ["-wal", "-shm"]) {
1157
+ if (existsSync(dbPath + ext)) {
1158
+ try {
1159
+ copyFileSync(dbPath + ext, dst + ext);
1160
+ }
1161
+ catch {
1162
+ // sidecar missing/locked — main file alone is still queryable
1163
+ }
1164
+ }
1165
+ }
1166
+ const db = new Database(dst, { readonly: true });
1167
+ const out = fn(db);
1168
+ db.close();
1169
+ return out;
1170
+ }
1171
+ catch {
1172
+ return null;
1173
+ }
1174
+ finally {
1175
+ if (tmp) {
1176
+ try {
1177
+ rmSync(tmp, { recursive: true, force: true });
1178
+ }
1179
+ catch {
1180
+ // best-effort cleanup
1181
+ }
1182
+ }
1183
+ }
1184
+ }
1185
+ /** Read specific keys from a Cursor `state.vscdb` (VS Code ItemTable). */
1186
+ function readVscdbItems(dbPath, keys) {
1187
+ return withSqlite(dbPath, (db) => {
1188
+ const out = {};
1189
+ for (const k of keys) {
1190
+ const row = db.query("SELECT value FROM ItemTable WHERE key = ?").get(k);
1191
+ if (row?.value != null)
1192
+ out[k] =
1193
+ typeof row.value === "string" ? row.value : Buffer.from(row.value).toString("utf8");
1194
+ }
1195
+ return out;
1196
+ });
1197
+ }
1198
+ // ---------------------------------------------------------------------------
1199
+ // helpers
1200
+ // ---------------------------------------------------------------------------
1201
+ function base(tool) {
1202
+ return {
1203
+ tool,
1204
+ installed: false,
1205
+ loggedIn: null,
1206
+ account: null,
1207
+ plan: null,
1208
+ rateLimitTier: null,
1209
+ authExpiresAt: null,
1210
+ sessions: null,
1211
+ lastActivity: null,
1212
+ quota: null,
1213
+ tokensUsed: null,
1214
+ api: null,
1215
+ usage: null,
1216
+ spend: null,
1217
+ notes: [],
1218
+ };
1219
+ }
1220
+ function readJson(path) {
1221
+ try {
1222
+ return JSON.parse(readFileSync(path, "utf8"));
1223
+ }
1224
+ catch {
1225
+ return null;
1226
+ }
1227
+ }
1228
+ /** Parse a JSON string that may be undefined; null on absence or parse error. */
1229
+ function safeJson(s) {
1230
+ if (!s)
1231
+ return null;
1232
+ try {
1233
+ return JSON.parse(s);
1234
+ }
1235
+ catch {
1236
+ return null;
1237
+ }
1238
+ }
1239
+ /** Yield parsed JSON objects from a .jsonl file, skipping unparseable lines. */
1240
+ function* readJsonlSync(path) {
1241
+ let content;
1242
+ try {
1243
+ content = readFileSync(path, "utf8");
1244
+ }
1245
+ catch {
1246
+ return;
1247
+ }
1248
+ for (const line of content.split("\n")) {
1249
+ if (!line.trim())
1250
+ continue;
1251
+ try {
1252
+ yield JSON.parse(line);
1253
+ }
1254
+ catch {
1255
+ // partial/corrupt line — skip
1256
+ }
1257
+ }
1258
+ }
1259
+ /** Decode the (non-secret) claims payload of a JWT. Never returns the token. */
1260
+ function decodeJwtClaims(jwt) {
1261
+ try {
1262
+ const part = jwt.split(".")[1];
1263
+ if (!part)
1264
+ return null;
1265
+ const padded = part + "=".repeat((4 - (part.length % 4)) % 4);
1266
+ const json = Buffer.from(padded, "base64url").toString("utf8");
1267
+ return JSON.parse(json);
1268
+ }
1269
+ catch {
1270
+ return null;
1271
+ }
1272
+ }
1273
+ function listFilesRecursive(root, ext) {
1274
+ if (!existsSync(root))
1275
+ return [];
1276
+ const out = [];
1277
+ const stack = [root];
1278
+ while (stack.length) {
1279
+ const cur = stack.pop();
1280
+ let entries;
1281
+ try {
1282
+ entries = readdirSync(cur, { withFileTypes: true });
1283
+ }
1284
+ catch {
1285
+ continue;
1286
+ }
1287
+ for (const e of entries) {
1288
+ const p = join(cur, e.name);
1289
+ if (e.isDirectory())
1290
+ stack.push(p);
1291
+ else if (e.isFile() && e.name.endsWith(ext))
1292
+ out.push(p);
1293
+ }
1294
+ }
1295
+ return out;
1296
+ }
1297
+ function safeMtime(path) {
1298
+ try {
1299
+ return statSync(path).mtimeMs;
1300
+ }
1301
+ catch {
1302
+ return 0;
1303
+ }
1304
+ }
1305
+ function newestFile(paths) {
1306
+ let best = null;
1307
+ let bestM = -1;
1308
+ for (const p of paths) {
1309
+ const m = safeMtime(p);
1310
+ if (m > bestM) {
1311
+ bestM = m;
1312
+ best = p;
1313
+ }
1314
+ }
1315
+ return best;
1316
+ }
1317
+ function latestMtimeIso(paths) {
1318
+ let m = 0;
1319
+ for (const p of paths)
1320
+ m = Math.max(m, safeMtime(p));
1321
+ return m > 0 ? new Date(m).toISOString() : null;
1322
+ }
1323
+ function strOr(v) {
1324
+ return typeof v === "string" && v.length > 0 ? v : null;
1325
+ }
1326
+ function numOr(v) {
1327
+ return typeof v === "number" && Number.isFinite(v) ? v : null;
1328
+ }