@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/README.md CHANGED
@@ -282,7 +282,7 @@ Claude Code event
282
282
  ## Troubleshooting
283
283
 
284
284
  **`claudestat start` hangs for ~5 seconds**
285
- Normal — `require('express')` takes a few seconds on first load.
285
+ Normal — `require('express')` takes a few seconds on first load. The daemon is running; wait for the "Daemon started" confirmation.
286
286
 
287
287
  **Hooks are not firing / dashboard shows no events**
288
288
  Run `claudestat doctor` — it checks every component and prints the exact fix command.
@@ -302,7 +302,38 @@ The daemon polls quota every 60s and logs warnings at 70%, 85%, and 95%. Check a
302
302
  **Working with multiple projects**
303
303
  claudestat tracks every project automatically. The Projects tab groups sessions by working directory.
304
304
 
305
- ---
305
+ **Dashboard shows 0 cost / $0.00 for all sessions**
306
+ Token data comes from Claude Code's JSONL files, not from hook events. Make sure Claude Code is writing JSONL logs — check `~/.claude/projects/` for `.jsonl` files. If the directory is empty, Claude Code may not have logging enabled.
307
+
308
+ **Daemon stops after terminal closes**
309
+ The daemon must be started with `nohup` to persist beyond the shell session:
310
+ ```bash
311
+ nohup claudestat start &
312
+ ```
313
+ Or use `claudestat setup` which installs a system service (launchd on macOS, systemd on Linux).
314
+
315
+ **`claudestat export` produces empty output**
316
+ If no sessions appear, the daemon may not have been running during your Claude Code sessions. Check `claudestat status` and restart with `claudestat start`. For historical data only (without a running daemon), export still reads from the local SQLite database — so past sessions captured while the daemon was running are always available.
317
+
318
+ **Loop detector fires too often / not enough**
319
+ Adjust the threshold and window:
320
+ ```bash
321
+ claudestat config --loop-threshold 5 # default: 8 calls
322
+ claudestat config --loop-window 90 # default: 120 seconds
323
+ ```
324
+
325
+ **MCP server not responding**
326
+ Restart the daemon (`claudestat restart`) and verify it's registered:
327
+ ```bash
328
+ claude mcp list
329
+ ```
330
+ If not listed, re-run: `claude mcp add claudestat -s user -- claudestat-mcp`
331
+
332
+ **OpenCode sessions not appearing**
333
+ claudestat reads OpenCode data from `~/.local/share/opencode/opencode.db`. If the file does not exist, OpenCode has not run yet or uses a different data path on your system. Run `opencode` at least once to initialize it.
334
+
335
+ **Node.js experimental SQLite warning on startup**
336
+ Expected — `node:sqlite` is experimental in Node 22. The warning is suppressed automatically. If you see it repeatedly, ensure you are running Node.js 22 or later (`node --version`).
306
337
 
307
338
  ## FAQ
308
339
 
package/dist/config.d.ts CHANGED
@@ -32,6 +32,13 @@ export interface ClaudestatConfig {
32
32
  reportDay: number;
33
33
  reportTime: string;
34
34
  alertsEnabled: boolean;
35
+ killSwitchForce: boolean;
36
+ logLevel: 'debug' | 'info' | 'warn' | 'error';
37
+ port: number;
38
+ loopThreshold: number;
39
+ loopWindowSecs: number;
40
+ projectAliases: Record<string, string>;
41
+ webhookUrl: string | null;
35
42
  }
36
43
  /** Lee la config del disco. Valores ausentes se rellenan con defaults. */
37
44
  export declare function readConfig(): ClaudestatConfig;
package/dist/config.js CHANGED
@@ -43,6 +43,13 @@ const DEFAULTS = {
43
43
  reportDay: 1,
44
44
  reportTime: '09:00',
45
45
  alertsEnabled: true,
46
+ killSwitchForce: false,
47
+ logLevel: 'info',
48
+ port: 7337,
49
+ loopThreshold: 8,
50
+ loopWindowSecs: 120,
51
+ projectAliases: {},
52
+ webhookUrl: null,
46
53
  };
47
54
  /** Lee la config del disco. Valores ausentes se rellenan con defaults. */
48
55
  function readConfig() {
@@ -100,6 +107,30 @@ function validateConfig(raw) {
100
107
  }
101
108
  if ('alertsEnabled' in cfg && typeof cfg.alertsEnabled !== 'boolean')
102
109
  return 'alertsEnabled debe ser boolean';
110
+ if ('killSwitchForce' in cfg && typeof cfg.killSwitchForce !== 'boolean')
111
+ return 'killSwitchForce must be boolean';
112
+ if ('logLevel' in cfg && !['debug', 'info', 'warn', 'error'].includes(cfg.logLevel))
113
+ return 'logLevel must be: debug, info, warn, error';
114
+ if ('loopThreshold' in cfg) {
115
+ const v = cfg.loopThreshold;
116
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 2 || v > 50)
117
+ return 'loopThreshold must be an integer between 2 and 50';
118
+ }
119
+ if ('loopWindowSecs' in cfg) {
120
+ const v = cfg.loopWindowSecs;
121
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 10 || v > 600)
122
+ return 'loopWindowSecs must be an integer between 10 and 600';
123
+ }
124
+ if ('projectAliases' in cfg) {
125
+ const v = cfg.projectAliases;
126
+ if (typeof v !== 'object' || v === null || Array.isArray(v))
127
+ return 'projectAliases must be an object { "/path": "Alias" }';
128
+ }
129
+ if ('webhookUrl' in cfg) {
130
+ const v = cfg.webhookUrl;
131
+ if (v !== null && (typeof v !== 'string' || (!v.startsWith('http://') && !v.startsWith('https://'))))
132
+ return 'webhookUrl must be null or a valid http/https URL';
133
+ }
103
134
  if ('reportsEnabled' in cfg && typeof cfg.reportsEnabled !== 'boolean')
104
135
  return 'reportsEnabled debe ser boolean';
105
136
  if ('reportFrequency' in cfg && !['weekly', 'biweekly', 'monthly'].includes(cfg.reportFrequency))
@@ -113,6 +144,11 @@ function validateConfig(raw) {
113
144
  if (typeof cfg.reportTime !== 'string' || !/^\d{2}:\d{2}$/.test(cfg.reportTime))
114
145
  return 'reportTime debe tener formato HH:MM';
115
146
  }
147
+ if ('port' in cfg) {
148
+ const v = cfg.port;
149
+ if (typeof v !== 'number' || !Number.isInteger(v) || v < 1024 || v > 65535)
150
+ return 'port must be an integer between 1024 and 65535';
151
+ }
116
152
  return null;
117
153
  }
118
154
  /** Devuelve el nivel de warning para un % dado, o null si no alcanza ningún threshold. */
package/dist/daemon.js CHANGED
@@ -75,7 +75,8 @@ const projects_cache_1 = require("./cache/projects-cache");
75
75
  const rate_limiter_1 = require("./middleware/rate-limiter");
76
76
  const summarizer_1 = require("./summarizer");
77
77
  const paths_1 = require("./paths");
78
- const PORT = 7337;
78
+ const logger_1 = require("./logger");
79
+ const PORT = (0, config_1.readConfig)().port;
79
80
  const app = (0, express_1.default)();
80
81
  app.use(express_1.default.json());
81
82
  // ─── Shutdown graceful (cross-platform, no depende de SIGTERM) ────────────────
@@ -131,7 +132,7 @@ function migrateSessionProjects() {
131
132
  }
132
133
  }
133
134
  if (tagged > 0)
134
- console.log(`[daemon] ${tagged} sessions tagged with project`);
135
+ logger_1.logger.info(`${tagged} sessions tagged with project`);
135
136
  }
136
137
  /**
137
138
  * Genera summaries IA para las últimas N sesiones que no tienen uno.
@@ -148,11 +149,11 @@ async function migrateSessionSummaries(limit = 5) {
148
149
  const summary = await (0, summarizer_1.summarizeSession)(events, s.total_cost_usd ?? 0, projectName);
149
150
  if (summary) {
150
151
  db_1.dbOps.updateSessionSummary(s.id, summary);
151
- console.log(`[daemon] Summary generated for session ${s.id.slice(0, 8)}: "${summary}"`);
152
+ logger_1.logger.info(`Summary generated for session ${s.id.slice(0, 8)}: "${summary}"`);
152
153
  }
153
154
  }
154
155
  catch (err) {
155
- console.error('[daemon] Error generating summary:', err);
156
+ logger_1.logger.error(`Error generating summary: ${err}`);
156
157
  }
157
158
  }
158
159
  }
@@ -206,6 +207,7 @@ function checkAlertLevel(level, lastLevel, logMsg, notifTitle, notifBody) {
206
207
  const prevRank = lastLevel ? LEVEL_RANK[lastLevel] ?? 0 : 0;
207
208
  const currRank = LEVEL_RANK[level];
208
209
  if (currRank > prevRank) {
210
+ logger_1.logger.warn(logMsg);
209
211
  process.stderr.write(`${LEVEL_COLOR[level]}${logMsg}\x1b[0m\n`);
210
212
  (0, notifier_1.sendDesktopNotification)(notifTitle, notifBody);
211
213
  }
@@ -220,9 +222,37 @@ function startAlertPolling() {
220
222
  const data = (0, quota_tracker_1.computeQuota)(cfg.plan ?? undefined);
221
223
  const resetMins = Math.ceil(data.cycleResetMs / 60000);
222
224
  // ── Cycle 5h alerts ──────────────────────────────────────────────────────
223
- _lastCycleAlertLevel = checkAlertLevel((0, config_1.getWarnLevel)(data.cyclePct, cfg.warnThresholds), _lastCycleAlertLevel, `[claudestat] ⚠️ 5h cycle at ${data.cyclePct}% (${data.cyclePrompts}/${data.cycleLimit} prompts)`, 'claudestat — 5h cycle alert', `${data.cyclePct}% of cycle used · resets in ${resetMins}m`);
225
+ const cycleLevel = (0, config_1.getWarnLevel)(data.cyclePct, cfg.warnThresholds);
226
+ if (cycleLevel && cycleLevel !== _lastCycleAlertLevel) {
227
+ const webhookUrl = (0, config_1.readConfig)().webhookUrl;
228
+ if (webhookUrl) {
229
+ (0, notifier_1.sendWebhookAlert)(webhookUrl, {
230
+ title: 'claudestat — 5h cycle alert',
231
+ body: `${data.cyclePct}% of cycle used · resets in ${resetMins}m`,
232
+ level: cycleLevel,
233
+ cyclePct: data.cyclePct,
234
+ resetInMins: resetMins,
235
+ burnRate: data.burnRateTokensPerMin,
236
+ });
237
+ }
238
+ }
239
+ _lastCycleAlertLevel = checkAlertLevel(cycleLevel, _lastCycleAlertLevel, `[claudestat] ⚠️ 5h cycle at ${data.cyclePct}% (${data.cyclePrompts}/${data.cycleLimit} prompts)`, 'claudestat — 5h cycle alert', `${data.cyclePct}% of cycle used · resets in ${resetMins}m`);
224
240
  // ── Weekly alerts ────────────────────────────────────────────────────────
225
- _lastWeeklyAlertLevel = checkAlertLevel((0, config_1.getWarnLevel)(data.weeklyPctAll, cfg.weeklyWarnThresholds), _lastWeeklyAlertLevel, `[claudestat] ⚠️ Weekly usage at ${data.weeklyPctAll}%`, 'claudestat — Weekly usage alert', `${data.weeklyPctAll}% of weekly quota used`);
241
+ const weeklyLevel = (0, config_1.getWarnLevel)(data.weeklyPctAll, cfg.weeklyWarnThresholds);
242
+ if (weeklyLevel && weeklyLevel !== _lastWeeklyAlertLevel) {
243
+ const webhookUrl = (0, config_1.readConfig)().webhookUrl;
244
+ if (webhookUrl) {
245
+ (0, notifier_1.sendWebhookAlert)(webhookUrl, {
246
+ title: 'claudestat — Weekly usage alert',
247
+ body: `${data.weeklyPctAll}% of weekly quota used`,
248
+ level: weeklyLevel,
249
+ cyclePct: data.cyclePct,
250
+ weeklyPct: data.weeklyPctAll,
251
+ resetInMins: resetMins,
252
+ });
253
+ }
254
+ }
255
+ _lastWeeklyAlertLevel = checkAlertLevel(weeklyLevel, _lastWeeklyAlertLevel, `[claudestat] ⚠️ Weekly usage at ${data.weeklyPctAll}%`, 'claudestat — Weekly usage alert', `${data.weeklyPctAll}% of weekly quota used`);
226
256
  // ── Reset reminder ───────────────────────────────────────────────────────
227
257
  const reminderMs = (cfg.resetReminderMins ?? 10) * 60000;
228
258
  if (reminderMs > 0) {
@@ -231,11 +261,28 @@ function startAlertPolling() {
231
261
  }
232
262
  else if (data.cycleResetMs <= reminderMs && data.cycleResetMs > 0 && !_resetReminderFired) {
233
263
  const mins = Math.ceil(data.cycleResetMs / 60000);
264
+ logger_1.logger.info(`Quota resets in ${mins}m — good time to wrap up`);
234
265
  process.stderr.write(`\x1b[36m[claudestat] ⏰ Quota resets in ${mins}m — good time to wrap up\x1b[0m\n`);
235
266
  (0, notifier_1.sendDesktopNotification)('claudestat — Quota reset soon', `Your 5h cycle resets in ${mins} min — good time to start a new task`);
236
267
  _resetReminderFired = true;
237
268
  }
238
269
  }
270
+ // Write or remove pause signal file based on kill switch state
271
+ const signalFile = (0, paths_1.getPauseSignalFile)();
272
+ if (cfg.killSwitchEnabled && data.cyclePct >= cfg.killSwitchThreshold) {
273
+ const msg = `Quota at ${data.cyclePct}% — threshold is ${cfg.killSwitchThreshold}%. Resets in ${Math.ceil(data.cycleResetMs / 60000)}m.`;
274
+ try {
275
+ fs_1.default.writeFileSync(signalFile, msg);
276
+ }
277
+ catch { }
278
+ (0, stream_1.broadcast)({ type: 'kill_switch', payload: { blocked: true, reason: msg } });
279
+ }
280
+ else {
281
+ try {
282
+ fs_1.default.unlinkSync(signalFile);
283
+ }
284
+ catch { }
285
+ }
239
286
  }
240
287
  catch {
241
288
  // quota read failed — ignore
@@ -262,6 +309,7 @@ function cleanPid() {
262
309
  function startDaemon() {
263
310
  _server = app.listen(PORT, '127.0.0.1', () => {
264
311
  writePid();
312
+ (0, paths_1.writePortFile)(PORT);
265
313
  process.on('exit', cleanPid);
266
314
  process.on('SIGTERM', () => { if (_server)
267
315
  shutdown(_server); process.exit(0); });
@@ -289,7 +337,7 @@ function startDaemon() {
289
337
  // Se ejecuta en background para no retrasar el inicio del servidor
290
338
  setImmediate(() => {
291
339
  const projects = (0, projects_cache_1.getProjectsCached)();
292
- console.log(`[daemon] ${projects?.length ?? 0} projects scanned`);
340
+ logger_1.logger.info(`${projects?.length ?? 0} projects scanned`);
293
341
  });
294
342
  // Refresh automático del cache de proyectos cada 2 minutos
295
343
  // Recoge cambios en HANDOFF.md aunque el daemon lleve horas corriendo
@@ -311,7 +359,7 @@ function startDaemon() {
311
359
  return; // ya existe
312
360
  const markdown = (0, reports_1.generateReport)(dateLabel, cfg);
313
361
  db_1.dbOps.insertWeeklyReport(dateLabel, markdown);
314
- console.log(`[daemon] Report auto-generated: ${dateLabel}`);
362
+ logger_1.logger.info(`Report auto-generated: ${dateLabel}`);
315
363
  }, 60000);
316
364
  // Al arrancar: liberar intents activos de sesiones anteriores (ya no hay nadie que los use)
317
365
  db_1.dbOps.releaseOrphanedIntents();
package/dist/db.d.ts CHANGED
@@ -176,6 +176,17 @@ export declare const dbOps: {
176
176
  week_start: number;
177
177
  week_end: number;
178
178
  };
179
+ getPrevWeekInsight(): {
180
+ total_sessions: any;
181
+ total_cost: any;
182
+ input_tokens: any;
183
+ output_tokens: any;
184
+ cache_read: any;
185
+ total_loops: any;
186
+ avg_efficiency: any;
187
+ week_start: any;
188
+ week_end: any;
189
+ };
179
190
  setMeta(key: string, value: string): void;
180
191
  getMeta(key: string): string | undefined;
181
192
  getCostProjection(days?: number): {
package/dist/db.js CHANGED
@@ -719,6 +719,36 @@ exports.dbOps = {
719
719
  const since = Date.now() - days * 86400000;
720
720
  return stmts.getWeeklyInsight.get(since);
721
721
  },
722
+ getPrevWeekInsight() {
723
+ const now = Date.now();
724
+ const weekEnd = now - 7 * 86400000;
725
+ const weekStart = now - 14 * 86400000;
726
+ const row = db.prepare(`
727
+ SELECT
728
+ COUNT(*) AS total_sessions,
729
+ COALESCE(SUM(total_cost_usd), 0) AS total_cost,
730
+ COALESCE(SUM(total_input_tokens), 0) AS input_tokens,
731
+ COALESCE(SUM(total_output_tokens), 0) AS output_tokens,
732
+ COALESCE(SUM(total_cache_read), 0) AS cache_read,
733
+ COALESCE(SUM(loops_detected), 0) AS total_loops,
734
+ COALESCE(AVG(efficiency_score), 100) AS avg_efficiency,
735
+ MIN(started_at) AS week_start,
736
+ MAX(started_at) AS week_end
737
+ FROM sessions
738
+ WHERE started_at >= ? AND started_at < ?
739
+ `).get(weekStart, weekEnd);
740
+ return {
741
+ total_sessions: row.total_sessions ?? 0,
742
+ total_cost: row.total_cost ?? 0,
743
+ input_tokens: row.input_tokens ?? 0,
744
+ output_tokens: row.output_tokens ?? 0,
745
+ cache_read: row.cache_read ?? 0,
746
+ total_loops: row.total_loops ?? 0,
747
+ avg_efficiency: row.avg_efficiency ?? 100,
748
+ week_start: row.week_start ?? weekStart,
749
+ week_end: row.week_end ?? null,
750
+ };
751
+ },
722
752
  setMeta(key, value) {
723
753
  stmts.upsertMeta.run(key, value);
724
754
  },
package/dist/doctor.js CHANGED
@@ -6,8 +6,10 @@ Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.runDoctor = runDoctor;
7
7
  const fs_1 = __importDefault(require("fs"));
8
8
  const path_1 = __importDefault(require("path"));
9
+ const os_1 = __importDefault(require("os"));
9
10
  const child_process_1 = require("child_process");
10
11
  const paths_1 = require("./paths");
12
+ const config_1 = require("./config");
11
13
  async function runDoctor() {
12
14
  const checks = [];
13
15
  const G = '\x1b[32m✓\x1b[0m';
@@ -66,15 +68,16 @@ async function runDoctor() {
66
68
  fix: hookOk ? undefined : 'claudestat install',
67
69
  });
68
70
  // 6. Daemon reachable
71
+ const cfgPort = (0, config_1.readConfig)().port;
69
72
  const daemonOk = await (async () => { try {
70
- const res = await fetch('http://localhost:7337/health');
73
+ const res = await fetch(`http://localhost:${cfgPort}/health`);
71
74
  return res.ok;
72
75
  }
73
76
  catch {
74
77
  return false;
75
78
  } })();
76
79
  checks.push({
77
- label: 'Daemon running (localhost:7337)',
80
+ label: 'Daemon running (localhost:' + cfgPort + ')',
78
81
  ok: daemonOk,
79
82
  fix: daemonOk ? undefined : 'claudestat start',
80
83
  });
@@ -166,6 +169,21 @@ async function runDoctor() {
166
169
  `nvm use default && npm install -g @statforge/claudestat\n Then restart terminal`,
167
170
  });
168
171
  }
172
+ // 12. Daemon service node matches current node (only if service file exists)
173
+ if (process.platform === 'darwin') {
174
+ const plistPath = path_1.default.join(process.env.HOME ?? os_1.default.homedir(), 'Library', 'LaunchAgents', 'com.statforge.claudestat.plist');
175
+ if (fs_1.default.existsSync(plistPath)) {
176
+ const plistContent = fs_1.default.readFileSync(plistPath, 'utf8');
177
+ const currentNode = process.execPath;
178
+ const nodeOk = plistContent.includes(currentNode);
179
+ checks.push({
180
+ label: 'Daemon service uses current Node binary',
181
+ ok: nodeOk,
182
+ note: nodeOk ? undefined : `Service file uses a different node than ${currentNode}`,
183
+ fix: nodeOk ? undefined : 'claudestat setup --uninstall && claudestat setup',
184
+ });
185
+ }
186
+ }
169
187
  // 11. MCP server registered in Claude Code
170
188
  let mcpOk = false;
171
189
  let mcpNote;
package/dist/export.d.ts CHANGED
@@ -1,7 +1,8 @@
1
1
  export interface ExportOpts {
2
- format: 'json' | 'csv';
2
+ format: 'json' | 'csv' | 'markdown';
3
3
  from?: string;
4
4
  to?: string;
5
+ since?: string;
5
6
  project?: string;
6
7
  output?: string;
7
8
  }
package/dist/export.js CHANGED
@@ -5,9 +5,9 @@ var __importDefault = (this && this.__importDefault) || function (mod) {
5
5
  Object.defineProperty(exports, "__esModule", { value: true });
6
6
  exports.runExport = runExport;
7
7
  const fs_1 = __importDefault(require("fs"));
8
- const os_1 = __importDefault(require("os"));
9
8
  const path_1 = __importDefault(require("path"));
10
9
  const db_1 = require("./db");
10
+ const MD_HEADERS = ['Date', 'Project', 'Cost (USD)', 'Input tokens', 'Output tokens', 'Efficiency', 'Loops'];
11
11
  function parseDate(str, endOfDay = false) {
12
12
  const ms = Date.parse(str);
13
13
  if (isNaN(ms))
@@ -23,6 +23,23 @@ const CSV_HEADERS = [
23
23
  'total_cost_usd', 'total_input_tokens', 'total_output_tokens',
24
24
  'efficiency_score', 'loops_detected',
25
25
  ];
26
+ function parseSince(since) {
27
+ const match = since.match(/^(\d+)d$/);
28
+ if (!match)
29
+ throw new Error(`Invalid --since format: "${since}" — use e.g. "7d" or "30d"`);
30
+ return Date.now() - parseInt(match[1], 10) * 86400000;
31
+ }
32
+ function toMarkdownRow(r) {
33
+ return [
34
+ String(r.started_at).slice(0, 10),
35
+ r.project_path ? String(r.project_path).split('/').pop() ?? '—' : '—',
36
+ `$${Number(r.total_cost_usd).toFixed(4)}`,
37
+ String(r.total_input_tokens),
38
+ String(r.total_output_tokens),
39
+ `${r.efficiency_score}/100`,
40
+ String(r.loops_detected),
41
+ ].map(v => v.replace(/\|/g, '\\|')).join(' | ');
42
+ }
26
43
  function toRow(s) {
27
44
  return {
28
45
  id: s.id,
@@ -40,6 +57,10 @@ function runExport(opts) {
40
57
  let fromMs;
41
58
  let toMs;
42
59
  try {
60
+ if (opts.since && !opts.from) {
61
+ const sinceMs = parseSince(opts.since);
62
+ opts.from = new Date(sinceMs).toISOString().slice(0, 10);
63
+ }
43
64
  if (opts.from)
44
65
  fromMs = parseDate(opts.from);
45
66
  if (opts.to)
@@ -71,12 +92,26 @@ function runExport(opts) {
71
92
  ];
72
93
  output = lines.join('\n') + '\n';
73
94
  }
95
+ else if (opts.format === 'markdown') {
96
+ const separator = MD_HEADERS.map(() => '---').join(' | ');
97
+ const lines = [
98
+ MD_HEADERS.join(' | '),
99
+ separator,
100
+ ...rows.map(toMarkdownRow),
101
+ ];
102
+ output = lines.join('\n') + '\n';
103
+ }
74
104
  else {
75
105
  output = JSON.stringify(rows, null, 2) + '\n';
76
106
  }
77
- const date = new Date().toISOString().slice(0, 10);
78
- const dest = path_1.default.resolve(opts.output ?? path_1.default.join(os_1.default.homedir(), 'Downloads', `claudestat-export-${date}.${opts.format}`));
79
- fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
80
- fs_1.default.writeFileSync(dest, output);
81
- console.log(`✓ Exported ${rows.length} session(s) → ${dest}`);
107
+ if (!opts.output) {
108
+ process.stdout.write(output);
109
+ console.error(`✓ Exported ${rows.length} session(s)`); // stderr para no contaminar stdout
110
+ }
111
+ else {
112
+ const dest = path_1.default.resolve(opts.output);
113
+ fs_1.default.mkdirSync(path_1.default.dirname(dest), { recursive: true });
114
+ fs_1.default.writeFileSync(dest, output);
115
+ console.log(`✓ Exported ${rows.length} session(s) → ${dest}`);
116
+ }
82
117
  }