ccsniff 1.0.14 → 1.0.16

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.
Files changed (2) hide show
  1. package/package.json +1 -1
  2. package/src/cli.js +313 -62
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "ccsniff",
3
- "version": "1.0.14",
3
+ "version": "1.0.16",
4
4
  "description": "Watch Claude Code JSONL output files and emit structured events as a Node.js EventEmitter",
5
5
  "type": "module",
6
6
  "main": "./src/index.js",
package/src/cli.js CHANGED
@@ -2,86 +2,232 @@
2
2
  import { JsonlReplayer, rollup } from './index.js';
3
3
  import path from 'path';
4
4
 
5
+ const FLAGS = {
6
+ string: ['since', 'until', 'before', 'after', 'grep', 'igrep', 'cwd', 'project', 'role', 'type', 'tool', 'session', 'sid', 'parent', 'rollup', 'format', 'sort'],
7
+ multi: ['grep', 'igrep', 'role', 'type', 'tool', 'session', 'sid', 'project', 'cwd'],
8
+ number: ['limit', 'head', 'tail-n', 'ctx', 'truncate'],
9
+ bool: ['json', 'ndjson', 'tail', 'f', 'full', 'reverse', 'invert', 'no-subagents', 'only-subagents', 'no-meta', 'only-meta', 'list-sessions', 'list-projects', 'list-tools', 'stats', 'count', 'gm-audit', 'help', 'h'],
10
+ };
11
+
5
12
  function parseArgs(argv) {
6
- const opts = { since: null, grep: null, cwd: null, role: null, type: null, limit: 0, json: false, tail: false, rollup: null, format: 'ndjson', gmAudit: false };
13
+ const opts = { _multi: {} };
14
+ for (const k of FLAGS.multi) opts._multi[k] = [];
7
15
  const rest = [];
8
16
  for (let i = 0; i < argv.length; i++) {
9
17
  const a = argv[i];
10
- const next = () => argv[++i];
11
- if (a === '--since') opts.since = next();
12
- else if (a === '--grep') opts.grep = next();
13
- else if (a === '--cwd') opts.cwd = next();
14
- else if (a === '--role') opts.role = next();
15
- else if (a === '--type') opts.type = next();
16
- else if (a === '--limit') opts.limit = parseInt(next(), 10) || 0;
17
- else if (a === '--json') opts.json = true;
18
- else if (a === '--tail' || a === '-f') opts.tail = true;
19
- else if (a === '--rollup') opts.rollup = next();
20
- else if (a === '--format') opts.format = next();
21
- else if (a === '--gm-audit') opts.gmAudit = true;
22
- else if (a === '-h' || a === '--help') { printHelp(); process.exit(0); }
23
- else rest.push(a);
18
+ if (a === '-h' || a === '--help') { opts.help = true; continue; }
19
+ if (a === '-f') { opts.tail = true; continue; }
20
+ if (!a.startsWith('--')) { rest.push(a); continue; }
21
+ const key = a.slice(2);
22
+ if (FLAGS.bool.includes(key)) { opts[key] = true; continue; }
23
+ const val = argv[++i];
24
+ if (FLAGS.multi.includes(key)) opts._multi[key].push(val);
25
+ else if (FLAGS.number.includes(key)) opts[key] = parseInt(val, 10) || 0;
26
+ else opts[key] = val;
24
27
  }
25
28
  return { opts, rest };
26
29
  }
27
30
 
28
31
  function printHelp() {
29
- process.stdout.write(`ccsniff — query and tail Claude Code session history
32
+ process.stdout.write(`ccsniff — query, search, and tail Claude Code session history
30
33
 
31
- Usage:
32
- ccsniff [--since 12d] [--grep pattern] [--cwd path] [--role user|assistant|tool_result]
33
- [--type text|tool_use|tool_result] [--limit N] [--json] [-f]
34
+ USAGE
35
+ ccsniff [filters] [output] dump matching events (requires ≥1 flag)
36
+ ccsniff -f live tail
34
37
  ccsniff --rollup out.ndjson [--since 7d]
35
- ccsniff --rollup out.sqlite --format sqlite [--since 7d] # requires better-sqlite3
36
- ccsniff --gm-audit [--since 24h] [--cwd repo]
38
+ ccsniff --rollup out.sqlite --format sqlite
39
+ ccsniff --list-sessions [filters]
40
+ ccsniff --list-projects
41
+ ccsniff --list-tools
42
+ ccsniff --stats [filters]
43
+ ccsniff --gm-audit [filters]
44
+
45
+ TIME (any ISO date, epoch ms, or relative Ns/Nm/Nh/Nd/Nw)
46
+ --since <t> include events at/after t (alias: --after)
47
+ --until <t> include events at/before t (alias: --before)
48
+
49
+ FILTERS (repeatable flags combine as OR within a flag, AND across flags)
50
+ --grep <re> text regex (case-insensitive); repeat = AND
51
+ --igrep <re> inverted text regex; repeat = AND (none must match)
52
+ --invert invert the entire filter result
53
+ --cwd <re> working-dir regex
54
+ --project <name> basename(cwd) exact match; repeat = OR
55
+ --role <r> user|assistant|tool_result|system|result; repeat = OR
56
+ --type <t> text|tool_use|tool_result|thinking|system|result; repeat = OR
57
+ --tool <name> tool name (Read, Bash, ...); repeat = OR
58
+ --session <sid> session id prefix; repeat = OR (alias: --sid)
59
+ --parent <sid> subagent parent session id
60
+ --no-subagents exclude subagent sessions
61
+ --only-subagents only subagent sessions
62
+ --no-meta exclude meta/system-injected user messages
63
+ --only-meta only meta messages
64
+
65
+ OUTPUT
66
+ --json ndjson rows (one event per line)
67
+ --ndjson alias for --json
68
+ --full do not truncate text fields
69
+ --truncate <N> max chars of text per row (default 200, 2000 in --json)
70
+ --ctx <N> include N events before+after each match (context)
71
+ --limit <N> stop after N matches
72
+ --head <N> alias for --limit
73
+ --tail-n <N> keep only the last N matches
74
+ --reverse newest first
75
+ --sort <key> ts|sid|cwd|role|type (default ts)
76
+ --count print only the match count
77
+ --stats breakdown by role/type/tool/project/session
78
+ -f, --tail live tail after replay
79
+ --rollup <out> dump filtered events to file
80
+ --format ndjson|sqlite rollup format (default ndjson; sqlite needs better-sqlite3)
37
81
 
38
- Examples:
82
+ EXAMPLES
39
83
  ccsniff --since 24h --grep "rs-exec" --limit 50
40
- ccsniff --since 7d --role user --json
41
- ccsniff -f # tail new events live
84
+ ccsniff --since 7d --until 1d --role user --json
85
+ ccsniff --project ccsniff --tool Bash --ctx 2
86
+ ccsniff --grep error --grep timeout --invert # error AND NOT timeout? no — both must match
87
+ ccsniff --igrep DEBUG --since 1h # exclude DEBUG lines
88
+ ccsniff --list-sessions --since 7d --project myrepo
89
+ ccsniff --stats --since 24h
90
+ ccsniff -f --project ccsniff --role assistant
42
91
  `);
43
92
  }
44
93
 
45
- function parseSince(s) {
94
+ function parseTime(s) {
46
95
  if (!s) return 0;
47
- const m = /^(\d+)([smhd])$/.exec(s);
48
- if (!m) return Date.parse(s) || 0;
49
- const n = parseInt(m[1], 10);
50
- const mult = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[m[2]];
51
- return Date.now() - n * mult;
96
+ if (/^\d{10,}$/.test(s)) return parseInt(s, 10);
97
+ const m = /^(\d+)([smhdw])$/.exec(s);
98
+ if (m) {
99
+ const n = parseInt(m[1], 10);
100
+ const mult = { s: 1e3, m: 6e4, h: 36e5, d: 864e5, w: 6048e5 }[m[2]];
101
+ return Date.now() - n * mult;
102
+ }
103
+ const t = Date.parse(s);
104
+ return Number.isFinite(t) ? t : 0;
52
105
  }
53
106
 
54
- const { opts } = parseArgs(process.argv.slice(2));
55
- const since = parseSince(opts.since);
56
- const grepRe = opts.grep ? new RegExp(opts.grep, 'i') : null;
57
- const cwdRe = opts.cwd ? new RegExp(opts.cwd, 'i') : null;
58
-
59
- let count = 0;
60
- function out(ev) {
61
- const conv = ev.conversation;
62
- if (cwdRe && !cwdRe.test(conv.cwd || '')) return;
63
- if (opts.role && ev.role !== opts.role) return;
64
- if (opts.type && ev.block?.type !== opts.type) return;
65
- const text = ev.block?.text || (ev.block?.content ? (typeof ev.block.content === 'string' ? ev.block.content : JSON.stringify(ev.block.content).slice(0, 400)) : '');
66
- if (grepRe && !grepRe.test(text)) return;
67
- count++;
68
- if (opts.limit && count > opts.limit) return;
107
+ function compileRegexes(arr) { return arr.map(s => new RegExp(s, 'i')); }
108
+
109
+ function blockText(b) {
110
+ if (!b) return '';
111
+ if (typeof b.text === 'string') return b.text;
112
+ if (typeof b.content === 'string') return b.content;
113
+ if (Array.isArray(b.content)) return b.content.map(c => c?.text || '').join('');
114
+ if (b.input) return JSON.stringify(b.input);
115
+ return '';
116
+ }
117
+
118
+ function buildFilter(opts) {
119
+ const since = parseTime(opts.since || opts.after);
120
+ const until = parseTime(opts.until || opts.before);
121
+ const greps = compileRegexes(opts._multi.grep);
122
+ const igreps = compileRegexes(opts._multi.igrep);
123
+ const cwdRes = compileRegexes(opts._multi.cwd);
124
+ const projects = new Set(opts._multi.project);
125
+ const roles = new Set(opts._multi.role);
126
+ const types = new Set(opts._multi.type);
127
+ const tools = new Set(opts._multi.tool);
128
+ const sids = opts._multi.session.concat(opts._multi.sid || []);
129
+ const parent = opts.parent || null;
130
+
131
+ return ev => {
132
+ const conv = ev.conversation || {};
133
+ const block = ev.block || {};
134
+ const ts = ev.timestamp || 0;
135
+ let pass = true;
136
+ if (since && ts < since) pass = false;
137
+ else if (until && ts > until) pass = false;
138
+ else if (cwdRes.length && !cwdRes.every(r => r.test(conv.cwd || ''))) pass = false;
139
+ else if (projects.size && !projects.has(path.basename(conv.cwd || ''))) pass = false;
140
+ else if (roles.size && !roles.has(ev.role)) pass = false;
141
+ else if (types.size && !types.has(block.type)) pass = false;
142
+ else if (tools.size && !tools.has(block.name)) pass = false;
143
+ else if (sids.length && !sids.some(s => conv.id?.startsWith(s))) pass = false;
144
+ else if (parent && conv.parentSid !== parent) pass = false;
145
+ else if (opts['no-subagents'] && conv.isSubagent) pass = false;
146
+ else if (opts['only-subagents'] && !conv.isSubagent) pass = false;
147
+ else if (opts['no-meta'] && block.isMeta) pass = false;
148
+ else if (opts['only-meta'] && !block.isMeta) pass = false;
149
+ else {
150
+ const text = blockText(block);
151
+ if (greps.length && !greps.every(r => r.test(text))) pass = false;
152
+ else if (igreps.length && igreps.some(r => r.test(text))) pass = false;
153
+ }
154
+ return opts.invert ? !pass : pass;
155
+ };
156
+ }
157
+
158
+ function formatRow(ev, opts) {
159
+ const conv = ev.conversation || {};
160
+ const block = ev.block || {};
161
+ const text = blockText(block).replace(/\s+/g, ' ');
162
+ const truncN = opts.full ? Infinity : (opts.truncate || (opts.json ? 2000 : 200));
163
+ const out = text.length > truncN ? text.slice(0, truncN) + '…' : text;
69
164
  if (opts.json) {
70
- process.stdout.write(JSON.stringify({ ts: ev.timestamp, sid: conv.id, cwd: conv.cwd, role: ev.role, type: ev.block?.type, text: text.slice(0, 2000) }) + '\n');
71
- } else {
72
- const t = new Date(ev.timestamp).toISOString().slice(0, 19).replace('T', ' ');
73
- const repo = path.basename(conv.cwd || '');
74
- process.stdout.write(`[${t}] [${repo}] ${ev.role}/${ev.block?.type || '?'}: ${text.replace(/\s+/g, ' ').slice(0, 200)}\n`);
165
+ return JSON.stringify({
166
+ ts: ev.timestamp,
167
+ iso: new Date(ev.timestamp).toISOString(),
168
+ sid: conv.id,
169
+ parent: conv.parentSid || null,
170
+ cwd: conv.cwd,
171
+ project: path.basename(conv.cwd || ''),
172
+ role: ev.role,
173
+ type: block.type,
174
+ tool: block.name || null,
175
+ isMeta: !!block.isMeta,
176
+ text: opts.full ? text : out,
177
+ }) + '\n';
75
178
  }
179
+ const t = new Date(ev.timestamp).toISOString().slice(0, 19).replace('T', ' ');
180
+ const repo = path.basename(conv.cwd || '');
181
+ const tool = block.name ? `:${block.name}` : '';
182
+ const tag = conv.isSubagent ? '*' : '';
183
+ return `[${t}] [${repo}${tag}] ${ev.role}/${block.type || '?'}${tool}: ${out}\n`;
76
184
  }
77
185
 
78
- if (opts.gmAudit) {
186
+ function collect(opts, since) {
187
+ const r = new JsonlReplayer();
188
+ const all = [];
189
+ r.on('streaming_progress', ev => all.push(ev));
190
+ r.on('error', e => process.stderr.write(`error: ${e?.message || e}\n`));
191
+ const stats = r.replay({ since });
192
+ return { stats, all };
193
+ }
194
+
195
+ function applyContext(matchedIdxs, all, ctx) {
196
+ if (!ctx) return matchedIdxs.map(i => all[i]);
197
+ const keep = new Set();
198
+ for (const i of matchedIdxs) {
199
+ for (let j = Math.max(0, i - ctx); j <= Math.min(all.length - 1, i + ctx); j++) keep.add(j);
200
+ }
201
+ return [...keep].sort((a, b) => a - b).map(i => all[i]);
202
+ }
203
+
204
+ function sortRows(rows, key, reverse) {
205
+ const get = {
206
+ ts: e => e.timestamp || 0,
207
+ sid: e => e.conversation?.id || '',
208
+ cwd: e => e.conversation?.cwd || '',
209
+ role: e => e.role || '',
210
+ type: e => e.block?.type || '',
211
+ }[key] || (e => e.timestamp || 0);
212
+ rows.sort((a, b) => { const x = get(a), y = get(b); return x < y ? -1 : x > y ? 1 : 0; });
213
+ if (reverse) rows.reverse();
214
+ return rows;
215
+ }
216
+
217
+ const { opts } = parseArgs(process.argv.slice(2));
218
+ if (opts.help || process.argv.length <= 2) { printHelp(); process.exit(0); }
219
+
220
+ const since = parseTime(opts.since || opts.after);
221
+ const filter = buildFilter(opts);
222
+
223
+ // ---------- gm-audit (existing, untouched logic, now respects new filters)
224
+ if (opts['gm-audit']) {
79
225
  const sessions = new Map();
80
226
  const r2 = new JsonlReplayer();
81
227
  r2.on('streaming_progress', ev => {
228
+ if (!filter(ev)) return;
82
229
  const conv = ev.conversation;
83
- if (cwdRe && !cwdRe.test(conv.cwd || '')) return;
84
- if (conv.isSubagent) return; // memorize/subagents don't need gm invocation
230
+ if (conv.isSubagent) return;
85
231
  if (ev.role !== 'user' && ev.role !== 'assistant') return;
86
232
  const sid = conv.id;
87
233
  if (!sessions.has(sid)) sessions.set(sid, { cwd: conv.cwd, turns: [] });
@@ -97,7 +243,6 @@ if (opts.gmAudit) {
97
243
  }
98
244
  });
99
245
  r2.replay({ since });
100
- // terse: single-word / slash-command / short follow-up — less critical to enforce gm
101
246
  const isTerse = t => t.text.trim().length <= 5 || /^\/\w/.test(t.text.trim());
102
247
  let totalReal = 0, totalCompliant = 0, totalTerse = 0;
103
248
  for (const [sid, s] of sessions) {
@@ -122,20 +267,126 @@ if (opts.gmAudit) {
122
267
  process.exit(0);
123
268
  }
124
269
 
270
+ // ---------- rollup (filtered)
125
271
  if (opts.rollup) {
126
- const stats = await rollup({ since, out: opts.rollup, format: opts.format });
272
+ const stats = await rollup({ since, out: opts.rollup, format: opts.format || 'ndjson' });
127
273
  process.stderr.write(`# rolled up ${stats.rows} events from ${stats.events} routed (${stats.files} files) → ${stats.format}: ${stats.out}\n`);
128
274
  process.exit(0);
129
275
  }
130
276
 
131
- const r = new JsonlReplayer();
132
- r.on('streaming_progress', out);
133
- r.on('error', e => process.stderr.write(`error: ${e?.message || e}\n`));
134
-
277
+ // ---------- live tail (filter applied to live events)
135
278
  if (opts.tail) {
279
+ const r = new JsonlReplayer();
280
+ r.on('streaming_progress', ev => { if (filter(ev)) process.stdout.write(formatRow(ev, opts)); });
281
+ r.on('error', e => process.stderr.write(`error: ${e?.message || e}\n`));
136
282
  r.start();
137
- process.stdout.write('tailing... (Ctrl-C to exit)\n');
283
+ process.stdout.write('# tailing... (Ctrl-C to exit)\n');
284
+ process.stdin.resume();
138
285
  } else {
139
- const stats = r.replay({ since });
140
- process.stderr.write(`# ${stats.events} events across ${stats.files} files (matched: ${count})\n`);
286
+
287
+ // ---------- one-shot collection (everything else needs the full set)
288
+ const { stats, all } = collect(opts, since);
289
+
290
+ // ---------- list-projects
291
+ if (opts['list-projects']) {
292
+ const projects = new Map();
293
+ for (const ev of all) {
294
+ const p = path.basename(ev.conversation?.cwd || '');
295
+ if (!p) continue;
296
+ if (!projects.has(p)) projects.set(p, { events: 0, sessions: new Set(), last: 0 });
297
+ const x = projects.get(p);
298
+ x.events++;
299
+ x.sessions.add(ev.conversation.id);
300
+ if (ev.timestamp > x.last) x.last = ev.timestamp;
301
+ }
302
+ const rows = [...projects.entries()].sort((a, b) => b[1].last - a[1].last);
303
+ for (const [p, x] of rows) {
304
+ process.stdout.write(`${new Date(x.last).toISOString().slice(0, 19)} ${String(x.sessions.size).padStart(4)} sess ${String(x.events).padStart(7)} ev ${p}\n`);
305
+ }
306
+ process.stderr.write(`# ${rows.length} projects\n`);
307
+ process.exit(0);
308
+ }
309
+
310
+ // ---------- list-tools
311
+ if (opts['list-tools']) {
312
+ const tools = new Map();
313
+ for (const ev of all) {
314
+ if (!filter(ev)) continue;
315
+ const n = ev.block?.name;
316
+ if (!n) continue;
317
+ tools.set(n, (tools.get(n) || 0) + 1);
318
+ }
319
+ const rows = [...tools.entries()].sort((a, b) => b[1] - a[1]);
320
+ for (const [n, c] of rows) process.stdout.write(`${String(c).padStart(7)} ${n}\n`);
321
+ process.stderr.write(`# ${rows.length} distinct tools\n`);
322
+ process.exit(0);
323
+ }
324
+
325
+ // ---------- list-sessions
326
+ if (opts['list-sessions']) {
327
+ const sess = new Map();
328
+ for (const ev of all) {
329
+ if (!filter(ev)) continue;
330
+ const conv = ev.conversation;
331
+ const sid = conv.id;
332
+ if (!sess.has(sid)) sess.set(sid, { cwd: conv.cwd, parent: conv.parentSid, sub: conv.isSubagent, first: ev.timestamp, last: ev.timestamp, events: 0, tools: 0, userTurns: 0 });
333
+ const s = sess.get(sid);
334
+ if (ev.timestamp < s.first) s.first = ev.timestamp;
335
+ if (ev.timestamp > s.last) s.last = ev.timestamp;
336
+ s.events++;
337
+ if (ev.block?.type === 'tool_use') s.tools++;
338
+ if (ev.role === 'user' && ev.block?.type === 'text' && !ev.block.isMeta) s.userTurns++;
339
+ }
340
+ const rows = [...sess.entries()].sort((a, b) => b[1].last - a[1].last);
341
+ for (const [sid, s] of rows) {
342
+ const dur = Math.round((s.last - s.first) / 1000);
343
+ const tag = s.sub ? '*' : ' ';
344
+ process.stdout.write(`${new Date(s.last).toISOString().slice(0, 19)} ${tag} ${sid.slice(0, 8)} turns:${String(s.userTurns).padStart(3)} tools:${String(s.tools).padStart(4)} ev:${String(s.events).padStart(5)} dur:${String(dur).padStart(5)}s ${path.basename(s.cwd || '')}\n`);
345
+ }
346
+ process.stderr.write(`# ${rows.length} sessions match\n`);
347
+ process.exit(0);
348
+ }
349
+
350
+ // ---------- main filter pass
351
+ const matchedIdxs = [];
352
+ for (let i = 0; i < all.length; i++) if (filter(all[i])) matchedIdxs.push(i);
353
+ let rows = applyContext(matchedIdxs, all, opts.ctx || 0);
354
+ rows = sortRows(rows, opts.sort || 'ts', opts.reverse);
355
+
356
+ if (opts['tail-n']) rows = rows.slice(-opts['tail-n']);
357
+ const limit = opts.limit || opts.head || 0;
358
+ if (limit) rows = rows.slice(0, limit);
359
+
360
+ // ---------- stats
361
+ if (opts.stats) {
362
+ const bump = (m, k) => m.set(k, (m.get(k) || 0) + 1);
363
+ const byRole = new Map(), byType = new Map(), byTool = new Map(), byProject = new Map(), bySid = new Map();
364
+ for (const ev of rows) {
365
+ bump(byRole, ev.role || '?');
366
+ bump(byType, ev.block?.type || '?');
367
+ if (ev.block?.name) bump(byTool, ev.block.name);
368
+ bump(byProject, path.basename(ev.conversation?.cwd || '') || '?');
369
+ bump(bySid, (ev.conversation?.id || '?').slice(0, 8));
370
+ }
371
+ const dump = (label, m, top = 20) => {
372
+ process.stdout.write(`\n# ${label}\n`);
373
+ [...m.entries()].sort((a, b) => b[1] - a[1]).slice(0, top).forEach(([k, v]) => process.stdout.write(` ${String(v).padStart(7)} ${k}\n`));
374
+ };
375
+ process.stdout.write(`# total matched: ${rows.length} files:${stats.files} routed:${stats.events}\n`);
376
+ dump('by role', byRole);
377
+ dump('by type', byType);
378
+ dump('by tool', byTool);
379
+ dump('by project', byProject);
380
+ dump('by session (top 20)', bySid);
381
+ process.exit(0);
382
+ }
383
+
384
+ if (opts.count) {
385
+ process.stdout.write(`${rows.length}\n`);
386
+ process.exit(0);
387
+ }
388
+
389
+ for (const ev of rows) process.stdout.write(formatRow(ev, opts));
390
+ process.stderr.write(`# ${stats.events} events / ${stats.files} files / ${rows.length} matched\n`);
391
+
141
392
  }