cd /news/ai-tools/cc-history-browse-and-search-claude-… Β· home β€Ί topics β€Ί ai-tools β€Ί article
[ARTICLE Β· art-149289] src=gist.github.com β†— pub= topic=ai-tools verified=true sentiment=↑ positive

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

A developer released cc-history, a cross-platform Python 3.8+ command-line tool for browsing and searching Claude Code conversation history stored in the JSONL logs under ~/.claude/projects/. The utility offers session listing, plain-text, regex and typo-tolerant search, natural-language date filters, interactive pickers, and per-session views of tool calls, touched files and git commits, with shell completions for bash, zsh, fish and PowerShell.

by read66 min views1 publishedOct 11, 2026

| | #!/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/[1][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/[2]$[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() |

  1. [:space:] β†©οΈŽ

  2. [:space:] β†©οΈŽ

── more in #ai-tools 4 stories Β· sorted by recency
── more on @claude code 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain β€” perfect for shipping the agent you just read about.

$git push zahid main
β†’ Live at https://your-agent.zahid.host βœ“
Get free account β†’ Pricing
from €0/mo Β· no card required
LIVE [news/cc-history-browse-an…] indexed:0 read:66min 2026-10-11 Β· β€”