getaimeter 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +81 -0
- package/cli.js +356 -0
- package/config.js +74 -0
- package/package.json +42 -0
- package/reporter.js +39 -0
- package/service.js +294 -0
- package/state.js +50 -0
- package/update-check.js +75 -0
- package/watcher.js +272 -0
package/README.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# AIMeter
|
|
2
|
+
|
|
3
|
+
Track your Claude AI token usage across every surface — CLI, VS Code, Desktop App — in one dashboard.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+
|
|
7
|
+
## Why
|
|
8
|
+
|
|
9
|
+
Anthropic doesn't show per-session or per-project token usage. If you use Claude Code across multiple tools, you have no idea where your tokens are going.
|
|
10
|
+
|
|
11
|
+
AIMeter watches Claude's transcript files locally, extracts token counts, and sends them to your dashboard at [getaimeter.com](https://getaimeter.com). No proxy, no code changes, no config files.
|
|
12
|
+
|
|
13
|
+
## Quick Start
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
npx aimeter setup
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
The wizard will:
|
|
20
|
+
1. Ask for your API key (get one free at [getaimeter.com](https://getaimeter.com))
|
|
21
|
+
2. Detect your Claude installations
|
|
22
|
+
3. Install a background service that auto-starts on login
|
|
23
|
+
|
|
24
|
+
That's it. Use Claude normally — your dashboard updates in real time.
|
|
25
|
+
|
|
26
|
+
## What Gets Tracked
|
|
27
|
+
|
|
28
|
+
| Source | How |
|
|
29
|
+
|--------|-----|
|
|
30
|
+
| **Claude Code CLI** | File watcher on `~/.claude/projects/` |
|
|
31
|
+
| **VS Code Extension** | File watcher on `~/.claude/projects/` |
|
|
32
|
+
| **Desktop App (Agent Mode)** | File watcher on local-agent-mode sessions |
|
|
33
|
+
| **claude.ai (Web/Desktop Chat)** | Browser extension (optional) |
|
|
34
|
+
|
|
35
|
+
**Only token counts are sent** — no prompts, no responses, no code. Your conversations stay on your machine.
|
|
36
|
+
|
|
37
|
+
## Commands
|
|
38
|
+
|
|
39
|
+
```
|
|
40
|
+
aimeter setup Full onboarding wizard (recommended)
|
|
41
|
+
aimeter status Check configuration and service status
|
|
42
|
+
aimeter watch Run watcher in foreground (for testing)
|
|
43
|
+
aimeter install Install as background service
|
|
44
|
+
aimeter uninstall Remove background service
|
|
45
|
+
aimeter start Start the background service
|
|
46
|
+
aimeter stop Stop the background service
|
|
47
|
+
aimeter logs Tail the watcher log
|
|
48
|
+
aimeter key Print your API key
|
|
49
|
+
```
|
|
50
|
+
|
|
51
|
+
## How It Works
|
|
52
|
+
|
|
53
|
+
Claude Code (CLI, VS Code, Desktop Agent Mode) writes JSONL transcript files with full token usage data. AIMeter watches these files using `fs.watch`, reads only new bytes via offset tracking, and POSTs token counts to your dashboard.
|
|
54
|
+
|
|
55
|
+
- **Zero dependencies** — pure Node.js, no native modules
|
|
56
|
+
- **Minimal overhead** — file watcher + byte offset reads, ~5MB memory
|
|
57
|
+
- **Deduplication** — MD5 hashing prevents double-counting
|
|
58
|
+
- **Crash-safe** — state persisted to disk every 30 seconds
|
|
59
|
+
|
|
60
|
+
## Privacy
|
|
61
|
+
|
|
62
|
+
AIMeter sends **only**:
|
|
63
|
+
- Token counts (input, output, thinking, cache)
|
|
64
|
+
- Model name (e.g., `claude-sonnet-4-20250514`)
|
|
65
|
+
- Source type (CLI, VS Code, Desktop)
|
|
66
|
+
|
|
67
|
+
It **never** sends your prompts, responses, code, or file contents.
|
|
68
|
+
|
|
69
|
+
## Requirements
|
|
70
|
+
|
|
71
|
+
- Node.js 18+
|
|
72
|
+
- Claude Code (CLI, VS Code extension, or Desktop App)
|
|
73
|
+
|
|
74
|
+
## Links
|
|
75
|
+
|
|
76
|
+
- **Dashboard**: [getaimeter.com](https://getaimeter.com)
|
|
77
|
+
- **Issues**: [github.com/AlejoCJaworworski/aimeter](https://github.com/AlejoCJaworworski/aimeter)
|
|
78
|
+
|
|
79
|
+
## License
|
|
80
|
+
|
|
81
|
+
MIT
|
package/cli.js
ADDED
|
@@ -0,0 +1,356 @@
|
|
|
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
|
+
|
|
9
|
+
const command = process.argv[2] || 'help';
|
|
10
|
+
|
|
11
|
+
// Version check on interactive commands (non-blocking)
|
|
12
|
+
if (['setup', 'status', 'help', '--help', '-h'].includes(command)) {
|
|
13
|
+
checkForUpdate();
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
switch (command) {
|
|
17
|
+
case 'setup': runSetup(); break;
|
|
18
|
+
case 'watch': runWatch(); break;
|
|
19
|
+
case 'install': runInstall(); break;
|
|
20
|
+
case 'uninstall': runUninstall(); break;
|
|
21
|
+
case 'start': runStart(); break;
|
|
22
|
+
case 'stop': runStop(); break;
|
|
23
|
+
case 'status': runStatus(); break;
|
|
24
|
+
case 'logs': runLogs(); break;
|
|
25
|
+
case 'key': runKey(); break;
|
|
26
|
+
case 'version': case '--version': case '-v':
|
|
27
|
+
console.log(getCurrentVersion());
|
|
28
|
+
break;
|
|
29
|
+
case 'help': case '--help': case '-h':
|
|
30
|
+
printHelp();
|
|
31
|
+
break;
|
|
32
|
+
default:
|
|
33
|
+
console.log(`Unknown command: ${command}\n`);
|
|
34
|
+
printHelp();
|
|
35
|
+
process.exitCode = 1;
|
|
36
|
+
break;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// setup — full onboarding wizard
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
|
|
43
|
+
async function runSetup() {
|
|
44
|
+
const readline = require('readline');
|
|
45
|
+
const rl = readline.createInterface({ input: process.stdin, output: process.stdout });
|
|
46
|
+
const ask = (q) => new Promise((resolve) => rl.question(q, (a) => resolve(a.trim())));
|
|
47
|
+
|
|
48
|
+
console.log('');
|
|
49
|
+
console.log(' ╔══════════════════════════════════╗');
|
|
50
|
+
console.log(' ║ AIMeter Setup Wizard ║');
|
|
51
|
+
console.log(' ╚══════════════════════════════════╝');
|
|
52
|
+
console.log('');
|
|
53
|
+
|
|
54
|
+
// Step 1: API Key
|
|
55
|
+
console.log(' Step 1: API Key');
|
|
56
|
+
console.log(' ───────────────');
|
|
57
|
+
const existing = getApiKey();
|
|
58
|
+
let key = existing;
|
|
59
|
+
|
|
60
|
+
if (existing) {
|
|
61
|
+
console.log(` Current key: ${existing.slice(0, 8)}...${existing.slice(-4)}`);
|
|
62
|
+
const answer = await ask(' Press Enter to keep, or paste a new key: ');
|
|
63
|
+
if (answer.length > 0) {
|
|
64
|
+
if (!answer.startsWith('aim_')) {
|
|
65
|
+
console.log(' ✗ Invalid key (must start with aim_)');
|
|
66
|
+
rl.close();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
saveApiKey(answer);
|
|
70
|
+
key = answer;
|
|
71
|
+
console.log(' ✓ Key updated.');
|
|
72
|
+
} else {
|
|
73
|
+
console.log(' ✓ Key unchanged.');
|
|
74
|
+
}
|
|
75
|
+
} else {
|
|
76
|
+
console.log(' Sign up at: https://getaimeter.com');
|
|
77
|
+
console.log(' Then copy your API key from the dashboard.\n');
|
|
78
|
+
while (!key || !key.startsWith('aim_')) {
|
|
79
|
+
key = await ask(' Paste your API key: ');
|
|
80
|
+
if (!key.startsWith('aim_')) {
|
|
81
|
+
console.log(' ✗ Invalid — keys start with aim_');
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
saveApiKey(key);
|
|
85
|
+
console.log(' ✓ Key saved.');
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// Step 2: Check watch paths
|
|
89
|
+
console.log('');
|
|
90
|
+
console.log(' Step 2: Detect Claude installations');
|
|
91
|
+
console.log(' ────────────────────────────────────');
|
|
92
|
+
const paths = getWatchPaths();
|
|
93
|
+
if (paths.length === 0) {
|
|
94
|
+
console.log(' ⚠ No Claude transcript directories found.');
|
|
95
|
+
console.log(' Install Claude Code (CLI, VS Code, or Desktop App) first.');
|
|
96
|
+
console.log(' Paths checked:');
|
|
97
|
+
console.log(' ~/.claude/projects/');
|
|
98
|
+
const platform = process.platform;
|
|
99
|
+
if (platform === 'win32') {
|
|
100
|
+
console.log(' %APPDATA%/Claude/local-agent-mode-sessions/');
|
|
101
|
+
} else if (platform === 'darwin') {
|
|
102
|
+
console.log(' ~/Library/Application Support/Claude/local-agent-mode-sessions/');
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
for (const p of paths) {
|
|
106
|
+
console.log(` ✓ ${p}`);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// Step 3: Install background service
|
|
111
|
+
console.log('');
|
|
112
|
+
console.log(' Step 3: Background service');
|
|
113
|
+
console.log(' ──────────────────────────');
|
|
114
|
+
|
|
115
|
+
if (isInstalled()) {
|
|
116
|
+
console.log(' ✓ Already installed as a background service.');
|
|
117
|
+
const reinstall = await ask(' Reinstall? (y/N): ');
|
|
118
|
+
if (reinstall.toLowerCase() === 'y') {
|
|
119
|
+
const result = install();
|
|
120
|
+
startNow();
|
|
121
|
+
console.log(` ✓ Reinstalled at: ${result.path}`);
|
|
122
|
+
}
|
|
123
|
+
} else {
|
|
124
|
+
const doInstall = await ask(' Install as background service? (Y/n): ');
|
|
125
|
+
if (doInstall.toLowerCase() !== 'n') {
|
|
126
|
+
const result = install();
|
|
127
|
+
startNow();
|
|
128
|
+
console.log(` ✓ Installed at: ${result.path}`);
|
|
129
|
+
if (result.platform === 'windows') {
|
|
130
|
+
console.log(' ✓ Will auto-start on login (Windows Startup folder)');
|
|
131
|
+
} else if (result.platform === 'macos') {
|
|
132
|
+
console.log(' ✓ Will auto-start on login (launchd)');
|
|
133
|
+
} else {
|
|
134
|
+
console.log(' ✓ Will auto-start on login (systemd user service)');
|
|
135
|
+
}
|
|
136
|
+
} else {
|
|
137
|
+
console.log(' Skipped. You can run manually with: aimeter watch');
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Done!
|
|
142
|
+
console.log('');
|
|
143
|
+
console.log(' ╔══════════════════════════════════╗');
|
|
144
|
+
console.log(' ║ Setup complete! ║');
|
|
145
|
+
console.log(' ╚══════════════════════════════════╝');
|
|
146
|
+
console.log('');
|
|
147
|
+
console.log(' Your Claude usage is now being tracked.');
|
|
148
|
+
console.log(' View your dashboard at: https://getaimeter.com/dashboard');
|
|
149
|
+
console.log('');
|
|
150
|
+
console.log(' Commands:');
|
|
151
|
+
console.log(' aimeter status — check what\'s running');
|
|
152
|
+
console.log(' aimeter logs — view watcher logs');
|
|
153
|
+
console.log(' aimeter stop — stop the watcher');
|
|
154
|
+
console.log('');
|
|
155
|
+
|
|
156
|
+
rl.close();
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
// ---------------------------------------------------------------------------
|
|
160
|
+
// watch — foreground mode
|
|
161
|
+
// ---------------------------------------------------------------------------
|
|
162
|
+
|
|
163
|
+
function runWatch() {
|
|
164
|
+
const cleanup = startWatching();
|
|
165
|
+
|
|
166
|
+
process.on('SIGINT', () => { cleanup(); process.exit(0); });
|
|
167
|
+
process.on('SIGTERM', () => { cleanup(); process.exit(0); });
|
|
168
|
+
|
|
169
|
+
process.on('uncaughtException', (err) => {
|
|
170
|
+
console.error('[aimeter] Uncaught:', err.message);
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
process.on('unhandledRejection', (reason) => {
|
|
174
|
+
console.error('[aimeter] Unhandled rejection:', reason);
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// ---------------------------------------------------------------------------
|
|
179
|
+
// install / uninstall / start / stop
|
|
180
|
+
// ---------------------------------------------------------------------------
|
|
181
|
+
|
|
182
|
+
function runInstall() {
|
|
183
|
+
if (!getApiKey()) {
|
|
184
|
+
console.log('No API key configured. Run: aimeter setup');
|
|
185
|
+
process.exitCode = 1;
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
const result = install();
|
|
190
|
+
console.log(`Installed background service (${result.platform})`);
|
|
191
|
+
console.log(` File: ${result.path}`);
|
|
192
|
+
console.log('Starting...');
|
|
193
|
+
startNow();
|
|
194
|
+
console.log('Done. AIMeter is now tracking your Claude usage.');
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
function runUninstall() {
|
|
198
|
+
stopNow();
|
|
199
|
+
const removed = uninstall();
|
|
200
|
+
if (removed) {
|
|
201
|
+
console.log('Background service removed.');
|
|
202
|
+
} else {
|
|
203
|
+
console.log('No background service found to remove.');
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function runStart() {
|
|
208
|
+
if (!isInstalled()) {
|
|
209
|
+
console.log('Service not installed. Run: aimeter install');
|
|
210
|
+
process.exitCode = 1;
|
|
211
|
+
return;
|
|
212
|
+
}
|
|
213
|
+
startNow();
|
|
214
|
+
console.log('AIMeter watcher started.');
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
function runStop() {
|
|
218
|
+
stopNow();
|
|
219
|
+
console.log('AIMeter watcher stopped.');
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// status
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
function runStatus() {
|
|
227
|
+
const fs = require('fs');
|
|
228
|
+
const path = require('path');
|
|
229
|
+
|
|
230
|
+
console.log('');
|
|
231
|
+
console.log(' AIMeter Status');
|
|
232
|
+
console.log(' ══════════════');
|
|
233
|
+
console.log('');
|
|
234
|
+
|
|
235
|
+
// API key
|
|
236
|
+
const key = getApiKey();
|
|
237
|
+
console.log(` API key: ${key ? key.slice(0, 8) + '...' + key.slice(-4) : '✗ NOT SET (run: aimeter setup)'}`);
|
|
238
|
+
|
|
239
|
+
// Service
|
|
240
|
+
const installed = isInstalled();
|
|
241
|
+
console.log(` Service: ${installed ? '✓ installed' : '✗ not installed'}`);
|
|
242
|
+
|
|
243
|
+
// Watch paths
|
|
244
|
+
const paths = getWatchPaths();
|
|
245
|
+
console.log(` Watch paths: ${paths.length > 0 ? '' : '(none found)'}`);
|
|
246
|
+
for (const p of paths) console.log(` → ${p}`);
|
|
247
|
+
|
|
248
|
+
// State
|
|
249
|
+
const stateFile = path.join(AIMETER_DIR, 'state.json');
|
|
250
|
+
try {
|
|
251
|
+
const state = JSON.parse(fs.readFileSync(stateFile, 'utf8'));
|
|
252
|
+
const fileCount = Object.keys(state.fileOffsets || {}).length;
|
|
253
|
+
console.log(` Files: ${fileCount} tracked`);
|
|
254
|
+
console.log(` Last active: ${state.lastSaved || 'never'}`);
|
|
255
|
+
} catch {
|
|
256
|
+
console.log(' State: no data yet (first run?)');
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Log file
|
|
260
|
+
const logFile = path.join(AIMETER_DIR, 'watcher.log');
|
|
261
|
+
try {
|
|
262
|
+
const stat = fs.statSync(logFile);
|
|
263
|
+
const sizeMB = (stat.size / 1024 / 1024).toFixed(1);
|
|
264
|
+
console.log(` Log file: ${logFile} (${sizeMB} MB)`);
|
|
265
|
+
} catch {
|
|
266
|
+
console.log(' Log file: (none)');
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
console.log('');
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// logs — tail the watcher log
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
function runLogs() {
|
|
277
|
+
const fs = require('fs');
|
|
278
|
+
const path = require('path');
|
|
279
|
+
const logFile = path.join(AIMETER_DIR, 'watcher.log');
|
|
280
|
+
|
|
281
|
+
if (!fs.existsSync(logFile)) {
|
|
282
|
+
console.log('No log file yet. Start the watcher first.');
|
|
283
|
+
return;
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
// Show last 50 lines, then follow
|
|
287
|
+
const lines = process.argv[3] ? parseInt(process.argv[3], 10) : 50;
|
|
288
|
+
const content = fs.readFileSync(logFile, 'utf8');
|
|
289
|
+
const allLines = content.split('\n');
|
|
290
|
+
const tail = allLines.slice(-lines).join('\n');
|
|
291
|
+
console.log(tail);
|
|
292
|
+
|
|
293
|
+
// Follow mode
|
|
294
|
+
console.log('\n--- Watching for new entries (Ctrl+C to stop) ---\n');
|
|
295
|
+
|
|
296
|
+
let fileSize = fs.statSync(logFile).size;
|
|
297
|
+
fs.watch(logFile, () => {
|
|
298
|
+
try {
|
|
299
|
+
const newSize = fs.statSync(logFile).size;
|
|
300
|
+
if (newSize > fileSize) {
|
|
301
|
+
const fd = fs.openSync(logFile, 'r');
|
|
302
|
+
const buf = Buffer.alloc(newSize - fileSize);
|
|
303
|
+
fs.readSync(fd, buf, 0, buf.length, fileSize);
|
|
304
|
+
fs.closeSync(fd);
|
|
305
|
+
process.stdout.write(buf.toString('utf8'));
|
|
306
|
+
fileSize = newSize;
|
|
307
|
+
}
|
|
308
|
+
} catch {}
|
|
309
|
+
});
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// ---------------------------------------------------------------------------
|
|
313
|
+
// key — quick key management
|
|
314
|
+
// ---------------------------------------------------------------------------
|
|
315
|
+
|
|
316
|
+
function runKey() {
|
|
317
|
+
const key = getApiKey();
|
|
318
|
+
if (key) {
|
|
319
|
+
console.log(key);
|
|
320
|
+
} else {
|
|
321
|
+
console.log('No API key configured. Run: aimeter setup');
|
|
322
|
+
process.exitCode = 1;
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ---------------------------------------------------------------------------
|
|
327
|
+
// help
|
|
328
|
+
// ---------------------------------------------------------------------------
|
|
329
|
+
|
|
330
|
+
function printHelp() {
|
|
331
|
+
console.log(`
|
|
332
|
+
AIMeter — Track your Claude AI usage
|
|
333
|
+
═════════════════════════════════════
|
|
334
|
+
|
|
335
|
+
Usage: aimeter <command>
|
|
336
|
+
|
|
337
|
+
Getting started:
|
|
338
|
+
setup Full onboarding wizard (recommended)
|
|
339
|
+
|
|
340
|
+
Service management:
|
|
341
|
+
install Install background service
|
|
342
|
+
uninstall Remove background service
|
|
343
|
+
start Start the background service
|
|
344
|
+
stop Stop the background service
|
|
345
|
+
|
|
346
|
+
Manual mode:
|
|
347
|
+
watch Run watcher in foreground
|
|
348
|
+
|
|
349
|
+
Info:
|
|
350
|
+
status Show current configuration
|
|
351
|
+
logs [N] Tail watcher log (last N lines, default 50)
|
|
352
|
+
key Print current API key
|
|
353
|
+
|
|
354
|
+
https://getaimeter.com
|
|
355
|
+
`);
|
|
356
|
+
}
|
package/config.js
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
const AIMETER_DIR = path.join(os.homedir(), '.aimeter');
|
|
8
|
+
const CONFIG_FILE = path.join(AIMETER_DIR, 'config.json');
|
|
9
|
+
const LEGACY_KEY = path.join(os.homedir(), '.claude', 'aimeter-key');
|
|
10
|
+
|
|
11
|
+
function ensureDir() {
|
|
12
|
+
fs.mkdirSync(AIMETER_DIR, { recursive: true });
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function loadConfig() {
|
|
16
|
+
try {
|
|
17
|
+
return JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
18
|
+
} catch {
|
|
19
|
+
return {};
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function saveConfig(cfg) {
|
|
24
|
+
ensureDir();
|
|
25
|
+
fs.writeFileSync(CONFIG_FILE, JSON.stringify(cfg, null, 2) + '\n', { mode: 0o600 });
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function getApiKey() {
|
|
29
|
+
if (process.env.AIMETER_KEY) return process.env.AIMETER_KEY.trim();
|
|
30
|
+
|
|
31
|
+
const cfg = loadConfig();
|
|
32
|
+
if (cfg.apiKey) return cfg.apiKey;
|
|
33
|
+
|
|
34
|
+
// Legacy: ~/.claude/aimeter-key
|
|
35
|
+
try {
|
|
36
|
+
const key = fs.readFileSync(LEGACY_KEY, 'utf8').trim();
|
|
37
|
+
if (key) return key;
|
|
38
|
+
} catch {}
|
|
39
|
+
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function saveApiKey(key) {
|
|
44
|
+
const cfg = loadConfig();
|
|
45
|
+
cfg.apiKey = key.trim();
|
|
46
|
+
saveConfig(cfg);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Return all directories to watch for JSONL transcripts.
|
|
51
|
+
*/
|
|
52
|
+
function getWatchPaths() {
|
|
53
|
+
const paths = [];
|
|
54
|
+
|
|
55
|
+
// 1. ~/.claude/projects/ — CLI + VS Code transcripts
|
|
56
|
+
const claudeProjects = path.join(os.homedir(), '.claude', 'projects');
|
|
57
|
+
if (fs.existsSync(claudeProjects)) paths.push(claudeProjects);
|
|
58
|
+
|
|
59
|
+
// 2. Desktop app agent mode sessions
|
|
60
|
+
const platform = process.platform;
|
|
61
|
+
let desktopSessions;
|
|
62
|
+
if (platform === 'win32') {
|
|
63
|
+
desktopSessions = path.join(process.env.APPDATA || '', 'Claude', 'local-agent-mode-sessions');
|
|
64
|
+
} else if (platform === 'darwin') {
|
|
65
|
+
desktopSessions = path.join(os.homedir(), 'Library', 'Application Support', 'Claude', 'local-agent-mode-sessions');
|
|
66
|
+
} else {
|
|
67
|
+
desktopSessions = path.join(os.homedir(), '.config', 'Claude', 'local-agent-mode-sessions');
|
|
68
|
+
}
|
|
69
|
+
if (fs.existsSync(desktopSessions)) paths.push(desktopSessions);
|
|
70
|
+
|
|
71
|
+
return paths;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
module.exports = { loadConfig, saveConfig, getApiKey, saveApiKey, getWatchPaths, AIMETER_DIR };
|
package/package.json
ADDED
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "getaimeter",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"description": "Track your Claude AI usage across CLI, VS Code, and Desktop App. One command to start.",
|
|
5
|
+
"bin": {
|
|
6
|
+
"aimeter": "cli.js"
|
|
7
|
+
},
|
|
8
|
+
"main": "watcher.js",
|
|
9
|
+
"engines": {
|
|
10
|
+
"node": ">=18"
|
|
11
|
+
},
|
|
12
|
+
"license": "MIT",
|
|
13
|
+
"keywords": [
|
|
14
|
+
"claude",
|
|
15
|
+
"anthropic",
|
|
16
|
+
"usage",
|
|
17
|
+
"tracking",
|
|
18
|
+
"ai",
|
|
19
|
+
"tokens",
|
|
20
|
+
"cost",
|
|
21
|
+
"monitor",
|
|
22
|
+
"claude-code",
|
|
23
|
+
"vscode"
|
|
24
|
+
],
|
|
25
|
+
"files": [
|
|
26
|
+
"cli.js",
|
|
27
|
+
"watcher.js",
|
|
28
|
+
"reporter.js",
|
|
29
|
+
"config.js",
|
|
30
|
+
"state.js",
|
|
31
|
+
"service.js",
|
|
32
|
+
"update-check.js",
|
|
33
|
+
"README.md"
|
|
34
|
+
],
|
|
35
|
+
"repository": {
|
|
36
|
+
"type": "git",
|
|
37
|
+
"url": "git+https://github.com/AlejoCJaworworski/aimeter.git"
|
|
38
|
+
},
|
|
39
|
+
"homepage": "https://getaimeter.com",
|
|
40
|
+
"author": "Alejandro Ceja",
|
|
41
|
+
"preferGlobal": true
|
|
42
|
+
}
|
package/reporter.js
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
|
|
5
|
+
const INGEST_HOST = 'aimeter-api.fly.dev';
|
|
6
|
+
const INGEST_PATH = '/ingest/events';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* POST a usage event to the AIMeter backend.
|
|
10
|
+
* Returns { ok, status, error }.
|
|
11
|
+
*/
|
|
12
|
+
function postUsage(apiKey, payload) {
|
|
13
|
+
return new Promise((resolve) => {
|
|
14
|
+
const body = JSON.stringify(payload);
|
|
15
|
+
const options = {
|
|
16
|
+
hostname: INGEST_HOST,
|
|
17
|
+
port: 443,
|
|
18
|
+
path: INGEST_PATH,
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: {
|
|
21
|
+
'Content-Type': 'application/json',
|
|
22
|
+
'Content-Length': Buffer.byteLength(body),
|
|
23
|
+
'X-AIMeter-Key': apiKey,
|
|
24
|
+
},
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
const req = https.request(options, (res) => {
|
|
28
|
+
res.resume(); // drain
|
|
29
|
+
resolve({ ok: res.statusCode >= 200 && res.statusCode < 300, status: res.statusCode });
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
req.on('error', (err) => resolve({ ok: false, status: 0, error: err.message }));
|
|
33
|
+
req.setTimeout(8000, () => { req.destroy(); resolve({ ok: false, status: 0, error: 'timeout' }); });
|
|
34
|
+
req.write(body);
|
|
35
|
+
req.end();
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
module.exports = { postUsage };
|
package/service.js
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const os = require('os');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
|
|
7
|
+
// ---------------------------------------------------------------------------
|
|
8
|
+
// Cross-platform background service installer
|
|
9
|
+
// ---------------------------------------------------------------------------
|
|
10
|
+
|
|
11
|
+
const SERVICE_NAME = 'aimeter';
|
|
12
|
+
|
|
13
|
+
function getNodePath() {
|
|
14
|
+
return process.execPath;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Resolve the best way to launch the watcher.
|
|
19
|
+
* If installed globally (npm i -g), use the global bin path.
|
|
20
|
+
* If running via npx or dev, use __dirname but warn if it looks temporary.
|
|
21
|
+
*/
|
|
22
|
+
function getWatcherScript() {
|
|
23
|
+
const cliPath = path.join(__dirname, 'cli.js');
|
|
24
|
+
|
|
25
|
+
// Check if we're in a temp/npx cache directory
|
|
26
|
+
const normalized = __dirname.replace(/\\/g, '/').toLowerCase();
|
|
27
|
+
const isTempDir = normalized.includes('/_npx/') ||
|
|
28
|
+
normalized.includes('\\_npx\\') ||
|
|
29
|
+
normalized.includes('/temp/') ||
|
|
30
|
+
normalized.includes('\\temp\\') ||
|
|
31
|
+
normalized.includes('/tmp/');
|
|
32
|
+
|
|
33
|
+
if (isTempDir) {
|
|
34
|
+
// Try to find the globally installed aimeter
|
|
35
|
+
const { execSync } = require('child_process');
|
|
36
|
+
try {
|
|
37
|
+
const globalBin = execSync('npm root -g', { encoding: 'utf8' }).trim();
|
|
38
|
+
const globalCli = path.join(globalBin, 'aimeter', 'cli.js');
|
|
39
|
+
if (fs.existsSync(globalCli)) return globalCli;
|
|
40
|
+
} catch {}
|
|
41
|
+
|
|
42
|
+
console.log('');
|
|
43
|
+
console.log(' WARNING: You ran this via npx. The background service needs a');
|
|
44
|
+
console.log(' permanent install. Installing globally first...');
|
|
45
|
+
console.log('');
|
|
46
|
+
try {
|
|
47
|
+
const { execSync: ex } = require('child_process');
|
|
48
|
+
ex('npm install -g aimeter', { stdio: 'inherit' });
|
|
49
|
+
const globalBin = ex('npm root -g', { encoding: 'utf8' }).trim();
|
|
50
|
+
const globalCli = path.join(globalBin, 'aimeter', 'cli.js');
|
|
51
|
+
if (fs.existsSync(globalCli)) return globalCli;
|
|
52
|
+
} catch {
|
|
53
|
+
console.log(' Could not install globally. Install manually:');
|
|
54
|
+
console.log(' npm install -g aimeter');
|
|
55
|
+
console.log(' Then run: aimeter install');
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
return cliPath;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
// ---------------------------------------------------------------------------
|
|
63
|
+
// Windows — VBS script in Startup folder (runs hidden, no console window)
|
|
64
|
+
// ---------------------------------------------------------------------------
|
|
65
|
+
|
|
66
|
+
function getWindowsStartupDir() {
|
|
67
|
+
return path.join(os.homedir(), 'AppData', 'Roaming', 'Microsoft', 'Windows', 'Start Menu', 'Programs', 'Startup');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function getWindowsVbsPath() {
|
|
71
|
+
return path.join(getWindowsStartupDir(), 'aimeter.vbs');
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function installWindows() {
|
|
75
|
+
const vbsPath = getWindowsVbsPath();
|
|
76
|
+
const nodePath = getNodePath().replace(/\\/g, '\\\\');
|
|
77
|
+
const script = getWatcherScript().replace(/\\/g, '\\\\');
|
|
78
|
+
const logFile = path.join(os.homedir(), '.aimeter', 'watcher.log').replace(/\\/g, '\\\\');
|
|
79
|
+
|
|
80
|
+
// VBS script that runs node hidden (no console window)
|
|
81
|
+
const vbs = `' AIMeter Watcher — auto-start script
|
|
82
|
+
' Created by: npx aimeter install
|
|
83
|
+
Set WshShell = CreateObject("WScript.Shell")
|
|
84
|
+
WshShell.Run """${nodePath}"" ""${script}"" watch", 0, False
|
|
85
|
+
`;
|
|
86
|
+
|
|
87
|
+
fs.mkdirSync(path.dirname(vbsPath), { recursive: true });
|
|
88
|
+
fs.writeFileSync(vbsPath, vbs, 'utf8');
|
|
89
|
+
return vbsPath;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function uninstallWindows() {
|
|
93
|
+
const vbsPath = getWindowsVbsPath();
|
|
94
|
+
try { fs.unlinkSync(vbsPath); return true; } catch { return false; }
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function isInstalledWindows() {
|
|
98
|
+
return fs.existsSync(getWindowsVbsPath());
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// ---------------------------------------------------------------------------
|
|
102
|
+
// macOS — LaunchAgent plist
|
|
103
|
+
// ---------------------------------------------------------------------------
|
|
104
|
+
|
|
105
|
+
function getMacPlistPath() {
|
|
106
|
+
return path.join(os.homedir(), 'Library', 'LaunchAgents', 'com.aimeter.watcher.plist');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function installMac() {
|
|
110
|
+
const plistPath = getMacPlistPath();
|
|
111
|
+
const logFile = path.join(os.homedir(), '.aimeter', 'watcher.log');
|
|
112
|
+
|
|
113
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
114
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
115
|
+
<plist version="1.0">
|
|
116
|
+
<dict>
|
|
117
|
+
<key>Label</key>
|
|
118
|
+
<string>com.aimeter.watcher</string>
|
|
119
|
+
<key>ProgramArguments</key>
|
|
120
|
+
<array>
|
|
121
|
+
<string>${getNodePath()}</string>
|
|
122
|
+
<string>${getWatcherScript()}</string>
|
|
123
|
+
<string>watch</string>
|
|
124
|
+
</array>
|
|
125
|
+
<key>RunAtLoad</key>
|
|
126
|
+
<true/>
|
|
127
|
+
<key>KeepAlive</key>
|
|
128
|
+
<true/>
|
|
129
|
+
<key>StandardOutPath</key>
|
|
130
|
+
<string>${logFile}</string>
|
|
131
|
+
<key>StandardErrorPath</key>
|
|
132
|
+
<string>${logFile}</string>
|
|
133
|
+
<key>EnvironmentVariables</key>
|
|
134
|
+
<dict>
|
|
135
|
+
<key>PATH</key>
|
|
136
|
+
<string>/usr/local/bin:/usr/bin:/bin</string>
|
|
137
|
+
</dict>
|
|
138
|
+
</dict>
|
|
139
|
+
</plist>
|
|
140
|
+
`;
|
|
141
|
+
|
|
142
|
+
fs.mkdirSync(path.dirname(plistPath), { recursive: true });
|
|
143
|
+
fs.writeFileSync(plistPath, plist, 'utf8');
|
|
144
|
+
return plistPath;
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function uninstallMac() {
|
|
148
|
+
const plistPath = getMacPlistPath();
|
|
149
|
+
try {
|
|
150
|
+
// Try to unload first
|
|
151
|
+
require('child_process').execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { stdio: 'ignore' });
|
|
152
|
+
} catch {}
|
|
153
|
+
try { fs.unlinkSync(plistPath); return true; } catch { return false; }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function isInstalledMac() {
|
|
157
|
+
return fs.existsSync(getMacPlistPath());
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// ---------------------------------------------------------------------------
|
|
161
|
+
// Linux — systemd user service
|
|
162
|
+
// ---------------------------------------------------------------------------
|
|
163
|
+
|
|
164
|
+
function getLinuxServicePath() {
|
|
165
|
+
return path.join(os.homedir(), '.config', 'systemd', 'user', 'aimeter.service');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function installLinux() {
|
|
169
|
+
const servicePath = getLinuxServicePath();
|
|
170
|
+
|
|
171
|
+
const unit = `[Unit]
|
|
172
|
+
Description=AIMeter Watcher — Claude AI usage tracker
|
|
173
|
+
After=default.target
|
|
174
|
+
|
|
175
|
+
[Service]
|
|
176
|
+
Type=simple
|
|
177
|
+
ExecStart=${getNodePath()} ${getWatcherScript()} watch
|
|
178
|
+
Restart=on-failure
|
|
179
|
+
RestartSec=10
|
|
180
|
+
|
|
181
|
+
[Install]
|
|
182
|
+
WantedBy=default.target
|
|
183
|
+
`;
|
|
184
|
+
|
|
185
|
+
fs.mkdirSync(path.dirname(servicePath), { recursive: true });
|
|
186
|
+
fs.writeFileSync(servicePath, unit, 'utf8');
|
|
187
|
+
|
|
188
|
+
// Enable and start
|
|
189
|
+
const { execSync } = require('child_process');
|
|
190
|
+
try {
|
|
191
|
+
execSync('systemctl --user daemon-reload', { stdio: 'ignore' });
|
|
192
|
+
execSync('systemctl --user enable aimeter.service', { stdio: 'ignore' });
|
|
193
|
+
execSync('systemctl --user start aimeter.service', { stdio: 'ignore' });
|
|
194
|
+
} catch {}
|
|
195
|
+
|
|
196
|
+
return servicePath;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function uninstallLinux() {
|
|
200
|
+
const servicePath = getLinuxServicePath();
|
|
201
|
+
const { execSync } = require('child_process');
|
|
202
|
+
try {
|
|
203
|
+
execSync('systemctl --user stop aimeter.service', { stdio: 'ignore' });
|
|
204
|
+
execSync('systemctl --user disable aimeter.service', { stdio: 'ignore' });
|
|
205
|
+
} catch {}
|
|
206
|
+
try { fs.unlinkSync(servicePath); return true; } catch { return false; }
|
|
207
|
+
try { execSync('systemctl --user daemon-reload', { stdio: 'ignore' }); } catch {}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function isInstalledLinux() {
|
|
211
|
+
return fs.existsSync(getLinuxServicePath());
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// ---------------------------------------------------------------------------
|
|
215
|
+
// Public API
|
|
216
|
+
// ---------------------------------------------------------------------------
|
|
217
|
+
|
|
218
|
+
function install() {
|
|
219
|
+
const platform = process.platform;
|
|
220
|
+
if (platform === 'win32') return { path: installWindows(), platform: 'windows' };
|
|
221
|
+
if (platform === 'darwin') return { path: installMac(), platform: 'macos' };
|
|
222
|
+
return { path: installLinux(), platform: 'linux' };
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
function uninstall() {
|
|
226
|
+
const platform = process.platform;
|
|
227
|
+
if (platform === 'win32') return uninstallWindows();
|
|
228
|
+
if (platform === 'darwin') return uninstallMac();
|
|
229
|
+
return uninstallLinux();
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
function isInstalled() {
|
|
233
|
+
const platform = process.platform;
|
|
234
|
+
if (platform === 'win32') return isInstalledWindows();
|
|
235
|
+
if (platform === 'darwin') return isInstalledMac();
|
|
236
|
+
return isInstalledLinux();
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
function startNow() {
|
|
240
|
+
const platform = process.platform;
|
|
241
|
+
const { execSync, exec } = require('child_process');
|
|
242
|
+
|
|
243
|
+
if (platform === 'win32') {
|
|
244
|
+
// Launch via wscript (hidden, non-blocking)
|
|
245
|
+
const vbsPath = getWindowsVbsPath();
|
|
246
|
+
if (fs.existsSync(vbsPath)) {
|
|
247
|
+
exec(`wscript "${vbsPath}"`, { stdio: 'ignore' });
|
|
248
|
+
return true;
|
|
249
|
+
}
|
|
250
|
+
return false;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
if (platform === 'darwin') {
|
|
254
|
+
try {
|
|
255
|
+
execSync(`launchctl load "${getMacPlistPath()}"`, { stdio: 'ignore' });
|
|
256
|
+
return true;
|
|
257
|
+
} catch { return false; }
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Linux
|
|
261
|
+
try {
|
|
262
|
+
execSync('systemctl --user start aimeter.service', { stdio: 'ignore' });
|
|
263
|
+
return true;
|
|
264
|
+
} catch { return false; }
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function stopNow() {
|
|
268
|
+
const platform = process.platform;
|
|
269
|
+
const { execSync } = require('child_process');
|
|
270
|
+
|
|
271
|
+
if (platform === 'win32') {
|
|
272
|
+
// Kill any running node process with our watcher
|
|
273
|
+
try {
|
|
274
|
+
execSync('taskkill /F /FI "WINDOWTITLE eq aimeter*" 2>nul', { stdio: 'ignore' });
|
|
275
|
+
// Also kill by script name
|
|
276
|
+
execSync(`wmic process where "commandline like '%aimeter-watcher%cli.js%watch%'" call terminate 2>nul`, { stdio: 'ignore' });
|
|
277
|
+
return true;
|
|
278
|
+
} catch { return false; }
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
if (platform === 'darwin') {
|
|
282
|
+
try {
|
|
283
|
+
execSync(`launchctl unload "${getMacPlistPath()}"`, { stdio: 'ignore' });
|
|
284
|
+
return true;
|
|
285
|
+
} catch { return false; }
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
try {
|
|
289
|
+
execSync('systemctl --user stop aimeter.service', { stdio: 'ignore' });
|
|
290
|
+
return true;
|
|
291
|
+
} catch { return false; }
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = { install, uninstall, isInstalled, startNow, stopNow };
|
package/state.js
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const { AIMETER_DIR } = require('./config');
|
|
6
|
+
|
|
7
|
+
const STATE_FILE = path.join(AIMETER_DIR, 'state.json');
|
|
8
|
+
|
|
9
|
+
let _state = null;
|
|
10
|
+
|
|
11
|
+
function load() {
|
|
12
|
+
if (_state) return _state;
|
|
13
|
+
try {
|
|
14
|
+
_state = JSON.parse(fs.readFileSync(STATE_FILE, 'utf8'));
|
|
15
|
+
} catch {
|
|
16
|
+
_state = { fileOffsets: {}, recentHashes: [] };
|
|
17
|
+
}
|
|
18
|
+
return _state;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
function save() {
|
|
22
|
+
if (!_state) return;
|
|
23
|
+
fs.mkdirSync(AIMETER_DIR, { recursive: true });
|
|
24
|
+
// Keep recentHashes bounded
|
|
25
|
+
if (_state.recentHashes && _state.recentHashes.length > 200) {
|
|
26
|
+
_state.recentHashes = _state.recentHashes.slice(-100);
|
|
27
|
+
}
|
|
28
|
+
_state.lastSaved = new Date().toISOString();
|
|
29
|
+
fs.writeFileSync(STATE_FILE, JSON.stringify(_state, null, 2) + '\n');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function getOffset(filePath) {
|
|
33
|
+
const s = load();
|
|
34
|
+
return s.fileOffsets[filePath] || 0;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function setOffset(filePath, offset) {
|
|
38
|
+
const s = load();
|
|
39
|
+
s.fileOffsets[filePath] = offset;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function isDuplicate(hash) {
|
|
43
|
+
const s = load();
|
|
44
|
+
if (!s.recentHashes) s.recentHashes = [];
|
|
45
|
+
if (s.recentHashes.includes(hash)) return true;
|
|
46
|
+
s.recentHashes.push(hash);
|
|
47
|
+
return false;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
module.exports = { load, save, getOffset, setOffset, isDuplicate };
|
package/update-check.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const https = require('https');
|
|
4
|
+
const fs = require('fs');
|
|
5
|
+
const path = require('path');
|
|
6
|
+
const { AIMETER_DIR } = require('./config');
|
|
7
|
+
|
|
8
|
+
const PACKAGE_NAME = 'getaimeter';
|
|
9
|
+
const CHECK_FILE = path.join(AIMETER_DIR, 'last-update-check.json');
|
|
10
|
+
const CHECK_INTERVAL = 24 * 60 * 60 * 1000; // 24 hours
|
|
11
|
+
|
|
12
|
+
function getCurrentVersion() {
|
|
13
|
+
const pkg = require('./package.json');
|
|
14
|
+
return pkg.version;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Non-blocking check for newer version on npm.
|
|
19
|
+
* Shows a message if a newer version exists.
|
|
20
|
+
* Only checks once every 24 hours.
|
|
21
|
+
*/
|
|
22
|
+
function checkForUpdate() {
|
|
23
|
+
try {
|
|
24
|
+
// Skip if checked recently
|
|
25
|
+
try {
|
|
26
|
+
const data = JSON.parse(fs.readFileSync(CHECK_FILE, 'utf8'));
|
|
27
|
+
if (Date.now() - data.lastCheck < CHECK_INTERVAL) {
|
|
28
|
+
// Still show cached message if there was an update
|
|
29
|
+
if (data.latestVersion && data.latestVersion !== getCurrentVersion()) {
|
|
30
|
+
showUpdateMessage(data.latestVersion);
|
|
31
|
+
}
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
} catch {}
|
|
35
|
+
|
|
36
|
+
// Fetch latest version from npm registry
|
|
37
|
+
const req = https.get(`https://registry.npmjs.org/${PACKAGE_NAME}/latest`, {
|
|
38
|
+
headers: { 'Accept': 'application/json' },
|
|
39
|
+
timeout: 3000,
|
|
40
|
+
}, (res) => {
|
|
41
|
+
let body = '';
|
|
42
|
+
res.on('data', (chunk) => body += chunk);
|
|
43
|
+
res.on('end', () => {
|
|
44
|
+
try {
|
|
45
|
+
const data = JSON.parse(body);
|
|
46
|
+
const latest = data.version;
|
|
47
|
+
|
|
48
|
+
// Save check result
|
|
49
|
+
fs.mkdirSync(AIMETER_DIR, { recursive: true });
|
|
50
|
+
fs.writeFileSync(CHECK_FILE, JSON.stringify({
|
|
51
|
+
lastCheck: Date.now(),
|
|
52
|
+
latestVersion: latest,
|
|
53
|
+
}) + '\n');
|
|
54
|
+
|
|
55
|
+
if (latest && latest !== getCurrentVersion()) {
|
|
56
|
+
showUpdateMessage(latest);
|
|
57
|
+
}
|
|
58
|
+
} catch {}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
req.on('error', () => {}); // Silently fail — not critical
|
|
63
|
+
req.setTimeout(3000, () => req.destroy());
|
|
64
|
+
} catch {}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function showUpdateMessage(latest) {
|
|
68
|
+
const current = getCurrentVersion();
|
|
69
|
+
console.log('');
|
|
70
|
+
console.log(` Update available: ${current} → ${latest}`);
|
|
71
|
+
console.log(` Run: npm update -g aimeter`);
|
|
72
|
+
console.log('');
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { checkForUpdate, getCurrentVersion };
|
package/watcher.js
ADDED
|
@@ -0,0 +1,272 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
const crypto = require('crypto');
|
|
6
|
+
const { getApiKey, getWatchPaths } = require('./config');
|
|
7
|
+
const { getOffset, setOffset, isDuplicate, save: saveState } = require('./state');
|
|
8
|
+
const { postUsage } = require('./reporter');
|
|
9
|
+
|
|
10
|
+
// ---------------------------------------------------------------------------
|
|
11
|
+
// Logging
|
|
12
|
+
// ---------------------------------------------------------------------------
|
|
13
|
+
|
|
14
|
+
const LOG_FILE = path.join(require('./config').AIMETER_DIR, 'watcher.log');
|
|
15
|
+
|
|
16
|
+
function log(...args) {
|
|
17
|
+
const ts = new Date().toISOString();
|
|
18
|
+
const msg = `[${ts}] ${args.join(' ')}`;
|
|
19
|
+
console.log(msg);
|
|
20
|
+
try {
|
|
21
|
+
fs.mkdirSync(path.dirname(LOG_FILE), { recursive: true });
|
|
22
|
+
fs.appendFileSync(LOG_FILE, msg + '\n');
|
|
23
|
+
} catch {}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function logError(...args) {
|
|
27
|
+
log('ERROR:', ...args);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// ---------------------------------------------------------------------------
|
|
31
|
+
// Source detection from file path
|
|
32
|
+
// ---------------------------------------------------------------------------
|
|
33
|
+
|
|
34
|
+
function detectSource(filePath) {
|
|
35
|
+
const normalized = filePath.replace(/\\/g, '/');
|
|
36
|
+
if (normalized.includes('local-agent-mode-sessions')) return 'desktop_agent_mode';
|
|
37
|
+
// VS Code uses lowercase 'c' in the sanitized project dir name on Windows
|
|
38
|
+
const projectsMatch = normalized.match(/\.claude\/projects\/([^/])/);
|
|
39
|
+
if (projectsMatch) {
|
|
40
|
+
const firstChar = projectsMatch[1];
|
|
41
|
+
// On Windows: CLI produces uppercase (C--Users), VS Code produces lowercase (c--Users)
|
|
42
|
+
if (firstChar === firstChar.toLowerCase() && firstChar !== firstChar.toUpperCase()) {
|
|
43
|
+
return 'claude_code_vscode';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return 'claude_code_cli';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
// JSONL parsing — extract usage from new bytes in a transcript file
|
|
51
|
+
// ---------------------------------------------------------------------------
|
|
52
|
+
|
|
53
|
+
function extractNewUsage(filePath) {
|
|
54
|
+
let stat;
|
|
55
|
+
try { stat = fs.statSync(filePath); } catch { return []; }
|
|
56
|
+
|
|
57
|
+
const currentSize = stat.size;
|
|
58
|
+
const lastOffset = getOffset(filePath);
|
|
59
|
+
|
|
60
|
+
if (currentSize <= lastOffset) return [];
|
|
61
|
+
|
|
62
|
+
// Read only the new bytes
|
|
63
|
+
const fd = fs.openSync(filePath, 'r');
|
|
64
|
+
const buf = Buffer.alloc(currentSize - lastOffset);
|
|
65
|
+
fs.readSync(fd, buf, 0, buf.length, lastOffset);
|
|
66
|
+
fs.closeSync(fd);
|
|
67
|
+
|
|
68
|
+
const text = buf.toString('utf8');
|
|
69
|
+
const lines = text.split('\n');
|
|
70
|
+
|
|
71
|
+
// If we're reading from mid-file (offset > 0), the first line may be partial
|
|
72
|
+
if (lastOffset > 0 && lines.length > 0) lines.shift();
|
|
73
|
+
|
|
74
|
+
const usageEvents = [];
|
|
75
|
+
|
|
76
|
+
for (const line of lines) {
|
|
77
|
+
const trimmed = line.trim();
|
|
78
|
+
if (!trimmed) continue;
|
|
79
|
+
|
|
80
|
+
let obj;
|
|
81
|
+
try { obj = JSON.parse(trimmed); } catch { continue; }
|
|
82
|
+
|
|
83
|
+
if (obj.type !== 'assistant' || !obj.message || !obj.message.usage) continue;
|
|
84
|
+
|
|
85
|
+
const u = obj.message.usage;
|
|
86
|
+
const model = obj.message.model || 'unknown';
|
|
87
|
+
|
|
88
|
+
// Build dedup hash
|
|
89
|
+
const hash = crypto.createHash('md5')
|
|
90
|
+
.update(`${filePath}:${model}:${u.input_tokens || 0}:${u.output_tokens || 0}:${currentSize}`)
|
|
91
|
+
.digest('hex');
|
|
92
|
+
|
|
93
|
+
if (isDuplicate(hash)) continue;
|
|
94
|
+
|
|
95
|
+
usageEvents.push({
|
|
96
|
+
provider: 'anthropic',
|
|
97
|
+
model,
|
|
98
|
+
source: detectSource(filePath),
|
|
99
|
+
inputTokens: u.input_tokens || 0,
|
|
100
|
+
outputTokens: u.output_tokens || 0,
|
|
101
|
+
thinkingTokens: u.thinking_tokens || 0,
|
|
102
|
+
cacheReadTokens: u.cache_read_input_tokens || 0,
|
|
103
|
+
cacheWriteTokens: u.cache_creation_input_tokens || 0,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Update offset to current file size
|
|
108
|
+
setOffset(filePath, currentSize);
|
|
109
|
+
|
|
110
|
+
return usageEvents;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
// ---------------------------------------------------------------------------
|
|
114
|
+
// Report usage events to backend
|
|
115
|
+
// ---------------------------------------------------------------------------
|
|
116
|
+
|
|
117
|
+
async function reportEvents(events) {
|
|
118
|
+
const apiKey = getApiKey();
|
|
119
|
+
if (!apiKey) {
|
|
120
|
+
logError('No API key configured. Run: aimeter setup');
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
for (const evt of events) {
|
|
125
|
+
const result = await postUsage(apiKey, evt);
|
|
126
|
+
if (result.ok) {
|
|
127
|
+
log(`Reported: ${evt.source} ${evt.model} in=${evt.inputTokens} out=${evt.outputTokens} cache_r=${evt.cacheReadTokens}`);
|
|
128
|
+
} else {
|
|
129
|
+
logError(`Failed to report: HTTP ${result.status} ${result.error || ''}`);
|
|
130
|
+
}
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ---------------------------------------------------------------------------
|
|
135
|
+
// File watcher
|
|
136
|
+
// ---------------------------------------------------------------------------
|
|
137
|
+
|
|
138
|
+
const _debounceTimers = new Map();
|
|
139
|
+
|
|
140
|
+
function handleFileChange(filePath) {
|
|
141
|
+
// Only care about .jsonl files
|
|
142
|
+
if (!filePath.endsWith('.jsonl')) return;
|
|
143
|
+
|
|
144
|
+
// Debounce: wait 500ms after last change before processing
|
|
145
|
+
const existing = _debounceTimers.get(filePath);
|
|
146
|
+
if (existing) clearTimeout(existing);
|
|
147
|
+
|
|
148
|
+
_debounceTimers.set(filePath, setTimeout(async () => {
|
|
149
|
+
_debounceTimers.delete(filePath);
|
|
150
|
+
try {
|
|
151
|
+
const events = extractNewUsage(filePath);
|
|
152
|
+
if (events.length > 0) {
|
|
153
|
+
await reportEvents(events);
|
|
154
|
+
saveState();
|
|
155
|
+
}
|
|
156
|
+
} catch (err) {
|
|
157
|
+
logError(`Processing ${filePath}:`, err.message);
|
|
158
|
+
}
|
|
159
|
+
}, 500));
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/**
|
|
163
|
+
* Recursively find all .jsonl files under a directory.
|
|
164
|
+
*/
|
|
165
|
+
function findJsonlFiles(dir) {
|
|
166
|
+
const results = [];
|
|
167
|
+
let entries;
|
|
168
|
+
try { entries = fs.readdirSync(dir, { withFileTypes: true }); } catch { return results; }
|
|
169
|
+
|
|
170
|
+
for (const entry of entries) {
|
|
171
|
+
const full = path.join(dir, entry.name);
|
|
172
|
+
if (entry.isDirectory()) {
|
|
173
|
+
results.push(...findJsonlFiles(full));
|
|
174
|
+
} else if (entry.name.endsWith('.jsonl')) {
|
|
175
|
+
results.push(full);
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
return results;
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/**
|
|
182
|
+
* Start watching all configured paths.
|
|
183
|
+
* Returns a cleanup function.
|
|
184
|
+
*/
|
|
185
|
+
function startWatching() {
|
|
186
|
+
const watchPaths = getWatchPaths();
|
|
187
|
+
|
|
188
|
+
if (watchPaths.length === 0) {
|
|
189
|
+
logError('No Claude transcript directories found. Is Claude Code installed?');
|
|
190
|
+
process.exit(1);
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
log('AIMeter Watcher starting...');
|
|
194
|
+
log('Watching:', watchPaths.join(', '));
|
|
195
|
+
|
|
196
|
+
const apiKey = getApiKey();
|
|
197
|
+
if (!apiKey) {
|
|
198
|
+
log('WARNING: No API key found. Usage will not be reported.');
|
|
199
|
+
log('Run: aimeter setup');
|
|
200
|
+
} else {
|
|
201
|
+
log('API key:', apiKey.slice(0, 8) + '...' + apiKey.slice(-4));
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Initial scan: mark existing files as "already read" so we only report
|
|
205
|
+
// NEW usage going forward. Without this, first run floods the backend.
|
|
206
|
+
const { load: loadState } = require('./state');
|
|
207
|
+
const state = loadState();
|
|
208
|
+
const isFirstRun = Object.keys(state.fileOffsets || {}).length === 0;
|
|
209
|
+
|
|
210
|
+
let filesMarked = 0;
|
|
211
|
+
for (const watchPath of watchPaths) {
|
|
212
|
+
const files = findJsonlFiles(watchPath);
|
|
213
|
+
for (const file of files) {
|
|
214
|
+
if (isFirstRun) {
|
|
215
|
+
// First run: skip to end of all files
|
|
216
|
+
try {
|
|
217
|
+
const size = fs.statSync(file).size;
|
|
218
|
+
setOffset(file, size);
|
|
219
|
+
filesMarked++;
|
|
220
|
+
} catch {}
|
|
221
|
+
} else {
|
|
222
|
+
// Subsequent runs: process new data since last offset
|
|
223
|
+
const events = extractNewUsage(file);
|
|
224
|
+
if (events.length > 0) {
|
|
225
|
+
reportEvents(events);
|
|
226
|
+
filesMarked += events.length;
|
|
227
|
+
}
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
if (isFirstRun) {
|
|
232
|
+
log(`First run: marked ${filesMarked} existing files as read. Only new usage will be reported.`);
|
|
233
|
+
} else if (filesMarked > 0) {
|
|
234
|
+
log(`Catch-up: processed ${filesMarked} new events since last run`);
|
|
235
|
+
}
|
|
236
|
+
saveState();
|
|
237
|
+
|
|
238
|
+
// Set up fs.watch on each path
|
|
239
|
+
const watchers = [];
|
|
240
|
+
for (const watchPath of watchPaths) {
|
|
241
|
+
try {
|
|
242
|
+
const w = fs.watch(watchPath, { recursive: true }, (eventType, filename) => {
|
|
243
|
+
if (!filename) return;
|
|
244
|
+
const fullPath = path.join(watchPath, filename);
|
|
245
|
+
handleFileChange(fullPath);
|
|
246
|
+
});
|
|
247
|
+
|
|
248
|
+
w.on('error', (err) => {
|
|
249
|
+
logError(`Watcher error on ${watchPath}:`, err.message);
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
watchers.push(w);
|
|
253
|
+
log('Watching:', watchPath);
|
|
254
|
+
} catch (err) {
|
|
255
|
+
logError(`Could not watch ${watchPath}:`, err.message);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
// Periodic state save
|
|
260
|
+
const saveInterval = setInterval(() => saveState(), 30_000);
|
|
261
|
+
|
|
262
|
+
// Return cleanup
|
|
263
|
+
return () => {
|
|
264
|
+
clearInterval(saveInterval);
|
|
265
|
+
for (const w of watchers) w.close();
|
|
266
|
+
for (const t of _debounceTimers.values()) clearTimeout(t);
|
|
267
|
+
saveState();
|
|
268
|
+
log('Watcher stopped.');
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
module.exports = { startWatching };
|