getaimeter 0.8.1 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/cli.js +235 -1
- package/config-schema.json +61 -0
- package/mcp.js +772 -0
- package/package.json +13 -3
package/cli.js
CHANGED
|
@@ -10,7 +10,7 @@ const { startTray, stopTray } = require('./tray');
|
|
|
10
10
|
const command = process.argv[2] || 'help';
|
|
11
11
|
|
|
12
12
|
// Version check on interactive commands (non-blocking)
|
|
13
|
-
if (['setup', 'status', 'help', '--help', '-h'].includes(command)) {
|
|
13
|
+
if (['setup', 'status', 'summary', 'help', '--help', '-h'].includes(command)) {
|
|
14
14
|
checkForUpdate();
|
|
15
15
|
}
|
|
16
16
|
|
|
@@ -24,9 +24,12 @@ switch (command) {
|
|
|
24
24
|
case 'status': runStatus(); break;
|
|
25
25
|
case 'logs': runLogs(); break;
|
|
26
26
|
case 'key': runKey(); break;
|
|
27
|
+
case 'summary': runSummary(); break;
|
|
28
|
+
case 'blocks': runBlocks(); break;
|
|
27
29
|
case 'version': case '--version': case '-v':
|
|
28
30
|
console.log(getCurrentVersion());
|
|
29
31
|
break;
|
|
32
|
+
case 'mcp': runMcp(); break;
|
|
30
33
|
case 'help': case '--help': case '-h':
|
|
31
34
|
printHelp();
|
|
32
35
|
break;
|
|
@@ -37,6 +40,41 @@ switch (command) {
|
|
|
37
40
|
break;
|
|
38
41
|
}
|
|
39
42
|
|
|
43
|
+
// ---------------------------------------------------------------------------
|
|
44
|
+
// fetchFromApi — shared helper for API calls (native https, zero deps)
|
|
45
|
+
// ---------------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
function fetchFromApi(urlPath, apiKey) {
|
|
48
|
+
return new Promise((resolve, reject) => {
|
|
49
|
+
const https = require('https');
|
|
50
|
+
const options = {
|
|
51
|
+
hostname: 'aimeter-api.fly.dev',
|
|
52
|
+
port: 443,
|
|
53
|
+
path: urlPath,
|
|
54
|
+
method: 'GET',
|
|
55
|
+
headers: {
|
|
56
|
+
'X-AIMeter-Key': apiKey,
|
|
57
|
+
},
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
const req = https.request(options, (res) => {
|
|
61
|
+
let body = '';
|
|
62
|
+
res.on('data', chunk => body += chunk);
|
|
63
|
+
res.on('end', () => {
|
|
64
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
65
|
+
try { resolve(JSON.parse(body)); }
|
|
66
|
+
catch { reject(new Error('Invalid JSON response')); }
|
|
67
|
+
} else {
|
|
68
|
+
reject(new Error(`HTTP ${res.statusCode}: ${body.slice(0, 200)}`));
|
|
69
|
+
}
|
|
70
|
+
});
|
|
71
|
+
});
|
|
72
|
+
req.on('error', reject);
|
|
73
|
+
req.setTimeout(10000, () => { req.destroy(); reject(new Error('Request timeout')); });
|
|
74
|
+
req.end();
|
|
75
|
+
});
|
|
76
|
+
}
|
|
77
|
+
|
|
40
78
|
// ---------------------------------------------------------------------------
|
|
41
79
|
// setup — full onboarding wizard
|
|
42
80
|
// ---------------------------------------------------------------------------
|
|
@@ -390,6 +428,195 @@ function runKey() {
|
|
|
390
428
|
}
|
|
391
429
|
}
|
|
392
430
|
|
|
431
|
+
// ---------------------------------------------------------------------------
|
|
432
|
+
// mcp — start MCP server for Claude Code/Desktop
|
|
433
|
+
// ---------------------------------------------------------------------------
|
|
434
|
+
|
|
435
|
+
function runMcp() {
|
|
436
|
+
require('./mcp').startMcpServer();
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// ---------------------------------------------------------------------------
|
|
440
|
+
// summary — show current usage summary from API
|
|
441
|
+
// ---------------------------------------------------------------------------
|
|
442
|
+
|
|
443
|
+
async function runSummary() {
|
|
444
|
+
const https = require('https');
|
|
445
|
+
const apiKey = getApiKey();
|
|
446
|
+
if (!apiKey) {
|
|
447
|
+
console.log('No API key configured. Run: aimeter setup');
|
|
448
|
+
process.exitCode = 1;
|
|
449
|
+
return;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
// Check for --json flag
|
|
453
|
+
const jsonMode = process.argv.includes('--json');
|
|
454
|
+
|
|
455
|
+
try {
|
|
456
|
+
// Fetch current usage from API
|
|
457
|
+
const data = await fetchFromApi('/api/usage/current', apiKey);
|
|
458
|
+
|
|
459
|
+
if (jsonMode) {
|
|
460
|
+
console.log(JSON.stringify(data, null, 2));
|
|
461
|
+
return;
|
|
462
|
+
}
|
|
463
|
+
|
|
464
|
+
// Pretty print
|
|
465
|
+
const fmtTokens = (n) => {
|
|
466
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(2)}M`;
|
|
467
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(1)}k`;
|
|
468
|
+
return String(n);
|
|
469
|
+
};
|
|
470
|
+
const fmtCost = (c) => {
|
|
471
|
+
if (c >= 1) return `$${c.toFixed(2)}`;
|
|
472
|
+
if (c >= 0.01) return `$${c.toFixed(3)}`;
|
|
473
|
+
return `$${c.toFixed(4)}`;
|
|
474
|
+
};
|
|
475
|
+
|
|
476
|
+
// Calculate total cost from byModel
|
|
477
|
+
const totalCost = (data.byModel || []).reduce((sum, m) => sum + (m.estimatedCost || 0), 0);
|
|
478
|
+
|
|
479
|
+
// Reset countdown
|
|
480
|
+
let resetStr = 'N/A';
|
|
481
|
+
if (data.nextReset) {
|
|
482
|
+
const resetMs = new Date(data.nextReset) - Date.now();
|
|
483
|
+
if (resetMs > 0) {
|
|
484
|
+
const h = Math.floor(resetMs / 3600000);
|
|
485
|
+
const m = Math.floor((resetMs % 3600000) / 60000);
|
|
486
|
+
resetStr = `${h}h ${m}m`;
|
|
487
|
+
} else {
|
|
488
|
+
resetStr = 'now';
|
|
489
|
+
}
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
console.log('');
|
|
493
|
+
console.log(' AIMeter — Current Usage (5-hour window)');
|
|
494
|
+
console.log(' ════════════════════════════════════════');
|
|
495
|
+
console.log('');
|
|
496
|
+
console.log(` Cost: ${fmtCost(totalCost)}`);
|
|
497
|
+
console.log(` Input: ${fmtTokens(data.totalInputTokens || 0)}`);
|
|
498
|
+
console.log(` Output: ${fmtTokens(data.totalOutputTokens || 0)}`);
|
|
499
|
+
console.log(` Thinking: ${fmtTokens(data.totalThinkingTokens || 0)}`);
|
|
500
|
+
console.log(` Cache Read: ${fmtTokens(data.totalCacheReadTokens || 0)}`);
|
|
501
|
+
console.log(` Requests: ${data.windowRequests || 0}`);
|
|
502
|
+
console.log(` Reset in: ${resetStr}`);
|
|
503
|
+
|
|
504
|
+
// By source
|
|
505
|
+
if (data.bySource && data.bySource.length > 0) {
|
|
506
|
+
console.log('');
|
|
507
|
+
console.log(' By Source:');
|
|
508
|
+
const sourceLabels = {
|
|
509
|
+
cli: 'Terminal', vscode: 'VS Code', desktop_app: 'Desktop',
|
|
510
|
+
cursor: 'Cursor', codex_cli: 'Codex CLI', codex_vscode: 'Codex VS',
|
|
511
|
+
gemini_cli: 'Gemini', copilot_cli: 'Copilot', copilot_vscode: 'Copilot VS',
|
|
512
|
+
};
|
|
513
|
+
for (const s of data.bySource) {
|
|
514
|
+
const label = sourceLabels[s.source] || s.source;
|
|
515
|
+
console.log(` ${label.padEnd(14)} ${String(s.requests).padStart(4)} req`);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// By model
|
|
520
|
+
if (data.byModel && data.byModel.length > 0) {
|
|
521
|
+
console.log('');
|
|
522
|
+
console.log(' By Model:');
|
|
523
|
+
for (const m of data.byModel) {
|
|
524
|
+
const label = m.model.replace(/-\d{8}$/, '');
|
|
525
|
+
console.log(` ${label.padEnd(22)} ${String(m.requests).padStart(4)} req ${fmtCost(m.estimatedCost || 0)}`);
|
|
526
|
+
}
|
|
527
|
+
}
|
|
528
|
+
|
|
529
|
+
// Previous window comparison
|
|
530
|
+
if (data.previous && data.previous.totalCost > 0) {
|
|
531
|
+
const prevCost = data.previous.totalCost;
|
|
532
|
+
const change = totalCost - prevCost;
|
|
533
|
+
const pct = prevCost > 0 ? ((change / prevCost) * 100).toFixed(0) : '∞';
|
|
534
|
+
const arrow = change >= 0 ? '↑' : '↓';
|
|
535
|
+
console.log('');
|
|
536
|
+
console.log(` vs Previous: ${arrow} ${Math.abs(change).toFixed(2)} (${pct}%)`);
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
console.log('');
|
|
540
|
+
} catch (err) {
|
|
541
|
+
console.error(`Error fetching usage: ${err.message}`);
|
|
542
|
+
process.exitCode = 1;
|
|
543
|
+
}
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
// ---------------------------------------------------------------------------
|
|
547
|
+
// blocks — show 5-hour billing blocks
|
|
548
|
+
// ---------------------------------------------------------------------------
|
|
549
|
+
|
|
550
|
+
async function runBlocks() {
|
|
551
|
+
const apiKey = getApiKey();
|
|
552
|
+
if (!apiKey) {
|
|
553
|
+
console.log('No API key configured. Run: aimeter setup');
|
|
554
|
+
process.exitCode = 1;
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
const jsonMode = process.argv.includes('--json');
|
|
559
|
+
const days = parseInt(process.argv[3]) || 3;
|
|
560
|
+
|
|
561
|
+
try {
|
|
562
|
+
const data = await fetchFromApi(`/api/usage/blocks?days=${days}`, apiKey);
|
|
563
|
+
|
|
564
|
+
if (jsonMode) {
|
|
565
|
+
console.log(JSON.stringify(data, null, 2));
|
|
566
|
+
return;
|
|
567
|
+
}
|
|
568
|
+
|
|
569
|
+
console.log('');
|
|
570
|
+
console.log(` AIMeter — Billing Blocks (last ${days} days)`);
|
|
571
|
+
console.log(' ════════════════════════════════════════');
|
|
572
|
+
console.log('');
|
|
573
|
+
|
|
574
|
+
if (!data.blocks || data.blocks.length === 0) {
|
|
575
|
+
console.log(' No billing blocks found.');
|
|
576
|
+
console.log('');
|
|
577
|
+
return;
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
for (const block of data.blocks) {
|
|
581
|
+
const start = new Date(block.startTime);
|
|
582
|
+
const fmtTime = (d) => d.toLocaleString([], { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
|
|
583
|
+
const active = block.isActive ? ' ◉ ACTIVE' : '';
|
|
584
|
+
|
|
585
|
+
console.log(` ${fmtTime(start)}${active}`);
|
|
586
|
+
console.log(` ────────────────────────────`);
|
|
587
|
+
|
|
588
|
+
const fmtCost = (c) => c >= 1 ? `$${c.toFixed(2)}` : `$${c.toFixed(3)}`;
|
|
589
|
+
const fmtTokens = (n) => {
|
|
590
|
+
if (n >= 1_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
|
|
591
|
+
if (n >= 1_000) return `${(n / 1_000).toFixed(0)}k`;
|
|
592
|
+
return String(n);
|
|
593
|
+
};
|
|
594
|
+
|
|
595
|
+
console.log(` Cost: ${fmtCost(block.totalCost)} | Requests: ${block.requestCount} | Tokens: ${fmtTokens(block.totalInputTokens + block.totalOutputTokens)}`);
|
|
596
|
+
|
|
597
|
+
if (block.burnRate) {
|
|
598
|
+
console.log(` Burn: ${fmtTokens(block.burnRate.tokensPerMinute)}/min | ${fmtCost(block.burnRate.costPerHour)}/hr`);
|
|
599
|
+
}
|
|
600
|
+
|
|
601
|
+
if (block.isActive && block.remainingMinutes > 0) {
|
|
602
|
+
const h = Math.floor(block.remainingMinutes / 60);
|
|
603
|
+
const m = Math.round(block.remainingMinutes % 60);
|
|
604
|
+
console.log(` Remaining: ${h}h ${m}m`);
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
console.log(` Sources: ${(block.sources || []).join(', ')}`);
|
|
608
|
+
console.log(` Models: ${(block.models || []).join(', ')}`);
|
|
609
|
+
console.log('');
|
|
610
|
+
}
|
|
611
|
+
|
|
612
|
+
console.log(` Total: ${data.totalBlocks} blocks, $${data.totalCost.toFixed(2)}`);
|
|
613
|
+
console.log('');
|
|
614
|
+
} catch (err) {
|
|
615
|
+
console.error(`Error fetching blocks: ${err.message}`);
|
|
616
|
+
process.exitCode = 1;
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
|
|
393
620
|
// ---------------------------------------------------------------------------
|
|
394
621
|
// help
|
|
395
622
|
// ---------------------------------------------------------------------------
|
|
@@ -413,11 +640,18 @@ function printHelp() {
|
|
|
413
640
|
Manual mode:
|
|
414
641
|
watch Run watcher in foreground
|
|
415
642
|
|
|
643
|
+
Usage insights:
|
|
644
|
+
summary Show current usage summary (--json for JSON)
|
|
645
|
+
blocks [N] Show 5-hour billing blocks (last N days, default 3)
|
|
646
|
+
|
|
416
647
|
Info:
|
|
417
648
|
status Show current configuration
|
|
418
649
|
logs [N] Tail watcher log (last N lines, default 50)
|
|
419
650
|
key Print current API key
|
|
420
651
|
|
|
652
|
+
Integration:
|
|
653
|
+
mcp Start MCP server (for Claude Code/Desktop)
|
|
654
|
+
|
|
421
655
|
https://getaimeter.com
|
|
422
656
|
`);
|
|
423
657
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "http://json-schema.org/draft-07/schema#",
|
|
3
|
+
"title": "AIMeter Configuration",
|
|
4
|
+
"description": "Configuration file for AIMeter watcher (~/.aimeter/config.json)",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"properties": {
|
|
7
|
+
"apiKey": {
|
|
8
|
+
"type": "string",
|
|
9
|
+
"description": "Your AIMeter API key (starts with aim_)",
|
|
10
|
+
"pattern": "^aim_[a-zA-Z0-9]+$"
|
|
11
|
+
},
|
|
12
|
+
"apiUrl": {
|
|
13
|
+
"type": "string",
|
|
14
|
+
"description": "API server URL (default: https://aimeter-api.fly.dev)",
|
|
15
|
+
"format": "uri",
|
|
16
|
+
"default": "https://aimeter-api.fly.dev"
|
|
17
|
+
},
|
|
18
|
+
"pollInterval": {
|
|
19
|
+
"type": "integer",
|
|
20
|
+
"description": "File polling interval in milliseconds (default: 5000)",
|
|
21
|
+
"minimum": 1000,
|
|
22
|
+
"maximum": 60000,
|
|
23
|
+
"default": 5000
|
|
24
|
+
},
|
|
25
|
+
"enableCursor": {
|
|
26
|
+
"type": "boolean",
|
|
27
|
+
"description": "Enable Cursor IDE tracking (requires sqlite3)",
|
|
28
|
+
"default": true
|
|
29
|
+
},
|
|
30
|
+
"enableCodex": {
|
|
31
|
+
"type": "boolean",
|
|
32
|
+
"description": "Enable OpenAI Codex tracking",
|
|
33
|
+
"default": true
|
|
34
|
+
},
|
|
35
|
+
"enableCopilot": {
|
|
36
|
+
"type": "boolean",
|
|
37
|
+
"description": "Enable GitHub Copilot tracking",
|
|
38
|
+
"default": true
|
|
39
|
+
},
|
|
40
|
+
"enableGemini": {
|
|
41
|
+
"type": "boolean",
|
|
42
|
+
"description": "Enable Google Gemini tracking",
|
|
43
|
+
"default": true
|
|
44
|
+
},
|
|
45
|
+
"logLevel": {
|
|
46
|
+
"type": "string",
|
|
47
|
+
"description": "Logging verbosity",
|
|
48
|
+
"enum": ["debug", "info", "warn", "error"],
|
|
49
|
+
"default": "info"
|
|
50
|
+
},
|
|
51
|
+
"maxLogSizeMb": {
|
|
52
|
+
"type": "number",
|
|
53
|
+
"description": "Maximum log file size in MB before rotation (default: 10)",
|
|
54
|
+
"minimum": 1,
|
|
55
|
+
"maximum": 100,
|
|
56
|
+
"default": 10
|
|
57
|
+
}
|
|
58
|
+
},
|
|
59
|
+
"required": ["apiKey"],
|
|
60
|
+
"additionalProperties": false
|
|
61
|
+
}
|
package/mcp.js
ADDED
|
@@ -0,0 +1,772 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* AIMeter MCP Server
|
|
6
|
+
*
|
|
7
|
+
* Model Context Protocol server that exposes AI usage data to Claude Code/Desktop.
|
|
8
|
+
* Uses STDIO transport (JSON-RPC 2.0) with zero npm dependencies.
|
|
9
|
+
*
|
|
10
|
+
* Usage:
|
|
11
|
+
* node mcp.js — start the MCP server (stdin/stdout)
|
|
12
|
+
* aimeter mcp — same, via CLI
|
|
13
|
+
* aimeter-mcp — same, via bin alias
|
|
14
|
+
*
|
|
15
|
+
* Configuration for Claude Code (~/.claude/settings.json):
|
|
16
|
+
* {
|
|
17
|
+
* "mcpServers": {
|
|
18
|
+
* "aimeter": {
|
|
19
|
+
* "command": "aimeter-mcp"
|
|
20
|
+
* }
|
|
21
|
+
* }
|
|
22
|
+
* }
|
|
23
|
+
*/
|
|
24
|
+
|
|
25
|
+
const https = require('https');
|
|
26
|
+
const fs = require('fs');
|
|
27
|
+
const os = require('os');
|
|
28
|
+
const path = require('path');
|
|
29
|
+
|
|
30
|
+
// ─── Constants ───────────────────────────────────────────────────────────────
|
|
31
|
+
|
|
32
|
+
const API_HOST = 'aimeter-api.fly.dev';
|
|
33
|
+
const CONFIG_FILE = path.join(os.homedir(), '.aimeter', 'config.json');
|
|
34
|
+
const MCP_PROTOCOL_VERSION = '2024-11-05';
|
|
35
|
+
|
|
36
|
+
const SERVER_INFO = {
|
|
37
|
+
name: 'aimeter',
|
|
38
|
+
version: '0.9.0',
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
// ─── Tool Definitions ────────────────────────────────────────────────────────
|
|
42
|
+
|
|
43
|
+
const TOOLS = [
|
|
44
|
+
{
|
|
45
|
+
name: 'get_usage_summary',
|
|
46
|
+
description:
|
|
47
|
+
'Get your current AI coding costs across all tools (Claude, Cursor, Codex, Copilot, Gemini) for the active 5-hour billing window. Shows token counts, cost breakdown by model and source, and time until reset.',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {},
|
|
51
|
+
required: [],
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'get_daily_costs',
|
|
56
|
+
description:
|
|
57
|
+
'Get daily cost breakdown showing spend per day across all AI coding tools. Shows date, cost, and request count for each day.',
|
|
58
|
+
inputSchema: {
|
|
59
|
+
type: 'object',
|
|
60
|
+
properties: {
|
|
61
|
+
days: {
|
|
62
|
+
type: 'number',
|
|
63
|
+
description: 'Number of days to look back (default: 7, max: 90)',
|
|
64
|
+
},
|
|
65
|
+
},
|
|
66
|
+
required: [],
|
|
67
|
+
},
|
|
68
|
+
},
|
|
69
|
+
{
|
|
70
|
+
name: 'get_session_blocks',
|
|
71
|
+
description:
|
|
72
|
+
'Analyze your usage in 5-hour billing blocks. Shows token consumption and cost for each block to help identify high-usage periods.',
|
|
73
|
+
inputSchema: {
|
|
74
|
+
type: 'object',
|
|
75
|
+
properties: {
|
|
76
|
+
hours: {
|
|
77
|
+
type: 'number',
|
|
78
|
+
description: 'Number of hours to look back (default: 24, max: 120)',
|
|
79
|
+
},
|
|
80
|
+
},
|
|
81
|
+
required: [],
|
|
82
|
+
},
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
name: 'get_cost_forecast',
|
|
86
|
+
description:
|
|
87
|
+
'Get monthly spend projection based on your current usage patterns. Shows spent so far, daily average, and projected monthly total.',
|
|
88
|
+
inputSchema: {
|
|
89
|
+
type: 'object',
|
|
90
|
+
properties: {},
|
|
91
|
+
required: [],
|
|
92
|
+
},
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: 'get_optimizations',
|
|
96
|
+
description:
|
|
97
|
+
'Get personalized cost optimization recommendations based on your usage patterns. Analyzes model choices, caching efficiency, and usage timing. Requires Pro plan.',
|
|
98
|
+
inputSchema: {
|
|
99
|
+
type: 'object',
|
|
100
|
+
properties: {
|
|
101
|
+
days: {
|
|
102
|
+
type: 'number',
|
|
103
|
+
description: 'Number of days of data to analyze (default: 30)',
|
|
104
|
+
},
|
|
105
|
+
},
|
|
106
|
+
required: [],
|
|
107
|
+
},
|
|
108
|
+
},
|
|
109
|
+
];
|
|
110
|
+
|
|
111
|
+
// ─── API Key ─────────────────────────────────────────────────────────────────
|
|
112
|
+
|
|
113
|
+
function getApiKey() {
|
|
114
|
+
// Environment variable takes precedence
|
|
115
|
+
if (process.env.AIMETER_KEY) return process.env.AIMETER_KEY.trim();
|
|
116
|
+
|
|
117
|
+
// Read from config file
|
|
118
|
+
try {
|
|
119
|
+
const cfg = JSON.parse(fs.readFileSync(CONFIG_FILE, 'utf8'));
|
|
120
|
+
if (cfg.apiKey) return cfg.apiKey;
|
|
121
|
+
} catch {
|
|
122
|
+
// config file doesn't exist or is invalid
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// Legacy: ~/.claude/aimeter-key
|
|
126
|
+
try {
|
|
127
|
+
const legacyPath = path.join(os.homedir(), '.claude', 'aimeter-key');
|
|
128
|
+
const key = fs.readFileSync(legacyPath, 'utf8').trim();
|
|
129
|
+
if (key) return key;
|
|
130
|
+
} catch {}
|
|
131
|
+
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
// ─── HTTP Client ─────────────────────────────────────────────────────────────
|
|
136
|
+
|
|
137
|
+
function callApi(apiPath) {
|
|
138
|
+
return new Promise((resolve, reject) => {
|
|
139
|
+
const apiKey = getApiKey();
|
|
140
|
+
if (!apiKey) {
|
|
141
|
+
reject(new Error('No AIMeter API key configured. Run: aimeter setup'));
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const options = {
|
|
146
|
+
hostname: API_HOST,
|
|
147
|
+
port: 443,
|
|
148
|
+
path: apiPath,
|
|
149
|
+
method: 'GET',
|
|
150
|
+
headers: {
|
|
151
|
+
'X-AIMeter-Key': apiKey,
|
|
152
|
+
'Accept': 'application/json',
|
|
153
|
+
},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const req = https.request(options, (res) => {
|
|
157
|
+
let body = '';
|
|
158
|
+
res.on('data', (chunk) => { body += chunk; });
|
|
159
|
+
res.on('end', () => {
|
|
160
|
+
if (res.statusCode >= 200 && res.statusCode < 300) {
|
|
161
|
+
try {
|
|
162
|
+
resolve(JSON.parse(body));
|
|
163
|
+
} catch (e) {
|
|
164
|
+
reject(new Error(`Invalid JSON response from API: ${e.message}`));
|
|
165
|
+
}
|
|
166
|
+
} else if (res.statusCode === 401) {
|
|
167
|
+
reject(new Error('Invalid API key. Run: aimeter setup'));
|
|
168
|
+
} else if (res.statusCode === 403) {
|
|
169
|
+
reject(new Error('Pro plan required for this feature. Upgrade at https://getaimeter.com/dashboard/account'));
|
|
170
|
+
} else if (res.statusCode === 429) {
|
|
171
|
+
reject(new Error('Rate limit exceeded. Please wait a moment and try again.'));
|
|
172
|
+
} else {
|
|
173
|
+
let errorMsg = `API returned ${res.statusCode}`;
|
|
174
|
+
try {
|
|
175
|
+
const parsed = JSON.parse(body);
|
|
176
|
+
if (parsed.error) errorMsg = parsed.error;
|
|
177
|
+
} catch {}
|
|
178
|
+
reject(new Error(errorMsg));
|
|
179
|
+
}
|
|
180
|
+
});
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
req.on('error', (err) => reject(new Error(`Network error: ${err.message}`)));
|
|
184
|
+
req.setTimeout(15000, () => {
|
|
185
|
+
req.destroy();
|
|
186
|
+
reject(new Error('API request timed out (15s)'));
|
|
187
|
+
});
|
|
188
|
+
req.end();
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// ─── Formatting Helpers ──────────────────────────────────────────────────────
|
|
193
|
+
|
|
194
|
+
function formatCost(cost) {
|
|
195
|
+
if (cost == null || isNaN(cost)) return '$0.00';
|
|
196
|
+
return '$' + Number(cost).toFixed(2);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
function formatTokens(count) {
|
|
200
|
+
if (count == null || isNaN(count)) return '0';
|
|
201
|
+
count = Number(count);
|
|
202
|
+
if (count >= 1_000_000) return (count / 1_000_000).toFixed(1) + 'M';
|
|
203
|
+
if (count >= 1_000) return (count / 1_000).toFixed(1) + 'k';
|
|
204
|
+
return count.toString();
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
function formatDuration(ms) {
|
|
208
|
+
if (ms == null || isNaN(ms) || ms <= 0) return '0m';
|
|
209
|
+
const totalMinutes = Math.floor(ms / 60000);
|
|
210
|
+
const hours = Math.floor(totalMinutes / 60);
|
|
211
|
+
const minutes = totalMinutes % 60;
|
|
212
|
+
if (hours > 0) return `${hours}h ${minutes}m`;
|
|
213
|
+
return `${minutes}m`;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function padRight(str, len) {
|
|
217
|
+
str = String(str);
|
|
218
|
+
while (str.length < len) str += ' ';
|
|
219
|
+
return str;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
function padLeft(str, len) {
|
|
223
|
+
str = String(str);
|
|
224
|
+
while (str.length < len) str = ' ' + str;
|
|
225
|
+
return str;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// ─── Tool Handlers ───────────────────────────────────────────────────────────
|
|
229
|
+
|
|
230
|
+
async function handleGetUsageSummary() {
|
|
231
|
+
const data = await callApi('/api/usage/current');
|
|
232
|
+
|
|
233
|
+
const totalCost = (data.byModel || []).reduce((sum, m) => sum + (m.estimatedCost || 0), 0);
|
|
234
|
+
|
|
235
|
+
// Calculate time until reset
|
|
236
|
+
let resetText = 'N/A';
|
|
237
|
+
if (data.nextReset) {
|
|
238
|
+
const resetTime = new Date(data.nextReset);
|
|
239
|
+
const now = new Date();
|
|
240
|
+
const diffMs = resetTime.getTime() - now.getTime();
|
|
241
|
+
if (diffMs > 0) {
|
|
242
|
+
resetText = formatDuration(diffMs);
|
|
243
|
+
} else {
|
|
244
|
+
resetText = 'Imminent';
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
let output = '';
|
|
249
|
+
output += 'Current Usage (5-hour window)\n';
|
|
250
|
+
output += '='.repeat(40) + '\n';
|
|
251
|
+
output += `Total Cost: ${formatCost(totalCost)}\n`;
|
|
252
|
+
output += `Input Tokens: ${formatTokens(data.totalInputTokens)}\n`;
|
|
253
|
+
output += `Output Tokens: ${formatTokens(data.totalOutputTokens)}\n`;
|
|
254
|
+
output += `Thinking: ${formatTokens(data.totalThinkingTokens)}\n`;
|
|
255
|
+
output += `Cache Read: ${formatTokens(data.totalCacheReadTokens)}\n`;
|
|
256
|
+
output += `Requests: ${data.windowRequests || 0}\n`;
|
|
257
|
+
output += `Reset in: ${resetText}\n`;
|
|
258
|
+
|
|
259
|
+
// By Source
|
|
260
|
+
const bySource = data.bySource || [];
|
|
261
|
+
if (bySource.length > 0) {
|
|
262
|
+
output += '\nBy Source:\n';
|
|
263
|
+
output += '-'.repeat(40) + '\n';
|
|
264
|
+
for (const s of bySource) {
|
|
265
|
+
const srcName = padRight(s.source || 'unknown', 14);
|
|
266
|
+
const reqCount = padLeft(String(s.requests || 0), 4);
|
|
267
|
+
const srcTokens = formatTokens((s.inputTokens || 0) + (s.outputTokens || 0));
|
|
268
|
+
output += ` ${srcName} ${reqCount} requests ${padLeft(srcTokens, 8)} tokens\n`;
|
|
269
|
+
}
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// By Model
|
|
273
|
+
const byModel = data.byModel || [];
|
|
274
|
+
if (byModel.length > 0) {
|
|
275
|
+
output += '\nBy Model:\n';
|
|
276
|
+
output += '-'.repeat(40) + '\n';
|
|
277
|
+
for (const m of byModel) {
|
|
278
|
+
const modelName = padRight(m.model || 'unknown', 22);
|
|
279
|
+
const reqCount = padLeft(String(m.requests || 0), 4);
|
|
280
|
+
output += ` ${modelName} ${reqCount} req ${padLeft(formatCost(m.estimatedCost), 8)}\n`;
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Previous window comparison
|
|
285
|
+
if (data.previous) {
|
|
286
|
+
const prevCost = data.previous.totalCost || 0;
|
|
287
|
+
const prevReqs = data.previous.totalRequests || 0;
|
|
288
|
+
output += '\nPrevious Window:\n';
|
|
289
|
+
output += '-'.repeat(40) + '\n';
|
|
290
|
+
output += ` Cost: ${formatCost(prevCost)} | Requests: ${prevReqs}\n`;
|
|
291
|
+
if (prevCost > 0 && totalCost > 0) {
|
|
292
|
+
const change = ((totalCost - prevCost) / prevCost * 100).toFixed(0);
|
|
293
|
+
const arrow = totalCost > prevCost ? 'UP' : 'DOWN';
|
|
294
|
+
output += ` Change: ${change > 0 ? '+' : ''}${change}% (${arrow})\n`;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
// Weekly summary
|
|
299
|
+
if (data.weekly) {
|
|
300
|
+
output += '\nWeekly Rolling (7 days):\n';
|
|
301
|
+
output += '-'.repeat(40) + '\n';
|
|
302
|
+
output += ` Total Tokens: ${formatTokens(data.weekly.totalTokens)} | Requests: ${data.weekly.requests || 0}\n`;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
return output;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function handleGetDailyCosts(args) {
|
|
309
|
+
const days = Math.min(Math.max(parseInt(args.days) || 7, 1), 90);
|
|
310
|
+
|
|
311
|
+
const data = await callApi(`/api/usage/history?days=${days}`);
|
|
312
|
+
|
|
313
|
+
const dailyData = data.days || [];
|
|
314
|
+
|
|
315
|
+
if (dailyData.length === 0) {
|
|
316
|
+
return `No usage data found for the last ${days} days.`;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// We need cost data from the analytics endpoint for pro users,
|
|
320
|
+
// but history gives us token counts. Let's present what we have.
|
|
321
|
+
let output = `Daily Usage (last ${days} days)\n`;
|
|
322
|
+
output += '='.repeat(50) + '\n';
|
|
323
|
+
output += `${padRight('Date', 14)} ${padLeft('Input', 10)} ${padLeft('Output', 10)} ${padLeft('Thinking', 10)} ${padLeft('Requests', 10)}\n`;
|
|
324
|
+
output += '-'.repeat(54) + '\n';
|
|
325
|
+
|
|
326
|
+
let totalInput = 0;
|
|
327
|
+
let totalOutput = 0;
|
|
328
|
+
let totalThinking = 0;
|
|
329
|
+
let totalRequests = 0;
|
|
330
|
+
|
|
331
|
+
for (const day of dailyData) {
|
|
332
|
+
const dateStr = day.date ? new Date(day.date).toISOString().slice(0, 10) : 'unknown';
|
|
333
|
+
const input = day.inputTokens || 0;
|
|
334
|
+
const output2 = day.outputTokens || 0;
|
|
335
|
+
const thinking = day.thinkingTokens || 0;
|
|
336
|
+
const reqs = day.requests || 0;
|
|
337
|
+
|
|
338
|
+
totalInput += input;
|
|
339
|
+
totalOutput += output2;
|
|
340
|
+
totalThinking += thinking;
|
|
341
|
+
totalRequests += reqs;
|
|
342
|
+
|
|
343
|
+
output += `${padRight(dateStr, 14)} ${padLeft(formatTokens(input), 10)} ${padLeft(formatTokens(output2), 10)} ${padLeft(formatTokens(thinking), 10)} ${padLeft(String(reqs), 10)}\n`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
output += '-'.repeat(54) + '\n';
|
|
347
|
+
output += `${padRight('TOTAL', 14)} ${padLeft(formatTokens(totalInput), 10)} ${padLeft(formatTokens(totalOutput), 10)} ${padLeft(formatTokens(totalThinking), 10)} ${padLeft(String(totalRequests), 10)}\n`;
|
|
348
|
+
|
|
349
|
+
const avgReqs = Math.round(totalRequests / dailyData.length);
|
|
350
|
+
output += `\nAverage: ${avgReqs} requests/day\n`;
|
|
351
|
+
|
|
352
|
+
return output;
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
async function handleGetSessionBlocks(args) {
|
|
356
|
+
const hours = Math.min(Math.max(parseInt(args.hours) || 24, 1), 120);
|
|
357
|
+
|
|
358
|
+
// Fetch recent events to analyze blocks
|
|
359
|
+
const limit = 200;
|
|
360
|
+
const data = await callApi(`/api/usage/events?limit=${limit}&offset=0`);
|
|
361
|
+
|
|
362
|
+
const events = (data.events || []).filter((e) => {
|
|
363
|
+
if (!e.timestamp) return false;
|
|
364
|
+
const eventTime = new Date(e.timestamp);
|
|
365
|
+
const cutoff = new Date(Date.now() - hours * 3600000);
|
|
366
|
+
return eventTime >= cutoff;
|
|
367
|
+
});
|
|
368
|
+
|
|
369
|
+
if (events.length === 0) {
|
|
370
|
+
return `No usage events found in the last ${hours} hours.`;
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Group events into 5-hour blocks
|
|
374
|
+
const blockSize = 5 * 3600000; // 5 hours in ms
|
|
375
|
+
const now = Date.now();
|
|
376
|
+
const blocks = new Map();
|
|
377
|
+
|
|
378
|
+
for (const event of events) {
|
|
379
|
+
const eventTime = new Date(event.timestamp).getTime();
|
|
380
|
+
const blockIndex = Math.floor((now - eventTime) / blockSize);
|
|
381
|
+
const blockStart = now - (blockIndex + 1) * blockSize;
|
|
382
|
+
const blockEnd = now - blockIndex * blockSize;
|
|
383
|
+
const key = blockIndex;
|
|
384
|
+
|
|
385
|
+
if (!blocks.has(key)) {
|
|
386
|
+
blocks.set(key, {
|
|
387
|
+
start: new Date(blockStart),
|
|
388
|
+
end: new Date(blockEnd),
|
|
389
|
+
inputTokens: 0,
|
|
390
|
+
outputTokens: 0,
|
|
391
|
+
thinkingTokens: 0,
|
|
392
|
+
cost: 0,
|
|
393
|
+
requests: 0,
|
|
394
|
+
models: new Set(),
|
|
395
|
+
});
|
|
396
|
+
}
|
|
397
|
+
|
|
398
|
+
const block = blocks.get(key);
|
|
399
|
+
block.inputTokens += event.inputTokens || 0;
|
|
400
|
+
block.outputTokens += event.outputTokens || 0;
|
|
401
|
+
block.thinkingTokens += event.thinkingTokens || 0;
|
|
402
|
+
block.cost += event.estimatedCost || 0;
|
|
403
|
+
block.requests += 1;
|
|
404
|
+
if (event.model) block.models.add(event.model);
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
// Sort blocks by time (most recent first)
|
|
408
|
+
const sortedBlocks = Array.from(blocks.entries())
|
|
409
|
+
.sort((a, b) => a[0] - b[0])
|
|
410
|
+
.map((entry) => entry[1]);
|
|
411
|
+
|
|
412
|
+
let output = `5-Hour Billing Blocks (last ${hours}h)\n`;
|
|
413
|
+
output += '='.repeat(55) + '\n';
|
|
414
|
+
|
|
415
|
+
for (const block of sortedBlocks) {
|
|
416
|
+
const startStr = block.start.toISOString().slice(11, 16);
|
|
417
|
+
const endStr = block.end.toISOString().slice(11, 16);
|
|
418
|
+
const dateStr = block.start.toISOString().slice(0, 10);
|
|
419
|
+
|
|
420
|
+
output += `\n[${dateStr} ${startStr} - ${endStr} UTC]\n`;
|
|
421
|
+
output += '-'.repeat(40) + '\n';
|
|
422
|
+
output += ` Cost: ${formatCost(block.cost)}\n`;
|
|
423
|
+
output += ` Requests: ${block.requests}\n`;
|
|
424
|
+
output += ` Input: ${formatTokens(block.inputTokens)}\n`;
|
|
425
|
+
output += ` Output: ${formatTokens(block.outputTokens)}\n`;
|
|
426
|
+
output += ` Thinking: ${formatTokens(block.thinkingTokens)}\n`;
|
|
427
|
+
output += ` Models: ${Array.from(block.models).join(', ') || 'N/A'}\n`;
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
output += '\n' + '='.repeat(55) + '\n';
|
|
431
|
+
output += `Total: ${sortedBlocks.length} block(s), ${events.length} events\n`;
|
|
432
|
+
|
|
433
|
+
return output;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
async function handleGetCostForecast() {
|
|
437
|
+
const data = await callApi('/api/analytics/forecast');
|
|
438
|
+
|
|
439
|
+
let output = 'Monthly Cost Forecast\n';
|
|
440
|
+
output += '='.repeat(40) + '\n';
|
|
441
|
+
output += `Spent so far: ${formatCost(data.spentSoFar)}\n`;
|
|
442
|
+
output += `Daily average: ${formatCost(data.dailyAverage)}\n`;
|
|
443
|
+
output += `Projected monthly: ${formatCost(data.projectedMonthly)}\n`;
|
|
444
|
+
output += '\n';
|
|
445
|
+
output += `Days elapsed: ${data.daysElapsed}\n`;
|
|
446
|
+
output += `Days remaining: ${data.daysRemaining}\n`;
|
|
447
|
+
|
|
448
|
+
// Add some context
|
|
449
|
+
const projected = Number(data.projectedMonthly) || 0;
|
|
450
|
+
if (projected > 0) {
|
|
451
|
+
output += '\n';
|
|
452
|
+
output += 'Projections at current pace:\n';
|
|
453
|
+
output += '-'.repeat(40) + '\n';
|
|
454
|
+
output += ` This week: ~${formatCost((data.dailyAverage || 0) * 7)}\n`;
|
|
455
|
+
output += ` This month: ~${formatCost(projected)}\n`;
|
|
456
|
+
output += ` This year: ~${formatCost(projected * 12)}\n`;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
return output;
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
async function handleGetOptimizations(args) {
|
|
463
|
+
const days = Math.min(Math.max(parseInt(args.days) || 30, 1), 365);
|
|
464
|
+
|
|
465
|
+
const data = await callApi(`/api/analytics/optimizations?days=${days}`);
|
|
466
|
+
|
|
467
|
+
let output = `Cost Optimization Recommendations (${days} days)\n`;
|
|
468
|
+
output += '='.repeat(50) + '\n';
|
|
469
|
+
|
|
470
|
+
// The optimization engine returns various recommendations
|
|
471
|
+
// Format them in a readable way
|
|
472
|
+
if (data.score != null) {
|
|
473
|
+
output += `\nEfficiency Score: ${data.score}/100\n`;
|
|
474
|
+
}
|
|
475
|
+
|
|
476
|
+
if (data.totalCost != null) {
|
|
477
|
+
output += `Total Cost Analyzed: ${formatCost(data.totalCost)}\n`;
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
if (data.potentialSavings != null) {
|
|
481
|
+
output += `Potential Savings: ${formatCost(data.potentialSavings)}\n`;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const recommendations = data.recommendations || data.tips || [];
|
|
485
|
+
if (recommendations.length > 0) {
|
|
486
|
+
output += '\nRecommendations:\n';
|
|
487
|
+
output += '-'.repeat(50) + '\n';
|
|
488
|
+
for (let i = 0; i < recommendations.length; i++) {
|
|
489
|
+
const rec = recommendations[i];
|
|
490
|
+
if (typeof rec === 'string') {
|
|
491
|
+
output += ` ${i + 1}. ${rec}\n`;
|
|
492
|
+
} else {
|
|
493
|
+
const title = rec.title || rec.name || `Recommendation ${i + 1}`;
|
|
494
|
+
const desc = rec.description || rec.detail || rec.message || '';
|
|
495
|
+
const savings = rec.savings || rec.estimatedSavings;
|
|
496
|
+
output += `\n ${i + 1}. ${title}\n`;
|
|
497
|
+
if (desc) output += ` ${desc}\n`;
|
|
498
|
+
if (savings != null) output += ` Estimated savings: ${formatCost(savings)}/month\n`;
|
|
499
|
+
if (rec.priority) output += ` Priority: ${rec.priority}\n`;
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
// Model breakdown if available
|
|
505
|
+
const models = data.modelBreakdown || data.byModel || [];
|
|
506
|
+
if (models.length > 0) {
|
|
507
|
+
output += '\nCost by Model:\n';
|
|
508
|
+
output += '-'.repeat(50) + '\n';
|
|
509
|
+
for (const m of models) {
|
|
510
|
+
const name = padRight(m.model || m.name || 'unknown', 24);
|
|
511
|
+
output += ` ${name} ${padLeft(formatCost(m.cost || m.totalCost), 10)}\n`;
|
|
512
|
+
}
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
if (recommendations.length === 0 && !data.score) {
|
|
516
|
+
// Raw data fallback - format whatever we got
|
|
517
|
+
output += '\n' + JSON.stringify(data, null, 2) + '\n';
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
return output;
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ─── MCP Protocol Handler ────────────────────────────────────────────────────
|
|
524
|
+
|
|
525
|
+
async function handleMessage(message) {
|
|
526
|
+
const { id, method, params } = message;
|
|
527
|
+
|
|
528
|
+
// Notifications (no id) - acknowledge silently
|
|
529
|
+
if (id === undefined || id === null) {
|
|
530
|
+
if (method === 'notifications/initialized') {
|
|
531
|
+
// Client acknowledged initialization - nothing to respond
|
|
532
|
+
return null;
|
|
533
|
+
}
|
|
534
|
+
if (method === 'notifications/cancelled') {
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
// Unknown notification - ignore
|
|
538
|
+
return null;
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
switch (method) {
|
|
542
|
+
case 'initialize':
|
|
543
|
+
return {
|
|
544
|
+
jsonrpc: '2.0',
|
|
545
|
+
id,
|
|
546
|
+
result: {
|
|
547
|
+
protocolVersion: MCP_PROTOCOL_VERSION,
|
|
548
|
+
capabilities: {
|
|
549
|
+
tools: {},
|
|
550
|
+
},
|
|
551
|
+
serverInfo: SERVER_INFO,
|
|
552
|
+
},
|
|
553
|
+
};
|
|
554
|
+
|
|
555
|
+
case 'ping':
|
|
556
|
+
return {
|
|
557
|
+
jsonrpc: '2.0',
|
|
558
|
+
id,
|
|
559
|
+
result: {},
|
|
560
|
+
};
|
|
561
|
+
|
|
562
|
+
case 'tools/list':
|
|
563
|
+
return {
|
|
564
|
+
jsonrpc: '2.0',
|
|
565
|
+
id,
|
|
566
|
+
result: {
|
|
567
|
+
tools: TOOLS,
|
|
568
|
+
},
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
case 'tools/call': {
|
|
572
|
+
const toolName = params && params.name;
|
|
573
|
+
const toolArgs = (params && params.arguments) || {};
|
|
574
|
+
|
|
575
|
+
try {
|
|
576
|
+
let resultText;
|
|
577
|
+
|
|
578
|
+
switch (toolName) {
|
|
579
|
+
case 'get_usage_summary':
|
|
580
|
+
resultText = await handleGetUsageSummary();
|
|
581
|
+
break;
|
|
582
|
+
case 'get_daily_costs':
|
|
583
|
+
resultText = await handleGetDailyCosts(toolArgs);
|
|
584
|
+
break;
|
|
585
|
+
case 'get_session_blocks':
|
|
586
|
+
resultText = await handleGetSessionBlocks(toolArgs);
|
|
587
|
+
break;
|
|
588
|
+
case 'get_cost_forecast':
|
|
589
|
+
resultText = await handleGetCostForecast();
|
|
590
|
+
break;
|
|
591
|
+
case 'get_optimizations':
|
|
592
|
+
resultText = await handleGetOptimizations(toolArgs);
|
|
593
|
+
break;
|
|
594
|
+
default:
|
|
595
|
+
return {
|
|
596
|
+
jsonrpc: '2.0',
|
|
597
|
+
id,
|
|
598
|
+
result: {
|
|
599
|
+
content: [{ type: 'text', text: `Unknown tool: ${toolName}` }],
|
|
600
|
+
isError: true,
|
|
601
|
+
},
|
|
602
|
+
};
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
return {
|
|
606
|
+
jsonrpc: '2.0',
|
|
607
|
+
id,
|
|
608
|
+
result: {
|
|
609
|
+
content: [{ type: 'text', text: resultText }],
|
|
610
|
+
},
|
|
611
|
+
};
|
|
612
|
+
} catch (err) {
|
|
613
|
+
return {
|
|
614
|
+
jsonrpc: '2.0',
|
|
615
|
+
id,
|
|
616
|
+
result: {
|
|
617
|
+
content: [{ type: 'text', text: `Error: ${err.message}` }],
|
|
618
|
+
isError: true,
|
|
619
|
+
},
|
|
620
|
+
};
|
|
621
|
+
}
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
default:
|
|
625
|
+
return {
|
|
626
|
+
jsonrpc: '2.0',
|
|
627
|
+
id,
|
|
628
|
+
error: {
|
|
629
|
+
code: -32601,
|
|
630
|
+
message: `Method not found: ${method}`,
|
|
631
|
+
},
|
|
632
|
+
};
|
|
633
|
+
}
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
// ─── STDIO Transport ─────────────────────────────────────────────────────────
|
|
637
|
+
|
|
638
|
+
function startMcpServer() {
|
|
639
|
+
// Suppress any accidental console.log from dependencies
|
|
640
|
+
// MCP protocol uses stdout exclusively for JSON-RPC messages
|
|
641
|
+
const originalStdoutWrite = process.stdout.write.bind(process.stdout);
|
|
642
|
+
|
|
643
|
+
function sendResponse(response) {
|
|
644
|
+
if (!response) return;
|
|
645
|
+
const json = JSON.stringify(response);
|
|
646
|
+
originalStdoutWrite(json + '\n');
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
// Read from stdin line by line
|
|
650
|
+
let buffer = '';
|
|
651
|
+
|
|
652
|
+
process.stdin.setEncoding('utf8');
|
|
653
|
+
process.stdin.on('data', (chunk) => {
|
|
654
|
+
buffer += chunk;
|
|
655
|
+
|
|
656
|
+
// Process complete lines
|
|
657
|
+
let newlineIndex;
|
|
658
|
+
while ((newlineIndex = buffer.indexOf('\n')) !== -1) {
|
|
659
|
+
const line = buffer.slice(0, newlineIndex).trim();
|
|
660
|
+
buffer = buffer.slice(newlineIndex + 1);
|
|
661
|
+
|
|
662
|
+
if (!line) continue;
|
|
663
|
+
|
|
664
|
+
let message;
|
|
665
|
+
try {
|
|
666
|
+
message = JSON.parse(line);
|
|
667
|
+
} catch (err) {
|
|
668
|
+
// Try reading as Content-Length framed message
|
|
669
|
+
// Some MCP clients use HTTP-style framing: "Content-Length: N\r\n\r\n{...}"
|
|
670
|
+
// We'll handle this in the content-length path below
|
|
671
|
+
continue;
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
handleMessage(message)
|
|
675
|
+
.then((response) => sendResponse(response))
|
|
676
|
+
.catch((err) => {
|
|
677
|
+
// Internal error
|
|
678
|
+
if (message && message.id != null) {
|
|
679
|
+
sendResponse({
|
|
680
|
+
jsonrpc: '2.0',
|
|
681
|
+
id: message.id,
|
|
682
|
+
error: {
|
|
683
|
+
code: -32603,
|
|
684
|
+
message: `Internal error: ${err.message}`,
|
|
685
|
+
},
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
});
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
// Handle Content-Length framed messages (some clients use this format)
|
|
692
|
+
handleContentLengthFrames();
|
|
693
|
+
});
|
|
694
|
+
|
|
695
|
+
// Content-Length based frame parsing
|
|
696
|
+
let contentLengthBuffer = '';
|
|
697
|
+
let expectedLength = -1;
|
|
698
|
+
|
|
699
|
+
function handleContentLengthFrames() {
|
|
700
|
+
// Check if buffer contains Content-Length headers
|
|
701
|
+
if (expectedLength === -1) {
|
|
702
|
+
const headerEnd = buffer.indexOf('\r\n\r\n');
|
|
703
|
+
if (headerEnd === -1) return;
|
|
704
|
+
|
|
705
|
+
const header = buffer.slice(0, headerEnd);
|
|
706
|
+
const match = header.match(/Content-Length:\s*(\d+)/i);
|
|
707
|
+
if (!match) return;
|
|
708
|
+
|
|
709
|
+
expectedLength = parseInt(match[1], 10);
|
|
710
|
+
buffer = buffer.slice(headerEnd + 4);
|
|
711
|
+
}
|
|
712
|
+
|
|
713
|
+
if (expectedLength > 0 && buffer.length >= expectedLength) {
|
|
714
|
+
const body = buffer.slice(0, expectedLength);
|
|
715
|
+
buffer = buffer.slice(expectedLength);
|
|
716
|
+
expectedLength = -1;
|
|
717
|
+
|
|
718
|
+
let message;
|
|
719
|
+
try {
|
|
720
|
+
message = JSON.parse(body);
|
|
721
|
+
} catch {
|
|
722
|
+
return;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
handleMessage(message)
|
|
726
|
+
.then((response) => sendResponse(response))
|
|
727
|
+
.catch((err) => {
|
|
728
|
+
if (message && message.id != null) {
|
|
729
|
+
sendResponse({
|
|
730
|
+
jsonrpc: '2.0',
|
|
731
|
+
id: message.id,
|
|
732
|
+
error: {
|
|
733
|
+
code: -32603,
|
|
734
|
+
message: `Internal error: ${err.message}`,
|
|
735
|
+
},
|
|
736
|
+
});
|
|
737
|
+
}
|
|
738
|
+
});
|
|
739
|
+
|
|
740
|
+
// Recurse to handle any remaining frames
|
|
741
|
+
handleContentLengthFrames();
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
|
|
745
|
+
process.stdin.on('end', () => {
|
|
746
|
+
process.exit(0);
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
process.stdin.on('error', () => {
|
|
750
|
+
process.exit(1);
|
|
751
|
+
});
|
|
752
|
+
|
|
753
|
+
// Handle process signals gracefully
|
|
754
|
+
process.on('SIGINT', () => process.exit(0));
|
|
755
|
+
process.on('SIGTERM', () => process.exit(0));
|
|
756
|
+
|
|
757
|
+
// Suppress unhandled rejections from crashing the server
|
|
758
|
+
process.on('unhandledRejection', () => {});
|
|
759
|
+
process.on('uncaughtException', () => {});
|
|
760
|
+
|
|
761
|
+
// Log to stderr (not stdout — stdout is reserved for MCP protocol)
|
|
762
|
+
process.stderr.write('[aimeter-mcp] Server started on stdio\n');
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
// ─── Entry Point ─────────────────────────────────────────────────────────────
|
|
766
|
+
|
|
767
|
+
// If run directly (not required as module), start the server
|
|
768
|
+
if (require.main === module) {
|
|
769
|
+
startMcpServer();
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
module.exports = { startMcpServer };
|
package/package.json
CHANGED
|
@@ -1,11 +1,17 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "getaimeter",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Track AI coding costs across Claude, Cursor, Codex, and Gemini.
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Track AI coding costs across Claude, Cursor, Codex, Copilot, and Gemini. MCP server, billing blocks, optimization recommendations.",
|
|
5
5
|
"bin": {
|
|
6
|
-
"aimeter": "cli.js"
|
|
6
|
+
"aimeter": "cli.js",
|
|
7
|
+
"aimeter-mcp": "mcp.js"
|
|
7
8
|
},
|
|
8
9
|
"main": "watcher.js",
|
|
10
|
+
"scripts": {
|
|
11
|
+
"test": "node --test test/parser.test.js",
|
|
12
|
+
"test:e2e": "node --test test/e2e.test.js",
|
|
13
|
+
"test:all": "node --test test/*.test.js"
|
|
14
|
+
},
|
|
9
15
|
"engines": {
|
|
10
16
|
"node": ">=18"
|
|
11
17
|
},
|
|
@@ -25,10 +31,13 @@
|
|
|
25
31
|
"optimization",
|
|
26
32
|
"claude-code",
|
|
27
33
|
"vscode",
|
|
34
|
+
"copilot",
|
|
35
|
+
"mcp",
|
|
28
36
|
"multi-tool"
|
|
29
37
|
],
|
|
30
38
|
"files": [
|
|
31
39
|
"cli.js",
|
|
40
|
+
"mcp.js",
|
|
32
41
|
"watcher.js",
|
|
33
42
|
"reporter.js",
|
|
34
43
|
"config.js",
|
|
@@ -40,6 +49,7 @@
|
|
|
40
49
|
"tray-launcher.vbs",
|
|
41
50
|
"icon.ico",
|
|
42
51
|
"update-check.js",
|
|
52
|
+
"config-schema.json",
|
|
43
53
|
"README.md"
|
|
44
54
|
],
|
|
45
55
|
"repository": {
|