ccq 0.1.0__py3-none-any.whl

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.
ccq/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """ccq - query your own Claude Code agent history, read-only over the JSONL transcripts."""
2
+
3
+ __version__ = "0.1.0"
ccq/cli.py ADDED
@@ -0,0 +1,425 @@
1
+ """ccq - query your own Claude Code agent history.
2
+
3
+ Read-only DuckDB over ~/.claude/projects/*.jsonl. Never writes the transcripts.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+ from typing import TYPE_CHECKING
10
+
11
+ import click
12
+
13
+ from ccq import db
14
+ from ccq.format import render
15
+
16
+ if TYPE_CHECKING:
17
+ from collections.abc import Sequence
18
+
19
+ import duckdb
20
+
21
+ FORMATS = ["table", "json", "csv"]
22
+ _fmt_option = click.option(
23
+ "-f",
24
+ "--format",
25
+ "fmt",
26
+ type=click.Choice(FORMATS),
27
+ default="table",
28
+ help="Output format.",
29
+ )
30
+
31
+
32
+ def _emit(columns: list[str], rows: Sequence[tuple[object, ...]], fmt: str) -> None:
33
+ click.echo(render(columns, rows, fmt))
34
+
35
+
36
+ def _con(ctx: click.Context) -> duckdb.DuckDBPyConnection:
37
+ """Connect live, or to the materialized snapshot when --fast is set.
38
+
39
+ In fast mode the snapshot is built on first use if it does not exist yet.
40
+ """
41
+ if ctx.obj.get("fast"):
42
+ projects_dir = ctx.obj["projects_dir"]
43
+ try:
44
+ con = db.connect_fast()
45
+ except FileNotFoundError:
46
+ click.echo("Building snapshot (first --fast run)...", err=True)
47
+ db.build_cache(projects_dir)
48
+ return db.connect_fast()
49
+ # Use the existing snapshot but flag staleness (rebuilding on every query
50
+ # would thrash during an active session as transcripts grow).
51
+ if db.is_cache_stale(projects_dir=projects_dir):
52
+ click.echo("Note: snapshot is stale; run `ccq cache build` to refresh.", err=True)
53
+ return con
54
+ return db.connect(ctx.obj["projects_dir"])
55
+
56
+
57
+ @click.group(context_settings={"help_option_names": ["-h", "--help"]})
58
+ @click.option(
59
+ "--projects-dir",
60
+ default=str(db.DEFAULT_PROJECTS_DIR),
61
+ help="Directory of Claude Code project transcripts.",
62
+ show_default=True,
63
+ )
64
+ @click.option(
65
+ "--fast",
66
+ "-F",
67
+ is_flag=True,
68
+ help="Query the materialized snapshot (instant repeat queries; build with `ccq cache build`).",
69
+ )
70
+ @click.version_option(package_name="ccq")
71
+ @click.pass_context
72
+ def cli(ctx: click.Context, projects_dir: str, fast: bool) -> None:
73
+ """Query your own Claude Code agent history."""
74
+ ctx.ensure_object(dict)
75
+ ctx.obj["projects_dir"] = projects_dir
76
+ ctx.obj["fast"] = fast
77
+
78
+
79
+ # --------------------------------------------------------------------------- #
80
+ # sessions
81
+ # --------------------------------------------------------------------------- #
82
+ _SESSION_SORTS = {
83
+ "recent": "started_at",
84
+ "cost": "cost_usd",
85
+ "duration": "duration_min",
86
+ "messages": "messages",
87
+ }
88
+
89
+
90
+ @cli.command()
91
+ @click.option("--project", "-p", help="Filter to projects matching this (case-insensitive).")
92
+ @click.option("--since", help="Only sessions started on/after this date (YYYY-MM-DD).")
93
+ @click.option("--sort", type=click.Choice(list(_SESSION_SORTS)), default="recent", help="Sort key.")
94
+ @click.option("--limit", "-n", default=25, help="Max rows.")
95
+ @_fmt_option
96
+ @click.pass_context
97
+ def sessions(
98
+ ctx: click.Context, project: str | None, since: str | None, sort: str, limit: int, fmt: str
99
+ ) -> None:
100
+ """List sessions with project, span, volume, tokens and estimated cost."""
101
+ order = _SESSION_SORTS[sort]
102
+ sql = f"""
103
+ SELECT left(session_id, 8) AS id, project, started_at, duration_min AS dur_min,
104
+ messages AS msgs, models, output_tokens AS out_tok,
105
+ round(cost_usd, 2) AS cost_usd
106
+ FROM sessions
107
+ WHERE (? IS NULL OR project ILIKE '%' || ? || '%')
108
+ AND (? IS NULL OR started_at >= TRY_CAST(? AS TIMESTAMP))
109
+ ORDER BY {order} DESC NULLS LAST
110
+ LIMIT ?
111
+ """
112
+ cols, rows = db.query(_con(ctx), sql, [project, project, since, since, limit])
113
+ _emit(cols, rows, fmt)
114
+
115
+
116
+ # --------------------------------------------------------------------------- #
117
+ # cost
118
+ # --------------------------------------------------------------------------- #
119
+ @cli.command()
120
+ @click.option(
121
+ "--by",
122
+ type=click.Choice(["project", "model", "day", "session"]),
123
+ default="project",
124
+ help="Cost rollup dimension.",
125
+ )
126
+ @click.option("--since", help="Only count usage on/after this date (YYYY-MM-DD).")
127
+ @click.option("--limit", "-n", default=30, help="Max rows.")
128
+ @_fmt_option
129
+ @click.pass_context
130
+ def cost(ctx: click.Context, by: str, since: str | None, limit: int, fmt: str) -> None:
131
+ """Estimated cost rollups. Main-loop only - subagent spend is unpriced (see `agents`)."""
132
+ since_pred = "(? IS NULL OR ts >= TRY_CAST(? AS TIMESTAMP))"
133
+ params: list[object]
134
+ if by == "model":
135
+ sql = f"""
136
+ SELECT model, count(*) AS turns,
137
+ sum(input_tokens + cache_creation_tokens + cache_read_tokens) AS in_tok,
138
+ sum(output_tokens) AS out_tok, round(sum(cost_usd), 2) AS cost_usd
139
+ FROM message_usage WHERE {since_pred}
140
+ GROUP BY 1 ORDER BY cost_usd DESC LIMIT ?
141
+ """
142
+ params = [since, since, limit]
143
+ elif by == "day":
144
+ sql = f"""
145
+ SELECT CAST(ts AS DATE) AS day, count(*) AS turns,
146
+ sum(output_tokens) AS out_tok, round(sum(cost_usd), 2) AS cost_usd
147
+ FROM message_usage WHERE {since_pred} AND ts IS NOT NULL
148
+ GROUP BY 1 ORDER BY day DESC LIMIT ?
149
+ """
150
+ params = [since, since, limit]
151
+ elif by == "session":
152
+ sql = """
153
+ SELECT left(session_id, 8) AS id, project, started_at,
154
+ output_tokens AS out_tok, round(cost_usd, 2) AS cost_usd
155
+ FROM sessions WHERE (? IS NULL OR started_at >= TRY_CAST(? AS TIMESTAMP))
156
+ ORDER BY cost_usd DESC LIMIT ?
157
+ """
158
+ params = [since, since, limit]
159
+ else: # project
160
+ sql = f"""
161
+ SELECT project, count(DISTINCT session_id) AS sessions,
162
+ sum(output_tokens) AS out_tok, round(sum(cost_usd), 2) AS cost_usd
163
+ FROM message_usage WHERE {since_pred}
164
+ GROUP BY 1 ORDER BY cost_usd DESC LIMIT ?
165
+ """
166
+ params = [since, since, limit]
167
+ _emit(*db.query(_con(ctx), sql, params), fmt=fmt)
168
+
169
+
170
+ # --------------------------------------------------------------------------- #
171
+ # tools
172
+ # --------------------------------------------------------------------------- #
173
+ @cli.command()
174
+ @click.option("--project", "-p", help="Filter to projects matching this.")
175
+ @click.option("--bash", is_flag=True, help="Break down Bash invocations by leading command.")
176
+ @click.option("--limit", "-n", default=30, help="Max rows.")
177
+ @_fmt_option
178
+ @click.pass_context
179
+ def tools(ctx: click.Context, project: str | None, bash: bool, limit: int, fmt: str) -> None:
180
+ """Tool-use frequency across your history (optionally a Bash command breakdown)."""
181
+ proj_pred = "(? IS NULL OR project ILIKE '%' || ? || '%')"
182
+ if bash:
183
+ sql = f"""
184
+ SELECT lower(split_part(trim(tool_input->>'$.command'), ' ', 1)) AS command,
185
+ count(*) AS calls, count(DISTINCT session_id) AS sessions
186
+ FROM tool_calls
187
+ WHERE tool_name = 'Bash' AND {proj_pred}
188
+ GROUP BY 1 ORDER BY calls DESC LIMIT ?
189
+ """
190
+ else:
191
+ sql = f"""
192
+ SELECT tool_name, count(*) AS calls,
193
+ count(DISTINCT session_id) AS sessions,
194
+ count(DISTINCT project) AS projects
195
+ FROM tool_calls WHERE {proj_pred}
196
+ GROUP BY 1 ORDER BY calls DESC LIMIT ?
197
+ """
198
+ _emit(*db.query(_con(ctx), sql, [project, project, limit]), fmt=fmt)
199
+
200
+
201
+ # --------------------------------------------------------------------------- #
202
+ # errors
203
+ # --------------------------------------------------------------------------- #
204
+ @cli.command()
205
+ @click.option("--project", "-p", help="Filter to projects matching this.")
206
+ @click.option("--list", "list_", is_flag=True, help="List recent error events instead of a rollup.")
207
+ @click.option("--limit", "-n", default=30, help="Max rows.")
208
+ @_fmt_option
209
+ @click.pass_context
210
+ def errors(ctx: click.Context, project: str | None, list_: bool, limit: int, fmt: str) -> None:
211
+ """API errors and retries (rate limits, transient failures), by project/status."""
212
+ proj_pred = "(? IS NULL OR project ILIKE '%' || ? || '%')"
213
+ if list_:
214
+ sql = f"""
215
+ SELECT left(session_id, 8) AS id, project, ts, status, model
216
+ FROM errors WHERE {proj_pred}
217
+ ORDER BY ts DESC NULLS LAST LIMIT ?
218
+ """
219
+ else:
220
+ sql = f"""
221
+ SELECT project, status, count(*) AS hits,
222
+ count(DISTINCT session_id) AS sessions
223
+ FROM errors WHERE {proj_pred}
224
+ GROUP BY 1, 2 ORDER BY hits DESC LIMIT ?
225
+ """
226
+ _emit(*db.query(_con(ctx), sql, [project, project, limit]), fmt=fmt)
227
+
228
+
229
+ # --------------------------------------------------------------------------- #
230
+ # agents
231
+ # --------------------------------------------------------------------------- #
232
+ @cli.command()
233
+ @click.option(
234
+ "--by",
235
+ type=click.Choice(["type", "model", "session", "project"]),
236
+ default="type",
237
+ help="Group subagent dispatches by this dimension.",
238
+ )
239
+ @click.option("--limit", "-n", default=30, help="Max rows.")
240
+ @_fmt_option
241
+ @click.pass_context
242
+ def agents(ctx: click.Context, by: str, limit: int, fmt: str) -> None:
243
+ """Subagent (Agent tool) dispatches and their token totals (unpriced)."""
244
+ dim = {
245
+ "type": "subagent_type",
246
+ "model": "model",
247
+ "session": "left(session_id, 8)",
248
+ "project": "project",
249
+ }[by]
250
+ sql = f"""
251
+ SELECT {dim} AS {by}, count(*) AS dispatches,
252
+ sum(subagent_tokens) AS subagent_tokens,
253
+ CAST(round(avg(subagent_tokens)) AS BIGINT) AS avg_tokens
254
+ FROM agents
255
+ GROUP BY 1 ORDER BY dispatches DESC NULLS LAST LIMIT ?
256
+ """
257
+ _emit(*db.query(_con(ctx), sql, [limit]), fmt=fmt)
258
+
259
+
260
+ # --------------------------------------------------------------------------- #
261
+ # session <id>
262
+ # --------------------------------------------------------------------------- #
263
+ @cli.command()
264
+ @click.argument("session_id")
265
+ @click.option("--limit", "-n", default=80, help="Max timeline rows.")
266
+ @click.pass_context
267
+ def session(ctx: click.Context, session_id: str, limit: int) -> None:
268
+ """Show one session's decision timeline (prompts, tool calls, errors) by id prefix."""
269
+ con = _con(ctx)
270
+ _, rows = db.query(
271
+ con,
272
+ "SELECT session_id, project, git_branch, started_at, ended_at, duration_min, "
273
+ "messages, models, round(cost_usd, 2) FROM sessions "
274
+ "WHERE session_id LIKE ? || '%' LIMIT 2",
275
+ [session_id],
276
+ )
277
+ if not rows:
278
+ click.echo(f"No session matching prefix {session_id!r}.", err=True)
279
+ sys.exit(1)
280
+ if len(rows) > 1:
281
+ click.echo(f"Prefix {session_id!r} is ambiguous - be more specific.", err=True)
282
+ sys.exit(1)
283
+ full_id, project, branch, started, ended, dur, msgs, models, usd = rows[0]
284
+ click.echo(f"session {full_id}")
285
+ click.echo(f"project {project} branch {branch}")
286
+ click.echo(f"span {started} -> {ended} ({dur} min, {msgs} messages)")
287
+ model_list = models if isinstance(models, list) else []
288
+ click.echo(f"models {', '.join(str(m) for m in model_list)}")
289
+ click.echo(f"est cost ${usd}")
290
+ click.echo("-" * 60)
291
+ timeline_sql = """
292
+ SELECT ts, 'prompt' AS kind,
293
+ left(regexp_replace(text, '\\s+', ' ', 'g'), 110) AS detail
294
+ FROM prompts WHERE session_id = ? AND kind = 'prompt'
295
+ UNION ALL
296
+ SELECT ts, 'tool:' || tool_name,
297
+ left(coalesce(tool_input->>'$.command', tool_input->>'$.file_path',
298
+ tool_input->>'$.description', tool_input->>'$.pattern',
299
+ tool_input->>'$.query', tool_input->>'$.subagent_type', ''), 110)
300
+ FROM tool_calls WHERE session_id = ?
301
+ UNION ALL
302
+ SELECT ts, 'ERROR ' || status, coalesce(model, '')
303
+ FROM errors WHERE session_id = ?
304
+ ORDER BY ts NULLS FIRST
305
+ LIMIT ?
306
+ """
307
+ tcols, trows = db.query(con, timeline_sql, [full_id, full_id, full_id, limit])
308
+ _emit(tcols, trows, "table")
309
+
310
+
311
+ # --------------------------------------------------------------------------- #
312
+ # search
313
+ # --------------------------------------------------------------------------- #
314
+ @cli.command()
315
+ @click.argument("text")
316
+ @click.option("--limit", "-n", default=25, help="Max matching sessions.")
317
+ @_fmt_option
318
+ @click.pass_context
319
+ def search(ctx: click.Context, text: str, limit: int, fmt: str) -> None:
320
+ """Full-text search your typed prompts and session titles; returns matching sessions."""
321
+ sql = """
322
+ SELECT left(p.session_id, 8) AS id, s.project, s.started_at,
323
+ count(*) AS hits,
324
+ left(regexp_replace(any_value(p.text), '\\s+', ' ', 'g'), 90) AS sample
325
+ FROM prompts p LEFT JOIN sessions s ON s.session_id = p.session_id
326
+ WHERE lower(p.text) LIKE '%' || lower(?) || '%'
327
+ GROUP BY 1, 2, 3 ORDER BY s.started_at DESC NULLS LAST LIMIT ?
328
+ """
329
+ _emit(*db.query(_con(ctx), sql, [text, limit]), fmt=fmt)
330
+
331
+
332
+ # --------------------------------------------------------------------------- #
333
+ # sql
334
+ # --------------------------------------------------------------------------- #
335
+ @cli.command(name="sql")
336
+ @click.argument("query")
337
+ @_fmt_option
338
+ @click.pass_context
339
+ def sql_cmd(ctx: click.Context, query: str, fmt: str) -> None:
340
+ """Run an arbitrary read-only SQL SELECT over the views (sessions, message_usage,
341
+ tool_calls, errors, agents, agent_results, events, prompts, model_pricing).
342
+ """
343
+ try:
344
+ cols, rows = db.run_read_only(_con(ctx), query)
345
+ except db.UnsafeSQLError as exc:
346
+ click.echo(f"Refused: {exc}", err=True)
347
+ sys.exit(2)
348
+ _emit(cols, rows, fmt)
349
+
350
+
351
+ # --------------------------------------------------------------------------- #
352
+ # serve (thin local web viewer)
353
+ # --------------------------------------------------------------------------- #
354
+ @cli.command()
355
+ @click.option("--port", "-p", default=8787, help="Port to bind on localhost.")
356
+ @click.pass_context
357
+ def serve(ctx: click.Context, port: int) -> None:
358
+ """Launch a local web dashboard over your history (localhost only)."""
359
+ from ccq.server import serve as make_server # noqa: PLC0415 - optional/lazy import
360
+
361
+ httpd = make_server(_con(ctx), port=port)
362
+ url = f"http://127.0.0.1:{port}/"
363
+ click.echo(f"ccq viewer on {url} (Ctrl-C to stop)")
364
+ try:
365
+ httpd.serve_forever()
366
+ except KeyboardInterrupt:
367
+ click.echo("\nstopped.")
368
+ finally:
369
+ httpd.server_close()
370
+
371
+
372
+ # --------------------------------------------------------------------------- #
373
+ # cache (materialized snapshot for --fast)
374
+ # --------------------------------------------------------------------------- #
375
+ @cli.group()
376
+ def cache() -> None:
377
+ """Manage the materialized snapshot that powers --fast."""
378
+
379
+
380
+ @cache.command(name="build")
381
+ @click.pass_context
382
+ def cache_build(ctx: click.Context) -> None:
383
+ """(Re)build the snapshot from the transcripts."""
384
+ path = db.build_cache(ctx.obj["projects_dir"])
385
+ size_mb = path.stat().st_size / 1e6
386
+ click.echo(f"Built snapshot: {path} ({size_mb:.0f} MB)")
387
+
388
+
389
+ @cache.command(name="status")
390
+ def cache_status() -> None:
391
+ """Show snapshot location, size, and age relative to the newest transcript."""
392
+ path = db.default_cache_path()
393
+ if not path.exists():
394
+ click.echo(f"No snapshot at {path}. Build one with `ccq cache build`.")
395
+ return
396
+ stale = db.is_cache_stale()
397
+ size_mb = path.stat().st_size / 1e6
398
+ click.echo(f"snapshot : {path} ({size_mb:.0f} MB)")
399
+ click.echo(f"status : {'STALE - transcripts changed since build' if stale else 'up to date'}")
400
+
401
+
402
+ @cache.command(name="path")
403
+ def cache_path() -> None:
404
+ """Print the snapshot path."""
405
+ click.echo(str(db.default_cache_path()))
406
+
407
+
408
+ @cache.command(name="clear")
409
+ def cache_clear() -> None:
410
+ """Delete the snapshot."""
411
+ path = db.default_cache_path()
412
+ if path.exists():
413
+ path.unlink()
414
+ click.echo(f"Removed {path}")
415
+ else:
416
+ click.echo("No snapshot to remove.")
417
+
418
+
419
+ def main() -> None:
420
+ """Console-script entry point."""
421
+ cli(obj={})
422
+
423
+
424
+ if __name__ == "__main__":
425
+ main()