claude-usage-limits 1.9.2 → 1.11.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,489 @@
1
+ 'use strict';
2
+
3
+ // The reading itself, taken the way Claude Code takes it.
4
+ //
5
+ // /usage in Claude Code is one GET to Anthropic's usage endpoint with the
6
+ // login token Claude Code already holds. This file makes that same call, with
7
+ // the same headers and the same timeout, and keeps the answer in a small file
8
+ // of its own so the rest of the plugin can read it without going anywhere
9
+ // near the network.
10
+ //
11
+ // Two rules that matter more than anything else here:
12
+ //
13
+ // - The token is read, used for this one request, and sent nowhere else. It is
14
+ // never written to the live file, never printed, never refreshed or rotated.
15
+ // Refreshing it would race Claude Code's own refresh and could sign the user
16
+ // out; if it has expired the endpoint says 401 and Claude Code fixes it on
17
+ // its next call.
18
+ // - Every failure is a kind, not an exception. Offline, signed out, busy and
19
+ // broken all need different waits and different words, and the panel has to
20
+ // keep drawing through all of them.
21
+
22
+ const fs = require('fs');
23
+ const os = require('os');
24
+ const path = require('path');
25
+ const http = require('http');
26
+ const https = require('https');
27
+ const { execFileSync } = require('child_process');
28
+
29
+ const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage';
30
+ const BETA = 'oauth-2025-04-20';
31
+ // Claude Code gives the call five seconds; so does this.
32
+ const DEFAULT_TIMEOUT_MS = 5000;
33
+ const KEYCHAIN_SERVICE = 'Claude Code-credentials';
34
+ const MAX_BODY = 1024 * 1024;
35
+ const MINUTE = 60 * 1000;
36
+
37
+ function configDir() {
38
+ return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
39
+ }
40
+
41
+ function credentialsFile() {
42
+ return path.join(configDir(), '.credentials.json');
43
+ }
44
+
45
+ function liveFile() {
46
+ return path.join(configDir(), 'usage-limits-live.json');
47
+ }
48
+
49
+ function version() {
50
+ try {
51
+ return require('../../../package.json').version;
52
+ } catch (err) {
53
+ return '0';
54
+ }
55
+ }
56
+
57
+ function userAgent() {
58
+ return 'claude-usage-limits/' + version();
59
+ }
60
+
61
+ function parseCredentials(text) {
62
+ let parsed;
63
+ try {
64
+ parsed = JSON.parse(text);
65
+ } catch (err) {
66
+ return null;
67
+ }
68
+ if (!parsed || typeof parsed !== 'object') return null;
69
+ const oauth = parsed.claudeAiOauth;
70
+ if (!oauth || typeof oauth !== 'object') return { token: null, expiresAt: null };
71
+ return {
72
+ token: typeof oauth.accessToken === 'string' && oauth.accessToken ? oauth.accessToken : null,
73
+ expiresAt: Number.isFinite(oauth.expiresAt) ? oauth.expiresAt : null,
74
+ };
75
+ }
76
+
77
+ function defaultExec(args) {
78
+ return execFileSync('security', args, {
79
+ encoding: 'utf8',
80
+ timeout: 3000,
81
+ windowsHide: true,
82
+ stdio: ['ignore', 'pipe', 'ignore'],
83
+ });
84
+ }
85
+
86
+ // Where Claude Code keeps the login: a file beside the config directory on
87
+ // Windows and Linux, the keychain on macOS. The file is checked first on every
88
+ // platform because CLAUDE_CONFIG_DIR installs write it there too.
89
+ function readToken(options) {
90
+ const opts = options || {};
91
+ const platform = opts.platform || process.platform;
92
+ const file = opts.file || credentialsFile();
93
+
94
+ let raw = null;
95
+ try {
96
+ raw = fs.readFileSync(file, 'utf8');
97
+ } catch (err) {
98
+ if (err && err.code !== 'ENOENT') return { token: null, reason: 'unreadable', detail: err.code };
99
+ }
100
+ if (raw !== null) {
101
+ const parsed = parseCredentials(raw);
102
+ if (!parsed) return { token: null, reason: 'unreadable' };
103
+ if (!parsed.token) return { token: null, reason: 'no_credentials' };
104
+ return { token: parsed.token, expiresAt: parsed.expiresAt, source: 'file' };
105
+ }
106
+
107
+ if (platform === 'darwin') {
108
+ // The keychain is a child process each time, so a long-lived panel or the
109
+ // VS Code extension remembers the answer for a few minutes.
110
+ const nowMs = Date.now();
111
+ // Only the real keychain is remembered; an injected reader (the tests)
112
+ // is asked every time.
113
+ const remember = opts.cache !== false && !opts.exec;
114
+ if (remember && keychainMemo && nowMs - keychainMemo.at < KEYCHAIN_MEMO_MS) return keychainMemo.result;
115
+ const exec = opts.exec || defaultExec;
116
+ try {
117
+ const out = exec(['find-generic-password', '-s', KEYCHAIN_SERVICE, '-w']);
118
+ const parsed = parseCredentials(String(out || '').trim());
119
+ if (parsed && parsed.token) {
120
+ const result = { token: parsed.token, expiresAt: parsed.expiresAt, source: 'keychain' };
121
+ if (remember) keychainMemo = { at: nowMs, result };
122
+ return result;
123
+ }
124
+ } catch (err) {
125
+ // No keychain entry, or no permission to read it: same answer as no file.
126
+ }
127
+ }
128
+
129
+ return { token: null, reason: 'no_credentials' };
130
+ }
131
+
132
+ let keychainMemo = null;
133
+ const KEYCHAIN_MEMO_MS = 5 * MINUTE;
134
+
135
+ const LOOPBACK = new Set(['127.0.0.1', 'localhost', '::1', '[::1]']);
136
+
137
+ function isLoopback(hostname) {
138
+ return LOOPBACK.has(String(hostname || '').toLowerCase());
139
+ }
140
+
141
+ // Where the login may be sent: Anthropic over https, or this machine (the
142
+ // tests run a stub on a loopback port). A project's settings file can put
143
+ // anything into the environment of a hook, and USAGE_LIMITS_USAGE_URL must
144
+ // not be a way to walk off with the token.
145
+ function allowedUrl(raw) {
146
+ if (!raw) return null;
147
+ let url;
148
+ try {
149
+ url = new URL(String(raw));
150
+ } catch (err) {
151
+ return null;
152
+ }
153
+ if (isLoopback(url.hostname)) return url.toString();
154
+ if (url.protocol !== 'https:') return null;
155
+ const host = url.hostname.toLowerCase();
156
+ if (host === 'anthropic.com' || host.endsWith('.anthropic.com')) return url.toString();
157
+ return null;
158
+ }
159
+
160
+ function bad(kind, status, retryAfterMs, message) {
161
+ return {
162
+ ok: false,
163
+ kind,
164
+ status: Number.isFinite(status) ? status : null,
165
+ retryAfterMs: Number.isFinite(retryAfterMs) ? retryAfterMs : null,
166
+ message: message || kind,
167
+ };
168
+ }
169
+
170
+ function retryAfterMs(headers) {
171
+ const value = headers && headers['retry-after'];
172
+ if (!value) return null;
173
+ const seconds = Number(value);
174
+ if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
175
+ const at = Date.parse(value);
176
+ return Number.isFinite(at) ? Math.max(0, at - Date.now()) : null;
177
+ }
178
+
179
+ // Every transport failure is "offline" as far as the panel is concerned: there
180
+ // is nothing different to do about a DNS failure and a reset connection.
181
+ function classify(err) {
182
+ return 'offline';
183
+ }
184
+
185
+ function fetchUsage(options) {
186
+ const opts = options || {};
187
+ return new Promise((resolve) => {
188
+ if (!opts.token) return resolve(bad('no_credentials', null, null, 'no Claude login found'));
189
+
190
+ const permitted = allowedUrl(opts.url || USAGE_URL);
191
+ if (!permitted) {
192
+ return resolve(bad('bad_response', null, null, 'refusing to send the login anywhere but Anthropic over https'));
193
+ }
194
+ const url = new URL(permitted);
195
+ const client = url.protocol === 'http:' ? http : https;
196
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
197
+
198
+ let settled = false;
199
+ const done = (outcome) => {
200
+ if (settled) return;
201
+ settled = true;
202
+ resolve(outcome);
203
+ };
204
+
205
+ const req = client.request(
206
+ {
207
+ protocol: url.protocol,
208
+ hostname: url.hostname,
209
+ port: url.port || undefined,
210
+ path: url.pathname + url.search,
211
+ method: 'GET',
212
+ headers: {
213
+ Authorization: 'Bearer ' + opts.token,
214
+ 'anthropic-beta': BETA,
215
+ 'Content-Type': 'application/json',
216
+ Accept: 'application/json',
217
+ 'User-Agent': opts.userAgent || userAgent(),
218
+ },
219
+ },
220
+ (res) => {
221
+ let body = '';
222
+ res.setEncoding('utf8');
223
+ res.on('data', (chunk) => {
224
+ if (body.length < MAX_BODY) body += chunk;
225
+ });
226
+ res.on('error', (err) => done(bad('offline', null, null, (err && err.code) || 'read error')));
227
+ res.on('end', () => {
228
+ const status = res.statusCode;
229
+ if (status === 200) {
230
+ let parsed;
231
+ try {
232
+ parsed = JSON.parse(body);
233
+ } catch (err) {
234
+ return done(bad('bad_response', status, null, 'the usage endpoint did not answer with JSON'));
235
+ }
236
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
237
+ return done(bad('bad_response', status, null, 'the usage endpoint answered with something unexpected'));
238
+ }
239
+ return done({ ok: true, status, utilization: parsed, fetchedAtMs: Date.now() });
240
+ }
241
+ if (status === 401) return done(bad('unauthorized', status, null, 'the login has expired'));
242
+ if (status === 403) return done(bad('forbidden', status, null, 'usage is not available for this login'));
243
+ if (status === 429) {
244
+ return done(bad('rate_limited', status, retryAfterMs(res.headers), 'the usage endpoint is busy'));
245
+ }
246
+ if (status >= 500) return done(bad('server', status, null, 'the usage endpoint returned ' + status));
247
+ return done(bad('http', status, null, 'the usage endpoint returned ' + status));
248
+ });
249
+ }
250
+ );
251
+ req.setTimeout(timeoutMs, () => {
252
+ req.destroy(new Error('timeout'));
253
+ });
254
+ req.on('error', (err) => done(bad(classify(err), null, null, (err && err.code) || (err && err.message) || 'offline')));
255
+ req.end();
256
+ });
257
+ }
258
+
259
+ function finite(value, fallback) {
260
+ return Number.isFinite(value) ? value : fallback;
261
+ }
262
+
263
+ // How long to wait before trying again. The shape matters: offline should be
264
+ // retried soon and then less often, a busy server told us exactly how long, a
265
+ // login problem is something Claude Code fixes on its own next call, and
266
+ // server trouble is not helped by hammering it.
267
+ function nextDelayMs(outcome, previousMs, options) {
268
+ const opts = options || {};
269
+ const base = finite(opts.baseMs, 60 * 1000);
270
+ const max = finite(opts.maxMs, 120 * 1000);
271
+ const prev = finite(previousMs, 0);
272
+ if (!outcome || outcome.ok) return base;
273
+ switch (outcome.kind) {
274
+ case 'disabled':
275
+ return max;
276
+ case 'rate_limited':
277
+ return Number.isFinite(outcome.retryAfterMs) && outcome.retryAfterMs > 0
278
+ ? Math.min(Math.max(outcome.retryAfterMs, 1000), 10 * MINUTE)
279
+ : 60 * 1000;
280
+ case 'unauthorized':
281
+ case 'expired':
282
+ case 'forbidden':
283
+ case 'no_credentials':
284
+ return 30 * 1000;
285
+ case 'offline':
286
+ return Math.min(Math.max(5 * 1000, prev * 2), 60 * 1000);
287
+ default:
288
+ return Math.min(Math.max(15 * 1000, prev * 2), 120 * 1000);
289
+ }
290
+ }
291
+
292
+ // A few words for a footer.
293
+ function describe(outcome) {
294
+ if (!outcome) return '';
295
+ if (outcome.ok) return 'live';
296
+ switch (outcome.kind) {
297
+ case 'disabled':
298
+ return 'network off';
299
+ case 'offline':
300
+ return 'offline';
301
+ case 'unauthorized':
302
+ return 'sign in to Claude Code again';
303
+ case 'expired':
304
+ return 'login expired, Claude Code renews it on its next call';
305
+ case 'forbidden':
306
+ return 'usage not available for this login';
307
+ case 'no_credentials':
308
+ return 'no Claude login found';
309
+ case 'rate_limited':
310
+ return 'usage endpoint busy';
311
+ default:
312
+ return 'usage endpoint error';
313
+ }
314
+ }
315
+
316
+ function readLive() {
317
+ let parsed;
318
+ try {
319
+ parsed = JSON.parse(fs.readFileSync(liveFile(), 'utf8'));
320
+ } catch (err) {
321
+ return null;
322
+ }
323
+ if (!parsed || typeof parsed !== 'object') return null;
324
+ if (!Number.isFinite(parsed.fetchedAtMs)) return null;
325
+ if (!parsed.utilization || typeof parsed.utilization !== 'object') return null;
326
+ return parsed;
327
+ }
328
+
329
+ // Through a temporary file named for this process, so a reader never sees
330
+ // half a reading and two writers never share a temp file. A rename that fails
331
+ // (Windows, with the target held open) leaves nothing behind.
332
+ function writeAtomic(file, text) {
333
+ const temp = file + '.' + process.pid + '.usage-limits-tmp';
334
+ try {
335
+ fs.mkdirSync(path.dirname(file), { recursive: true });
336
+ fs.writeFileSync(temp, text, 'utf8');
337
+ fs.renameSync(temp, file);
338
+ return true;
339
+ } catch (err) {
340
+ try {
341
+ fs.unlinkSync(temp);
342
+ } catch (gone) {
343
+ // Nothing to clean up.
344
+ }
345
+ return false;
346
+ }
347
+ }
348
+
349
+ function writeLive(snapshot) {
350
+ return writeAtomic(liveFile(), JSON.stringify(snapshot));
351
+ }
352
+
353
+ function fetchDisabled(env) {
354
+ return String((env || process.env).USAGE_LIMITS_FETCH || '').toLowerCase() === 'off';
355
+ }
356
+
357
+ // Take a reading and keep it. Whatever goes wrong, the previous reading on
358
+ // disk comes back so the caller always has something to draw.
359
+ async function refresh(options) {
360
+ const opts = options || {};
361
+ const env = opts.env || process.env;
362
+ if (opts.fetch === false || fetchDisabled(env)) {
363
+ return { outcome: bad('disabled', null, null, 'network use is off'), snapshot: readLive() };
364
+ }
365
+ const creds = readToken({ file: opts.credentialsFile, platform: opts.platform, exec: opts.exec });
366
+ if (!creds.token) {
367
+ return {
368
+ outcome: bad('no_credentials', null, null, creds.reason === 'unreadable' ? 'the login file could not be read' : 'no Claude login found'),
369
+ snapshot: readLive(),
370
+ };
371
+ }
372
+ // A token past its expiry gets a 401 and nothing else. Claude Code renews it
373
+ // on its own next call, so do not send it, and say what is being waited for.
374
+ if (Number.isFinite(creds.expiresAt) && creds.expiresAt < (Number.isFinite(opts.now) ? opts.now : Date.now())) {
375
+ return { outcome: bad('expired', null, null, 'the login has expired; Claude Code renews it on its next call'), snapshot: readLive() };
376
+ }
377
+ const outcome = await fetchUsage({
378
+ token: creds.token,
379
+ url: allowedUrl(opts.url || env.USAGE_LIMITS_USAGE_URL) || USAGE_URL,
380
+ timeoutMs: opts.timeoutMs,
381
+ });
382
+ if (!outcome.ok) return { outcome, snapshot: readLive() };
383
+ const snapshot = {
384
+ fetchedAtMs: Number.isFinite(opts.now) ? opts.now : outcome.fetchedAtMs,
385
+ utilization: outcome.utilization,
386
+ accountUuid: opts.accountUuid || null,
387
+ source: 'api',
388
+ };
389
+ writeLive(snapshot);
390
+ return { outcome, snapshot };
391
+ }
392
+
393
+ // When the last attempt was, and how long to leave it. Kept apart from the
394
+ // reading so a failed attempt never disturbs a good reading.
395
+ function attemptFile() {
396
+ return path.join(configDir(), 'usage-limits-fetch.json');
397
+ }
398
+
399
+ function readAttempt() {
400
+ try {
401
+ const parsed = JSON.parse(fs.readFileSync(attemptFile(), 'utf8'));
402
+ return parsed && Number.isFinite(parsed.attemptedAtMs) ? parsed : null;
403
+ } catch (err) {
404
+ return null;
405
+ }
406
+ }
407
+
408
+ function writeAttempt(record) {
409
+ // Atomic, so a concurrent reader never parses half a record and skips the
410
+ // backoff by accident.
411
+ writeAtomic(attemptFile(), JSON.stringify(record));
412
+ }
413
+
414
+ // A reading only when the one on disk has aged. This is what the hooks call:
415
+ // the budget line was once seventeen minutes old while eight agents spent half
416
+ // a window in parallel, and the plugin said 42 percent as the wall arrived. A
417
+ // reading that is younger than maxAgeMs (from this file or from Claude Code's
418
+ // own cache, whichever is newer) is left alone, a failed attempt is not
419
+ // repeated until its backoff has passed, and the whole thing is skipped when
420
+ // the network is off.
421
+ async function refreshIfStale(options) {
422
+ const opts = options || {};
423
+ const env = opts.env || process.env;
424
+ const now = Number.isFinite(opts.now) ? opts.now : Date.now();
425
+ const maxAgeMs = Number.isFinite(opts.maxAgeMs) ? opts.maxAgeMs : 3 * MINUTE;
426
+ const current = readLive();
427
+ if (opts.fetch === false || fetchDisabled(env)) return { outcome: null, snapshot: current, skipped: 'disabled' };
428
+
429
+ const newestAt = Math.max(
430
+ current ? current.fetchedAtMs : 0,
431
+ Number.isFinite(opts.cacheFetchedAtMs) ? opts.cacheFetchedAtMs : 0
432
+ );
433
+ if (newestAt > 0 && now - newestAt < maxAgeMs) return { outcome: null, snapshot: current, skipped: 'fresh' };
434
+
435
+ const attempt = readAttempt();
436
+ // A record from a clock that was ahead would hold the backoff until the
437
+ // wall clock caught up with it; a minute of skew is all that is honoured.
438
+ const sinceAttempt = attempt ? now - attempt.attemptedAtMs : 0;
439
+ if (attempt && Number.isFinite(attempt.delayMs) && sinceAttempt >= -MINUTE && sinceAttempt < attempt.delayMs) {
440
+ return { outcome: null, snapshot: current, skipped: 'backoff' };
441
+ }
442
+
443
+ // Claim the attempt before making it, so the other hooks that fire in the
444
+ // same second (parallel agents, several windows) skip rather than each
445
+ // sending its own request and each waiting out its own timeout.
446
+ const timeoutMs = Number.isFinite(opts.timeoutMs) ? opts.timeoutMs : DEFAULT_TIMEOUT_MS;
447
+ writeAttempt({ attemptedAtMs: now, delayMs: timeoutMs, kind: 'inflight' });
448
+
449
+ const result = await refresh({
450
+ now,
451
+ accountUuid: opts.accountUuid,
452
+ env,
453
+ timeoutMs: opts.timeoutMs,
454
+ url: opts.url,
455
+ credentialsFile: opts.credentialsFile,
456
+ });
457
+ writeAttempt({
458
+ attemptedAtMs: now,
459
+ delayMs: nextDelayMs(result.outcome, attempt && attempt.kind !== 'inflight' ? attempt.delayMs : 0, {
460
+ baseMs: maxAgeMs,
461
+ maxMs: 10 * MINUTE,
462
+ }),
463
+ kind: result.outcome.ok ? 'ok' : result.outcome.kind,
464
+ });
465
+ return { outcome: result.outcome, snapshot: result.snapshot, skipped: null };
466
+ }
467
+
468
+ module.exports = {
469
+ USAGE_URL,
470
+ BETA,
471
+ DEFAULT_TIMEOUT_MS,
472
+ KEYCHAIN_SERVICE,
473
+ credentialsFile,
474
+ liveFile,
475
+ attemptFile,
476
+ allowedUrl,
477
+ isLoopback,
478
+ writeAtomic,
479
+ userAgent,
480
+ readToken,
481
+ fetchUsage,
482
+ nextDelayMs,
483
+ describe,
484
+ readLive,
485
+ writeLive,
486
+ fetchDisabled,
487
+ refresh,
488
+ refreshIfStale,
489
+ };