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