@maci0/dsh-quota-check 0.12.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,453 @@
1
+ /**
2
+ * Local CLI credentials for the subscription providers: where the token lives,
3
+ * when it is stale, how it rotates, and the exact headers the vendor's usage
4
+ * endpoint expects.
5
+ *
6
+ * These four providers bill a plan, not a key, so there is no settings API key
7
+ * to resolve: the token already sits on this machine, written by the CLI the
8
+ * human signed into. This module is the port of `quota-widget`'s fetchers
9
+ * (`~/Desktop/Projects/quota-widget/package/contents/code/fetch_quota.py`);
10
+ * it keeps the observable behavior (file paths, expiry skews, refresh bodies,
11
+ * and the write-back that keeps the CLI itself signed in) and drops the parts
12
+ * a statusbar chip does not need (disk caches, Retry-After sleeps, the TUI
13
+ * JSON shape).
14
+ *
15
+ * Rotation writes back only through an atomic replace in the file's own
16
+ * directory, mode 0600, preserving every other field, so a crash mid-write can
17
+ * never leave a CLI with a truncated credential.
18
+ *
19
+ * @module dsh-quota-check/local-usage
20
+ */
21
+ import { chmod, readFile, rename, rm, writeFile } from 'node:fs/promises';
22
+ import { homedir } from 'node:os';
23
+ import { join, resolve } from 'node:path';
24
+ import { isoToMs, numberOf, record, stringOf } from './util.js';
25
+ /** This plugin's own User-Agent for token endpoints. */
26
+ const USER_AGENT = 'dsh-quota-check/0.1';
27
+ /** Claude Code's own User-Agent: Anthropic rate-limits the usage endpoint per agent. */
28
+ const CLAUDE_USER_AGENT = 'claude-code/2.1.251';
29
+ const CLAUDE_CREDENTIALS = ['.claude', '.credentials.json'];
30
+ const CLAUDE_USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
31
+ const CLAUDE_CLIENT_ID = '9d1c250a-e61b-44d9-88ed-5944d1962f5e';
32
+ const CLAUDE_TOKEN_URLS = [
33
+ 'https://platform.claude.com/v1/oauth/token',
34
+ 'https://console.anthropic.com/v1/oauth/token',
35
+ ];
36
+ const CODEX_AUTH = ['.codex', 'auth.json'];
37
+ const CODEX_USAGE_URL = 'https://chatgpt.com/backend-api/wham/usage';
38
+ const CODEX_TOKEN_URL = 'https://auth.openai.com/oauth/token';
39
+ const CODEX_CLIENT_ID = 'app_EMoamEEZ73f0CkXaXp7hrann';
40
+ const GROK_AUTH = ['.grok', 'auth.json'];
41
+ const GROK_BILLING_URL = 'https://cli-chat-proxy.grok.com/v1/billing';
42
+ const GROK_OIDC_DISCOVERY = 'https://auth.x.ai/.well-known/openid-configuration';
43
+ const CURSOR_AUTH_JSON = ['.config', 'cursor', 'auth.json'];
44
+ const CURSOR_SUMMARY_URL = 'https://cursor.com/api/usage-summary';
45
+ /** Token refresh happens this close to expiry, in milliseconds / seconds. */
46
+ const TOKEN_SKEW_MS = 120_000;
47
+ const TOKEN_SKEW_S = 120;
48
+ /** Epoch values above this are milliseconds; below it, seconds. */
49
+ const MS_EPOCH_CUTOFF = 10_000_000_000;
50
+ /** Read and decode one JSON file, or `undefined` when absent or unreadable. */
51
+ async function readJson(path) {
52
+ try {
53
+ return JSON.parse(await readFile(path, 'utf8'));
54
+ }
55
+ catch {
56
+ return undefined;
57
+ }
58
+ }
59
+ /** Atomically replace one JSON file, privately, keeping other fields intact. */
60
+ async function writeJson(path, value) {
61
+ const staged = `${path}.${String(process.pid)}.${Math.random().toString(36).slice(2, 8)}.tmp`;
62
+ try {
63
+ await writeFile(staged, `${JSON.stringify(value, null, 2)}\n`, { mode: 0o600 });
64
+ await chmod(staged, 0o600);
65
+ await rename(staged, path);
66
+ }
67
+ catch (error) {
68
+ await rm(staged, { force: true }).catch(() => { });
69
+ throw error;
70
+ }
71
+ }
72
+ /** Preserve edits made while refreshing, and never replace a newer login or resurrect a removed one. */
73
+ async function saveRotation(path, field, expected, patch, topPatch = {}) {
74
+ const latest = record(await readJson(path));
75
+ const current = record(latest?.[field]);
76
+ if (current === undefined || !Object.entries(expected).every(([key, value]) => current[key] === value))
77
+ return;
78
+ await writeJson(path, { ...latest, ...topPatch, [field]: { ...current, ...patch } });
79
+ }
80
+ /** Decoded JWT payload, or `undefined` for an opaque or malformed token. */
81
+ function jwtPayload(token) {
82
+ const part = token.split('.')[1];
83
+ if (part === undefined)
84
+ return undefined;
85
+ try {
86
+ const padding = '='.repeat((4 - (part.length % 4)) % 4);
87
+ return record(JSON.parse(Buffer.from(part + padding, 'base64url').toString('utf8')));
88
+ }
89
+ catch {
90
+ return undefined;
91
+ }
92
+ }
93
+ /** Read one nested JWT claim, or `undefined`. */
94
+ function jwtClaim(token, ...path) {
95
+ let current = jwtPayload(token);
96
+ for (const key of path) {
97
+ current = record(current)?.[key];
98
+ }
99
+ return current;
100
+ }
101
+ /** JWT `exp` as epoch milliseconds, or `undefined`. */
102
+ function jwtExpiryMs(token) {
103
+ const exp = numberOf(jwtClaim(token, 'exp'));
104
+ return exp === undefined ? undefined : exp * 1_000;
105
+ }
106
+ /** POST one token request and decode the reply with its status. */
107
+ async function requestToken(url, headers, body, timeoutMs) {
108
+ try {
109
+ const response = await fetch(url, { method: 'POST', headers, body, signal: AbortSignal.timeout(timeoutMs) });
110
+ if (!response.ok)
111
+ return { status: response.status };
112
+ return { status: response.status, body: record(await response.json().catch(() => undefined)) };
113
+ }
114
+ catch {
115
+ return { status: 0 };
116
+ }
117
+ }
118
+ /** Form-encode a token refresh body. */
119
+ function formBody(fields) {
120
+ return new URLSearchParams(fields).toString();
121
+ }
122
+ /**
123
+ * Claude Code's OAuth credential and usage request.
124
+ * @param home - home directory holding `.claude`.
125
+ * @param force - refresh even when the token looks live.
126
+ * @param timeoutMs - deadline for each token-endpoint request.
127
+ * @returns the usage request, or `[]` when no credential is usable.
128
+ */
129
+ async function claudeRequests(home, force, timeoutMs) {
130
+ const path = join(home, ...CLAUDE_CREDENTIALS);
131
+ const credentials = await readJson(path);
132
+ const oauth = record(record(credentials)?.['claudeAiOauth']);
133
+ if (oauth === undefined)
134
+ return [];
135
+ let token = stringOf(oauth['accessToken']);
136
+ if (token === undefined)
137
+ return [];
138
+ const expiresAt = numberOf(oauth['expiresAt']);
139
+ const expiresMs = expiresAt === undefined
140
+ ? undefined
141
+ : expiresAt > MS_EPOCH_CUTOFF ? expiresAt : expiresAt * 1_000;
142
+ if (force || (expiresMs !== undefined && expiresMs <= Date.now() + TOKEN_SKEW_MS)) {
143
+ const refreshToken = stringOf(oauth['refreshToken']);
144
+ if (refreshToken !== undefined) {
145
+ const body = JSON.stringify({
146
+ grant_type: 'refresh_token',
147
+ refresh_token: refreshToken,
148
+ client_id: CLAUDE_CLIENT_ID,
149
+ });
150
+ for (const url of CLAUDE_TOKEN_URLS) {
151
+ const rotated = await requestToken(url, {
152
+ 'content-type': 'application/json',
153
+ accept: 'application/json',
154
+ 'user-agent': USER_AGENT,
155
+ }, body, timeoutMs);
156
+ // A refused refresh is a refused refresh: the second host would answer
157
+ // the same, and Anthropic counts the attempts.
158
+ if (rotated.status === 400 || rotated.status === 401)
159
+ break;
160
+ const access = stringOf(rotated.body?.['access_token']);
161
+ if (access === undefined)
162
+ continue;
163
+ const next = { accessToken: access };
164
+ const rotatedRefresh = stringOf(rotated.body?.['refresh_token']);
165
+ if (rotatedRefresh !== undefined)
166
+ next['refreshToken'] = rotatedRefresh;
167
+ const expiresIn = numberOf(rotated.body?.['expires_in']);
168
+ if (expiresIn !== undefined)
169
+ next['expiresAt'] = Date.now() + expiresIn * 1_000;
170
+ await saveRotation(path, 'claudeAiOauth', { accessToken: token, refreshToken }, next)
171
+ .catch(() => { });
172
+ token = access;
173
+ break;
174
+ }
175
+ }
176
+ }
177
+ if (token === undefined)
178
+ return [];
179
+ return [{
180
+ url: CLAUDE_USAGE_URL,
181
+ headers: {
182
+ authorization: `Bearer ${token}`,
183
+ 'anthropic-beta': 'oauth-2025-04-20',
184
+ 'anthropic-version': '2023-06-01',
185
+ 'user-agent': CLAUDE_USER_AGENT,
186
+ accept: 'application/json',
187
+ },
188
+ }];
189
+ }
190
+ /**
191
+ * Codex (ChatGPT subscription) credential and usage request.
192
+ * @param home - home directory holding `.codex`.
193
+ * @param force - refresh even when the token looks live.
194
+ * @param timeoutMs - deadline for each token-endpoint request.
195
+ * @returns the usage request, or `[]` when no credential is usable.
196
+ */
197
+ async function codexRequests(home, force, timeoutMs) {
198
+ const path = join(home, ...CODEX_AUTH);
199
+ const auth = record(await readJson(path));
200
+ if (auth === undefined)
201
+ return [];
202
+ const tokens = record(auth['tokens']);
203
+ let access = stringOf(tokens?.['access_token']);
204
+ if (access === undefined)
205
+ return [];
206
+ const account = stringOf(tokens?.['account_id'])
207
+ ?? stringOf(jwtClaim(access, 'https://api.openai.com/auth', 'chatgpt_account_id'));
208
+ const expiresMs = jwtExpiryMs(access);
209
+ const refreshToken = stringOf(tokens?.['refresh_token']);
210
+ if (refreshToken !== undefined
211
+ && (force || (expiresMs !== undefined && expiresMs <= Date.now() + TOKEN_SKEW_MS))) {
212
+ const rotated = await requestToken(CODEX_TOKEN_URL, {
213
+ 'content-type': 'application/x-www-form-urlencoded',
214
+ accept: 'application/json',
215
+ 'user-agent': USER_AGENT,
216
+ }, formBody({
217
+ grant_type: 'refresh_token',
218
+ refresh_token: refreshToken,
219
+ client_id: CODEX_CLIENT_ID,
220
+ }), timeoutMs);
221
+ const rotatedAccess = stringOf(rotated.body?.['access_token']);
222
+ if (rotatedAccess !== undefined) {
223
+ const nextTokens = { access_token: rotatedAccess };
224
+ const rotatedRefresh = stringOf(rotated.body?.['refresh_token']);
225
+ if (rotatedRefresh !== undefined)
226
+ nextTokens['refresh_token'] = rotatedRefresh;
227
+ const rotatedId = stringOf(rotated.body?.['id_token']);
228
+ if (rotatedId !== undefined)
229
+ nextTokens['id_token'] = rotatedId;
230
+ await saveRotation(path, 'tokens', { access_token: access, refresh_token: refreshToken }, nextTokens, { last_refresh: new Date().toISOString() }).catch(() => { });
231
+ access = rotatedAccess;
232
+ }
233
+ }
234
+ const headers = {
235
+ authorization: `Bearer ${access}`,
236
+ accept: 'application/json',
237
+ 'user-agent': USER_AGENT,
238
+ };
239
+ if (account !== undefined)
240
+ headers['chatgpt-account-id'] = account;
241
+ return [{ url: CODEX_USAGE_URL, headers }];
242
+ }
243
+ /** Grok's newest OIDC entry, with the key it is stored under. */
244
+ function newestGrokEntry(store) {
245
+ let best;
246
+ let bestExpiry = -Infinity;
247
+ for (const [key, value] of Object.entries(store)) {
248
+ const entry = record(value);
249
+ if (entry === undefined || stringOf(entry['key']) === undefined)
250
+ continue;
251
+ const expiry = isoToMs(entry['expires_at']) ?? -Infinity;
252
+ if (best === undefined || expiry > bestExpiry) {
253
+ best = { key, entry };
254
+ bestExpiry = expiry;
255
+ }
256
+ }
257
+ return best;
258
+ }
259
+ /**
260
+ * Grok's OIDC credential and the two billing meters (weekly credits, monthly spend).
261
+ * @param home - home directory holding `.grok`.
262
+ * @param force - refresh even when the token looks live.
263
+ * @param timeoutMs - deadline for each token-endpoint request.
264
+ * @returns the billing requests, or `[]` when no credential is usable.
265
+ */
266
+ async function grokRequests(home, force, timeoutMs) {
267
+ const path = join(home, ...GROK_AUTH);
268
+ const store = record(await readJson(path));
269
+ if (store === undefined)
270
+ return [];
271
+ const found = newestGrokEntry(store);
272
+ if (found === undefined)
273
+ return [];
274
+ const { entry } = found;
275
+ let token = stringOf(entry['key']);
276
+ if (token === undefined)
277
+ return [];
278
+ const expiresMs = isoToMs(entry['expires_at']);
279
+ const refreshToken = stringOf(entry['refresh_token']);
280
+ const clientId = stringOf(entry['oidc_client_id'])
281
+ ?? (found.key.includes('::') ? found.key.split('::')[1] : undefined);
282
+ if (clientId !== undefined && refreshToken !== undefined
283
+ && (force || (expiresMs !== undefined && expiresMs <= Date.now() + TOKEN_SKEW_S * 1_000))) {
284
+ let rotated = { status: 0 };
285
+ try {
286
+ const discovery = record(await (await fetch(GROK_OIDC_DISCOVERY, {
287
+ signal: AbortSignal.timeout(timeoutMs),
288
+ headers: { accept: 'application/json', 'user-agent': USER_AGENT },
289
+ })).json());
290
+ const tokenUrl = stringOf(discovery?.['token_endpoint']);
291
+ if (tokenUrl !== undefined) {
292
+ rotated = await requestToken(tokenUrl, {
293
+ 'content-type': 'application/x-www-form-urlencoded',
294
+ accept: 'application/json',
295
+ 'user-agent': USER_AGENT,
296
+ }, formBody({
297
+ grant_type: 'refresh_token',
298
+ refresh_token: refreshToken,
299
+ client_id: clientId,
300
+ }), timeoutMs);
301
+ }
302
+ }
303
+ catch {
304
+ rotated = { status: 0 };
305
+ }
306
+ const rotatedAccess = stringOf(rotated.body?.['access_token']);
307
+ if (rotatedAccess !== undefined) {
308
+ const next = { key: rotatedAccess };
309
+ const rotatedRefresh = stringOf(rotated.body?.['refresh_token']);
310
+ if (rotatedRefresh !== undefined)
311
+ next['refresh_token'] = rotatedRefresh;
312
+ const expiresIn = numberOf(rotated.body?.['expires_in']);
313
+ if (expiresIn !== undefined) {
314
+ next['expires_at'] = new Date(Date.now() + expiresIn * 1_000).toISOString();
315
+ }
316
+ await saveRotation(path, found.key, { key: token, refresh_token: refreshToken }, next)
317
+ .catch(() => { });
318
+ token = rotatedAccess;
319
+ }
320
+ }
321
+ if (token === undefined)
322
+ return [];
323
+ const headers = {
324
+ authorization: `Bearer ${token}`,
325
+ accept: 'application/json',
326
+ 'user-agent': USER_AGENT,
327
+ };
328
+ return [
329
+ { url: `${GROK_BILLING_URL}?format=credits`, headers },
330
+ { url: GROK_BILLING_URL, headers },
331
+ ];
332
+ }
333
+ /** Cursor's IDE database path for this platform. */
334
+ function cursorStateDb(home) {
335
+ if (process.platform === 'darwin') {
336
+ return join(home, 'Library', 'Application Support', 'Cursor', 'User', 'globalStorage', 'state.vscdb');
337
+ }
338
+ if (process.platform === 'win32') {
339
+ const appData = process.env['APPDATA'];
340
+ return join(appData ?? join(home, 'AppData', 'Roaming'), 'Cursor', 'User', 'globalStorage', 'state.vscdb');
341
+ }
342
+ const xdg = process.env['XDG_CONFIG_HOME'];
343
+ return join(xdg ?? join(home, '.config'), 'Cursor', 'User', 'globalStorage', 'state.vscdb');
344
+ }
345
+ /** Decode one `ItemTable` cell: raw text, bytes, or a JSON-quoted string. */
346
+ function cellString(value) {
347
+ if (value === null || value === undefined)
348
+ return undefined;
349
+ const raw = value instanceof Uint8Array ? Buffer.from(value).toString('utf8') : String(value);
350
+ const text = raw.trim();
351
+ if (text === '')
352
+ return undefined;
353
+ if (text.startsWith('"')) {
354
+ try {
355
+ const decoded = JSON.parse(text);
356
+ if (typeof decoded === 'string')
357
+ return decoded;
358
+ }
359
+ catch {
360
+ return text;
361
+ }
362
+ }
363
+ return text;
364
+ }
365
+ /** Cursor's token and plan from the IDE database, or `undefined`. */
366
+ async function cursorFromStateDb(path) {
367
+ try {
368
+ const { DatabaseSync } = await import('node:sqlite');
369
+ const database = new DatabaseSync(path, { readOnly: true, timeout: 1_000 });
370
+ try {
371
+ const rows = database.prepare('SELECT key, value FROM ItemTable WHERE key IN (?, ?)').all('cursorAuth/accessToken', 'cursorAuth/stripeMembershipType');
372
+ const cells = new Map();
373
+ for (const row of rows) {
374
+ const entry = record(row);
375
+ const key = cellString(entry?.['key']);
376
+ if (key !== undefined)
377
+ cells.set(key, cellString(entry?.['value']));
378
+ }
379
+ const token = cells.get('cursorAuth/accessToken');
380
+ if (token === undefined)
381
+ return undefined;
382
+ return { token, plan: cells.get('cursorAuth/stripeMembershipType') ?? '' };
383
+ }
384
+ finally {
385
+ database.close();
386
+ }
387
+ }
388
+ catch {
389
+ return undefined;
390
+ }
391
+ }
392
+ /**
393
+ * Cursor's session credential and usage request. Cursor has no refresh path:
394
+ * the session token is long-lived and only the IDE or `cursor-agent` rotates it.
395
+ * @param home - home directory holding `.config`.
396
+ * @returns the usage request, or `[]` when no credential is usable.
397
+ */
398
+ async function cursorRequests(home) {
399
+ const fromJson = async (path) => stringOf(record(await readJson(path))?.['accessToken']);
400
+ const override = process.env['CURSOR_AUTH_JSON'];
401
+ let token = override === undefined || override === '' ? undefined : await fromJson(override);
402
+ token ??= await fromJson(join(home, ...CURSOR_AUTH_JSON));
403
+ if (token === undefined)
404
+ token = (await cursorFromStateDb(cursorStateDb(home)))?.token;
405
+ if (token === undefined)
406
+ return [];
407
+ // The cookie carries the WorkOS user id, which only the token's `sub` names.
408
+ const sub = stringOf(jwtClaim(token, 'sub'));
409
+ if (sub === undefined)
410
+ return [];
411
+ const user = sub.includes('|') ? sub.slice(sub.lastIndexOf('|') + 1) : sub;
412
+ return [{
413
+ url: CURSOR_SUMMARY_URL,
414
+ headers: {
415
+ cookie: `WorkosCursorSessionToken=${encodeURIComponent(`${user}::${token}`)}`,
416
+ accept: 'application/json',
417
+ 'user-agent': USER_AGENT,
418
+ origin: 'https://cursor.com',
419
+ referer: 'https://cursor.com/dashboard/usage',
420
+ },
421
+ }];
422
+ }
423
+ // ponytail: serializes this process's reads; CLI processes do not share a locking protocol.
424
+ const localReads = new Map();
425
+ /**
426
+ * Build the outbound requests for one subscription provider.
427
+ * @param provider - which CLI credential to read.
428
+ * @param options - home directory, forced-refresh flag, and token-request deadline.
429
+ * @returns one or two requests, or `[]` when nothing usable is on disk.
430
+ */
431
+ export async function localRequests(provider, options) {
432
+ // `$HOME` first: node's homedir() reads it on every call, but bun's caches
433
+ // the value at startup, so a later change (a test's scratch home) is missed.
434
+ const home = options.home ?? (process.env['HOME'] || homedir());
435
+ const force = options.forceRefresh === true;
436
+ const key = JSON.stringify([resolve(home), provider]);
437
+ const pending = (localReads.get(key) ?? Promise.resolve([])).catch(() => []).then(async () => {
438
+ switch (provider) {
439
+ case 'claude': return await claudeRequests(home, force, options.timeoutMs);
440
+ case 'codex': return await codexRequests(home, force, options.timeoutMs);
441
+ case 'grok': return await grokRequests(home, force, options.timeoutMs);
442
+ case 'cursor': return await cursorRequests(home);
443
+ }
444
+ });
445
+ localReads.set(key, pending);
446
+ try {
447
+ return await pending;
448
+ }
449
+ finally {
450
+ if (localReads.get(key) === pending)
451
+ localReads.delete(key);
452
+ }
453
+ }