#!/usr/bin/env python3 """ Convert Claude Code conversation from JSONL to MDX format for Suno Wiki. Usage: python convert-claude-chat.py [--output ] [--title ] # Convert latest session python convert-claude-chat.py $(ls -t ~/.claude/projects/*/*.jsonl | head -n 1) # Convert with custom title and output python convert-claude-chat.py session.jsonl --title "My Conversation" --output my-convo.mdx """ import argparse import json import re import sys from datetime import datetime from pathlib import Path from typing import Any, Dict, List, Tuple # Import our utility modules from jsonl_utils import ( parse_jsonl, get_session_metadata, extract_token_usage, extract_model_info, calculate_relative_time, get_relative_file_path, find_tool_use_by_id, calculate_total_tokens, ) from diff_formatter import ( format_edit_as_diff, detect_language_from_path, ) def format_timestamp(ts_str: str) -> str: """Format ISO timestamp to readable format with timezones.""" from zoneinfo import ZoneInfo # Parse UTC timestamp dt_utc = datetime.fromisoformat(ts_str.replace("Z", "+00:00")) # Convert to Eastern and Pacific times dt_eastern = dt_utc.astimezone(ZoneInfo("America/New_York")) dt_pacific = dt_utc.astimezone(ZoneInfo("America/Los_Angeles")) # Format with timezone abbreviations eastern_str = dt_eastern.strftime("%Y-%m-%d %I:%M %p %Z") pacific_str = dt_pacific.strftime("%I:%M %p %Z") return f"{eastern_str} / {pacific_str}" def escape_jsx_and_imports(text: str) -> str: """ Escape JSX-like syntax and import statements for MDX. Handles curly braces, angle brackets, and import/export keywords. """ # Escape curly braces text = text.replace("{", "\\{").replace("}", "\\}") # Escape self-closing tags as complete units # Match <ComponentName attr="value" /> and escape the whole thing text = re.sub(r"<(\w+)([^>]*?)/>", r"\\<\1\2/\\>", text) # Now escape remaining angle brackets (but not ones we just escaped) # We need to be careful not to double-escape # First, temporarily mark our escaped tags text = text.replace("\\<", "\x00ESCAPED_LT\x00") text = text.replace("/\\>", "\x00ESCAPED_SELFCLOSE\x00") # Now escape remaining unescaped brackets text = text.replace("<", "\\<").replace(">", "\\>") # Restore our escaped tags text = text.replace("\x00ESCAPED_LT\x00", "\\<") text = text.replace("\x00ESCAPED_SELFCLOSE\x00", "/\\>") # Escape import/export statements by escaping the keywords # This prevents them from being parsed as actual JS imports text = re.sub(r"\b(import|export)\s", r"\\\1 ", text) return text def escape_mdx(text: str) -> str: """Escape text for safe MDX rendering.""" # Escape curly braces and angle brackets - in MDX they're interpreted as JSX # Only preserve triple-backtick code blocks without escaping # Inline code (single backticks) still needs escaping # # Also escape import/export statements that appear outside code blocks, # as these will be parsed as JavaScript by the MDX compiler # Split by code fence blocks only (triple backticks) parts = [] code_fence_pattern = r"```[\s\S]*?```" last_end = 0 for match in re.finditer(code_fence_pattern, text): # Add text before code block (escaped) before_text = text[last_end : match.start()] before_text = escape_jsx_and_imports(before_text) parts.append(before_text) # Add code fence block (not escaped) parts.append(match.group(0)) last_end = match.end() # Add remaining text (escaped) remaining_text = text[last_end:] remaining_text = escape_jsx_and_imports(remaining_text) parts.append(remaining_text) return "".join(parts) def format_code_block(code: str, language: str = "") -> str: """Format code block with language specification.""" return f"```{language}\n{code}\n```" def enhance_at_mentions(text: str, cwd: str = "") -> str: """ Enhance @ mentions by formatting them as code references. Skips @ symbols in import statements. Args: text: Text potentially containing @mentions cwd: Working directory for relative path conversion Returns: Text with enhanced @ mentions """ # Don't process import/from statements - they use @ for path aliases if text.strip().startswith(("import ", "from ")): return text # Pattern: @ followed by file path (with or without spaces) # Match @path/to/file.ext or @"path with spaces/file.ext" def replace_mention(match): path = match.group(1).strip("\"'") relative_path = get_relative_file_path(path, cwd) if cwd else path return f"`@{relative_path}`" # Pattern for @mentions text = re.sub(r"@([^\s]+(?:\s+[^\s]+)*?\.\w+)", replace_mention, text) return text def format_token_usage(usage: Dict[str, int]) -> str: """ Format token usage as a badge/callout with smaller font. Args: usage: Token usage dictionary Returns: Formatted markdown string wrapped in span for smaller font """ input_tokens = usage.get("input_tokens", 0) output_tokens = usage.get("output_tokens", 0) cache_read = usage.get("cache_read_input_tokens", 0) cache_creation = usage.get("cache_creation_input_tokens", 0) content = f"**Tokens:** {input_tokens:,} in / {output_tokens:,} out" if cache_read > 0 or cache_creation > 0: content += f" | **Cache:** {cache_read:,} read" if cache_creation > 0: content += f", {cache_creation:,} created" return f'<div class="token-usage">{content}</div>' def format_tool_use( tool_data: Dict[str, Any], all_messages: List[Dict[str, Any]] = None, cwd: str = "", ) -> str: """ Format tool use into readable markdown with enhanced formatting. Args: tool_data: Tool use block all_messages: All messages for context cwd: Working directory for relative paths Returns: Formatted markdown string """ tool_name = tool_data.get("name", "Unknown Tool") tool_input = tool_data.get("input", {}) # Special formatting for Edit tool - show as diff if tool_name == "Edit": return format_edit_as_diff(tool_input) # Special formatting for other tools with file paths if "file_path" in tool_input and cwd: tool_input = tool_input.copy() tool_input["file_path"] = get_relative_file_path(tool_input["file_path"], cwd) output = f"**🔧 Tool Used: `{tool_name}`**\n\n" if tool_input: # Pretty print the input as JSON input_json = json.dumps(tool_input, indent=2) output += format_code_block(input_json, "json") return output def format_tool_result( tool_data: Dict[str, Any], all_messages: List[Dict[str, Any]] = None, cwd: str = "", ) -> str: """ Format tool result with enhanced formatting by tool type. Args: tool_data: Tool result block all_messages: All messages for context cwd: Working directory for relative paths Returns: Formatted markdown string """ tool_id = tool_data.get("tool_use_id", "unknown") content = tool_data.get("content", "") is_error = tool_data.get("is_error", False) # Find corresponding tool use to determine tool type tool_use = None if all_messages: tool_use = find_tool_use_by_id(all_messages, tool_id) tool_name = tool_use.get("name") if tool_use else "Unknown" # Status indicator status_icon = "❌" if is_error else "✅" if is_error: return f"**{status_icon} Tool Error** (`{tool_name}`)\n\n{format_code_block(str(content), '')}" output = f"**{status_icon} Tool Result** (`{tool_name}`)\n\n" # Enhanced formatting based on tool type if tool_name == "Read": # Read results already have line numbers with → character output += f"<details>\n<summary>View File Contents</summary>\n\n{format_code_block(content, '')}\n\n</details>" elif tool_name == "Bash": # Format bash output with command context if tool_use: command = tool_use.get("input", {}).get("command", "") output += f"**Command:** `{command}`\n\n" output += f"<details>\n<summary>View Output</summary>\n\n{format_code_block(content, 'bash')}\n\n</details>" elif tool_name == "Grep": # Grep results with syntax highlighting output += f"<details>\n<summary>View Search Results</summary>\n\n{format_code_block(content, '')}\n\n</details>" elif tool_name == "Write": # Show file created if tool_use: file_path = tool_use.get("input", {}).get("file_path", "") relative_path = get_relative_file_path(file_path, cwd) if cwd else file_path language = detect_language_from_path(file_path) output = f"**{status_icon} Created File:** `{relative_path}`\n\n" if content and len(content) < 1000: output += f"<details>\n<summary>View Contents</summary>\n\n{format_code_block(content, language)}\n\n</details>" elif tool_name == "Edit": # Edit confirmation output = f"**{status_icon} Edit Applied Successfully**\n\n" if content: output += ( f"<details>\n<summary>Details</summary>\n\n{content}\n\n</details>" ) else: # Generic formatting for other tools if isinstance(content, str) and (len(content) > 200 or "\n" in content): output += f"<details>\n<summary>View Output</summary>\n\n{format_code_block(content, '')}\n\n</details>" else: output += f"{content}" return output def format_text_content(text: str, cwd: str = "") -> str: """Format text content, preserving code blocks and enhancing @ mentions.""" text = enhance_at_mentions(text, cwd) text = escape_mdx(text) # Convert any markdown headings to HTML to prevent them from appearing in TOC # This handles headings in user messages or assistant responses that aren't in the main heading text = re.sub(r"^## (.+)$", r"<h3>\1</h3>", text, flags=re.MULTILINE) text = re.sub(r"^### (.+)$", r"<h4>\1</h4>", text, flags=re.MULTILINE) text = re.sub(r"^#### (.+)$", r"<h5>\1</h5>", text, flags=re.MULTILINE) text = re.sub(r"^##### (.+)$", r"<h6>\1</h6>", text, flags=re.MULTILINE) return text def extract_prompt_summary(content: str, max_length: int = 50) -> str: """ Extract a short summary from user prompt for TOC. Takes first sentence or first N characters, whichever is shorter. """ # Strip system tags first content = strip_system_tags(content) # Remove IDE file opening messages - everything before the backtick close if "📁 Opened `" in content: parts = content.split("`", 2) if len(parts) > 2: content = parts[2].strip() else: content = content.replace("📁 Opened `", "").replace("`", "").strip() # Remove markdown formatting content = re.sub(r"\*\*(.+?)\*\*", r"\1", content) # Bold content = re.sub(r"\*(.+?)\*", r"\1", content) # Italic content = re.sub(r"`(.+?)`", r"\1", content) # Code content = re.sub(r"\[(.+?)\]\(.+?\)", r"\1", content) # Links # Get first sentence or first N chars first_sentence = content.split(".")[0].split("?")[0].split("!")[0].strip() if len(first_sentence) > max_length: return first_sentence[:max_length].strip() + "..." return first_sentence if first_sentence else "User Prompt" def format_assistant_message( content_blocks: List[Dict[str, Any]], all_messages: List[Dict[str, Any]] = None, cwd: str = "", ) -> str: """Format assistant message content blocks.""" output = [] for block in content_blocks: block_type = block.get("type", "text") if block_type == "text": text = format_text_content(block.get("text", ""), cwd) # Replace markdown headings with HTML to avoid TOC entries # Replace all headings (##, ###, ####, etc.) with HTML tags to hide from TOC text = re.sub(r"^## (.+)$", r"<h3>\1</h3>", text, flags=re.MULTILINE) text = re.sub(r"^### (.+)$", r"<h4>\1</h4>", text, flags=re.MULTILINE) text = re.sub(r"^#### (.+)$", r"<h5>\1</h5>", text, flags=re.MULTILINE) text = re.sub(r"^##### (.+)$", r"<h6>\1</h6>", text, flags=re.MULTILINE) output.append(text) elif block_type == "tool_use": output.append(format_tool_use(block, all_messages, cwd)) elif block_type == "tool_result": output.append(format_tool_result(block, all_messages, cwd)) return "\n\n".join(output) def strip_system_tags(content: str) -> str: """Remove system tags like <ide_selection> and <system-reminder> from content.""" # Remove <ide_selection> tags and their content content = re.sub( r"<ide_selection>.*?</ide_selection>", "", content, flags=re.DOTALL ) # Remove <system-reminder> tags and their content content = re.sub( r"<system-reminder>.*?</system-reminder>", "", content, flags=re.DOTALL ) # Remove <command-message> and <command-name> tags and their content content = re.sub( r"<command-message>.*?</command-message>", "", content, flags=re.DOTALL ) content = re.sub(r"<command-name>.*?</command-name>", "", content, flags=re.DOTALL) content = re.sub(r"<command-args>.*?</command-args>", "", content, flags=re.DOTALL) # Remove <ide_opened_file> tags and their content content = re.sub( r"<ide_opened_file>.*?</ide_opened_file>", "", content, flags=re.DOTALL ) # Remove local command output tags (stderr, stdout, etc.) content = re.sub( r"<local-command-stderr>.*?</local-command-stderr>", "", content, flags=re.DOTALL, ) content = re.sub( r"<local-command-stdout>.*?</local-command-stdout>", "", content, flags=re.DOTALL, ) # Remove entire "Caveat" messages about local commands content = re.sub( r"Caveat: The messages below.*?(?=\n\n|$)", "", content, flags=re.DOTALL ) # Clean up extra whitespace content = re.sub(r"\n\n\n+", "\n\n", content) return content.strip() def condense_ide_message(content: str) -> str: """Condense IDE file opening messages to a compact format.""" # Pattern for IDE file opening messages ide_pattern = r"<ide_opened_file>The user opened the file (.+?) in the IDE\. This may or may not be related to the current task\.</ide_opened_file>" # Replace with condensed format match = re.search(ide_pattern, content) if match: file_path = match.group(1) # Extract just the filename or relative path if "/" in file_path: # Try to get a meaningful relative path (last 2-3 segments) parts = file_path.split("/") if len(parts) > 3: condensed_path = "/".join(parts[-3:]) else: condensed_path = parts[-1] else: condensed_path = file_path # Use backticks for code-style formatting instead of markdown links (MDX-safe) return f"📁 Opened `{condensed_path}`" return content def format_user_message(content: Any, cwd: str = "") -> Tuple[str, bool]: """ Format user message content. Returns tuple of (formatted_content, is_system_action) """ if isinstance(content, str): # Strip system tags first content = strip_system_tags(content) # Check if this is an IDE file opening message if "<ide_opened_file>" in content: condensed = condense_ide_message(content) # Check if there's additional user input after the IDE message remaining = content.split("</ide_opened_file>")[-1].strip() if remaining: return (condensed + " " + format_text_content(remaining, cwd), False) else: return (condensed, True) return (format_text_content(content, cwd), False) elif isinstance(content, list): # Handle content blocks if user message has them texts = [] has_tool_results_only = True for block in content: if isinstance(block, dict): if block.get("type") == "text": texts.append(block.get("text", "")) has_tool_results_only = False elif block.get("type") != "tool_result": # If there's any non-tool_result, non-text block, it's not a pure system continuation has_tool_results_only = False elif isinstance(block, str): texts.append(block) has_tool_results_only = False combined = " ".join(texts) # Strip system tags combined = strip_system_tags(combined) # If message contains only tool_result blocks (no text), treat as system continuation if has_tool_results_only and not combined.strip(): return ("_[System continuation]_", True) if "<ide_opened_file>" in combined: condensed = condense_ide_message(combined) remaining = combined.split("</ide_opened_file>")[-1].strip() if remaining: return (condensed + " " + format_text_content(remaining, cwd), False) else: return (condensed, True) return (format_text_content(combined, cwd), False) return (str(content), False) def convert_to_mdx( messages: List[Dict[str, Any]], title: str, session_id: str = None ) -> str: """Convert parsed messages to MDX format with enhanced features.""" # Filter to main conversation (not sidechains) main_messages = [m for m in messages if not m.get("isSidechain", False)] # Get session metadata metadata = get_session_metadata(messages) cwd = metadata.get("cwd", "") git_branch = metadata.get("git_branch", "") first_timestamp = metadata.get("first_timestamp", "") # Format date formatted_date = ( format_timestamp(first_timestamp) if first_timestamp else "Unknown date" ) # Extract repository name from cwd repo_name = Path(cwd).name if cwd else "Unknown" # Start building MDX with Astro components mdx_parts = [ "---\n", f'title: "{title}"\n', f"date: {first_timestamp if first_timestamp else datetime.now().isoformat()}\n", 'desc: "Claude Code conversation"\n', f'slug: "{title.lower().replace(" ", "-")}"\n', 'tags: ["claude-code", "conversation"]\n', 'category: "prompt"\n', ] if session_id: mdx_parts.append(f'sessionId: "{session_id}"\n') if repo_name: mdx_parts.append(f'repository: "{repo_name}"\n') if git_branch: mdx_parts.append(f'branch: "{git_branch}"\n') mdx_parts.append("---\n\n") mdx_parts.append( "import PromptSummary from '@/components/prompts/PromptSummary.astro';\n\n" ) # Track models used models_used = set() # Process conversation pairs conversation_index = 1 user_prompt_number = 0 # Sequential numbering for user prompts in TOC message_index = 0 i = 0 while i < len(main_messages): msg = main_messages[i] msg_type = msg.get("type") if msg_type == "user": message_index += 1 user_content = msg.get("message", {}).get("content", "") # Add anchor for message threading anchor_id = f"message-{message_index}" # Calculate relative time msg_timestamp = msg.get("timestamp", "") relative_time = "" if msg_timestamp and first_timestamp: relative_time = calculate_relative_time(msg_timestamp, first_timestamp) # Format user message and check if it's a system action formatted_content, is_system_action = format_user_message(user_content, cwd) # Skip completely empty messages if not formatted_content.strip(): # Still look for assistant responses but mark as system continuation formatted_content = "_[System continuation]_" is_system_action = True # Look ahead to see if we have consecutive system continuations system_continuation_group = [] if is_system_action and "[System continuation]" in formatted_content: # Collect consecutive system continuations starting from current message temp_i = i continuation_count = 0 while temp_i < len(main_messages): temp_msg = main_messages[temp_i] if temp_msg.get("type") != "user": break temp_content, temp_is_system = format_user_message( temp_msg.get("message", {}).get("content", ""), cwd ) if ( not temp_is_system or "[System continuation]" not in temp_content ): break # Collect this system continuation with its assistant responses system_continuation_group.append( { "index": temp_i, "conversation_index": conversation_index + continuation_count, "timestamp": temp_msg.get("timestamp", ""), "message_index": message_index + continuation_count, } ) continuation_count += 1 # Skip past assistant messages for this continuation temp_j = temp_i + 1 while ( temp_j < len(main_messages) and main_messages[temp_j].get("type") == "assistant" ): temp_j += 1 temp_i = temp_j # If we have multiple system continuations, group them if len(system_continuation_group) > 1: start_idx = system_continuation_group[0]["conversation_index"] end_idx = system_continuation_group[-1]["conversation_index"] mdx_parts.append( f'\n<span id="{anchor_id}"></span>\n\n_**Actions {start_idx}-{end_idx}:** System continuations_' ) if relative_time: mdx_parts.append(f" _{relative_time}_") mdx_parts.append("\n") # Collect all assistant responses from the group mdx_parts.append("\n<details>") mdx_parts.append("\n<summary>🤖 View Claude's Responses</summary>\n") for group_item in system_continuation_group: group_i = group_item["index"] group_j = group_i + 1 # Collect assistant messages for this continuation while ( group_j < len(main_messages) and main_messages[group_j].get("type") == "assistant" ): asst_msg = main_messages[group_j] # Extract model info model = extract_model_info(asst_msg) if model: models_used.add(model) # Extract token usage usage = extract_token_usage(asst_msg) content = asst_msg.get("message", {}).get("content", []) if isinstance(content, list): formatted = format_assistant_message( content, main_messages, cwd ) if formatted.strip(): if ( group_item != system_continuation_group[0] or group_j != group_i + 1 ): mdx_parts.append("\n\n---\n\n") mdx_parts.append(formatted) # Add token usage after assistant response if usage: mdx_parts.append(f"\n\n{format_token_usage(usage)}") group_j += 1 mdx_parts.append("\n\n</details>\n") mdx_parts.append("\n---\n") # Update indices conversation_index = end_idx + 1 i = system_continuation_group[-1]["index"] # Skip past the last group's assistant messages j = i + 1 while ( j < len(main_messages) and main_messages[j].get("type") == "assistant" ): j += 1 i = j else: # Single message (system action or regular prompt) if is_system_action: # System action - use italic/subdued style mdx_parts.append( f'\n<span id="{anchor_id}"></span>\n\n_**Action {conversation_index}:** {formatted_content}_' ) if relative_time: mdx_parts.append(f" _{relative_time}_") mdx_parts.append("\n") else: # Regular user prompt - increment prompt number and extract summary for heading user_prompt_number += 1 prompt_summary = extract_prompt_summary(formatted_content) mdx_parts.append( f'\n<span id="{anchor_id}"></span>\n\n## {user_prompt_number}. {prompt_summary}' ) if relative_time: mdx_parts.append(f" <small>_{relative_time}_</small>") mdx_parts.append(f"\n{formatted_content}\n") # Look for corresponding assistant response(s) assistant_responses = [] j = i + 1 # Collect all assistant messages until next user message while ( j < len(main_messages) and main_messages[j].get("type") == "assistant" ): assistant_responses.append(main_messages[j]) j += 1 # Format assistant responses if assistant_responses: mdx_parts.append("\n<details>") mdx_parts.append("\n<summary>🤖 View Claude's Response</summary>\n") for idx, asst_msg in enumerate(assistant_responses): # Extract model info model = extract_model_info(asst_msg) if model: models_used.add(model) # Extract token usage usage = extract_token_usage(asst_msg) content = asst_msg.get("message", {}).get("content", []) if isinstance(content, list): formatted = format_assistant_message( content, main_messages, cwd ) if formatted.strip(): if idx > 0: mdx_parts.append("\n\n---\n\n") mdx_parts.append(formatted) # Add token usage after assistant response if usage: mdx_parts.append( f"\n\n---\n\n{format_token_usage(usage)}" ) mdx_parts.append("\n\n</details>\n") mdx_parts.append("\n---\n") conversation_index += 1 # Skip past the assistant messages we just processed i = j else: i += 1 # Add summary at the end using Astro component total_tokens = calculate_total_tokens(main_messages) if total_tokens["total_input"] > 0: mdx_parts.append("\n---\n\n") mdx_parts.append("<PromptSummary\n") mdx_parts.append(f" totalInput={{{total_tokens['total_input']}}}\n") mdx_parts.append(f" totalOutput={{{total_tokens['total_output']}}}\n") if total_tokens["total_cache_read"] > 0: mdx_parts.append(f" cacheRead={{{total_tokens['total_cache_read']}}}\n") if total_tokens["total_cache_creation"] > 0: mdx_parts.append( f" cacheCreation={{{total_tokens['total_cache_creation']}}}\n" ) if models_used: models_list = ", ".join(f'"{m}"' for m in sorted(models_used)) mdx_parts.append(f" models={{[{models_list}]}}\n") mdx_parts.append("/>\n") return "".join(mdx_parts) def main(): parser = argparse.ArgumentParser( description="Convert Claude Code conversation from JSONL to MDX format.", formatter_class=argparse.RawDescriptionHelpFormatter, epilog=""" Examples: # Convert latest session python convert-claude-chat.py $(ls -t ~/.claude/projects/*/*.jsonl | head -n 1) # Convert with custom title python convert-claude-chat.py session.jsonl --title "Building a Wiki Feature" # Convert to specific output file python convert-claude-chat.py session.jsonl --output ../src/content/prompts/my-chat.mdx """, ) parser.add_argument( "jsonl_file", type=Path, help="Path to Claude Code JSONL session file" ) parser.add_argument( "--output", "-o", type=Path, help="Output MDX file path (default: stdout)" ) parser.add_argument( "--title", "-t", type=str, help="Title for the conversation (default: auto-generated)", ) args = parser.parse_args() # Validate input file if not args.jsonl_file.exists(): print(f"Error: File not found: {args.jsonl_file}", file=sys.stderr) sys.exit(1) # Parse JSONL try: messages = parse_jsonl(args.jsonl_file) except Exception as e: print(f"Error parsing JSONL: {e}", file=sys.stderr) sys.exit(1) if not messages: print("Error: No messages found in JSONL file", file=sys.stderr) sys.exit(1) # Extract session ID from messages session_id = messages[0].get("sessionId", None) # Generate title if not provided if not args.title: first_user_msg = next((m for m in messages if m.get("type") == "user"), None) if first_user_msg: content = first_user_msg.get("message", {}).get("content", "") if isinstance(content, str): # Use first line or first 50 chars as title title_text = content.split("\n")[0][:50] args.title = f"Conversation: {title_text}" else: args.title = "Claude Code Conversation" else: args.title = "Claude Code Conversation" # Convert to MDX try: mdx_content = convert_to_mdx(messages, args.title, session_id) except Exception as e: print(f"Error converting to MDX: {e}", file=sys.stderr) import traceback traceback.print_exc() sys.exit(1) # Output if args.output: args.output.parent.mkdir(parents=True, exist_ok=True) args.output.write_text(mdx_content, encoding="utf-8") print(f"✅ Converted to: {args.output}") else: print(mdx_content) if __name__ == "__main__": main()