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 +1 -0
- ccsearch/__main__.py +9 -0
- ccsearch/cli.py +785 -0
- ccsearch-0.2.0.dist-info/METADATA +128 -0
- ccsearch-0.2.0.dist-info/RECORD +8 -0
- ccsearch-0.2.0.dist-info/WHEEL +4 -0
- ccsearch-0.2.0.dist-info/entry_points.txt +3 -0
- ccsearch-0.2.0.dist-info/licenses/LICENSE +141 -0
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,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
|