ctxline-claude 0.0.1 → 0.0.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.
package/statusline.js CHANGED
@@ -1,345 +1,394 @@
1
- #!/usr/bin/env node
2
- // Claude Code Enhanced Statusline
3
- // Shows: directory | model | context usage | API usage (5-hour limit) | current task
4
- // Auto-detects API key vs subscription usage
5
- // https://github.com/MithunWijayasiri/claudecode-statusline
6
-
7
- const fs = require('fs');
8
- const path = require('path');
9
- const os = require('os');
10
- const https = require('https');
11
- const { execSync } = require('child_process');
12
-
13
- const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
14
-
15
- // Cache configuration
16
- const CACHE_DIR = path.join(os.homedir(), '.claude', 'cache');
17
- const USAGE_CACHE_FILE = path.join(CACHE_DIR, 'usage-cache.json');
18
- // Fresh: trust the cache and skip the API call entirely (fewer calls, faster render).
19
- const FRESH_TTL_MS = 30000; // 30 seconds
20
- // Stale: used only as a fallback when a live API call fails, so the usage bar stays
21
- // visible through transient timeouts/errors instead of disappearing.
22
- const STALE_TTL_MS = 10 * 60 * 1000; // 10 minutes
23
-
24
- // ANSI color codes
25
- const colors = {
26
- reset: '\x1b[0m',
27
- dim: '\x1b[2m',
28
- bold: '\x1b[1m',
29
- green: '\x1b[32m',
30
- yellow: '\x1b[33m',
31
- orange: '\x1b[38;5;208m',
32
- red: '\x1b[31m',
33
- blink: '\x1b[5m'
34
- };
35
-
36
- function getUsageColor(percentage) {
37
- if (percentage < 50) return colors.green;
38
- if (percentage < 75) return colors.yellow;
39
- if (percentage < 90) return colors.orange;
40
- return colors.red;
41
- }
42
-
43
- function getContextBar(remaining) {
44
- const effectiveRemaining = remaining ?? 100;
45
- const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));
46
-
47
- const filled = Math.floor(used / 10);
48
- const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(10 - filled);
49
-
50
- let coloredBar;
51
- if (used < 50) {
52
- coloredBar = `${colors.green}${bar} ${used}%${colors.reset}`;
53
- } else if (used < 65) {
54
- coloredBar = `${colors.yellow}${bar} ${used}%${colors.reset}`;
55
- } else if (used < 80) {
56
- coloredBar = `${colors.orange}${bar} ${used}%${colors.reset}`;
57
- } else {
58
- coloredBar = `${colors.blink}${colors.red}\u{1F480} ${bar} ${used}%${colors.reset}`;
59
- }
60
-
61
- return coloredBar;
62
- }
63
-
64
- // Render the usage bar from raw data. Called on every read (live or cached) so the
65
- // reset countdown is always recomputed from resetsAt rather than frozen at fetch time.
66
- function buildUsageBar(percentage, resetsAt) {
67
- let timeStr = '';
68
- if (resetsAt) {
69
- const diffMins = Math.max(0, Math.floor((new Date(resetsAt) - new Date()) / 60000));
70
- const hours = Math.floor(diffMins / 60);
71
- const mins = diffMins % 60;
72
- timeStr = hours > 0 ? `${hours}h${mins}m` : `${mins}m`;
73
- }
74
-
75
- const barWidth = 10;
76
- const filledWidth = Math.max(0, Math.min(barWidth, Math.round((percentage / 100) * barWidth)));
77
- const filled = '█'.repeat(filledWidth);
78
- const empty = '░'.repeat(barWidth - filledWidth);
79
- const color = getUsageColor(percentage);
80
-
81
- return `${color}${filled}${empty} ${percentage}%${colors.reset}${colors.dim} (${timeStr})${colors.reset}`;
82
- }
83
-
84
- // Read the raw cached usage data ({ timestamp, data: { percentage, resetsAt } }).
85
- // Returns { age, data } or null. Age-vs-TTL decisions are made by the caller.
86
- function readCachedUsage() {
87
- try {
88
- if (!fs.existsSync(USAGE_CACHE_FILE)) return null;
89
-
90
- const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
91
- if (!cache || !Number.isFinite(cache.timestamp) || cache.timestamp <= 0) return null;
92
-
93
- // Validate data (also rejects the legacy string-cache format from older versions),
94
- // so callers never receive undefined/NaN percentage or an unparseable resetsAt.
95
- const data = cache.data;
96
- if (!data || typeof data !== 'object') return null;
97
- if (!Number.isFinite(data.percentage) || data.percentage < 0 || data.percentage > 100) return null;
98
- if (data.resetsAt != null && Number.isNaN(new Date(data.resetsAt).getTime())) return null;
99
-
100
- return { age: Date.now() - cache.timestamp, data };
101
- } catch (e) {
102
- return null;
103
- }
104
- }
105
-
106
- // Write usage data to cache (shared across all sessions)
107
- function setCachedUsage(data) {
108
- try {
109
- if (!fs.existsSync(CACHE_DIR)) {
110
- fs.mkdirSync(CACHE_DIR, { recursive: true });
111
- }
112
-
113
- const cache = {
114
- timestamp: Date.now(),
115
- data: data
116
- };
117
-
118
- fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(cache), 'utf8');
119
- } catch (e) {
120
- // Silently fail
121
- }
122
- }
123
-
124
- function getCredentials() {
125
- // Try file first (legacy / Linux / Windows)
126
- const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
127
- if (fs.existsSync(credsPath)) {
128
- try {
129
- return JSON.parse(fs.readFileSync(credsPath, 'utf8'));
130
- } catch (e) {}
131
- }
132
-
133
- // Fallback: macOS keychain
134
- if (os.platform() === 'darwin') {
135
- try {
136
- const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', { encoding: 'utf8', timeout: 1000 });
137
- return JSON.parse(raw.trim());
138
- } catch (e) {}
139
- }
140
-
141
- return null;
142
- }
143
-
144
- function getApiUsage(callback) {
145
- try {
146
- // Read credentials (file or macOS keychain)
147
- const creds = getCredentials();
148
- if (!creds) {
149
- return callback(null);
150
- }
151
-
152
- const accessToken = creds.claudeAiOauth?.accessToken;
153
-
154
- if (!accessToken) {
155
- return callback(null);
156
- }
157
-
158
- // Adaptive timeout: if cache exists, be faster (1200ms); if not, be patient (1500ms)
159
- // API typically takes ~850ms, so 1200ms gives reasonable headroom
160
- const hasCache = fs.existsSync(USAGE_CACHE_FILE);
161
- const timeout = hasCache ? 1200 : 1500;
162
-
163
- // Make API call with adaptive timeout
164
- const req = https.request({
165
- hostname: 'api.anthropic.com',
166
- path: '/api/oauth/usage',
167
- method: 'GET',
168
- headers: {
169
- 'Authorization': `Bearer ${accessToken}`,
170
- 'Content-Type': 'application/json',
171
- 'anthropic-beta': 'oauth-2025-04-20'
172
- },
173
- timeout: timeout
174
- }, (res) => {
175
- let data = '';
176
-
177
- res.on('data', chunk => data += chunk);
178
- res.on('end', () => {
179
- try {
180
- const usage = JSON.parse(data);
181
-
182
- // Get 5-hour session usage
183
- if (usage.five_hour) {
184
- const percentage = Math.round(usage.five_hour.utilization);
185
- const resetsAt = usage.five_hour.resets_at || null;
186
-
187
- // Cache the raw data (shared across sessions); render the bar from it.
188
- setCachedUsage({ percentage, resetsAt });
189
- callback(buildUsageBar(percentage, resetsAt));
190
- } else {
191
- callback(null);
192
- }
193
- } catch (e) {
194
- callback(null);
195
- }
196
- });
197
- });
198
-
199
- req.on('error', () => callback(null));
200
- req.on('timeout', () => {
201
- req.destroy();
202
- callback(null);
203
- });
204
-
205
- req.end();
206
- } catch (e) {
207
- callback(null);
208
- }
209
- }
210
-
211
- // Get usage, cache-first.
212
- function getUsageWithCache(callback) {
213
- const cached = readCachedUsage();
214
-
215
- // Cache is fresh -> render it and skip the API entirely (fewer calls, faster).
216
- if (cached && cached.age < FRESH_TTL_MS) {
217
- return callback(buildUsageBar(cached.data.percentage, cached.data.resetsAt));
218
- }
219
-
220
- // Cache is stale or missing -> refresh from the API.
221
- getApiUsage((freshBar) => {
222
- if (freshBar) {
223
- callback(freshBar);
224
- } else if (cached && cached.age < STALE_TTL_MS) {
225
- // API failed/timed out, but recent cache exists -> show it instead of nothing.
226
- callback(buildUsageBar(cached.data.percentage, cached.data.resetsAt));
227
- } else {
228
- callback(null);
229
- }
230
- });
231
- }
232
-
233
- function getCurrentTask(sessionId) {
234
- if (!sessionId) return '';
235
-
236
- const homeDir = os.homedir();
237
- const todosDir = path.join(homeDir, '.claude', 'todos');
238
-
239
- if (!fs.existsSync(todosDir)) return '';
240
-
241
- try {
242
- const files = fs.readdirSync(todosDir)
243
- .filter(f => f.startsWith(sessionId) && f.includes('-agent-') && f.endsWith('.json'))
244
- .map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime }))
245
- .sort((a, b) => b.mtime - a.mtime);
246
-
247
- if (files.length > 0) {
248
- const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8'));
249
- const inProgress = todos.find(t => t.status === 'in_progress');
250
- if (inProgress) return inProgress.activeForm || '';
251
- }
252
- } catch (e) {}
253
-
254
- return '';
255
- }
256
-
257
- // Main
258
- function outputStatus(data, usageBar) {
259
- try {
260
- const model = data?.model?.display_name || 'Claude';
261
- const dir = data?.workspace?.current_dir || process.cwd();
262
- const dirname = path.basename(dir);
263
- const sessionId = data?.session_id || '';
264
- const remaining = data?.context_window?.remaining_percentage;
265
-
266
- const contextBar = getContextBar(remaining);
267
- const task = getCurrentTask(sessionId);
268
- const parts = [];
269
- parts.push(dirname);
270
- parts.push(model);
271
- parts.push(`context: ${contextBar}`);
272
-
273
- if (usageBar) {
274
- parts.push(`usage: ${usageBar}`);
275
- }
276
-
277
- if (task) parts.push(`${colors.dim}${task}${colors.reset}`);
278
- process.stdout.write(parts.join(' \u2502 '));
279
- } catch (e) {
280
- process.stdout.write('Status unavailable');
281
- }
282
- }
283
-
284
- function outputFallback(usageBar) {
285
- const contextBar = getContextBar(undefined);
286
- const parts = ['~', 'Claude', `context: ${contextBar}`];
287
- if (usageBar) parts.push(`usage: ${usageBar}`);
288
- process.stdout.write(parts.join(' \u2502 '));
289
- }
290
-
291
- // Wrapper that skips usage fetch for API key users
292
- function getUsage(callback) {
293
- if (IS_API_KEY) {
294
- callback(null);
295
- } else {
296
- getUsageWithCache(callback);
297
- }
298
- }
299
-
300
- // Process with timeout
301
- if (process.stdin.isTTY) {
302
- getUsage((usageBar) => {
303
- outputFallback(usageBar);
304
- process.exit(0);
305
- });
306
- } else {
307
- let input = '';
308
- let timeoutReached = false;
309
-
310
- const overallTimeout = IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600);
311
-
312
- const timeout = setTimeout(() => {
313
- timeoutReached = true;
314
- getUsage((usageBar) => {
315
- if (input.length > 0) {
316
- try {
317
- const data = JSON.parse(input);
318
- outputStatus(data, usageBar);
319
- } catch (e) {
320
- outputFallback(usageBar);
321
- }
322
- } else {
323
- outputFallback(usageBar);
324
- }
325
- process.exit(0);
326
- });
327
- }, overallTimeout);
328
-
329
- process.stdin.setEncoding('utf8');
330
- process.stdin.on('data', chunk => input += chunk);
331
- process.stdin.on('end', () => {
332
- if (timeoutReached) return;
333
- clearTimeout(timeout);
334
-
335
- getUsage((usageBar) => {
336
- try {
337
- const data = JSON.parse(input);
338
- outputStatus(data, usageBar);
339
- } catch (e) {
340
- outputFallback(usageBar);
341
- }
342
- process.exit(0);
343
- });
344
- });
345
- }
1
+ #!/usr/bin/env node
2
+ // Claude Code Enhanced Statusline
3
+ // Shows: directory | model | context usage | current (5-hour) + weekly usage | current task
4
+ // Auto-detects API key vs subscription usage
5
+ // https://github.com/MithunWijayasiri/claudecode-statusline
6
+
7
+ const fs = require('fs');
8
+ const path = require('path');
9
+ const os = require('os');
10
+ const https = require('https');
11
+ const { execSync } = require('child_process');
12
+
13
+ const IS_API_KEY = !!process.env.ANTHROPIC_API_KEY;
14
+
15
+ // Shared width (cells) for all progress bars: context, current, weekly.
16
+ const BAR_WIDTH = 6;
17
+
18
+ // Cache configuration
19
+ const CACHE_DIR = path.join(os.homedir(), '.claude', 'cache');
20
+ const USAGE_CACHE_FILE = path.join(CACHE_DIR, 'usage-cache.json');
21
+ // Fresh: trust the cache and skip the API call entirely (fewer calls, faster render).
22
+ const FRESH_TTL_MS = 30000; // 30 seconds
23
+ // Stale: used only as a fallback when a live API call fails, so the usage bar stays
24
+ // visible through transient timeouts/errors instead of disappearing.
25
+ const STALE_TTL_MS = 10 * 60 * 1000; // 10 minutes
26
+
27
+ // ANSI color codes
28
+ const colors = {
29
+ reset: '\x1b[0m',
30
+ dim: '\x1b[2m',
31
+ bold: '\x1b[1m',
32
+ green: '\x1b[32m',
33
+ yellow: '\x1b[33m',
34
+ orange: '\x1b[38;5;208m',
35
+ red: '\x1b[31m',
36
+ blink: '\x1b[5m'
37
+ };
38
+
39
+ function getUsageColor(percentage) {
40
+ if (percentage < 50) return colors.green;
41
+ if (percentage < 75) return colors.yellow;
42
+ if (percentage < 90) return colors.orange;
43
+ return colors.red;
44
+ }
45
+
46
+ // Shorten verbose model names for the statusline: "Opus 4.8 (1M context)" -> "Opus 4.8 (1M)".
47
+ function shortenModel(name) {
48
+ return name.replace(/\s+context\)/i, ')');
49
+ }
50
+
51
+ function getContextBar(remaining) {
52
+ const effectiveRemaining = remaining ?? 100;
53
+ const used = Math.max(0, Math.min(100, 100 - Math.round(effectiveRemaining)));
54
+
55
+ const filled = Math.floor((used / 100) * BAR_WIDTH);
56
+ const bar = '\u2588'.repeat(filled) + '\u2591'.repeat(BAR_WIDTH - filled);
57
+
58
+ let coloredBar;
59
+ if (used < 50) {
60
+ coloredBar = `${colors.green}${bar} ${used}%${colors.reset}`;
61
+ } else if (used < 65) {
62
+ coloredBar = `${colors.yellow}${bar} ${used}%${colors.reset}`;
63
+ } else if (used < 80) {
64
+ coloredBar = `${colors.orange}${bar} ${used}%${colors.reset}`;
65
+ } else {
66
+ coloredBar = `${colors.blink}${colors.red}${bar} ${used}%${colors.reset}`;
67
+ }
68
+
69
+ return coloredBar;
70
+ }
71
+
72
+ // Render the usage bar from raw data. Called on every read (live or cached) so the
73
+ // reset countdown is always recomputed from resetsAt rather than frozen at fetch time.
74
+ function buildUsageBar(percentage, resetsAt) {
75
+ let timeStr = '';
76
+ if (resetsAt) {
77
+ const diffMins = Math.max(0, Math.floor((new Date(resetsAt) - new Date()) / 60000));
78
+ const days = Math.floor(diffMins / 1440);
79
+ const hours = Math.floor((diffMins % 1440) / 60);
80
+ const mins = diffMins % 60;
81
+ if (days > 0) timeStr = `${days}d${hours}h`;
82
+ else if (hours > 0) timeStr = `${hours}h${mins}m`;
83
+ else timeStr = `${mins}m`;
84
+ }
85
+
86
+ const filledWidth = Math.max(0, Math.min(BAR_WIDTH, Math.round((percentage / 100) * BAR_WIDTH)));
87
+ const filled = '█'.repeat(filledWidth);
88
+ const empty = '░'.repeat(BAR_WIDTH - filledWidth);
89
+ const color = getUsageColor(percentage);
90
+ const timePart = timeStr ? `${colors.dim} (${timeStr})${colors.reset}` : '';
91
+
92
+ return `${color}${filled}${empty} ${percentage}%${colors.reset}${timePart}`;
93
+ }
94
+
95
+ // Build both usage bars from raw entries. Each entry is { percentage, resetsAt } or
96
+ // null/absent. Returns { current, weekly } where each is a rendered bar string or null.
97
+ function buildUsageBars(fiveHour, weekly) {
98
+ return {
99
+ current: fiveHour ? buildUsageBar(fiveHour.percentage, fiveHour.resetsAt) : null,
100
+ weekly: weekly ? buildUsageBar(weekly.percentage, weekly.resetsAt) : null
101
+ };
102
+ }
103
+
104
+ // Normalize a raw API utilization into the 0-100 integer that the rest of the
105
+ // pipeline (cache validation + bar rendering) expects. Returns null when the value
106
+ // isn't a finite number, so callers can omit that bar instead of rendering "NaN%".
107
+ function normalizePercentage(value) {
108
+ if (!Number.isFinite(value)) return null;
109
+ return Math.max(0, Math.min(100, Math.round(value)));
110
+ }
111
+
112
+ // Validate a single usage entry ({ percentage, resetsAt }). Returns true only for a
113
+ // finite 0-100 percentage and a parseable (or absent) resetsAt.
114
+ function isValidUsageEntry(entry) {
115
+ if (!entry || typeof entry !== 'object') return false;
116
+ if (!Number.isFinite(entry.percentage) || entry.percentage < 0 || entry.percentage > 100) return false;
117
+ if (entry.resetsAt != null && Number.isNaN(new Date(entry.resetsAt).getTime())) return false;
118
+ return true;
119
+ }
120
+
121
+ // Read the raw cached usage data
122
+ // ({ timestamp, data: { fiveHour: {percentage,resetsAt}, weekly: {...}|null } }).
123
+ // Returns { age, data } or null. Age-vs-TTL decisions are made by the caller.
124
+ function readCachedUsage() {
125
+ try {
126
+ if (!fs.existsSync(USAGE_CACHE_FILE)) return null;
127
+
128
+ const cache = JSON.parse(fs.readFileSync(USAGE_CACHE_FILE, 'utf8'));
129
+ if (!cache || !Number.isFinite(cache.timestamp) || cache.timestamp <= 0) return null;
130
+
131
+ // Validate data. fiveHour is required; weekly is optional (the API may omit it).
132
+ // This also rejects the legacy single-{percentage,resetsAt} format from older
133
+ // versions, which had no fiveHour key, so stale caches are ignored on read.
134
+ const data = cache.data;
135
+ if (!data || typeof data !== 'object') return null;
136
+ if (!isValidUsageEntry(data.fiveHour)) return null;
137
+ if (data.weekly != null && !isValidUsageEntry(data.weekly)) return null;
138
+
139
+ return { age: Date.now() - cache.timestamp, data };
140
+ } catch (e) {
141
+ return null;
142
+ }
143
+ }
144
+
145
+ // Write usage data to cache (shared across all sessions)
146
+ function setCachedUsage(data) {
147
+ try {
148
+ if (!fs.existsSync(CACHE_DIR)) {
149
+ fs.mkdirSync(CACHE_DIR, { recursive: true });
150
+ }
151
+
152
+ const cache = {
153
+ timestamp: Date.now(),
154
+ data: data
155
+ };
156
+
157
+ fs.writeFileSync(USAGE_CACHE_FILE, JSON.stringify(cache), 'utf8');
158
+ } catch (e) {
159
+ // Silently fail
160
+ }
161
+ }
162
+
163
+ function getCredentials() {
164
+ // Try file first (legacy / Linux / Windows)
165
+ const credsPath = path.join(os.homedir(), '.claude', '.credentials.json');
166
+ if (fs.existsSync(credsPath)) {
167
+ try {
168
+ return JSON.parse(fs.readFileSync(credsPath, 'utf8'));
169
+ } catch (e) {}
170
+ }
171
+
172
+ // Fallback: macOS keychain
173
+ if (os.platform() === 'darwin') {
174
+ try {
175
+ const raw = execSync('security find-generic-password -s "Claude Code-credentials" -w 2>/dev/null', { encoding: 'utf8', timeout: 1000 });
176
+ return JSON.parse(raw.trim());
177
+ } catch (e) {}
178
+ }
179
+
180
+ return null;
181
+ }
182
+
183
+ function getApiUsage(callback) {
184
+ try {
185
+ // Read credentials (file or macOS keychain)
186
+ const creds = getCredentials();
187
+ if (!creds) {
188
+ return callback(null);
189
+ }
190
+
191
+ const accessToken = creds.claudeAiOauth?.accessToken;
192
+
193
+ if (!accessToken) {
194
+ return callback(null);
195
+ }
196
+
197
+ // Adaptive timeout: if cache exists, be faster (1200ms); if not, be patient (1500ms)
198
+ // API typically takes ~850ms, so 1200ms gives reasonable headroom
199
+ const hasCache = fs.existsSync(USAGE_CACHE_FILE);
200
+ const timeout = hasCache ? 1200 : 1500;
201
+
202
+ // Make API call with adaptive timeout
203
+ const req = https.request({
204
+ hostname: 'api.anthropic.com',
205
+ path: '/api/oauth/usage',
206
+ method: 'GET',
207
+ headers: {
208
+ 'Authorization': `Bearer ${accessToken}`,
209
+ 'Content-Type': 'application/json',
210
+ 'anthropic-beta': 'oauth-2025-04-20'
211
+ },
212
+ timeout: timeout
213
+ }, (res) => {
214
+ let data = '';
215
+
216
+ res.on('data', chunk => data += chunk);
217
+ res.on('end', () => {
218
+ try {
219
+ const usage = JSON.parse(data);
220
+
221
+ // 5-hour session usage is required; weekly (seven_day) is rendered when present.
222
+ // Normalize utilization first so a missing/non-finite value omits the bar
223
+ // instead of rendering "NaN%" or an out-of-range percentage.
224
+ const fivePct = usage.five_hour ? normalizePercentage(usage.five_hour.utilization) : null;
225
+ if (fivePct != null) {
226
+ const fiveHour = {
227
+ percentage: fivePct,
228
+ resetsAt: usage.five_hour.resets_at || null
229
+ };
230
+ const weeklyPct = usage.seven_day ? normalizePercentage(usage.seven_day.utilization) : null;
231
+ const weekly = weeklyPct != null ? {
232
+ percentage: weeklyPct,
233
+ resetsAt: usage.seven_day.resets_at || null
234
+ } : null;
235
+
236
+ // Cache the raw data (shared across sessions); render the bars from it.
237
+ setCachedUsage({ fiveHour, weekly });
238
+ callback(buildUsageBars(fiveHour, weekly));
239
+ } else {
240
+ callback(null);
241
+ }
242
+ } catch (e) {
243
+ callback(null);
244
+ }
245
+ });
246
+ });
247
+
248
+ req.on('error', () => callback(null));
249
+ req.on('timeout', () => {
250
+ req.destroy();
251
+ callback(null);
252
+ });
253
+
254
+ req.end();
255
+ } catch (e) {
256
+ callback(null);
257
+ }
258
+ }
259
+
260
+ // Get usage, cache-first.
261
+ function getUsageWithCache(callback) {
262
+ const cached = readCachedUsage();
263
+
264
+ // Cache is fresh -> render it and skip the API entirely (fewer calls, faster).
265
+ if (cached && cached.age < FRESH_TTL_MS) {
266
+ return callback(buildUsageBars(cached.data.fiveHour, cached.data.weekly));
267
+ }
268
+
269
+ // Cache is stale or missing -> refresh from the API.
270
+ getApiUsage((freshBars) => {
271
+ if (freshBars) {
272
+ callback(freshBars);
273
+ } else if (cached && cached.age < STALE_TTL_MS) {
274
+ // API failed/timed out, but recent cache exists -> show it instead of nothing.
275
+ callback(buildUsageBars(cached.data.fiveHour, cached.data.weekly));
276
+ } else {
277
+ callback(null);
278
+ }
279
+ });
280
+ }
281
+
282
+ function getCurrentTask(sessionId) {
283
+ if (!sessionId) return '';
284
+
285
+ const homeDir = os.homedir();
286
+ const todosDir = path.join(homeDir, '.claude', 'todos');
287
+
288
+ if (!fs.existsSync(todosDir)) return '';
289
+
290
+ try {
291
+ const files = fs.readdirSync(todosDir)
292
+ .filter(f => f.startsWith(sessionId) && f.includes('-agent-') && f.endsWith('.json'))
293
+ .map(f => ({ name: f, mtime: fs.statSync(path.join(todosDir, f)).mtime }))
294
+ .sort((a, b) => b.mtime - a.mtime);
295
+
296
+ if (files.length > 0) {
297
+ const todos = JSON.parse(fs.readFileSync(path.join(todosDir, files[0].name), 'utf8'));
298
+ const inProgress = todos.find(t => t.status === 'in_progress');
299
+ if (inProgress) return inProgress.activeForm || '';
300
+ }
301
+ } catch (e) {}
302
+
303
+ return '';
304
+ }
305
+
306
+ // Main
307
+ function outputStatus(data, usage) {
308
+ try {
309
+ const model = shortenModel(data?.model?.display_name || 'Claude');
310
+ const dir = data?.workspace?.current_dir || process.cwd();
311
+ const dirname = path.basename(dir);
312
+ const sessionId = data?.session_id || '';
313
+ const remaining = data?.context_window?.remaining_percentage;
314
+
315
+ const contextBar = getContextBar(remaining);
316
+ const task = getCurrentTask(sessionId);
317
+ const parts = [];
318
+ parts.push(dirname);
319
+ parts.push(model);
320
+ parts.push(`CTX ${contextBar}`);
321
+
322
+ if (usage?.current) parts.push(`5h ${usage.current}`);
323
+ if (usage?.weekly) parts.push(`7d ${usage.weekly}`);
324
+
325
+ if (task) parts.push(`${colors.dim}${task}${colors.reset}`);
326
+ process.stdout.write(parts.join(' \u2502 '));
327
+ } catch (e) {
328
+ process.stdout.write('Status unavailable');
329
+ }
330
+ }
331
+
332
+ function outputFallback(usage) {
333
+ const contextBar = getContextBar(undefined);
334
+ const parts = ['~', 'Claude', `CTX ${contextBar}`];
335
+ if (usage?.current) parts.push(`5h ${usage.current}`);
336
+ if (usage?.weekly) parts.push(`7d ${usage.weekly}`);
337
+ process.stdout.write(parts.join(' \u2502 '));
338
+ }
339
+
340
+ // Wrapper that skips usage fetch for API key users
341
+ function getUsage(callback) {
342
+ if (IS_API_KEY) {
343
+ callback(null);
344
+ } else {
345
+ getUsageWithCache(callback);
346
+ }
347
+ }
348
+
349
+ // Process with timeout
350
+ if (process.stdin.isTTY) {
351
+ getUsage((usage) => {
352
+ outputFallback(usage);
353
+ process.exit(0);
354
+ });
355
+ } else {
356
+ let input = '';
357
+ let timeoutReached = false;
358
+
359
+ const overallTimeout = IS_API_KEY ? 500 : (fs.existsSync(USAGE_CACHE_FILE) ? 1300 : 1600);
360
+
361
+ const timeout = setTimeout(() => {
362
+ timeoutReached = true;
363
+ getUsage((usage) => {
364
+ if (input.length > 0) {
365
+ try {
366
+ const data = JSON.parse(input);
367
+ outputStatus(data, usage);
368
+ } catch (e) {
369
+ outputFallback(usage);
370
+ }
371
+ } else {
372
+ outputFallback(usage);
373
+ }
374
+ process.exit(0);
375
+ });
376
+ }, overallTimeout);
377
+
378
+ process.stdin.setEncoding('utf8');
379
+ process.stdin.on('data', chunk => input += chunk);
380
+ process.stdin.on('end', () => {
381
+ if (timeoutReached) return;
382
+ clearTimeout(timeout);
383
+
384
+ getUsage((usage) => {
385
+ try {
386
+ const data = JSON.parse(input);
387
+ outputStatus(data, usage);
388
+ } catch (e) {
389
+ outputFallback(usage);
390
+ }
391
+ process.exit(0);
392
+ });
393
+ });
394
+ }