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