Created
December 29, 2025 04:39
-
-
Save b1tg/86160fe16ac33aaaa7ae7a418acbddd9 to your computer and use it in GitHub Desktop.
Claude Code History to Markdown Converter
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """ | |
| Claude Code History to Markdown Converter | |
| Converts Claude Code conversation history (JSONL format) to readable Markdown. | |
| Usage: | |
| python claude_code_history_to_markdown.py <input_file> [output_file] | |
| input_file: Path to Claude Code history file (.jsonl) | |
| output_file: Optional output path (defaults to input_file.md) | |
| Claude Code stores history in: | |
| ~/.claude/projects/<project_hash>/history/<conversation_id>.jsonl | |
| """ | |
| import json | |
| import sys | |
| import os | |
| from datetime import datetime | |
| from pathlib import Path | |
| from typing import Any | |
| def format_timestamp(ts: int | float | str | None) -> str: | |
| """Convert timestamp to readable format.""" | |
| if ts is None: | |
| return "" | |
| try: | |
| if isinstance(ts, str): | |
| ts = float(ts) | |
| # Handle milliseconds | |
| if ts > 1e12: | |
| ts = ts / 1000 | |
| dt = datetime.fromtimestamp(ts) | |
| return dt.strftime("%Y-%m-%d %H:%M:%S") | |
| except (ValueError, OSError): | |
| return str(ts) | |
| def extract_text_content(content: Any) -> str: | |
| """Extract text from various content formats.""" | |
| if content is None: | |
| return "" | |
| if isinstance(content, str): | |
| return content | |
| if isinstance(content, list): | |
| parts = [] | |
| for item in content: | |
| if isinstance(item, str): | |
| parts.append(item) | |
| elif isinstance(item, dict): | |
| if item.get("type") == "text": | |
| parts.append(item.get("text", "")) | |
| elif item.get("type") == "tool_use": | |
| tool_name = item.get("name", "unknown_tool") | |
| tool_input = item.get("input", {}) | |
| parts.append(f"\n**Tool Call: `{tool_name}`**\n") | |
| if tool_input: | |
| parts.append(f"```json\n{json.dumps(tool_input, indent=2)}\n```\n") | |
| elif item.get("type") == "tool_result": | |
| tool_id = item.get("tool_use_id", "") | |
| result_content = item.get("content", "") | |
| parts.append(f"\n**Tool Result** (`{tool_id[:8]}...`)\n") | |
| if isinstance(result_content, str): | |
| # Truncate very long results | |
| if len(result_content) > 2000: | |
| result_content = result_content[:2000] + "\n... (truncated)" | |
| parts.append(f"```\n{result_content}\n```\n") | |
| elif item.get("type") == "image": | |
| parts.append("\n*[Image]*\n") | |
| return "".join(parts) | |
| if isinstance(content, dict): | |
| if content.get("type") == "text": | |
| return content.get("text", "") | |
| return json.dumps(content, indent=2) | |
| return str(content) | |
| def format_message(msg: dict) -> str: | |
| """Format a single message as markdown.""" | |
| role = msg.get("role", "unknown") | |
| content = extract_text_content(msg.get("content")) | |
| timestamp = format_timestamp(msg.get("timestamp")) | |
| # Role emoji and header | |
| role_map = { | |
| "user": ("👤", "User"), | |
| "human": ("👤", "User"), | |
| "assistant": ("🤖", "Claude"), | |
| "system": ("⚙️", "System"), | |
| } | |
| emoji, display_role = role_map.get(role, ("❓", role.title())) | |
| lines = [] | |
| header = f"### {emoji} {display_role}" | |
| if timestamp: | |
| header += f" <small>({timestamp})</small>" | |
| lines.append(header) | |
| lines.append("") | |
| if content.strip(): | |
| lines.append(content) | |
| else: | |
| lines.append("*[No content]*") | |
| lines.append("") | |
| lines.append("---") | |
| lines.append("") | |
| return "\n".join(lines) | |
| def parse_jsonl_file(filepath: Path) -> list[dict]: | |
| """Parse a JSONL file and return list of messages.""" | |
| messages = [] | |
| with open(filepath, "r", encoding="utf-8") as f: | |
| for line_num, line in enumerate(f, 1): | |
| line = line.strip() | |
| if not line: | |
| continue | |
| try: | |
| data = json.loads(line) | |
| messages.append(data) | |
| except json.JSONDecodeError as e: | |
| print(f"Warning: Skipping invalid JSON on line {line_num}: {e}", file=sys.stderr) | |
| return messages | |
| def extract_messages_from_data(data: list[dict]) -> list[dict]: | |
| """Extract conversation messages from parsed data.""" | |
| messages = [] | |
| for entry in data: | |
| # Direct message format | |
| if "role" in entry and "content" in entry: | |
| messages.append(entry) | |
| # Nested message format (some Claude Code versions) | |
| elif "message" in entry: | |
| messages.append(entry["message"]) | |
| # Conversation wrapper format | |
| elif "messages" in entry: | |
| messages.extend(entry["messages"]) | |
| # Event-based format | |
| elif "type" in entry: | |
| event_type = entry.get("type", "") | |
| if event_type in ("user", "assistant", "human"): | |
| messages.append({ | |
| "role": event_type if event_type != "human" else "user", | |
| "content": entry.get("content", entry.get("text", "")), | |
| "timestamp": entry.get("timestamp") | |
| }) | |
| return messages | |
| def convert_to_markdown(messages: list[dict], title: str = "Claude Code Conversation") -> str: | |
| """Convert messages to a markdown document.""" | |
| lines = [] | |
| # Header | |
| lines.append(f"# {title}") | |
| lines.append("") | |
| lines.append(f"*Exported on {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}*") | |
| lines.append("") | |
| lines.append(f"**Total messages:** {len(messages)}") | |
| lines.append("") | |
| lines.append("---") | |
| lines.append("") | |
| # Messages | |
| for msg in messages: | |
| lines.append(format_message(msg)) | |
| return "\n".join(lines) | |
| def find_claude_code_history_dir() -> Path | None: | |
| """Find the Claude Code history directory.""" | |
| home = Path.home() | |
| claude_dir = home / ".claude" | |
| if claude_dir.exists(): | |
| projects_dir = claude_dir / "projects" | |
| if projects_dir.exists(): | |
| return projects_dir | |
| return None | |
| def list_recent_conversations(limit: int = 10) -> list[Path]: | |
| """List recent Claude Code conversation files.""" | |
| history_dir = find_claude_code_history_dir() | |
| if not history_dir: | |
| return [] | |
| jsonl_files = list(history_dir.rglob("*.jsonl")) | |
| # Sort by modification time (most recent first) | |
| jsonl_files.sort(key=lambda p: p.stat().st_mtime, reverse=True) | |
| return jsonl_files[:limit] | |
| def main(): | |
| if len(sys.argv) < 2: | |
| # Show usage and list recent conversations | |
| print(__doc__) | |
| print("\nLooking for Claude Code history...") | |
| recent = list_recent_conversations() | |
| if recent: | |
| print(f"\nFound {len(recent)} recent conversation(s):") | |
| for i, f in enumerate(recent, 1): | |
| mtime = datetime.fromtimestamp(f.stat().st_mtime) | |
| size = f.stat().st_size / 1024 | |
| print(f" {i}. {f}") | |
| print(f" Modified: {mtime.strftime('%Y-%m-%d %H:%M')} | Size: {size:.1f} KB") | |
| else: | |
| print("\nNo Claude Code history found in ~/.claude/projects/") | |
| sys.exit(0) | |
| input_path = Path(sys.argv[1]) | |
| if not input_path.exists(): | |
| print(f"Error: File not found: {input_path}", file=sys.stderr) | |
| sys.exit(1) | |
| # Determine output path | |
| if len(sys.argv) >= 3: | |
| output_path = Path(sys.argv[2]) | |
| else: | |
| output_path = input_path.with_suffix(".md") | |
| print(f"Reading: {input_path}") | |
| # Parse and convert | |
| data = parse_jsonl_file(input_path) | |
| if not data: | |
| print("Error: No valid data found in file", file=sys.stderr) | |
| sys.exit(1) | |
| messages = extract_messages_from_data(data) | |
| if not messages: | |
| print("Warning: No messages extracted from data", file=sys.stderr) | |
| # Try treating the whole data as messages | |
| messages = data | |
| print(f"Found {len(messages)} messages") | |
| # Generate title from filename | |
| title = input_path.stem.replace("_", " ").replace("-", " ").title() | |
| markdown = convert_to_markdown(messages, title) | |
| # Write output | |
| with open(output_path, "w", encoding="utf-8") as f: | |
| f.write(markdown) | |
| print(f"Written: {output_path}") | |
| print(f"Size: {output_path.stat().st_size / 1024:.1f} KB") | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment