# cc-history: browse and search Claude Code conversation history (Linux, macOS, Windows)

> Source: <https://gist.github.com/yashjha123/5960f4b9fb312adc673cb5a00a64af22>
> Published: 2026-10-11 20:00:27+00:00

|  | #!/usr/bin/env python3 | 
|  | """ | 
|  | cc-history — Browse and search Claude Code conversation history. | 
|  |  | 
|  | Works on Linux, macOS and Windows (Python 3.8+). Reads the JSONL logs that | 
|  | Claude Code writes to ~/.claude/projects/ (or $CLAUDE_CONFIG_DIR/projects/). | 
|  |  | 
|  | Examples: | 
|  | cc-history summary                     # dashboard for the current project | 
|  | cc-history list                        # sessions in the current project, newest first | 
|  | cc-history list -g                     # sessions across all projects | 
|  | cc-history list -p myapp               # only projects whose name contains "myapp" | 
|  | cc-history list -d "yesterday"         # natural-language date filters | 
|  | cc-history list -d "past 3 days" | 
|  | cc-history list -d "before may 5" | 
|  | cc-history search "wifi reconnect"     # plain-text search | 
|  | cc-history search "nmcli\|wifi" -E      # regex search | 
|  | cc-history search reconect -F          # typo-tolerant search | 
|  | cc-history search wifi -d "last week" -C 3 | 
|  | cc-history read d31025da               # read a session (prefix match OK) | 
|  | cc-history read                        # interactive picker | 
|  | cc-history read --fzf                  # interactive picker using fzf | 
|  | cc-history read d31025da --no-tools    # hide tool calls | 
|  | cc-history read d31025da --all-tools   # every tool call with full input+output | 
|  | cc-history read d31025da --ai          # just the last assistant message | 
|  | cc-history read d31025da --git         # git commits made in the session | 
|  | cc-history tools d31025da --summary    # tool/MCP call counts | 
|  | cc-history files d31025da --code       # files the session touched | 
|  | cc-history projects                    # list all projects | 
|  | cc-history stats                       # stats per project | 
|  | cc-history completions bash\|zsh\|fish\|powershell | 
|  | """ | 
|  |  | 
|  | import argparse | 
|  | import json | 
|  | import os | 
|  | import re | 
|  | import shlex | 
|  | import shutil | 
|  | import signal | 
|  | import subprocess | 
|  | import sys | 
|  | import time | 
|  | from collections import defaultdict | 
|  | from datetime import datetime | 
|  | from difflib import SequenceMatcher | 
|  | from pathlib import Path | 
|  |  | 
|  | try: | 
|  | import curses # not in the Windows stdlib; `pip install windows-curses` provides it | 
|  | except ImportError: | 
|  | curses = None | 
|  |  | 
|  | IS_WINDOWS = os.name == "nt" | 
|  |  | 
|  | CLAUDE_DIR = Path(os.environ.get("CLAUDE_CONFIG_DIR") or Path.home() / ".claude").expanduser() | 
|  | PROJECTS_DIR = CLAUDE_DIR / "projects" | 
|  | INDEX_PATH = CLAUDE_DIR / "cache" / "cc-history-index.json" | 
|  |  | 
|  | # Claude Code truncates long project dir names and appends a hash | 
|  | MAX_PROJECT_NAME = 200 | 
|  |  | 
|  |  | 
|  | def _encode_path(p): | 
|  | # Claude Code names project dirs by replacing every non-alphanumeric char with '-' | 
|  | # ("/home/me/my_app" -> "-home-me-my-app", "C:\\Users\\me" -> "C--Users-me") | 
|  | return re.sub(r"[^A-Za-z0-9]", "-", str(p)) | 
|  |  | 
|  |  | 
|  | def _norm_name(name): | 
|  | # Windows paths are case-insensitive | 
|  | return name.lower() if IS_WINDOWS else name | 
|  |  | 
|  |  | 
|  | def cwd_to_project_dir(): | 
|  | """Map the current working directory to the Claude project directory.""" | 
|  | if not PROJECTS_DIR.is_dir(): | 
|  | return None | 
|  | dirs = {_norm_name(d.name): d for d in PROJECTS_DIR.iterdir() if d.is_dir()} | 
|  |  | 
|  | # Walk up parent dirs — Claude stores by project root, which may be a parent | 
|  | cwd = Path.cwd().resolve() | 
|  | for p in (cwd, *cwd.parents): | 
|  | name = _norm_name(_encode_path(p)) | 
|  | if name in dirs: | 
|  | return dirs[name] | 
|  | if len(name) > MAX_PROJECT_NAME: | 
|  | for dname, d in dirs.items(): | 
|  | if dname.startswith(name[:MAX_PROJECT_NAME]): | 
|  | return d | 
|  | return None | 
|  |  | 
|  | # ─── Colors ────────────────────────────────────────────────────────────────── | 
|  |  | 
|  | def _supports_color(): | 
|  | if os.environ.get("NO_COLOR") or not sys.stdout.isatty(): | 
|  | return False | 
|  | if IS_WINDOWS: | 
|  | # Turn on ANSI escape processing in the Windows console | 
|  | try: | 
|  | import ctypes | 
|  | kernel32 = ctypes.windll.kernel32 | 
|  | handle = kernel32.GetStdHandle(-11)  # STD_OUTPUT_HANDLE | 
|  | mode = ctypes.c_uint32() | 
|  | if not kernel32.GetConsoleMode(handle, ctypes.byref(mode)): | 
|  | return False | 
|  | kernel32.SetConsoleMode(handle, mode.value \| 0x0004)  # ENABLE_VIRTUAL_TERMINAL_PROCESSING | 
|  | except Exception: | 
|  | return False | 
|  | return True | 
|  |  | 
|  | USE_COLOR = _supports_color() | 
|  |  | 
|  | def c(code, text): | 
|  | if not USE_COLOR: | 
|  | return text | 
|  | return f"\033[{code}m{text}\033[0m" | 
|  |  | 
|  | def dim(t):     return c("2", t) | 
|  | def bold(t):    return c("1", t) | 
|  | def green(t):   return c("32", t) | 
|  | def cyan(t):    return c("36", t) | 
|  | def yellow(t):  return c("33", t) | 
|  | def magenta(t): return c("35", t) | 
|  | def red(t):     return c("31", t) | 
|  | def blue(t):    return c("34", t) | 
|  |  | 
|  | # ─── JSONL Parsing ─────────────────────────────────────────────────────────── | 
|  |  | 
|  | def iter_jsonl(path): | 
|  | with open(path, "r", encoding="utf-8", errors="replace") as f: | 
|  | for line in f: | 
|  | line = line.strip() | 
|  | if not line: | 
|  | continue | 
|  | try: | 
|  | yield json.loads(line) | 
|  | except json.JSONDecodeError: | 
|  | continue | 
|  |  | 
|  |  | 
|  | def extract_text(content): | 
|  | """Extract plain text from a message content field.""" | 
|  | if isinstance(content, str): | 
|  | return content | 
|  | if isinstance(content, list): | 
|  | parts = [] | 
|  | for item in content: | 
|  | if isinstance(item, dict): | 
|  | if item.get("type") == "text": | 
|  | parts.append(item.get("text", "")) | 
|  | elif item.get("type") == "tool_use": | 
|  | parts.append(f"[tool: {item.get('name', '?')}]") | 
|  | elif item.get("type") == "tool_result": | 
|  | rc = item.get("content", "") | 
|  | if isinstance(rc, list): | 
|  | for r in rc: | 
|  | if isinstance(r, dict) and r.get("type") == "text": | 
|  | parts.append(r.get("text", "")) | 
|  | elif isinstance(rc, str): | 
|  | parts.append(rc) | 
|  | return "\n".join(parts) | 
|  | return "" | 
|  |  | 
|  |  | 
|  | def strip_system_tags(text): | 
|  | """Remove <system-reminder>, <command-name>, etc.""" | 
|  | text = re.sub(r"<system-reminder>.*?</system-reminder>", "", text, flags=re.DOTALL) | 
|  | text = re.sub(r"<command-name>.*?</command-name>", "", text, flags=re.DOTALL) | 
|  | text = re.sub(r"<command-message>.*?</command-message>", "", text, flags=re.DOTALL) | 
|  | text = re.sub(r"<command-args>.*?</command-args>", "", text, flags=re.DOTALL) | 
|  | text = re.sub(r"<local-command-caveat>.*?</local-command-caveat>", "", text, flags=re.DOTALL) | 
|  | text = re.sub(r"<local-command-stdout>.*?</local-command-stdout>", "", text, flags=re.DOTALL) | 
|  | return text.strip() | 
|  |  | 
|  |  | 
|  | def _parse_ts(ts): | 
|  | """Convert a record timestamp (ISO string or epoch s/ms) to epoch seconds.""" | 
|  | if isinstance(ts, (int, float)): | 
|  | return ts / 1000 if ts > 1e12 else ts | 
|  | if isinstance(ts, str): | 
|  | try: | 
|  | return datetime.fromisoformat(ts.replace("Z", "+00:00")).timestamp() | 
|  | except ValueError: | 
|  | return None | 
|  | return None | 
|  |  | 
|  |  | 
|  | _label_cache = {} | 
|  |  | 
|  | def project_label(proj_dir): | 
|  | """Readable project name: the session's working dir relative to home. | 
|  |  | 
|  | Dir names like '-home-me-my-app' are lossy ('-' could have been '/', '_', '.'), | 
|  | so prefer the 'cwd' field recorded in the session logs. | 
|  | """ | 
|  | proj_dir = Path(proj_dir) | 
|  | if proj_dir in _label_cache: | 
|  | return _label_cache[proj_dir] | 
|  |  | 
|  | label = proj_dir.name | 
|  | for jsonl_file in sorted(proj_dir.glob("*.jsonl"), key=lambda f: f.stat().st_mtime, reverse=True): | 
|  | cwd = None | 
|  | for n, record in enumerate(iter_jsonl(jsonl_file)): | 
|  | cwd = record.get("cwd") | 
|  | if cwd or n > 50: | 
|  | break | 
|  | if cwd: | 
|  | p = Path(cwd) | 
|  | try: | 
|  | label = p.relative_to(Path.home()).as_posix() | 
|  | label = "~" if label == "." else label | 
|  | except ValueError: | 
|  | label = p.as_posix() | 
|  | break | 
|  |  | 
|  | _label_cache[proj_dir] = label | 
|  | return label | 
|  |  | 
|  |  | 
|  | # ─── Session Discovery ────────────────────────────────────────────────────── | 
|  |  | 
|  | def discover_sessions(project_filter=None, project_dirs=None): | 
|  | """Yield (session_id, project_label, path, title, timestamp) tuples. | 
|  |  | 
|  | project_dirs: if set, only scan these specific directories. | 
|  | project_filter: substring filter on project label. | 
|  | """ | 
|  | if not PROJECTS_DIR.exists(): | 
|  | return | 
|  |  | 
|  | if project_dirs is not None: | 
|  | dirs_to_scan = [d for d in project_dirs if d.is_dir()] | 
|  | else: | 
|  | dirs_to_scan = sorted(PROJECTS_DIR.iterdir()) | 
|  |  | 
|  | for proj_dir in dirs_to_scan: | 
|  | if not proj_dir.is_dir(): | 
|  | continue | 
|  | label = project_label(proj_dir) | 
|  | if project_filter and project_filter.lower() not in label.lower(): | 
|  | continue | 
|  |  | 
|  | for jsonl_file in sorted(proj_dir.glob("*.jsonl")): | 
|  | session_id = jsonl_file.stem | 
|  | title = None | 
|  | timestamp = None | 
|  | has_messages = False | 
|  |  | 
|  | for record in iter_jsonl(jsonl_file): | 
|  | rtype = record.get("type") | 
|  | if rtype == "ai-title" and record.get("aiTitle"): | 
|  | title = record["aiTitle"] | 
|  | if rtype in ("user", "assistant") and "message" in record: | 
|  | has_messages = True | 
|  | # Keep the latest timestamp, so sessions sort by last activity | 
|  | ts = _parse_ts(record.get("timestamp")) | 
|  | if ts is not None: | 
|  | timestamp = ts | 
|  |  | 
|  | if not has_messages: | 
|  | continue | 
|  |  | 
|  | if timestamp is None: | 
|  | timestamp = jsonl_file.stat().st_mtime | 
|  |  | 
|  | if title is None: | 
|  | for record in iter_jsonl(jsonl_file): | 
|  | if record.get("type") == "user" and "message" in record: | 
|  | text = extract_text(record["message"].get("content", "")) | 
|  | text = strip_system_tags(text).strip() | 
|  | if text and len(text) > 5: | 
|  | title = text[:80] | 
|  | break | 
|  |  | 
|  | if title is None: | 
|  | title = "(untitled)" | 
|  |  | 
|  | yield session_id, label, str(jsonl_file), title, timestamp | 
|  |  | 
|  |  | 
|  | def find_session_file(session_prefix): | 
|  | """Find a session file by prefix match.""" | 
|  | for proj_dir in PROJECTS_DIR.iterdir(): | 
|  | if not proj_dir.is_dir(): | 
|  | continue | 
|  | for jsonl_file in proj_dir.glob("*.jsonl"): | 
|  | if jsonl_file.stem.startswith(session_prefix): | 
|  | return jsonl_file | 
|  | return None | 
|  |  | 
|  |  | 
|  | # ─── Fuzzy Matching ────────────────────────────────────────────────────────── | 
|  |  | 
|  | FUZZY_THRESHOLD = 0.75 | 
|  |  | 
|  | def fuzzy_line_matches(needle, line): | 
|  | """Return list of (start, end) spans in line that fuzzy-match needle.""" | 
|  | needle_lower = needle.lower() | 
|  | n = len(needle_lower) | 
|  | if n == 0: | 
|  | return [] | 
|  |  | 
|  | matches = [] | 
|  | for m in re.finditer(r"[a-zA-Z0-9_]+", line): | 
|  | word = m.group().lower() | 
|  | if len(word) < 2: | 
|  | continue | 
|  | ratio = SequenceMatcher(None, needle_lower, word).ratio() | 
|  | if ratio >= FUZZY_THRESHOLD: | 
|  | matches.append((m.start(), m.end())) | 
|  |  | 
|  | return matches | 
|  |  | 
|  |  | 
|  | def fuzzy_text_matches(needle, text): | 
|  | """Check if any line in text fuzzy-matches needle.""" | 
|  | for line in text.split("\n"): | 
|  | if fuzzy_line_matches(needle, line): | 
|  | return True | 
|  | return False | 
|  |  | 
|  |  | 
|  | def highlight_fuzzy(needle, text): | 
|  | """Highlight fuzzy-matched spans in text.""" | 
|  | lines = text.split("\n") | 
|  | result = [] | 
|  | for line in lines: | 
|  | spans = fuzzy_line_matches(needle, line) | 
|  | if not spans: | 
|  | result.append(line) | 
|  | continue | 
|  | # Build highlighted line from right to left to preserve indices | 
|  | chars = list(line) | 
|  | for start, end in sorted(spans, reverse=True): | 
|  | matched_text = line[start:end] | 
|  | chars[start:end] = [bold(red(matched_text))] | 
|  | result.append("".join(chars)) | 
|  | return "\n".join(result) | 
|  |  | 
|  |  | 
|  | # ─── Date Filtering ────────────────────────────────────────────────────────── | 
|  |  | 
|  | def parse_date_filter(text): | 
|  | """Parse a natural language date expression into a (start_ts, end_ts) range. | 
|  |  | 
|  | Returns (since_ts, until_ts) — either may be None for open-ended. | 
|  | Handles: | 
|  | "two days ago"          -> that specific day | 
|  | "may 5"                 -> that specific day | 
|  | "in the past two days"  -> from two days ago to now | 
|  | "last week"             -> from one week ago to now | 
|  | "past 3 days"           -> from 3 days ago to now | 
|  | "since yesterday"       -> from yesterday to now | 
|  | "before may 5"          -> everything before may 5 | 
|  | """ | 
|  | try: | 
|  | import dateparser | 
|  | except ImportError: | 
|  | print("dateparser is required for natural-language dates: pip install dateparser", | 
|  | file=sys.stderr) | 
|  | sys.exit(1) | 
|  |  | 
|  | original = text.strip() | 
|  | lower = original.lower() | 
|  |  | 
|  | before_prefix = re.match(r'^before\s+', lower) | 
|  | if before_prefix: | 
|  | remainder = original[before_prefix.end():] | 
|  | parsed = dateparser.parse(remainder) | 
|  | if not parsed: | 
|  | print(f"Could not parse date: '{original}'", file=sys.stderr) | 
|  | sys.exit(1) | 
|  | return (None, parsed.timestamp()) | 
|  |  | 
|  | # Detect range-style queries: "in the past X", "past X", "since X", "last N days" | 
|  | range_prefix = re.match( | 
|  | r'^(in the \|)(past\|since\|previous)\s+', lower | 
|  | ) | 
|  | # "last N <unit>" is a range, but "last week"/"last month" are dateparser-native | 
|  | last_n_prefix = re.match(r'^(in the \|)last\s+(?=\d)', lower) | 
|  |  | 
|  | is_range = bool(range_prefix or last_n_prefix) | 
|  |  | 
|  | if is_range: | 
|  | prefix = range_prefix or last_n_prefix | 
|  | remainder = original[prefix.end():] | 
|  | parsed = dateparser.parse(remainder) or dateparser.parse(remainder + " ago") | 
|  | if not parsed: | 
|  | print(f"Could not parse date: '{original}'", file=sys.stderr) | 
|  | sys.exit(1) | 
|  | return (parsed.timestamp(), None) | 
|  |  | 
|  | # Try dateparser on the original — handles "last week", "two days ago", "yesterday", "may 5" | 
|  | parsed = dateparser.parse(original) | 
|  | if not parsed: | 
|  | print(f"Could not parse date: '{original}'", file=sys.stderr) | 
|  | sys.exit(1) | 
|  |  | 
|  | # Decide: range or exact day? | 
|  | # Phrases like "last week", "last month" are range-like (everything since then) | 
|  | if re.match(r'^last\s+(week\|month\|year)', lower): | 
|  | return (parsed.timestamp(), None) | 
|  |  | 
|  | # Otherwise treat as a specific day | 
|  | day_start = parsed.replace(hour=0, minute=0, second=0, microsecond=0) | 
|  | day_end = day_start.replace(hour=23, minute=59, second=59) | 
|  | return (day_start.timestamp(), day_end.timestamp()) | 
|  |  | 
|  |  | 
|  | def filter_sessions_by_date(sessions, date_expr): | 
|  | """Filter sessions by a natural language date expression.""" | 
|  | since_ts, until_ts = parse_date_filter(date_expr) | 
|  | filtered = [] | 
|  | for s in sessions: | 
|  | ts = s[4] | 
|  | if ts is None: | 
|  | continue | 
|  | if since_ts is not None and ts < since_ts: | 
|  | continue | 
|  | if until_ts is not None and ts > until_ts: | 
|  | continue | 
|  | filtered.append(s) | 
|  | return filtered | 
|  |  | 
|  |  | 
|  | # ─── Interactive Picker ─────────────────────────────────────────────────────── | 
|  |  | 
|  | def pick_fzf(items, format_fn, preview_cmd=None): | 
|  | """Use fzf to pick from a list. Returns the selected item or None.""" | 
|  | fzf_path = shutil.which("fzf") | 
|  | if not fzf_path: | 
|  | return None | 
|  |  | 
|  | lines = [format_fn(item) for item in items] | 
|  | input_text = "\n".join(lines) | 
|  |  | 
|  | cmd = [fzf_path, "--ansi", "--no-sort", "--reverse", | 
|  | "--header=Type to filter, Enter to select, Esc to cancel"] | 
|  | if preview_cmd: | 
|  | cmd.extend(["--preview", preview_cmd]) | 
|  |  | 
|  | try: | 
|  | env = dict(os.environ, PYTHONIOENCODING="utf-8")  # for the preview subprocess | 
|  | result = subprocess.run(cmd, input=input_text, capture_output=True, text=True, | 
|  | encoding="utf-8", errors="replace", env=env) | 
|  | except (OSError, KeyboardInterrupt): | 
|  | return None | 
|  |  | 
|  | if result.returncode != 0 or not result.stdout.strip(): | 
|  | return None | 
|  |  | 
|  | selected_line = result.stdout.strip() | 
|  | for i, line in enumerate(lines): | 
|  | # Strip ANSI for comparison | 
|  | clean = re.sub(r"\033\[[0-9;]*m", "", line) | 
|  | clean_sel = re.sub(r"\033\[[0-9;]*m", "", selected_line) | 
|  | if clean == clean_sel: | 
|  | return items[i] | 
|  | return None | 
|  |  | 
|  |  | 
|  | def pick_curses(items, format_fn): | 
|  | """Pure-Python interactive picker using curses. Returns selected item or None.""" | 
|  | if not items: | 
|  | return None | 
|  |  | 
|  | def _run(stdscr): | 
|  | curses.curs_set(1) | 
|  | curses.use_default_colors() | 
|  | curses.init_pair(1, curses.COLOR_CYAN, -1) | 
|  | curses.init_pair(2, curses.COLOR_YELLOW, -1) | 
|  | curses.init_pair(3, curses.COLOR_BLACK, curses.COLOR_WHITE) | 
|  |  | 
|  | query = "" | 
|  | cursor = 0 | 
|  | selected = None | 
|  |  | 
|  | while True: | 
|  | stdscr.clear() | 
|  | h, w = stdscr.getmaxyx() | 
|  |  | 
|  | # Filter items | 
|  | if query: | 
|  | q = query.lower() | 
|  | filtered = [(i, item) for i, item in enumerate(items) | 
|  | if q in format_fn(item).lower()] | 
|  | else: | 
|  | filtered = list(enumerate(items)) | 
|  |  | 
|  | cursor = max(0, min(cursor, len(filtered) - 1)) | 
|  |  | 
|  | # Header | 
|  | header = f" cc-history picker ({len(filtered)}/{len(items)})  ─  type to filter, ↑↓ navigate, Enter select, Esc quit" | 
|  | stdscr.addnstr(0, 0, header, w - 1, curses.A_DIM) | 
|  |  | 
|  | # Query line | 
|  | prompt = f" > {query}" | 
|  | stdscr.addnstr(1, 0, prompt, w - 1) | 
|  |  | 
|  | # Items (starting from row 3) | 
|  | visible_start = max(0, cursor - (h - 4)) | 
|  | for row_idx, fi in enumerate(filtered[visible_start:]): | 
|  | row = row_idx + 3 | 
|  | if row >= h - 1: | 
|  | break | 
|  |  | 
|  | idx, item = fi | 
|  | line = format_fn(item) | 
|  | # Strip ANSI codes for curses display | 
|  | clean = re.sub(r"\033\[[0-9;]*m", "", line) | 
|  |  | 
|  | abs_idx = visible_start + row_idx | 
|  | if abs_idx == cursor: | 
|  | stdscr.addnstr(row, 0, f" ▸ {clean}", w - 1, curses.color_pair(3)) | 
|  | else: | 
|  | stdscr.addnstr(row, 0, f"   {clean}", w - 1) | 
|  |  | 
|  | # Position cursor at end of query | 
|  | stdscr.move(1, min(len(prompt), w - 1)) | 
|  | stdscr.refresh() | 
|  |  | 
|  | try: | 
|  | key = stdscr.get_wch() | 
|  | except curses.error: | 
|  | continue | 
|  |  | 
|  | if isinstance(key, str): | 
|  | if key == "\x1b":  # Esc | 
|  | return None | 
|  | elif key == "\n" or key == "\r": | 
|  | if filtered: | 
|  | return filtered[cursor][1] | 
|  | return None | 
|  | elif key in ("\x7f", "\b"):  # Backspace | 
|  | query = query[:-1] | 
|  | cursor = 0 | 
|  | elif key == "\t":  # Tab = select | 
|  | if filtered: | 
|  | return filtered[cursor][1] | 
|  | return None | 
|  | elif len(key) == 1 and key.isprintable(): | 
|  | query += key | 
|  | cursor = 0 | 
|  | elif isinstance(key, int): | 
|  | if key in (curses.KEY_ENTER, 459):  # 459 = PADENTER in windows-curses | 
|  | if filtered: | 
|  | return filtered[cursor][1] | 
|  | return None | 
|  | elif key == curses.KEY_UP: | 
|  | cursor = max(0, cursor - 1) | 
|  | elif key == curses.KEY_DOWN: | 
|  | cursor = min(len(filtered) - 1, cursor + 1) | 
|  | elif key == curses.KEY_BACKSPACE: | 
|  | query = query[:-1] | 
|  | cursor = 0 | 
|  |  | 
|  | try: | 
|  | return curses.wrapper(_run) | 
|  | except (KeyboardInterrupt, curses.error): | 
|  | return None | 
|  |  | 
|  |  | 
|  | def pick_prompt(items, format_fn, page_size=20): | 
|  | """Fallback picker without curses/fzf: numbered list + input().""" | 
|  | query = "" | 
|  | while True: | 
|  | if query: | 
|  | q = query.lower() | 
|  | filtered = [item for item in items if q in format_fn(item).lower()] | 
|  | else: | 
|  | filtered = list(items) | 
|  | for i, item in enumerate(filtered[:page_size], 1): | 
|  | print(f"  {cyan(f'{i:3d}')} {format_fn(item)}") | 
|  | if len(filtered) > page_size: | 
|  | print(dim(f"  ... {len(filtered) - page_size} more, type text to filter")) | 
|  | try: | 
|  | answer = input("Number to open, text to filter, empty to quit: ").strip() | 
|  | except (EOFError, KeyboardInterrupt): | 
|  | return None | 
|  | if not answer: | 
|  | return None | 
|  | if answer.isdigit() and 1 <= int(answer) <= min(len(filtered), page_size): | 
|  | return filtered[int(answer) - 1] | 
|  | query = answer | 
|  |  | 
|  |  | 
|  | def interactive_pick_session(sessions, use_fzf=False): | 
|  | """Pick a session interactively. Returns (session_id, label, path, title, ts) or None.""" | 
|  | if not sessions: | 
|  | print("No sessions found.", file=sys.stderr) | 
|  | return None | 
|  |  | 
|  | def fmt(s): | 
|  | sid, proj, path, title, ts = s | 
|  | date_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "????" | 
|  | return f"{sid[:8]} {date_str}  [{proj}]  {title}" | 
|  |  | 
|  | if use_fzf and shutil.which("fzf"): | 
|  | # Re-invoke this script for the preview; fzf clips output to the pane | 
|  | argv = [sys.executable, os.path.abspath(__file__)] | 
|  | if IS_WINDOWS: | 
|  | prog = subprocess.list2cmdline(argv) | 
|  | else: | 
|  | prog = " ".join(shlex.quote(a) for a in argv) | 
|  | return pick_fzf(sessions, fmt, preview_cmd=f"{prog} read {{1}} --no-tools") | 
|  | if use_fzf: | 
|  | print("fzf not found on PATH, using the built-in picker.", file=sys.stderr) | 
|  | if curses is not None and sys.stdin.isatty(): | 
|  | return pick_curses(sessions, fmt) | 
|  | return pick_prompt(sessions, fmt) | 
|  |  | 
|  |  | 
|  | # ─── Commands ──────────────────────────────────────────────────────────────── | 
|  |  | 
|  | def _resolve_project_dirs(args): | 
|  | """Return project_dirs list based on --global flag and cwd.""" | 
|  | if getattr(args, "glob", False) or getattr(args, "project", None): | 
|  | return None # scan all | 
|  | proj_dir = cwd_to_project_dir() | 
|  | if proj_dir: | 
|  | return [proj_dir] | 
|  | return None # cwd not in any project — fall back to all | 
|  |  | 
|  |  | 
|  | def cmd_list(args): | 
|  | project_dirs = _resolve_project_dirs(args) | 
|  | sessions = list(discover_sessions(project_filter=args.project, project_dirs=project_dirs)) | 
|  | sessions.sort(key=lambda s: s[4] or 0, reverse=True) | 
|  |  | 
|  | if args.date: | 
|  | sessions = filter_sessions_by_date(sessions, args.date) | 
|  |  | 
|  | if args.limit: | 
|  | sessions = sessions[: args.limit] | 
|  |  | 
|  | if args.json: | 
|  | out = [] | 
|  | for sid, proj, path, title, ts in sessions: | 
|  | out.append({ | 
|  | "session_id": sid, | 
|  | "project": proj, | 
|  | "title": title, | 
|  | "timestamp": ts, | 
|  | "date": datetime.fromtimestamp(ts).isoformat() if ts else None, | 
|  | }) | 
|  | print(json.dumps(out, indent=2)) | 
|  | return | 
|  |  | 
|  | if not sessions: | 
|  | print("No sessions found.") | 
|  | return | 
|  |  | 
|  | if args.pick: | 
|  | picked = interactive_pick_session(sessions, use_fzf=args.fzf) | 
|  | if picked: | 
|  | args_read = argparse.Namespace(session_id=picked[0], raw=False, no_tools=True) | 
|  | cmd_read(args_read) | 
|  | return | 
|  |  | 
|  | for sid, proj, path, title, ts in sessions: | 
|  | date_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "unknown date" | 
|  | short_id = sid[:8] | 
|  | print(f"  {cyan(short_id)} {dim(date_str)} {dim('[' + proj + ']')} {title}") | 
|  |  | 
|  |  | 
|  | def cmd_search(args): | 
|  | use_regex = args.regex | 
|  | use_fuzzy = args.fuzzy | 
|  | if use_regex: | 
|  | try: | 
|  | pattern = re.compile(args.term, re.IGNORECASE) | 
|  | except re.error as e: | 
|  | print(f"Invalid regex '{args.term}': {e}", file=sys.stderr) | 
|  | sys.exit(1) | 
|  | else: | 
|  | term = args.term.lower() | 
|  |  | 
|  | date_since, date_until = None, None | 
|  | if args.date: | 
|  | date_since, date_until = parse_date_filter(args.date) | 
|  |  | 
|  | # Timeout: default 10s for fuzzy, 0 (unlimited) for exact/regex | 
|  | timeout = args.timeout | 
|  | if timeout is None: | 
|  | timeout = 10 if use_fuzzy else 0 | 
|  | timed_out = False | 
|  | deadline = time.monotonic() + timeout if timeout > 0 else None | 
|  |  | 
|  | results = [] | 
|  |  | 
|  | project_dirs = _resolve_project_dirs(args) | 
|  | if project_dirs is not None: | 
|  | dirs_to_scan = [d for d in project_dirs if d.is_dir()] | 
|  | else: | 
|  | dirs_to_scan = sorted(PROJECTS_DIR.iterdir()) | 
|  |  | 
|  | try: | 
|  | for proj_dir in dirs_to_scan: | 
|  | if not proj_dir.is_dir(): | 
|  | continue | 
|  | label = project_label(proj_dir) | 
|  | if args.project and args.project.lower() not in label.lower(): | 
|  | continue | 
|  |  | 
|  | for jsonl_file in proj_dir.glob("*.jsonl"): | 
|  | if date_since is not None or date_until is not None: | 
|  | file_ts = jsonl_file.stat().st_mtime | 
|  | if date_since is not None and file_ts < date_since: | 
|  | continue | 
|  | if date_until is not None and file_ts > date_until: | 
|  | continue | 
|  |  | 
|  | session_id = jsonl_file.stem | 
|  | title = None | 
|  | messages = [] | 
|  |  | 
|  | for record in iter_jsonl(jsonl_file): | 
|  | rtype = record.get("type") | 
|  | if rtype == "ai-title" and record.get("aiTitle"): | 
|  | title = record["aiTitle"] | 
|  | if rtype in ("user", "assistant") and "message" in record: | 
|  | text = extract_text(record["message"].get("content", "")) | 
|  | text = strip_system_tags(text) | 
|  | # For fuzzy mode, skip very large blocks (tool output dumps) | 
|  | if use_fuzzy and len(text) > 5000: | 
|  | text = text[:5000] | 
|  | role = record["message"].get("role", rtype) | 
|  | messages.append((role, text)) | 
|  |  | 
|  | for i, (role, text) in enumerate(messages): | 
|  | if deadline is not None and time.monotonic() > deadline: | 
|  | raise TimeoutError() | 
|  | if use_regex: | 
|  | match_found = pattern.search(text) | 
|  | elif use_fuzzy: | 
|  | match_found = fuzzy_text_matches(args.term, text) | 
|  | else: | 
|  | match_found = term in text.lower() | 
|  | if match_found: | 
|  | lines = text.split("\n") | 
|  | for li, line in enumerate(lines): | 
|  | if use_regex: | 
|  | line_match = pattern.search(line) | 
|  | elif use_fuzzy: | 
|  | line_match = bool(fuzzy_line_matches(args.term, line)) | 
|  | else: | 
|  | line_match = term in line.lower() | 
|  | if line_match: | 
|  | ctx_start = max(0, li - args.context) | 
|  | ctx_end = min(len(lines), li + args.context + 1) | 
|  | snippet = "\n".join(lines[ctx_start:ctx_end]) | 
|  | results.append({ | 
|  | "session_id": session_id, | 
|  | "project": label, | 
|  | "title": title or "(untitled)", | 
|  | "role": role, | 
|  | "snippet": snippet, | 
|  | "line": li, | 
|  | }) | 
|  | except TimeoutError: | 
|  | timed_out = True | 
|  |  | 
|  | if args.json: | 
|  | print(json.dumps(results, indent=2)) | 
|  | return | 
|  |  | 
|  | if not results: | 
|  | print(f"No results for '{args.term}'.") | 
|  | return | 
|  |  | 
|  | seen_sessions = set() | 
|  | for r in results: | 
|  | sid = r["session_id"][:8] | 
|  | header_needed = sid not in seen_sessions | 
|  | seen_sessions.add(sid) | 
|  |  | 
|  | if header_needed: | 
|  | print() | 
|  | print(f"  {cyan(sid)} {dim('[' + r['project'] + ']')} {r['title']}") | 
|  | print(f"  {'─' * 60}") | 
|  |  | 
|  | role_color = green if r["role"] == "user" else yellow | 
|  | snippet = r["snippet"] | 
|  | if use_regex: | 
|  | highlighted = pattern.sub(lambda m: bold(red(m.group(0))), snippet) | 
|  | elif use_fuzzy: | 
|  | highlighted = highlight_fuzzy(args.term, snippet) | 
|  | else: | 
|  | highlighted = re.sub( | 
|  | re.escape(args.term), | 
|  | lambda m: bold(red(m.group(0))), | 
|  | snippet, | 
|  | flags=re.IGNORECASE, | 
|  | ) | 
|  | for line in highlighted.split("\n"): | 
|  | print(f"    {role_color(r['role'] + ':')} {line}") | 
|  |  | 
|  | summary = f"{len(results)} match(es) across {len(seen_sessions)} session(s)" | 
|  | if timed_out: | 
|  | summary += f" (timed out after {timeout}s — use --timeout N to increase)" | 
|  | print(f"\n {dim(summary)}") | 
|  |  | 
|  |  | 
|  | def cmd_read(args): | 
|  | if not args.session_id: | 
|  | project_dirs = _resolve_project_dirs(args) | 
|  | sessions = list(discover_sessions(project_dirs=project_dirs)) | 
|  | sessions.sort(key=lambda s: s[4] or 0, reverse=True) | 
|  | use_fzf = getattr(args, "fzf", False) | 
|  | picked = interactive_pick_session(sessions, use_fzf=use_fzf) | 
|  | if not picked: | 
|  | return | 
|  | args.session_id = picked[0] | 
|  |  | 
|  | path = find_session_file(args.session_id) | 
|  | if not path: | 
|  | print(f"No session found matching '{args.session_id}'", file=sys.stderr) | 
|  | sys.exit(1) | 
|  |  | 
|  | show_git = getattr(args, "git", False) | 
|  | show_ai = getattr(args, "ai", False) | 
|  | show_human = getattr(args, "human", False) | 
|  | show_full = getattr(args, "full", False) | 
|  | show_all_tools = getattr(args, "all_tools", False) | 
|  |  | 
|  | if show_all_tools and getattr(args, "no_tools", False): | 
|  | print("--all-tools and --no-tools are mutually exclusive", file=sys.stderr) | 
|  | sys.exit(1) | 
|  |  | 
|  | # Git mode: show commits and exit | 
|  | if show_git: | 
|  | commits = _extract_git_commits(path) | 
|  | if not commits: | 
|  | print("  No git commits found in this session.") | 
|  | return | 
|  | print(f"\n {bold('Git commits')}") | 
|  | print(f"  {'─' * 40}") | 
|  | for msg in commits: | 
|  | print(f"    {msg}") | 
|  | print() | 
|  | return | 
|  |  | 
|  | # Collect all messages for --ai / --human filtering | 
|  | if show_ai or show_human: | 
|  | records = [] | 
|  | title = None | 
|  | for record in iter_jsonl(path): | 
|  | rtype = record.get("type") | 
|  | if rtype == "ai-title" and record.get("aiTitle") and title is None: | 
|  | title = record["aiTitle"] | 
|  | if rtype in ("user", "assistant"): | 
|  | records.append(record) | 
|  |  | 
|  | proj = project_label(path.parent) | 
|  | if title: | 
|  | print(f"\n {bold(title)} {dim('[' + proj + ']')}") | 
|  | print(f"  {dim(path.stem)}") | 
|  | print(f"  {'━' * 60}\n") | 
|  |  | 
|  | target_role = "assistant" if show_ai else "user" | 
|  | matching = [r for r in records if r.get("message", {}).get("role", r.get("type")) == target_role] | 
|  |  | 
|  | if not matching: | 
|  | print(f"  No {target_role} messages found.") | 
|  | return | 
|  |  | 
|  | # Find the last message of that role with actual text content | 
|  | record = None | 
|  | text = "" | 
|  | for r in reversed(matching): | 
|  | msg = r.get("message", {}) | 
|  | content = msg.get("content", "") | 
|  | if show_all_tools: | 
|  | candidate = _extract_all_tools_text(content) | 
|  | elif show_full: | 
|  | candidate = extract_text(content) | 
|  | else: | 
|  | candidate = _extract_clean_text(content) | 
|  | candidate = strip_system_tags(candidate) | 
|  | if candidate.strip(): | 
|  | record = r | 
|  | text = candidate | 
|  | break | 
|  |  | 
|  | if not record: | 
|  | print(f"  No {target_role} messages with content found.") | 
|  | return | 
|  |  | 
|  | role_color = green if target_role == "user" else yellow | 
|  | print(role_color(f"  [{target_role}]")) | 
|  | for line in text.split("\n"): | 
|  | print(f"    {line}") | 
|  | print() | 
|  | return | 
|  |  | 
|  | # Default read mode | 
|  | title = None | 
|  | for record in iter_jsonl(path): | 
|  | rtype = record.get("type") | 
|  |  | 
|  | if rtype == "ai-title" and record.get("aiTitle"): | 
|  | if title is None: | 
|  | title = record["aiTitle"] | 
|  | proj = project_label(path.parent) | 
|  | print(f"\n {bold(title)} {dim('[' + proj + ']')}") | 
|  | print(f"  {dim(path.stem)}") | 
|  | print(f"  {'━' * 60}\n") | 
|  |  | 
|  | if rtype not in ("user", "assistant"): | 
|  | continue | 
|  |  | 
|  | msg = record.get("message", {}) | 
|  | role = msg.get("role", rtype) | 
|  | content = msg.get("content", "") | 
|  |  | 
|  | if args.no_tools and isinstance(content, list): | 
|  | content = [ | 
|  | item for item in content | 
|  | if isinstance(item, dict) and item.get("type") not in ("tool_use", "tool_result") | 
|  | ] | 
|  |  | 
|  | if args.raw: | 
|  | text = json.dumps(content, indent=2) if not isinstance(content, str) else content | 
|  | elif show_all_tools: | 
|  | text = _extract_all_tools_text(content) | 
|  | elif show_full: | 
|  | text = extract_text(content) | 
|  | text = strip_system_tags(text) | 
|  | else: | 
|  | text = _extract_clean_text(content) | 
|  |  | 
|  | if not text.strip(): | 
|  | continue | 
|  |  | 
|  | role_color = green if role == "user" else yellow | 
|  | prefix = role_color(f"  [{role}]") | 
|  | print(prefix) | 
|  | for line in text.split("\n"): | 
|  | print(f"    {line}") | 
|  | print() | 
|  |  | 
|  |  | 
|  | # ─── File extension categories ─────────────────────────────────────────────── | 
|  |  | 
|  | EXT_CATEGORIES = { | 
|  | "images": {".png", ".jpg", ".jpeg", ".gif", ".bmp", ".svg", ".webp", ".ico", ".tiff"}, | 
|  | "code": { | 
|  | ".py", ".js", ".ts", ".tsx", ".jsx", ".c", ".cc", ".cpp", ".h", ".hpp", | 
|  | ".rs", ".go", ".java", ".rb", ".sh", ".bash", ".zsh", ".lua", ".zig", | 
|  | ".cs", ".swift", ".kt", ".scala", ".r", ".m", ".pl", ".php", ".ex", | 
|  | ".html", ".css", ".scss", ".less", ".vue", ".svelte", | 
|  | }, | 
|  | "data": {".csv", ".tsv", ".json", ".jsonl", ".yaml", ".yml", ".xml", ".toml", ".ini", ".parquet", ".npy", ".npz"}, | 
|  | "docs": {".md", ".txt", ".rst", ".tex", ".pdf", ".org", ".log"}, | 
|  | "config": {".env", ".gitignore", ".dockerignore", ".editorconfig", ".prettierrc", ".eslintrc"}, | 
|  | } | 
|  |  | 
|  | # Tools that run shell commands (Claude Code on Windows can use PowerShell) | 
|  | SHELL_TOOLS = ("Bash", "PowerShell") | 
|  |  | 
|  |  | 
|  | def categorize_file(filepath): | 
|  | ext = os.path.splitext(filepath)[1].lower() | 
|  | for cat, exts in EXT_CATEGORIES.items(): | 
|  | if ext in exts: | 
|  | return cat | 
|  | return "misc" | 
|  |  | 
|  |  | 
|  | def _extract_tool_calls(path): | 
|  | """Extract all tool calls from a session JSONL. Returns list of dicts.""" | 
|  | calls = [] | 
|  | for record in iter_jsonl(path): | 
|  | if record.get("type") != "assistant": | 
|  | continue | 
|  | content = record.get("message", {}).get("content", []) | 
|  | if not isinstance(content, list): | 
|  | continue | 
|  | for item in content: | 
|  | if not isinstance(item, dict) or item.get("type") != "tool_use": | 
|  | continue | 
|  | name = item.get("name", "?") | 
|  | inp = item.get("input", {}) | 
|  | call = {"tool": name, "input": inp} | 
|  |  | 
|  | if name in ("Read", "Write", "Edit"): | 
|  | call["file"] = inp.get("file_path", "") | 
|  | elif name in SHELL_TOOLS: | 
|  | call["command"] = inp.get("command", "") | 
|  |  | 
|  | calls.append(call) | 
|  | return calls | 
|  |  | 
|  |  | 
|  | def cmd_tools(args): | 
|  | """List tool/MCP calls made in a session.""" | 
|  | path = find_session_file(args.session_id) | 
|  | if not path: | 
|  | print(f"No session found matching '{args.session_id}'", file=sys.stderr) | 
|  | sys.exit(1) | 
|  |  | 
|  | calls = _extract_tool_calls(path) | 
|  |  | 
|  | if args.mcp_only: | 
|  | calls = [c for c in calls if c["tool"].startswith("mcp__")] | 
|  | if args.no_bash: | 
|  | calls = [c for c in calls if c["tool"] not in SHELL_TOOLS] | 
|  |  | 
|  | if args.json: | 
|  | print(json.dumps(calls, indent=2)) | 
|  | return | 
|  |  | 
|  | if not calls: | 
|  | print("  No tool calls found.") | 
|  | return | 
|  |  | 
|  | # Group and display | 
|  | tool_counts = defaultdict(int) | 
|  | for c in calls: | 
|  | tool_counts[c["tool"]] += 1 | 
|  |  | 
|  | if args.summary: | 
|  | print(f"\n {bold('Tool call summary')}") | 
|  | print(f"  {'─' * 40}") | 
|  | for tool, count in sorted(tool_counts.items(), key=lambda x: -x[1]): | 
|  | is_mcp = tool.startswith("mcp__") | 
|  | label = magenta(tool) if is_mcp else cyan(tool) | 
|  | print(f"  {count:4d}x  {label}") | 
|  | print(f"\n {dim(f'{len(calls)} total calls')}") | 
|  | return | 
|  |  | 
|  | for c in calls: | 
|  | tool = c["tool"] | 
|  | is_mcp = tool.startswith("mcp__") | 
|  | label = magenta(tool) if is_mcp else cyan(tool) | 
|  |  | 
|  | detail = "" | 
|  | if "file" in c: | 
|  | detail = dim(c["file"]) | 
|  | elif "command" in c: | 
|  | cmd = c["command"][:100].split("\n")[0] | 
|  | detail = dim(cmd) | 
|  | elif is_mcp: | 
|  | inp_str = json.dumps(c["input"]) | 
|  | if len(inp_str) > 100: | 
|  | inp_str = inp_str[:100] + "..." | 
|  | detail = dim(inp_str) | 
|  |  | 
|  | print(f"  {label} {detail}") | 
|  |  | 
|  | print(f"\n {dim(f'{len(calls)} total calls')}") | 
|  |  | 
|  |  | 
|  | def cmd_files(args): | 
|  | """List files accessed in a session, categorized by type.""" | 
|  | path = find_session_file(args.session_id) | 
|  | if not path: | 
|  | print(f"No session found matching '{args.session_id}'", file=sys.stderr) | 
|  | sys.exit(1) | 
|  |  | 
|  | calls = _extract_tool_calls(path) | 
|  |  | 
|  | files_by_cat = defaultdict(lambda: defaultdict(set))  # cat -> {filepath -> set(operations)} | 
|  |  | 
|  | for c in calls: | 
|  | tool = c["tool"] | 
|  | # Only Read/Write/Edit; file paths inside shell commands can't be extracted reliably | 
|  | if tool in ("Read", "Write", "Edit"): | 
|  | fp = c.get("file", "") | 
|  | if not fp: | 
|  | continue | 
|  | cat = categorize_file(fp) | 
|  | op = tool.lower() | 
|  | files_by_cat[cat][fp].add(op) | 
|  |  | 
|  | # Apply category filters | 
|  | selected_cats = set() | 
|  | if args.images: selected_cats.add("images") | 
|  | if args.code: selected_cats.add("code") | 
|  | if args.data: selected_cats.add("data") | 
|  | if args.docs: selected_cats.add("docs") | 
|  | if args.config: selected_cats.add("config") | 
|  | if args.misc: selected_cats.add("misc") | 
|  |  | 
|  | if not selected_cats: | 
|  | selected_cats = set(EXT_CATEGORIES.keys()) \| {"misc"} | 
|  |  | 
|  | if args.json: | 
|  | out = {} | 
|  | for cat in sorted(files_by_cat): | 
|  | if cat not in selected_cats: | 
|  | continue | 
|  | out[cat] = {fp: sorted(ops) for fp, ops in files_by_cat[cat].items()} | 
|  | print(json.dumps(out, indent=2)) | 
|  | return | 
|  |  | 
|  | total = 0 | 
|  | for cat in ["images", "code", "data", "docs", "config", "misc"]: | 
|  | if cat not in selected_cats or cat not in files_by_cat: | 
|  | continue | 
|  | entries = files_by_cat[cat] | 
|  | cat_color = {"images": magenta, "code": cyan, "data": yellow, "docs": green, "config": dim, "misc": dim} | 
|  | color_fn = cat_color.get(cat, dim) | 
|  | print(f"\n {bold(color_fn(cat.upper()))}  ({len(entries)} files)") | 
|  | for fp, ops in sorted(entries.items()): | 
|  | ops_str = ",".join(sorted(ops)) | 
|  | print(f"    {dim(f'[{ops_str}]'):20s} {fp}") | 
|  | total += 1 | 
|  |  | 
|  | print(f"\n {dim(f'{total} files across {len([c for c in files_by_cat if c in selected_cats])} categories')}") | 
|  |  | 
|  |  | 
|  | def _extract_messages(path): | 
|  | """Extract all user/assistant messages from a session. Returns list of (role, text) tuples.""" | 
|  | messages = [] | 
|  | for record in iter_jsonl(path): | 
|  | if record.get("type") not in ("user", "assistant"): | 
|  | continue | 
|  | msg = record.get("message", {}) | 
|  | role = msg.get("role", record["type"]) | 
|  | text = extract_text(msg.get("content", "")) | 
|  | text = strip_system_tags(text) | 
|  | if text.strip(): | 
|  | messages.append((role, text)) | 
|  | return messages | 
|  |  | 
|  |  | 
|  | def _extract_git_commits(path): | 
|  | """Extract git commit messages from Bash tool calls in a session.""" | 
|  | commits = [] | 
|  | calls = _extract_tool_calls(path) | 
|  | for c in calls: | 
|  | if c["tool"] not in SHELL_TOOLS: | 
|  | continue | 
|  | cmd = c.get("command", "") | 
|  | if "git commit" not in cmd: | 
|  | continue | 
|  | # Extract the commit message from a bash HEREDOC, a PowerShell here-string, or -m "..." | 
|  | m = re.search(r'git commit.*?-m\s+"?\$\(cat <<\'?EOF\'?\n(.*?)\nEOF', cmd, re.DOTALL) | 
|  | if m: | 
|  | commits.append(m.group(1).strip().split("\n")[0]) | 
|  | continue | 
|  | m = re.search(r'git commit.*?-m\s+@[\'"]\r?\n(.*?)\r?\n[\'"]@', cmd, re.DOTALL) | 
|  | if m: | 
|  | commits.append(m.group(1).strip().split("\n")[0]) | 
|  | continue | 
|  | m = re.search(r'git commit.*?-m\s+"([^"]+)"', cmd) | 
|  | if m: | 
|  | commits.append(m.group(1).strip().split("\n")[0]) | 
|  | continue | 
|  | m = re.search(r"git commit.*?-m\s+'([^']+)'", cmd) | 
|  | if m: | 
|  | commits.append(m.group(1).strip().split("\n")[0]) | 
|  | return commits | 
|  |  | 
|  |  | 
|  | def _truncate(text, max_len, suffix="..."): | 
|  | """Truncate text to max_len, appending suffix and char count if truncated.""" | 
|  | if len(text) <= max_len: | 
|  | return text | 
|  | remaining = len(text) - max_len | 
|  | return text[:max_len] + f"{suffix} (+{remaining} chars)" | 
|  |  | 
|  |  | 
|  | def _extract_clean_text(content): | 
|  | """Extract text from message content, replacing file diffs with placeholders.""" | 
|  | if isinstance(content, str): | 
|  | return strip_system_tags(content) | 
|  | if not isinstance(content, list): | 
|  | return "" | 
|  | parts = [] | 
|  | for item in content: | 
|  | if not isinstance(item, dict): | 
|  | continue | 
|  | itype = item.get("type", "") | 
|  | if itype == "text": | 
|  | parts.append(item.get("text", "")) | 
|  | elif itype == "tool_use": | 
|  | name = item.get("name", "?") | 
|  | inp = item.get("input", {}) | 
|  | if name in ("Read", "Write", "Edit"): | 
|  | fp = inp.get("file_path", "") | 
|  | parts.append(f"[{name.lower()}: {fp}]") | 
|  | elif name in SHELL_TOOLS: | 
|  | cmd = inp.get("command", "").split("\n")[0][:80] | 
|  | parts.append(f"[{name.lower()}: {cmd}]") | 
|  | elif name.startswith("mcp__"): | 
|  | parts.append(f"[mcp: {name}]") | 
|  | else: | 
|  | parts.append(f"[{name}]") | 
|  | elif itype == "tool_result": | 
|  | rc = item.get("content", "") | 
|  | rc_text = "" | 
|  | if isinstance(rc, str): | 
|  | rc_text = rc | 
|  | elif isinstance(rc, list): | 
|  | rc_text = " ".join( | 
|  | r.get("text", "") for r in rc | 
|  | if isinstance(r, dict) and r.get("type") == "text" | 
|  | ) | 
|  | # Detect file diffs/contents and replace with placeholder | 
|  | lines = rc_text.split("\n") | 
|  | if len(lines) > 10 and any(l.startswith(("---", "+++", "@@", "diff ")) for l in lines[:5]): | 
|  | parts.append(f"[diff output, {len(lines)} lines]") | 
|  | elif len(lines) > 15: | 
|  | parts.append(f"[output, {len(lines)} lines]") | 
|  | elif rc_text.strip(): | 
|  | parts.append(rc_text) | 
|  | text = "\n".join(parts) | 
|  | return strip_system_tags(text) | 
|  |  | 
|  |  | 
|  | def _extract_all_tools_text(content): | 
|  | """Extract text showing every tool_use with full input and every tool_result in full.""" | 
|  | if isinstance(content, str): | 
|  | return strip_system_tags(content) | 
|  | if not isinstance(content, list): | 
|  | return "" | 
|  | parts = [] | 
|  | for item in content: | 
|  | if not isinstance(item, dict): | 
|  | continue | 
|  | itype = item.get("type", "") | 
|  | if itype == "text": | 
|  | parts.append(item.get("text", "")) | 
|  | elif itype == "tool_use": | 
|  | name = item.get("name", "?") | 
|  | inp = item.get("input", {}) | 
|  | try: | 
|  | inp_str = json.dumps(inp, indent=2, ensure_ascii=False) | 
|  | except (TypeError, ValueError): | 
|  | inp_str = repr(inp) | 
|  | parts.append(f"[tool_use: {name}]\n{inp_str}") | 
|  | elif itype == "tool_result": | 
|  | rc = item.get("content", "") | 
|  | if isinstance(rc, str): | 
|  | rc_text = rc | 
|  | elif isinstance(rc, list): | 
|  | rc_text = "\n".join( | 
|  | r.get("text", "") for r in rc | 
|  | if isinstance(r, dict) and r.get("type") == "text" | 
|  | ) | 
|  | else: | 
|  | rc_text = "" | 
|  | parts.append(f"[tool_result]\n{rc_text}") | 
|  | text = "\n".join(parts) | 
|  | return strip_system_tags(text) | 
|  |  | 
|  |  | 
|  | def cmd_summary(args): | 
|  | """Show a summary of recent sessions: conversations, files, and commits.""" | 
|  | project_dirs = _resolve_project_dirs(args) | 
|  | sessions = list(discover_sessions(project_dirs=project_dirs)) | 
|  | sessions.sort(key=lambda s: s[4] or 0, reverse=True) | 
|  | sessions = sessions[:args.limit] | 
|  |  | 
|  | if not sessions: | 
|  | print("  No sessions found.") | 
|  | return | 
|  |  | 
|  | # ── Git commits across recent sessions ── | 
|  | all_commits = [] | 
|  | for sid, proj, path, title, ts in sessions: | 
|  | commits = _extract_git_commits(Path(path)) | 
|  | for msg in commits: | 
|  | date_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d") if ts else "?" | 
|  | all_commits.append((date_str, proj, msg)) | 
|  |  | 
|  | if all_commits: | 
|  | print(f"\n {bold('Git commits')}") | 
|  | print(f"  {'─' * 60}") | 
|  | for date_str, proj, msg in all_commits[:8]: | 
|  | print(f"    {dim(date_str)} {dim('[' + proj + ']')} {msg[:80]}") | 
|  | if len(all_commits) > 8: | 
|  | print(f"    {dim(f'... +{len(all_commits) - 8} more')}") | 
|  | print() | 
|  |  | 
|  | # ── Recent conversations ── | 
|  | print(f"  {bold('Recent conversations')}") | 
|  | print(f"  {'─' * 60}") | 
|  | for sid, proj, path, title, ts in sessions: | 
|  | date_str = datetime.fromtimestamp(ts).strftime("%Y-%m-%d %H:%M") if ts else "?" | 
|  | short_id = sid[:8] | 
|  | print(f"\n {cyan(short_id)} {dim(date_str)} {dim('[' + proj + ']')} {bold(title)}") | 
|  |  | 
|  | messages = _extract_messages(Path(path)) | 
|  | # Show first user message (3 lines max) | 
|  | for role, text in messages: | 
|  | if role == "user": | 
|  | lines = [l for l in text.split("\n") if l.strip()][:3] | 
|  | for line in lines: | 
|  | print(f"    {green('>')} {_truncate(line, 90)}") | 
|  | if len([l for l in text.split("\n") if l.strip()]) > 3: | 
|  | print(f"    {dim('...')}") | 
|  | break | 
|  |  | 
|  | # Show last assistant message (2 lines max, truncated) | 
|  | for role, text in reversed(messages): | 
|  | if role == "assistant": | 
|  | lines = [l for l in text.split("\n") if l.strip()][:2] | 
|  | for line in lines: | 
|  | print(f"    {yellow('<')} {_truncate(line, 80)}") | 
|  | remaining_lines = len([l for l in text.split("\n") if l.strip()]) - 2 | 
|  | if remaining_lines > 0: | 
|  | print(f"    {dim(f'  ... +{remaining_lines} lines')}") | 
|  | break | 
|  |  | 
|  | # ── Files across recent sessions ── | 
|  | all_files = defaultdict(lambda: {"ops": set(), "session": None, "ts": 0}) | 
|  | for sid, proj, path, title, ts in sessions: | 
|  | calls = _extract_tool_calls(Path(path)) | 
|  | for c in calls: | 
|  | if c["tool"] in ("Read", "Write", "Edit"): | 
|  | fp = c.get("file", "") | 
|  | if not fp: | 
|  | continue | 
|  | entry = all_files[fp] | 
|  | entry["ops"].add(c["tool"].lower()) | 
|  | if ts and ts > entry["ts"]: | 
|  | entry["ts"] = ts | 
|  | entry["session"] = sid[:8] | 
|  |  | 
|  | if all_files: | 
|  | by_cat = defaultdict(list) | 
|  | for fp, info in all_files.items(): | 
|  | cat = categorize_file(fp) | 
|  | by_cat[cat].append((fp, info)) | 
|  |  | 
|  | # Sort each category by timestamp descending | 
|  | for cat in by_cat: | 
|  | by_cat[cat].sort(key=lambda x: -x[1]["ts"]) | 
|  |  | 
|  | print(f"\n\n {bold('Files touched')}") | 
|  | print(f"  {'─' * 60}") | 
|  |  | 
|  | cat_order = ["images", "code", "data", "docs", "config", "misc"] | 
|  | for cat in cat_order: | 
|  | if cat not in by_cat: | 
|  | continue | 
|  | entries = by_cat[cat] | 
|  | cat_color = {"images": magenta, "code": cyan, "data": yellow, "docs": green, "config": dim, "misc": dim} | 
|  | color_fn = cat_color.get(cat, dim) | 
|  |  | 
|  | shown = entries[:3] | 
|  | extra = len(entries) - 3 | 
|  | print(f"\n {color_fn(cat.upper())}  ({len(entries)} files)") | 
|  |  | 
|  | for i, (fp, info) in enumerate(shown): | 
|  | age = f"T-{i}" if i > 0 else "T  " | 
|  | ops = ",".join(sorted(info["ops"])) | 
|  | basename = os.path.basename(fp) | 
|  | dirname = os.path.dirname(fp) | 
|  | # Shorten home prefix | 
|  | dirname = dirname.replace(str(Path.home()), "~") | 
|  | print(f"      {dim(age)} {dim(f'[{ops}]'):18s} {basename} {dim(dirname)}") | 
|  |  | 
|  | if extra > 0: | 
|  | print(f"      {dim(f'     +{extra} more')}") | 
|  |  | 
|  | print() | 
|  |  | 
|  |  | 
|  | def cmd_index(args): | 
|  | """Build a term-frequency index across all sessions for fast search.""" | 
|  | print("Building index...") | 
|  | index = {} | 
|  | count = 0 | 
|  |  | 
|  | for sid, proj, path, title, ts in discover_sessions(): | 
|  | count += 1 | 
|  | terms = defaultdict(int) | 
|  |  | 
|  | for record in iter_jsonl(path): | 
|  | if record.get("type") not in ("user", "assistant"): | 
|  | continue | 
|  | text = extract_text(record.get("message", {}).get("content", "")) | 
|  | text = strip_system_tags(text).lower() | 
|  | # Tokenize: split on non-alphanumeric, keep tokens >= 3 chars | 
|  | for word in re.findall(r"[a-z0-9_]{3,}", text): | 
|  | terms[word] += 1 | 
|  |  | 
|  | # Keep top 100 terms per session (skip ultra-common ones) | 
|  | STOPWORDS = { | 
|  | "the", "and", "for", "that", "this", "with", "you", "are", "from", | 
|  | "have", "has", "was", "were", "will", "can", "not", "but", "they", | 
|  | "been", "would", "could", "should", "which", "their", "there", | 
|  | "about", "into", "than", "then", "them", "these", "those", | 
|  | "some", "other", "its", "your", "our", "what", "when", "how", | 
|  | "all", "each", "more", "also", "use", "used", "using", | 
|  | "text", "type", "tool", "name", "content", "message", | 
|  | } | 
|  | filtered = {k: v for k, v in terms.items() if k not in STOPWORDS} | 
|  | top = sorted(filtered.items(), key=lambda x: -x[1])[:100] | 
|  |  | 
|  | index[sid] = { | 
|  | "project": proj, | 
|  | "title": title, | 
|  | "timestamp": ts, | 
|  | "date": datetime.fromtimestamp(ts).isoformat() if ts else None, | 
|  | "terms": {k: v for k, v in top}, | 
|  | } | 
|  |  | 
|  | INDEX_PATH.parent.mkdir(parents=True, exist_ok=True) | 
|  | with open(INDEX_PATH, "w", encoding="utf-8") as f: | 
|  | json.dump(index, f, indent=1) | 
|  |  | 
|  | total_terms = sum(len(v["terms"]) for v in index.values()) | 
|  | print(f"Indexed {count} sessions, {total_terms} unique terms.") | 
|  | print(f"Index saved to {INDEX_PATH}") | 
|  |  | 
|  |  | 
|  | def cmd_projects(args): | 
|  | projects = defaultdict(int) | 
|  | for proj_dir in sorted(PROJECTS_DIR.iterdir()): | 
|  | if not proj_dir.is_dir(): | 
|  | continue | 
|  | label = project_label(proj_dir) | 
|  | count = sum(1 for _ in proj_dir.glob("*.jsonl")) | 
|  | projects[label] = count | 
|  |  | 
|  | for proj, count in sorted(projects.items(), key=lambda x: -x[1]): | 
|  | print(f"  {cyan(f'{count:3d}')} sessions  {proj}") | 
|  |  | 
|  |  | 
|  | def cmd_stats(args): | 
|  | total_sessions = 0 | 
|  | total_messages = 0 | 
|  | total_bytes = 0 | 
|  | by_project = defaultdict(lambda: {"sessions": 0, "messages": 0, "bytes": 0}) | 
|  |  | 
|  | for proj_dir in sorted(PROJECTS_DIR.iterdir()): | 
|  | if not proj_dir.is_dir(): | 
|  | continue | 
|  | label = project_label(proj_dir) | 
|  |  | 
|  | for jsonl_file in proj_dir.glob("*.jsonl"): | 
|  | total_sessions += 1 | 
|  | by_project[label]["sessions"] += 1 | 
|  | fsize = jsonl_file.stat().st_size | 
|  | total_bytes += fsize | 
|  | by_project[label]["bytes"] += fsize | 
|  |  | 
|  | for record in iter_jsonl(jsonl_file): | 
|  | if record.get("type") in ("user", "assistant"): | 
|  | total_messages += 1 | 
|  | by_project[label]["messages"] += 1 | 
|  |  | 
|  | print(f"\n {bold('Claude Code History Stats')}") | 
|  | print(f"  {'━' * 40}") | 
|  | print(f"  Sessions:  {total_sessions}") | 
|  | print(f"  Messages:  {total_messages}") | 
|  | print(f"  Size:      {total_bytes / 1024 / 1024:.1f} MB") | 
|  | print() | 
|  |  | 
|  | for proj, s in sorted(by_project.items(), key=lambda x: -x[1]["sessions"]): | 
|  | print(f"  {cyan(proj):40s} {s['sessions']:3d} sessions  {s['messages']:5d} msgs  {s['bytes']/1024:.0f} KB") | 
|  | print() | 
|  |  | 
|  |  | 
|  | # ─── Completions ───────────────────────────────────────────────────────────── | 
|  |  | 
|  | ZSH_COMPLETION = r''' | 
|  | #compdef cc-history | 
|  |  | 
|  | _cc-history() { | 
|  | local -a commands | 
|  | commands=( | 
|  | 'list:List sessions' | 
|  | 'ls:List sessions' | 
|  | 'search:Search conversations' | 
|  | 's:Search conversations' | 
|  | 'grep:Search conversations' | 
|  | 'read:Read a session' | 
|  | 'r:Read a session' | 
|  | 'show:Read a session' | 
|  | 'summary:Summary of recent sessions' | 
|  | 'tools:List tool/MCP calls' | 
|  | 't:List tool/MCP calls' | 
|  | 'calls:List tool/MCP calls' | 
|  | 'files:List files accessed' | 
|  | 'f:List files accessed' | 
|  | 'index:Build search term index' | 
|  | 'projects:List projects' | 
|  | 'p:List projects' | 
|  | 'stats:Show stats' | 
|  | 'completions:Generate shell completions' | 
|  | ) | 
|  |  | 
|  | _arguments -C \ | 
|  | '1:command:->command' \ | 
|  | '*::arg:->args' | 
|  |  | 
|  | case "$state" in | 
|  | command) | 
|  | _describe -t commands 'cc-history command' commands | 
|  | ;; | 
|  | args) | 
|  | case "$words[1]" in | 
|  | list\|ls) | 
|  | _arguments \ | 
|  | '(-g --global)'{-g,--global}'[Search all projects]' \ | 
|  | '(-p --project)'{-p,--project}'[Filter by project]:project:_cc_history_projects' \ | 
|  | '(-n --limit)'{-n,--limit}'[Max sessions to show]:limit:' \ | 
|  | '(-d --date)'{-d,--date}'[Filter by date]:date expression:' \ | 
|  | '--json[JSON output]' \ | 
|  | '--pick[Interactive picker]' \ | 
|  | '--fzf[Use fzf for picker]' | 
|  | ;; | 
|  | search\|s\|grep) | 
|  | _arguments \ | 
|  | '1:search term:' \ | 
|  | '(-g --global)'{-g,--global}'[Search all projects]' \ | 
|  | '(-E --regex)'{-E,--regex}'[Treat term as regex]' \ | 
|  | '(-F --fuzzy)'{-F,--fuzzy}'[Fuzzy match]' \ | 
|  | '(-p --project)'{-p,--project}'[Filter by project]:project:_cc_history_projects' \ | 
|  | '(-d --date)'{-d,--date}'[Filter by date]:date expression:' \ | 
|  | '(-C --context)'{-C,--context}'[Lines of context]:lines:' \ | 
|  | '--json[JSON output]' | 
|  | ;; | 
|  | read\|r\|show) | 
|  | _arguments \ | 
|  | '1:session ID:_cc_history_sessions' \ | 
|  | '(-g --global)'{-g,--global}'[Pick from all projects]' \ | 
|  | '--ai[Show last AI message]' \ | 
|  | '--human[Show last human message]' \ | 
|  | '--git[Show git commits]' \ | 
|  | '--full[Show full unabridged content]' \ | 
|  | '--raw[Show raw JSON content]' \ | 
|  | '--no-tools[Hide tool blocks]' \ | 
|  | '--all-tools[Show every tool call in full]' \ | 
|  | '--fzf[Use fzf for picker]' | 
|  | ;; | 
|  | summary) | 
|  | _arguments \ | 
|  | '(-g --global)'{-g,--global}'[Include all projects]' \ | 
|  | '(-n --limit)'{-n,--limit}'[Number of sessions]:count:' | 
|  | ;; | 
|  | tools\|t\|calls) | 
|  | _arguments \ | 
|  | '1:session ID:_cc_history_sessions' \ | 
|  | '--mcp-only[Only MCP calls]' \ | 
|  | '--no-bash[Hide Bash calls]' \ | 
|  | '--summary[Count summary only]' \ | 
|  | '--json[JSON output]' | 
|  | ;; | 
|  | files\|f) | 
|  | _arguments \ | 
|  | '1:session ID:_cc_history_sessions' \ | 
|  | '--images[Only image files]' \ | 
|  | '--code[Only code files]' \ | 
|  | '--data[Only data files]' \ | 
|  | '--docs[Only document files]' \ | 
|  | '--config[Only config files]' \ | 
|  | '--misc[Only uncategorized files]' \ | 
|  | '--json[JSON output]' | 
|  | ;; | 
|  | completions) | 
|  | _arguments '1:shell:(zsh bash fish powershell)' | 
|  | ;; | 
|  | esac | 
|  | ;; | 
|  | esac | 
|  | } | 
|  |  | 
|  | _cc_history_projects() { | 
|  | local -a projects | 
|  | projects=(${(f)"$(cc-history projects 2>/dev/null \| sed 's/^[[:space:]]*[0-9]* sessions[[:space:]]*//')"}) | 
|  | _describe -t projects 'project' projects | 
|  | } | 
|  |  | 
|  | _cc_history_sessions() { | 
|  | local -a sessions | 
|  | local idx="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/cache/cc-history-index.json" | 
|  | if [[ -f $idx ]]; then | 
|  | sessions=(${(f)"$(python3 -c " | 
|  | import json, sys | 
|  | with open(sys.argv[1], encoding='utf-8') as f: | 
|  | idx = json.load(f) | 
|  | for sid, info in sorted(idx.items(), key=lambda x: x[1].get('timestamp') or 0, reverse=True)[:30]: | 
|  | title = info.get('title', '').replace(':', ' ').replace(\"'\", '')[:60] | 
|  | print(f'{sid[:8]}:{title}') | 
|  | " "$idx" 2>/dev/null)"}) | 
|  | else | 
|  | sessions=(${(f)"$(cc-history list --limit 30 2>/dev/null \| sed -n 's/^[[:space:]]*$[a-f0-9]\{8\}$.*/\1/p')"}) | 
|  | fi | 
|  | _describe -t sessions 'session' sessions | 
|  | } | 
|  |  | 
|  | compdef _cc-history cc-history | 
|  | ''' | 
|  |  | 
|  | BASH_COMPLETION = r''' | 
|  | _cc_history() { | 
|  | local cur prev commands | 
|  | COMPREPLY=() | 
|  | cur="${COMP_WORDS[COMP_CWORD]}" | 
|  | prev="${COMP_WORDS[COMP_CWORD-1]}" | 
|  |  | 
|  | commands="list ls search s grep read r show summary tools t calls files f index projects p stats completions" | 
|  |  | 
|  | if [[ ${COMP_CWORD} -eq 1 ]]; then | 
|  | COMPREPLY=( $(compgen -W "${commands}" -- "${cur}") ) | 
|  | return 0 | 
|  | fi | 
|  |  | 
|  | local subcmd="${COMP_WORDS[1]}" | 
|  |  | 
|  | case "${subcmd}" in | 
|  | list\|ls) | 
|  | COMPREPLY=( $(compgen -W "-g --global --project -p --limit -n --date -d --json --pick --fzf" -- "${cur}") ) | 
|  | if [[ "${prev}" == "-p" \|\| "${prev}" == "--project" ]]; then | 
|  | local projects | 
|  | projects=$(cc-history projects 2>/dev/null \| sed 's/^[[:space:]]*[0-9]* sessions[[:space:]]*//') | 
|  | COMPREPLY=( $(compgen -W "${projects}" -- "${cur}") ) | 
|  | fi | 
|  | ;; | 
|  | search\|s\|grep) | 
|  | COMPREPLY=( $(compgen -W "-g --global --regex -E --fuzzy -F --project -p --date -d --context -C --json" -- "${cur}") ) | 
|  | if [[ "${prev}" == "-p" \|\| "${prev}" == "--project" ]]; then | 
|  | local projects | 
|  | projects=$(cc-history projects 2>/dev/null \| sed 's/^[[:space:]]*[0-9]* sessions[[:space:]]*//') | 
|  | COMPREPLY=( $(compgen -W "${projects}" -- "${cur}") ) | 
|  | fi | 
|  | ;; | 
|  | read\|r\|show) | 
|  | if [[ ${COMP_CWORD} -eq 2 ]]; then | 
|  | local sessions | 
|  | sessions=$(cc-history list --limit 30 2>/dev/null \| sed -n 's/^[[:space:]]*$[a-f0-9]\{8\}$.*/\1/p') | 
|  | COMPREPLY=( $(compgen -W "${sessions} -g --global --fzf" -- "${cur}") ) | 
|  | else | 
|  | COMPREPLY=( $(compgen -W "--ai --human --git --full --raw --no-tools --all-tools" -- "${cur}") ) | 
|  | fi | 
|  | ;; | 
|  | summary) | 
|  | COMPREPLY=( $(compgen -W "-g --global --limit -n" -- "${cur}") ) | 
|  | ;; | 
|  | tools\|t\|calls) | 
|  | if [[ ${COMP_CWORD} -eq 2 ]]; then | 
|  | local sessions | 
|  | sessions=$(cc-history list --limit 30 2>/dev/null \| sed -n 's/^[[:space:]]*$[a-f0-9]\{8\}$.*/\1/p') | 
|  | COMPREPLY=( $(compgen -W "${sessions}" -- "${cur}") ) | 
|  | else | 
|  | COMPREPLY=( $(compgen -W "--mcp-only --no-bash --summary --json" -- "${cur}") ) | 
|  | fi | 
|  | ;; | 
|  | files\|f) | 
|  | if [[ ${COMP_CWORD} -eq 2 ]]; then | 
|  | local sessions | 
|  | sessions=$(cc-history list --limit 30 2>/dev/null \| sed -n 's/^[[:space:]]*$[a-f0-9]\{8\}$.*/\1/p') | 
|  | COMPREPLY=( $(compgen -W "${sessions}" -- "${cur}") ) | 
|  | else | 
|  | COMPREPLY=( $(compgen -W "--images --code --data --docs --config --misc --json" -- "${cur}") ) | 
|  | fi | 
|  | ;; | 
|  | completions) | 
|  | COMPREPLY=( $(compgen -W "zsh bash fish powershell" -- "${cur}") ) | 
|  | ;; | 
|  | esac | 
|  | return 0 | 
|  | } | 
|  | complete -F _cc_history cc-history | 
|  | ''' | 
|  |  | 
|  | FISH_COMPLETION = r''' | 
|  | # Disable file completions for cc-history | 
|  | complete -c cc-history -f | 
|  |  | 
|  | # Subcommands | 
|  | complete -c cc-history -n "__fish_use_subcommand" -a "list ls"       -d "List sessions" | 
|  | complete -c cc-history -n "__fish_use_subcommand" -a "search s grep" -d "Search conversations" | 
|  | complete -c cc-history -n "__fish_use_subcommand" -a "read r show"   -d "Read a session" | 
|  | complete -c cc-history -n "__fish_use_subcommand" -a "summary"        -d "Summary of recent sessions" | 
|  | complete -c cc-history -n "__fish_use_subcommand" -a "tools t calls"  -d "List tool/MCP calls" | 
|  | complete -c cc-history -n "__fish_use_subcommand" -a "files f"       -d "List files accessed" | 
|  | complete -c cc-history -n "__fish_use_subcommand" -a "index"         -d "Build search term index" | 
|  | complete -c cc-history -n "__fish_use_subcommand" -a "projects p"    -d "List projects" | 
|  | complete -c cc-history -n "__fish_use_subcommand" -a "stats"         -d "Show stats" | 
|  | complete -c cc-history -n "__fish_use_subcommand" -a "completions"   -d "Generate shell completions" | 
|  |  | 
|  | # list flags | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from list ls" -s p -l project -d "Filter by project" -x -a "(cc-history projects 2>/dev/null \| string replace -r '^\s*\d+ sessions\s+' '')" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from list ls" -s n -l limit   -d "Max sessions" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from list ls"      -l json    -d "JSON output" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from list ls"      -l pick    -d "Interactive picker" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from list ls"      -l fzf     -d "Use fzf for picker" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from list ls" -s d -l date    -d "Filter by date" | 
|  |  | 
|  | # search flags | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from search s grep" -s E -l regex   -d "Regex mode" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from search s grep" -s F -l fuzzy   -d "Fuzzy match" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from search s grep" -s p -l project -d "Filter by project" -x -a "(cc-history projects 2>/dev/null \| string replace -r '^\s*\d+ sessions\s+' '')" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from search s grep" -s d -l date    -d "Filter by date" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from search s grep" -s C -l context -d "Context lines" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from search s grep"      -l json    -d "JSON output" | 
|  |  | 
|  | # read flags | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from read r show" -l raw      -d "Raw JSON content" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from read r show" -l no-tools  -d "Hide tool blocks" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from read r show" -l all-tools -d "Show every tool call in full" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from read r show" -l ai       -d "Last AI message" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from read r show" -l human    -d "Last human message" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from read r show" -l git      -d "Show git commits" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from read r show" -l full     -d "Full unabridged content" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from read r show" -l fzf     -d "Use fzf for picker" | 
|  |  | 
|  | # summary flags | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from summary" -s g -l global -d "Include all projects" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from summary" -s n -l limit  -d "Number of sessions" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from read r show" -x -a "(cc-history list --limit 30 2>/dev/null \| string replace -r '^\s*([a-f0-9]{8}).*' '\$1')" | 
|  |  | 
|  | # tools flags | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from tools t calls" -l mcp-only -d "Only MCP calls" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from tools t calls" -l no-bash  -d "Hide Bash calls" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from tools t calls" -l summary  -d "Count summary" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from tools t calls" -l json     -d "JSON output" | 
|  |  | 
|  | # files flags | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from files f" -l images        -d "Only images" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from files f" -l code          -d "Only code" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from files f" -l data          -d "Only data files" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from files f" -l docs          -d "Only documents" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from files f" -l config        -d "Only config" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from files f" -l misc          -d "Only uncategorized" | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from files f" -l json          -d "JSON output" | 
|  |  | 
|  | # completions flags | 
|  | complete -c cc-history -n "__fish_seen_subcommand_from completions" -x -a "zsh bash fish powershell" | 
|  | ''' | 
|  |  | 
|  | POWERSHELL_COMPLETION = r''' | 
|  | Register-ArgumentCompleter -Native -CommandName cc-history, cc-history.exe -ScriptBlock { | 
|  | param($wordToComplete, $commandAst, $cursorPosition) | 
|  |  | 
|  | $commands = [ordered]@{ | 
|  | 'list' = 'List sessions'; 'search' = 'Search conversations'; 'read' = 'Read a session' | 
|  | 'summary' = 'Summary of recent sessions'; 'tools' = 'List tool/MCP calls' | 
|  | 'files' = 'List files accessed'; 'index' = 'Build search term index' | 
|  | 'projects' = 'List projects'; 'stats' = 'Show stats'; 'completions' = 'Generate shell completions' | 
|  | } | 
|  | $aliases = @{ 'ls' = 'list'; 's' = 'search'; 'grep' = 'search'; 'r' = 'read'; 'show' = 'read' | 
|  | 't' = 'tools'; 'calls' = 'tools'; 'f' = 'files'; 'p' = 'projects' } | 
|  | $flags = @{ | 
|  | 'list'        = @('-g', '--global', '-p', '--project', '-n', '--limit', '-d', '--date', '--json', '--pick', '--fzf') | 
|  | 'search'      = @('-g', '--global', '-E', '--regex', '-F', '--fuzzy', '-p', '--project', '-d', '--date', '-C', '--context', '-T', '--timeout', '--json') | 
|  | 'read'        = @('-g', '--global', '--ai', '--human', '--git', '--full', '--raw', '--no-tools', '--all-tools', '--fzf') | 
|  | 'summary'     = @('-g', '--global', '-n', '--limit') | 
|  | 'tools'       = @('--mcp-only', '--no-bash', '--summary', '--json') | 
|  | 'files'       = @('--images', '--code', '--data', '--docs', '--config', '--misc', '--json') | 
|  | 'completions' = @('zsh', 'bash', 'fish', 'powershell') | 
|  | } | 
|  |  | 
|  | $words = @($commandAst.CommandElements \| ForEach-Object { $_.ToString() }) | 
|  | if ($words.Count -lt 2 -or ($words.Count -eq 2 -and $wordToComplete)) { | 
|  | foreach ($cmd in $commands.Keys) { | 
|  | if ($cmd -like "$wordToComplete*") { | 
|  | [System.Management.Automation.CompletionResult]::new($cmd, $cmd, 'ParameterValue', $commands[$cmd]) | 
|  | } | 
|  | } | 
|  | return | 
|  | } | 
|  |  | 
|  | $sub = $words[1] | 
|  | if ($aliases.ContainsKey($sub)) { $sub = $aliases[$sub] } | 
|  | $candidates = @() | 
|  | if ($sub -in 'read', 'tools', 'files' -and -not $wordToComplete.StartsWith('-')) { | 
|  | $candidates += @(cc-history list --limit 30 --json 2>$null \| ConvertFrom-Json) \| | 
|  | ForEach-Object { $_.session_id.Substring(0, 8) } | 
|  | } | 
|  | if ($flags.ContainsKey($sub)) { $candidates += $flags[$sub] } | 
|  | foreach ($c in $candidates) { | 
|  | if ($c -like "$wordToComplete*") { | 
|  | [System.Management.Automation.CompletionResult]::new($c, $c, 'ParameterValue', $c) | 
|  | } | 
|  | } | 
|  | } | 
|  | ''' | 
|  |  | 
|  |  | 
|  | def cmd_completions(args): | 
|  | shell = args.shell | 
|  | if shell is None: | 
|  | shell_env = os.environ.get("SHELL", "") | 
|  | if "zsh" in shell_env: | 
|  | shell = "zsh" | 
|  | elif "fish" in shell_env: | 
|  | shell = "fish" | 
|  | elif IS_WINDOWS and not shell_env: | 
|  | shell = "powershell" | 
|  | else: | 
|  | shell = "bash" | 
|  |  | 
|  | if shell == "zsh": | 
|  | print(ZSH_COMPLETION.strip()) | 
|  | elif shell == "bash": | 
|  | print(BASH_COMPLETION.strip()) | 
|  | elif shell == "fish": | 
|  | print(FISH_COMPLETION.strip()) | 
|  | elif shell == "powershell": | 
|  | print(POWERSHELL_COMPLETION.strip()) | 
|  |  | 
|  |  | 
|  | # ─── Main ──────────────────────────────────────────────────────────────────── | 
|  |  | 
|  | def main(): | 
|  | parser = argparse.ArgumentParser( | 
|  | prog="cc-history", | 
|  | description="Browse and search Claude Code conversation history.", | 
|  | ) | 
|  | sub = parser.add_subparsers(dest="command") | 
|  |  | 
|  | # list | 
|  | p_list = sub.add_parser("list", aliases=["ls"], help="List sessions") | 
|  | p_list.add_argument("-g", "--global", dest="glob", action="store_true", help="Search all projects (default: current project only)") | 
|  | p_list.add_argument("--project", "-p", help="Filter by project name (substring match)") | 
|  | p_list.add_argument("--limit", "-n", type=int, help="Max sessions to show") | 
|  | p_list.add_argument("--date", "-d", help="Filter by date (e.g. 'yesterday', 'past 3 days', 'before may 5')") | 
|  | p_list.add_argument("--json", action="store_true", help="JSON output") | 
|  | p_list.add_argument("--pick", action="store_true", help="Interactive session picker") | 
|  | p_list.add_argument("--fzf", action="store_true", help="Use fzf for interactive picker") | 
|  |  | 
|  | # search | 
|  | p_search = sub.add_parser("search", aliases=["s", "grep"], help="Search conversations") | 
|  | p_search.add_argument("term", help="Search term (plain text or regex with -E)") | 
|  | p_search.add_argument("-g", "--global", dest="glob", action="store_true", help="Search all projects (default: current project only)") | 
|  | p_search.add_argument("--regex", "-E", action="store_true", help="Treat term as a regex pattern") | 
|  | p_search.add_argument("--fuzzy", "-F", action="store_true", help="Fuzzy match (typo-tolerant search)") | 
|  | p_search.add_argument("--project", "-p", help="Filter by project") | 
|  | p_search.add_argument("--date", "-d", help="Filter by date (e.g. 'two days ago', 'in the past week')") | 
|  | p_search.add_argument("--context", "-C", type=int, default=1, help="Lines of context around match") | 
|  | p_search.add_argument("--timeout", "-T", type=int, default=None, help="Timeout in seconds (default: 10 for fuzzy, unlimited otherwise)") | 
|  | p_search.add_argument("--json", action="store_true", help="JSON output") | 
|  |  | 
|  | # read | 
|  | p_read = sub.add_parser("read", aliases=["r", "show"], help="Read a session") | 
|  | p_read.add_argument("session_id", nargs="?", default=None, help="Session ID (prefix match OK, omit for picker)") | 
|  | p_read.add_argument("-g", "--global", dest="glob", action="store_true", help="Pick from all projects (default: current project only)") | 
|  | p_read.add_argument("--ai", action="store_true", help="Show only the last AI message") | 
|  | p_read.add_argument("--human", action="store_true", help="Show only the last human message") | 
|  | p_read.add_argument("--git", action="store_true", help="Show git commits from the session") | 
|  | p_read.add_argument("--full", action="store_true", help="Show full unabridged content (including diffs/tool output)") | 
|  | p_read.add_argument("--raw", action="store_true", help="Show raw content (JSON)") | 
|  | p_read.add_argument("--no-tools", action="store_true", help="Hide tool_use/tool_result blocks") | 
|  | p_read.add_argument("--all-tools", dest="all_tools", action="store_true", | 
|  | help="Show every tool_use with full input and every tool_result in full") | 
|  | p_read.add_argument("--fzf", action="store_true", help="Use fzf for interactive picker") | 
|  |  | 
|  | # summary | 
|  | p_summary = sub.add_parser("summary", help="Summary of recent sessions, files, and commits") | 
|  | p_summary.add_argument("-g", "--global", dest="glob", action="store_true", help="Include all projects") | 
|  | p_summary.add_argument("--limit", "-n", type=int, default=5, help="Number of recent sessions (default: 5)") | 
|  |  | 
|  | # tools | 
|  | p_tools = sub.add_parser("tools", aliases=["t", "calls"], help="List tool/MCP calls in a session") | 
|  | p_tools.add_argument("session_id", help="Session ID (prefix match OK)") | 
|  | p_tools.add_argument("--mcp-only", action="store_true", help="Only show MCP tool calls") | 
|  | p_tools.add_argument("--no-bash", action="store_true", help="Hide shell (Bash/PowerShell) tool calls") | 
|  | p_tools.add_argument("--summary", action="store_true", help="Show call count summary only") | 
|  | p_tools.add_argument("--json", action="store_true", help="JSON output") | 
|  |  | 
|  | # files | 
|  | p_files = sub.add_parser("files", aliases=["f"], help="List files accessed in a session") | 
|  | p_files.add_argument("session_id", help="Session ID (prefix match OK)") | 
|  | p_files.add_argument("--images", action="store_true", help="Only show image files") | 
|  | p_files.add_argument("--code", action="store_true", help="Only show code files") | 
|  | p_files.add_argument("--data", action="store_true", help="Only show data files (csv, json, etc.)") | 
|  | p_files.add_argument("--docs", action="store_true", help="Only show document files") | 
|  | p_files.add_argument("--config", action="store_true", help="Only show config files") | 
|  | p_files.add_argument("--misc", action="store_true", help="Only show uncategorized files") | 
|  | p_files.add_argument("--json", action="store_true", help="JSON output") | 
|  |  | 
|  | # index | 
|  | sub.add_parser("index", help="Build search term index (speeds up zsh session completion)") | 
|  |  | 
|  | # projects | 
|  | sub.add_parser("projects", aliases=["p"], help="List projects") | 
|  |  | 
|  | # stats | 
|  | sub.add_parser("stats", help="Show stats") | 
|  |  | 
|  | # completions | 
|  | p_comp = sub.add_parser("completions", help="Generate shell completions") | 
|  | p_comp.add_argument("shell", nargs="?", default=None, choices=["zsh", "bash", "fish", "powershell"], | 
|  | help="Shell type (auto-detected if omitted)") | 
|  |  | 
|  | # Exit quietly when piped into `head` etc. (no SIGPIPE on Windows) | 
|  | if hasattr(signal, "SIGPIPE"): | 
|  | signal.signal(signal.SIGPIPE, signal.SIG_DFL) | 
|  |  | 
|  | # Don't crash on characters the console/pipe encoding can't represent (e.g. cp1252 on Windows) | 
|  | for stream in (sys.stdout, sys.stderr): | 
|  | try: | 
|  | stream.reconfigure(errors="replace") | 
|  | except (AttributeError, ValueError): | 
|  | pass | 
|  |  | 
|  | args = parser.parse_args() | 
|  |  | 
|  | if args.command not in (None, "completions") and not PROJECTS_DIR.is_dir(): | 
|  | print(f"No Claude Code history found at {PROJECTS_DIR}", file=sys.stderr) | 
|  | print("Set CLAUDE_CONFIG_DIR if your Claude config lives elsewhere.", file=sys.stderr) | 
|  | sys.exit(1) | 
|  |  | 
|  | if args.command in ("list", "ls"): | 
|  | cmd_list(args) | 
|  | elif args.command in ("search", "s", "grep"): | 
|  | cmd_search(args) | 
|  | elif args.command in ("read", "r", "show"): | 
|  | cmd_read(args) | 
|  | elif args.command == "summary": | 
|  | cmd_summary(args) | 
|  | elif args.command in ("tools", "t", "calls"): | 
|  | cmd_tools(args) | 
|  | elif args.command in ("files", "f"): | 
|  | cmd_files(args) | 
|  | elif args.command == "index": | 
|  | cmd_index(args) | 
|  | elif args.command in ("projects", "p"): | 
|  | cmd_projects(args) | 
|  | elif args.command == "stats": | 
|  | cmd_stats(args) | 
|  | elif args.command == "completions": | 
|  | cmd_completions(args) | 
|  | else: | 
|  | parser.print_help() | 
|  |  | 
|  |  | 
|  | if __name__ == "__main__": | 
|  | main() |
