getaimeter 0.10.0 → 0.11.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/cli.js +657 -657
- package/package.json +1 -1
- package/tray.ps1 +208 -208
- package/watcher.js +36 -8
package/cli.js
CHANGED
|
@@ -1,657 +1,657 @@
|
|
|
1
|
-
#!/usr/bin/env node
|
|
2
|
-
'use strict';
|
|
3
|
-
|
|
4
|
-
const { getApiKey, saveApiKey, getWatchPaths, AIMETER_DIR } = require('./config');
|
|
5
|
-
const { startWatching } = require('./watcher');
|
|
6
|
-
const { install, uninstall, isInstalled, startNow, stopNow } = require('./service');
|
|
7
|
-
const { checkForUpdate, getCurrentVersion } = require('./update-check');
|
|
8
|
-
const { startTray, stopTray } = require('./tray');
|
|
9
|
-
|
|
10
|
-
const command = process.argv[2] || 'help';
|
|
11
|
-
|
|
12
|
-
// Version check on interactive commands (non-blocking)
|
|
13
|
-
if (['setup', 'status', 'summary', 'help', '--help', '-h'].includes(command)) {
|
|
14
|
-
checkForUpdate();
|
|
15
|
-
}
|
|
16
|
-
|
|
17
|
-
switch (command) {
|
|
18
|
-
case 'setup': runSetup(); break;
|
|
19
|
-
case 'watch': runWatch(); break;
|
|
20
|
-
case 'install': runInstall(); break;
|
|
21
|
-
case 'uninstall': runUninstall(); break;
|
|
22
|
-
case 'start': runStart(); break;
|
|
23
|
-
case 'stop': runStop(); break;
|
|
24
|
-
case 'status': runStatus(); break;
|
|
25
|
-
case 'logs': runLogs(); break;
|
|
26
|
-
case 'key': runKey(); break;
|
|
27
|
-
case 'summary': runSummary(); break;
|
|
28
|
-
case 'blocks': runBlocks(); break;
|
|
29
|
-
case 'version': case '--version': case '-v':
|
|
30
|
-
console.log(getCurrentVersion());
|
|
31
|
-
break;
|
|
32
|
-
case 'mcp': runMcp(); break;
|
|
33
|
-
case 'help': case '--help': case '-h':
|
|
34
|
-
printHelp();
|
|
35
|
-
break;
|
|
36
|
-
default:
|
|
37
|
-
console.log(`Unknown command: ${command}\n`);
|
|
38
|
-
printHelp();
|
|
39
|
-
process.exitCode = 1;
|
|
40
|
-
break;
|
|
41
|
-
}
|
|
42
|
-
|
|
43
|
-
// ---------------------------------------------------------------------------
|
|
44
|
-
// fetchFromApi — shared helper for API calls (native https, zero deps)
|
|
45
|
-
// ---------------------------------------------------------------------------
|
|
46
|
-
|
|
47
|
-
function fetchFromApi(urlPath, apiKey) {
|
|
48
|
-
return new Promise((resolve, reject) => {
|
|
49
|
-
const https = require('https');
|
|
50
|
-
const options = {
|
|
51
|
-
hostname: 'aimeter-api.fly.dev',
|
|
52
|
-
port: 443,
|
|
53
|
-
path: urlPath,
|
|
54
|
-
method: 'GET',
|
|
55
|
-
headers: {
|
|
56
|
-
'X-AIMeter-Key': apiKey,
|
|
57
|
-
},
|
|
58
|
-
};
|
|
59
|
-
|
|
60
|
-
const req = https.request(options, (res) => {
|
|
61
|
-
let body = '';
|
|
62
|
-
res.on('data', chunk => body += chunk);
|
|
63
|
-
res.on('end', () => {
|
|
64
|
-
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
65
|
-
try { resolve(JSON.parse(body)); }
|
|
66
|
-
catch { reject(new Error('Invalid JSON response')); }
|
|
67
|
-
} else {
|
|
68
|
-
reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
|
|
69
|
-
}
|
|
70
|
-
});
|
|
71
|
-
});
|
|
72
|
-
req.on('error', reject);
|
|
73
|
-
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
74
|
-
req.end();
|
|
75
|
-
});
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
// ---------------------------------------------------------------------------
|
|
79
|
-
// setup — full onboarding wizard
|
|
80
|
-
// ---------------------------------------------------------------------------
|
|
81
|
-
|
|
82
|
-
async function runSetup() {
|
|
83
|
-
const readline = require('readline');
|
|
84
|
-
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
85
|
-
const ask = (q) => new Promise((resolve) => rl.question(q, (a) => resolve(a.trim())));
|
|
86
|
-
|
|
87
|
-
console.log('');
|
|
88
|
-
console.log(' ╔══════════════════════════════════╗');
|
|
89
|
-
console.log(' ║ AIMeter Setup Wizard ║');
|
|
90
|
-
console.log(' ╚══════════════════════════════════╝');
|
|
91
|
-
console.log('');
|
|
92
|
-
|
|
93
|
-
// Step 1: API Key
|
|
94
|
-
console.log(' Step 1: API Key');
|
|
95
|
-
console.log(' ───────────────');
|
|
96
|
-
const existing = getApiKey();
|
|
97
|
-
let key = existing;
|
|
98
|
-
|
|
99
|
-
if (existing) {
|
|
100
|
-
console.log(` Current key: ${existing.slice(0, 8)}...${existing.slice(-4)}`);
|
|
101
|
-
const answer = await ask(' Press Enter to keep, or paste a new key: ');
|
|
102
|
-
if (answer.length > 0) {
|
|
103
|
-
if (!answer.startsWith('aim_')) {
|
|
104
|
-
console.log(' ✗ Invalid key (must start with aim_)');
|
|
105
|
-
rl.close();
|
|
106
|
-
return;
|
|
107
|
-
}
|
|
108
|
-
saveApiKey(answer);
|
|
109
|
-
key = answer;
|
|
110
|
-
console.log(' ✓ Key updated.');
|
|
111
|
-
} else {
|
|
112
|
-
console.log(' ✓ Key unchanged.');
|
|
113
|
-
}
|
|
114
|
-
} else {
|
|
115
|
-
console.log(' Sign up at: https://getaimeter.com');
|
|
116
|
-
console.log(' Then copy your API key from the dashboard.\n');
|
|
117
|
-
while (!key || !key.startsWith('aim_')) {
|
|
118
|
-
key = await ask(' Paste your API key: ');
|
|
119
|
-
if (!key.startsWith('aim_')) {
|
|
120
|
-
console.log(' ✗ Invalid — keys start with aim_');
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
saveApiKey(key);
|
|
124
|
-
console.log(' ✓ Key saved.');
|
|
125
|
-
}
|
|
126
|
-
|
|
127
|
-
// Step 2: Check watch paths
|
|
128
|
-
console.log('');
|
|
129
|
-
console.log(' Step 2: Detect Claude installations');
|
|
130
|
-
console.log(' ────────────────────────────────────');
|
|
131
|
-
const paths = getWatchPaths();
|
|
132
|
-
if (paths.length === 0) {
|
|
133
|
-
console.log(' ⚠ No Claude transcript directories found.');
|
|
134
|
-
console.log(' Install Claude Code (CLI, VS Code, or Desktop App) first.');
|
|
135
|
-
console.log(' Paths checked:');
|
|
136
|
-
console.log(' ~/.claude/projects/');
|
|
137
|
-
const platform = process.platform;
|
|
138
|
-
if (platform === 'win32') {
|
|
139
|
-
console.log(' %APPDATA%/Claude/local-agent-mode-sessions/');
|
|
140
|
-
} else if (platform === 'darwin') {
|
|
141
|
-
console.log(' ~/Library/Application Support/Claude/local-agent-mode-sessions/');
|
|
142
|
-
}
|
|
143
|
-
} else {
|
|
144
|
-
for (const p of paths) {
|
|
145
|
-
console.log(` ✓ ${p}`);
|
|
146
|
-
}
|
|
147
|
-
}
|
|
148
|
-
|
|
149
|
-
// Step 3: Install background service
|
|
150
|
-
console.log('');
|
|
151
|
-
console.log(' Step 3: Background service');
|
|
152
|
-
console.log(' ──────────────────────────');
|
|
153
|
-
|
|
154
|
-
if (isInstalled()) {
|
|
155
|
-
console.log(' ✓ Already installed as a background service.');
|
|
156
|
-
const reinstall = await ask(' Reinstall? (y/N): ');
|
|
157
|
-
if (reinstall.toLowerCase() === 'y') {
|
|
158
|
-
const result = install();
|
|
159
|
-
startNow();
|
|
160
|
-
console.log(` ✓ Reinstalled at: ${result.path}`);
|
|
161
|
-
}
|
|
162
|
-
} else {
|
|
163
|
-
const doInstall = await ask(' Install as background service? (Y/n): ');
|
|
164
|
-
if (doInstall.toLowerCase() !== 'n') {
|
|
165
|
-
const result = install();
|
|
166
|
-
startNow();
|
|
167
|
-
console.log(` ✓ Installed at: ${result.path}`);
|
|
168
|
-
if (result.platform === 'windows') {
|
|
169
|
-
console.log(' ✓ Will auto-start on login (Windows Startup folder)');
|
|
170
|
-
console.log(' ✓ Desktop shortcut created (double-click "AIMeter" to start)');
|
|
171
|
-
console.log(' ✓ Start Menu shortcut created');
|
|
172
|
-
} else if (result.platform === 'macos') {
|
|
173
|
-
console.log(' ✓ Will auto-start on login (launchd)');
|
|
174
|
-
} else {
|
|
175
|
-
console.log(' ✓ Will auto-start on login (systemd user service)');
|
|
176
|
-
}
|
|
177
|
-
} else {
|
|
178
|
-
console.log(' Skipped. You can run manually with: aimeter watch');
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
|
-
// Done!
|
|
183
|
-
console.log('');
|
|
184
|
-
console.log(' ╔══════════════════════════════════╗');
|
|
185
|
-
console.log(' ║ Setup complete! ║');
|
|
186
|
-
console.log(' ╚══════════════════════════════════╝');
|
|
187
|
-
console.log('');
|
|
188
|
-
console.log(' Your Claude usage is now being tracked.');
|
|
189
|
-
console.log(' View your dashboard at: https://getaimeter.com/dashboard');
|
|
190
|
-
console.log('');
|
|
191
|
-
console.log(' Commands:');
|
|
192
|
-
console.log(' aimeter status — check what\'s running');
|
|
193
|
-
console.log(' aimeter logs — view watcher logs');
|
|
194
|
-
console.log(' aimeter stop — stop the watcher');
|
|
195
|
-
console.log('');
|
|
196
|
-
|
|
197
|
-
rl.close();
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
// ---------------------------------------------------------------------------
|
|
201
|
-
// watch — foreground mode
|
|
202
|
-
// ---------------------------------------------------------------------------
|
|
203
|
-
|
|
204
|
-
function runWatch() {
|
|
205
|
-
const fs = require('fs');
|
|
206
|
-
const path = require('path');
|
|
207
|
-
const lockFile = path.join(AIMETER_DIR, 'watcher.lock');
|
|
208
|
-
|
|
209
|
-
// Check if another instance is already running
|
|
210
|
-
try {
|
|
211
|
-
if (fs.existsSync(lockFile)) {
|
|
212
|
-
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
|
|
213
|
-
// Check if the PID is still alive
|
|
214
|
-
try {
|
|
215
|
-
process.kill(lockData.pid, 0); // signal 0 = just check if alive
|
|
216
|
-
console.log(`Another watcher is already running (PID ${lockData.pid}). Use 'aimeter stop' first.`);
|
|
217
|
-
process.exitCode = 1;
|
|
218
|
-
return;
|
|
219
|
-
} catch {
|
|
220
|
-
// PID is dead — stale lock, remove it
|
|
221
|
-
}
|
|
222
|
-
}
|
|
223
|
-
} catch {}
|
|
224
|
-
|
|
225
|
-
// Write lock file
|
|
226
|
-
fs.mkdirSync(AIMETER_DIR, { recursive: true });
|
|
227
|
-
fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
228
|
-
|
|
229
|
-
const cleanup = startWatching();
|
|
230
|
-
|
|
231
|
-
// Launch system tray icon (non-blocking, fails silently if systray2 not available)
|
|
232
|
-
const showTray = !process.argv.includes('--no-tray');
|
|
233
|
-
if (showTray) {
|
|
234
|
-
startTray(() => {
|
|
235
|
-
cleanup();
|
|
236
|
-
try { fs.unlinkSync(lockFile); } catch {}
|
|
237
|
-
}).catch(() => {}); // ignore tray errors
|
|
238
|
-
}
|
|
239
|
-
|
|
240
|
-
const cleanupAll = () => {
|
|
241
|
-
cleanup();
|
|
242
|
-
stopTray();
|
|
243
|
-
try { fs.unlinkSync(lockFile); } catch {}
|
|
244
|
-
process.exit(0);
|
|
245
|
-
};
|
|
246
|
-
|
|
247
|
-
process.on('SIGINT', cleanupAll);
|
|
248
|
-
process.on('SIGTERM', cleanupAll);
|
|
249
|
-
process.on('exit', () => {
|
|
250
|
-
stopTray();
|
|
251
|
-
try { fs.unlinkSync(lockFile); } catch {}
|
|
252
|
-
});
|
|
253
|
-
|
|
254
|
-
process.on('uncaughtException', (err) => {
|
|
255
|
-
console.error('[aimeter] Uncaught:', err.message);
|
|
256
|
-
});
|
|
257
|
-
|
|
258
|
-
process.on('unhandledRejection', (reason) => {
|
|
259
|
-
console.error('[aimeter] Unhandled rejection:', reason);
|
|
260
|
-
});
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
// ---------------------------------------------------------------------------
|
|
264
|
-
// install / uninstall / start / stop
|
|
265
|
-
// ---------------------------------------------------------------------------
|
|
266
|
-
|
|
267
|
-
function runInstall() {
|
|
268
|
-
if (!getApiKey()) {
|
|
269
|
-
console.log('No API key configured. Run: aimeter setup');
|
|
270
|
-
process.exitCode = 1;
|
|
271
|
-
return;
|
|
272
|
-
}
|
|
273
|
-
|
|
274
|
-
const result = install();
|
|
275
|
-
console.log(`Installed background service (${result.platform})`);
|
|
276
|
-
console.log(` File: ${result.path}`);
|
|
277
|
-
console.log('Starting...');
|
|
278
|
-
startNow();
|
|
279
|
-
console.log('Done. AIMeter is now tracking your Claude usage.');
|
|
280
|
-
}
|
|
281
|
-
|
|
282
|
-
function runUninstall() {
|
|
283
|
-
stopNow();
|
|
284
|
-
const removed = uninstall();
|
|
285
|
-
if (removed) {
|
|
286
|
-
console.log('Background service removed.');
|
|
287
|
-
} else {
|
|
288
|
-
console.log('No background service found to remove.');
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
|
|
292
|
-
function runStart() {
|
|
293
|
-
if (!isInstalled()) {
|
|
294
|
-
console.log('Service not installed. Run: aimeter install');
|
|
295
|
-
process.exitCode = 1;
|
|
296
|
-
return;
|
|
297
|
-
}
|
|
298
|
-
|
|
299
|
-
// Clean up stale lock file if the previous process died
|
|
300
|
-
const fs = require('fs');
|
|
301
|
-
const pathMod = require('path');
|
|
302
|
-
const lockFile = pathMod.join(AIMETER_DIR, 'watcher.lock');
|
|
303
|
-
try {
|
|
304
|
-
if (fs.existsSync(lockFile)) {
|
|
305
|
-
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
|
|
306
|
-
try {
|
|
307
|
-
process.kill(lockData.pid, 0);
|
|
308
|
-
// PID is alive — watcher is already running
|
|
309
|
-
console.log(`AIMeter watcher is already running (PID ${lockData.pid}).`);
|
|
310
|
-
return;
|
|
311
|
-
} catch {
|
|
312
|
-
// PID is dead — stale lock, remove it
|
|
313
|
-
fs.unlinkSync(lockFile);
|
|
314
|
-
}
|
|
315
|
-
}
|
|
316
|
-
} catch {}
|
|
317
|
-
|
|
318
|
-
startNow();
|
|
319
|
-
console.log('AIMeter watcher started.');
|
|
320
|
-
}
|
|
321
|
-
|
|
322
|
-
function runStop() {
|
|
323
|
-
stopNow();
|
|
324
|
-
console.log('AIMeter watcher stopped.');
|
|
325
|
-
}
|
|
326
|
-
|
|
327
|
-
// ---------------------------------------------------------------------------
|
|
328
|
-
// status
|
|
329
|
-
// ---------------------------------------------------------------------------
|
|
330
|
-
|
|
331
|
-
function runStatus() {
|
|
332
|
-
const fs = require('fs');
|
|
333
|
-
const path = require('path');
|
|
334
|
-
|
|
335
|
-
console.log('');
|
|
336
|
-
console.log(' AIMeter Status');
|
|
337
|
-
console.log(' ══════════════');
|
|
338
|
-
console.log('');
|
|
339
|
-
|
|
340
|
-
// API key
|
|
341
|
-
const key = getApiKey();
|
|
342
|
-
console.log(` API key: ${key ? key.slice(0, 8) + '...' + key.slice(-4) : '✗ NOT SET (run: aimeter setup)'}`);
|
|
343
|
-
|
|
344
|
-
// Service
|
|
345
|
-
const installed = isInstalled();
|
|
346
|
-
console.log(` Service: ${installed ? '✓ installed' : '✗ not installed'}`);
|
|
347
|
-
|
|
348
|
-
// Watch paths
|
|
349
|
-
const paths = getWatchPaths();
|
|
350
|
-
console.log(` Watch paths: ${paths.length > 0 ? '' : '(none found)'}`);
|
|
351
|
-
for (const p of paths) console.log(` → ${p}`);
|
|
352
|
-
|
|
353
|
-
// State
|
|
354
|
-
const stateFile = path.join(AIMETER_DIR, 'state.json');
|
|
355
|
-
try {
|
|
356
|
-
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
357
|
-
const fileCount = Object.keys(state.fileOffsets || {}).length;
|
|
358
|
-
console.log(` Files: ${fileCount} tracked`);
|
|
359
|
-
console.log(` Last active: ${state.lastSaved || 'never'}`);
|
|
360
|
-
} catch {
|
|
361
|
-
console.log(' State: no data yet (first run?)');
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
// Log file
|
|
365
|
-
const logFile = path.join(AIMETER_DIR, 'watcher.log');
|
|
366
|
-
try {
|
|
367
|
-
const stat = fs.statSync(logFile);
|
|
368
|
-
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
369
|
-
console.log(` Log file: ${logFile} (${sizeMB} MB)`);
|
|
370
|
-
} catch {
|
|
371
|
-
console.log(' Log file: (none)');
|
|
372
|
-
}
|
|
373
|
-
|
|
374
|
-
console.log('');
|
|
375
|
-
}
|
|
376
|
-
|
|
377
|
-
// ---------------------------------------------------------------------------
|
|
378
|
-
// logs — tail the watcher log
|
|
379
|
-
// ---------------------------------------------------------------------------
|
|
380
|
-
|
|
381
|
-
function runLogs() {
|
|
382
|
-
const fs = require('fs');
|
|
383
|
-
const path = require('path');
|
|
384
|
-
const logFile = path.join(AIMETER_DIR, 'watcher.log');
|
|
385
|
-
|
|
386
|
-
if (!fs.existsSync(logFile)) {
|
|
387
|
-
console.log('No log file yet. Start the watcher first.');
|
|
388
|
-
return;
|
|
389
|
-
}
|
|
390
|
-
|
|
391
|
-
// Show last 50 lines, then follow
|
|
392
|
-
const lines = process.argv[3] ? parseInt(process.argv[3], 10) : 50;
|
|
393
|
-
const content = fs.readFileSync(logFile, 'utf8');
|
|
394
|
-
const allLines = content.split('\n');
|
|
395
|
-
const tail = allLines.slice(-lines).join('\n');
|
|
396
|
-
console.log(tail);
|
|
397
|
-
|
|
398
|
-
// Follow mode — poll every second (fs.watch is unreliable on Windows)
|
|
399
|
-
console.log('\n--- Watching for new entries (Ctrl+C to stop) ---\n');
|
|
400
|
-
|
|
401
|
-
let fileSize = fs.statSync(logFile).size;
|
|
402
|
-
setInterval(() => {
|
|
403
|
-
try {
|
|
404
|
-
const newSize = fs.statSync(logFile).size;
|
|
405
|
-
if (newSize > fileSize) {
|
|
406
|
-
const fd = fs.openSync(logFile, 'r');
|
|
407
|
-
const buf = Buffer.alloc(newSize - fileSize);
|
|
408
|
-
fs.readSync(fd, buf, 0, buf.length, fileSize);
|
|
409
|
-
fs.closeSync(fd);
|
|
410
|
-
process.stdout.write(buf.toString('utf8'));
|
|
411
|
-
fileSize = newSize;
|
|
412
|
-
}
|
|
413
|
-
} catch {}
|
|
414
|
-
}, 1000);
|
|
415
|
-
}
|
|
416
|
-
|
|
417
|
-
// ---------------------------------------------------------------------------
|
|
418
|
-
// key — quick key management
|
|
419
|
-
// ---------------------------------------------------------------------------
|
|
420
|
-
|
|
421
|
-
function runKey() {
|
|
422
|
-
const key = getApiKey();
|
|
423
|
-
if (key) {
|
|
424
|
-
console.log(key);
|
|
425
|
-
} else {
|
|
426
|
-
console.log('No API key configured. Run: aimeter setup');
|
|
427
|
-
process.exitCode = 1;
|
|
428
|
-
}
|
|
429
|
-
}
|
|
430
|
-
|
|
431
|
-
// ---------------------------------------------------------------------------
|
|
432
|
-
// mcp — start MCP server for Claude Code/Desktop
|
|
433
|
-
// ---------------------------------------------------------------------------
|
|
434
|
-
|
|
435
|
-
function runMcp() {
|
|
436
|
-
require('./mcp').startMcpServer();
|
|
437
|
-
}
|
|
438
|
-
|
|
439
|
-
// ---------------------------------------------------------------------------
|
|
440
|
-
// summary — show current usage summary from API
|
|
441
|
-
// ---------------------------------------------------------------------------
|
|
442
|
-
|
|
443
|
-
async function runSummary() {
|
|
444
|
-
const https = require('https');
|
|
445
|
-
const apiKey = getApiKey();
|
|
446
|
-
if (!apiKey) {
|
|
447
|
-
console.log('No API key configured. Run: aimeter setup');
|
|
448
|
-
process.exitCode = 1;
|
|
449
|
-
return;
|
|
450
|
-
}
|
|
451
|
-
|
|
452
|
-
// Check for --json flag
|
|
453
|
-
const jsonMode = process.argv.includes('--json');
|
|
454
|
-
|
|
455
|
-
try {
|
|
456
|
-
// Fetch current usage from API
|
|
457
|
-
const data = await fetchFromApi('/api/usage/current', apiKey);
|
|
458
|
-
|
|
459
|
-
if (jsonMode) {
|
|
460
|
-
console.log(JSON.stringify(data, null, 2));
|
|
461
|
-
return;
|
|
462
|
-
}
|
|
463
|
-
|
|
464
|
-
// Pretty print
|
|
465
|
-
const fmtTokens = (n) => {
|
|
466
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
467
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
468
|
-
return String(n);
|
|
469
|
-
};
|
|
470
|
-
const fmtCost = (c) => {
|
|
471
|
-
if (c >= 1) return `$${c.toFixed(2)}`;
|
|
472
|
-
if (c >= 0.01) return `$${c.toFixed(3)}`;
|
|
473
|
-
return `$${c.toFixed(4)}`;
|
|
474
|
-
};
|
|
475
|
-
|
|
476
|
-
// Calculate total cost from byModel
|
|
477
|
-
const totalCost = (data.byModel || []).reduce((sum, m) => sum + (m.estimatedCost || 0), 0);
|
|
478
|
-
|
|
479
|
-
// Reset countdown
|
|
480
|
-
let resetStr = 'N/A';
|
|
481
|
-
if (data.nextReset) {
|
|
482
|
-
const resetMs = new Date(data.nextReset) - Date.now();
|
|
483
|
-
if (resetMs > 0) {
|
|
484
|
-
const h = Math.floor(resetMs / 3600000);
|
|
485
|
-
const m = Math.floor((resetMs % 3600000) / 60000);
|
|
486
|
-
resetStr = `${h}h ${m}m`;
|
|
487
|
-
} else {
|
|
488
|
-
resetStr = 'now';
|
|
489
|
-
}
|
|
490
|
-
}
|
|
491
|
-
|
|
492
|
-
console.log('');
|
|
493
|
-
console.log(' AIMeter — Current Usage (5-hour window)');
|
|
494
|
-
console.log(' ════════════════════════════════════════');
|
|
495
|
-
console.log('');
|
|
496
|
-
console.log(` Cost: ${fmtCost(totalCost)}`);
|
|
497
|
-
console.log(` Input: ${fmtTokens(data.totalInputTokens || 0)}`);
|
|
498
|
-
console.log(` Output: ${fmtTokens(data.totalOutputTokens || 0)}`);
|
|
499
|
-
console.log(` Thinking: ${fmtTokens(data.totalThinkingTokens || 0)}`);
|
|
500
|
-
console.log(` Cache Read: ${fmtTokens(data.totalCacheReadTokens || 0)}`);
|
|
501
|
-
console.log(` Requests: ${data.windowRequests || 0}`);
|
|
502
|
-
console.log(` Reset in: ${resetStr}`);
|
|
503
|
-
|
|
504
|
-
// By source
|
|
505
|
-
if (data.bySource && data.bySource.length > 0) {
|
|
506
|
-
console.log('');
|
|
507
|
-
console.log(' By Source:');
|
|
508
|
-
const sourceLabels = {
|
|
509
|
-
cli: 'Terminal', vscode: 'VS Code', desktop_app: 'Desktop',
|
|
510
|
-
cursor: 'Cursor', codex_cli: 'Codex CLI', codex_vscode: 'Codex VS',
|
|
511
|
-
gemini_cli: 'Gemini', copilot_cli: 'Copilot', copilot_vscode: 'Copilot VS',
|
|
512
|
-
};
|
|
513
|
-
for (const s of data.bySource) {
|
|
514
|
-
const label = sourceLabels[s.source] || s.source;
|
|
515
|
-
console.log(` ${label.padEnd(14)} ${String(s.requests).padStart(4)} req`);
|
|
516
|
-
}
|
|
517
|
-
}
|
|
518
|
-
|
|
519
|
-
// By model
|
|
520
|
-
if (data.byModel && data.byModel.length > 0) {
|
|
521
|
-
console.log('');
|
|
522
|
-
console.log(' By Model:');
|
|
523
|
-
for (const m of data.byModel) {
|
|
524
|
-
const label = m.model.replace(/-\d{8}$/, '');
|
|
525
|
-
console.log(` ${label.padEnd(22)} ${String(m.requests).padStart(4)} req ${fmtCost(m.estimatedCost || 0)}`);
|
|
526
|
-
}
|
|
527
|
-
}
|
|
528
|
-
|
|
529
|
-
// Previous window comparison
|
|
530
|
-
if (data.previous && data.previous.totalCost > 0) {
|
|
531
|
-
const prevCost = data.previous.totalCost;
|
|
532
|
-
const change = totalCost - prevCost;
|
|
533
|
-
const pct = prevCost > 0 ? ((change / prevCost) * 100).toFixed(0) : '∞';
|
|
534
|
-
const arrow = change >= 0 ? '↑' : '↓';
|
|
535
|
-
console.log('');
|
|
536
|
-
console.log(` vs Previous: ${arrow} ${Math.abs(change).toFixed(2)} (${pct}%)`);
|
|
537
|
-
}
|
|
538
|
-
|
|
539
|
-
console.log('');
|
|
540
|
-
} catch (err) {
|
|
541
|
-
console.error(`Error fetching usage: ${err.message}`);
|
|
542
|
-
process.exitCode = 1;
|
|
543
|
-
}
|
|
544
|
-
}
|
|
545
|
-
|
|
546
|
-
// ---------------------------------------------------------------------------
|
|
547
|
-
// blocks — show 5-hour billing blocks
|
|
548
|
-
// ---------------------------------------------------------------------------
|
|
549
|
-
|
|
550
|
-
async function runBlocks() {
|
|
551
|
-
const apiKey = getApiKey();
|
|
552
|
-
if (!apiKey) {
|
|
553
|
-
console.log('No API key configured. Run: aimeter setup');
|
|
554
|
-
process.exitCode = 1;
|
|
555
|
-
return;
|
|
556
|
-
}
|
|
557
|
-
|
|
558
|
-
const jsonMode = process.argv.includes('--json');
|
|
559
|
-
const days = parseInt(process.argv[3]) || 3;
|
|
560
|
-
|
|
561
|
-
try {
|
|
562
|
-
const data = await fetchFromApi(`/api/usage/blocks?days=${days}`, apiKey);
|
|
563
|
-
|
|
564
|
-
if (jsonMode) {
|
|
565
|
-
console.log(JSON.stringify(data, null, 2));
|
|
566
|
-
return;
|
|
567
|
-
}
|
|
568
|
-
|
|
569
|
-
console.log('');
|
|
570
|
-
console.log(` AIMeter — Billing Blocks (last ${days} days)`);
|
|
571
|
-
console.log(' ════════════════════════════════════════');
|
|
572
|
-
console.log('');
|
|
573
|
-
|
|
574
|
-
if (!data.blocks || data.blocks.length === 0) {
|
|
575
|
-
console.log(' No billing blocks found.');
|
|
576
|
-
console.log('');
|
|
577
|
-
return;
|
|
578
|
-
}
|
|
579
|
-
|
|
580
|
-
for (const block of data.blocks) {
|
|
581
|
-
const start = new Date(block.startTime);
|
|
582
|
-
const fmtTime = (d) => d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
583
|
-
const active = block.isActive ? ' ◉ ACTIVE' : '';
|
|
584
|
-
|
|
585
|
-
console.log(` ${fmtTime(start)}${active}`);
|
|
586
|
-
console.log(` ────────────────────────────`);
|
|
587
|
-
|
|
588
|
-
const fmtCost = (c) => c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(3)}`;
|
|
589
|
-
const fmtTokens = (n) => {
|
|
590
|
-
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
591
|
-
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
|
592
|
-
return String(n);
|
|
593
|
-
};
|
|
594
|
-
|
|
595
|
-
console.log(` Cost: ${fmtCost(block.totalCost)} | Requests: ${block.requestCount} | Tokens: ${fmtTokens(block.totalInputTokens + block.totalOutputTokens)}`);
|
|
596
|
-
|
|
597
|
-
if (block.burnRate) {
|
|
598
|
-
console.log(` Burn: ${fmtTokens(block.burnRate.tokensPerMinute)}/min | ${fmtCost(block.burnRate.costPerHour)}/hr`);
|
|
599
|
-
}
|
|
600
|
-
|
|
601
|
-
if (block.isActive && block.remainingMinutes > 0) {
|
|
602
|
-
const h = Math.floor(block.remainingMinutes / 60);
|
|
603
|
-
const m = Math.round(block.remainingMinutes % 60);
|
|
604
|
-
console.log(` Remaining: ${h}h ${m}m`);
|
|
605
|
-
}
|
|
606
|
-
|
|
607
|
-
console.log(` Sources: ${(block.sources || []).join(', ')}`);
|
|
608
|
-
console.log(` Models: ${(block.models || []).join(', ')}`);
|
|
609
|
-
console.log('');
|
|
610
|
-
}
|
|
611
|
-
|
|
612
|
-
console.log(` Total: ${data.totalBlocks} blocks, $${data.totalCost.toFixed(2)}`);
|
|
613
|
-
console.log('');
|
|
614
|
-
} catch (err) {
|
|
615
|
-
console.error(`Error fetching blocks: ${err.message}`);
|
|
616
|
-
process.exitCode = 1;
|
|
617
|
-
}
|
|
618
|
-
}
|
|
619
|
-
|
|
620
|
-
// ---------------------------------------------------------------------------
|
|
621
|
-
// help
|
|
622
|
-
// ---------------------------------------------------------------------------
|
|
623
|
-
|
|
624
|
-
function printHelp() {
|
|
625
|
-
console.log(`
|
|
626
|
-
AIMeter — Track your Claude AI usage
|
|
627
|
-
═════════════════════════════════════
|
|
628
|
-
|
|
629
|
-
Usage: aimeter <command>
|
|
630
|
-
|
|
631
|
-
Getting started:
|
|
632
|
-
setup Full onboarding wizard (recommended)
|
|
633
|
-
|
|
634
|
-
Service management:
|
|
635
|
-
install Install background service
|
|
636
|
-
uninstall Remove background service
|
|
637
|
-
start Start the background service
|
|
638
|
-
stop Stop the background service
|
|
639
|
-
|
|
640
|
-
Manual mode:
|
|
641
|
-
watch Run watcher in foreground
|
|
642
|
-
|
|
643
|
-
Usage insights:
|
|
644
|
-
summary Show current usage summary (--json for JSON)
|
|
645
|
-
blocks [N] Show 5-hour billing blocks (last N days, default 3)
|
|
646
|
-
|
|
647
|
-
Info:
|
|
648
|
-
status Show current configuration
|
|
649
|
-
logs [N] Tail watcher log (last N lines, default 50)
|
|
650
|
-
key Print current API key
|
|
651
|
-
|
|
652
|
-
Integration:
|
|
653
|
-
mcp Start MCP server (for Claude Code/Desktop)
|
|
654
|
-
|
|
655
|
-
https://getaimeter.com
|
|
656
|
-
`);
|
|
657
|
-
}
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
const { getApiKey, saveApiKey, getWatchPaths, AIMETER_DIR } = require('./config');
|
|
5
|
+
const { startWatching } = require('./watcher');
|
|
6
|
+
const { install, uninstall, isInstalled, startNow, stopNow } = require('./service');
|
|
7
|
+
const { checkForUpdate, getCurrentVersion } = require('./update-check');
|
|
8
|
+
const { startTray, stopTray } = require('./tray');
|
|
9
|
+
|
|
10
|
+
const command = process.argv[2] || 'help';
|
|
11
|
+
|
|
12
|
+
// Version check on interactive commands (non-blocking)
|
|
13
|
+
if (['setup', 'status', 'summary', 'help', '--help', '-h'].includes(command)) {
|
|
14
|
+
checkForUpdate();
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
switch (command) {
|
|
18
|
+
case 'setup': runSetup(); break;
|
|
19
|
+
case 'watch': runWatch(); break;
|
|
20
|
+
case 'install': runInstall(); break;
|
|
21
|
+
case 'uninstall': runUninstall(); break;
|
|
22
|
+
case 'start': runStart(); break;
|
|
23
|
+
case 'stop': runStop(); break;
|
|
24
|
+
case 'status': runStatus(); break;
|
|
25
|
+
case 'logs': runLogs(); break;
|
|
26
|
+
case 'key': runKey(); break;
|
|
27
|
+
case 'summary': runSummary(); break;
|
|
28
|
+
case 'blocks': runBlocks(); break;
|
|
29
|
+
case 'version': case '--version': case '-v':
|
|
30
|
+
console.log(getCurrentVersion());
|
|
31
|
+
break;
|
|
32
|
+
case 'mcp': runMcp(); break;
|
|
33
|
+
case 'help': case '--help': case '-h':
|
|
34
|
+
printHelp();
|
|
35
|
+
break;
|
|
36
|
+
default:
|
|
37
|
+
console.log(`Unknown command: ${command}\n`);
|
|
38
|
+
printHelp();
|
|
39
|
+
process.exitCode = 1;
|
|
40
|
+
break;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// fetchFromApi — shared helper for API calls (native https, zero deps)
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function fetchFromApi(urlPath, apiKey) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const https = require('https');
|
|
50
|
+
const options = {
|
|
51
|
+
hostname: 'aimeter-api.fly.dev',
|
|
52
|
+
port: 443,
|
|
53
|
+
path: urlPath,
|
|
54
|
+
method: 'GET',
|
|
55
|
+
headers: {
|
|
56
|
+
'X-AIMeter-Key': apiKey,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const req = https.request(options, (res) => {
|
|
61
|
+
let body = '';
|
|
62
|
+
res.on('data', chunk => body += chunk);
|
|
63
|
+
res.on('end', () => {
|
|
64
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
65
|
+
try { resolve(JSON.parse(body)); }
|
|
66
|
+
catch { reject(new Error('Invalid JSON response')); }
|
|
67
|
+
} else {
|
|
68
|
+
reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
req.on('error', reject);
|
|
73
|
+
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
74
|
+
req.end();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// setup — full onboarding wizard
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
async function runSetup() {
|
|
83
|
+
const readline = require('readline');
|
|
84
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
85
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, (a) => resolve(a.trim())));
|
|
86
|
+
|
|
87
|
+
console.log('');
|
|
88
|
+
console.log(' ╔══════════════════════════════════╗');
|
|
89
|
+
console.log(' ║ AIMeter Setup Wizard ║');
|
|
90
|
+
console.log(' ╚══════════════════════════════════╝');
|
|
91
|
+
console.log('');
|
|
92
|
+
|
|
93
|
+
// Step 1: API Key
|
|
94
|
+
console.log(' Step 1: API Key');
|
|
95
|
+
console.log(' ───────────────');
|
|
96
|
+
const existing = getApiKey();
|
|
97
|
+
let key = existing;
|
|
98
|
+
|
|
99
|
+
if (existing) {
|
|
100
|
+
console.log(` Current key: ${existing.slice(0, 8)}...${existing.slice(-4)}`);
|
|
101
|
+
const answer = await ask(' Press Enter to keep, or paste a new key: ');
|
|
102
|
+
if (answer.length > 0) {
|
|
103
|
+
if (!answer.startsWith('aim_')) {
|
|
104
|
+
console.log(' ✗ Invalid key (must start with aim_)');
|
|
105
|
+
rl.close();
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
saveApiKey(answer);
|
|
109
|
+
key = answer;
|
|
110
|
+
console.log(' ✓ Key updated.');
|
|
111
|
+
} else {
|
|
112
|
+
console.log(' ✓ Key unchanged.');
|
|
113
|
+
}
|
|
114
|
+
} else {
|
|
115
|
+
console.log(' Sign up at: https://getaimeter.com');
|
|
116
|
+
console.log(' Then copy your API key from the dashboard.\n');
|
|
117
|
+
while (!key || !key.startsWith('aim_')) {
|
|
118
|
+
key = await ask(' Paste your API key: ');
|
|
119
|
+
if (!key.startsWith('aim_')) {
|
|
120
|
+
console.log(' ✗ Invalid — keys start with aim_');
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
saveApiKey(key);
|
|
124
|
+
console.log(' ✓ Key saved.');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Step 2: Check watch paths
|
|
128
|
+
console.log('');
|
|
129
|
+
console.log(' Step 2: Detect Claude installations');
|
|
130
|
+
console.log(' ────────────────────────────────────');
|
|
131
|
+
const paths = getWatchPaths();
|
|
132
|
+
if (paths.length === 0) {
|
|
133
|
+
console.log(' ⚠ No Claude transcript directories found.');
|
|
134
|
+
console.log(' Install Claude Code (CLI, VS Code, or Desktop App) first.');
|
|
135
|
+
console.log(' Paths checked:');
|
|
136
|
+
console.log(' ~/.claude/projects/');
|
|
137
|
+
const platform = process.platform;
|
|
138
|
+
if (platform === 'win32') {
|
|
139
|
+
console.log(' %APPDATA%/Claude/local-agent-mode-sessions/');
|
|
140
|
+
} else if (platform === 'darwin') {
|
|
141
|
+
console.log(' ~/Library/Application Support/Claude/local-agent-mode-sessions/');
|
|
142
|
+
}
|
|
143
|
+
} else {
|
|
144
|
+
for (const p of paths) {
|
|
145
|
+
console.log(` ✓ ${p}`);
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Step 3: Install background service
|
|
150
|
+
console.log('');
|
|
151
|
+
console.log(' Step 3: Background service');
|
|
152
|
+
console.log(' ──────────────────────────');
|
|
153
|
+
|
|
154
|
+
if (isInstalled()) {
|
|
155
|
+
console.log(' ✓ Already installed as a background service.');
|
|
156
|
+
const reinstall = await ask(' Reinstall? (y/N): ');
|
|
157
|
+
if (reinstall.toLowerCase() === 'y') {
|
|
158
|
+
const result = install();
|
|
159
|
+
startNow();
|
|
160
|
+
console.log(` ✓ Reinstalled at: ${result.path}`);
|
|
161
|
+
}
|
|
162
|
+
} else {
|
|
163
|
+
const doInstall = await ask(' Install as background service? (Y/n): ');
|
|
164
|
+
if (doInstall.toLowerCase() !== 'n') {
|
|
165
|
+
const result = install();
|
|
166
|
+
startNow();
|
|
167
|
+
console.log(` ✓ Installed at: ${result.path}`);
|
|
168
|
+
if (result.platform === 'windows') {
|
|
169
|
+
console.log(' ✓ Will auto-start on login (Windows Startup folder)');
|
|
170
|
+
console.log(' ✓ Desktop shortcut created (double-click "AIMeter" to start)');
|
|
171
|
+
console.log(' ✓ Start Menu shortcut created');
|
|
172
|
+
} else if (result.platform === 'macos') {
|
|
173
|
+
console.log(' ✓ Will auto-start on login (launchd)');
|
|
174
|
+
} else {
|
|
175
|
+
console.log(' ✓ Will auto-start on login (systemd user service)');
|
|
176
|
+
}
|
|
177
|
+
} else {
|
|
178
|
+
console.log(' Skipped. You can run manually with: aimeter watch');
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Done!
|
|
183
|
+
console.log('');
|
|
184
|
+
console.log(' ╔══════════════════════════════════╗');
|
|
185
|
+
console.log(' ║ Setup complete! ║');
|
|
186
|
+
console.log(' ╚══════════════════════════════════╝');
|
|
187
|
+
console.log('');
|
|
188
|
+
console.log(' Your Claude usage is now being tracked.');
|
|
189
|
+
console.log(' View your dashboard at: https://getaimeter.com/dashboard');
|
|
190
|
+
console.log('');
|
|
191
|
+
console.log(' Commands:');
|
|
192
|
+
console.log(' aimeter status — check what\'s running');
|
|
193
|
+
console.log(' aimeter logs — view watcher logs');
|
|
194
|
+
console.log(' aimeter stop — stop the watcher');
|
|
195
|
+
console.log('');
|
|
196
|
+
|
|
197
|
+
rl.close();
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ---------------------------------------------------------------------------
|
|
201
|
+
// watch — foreground mode
|
|
202
|
+
// ---------------------------------------------------------------------------
|
|
203
|
+
|
|
204
|
+
function runWatch() {
|
|
205
|
+
const fs = require('fs');
|
|
206
|
+
const path = require('path');
|
|
207
|
+
const lockFile = path.join(AIMETER_DIR, 'watcher.lock');
|
|
208
|
+
|
|
209
|
+
// Check if another instance is already running
|
|
210
|
+
try {
|
|
211
|
+
if (fs.existsSync(lockFile)) {
|
|
212
|
+
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
|
|
213
|
+
// Check if the PID is still alive
|
|
214
|
+
try {
|
|
215
|
+
process.kill(lockData.pid, 0); // signal 0 = just check if alive
|
|
216
|
+
console.log(`Another watcher is already running (PID ${lockData.pid}). Use 'aimeter stop' first.`);
|
|
217
|
+
process.exitCode = 1;
|
|
218
|
+
return;
|
|
219
|
+
} catch {
|
|
220
|
+
// PID is dead — stale lock, remove it
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
} catch {}
|
|
224
|
+
|
|
225
|
+
// Write lock file
|
|
226
|
+
fs.mkdirSync(AIMETER_DIR, { recursive: true });
|
|
227
|
+
fs.writeFileSync(lockFile, JSON.stringify({ pid: process.pid, startedAt: new Date().toISOString() }));
|
|
228
|
+
|
|
229
|
+
const cleanup = startWatching();
|
|
230
|
+
|
|
231
|
+
// Launch system tray icon (non-blocking, fails silently if systray2 not available)
|
|
232
|
+
const showTray = !process.argv.includes('--no-tray');
|
|
233
|
+
if (showTray) {
|
|
234
|
+
startTray(() => {
|
|
235
|
+
cleanup();
|
|
236
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
237
|
+
}).catch(() => {}); // ignore tray errors
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const cleanupAll = () => {
|
|
241
|
+
cleanup();
|
|
242
|
+
stopTray();
|
|
243
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
244
|
+
process.exit(0);
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
process.on('SIGINT', cleanupAll);
|
|
248
|
+
process.on('SIGTERM', cleanupAll);
|
|
249
|
+
process.on('exit', () => {
|
|
250
|
+
stopTray();
|
|
251
|
+
try { fs.unlinkSync(lockFile); } catch {}
|
|
252
|
+
});
|
|
253
|
+
|
|
254
|
+
process.on('uncaughtException', (err) => {
|
|
255
|
+
console.error('[aimeter] Uncaught:', err.message);
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
process.on('unhandledRejection', (reason) => {
|
|
259
|
+
console.error('[aimeter] Unhandled rejection:', reason);
|
|
260
|
+
});
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
// ---------------------------------------------------------------------------
|
|
264
|
+
// install / uninstall / start / stop
|
|
265
|
+
// ---------------------------------------------------------------------------
|
|
266
|
+
|
|
267
|
+
function runInstall() {
|
|
268
|
+
if (!getApiKey()) {
|
|
269
|
+
console.log('No API key configured. Run: aimeter setup');
|
|
270
|
+
process.exitCode = 1;
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
const result = install();
|
|
275
|
+
console.log(`Installed background service (${result.platform})`);
|
|
276
|
+
console.log(` File: ${result.path}`);
|
|
277
|
+
console.log('Starting...');
|
|
278
|
+
startNow();
|
|
279
|
+
console.log('Done. AIMeter is now tracking your Claude usage.');
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
function runUninstall() {
|
|
283
|
+
stopNow();
|
|
284
|
+
const removed = uninstall();
|
|
285
|
+
if (removed) {
|
|
286
|
+
console.log('Background service removed.');
|
|
287
|
+
} else {
|
|
288
|
+
console.log('No background service found to remove.');
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function runStart() {
|
|
293
|
+
if (!isInstalled()) {
|
|
294
|
+
console.log('Service not installed. Run: aimeter install');
|
|
295
|
+
process.exitCode = 1;
|
|
296
|
+
return;
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
// Clean up stale lock file if the previous process died
|
|
300
|
+
const fs = require('fs');
|
|
301
|
+
const pathMod = require('path');
|
|
302
|
+
const lockFile = pathMod.join(AIMETER_DIR, 'watcher.lock');
|
|
303
|
+
try {
|
|
304
|
+
if (fs.existsSync(lockFile)) {
|
|
305
|
+
const lockData = JSON.parse(fs.readFileSync(lockFile, 'utf8'));
|
|
306
|
+
try {
|
|
307
|
+
process.kill(lockData.pid, 0);
|
|
308
|
+
// PID is alive — watcher is already running
|
|
309
|
+
console.log(`AIMeter watcher is already running (PID ${lockData.pid}).`);
|
|
310
|
+
return;
|
|
311
|
+
} catch {
|
|
312
|
+
// PID is dead — stale lock, remove it
|
|
313
|
+
fs.unlinkSync(lockFile);
|
|
314
|
+
}
|
|
315
|
+
}
|
|
316
|
+
} catch {}
|
|
317
|
+
|
|
318
|
+
startNow();
|
|
319
|
+
console.log('AIMeter watcher started.');
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
function runStop() {
|
|
323
|
+
stopNow();
|
|
324
|
+
console.log('AIMeter watcher stopped.');
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
// ---------------------------------------------------------------------------
|
|
328
|
+
// status
|
|
329
|
+
// ---------------------------------------------------------------------------
|
|
330
|
+
|
|
331
|
+
function runStatus() {
|
|
332
|
+
const fs = require('fs');
|
|
333
|
+
const path = require('path');
|
|
334
|
+
|
|
335
|
+
console.log('');
|
|
336
|
+
console.log(' AIMeter Status');
|
|
337
|
+
console.log(' ══════════════');
|
|
338
|
+
console.log('');
|
|
339
|
+
|
|
340
|
+
// API key
|
|
341
|
+
const key = getApiKey();
|
|
342
|
+
console.log(` API key: ${key ? key.slice(0, 8) + '...' + key.slice(-4) : '✗ NOT SET (run: aimeter setup)'}`);
|
|
343
|
+
|
|
344
|
+
// Service
|
|
345
|
+
const installed = isInstalled();
|
|
346
|
+
console.log(` Service: ${installed ? '✓ installed' : '✗ not installed'}`);
|
|
347
|
+
|
|
348
|
+
// Watch paths
|
|
349
|
+
const paths = getWatchPaths();
|
|
350
|
+
console.log(` Watch paths: ${paths.length > 0 ? '' : '(none found)'}`);
|
|
351
|
+
for (const p of paths) console.log(` → ${p}`);
|
|
352
|
+
|
|
353
|
+
// State
|
|
354
|
+
const stateFile = path.join(AIMETER_DIR, 'state.json');
|
|
355
|
+
try {
|
|
356
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
357
|
+
const fileCount = Object.keys(state.fileOffsets || {}).length;
|
|
358
|
+
console.log(` Files: ${fileCount} tracked`);
|
|
359
|
+
console.log(` Last active: ${state.lastSaved || 'never'}`);
|
|
360
|
+
} catch {
|
|
361
|
+
console.log(' State: no data yet (first run?)');
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
// Log file
|
|
365
|
+
const logFile = path.join(AIMETER_DIR, 'watcher.log');
|
|
366
|
+
try {
|
|
367
|
+
const stat = fs.statSync(logFile);
|
|
368
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
369
|
+
console.log(` Log file: ${logFile} (${sizeMB} MB)`);
|
|
370
|
+
} catch {
|
|
371
|
+
console.log(' Log file: (none)');
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
console.log('');
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// ---------------------------------------------------------------------------
|
|
378
|
+
// logs — tail the watcher log
|
|
379
|
+
// ---------------------------------------------------------------------------
|
|
380
|
+
|
|
381
|
+
function runLogs() {
|
|
382
|
+
const fs = require('fs');
|
|
383
|
+
const path = require('path');
|
|
384
|
+
const logFile = path.join(AIMETER_DIR, 'watcher.log');
|
|
385
|
+
|
|
386
|
+
if (!fs.existsSync(logFile)) {
|
|
387
|
+
console.log('No log file yet. Start the watcher first.');
|
|
388
|
+
return;
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
// Show last 50 lines, then follow
|
|
392
|
+
const lines = process.argv[3] ? parseInt(process.argv[3], 10) : 50;
|
|
393
|
+
const content = fs.readFileSync(logFile, 'utf8');
|
|
394
|
+
const allLines = content.split('\n');
|
|
395
|
+
const tail = allLines.slice(-lines).join('\n');
|
|
396
|
+
console.log(tail);
|
|
397
|
+
|
|
398
|
+
// Follow mode — poll every second (fs.watch is unreliable on Windows)
|
|
399
|
+
console.log('\n--- Watching for new entries (Ctrl+C to stop) ---\n');
|
|
400
|
+
|
|
401
|
+
let fileSize = fs.statSync(logFile).size;
|
|
402
|
+
setInterval(() => {
|
|
403
|
+
try {
|
|
404
|
+
const newSize = fs.statSync(logFile).size;
|
|
405
|
+
if (newSize > fileSize) {
|
|
406
|
+
const fd = fs.openSync(logFile, 'r');
|
|
407
|
+
const buf = Buffer.alloc(newSize - fileSize);
|
|
408
|
+
fs.readSync(fd, buf, 0, buf.length, fileSize);
|
|
409
|
+
fs.closeSync(fd);
|
|
410
|
+
process.stdout.write(buf.toString('utf8'));
|
|
411
|
+
fileSize = newSize;
|
|
412
|
+
}
|
|
413
|
+
} catch {}
|
|
414
|
+
}, 1000);
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
// ---------------------------------------------------------------------------
|
|
418
|
+
// key — quick key management
|
|
419
|
+
// ---------------------------------------------------------------------------
|
|
420
|
+
|
|
421
|
+
function runKey() {
|
|
422
|
+
const key = getApiKey();
|
|
423
|
+
if (key) {
|
|
424
|
+
console.log(key);
|
|
425
|
+
} else {
|
|
426
|
+
console.log('No API key configured. Run: aimeter setup');
|
|
427
|
+
process.exitCode = 1;
|
|
428
|
+
}
|
|
429
|
+
}
|
|
430
|
+
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// mcp — start MCP server for Claude Code/Desktop
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
function runMcp() {
|
|
436
|
+
require('./mcp').startMcpServer();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// summary — show current usage summary from API
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
async function runSummary() {
|
|
444
|
+
const https = require('https');
|
|
445
|
+
const apiKey = getApiKey();
|
|
446
|
+
if (!apiKey) {
|
|
447
|
+
console.log('No API key configured. Run: aimeter setup');
|
|
448
|
+
process.exitCode = 1;
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Check for --json flag
|
|
453
|
+
const jsonMode = process.argv.includes('--json');
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
// Fetch current usage from API
|
|
457
|
+
const data = await fetchFromApi('/api/usage/current', apiKey);
|
|
458
|
+
|
|
459
|
+
if (jsonMode) {
|
|
460
|
+
console.log(JSON.stringify(data, null, 2));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Pretty print
|
|
465
|
+
const fmtTokens = (n) => {
|
|
466
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
467
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
468
|
+
return String(n);
|
|
469
|
+
};
|
|
470
|
+
const fmtCost = (c) => {
|
|
471
|
+
if (c >= 1) return `$${c.toFixed(2)}`;
|
|
472
|
+
if (c >= 0.01) return `$${c.toFixed(3)}`;
|
|
473
|
+
return `$${c.toFixed(4)}`;
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// Calculate total cost from byModel
|
|
477
|
+
const totalCost = (data.byModel || []).reduce((sum, m) => sum + (m.estimatedCost || 0), 0);
|
|
478
|
+
|
|
479
|
+
// Reset countdown
|
|
480
|
+
let resetStr = 'N/A';
|
|
481
|
+
if (data.nextReset) {
|
|
482
|
+
const resetMs = new Date(data.nextReset) - Date.now();
|
|
483
|
+
if (resetMs > 0) {
|
|
484
|
+
const h = Math.floor(resetMs / 3600000);
|
|
485
|
+
const m = Math.floor((resetMs % 3600000) / 60000);
|
|
486
|
+
resetStr = `${h}h ${m}m`;
|
|
487
|
+
} else {
|
|
488
|
+
resetStr = 'now';
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
console.log('');
|
|
493
|
+
console.log(' AIMeter — Current Usage (5-hour window)');
|
|
494
|
+
console.log(' ════════════════════════════════════════');
|
|
495
|
+
console.log('');
|
|
496
|
+
console.log(` Cost: ${fmtCost(totalCost)}`);
|
|
497
|
+
console.log(` Input: ${fmtTokens(data.totalInputTokens || 0)}`);
|
|
498
|
+
console.log(` Output: ${fmtTokens(data.totalOutputTokens || 0)}`);
|
|
499
|
+
console.log(` Thinking: ${fmtTokens(data.totalThinkingTokens || 0)}`);
|
|
500
|
+
console.log(` Cache Read: ${fmtTokens(data.totalCacheReadTokens || 0)}`);
|
|
501
|
+
console.log(` Requests: ${data.windowRequests || 0}`);
|
|
502
|
+
console.log(` Reset in: ${resetStr}`);
|
|
503
|
+
|
|
504
|
+
// By source
|
|
505
|
+
if (data.bySource && data.bySource.length > 0) {
|
|
506
|
+
console.log('');
|
|
507
|
+
console.log(' By Source:');
|
|
508
|
+
const sourceLabels = {
|
|
509
|
+
cli: 'Terminal', vscode: 'VS Code', desktop_app: 'Desktop',
|
|
510
|
+
cursor: 'Cursor', codex_cli: 'Codex CLI', codex_vscode: 'Codex VS',
|
|
511
|
+
gemini_cli: 'Gemini', copilot_cli: 'Copilot', copilot_vscode: 'Copilot VS',
|
|
512
|
+
};
|
|
513
|
+
for (const s of data.bySource) {
|
|
514
|
+
const label = sourceLabels[s.source] || s.source;
|
|
515
|
+
console.log(` ${label.padEnd(14)} ${String(s.requests).padStart(4)} req`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// By model
|
|
520
|
+
if (data.byModel && data.byModel.length > 0) {
|
|
521
|
+
console.log('');
|
|
522
|
+
console.log(' By Model:');
|
|
523
|
+
for (const m of data.byModel) {
|
|
524
|
+
const label = m.model.replace(/-\d{8}$/, '');
|
|
525
|
+
console.log(` ${label.padEnd(22)} ${String(m.requests).padStart(4)} req ${fmtCost(m.estimatedCost || 0)}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Previous window comparison
|
|
530
|
+
if (data.previous && data.previous.totalCost > 0) {
|
|
531
|
+
const prevCost = data.previous.totalCost;
|
|
532
|
+
const change = totalCost - prevCost;
|
|
533
|
+
const pct = prevCost > 0 ? ((change / prevCost) * 100).toFixed(0) : '∞';
|
|
534
|
+
const arrow = change >= 0 ? '↑' : '↓';
|
|
535
|
+
console.log('');
|
|
536
|
+
console.log(` vs Previous: ${arrow} ${Math.abs(change).toFixed(2)} (${pct}%)`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
console.log('');
|
|
540
|
+
} catch (err) {
|
|
541
|
+
console.error(`Error fetching usage: ${err.message}`);
|
|
542
|
+
process.exitCode = 1;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// blocks — show 5-hour billing blocks
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
async function runBlocks() {
|
|
551
|
+
const apiKey = getApiKey();
|
|
552
|
+
if (!apiKey) {
|
|
553
|
+
console.log('No API key configured. Run: aimeter setup');
|
|
554
|
+
process.exitCode = 1;
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const jsonMode = process.argv.includes('--json');
|
|
559
|
+
const days = parseInt(process.argv[3]) || 3;
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const data = await fetchFromApi(`/api/usage/blocks?days=${days}`, apiKey);
|
|
563
|
+
|
|
564
|
+
if (jsonMode) {
|
|
565
|
+
console.log(JSON.stringify(data, null, 2));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
console.log('');
|
|
570
|
+
console.log(` AIMeter — Billing Blocks (last ${days} days)`);
|
|
571
|
+
console.log(' ════════════════════════════════════════');
|
|
572
|
+
console.log('');
|
|
573
|
+
|
|
574
|
+
if (!data.blocks || data.blocks.length === 0) {
|
|
575
|
+
console.log(' No billing blocks found.');
|
|
576
|
+
console.log('');
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
for (const block of data.blocks) {
|
|
581
|
+
const start = new Date(block.startTime);
|
|
582
|
+
const fmtTime = (d) => d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
583
|
+
const active = block.isActive ? ' ◉ ACTIVE' : '';
|
|
584
|
+
|
|
585
|
+
console.log(` ${fmtTime(start)}${active}`);
|
|
586
|
+
console.log(` ────────────────────────────`);
|
|
587
|
+
|
|
588
|
+
const fmtCost = (c) => c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(3)}`;
|
|
589
|
+
const fmtTokens = (n) => {
|
|
590
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
591
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
|
592
|
+
return String(n);
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
console.log(` Cost: ${fmtCost(block.totalCost)} | Requests: ${block.requestCount} | Tokens: ${fmtTokens(block.totalInputTokens + block.totalOutputTokens)}`);
|
|
596
|
+
|
|
597
|
+
if (block.burnRate) {
|
|
598
|
+
console.log(` Burn: ${fmtTokens(block.burnRate.tokensPerMinute)}/min | ${fmtCost(block.burnRate.costPerHour)}/hr`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (block.isActive && block.remainingMinutes > 0) {
|
|
602
|
+
const h = Math.floor(block.remainingMinutes / 60);
|
|
603
|
+
const m = Math.round(block.remainingMinutes % 60);
|
|
604
|
+
console.log(` Remaining: ${h}h ${m}m`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
console.log(` Sources: ${(block.sources || []).join(', ')}`);
|
|
608
|
+
console.log(` Models: ${(block.models || []).join(', ')}`);
|
|
609
|
+
console.log('');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
console.log(` Total: ${data.totalBlocks} blocks, $${data.totalCost.toFixed(2)}`);
|
|
613
|
+
console.log('');
|
|
614
|
+
} catch (err) {
|
|
615
|
+
console.error(`Error fetching blocks: ${err.message}`);
|
|
616
|
+
process.exitCode = 1;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
620
|
+
// ---------------------------------------------------------------------------
|
|
621
|
+
// help
|
|
622
|
+
// ---------------------------------------------------------------------------
|
|
623
|
+
|
|
624
|
+
function printHelp() {
|
|
625
|
+
console.log(`
|
|
626
|
+
AIMeter — Track your Claude AI usage
|
|
627
|
+
═════════════════════════════════════
|
|
628
|
+
|
|
629
|
+
Usage: aimeter <command>
|
|
630
|
+
|
|
631
|
+
Getting started:
|
|
632
|
+
setup Full onboarding wizard (recommended)
|
|
633
|
+
|
|
634
|
+
Service management:
|
|
635
|
+
install Install background service
|
|
636
|
+
uninstall Remove background service
|
|
637
|
+
start Start the background service
|
|
638
|
+
stop Stop the background service
|
|
639
|
+
|
|
640
|
+
Manual mode:
|
|
641
|
+
watch Run watcher in foreground
|
|
642
|
+
|
|
643
|
+
Usage insights:
|
|
644
|
+
summary Show current usage summary (--json for JSON)
|
|
645
|
+
blocks [N] Show 5-hour billing blocks (last N days, default 3)
|
|
646
|
+
|
|
647
|
+
Info:
|
|
648
|
+
status Show current configuration
|
|
649
|
+
logs [N] Tail watcher log (last N lines, default 50)
|
|
650
|
+
key Print current API key
|
|
651
|
+
|
|
652
|
+
Integration:
|
|
653
|
+
mcp Start MCP server (for Claude Code/Desktop)
|
|
654
|
+
|
|
655
|
+
https://getaimeter.com
|
|
656
|
+
`);
|
|
657
|
+
}
|