#!/usr/bin/env python3 """Convert Claude Code chat sessions (.jsonl) to Markdown. Reads session JSONL files from ~/.claude/projects/ and produces clean, readable Markdown — including subagent conversations. Usage: # List all sessions claude-chat-to-md --list # Convert a specific session (by UUID or partial match) claude-chat-to-md 2354ca15 # Convert the most recent session for a project claude-chat-to-md --latest --project myapp # Convert all sessions claude-chat-to-md --all # Output to a specific file claude-chat-to-md 2354ca15 -o chat.md """ from __future__ import annotations import argparse import json import re import sys from dataclasses import dataclass from datetime import datetime from pathlib import Path from typing import TextIO import os # Allow overriding via environment variable or use current dir if it looks like .claude if Path("projects").is_dir() and Path("sessions").is_dir(): CLAUDE_DIR = Path.cwd() else: CLAUDE_DIR = Path.home() / ".claude" PROJECTS_DIR = CLAUDE_DIR / "projects" @dataclass class SessionInfo: """Metadata about a discovered session.""" path: Path session_id: str project_path: str title: str | None = None timestamp: str | None = None subagent_dir: Path | None = None @property def display_project(self) -> str: """Human-readable project path. Claude Code encodes project paths by replacing '/' with '-', e.g. '/Users/nick/my-project' becomes '-Users-nick-my-project'. We reverse this by splitting on the known leading pattern and reconstructing path separators only where they originally were. """ # The encoded path starts with '-' (representing the leading '/') # and uses '-' for every '/' in the original path. However, actual # directory names may contain hyphens too. We can't perfectly reverse # this, but we can use os.sep knowledge: the original path segments # are valid directory names. We try to find the longest valid prefix. raw = self.project_path if not raw: return raw # Try to find the actual path on disk by checking ~/.claude/projects/ # Fall back to a heuristic: replace leading '-' with '/' and try to # reconstruct by checking which splits produce real-looking paths. # Best heuristic: the encoded form is the absolute path with '/' -> '-' # and a leading '-'. Try to resolve by checking if the path exists. candidate = "/" + raw[1:] if raw.startswith("-") else raw # Replace hyphens with '/' and check if it's a real path full_replace = candidate.replace("-", "/") if Path(full_replace).exists(): return full_replace.lstrip("/") # Fallback: use a smarter reconstruction. Split by '-' and greedily # rejoin segments that form existing directories. parts = raw.lstrip("-").split("-") reconstructed = [parts[0]] for part in parts[1:]: # Try joining with the previous segment (hyphenated name) test_hyphen = "/" + "/".join(reconstructed[:-1] + [reconstructed[-1] + "-" + part]) test_slash = "/" + "/".join(reconstructed + [part]) if Path(test_hyphen).exists(): reconstructed[-1] += "-" + part else: reconstructed.append(part) return "/".join(reconstructed) def discover_sessions() -> list[SessionInfo]: """Find all session JSONL files under ~/.claude/projects/.""" sessions: list[SessionInfo] = [] if not PROJECTS_DIR.exists(): return sessions for project_dir in PROJECTS_DIR.iterdir(): if not project_dir.is_dir(): continue for jsonl_file in project_dir.glob("*.jsonl"): sid = jsonl_file.stem # Skip subagent files if sid.startswith("agent-"): continue info = SessionInfo( path=jsonl_file, session_id=sid, project_path=project_dir.name, ) # Check for subagent directory subagent_dir = project_dir / sid / "subagents" if subagent_dir.is_dir(): info.subagent_dir = subagent_dir # Quick scan for title and first timestamp try: with open(jsonl_file, encoding='utf-8') as f: for line in f: obj = json.loads(line) if obj.get("type") == "ai-title": info.title = obj.get("aiTitle") if not info.timestamp and obj.get("timestamp"): info.timestamp = obj["timestamp"] if info.title and info.timestamp: break except (json.JSONDecodeError, OSError): pass sessions.append(info) sessions.sort(key=lambda s: s.timestamp or "", reverse=True) return sessions def parse_messages(jsonl_path: Path) -> list[dict]: """Parse a JSONL file into an ordered list of message records.""" messages = [] with open(jsonl_path, encoding='utf-8') as f: for line in f: try: obj = json.loads(line) except json.JSONDecodeError: continue if obj.get("type") in ("user", "assistant"): messages.append(obj) return messages def format_tool_use(content_block: dict) -> str: """Format a tool_use content block as Markdown.""" name = content_block.get("name", "Unknown") inp = content_block.get("input", {}) lines = [f"**Tool: {name}**"] if name == "Bash": cmd = inp.get("command", "") desc = inp.get("description", "") if desc: lines.append(f"*{desc}*") lines.append(f"```bash\n{cmd}\n```") elif name == "Read": fp = inp.get("file_path", "") lines.append(f"Reading `{fp}`") elif name == "Write": fp = inp.get("file_path", "") content = inp.get("content", "") lines.append(f"Writing `{fp}`") if content: # Show first/last lines for long files content_lines = content.split("\n") if len(content_lines) > 30: preview = "\n".join(content_lines[:15]) preview += f"\n\n... ({len(content_lines) - 30} lines omitted) ...\n\n" preview += "\n".join(content_lines[-15:]) else: preview = content ext = Path(fp).suffix.lstrip(".") lines.append(f"```{ext}\n{preview}\n```") elif name == "Edit": fp = inp.get("file_path", "") old = inp.get("old_string", "") new = inp.get("new_string", "") lines.append(f"Editing `{fp}`") if old or new: lines.append("```diff") for ol in old.split("\n"): lines.append(f"- {ol}") for nl in new.split("\n"): lines.append(f"+ {nl}") lines.append("```") elif name == "Grep": pattern = inp.get("pattern", "") path = inp.get("path", ".") lines.append(f"Searching for `{pattern}` in `{path}`") elif name == "Glob": pattern = inp.get("pattern", "") lines.append(f"Finding files matching `{pattern}`") elif name == "Agent": desc = inp.get("description", "") subtype = inp.get("subagent_type", "general-purpose") lines.append(f"Spawning **{subtype}** agent: *{desc}*") prompt = inp.get("prompt", "") if prompt: # Truncate long prompts if len(prompt) > 500: prompt = prompt[:500] + "..." lines.append(f"\n> {prompt}") elif name in ("WebSearch", "WebFetch"): query = inp.get("query", inp.get("url", "")) lines.append(f"`{query}`") else: # Generic: show input as JSON if inp: lines.append(f"```json\n{json.dumps(inp, indent=2)[:500]}\n```") return "\n".join(lines) def format_tool_result(content_block: dict) -> str: """Format a tool_result content block as Markdown.""" content = content_block.get("content", "") is_error = content_block.get("is_error", False) if isinstance(content, list): # Content can be a list of text/image blocks parts = [] for item in content: if isinstance(item, dict) and item.get("type") == "text": parts.append(item["text"]) elif isinstance(item, dict) and item.get("type") == "image": parts.append("*[image]*") else: parts.append(str(item)) content = "\n".join(parts) if not content: return "" prefix = "**Error:**\n" if is_error else "" # Truncate very long results lines = content.split("\n") if len(lines) > 50: content = "\n".join(lines[:25]) content += f"\n\n... ({len(lines) - 50} lines omitted) ...\n\n" content += "\n".join(lines[-25:]) return f"{prefix}```\n{content}\n```" def format_content(content: list | str) -> str: """Format a message's content array into Markdown.""" if isinstance(content, str): return content parts = [] for block in content: if isinstance(block, str): parts.append(block) elif not isinstance(block, dict): continue elif block.get("type") == "text": text = block["text"] # Strip IDE system tags for cleaner output text = re.sub(r".*?", "", text, flags=re.DOTALL) text = re.sub(r".*?", "", text, flags=re.DOTALL) text = text.strip() if text: parts.append(text) elif block.get("type") == "tool_use": parts.append(format_tool_use(block)) elif block.get("type") == "tool_result": parts.append(format_tool_result(block)) return "\n\n".join(parts) def convert_subagent(jsonl_path: Path, meta_path: Path | None) -> str: """Convert a subagent session into a Markdown section.""" meta = {} if meta_path and meta_path.exists(): try: meta = json.loads(meta_path.read_text(encoding='utf-8')) except (json.JSONDecodeError, OSError): pass agent_type = meta.get("agentType", "unknown") description = meta.get("description", "Subagent") messages = parse_messages(jsonl_path) lines = [f"#### Subagent: {description}", f"*Type: {agent_type}*\n"] for msg in messages: role = msg.get("type", msg.get("message", {}).get("role", "unknown")) content = msg.get("message", {}).get("content", []) formatted = format_content(content) if not formatted: continue if role == "user": lines.append(f"**Prompt:**\n\n{formatted}\n") elif role == "assistant": lines.append(f"{formatted}\n") return "\n".join(lines) def convert_session( session: SessionInfo, include_subagents: bool = True, include_tool_results: bool = True, ) -> str: """Convert a full session to Markdown.""" messages = parse_messages(session.path) # Collect subagent data if available subagents: dict[str, str] = {} # tool_use_id -> markdown if include_subagents and session.subagent_dir: for meta_file in session.subagent_dir.glob("*.meta.json"): agent_id = meta_file.stem.replace(".meta", "") jsonl_file = session.subagent_dir / f"{agent_id}.jsonl" if jsonl_file.exists(): subagents[agent_id] = convert_subagent(jsonl_file, meta_file) # Build output lines = [] # Header title = session.title or "Untitled Session" ts = session.timestamp or "" if ts: try: dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) ts = dt.strftime("%Y-%m-%d %H:%M UTC") except ValueError: pass lines.append(f"# {title}") lines.append("") lines.append(f"**Session:** `{session.session_id}` ") lines.append(f"**Project:** `{session.display_project}` ") if ts: lines.append(f"**Date:** {ts} ") lines.append("") lines.append("---") lines.append("") # Track which agent tool_use_ids map to which subagent agent_tool_use_map: dict[str, str] = {} for msg in messages: role = msg.get("type", "") content = msg.get("message", {}).get("content", []) if role == "user": # Check if this is a tool_result (not direct user input) if isinstance(content, list) and all( isinstance(b, dict) and b.get("type") == "tool_result" for b in content ): if include_tool_results: formatted = format_content(content) if formatted.strip(): lines.append(f"
Tool Result\n\n{formatted}\n\n
\n") continue formatted = format_content(content) if formatted.strip(): lines.append(f"## User\n\n{formatted}\n") elif role == "assistant": formatted_parts = [] if isinstance(content, list): for block in content: if isinstance(block, dict) and block.get("type") == "tool_use": if block.get("name") == "Agent": # Map this tool_use to subagent output desc = block.get("input", {}).get("description", "") # Find matching subagent by description for aid, md in subagents.items(): if desc and desc in md: agent_tool_use_map[block["id"]] = aid formatted_parts.append(format_tool_use(block)) elif isinstance(block, dict) and block.get("type") == "text": import re text = block["text"] text = re.sub(r".*?", "", text, flags=re.DOTALL) text = re.sub(r".*?", "", text, flags=re.DOTALL) text = text.strip() if text: formatted_parts.append(text) elif isinstance(block, dict) and block.get("type") == "tool_result": if include_tool_results: r = format_tool_result(block) if r: formatted_parts.append(r) formatted = "\n\n".join(formatted_parts) if formatted.strip(): lines.append(f"## Assistant\n\n{formatted}\n") # Insert subagent conversations after the assistant message that spawned them if isinstance(content, list): for block in content: if ( isinstance(block, dict) and block.get("type") == "tool_use" and block.get("name") == "Agent" ): tid = block.get("id", "") if tid in agent_tool_use_map: aid = agent_tool_use_map[tid] lines.append(f"
Subagent Conversation\n") lines.append(subagents[aid]) lines.append("
\n") else: # Try to find by description match desc = block.get("input", {}).get("description", "") for aid, md in subagents.items(): if desc and desc.lower() in md.lower() and aid not in agent_tool_use_map.values(): lines.append(f"
Subagent Conversation\n") lines.append(md) lines.append("
\n") agent_tool_use_map[tid] = aid break return "\n".join(lines) def list_sessions(sessions: list[SessionInfo], out: TextIO = sys.stdout) -> None: """Print a table of discovered sessions.""" if not sessions: print("No sessions found.", file=out) return print(f"{'#':<4} {'Date':<20} {'ID':<12} {'Title':<50} {'Project'}", file=out) print("-" * 120, file=out) for i, s in enumerate(sessions, 1): ts = "" if s.timestamp: try: dt = datetime.fromisoformat(s.timestamp.replace("Z", "+00:00")) ts = dt.strftime("%Y-%m-%d %H:%M") except ValueError: ts = s.timestamp[:16] title = (s.title or "Untitled")[:50] sid = s.session_id[:10] + ".." proj = s.display_project # Shorten project path parts = proj.split("/") if len(parts) > 3: proj = "/".join(parts[-3:]) print(f"{i:<4} {ts:<20} {sid:<12} {title:<50} {proj}", file=out) def find_session(sessions: list[SessionInfo], query: str) -> SessionInfo | None: """Find a session by UUID prefix or index.""" # Try as index try: idx = int(query) - 1 if 0 <= idx < len(sessions): return sessions[idx] except ValueError: pass # Try as UUID prefix for s in sessions: if s.session_id.startswith(query): return s # Try title substring for s in sessions: if s.title and query.lower() in s.title.lower(): return s return None def main() -> None: parser = argparse.ArgumentParser( description="Convert Claude Code chat sessions to Markdown", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=__doc__, ) parser.add_argument( "session", nargs="?", help="Session UUID (prefix), index number from --list, or title substring", ) parser.add_argument("--list", "-l", action="store_true", help="List all sessions") parser.add_argument("--latest", action="store_true", help="Convert the most recent session") parser.add_argument("--all", action="store_true", help="Convert all sessions") parser.add_argument("--project", "-p", help="Filter by project path substring") parser.add_argument("--output", "-o", help="Output file path (default: stdout)") parser.add_argument( "--no-subagents", action="store_true", help="Exclude subagent conversations" ) parser.add_argument( "--no-tool-results", action="store_true", help="Exclude tool results" ) parser.add_argument( "--output-dir", "-d", help="Output directory (for --all mode)" ) args = parser.parse_args() sessions = discover_sessions() if args.project: sessions = [s for s in sessions if args.project.lower() in s.project_path.lower()] if args.list: list_sessions(sessions) return if not args.session and not args.latest and not args.all: parser.print_help() print("\nUse --list to see available sessions.", file=sys.stderr) sys.exit(1) include_subagents = not args.no_subagents include_tool_results = not args.no_tool_results if args.all: out_dir = Path(args.output_dir) if args.output_dir else Path(".") out_dir.mkdir(parents=True, exist_ok=True) for s in sessions: md = convert_session(s, include_subagents, include_tool_results) ts = "" if s.timestamp: try: dt = datetime.fromisoformat(s.timestamp.replace("Z", "+00:00")) ts = dt.strftime("%Y-%m-%d") except ValueError: pass safe_title = (s.title or "untitled").replace(" ", "-").replace("/", "-")[:50] filename = f"{ts}-{safe_title}.md" if ts else f"{s.session_id[:8]}-{safe_title}.md" out_path = out_dir / filename out_path.write_text(md, encoding='utf-8') print(f"Wrote {out_path}", file=sys.stderr) return if args.latest: if not sessions: print("No sessions found.", file=sys.stderr) sys.exit(1) session = sessions[0] else: session = find_session(sessions, args.session) if not session: print(f"Session not found: {args.session}", file=sys.stderr) print("Use --list to see available sessions.", file=sys.stderr) sys.exit(1) md = convert_session(session, include_subagents, include_tool_results) if args.output: Path(args.output).write_text(md, encoding='utf-8') print(f"Wrote {args.output}", file=sys.stderr) else: print(md) if __name__ == "__main__": main()