| | #!/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() |