Created
March 20, 2026 17:58
-
-
Save digitalsignalperson/9ab8541705c493bb0ba43494c366a6bb to your computer and use it in GitHub Desktop.
Simple Claude Code Session Exporter
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 | |
| """ | |
| Simple Claude Code Session Exporter | |
| Finds sessions, lists them, exports to markdown. | |
| Inspired by https://github.com/jimmc414/cctrace/blob/master/export_claude_session.py | |
| """ | |
| import json | |
| import os | |
| import sys | |
| from datetime import datetime | |
| from pathlib import Path | |
| def find_sessions(project_path): | |
| """Find all JSONL session files for the current project.""" | |
| project_path = str(project_path) | |
| # Normalize path like Claude does | |
| if os.name == 'nt': # Windows | |
| project_dir_name = project_path.replace('\\', '-').replace(':', '-').replace('/', '-').replace('.', '-').replace('_', '-') | |
| else: # Unix | |
| normalized = project_path.replace('\\', '/') | |
| project_dir_name = normalized.replace('/', '-').replace('.', '-').replace('_', '-') | |
| if project_dir_name.startswith('-'): | |
| project_dir_name = project_dir_name[1:] | |
| claude_dir = Path.home() / '.claude' / 'projects' | |
| if os.name == 'nt': | |
| project_sessions_dir = claude_dir / project_dir_name | |
| else: | |
| project_sessions_dir = claude_dir / f'-{project_dir_name}' | |
| if not project_sessions_dir.exists(): | |
| return [] | |
| # Get all JSONL files sorted by modification time (newest first) | |
| files = [] | |
| for file in project_sessions_dir.glob('*.jsonl'): | |
| if not file.name.startswith('agent-'): # Skip agent sessions | |
| files.append({ | |
| 'path': file, | |
| 'name': file.stem, | |
| 'mtime': file.stat().st_mtime | |
| }) | |
| return sorted(files, key=lambda x: x['mtime'], reverse=True) | |
| def parse_session(file_path): | |
| """Parse JSONL session file.""" | |
| messages = [] | |
| with open(file_path, 'r', encoding='utf-8') as f: | |
| for line in f: | |
| try: | |
| messages.append(json.loads(line.strip())) | |
| except json.JSONDecodeError: | |
| continue | |
| return messages | |
| def format_message(msg_data): | |
| """Format a single message as markdown.""" | |
| if 'message' not in msg_data: | |
| return "" | |
| msg = msg_data['message'] | |
| timestamp = msg_data.get('timestamp', '') | |
| lines = [] | |
| # Timestamp | |
| if timestamp: | |
| try: | |
| dt = datetime.fromisoformat(timestamp.replace('Z', '+00:00')) | |
| lines.append(f"**[{dt.strftime('%Y-%m-%d %H:%M:%S')}]**") | |
| except: | |
| pass | |
| # Role header | |
| role = msg.get('role', 'unknown') | |
| if role == 'user': | |
| lines.append("\n### π€ User\n") | |
| elif role == 'assistant': | |
| model = msg.get('model', 'Claude') | |
| lines.append(f"\n### π€ Assistant ({model})\n") | |
| # Content | |
| if 'content' in msg: | |
| if isinstance(msg['content'], str): | |
| lines.append(msg['content']) | |
| elif isinstance(msg['content'], list): | |
| for content in msg['content']: | |
| if not isinstance(content, dict): | |
| continue | |
| ctype = content.get('type') | |
| if ctype == 'text': | |
| lines.append(content.get('text', '')) | |
| elif ctype == 'thinking': | |
| lines.append("\n<details>") | |
| lines.append("<summary>π Reasoning</summary>\n") | |
| lines.append("```") | |
| lines.append(content.get('thinking', '')) | |
| lines.append("```") | |
| lines.append("</details>\n") | |
| elif ctype == 'tool_use': | |
| tool_name = content.get('name', 'unknown') | |
| lines.append(f"\nπ§ Tool: {tool_name}") | |
| lines.append("```json") | |
| lines.append(json.dumps(content.get('input', {}), indent=2)) | |
| lines.append("```\n") | |
| elif ctype == 'tool_result': | |
| lines.append("\nπ Result:") | |
| result = content.get('content', '') | |
| if isinstance(result, str): | |
| lines.append(result[:2000]) # Limit | |
| else: | |
| lines.append(str(result)[:2000]) | |
| lines.append("") | |
| return '\n'.join(lines) | |
| def main(): | |
| cwd = Path.cwd() | |
| sessions = find_sessions(cwd) | |
| if not sessions: | |
| print("β No Claude Code sessions found in this project") | |
| return 1 | |
| print(f"Found {len(sessions)} session(s):\n") | |
| for i, session in enumerate(sessions): | |
| age_seconds = int(datetime.now().timestamp() - session['mtime']) | |
| age_str = format_age(age_seconds) | |
| print(f"{i+1}. {session['name'][:20]:20} ({age_str})") | |
| # Default to most recent | |
| choice = input(f"\nSelect session [1]: ").strip() or "1" | |
| try: | |
| idx = int(choice) - 1 | |
| if idx < 0 or idx >= len(sessions): | |
| print("Invalid selection") | |
| return 1 | |
| except ValueError: | |
| print("Invalid input") | |
| return 1 | |
| selected = sessions[idx] | |
| print(f"\nπ€ Exporting {selected['name']}...") | |
| messages = parse_session(selected['path']) | |
| # Write markdown | |
| output_file = f"claude_export_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md" | |
| with open(output_file, 'w', encoding='utf-8') as f: | |
| f.write(f"# Claude Code Session\n\n") | |
| f.write(f"**Session:** `{selected['name']}`\n") | |
| f.write(f"**Messages:** {len(messages)}\n") | |
| f.write(f"**Exported:** {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}\n\n") | |
| f.write("---\n\n") | |
| for msg in messages: | |
| formatted = format_message(msg) | |
| if formatted: | |
| f.write(formatted) | |
| f.write("\n\n---\n\n") | |
| print(f"β Exported to: {output_file}") | |
| return 0 | |
| def format_age(seconds): | |
| """Format age in seconds to readable string.""" | |
| if seconds < 60: | |
| return f"{seconds}s ago" | |
| elif seconds < 3600: | |
| return f"{seconds//60}m ago" | |
| elif seconds < 86400: | |
| return f"{seconds//3600}h ago" | |
| else: | |
| return f"{seconds//86400}d ago" | |
| if __name__ == '__main__': | |
| sys.exit(main()) |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment