harnery 0.5.0 → 0.7.0

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