ccsearch 0.2.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.
ccsearch/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """ccsearch — search your Claude Code sessions by content."""
ccsearch/__main__.py ADDED
@@ -0,0 +1,9 @@
1
+ """Enable `python -m ccsearch`. The fzf browser re-invokes ccsearch this way for its
2
+ reload/preview binds, so this entry point must stay in lockstep with the console script."""
3
+
4
+ import sys
5
+
6
+ from ccsearch.cli import main
7
+
8
+ if __name__ == "__main__":
9
+ sys.exit(main())
ccsearch/cli.py ADDED
@@ -0,0 +1,785 @@
1
+ #!/usr/bin/env python3
2
+ """ccsearch — search your Claude Code sessions by CONTENT, not by name.
3
+
4
+ Claude Code stores every session as a transcript under ~/.claude/projects/. This greps
5
+ the actual conversation (your messages + AI responses) — never the JSON metadata
6
+ (uuids, timestamps, token counts, tool I/O) — so you can find which session discussed a
7
+ ticket / PR / file weeks later, even after the chat drifted across topics.
8
+
9
+ Requirements:
10
+ • python3 (3.9+; macOS/Linux ship it)
11
+ • ripgrep (`rg`) — brew install ripgrep / apt install ripgrep
12
+ • fzf — brew install fzf / apt install fzf (only for the browser)
13
+
14
+ Install:
15
+ • brew install alex-yanchenko/tap/ccsearch (ripgrep + fzf come as dependencies)
16
+ • or: uvx ccsearch / pipx install ccsearch / pip install ccsearch
17
+ Then run `ccsearch` — the first run builds a text cache of your sessions (one-time, a few seconds).
18
+
19
+ Usage:
20
+ ccsearch # interactive fzf browser: type to search bodies, enter resumes
21
+ ccsearch <keyword> # which sessions mention <keyword>, ranked by match count
22
+ ccsearch --index [N] # fingerprint the N most-recent sessions (title + tickets touched)
23
+
24
+ Env:
25
+ CC_JIRA_PREFIXES="ABC|XYZ" # ticket-key prefixes for the fingerprint (default: any [A-Z]{2,}-123)
26
+ CC_PROJ_STRIP="foo-|bar-" # workspace dir prefixes to trim from project labels (default: code-|src-|…)
27
+ CLAUDE_CONFIG_DIR=… # read sessions from $CLAUDE_CONFIG_DIR/projects if you relocated it
28
+ NO_COLOR=1 # disable all color
29
+ """
30
+
31
+ import datetime
32
+ import glob
33
+ import json
34
+ import os
35
+ import re
36
+ import shlex
37
+ import shutil
38
+ import subprocess
39
+ import sys
40
+ import textwrap
41
+ import time
42
+
43
+ ROOT = os.path.join(os.environ.get("CLAUDE_CONFIG_DIR", os.path.expanduser("~/.claude")), "projects")
44
+ # Ticket-key prefixes for the --index fingerprint. Default matches any Jira-style key
45
+ # (e.g. ABC-123). Set CC_JIRA_PREFIXES="ABC|XYZ" to restrict to your projects and keep
46
+ # lookalikes (e.g. course codes like BIO-111) out.
47
+ PREFIXES = os.environ.get("CC_JIRA_PREFIXES", "[A-Z]{2,}")
48
+ TICKET = re.compile(rf"\b(?:{PREFIXES})-\d{{2,}}\b")
49
+
50
+ # ── style ────────────────────────────────────────────────────────────────
51
+ _TTY = sys.stdout.isatty() and os.environ.get("NO_COLOR") is None
52
+
53
+
54
+ def c(code, s):
55
+ return f"\033[{code}m{s}\033[0m" if _TTY else s
56
+
57
+
58
+ BOLD = lambda s: c("1", s)
59
+ DIM = lambda s: c("2", s)
60
+ CYAN = lambda s: c("36", s)
61
+ GREEN = lambda s: c("32", s)
62
+ YELLOW = lambda s: c("33", s)
63
+ MAGENTA = lambda s: c("35", s)
64
+
65
+
66
+ def rel_time(ts):
67
+ d = time.time() - ts
68
+ if d < 3600:
69
+ return f"{int(d // 60)}m ago"
70
+ if d < 86400:
71
+ return f"{int(d // 3600)}h ago"
72
+ if d < 86400 * 14:
73
+ return f"{int(d // 86400)}d ago"
74
+ return time.strftime("%b %d", time.localtime(ts))
75
+
76
+
77
+ def local_stamp(ts):
78
+ """ISO-8601 UTC timestamp (…Z) → local-timezone 'MM-DD HH:MM'. 11-space pad if absent/bad."""
79
+ if not ts:
80
+ return " " * 11
81
+ try:
82
+ return datetime.datetime.fromisoformat(ts.replace("Z", "+00:00")).astimezone().strftime("%m-%d %H:%M")
83
+ except Exception:
84
+ return " " * 11
85
+
86
+
87
+ CHANNEL = re.compile(r'<channel\s+source="([^"]+)"(?:\s+event_type="([^"]+)")?')
88
+ COMMAND = re.compile(r"<command-name>\s*(\S+)")
89
+
90
+
91
+ def clean_label(label):
92
+ """Turn channel/command transcript blobs into a short readable title."""
93
+ m = CHANNEL.search(label)
94
+ if m:
95
+ src, evt = m.group(1), m.group(2)
96
+ return f"⟨{src}{(' · ' + evt) if evt else ''}⟩"
97
+ m = COMMAND.search(label)
98
+ if m:
99
+ return m.group(1)
100
+ return label
101
+
102
+
103
+ def clean_snip(snip):
104
+ """De-noise a raw JSONL snippet: unescape, collapse whitespace, trim quotes."""
105
+ snip = snip.replace('\\"', '"').replace("\\n", " ").replace("\\t", " ")
106
+ snip = re.sub(r"\s+", " ", snip).strip().strip(',"')
107
+ return snip
108
+
109
+
110
+ def proj_of(path):
111
+ # project dir name is the cwd path with non-alphanumerics → '-'; strip the encoded $HOME prefix
112
+ name = os.path.basename(os.path.dirname(path))
113
+ home = os.path.expanduser("~").replace("/", "-")
114
+ return "~/" + name[len(home) :].lstrip("-") if name.startswith(home) else name
115
+
116
+
117
+ def is_session(path):
118
+ return "/subagents/" not in path
119
+
120
+
121
+ # ── content cache ──────────────────────────────────────────────────────────
122
+ # We never grep raw .jsonl — it's full of uuids, timestamps, token/cache usage,
123
+ # tool I/O and other metadata. Instead we extract the real conversation
124
+ # (your messages + AI text + AI thinking) into a per-session text cache and
125
+ # search that. Rebuilt only when a session's transcript changes.
126
+ CACHE = os.path.expanduser("~/.cache/ccsearch")
127
+ MODE_FILE = os.path.join(CACHE, ".mode")
128
+ # Search modes (cycled with ^t in the browser):
129
+ # session — every space-separated term appears somewhere in the session (AND)
130
+ # message — every term appears together in a single message (default)
131
+ # phrase — the whole query matches as one literal substring
132
+ SEARCH_MODES = ["session", "message", "phrase"]
133
+
134
+
135
+ def get_mode():
136
+ try:
137
+ m = open(MODE_FILE).read().strip()
138
+ except OSError:
139
+ m = ""
140
+ return m if m in SEARCH_MODES else "message"
141
+
142
+
143
+ def set_mode(m):
144
+ os.makedirs(CACHE, exist_ok=True)
145
+ with open(MODE_FILE, "w") as fh:
146
+ fh.write(m)
147
+
148
+
149
+ def cycle_mode():
150
+ m = SEARCH_MODES[(SEARCH_MODES.index(get_mode()) + 1) % len(SEARCH_MODES)]
151
+ set_mode(m)
152
+ return m
153
+
154
+
155
+ SORT_FILE = os.path.join(CACHE, ".sort")
156
+ SORTS = ["date", "matches"] # date = most recent first (default); matches = by hit count, recency tiebreak
157
+
158
+
159
+ def get_sort():
160
+ try:
161
+ s = open(SORT_FILE).read().strip()
162
+ except OSError:
163
+ s = ""
164
+ return s if s in SORTS else "date"
165
+
166
+
167
+ def set_sort(s):
168
+ os.makedirs(CACHE, exist_ok=True)
169
+ with open(SORT_FILE, "w") as fh:
170
+ fh.write(s)
171
+
172
+
173
+ def toggle_sort():
174
+ set_sort("matches" if get_sort() == "date" else "date")
175
+
176
+
177
+ def prompt_label():
178
+ mode_word = {"session": "anywhere", "message": "one-msg", "phrase": "exact"}[get_mode()]
179
+ return f"{mode_word} · ⇅{get_sort()} ▸ "
180
+
181
+
182
+ def iter_entries(lines):
183
+ """Yield (role, ts, text) for conversational messages only — no tool I/O, no metadata,
184
+ no <channel>/<command> machinery. role ∈ {you, cc, think}."""
185
+ for ln in lines:
186
+ try:
187
+ o = json.loads(ln)
188
+ except Exception:
189
+ continue
190
+ if o.get("type") not in ("user", "assistant"):
191
+ continue
192
+ is_user = o["type"] == "user"
193
+ ts = o.get("timestamp")
194
+ raw = o.get("message", {}).get("content")
195
+ if isinstance(raw, str):
196
+ if raw and not raw.lstrip().startswith("<"): # skip <channel>/<command> machinery
197
+ yield ("you", ts, raw)
198
+ continue
199
+ for p in raw if isinstance(raw, list) else []:
200
+ if not isinstance(p, dict):
201
+ continue
202
+ kind = p.get("type")
203
+ if (kind == "text" or (kind is None and "text" in p)) and p.get("text"):
204
+ yield ("you" if is_user else "cc", ts, p["text"])
205
+ elif kind == "thinking" and p.get("thinking"):
206
+ yield ("think", ts, p["thinking"])
207
+
208
+
209
+ def text_cache(sid):
210
+ return os.path.join(CACHE, sid + ".txt")
211
+
212
+
213
+ def meta_cache(sid):
214
+ return os.path.join(CACHE, sid + ".meta")
215
+
216
+
217
+ def build_cache(src, sid):
218
+ """Structured cache: one collapsed message per line in <sid>.txt (clean text for search),
219
+ plus a parallel <sid>.meta line 'role\\tts'. One message per line so message/phrase modes
220
+ and the preview work directly off the small cache — no re-parsing the raw transcript."""
221
+ try:
222
+ with open(src, errors="ignore") as fh:
223
+ lines = fh.read().splitlines()
224
+ except OSError:
225
+ return
226
+ texts, metas = [], []
227
+ # the AI-generated session title is searchable too — store it as the first line
228
+ title = None
229
+ for ln in lines:
230
+ try:
231
+ o = json.loads(ln)
232
+ except Exception:
233
+ continue
234
+ if o.get("type") == "ai-title":
235
+ title = o.get("aiTitle")
236
+ if title and title.strip():
237
+ texts.append(" ".join(title.split()))
238
+ metas.append("title\t")
239
+ for role, ts, text in iter_entries(lines):
240
+ one = " ".join(text.split())
241
+ if not one:
242
+ continue
243
+ texts.append(one)
244
+ metas.append(f"{role}\t{ts or ''}")
245
+ # Write .meta first, .txt last: refresh_cache keys staleness off .txt, so a kill between
246
+ # the two leaves .txt missing/older → clean rebuild, never a mismatched .txt/.meta pair.
247
+ for path, body in ((meta_cache(sid), "\n".join(metas)), (text_cache(sid), "\n".join(texts))):
248
+ tmp = path + ".tmp"
249
+ with open(tmp, "w") as fh:
250
+ fh.write(body)
251
+ os.replace(tmp, path)
252
+
253
+
254
+ def cache_entries(sid):
255
+ """[(role, ts, text)] from a session's structured cache (small — read in full)."""
256
+ try:
257
+ texts = open(text_cache(sid), errors="ignore").read().split("\n")
258
+ metas = open(meta_cache(sid), errors="ignore").read().split("\n")
259
+ except OSError:
260
+ return []
261
+ out = []
262
+ for one, m in zip(texts, metas):
263
+ if not one:
264
+ continue
265
+ role, _, ts = m.partition("\t")
266
+ out.append((role or "cc", one, ts or None)) # (role, text, ts) — matches preview unpacking
267
+ return out
268
+
269
+
270
+ def refresh_cache(verbose=False):
271
+ """Ensure each live session has an up-to-date structured cache. Returns {sid: src_path}."""
272
+ os.makedirs(CACHE, exist_ok=True)
273
+ srcs = {os.path.basename(f)[:-6]: f for f in glob.glob(f"{ROOT}/*/*.jsonl") if is_session(f)}
274
+ stale = [
275
+ (sid, f)
276
+ for sid, f in srcs.items()
277
+ if not os.path.exists(text_cache(sid))
278
+ or not os.path.exists(meta_cache(sid))
279
+ or os.path.getmtime(text_cache(sid)) < os.path.getmtime(f)
280
+ ]
281
+ if stale and verbose:
282
+ print(DIM(f"ccsearch: indexing {len(stale)} session(s)…"), file=sys.stderr)
283
+ for sid, f in stale:
284
+ build_cache(f, sid)
285
+ return srcs
286
+
287
+
288
+ def term_counts(term):
289
+ """{sid: occurrences} for one literal, case-insensitive term over the text caches."""
290
+ counts = {}
291
+ try:
292
+ out = subprocess.run(
293
+ ["rg", "--count-matches", "-i", "-F", "--glob", "*.txt", term, CACHE],
294
+ capture_output=True,
295
+ text=True,
296
+ ).stdout
297
+ except FileNotFoundError:
298
+ return counts
299
+ for line in out.splitlines():
300
+ path, _, c = line.rpartition(":")
301
+ if path and c.isdigit():
302
+ counts[os.path.basename(path)[:-4]] = int(c)
303
+ return counts
304
+
305
+
306
+ def message_has_all(sid, terms_lower):
307
+ """True if a single cached message line contains every term (reads the small text cache)."""
308
+ try:
309
+ with open(text_cache(sid), errors="ignore") as fh:
310
+ for ln in fh:
311
+ low = ln.lower()
312
+ if all(t in low for t in terms_lower):
313
+ return True
314
+ except OSError:
315
+ pass
316
+ return False
317
+
318
+
319
+ def matching(keyword, mode, srcs):
320
+ """{sid: score} for sessions matching `keyword` under `mode`, restricted to live srcs.
321
+
322
+ session — every space-separated term appears somewhere in the session (AND)
323
+ message — every term appears together in a single message
324
+ phrase — the whole query matches as one literal substring
325
+ A single token behaves the same in every mode (literal substring).
326
+ """
327
+ terms = keyword.split()
328
+ if not terms:
329
+ return {}
330
+ if mode == "phrase":
331
+ return {sid: c for sid, c in term_counts(keyword).items() if sid in srcs}
332
+ per_term = [term_counts(t) for t in terms]
333
+ common = set(per_term[0])
334
+ for d in per_term[1:]:
335
+ common &= d.keys()
336
+ scores = {sid: sum(d.get(sid, 0) for d in per_term) for sid in common if sid in srcs}
337
+ if mode == "message" and len(terms) > 1:
338
+ tl = [t.lower() for t in terms]
339
+ scores = {sid: s for sid, s in scores.items() if message_has_all(sid, tl)}
340
+ return scores
341
+
342
+
343
+ def scan(path):
344
+ """Return (latest_title, first_user_text, set_of_tickets) for one transcript."""
345
+ title, first, tickets = None, None, set()
346
+ try:
347
+ with open(path, errors="ignore") as fh:
348
+ for ln in fh:
349
+ try:
350
+ o = json.loads(ln)
351
+ except Exception:
352
+ continue
353
+ t = o.get("type")
354
+ if t == "ai-title":
355
+ title = o.get("aiTitle")
356
+ elif t == "user" and first is None:
357
+ raw = o.get("message", {}).get("content")
358
+ txt = (
359
+ raw
360
+ if isinstance(raw, str)
361
+ else " ".join(p.get("text", "") for p in raw if isinstance(p, dict))
362
+ )
363
+ first = " ".join(txt.split())[:70]
364
+ tickets.update(TICKET.findall(ln))
365
+ except Exception:
366
+ pass
367
+ return title, first, tickets
368
+
369
+
370
+ def highlight(snip, terms):
371
+ """Highlight each term in `terms` (a list). Allows a wrap break between characters so a
372
+ long term (URL) still highlights when textwrap has split it across lines."""
373
+ if not _TTY or not terms:
374
+ return snip
375
+ for term in terms:
376
+ # whitespace in the term matches a run of any whitespace (incl. a wrap break)
377
+ pat = r"(?:\n\s*)?".join(r"\s+" if ch.isspace() else re.escape(ch) for ch in term)
378
+ snip = re.sub(f"({pat})", lambda m: YELLOW(m.group(1)), snip, flags=re.I)
379
+ return snip
380
+
381
+
382
+ def find(keyword):
383
+ if not os.path.isdir(ROOT):
384
+ print(f"\n {DIM('no Claude Code sessions under')} {BOLD(ROOT)} {DIM('(set CLAUDE_CONFIG_DIR?)')}\n")
385
+ return
386
+ if shutil.which("rg") is None:
387
+ print(f"\n {DIM('ripgrep (rg) not found — install it (macOS: brew install ripgrep)')}\n")
388
+ return
389
+ srcs = refresh_cache(verbose=True)
390
+ counts = matching(keyword, "session", srcs)
391
+ rows = []
392
+ for sid, n in counts.items():
393
+ f = srcs.get(sid)
394
+ if not f:
395
+ continue
396
+ title, first, _ = scan(f)
397
+ snip = ""
398
+ try:
399
+ alt = "|".join(re.escape(t) for t in keyword.split())
400
+ s = (
401
+ subprocess.run(
402
+ ["rg", "-i", "-m1", "-o", f".{{0,30}}(?:{alt}).{{0,45}}", text_cache(sid)],
403
+ capture_output=True,
404
+ text=True,
405
+ )
406
+ .stdout.strip()
407
+ .splitlines()
408
+ )
409
+ snip = clean_snip(s[0]) if s else ""
410
+ except Exception:
411
+ pass
412
+ rows.append((n, os.path.getmtime(f), proj_of(f), sid, clean_label(title or first or "(empty)"), snip))
413
+ # rank by match count, then recency
414
+ rows.sort(key=lambda r: (r[0], r[1]), reverse=True)
415
+
416
+ shown = keyword if len(keyword) <= 50 else "…" + keyword[-47:]
417
+ if not rows:
418
+ print(f"\n {DIM('no sessions mention')} {BOLD(shown)}\n")
419
+ return
420
+ print(f"\n {BOLD(str(len(rows)))} session(s) mention {YELLOW(shown)}\n")
421
+ for i, (n, mtime, proj, sid, label, snip) in enumerate(rows, 1):
422
+ hits = f"{n} match{'es' if n != 1 else ''}"
423
+ print(f" {CYAN(f'{i:>2}')} {BOLD(label)}")
424
+ print(
425
+ f" {YELLOW(hits.ljust(10))} {DIM('·')} {DIM(rel_time(mtime).ljust(8))} {DIM('·')} {DIM(proj)} {DIM('·')} {DIM(sid[:8])}" # noqa: E501
426
+ )
427
+ if snip:
428
+ print(f" {DIM('…' + highlight(snip, keyword.split()) + '…')}")
429
+ print(f" {GREEN('claude --resume ' + sid)}")
430
+ print()
431
+
432
+
433
+ def index(n):
434
+ files = sorted(
435
+ (f for f in glob.glob(f"{ROOT}/*/*.jsonl") if is_session(f)), key=os.path.getmtime, reverse=True
436
+ )[:n]
437
+ print()
438
+ for f in files:
439
+ title, first, tickets = scan(f)
440
+ sid = os.path.basename(f)[:-6]
441
+ tk = " ".join(sorted(tickets)[:8]) or DIM("—")
442
+ print(f" {BOLD(clean_label(title or first or '(empty)'))}")
443
+ print(
444
+ f" {DIM(rel_time(os.path.getmtime(f)).ljust(8))} {DIM('·')} {DIM(proj_of(f))} {DIM('·')} {DIM(sid[:8])}" # noqa: E501
445
+ )
446
+ print(f" {DIM('tickets:')} {CYAN(tk)}")
447
+ print()
448
+
449
+
450
+ # ── interactive browser (fzf) ────────────────────────────────────────────
451
+ def read_head(path, nbytes=65536):
452
+ with open(path, "rb") as fh:
453
+ return fh.read(nbytes).decode(errors="ignore")
454
+
455
+
456
+ def read_tail(path, nbytes=262144):
457
+ with open(path, "rb") as fh:
458
+ fh.seek(0, 2)
459
+ size = fh.tell()
460
+ fh.seek(max(0, size - nbytes))
461
+ data = fh.read().decode(errors="ignore")
462
+ return data if size <= nbytes else data.split("\n", 1)[-1]
463
+
464
+
465
+ def meta_fast(path):
466
+ """Title + first prompt + tickets without reading huge files in full."""
467
+ head, tail = read_head(path), read_tail(path)
468
+ title, first, tickets = None, None, set()
469
+ for ln in tail.splitlines():
470
+ try:
471
+ o = json.loads(ln)
472
+ except Exception:
473
+ continue
474
+ if o.get("type") == "ai-title":
475
+ title = o.get("aiTitle")
476
+ for ln in head.splitlines():
477
+ try:
478
+ o = json.loads(ln)
479
+ except Exception:
480
+ continue
481
+ if o.get("type") == "user":
482
+ raw = o.get("message", {}).get("content")
483
+ txt = (
484
+ raw
485
+ if isinstance(raw, str)
486
+ else " ".join(p.get("text", "") for p in raw if isinstance(p, dict))
487
+ )
488
+ first = " ".join(txt.split())[:70]
489
+ break
490
+ tickets.update(TICKET.findall(head))
491
+ tickets.update(TICKET.findall(tail))
492
+ return title, first, tickets
493
+
494
+
495
+ def msg_text(o):
496
+ """Natural-language text only — tool_use / tool_result parts are skipped."""
497
+ raw = o.get("message", {}).get("content")
498
+ if isinstance(raw, str):
499
+ return raw
500
+ parts = [
501
+ p.get("text", "")
502
+ for p in (raw if isinstance(raw, list) else [])
503
+ if isinstance(p, dict) and (p.get("type") == "text" or "text" in p)
504
+ ]
505
+ return " ".join(s for s in parts if s)
506
+
507
+
508
+ def _wrap(tag_colored, tag_width, text, width, terms=None):
509
+ """Print text with a colored role/label tag and a hanging indent that lines up under it."""
510
+ indent = " " * (tag_width + 1)
511
+ body = textwrap.fill(text, width=max(20, width), initial_indent=indent, subsequent_indent=indent)
512
+ body = (tag_colored + " ") + body[len(indent) :]
513
+ if terms:
514
+ body = highlight(body, terms)
515
+ print(body)
516
+
517
+
518
+ TAGS = {"you": MAGENTA("you "), "cc": GREEN("cc "), "think": DIM("think"), "title": CYAN("title")}
519
+ # stamp "MM-DD HH:MM" (11) + space + role (5) = 17
520
+ TAG_WIDTH = 17
521
+
522
+
523
+ def render_msg(role, ts, text, width, limit=None, terms=None):
524
+ body = " ".join(text.split())
525
+ if limit and len(body) > limit:
526
+ body = body[:limit].rstrip() + " …"
527
+ _wrap(f"{DIM(local_stamp(ts))} {TAGS[role]}", TAG_WIDTH, body, width, terms)
528
+ print()
529
+
530
+
531
+ def preview_session(path, keyword=None):
532
+ width = int(os.environ.get("FZF_PREVIEW_COLUMNS", 84))
533
+ title, first, tickets = meta_fast(path)
534
+ print(BOLD(clean_label(title or first or "(empty)")))
535
+ print(
536
+ DIM(f"{rel_time(os.path.getmtime(path))} · {proj_of(path)} · {os.path.basename(path)[:-6][:8]}")
537
+ )
538
+ if tickets:
539
+ print(DIM("tickets ") + CYAN(" ".join(sorted(tickets)[:10])))
540
+ print()
541
+
542
+ sid = os.path.basename(path)[:-6]
543
+ if not os.path.exists(text_cache(sid)):
544
+ build_cache(path, sid)
545
+ entries = cache_entries(sid)
546
+
547
+ if keyword:
548
+ mode = get_mode()
549
+ hl_terms = [keyword] if mode == "phrase" else keyword.split() # phrase = one literal term
550
+ ml = [t.lower() for t in hl_terms]
551
+ if mode == "message" and len(ml) > 1:
552
+ matches = [
553
+ i for i, (role, txt, ts) in enumerate(entries) if all(t in txt.lower() for t in ml)
554
+ ] # all terms in one message
555
+ else:
556
+ matches = [
557
+ i for i, (role, txt, ts) in enumerate(entries) if any(t in txt.lower() for t in ml)
558
+ ] # any term (or the phrase)
559
+ total = len(matches)
560
+ if not total:
561
+ print(
562
+ DIM(
563
+ f"“{keyword}” not shown ({mode} mode): the terms may be in tool output, "
564
+ "or — in session mode — only across separate messages."
565
+ )
566
+ )
567
+ return
568
+ matchset = set(matches)
569
+ # Group selection: in session mode guarantee one message per term (so every AND term
570
+ # is visible); otherwise just the earliest matches. Cap at 4 groups.
571
+ groups, seen = [], set()
572
+ if mode == "session" and len(ml) > 1:
573
+ for term in ml:
574
+ for i in matches:
575
+ if i not in seen and term in entries[i][1].lower():
576
+ groups.append(i)
577
+ seen.add(i)
578
+ break
579
+ for i in matches:
580
+ if len(groups) >= 4:
581
+ break
582
+ if i not in seen:
583
+ groups.append(i)
584
+ seen.add(i)
585
+ groups.sort()
586
+ print(YELLOW(f"▌ {total} match{'es' if total != 1 else ''} for “{keyword}” · {mode}"))
587
+ print(DIM("─" * min(width, 60)))
588
+ printed = set()
589
+ for gi, mi in enumerate(groups):
590
+ for j in (mi - 1, mi, mi + 1):
591
+ if not (0 <= j < len(entries)) or j in printed:
592
+ continue
593
+ printed.add(j)
594
+ role, txt, ts = entries[j]
595
+ if j in matchset:
596
+ render_msg(role, ts, txt, width, terms=hl_terms) # matched: full + highlight
597
+ else:
598
+ render_msg(role, ts, txt, width, limit=200) # context before/after: collapsed
599
+ if gi < len(groups) - 1:
600
+ print(DIM(" ⋯"))
601
+ if total > len(groups):
602
+ print(DIM(f" ⋯ +{total - len(groups)} more"))
603
+ return
604
+
605
+ print(DIM("─" * min(width, 60)))
606
+ for role, txt, ts in entries[-14:]:
607
+ render_msg(role, ts, txt, width, limit=400)
608
+
609
+
610
+ def short_proj(path):
611
+ # Trim a leading workspace/monorepo prefix so the label is just the repo/dir name.
612
+ # Extend for your own layout with CC_PROJ_STRIP="prefix1-|prefix2-" (longest-match wins).
613
+ p = proj_of(path).replace("~/", "")
614
+ prefixes = os.environ.get("CC_PROJ_STRIP", "code-|src-|repos-|dev-|projects-").split("|")
615
+ for pre in sorted((x for x in prefixes if x), key=len, reverse=True):
616
+ if p.startswith(pre):
617
+ p = p[len(pre) :]
618
+ break
619
+ return p or "~"
620
+
621
+
622
+ def fzf_line(f, count=None):
623
+ title, first, tickets = meta_fast(f)
624
+ sid = os.path.basename(f)[:-6]
625
+ ago = rel_time(os.path.getmtime(f))
626
+ tk = " ".join(sorted(tickets)[:5])
627
+ label = clean_label(title or first or "(empty)")
628
+ cnt = f"{count}×" if count else ""
629
+ # pad plain text first, then colorize, so columns line up (ANSI codes have zero display width)
630
+ display = (
631
+ f"{DIM(ago.ljust(7))} {CYAN(cnt.ljust(6))}{DIM(short_proj(f).ljust(15))} {BOLD(label)} {DIM(tk)}"
632
+ )
633
+ return f"{display}\t{sid}\t{f}"
634
+
635
+
636
+ def recent_files(n):
637
+ return sorted(
638
+ (f for f in glob.glob(f"{ROOT}/*/*.jsonl") if is_session(f)), key=os.path.getmtime, reverse=True
639
+ )[:n]
640
+
641
+
642
+ def search_lines(keyword, n=60):
643
+ """Emit fzf candidate lines. Empty query → recent N; else → conversation matches ranked by count."""
644
+ srcs = refresh_cache(verbose=False)
645
+ if not keyword.strip():
646
+ for f in sorted(srcs.values(), key=os.path.getmtime, reverse=True)[:n]:
647
+ print(fzf_line(f))
648
+ return
649
+ counts = matching(keyword, get_mode(), srcs)
650
+ rows = [(srcs[sid], counts[sid]) for sid in counts]
651
+ if get_sort() == "date":
652
+ rows.sort(key=lambda r: os.path.getmtime(r[0]), reverse=True)
653
+ else:
654
+ rows.sort(key=lambda r: (r[1], os.path.getmtime(r[0])), reverse=True)
655
+ for f, c in rows:
656
+ print(fzf_line(f, c))
657
+
658
+
659
+ def browse(n):
660
+ if not os.path.isdir(ROOT):
661
+ print(DIM(f"no Claude Code sessions found under {ROOT} (set CLAUDE_CONFIG_DIR?)"))
662
+ return
663
+ if shutil.which("rg") is None:
664
+ print(DIM("ripgrep (rg) not found — install it (macOS: brew install ripgrep)"))
665
+ return
666
+ if shutil.which("fzf") is None:
667
+ print(DIM("fzf not found — falling back to --index. Install with: brew install fzf\n"))
668
+ index(n)
669
+ return
670
+ set_mode("message") # each launch starts in one-message mode, sorted by date
671
+ set_sort("date")
672
+ # fzf re-invokes ccsearch for its reload/preview/transform binds. Route those back through
673
+ # the installed package (`python -m ccsearch`), not the source file — once installed as a
674
+ # wheel the entry point lives in a venv and `python3 <cli.py>` would not resolve imports.
675
+ me = f"{shlex.quote(sys.executable)} -m ccsearch"
676
+ reload_cmd = f"{me} --search-lines {n} {{q}}"
677
+ scheme = (
678
+ "fg+:bright-white:bold,bg+:238,hl:cyan,hl+:bright-yellow,"
679
+ "prompt:green,pointer:bright-magenta,marker:bright-magenta,info:dim,header:dim,"
680
+ "border:240,separator:240,preview-border:240,scrollbar:240,"
681
+ "preview-scrollbar:240,gutter:-1,label:cyan,preview-label:cyan,query:bright-white"
682
+ )
683
+ res = subprocess.run(
684
+ [
685
+ "fzf",
686
+ "--ansi",
687
+ "--disabled",
688
+ "--delimiter",
689
+ "\t",
690
+ "--with-nth",
691
+ "1",
692
+ "--prompt",
693
+ prompt_label(),
694
+ "--pointer",
695
+ "▌",
696
+ "--marker",
697
+ "▌",
698
+ "--height",
699
+ "100%",
700
+ "--layout",
701
+ "reverse",
702
+ "--info",
703
+ "inline-right",
704
+ "--border",
705
+ "rounded",
706
+ "--border-label",
707
+ " ccsearch ",
708
+ "--padding",
709
+ "0,1",
710
+ "--color",
711
+ scheme,
712
+ "--header",
713
+ "type to search · ^t mode (anywhere/one-msg/exact) · ^s sort (date/matches) · enter open · esc",
714
+ "--bind",
715
+ f"start:reload({reload_cmd})",
716
+ "--bind",
717
+ f"change:reload({reload_cmd})",
718
+ "--bind",
719
+ "ctrl-u:preview-half-page-up,ctrl-d:preview-half-page-down",
720
+ "--bind",
721
+ f"ctrl-t:transform-prompt({me} --cycle-mode)+reload({reload_cmd})+refresh-preview",
722
+ "--bind",
723
+ f"ctrl-s:transform-prompt({me} --toggle-sort)+reload({reload_cmd})",
724
+ "--preview",
725
+ f"{me} --preview {{3}} {{q}}",
726
+ "--preview-window",
727
+ "right,56%,wrap,border-left",
728
+ ],
729
+ capture_output=True,
730
+ text=True,
731
+ )
732
+ if res.returncode != 0 or not res.stdout.strip():
733
+ return
734
+ sid = res.stdout.split("\t")[1]
735
+ print(GREEN(f"claude --resume {sid}"))
736
+ try:
737
+ os.execvp("claude", ["claude", "--resume", sid])
738
+ except OSError as e:
739
+ print(DIM(f"couldn't launch claude ({e}) — run the command above manually."), file=sys.stderr)
740
+
741
+
742
+ def _int_arg(args, default):
743
+ """Parse args[1] as a positive int; fall back to default on missing/invalid."""
744
+ if len(args) > 1:
745
+ try:
746
+ return max(1, int(args[1]))
747
+ except ValueError:
748
+ print(DIM(f"expected a number, got '{args[1]}' — using {default}"), file=sys.stderr)
749
+ return default
750
+
751
+
752
+ def main(argv=None):
753
+ global _TTY
754
+ args = sys.argv[1:] if argv is None else list(argv)
755
+ # fzf pipes these two modes' stdout (not a TTY) but renders ANSI via --ansi / the
756
+ # preview window — so force color on; isatty() would otherwise strip it.
757
+ if args and args[0] in ("--preview", "--search-lines"):
758
+ _TTY = os.environ.get("NO_COLOR") is None
759
+ if args and args[0] in ("-h", "--help"):
760
+ print(__doc__)
761
+ elif args and args[0] == "--index":
762
+ index(_int_arg(args, 15))
763
+ elif args and args[0] == "--preview":
764
+ # --preview <path> [query words...]
765
+ preview_session(args[1], " ".join(args[2:]) or None)
766
+ elif args and args[0] == "--search-lines":
767
+ # --search-lines <n> [query words...]
768
+ search_lines(" ".join(args[2:]), _int_arg(args, 60))
769
+ elif args and args[0] == "--cycle-mode":
770
+ cycle_mode()
771
+ print(prompt_label(), end="") # for transform-prompt
772
+ elif args and args[0] == "--toggle-sort":
773
+ toggle_sort()
774
+ print(prompt_label(), end="") # for transform-prompt
775
+ elif args and args[0] in ("-i", "--browse"):
776
+ browse(_int_arg(args, 60))
777
+ elif args:
778
+ find(args[0])
779
+ else:
780
+ browse(60)
781
+ return 0
782
+
783
+
784
+ if __name__ == "__main__":
785
+ raise SystemExit(main())
@@ -0,0 +1,128 @@
1
+ Metadata-Version: 2.4
2
+ Name: ccsearch
3
+ Version: 0.2.0
4
+ Summary: Search your Claude Code sessions by content
5
+ Keywords: claude,claude-code,search,cli,transcripts,fzf
6
+ Author: Alex Yanchenko
7
+ Author-email: Alex Yanchenko <a.inbox.2026@gmail.com>
8
+ License-Expression: PolyForm-Noncommercial-1.0.0
9
+ License-File: LICENSE
10
+ Classifier: Development Status :: 4 - Beta
11
+ Classifier: Environment :: Console
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.9
15
+ Classifier: Programming Language :: Python :: 3.10
16
+ Classifier: Programming Language :: Python :: 3.11
17
+ Classifier: Programming Language :: Python :: 3.12
18
+ Classifier: Programming Language :: Python :: 3.13
19
+ Classifier: Topic :: Utilities
20
+ Requires-Python: >=3.9
21
+ Project-URL: Repository, https://github.com/alex-yanchenko/ccsearch
22
+ Project-URL: Issues, https://github.com/alex-yanchenko/ccsearch/issues
23
+ Description-Content-Type: text/markdown
24
+
25
+ # ccsearch
26
+
27
+ `ccsearch` searches your **Claude Code sessions by content** — so you can find which
28
+ of your many long-running chats discussed a ticket, PR, file, or idea, even weeks
29
+ later and even after the conversation drifted across topics.
30
+
31
+ Claude Code stores every session as a transcript under `~/.claude/projects/`.
32
+ `ccsearch` indexes the actual conversation (your messages + the AI's responses, plus
33
+ each session's title) — never the JSON metadata (uuids, timestamps, token counts,
34
+ tool input/output) — and gives you a fast fuzzy browser with a live preview and
35
+ one-key resume.
36
+
37
+ ## Quick start
38
+
39
+ Prerequisites: **python3** (3.9+), **ripgrep** (`rg`), and **fzf** (only for the
40
+ interactive browser — `ccsearch <keyword>` works without it).
41
+
42
+ ```bash
43
+ # macOS / Linux — Homebrew pulls in ripgrep + fzf for you
44
+ brew install alex-yanchenko/tap/ccsearch
45
+
46
+ # or, if you use uv / pipx (install ripgrep + fzf separately)
47
+ uvx ccsearch # run without installing
48
+ pipx install ccsearch # install for repeated use
49
+ ```
50
+
51
+ Then run:
52
+
53
+ ```bash
54
+ ccsearch
55
+ ```
56
+
57
+ The first run builds a small text cache of your sessions (a few seconds); after
58
+ that it only re-indexes sessions that changed.
59
+
60
+ ## Usage
61
+
62
+ ```bash
63
+ ccsearch # interactive browser — type to search, Enter resumes the session
64
+ ccsearch <keyword> # list sessions mentioning <keyword>, ranked
65
+ ccsearch --index [N] # fingerprint the N most-recent sessions (title + tickets touched)
66
+ ```
67
+
68
+ In the browser:
69
+
70
+ - **type** to live-search conversation text; the right pane previews the matched
71
+ message in full with the surrounding context collapsed.
72
+ - **`Enter`** runs `claude --resume <id>` for the highlighted session.
73
+ - **`Ctrl-T`** cycles the search mode; **`Ctrl-S`** toggles the sort (date ⇄ matches).
74
+ - **`Ctrl-D` / `Ctrl-U`** (or the mouse) scroll the preview; **`Esc`** quits.
75
+
76
+ The current mode and sort show in the prompt, e.g. `anywhere · ⇅date ▸`.
77
+
78
+ ### Search modes (`Ctrl-T`)
79
+
80
+ Multi-word queries are split on spaces; the mode controls how the words must match:
81
+
82
+ - **anywhere** — every word appears *somewhere* in the session (words may be in
83
+ different messages).
84
+ - **one-msg** — every word appears together in a *single* message. The default.
85
+ - **exact** — the whole query matches as one literal phrase.
86
+
87
+ Each mode is a strict subset of the one before it. A single-word query behaves the
88
+ same in all three.
89
+
90
+ ## How it works
91
+
92
+ Each session is indexed into a small structured cache (`~/.cache/ccsearch/<id>.txt`,
93
+ one message per line, plus a parallel `.meta` with role + timestamp). Both the
94
+ result ranking and the preview read this cache, so search is fast and accurate even
95
+ on very large transcripts. The cache excludes tool I/O, metadata, and
96
+ `<channel>`/`<command>` automation lines, so you only ever match real conversation.
97
+
98
+ The cache stores your conversation text in **plaintext** under `~/.cache/ccsearch/`.
99
+ It's derived from your existing Claude Code transcripts, makes no network calls, and
100
+ can be deleted at any time with `rm -rf ~/.cache/ccsearch` (your transcripts are untouched).
101
+
102
+ > AI *thinking* text is not searchable — Claude Code persists only a signature for
103
+ > thinking blocks, not the text.
104
+
105
+ ## Environment variables
106
+
107
+ - `CLAUDE_CONFIG_DIR` — read sessions from `$CLAUDE_CONFIG_DIR/projects` if you
108
+ relocated your Claude config.
109
+ - `CC_JIRA_PREFIXES` — ticket-key prefixes detected for the `--index` fingerprint.
110
+ Default matches any Jira-style key (`[A-Z]{2,}-123`); set your own to restrict and
111
+ keep lookalikes out, e.g. `export CC_JIRA_PREFIXES="ABC|XYZ"`.
112
+ - `CC_PROJ_STRIP` — pipe-separated workspace-dir prefixes trimmed from project labels
113
+ (default `code-|src-|repos-|dev-|projects-`). Extend for your own layout.
114
+ - `NO_COLOR` — disable all color.
115
+
116
+ ## Troubleshooting
117
+
118
+ | Symptom | Fix |
119
+ |---|---|
120
+ | `command not found: ccsearch` | Brew install: run `brew link ccsearch`. pipx install: confirm `~/.local/bin` is on `PATH` (`pipx ensurepath`). |
121
+ | No results / browser falls back to a plain list | Install `ripgrep` and `fzf`. |
122
+ | "No sessions" | No Claude Code sessions yet, or config relocated — set `CLAUDE_CONFIG_DIR` to the dir containing `projects/`. |
123
+ | Stale or odd results | Delete the cache and let it rebuild: `rm -rf ~/.cache/ccsearch`. |
124
+ | Colors look wrong | Run `NO_COLOR=1 ccsearch`, or use a 256-color terminal. |
125
+
126
+ ## License
127
+
128
+ [PolyForm Noncommercial 1.0.0](./LICENSE) — free for noncommercial use.
@@ -0,0 +1,8 @@
1
+ ccsearch/__init__.py,sha256=_0oEbpfuEEbOWVG9huIFl2IC_XqHXHSsld8gQ1bz2KU,64
2
+ ccsearch/__main__.py,sha256=EgsMWFHcVkqjxky-HhbzjBwyiWLgVWwp7uGt0tVkNNc,269
3
+ ccsearch/cli.py,sha256=YssIibeUKDo8wcAQ6PYy0OZl1oPcEeJDEekkzaNqDO8,29067
4
+ ccsearch-0.2.0.dist-info/licenses/LICENSE,sha256=5hjevdfsn0gpJeQZUFK3r1IZkZVbTUBR-FyRkTd7ZSw,5135
5
+ ccsearch-0.2.0.dist-info/WHEEL,sha256=CoDSoyhtC_eO_tlxRYzsTraPv1fPJRXFx91k6ISeAvA,81
6
+ ccsearch-0.2.0.dist-info/entry_points.txt,sha256=aCplwieRuzTkhZ7iFwEekqas2Ym3bEVeaUF_CDX8g4A,48
7
+ ccsearch-0.2.0.dist-info/METADATA,sha256=0e4zPxIcxYT-CjNJUhtot3Ljs7gQYhLQCSoxYChlANA,5427
8
+ ccsearch-0.2.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.28
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ ccsearch = ccsearch.cli:main
3
+
@@ -0,0 +1,141 @@
1
+ # PolyForm Noncommercial License 1.0.0
2
+
3
+ <https://polyformproject.org/licenses/noncommercial/1.0.0>
4
+
5
+ Required Notice: Copyright 2026 Alex Yanchenko (oleksandr.yanchenko.ca@gmail.com)
6
+
7
+ ## Acceptance
8
+
9
+ In order to get any license under these terms, you must agree
10
+ to them as both strict obligations and conditions to all
11
+ your licenses.
12
+
13
+ ## Copyright License
14
+
15
+ The licensor grants you a copyright license for the
16
+ software to do everything you might do with the software
17
+ that would otherwise infringe the licensor's copyright
18
+ in it for any permitted purpose. However, you may
19
+ only distribute the software according to [Distribution License](#distribution-license) and make changes or new works
20
+ based on the software according to [Changes and New Works License](#changes-and-new-works-license).
21
+
22
+ ## Distribution License
23
+
24
+ The licensor grants you an additional copyright license
25
+ to distribute copies of the software. Your license
26
+ to distribute covers distributing the software with
27
+ changes and new works permitted by [Changes and New Works License](#changes-and-new-works-license).
28
+
29
+ ## Notices
30
+
31
+ You must ensure that anyone who gets a copy of any part of
32
+ the software from you also gets a copy of these terms or the
33
+ URL for them above, as well as copies of any plain-text lines
34
+ beginning with `Required Notice:` that the licensor provided
35
+ with the software. For example:
36
+
37
+ > Required Notice: Copyright Yoyodyne, Inc. (http://example.com)
38
+
39
+ ## Changes and New Works License
40
+
41
+ The licensor grants you an additional copyright license to
42
+ make changes and new works based on the software for any
43
+ permitted purpose.
44
+
45
+ ## Patent License
46
+
47
+ The licensor grants you a patent license for the software that
48
+ covers patent claims the licensor can license, or becomes able
49
+ to license, that you would infringe by using the software.
50
+
51
+ ## Noncommercial Purposes
52
+
53
+ Any noncommercial purpose is a permitted purpose.
54
+
55
+ ## Personal Uses
56
+
57
+ Personal use for research, experiment, and testing for
58
+ the benefit of public knowledge, personal study, private
59
+ entertainment, hobby projects, amateur pursuits, or religious
60
+ observance, without any anticipated commercial application,
61
+ is use for a permitted purpose.
62
+
63
+ ## Noncommercial Organizations
64
+
65
+ Use by any charitable organization, educational institution,
66
+ public research organization, public safety or health
67
+ organization, environmental protection organization,
68
+ or government institution is use for a permitted purpose
69
+ regardless of the source of funding or obligations resulting
70
+ from the funding.
71
+
72
+ ## Fair Use
73
+
74
+ You may have "fair use" rights for the software under the
75
+ law. These terms do not limit them.
76
+
77
+ ## No Other Rights
78
+
79
+ These terms do not allow you to sublicense or transfer any of
80
+ your licenses to anyone else, or prevent the licensor from
81
+ granting licenses to anyone else. These terms do not imply
82
+ any other licenses.
83
+
84
+ ## Patent Defense
85
+
86
+ If you make any written claim that the software infringes or
87
+ contributes to infringement of any patent, your patent license
88
+ for the software granted under these terms ends immediately. If
89
+ your company makes such a claim, your patent license ends
90
+ immediately for work on behalf of your company.
91
+
92
+ ## Violations
93
+
94
+ The first time you are notified in writing that you have
95
+ violated any of these terms, or done anything with the software
96
+ not covered by your licenses, your licenses can nonetheless
97
+ continue if you come into full compliance with these terms,
98
+ and take practical steps to correct past violations, within
99
+ 32 days of receiving notice. Otherwise, all your licenses
100
+ end immediately.
101
+
102
+ ## No Liability
103
+
104
+ ***As far as the law allows, the software comes as is, without
105
+ any warranty or condition, and the licensor will not be liable
106
+ to you for any damages arising out of these terms or the use
107
+ or nature of the software, under any kind of legal claim.***
108
+
109
+ ## Definitions
110
+
111
+ The **licensor** is the individual or entity offering these
112
+ terms, and the **software** is the software the licensor makes
113
+ available under these terms.
114
+
115
+ **You** refers to the individual or entity agreeing to these
116
+ terms.
117
+
118
+ **Your company** is any legal entity, sole proprietorship,
119
+ or other kind of organization that you work for, plus all
120
+ organizations that have control over, are under the control of,
121
+ or are under common control with that organization. **Control** means ownership of substantially all the assets of an entity,
122
+ or the power to direct its management and policies by vote,
123
+ contract, or otherwise. Control can be direct or indirect.
124
+
125
+ **Your licenses** are all the licenses granted to you for the
126
+ software under these terms.
127
+
128
+ **Use** means anything you do with the software requiring one
129
+ of your licenses.
130
+
131
+ © PolyForm Project Inc.
132
+
133
+ ---
134
+
135
+ A note from the author: use it, study it, modify it, and share it freely for any
136
+ noncommercial purpose — just keep the "Required Notice" line above so it's clear
137
+ where it came from. You may not sell it or use it commercially. If you'd like to
138
+ use it commercially, or you think it deserves a different (even more open) license,
139
+ please reach out — I'd genuinely love to hear from you and I'm happy to change it.
140
+
141
+ Reach out: oleksandr.yanchenko.ca@gmail.com