@statforge/claudestat 1.1.0 → 1.2.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 CHANGED
@@ -12,7 +12,7 @@ Works with Claude Pro, Max 5, and Max 20. Zero cloud dependencies. Pure Node.js.
12
12
  [![License: MIT](https://img.shields.io/badge/license-MIT-green)](LICENSE)
13
13
  [![Node.js](https://img.shields.io/badge/node-%3E%3D22-brightgreen)](https://nodejs.org)
14
14
  [![Platform](https://img.shields.io/badge/platform-macOS%20%7C%20Linux%20%7C%20Windows-brightgreen)]()
15
- [![Tests](https://img.shields.io/badge/tests-208%2F208-brightgreen)]()
15
+ [![Tests](https://img.shields.io/badge/tests-214%2F214-brightgreen)]()
16
16
  [![PRs Welcome](https://img.shields.io/badge/PRs-welcome-brightgreen)](CONTRIBUTING.md)
17
17
 
18
18
  [Installation](#installation) • [Quick Start](#quick-start) • [Commands](#commands) • [Dashboard](#dashboard) • [Contributing](#contributing)
@@ -43,7 +43,9 @@ Claude Code is powerful — but it's a black box while it runs. You can't see wh
43
43
  - Quota guard with configurable kill switch (block new sessions at X%)
44
44
  - Pattern analyzer: detects loops, Bash overuse, low cache reuse, and more
45
45
  - Per-session cost breakdown + cache savings + burn rate
46
+ - Weekly usage insights with actionable tips
46
47
  - AI-generated weekly usage reports
48
+ - MCP server: query quota, sessions, and tools from within Claude Code
47
49
 
48
50
  > If claudestat is useful, give it a ⭐ — it helps other developers find it.
49
51
 
@@ -137,9 +139,13 @@ That's it. Start a Claude Code session and watch the events flow in.
137
139
  | `claudestat uninstall` | Remove hooks from Claude Code |
138
140
  | `claudestat watch` | Live terminal trace view |
139
141
  | `claudestat status` | Show quota, cost, and burn rate |
142
+ | `claudestat status --compact` | One-line output for tmux status bar |
140
143
  | `claudestat config` | View or edit configuration |
141
144
  | `claudestat top` | Rank tools by cost, call count, or duration |
145
+ | `claudestat weekly` | Weekly usage summary with actionable tips |
142
146
  | `claudestat export [format]` | Export session data to JSON or CSV |
147
+ | `claudestat share [session-id]` | Generate shareable session card (ASCII/JSON) |
148
+ | `claudestat roast` | Sarcastic usage analysis with roast jokes |
143
149
  | `claudestat doctor` | Check installation health and diagnose issues |
144
150
 
145
151
  ### `claudestat watch`
@@ -180,6 +186,22 @@ claudestat top
180
186
 
181
187
  Options: `--by cost|count|duration` · `--days 7|30|90` · `--limit N`
182
188
 
189
+ ### `claudestat weekly`
190
+
191
+ Weekly usage summary with an actionable tip. Detects patterns like Bash overuse, low efficiency, high session count, and loop frequency.
192
+
193
+ ```
194
+ claudestat weekly
195
+
196
+ 📊 claudestat weekly insight (May 5 — May 11)
197
+ ──────────────────────────────────────────────
198
+ Sessions: 42 · Cost: $146.21 · Loops: 93
199
+ Top tool: Bash (21% of cost) · Efficiency: 93/100
200
+ ⚡ Tip: Group bash commands to reduce tool calls — each call costs context
201
+ ```
202
+
203
+ Options: `--json` for machine-readable output.
204
+
183
205
  ### `claudestat status`
184
206
 
185
207
  ```
@@ -187,10 +209,72 @@ claudestat status
187
209
 
188
210
  Quota 5h 45/50 prompts (90%) | reset in 22m
189
211
  Plan MAX5
190
- Sonnet 3.2h / 5h this week
212
+ Weekly 3.5h / 40h (9%) this week
191
213
  Burn rate 1,240 tok/min
192
214
  ```
193
215
 
216
+ ### `claudestat status --compact`
217
+
218
+ One-line output for tmux status bar or scripting. Shows cycle quota and weekly usage with colored emoji.
219
+
220
+ ```bash
221
+ claudestat status --compact
222
+ C:45%🟡 W:9%🟢 pro
223
+ ```
224
+
225
+ ### `claudestat share`
226
+
227
+ Generate a shareable session card — perfect for sharing on social media or in bug reports.
228
+
229
+ ```bash
230
+ claudestat share
231
+ ╔═══════════════════════════════════╗
232
+ ║ Session Report · claudestat ║
233
+ ╠═══════════════════════════════════╣
234
+ ║ Project my-project ║
235
+ ║ Duration 2h 14m ║
236
+ ║ Tools 847 calls ║
237
+ ║ Cost $0.84 ║
238
+ ║ Cache hit 27% saved ($0.31) ║
239
+ ║ Top tool Bash (38%) ║
240
+ ║ Efficiency 91 / 100 ║
241
+ ╚═══════════════════════════════════╝
242
+ github.com/DeibyGS/claudestat
243
+ ```
244
+
245
+ Options:
246
+ - `--format ascii|json` — output format (default: ascii)
247
+ - `--copy` — copy to clipboard automatically (macOS only)
248
+
249
+ ### `claudestat roast`
250
+
251
+ Get a sarcastic analysis of your Claude Code usage — humor with insights.
252
+
253
+ ```bash
254
+ claudestat roast
255
+
256
+ === Claude Code Stats (last 30 days) ===
257
+ Sessions: 47
258
+ Total cost: $12.40
259
+ Bash calls: 1,240
260
+ Loops: 8
261
+ Efficiency: 72/100
262
+
263
+ 🔥 Your Claude Code Roast
264
+
265
+ You called Bash 1,240 times last month.
266
+ That's once every 2.3 minutes.
267
+ Are you okay?
268
+
269
+ You hit 90%+ context in 12 sessions.
270
+ Claude was writing with amnesia half the time.
271
+
272
+ You spent $4.20 on loops you never noticed.
273
+ That's 14 coffees. Just saying.
274
+
275
+ Efficiency: 72/100 — room for growth, champ.
276
+ ```
277
+
194
278
  ### `claudestat doctor`
195
279
 
196
280
  Diagnoses common installation problems — useful if `claudestat start` fails or hooks are not firing.
@@ -259,6 +343,35 @@ Config is stored at `~/.claudestat/config.json` (macOS/Linux) or `%USERPROFILE%\
259
343
 
260
344
  ---
261
345
 
346
+ ## MCP Server
347
+
348
+ claudestat includes an MCP (Model Context Protocol) server that lets Claude Code query its own usage stats — Claude can tell you its quota, session cost, and top tools in real time.
349
+
350
+ ### Tools exposed
351
+
352
+ | Tool | Description |
353
+ |------|------------|
354
+ | `get_quota_status` | 5h cycle usage %, plan, weekly hours, burn rate |
355
+ | `get_current_session` | Latest session: cost, tokens, efficiency, loops |
356
+ | `get_session_stats` | Aggregated stats for N days |
357
+ | `get_top_tools` | Top 10 tools by cost/count/duration |
358
+ | `get_weekly_insight` | Weekly summary with actionable tip |
359
+
360
+ ### Register with Claude Code
361
+
362
+ ```bash
363
+ claude mcp add --transport stdio claudestat -- claudestat-mcp
364
+ ```
365
+
366
+ Once registered, ask Claude things like:
367
+ - *"What's my current quota status?"*
368
+ - *"Show me my latest session cost"*
369
+ - *"What are my top 5 tools by cost this week?"*
370
+
371
+ Zero extra dependencies — stdio JSON-RPC, works without the daemon running.
372
+
373
+ ---
374
+
262
375
  ## Dashboard
263
376
 
264
377
  The dashboard lives at `http://localhost:7337` and has six tabs:
@@ -436,6 +549,16 @@ See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines.
436
549
 
437
550
  ---
438
551
 
552
+ ## Contributors
553
+
554
+ Thanks to everyone who has contributed to claudestat:
555
+
556
+ [Deiby Gorrin](https://github.com/DeibyGS) — creator and maintainer
557
+
558
+ Want to appear here? Pick a [good-first-issue](https://github.com/DeibyGS/claudestat/labels/good-first-issue) and open a PR.
559
+
560
+ ---
561
+
439
562
  ## FAQ
440
563
 
441
564
  **What is claudestat?**
package/dist/daemon.js CHANGED
@@ -13,6 +13,39 @@
13
13
  * - Endpoint GET /meta-stats: KPIs de HANDOFF, Engram, config y alertas
14
14
  * - Procesa JSONL al conectar nuevo cliente SSE (contexto inmediato)
15
15
  */
16
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ var desc = Object.getOwnPropertyDescriptor(m, k);
19
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
20
+ desc = { enumerable: true, get: function() { return m[k]; } };
21
+ }
22
+ Object.defineProperty(o, k2, desc);
23
+ }) : (function(o, m, k, k2) {
24
+ if (k2 === undefined) k2 = k;
25
+ o[k2] = m[k];
26
+ }));
27
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
28
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
29
+ }) : function(o, v) {
30
+ o["default"] = v;
31
+ });
32
+ var __importStar = (this && this.__importStar) || (function () {
33
+ var ownKeys = function(o) {
34
+ ownKeys = Object.getOwnPropertyNames || function (o) {
35
+ var ar = [];
36
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
37
+ return ar;
38
+ };
39
+ return ownKeys(o);
40
+ };
41
+ return function (mod) {
42
+ if (mod && mod.__esModule) return mod;
43
+ var result = {};
44
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
45
+ __setModuleDefault(result, mod);
46
+ return result;
47
+ };
48
+ })();
16
49
  var __importDefault = (this && this.__importDefault) || function (mod) {
17
50
  return (mod && mod.__esModule) ? mod : { "default": mod };
18
51
  };
@@ -199,6 +232,19 @@ function startDaemon() {
199
232
  console.log(`\n● claudestat daemon → http://localhost:${PORT}`);
200
233
  console.log(` Waiting for Claude Code events...\n`);
201
234
  console.log(` In another terminal: \x1b[36mclaudestat watch\x1b[0m\n`);
235
+ // Weekly insight — se muestra una vez por semana al iniciar el daemon
236
+ Promise.resolve().then(() => __importStar(require('./insights'))).then(({ getWeeklyInsightData, shouldShowInsight, markInsightShown, renderWeeklyInsight }) => {
237
+ try {
238
+ if (!shouldShowInsight())
239
+ return;
240
+ const data = getWeeklyInsightData();
241
+ if (data.total_sessions >= 3) {
242
+ console.log(renderWeeklyInsight(data));
243
+ }
244
+ markInsightShown();
245
+ }
246
+ catch { /* insight is non-critical */ }
247
+ });
202
248
  // Etiquetar sesiones históricas que no tienen proyecto asignado
203
249
  migrateSessionProjects();
204
250
  // Pre-scan de proyectos al arrancar — garantiza respuesta inmediata en el tab
package/dist/db.d.ts CHANGED
@@ -126,6 +126,19 @@ export declare const dbOps: {
126
126
  total_duration_ms: number;
127
127
  total_cost_usd: number;
128
128
  }[];
129
+ getWeeklyInsight(days?: number): {
130
+ total_sessions: number;
131
+ total_cost: number;
132
+ input_tokens: number;
133
+ output_tokens: number;
134
+ cache_read: number;
135
+ total_loops: number;
136
+ avg_efficiency: number;
137
+ week_start: number;
138
+ week_end: number;
139
+ };
140
+ setMeta(key: string, value: string): void;
141
+ getMeta(key: string): string | undefined;
129
142
  getCostProjection(days?: number): {
130
143
  total_cost_usd: number;
131
144
  earliest: number;
package/dist/db.js CHANGED
@@ -42,6 +42,15 @@ try {
42
42
  `);
43
43
  }
44
44
  catch { /* ya existe */ }
45
+ try {
46
+ db.exec(`
47
+ CREATE TABLE IF NOT EXISTS meta (
48
+ key TEXT PRIMARY KEY,
49
+ value TEXT NOT NULL
50
+ )
51
+ `);
52
+ }
53
+ catch { /* ya existe */ }
45
54
  db.exec(`
46
55
  CREATE TABLE IF NOT EXISTS sessions (
47
56
  id TEXT PRIMARY KEY,
@@ -381,6 +390,25 @@ const stmts = {
381
390
  ORDER BY total_duration_ms DESC
382
391
  LIMIT ?
383
392
  `),
393
+ getWeeklyInsight: db.prepare(`
394
+ SELECT
395
+ COUNT(*) AS total_sessions,
396
+ COALESCE(SUM(total_cost_usd), 0) AS total_cost,
397
+ COALESCE(SUM(total_input_tokens), 0) AS input_tokens,
398
+ COALESCE(SUM(total_output_tokens), 0) AS output_tokens,
399
+ COALESCE(SUM(total_cache_read), 0) AS cache_read,
400
+ COALESCE(SUM(loops_detected), 0) AS total_loops,
401
+ COALESCE(AVG(CASE WHEN efficiency_score > 0 THEN efficiency_score END), 100) AS avg_efficiency,
402
+ MIN(started_at) AS week_start,
403
+ MAX(last_event_at) AS week_end
404
+ FROM sessions
405
+ WHERE started_at >= ?
406
+ `),
407
+ upsertMeta: db.prepare(`
408
+ INSERT INTO meta (key, value) VALUES (?, ?)
409
+ ON CONFLICT(key) DO UPDATE SET value = excluded.value
410
+ `),
411
+ getMeta: db.prepare(`SELECT value FROM meta WHERE key = ?`),
384
412
  getCostProjection: db.prepare(`
385
413
  SELECT
386
414
  SUM(total_cost_usd) AS total_cost_usd,
@@ -539,6 +567,17 @@ exports.dbOps = {
539
567
  }
540
568
  return tools;
541
569
  },
570
+ getWeeklyInsight(days = 7) {
571
+ const since = Date.now() - days * 86400000;
572
+ return stmts.getWeeklyInsight.get(since);
573
+ },
574
+ setMeta(key, value) {
575
+ stmts.upsertMeta.run(key, value);
576
+ },
577
+ getMeta(key) {
578
+ const row = stmts.getMeta.get(key);
579
+ return row?.value;
580
+ },
542
581
  getCostProjection(days = 7) {
543
582
  const since = Date.now() - days * 86400000;
544
583
  return stmts.getCostProjection.get(since);
package/dist/index.js CHANGED
@@ -29,6 +29,7 @@ const config_1 = require("./config");
29
29
  const doctor_1 = require("./doctor");
30
30
  const share_1 = require("./share");
31
31
  const roast_1 = require("./roast");
32
+ const insights_1 = require("./insights");
32
33
  const paths_1 = require("./paths");
33
34
  const program = new commander_1.Command();
34
35
  const PKG_VERSION = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__dirname, '..', 'package.json'), 'utf8')).version;
@@ -64,7 +65,12 @@ async function stopDaemon() {
64
65
  catch { }
65
66
  try {
66
67
  const pid = parseInt(fs_1.default.readFileSync(PID_FILE, 'utf8').trim(), 10);
67
- process.kill(pid, 'SIGTERM');
68
+ if (process.platform === 'win32') {
69
+ process.kill(pid);
70
+ }
71
+ else {
72
+ process.kill(pid, 'SIGTERM');
73
+ }
68
74
  console.log(`✅ claudestat daemon stopped (pid ${pid})`);
69
75
  removePidFile();
70
76
  }
@@ -160,7 +166,8 @@ program
160
166
  if (opts.compact) {
161
167
  const pctCycle = q.cyclePct;
162
168
  const cycleEmoji = pctCycle >= 95 ? '🔴' : pctCycle >= 70 ? '🟡' : '🟢';
163
- console.log(`Current ${pctCycle}%${cycleEmoji} ${q.detectedPlan}`);
169
+ const wEmoji = q.weeklyPctAll >= 95 ? '🔴' : q.weeklyPctAll >= 70 ? '🟡' : '🟢';
170
+ console.log(`C:${pctCycle}%${cycleEmoji} W:${q.weeklyPctAll}%${wEmoji} ${q.detectedPlan}`);
164
171
  process.exit(0);
165
172
  }
166
173
  if (opts.json) {
@@ -174,6 +181,7 @@ program
174
181
  weeklyLimitSonnet: q.weeklyLimitSonnet,
175
182
  weeklyHoursOpus: q.weeklyHoursOpus,
176
183
  weeklyLimitOpus: q.weeklyLimitOpus,
184
+ weeklyPctAll: q.weeklyPctAll,
177
185
  burnRateTokensPerMin: q.burnRateTokensPerMin,
178
186
  }));
179
187
  process.exit(0);
@@ -187,19 +195,22 @@ program
187
195
  const resetLabel = resetMin >= 60
188
196
  ? `${Math.floor(resetMin / 60)}h ${resetMin % 60}m`
189
197
  : `${resetMin}m`;
190
- const burnLabel = q.burnRateTokensPerMin > 0
191
- ? ` │ 🔥 ${q.burnRateTokensPerMin.toLocaleString()} tok/min`
192
- : '';
193
198
  const burnRow = q.burnRateTokensPerMin > 0
194
199
  ? ` Burn rate ${q.burnRateTokensPerMin.toLocaleString()} tok/min\n`
195
200
  : '';
201
+ const weeklyTotalHours = q.weeklyHoursSonnet + q.weeklyHoursOpus;
202
+ const weeklyLimitTotal = q.weeklyLimitSonnet + q.weeklyLimitOpus;
203
+ const weeklyPctColor = q.weeklyPctAll >= 95 ? '\x1b[31m'
204
+ : q.weeklyPctAll >= 70 ? '\x1b[33m'
205
+ : '\x1b[32m';
196
206
  console.log(`\n📊 claudestat status\n` +
197
207
  `──────────────────────────────────────────\n` +
198
208
  ` Quota 5h ${pctColor}${q.cyclePrompts}/${q.cycleLimit} prompts (${q.cyclePct}%)${R} │ resets in ${resetLabel}\n` +
199
209
  ` Plan ${q.detectedPlan.toUpperCase()}\n` +
200
- ` Sonnet ${q.weeklyHoursSonnet}h / ${q.weeklyLimitSonnet}h this week\n` +
210
+ ` Weekly ${weeklyTotalHours}h / ${weeklyLimitTotal}h (${weeklyPctColor}${q.weeklyPctAll}%${R}) this week\n` +
201
211
  (q.weeklyLimitOpus > 0
202
- ? ` Opus ${q.weeklyHoursOpus}h / ${q.weeklyLimitOpus}h this week\n`
212
+ ? ` ├─ Sonnet ${q.weeklyHoursSonnet}h / ${q.weeklyLimitSonnet}h\n` +
213
+ ` └─ Opus ${q.weeklyHoursOpus}h / ${q.weeklyLimitOpus}h\n`
203
214
  : '') +
204
215
  `${burnRow}` +
205
216
  `──────────────────────────────────────────\n`);
@@ -358,4 +369,27 @@ program
358
369
  process.exit(1);
359
370
  }
360
371
  });
372
+ program
373
+ .command('weekly')
374
+ .description('Show weekly usage summary')
375
+ .option('--json', 'Output as JSON')
376
+ .action(async (opts) => {
377
+ try {
378
+ const data = (0, insights_1.getWeeklyInsightData)();
379
+ if (data.total_sessions === 0) {
380
+ console.log('\n📊 No usage data yet — start using Claude Code and claudestat will track it.\n');
381
+ process.exit(0);
382
+ }
383
+ if (opts.json) {
384
+ console.log(JSON.stringify(data, null, 2));
385
+ process.exit(0);
386
+ }
387
+ console.log((0, insights_1.renderWeeklyInsight)(data));
388
+ process.exit(0);
389
+ }
390
+ catch (err) {
391
+ console.error('\n❌ Error:', err.message);
392
+ process.exit(1);
393
+ }
394
+ });
361
395
  program.parse();
@@ -0,0 +1,19 @@
1
+ export interface WeeklyInsightData {
2
+ total_sessions: number;
3
+ total_cost: number;
4
+ input_tokens: number;
5
+ output_tokens: number;
6
+ cache_read: number;
7
+ cache_hit_pct: number;
8
+ total_loops: number;
9
+ avg_efficiency: number;
10
+ top_tool: string;
11
+ top_tool_cost_pct: number;
12
+ week_start: number;
13
+ week_end: number;
14
+ }
15
+ export declare function getWeeklyInsightData(days?: number): WeeklyInsightData;
16
+ export declare function generateTip(d: WeeklyInsightData): string;
17
+ export declare function shouldShowInsight(): boolean;
18
+ export declare function markInsightShown(): void;
19
+ export declare function renderWeeklyInsight(d: WeeklyInsightData): string;
@@ -0,0 +1,105 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.getWeeklyInsightData = getWeeklyInsightData;
4
+ exports.generateTip = generateTip;
5
+ exports.shouldShowInsight = shouldShowInsight;
6
+ exports.markInsightShown = markInsightShown;
7
+ exports.renderWeeklyInsight = renderWeeklyInsight;
8
+ const db_1 = require("./db");
9
+ const WEEK_MS = 7 * 86400000;
10
+ const META_KEY = 'last_insight_at';
11
+ function getWeeklyInsightData(days = 7) {
12
+ const agg = db_1.dbOps.getWeeklyInsight(days);
13
+ const topTools = db_1.dbOps.getTopTools(days, 'cost', 1);
14
+ const topTool = topTools[0];
15
+ const topToolName = topTool?.tool_name ?? 'Unknown';
16
+ const topToolPct = agg.total_cost > 0
17
+ ? Math.round((topTool?.total_cost_usd ?? 0) / agg.total_cost * 100)
18
+ : 0;
19
+ const totalInputWithCache = agg.input_tokens + agg.cache_read;
20
+ const cacheHitPct = totalInputWithCache > 0
21
+ ? Math.min(100, Math.round(agg.cache_read / totalInputWithCache * 100))
22
+ : 0;
23
+ return {
24
+ total_sessions: agg.total_sessions,
25
+ total_cost: agg.total_cost,
26
+ input_tokens: agg.input_tokens,
27
+ output_tokens: agg.output_tokens,
28
+ cache_read: agg.cache_read,
29
+ cache_hit_pct: cacheHitPct,
30
+ total_loops: agg.total_loops,
31
+ avg_efficiency: Math.round(agg.avg_efficiency),
32
+ top_tool: topToolName,
33
+ top_tool_cost_pct: topToolPct,
34
+ week_start: agg.week_start,
35
+ week_end: agg.week_end ?? agg.week_start,
36
+ };
37
+ }
38
+ function generateTip(d) {
39
+ const costPct = d.top_tool_cost_pct;
40
+ const tool = d.top_tool;
41
+ if (tool === 'Bash' && costPct >= 40) {
42
+ return 'Group bash commands to reduce tool calls — each call costs context';
43
+ }
44
+ if (d.total_loops >= 3) {
45
+ return `${d.total_loops} loops detected — consider using /compact earlier to prevent context thrashing`;
46
+ }
47
+ if (d.avg_efficiency < 60) {
48
+ return 'Low efficiency score — try smaller, focused tasks instead of long sessions';
49
+ }
50
+ if (d.total_sessions > 30) {
51
+ return `${d.total_sessions} sessions this week — consider batching related work into fewer sessions`;
52
+ }
53
+ if (d.cache_hit_pct < 10 && d.total_sessions > 5) {
54
+ return 'Low cache hit rate — repetitive context is costing you; use CLAUDE.md for common instructions';
55
+ }
56
+ if (d.total_cost > 20) {
57
+ return `$${d.total_cost.toFixed(0)} spent this week — enable quota alerts with "claudestat config --alerts true" to stay in control`;
58
+ }
59
+ return 'Enable quota alerts with "claudestat config --alerts true" to avoid surprise limits';
60
+ }
61
+ function shouldShowInsight() {
62
+ const last = db_1.dbOps.getMeta(META_KEY);
63
+ if (!last)
64
+ return true;
65
+ return Date.now() - parseInt(last, 10) >= WEEK_MS;
66
+ }
67
+ function markInsightShown() {
68
+ db_1.dbOps.setMeta(META_KEY, Date.now().toString());
69
+ }
70
+ function renderWeeklyInsight(d) {
71
+ const fmtTok = (n) => {
72
+ if (n >= 1000000)
73
+ return `${(n / 1000000).toFixed(1)}M`;
74
+ if (n >= 1000)
75
+ return `${Math.round(n / 1000)}K`;
76
+ return n.toString();
77
+ };
78
+ const fmtCost = (n) => `$${n.toFixed(2)}`;
79
+ const fmtDate = (ts) => {
80
+ const d = new Date(ts);
81
+ return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
82
+ };
83
+ const R = '\x1b[0m';
84
+ const B = '\x1b[1m';
85
+ const D = '\x1b[2m';
86
+ const C = '\x1b[36m';
87
+ const lines = [];
88
+ lines.push(`\n${B}📊 claudestat weekly insight${R} ${D}(${fmtDate(d.week_start)} – ${fmtDate(d.week_end)})${R}`);
89
+ lines.push(`${'─'.repeat(60)}`);
90
+ lines.push(` Sessions: ${d.total_sessions} · Cost: ${fmtCost(d.total_cost)} · Loops: ${d.total_loops}`);
91
+ lines.push(` Top tool: ${d.top_tool} (${d.top_tool_cost_pct}% of cost) · Efficiency: ${d.avg_efficiency}/100`);
92
+ const cacheLabel = d.total_sessions > 0
93
+ ? ` · Cache hit: ${d.cache_hit_pct}%`
94
+ : '';
95
+ const tokLabel = d.input_tokens + d.output_tokens > 0
96
+ ? ` · Tokens: ${fmtTok(d.input_tokens)}+${fmtTok(d.output_tokens)}`
97
+ : '';
98
+ if (cacheLabel || tokLabel) {
99
+ lines.push(` ${D}${tokLabel}${cacheLabel}${R}`);
100
+ }
101
+ lines.push(`${'─'.repeat(60)}`);
102
+ lines.push(` ${C}⚡${R} Tip: ${generateTip(d)}`);
103
+ lines.push('');
104
+ return lines.join('\n');
105
+ }
@@ -0,0 +1,9 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * mcp-server.ts — MCP (Model Context Protocol) server for claudestat
4
+ *
5
+ * Exposes Claude Code usage metrics as tools that Claude can query.
6
+ * Zero extra dependencies — stdio JSON-RPC 2.0, readline only.
7
+ * Works without the daemon — reads SQLite + JSONL directly.
8
+ */
9
+ export {};
@@ -0,0 +1,315 @@
1
+ #!/usr/bin/env node
2
+ "use strict";
3
+ /**
4
+ * mcp-server.ts — MCP (Model Context Protocol) server for claudestat
5
+ *
6
+ * Exposes Claude Code usage metrics as tools that Claude can query.
7
+ * Zero extra dependencies — stdio JSON-RPC 2.0, readline only.
8
+ * Works without the daemon — reads SQLite + JSONL directly.
9
+ */
10
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
11
+ if (k2 === undefined) k2 = k;
12
+ var desc = Object.getOwnPropertyDescriptor(m, k);
13
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
14
+ desc = { enumerable: true, get: function() { return m[k]; } };
15
+ }
16
+ Object.defineProperty(o, k2, desc);
17
+ }) : (function(o, m, k, k2) {
18
+ if (k2 === undefined) k2 = k;
19
+ o[k2] = m[k];
20
+ }));
21
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
22
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
23
+ }) : function(o, v) {
24
+ o["default"] = v;
25
+ });
26
+ var __importStar = (this && this.__importStar) || (function () {
27
+ var ownKeys = function(o) {
28
+ ownKeys = Object.getOwnPropertyNames || function (o) {
29
+ var ar = [];
30
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
31
+ return ar;
32
+ };
33
+ return ownKeys(o);
34
+ };
35
+ return function (mod) {
36
+ if (mod && mod.__esModule) return mod;
37
+ var result = {};
38
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
39
+ __setModuleDefault(result, mod);
40
+ return result;
41
+ };
42
+ })();
43
+ Object.defineProperty(exports, "__esModule", { value: true });
44
+ process.on('warning', (w) => {
45
+ if (w.name === 'ExperimentalWarning' && w.message.includes('SQLite'))
46
+ return;
47
+ process.stderr.write(`${w.name}: ${w.message}\n`);
48
+ });
49
+ const readline = __importStar(require("readline"));
50
+ const db_1 = require("./db");
51
+ const quota_tracker_1 = require("./quota-tracker");
52
+ const insights_1 = require("./insights");
53
+ const SERVER_NAME = 'claudestat';
54
+ const SERVER_VERSION = '1.2.0';
55
+ const PROTOCOL_VERSION = '2025-06-18';
56
+ const TOOLS = [
57
+ {
58
+ name: 'get_quota_status',
59
+ description: 'Get current Claude Code quota status: 5h cycle usage %, plan type, weekly hours per model, and burn rate (tokens/min)',
60
+ inputSchema: {
61
+ type: 'object',
62
+ properties: {},
63
+ required: []
64
+ }
65
+ },
66
+ {
67
+ name: 'get_current_session',
68
+ description: 'Get details about the most recent Claude Code session: cost, tokens, efficiency score, and loops detected',
69
+ inputSchema: {
70
+ type: 'object',
71
+ properties: {},
72
+ required: []
73
+ }
74
+ },
75
+ {
76
+ name: 'get_session_stats',
77
+ description: 'Get aggregated session statistics for the last N days: session count, total cost, total tokens, loops, and average efficiency',
78
+ inputSchema: {
79
+ type: 'object',
80
+ properties: {
81
+ days: {
82
+ type: 'number',
83
+ description: 'Number of days to look back (1–90, default 7)'
84
+ }
85
+ },
86
+ required: []
87
+ }
88
+ },
89
+ {
90
+ name: 'get_top_tools',
91
+ description: 'Get the top 10 most used tools by cost, call count, or duration in the last N days',
92
+ inputSchema: {
93
+ type: 'object',
94
+ properties: {
95
+ days: {
96
+ type: 'number',
97
+ description: 'Days to look back (default 30)'
98
+ },
99
+ sort_by: {
100
+ type: 'string',
101
+ description: 'Sort by: cost, count, or duration (default cost)'
102
+ }
103
+ },
104
+ required: []
105
+ }
106
+ },
107
+ {
108
+ name: 'get_weekly_insight',
109
+ description: 'Get the weekly usage summary with an actionable tip (same as claudestat weekly command)',
110
+ inputSchema: {
111
+ type: 'object',
112
+ properties: {
113
+ days: {
114
+ type: 'number',
115
+ description: 'Days to look back (default 7)'
116
+ }
117
+ },
118
+ required: []
119
+ }
120
+ }
121
+ ];
122
+ function fmtDollar(n) {
123
+ if (n === 0)
124
+ return '$0.00';
125
+ if (n < 0.01)
126
+ return '< $0.01';
127
+ return `$${n.toFixed(2)}`;
128
+ }
129
+ function fmtTok(n) {
130
+ if (n >= 1000000)
131
+ return `${(n / 1000000).toFixed(1)}M`;
132
+ if (n >= 1000)
133
+ return `${Math.round(n / 1000)}K`;
134
+ return n.toString();
135
+ }
136
+ function toolGetQuotaStatus() {
137
+ const q = (0, quota_tracker_1.computeQuota)();
138
+ const resetMin = Math.ceil(q.cycleResetMs / 60000);
139
+ const resetLabel = resetMin >= 60
140
+ ? `${Math.floor(resetMin / 60)}h ${resetMin % 60}m`
141
+ : `${resetMin}m`;
142
+ const weeklyTotalHours = q.weeklyHoursSonnet + q.weeklyHoursOpus;
143
+ const weeklyLimitTotal = q.weeklyLimitSonnet + q.weeklyLimitOpus;
144
+ const parts = [
145
+ `Quota status — ${q.detectedPlan.toUpperCase()} plan`,
146
+ ``,
147
+ `5h cycle: ${q.cyclePrompts}/${q.cycleLimit} prompts (${q.cyclePct}%) · resets in ${resetLabel}`,
148
+ `Weekly: ${weeklyTotalHours}h / ${weeklyLimitTotal}h (${q.weeklyPctAll}%)`,
149
+ ];
150
+ if (q.weeklyLimitOpus > 0) {
151
+ parts.push(` ├─ Sonnet ${q.weeklyHoursSonnet}h / ${q.weeklyLimitSonnet}h`);
152
+ parts.push(` └─ Opus ${q.weeklyHoursOpus}h / ${q.weeklyLimitOpus}h`);
153
+ }
154
+ if (q.burnRateTokensPerMin > 0) {
155
+ parts.push(`Burn rate: ${q.burnRateTokensPerMin.toLocaleString()} tokens/min`);
156
+ }
157
+ return parts.join('\n');
158
+ }
159
+ function toolGetCurrentSession() {
160
+ const session = db_1.dbOps.getLatestSession();
161
+ if (!session)
162
+ return 'No sessions recorded yet.';
163
+ const cost = fmtDollar(session.total_cost_usd ?? 0);
164
+ const inp = fmtTok(session.total_input_tokens ?? 0);
165
+ const out = fmtTok(session.total_output_tokens ?? 0);
166
+ const cache = fmtTok(session.total_cache_read ?? 0);
167
+ const eff = session.efficiency_score ?? 100;
168
+ const loops = session.loops_detected ?? 0;
169
+ const started = new Date(session.started_at).toISOString();
170
+ const project = session.project_path ?? 'No project';
171
+ const model = session.dominant_model ?? 'unknown';
172
+ return [
173
+ `Latest session: ${session.id.slice(0, 8)}...`,
174
+ ``,
175
+ `Project: ${project}`,
176
+ `Model: ${model}`,
177
+ `Started: ${started}`,
178
+ `Cost: ${cost}`,
179
+ `Tokens: ${inp} in + ${out} out (${cache} cache read)`,
180
+ `Efficiency: ${eff}/100`,
181
+ `Loops: ${loops}`,
182
+ ].join('\n');
183
+ }
184
+ function toolGetSessionStats(days) {
185
+ const d = Math.max(1, Math.min(90, Math.floor(days || 7)));
186
+ const insight = db_1.dbOps.getWeeklyInsight(d);
187
+ if (!insight || insight.total_sessions === 0)
188
+ return `No sessions in the last ${d} days.`;
189
+ const totalTok = insight.input_tokens + insight.output_tokens;
190
+ return [
191
+ `Session stats — last ${d} days`,
192
+ ``,
193
+ `Sessions: ${insight.total_sessions}`,
194
+ `Cost: ${fmtDollar(insight.total_cost)}`,
195
+ `Tokens: ${fmtTok(totalTok)} (${fmtTok(insight.input_tokens)} in + ${fmtTok(insight.output_tokens)} out)`,
196
+ `Cache read: ${fmtTok(insight.cache_read)}`,
197
+ `Loops: ${insight.total_loops}`,
198
+ `Efficiency: ${Math.round(insight.avg_efficiency)}/100 avg`,
199
+ ].join('\n');
200
+ }
201
+ function toolGetTopTools(days, sortBy) {
202
+ const d = Math.max(1, Math.min(90, Math.floor(days || 30)));
203
+ const sort = (sortBy === 'count' || sortBy === 'duration') ? sortBy : 'cost';
204
+ const tools = db_1.dbOps.getTopTools(d, sort, 10);
205
+ if (tools.length === 0)
206
+ return `No tool usage data in the last ${d} days.`;
207
+ const lines = [
208
+ `Top tools — last ${d} days (sorted by ${sort})`,
209
+ '',
210
+ ];
211
+ for (let i = 0; i < tools.length; i++) {
212
+ const t = tools[i];
213
+ const idx = `${i + 1}.`.padEnd(4);
214
+ const name = t.tool_name.padEnd(14);
215
+ const cnt = `${t.count} calls`.padEnd(14);
216
+ const dur = t.total_duration_ms > 0
217
+ ? `${(t.total_duration_ms / 1000).toFixed(1)}s`.padEnd(10)
218
+ : '—'.padEnd(10);
219
+ const cost = fmtDollar(t.total_cost_usd);
220
+ lines.push(` ${idx}${name}${cnt}${dur}${cost}`);
221
+ }
222
+ return lines.join('\n');
223
+ }
224
+ function toolGetWeeklyInsight(days) {
225
+ const d = Math.max(1, Math.min(90, Math.floor(days || 7)));
226
+ const data = (0, insights_1.getWeeklyInsightData)(d);
227
+ if (data.total_sessions === 0)
228
+ return `No usage data for the last ${d} days.`;
229
+ const fmtDate = (ts) => {
230
+ const dt = new Date(ts);
231
+ return dt.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
232
+ };
233
+ return [
234
+ `Weekly insight (${fmtDate(data.week_start)} – ${fmtDate(data.week_end)})`,
235
+ `──────────────────────────────────────────────`,
236
+ `Sessions: ${data.total_sessions} · Cost: ${fmtDollar(data.total_cost)} · Loops: ${data.total_loops}`,
237
+ `Top tool: ${data.top_tool} (${data.top_tool_cost_pct}% of cost) · Efficiency: ${data.avg_efficiency}/100`,
238
+ `Tokens: ${fmtTok(data.input_tokens)} in + ${fmtTok(data.output_tokens)} out · Cache hit: ${data.cache_hit_pct}%`,
239
+ `Tip: ${(0, insights_1.generateTip)(data)}`,
240
+ ].join('\n');
241
+ }
242
+ function handleToolCall(name, args) {
243
+ const days = typeof args.days === 'number' ? args.days : 7;
244
+ const sortBy = typeof args.sort_by === 'string' ? args.sort_by : 'cost';
245
+ switch (name) {
246
+ case 'get_quota_status': return toolGetQuotaStatus();
247
+ case 'get_current_session': return toolGetCurrentSession();
248
+ case 'get_session_stats': return toolGetSessionStats(days);
249
+ case 'get_top_tools': return toolGetTopTools(days, sortBy);
250
+ case 'get_weekly_insight': return toolGetWeeklyInsight(days);
251
+ default: return `Unknown tool: ${name}`;
252
+ }
253
+ }
254
+ function handleRequest(msg) {
255
+ const { id, method, params } = msg;
256
+ if (id === undefined) {
257
+ if (method === 'notifications/initialized')
258
+ return null;
259
+ return null;
260
+ }
261
+ try {
262
+ switch (method) {
263
+ case 'initialize':
264
+ return {
265
+ jsonrpc: '2.0', id,
266
+ result: {
267
+ protocolVersion: PROTOCOL_VERSION,
268
+ capabilities: { tools: {} },
269
+ serverInfo: { name: SERVER_NAME, version: SERVER_VERSION }
270
+ }
271
+ };
272
+ case 'tools/list':
273
+ return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
274
+ case 'tools/call': {
275
+ const toolName = params?.name;
276
+ const toolArgs = (params?.arguments ?? {});
277
+ const text = handleToolCall(toolName, toolArgs);
278
+ return {
279
+ jsonrpc: '2.0', id,
280
+ result: { content: [{ type: 'text', text }], isError: false }
281
+ };
282
+ }
283
+ default:
284
+ return {
285
+ jsonrpc: '2.0', id,
286
+ error: { code: -32601, message: `Method not found: ${method}` }
287
+ };
288
+ }
289
+ }
290
+ catch (e) {
291
+ return {
292
+ jsonrpc: '2.0', id,
293
+ result: { content: [{ type: 'text', text: `Error: ${e.message}` }], isError: true }
294
+ };
295
+ }
296
+ }
297
+ const rl = readline.createInterface({ input: process.stdin, terminal: false });
298
+ rl.on('line', (line) => {
299
+ const trimmed = line.trim();
300
+ if (!trimmed)
301
+ return;
302
+ try {
303
+ const msg = JSON.parse(trimmed);
304
+ const response = handleRequest(msg);
305
+ if (response) {
306
+ process.stdout.write(JSON.stringify(response) + '\n');
307
+ }
308
+ }
309
+ catch (e) {
310
+ process.stderr.write(`[claudestat-mcp] Parse error: ${e.message}\n`);
311
+ }
312
+ });
313
+ process.on('SIGTERM', () => process.exit(0));
314
+ process.on('SIGINT', () => process.exit(0));
315
+ process.stderr.write(`[claudestat-mcp] Server ready (stdio, protocol ${PROTOCOL_VERSION})\n`);
@@ -32,6 +32,7 @@ export interface QuotaData {
32
32
  weeklyTokensHaiku: number;
33
33
  weeklyLimitSonnet: number;
34
34
  weeklyLimitOpus: number;
35
+ weeklyPctAll: number;
35
36
  burnRateTokensPerMin: number;
36
37
  detectedPlan: ClaudePlan;
37
38
  planSource: 'config' | 'keychain' | 'inferred';
@@ -293,6 +293,10 @@ function computeQuota(forcePlan) {
293
293
  const burnRateTokensPerMin = recentAssistant.length > 0
294
294
  ? Math.round(totalRecentTok / 30)
295
295
  : 0;
296
+ // ─ % semanal combinado (Sonnet + Opus, coincide con "Todos los modelos" de claude.ai) ─
297
+ const weeklyPctAll = limits.weeklyHoursSonnet + limits.weeklyHoursOpus > 0
298
+ ? Math.min(100, Math.round((weeklyHoursSonnet + weeklyHoursOpus) / (limits.weeklyHoursSonnet + limits.weeklyHoursOpus) * 100))
299
+ : 0;
296
300
  const data = {
297
301
  cyclePrompts: cycleEntries.filter(e => e.type === 'human').length,
298
302
  cycleLimit: limits.prompts5h,
@@ -310,6 +314,7 @@ function computeQuota(forcePlan) {
310
314
  weeklyTokensHaiku,
311
315
  weeklyLimitSonnet: limits.weeklyHoursSonnet,
312
316
  weeklyLimitOpus: limits.weeklyHoursOpus,
317
+ weeklyPctAll,
313
318
  burnRateTokensPerMin,
314
319
  detectedPlan: plan,
315
320
  planSource,
@@ -75,7 +75,7 @@ exports.eventsRouter.post('/event', (req, res) => {
75
75
  return;
76
76
  }
77
77
  const resolvedCwd = cwd
78
- ?? (transcript_path ? transcript_path.split('/').slice(0, -1).join('/') : undefined);
78
+ ?? (transcript_path ? path_1.default.dirname(transcript_path) || undefined : undefined);
79
79
  db_1.dbOps.upsertSession({ id: session_id, cwd: resolvedCwd, started_at: ts, last_event_at: ts });
80
80
  // Skill grouping: get current parent BEFORE processing this event
81
81
  // (the Skill Done event itself is NOT tagged — only its subsequent sub-calls are)
@@ -125,7 +125,7 @@ exports.eventsRouter.post('/event', (req, res) => {
125
125
  try {
126
126
  const inp = typeof tool_input === 'string' ? JSON.parse(tool_input) : (tool_input ?? {});
127
127
  const filePath = inp?.file_path ?? inp?.path;
128
- if (typeof filePath === 'string' && filePath.startsWith('/')) {
128
+ if (typeof filePath === 'string' && path_1.default.isAbsolute(filePath)) {
129
129
  const projectCwd = findProjectCwdForFile(filePath);
130
130
  if (projectCwd)
131
131
  db_1.dbOps.updateSessionProject(session_id, projectCwd);
@@ -39,7 +39,7 @@ function inferProjectCwd(events) {
39
39
  try {
40
40
  const inp = JSON.parse(ev.tool_input);
41
41
  const filePath = (inp.file_path || inp.path);
42
- if (!filePath?.startsWith('/'))
42
+ if (!filePath || !path_1.default.isAbsolute(filePath))
43
43
  continue;
44
44
  const cwd = findProjectCwdForFile(filePath);
45
45
  if (cwd)
@@ -71,7 +71,7 @@ function inferActiveProjectByMajority(events, windowMs) {
71
71
  try {
72
72
  const inp = JSON.parse(ev.tool_input);
73
73
  const filePath = (inp.file_path || inp.path);
74
- if (!filePath?.startsWith('/'))
74
+ if (!filePath || !path_1.default.isAbsolute(filePath))
75
75
  continue;
76
76
  const project = findProjectCwdForFile(filePath);
77
77
  if (!project)
package/dist/share.js CHANGED
@@ -1,8 +1,12 @@
1
1
  "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
2
5
  Object.defineProperty(exports, "__esModule", { value: true });
3
6
  exports.runShare = runShare;
4
7
  const db_js_1 = require("./db.js");
5
8
  const child_process_1 = require("child_process");
9
+ const path_1 = __importDefault(require("path"));
6
10
  function formatDuration(ms) {
7
11
  const seconds = Math.floor(ms / 1000);
8
12
  const minutes = Math.floor(seconds / 60);
@@ -45,7 +49,7 @@ async function getSessionData(sessionId) {
45
49
  topToolPct = Math.round((sorted[0][1] / toolCalls.length) * 100);
46
50
  }
47
51
  const durationMs = (session.last_event_at || session.started_at) - session.started_at;
48
- const project = session.project_path?.split('/').pop() || 'unknown';
52
+ const project = path_1.default.basename(session.project_path ?? '') || 'unknown';
49
53
  return {
50
54
  id: session.id,
51
55
  project: project.length > 18 ? project.slice(0, 15) + '...' : project,
@@ -92,7 +92,7 @@ function buildContext(events, costUsd, projectName) {
92
92
  try {
93
93
  const inp = JSON.parse(e.tool_input);
94
94
  const fp = inp.file_path || inp.path;
95
- if (typeof fp === 'string' && fp.startsWith('/')) {
95
+ if (typeof fp === 'string' && path_1.default.isAbsolute(fp)) {
96
96
  filesSet.add(path_1.default.basename(fp));
97
97
  }
98
98
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@statforge/claudestat",
3
- "version": "1.1.0",
3
+ "version": "1.2.0",
4
4
  "description": "Observability layer for Claude Code — live token tracking, cost analytics, quota guard, loop detection, and usage dashboard. The htop for Claude Code.",
5
5
  "keywords": [
6
6
  "claude-code",
@@ -48,7 +48,8 @@
48
48
  "dashboard/dist/"
49
49
  ],
50
50
  "bin": {
51
- "claudestat": "dist/index.js"
51
+ "claudestat": "dist/index.js",
52
+ "claudestat-mcp": "dist/mcp-server.js"
52
53
  },
53
54
  "scripts": {
54
55
  "build": "tsc && npm run build:dashboard",
@@ -58,7 +59,7 @@
58
59
  "dev": "tsx src/index.ts",
59
60
  "dev:full": "tsx src/index.ts start & sleep 1 && cd dashboard && npm run dev",
60
61
  "start": "node dist/index.js",
61
- "test": "bash run-tests.sh"
62
+ "test": "node --require tsx/cjs tests/index.ts"
62
63
  },
63
64
  "dependencies": {
64
65
  "@anthropic-ai/sdk": "^0.88.0",