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 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
+ ![Dashboard](https://getaimeter.com/og-image.png)
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 };
@@ -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 };