Skip to content

Instantly share code, notes, and snippets.

@b1tg
Created December 29, 2025 04:39
Show Gist options
  • Select an option

  • Save b1tg/86160fe16ac33aaaa7ae7a418acbddd9 to your computer and use it in GitHub Desktop.

Select an option

Save b1tg/86160fe16ac33aaaa7ae7a418acbddd9 to your computer and use it in GitHub Desktop.
Claude Code History to Markdown Converter
#!/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