@sooneocean/claude-hud 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (210) hide show
  1. package/.claude-plugin/marketplace.json +20 -0
  2. package/.claude-plugin/plugin.json +20 -0
  3. package/LICENSE +21 -0
  4. package/README.md +379 -0
  5. package/commands/configure.md +361 -0
  6. package/commands/export.md +43 -0
  7. package/commands/health.md +61 -0
  8. package/commands/setup.md +287 -0
  9. package/commands/theme.md +31 -0
  10. package/dist/alert.d.ts +31 -0
  11. package/dist/alert.d.ts.map +1 -0
  12. package/dist/alert.js +53 -0
  13. package/dist/alert.js.map +1 -0
  14. package/dist/burn-rate.d.ts +4 -0
  15. package/dist/burn-rate.d.ts.map +1 -0
  16. package/dist/burn-rate.js +36 -0
  17. package/dist/burn-rate.js.map +1 -0
  18. package/dist/cache.d.ts +6 -0
  19. package/dist/cache.d.ts.map +1 -0
  20. package/dist/cache.js +47 -0
  21. package/dist/cache.js.map +1 -0
  22. package/dist/claude-config-dir.d.ts +4 -0
  23. package/dist/claude-config-dir.d.ts.map +1 -0
  24. package/dist/claude-config-dir.js +24 -0
  25. package/dist/claude-config-dir.js.map +1 -0
  26. package/dist/config-io.d.ts +6 -0
  27. package/dist/config-io.d.ts.map +1 -0
  28. package/dist/config-io.js +27 -0
  29. package/dist/config-io.js.map +1 -0
  30. package/dist/config-reader.d.ts +8 -0
  31. package/dist/config-reader.d.ts.map +1 -0
  32. package/dist/config-reader.js +204 -0
  33. package/dist/config-reader.js.map +1 -0
  34. package/dist/config.d.ts +94 -0
  35. package/dist/config.d.ts.map +1 -0
  36. package/dist/config.js +358 -0
  37. package/dist/config.js.map +1 -0
  38. package/dist/constants.d.ts +11 -0
  39. package/dist/constants.d.ts.map +1 -0
  40. package/dist/constants.js +11 -0
  41. package/dist/constants.js.map +1 -0
  42. package/dist/cost-tracker.d.ts +9 -0
  43. package/dist/cost-tracker.d.ts.map +1 -0
  44. package/dist/cost-tracker.js +46 -0
  45. package/dist/cost-tracker.js.map +1 -0
  46. package/dist/debug.d.ts +6 -0
  47. package/dist/debug.d.ts.map +1 -0
  48. package/dist/debug.js +15 -0
  49. package/dist/debug.js.map +1 -0
  50. package/dist/extra-cmd.d.ts +20 -0
  51. package/dist/extra-cmd.d.ts.map +1 -0
  52. package/dist/extra-cmd.js +112 -0
  53. package/dist/extra-cmd.js.map +1 -0
  54. package/dist/git.d.ts +16 -0
  55. package/dist/git.d.ts.map +1 -0
  56. package/dist/git.js +94 -0
  57. package/dist/git.js.map +1 -0
  58. package/dist/health-check.d.ts +12 -0
  59. package/dist/health-check.d.ts.map +1 -0
  60. package/dist/health-check.js +37 -0
  61. package/dist/health-check.js.map +1 -0
  62. package/dist/index.d.ts +24 -0
  63. package/dist/index.d.ts.map +1 -0
  64. package/dist/index.js +198 -0
  65. package/dist/index.js.map +1 -0
  66. package/dist/providers/agent-teams-provider.d.ts +10 -0
  67. package/dist/providers/agent-teams-provider.d.ts.map +1 -0
  68. package/dist/providers/agent-teams-provider.js +57 -0
  69. package/dist/providers/agent-teams-provider.js.map +1 -0
  70. package/dist/providers/agw-provider.d.ts +10 -0
  71. package/dist/providers/agw-provider.d.ts.map +1 -0
  72. package/dist/providers/agw-provider.js +49 -0
  73. package/dist/providers/agw-provider.js.map +1 -0
  74. package/dist/providers/index.d.ts +14 -0
  75. package/dist/providers/index.d.ts.map +1 -0
  76. package/dist/providers/index.js +25 -0
  77. package/dist/providers/index.js.map +1 -0
  78. package/dist/render/agents-line.d.ts +3 -0
  79. package/dist/render/agents-line.d.ts.map +1 -0
  80. package/dist/render/agents-line.js +40 -0
  81. package/dist/render/agents-line.js.map +1 -0
  82. package/dist/render/alert-line.d.ts +3 -0
  83. package/dist/render/alert-line.d.ts.map +1 -0
  84. package/dist/render/alert-line.js +11 -0
  85. package/dist/render/alert-line.js.map +1 -0
  86. package/dist/render/colors.d.ts +39 -0
  87. package/dist/render/colors.d.ts.map +1 -0
  88. package/dist/render/colors.js +109 -0
  89. package/dist/render/colors.js.map +1 -0
  90. package/dist/render/framework-line.d.ts +3 -0
  91. package/dist/render/framework-line.d.ts.map +1 -0
  92. package/dist/render/framework-line.js +32 -0
  93. package/dist/render/framework-line.js.map +1 -0
  94. package/dist/render/index.d.ts +3 -0
  95. package/dist/render/index.d.ts.map +1 -0
  96. package/dist/render/index.js +435 -0
  97. package/dist/render/index.js.map +1 -0
  98. package/dist/render/lines/environment.d.ts +3 -0
  99. package/dist/render/lines/environment.d.ts.map +1 -0
  100. package/dist/render/lines/environment.js +30 -0
  101. package/dist/render/lines/environment.js.map +1 -0
  102. package/dist/render/lines/identity.d.ts +3 -0
  103. package/dist/render/lines/identity.d.ts.map +1 -0
  104. package/dist/render/lines/identity.js +93 -0
  105. package/dist/render/lines/identity.js.map +1 -0
  106. package/dist/render/lines/index.d.ts +5 -0
  107. package/dist/render/lines/index.d.ts.map +1 -0
  108. package/dist/render/lines/index.js +5 -0
  109. package/dist/render/lines/index.js.map +1 -0
  110. package/dist/render/lines/project.d.ts +3 -0
  111. package/dist/render/lines/project.d.ts.map +1 -0
  112. package/dist/render/lines/project.js +100 -0
  113. package/dist/render/lines/project.js.map +1 -0
  114. package/dist/render/lines/usage.d.ts +3 -0
  115. package/dist/render/lines/usage.d.ts.map +1 -0
  116. package/dist/render/lines/usage.js +65 -0
  117. package/dist/render/lines/usage.js.map +1 -0
  118. package/dist/render/session-line.d.ts +7 -0
  119. package/dist/render/session-line.d.ts.map +1 -0
  120. package/dist/render/session-line.js +227 -0
  121. package/dist/render/session-line.js.map +1 -0
  122. package/dist/render/todos-line.d.ts +3 -0
  123. package/dist/render/todos-line.d.ts.map +1 -0
  124. package/dist/render/todos-line.js +29 -0
  125. package/dist/render/todos-line.js.map +1 -0
  126. package/dist/render/tools-line.d.ts +3 -0
  127. package/dist/render/tools-line.d.ts.map +1 -0
  128. package/dist/render/tools-line.js +45 -0
  129. package/dist/render/tools-line.js.map +1 -0
  130. package/dist/session-history.d.ts +15 -0
  131. package/dist/session-history.d.ts.map +1 -0
  132. package/dist/session-history.js +46 -0
  133. package/dist/session-history.js.map +1 -0
  134. package/dist/session-stats.d.ts +11 -0
  135. package/dist/session-stats.d.ts.map +1 -0
  136. package/dist/session-stats.js +48 -0
  137. package/dist/session-stats.js.map +1 -0
  138. package/dist/speed-tracker.d.ts +7 -0
  139. package/dist/speed-tracker.d.ts.map +1 -0
  140. package/dist/speed-tracker.js +34 -0
  141. package/dist/speed-tracker.js.map +1 -0
  142. package/dist/stdin.d.ts +9 -0
  143. package/dist/stdin.d.ts.map +1 -0
  144. package/dist/stdin.js +142 -0
  145. package/dist/stdin.js.map +1 -0
  146. package/dist/themes.d.ts +10 -0
  147. package/dist/themes.d.ts.map +1 -0
  148. package/dist/themes.js +81 -0
  149. package/dist/themes.js.map +1 -0
  150. package/dist/transcript.d.ts +3 -0
  151. package/dist/transcript.d.ts.map +1 -0
  152. package/dist/transcript.js +221 -0
  153. package/dist/transcript.js.map +1 -0
  154. package/dist/types.d.ts +124 -0
  155. package/dist/types.d.ts.map +1 -0
  156. package/dist/types.js +5 -0
  157. package/dist/types.js.map +1 -0
  158. package/dist/usage-api.d.ts +62 -0
  159. package/dist/usage-api.d.ts.map +1 -0
  160. package/dist/usage-api.js +908 -0
  161. package/dist/usage-api.js.map +1 -0
  162. package/dist/utils/format.d.ts +9 -0
  163. package/dist/utils/format.d.ts.map +1 -0
  164. package/dist/utils/format.js +75 -0
  165. package/dist/utils/format.js.map +1 -0
  166. package/dist/utils/terminal.d.ts +5 -0
  167. package/dist/utils/terminal.d.ts.map +1 -0
  168. package/dist/utils/terminal.js +42 -0
  169. package/dist/utils/terminal.js.map +1 -0
  170. package/package.json +36 -0
  171. package/src/alert.ts +75 -0
  172. package/src/burn-rate.ts +45 -0
  173. package/src/cache.ts +57 -0
  174. package/src/claude-config-dir.ts +27 -0
  175. package/src/config-io.ts +26 -0
  176. package/src/config-reader.ts +236 -0
  177. package/src/config.ts +496 -0
  178. package/src/constants.ts +10 -0
  179. package/src/cost-tracker.ts +53 -0
  180. package/src/debug.ts +16 -0
  181. package/src/extra-cmd.ts +125 -0
  182. package/src/git.ts +126 -0
  183. package/src/health-check.ts +50 -0
  184. package/src/index.ts +234 -0
  185. package/src/providers/agent-teams-provider.ts +56 -0
  186. package/src/providers/agw-provider.ts +47 -0
  187. package/src/providers/index.ts +27 -0
  188. package/src/render/agents-line.ts +51 -0
  189. package/src/render/alert-line.ts +11 -0
  190. package/src/render/colors.ts +145 -0
  191. package/src/render/framework-line.ts +34 -0
  192. package/src/render/index.ts +512 -0
  193. package/src/render/lines/environment.ts +41 -0
  194. package/src/render/lines/identity.ts +109 -0
  195. package/src/render/lines/index.ts +4 -0
  196. package/src/render/lines/project.ts +113 -0
  197. package/src/render/lines/usage.ts +79 -0
  198. package/src/render/session-line.ts +253 -0
  199. package/src/render/todos-line.ts +35 -0
  200. package/src/render/tools-line.ts +58 -0
  201. package/src/session-history.ts +62 -0
  202. package/src/session-stats.ts +65 -0
  203. package/src/speed-tracker.ts +51 -0
  204. package/src/stdin.ts +169 -0
  205. package/src/themes.ts +90 -0
  206. package/src/transcript.ts +268 -0
  207. package/src/types.ts +146 -0
  208. package/src/usage-api.ts +1090 -0
  209. package/src/utils/format.ts +79 -0
  210. package/src/utils/terminal.ts +46 -0
@@ -0,0 +1,908 @@
1
+ import * as fs from 'fs';
2
+ import * as path from 'path';
3
+ import * as os from 'os';
4
+ import * as net from 'net';
5
+ import * as tls from 'tls';
6
+ import * as https from 'https';
7
+ import { execFileSync } from 'child_process';
8
+ import { createHash } from 'crypto';
9
+ import { createDebug } from './debug.js';
10
+ import { getClaudeConfigDir, getHudPluginDir } from './claude-config-dir.js';
11
+ const debug = createDebug('usage');
12
+ const LEGACY_KEYCHAIN_SERVICE_NAME = 'Claude Code-credentials';
13
+ // File-based cache (HUD runs as new process each render, so in-memory cache won't persist)
14
+ const CACHE_TTL_MS = 5 * 60_000; // 5 minutes — matches Anthropic usage API rate limit window
15
+ const CACHE_FAILURE_TTL_MS = 15_000; // 15 seconds for failed requests
16
+ const CACHE_RATE_LIMITED_BASE_MS = 60_000; // 60s base for 429 backoff
17
+ const CACHE_RATE_LIMITED_MAX_MS = 5 * 60_000; // 5 min max backoff
18
+ const CACHE_LOCK_STALE_MS = 30_000;
19
+ const CACHE_LOCK_WAIT_MS = 2_000;
20
+ const CACHE_LOCK_POLL_MS = 50;
21
+ const KEYCHAIN_TIMEOUT_MS = 3000;
22
+ const KEYCHAIN_BACKOFF_MS = 60_000; // Backoff on keychain failures to avoid re-prompting
23
+ const USAGE_API_TIMEOUT_MS_DEFAULT = 15_000;
24
+ export const USAGE_API_USER_AGENT = 'claude-code/2.1';
25
+ /**
26
+ * Check if user is using a custom API endpoint instead of the default Anthropic API.
27
+ * When using custom providers (e.g., via cc-switch), the OAuth usage API is not applicable.
28
+ */
29
+ function isUsingCustomApiEndpoint(env = process.env) {
30
+ const baseUrl = env.ANTHROPIC_BASE_URL?.trim() || env.ANTHROPIC_API_BASE_URL?.trim();
31
+ // No custom endpoint configured - using default Anthropic API
32
+ if (!baseUrl) {
33
+ return false;
34
+ }
35
+ try {
36
+ return new URL(baseUrl).origin !== 'https://api.anthropic.com';
37
+ }
38
+ catch {
39
+ return true;
40
+ }
41
+ }
42
+ function getCachePath(homeDir) {
43
+ return path.join(getHudPluginDir(homeDir), '.usage-cache.json');
44
+ }
45
+ function getCacheLockPath(homeDir) {
46
+ return path.join(getHudPluginDir(homeDir), '.usage-cache.lock');
47
+ }
48
+ function hydrateCacheData(data) {
49
+ // JSON.stringify converts Date to ISO string, so we need to reconvert on read.
50
+ // new Date() handles both Date objects and ISO strings safely.
51
+ if (data.fiveHourResetAt) {
52
+ data.fiveHourResetAt = new Date(data.fiveHourResetAt);
53
+ }
54
+ if (data.sevenDayResetAt) {
55
+ data.sevenDayResetAt = new Date(data.sevenDayResetAt);
56
+ }
57
+ return data;
58
+ }
59
+ function getRateLimitedTtlMs(count) {
60
+ // Exponential backoff: 60s, 120s, 240s, capped at 5 min
61
+ return Math.min(CACHE_RATE_LIMITED_BASE_MS * Math.pow(2, Math.max(0, count - 1)), CACHE_RATE_LIMITED_MAX_MS);
62
+ }
63
+ function getRateLimitedRetryUntil(cache) {
64
+ if (cache.data.apiError !== 'rate-limited') {
65
+ return null;
66
+ }
67
+ if (cache.retryAfterUntil && cache.retryAfterUntil > cache.timestamp) {
68
+ return cache.retryAfterUntil;
69
+ }
70
+ if (cache.rateLimitedCount && cache.rateLimitedCount > 0) {
71
+ return cache.timestamp + getRateLimitedTtlMs(cache.rateLimitedCount);
72
+ }
73
+ return null;
74
+ }
75
+ function withRateLimitedSyncing(data) {
76
+ return {
77
+ ...data,
78
+ apiError: 'rate-limited',
79
+ };
80
+ }
81
+ function readUsageCacheState(homeDir, now, ttls) {
82
+ try {
83
+ const cachePath = getCachePath(homeDir);
84
+ if (!fs.existsSync(cachePath))
85
+ return null;
86
+ const content = fs.readFileSync(cachePath, 'utf8');
87
+ const cache = JSON.parse(content);
88
+ // Only serve lastGoodData during rate-limit backoff. Other failures should remain visible.
89
+ const displayData = (cache.data.apiError === 'rate-limited' && cache.lastGoodData)
90
+ ? withRateLimitedSyncing(cache.lastGoodData)
91
+ : cache.data;
92
+ const rateLimitedRetryUntil = getRateLimitedRetryUntil(cache);
93
+ if (rateLimitedRetryUntil && now < rateLimitedRetryUntil) {
94
+ return { data: hydrateCacheData(displayData), timestamp: cache.timestamp, isFresh: true };
95
+ }
96
+ const ttl = cache.data.apiUnavailable ? ttls.failureCacheTtlMs : ttls.cacheTtlMs;
97
+ return {
98
+ data: hydrateCacheData(displayData),
99
+ timestamp: cache.timestamp,
100
+ isFresh: now - cache.timestamp < ttl,
101
+ };
102
+ }
103
+ catch {
104
+ return null;
105
+ }
106
+ }
107
+ function readRateLimitedCount(homeDir) {
108
+ try {
109
+ const cachePath = getCachePath(homeDir);
110
+ if (!fs.existsSync(cachePath))
111
+ return 0;
112
+ const content = fs.readFileSync(cachePath, 'utf8');
113
+ const cache = JSON.parse(content);
114
+ return cache.rateLimitedCount ?? 0;
115
+ }
116
+ catch {
117
+ return 0;
118
+ }
119
+ }
120
+ function readLastGoodData(homeDir) {
121
+ try {
122
+ const cachePath = getCachePath(homeDir);
123
+ if (!fs.existsSync(cachePath))
124
+ return null;
125
+ const content = fs.readFileSync(cachePath, 'utf8');
126
+ const cache = JSON.parse(content);
127
+ return cache.lastGoodData ? hydrateCacheData(cache.lastGoodData) : null;
128
+ }
129
+ catch {
130
+ return null;
131
+ }
132
+ }
133
+ function readUsageCache(homeDir, now, ttls) {
134
+ const cache = readUsageCacheState(homeDir, now, ttls);
135
+ return cache?.isFresh ? cache.data : null;
136
+ }
137
+ function writeUsageCache(homeDir, data, timestamp, opts) {
138
+ try {
139
+ const cachePath = getCachePath(homeDir);
140
+ const cacheDir = path.dirname(cachePath);
141
+ if (!fs.existsSync(cacheDir)) {
142
+ fs.mkdirSync(cacheDir, { recursive: true });
143
+ }
144
+ const cache = { data, timestamp };
145
+ if (opts?.rateLimitedCount && opts.rateLimitedCount > 0) {
146
+ cache.rateLimitedCount = opts.rateLimitedCount;
147
+ }
148
+ if (opts?.retryAfterUntil) {
149
+ cache.retryAfterUntil = opts.retryAfterUntil;
150
+ }
151
+ if (opts?.lastGoodData) {
152
+ cache.lastGoodData = opts.lastGoodData;
153
+ }
154
+ fs.writeFileSync(cachePath, JSON.stringify(cache), 'utf8');
155
+ }
156
+ catch {
157
+ // Ignore cache write failures
158
+ }
159
+ }
160
+ function readLockTimestamp(lockPath) {
161
+ try {
162
+ if (!fs.existsSync(lockPath))
163
+ return null;
164
+ const raw = fs.readFileSync(lockPath, 'utf8').trim();
165
+ const parsed = Number.parseInt(raw, 10);
166
+ return Number.isFinite(parsed) ? parsed : null;
167
+ }
168
+ catch {
169
+ return null;
170
+ }
171
+ }
172
+ function tryAcquireCacheLock(homeDir) {
173
+ const lockPath = getCacheLockPath(homeDir);
174
+ const cacheDir = path.dirname(lockPath);
175
+ try {
176
+ if (!fs.existsSync(cacheDir)) {
177
+ fs.mkdirSync(cacheDir, { recursive: true });
178
+ }
179
+ const fd = fs.openSync(lockPath, 'wx');
180
+ try {
181
+ fs.writeFileSync(fd, String(Date.now()), 'utf8');
182
+ }
183
+ finally {
184
+ fs.closeSync(fd);
185
+ }
186
+ return 'acquired';
187
+ }
188
+ catch (error) {
189
+ const maybeError = error;
190
+ if (maybeError.code !== 'EEXIST') {
191
+ debug('Usage cache lock unavailable, continuing without coordination:', maybeError.message);
192
+ return 'unsupported';
193
+ }
194
+ }
195
+ const lockTimestamp = readLockTimestamp(lockPath);
196
+ // Unparseable timestamp — use mtime to distinguish a crash leftover from an active writer.
197
+ if (lockTimestamp === null) {
198
+ try {
199
+ const lockStat = fs.statSync(lockPath);
200
+ if (Date.now() - lockStat.mtimeMs < CACHE_LOCK_STALE_MS) {
201
+ return 'busy';
202
+ }
203
+ }
204
+ catch {
205
+ return tryAcquireCacheLock(homeDir);
206
+ }
207
+ try {
208
+ fs.unlinkSync(lockPath);
209
+ }
210
+ catch {
211
+ return 'busy';
212
+ }
213
+ return tryAcquireCacheLock(homeDir);
214
+ }
215
+ if (lockTimestamp != null && Date.now() - lockTimestamp > CACHE_LOCK_STALE_MS) {
216
+ try {
217
+ fs.unlinkSync(lockPath);
218
+ }
219
+ catch {
220
+ return 'busy';
221
+ }
222
+ return tryAcquireCacheLock(homeDir);
223
+ }
224
+ return 'busy';
225
+ }
226
+ function releaseCacheLock(homeDir) {
227
+ try {
228
+ const lockPath = getCacheLockPath(homeDir);
229
+ if (fs.existsSync(lockPath)) {
230
+ fs.unlinkSync(lockPath);
231
+ }
232
+ }
233
+ catch {
234
+ // Ignore lock cleanup failures
235
+ }
236
+ }
237
+ async function waitForFreshCache(homeDir, now, ttls, timeoutMs = CACHE_LOCK_WAIT_MS) {
238
+ const deadline = Date.now() + timeoutMs;
239
+ while (Date.now() < deadline) {
240
+ await new Promise((resolve) => setTimeout(resolve, CACHE_LOCK_POLL_MS));
241
+ const cached = readUsageCache(homeDir, now(), ttls);
242
+ if (cached) {
243
+ return cached;
244
+ }
245
+ if (!fs.existsSync(getCacheLockPath(homeDir))) {
246
+ break;
247
+ }
248
+ }
249
+ return readUsageCache(homeDir, now(), ttls);
250
+ }
251
+ const defaultDeps = {
252
+ homeDir: () => os.homedir(),
253
+ fetchApi: fetchUsageApi,
254
+ now: () => Date.now(),
255
+ readKeychain: readKeychainCredentials,
256
+ ttls: { cacheTtlMs: CACHE_TTL_MS, failureCacheTtlMs: CACHE_FAILURE_TTL_MS },
257
+ };
258
+ /**
259
+ * Get OAuth usage data from Anthropic API.
260
+ * Returns null if user is an API user (no OAuth credentials) or credentials are expired.
261
+ * Returns { apiUnavailable: true, ... } if API call fails (to show warning in HUD).
262
+ *
263
+ * Uses file-based cache since HUD runs as a new process each render (~300ms).
264
+ * Cache TTL is configurable via usage.cacheTtlSeconds / usage.failureCacheTtlSeconds in config.json
265
+ * (defaults: 60s for success, 15s for failures).
266
+ */
267
+ export async function getUsage(overrides = {}) {
268
+ const deps = { ...defaultDeps, ...overrides };
269
+ const now = deps.now();
270
+ const homeDir = deps.homeDir();
271
+ // Skip usage API if user is using a custom provider
272
+ if (isUsingCustomApiEndpoint()) {
273
+ debug('Skipping usage API: custom API endpoint configured');
274
+ return null;
275
+ }
276
+ // Check file-based cache first
277
+ const cacheState = readUsageCacheState(homeDir, now, deps.ttls);
278
+ if (cacheState?.isFresh) {
279
+ return cacheState.data;
280
+ }
281
+ let holdsCacheLock = false;
282
+ const lockStatus = tryAcquireCacheLock(homeDir);
283
+ if (lockStatus === 'busy') {
284
+ if (cacheState) {
285
+ return cacheState.data;
286
+ }
287
+ return await waitForFreshCache(homeDir, deps.now, deps.ttls);
288
+ }
289
+ holdsCacheLock = lockStatus === 'acquired';
290
+ try {
291
+ const refreshedCache = readUsageCache(homeDir, deps.now(), deps.ttls);
292
+ if (refreshedCache) {
293
+ return refreshedCache;
294
+ }
295
+ const credentials = readCredentials(homeDir, now, deps.readKeychain);
296
+ if (!credentials) {
297
+ return null;
298
+ }
299
+ const { accessToken, subscriptionType } = credentials;
300
+ // Determine plan name from subscriptionType
301
+ const planName = getPlanName(subscriptionType);
302
+ if (!planName) {
303
+ // API user, no usage limits to show
304
+ return null;
305
+ }
306
+ // Fetch usage from API
307
+ const apiResult = await deps.fetchApi(accessToken);
308
+ if (!apiResult.data) {
309
+ const isRateLimited = apiResult.error === 'rate-limited';
310
+ const prevCount = readRateLimitedCount(homeDir);
311
+ const rateLimitedCount = isRateLimited ? prevCount + 1 : 0;
312
+ const retryAfterUntil = isRateLimited && apiResult.retryAfterSec
313
+ ? now + apiResult.retryAfterSec * 1000
314
+ : undefined;
315
+ const backoffOpts = {
316
+ rateLimitedCount: isRateLimited ? rateLimitedCount : undefined,
317
+ retryAfterUntil,
318
+ };
319
+ const failureResult = {
320
+ planName,
321
+ fiveHour: null,
322
+ sevenDay: null,
323
+ fiveHourResetAt: null,
324
+ sevenDayResetAt: null,
325
+ apiUnavailable: true,
326
+ apiError: apiResult.error,
327
+ };
328
+ if (isRateLimited) {
329
+ const staleCache = readUsageCacheState(homeDir, now, deps.ttls);
330
+ const lastGood = readLastGoodData(homeDir);
331
+ const goodData = (staleCache && !staleCache.data.apiUnavailable)
332
+ ? staleCache.data
333
+ : lastGood;
334
+ if (goodData) {
335
+ // Preserve the backoff state in cache, but keep rendering the last successful values
336
+ // with a syncing hint so stale data is visible to the user.
337
+ writeUsageCache(homeDir, failureResult, now, { ...backoffOpts, lastGoodData: goodData });
338
+ return withRateLimitedSyncing(goodData);
339
+ }
340
+ }
341
+ writeUsageCache(homeDir, failureResult, now, backoffOpts);
342
+ return failureResult;
343
+ }
344
+ // Parse response - API returns 0-100 percentage directly
345
+ // Clamp to 0-100 and handle NaN/Infinity
346
+ const fiveHour = parseUtilization(apiResult.data.five_hour?.utilization);
347
+ const sevenDay = parseUtilization(apiResult.data.seven_day?.utilization);
348
+ const fiveHourResetAt = parseDate(apiResult.data.five_hour?.resets_at);
349
+ const sevenDayResetAt = parseDate(apiResult.data.seven_day?.resets_at);
350
+ const result = {
351
+ planName,
352
+ fiveHour,
353
+ sevenDay,
354
+ fiveHourResetAt,
355
+ sevenDayResetAt,
356
+ };
357
+ // Write to file cache — also store as lastGoodData for rate-limit resilience
358
+ writeUsageCache(homeDir, result, now, { lastGoodData: result });
359
+ return result;
360
+ }
361
+ catch (error) {
362
+ debug('getUsage failed:', error);
363
+ return null;
364
+ }
365
+ finally {
366
+ if (holdsCacheLock) {
367
+ releaseCacheLock(homeDir);
368
+ }
369
+ }
370
+ }
371
+ /**
372
+ * Get path for keychain failure backoff cache.
373
+ * Separate from usage cache to track keychain-specific failures.
374
+ */
375
+ function getKeychainBackoffPath(homeDir) {
376
+ return path.join(getHudPluginDir(homeDir), '.keychain-backoff');
377
+ }
378
+ /**
379
+ * Check if we're in keychain backoff period (recent failure/timeout).
380
+ * Prevents re-prompting user on every render cycle.
381
+ */
382
+ function isKeychainBackoff(homeDir, now) {
383
+ try {
384
+ const backoffPath = getKeychainBackoffPath(homeDir);
385
+ if (!fs.existsSync(backoffPath))
386
+ return false;
387
+ const timestamp = parseInt(fs.readFileSync(backoffPath, 'utf8'), 10);
388
+ return now - timestamp < KEYCHAIN_BACKOFF_MS;
389
+ }
390
+ catch {
391
+ return false;
392
+ }
393
+ }
394
+ /**
395
+ * Record keychain failure for backoff.
396
+ */
397
+ function recordKeychainFailure(homeDir, now) {
398
+ try {
399
+ const backoffPath = getKeychainBackoffPath(homeDir);
400
+ const dir = path.dirname(backoffPath);
401
+ if (!fs.existsSync(dir)) {
402
+ fs.mkdirSync(dir, { recursive: true });
403
+ }
404
+ fs.writeFileSync(backoffPath, String(now), 'utf8');
405
+ }
406
+ catch {
407
+ // Ignore write failures
408
+ }
409
+ }
410
+ /**
411
+ * Determine the macOS Keychain service name for Claude Code credentials.
412
+ * Claude Code uses the default service for ~/.claude and a hashed suffix for custom config directories.
413
+ */
414
+ export function getKeychainServiceName(configDir, homeDir) {
415
+ const normalizedConfigDir = path.normalize(path.resolve(configDir));
416
+ const normalizedDefaultDir = path.normalize(path.resolve(path.join(homeDir, '.claude')));
417
+ if (normalizedConfigDir === normalizedDefaultDir) {
418
+ return LEGACY_KEYCHAIN_SERVICE_NAME;
419
+ }
420
+ const hash = createHash('sha256').update(normalizedConfigDir).digest('hex').slice(0, 8);
421
+ return `${LEGACY_KEYCHAIN_SERVICE_NAME}-${hash}`;
422
+ }
423
+ export function getKeychainServiceNames(configDir, homeDir, env = process.env) {
424
+ const serviceNames = [getKeychainServiceName(configDir, homeDir)];
425
+ const envConfigDir = env.CLAUDE_CONFIG_DIR?.trim();
426
+ if (envConfigDir) {
427
+ const normalizedDefaultDir = path.normalize(path.resolve(path.join(homeDir, '.claude')));
428
+ const normalizedEnvDir = path.normalize(path.resolve(envConfigDir));
429
+ if (normalizedEnvDir === normalizedDefaultDir) {
430
+ serviceNames.push(LEGACY_KEYCHAIN_SERVICE_NAME);
431
+ }
432
+ else {
433
+ const envHash = createHash('sha256').update(envConfigDir).digest('hex').slice(0, 8);
434
+ serviceNames.push(`${LEGACY_KEYCHAIN_SERVICE_NAME}-${envHash}`);
435
+ }
436
+ }
437
+ serviceNames.push(LEGACY_KEYCHAIN_SERVICE_NAME);
438
+ return [...new Set(serviceNames)];
439
+ }
440
+ function isMissingKeychainItemError(error) {
441
+ if (!error || typeof error !== 'object')
442
+ return false;
443
+ const maybeError = error;
444
+ if (maybeError.status === 44)
445
+ return true;
446
+ const message = typeof maybeError.message === 'string' ? maybeError.message.toLowerCase() : '';
447
+ if (message.includes('could not be found in the keychain'))
448
+ return true;
449
+ const stderr = typeof maybeError.stderr === 'string'
450
+ ? maybeError.stderr.toLowerCase()
451
+ : Buffer.isBuffer(maybeError.stderr)
452
+ ? maybeError.stderr.toString('utf8').toLowerCase()
453
+ : '';
454
+ return stderr.includes('could not be found in the keychain');
455
+ }
456
+ export function resolveKeychainCredentials(serviceNames, now, loadService, accountName) {
457
+ let shouldBackoff = false;
458
+ let allowGenericFallback = Boolean(accountName);
459
+ for (const serviceName of serviceNames) {
460
+ try {
461
+ const keychainData = accountName
462
+ ? loadService(serviceName, accountName)
463
+ : loadService(serviceName);
464
+ if (accountName)
465
+ allowGenericFallback = false;
466
+ const trimmedKeychainData = keychainData.trim();
467
+ if (!trimmedKeychainData)
468
+ continue;
469
+ const data = JSON.parse(trimmedKeychainData);
470
+ const credentials = parseCredentialsData(data, now);
471
+ if (credentials) {
472
+ return { credentials, shouldBackoff: false };
473
+ }
474
+ }
475
+ catch (error) {
476
+ if (!isMissingKeychainItemError(error)) {
477
+ if (accountName)
478
+ allowGenericFallback = false;
479
+ shouldBackoff = true;
480
+ }
481
+ }
482
+ }
483
+ if (!accountName || !allowGenericFallback) {
484
+ return { credentials: null, shouldBackoff };
485
+ }
486
+ for (const serviceName of serviceNames) {
487
+ try {
488
+ const keychainData = loadService(serviceName).trim();
489
+ if (!keychainData)
490
+ continue;
491
+ const data = JSON.parse(keychainData);
492
+ const credentials = parseCredentialsData(data, now);
493
+ if (credentials) {
494
+ return { credentials, shouldBackoff: false };
495
+ }
496
+ }
497
+ catch (error) {
498
+ if (!isMissingKeychainItemError(error)) {
499
+ shouldBackoff = true;
500
+ }
501
+ }
502
+ }
503
+ return { credentials: null, shouldBackoff };
504
+ }
505
+ function getKeychainAccountName() {
506
+ try {
507
+ const username = os.userInfo().username.trim();
508
+ return username || null;
509
+ }
510
+ catch {
511
+ return null;
512
+ }
513
+ }
514
+ /**
515
+ * Read credentials from macOS Keychain.
516
+ * Claude Code stores OAuth credentials in the macOS Keychain with profile-specific service names.
517
+ * Returns null if not on macOS or credentials not found.
518
+ *
519
+ * Security: Uses execFileSync with absolute path to avoid shell injection and PATH hijacking.
520
+ */
521
+ function readKeychainCredentials(now, homeDir) {
522
+ // Only available on macOS
523
+ if (process.platform !== 'darwin') {
524
+ return null;
525
+ }
526
+ // Check backoff to avoid re-prompting on every render after a failure
527
+ if (isKeychainBackoff(homeDir, now)) {
528
+ debug('Keychain in backoff period, skipping');
529
+ return null;
530
+ }
531
+ try {
532
+ const configDir = getClaudeConfigDir(homeDir);
533
+ const serviceNames = getKeychainServiceNames(configDir, homeDir);
534
+ const accountName = getKeychainAccountName();
535
+ debug('Trying keychain service names:', serviceNames);
536
+ if (accountName) {
537
+ debug('Trying keychain account name:', accountName);
538
+ }
539
+ const resolved = resolveKeychainCredentials(serviceNames, now, (serviceName, lookupAccountName) => execFileSync('/usr/bin/security', lookupAccountName
540
+ ? ['find-generic-password', '-s', serviceName, '-a', lookupAccountName, '-w']
541
+ : ['find-generic-password', '-s', serviceName, '-w'], { encoding: 'utf8', stdio: ['pipe', 'pipe', 'pipe'], timeout: KEYCHAIN_TIMEOUT_MS }), accountName);
542
+ if (resolved.credentials) {
543
+ return resolved.credentials;
544
+ }
545
+ if (resolved.shouldBackoff) {
546
+ recordKeychainFailure(homeDir, now);
547
+ }
548
+ return null;
549
+ }
550
+ catch (error) {
551
+ // Security: Only log error message, not full error object (may contain stdout/stderr with tokens)
552
+ const message = error instanceof Error ? error.message : 'unknown error';
553
+ debug('Failed to read from macOS Keychain:', message);
554
+ // Record failure for backoff to avoid re-prompting
555
+ recordKeychainFailure(homeDir, now);
556
+ return null;
557
+ }
558
+ }
559
+ /**
560
+ * Read credentials from file (legacy method).
561
+ * Older versions of Claude Code stored credentials in {CLAUDE_CONFIG_DIR}/.credentials.json.
562
+ */
563
+ function readFileCredentials(homeDir, now) {
564
+ const credentialsPath = path.join(getClaudeConfigDir(homeDir), '.credentials.json');
565
+ if (!fs.existsSync(credentialsPath)) {
566
+ return null;
567
+ }
568
+ try {
569
+ const content = fs.readFileSync(credentialsPath, 'utf8');
570
+ const data = JSON.parse(content);
571
+ return parseCredentialsData(data, now);
572
+ }
573
+ catch (error) {
574
+ debug('Failed to read credentials file:', error);
575
+ return null;
576
+ }
577
+ }
578
+ function readFileSubscriptionType(homeDir) {
579
+ const credentialsPath = path.join(getClaudeConfigDir(homeDir), '.credentials.json');
580
+ if (!fs.existsSync(credentialsPath)) {
581
+ return null;
582
+ }
583
+ try {
584
+ const content = fs.readFileSync(credentialsPath, 'utf8');
585
+ const data = JSON.parse(content);
586
+ const subscriptionType = data.claudeAiOauth?.subscriptionType;
587
+ const normalizedSubscriptionType = typeof subscriptionType === 'string'
588
+ ? subscriptionType.trim()
589
+ : '';
590
+ if (!normalizedSubscriptionType) {
591
+ return null;
592
+ }
593
+ return normalizedSubscriptionType;
594
+ }
595
+ catch (error) {
596
+ debug('Failed to read file subscriptionType:', error);
597
+ return null;
598
+ }
599
+ }
600
+ /**
601
+ * Parse and validate credentials data from either Keychain or file.
602
+ */
603
+ function parseCredentialsData(data, now) {
604
+ const accessToken = data.claudeAiOauth?.accessToken;
605
+ const subscriptionType = data.claudeAiOauth?.subscriptionType ?? '';
606
+ if (!accessToken) {
607
+ return null;
608
+ }
609
+ // Check if token is expired (expiresAt is Unix ms timestamp)
610
+ // Use != null to handle expiresAt=0 correctly (would be expired)
611
+ const expiresAt = data.claudeAiOauth?.expiresAt;
612
+ if (expiresAt != null && expiresAt <= now) {
613
+ debug('OAuth token expired');
614
+ return null;
615
+ }
616
+ return { accessToken, subscriptionType };
617
+ }
618
+ /**
619
+ * Read OAuth credentials, trying macOS Keychain first (Claude Code 2.x),
620
+ * then falling back to file-based credentials (older versions).
621
+ *
622
+ * Token priority: Keychain token is authoritative (Claude Code 2.x stores current token there).
623
+ * SubscriptionType: Can be supplemented from file if keychain lacks it (display-only field).
624
+ */
625
+ function readCredentials(homeDir, now, readKeychain) {
626
+ // Try macOS Keychain first (Claude Code 2.x)
627
+ const keychainCreds = readKeychain(now, homeDir);
628
+ if (keychainCreds) {
629
+ if (keychainCreds.subscriptionType) {
630
+ debug('Using credentials from macOS Keychain');
631
+ return keychainCreds;
632
+ }
633
+ // Keychain has token but no subscriptionType - try to supplement from file
634
+ const fileSubscriptionType = readFileSubscriptionType(homeDir);
635
+ if (fileSubscriptionType) {
636
+ debug('Using keychain token with file subscriptionType');
637
+ return {
638
+ accessToken: keychainCreds.accessToken,
639
+ subscriptionType: fileSubscriptionType,
640
+ };
641
+ }
642
+ // No subscriptionType available - use keychain token anyway
643
+ debug('Using keychain token without subscriptionType');
644
+ return keychainCreds;
645
+ }
646
+ // Fall back to file-based credentials (older versions or non-macOS)
647
+ const fileCreds = readFileCredentials(homeDir, now);
648
+ if (fileCreds) {
649
+ debug('Using credentials from file');
650
+ return fileCreds;
651
+ }
652
+ return null;
653
+ }
654
+ function getPlanName(subscriptionType) {
655
+ const lower = subscriptionType.toLowerCase();
656
+ if (lower.includes('max'))
657
+ return 'Max';
658
+ if (lower.includes('pro'))
659
+ return 'Pro';
660
+ if (lower.includes('team'))
661
+ return 'Team';
662
+ // API users don't have subscriptionType or have 'api'
663
+ if (!subscriptionType || lower.includes('api'))
664
+ return null;
665
+ // Unknown subscription type - show it capitalized
666
+ return subscriptionType.charAt(0).toUpperCase() + subscriptionType.slice(1);
667
+ }
668
+ /** Parse utilization value, clamping to 0-100 and handling NaN/Infinity */
669
+ function parseUtilization(value) {
670
+ if (value == null)
671
+ return null;
672
+ if (!Number.isFinite(value))
673
+ return null; // Handles NaN and Infinity
674
+ return Math.round(Math.max(0, Math.min(100, value)));
675
+ }
676
+ /** Parse ISO date string safely, returning null for invalid dates */
677
+ function parseDate(dateStr) {
678
+ if (!dateStr)
679
+ return null;
680
+ const date = new Date(dateStr);
681
+ // Check for Invalid Date
682
+ if (isNaN(date.getTime())) {
683
+ debug('Invalid date string:', dateStr);
684
+ return null;
685
+ }
686
+ return date;
687
+ }
688
+ export function getUsageApiTimeoutMs(env = process.env) {
689
+ const raw = env.CLAUDE_HUD_USAGE_TIMEOUT_MS?.trim();
690
+ if (!raw)
691
+ return USAGE_API_TIMEOUT_MS_DEFAULT;
692
+ const parsed = Number(raw);
693
+ if (!Number.isInteger(parsed) || parsed <= 0) {
694
+ debug('Invalid CLAUDE_HUD_USAGE_TIMEOUT_MS value:', raw);
695
+ return USAGE_API_TIMEOUT_MS_DEFAULT;
696
+ }
697
+ return parsed;
698
+ }
699
+ export function isNoProxy(hostname, env = process.env) {
700
+ const noProxy = env.NO_PROXY ?? env.no_proxy;
701
+ if (!noProxy)
702
+ return false;
703
+ const host = hostname.toLowerCase();
704
+ return noProxy.split(',').some((entry) => {
705
+ const pattern = entry.trim().toLowerCase();
706
+ if (!pattern)
707
+ return false;
708
+ if (pattern === '*')
709
+ return true;
710
+ if (host === pattern)
711
+ return true;
712
+ const suffix = pattern.startsWith('.') ? pattern : `.${pattern}`;
713
+ return host.endsWith(suffix);
714
+ });
715
+ }
716
+ export function getProxyUrl(hostname, env = process.env) {
717
+ if (isNoProxy(hostname, env)) {
718
+ debug('Proxy bypassed by NO_PROXY for host:', hostname);
719
+ return null;
720
+ }
721
+ const proxyEnv = env.HTTPS_PROXY
722
+ ?? env.https_proxy
723
+ ?? env.ALL_PROXY
724
+ ?? env.all_proxy
725
+ ?? env.HTTP_PROXY
726
+ ?? env.http_proxy;
727
+ if (!proxyEnv)
728
+ return null;
729
+ try {
730
+ const proxyUrl = new URL(proxyEnv);
731
+ if (proxyUrl.protocol !== 'http:' && proxyUrl.protocol !== 'https:') {
732
+ debug('Unsupported proxy protocol:', proxyUrl.protocol);
733
+ return null;
734
+ }
735
+ return proxyUrl;
736
+ }
737
+ catch {
738
+ debug('Invalid proxy URL:', proxyEnv);
739
+ return null;
740
+ }
741
+ }
742
+ function createProxyTunnelAgent(proxyUrl) {
743
+ const proxyHost = proxyUrl.hostname;
744
+ const proxyPort = Number.parseInt(proxyUrl.port || (proxyUrl.protocol === 'https:' ? '443' : '80'), 10);
745
+ const proxyAuth = proxyUrl.username
746
+ ? `Basic ${Buffer.from(`${decodeURIComponent(proxyUrl.username)}:${decodeURIComponent(proxyUrl.password || '')}`).toString('base64')}`
747
+ : null;
748
+ return new class extends https.Agent {
749
+ createConnection(options, callback) {
750
+ const targetHost = String(options.host ?? options.hostname ?? 'localhost');
751
+ const targetPort = Number(options.port) || 443;
752
+ let settled = false;
753
+ const settle = (err, socket) => {
754
+ if (settled)
755
+ return;
756
+ settled = true;
757
+ callback?.(err, socket);
758
+ };
759
+ const proxySocket = proxyUrl.protocol === 'https:'
760
+ ? tls.connect({ host: proxyHost, port: proxyPort, servername: proxyHost })
761
+ : net.connect(proxyPort, proxyHost);
762
+ proxySocket.once('error', (error) => {
763
+ settle(error, proxySocket);
764
+ });
765
+ proxySocket.once('connect', () => {
766
+ const connectHeaders = [
767
+ `CONNECT ${targetHost}:${targetPort} HTTP/1.1`,
768
+ `Host: ${targetHost}:${targetPort}`,
769
+ ];
770
+ if (proxyAuth) {
771
+ connectHeaders.push(`Proxy-Authorization: ${proxyAuth}`);
772
+ }
773
+ connectHeaders.push('', '');
774
+ proxySocket.write(connectHeaders.join('\r\n'));
775
+ let responseBuffer = Buffer.alloc(0);
776
+ const onData = (chunk) => {
777
+ responseBuffer = Buffer.concat([responseBuffer, chunk]);
778
+ const headerEndIndex = responseBuffer.indexOf('\r\n\r\n');
779
+ if (headerEndIndex === -1)
780
+ return;
781
+ proxySocket.removeListener('data', onData);
782
+ const headerText = responseBuffer.subarray(0, headerEndIndex).toString('utf8');
783
+ const statusLine = headerText.split('\r\n')[0] ?? '';
784
+ if (!/^HTTP\/1\.[01] 200 /.test(statusLine)) {
785
+ const error = new Error(`Proxy CONNECT rejected: ${statusLine || 'unknown status'}`);
786
+ proxySocket.destroy(error);
787
+ settle(error, proxySocket);
788
+ return;
789
+ }
790
+ const tlsSocket = tls.connect({
791
+ socket: proxySocket,
792
+ servername: String(options.servername ?? targetHost),
793
+ rejectUnauthorized: options.rejectUnauthorized !== false,
794
+ }, () => {
795
+ settle(null, tlsSocket);
796
+ });
797
+ tlsSocket.once('error', (error) => {
798
+ settle(error, tlsSocket);
799
+ });
800
+ };
801
+ proxySocket.on('data', onData);
802
+ });
803
+ // Must not return the socket here. In Node.js _http_agent.js, createSocket()
804
+ // calls: `if (newSocket) oncreate(null, newSocket)` — returning a truthy value
805
+ // causes the HTTP request to be written to the raw proxy socket immediately,
806
+ // before the CONNECT tunnel is established. Only deliver the final TLS socket
807
+ // asynchronously via the callback after the CONNECT handshake succeeds.
808
+ return undefined;
809
+ }
810
+ }();
811
+ }
812
+ function fetchUsageApi(accessToken) {
813
+ return new Promise((resolve) => {
814
+ const host = 'api.anthropic.com';
815
+ const timeoutMs = getUsageApiTimeoutMs();
816
+ const proxyUrl = getProxyUrl(host);
817
+ const options = {
818
+ hostname: host,
819
+ path: '/api/oauth/usage',
820
+ method: 'GET',
821
+ headers: {
822
+ 'Authorization': `Bearer ${accessToken}`,
823
+ 'anthropic-beta': 'oauth-2025-04-20',
824
+ 'User-Agent': USAGE_API_USER_AGENT,
825
+ },
826
+ timeout: timeoutMs,
827
+ agent: proxyUrl ? createProxyTunnelAgent(proxyUrl) : undefined,
828
+ };
829
+ if (proxyUrl) {
830
+ debug('Using proxy for usage API:', proxyUrl.origin);
831
+ }
832
+ const req = https.request(options, (res) => {
833
+ let data = '';
834
+ res.on('data', (chunk) => {
835
+ data += chunk.toString();
836
+ });
837
+ res.on('end', () => {
838
+ if (res.statusCode !== 200) {
839
+ debug('API returned non-200 status:', res.statusCode);
840
+ // Use a distinct error key for 429 so cache/render can handle it specially
841
+ const error = res.statusCode === 429
842
+ ? 'rate-limited'
843
+ : res.statusCode ? `http-${res.statusCode}` : 'http-error';
844
+ const retryAfterSec = res.statusCode === 429
845
+ ? parseRetryAfterSeconds(res.headers['retry-after'])
846
+ : undefined;
847
+ if (retryAfterSec) {
848
+ debug('Retry-After:', retryAfterSec, 'seconds');
849
+ }
850
+ resolve({ data: null, error, retryAfterSec });
851
+ return;
852
+ }
853
+ try {
854
+ const parsed = JSON.parse(data);
855
+ resolve({ data: parsed });
856
+ }
857
+ catch (error) {
858
+ debug('Failed to parse API response:', error);
859
+ resolve({ data: null, error: 'parse' });
860
+ }
861
+ });
862
+ });
863
+ req.on('error', (error) => {
864
+ debug('API request error:', error);
865
+ resolve({ data: null, error: 'network' });
866
+ });
867
+ req.on('timeout', () => {
868
+ debug('API request timeout');
869
+ req.destroy();
870
+ resolve({ data: null, error: 'timeout' });
871
+ });
872
+ req.end();
873
+ });
874
+ }
875
+ export function parseRetryAfterSeconds(raw, nowMs = Date.now()) {
876
+ const value = Array.isArray(raw) ? raw[0] : raw;
877
+ if (!value)
878
+ return undefined;
879
+ const parsedSeconds = Number.parseInt(value, 10);
880
+ if (Number.isFinite(parsedSeconds) && parsedSeconds > 0) {
881
+ return parsedSeconds;
882
+ }
883
+ const retryAtMs = Date.parse(value);
884
+ if (!Number.isFinite(retryAtMs)) {
885
+ return undefined;
886
+ }
887
+ const retryAfterSeconds = Math.ceil((retryAtMs - nowMs) / 1000);
888
+ return retryAfterSeconds > 0 ? retryAfterSeconds : undefined;
889
+ }
890
+ // Export for testing
891
+ export function clearCache(homeDir) {
892
+ if (homeDir) {
893
+ try {
894
+ const cachePath = getCachePath(homeDir);
895
+ if (fs.existsSync(cachePath)) {
896
+ fs.unlinkSync(cachePath);
897
+ }
898
+ const lockPath = getCacheLockPath(homeDir);
899
+ if (fs.existsSync(lockPath)) {
900
+ fs.unlinkSync(lockPath);
901
+ }
902
+ }
903
+ catch {
904
+ // Ignore
905
+ }
906
+ }
907
+ }
908
+ //# sourceMappingURL=usage-api.js.map