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.
- package/README.md +16 -6
- package/dist/commander.d.ts +29 -0
- package/dist/commander.d.ts.map +1 -1
- package/dist/commander.js +4 -0
- package/dist/commands/agents.d.ts.map +1 -1
- package/dist/commands/agents.js +94 -33
- package/dist/commands/browse-ai.js +1 -1
- package/dist/commands/browse.d.ts.map +1 -1
- package/dist/commands/browse.js +41 -9
- package/dist/commands/cookies.js +1 -1
- package/dist/commands/decision.d.ts +4 -0
- package/dist/commands/decision.d.ts.map +1 -0
- package/dist/commands/decision.js +354 -0
- package/dist/commands/deinit.d.ts.map +1 -1
- package/dist/commands/deinit.js +4 -0
- package/dist/commands/devtools.d.ts +4 -0
- package/dist/commands/devtools.d.ts.map +1 -0
- package/dist/commands/devtools.js +239 -0
- package/dist/commands/docs.d.ts.map +1 -1
- package/dist/commands/docs.js +74 -2
- package/dist/commands/doctor.js +12 -4
- package/dist/commands/env.d.ts.map +1 -1
- package/dist/commands/env.js +3 -63
- package/dist/commands/fetch.js +1 -1
- package/dist/commands/init.d.ts +1 -0
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +54 -14
- package/dist/commands/scratch.js +1 -1
- package/dist/commands/tunnel.d.ts.map +1 -1
- package/dist/commands/tunnel.js +273 -62
- package/dist/commands/web-fetch.js +1 -1
- package/dist/core/agents/coord-client.d.ts.map +1 -1
- package/dist/core/agents/coord-client.js +32 -8
- package/dist/core/agents/events/consume.d.ts +25 -2
- package/dist/core/agents/events/consume.d.ts.map +1 -1
- package/dist/core/agents/events/consume.js +55 -7
- package/dist/core/agents/events/emit.d.ts +2 -1
- package/dist/core/agents/events/emit.d.ts.map +1 -1
- package/dist/core/agents/events/emit.js +6 -1
- package/dist/core/agents/rules/claim-conflict.d.ts.map +1 -1
- package/dist/core/agents/rules/claim-conflict.js +16 -5
- package/dist/core/agents/state/scratch.d.ts +1 -1
- package/dist/core/agents/state/scratch.js +2 -2
- package/dist/core/config.d.ts +10 -0
- package/dist/core/config.d.ts.map +1 -1
- package/dist/core/config.js +13 -0
- package/dist/core/hooks/cli.js +3 -3
- package/dist/core/hooks/effects/index.d.ts +11 -7
- package/dist/core/hooks/effects/index.d.ts.map +1 -1
- package/dist/core/hooks/effects/index.js +15 -17
- package/dist/core/hooks/events/emit.d.ts.map +1 -1
- package/dist/core/hooks/events/emit.js +4 -0
- package/dist/core/hooks/events/rotate.d.ts +43 -0
- package/dist/core/hooks/events/rotate.d.ts.map +1 -0
- package/dist/core/hooks/events/rotate.js +142 -0
- package/dist/core/hooks/harness/events.d.ts +11 -1
- package/dist/core/hooks/harness/events.d.ts.map +1 -1
- package/dist/core/hooks/harness/events.js +22 -3
- package/dist/core/hooks/harness/wiring.d.ts +8 -0
- package/dist/core/hooks/harness/wiring.d.ts.map +1 -1
- package/dist/core/hooks/harness/wiring.js +34 -5
- package/dist/core/scratch/index.d.ts.map +1 -0
- package/dist/{lib → core}/scratch/index.js +2 -2
- package/dist/lib/agent-browser/client.js +1 -1
- package/dist/lib/browser/client.d.ts +14 -0
- package/dist/lib/browser/client.d.ts.map +1 -1
- package/dist/lib/browser/client.js +20 -0
- package/dist/lib/browser/index.d.ts +1 -0
- package/dist/lib/browser/index.d.ts.map +1 -1
- package/dist/lib/browser/runts.d.ts +44 -0
- package/dist/lib/browser/runts.d.ts.map +1 -0
- package/dist/lib/browser/runts.js +193 -0
- package/dist/lib/completion/walk.js +1 -1
- package/dist/lib/cookies/client.d.ts +1 -1
- package/dist/lib/cookies/client.d.ts.map +1 -1
- package/dist/lib/cookies/client.js +1 -1
- package/dist/lib/decision/index.d.ts +212 -0
- package/dist/lib/decision/index.d.ts.map +1 -0
- package/dist/lib/decision/index.js +523 -0
- package/dist/lib/devtools.d.ts +178 -0
- package/dist/lib/devtools.d.ts.map +1 -0
- package/dist/lib/devtools.js +1328 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts +33 -0
- package/dist/lib/docs-frontmatter-migrate.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter-migrate.js +364 -0
- package/dist/lib/docs-frontmatter.d.ts +33 -0
- package/dist/lib/docs-frontmatter.d.ts.map +1 -0
- package/dist/lib/docs-frontmatter.js +130 -0
- package/dist/lib/docs-index.d.ts +1 -0
- package/dist/lib/docs-index.d.ts.map +1 -1
- package/dist/lib/docs-index.js +4 -5
- package/dist/lib/docs-lint.d.ts +3 -0
- package/dist/lib/docs-lint.d.ts.map +1 -1
- package/dist/lib/docs-lint.js +67 -12
- package/dist/lib/docs-meta.d.ts +14 -0
- package/dist/lib/docs-meta.d.ts.map +1 -0
- package/dist/lib/docs-meta.js +34 -0
- package/dist/lib/docs-sweep.d.ts +12 -0
- package/dist/lib/docs-sweep.d.ts.map +1 -1
- package/dist/lib/docs-sweep.js +98 -103
- package/dist/lib/format.js +2 -2
- package/dist/lib/http/index.d.ts +1 -0
- package/dist/lib/http/index.d.ts.map +1 -1
- package/dist/lib/http/index.js +1 -0
- package/dist/lib/http/request.d.ts +77 -0
- package/dist/lib/http/request.d.ts.map +1 -0
- package/dist/lib/http/request.js +105 -0
- package/dist/lib/instructions/apply.d.ts +63 -0
- package/dist/lib/instructions/apply.d.ts.map +1 -0
- package/dist/lib/instructions/apply.js +255 -0
- package/dist/lib/instructions/splice.d.ts +73 -0
- package/dist/lib/instructions/splice.d.ts.map +1 -0
- package/dist/lib/instructions/splice.js +118 -0
- package/dist/lib/instructions/templates.d.ts +45 -0
- package/dist/lib/instructions/templates.d.ts.map +1 -0
- package/dist/lib/instructions/templates.js +258 -0
- package/dist/lib/tunnel/gate.d.ts +1 -0
- package/dist/lib/tunnel/gate.d.ts.map +1 -1
- package/dist/lib/tunnel/gate.js +15 -10
- package/dist/lib/tunnel/state.d.ts +11 -1
- package/dist/lib/tunnel/state.d.ts.map +1 -1
- package/dist/lib/tunnel/state.js +8 -3
- package/package.json +9 -6
- package/src/commander.ts +35 -0
- package/src/commands/agents.ts +97 -29
- package/src/commands/browse-ai.ts +1 -1
- package/src/commands/browse.ts +63 -8
- package/src/commands/cookies.ts +1 -1
- package/src/commands/decision.ts +438 -0
- package/src/commands/deinit.ts +5 -0
- package/src/commands/devtools.ts +284 -0
- package/src/commands/docs.ts +86 -2
- package/src/commands/doctor.ts +13 -4
- package/src/commands/env.ts +11 -77
- package/src/commands/fetch.ts +1 -1
- package/src/commands/init.ts +66 -15
- package/src/commands/scratch.ts +1 -1
- package/src/commands/tunnel.ts +316 -65
- package/src/commands/web-fetch.ts +1 -1
- package/src/core/agents/coord-client.ts +34 -7
- package/src/core/agents/events/consume.ts +65 -7
- package/src/core/agents/events/emit.ts +7 -1
- package/src/core/agents/rules/claim-conflict.ts +17 -6
- package/src/core/agents/state/scratch.ts +2 -2
- package/src/core/config.ts +15 -1
- package/src/core/hooks/cli.ts +3 -3
- package/src/core/hooks/effects/index.ts +23 -16
- package/src/core/hooks/events/emit.ts +5 -0
- package/src/core/hooks/events/rotate.ts +151 -0
- package/src/core/hooks/harness/events.ts +30 -3
- package/src/core/hooks/harness/wiring.ts +46 -5
- package/src/{lib → core}/scratch/index.ts +2 -2
- package/src/lib/agent-browser/client.ts +1 -1
- package/src/lib/browser/client.ts +28 -0
- package/src/lib/browser/index.ts +4 -0
- package/src/lib/browser/runts.ts +218 -0
- package/src/lib/completion/walk.ts +1 -1
- package/src/lib/cookies/client.ts +2 -2
- package/src/lib/decision/index.ts +685 -0
- package/src/lib/devtools.ts +1653 -0
- package/src/lib/docs-frontmatter-migrate.ts +427 -0
- package/src/lib/docs-frontmatter.ts +151 -0
- package/src/lib/docs-index.ts +4 -5
- package/src/lib/docs-lint.ts +61 -11
- package/src/lib/docs-meta.ts +44 -0
- package/src/lib/docs-sweep.ts +104 -102
- package/src/lib/format.ts +2 -2
- package/src/lib/http/index.ts +1 -0
- package/src/lib/http/request.ts +154 -0
- package/src/lib/instructions/apply.ts +318 -0
- package/src/lib/instructions/splice.ts +148 -0
- package/src/lib/instructions/templates.ts +295 -0
- package/src/lib/tunnel/gate.ts +15 -10
- package/src/lib/tunnel/state.ts +19 -4
- package/dist/lib/scratch/index.d.ts.map +0 -1
- /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
|
+
}
|