@statforge/claudestat 1.7.0 → 1.8.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/dist/index.js CHANGED
@@ -6,6 +6,39 @@
6
6
  * Suprimimos el ExperimentalWarning de node:sqlite antes de importar nada.
7
7
  * El módulo funciona perfectamente — el warning es solo informativo.
8
8
  */
9
+ var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
10
+ if (k2 === undefined) k2 = k;
11
+ var desc = Object.getOwnPropertyDescriptor(m, k);
12
+ if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
13
+ desc = { enumerable: true, get: function() { return m[k]; } };
14
+ }
15
+ Object.defineProperty(o, k2, desc);
16
+ }) : (function(o, m, k, k2) {
17
+ if (k2 === undefined) k2 = k;
18
+ o[k2] = m[k];
19
+ }));
20
+ var __setModuleDefault = (this && this.__setModuleDefault) || (Object.create ? (function(o, v) {
21
+ Object.defineProperty(o, "default", { enumerable: true, value: v });
22
+ }) : function(o, v) {
23
+ o["default"] = v;
24
+ });
25
+ var __importStar = (this && this.__importStar) || (function () {
26
+ var ownKeys = function(o) {
27
+ ownKeys = Object.getOwnPropertyNames || function (o) {
28
+ var ar = [];
29
+ for (var k in o) if (Object.prototype.hasOwnProperty.call(o, k)) ar[ar.length] = k;
30
+ return ar;
31
+ };
32
+ return ownKeys(o);
33
+ };
34
+ return function (mod) {
35
+ if (mod && mod.__esModule) return mod;
36
+ var result = {};
37
+ if (mod != null) for (var k = ownKeys(mod), i = 0; i < k.length; i++) if (k[i] !== "default") __createBinding(result, mod, k[i]);
38
+ __setModuleDefault(result, mod);
39
+ return result;
40
+ };
41
+ })();
9
42
  var __importDefault = (this && this.__importDefault) || function (mod) {
10
43
  return (mod && mod.__esModule) ? mod : { "default": mod };
11
44
  };
@@ -20,8 +53,6 @@ const commander_1 = require("commander");
20
53
  const fs_1 = __importDefault(require("fs"));
21
54
  const path_1 = __importDefault(require("path"));
22
55
  const child_process_1 = require("child_process");
23
- const daemon_1 = require("./daemon");
24
- const watchdog_1 = require("./watchdog");
25
56
  const watch_1 = require("./watch");
26
57
  const install_1 = require("./install");
27
58
  const service_1 = require("./service");
@@ -36,6 +67,18 @@ const quota_tracker_1 = require("./quota-tracker");
36
67
  const program = new commander_1.Command();
37
68
  const PKG_VERSION = JSON.parse(fs_1.default.readFileSync(path_1.default.join(__dirname, '..', 'package.json'), 'utf8')).version;
38
69
  const PID_FILE = (0, paths_1.getPidFile)();
70
+ const PORT = (0, config_1.readConfig)().port;
71
+ function semverGt(a, b) {
72
+ const [pa, pb] = [a.split('.').map(Number), b.split('.').map(Number)];
73
+ for (let i = 0; i < 3; i++) {
74
+ const aVal = pa[i] ?? 0, bVal = pb[i] ?? 0;
75
+ if (aVal > bVal)
76
+ return true;
77
+ if (aVal < bVal)
78
+ return false;
79
+ }
80
+ return false;
81
+ }
39
82
  // ── Update notifier ────────────────────────────────────────────
40
83
  const SKIP_UPDATE_NOTICE = new Set(['start', 'stop', 'restart', 'watch']);
41
84
  const subcommand = process.argv[2];
@@ -62,17 +105,6 @@ if (!SKIP_UPDATE_NOTICE.has(subcommand)) {
62
105
  catch {
63
106
  fetchLatestVersion();
64
107
  }
65
- const semverGt = (a, b) => {
66
- const [pa, pb] = [a.split('.').map(Number), b.split('.').map(Number)];
67
- for (let i = 0; i < 3; i++) {
68
- const aVal = pa[i] ?? 0, bVal = pb[i] ?? 0;
69
- if (aVal > bVal)
70
- return true;
71
- if (aVal < bVal)
72
- return false;
73
- }
74
- return false;
75
- };
76
108
  const _exit = process.exit.bind(process);
77
109
  process.exit = ((code) => {
78
110
  if ((code ?? 0) === 0 && cachedLatest && semverGt(cachedLatest, PKG_VERSION)) {
@@ -90,7 +122,7 @@ function spawnDaemon() {
90
122
  });
91
123
  child.unref();
92
124
  console.log(`✅ claudestat daemon started (pid ${child.pid})`);
93
- console.log(` Dashboard → http://localhost:7337`);
125
+ console.log(` Dashboard → http://localhost:${PORT}`);
94
126
  }
95
127
  function removePidFile() {
96
128
  try {
@@ -100,7 +132,7 @@ function removePidFile() {
100
132
  }
101
133
  async function stopDaemon() {
102
134
  try {
103
- const res = await fetch('http://localhost:7337/shutdown', {
135
+ const res = await fetch(`http://localhost:${PORT}/shutdown`, {
104
136
  method: 'POST',
105
137
  signal: AbortSignal.timeout(2000),
106
138
  });
@@ -177,18 +209,96 @@ program
177
209
  }
178
210
  process.exit(0);
179
211
  });
212
+ program
213
+ .command('update')
214
+ .description('Check for updates and install the latest version from npm')
215
+ .option('--dry-run', 'Only check for updates, do not install')
216
+ .action(async (opts) => {
217
+ console.log('\n🔍 Checking for updates...');
218
+ const latest = await checkLatestVersion();
219
+ if (!latest) {
220
+ console.log(' ❌ Could not reach npm registry. Check your internet connection.\n');
221
+ process.exit(1);
222
+ }
223
+ if (!semverGt(latest, PKG_VERSION)) {
224
+ console.log(` ✅ Already on latest version (${PKG_VERSION})\n`);
225
+ process.exit(0);
226
+ }
227
+ console.log(` ✦ Update available: ${PKG_VERSION} → ${latest}`);
228
+ try {
229
+ const changelogPath = path_1.default.join(__dirname, '..', 'CHANGELOG.md');
230
+ const changelog = fs_1.default.readFileSync(changelogPath, 'utf8');
231
+ const sections = changelog.split(/^## /m).filter(Boolean);
232
+ const relevant = sections.filter(s => s.startsWith(`[${latest}]`) ||
233
+ (semverGt(s.split(']')[0].replace('[', ''), PKG_VERSION) &&
234
+ semverGt(latest, s.split(']')[0].replace('[', ''))));
235
+ if (relevant.length > 0) {
236
+ console.log('\n 📋 What\'s new:');
237
+ relevant.slice(0, 3).forEach(s => {
238
+ const lines = s.split('\n').slice(0, 6);
239
+ lines.forEach(l => console.log(` ${l}`));
240
+ });
241
+ }
242
+ }
243
+ catch { }
244
+ if (opts.dryRun) {
245
+ console.log(`\n Run \x1b[36mclaudestat update\x1b[0m to install.\n`);
246
+ process.exit(0);
247
+ }
248
+ console.log(`\n📦 Installing @statforge/claudestat@${latest}...`);
249
+ try {
250
+ (0, child_process_1.execSync)('npm install -g @statforge/claudestat', { stdio: 'inherit' });
251
+ }
252
+ catch {
253
+ console.error('\n❌ Installation failed. Try manually: npm install -g @statforge/claudestat\n');
254
+ process.exit(1);
255
+ }
256
+ console.log('\n🔗 Re-registering hooks...');
257
+ try {
258
+ const { installHooks } = await Promise.resolve().then(() => __importStar(require('./install')));
259
+ installHooks();
260
+ }
261
+ catch { }
262
+ console.log('\n🔄 Restarting daemon...');
263
+ try {
264
+ await stopDaemon().catch(() => { });
265
+ await new Promise(r => setTimeout(r, 500));
266
+ spawnDaemon();
267
+ }
268
+ catch { }
269
+ console.log(`\n✅ Updated to ${latest}. Run \x1b[36mclaudestat doctor\x1b[0m to verify.\n`);
270
+ process.exit(0);
271
+ });
180
272
  program
181
273
  .command('start')
182
274
  .description('Start the background daemon (receives Claude Code hook events)')
183
275
  .option('--watchdog', 'Auto-restart daemon if it crashes')
184
- .action((opts) => {
276
+ .option('--wait', 'Wait until daemon responds on /health before returning (max 10s)')
277
+ .action(async (opts) => {
185
278
  if (process.env.CLAUDESTAT_DAEMON) {
186
- (0, daemon_1.startDaemon)();
187
- if (opts.watchdog)
188
- (0, watchdog_1.startWatchdog)();
279
+ const { startDaemon } = require('./daemon');
280
+ startDaemon();
281
+ if (opts.watchdog) {
282
+ const { startWatchdog } = require('./watchdog');
283
+ startWatchdog();
284
+ }
189
285
  }
190
286
  else {
191
287
  spawnDaemon();
288
+ if (opts.wait) {
289
+ const deadline = Date.now() + 10000;
290
+ while (Date.now() < deadline) {
291
+ await new Promise(r => setTimeout(r, 200));
292
+ try {
293
+ const res = await fetch(`http://localhost:${PORT}/health`, { signal: AbortSignal.timeout(500) });
294
+ if (res.ok) {
295
+ console.log('✅ Daemon is ready');
296
+ break;
297
+ }
298
+ }
299
+ catch { }
300
+ }
301
+ }
192
302
  process.exit(0);
193
303
  }
194
304
  });
@@ -203,7 +313,21 @@ program
203
313
  .command('setup')
204
314
  .description('One-command setup: install hooks + register daemon as system service (auto-starts on login)')
205
315
  .option('--uninstall', 'Remove hooks and system service')
316
+ .option('--port <number>', 'Custom daemon port (default: 7337)')
317
+ .option('--reset', 'Reinstall from scratch (keeps SQLite history)')
206
318
  .action(async (opts) => {
319
+ if (opts.port) {
320
+ const p = parseInt(opts.port, 10);
321
+ if (!isNaN(p) && p >= 1024 && p <= 65535) {
322
+ const cfg = (0, config_1.readConfig)();
323
+ (0, config_1.writeConfig)({ ...cfg, port: p });
324
+ console.log(`✓ Port set to ${p}`);
325
+ }
326
+ else {
327
+ console.error('❌ Invalid port. Must be between 1024 and 65535.');
328
+ process.exit(1);
329
+ }
330
+ }
207
331
  if (opts.uninstall) {
208
332
  console.log('Uninstalling claudestat...');
209
333
  (0, service_1.uninstallService)();
@@ -212,10 +336,23 @@ program
212
336
  console.log('✅ claudestat fully removed');
213
337
  process.exit(0);
214
338
  }
339
+ if (opts.reset) {
340
+ console.log('\n🔄 Resetting claudestat installation...');
341
+ (0, service_1.uninstallService)();
342
+ (0, install_1.uninstallHooks)();
343
+ await stopDaemon().catch(() => { });
344
+ const cfgPath = path_1.default.join((0, paths_1.getClaudestatDir)(), 'config.json');
345
+ try {
346
+ fs_1.default.unlinkSync(cfgPath);
347
+ }
348
+ catch { }
349
+ console.log(' ✅ Hooks, service, and config removed (history preserved)');
350
+ console.log(' 🔁 Starting fresh install...\n');
351
+ }
215
352
  // 1. Wizard: Node check + plan + config + hooks + MCP
216
353
  await (0, install_1.runWizard)();
217
354
  // 2. Start daemon now
218
- const daemonRunning = await fetch('http://localhost:7337/health', {
355
+ const daemonRunning = await fetch(`http://localhost:${PORT}/health`, {
219
356
  signal: AbortSignal.timeout(2000),
220
357
  }).then(r => r.ok).catch(() => false);
221
358
  if (!daemonRunning) {
@@ -223,7 +360,7 @@ program
223
360
  }
224
361
  else {
225
362
  console.log('✅ Daemon already running');
226
- console.log(' Dashboard → http://localhost:7337');
363
+ console.log(` Dashboard → http://localhost:${PORT}`);
227
364
  }
228
365
  console.log('\n Run \x1b[36mclaudestat watch\x1b[0m to see live activity');
229
366
  process.exit(0);
@@ -238,15 +375,16 @@ program
238
375
  .action(() => { (0, install_1.uninstallHooks)(); process.exit(0); });
239
376
  program
240
377
  .command('export [format]')
241
- .description('Export session data (json | csv, default: json). Max 500 sessions.')
378
+ .description('Export session data (json | csv | markdown, default: json). Max 500 sessions.')
242
379
  .option('--from <date>', 'Start date YYYY-MM-DD (inclusive)')
243
380
  .option('--to <date>', 'End date YYYY-MM-DD (inclusive)')
381
+ .option('--since <period>', 'Shorthand: 7d, 30d, 90d (overrides --from)')
244
382
  .option('--project <name>', 'Filter by project path (case-insensitive substring)')
245
- .option('--output <path>', 'Write to file instead of stdout')
383
+ .option('--output <path>', 'Write to file (default: stdout)')
246
384
  .action((format, opts) => {
247
385
  const fmt = (format ?? 'json').toLowerCase();
248
- if (fmt !== 'json' && fmt !== 'csv') {
249
- console.error('Error: format must be "json" or "csv"');
386
+ if (!['json', 'csv', 'markdown'].includes(fmt)) {
387
+ console.error('Error: format must be "json", "csv", or "markdown"');
250
388
  process.exit(1);
251
389
  }
252
390
  (0, export_1.runExport)({ format: fmt, ...opts });
@@ -260,8 +398,8 @@ program
260
398
  try {
261
399
  await (0, quota_tracker_1.refreshFromApi)(); // refresh disk cache on demand; daemon reads from disk
262
400
  const [quotaRes] = await Promise.all([
263
- fetch('http://localhost:7337/quota'),
264
- fetch('http://localhost:7337/health'),
401
+ fetch(`http://localhost:${PORT}/quota`),
402
+ fetch(`http://localhost:${PORT}/health`),
265
403
  ]);
266
404
  if (!quotaRes.ok)
267
405
  throw new Error('Daemon unavailable');
@@ -336,6 +474,13 @@ program
336
474
  .option('--plan <plan>', 'Force plan detection: pro|max5|max20|auto')
337
475
  .option('--alerts <bool>', 'Enable/disable daemon rate limit alerts: true|false')
338
476
  .option('--session-limit <usd>', 'Alert when a session exceeds this cost in USD (0 = disabled)')
477
+ .option('--kill-switch-force <bool>', 'Hard-block on kill switch instead of warning: true|false')
478
+ .option('--log-level <level>', 'Set log level: debug|info|warn|error (default: info)')
479
+ .option('--loop-threshold <number>', 'Tool calls in window to trigger loop detection (default: 8)')
480
+ .option('--loop-window <seconds>', 'Detection window in seconds (default: 120)')
481
+ .option('--alias <path=name>', 'Set a project alias: --alias "/path/to/repo=MyApp"')
482
+ .option('--remove-alias <path>', 'Remove a project alias')
483
+ .option('--webhook <url>', 'Set webhook URL for external alerts (Slack, Discord). Use "off" to disable.')
339
484
  .action((opts) => {
340
485
  const cfg = (0, config_1.readConfig)();
341
486
  let changed = false;
@@ -374,6 +519,63 @@ program
374
519
  else
375
520
  console.warn(' ⚠️ session-limit must be a number >= 0 (e.g. 5 for $5)');
376
521
  }
522
+ if (opts.killSwitchForce !== undefined) {
523
+ cfg.killSwitchForce = opts.killSwitchForce === 'true';
524
+ changed = true;
525
+ }
526
+ if (opts.logLevel !== undefined) {
527
+ if (['debug', 'info', 'warn', 'error'].includes(opts.logLevel)) {
528
+ cfg.logLevel = opts.logLevel;
529
+ changed = true;
530
+ }
531
+ else {
532
+ console.warn(' ⚠️ log-level must be: debug | info | warn | error');
533
+ }
534
+ if (opts.loopThreshold !== undefined) {
535
+ const v = parseInt(opts.loopThreshold, 10);
536
+ if (!isNaN(v) && v >= 2 && v <= 50) {
537
+ cfg.loopThreshold = v;
538
+ changed = true;
539
+ }
540
+ else
541
+ console.warn(' ⚠️ loopThreshold must be between 2 and 50');
542
+ }
543
+ if (opts.loopWindow !== undefined) {
544
+ const v = parseInt(opts.loopWindow, 10);
545
+ if (!isNaN(v) && v >= 10 && v <= 600) {
546
+ cfg.loopWindowSecs = v;
547
+ changed = true;
548
+ }
549
+ else
550
+ console.warn(' ⚠️ loopWindow must be between 10 and 600 seconds');
551
+ }
552
+ if (opts.alias !== undefined) {
553
+ const eqIdx = opts.alias.indexOf('=');
554
+ if (eqIdx < 1) {
555
+ console.warn(' ⚠️ format: --alias "/absolute/path=Alias Name"');
556
+ }
557
+ else {
558
+ const projectPath = opts.alias.slice(0, eqIdx).trim();
559
+ const aliasName = opts.alias.slice(eqIdx + 1).trim();
560
+ if (!projectPath.startsWith('/') && !projectPath.match(/^[A-Z]:\\/)) {
561
+ console.warn(' ⚠️ project path must be absolute');
562
+ }
563
+ else {
564
+ cfg.projectAliases = { ...cfg.projectAliases, [projectPath]: aliasName };
565
+ changed = true;
566
+ }
567
+ }
568
+ }
569
+ if (opts.removeAlias !== undefined) {
570
+ const { [opts.removeAlias]: _, ...rest } = cfg.projectAliases;
571
+ cfg.projectAliases = rest;
572
+ changed = true;
573
+ }
574
+ if (opts.webhook !== undefined) {
575
+ cfg.webhookUrl = opts.webhook === 'off' ? null : opts.webhook;
576
+ changed = true;
577
+ }
578
+ }
377
579
  if (changed) {
378
580
  (0, config_1.writeConfig)(cfg);
379
581
  console.log('✅ Config saved to ~/.claudestat/config.json');
@@ -397,6 +599,7 @@ program
397
599
  lines.push('━'.repeat(42));
398
600
  lines.push('');
399
601
  lines.push(` Plan ${planColor}${planLabel.toUpperCase()}${R}`);
602
+ lines.push(` Port ${C}${cfg.port}${R}`);
400
603
  lines.push(` Alerts ${alertsIcon}`);
401
604
  lines.push('');
402
605
  lines.push(` Kill switch ${cfg.killSwitchEnabled ? `${Y}ON${R} at ${cfg.killSwitchThreshold}%` : `${D}OFF${R}`}`);
@@ -404,6 +607,17 @@ program
404
607
  lines.push(` ${bar(cfg.killSwitchThreshold)}`);
405
608
  }
406
609
  lines.push(` Session limit ${cfg.sessionCostLimitUsd > 0 ? `${Y}$${cfg.sessionCostLimitUsd.toFixed(2)}${R}` : `${D}OFF${R}`}`);
610
+ lines.push(` Kill switch mode ${cfg.killSwitchForce ? `${R}force-block${R}` : `${G}warn-only${R} ${D}(use --kill-switch-force true to hard-block)${R}`}`);
611
+ const logColors = { debug: D, info: '', warn: Y, error: '\x1b[31m' };
612
+ lines.push(` Log level ${logColors[cfg.logLevel] ?? ''}${cfg.logLevel.toUpperCase()}${R}`);
613
+ lines.push(` Loop detection ${C}${cfg.loopThreshold} calls${R} in ${C}${cfg.loopWindowSecs}s${R}`);
614
+ lines.push(` Webhook ${cfg.webhookUrl ? `${C}${cfg.webhookUrl}${R}` : `${D}off${R}`}`);
615
+ if (Object.keys(cfg.projectAliases).length > 0) {
616
+ lines.push(` Project aliases`);
617
+ for (const [p, a] of Object.entries(cfg.projectAliases)) {
618
+ lines.push(` ${D}${p}${R} → ${C}${a}${R}`);
619
+ }
620
+ }
407
621
  lines.push('');
408
622
  lines.push(` Cycle thresholds ${cfg.warnThresholds.join('%, ')}%`);
409
623
  lines.push(` ${D}yellow${R} ${bar(cfg.warnThresholds[0], 8)} ${D}orange${R} ${bar(cfg.warnThresholds[1], 8)} ${D}red${R} ${bar(cfg.warnThresholds[2], 8)}`);
@@ -420,6 +634,20 @@ program
420
634
  await stopDaemon().catch((e) => { console.error(`❌ ${e.message}`); process.exit(1); });
421
635
  process.exit(0);
422
636
  });
637
+ program
638
+ .command('resume')
639
+ .description('Remove the pause signal — allows Claude Code to continue after a quota warning')
640
+ .action(() => {
641
+ const signalFile = (0, paths_1.getPauseSignalFile)();
642
+ try {
643
+ fs_1.default.unlinkSync(signalFile);
644
+ console.log('✅ Pause signal removed — claudestat will no longer warn on tool calls');
645
+ }
646
+ catch {
647
+ console.log(' No pause signal active.');
648
+ }
649
+ process.exit(0);
650
+ });
423
651
  program
424
652
  .command('restart')
425
653
  .description('Restart the claudestat daemon')
@@ -436,16 +664,21 @@ program
436
664
  .option('--by <metric>', 'Sort by: cost, count, duration (default: cost)')
437
665
  .option('--limit <number>', 'Number of tools to show (default: 10)')
438
666
  .option('--days <number>', 'Look back N days (default: 30)')
667
+ .option('--json', 'Output as JSON')
439
668
  .action(async (opts) => {
440
669
  try {
441
670
  const by = opts.by ?? 'cost';
442
671
  const limit = opts.limit ?? 10;
443
672
  const days = opts.days ?? 30;
444
- const url = `http://localhost:7337/api/top?by=${by}&limit=${limit}&days=${days}`;
673
+ const url = `http://localhost:${PORT}/api/top?by=${by}&limit=${limit}&days=${days}`;
445
674
  const res = await fetch(url);
446
675
  if (!res.ok)
447
676
  throw new Error('Daemon unavailable');
448
677
  const data = await res.json();
678
+ if (opts.json) {
679
+ console.log(JSON.stringify(data, null, 2));
680
+ process.exit(0);
681
+ }
449
682
  const R = '\x1b[0m';
450
683
  const B = '\x1b[1m';
451
684
  const D = '\x1b[2m';
@@ -476,13 +709,16 @@ program
476
709
  const dur = isOther ? '—' : fmtDur(t.totalDurationMs);
477
710
  const cost = isOther ? fmtCost(t.estimatedCostUsd) : fmtCost(t.estimatedCostUsd);
478
711
  const countStr = isOther ? '—' : String(t.count);
712
+ const avgPerCall = t.count > 0 && !isOther
713
+ ? `$${(t.estimatedCostUsd / t.count).toFixed(4)}`
714
+ : '—';
479
715
  const toolName = (t.tool.length > 18 ? t.tool.slice(0, 16) + '…' : t.tool).padEnd(18);
480
716
  if (isOther) {
481
717
  lines.push(` ${D}Other${R} ${'—'.padStart(20)} ${cost.padStart(10)} ${fmtPct(pct)}`);
482
718
  }
483
719
  else {
484
720
  lines.push(` ${B}${(i + 1).toString().padStart(2)}${R} ${toolName} ${bar(val, maxVal)} ${cost.padStart(10)} ${fmtPct(pct)}`);
485
- lines.push(` ${D}${countStr} calls · ${dur}${R}`);
721
+ lines.push(` ${D}${countStr} calls · ${dur} · avg/call ${avgPerCall}${R}`);
486
722
  }
487
723
  }
488
724
  lines.push('');
@@ -496,6 +732,63 @@ program
496
732
  process.exit(1);
497
733
  }
498
734
  });
735
+ program
736
+ .command('loops')
737
+ .description('List sessions with detected loops')
738
+ .option('--days <number>', 'Look back N days (default: 30)', '30')
739
+ .option('--limit <number>', 'Max sessions to show (default: 10)', '10')
740
+ .option('--json', 'Output raw JSON')
741
+ .action(async (opts) => {
742
+ try {
743
+ const days = Math.min(parseInt(opts.days, 10) || 30, 365);
744
+ const limit = Math.min(parseInt(opts.limit, 10) || 10, 50);
745
+ const since = Date.now() - days * 86400000;
746
+ const res = await fetch(`http://localhost:${PORT}/sessions`);
747
+ if (!res.ok)
748
+ throw new Error('Daemon unavailable');
749
+ const sessions = await res.json();
750
+ const withLoops = sessions
751
+ .filter(s => (s.loops_detected ?? 0) > 0 && s.started_at >= since)
752
+ .slice(0, limit);
753
+ if (opts.json) {
754
+ console.log(JSON.stringify(withLoops, null, 2));
755
+ process.exit(0);
756
+ }
757
+ const R = '\x1b[0m', B = '\x1b[1m', D = '\x1b[2m', Y = '\x1b[33m';
758
+ if (withLoops.length === 0) {
759
+ console.log(`\n No loops detected in the last ${days} days.\n`);
760
+ process.exit(0);
761
+ }
762
+ console.log(`\n${B}🔁 claudestat loops${R} ${D}last ${days} days${R}`);
763
+ console.log('━'.repeat(52));
764
+ for (const s of withLoops) {
765
+ const date = new Date(s.started_at).toLocaleDateString('en-US', { month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit' });
766
+ const project = s.project_path ? path_1.default.basename(s.project_path) : 'unknown';
767
+ const cost = s.total_cost_usd ? `$${s.total_cost_usd.toFixed(3)}` : '$0.000';
768
+ console.log(`\n ${Y}${s.loops_detected} loop(s)${R} ${B}${project}${R} ${D}${date} ${cost}${R}`);
769
+ try {
770
+ const iRes = await fetch(`http://localhost:${PORT}/intelligence/${s.id}`);
771
+ const intel = await iRes.json();
772
+ for (const loop of (intel.loops ?? []).slice(0, 3)) {
773
+ console.log(` ${D}↳ ${loop.toolName} ×${loop.count} in ${loop.windowMs / 1000}s${R}`);
774
+ if (loop.context?.repeatedFiles?.length > 0) {
775
+ loop.context.repeatedFiles.slice(0, 3).forEach((f) => console.log(` ${D}file: ${path_1.default.basename(f)}${R}`));
776
+ }
777
+ if (loop.context?.repeatedCommands?.length > 0) {
778
+ loop.context.repeatedCommands.slice(0, 2).forEach((c) => console.log(` ${D}cmd: ${c.slice(0, 60)}${R}`));
779
+ }
780
+ }
781
+ }
782
+ catch { }
783
+ }
784
+ console.log('\n' + '━'.repeat(52) + '\n');
785
+ process.exit(0);
786
+ }
787
+ catch {
788
+ console.error('\n❌ Daemon is not running. Start it with: claudestat start\n');
789
+ process.exit(1);
790
+ }
791
+ });
499
792
  program
500
793
  .command('doctor')
501
794
  .description('Check installation health and diagnose common issues')
@@ -503,6 +796,70 @@ program
503
796
  console.error('\n❌ Error:', err.message);
504
797
  process.exit(1);
505
798
  }));
799
+ program
800
+ .command('logs')
801
+ .description('Show daemon log (~/.claudestat/daemon.log)')
802
+ .option('-n <number>', 'Number of lines to show (default: 50)', '50')
803
+ .option('--follow', 'Tail the log in real time')
804
+ .option('--level <level>', 'Filter by minimum level: debug|info|warn|error')
805
+ .action((opts) => {
806
+ const logFile = (0, paths_1.getDaemonLogFile)();
807
+ if (!fs_1.default.existsSync(logFile)) {
808
+ console.log('\n No daemon log found. Start the daemon first: claudestat start\n');
809
+ process.exit(0);
810
+ }
811
+ const minRank = { debug: 0, info: 1, warn: 2, error: 3 };
812
+ const levelFilter = opts.level ? (minRank[opts.level] ?? 0) : 0;
813
+ function filterLine(line) {
814
+ if (!opts.level)
815
+ return true;
816
+ const match = line.match(/\[(DEBUG|INFO|WARN|ERROR)\]/);
817
+ if (!match)
818
+ return true;
819
+ return (minRank[match[1].toLowerCase()] ?? 0) >= levelFilter;
820
+ }
821
+ const levelColor = {
822
+ DEBUG: '\x1b[2m', INFO: '\x1b[36m', WARN: '\x1b[33m', ERROR: '\x1b[31m'
823
+ };
824
+ function colorize(line) {
825
+ return line.replace(/\[(DEBUG|INFO|WARN|ERROR)\]/, (_, l) => `${levelColor[l] ?? ''}[${l}]\x1b[0m`);
826
+ }
827
+ if (opts.follow) {
828
+ const content = fs_1.default.readFileSync(logFile, 'utf8');
829
+ content.split('\n').filter(Boolean).filter(filterLine).slice(-20)
830
+ .forEach(l => console.log(colorize(l)));
831
+ let size = fs_1.default.statSync(logFile).size;
832
+ setInterval(() => {
833
+ try {
834
+ const newSize = fs_1.default.statSync(logFile).size;
835
+ if (newSize <= size)
836
+ return;
837
+ const buf = Buffer.alloc(newSize - size);
838
+ const fd = fs_1.default.openSync(logFile, 'r');
839
+ fs_1.default.readSync(fd, buf, 0, buf.length, size);
840
+ fs_1.default.closeSync(fd);
841
+ size = newSize;
842
+ buf.toString('utf8').split('\n').filter(Boolean).filter(filterLine)
843
+ .forEach(l => console.log(colorize(l)));
844
+ }
845
+ catch { }
846
+ }, 300);
847
+ }
848
+ else {
849
+ const n = Math.min(parseInt(opts.n, 10) || 50, 500);
850
+ const content = fs_1.default.readFileSync(logFile, 'utf8');
851
+ const lines = content.split('\n').filter(Boolean).filter(filterLine).slice(-n);
852
+ if (lines.length === 0) {
853
+ console.log(`\n No log entries${opts.level ? ` at level ≥ ${opts.level}` : ''}.\n`);
854
+ }
855
+ else {
856
+ console.log('');
857
+ lines.forEach(l => console.log(colorize(l)));
858
+ console.log('');
859
+ }
860
+ process.exit(0);
861
+ }
862
+ });
506
863
  program
507
864
  .command('roast')
508
865
  .description('Roast your Claude Code usage habits')
@@ -530,11 +887,29 @@ program
530
887
  console.log('\n📊 No usage data yet — start using Claude Code and claudestat will track it.\n');
531
888
  process.exit(0);
532
889
  }
890
+ const prev = (0, insights_1.getPrevWeekInsightData)();
891
+ const deltaCost = prev.total_cost > 0
892
+ ? Math.round((data.total_cost - prev.total_cost) / prev.total_cost * 100)
893
+ : null;
894
+ const deltaSessions = prev.total_sessions > 0
895
+ ? Math.round((data.total_sessions - prev.total_sessions) / prev.total_sessions * 100)
896
+ : null;
533
897
  if (opts.json) {
534
- console.log(JSON.stringify(data, null, 2));
898
+ console.log(JSON.stringify({ current: data, prev, deltaCostPct: deltaCost, deltaSessionsPct: deltaSessions }, null, 2));
535
899
  process.exit(0);
536
900
  }
537
901
  console.log((0, insights_1.renderWeeklyInsight)(data));
902
+ if (deltaCost !== null || deltaSessions !== null) {
903
+ const R = '\x1b[0m', D = '\x1b[2m', G = '\x1b[32m', Y = '\x1b[33m';
904
+ const sign = (n) => n >= 0 ? `+${n}` : `${n}`;
905
+ const color = (n) => n > 0 ? Y : G;
906
+ console.log(` ${D}vs last week:${R}`);
907
+ if (deltaCost !== null)
908
+ console.log(` cost ${color(deltaCost)}${sign(deltaCost)}%${R}`);
909
+ if (deltaSessions !== null)
910
+ console.log(` sessions ${sign(deltaSessions)}%`);
911
+ console.log('');
912
+ }
538
913
  process.exit(0);
539
914
  }
540
915
  catch (err) {
@@ -43,3 +43,4 @@ export declare function renderInsights(d: UsageInsightsData): string;
43
43
  export declare function shouldShowInsight(): boolean;
44
44
  export declare function markInsightShown(): void;
45
45
  export declare function renderWeeklyInsight(d: WeeklyInsightData): string;
46
+ export declare function getPrevWeekInsightData(): WeeklyInsightData;
package/dist/insights.js CHANGED
@@ -10,6 +10,7 @@ exports.renderInsights = renderInsights;
10
10
  exports.shouldShowInsight = shouldShowInsight;
11
11
  exports.markInsightShown = markInsightShown;
12
12
  exports.renderWeeklyInsight = renderWeeklyInsight;
13
+ exports.getPrevWeekInsightData = getPrevWeekInsightData;
13
14
  const path_1 = __importDefault(require("path"));
14
15
  const db_1 = require("./db");
15
16
  const WEEK_MS = 7 * 86400000;
@@ -253,3 +254,28 @@ function renderWeeklyInsight(d) {
253
254
  lines.push('');
254
255
  return lines.join('\n');
255
256
  }
257
+ function getPrevWeekInsightData() {
258
+ const agg = db_1.dbOps.getPrevWeekInsight();
259
+ const topTools = db_1.dbOps.getTopTools(14, 'cost', 1);
260
+ const topTool = topTools[0];
261
+ const totalInputWithCache = agg.input_tokens + agg.cache_read;
262
+ const cacheHitPct = totalInputWithCache > 0
263
+ ? Math.min(100, Math.round(agg.cache_read / totalInputWithCache * 100))
264
+ : 0;
265
+ return {
266
+ total_sessions: agg.total_sessions,
267
+ total_cost: agg.total_cost,
268
+ input_tokens: agg.input_tokens,
269
+ output_tokens: agg.output_tokens,
270
+ cache_read: agg.cache_read,
271
+ cache_hit_pct: cacheHitPct,
272
+ total_loops: agg.total_loops,
273
+ avg_efficiency: Math.round(agg.avg_efficiency),
274
+ top_tool: topTool?.tool_name ?? 'Unknown',
275
+ top_tool_cost_pct: agg.total_cost > 0
276
+ ? Math.round((topTool?.total_cost_usd ?? 0) / agg.total_cost * 100)
277
+ : 0,
278
+ week_start: agg.week_start,
279
+ week_end: agg.week_end ?? agg.week_start,
280
+ };
281
+ }