-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathexport-claude-code.py
More file actions
265 lines (225 loc) · 9.05 KB
/
export-claude-code.py
File metadata and controls
265 lines (225 loc) · 9.05 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
#!/usr/bin/env python3
"""
Claude Code Session Exporter
Exports Claude Code CLI conversation sessions to markdown files.
Sessions are stored as JSONL in:
~/.claude/projects/{encoded-path}/{session-uuid}.jsonl
Each file is one conversation. Message types:
user - human turn (string content or tool_result list)
assistant - AI turn (list of text/thinking/tool_use blocks)
summary - session title generated by Claude Code
progress - streaming progress (skip)
system - system prompts (skip)
file-history-snapshot - file snapshots (skip)
"""
import fcntl
import json
import os
import re
import sys
import time as _time
from datetime import datetime
from pathlib import Path
LOCK_FILE = '/tmp/export-claude-code.lock'
def get_session_title(entries: list) -> str:
"""Get title from summary entry, then first user text message."""
for e in entries:
if e.get('type') == 'summary':
s = e.get('summary', '').strip()
if s:
# Take only the first line, strip legacy "Title: " prefix
s = s.splitlines()[0].strip()
s = re.sub(r'^Title:\s*', '', s)
if s:
return s
for e in entries:
if e.get('type') == 'user':
content = e.get('message', {}).get('content', '')
if isinstance(content, str) and content.strip():
line = content.strip().splitlines()[0][:120]
return re.sub(r'^Title:\s*', '', line).strip() or 'Untitled'
if isinstance(content, list):
for block in content:
if block.get('type') == 'text' and block.get('text', '').strip():
line = block['text'].strip().splitlines()[0][:120]
return re.sub(r'^Title:\s*', '', line).strip() or 'Untitled'
return 'Untitled'
def get_session_cwd(entries: list) -> str:
for e in entries:
if e.get('type') == 'user':
return e.get('cwd', '')
return ''
def get_first_timestamp(entries: list) -> str:
for e in entries:
if e.get('type') in ('user', 'assistant') and e.get('timestamp'):
return e['timestamp']
return ''
def render_assistant_blocks(content: list) -> list:
"""Convert assistant content blocks to markdown strings."""
parts = []
for block in content:
t = block.get('type', '')
if t == 'text':
text = block.get('text', '').strip()
if text:
parts.append(text)
elif t == 'thinking':
text = (block.get('thinking') or block.get('text') or '').strip()
if text:
parts.append(
f"<details>\n<summary>Thinking</summary>\n\n{text}\n</details>")
elif t == 'tool_use':
name = block.get('name', '')
inp = block.get('input', {})
detail = ''
for key in ('command', 'path', 'query', 'pattern', 'description', 'prompt'):
val = inp.get(key, '')
if val:
detail = f": `{str(val)[:120]}`"
break
parts.append(f"*[Tool: {name}{detail}]*")
return parts
def format_session_markdown(session_id: str, entries: list, project_dir_name: str) -> str:
title = get_session_title(entries)
cwd = get_session_cwd(entries)
first_ts = get_first_timestamp(entries)
ts_str = ''
if first_ts:
try:
dt = datetime.fromisoformat(first_ts.replace('Z', '+00:00'))
ts_str = dt.strftime('%Y-%m-%d %H:%M UTC')
except Exception:
ts_str = first_ts
lines = [
f"# {title}",
'',
f"**Session:** `{session_id}` ",
f"**Project:** `{cwd or project_dir_name}` ",
f"**Date:** {ts_str} ",
'',
'---',
'',
]
for entry in entries:
t = entry.get('type')
if t == 'user':
content = entry.get('message', {}).get('content', '')
if isinstance(content, str):
text = content.strip()
if text:
lines.append(f"**User:** {text}")
lines.append('')
elif isinstance(content, list):
# Show text blocks; skip tool_result blocks (API plumbing)
text_parts = [
block['text'].strip()
for block in content
if block.get('type') == 'text' and block.get('text', '').strip()
]
if text_parts:
lines.append(f"**User:** {' '.join(text_parts)}")
lines.append('')
elif t == 'assistant':
content = entry.get('message', {}).get('content', [])
if not isinstance(content, list):
continue
parts = render_assistant_blocks(content)
if parts:
lines.append(f"**Assistant:** {parts[0]}")
for part in parts[1:]:
lines.append('')
lines.append(part)
lines.append('')
return '\n'.join(lines)
def generate_filename(session_id: str, title: str, first_ts: str) -> str:
ts_prefix = ''
if first_ts:
try:
dt = datetime.fromisoformat(first_ts.replace('Z', '+00:00'))
ts_prefix = dt.strftime('%Y-%m-%dT%H%M')
except Exception:
pass
slug = re.sub(r'[^a-zA-Z0-9]+', '-', title)[:50].strip('-')
short_id = session_id[:8]
if ts_prefix:
return f"{ts_prefix}-{slug}-{short_id}.md"
return f"{slug}-{short_id}.md"
def export_sessions(output_dir: str, verbose: bool = False) -> None:
output_path = Path(output_dir)
output_path.mkdir(parents=True, exist_ok=True)
projects_base = Path.home() / '.claude' / 'projects'
if not projects_base.exists():
print("No Claude Code projects directory found.")
return
# Pre-index existing files by short session ID (first 8 chars of UUID stem)
existing_by_sid: dict = {}
for f in output_path.glob('*.md'):
existing_by_sid[f.stem[-8:]] = f
exported_count = 0
skipped_count = 0
for project_dir in sorted(projects_base.iterdir()):
if not project_dir.is_dir():
continue
for session_file in sorted(project_dir.glob('*.jsonl')):
session_id = session_file.stem
short_id = session_id[:8]
session_mtime = session_file.stat().st_mtime
# Skip if output file is at least as new as the session file
if short_id in existing_by_sid:
if existing_by_sid[short_id].stat().st_mtime >= session_mtime:
skipped_count += 1
continue
entries = []
try:
with open(session_file, encoding='utf-8') as f:
for line in f:
line = line.strip()
if not line:
continue
try:
entries.append(json.loads(line))
except json.JSONDecodeError:
continue
except Exception as e:
if verbose:
print(f"Error reading {session_file}: {e}")
continue
if not any(e.get('type') in ('user', 'assistant') for e in entries):
continue
title = get_session_title(entries)
first_ts = get_first_timestamp(entries)
filename = generate_filename(session_id, title, first_ts)
file_path = output_path / filename
markdown_content = format_session_markdown(
session_id, entries, project_dir.name)
try:
with open(file_path, 'w', encoding='utf-8') as f:
f.write(markdown_content)
existing_by_sid[short_id] = file_path
exported_count += 1
if verbose:
print(f"Exported: {title[:60]} -> {filename}")
except Exception as e:
print(f"Error writing {filename}: {e}")
print(
f"Export complete! Exported {exported_count} sessions, skipped {skipped_count} (up to date)")
def main():
import argparse
parser = argparse.ArgumentParser(
description='Export Claude Code sessions to markdown')
parser.add_argument('output_dir', help='Directory to write markdown files to')
parser.add_argument('--verbose', '-v', action='store_true')
args = parser.parse_args()
export_sessions(args.output_dir, verbose=args.verbose)
if __name__ == '__main__':
_lock_fd = open(LOCK_FILE, 'w')
try:
fcntl.flock(_lock_fd, fcntl.LOCK_EX | fcntl.LOCK_NB)
except OSError:
print("export-claude-code: another instance is already running, exiting")
sys.exit(0)
_start = _time.time()
_start_iso = _time.strftime('%Y-%m-%dT%H:%M:%SZ', _time.gmtime(_start))
main()
with open(os.path.expanduser('~/log/cron.log'), 'a') as _f:
_f.write(f'{_start_iso}\t{int(_time.time() - _start)}\texport-claude-code.py\n')