Skip to content

Commit a4f9709

Browse files
committed
1 parent f886484 commit a4f9709

File tree

2 files changed

+214
-0
lines changed

2 files changed

+214
-0
lines changed

python/README.md

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,18 @@ These Python scripts can be run directly from their URLs using `uv run`.
44

55
Their source code is [available on GitHub](https://github.com/simonw/tools/tree/main/python).
66

7+
## claude_to_markdown.py
8+
9+
Convert a Claude `.jsonl` conversation log to readable Markdown.
10+
11+
```bash
12+
uv run https://tools.simonwillison.net/python/claude_to_markdown.py \
13+
aed89565-d168-4ff9-bb03-13ea532969ea.jsonl
14+
```
15+
Add a second filename to write to that file instead of a `.md` next to the `.jsonl`.
16+
17+
[Example output](https://gist.github.com/simonw/388d62cdb99dd844eb6ce63b538dbbd8) for the session that created this script.
18+
719
## openai_image.py
820

921
Generate an image from a text prompt using OpenAI's image models.

python/claude_to_markdown.py

Lines changed: 202 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,202 @@
1+
#!/usr/bin/env python3
2+
"""
3+
Convert Claude Code JSONL conversation logs to readable Markdown format.
4+
"""
5+
6+
import json
7+
import sys
8+
from datetime import datetime
9+
from pathlib import Path
10+
11+
12+
def format_timestamp(ts):
13+
"""Format ISO timestamp to readable format."""
14+
try:
15+
dt = datetime.fromisoformat(ts.replace('Z', '+00:00'))
16+
return dt.strftime('%Y-%m-%d %H:%M:%S')
17+
except:
18+
return ts
19+
20+
21+
def format_tool_use(tool):
22+
"""Format tool use content."""
23+
name = tool.get('name', 'Unknown')
24+
tool_input = tool.get('input', {})
25+
26+
md = f"**Tool:** `{name}`\n\n"
27+
28+
if tool_input:
29+
md += "**Input:**\n```json\n"
30+
md += json.dumps(tool_input, indent=2)
31+
md += "\n```\n"
32+
33+
return md
34+
35+
36+
def format_tool_result(result):
37+
"""Format tool result content."""
38+
content = result.get('content', '')
39+
40+
if isinstance(content, list):
41+
# Handle structured content
42+
parts = []
43+
for item in content:
44+
if isinstance(item, dict):
45+
if item.get('type') == 'text':
46+
parts.append(item.get('text', ''))
47+
else:
48+
parts.append(str(item))
49+
content = '\n'.join(parts)
50+
51+
md = "**Result:**\n```\n"
52+
md += str(content)
53+
md += "\n```\n"
54+
55+
return md
56+
57+
58+
def format_message_content(content):
59+
"""Format message content (can be text, tool use, thinking, etc)."""
60+
if isinstance(content, str):
61+
return content
62+
63+
if isinstance(content, list):
64+
parts = []
65+
for item in content:
66+
if isinstance(item, dict):
67+
msg_type = item.get('type', 'text')
68+
69+
if msg_type == 'text':
70+
parts.append(item.get('text', ''))
71+
elif msg_type == 'thinking':
72+
thinking = item.get('thinking', '')
73+
if thinking:
74+
parts.append(f"<details>\n<summary>💭 Thinking</summary>\n\n{thinking}\n</details>")
75+
elif msg_type == 'tool_use':
76+
parts.append(format_tool_use(item))
77+
elif msg_type == 'tool_result':
78+
parts.append(format_tool_result(item))
79+
else:
80+
parts.append(str(item))
81+
return '\n\n'.join(parts)
82+
83+
return str(content)
84+
85+
86+
def process_jsonl_line(line):
87+
"""Process a single JSONL line and convert to markdown."""
88+
try:
89+
data = json.loads(line)
90+
except json.JSONDecodeError:
91+
return None
92+
93+
entry_type = data.get('type', 'unknown')
94+
95+
# Skip file history snapshots
96+
if entry_type == 'file-history-snapshot':
97+
return None
98+
99+
# Process user and assistant messages
100+
if entry_type in ['user', 'assistant']:
101+
message = data.get('message', {})
102+
role = message.get('role', entry_type)
103+
content = message.get('content', '')
104+
timestamp = data.get('timestamp', '')
105+
106+
# Format header
107+
icon = '👤' if role == 'user' else '🤖'
108+
header = f"## {icon} {role.upper()}"
109+
110+
if timestamp:
111+
header += f" — {format_timestamp(timestamp)}"
112+
113+
# Format content
114+
formatted_content = format_message_content(content)
115+
116+
# Add metadata if available
117+
metadata = []
118+
if entry_type == 'assistant':
119+
model = message.get('model', '')
120+
if model:
121+
metadata.append(f"**Model:** `{model}`")
122+
123+
usage = message.get('usage', {})
124+
if usage:
125+
input_tokens = usage.get('input_tokens', 0)
126+
output_tokens = usage.get('output_tokens', 0)
127+
metadata.append(f"**Tokens:** {input_tokens} in / {output_tokens} out")
128+
129+
cwd = data.get('cwd', '')
130+
if cwd:
131+
metadata.append(f"**Working Dir:** `{cwd}`")
132+
133+
result = f"{header}\n\n"
134+
if metadata:
135+
result += '\n'.join(metadata) + '\n\n'
136+
result += f"{formatted_content}\n\n---\n"
137+
138+
return result
139+
140+
return None
141+
142+
143+
def convert_jsonl_to_markdown(input_file, output_file=None):
144+
"""Convert JSONL file to Markdown."""
145+
input_path = Path(input_file)
146+
147+
if not input_path.exists():
148+
print(f"Error: File '{input_file}' not found.", file=sys.stderr)
149+
return 1
150+
151+
# Determine output file
152+
if output_file is None:
153+
output_file = input_path.with_suffix('.md')
154+
155+
output_path = Path(output_file)
156+
157+
# Process file
158+
entries_processed = 0
159+
with open(input_path, 'r', encoding='utf-8') as infile, \
160+
open(output_path, 'w', encoding='utf-8') as outfile:
161+
162+
# Write header
163+
outfile.write(f"# Claude Code Conversation Log\n\n")
164+
outfile.write(f"**Source:** `{input_path.name}` \n")
165+
outfile.write(f"**Generated:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n")
166+
outfile.write("---\n\n")
167+
168+
# Process each line
169+
for line_num, line in enumerate(infile, 1):
170+
line = line.strip()
171+
if not line:
172+
continue
173+
174+
try:
175+
markdown = process_jsonl_line(line)
176+
if markdown:
177+
outfile.write(markdown)
178+
entries_processed += 1
179+
except Exception as e:
180+
print(f"Warning: Error processing line {line_num}: {e}", file=sys.stderr)
181+
continue
182+
183+
print(f"✓ Converted {entries_processed} entries")
184+
print(f"✓ Output written to: {output_path}")
185+
return 0
186+
187+
188+
def main():
189+
"""Main entry point."""
190+
if len(sys.argv) < 2:
191+
print("Usage: python claude_to_markdown.py <input.jsonl> [output.md]")
192+
print("\nConverts Claude Code JSONL conversation logs to readable Markdown format.")
193+
return 1
194+
195+
input_file = sys.argv[1]
196+
output_file = sys.argv[2] if len(sys.argv) > 2 else None
197+
198+
return convert_jsonl_to_markdown(input_file, output_file)
199+
200+
201+
if __name__ == '__main__':
202+
sys.exit(main())

0 commit comments

Comments
 (0)