claude-usage-limits 1.0.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 +18 -0
- package/.claude-plugin/plugin.json +22 -0
- package/LICENSE +21 -0
- package/README.md +291 -0
- package/bin/cli.js +62 -0
- package/commands/check.md +14 -0
- package/package.json +43 -0
- package/skills/usage-limits/SKILL.md +163 -0
- package/skills/usage-limits/references/how-it-works.md +136 -0
- package/skills/usage-limits/references/tactics.md +170 -0
- package/skills/usage-limits/scripts/lowpower.js +230 -0
- package/skills/usage-limits/scripts/usage.js +918 -0
|
@@ -0,0 +1,918 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
// Reports how much of the current Claude Code usage window is left, and
|
|
5
|
+
// converts that into something you can plan with: turns remaining and
|
|
6
|
+
// minutes remaining at the pace of the last hour.
|
|
7
|
+
//
|
|
8
|
+
// Two data sources, both local:
|
|
9
|
+
// ~/.claude.json cachedUsageUtilization - the real percentages
|
|
10
|
+
// and reset times, refreshed by the CLI itself
|
|
11
|
+
// ~/.claude/projects/** session transcripts, one JSON object per line,
|
|
12
|
+
// each assistant turn carrying a usage record
|
|
13
|
+
//
|
|
14
|
+
// The percentages alone tell you where you are but not how fast you are
|
|
15
|
+
// moving. The transcripts alone tell you how fast you are moving but not
|
|
16
|
+
// where the ceiling is. Combining them gives a dollars-per-percent factor
|
|
17
|
+
// for this account and plan, which is what the projections are built on.
|
|
18
|
+
|
|
19
|
+
const fs = require('fs');
|
|
20
|
+
const os = require('os');
|
|
21
|
+
const path = require('path');
|
|
22
|
+
const readline = require('readline');
|
|
23
|
+
|
|
24
|
+
const MINUTE = 60 * 1000;
|
|
25
|
+
const HOUR = 60 * MINUTE;
|
|
26
|
+
const DAY = 24 * HOUR;
|
|
27
|
+
|
|
28
|
+
// USD per million tokens, first-party API rates.
|
|
29
|
+
const RATES = {
|
|
30
|
+
'claude-fable-5': { input: 10, output: 50 },
|
|
31
|
+
'claude-mythos-5': { input: 10, output: 50 },
|
|
32
|
+
'claude-opus-5': { input: 5, output: 25 },
|
|
33
|
+
'claude-opus-4-8': { input: 5, output: 25 },
|
|
34
|
+
'claude-opus-4-7': { input: 5, output: 25 },
|
|
35
|
+
'claude-opus-4-6': { input: 5, output: 25 },
|
|
36
|
+
'claude-sonnet-5': { input: 3, output: 15 },
|
|
37
|
+
'claude-sonnet-4-6': { input: 3, output: 15 },
|
|
38
|
+
'claude-haiku-4-5': { input: 1, output: 5 },
|
|
39
|
+
};
|
|
40
|
+
const FALLBACK_RATE = { input: 5, output: 25 };
|
|
41
|
+
|
|
42
|
+
// Cache traffic is priced as a multiple of the input rate.
|
|
43
|
+
const CACHE_WRITE_5M = 1.25;
|
|
44
|
+
const CACHE_WRITE_1H = 2;
|
|
45
|
+
const CACHE_READ = 0.1;
|
|
46
|
+
|
|
47
|
+
const WINDOWS = [
|
|
48
|
+
{ key: 'five_hour', label: '5-hour', span: 5 * HOUR },
|
|
49
|
+
{ key: 'seven_day', label: 'weekly', span: 7 * DAY },
|
|
50
|
+
{ key: 'seven_day_opus', label: 'weekly (Opus)', span: 7 * DAY },
|
|
51
|
+
{ key: 'seven_day_sonnet', label: 'weekly (Sonnet)', span: 7 * DAY },
|
|
52
|
+
];
|
|
53
|
+
|
|
54
|
+
// organizationType gives the family; the rate limit tier is what separates
|
|
55
|
+
// Max 5x from Max 20x. Both come out of oauthAccount.
|
|
56
|
+
// Abbreviations for the status line, where there is no room to spell it out.
|
|
57
|
+
const SHORT_LABELS = {
|
|
58
|
+
five_hour: '5h',
|
|
59
|
+
seven_day: 'wk',
|
|
60
|
+
seven_day_opus: 'wk opus',
|
|
61
|
+
seven_day_sonnet: 'wk sonnet',
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
const PLANS = {
|
|
65
|
+
pro: {
|
|
66
|
+
label: 'Claude Pro',
|
|
67
|
+
advice:
|
|
68
|
+
'Pro has the smallest budget and the 5-hour window usually binds first. ' +
|
|
69
|
+
'Keep Opus for the hard calls and let Sonnet do the mechanical work.',
|
|
70
|
+
},
|
|
71
|
+
max_5x: {
|
|
72
|
+
label: 'Claude Max 5x',
|
|
73
|
+
advice:
|
|
74
|
+
'Max 5x has room for Opus on most work. On a heavy week the weekly ' +
|
|
75
|
+
'window is the one that bites, not the 5-hour one.',
|
|
76
|
+
},
|
|
77
|
+
max_20x: {
|
|
78
|
+
label: 'Claude Max 20x',
|
|
79
|
+
advice:
|
|
80
|
+
'Max 20x rarely binds. Do not slow down unless the weekly window is ' +
|
|
81
|
+
'already high.',
|
|
82
|
+
},
|
|
83
|
+
max: {
|
|
84
|
+
label: 'Claude Max',
|
|
85
|
+
advice:
|
|
86
|
+
'Max, but the tier was not reported. Treat it as roughly 5x Pro until ' +
|
|
87
|
+
'the measured numbers say otherwise.',
|
|
88
|
+
},
|
|
89
|
+
team: {
|
|
90
|
+
label: 'Claude Team',
|
|
91
|
+
advice:
|
|
92
|
+
'Team seats are pooled and overage is an org setting. Confirm headroom ' +
|
|
93
|
+
'with whoever administers the org.',
|
|
94
|
+
},
|
|
95
|
+
enterprise: {
|
|
96
|
+
label: 'Claude Enterprise',
|
|
97
|
+
advice:
|
|
98
|
+
'Enterprise seats are pooled and overage is an org setting. Confirm ' +
|
|
99
|
+
'headroom with whoever administers the org.',
|
|
100
|
+
},
|
|
101
|
+
unknown: { label: 'unknown', advice: null },
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
const RATE_LIMIT_TIERS = {
|
|
105
|
+
default_claude_max_5x: 'max_5x',
|
|
106
|
+
default_claude_max_20x: 'max_20x',
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function configDir() {
|
|
110
|
+
return process.env.CLAUDE_CONFIG_DIR || path.join(os.homedir(), '.claude');
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// The CLI keeps its account state in ~/.claude.json, or next to the config
|
|
114
|
+
// directory when CLAUDE_CONFIG_DIR moves it.
|
|
115
|
+
function accountFile() {
|
|
116
|
+
const scoped = path.join(configDir(), '.claude.json');
|
|
117
|
+
if (fs.existsSync(scoped)) return scoped;
|
|
118
|
+
return path.join(os.homedir(), '.claude.json');
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
function readJson(file) {
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
124
|
+
} catch (err) {
|
|
125
|
+
return null;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function rateFor(model) {
|
|
130
|
+
const id = String(model || '').toLowerCase();
|
|
131
|
+
if (RATES[id]) return RATES[id];
|
|
132
|
+
if (id.includes('fable') || id.includes('mythos')) return RATES['claude-fable-5'];
|
|
133
|
+
if (id.includes('opus')) return RATES['claude-opus-5'];
|
|
134
|
+
if (id.includes('sonnet')) return RATES['claude-sonnet-5'];
|
|
135
|
+
if (id.includes('haiku')) return RATES['claude-haiku-4-5'];
|
|
136
|
+
return FALLBACK_RATE;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// Cost of one assistant turn, in USD, from its usage record.
|
|
140
|
+
function costOf(usage, model) {
|
|
141
|
+
if (!usage) return 0;
|
|
142
|
+
const rate = rateFor(model);
|
|
143
|
+
const creation = usage.cache_creation || {};
|
|
144
|
+
const write5m = creation.ephemeral_5m_input_tokens || 0;
|
|
145
|
+
const write1h = creation.ephemeral_1h_input_tokens || 0;
|
|
146
|
+
|
|
147
|
+
let writeUnits = write5m * CACHE_WRITE_5M + write1h * CACHE_WRITE_1H;
|
|
148
|
+
if (writeUnits === 0) {
|
|
149
|
+
// Older records only carry the undifferentiated total.
|
|
150
|
+
writeUnits = (usage.cache_creation_input_tokens || 0) * CACHE_WRITE_5M;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
const inputUnits =
|
|
154
|
+
(usage.input_tokens || 0) +
|
|
155
|
+
(usage.cache_read_input_tokens || 0) * CACHE_READ +
|
|
156
|
+
writeUnits;
|
|
157
|
+
|
|
158
|
+
return (inputUnits * rate.input + (usage.output_tokens || 0) * rate.output) / 1e6;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
function tokensOf(usage) {
|
|
162
|
+
if (!usage) return 0;
|
|
163
|
+
const creation = usage.cache_creation || {};
|
|
164
|
+
const written =
|
|
165
|
+
usage.cache_creation_input_tokens ||
|
|
166
|
+
(creation.ephemeral_5m_input_tokens || 0) + (creation.ephemeral_1h_input_tokens || 0);
|
|
167
|
+
return (
|
|
168
|
+
(usage.input_tokens || 0) +
|
|
169
|
+
(usage.cache_read_input_tokens || 0) +
|
|
170
|
+
written +
|
|
171
|
+
(usage.output_tokens || 0)
|
|
172
|
+
);
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
// The four token classes, kept apart because they are priced differently
|
|
176
|
+
// and because knowing the split is what makes the totals reasonable about.
|
|
177
|
+
function tokenParts(usage) {
|
|
178
|
+
if (!usage) return { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
|
|
179
|
+
const creation = usage.cache_creation || {};
|
|
180
|
+
const written =
|
|
181
|
+
usage.cache_creation_input_tokens ||
|
|
182
|
+
(creation.ephemeral_5m_input_tokens || 0) + (creation.ephemeral_1h_input_tokens || 0);
|
|
183
|
+
return {
|
|
184
|
+
input: usage.input_tokens || 0,
|
|
185
|
+
cacheWrite: written,
|
|
186
|
+
cacheRead: usage.cache_read_input_tokens || 0,
|
|
187
|
+
output: usage.output_tokens || 0,
|
|
188
|
+
};
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// One transcript line to an event, or null if it is not a billable turn.
|
|
192
|
+
function eventFrom(line, seen, project) {
|
|
193
|
+
if (line.indexOf('"assistant"') === -1 || line.indexOf('"usage"') === -1) return null;
|
|
194
|
+
let entry;
|
|
195
|
+
try {
|
|
196
|
+
entry = JSON.parse(line);
|
|
197
|
+
} catch (err) {
|
|
198
|
+
return null;
|
|
199
|
+
}
|
|
200
|
+
if (entry.type !== 'assistant' || !entry.message || !entry.message.usage) return null;
|
|
201
|
+
|
|
202
|
+
const at = Date.parse(entry.timestamp);
|
|
203
|
+
if (!Number.isFinite(at)) return null;
|
|
204
|
+
|
|
205
|
+
// A resumed or forked session repeats earlier turns in a new file.
|
|
206
|
+
const id = (entry.message.id || '') + '|' + (entry.requestId || '');
|
|
207
|
+
if (id !== '|' && seen) {
|
|
208
|
+
if (seen.has(id)) return null;
|
|
209
|
+
seen.add(id);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
return {
|
|
213
|
+
at,
|
|
214
|
+
model: entry.message.model || '',
|
|
215
|
+
effort: entry.effort || null,
|
|
216
|
+
cost: costOf(entry.message.usage, entry.message.model),
|
|
217
|
+
tokens: tokensOf(entry.message.usage),
|
|
218
|
+
parts: tokenParts(entry.message.usage),
|
|
219
|
+
project: project || null,
|
|
220
|
+
};
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
async function readEvents(since) {
|
|
224
|
+
const root = path.join(configDir(), 'projects');
|
|
225
|
+
let dirs = [];
|
|
226
|
+
try {
|
|
227
|
+
dirs = fs.readdirSync(root, { withFileTypes: true });
|
|
228
|
+
} catch (err) {
|
|
229
|
+
return [];
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
const files = [];
|
|
233
|
+
for (const dir of dirs) {
|
|
234
|
+
if (!dir.isDirectory()) continue;
|
|
235
|
+
const full = path.join(root, dir.name);
|
|
236
|
+
let names = [];
|
|
237
|
+
try {
|
|
238
|
+
names = fs.readdirSync(full);
|
|
239
|
+
} catch (err) {
|
|
240
|
+
continue;
|
|
241
|
+
}
|
|
242
|
+
for (const name of names) {
|
|
243
|
+
if (!name.endsWith('.jsonl')) continue;
|
|
244
|
+
const file = path.join(full, name);
|
|
245
|
+
try {
|
|
246
|
+
// A file last touched before the window opened holds nothing useful.
|
|
247
|
+
if (fs.statSync(file).mtimeMs < since) continue;
|
|
248
|
+
} catch (err) {
|
|
249
|
+
continue;
|
|
250
|
+
}
|
|
251
|
+
files.push({ file, project: dir.name });
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
const seen = new Set();
|
|
256
|
+
const events = [];
|
|
257
|
+
for (const entry of files) {
|
|
258
|
+
const stream = fs.createReadStream(entry.file, { encoding: 'utf8' });
|
|
259
|
+
const lines = readline.createInterface({ input: stream, crlfDelay: Infinity });
|
|
260
|
+
try {
|
|
261
|
+
for await (const line of lines) {
|
|
262
|
+
const event = eventFrom(line, seen, entry.project);
|
|
263
|
+
if (event && event.at >= since) events.push(event);
|
|
264
|
+
}
|
|
265
|
+
} catch (err) {
|
|
266
|
+
// A half-written line at the tail of a live session is expected.
|
|
267
|
+
} finally {
|
|
268
|
+
lines.close();
|
|
269
|
+
stream.destroy();
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
events.sort((a, b) => a.at - b.at);
|
|
274
|
+
return events;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
function totals(events) {
|
|
278
|
+
let cost = 0;
|
|
279
|
+
let tokens = 0;
|
|
280
|
+
const parts = { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 };
|
|
281
|
+
for (const event of events) {
|
|
282
|
+
cost += event.cost;
|
|
283
|
+
tokens += event.tokens;
|
|
284
|
+
if (event.parts) {
|
|
285
|
+
parts.input += event.parts.input;
|
|
286
|
+
parts.cacheWrite += event.parts.cacheWrite;
|
|
287
|
+
parts.cacheRead += event.parts.cacheRead;
|
|
288
|
+
parts.output += event.parts.output;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
return { cost, tokens, turns: events.length, parts };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
// What each model actually cost, dearest first.
|
|
295
|
+
function byModel(events) {
|
|
296
|
+
const rows = new Map();
|
|
297
|
+
for (const event of events) {
|
|
298
|
+
const id = event.model || 'unknown';
|
|
299
|
+
if (!rows.has(id)) {
|
|
300
|
+
rows.set(id, {
|
|
301
|
+
model: id,
|
|
302
|
+
turns: 0,
|
|
303
|
+
tokens: 0,
|
|
304
|
+
cost: 0,
|
|
305
|
+
parts: { input: 0, cacheWrite: 0, cacheRead: 0, output: 0 },
|
|
306
|
+
});
|
|
307
|
+
}
|
|
308
|
+
const row = rows.get(id);
|
|
309
|
+
row.turns += 1;
|
|
310
|
+
row.tokens += event.tokens;
|
|
311
|
+
row.cost += event.cost;
|
|
312
|
+
if (event.parts) {
|
|
313
|
+
row.parts.input += event.parts.input;
|
|
314
|
+
row.parts.cacheWrite += event.parts.cacheWrite;
|
|
315
|
+
row.parts.cacheRead += event.parts.cacheRead;
|
|
316
|
+
row.parts.output += event.parts.output;
|
|
317
|
+
}
|
|
318
|
+
}
|
|
319
|
+
const list = [...rows.values()].sort((a, b) => b.cost - a.cost);
|
|
320
|
+
const total = list.reduce((sum, row) => sum + row.cost, 0);
|
|
321
|
+
for (const row of list) row.share = total > 0 ? row.cost / total : 0;
|
|
322
|
+
return list;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function dominantEffort(events) {
|
|
326
|
+
const counts = new Map();
|
|
327
|
+
for (const event of events) {
|
|
328
|
+
if (!event.effort) continue;
|
|
329
|
+
counts.set(event.effort, (counts.get(event.effort) || 0) + 1);
|
|
330
|
+
}
|
|
331
|
+
let best = null;
|
|
332
|
+
for (const entry of counts) {
|
|
333
|
+
if (!best || entry[1] > best[1]) best = entry;
|
|
334
|
+
}
|
|
335
|
+
return best ? best[0] : null;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// Which project directory the spend went to. Claude Code names these after
|
|
339
|
+
// the working directory, so they are recognisable even though the mangling
|
|
340
|
+
// is not reversible.
|
|
341
|
+
function byProject(events) {
|
|
342
|
+
const rows = new Map();
|
|
343
|
+
for (const event of events) {
|
|
344
|
+
const id = event.project || 'unknown';
|
|
345
|
+
if (!rows.has(id)) rows.set(id, { project: id, turns: 0, tokens: 0, cost: 0 });
|
|
346
|
+
const row = rows.get(id);
|
|
347
|
+
row.turns += 1;
|
|
348
|
+
row.tokens += event.tokens;
|
|
349
|
+
row.cost += event.cost;
|
|
350
|
+
}
|
|
351
|
+
const list = [...rows.values()].sort((a, b) => b.cost - a.cost);
|
|
352
|
+
const total = list.reduce((sum, row) => sum + row.cost, 0);
|
|
353
|
+
for (const row of list) row.share = total > 0 ? row.cost / total : 0;
|
|
354
|
+
return list;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// Keep the tail, which is the part that identifies the project.
|
|
358
|
+
function shortenProject(name, width) {
|
|
359
|
+
const value = String(name || '');
|
|
360
|
+
if (value.length <= width) return value;
|
|
361
|
+
return '...' + value.slice(value.length - (width - 3));
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Paid credits sit behind the plan allowance. Two blocks describe them and
|
|
365
|
+
// either can be absent, so read both and prefer whichever actually carries a
|
|
366
|
+
// number.
|
|
367
|
+
function creditsFrom(utilization) {
|
|
368
|
+
const extra = (utilization && utilization.extra_usage) || null;
|
|
369
|
+
const spend = (utilization && utilization.spend) || null;
|
|
370
|
+
if (!extra && !spend) return null;
|
|
371
|
+
|
|
372
|
+
const money = (amount) => {
|
|
373
|
+
if (!amount || typeof amount.amount_minor !== 'number') return null;
|
|
374
|
+
const exponent = typeof amount.exponent === 'number' ? amount.exponent : 2;
|
|
375
|
+
return amount.amount_minor / Math.pow(10, exponent);
|
|
376
|
+
};
|
|
377
|
+
|
|
378
|
+
const used = money(spend && spend.used);
|
|
379
|
+
const limit = money(spend && spend.limit);
|
|
380
|
+
const percent =
|
|
381
|
+
extra && typeof extra.utilization === 'number'
|
|
382
|
+
? extra.utilization
|
|
383
|
+
: spend && typeof spend.percent === 'number'
|
|
384
|
+
? spend.percent
|
|
385
|
+
: null;
|
|
386
|
+
|
|
387
|
+
return {
|
|
388
|
+
enabled: Boolean((extra && extra.is_enabled) || (spend && spend.enabled)),
|
|
389
|
+
everEnabled: Boolean(extra && extra.credits_ever_enabled),
|
|
390
|
+
limitReached: Boolean(extra && extra.spend_limit_reached),
|
|
391
|
+
used,
|
|
392
|
+
limit: limit === null && extra ? extra.monthly_limit : limit,
|
|
393
|
+
percent,
|
|
394
|
+
currency: (spend && spend.used && spend.used.currency) || (extra && extra.currency) || 'USD',
|
|
395
|
+
disabledReason: (extra && extra.disabled_reason) || (spend && spend.disabled_reason) || null,
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
// Everything the report needs about one limit window.
|
|
400
|
+
function buildWindow(spec, snapshot, events, now) {
|
|
401
|
+
const percent =
|
|
402
|
+
snapshot && typeof snapshot.utilization === 'number' ? snapshot.utilization : null;
|
|
403
|
+
const resetsAt = snapshot && snapshot.resets_at ? Date.parse(snapshot.resets_at) : null;
|
|
404
|
+
const hasReset = Number.isFinite(resetsAt);
|
|
405
|
+
const start = hasReset ? resetsAt - spec.span : now - spec.span;
|
|
406
|
+
|
|
407
|
+
const inWindow = events.filter((event) => event.at >= start && event.at <= now);
|
|
408
|
+
const spent = totals(inWindow);
|
|
409
|
+
|
|
410
|
+
const recentStart = Math.max(start, now - HOUR);
|
|
411
|
+
const recentEvents = events.filter((event) => event.at >= recentStart && event.at <= now);
|
|
412
|
+
const recent = totals(recentEvents);
|
|
413
|
+
const recentHours = Math.max((now - recentStart) / HOUR, 1 / 60);
|
|
414
|
+
|
|
415
|
+
const window = {
|
|
416
|
+
key: spec.key,
|
|
417
|
+
label: spec.label,
|
|
418
|
+
percentUsed: percent,
|
|
419
|
+
percentLeft: percent === null ? null : Math.max(0, 100 - percent),
|
|
420
|
+
resetsAt: hasReset ? resetsAt : null,
|
|
421
|
+
msToReset: hasReset ? resetsAt - now : null,
|
|
422
|
+
windowStart: start,
|
|
423
|
+
spentUSD: spent.cost,
|
|
424
|
+
spentTokens: spent.tokens,
|
|
425
|
+
turns: spent.turns,
|
|
426
|
+
recentTurns: recent.turns,
|
|
427
|
+
recentUSDPerHour: recent.cost / recentHours,
|
|
428
|
+
recentUSDPerTurn: recent.turns ? recent.cost / recent.turns : null,
|
|
429
|
+
usdPerPercent: null,
|
|
430
|
+
remainingUSD: null,
|
|
431
|
+
percentPerHour: null,
|
|
432
|
+
percentPerTurn: null,
|
|
433
|
+
turnsLeft: null,
|
|
434
|
+
headroomMs: null,
|
|
435
|
+
coarse: false,
|
|
436
|
+
stale: false,
|
|
437
|
+
verdict: 'unknown',
|
|
438
|
+
};
|
|
439
|
+
|
|
440
|
+
// Calibrate against this account: how many dollars of measured traffic
|
|
441
|
+
// moved the meter one point.
|
|
442
|
+
if (percent !== null && percent > 0 && spent.cost > 0) {
|
|
443
|
+
window.usdPerPercent = spent.cost / percent;
|
|
444
|
+
window.remainingUSD = window.usdPerPercent * window.percentLeft;
|
|
445
|
+
// The API reports whole numbers, so a low reading is a wide bracket.
|
|
446
|
+
window.coarse = percent < 5;
|
|
447
|
+
|
|
448
|
+
const perTurn = recent.turns
|
|
449
|
+
? recent.cost / recent.turns
|
|
450
|
+
: spent.cost / Math.max(spent.turns, 1);
|
|
451
|
+
window.percentPerTurn = perTurn / window.usdPerPercent;
|
|
452
|
+
window.percentPerHour = window.recentUSDPerHour / window.usdPerPercent;
|
|
453
|
+
if (window.percentPerTurn > 0) {
|
|
454
|
+
window.turnsLeft = Math.floor(window.percentLeft / window.percentPerTurn);
|
|
455
|
+
}
|
|
456
|
+
if (window.percentPerHour > 0) {
|
|
457
|
+
window.headroomMs = (window.percentLeft / window.percentPerHour) * HOUR;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
// A reset time in the past means the window already turned over and the
|
|
462
|
+
// cached percentage describes a window that no longer exists. Reporting it
|
|
463
|
+
// as current would claim the budget is gone when it has just come back.
|
|
464
|
+
window.stale = hasReset && resetsAt <= now;
|
|
465
|
+
if (window.stale) {
|
|
466
|
+
window.remainingUSD = null;
|
|
467
|
+
window.turnsLeft = null;
|
|
468
|
+
window.headroomMs = null;
|
|
469
|
+
window.percentPerHour = null;
|
|
470
|
+
window.percentPerTurn = null;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
if (percent === null) window.verdict = 'unknown';
|
|
474
|
+
else if (window.stale) window.verdict = 'rolled-over';
|
|
475
|
+
else if (percent >= 100) window.verdict = 'exhausted';
|
|
476
|
+
else if (window.headroomMs === null) window.verdict = 'idle';
|
|
477
|
+
else if (window.msToReset === null) window.verdict = 'burning';
|
|
478
|
+
else if (window.headroomMs >= window.msToReset) window.verdict = 'resets-first';
|
|
479
|
+
else window.verdict = 'runs-out';
|
|
480
|
+
|
|
481
|
+
return window;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// The window that will stop the work first.
|
|
485
|
+
function bindingWindow(windows) {
|
|
486
|
+
const known = windows.filter((w) => w.percentUsed !== null);
|
|
487
|
+
// Prefer windows we can still trust; fall back only if every one is stale.
|
|
488
|
+
const fresh = known.filter((w) => !w.stale);
|
|
489
|
+
const live = fresh.length ? fresh : known;
|
|
490
|
+
if (!live.length) return null;
|
|
491
|
+
|
|
492
|
+
const measured = live.filter((w) => w.headroomMs !== null);
|
|
493
|
+
if (measured.length) {
|
|
494
|
+
return measured.reduce((worst, w) => (w.headroomMs < worst.headroomMs ? w : worst));
|
|
495
|
+
}
|
|
496
|
+
return live.reduce((worst, w) => (w.percentUsed > worst.percentUsed ? w : worst));
|
|
497
|
+
}
|
|
498
|
+
|
|
499
|
+
function formatDuration(ms) {
|
|
500
|
+
if (ms === null || !Number.isFinite(ms)) return '-';
|
|
501
|
+
if (ms <= 0) return 'now';
|
|
502
|
+
const minutes = Math.round(ms / MINUTE);
|
|
503
|
+
if (minutes < 60) return minutes + 'm';
|
|
504
|
+
const hours = Math.floor(minutes / 60);
|
|
505
|
+
const rest = minutes % 60;
|
|
506
|
+
if (hours < 24) return rest ? hours + 'h ' + rest + 'm' : hours + 'h';
|
|
507
|
+
const days = Math.floor(hours / 24);
|
|
508
|
+
return days + 'd ' + (hours % 24) + 'h';
|
|
509
|
+
}
|
|
510
|
+
|
|
511
|
+
function formatClock(ms) {
|
|
512
|
+
if (!Number.isFinite(ms)) return '-';
|
|
513
|
+
return new Date(ms).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
function formatUSD(value) {
|
|
517
|
+
if (value === null || !Number.isFinite(value)) return '-';
|
|
518
|
+
if (value >= 100) return '$' + Math.round(value);
|
|
519
|
+
if (value >= 1) return '$' + value.toFixed(2);
|
|
520
|
+
return '$' + value.toFixed(3);
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
function formatTokens(value) {
|
|
524
|
+
if (!Number.isFinite(value)) return '-';
|
|
525
|
+
if (value >= 1e9) return (value / 1e9).toFixed(1) + 'B';
|
|
526
|
+
if (value >= 1e6) return (value / 1e6).toFixed(1) + 'M';
|
|
527
|
+
if (value >= 1e3) return Math.round(value / 1e3) + 'k';
|
|
528
|
+
return String(Math.round(value));
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function formatCount(value) {
|
|
532
|
+
if (value === null || !Number.isFinite(value)) return '-';
|
|
533
|
+
return Math.round(value).toLocaleString('en-US');
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
function pad(text, width) {
|
|
537
|
+
const value = String(text);
|
|
538
|
+
return value.length >= width ? value : value + ' '.repeat(width - value.length);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
function padLeft(text, width) {
|
|
542
|
+
const value = String(text);
|
|
543
|
+
return value.length >= width ? value : ' '.repeat(width - value.length) + value;
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// Pro, Max 5x, Max 20x, Team and Enterprise all have different amounts of
|
|
547
|
+
// room, which changes the advice even though it does not change the
|
|
548
|
+
// arithmetic. The window maths calibrates itself either way.
|
|
549
|
+
function detectPlan(oauth) {
|
|
550
|
+
const account = oauth || {};
|
|
551
|
+
const family = String(account.organizationType || '')
|
|
552
|
+
.replace(/^claude_/, '')
|
|
553
|
+
.toLowerCase();
|
|
554
|
+
const tier = String(account.userRateLimitTier || account.organizationRateLimitTier || '');
|
|
555
|
+
|
|
556
|
+
let id = family || 'unknown';
|
|
557
|
+
if (family === 'max') id = RATE_LIMIT_TIERS[tier] || 'max';
|
|
558
|
+
if (!PLANS[id]) id = 'unknown';
|
|
559
|
+
|
|
560
|
+
const plan = PLANS[id];
|
|
561
|
+
return {
|
|
562
|
+
id,
|
|
563
|
+
tier: tier || null,
|
|
564
|
+
// Show the raw value rather than "unknown" when it is a name we have
|
|
565
|
+
// simply not seen before.
|
|
566
|
+
label: id === 'unknown' && family ? family : plan.label,
|
|
567
|
+
advice: plan.advice,
|
|
568
|
+
};
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
function collect(now) {
|
|
572
|
+
const account = readJson(accountFile()) || {};
|
|
573
|
+
const settings = readJson(path.join(configDir(), 'settings.json')) || {};
|
|
574
|
+
const cache = account.cachedUsageUtilization || null;
|
|
575
|
+
const utilization = cache && cache.utilization ? cache.utilization : null;
|
|
576
|
+
const oauth = account.oauthAccount || {};
|
|
577
|
+
const plan = detectPlan(oauth);
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
now,
|
|
581
|
+
accountFile: accountFile(),
|
|
582
|
+
plan: plan.label,
|
|
583
|
+
planId: plan.id,
|
|
584
|
+
planTier: plan.tier,
|
|
585
|
+
planAdvice: plan.advice,
|
|
586
|
+
snapshotAgeMs: cache && cache.fetchedAtMs ? now - cache.fetchedAtMs : null,
|
|
587
|
+
utilization,
|
|
588
|
+
settings: {
|
|
589
|
+
model: settings.model || 'default',
|
|
590
|
+
effortLevel: settings.effortLevel || 'default',
|
|
591
|
+
},
|
|
592
|
+
extraUsage: utilization && utilization.extra_usage ? utilization.extra_usage : null,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
// No snapshot at all means no windows, which is what tells the report to
|
|
597
|
+
// explain itself rather than print a table of dashes.
|
|
598
|
+
function buildWindows(utilization, events, now) {
|
|
599
|
+
if (!utilization) return [];
|
|
600
|
+
return WINDOWS.map((spec) => {
|
|
601
|
+
const snapshot = utilization[spec.key];
|
|
602
|
+
// The per-model weekly windows only exist on some plans.
|
|
603
|
+
if (spec.key !== 'five_hour' && spec.key !== 'seven_day' && !snapshot) return null;
|
|
604
|
+
return buildWindow(spec, snapshot, events, now);
|
|
605
|
+
}).filter(Boolean);
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
async function report(now) {
|
|
609
|
+
const base = collect(now);
|
|
610
|
+
// A stale snapshot can put a window's start slightly further back than
|
|
611
|
+
// seven days, so give the scan a day of slack.
|
|
612
|
+
const earliest = now - 8 * DAY;
|
|
613
|
+
const events = await readEvents(earliest);
|
|
614
|
+
|
|
615
|
+
const windows = buildWindows(base.utilization, events, now);
|
|
616
|
+
|
|
617
|
+
const recentEvents = events.filter((event) => event.at >= now - HOUR);
|
|
618
|
+
const recent = totals(recentEvents);
|
|
619
|
+
|
|
620
|
+
const binding = bindingWindow(windows);
|
|
621
|
+
const scopeStart = binding ? binding.windowStart : now - 7 * DAY;
|
|
622
|
+
const scoped = events.filter((event) => event.at >= scopeStart && event.at <= now);
|
|
623
|
+
const scopedTotals = totals(scoped);
|
|
624
|
+
|
|
625
|
+
return Object.assign({}, base, {
|
|
626
|
+
windows,
|
|
627
|
+
binding,
|
|
628
|
+
credits: creditsFrom(base.utilization),
|
|
629
|
+
resumeAt: binding ? binding.resetsAt : null,
|
|
630
|
+
models: byModel(scoped),
|
|
631
|
+
projects: byProject(scoped),
|
|
632
|
+
tokens: scopedTotals.parts,
|
|
633
|
+
scopeLabel: binding ? binding.label : 'last 7 days',
|
|
634
|
+
recent: {
|
|
635
|
+
turns: recent.turns,
|
|
636
|
+
usd: recent.cost,
|
|
637
|
+
usdPerTurn: recent.turns ? recent.cost / recent.turns : null,
|
|
638
|
+
tokens: recent.tokens,
|
|
639
|
+
effort: dominantEffort(recentEvents),
|
|
640
|
+
},
|
|
641
|
+
measuredTurns: events.length,
|
|
642
|
+
});
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
function verdictLine(window) {
|
|
646
|
+
if (!window) {
|
|
647
|
+
return 'No limit snapshot on disk yet. Run /usage once in Claude Code to populate it.';
|
|
648
|
+
}
|
|
649
|
+
const name = window.label + ' limit';
|
|
650
|
+
switch (window.verdict) {
|
|
651
|
+
case 'rolled-over':
|
|
652
|
+
return (
|
|
653
|
+
'The ' + name + ' passed its reset time, so the cached reading is out ' +
|
|
654
|
+
'of date and the window has already turned over. Claude Code refreshes ' +
|
|
655
|
+
'it on the next request.'
|
|
656
|
+
);
|
|
657
|
+
case 'exhausted':
|
|
658
|
+
return 'The ' + name + ' is used up. It resets in ' + formatDuration(window.msToReset) + '.';
|
|
659
|
+
case 'resets-first':
|
|
660
|
+
return (
|
|
661
|
+
'The ' + name + ' is the binding one. At the current pace it lasts about ' +
|
|
662
|
+
formatDuration(window.headroomMs) + ', and it resets in ' +
|
|
663
|
+
formatDuration(window.msToReset) + ', so the window turns over before you run out.'
|
|
664
|
+
);
|
|
665
|
+
case 'runs-out':
|
|
666
|
+
return (
|
|
667
|
+
'The ' + name + ' is the binding one. At the current pace it runs out in about ' +
|
|
668
|
+
formatDuration(window.headroomMs) + ', which is ' +
|
|
669
|
+
formatDuration(window.msToReset - window.headroomMs) + ' short of the reset. ' +
|
|
670
|
+
'Size the work to fit, or slow the burn.'
|
|
671
|
+
);
|
|
672
|
+
case 'burning':
|
|
673
|
+
return (
|
|
674
|
+
'The ' + name + ' is at ' + window.percentUsed + '% and has about ' +
|
|
675
|
+
formatDuration(window.headroomMs) + ' left at the current pace. No reset time was reported.'
|
|
676
|
+
);
|
|
677
|
+
case 'idle':
|
|
678
|
+
return (
|
|
679
|
+
'The ' + name + ' is at ' + window.percentUsed +
|
|
680
|
+
'% with no recent traffic to measure. Percentages are current, pace is not.'
|
|
681
|
+
);
|
|
682
|
+
default:
|
|
683
|
+
return 'Not enough local data to project the ' + name + '.';
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
|
|
687
|
+
// One short line for the Claude Code status line. Deliberately reads only
|
|
688
|
+
// the cached percentages, never the transcripts, so it stays fast enough to
|
|
689
|
+
// run on every redraw.
|
|
690
|
+
function statusLine(collected) {
|
|
691
|
+
const utilization = collected && collected.utilization;
|
|
692
|
+
if (!utilization) return '';
|
|
693
|
+
|
|
694
|
+
const now = collected.now || Date.now();
|
|
695
|
+
const parts = [];
|
|
696
|
+
for (const spec of WINDOWS) {
|
|
697
|
+
const snapshot = utilization[spec.key];
|
|
698
|
+
if (!snapshot || typeof snapshot.utilization !== 'number') continue;
|
|
699
|
+
const resetsAt = snapshot.resets_at ? Date.parse(snapshot.resets_at) : null;
|
|
700
|
+
const msToReset = Number.isFinite(resetsAt) ? resetsAt - now : null;
|
|
701
|
+
parts.push({
|
|
702
|
+
label: SHORT_LABELS[spec.key] || spec.label,
|
|
703
|
+
percent: snapshot.utilization,
|
|
704
|
+
msToReset,
|
|
705
|
+
stale: msToReset !== null && msToReset <= 0,
|
|
706
|
+
});
|
|
707
|
+
}
|
|
708
|
+
if (!parts.length) return '';
|
|
709
|
+
|
|
710
|
+
const trusted = parts.filter((part) => !part.stale);
|
|
711
|
+
const worst = trusted.length
|
|
712
|
+
? trusted.reduce((a, b) => (b.percent > a.percent ? b : a))
|
|
713
|
+
: null;
|
|
714
|
+
const text = parts
|
|
715
|
+
.map((part) =>
|
|
716
|
+
part.stale
|
|
717
|
+
? part.label + ' rolling'
|
|
718
|
+
: part.label + ' ' + part.percent + '%' +
|
|
719
|
+
(part.msToReset === null ? '' : ' ' + formatDuration(part.msToReset))
|
|
720
|
+
)
|
|
721
|
+
.join(' ');
|
|
722
|
+
|
|
723
|
+
return (worst && worst.percent >= 90 ? 'LOW ' : '') + text;
|
|
724
|
+
}
|
|
725
|
+
|
|
726
|
+
function render(data) {
|
|
727
|
+
const lines = [];
|
|
728
|
+
lines.push('Claude Code usage');
|
|
729
|
+
lines.push('');
|
|
730
|
+
lines.push(' Plan ' + data.plan);
|
|
731
|
+
lines.push(
|
|
732
|
+
' Snapshot ' +
|
|
733
|
+
(data.snapshotAgeMs === null ? 'none on disk' : formatDuration(data.snapshotAgeMs) + ' old')
|
|
734
|
+
);
|
|
735
|
+
lines.push(' Settings model=' + data.settings.model + ' effort=' + data.settings.effortLevel);
|
|
736
|
+
const credits = data.credits;
|
|
737
|
+
if (credits) {
|
|
738
|
+
if (!credits.enabled) {
|
|
739
|
+
lines.push(' Credits off, work stops when the plan allowance runs out');
|
|
740
|
+
} else {
|
|
741
|
+
const amounts =
|
|
742
|
+
credits.used === null
|
|
743
|
+
? 'on'
|
|
744
|
+
: 'on, ' + formatUSD(credits.used) + ' used' +
|
|
745
|
+
(credits.limit ? ' of ' + formatUSD(credits.limit) : '') +
|
|
746
|
+
(credits.percent === null ? '' : ' (' + credits.percent + '%)');
|
|
747
|
+
lines.push(
|
|
748
|
+
' Credits ' + amounts +
|
|
749
|
+
(credits.limitReached ? ', spend limit reached' : '')
|
|
750
|
+
);
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
lines.push('');
|
|
754
|
+
|
|
755
|
+
if (!data.windows.length) {
|
|
756
|
+
lines.push(' No usage snapshot in ' + (data.accountFile || '~/.claude.json') + '.');
|
|
757
|
+
lines.push(' Run /usage once inside Claude Code to populate it, then try again.');
|
|
758
|
+
return lines.join('\n');
|
|
759
|
+
}
|
|
760
|
+
|
|
761
|
+
lines.push(
|
|
762
|
+
' ' + pad('Window', 15) + padLeft('Used', 6) + padLeft('Resets in', 12) +
|
|
763
|
+
padLeft('Left', 10) + padLeft('Turns left', 12)
|
|
764
|
+
);
|
|
765
|
+
for (const window of data.windows) {
|
|
766
|
+
const marker = data.binding && window.key === data.binding.key ? ' <- binding' : '';
|
|
767
|
+
lines.push(
|
|
768
|
+
' ' + pad(window.label, 15) +
|
|
769
|
+
padLeft(
|
|
770
|
+
window.stale ? 'stale' : window.percentUsed === null ? '-' : window.percentUsed + '%',
|
|
771
|
+
6
|
|
772
|
+
) +
|
|
773
|
+
padLeft(formatDuration(window.msToReset), 12) +
|
|
774
|
+
padLeft(formatUSD(window.remainingUSD), 10) +
|
|
775
|
+
padLeft(window.turnsLeft === null ? '-' : '~' + formatCount(window.turnsLeft), 12) +
|
|
776
|
+
marker
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
lines.push('');
|
|
780
|
+
|
|
781
|
+
if (data.models && data.models.length) {
|
|
782
|
+
lines.push(' Models in the ' + (data.scopeLabel || 'window') + ' window');
|
|
783
|
+
lines.push(
|
|
784
|
+
' ' + pad(' Model', 24) + padLeft('Turns', 7) + padLeft('Tokens', 10) +
|
|
785
|
+
padLeft('Output', 9) + padLeft('Share', 8)
|
|
786
|
+
);
|
|
787
|
+
for (const row of data.models) {
|
|
788
|
+
lines.push(
|
|
789
|
+
' ' + pad(' ' + row.model, 24) + padLeft(row.turns, 7) +
|
|
790
|
+
padLeft(formatTokens(row.tokens), 10) +
|
|
791
|
+
padLeft(formatTokens(row.parts.output), 9) +
|
|
792
|
+
padLeft(Math.round(row.share * 100) + '%', 8)
|
|
793
|
+
);
|
|
794
|
+
}
|
|
795
|
+
if (data.tokens) {
|
|
796
|
+
lines.push(
|
|
797
|
+
' Tokens input ' + formatTokens(data.tokens.input) +
|
|
798
|
+
', cache write ' + formatTokens(data.tokens.cacheWrite) +
|
|
799
|
+
', cache read ' + formatTokens(data.tokens.cacheRead) +
|
|
800
|
+
', output ' + formatTokens(data.tokens.output)
|
|
801
|
+
);
|
|
802
|
+
}
|
|
803
|
+
lines.push('');
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
if (data.projects && data.projects.length > 1) {
|
|
807
|
+
lines.push(' Projects in the ' + (data.scopeLabel || 'window') + ' window');
|
|
808
|
+
lines.push(
|
|
809
|
+
' ' + pad(' Project', 24) + padLeft('Turns', 7) + padLeft('Tokens', 10) +
|
|
810
|
+
padLeft('Share', 8)
|
|
811
|
+
);
|
|
812
|
+
for (const row of data.projects.slice(0, 5)) {
|
|
813
|
+
lines.push(
|
|
814
|
+
' ' + pad(' ' + shortenProject(row.project, 22), 24) +
|
|
815
|
+
padLeft(row.turns, 7) + padLeft(formatTokens(row.tokens), 10) +
|
|
816
|
+
padLeft(Math.round(row.share * 100) + '%', 8)
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
lines.push('');
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
if (data.recent.turns) {
|
|
823
|
+
lines.push(
|
|
824
|
+
' Recent pace ' + data.recent.turns + ' turns in the last hour, ' +
|
|
825
|
+
formatUSD(data.recent.usdPerTurn) + ' per turn' +
|
|
826
|
+
(data.recent.effort ? ', effort ' + data.recent.effort : '')
|
|
827
|
+
);
|
|
828
|
+
} else {
|
|
829
|
+
lines.push(' Recent pace no turns in the last hour');
|
|
830
|
+
}
|
|
831
|
+
lines.push(' Measured ' + formatCount(data.measuredTurns) + ' turns of local transcript');
|
|
832
|
+
|
|
833
|
+
if (data.binding && data.binding.coarse) {
|
|
834
|
+
lines.push(' Note the meter reads in whole percent, so a low reading is a wide bracket');
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
lines.push('');
|
|
838
|
+
lines.push(verdictLine(data.binding));
|
|
839
|
+
const binding = data.binding;
|
|
840
|
+
const outOfRoom = binding && (binding.verdict === 'runs-out' || binding.verdict === 'exhausted');
|
|
841
|
+
if (outOfRoom) {
|
|
842
|
+
// With credits on, Claude Code announces the switch itself and asks
|
|
843
|
+
// before drawing on them, so repeating it here would only add noise.
|
|
844
|
+
// With credits off there is no such prompt, and the wall is a hard stop.
|
|
845
|
+
const credits = data.credits;
|
|
846
|
+
if (!credits || !credits.enabled || credits.limitReached) {
|
|
847
|
+
lines.push(' Work stops when it does. Nothing carries on into paid credits.');
|
|
848
|
+
}
|
|
849
|
+
if (Number.isFinite(data.resumeAt)) {
|
|
850
|
+
lines.push(
|
|
851
|
+
' Land what exists, write the handoff, and resume after ' +
|
|
852
|
+
formatClock(data.resumeAt) + '.'
|
|
853
|
+
);
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
if (data.planAdvice) {
|
|
858
|
+
lines.push('');
|
|
859
|
+
lines.push(data.planAdvice);
|
|
860
|
+
}
|
|
861
|
+
return lines.join('\n');
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
async function main(argv) {
|
|
865
|
+
// The status line runs on every redraw, so it must not scan transcripts.
|
|
866
|
+
if (argv.indexOf('--status') !== -1) {
|
|
867
|
+
process.stdout.write(statusLine(collect(Date.now())) + '\n');
|
|
868
|
+
return 0;
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
const wantsJson = argv.indexOf('--json') !== -1;
|
|
872
|
+
const data = await report(Date.now());
|
|
873
|
+
if (wantsJson) {
|
|
874
|
+
process.stdout.write(JSON.stringify(data, null, 2) + '\n');
|
|
875
|
+
} else {
|
|
876
|
+
process.stdout.write(render(data) + '\n');
|
|
877
|
+
}
|
|
878
|
+
return 0;
|
|
879
|
+
}
|
|
880
|
+
|
|
881
|
+
if (require.main === module) {
|
|
882
|
+
main(process.argv.slice(2)).catch((err) => {
|
|
883
|
+
process.stderr.write('usage: ' + (err && err.message ? err.message : String(err)) + '\n');
|
|
884
|
+
process.exitCode = 1;
|
|
885
|
+
});
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
module.exports = {
|
|
889
|
+
main,
|
|
890
|
+
RATES,
|
|
891
|
+
WINDOWS,
|
|
892
|
+
rateFor,
|
|
893
|
+
costOf,
|
|
894
|
+
tokensOf,
|
|
895
|
+
eventFrom,
|
|
896
|
+
buildWindow,
|
|
897
|
+
buildWindows,
|
|
898
|
+
bindingWindow,
|
|
899
|
+
dominantEffort,
|
|
900
|
+
formatDuration,
|
|
901
|
+
formatUSD,
|
|
902
|
+
formatCount,
|
|
903
|
+
verdictLine,
|
|
904
|
+
render,
|
|
905
|
+
report,
|
|
906
|
+
collect,
|
|
907
|
+
detectPlan,
|
|
908
|
+
creditsFrom,
|
|
909
|
+
formatClock,
|
|
910
|
+
statusLine,
|
|
911
|
+
SHORT_LABELS,
|
|
912
|
+
tokenParts,
|
|
913
|
+
byModel,
|
|
914
|
+
byProject,
|
|
915
|
+
shortenProject,
|
|
916
|
+
formatTokens,
|
|
917
|
+
PLANS,
|
|
918
|
+
};
|