Last active
July 19, 2026 21:12
-
-
Save fasiha/3a54f2e3f9ecec9620dfeceb862d4337 to your computer and use it in GitHub Desktop.
Given a UUID, find the Claude Code session and convert to a Markdown
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 node | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const readline = require('readline'); | |
| async function findJsonlFile(uuid) { | |
| const projectsDir = path.expandUser('~/.claude/projects'); | |
| function searchDir(dir) { | |
| const files = fs.readdirSync(dir); | |
| for (const file of files) { | |
| const fullPath = path.join(dir, file); | |
| const stat = fs.statSync(fullPath); | |
| if (stat.isDirectory()) { | |
| const result = searchDir(fullPath); | |
| if (result) return result; | |
| } else if (file === `${uuid}.jsonl`) { | |
| return fullPath; | |
| } | |
| } | |
| return null; | |
| } | |
| return searchDir(projectsDir); | |
| } | |
| // Mimic path.expandUser for compatibility | |
| path.expandUser = (str) => { | |
| return str.replace(/^~/, process.env.HOME); | |
| }; | |
| function escapeMarkdownHeading(text) { | |
| // Escape # at the start of lines | |
| return text.replace(/^(#+\s)/gm, '\\$1'); | |
| } | |
| async function parseJsonl(filePath) { | |
| const records = []; | |
| const fileStream = fs.createReadStream(filePath); | |
| const rl = readline.createInterface({ | |
| input: fileStream, | |
| crlfDelay: Infinity | |
| }); | |
| for await (const line of rl) { | |
| if (line.trim()) { | |
| try { | |
| records.push(JSON.parse(line)); | |
| } catch (e) { | |
| console.error(`Failed to parse line: ${line.substring(0, 100)}`); | |
| } | |
| } | |
| } | |
| return records; | |
| } | |
| function parseClaudeWebJson(filePath) { | |
| const content = fs.readFileSync(filePath, 'utf-8'); | |
| const data = JSON.parse(content); | |
| if (!data.chat_messages || !Array.isArray(data.chat_messages)) { | |
| throw new Error('Invalid Claude.ai JSON: missing or invalid chat_messages array'); | |
| } | |
| const records = []; | |
| for (const message of data.chat_messages) { | |
| if (message.sender === 'human') { | |
| records.push({ | |
| type: 'user', | |
| message: { | |
| content: extractClaudeWebContent(message.content) | |
| }, | |
| timestamp: message.created_at | |
| }); | |
| } else if (message.sender === 'assistant') { | |
| records.push({ | |
| type: 'assistant', | |
| message: { | |
| content: extractClaudeWebContent(message.content) | |
| }, | |
| timestamp: message.created_at | |
| }); | |
| } | |
| } | |
| // Add title from top-level name if available | |
| const data_obj = JSON.parse(fs.readFileSync(filePath, 'utf-8')); | |
| if (data_obj.name) { | |
| records.unshift({ | |
| type: 'ai-title', | |
| aiTitle: data_obj.name, | |
| timestamp: data_obj.created_at | |
| }); | |
| } | |
| return records; | |
| } | |
| function extractClaudeWebContent(contentArray) { | |
| if (!Array.isArray(contentArray)) { | |
| return ''; | |
| } | |
| const blocks = contentArray.map(block => { | |
| if (block.type === 'text') { | |
| return block.text || ''; | |
| } else if (block.type === 'thinking') { | |
| const thinking = block.thinking?.trim(); | |
| return thinking ? `<details>\n<summary>Thinking</summary>\n\n${thinking}\n\n</details>` : ''; | |
| } else if (block.type === 'tool_use') { | |
| return `[Tool use: ${block.name}]`; | |
| } else if (block.type === 'tool_result') { | |
| if (Array.isArray(block.content)) { | |
| return block.content | |
| .map(c => (typeof c === 'string' ? c : c.title || JSON.stringify(c))) | |
| .join('\n'); | |
| } | |
| return '[Tool result]'; | |
| } | |
| return ''; | |
| }).filter(Boolean); | |
| return blocks.join('\n\n'); | |
| } | |
| function getTitle(records) { | |
| // Look for ai-title | |
| const titleRecord = records.find(r => r.type === 'ai-title'); | |
| if (titleRecord) return titleRecord.aiTitle; | |
| // Look for first user message | |
| const userRecord = records.find(r => r.type === 'user' && r.message); | |
| if (userRecord && userRecord.message.content) { | |
| const content = userRecord.message.content; | |
| const firstLine = content.split('\n')[0]; | |
| return firstLine.substring(0, 100); | |
| } | |
| return 'Claude Code Session'; | |
| } | |
| function extractContent(message) { | |
| if (typeof message.content === 'string') { | |
| return message.content; | |
| } | |
| if (Array.isArray(message.content)) { | |
| return message.content | |
| .map(block => { | |
| if (block.type === 'text') return block.text; | |
| if (block.type === 'thinking') { | |
| const thinking = block.thinking?.trim(); | |
| return thinking ? `<details>\n<summary>Thinking</summary>\n\n${thinking}\n\n</details>` : ''; | |
| } | |
| return ''; | |
| }) | |
| .filter(Boolean) | |
| .join('\n\n'); | |
| } | |
| return ''; | |
| } | |
| async function generateMarkdown(filePath, records) { | |
| const title = getTitle(records); | |
| let md = `# ${title}\n\n`; | |
| let turnCount = 0; | |
| for (const record of records) { | |
| if (record.type === 'user' && record.message) { | |
| const content = extractContent(record.message); | |
| // Skip empty user messages | |
| if (content.trim()) { | |
| turnCount++; | |
| const timestamp = new Date(record.timestamp).toLocaleString(); | |
| md += `## Turn ${turnCount}: User (${timestamp})\n\n`; | |
| md += escapeMarkdownHeading(content) + '\n\n'; | |
| } | |
| } | |
| if (record.type === 'assistant' && record.message) { | |
| const content = extractContent(record.message); | |
| // Skip empty responses | |
| if (content.trim()) { | |
| const timestamp = new Date(record.timestamp).toLocaleString(); | |
| md += `## Assistant Response\n\n`; | |
| md += escapeMarkdownHeading(content) + '\n\n'; | |
| } | |
| } | |
| if (record.type === 'ai-title' && record.aiTitle) { | |
| md += `**Session Title:** ${record.aiTitle}\n\n`; | |
| } | |
| } | |
| return md; | |
| } | |
| async function main() { | |
| const input = process.argv[2]; | |
| if (!input || input === '--help' || input === '-h') { | |
| console.log(`Usage: claude-session-to-md.js <file-path-or-uuid> | |
| Arguments: | |
| file-path Path to a JSON file (from Claude.ai) or JSONL file (from ~/.claude) | |
| uuid UUID to search for in ~/.claude/projects | |
| Examples: | |
| claude-session-to-md.js /path/to/7d99661e-873c-411c-bd83-d4ca796e6c7e.json | |
| claude-session-to-md.js ./session.jsonl | |
| claude-session-to-md.js a1b2c3d4-e5f6-7890-1234-567890abcdef | |
| `); | |
| process.exit(input ? 0 : 1); | |
| } | |
| let filePath; | |
| let records; | |
| let outputName; | |
| // Check if input is a file path | |
| if (fs.existsSync(input)) { | |
| console.log(`Loading file: ${input}`); | |
| filePath = input; | |
| // Determine if it's JSON or JSONL | |
| const ext = path.extname(input).toLowerCase(); | |
| const isJsonFile = ext === '.json'; | |
| if (isJsonFile) { | |
| console.log('Detected Claude.ai JSON format'); | |
| records = parseClaudeWebJson(filePath); | |
| outputName = path.basename(input, '.json'); | |
| } else { | |
| console.log('Detected JSONL format'); | |
| records = await parseJsonl(filePath); | |
| outputName = path.basename(input, path.extname(input)); | |
| } | |
| } else { | |
| // Treat as UUID and search in ~/.claude | |
| console.log(`Searching for JSONL file with UUID: ${input}`); | |
| filePath = await findJsonlFile(input); | |
| if (!filePath) { | |
| console.error(`No file found for: ${input} (not a valid path or UUID)`); | |
| process.exit(1); | |
| } | |
| console.log(`Found file: ${filePath}`); | |
| records = await parseJsonl(filePath); | |
| outputName = input; | |
| } | |
| console.log(`Parsed ${records.length} records`); | |
| const markdown = await generateMarkdown(filePath, records); | |
| const outputPath = path.join(process.cwd(), `${outputName}.md`); | |
| fs.writeFileSync(outputPath, markdown); | |
| console.log(`✓ Exported to: ${outputPath}`); | |
| } | |
| main().catch(err => { | |
| console.error('Error:', err.message); | |
| process.exit(1); | |
| }); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment