@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.
- package/.claude-plugin/marketplace.json +20 -0
- package/.claude-plugin/plugin.json +20 -0
- package/LICENSE +21 -0
- package/README.md +379 -0
- package/commands/configure.md +361 -0
- package/commands/export.md +43 -0
- package/commands/health.md +61 -0
- package/commands/setup.md +287 -0
- package/commands/theme.md +31 -0
- package/dist/alert.d.ts +31 -0
- package/dist/alert.d.ts.map +1 -0
- package/dist/alert.js +53 -0
- package/dist/alert.js.map +1 -0
- package/dist/burn-rate.d.ts +4 -0
- package/dist/burn-rate.d.ts.map +1 -0
- package/dist/burn-rate.js +36 -0
- package/dist/burn-rate.js.map +1 -0
- package/dist/cache.d.ts +6 -0
- package/dist/cache.d.ts.map +1 -0
- package/dist/cache.js +47 -0
- package/dist/cache.js.map +1 -0
- package/dist/claude-config-dir.d.ts +4 -0
- package/dist/claude-config-dir.d.ts.map +1 -0
- package/dist/claude-config-dir.js +24 -0
- package/dist/claude-config-dir.js.map +1 -0
- package/dist/config-io.d.ts +6 -0
- package/dist/config-io.d.ts.map +1 -0
- package/dist/config-io.js +27 -0
- package/dist/config-io.js.map +1 -0
- package/dist/config-reader.d.ts +8 -0
- package/dist/config-reader.d.ts.map +1 -0
- package/dist/config-reader.js +204 -0
- package/dist/config-reader.js.map +1 -0
- package/dist/config.d.ts +94 -0
- package/dist/config.d.ts.map +1 -0
- package/dist/config.js +358 -0
- package/dist/config.js.map +1 -0
- package/dist/constants.d.ts +11 -0
- package/dist/constants.d.ts.map +1 -0
- package/dist/constants.js +11 -0
- package/dist/constants.js.map +1 -0
- package/dist/cost-tracker.d.ts +9 -0
- package/dist/cost-tracker.d.ts.map +1 -0
- package/dist/cost-tracker.js +46 -0
- package/dist/cost-tracker.js.map +1 -0
- package/dist/debug.d.ts +6 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +15 -0
- package/dist/debug.js.map +1 -0
- package/dist/extra-cmd.d.ts +20 -0
- package/dist/extra-cmd.d.ts.map +1 -0
- package/dist/extra-cmd.js +112 -0
- package/dist/extra-cmd.js.map +1 -0
- package/dist/git.d.ts +16 -0
- package/dist/git.d.ts.map +1 -0
- package/dist/git.js +94 -0
- package/dist/git.js.map +1 -0
- package/dist/health-check.d.ts +12 -0
- package/dist/health-check.d.ts.map +1 -0
- package/dist/health-check.js +37 -0
- package/dist/health-check.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +198 -0
- package/dist/index.js.map +1 -0
- package/dist/providers/agent-teams-provider.d.ts +10 -0
- package/dist/providers/agent-teams-provider.d.ts.map +1 -0
- package/dist/providers/agent-teams-provider.js +57 -0
- package/dist/providers/agent-teams-provider.js.map +1 -0
- package/dist/providers/agw-provider.d.ts +10 -0
- package/dist/providers/agw-provider.d.ts.map +1 -0
- package/dist/providers/agw-provider.js +49 -0
- package/dist/providers/agw-provider.js.map +1 -0
- package/dist/providers/index.d.ts +14 -0
- package/dist/providers/index.d.ts.map +1 -0
- package/dist/providers/index.js +25 -0
- package/dist/providers/index.js.map +1 -0
- package/dist/render/agents-line.d.ts +3 -0
- package/dist/render/agents-line.d.ts.map +1 -0
- package/dist/render/agents-line.js +40 -0
- package/dist/render/agents-line.js.map +1 -0
- package/dist/render/alert-line.d.ts +3 -0
- package/dist/render/alert-line.d.ts.map +1 -0
- package/dist/render/alert-line.js +11 -0
- package/dist/render/alert-line.js.map +1 -0
- package/dist/render/colors.d.ts +39 -0
- package/dist/render/colors.d.ts.map +1 -0
- package/dist/render/colors.js +109 -0
- package/dist/render/colors.js.map +1 -0
- package/dist/render/framework-line.d.ts +3 -0
- package/dist/render/framework-line.d.ts.map +1 -0
- package/dist/render/framework-line.js +32 -0
- package/dist/render/framework-line.js.map +1 -0
- package/dist/render/index.d.ts +3 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +435 -0
- package/dist/render/index.js.map +1 -0
- package/dist/render/lines/environment.d.ts +3 -0
- package/dist/render/lines/environment.d.ts.map +1 -0
- package/dist/render/lines/environment.js +30 -0
- package/dist/render/lines/environment.js.map +1 -0
- package/dist/render/lines/identity.d.ts +3 -0
- package/dist/render/lines/identity.d.ts.map +1 -0
- package/dist/render/lines/identity.js +93 -0
- package/dist/render/lines/identity.js.map +1 -0
- package/dist/render/lines/index.d.ts +5 -0
- package/dist/render/lines/index.d.ts.map +1 -0
- package/dist/render/lines/index.js +5 -0
- package/dist/render/lines/index.js.map +1 -0
- package/dist/render/lines/project.d.ts +3 -0
- package/dist/render/lines/project.d.ts.map +1 -0
- package/dist/render/lines/project.js +100 -0
- package/dist/render/lines/project.js.map +1 -0
- package/dist/render/lines/usage.d.ts +3 -0
- package/dist/render/lines/usage.d.ts.map +1 -0
- package/dist/render/lines/usage.js +65 -0
- package/dist/render/lines/usage.js.map +1 -0
- package/dist/render/session-line.d.ts +7 -0
- package/dist/render/session-line.d.ts.map +1 -0
- package/dist/render/session-line.js +227 -0
- package/dist/render/session-line.js.map +1 -0
- package/dist/render/todos-line.d.ts +3 -0
- package/dist/render/todos-line.d.ts.map +1 -0
- package/dist/render/todos-line.js +29 -0
- package/dist/render/todos-line.js.map +1 -0
- package/dist/render/tools-line.d.ts +3 -0
- package/dist/render/tools-line.d.ts.map +1 -0
- package/dist/render/tools-line.js +45 -0
- package/dist/render/tools-line.js.map +1 -0
- package/dist/session-history.d.ts +15 -0
- package/dist/session-history.d.ts.map +1 -0
- package/dist/session-history.js +46 -0
- package/dist/session-history.js.map +1 -0
- package/dist/session-stats.d.ts +11 -0
- package/dist/session-stats.d.ts.map +1 -0
- package/dist/session-stats.js +48 -0
- package/dist/session-stats.js.map +1 -0
- package/dist/speed-tracker.d.ts +7 -0
- package/dist/speed-tracker.d.ts.map +1 -0
- package/dist/speed-tracker.js +34 -0
- package/dist/speed-tracker.js.map +1 -0
- package/dist/stdin.d.ts +9 -0
- package/dist/stdin.d.ts.map +1 -0
- package/dist/stdin.js +142 -0
- package/dist/stdin.js.map +1 -0
- package/dist/themes.d.ts +10 -0
- package/dist/themes.d.ts.map +1 -0
- package/dist/themes.js +81 -0
- package/dist/themes.js.map +1 -0
- package/dist/transcript.d.ts +3 -0
- package/dist/transcript.d.ts.map +1 -0
- package/dist/transcript.js +221 -0
- package/dist/transcript.js.map +1 -0
- package/dist/types.d.ts +124 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +5 -0
- package/dist/types.js.map +1 -0
- package/dist/usage-api.d.ts +62 -0
- package/dist/usage-api.d.ts.map +1 -0
- package/dist/usage-api.js +908 -0
- package/dist/usage-api.js.map +1 -0
- package/dist/utils/format.d.ts +9 -0
- package/dist/utils/format.d.ts.map +1 -0
- package/dist/utils/format.js +75 -0
- package/dist/utils/format.js.map +1 -0
- package/dist/utils/terminal.d.ts +5 -0
- package/dist/utils/terminal.d.ts.map +1 -0
- package/dist/utils/terminal.js +42 -0
- package/dist/utils/terminal.js.map +1 -0
- package/package.json +36 -0
- package/src/alert.ts +75 -0
- package/src/burn-rate.ts +45 -0
- package/src/cache.ts +57 -0
- package/src/claude-config-dir.ts +27 -0
- package/src/config-io.ts +26 -0
- package/src/config-reader.ts +236 -0
- package/src/config.ts +496 -0
- package/src/constants.ts +10 -0
- package/src/cost-tracker.ts +53 -0
- package/src/debug.ts +16 -0
- package/src/extra-cmd.ts +125 -0
- package/src/git.ts +126 -0
- package/src/health-check.ts +50 -0
- package/src/index.ts +234 -0
- package/src/providers/agent-teams-provider.ts +56 -0
- package/src/providers/agw-provider.ts +47 -0
- package/src/providers/index.ts +27 -0
- package/src/render/agents-line.ts +51 -0
- package/src/render/alert-line.ts +11 -0
- package/src/render/colors.ts +145 -0
- package/src/render/framework-line.ts +34 -0
- package/src/render/index.ts +512 -0
- package/src/render/lines/environment.ts +41 -0
- package/src/render/lines/identity.ts +109 -0
- package/src/render/lines/index.ts +4 -0
- package/src/render/lines/project.ts +113 -0
- package/src/render/lines/usage.ts +79 -0
- package/src/render/session-line.ts +253 -0
- package/src/render/todos-line.ts +35 -0
- package/src/render/tools-line.ts +58 -0
- package/src/session-history.ts +62 -0
- package/src/session-stats.ts +65 -0
- package/src/speed-tracker.ts +51 -0
- package/src/stdin.ts +169 -0
- package/src/themes.ts +90 -0
- package/src/transcript.ts +268 -0
- package/src/types.ts +146 -0
- package/src/usage-api.ts +1090 -0
- package/src/utils/format.ts +79 -0
- 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
|