@jojonax/codex-copilot 1.5.5 → 1.6.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.
@@ -1,361 +1,361 @@
1
- /**
2
- * codex-copilot usage - Show token usage stats for recent AI sessions
3
- *
4
- * Data sources:
5
- * Codex CLI: ~/.codex/state_5.sqlite (threads) + rollout JSONL (token breakdown)
6
- * Cursor: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
7
- * Claude Code: ~/.claude/stats-cache.json
8
- * Antigravity: (no local usage data available)
9
- */
10
-
11
- import { execSync } from 'child_process';
12
- import { readFileSync, existsSync, readdirSync } from 'fs';
13
- import { resolve, join } from 'path';
14
- import { log } from '../utils/logger.js';
15
-
16
- const HOME = process.env.HOME || process.env.USERPROFILE || '~';
17
-
18
- // ─── Helpers ─────────────────────────────────────────────
19
-
20
- function formatTokens(n) {
21
- if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
22
- if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
23
- return String(n);
24
- }
25
-
26
- function padRight(s, len) {
27
- const str = String(s);
28
- return str.length >= len ? str.substring(0, len) : str + ' '.repeat(len - str.length);
29
- }
30
-
31
- function padLeft(s, len) {
32
- const str = String(s);
33
- return str.length >= len ? str.substring(0, len) : ' '.repeat(len - str.length) + str;
34
- }
35
-
36
- function printTable(headers, rows, colWidths, alignRight = []) {
37
- const topBorder = ' ┌─' + colWidths.map(w => '─'.repeat(w)).join('─┬─') + '─┐';
38
- const headSep = ' ├─' + colWidths.map(w => '─'.repeat(w)).join('─┼─') + '─┤';
39
- const botBorder = ' └─' + colWidths.map(w => '─'.repeat(w)).join('─┴─') + '─┘';
40
-
41
- const headerLine = headers.map((h, i) => padRight(h, colWidths[i])).join(' │ ');
42
- console.log(topBorder);
43
- console.log(` │ ${headerLine} │`);
44
- console.log(headSep);
45
-
46
- for (const row of rows) {
47
- const cells = row.map((cell, i) =>
48
- alignRight.includes(i) ? padLeft(String(cell), colWidths[i]) : padRight(String(cell), colWidths[i])
49
- );
50
- console.log(` │ ${cells.join(' │ ')} │`);
51
- }
52
- console.log(botBorder);
53
- }
54
-
55
- function safeExec(cmd) {
56
- try {
57
- return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
58
- } catch { return ''; }
59
- }
60
-
61
- // ─── Codex CLI ───────────────────────────────────────────
62
-
63
- function getCodexAccount() {
64
- const authPath = resolve(HOME, '.codex/auth.json');
65
- if (!existsSync(authPath)) return { email: null, sub: null };
66
-
67
- try {
68
- const auth = JSON.parse(readFileSync(authPath, 'utf-8'));
69
- const token = auth.tokens?.access_token || '';
70
- if (token.startsWith('eyJ')) {
71
- const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
72
- return { email: payload.email || null, sub: payload.sub || null };
73
- }
74
- return { email: null, sub: null };
75
- } catch { return { email: null, sub: null }; }
76
- }
77
-
78
- function getCodexPlan() {
79
- // Get plan_type from the most recent rollout's token_count event
80
- const sessionsDir = resolve(HOME, '.codex/sessions');
81
- if (!existsSync(sessionsDir)) return null;
82
-
83
- try {
84
- // Find the most recent rollout file
85
- const raw = safeExec(`find "${sessionsDir}" -name "rollout-*.jsonl" -type f | sort -r | head -1`);
86
- if (!raw) return null;
87
-
88
- const planLine = safeExec(`grep '"plan_type"' "${raw}" | tail -1`);
89
- if (!planLine) return null;
90
-
91
- const d = JSON.parse(planLine);
92
- return d?.payload?.rate_limits?.plan_type || null;
93
- } catch { return null; }
94
- }
95
-
96
- function getCodexSessions(limit = 3) {
97
- const dbPath = resolve(HOME, '.codex/state_5.sqlite');
98
- if (!existsSync(dbPath)) return [];
99
-
100
- try {
101
- const query = `SELECT id, replace(replace(substr(title,1,50),char(10),' '),char(13),' ') as title, tokens_used, model, datetime(created_at,'unixepoch','localtime') as created, datetime(updated_at,'unixepoch','localtime') as updated, cwd, rollout_path FROM threads WHERE archived=0 ORDER BY updated_at DESC LIMIT ${limit}`;
102
-
103
- const raw = safeExec(`sqlite3 -json "${dbPath}" "${query}"`);
104
- if (!raw) return [];
105
-
106
- return JSON.parse(raw).map(r => ({
107
- id: r.id,
108
- title: (r.title || '').substring(0, 50),
109
- tokens: r.tokens_used || 0,
110
- model: r.model || 'unknown',
111
- created: r.created || '',
112
- updated: r.updated || '',
113
- cwd: r.cwd || '',
114
- rolloutPath: r.rollout_path || '',
115
- }));
116
- } catch { return []; }
117
- }
118
-
119
- function getCodexTokenBreakdown(rolloutPath) {
120
- if (!rolloutPath || !existsSync(rolloutPath)) return null;
121
-
122
- try {
123
- // Get the last token_count event with info (has cumulative totals)
124
- const raw = safeExec(`grep '"token_count"' "${rolloutPath}" | grep '"total_token_usage"' | tail -1`);
125
- if (!raw) return null;
126
-
127
- const d = JSON.parse(raw);
128
- const usage = d?.payload?.info?.total_token_usage;
129
- const limits = d?.payload?.rate_limits;
130
- if (!usage) return null;
131
-
132
- return {
133
- input: usage.input_tokens || 0,
134
- cached: usage.cached_input_tokens || 0,
135
- output: usage.output_tokens || 0,
136
- reasoning: usage.reasoning_output_tokens || 0,
137
- total: usage.total_tokens || 0,
138
- rateLimits: limits ? {
139
- primaryUsed: limits.primary?.used_percent,
140
- secondaryUsed: limits.secondary?.used_percent,
141
- primaryResets: limits.primary?.resets_at,
142
- secondaryResets: limits.secondary?.resets_at,
143
- } : null,
144
- };
145
- } catch { return null; }
146
- }
147
-
148
- // ─── Cursor ──────────────────────────────────────────────
149
-
150
- function getCursorAccount() {
151
- const dbPath = resolve(HOME, 'Library/Application Support/Cursor/User/globalStorage/state.vscdb');
152
- if (!existsSync(dbPath)) return null;
153
-
154
- try {
155
- const email = safeExec(`sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key='cursorAuth/cachedEmail'"`);
156
- const plan = safeExec(`sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key='cursorAuth/stripeMembershipType'"`);
157
- if (!email) return null;
158
- return { email, plan: plan || 'free' };
159
- } catch { return null; }
160
- }
161
-
162
- // ─── Claude Code ─────────────────────────────────────────
163
-
164
- function getClaudeAccount() {
165
- // Claude Code doesn't store email locally in an easily accessible way
166
- const settingsPath = resolve(HOME, '.claude/settings.json');
167
- if (!existsSync(settingsPath)) return null;
168
- try {
169
- const s = JSON.parse(readFileSync(settingsPath, 'utf-8'));
170
- return s.email || null;
171
- } catch { return null; }
172
- }
173
-
174
- function getClaudeStats(days = 7) {
175
- const statsPath = resolve(HOME, '.claude/stats-cache.json');
176
- if (!existsSync(statsPath)) return null;
177
-
178
- try {
179
- const data = JSON.parse(readFileSync(statsPath, 'utf-8'));
180
- if (!data.dailyActivity?.length) return null;
181
-
182
- const recent = data.dailyActivity.slice(-days).filter(d => d.messageCount > 0);
183
- return {
184
- days: recent,
185
- total: recent.reduce((s, d) => ({
186
- messages: s.messages + (d.messageCount || 0),
187
- sessions: s.sessions + (d.sessionCount || 0),
188
- toolCalls: s.toolCalls + (d.toolCallCount || 0),
189
- }), { messages: 0, sessions: 0, toolCalls: 0 }),
190
- };
191
- } catch { return null; }
192
- }
193
-
194
- // ─── Antigravity / Gemini ────────────────────────────────
195
-
196
- function getAntigravityInfo() {
197
- // Antigravity doesn't expose local usage data currently
198
- return null;
199
- }
200
-
201
- // ─── Main ────────────────────────────────────────────────
202
-
203
- export async function usage() {
204
- log.title('📊 AI Usage Report');
205
- log.blank();
206
-
207
- let hasAny = false;
208
-
209
- // ═══ Codex CLI ═══
210
- const codexAccount = getCodexAccount();
211
- const codexPlan = getCodexPlan();
212
- const codexSessions = getCodexSessions(3);
213
-
214
- if (codexSessions.length > 0) {
215
- hasAny = true;
216
- const accountDisplay = codexAccount.email || codexAccount.sub || 'N/A';
217
- console.log(`\x1b[36m ◆ Codex CLI\x1b[0m`);
218
- console.log(` Account: ${accountDisplay}`);
219
- if (codexPlan) console.log(` Plan: ${codexPlan}`);
220
- log.blank();
221
-
222
- // Session overview table
223
- const rows = codexSessions.map(s => {
224
- const project = s.cwd.split('/').pop() || s.cwd;
225
- const title = s.title.length > 36 ? s.title.substring(0, 33) + '...' : s.title;
226
- return [title, s.model, formatTokens(s.tokens), project, s.updated.substring(0, 16)];
227
- });
228
-
229
- printTable(
230
- ['Session', 'Model', 'Tokens', 'Project', 'Last Active'],
231
- rows,
232
- [36, 16, 10, 14, 16],
233
- [2],
234
- );
235
-
236
- const totalTokens = codexSessions.reduce((sum, s) => sum + s.tokens, 0);
237
- log.info(` Total (${codexSessions.length} sessions): ${formatTokens(totalTokens)} tokens`);
238
- log.blank();
239
-
240
- // Collect all breakdowns first to compute per-session quota delta
241
- const allBreakdowns = codexSessions.map(s => ({
242
- session: s,
243
- breakdown: getCodexTokenBreakdown(s.rolloutPath),
244
- })).filter(b => b.breakdown);
245
-
246
- // Token breakdown per session
247
- for (let idx = 0; idx < allBreakdowns.length; idx++) {
248
- const { session, breakdown } = allBreakdowns[idx];
249
-
250
- const title = session.title.length > 50 ? session.title.substring(0, 47) + '...' : session.title;
251
- console.log(`\x1b[2m ┌ ${title}\x1b[0m`);
252
-
253
- // Effective (non-cached) tokens and cache rate
254
- const effectiveInput = Math.max(0, breakdown.input - breakdown.cached);
255
- const cacheRate = breakdown.input > 0
256
- ? ((breakdown.cached / breakdown.input) * 100).toFixed(0)
257
- : '0';
258
- const effectiveTotal = effectiveInput + breakdown.output;
259
-
260
- const bRows = [
261
- ['Input', formatTokens(breakdown.input)],
262
- [' ↳ Cached', formatTokens(breakdown.cached)],
263
- ['Output', formatTokens(breakdown.output)],
264
- [' ↳ Reasoning', formatTokens(breakdown.reasoning)],
265
- ['Total', formatTokens(breakdown.total)],
266
- ['Effective', `${formatTokens(effectiveTotal)} (cache ${cacheRate}%)`],
267
- ];
268
- for (const [label, val] of bRows) {
269
- console.log(`\x1b[2m │ ${padRight(label, 16)} ${padLeft(val, 24)}\x1b[0m`);
270
- }
271
-
272
- if (breakdown.rateLimits) {
273
- const rl = breakdown.rateLimits;
274
- const resetPrimary = rl.primaryResets ? new Date(rl.primaryResets * 1000).toLocaleString() : 'N/A';
275
-
276
- // Estimate this session's quota contribution by comparing with next (older) session
277
- // Sessions are sorted newest-first, so next entry is the previous session
278
- let quotaNote5h = '';
279
- let quotaNote7d = '';
280
- const nextBreakdown = idx < allBreakdowns.length - 1 ? allBreakdowns[idx + 1].breakdown : null;
281
- if (nextBreakdown?.rateLimits) {
282
- const nrl = nextBreakdown.rateLimits;
283
- // Only compute delta if same reset window (same resets_at timestamp)
284
- if (nrl.primaryResets === rl.primaryResets && rl.primaryUsed != null && nrl.primaryUsed != null) {
285
- const delta5h = rl.primaryUsed - nrl.primaryUsed;
286
- quotaNote5h = delta5h >= 0 ? ` (this session: +${delta5h}%)` : '';
287
- }
288
- if (nrl.secondaryResets === rl.secondaryResets && rl.secondaryUsed != null && nrl.secondaryUsed != null) {
289
- const delta7d = rl.secondaryUsed - nrl.secondaryUsed;
290
- quotaNote7d = delta7d >= 0 ? ` (this session: +${delta7d}%)` : '';
291
- }
292
- }
293
-
294
- console.log(`\x1b[2m │ Quota: ${rl.primaryUsed ?? '?'}% (5h)${quotaNote5h} / ${rl.secondaryUsed ?? '?'}% (7d)${quotaNote7d} Resets: ${resetPrimary}\x1b[0m`);
295
- }
296
- console.log(`\x1b[2m └──\x1b[0m`);
297
- }
298
- log.blank();
299
- }
300
-
301
- // ═══ Cursor ═══
302
- const cursorAccount = getCursorAccount();
303
- if (cursorAccount) {
304
- hasAny = true;
305
- console.log(`\x1b[33m ◆ Cursor\x1b[0m`);
306
- console.log(` Account: ${cursorAccount.email}`);
307
- console.log(` Plan: ${cursorAccount.plan}`);
308
- console.log(`\x1b[2m Note: Cursor does not expose per-session token usage locally.\x1b[0m`);
309
- console.log(`\x1b[2m Check usage at: https://www.cursor.com/settings\x1b[0m`);
310
- log.blank();
311
- }
312
-
313
- // ═══ Claude Code ═══
314
- const claudeStats = getClaudeStats(7);
315
- const claudeAccount = getClaudeAccount();
316
-
317
- if (claudeStats) {
318
- hasAny = true;
319
- console.log(`\x1b[35m ◆ Claude Code\x1b[0m`);
320
- console.log(` Account: ${claudeAccount || 'N/A (check ~/.claude/settings.json)'}`);
321
- log.blank();
322
-
323
- const rows = claudeStats.days.slice(-5).map(d => [
324
- d.date, String(d.sessionCount), String(d.messageCount), String(d.toolCallCount),
325
- ]);
326
-
327
- if (rows.length > 0) {
328
- printTable(
329
- ['Date', 'Sessions', 'Messages', 'Tool Calls'],
330
- rows,
331
- [12, 10, 10, 12],
332
- [1, 2, 3],
333
- );
334
- }
335
-
336
- log.info(` Total (7d): ${claudeStats.total.messages} msgs, ${claudeStats.total.toolCalls} tool calls, ${claudeStats.total.sessions} sessions`);
337
- console.log(`\x1b[2m Note: Claude Code tracks message/tool counts, not token counts locally.\x1b[0m`);
338
- console.log(`\x1b[2m Token-level billing: https://console.anthropic.com/settings/billing\x1b[0m`);
339
- log.blank();
340
- }
341
-
342
- // ═══ Antigravity / Gemini ═══
343
- const agInfo = getAntigravityInfo();
344
- if (!agInfo) {
345
- console.log(`\x1b[2m ◆ Antigravity / Gemini: No local usage data available.\x1b[0m`);
346
- console.log(`\x1b[2m Check: https://console.cloud.google.com/billing\x1b[0m`);
347
- log.blank();
348
- }
349
-
350
- // ═══ Codex CLI vs Client Shared Pool Note ═══
351
- if (codexSessions.length > 0) {
352
- log.blank();
353
- console.log(`\x1b[2m ℹ Codex CLI and the Codex VS Code extension share the same OpenAI account token pool.\x1b[0m`);
354
- console.log(`\x1b[2m Usage shown above is CLI-only. Combined usage: https://platform.openai.com/usage\x1b[0m`);
355
- }
356
-
357
- if (!hasAny) {
358
- log.warn('No AI tool usage data found.');
359
- log.info('Supported: Codex CLI, Cursor, Claude Code');
360
- }
361
- }
1
+ /**
2
+ * codex-copilot usage - Show token usage stats for recent AI sessions
3
+ *
4
+ * Data sources:
5
+ * Codex CLI: ~/.codex/state_5.sqlite (threads) + rollout JSONL (token breakdown)
6
+ * Cursor: ~/Library/Application Support/Cursor/User/globalStorage/state.vscdb
7
+ * Claude Code: ~/.claude/stats-cache.json
8
+ * Antigravity: (no local usage data available)
9
+ */
10
+
11
+ import { execSync } from 'child_process';
12
+ import { readFileSync, existsSync, readdirSync } from 'fs';
13
+ import { resolve, join } from 'path';
14
+ import { log } from '../utils/logger.js';
15
+
16
+ const HOME = process.env.HOME || process.env.USERPROFILE || '~';
17
+
18
+ // ─── Helpers ─────────────────────────────────────────────
19
+
20
+ function formatTokens(n) {
21
+ if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
22
+ if (n >= 1_000) return `${(n / 1_000).toFixed(1)}K`;
23
+ return String(n);
24
+ }
25
+
26
+ function padRight(s, len) {
27
+ const str = String(s);
28
+ return str.length >= len ? str.substring(0, len) : str + ' '.repeat(len - str.length);
29
+ }
30
+
31
+ function padLeft(s, len) {
32
+ const str = String(s);
33
+ return str.length >= len ? str.substring(0, len) : ' '.repeat(len - str.length) + str;
34
+ }
35
+
36
+ function printTable(headers, rows, colWidths, alignRight = []) {
37
+ const topBorder = ' ┌─' + colWidths.map(w => '─'.repeat(w)).join('─┬─') + '─┐';
38
+ const headSep = ' ├─' + colWidths.map(w => '─'.repeat(w)).join('─┼─') + '─┤';
39
+ const botBorder = ' └─' + colWidths.map(w => '─'.repeat(w)).join('─┴─') + '─┘';
40
+
41
+ const headerLine = headers.map((h, i) => padRight(h, colWidths[i])).join(' │ ');
42
+ console.log(topBorder);
43
+ console.log(` │ ${headerLine} │`);
44
+ console.log(headSep);
45
+
46
+ for (const row of rows) {
47
+ const cells = row.map((cell, i) =>
48
+ alignRight.includes(i) ? padLeft(String(cell), colWidths[i]) : padRight(String(cell), colWidths[i])
49
+ );
50
+ console.log(` │ ${cells.join(' │ ')} │`);
51
+ }
52
+ console.log(botBorder);
53
+ }
54
+
55
+ function safeExec(cmd) {
56
+ try {
57
+ return execSync(cmd, { encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }).trim();
58
+ } catch { return ''; }
59
+ }
60
+
61
+ // ─── Codex CLI ───────────────────────────────────────────
62
+
63
+ function getCodexAccount() {
64
+ const authPath = resolve(HOME, '.codex/auth.json');
65
+ if (!existsSync(authPath)) return { email: null, sub: null };
66
+
67
+ try {
68
+ const auth = JSON.parse(readFileSync(authPath, 'utf-8'));
69
+ const token = auth.tokens?.access_token || '';
70
+ if (token.startsWith('eyJ')) {
71
+ const payload = JSON.parse(Buffer.from(token.split('.')[1], 'base64').toString());
72
+ return { email: payload.email || null, sub: payload.sub || null };
73
+ }
74
+ return { email: null, sub: null };
75
+ } catch { return { email: null, sub: null }; }
76
+ }
77
+
78
+ function getCodexPlan() {
79
+ // Get plan_type from the most recent rollout's token_count event
80
+ const sessionsDir = resolve(HOME, '.codex/sessions');
81
+ if (!existsSync(sessionsDir)) return null;
82
+
83
+ try {
84
+ // Find the most recent rollout file
85
+ const raw = safeExec(`find "${sessionsDir}" -name "rollout-*.jsonl" -type f | sort -r | head -1`);
86
+ if (!raw) return null;
87
+
88
+ const planLine = safeExec(`grep '"plan_type"' "${raw}" | tail -1`);
89
+ if (!planLine) return null;
90
+
91
+ const d = JSON.parse(planLine);
92
+ return d?.payload?.rate_limits?.plan_type || null;
93
+ } catch { return null; }
94
+ }
95
+
96
+ function getCodexSessions(limit = 3) {
97
+ const dbPath = resolve(HOME, '.codex/state_5.sqlite');
98
+ if (!existsSync(dbPath)) return [];
99
+
100
+ try {
101
+ const query = `SELECT id, replace(replace(substr(title,1,50),char(10),' '),char(13),' ') as title, tokens_used, model, datetime(created_at,'unixepoch','localtime') as created, datetime(updated_at,'unixepoch','localtime') as updated, cwd, rollout_path FROM threads WHERE archived=0 ORDER BY updated_at DESC LIMIT ${limit}`;
102
+
103
+ const raw = safeExec(`sqlite3 -json "${dbPath}" "${query}"`);
104
+ if (!raw) return [];
105
+
106
+ return JSON.parse(raw).map(r => ({
107
+ id: r.id,
108
+ title: (r.title || '').substring(0, 50),
109
+ tokens: r.tokens_used || 0,
110
+ model: r.model || 'unknown',
111
+ created: r.created || '',
112
+ updated: r.updated || '',
113
+ cwd: r.cwd || '',
114
+ rolloutPath: r.rollout_path || '',
115
+ }));
116
+ } catch { return []; }
117
+ }
118
+
119
+ function getCodexTokenBreakdown(rolloutPath) {
120
+ if (!rolloutPath || !existsSync(rolloutPath)) return null;
121
+
122
+ try {
123
+ // Get the last token_count event with info (has cumulative totals)
124
+ const raw = safeExec(`grep '"token_count"' "${rolloutPath}" | grep '"total_token_usage"' | tail -1`);
125
+ if (!raw) return null;
126
+
127
+ const d = JSON.parse(raw);
128
+ const usage = d?.payload?.info?.total_token_usage;
129
+ const limits = d?.payload?.rate_limits;
130
+ if (!usage) return null;
131
+
132
+ return {
133
+ input: usage.input_tokens || 0,
134
+ cached: usage.cached_input_tokens || 0,
135
+ output: usage.output_tokens || 0,
136
+ reasoning: usage.reasoning_output_tokens || 0,
137
+ total: usage.total_tokens || 0,
138
+ rateLimits: limits ? {
139
+ primaryUsed: limits.primary?.used_percent,
140
+ secondaryUsed: limits.secondary?.used_percent,
141
+ primaryResets: limits.primary?.resets_at,
142
+ secondaryResets: limits.secondary?.resets_at,
143
+ } : null,
144
+ };
145
+ } catch { return null; }
146
+ }
147
+
148
+ // ─── Cursor ──────────────────────────────────────────────
149
+
150
+ function getCursorAccount() {
151
+ const dbPath = resolve(HOME, 'Library/Application Support/Cursor/User/globalStorage/state.vscdb');
152
+ if (!existsSync(dbPath)) return null;
153
+
154
+ try {
155
+ const email = safeExec(`sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key='cursorAuth/cachedEmail'"`);
156
+ const plan = safeExec(`sqlite3 "${dbPath}" "SELECT value FROM ItemTable WHERE key='cursorAuth/stripeMembershipType'"`);
157
+ if (!email) return null;
158
+ return { email, plan: plan || 'free' };
159
+ } catch { return null; }
160
+ }
161
+
162
+ // ─── Claude Code ─────────────────────────────────────────
163
+
164
+ function getClaudeAccount() {
165
+ // Claude Code doesn't store email locally in an easily accessible way
166
+ const settingsPath = resolve(HOME, '.claude/settings.json');
167
+ if (!existsSync(settingsPath)) return null;
168
+ try {
169
+ const s = JSON.parse(readFileSync(settingsPath, 'utf-8'));
170
+ return s.email || null;
171
+ } catch { return null; }
172
+ }
173
+
174
+ function getClaudeStats(days = 7) {
175
+ const statsPath = resolve(HOME, '.claude/stats-cache.json');
176
+ if (!existsSync(statsPath)) return null;
177
+
178
+ try {
179
+ const data = JSON.parse(readFileSync(statsPath, 'utf-8'));
180
+ if (!data.dailyActivity?.length) return null;
181
+
182
+ const recent = data.dailyActivity.slice(-days).filter(d => d.messageCount > 0);
183
+ return {
184
+ days: recent,
185
+ total: recent.reduce((s, d) => ({
186
+ messages: s.messages + (d.messageCount || 0),
187
+ sessions: s.sessions + (d.sessionCount || 0),
188
+ toolCalls: s.toolCalls + (d.toolCallCount || 0),
189
+ }), { messages: 0, sessions: 0, toolCalls: 0 }),
190
+ };
191
+ } catch { return null; }
192
+ }
193
+
194
+ // ─── Antigravity / Gemini ────────────────────────────────
195
+
196
+ function getAntigravityInfo() {
197
+ // Antigravity doesn't expose local usage data currently
198
+ return null;
199
+ }
200
+
201
+ // ─── Main ────────────────────────────────────────────────
202
+
203
+ export async function usage() {
204
+ log.title('📊 AI Usage Report');
205
+ log.blank();
206
+
207
+ let hasAny = false;
208
+
209
+ // ═══ Codex CLI ═══
210
+ const codexAccount = getCodexAccount();
211
+ const codexPlan = getCodexPlan();
212
+ const codexSessions = getCodexSessions(3);
213
+
214
+ if (codexSessions.length > 0) {
215
+ hasAny = true;
216
+ const accountDisplay = codexAccount.email || codexAccount.sub || 'N/A';
217
+ console.log(`\x1b[36m ◆ Codex CLI\x1b[0m`);
218
+ console.log(` Account: ${accountDisplay}`);
219
+ if (codexPlan) console.log(` Plan: ${codexPlan}`);
220
+ log.blank();
221
+
222
+ // Session overview table
223
+ const rows = codexSessions.map(s => {
224
+ const project = s.cwd.split('/').pop() || s.cwd;
225
+ const title = s.title.length > 36 ? s.title.substring(0, 33) + '...' : s.title;
226
+ return [title, s.model, formatTokens(s.tokens), project, s.updated.substring(0, 16)];
227
+ });
228
+
229
+ printTable(
230
+ ['Session', 'Model', 'Tokens', 'Project', 'Last Active'],
231
+ rows,
232
+ [36, 16, 10, 14, 16],
233
+ [2],
234
+ );
235
+
236
+ const totalTokens = codexSessions.reduce((sum, s) => sum + s.tokens, 0);
237
+ log.info(` Total (${codexSessions.length} sessions): ${formatTokens(totalTokens)} tokens`);
238
+ log.blank();
239
+
240
+ // Collect all breakdowns first to compute per-session quota delta
241
+ const allBreakdowns = codexSessions.map(s => ({
242
+ session: s,
243
+ breakdown: getCodexTokenBreakdown(s.rolloutPath),
244
+ })).filter(b => b.breakdown);
245
+
246
+ // Token breakdown per session
247
+ for (let idx = 0; idx < allBreakdowns.length; idx++) {
248
+ const { session, breakdown } = allBreakdowns[idx];
249
+
250
+ const title = session.title.length > 50 ? session.title.substring(0, 47) + '...' : session.title;
251
+ console.log(`\x1b[2m ┌ ${title}\x1b[0m`);
252
+
253
+ // Effective (non-cached) tokens and cache rate
254
+ const effectiveInput = Math.max(0, breakdown.input - breakdown.cached);
255
+ const cacheRate = breakdown.input > 0
256
+ ? ((breakdown.cached / breakdown.input) * 100).toFixed(0)
257
+ : '0';
258
+ const effectiveTotal = effectiveInput + breakdown.output;
259
+
260
+ const bRows = [
261
+ ['Input', formatTokens(breakdown.input)],
262
+ [' ↳ Cached', formatTokens(breakdown.cached)],
263
+ ['Output', formatTokens(breakdown.output)],
264
+ [' ↳ Reasoning', formatTokens(breakdown.reasoning)],
265
+ ['Total', formatTokens(breakdown.total)],
266
+ ['Effective', `${formatTokens(effectiveTotal)} (cache ${cacheRate}%)`],
267
+ ];
268
+ for (const [label, val] of bRows) {
269
+ console.log(`\x1b[2m │ ${padRight(label, 16)} ${padLeft(val, 24)}\x1b[0m`);
270
+ }
271
+
272
+ if (breakdown.rateLimits) {
273
+ const rl = breakdown.rateLimits;
274
+ const resetPrimary = rl.primaryResets ? new Date(rl.primaryResets * 1000).toLocaleString() : 'N/A';
275
+
276
+ // Estimate this session's quota contribution by comparing with next (older) session
277
+ // Sessions are sorted newest-first, so next entry is the previous session
278
+ let quotaNote5h = '';
279
+ let quotaNote7d = '';
280
+ const nextBreakdown = idx < allBreakdowns.length - 1 ? allBreakdowns[idx + 1].breakdown : null;
281
+ if (nextBreakdown?.rateLimits) {
282
+ const nrl = nextBreakdown.rateLimits;
283
+ // Only compute delta if same reset window (same resets_at timestamp)
284
+ if (nrl.primaryResets === rl.primaryResets && rl.primaryUsed != null && nrl.primaryUsed != null) {
285
+ const delta5h = rl.primaryUsed - nrl.primaryUsed;
286
+ quotaNote5h = delta5h >= 0 ? ` (this session: +${delta5h}%)` : '';
287
+ }
288
+ if (nrl.secondaryResets === rl.secondaryResets && rl.secondaryUsed != null && nrl.secondaryUsed != null) {
289
+ const delta7d = rl.secondaryUsed - nrl.secondaryUsed;
290
+ quotaNote7d = delta7d >= 0 ? ` (this session: +${delta7d}%)` : '';
291
+ }
292
+ }
293
+
294
+ console.log(`\x1b[2m │ Quota: ${rl.primaryUsed ?? '?'}% (5h)${quotaNote5h} / ${rl.secondaryUsed ?? '?'}% (7d)${quotaNote7d} Resets: ${resetPrimary}\x1b[0m`);
295
+ }
296
+ console.log(`\x1b[2m └──\x1b[0m`);
297
+ }
298
+ log.blank();
299
+ }
300
+
301
+ // ═══ Cursor ═══
302
+ const cursorAccount = getCursorAccount();
303
+ if (cursorAccount) {
304
+ hasAny = true;
305
+ console.log(`\x1b[33m ◆ Cursor\x1b[0m`);
306
+ console.log(` Account: ${cursorAccount.email}`);
307
+ console.log(` Plan: ${cursorAccount.plan}`);
308
+ console.log(`\x1b[2m Note: Cursor does not expose per-session token usage locally.\x1b[0m`);
309
+ console.log(`\x1b[2m Check usage at: https://www.cursor.com/settings\x1b[0m`);
310
+ log.blank();
311
+ }
312
+
313
+ // ═══ Claude Code ═══
314
+ const claudeStats = getClaudeStats(7);
315
+ const claudeAccount = getClaudeAccount();
316
+
317
+ if (claudeStats) {
318
+ hasAny = true;
319
+ console.log(`\x1b[35m ◆ Claude Code\x1b[0m`);
320
+ console.log(` Account: ${claudeAccount || 'N/A (check ~/.claude/settings.json)'}`);
321
+ log.blank();
322
+
323
+ const rows = claudeStats.days.slice(-5).map(d => [
324
+ d.date, String(d.sessionCount), String(d.messageCount), String(d.toolCallCount),
325
+ ]);
326
+
327
+ if (rows.length > 0) {
328
+ printTable(
329
+ ['Date', 'Sessions', 'Messages', 'Tool Calls'],
330
+ rows,
331
+ [12, 10, 10, 12],
332
+ [1, 2, 3],
333
+ );
334
+ }
335
+
336
+ log.info(` Total (7d): ${claudeStats.total.messages} msgs, ${claudeStats.total.toolCalls} tool calls, ${claudeStats.total.sessions} sessions`);
337
+ console.log(`\x1b[2m Note: Claude Code tracks message/tool counts, not token counts locally.\x1b[0m`);
338
+ console.log(`\x1b[2m Token-level billing: https://console.anthropic.com/settings/billing\x1b[0m`);
339
+ log.blank();
340
+ }
341
+
342
+ // ═══ Antigravity / Gemini ═══
343
+ const agInfo = getAntigravityInfo();
344
+ if (!agInfo) {
345
+ console.log(`\x1b[2m ◆ Antigravity / Gemini: No local usage data available.\x1b[0m`);
346
+ console.log(`\x1b[2m Check: https://console.cloud.google.com/billing\x1b[0m`);
347
+ log.blank();
348
+ }
349
+
350
+ // ═══ Codex CLI vs Client Shared Pool Note ═══
351
+ if (codexSessions.length > 0) {
352
+ log.blank();
353
+ console.log(`\x1b[2m ℹ Codex CLI and the Codex VS Code extension share the same OpenAI account token pool.\x1b[0m`);
354
+ console.log(`\x1b[2m Usage shown above is CLI-only. Combined usage: https://platform.openai.com/usage\x1b[0m`);
355
+ }
356
+
357
+ if (!hasAny) {
358
+ log.warn('No AI tool usage data found.');
359
+ log.info('Supported: Codex CLI, Cursor, Claude Code');
360
+ }
361
+ }