From 5c9a48a2abd77844db14ba376eca740acad11cc7 Mon Sep 17 00:00:00 2001 From: Pikaliov Date: Thu, 30 Apr 2026 14:39:20 +0000 Subject: [PATCH] =?UTF-8?q?=D0=97=D0=B0=D0=B3=D1=80=D1=83=D0=B7=D0=B8?= =?UTF-8?q?=D1=82=D1=8C=20=D1=84=D0=B0=D0=B9=D0=BB=D1=8B=20=D0=B2=20=C2=AB?= =?UTF-8?q?/=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- claude_chat_to_md.py | 578 +++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 578 insertions(+) create mode 100644 claude_chat_to_md.py diff --git a/claude_chat_to_md.py b/claude_chat_to_md.py new file mode 100644 index 0000000..728ec97 --- /dev/null +++ b/claude_chat_to_md.py @@ -0,0 +1,578 @@ +#!/usr/bin/env python3 +"""Convert Claude Code chat sessions (.jsonl) to Markdown. + +Reads session JSONL files from ~/.claude/projects/ and produces clean, +readable Markdown — including subagent conversations. + +Usage: + # List all sessions + claude-chat-to-md --list + + # Convert a specific session (by UUID or partial match) + claude-chat-to-md 2354ca15 + + # Convert the most recent session for a project + claude-chat-to-md --latest --project myapp + + # Convert all sessions + claude-chat-to-md --all + + # Output to a specific file + claude-chat-to-md 2354ca15 -o chat.md +""" + +from __future__ import annotations + +import argparse +import json +import re +import sys +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import TextIO + + +import os + +# Allow overriding via environment variable or use current dir if it looks like .claude +if Path("projects").is_dir() and Path("sessions").is_dir(): + CLAUDE_DIR = Path.cwd() +else: + CLAUDE_DIR = Path.home() / ".claude" +PROJECTS_DIR = CLAUDE_DIR / "projects" + + +@dataclass +class SessionInfo: + """Metadata about a discovered session.""" + + path: Path + session_id: str + project_path: str + title: str | None = None + timestamp: str | None = None + subagent_dir: Path | None = None + + @property + def display_project(self) -> str: + """Human-readable project path. + + Claude Code encodes project paths by replacing '/' with '-', + e.g. '/Users/nick/my-project' becomes '-Users-nick-my-project'. + We reverse this by splitting on the known leading pattern and + reconstructing path separators only where they originally were. + """ + # The encoded path starts with '-' (representing the leading '/') + # and uses '-' for every '/' in the original path. However, actual + # directory names may contain hyphens too. We can't perfectly reverse + # this, but we can use os.sep knowledge: the original path segments + # are valid directory names. We try to find the longest valid prefix. + raw = self.project_path + if not raw: + return raw + + # Try to find the actual path on disk by checking ~/.claude/projects/ + # Fall back to a heuristic: replace leading '-' with '/' and try to + # reconstruct by checking which splits produce real-looking paths. + # Best heuristic: the encoded form is the absolute path with '/' -> '-' + # and a leading '-'. Try to resolve by checking if the path exists. + candidate = "/" + raw[1:] if raw.startswith("-") else raw + # Replace hyphens with '/' and check if it's a real path + full_replace = candidate.replace("-", "/") + if Path(full_replace).exists(): + return full_replace.lstrip("/") + + # Fallback: use a smarter reconstruction. Split by '-' and greedily + # rejoin segments that form existing directories. + parts = raw.lstrip("-").split("-") + reconstructed = [parts[0]] + for part in parts[1:]: + # Try joining with the previous segment (hyphenated name) + test_hyphen = "/" + "/".join(reconstructed[:-1] + [reconstructed[-1] + "-" + part]) + test_slash = "/" + "/".join(reconstructed + [part]) + if Path(test_hyphen).exists(): + reconstructed[-1] += "-" + part + else: + reconstructed.append(part) + + return "/".join(reconstructed) + + +def discover_sessions() -> list[SessionInfo]: + """Find all session JSONL files under ~/.claude/projects/.""" + sessions: list[SessionInfo] = [] + if not PROJECTS_DIR.exists(): + return sessions + + for project_dir in PROJECTS_DIR.iterdir(): + if not project_dir.is_dir(): + continue + for jsonl_file in project_dir.glob("*.jsonl"): + sid = jsonl_file.stem + # Skip subagent files + if sid.startswith("agent-"): + continue + + info = SessionInfo( + path=jsonl_file, + session_id=sid, + project_path=project_dir.name, + ) + + # Check for subagent directory + subagent_dir = project_dir / sid / "subagents" + if subagent_dir.is_dir(): + info.subagent_dir = subagent_dir + + # Quick scan for title and first timestamp + try: + with open(jsonl_file, encoding='utf-8') as f: + for line in f: + obj = json.loads(line) + if obj.get("type") == "ai-title": + info.title = obj.get("aiTitle") + if not info.timestamp and obj.get("timestamp"): + info.timestamp = obj["timestamp"] + if info.title and info.timestamp: + break + except (json.JSONDecodeError, OSError): + pass + + sessions.append(info) + + sessions.sort(key=lambda s: s.timestamp or "", reverse=True) + return sessions + + +def parse_messages(jsonl_path: Path) -> list[dict]: + """Parse a JSONL file into an ordered list of message records.""" + messages = [] + with open(jsonl_path, encoding='utf-8') as f: + for line in f: + try: + obj = json.loads(line) + except json.JSONDecodeError: + continue + if obj.get("type") in ("user", "assistant"): + messages.append(obj) + return messages + + +def format_tool_use(content_block: dict) -> str: + """Format a tool_use content block as Markdown.""" + name = content_block.get("name", "Unknown") + inp = content_block.get("input", {}) + + lines = [f"**Tool: {name}**"] + + if name == "Bash": + cmd = inp.get("command", "") + desc = inp.get("description", "") + if desc: + lines.append(f"*{desc}*") + lines.append(f"```bash\n{cmd}\n```") + elif name == "Read": + fp = inp.get("file_path", "") + lines.append(f"Reading `{fp}`") + elif name == "Write": + fp = inp.get("file_path", "") + content = inp.get("content", "") + lines.append(f"Writing `{fp}`") + if content: + # Show first/last lines for long files + content_lines = content.split("\n") + if len(content_lines) > 30: + preview = "\n".join(content_lines[:15]) + preview += f"\n\n... ({len(content_lines) - 30} lines omitted) ...\n\n" + preview += "\n".join(content_lines[-15:]) + else: + preview = content + ext = Path(fp).suffix.lstrip(".") + lines.append(f"```{ext}\n{preview}\n```") + elif name == "Edit": + fp = inp.get("file_path", "") + old = inp.get("old_string", "") + new = inp.get("new_string", "") + lines.append(f"Editing `{fp}`") + if old or new: + lines.append("```diff") + for ol in old.split("\n"): + lines.append(f"- {ol}") + for nl in new.split("\n"): + lines.append(f"+ {nl}") + lines.append("```") + elif name == "Grep": + pattern = inp.get("pattern", "") + path = inp.get("path", ".") + lines.append(f"Searching for `{pattern}` in `{path}`") + elif name == "Glob": + pattern = inp.get("pattern", "") + lines.append(f"Finding files matching `{pattern}`") + elif name == "Agent": + desc = inp.get("description", "") + subtype = inp.get("subagent_type", "general-purpose") + lines.append(f"Spawning **{subtype}** agent: *{desc}*") + prompt = inp.get("prompt", "") + if prompt: + # Truncate long prompts + if len(prompt) > 500: + prompt = prompt[:500] + "..." + lines.append(f"\n> {prompt}") + elif name in ("WebSearch", "WebFetch"): + query = inp.get("query", inp.get("url", "")) + lines.append(f"`{query}`") + else: + # Generic: show input as JSON + if inp: + lines.append(f"```json\n{json.dumps(inp, indent=2)[:500]}\n```") + + return "\n".join(lines) + + +def format_tool_result(content_block: dict) -> str: + """Format a tool_result content block as Markdown.""" + content = content_block.get("content", "") + is_error = content_block.get("is_error", False) + + if isinstance(content, list): + # Content can be a list of text/image blocks + parts = [] + for item in content: + if isinstance(item, dict) and item.get("type") == "text": + parts.append(item["text"]) + elif isinstance(item, dict) and item.get("type") == "image": + parts.append("*[image]*") + else: + parts.append(str(item)) + content = "\n".join(parts) + + if not content: + return "" + + prefix = "**Error:**\n" if is_error else "" + # Truncate very long results + lines = content.split("\n") + if len(lines) > 50: + content = "\n".join(lines[:25]) + content += f"\n\n... ({len(lines) - 50} lines omitted) ...\n\n" + content += "\n".join(lines[-25:]) + + return f"{prefix}```\n{content}\n```" + + +def format_content(content: list | str) -> str: + """Format a message's content array into Markdown.""" + if isinstance(content, str): + return content + + parts = [] + for block in content: + if isinstance(block, str): + parts.append(block) + elif not isinstance(block, dict): + continue + elif block.get("type") == "text": + text = block["text"] + # Strip IDE system tags for cleaner output + text = re.sub(r".*?", "", text, flags=re.DOTALL) + text = re.sub(r".*?", "", text, flags=re.DOTALL) + text = text.strip() + if text: + parts.append(text) + elif block.get("type") == "tool_use": + parts.append(format_tool_use(block)) + elif block.get("type") == "tool_result": + parts.append(format_tool_result(block)) + + return "\n\n".join(parts) + + +def convert_subagent(jsonl_path: Path, meta_path: Path | None) -> str: + """Convert a subagent session into a Markdown section.""" + meta = {} + if meta_path and meta_path.exists(): + try: + meta = json.loads(meta_path.read_text(encoding='utf-8')) + except (json.JSONDecodeError, OSError): + pass + + agent_type = meta.get("agentType", "unknown") + description = meta.get("description", "Subagent") + + messages = parse_messages(jsonl_path) + lines = [f"#### Subagent: {description}", f"*Type: {agent_type}*\n"] + + for msg in messages: + role = msg.get("type", msg.get("message", {}).get("role", "unknown")) + content = msg.get("message", {}).get("content", []) + formatted = format_content(content) + if not formatted: + continue + + if role == "user": + lines.append(f"**Prompt:**\n\n{formatted}\n") + elif role == "assistant": + lines.append(f"{formatted}\n") + + return "\n".join(lines) + + +def convert_session( + session: SessionInfo, + include_subagents: bool = True, + include_tool_results: bool = True, +) -> str: + """Convert a full session to Markdown.""" + messages = parse_messages(session.path) + + # Collect subagent data if available + subagents: dict[str, str] = {} # tool_use_id -> markdown + if include_subagents and session.subagent_dir: + for meta_file in session.subagent_dir.glob("*.meta.json"): + agent_id = meta_file.stem.replace(".meta", "") + jsonl_file = session.subagent_dir / f"{agent_id}.jsonl" + if jsonl_file.exists(): + subagents[agent_id] = convert_subagent(jsonl_file, meta_file) + + # Build output + lines = [] + + # Header + title = session.title or "Untitled Session" + ts = session.timestamp or "" + if ts: + try: + dt = datetime.fromisoformat(ts.replace("Z", "+00:00")) + ts = dt.strftime("%Y-%m-%d %H:%M UTC") + except ValueError: + pass + + lines.append(f"# {title}") + lines.append("") + lines.append(f"**Session:** `{session.session_id}` ") + lines.append(f"**Project:** `{session.display_project}` ") + if ts: + lines.append(f"**Date:** {ts} ") + lines.append("") + lines.append("---") + lines.append("") + + # Track which agent tool_use_ids map to which subagent + agent_tool_use_map: dict[str, str] = {} + + for msg in messages: + role = msg.get("type", "") + content = msg.get("message", {}).get("content", []) + + if role == "user": + # Check if this is a tool_result (not direct user input) + if isinstance(content, list) and all( + isinstance(b, dict) and b.get("type") == "tool_result" + for b in content + ): + if include_tool_results: + formatted = format_content(content) + if formatted.strip(): + lines.append(f"
Tool Result\n\n{formatted}\n\n
\n") + continue + + formatted = format_content(content) + if formatted.strip(): + lines.append(f"## User\n\n{formatted}\n") + + elif role == "assistant": + formatted_parts = [] + if isinstance(content, list): + for block in content: + if isinstance(block, dict) and block.get("type") == "tool_use": + if block.get("name") == "Agent": + # Map this tool_use to subagent output + desc = block.get("input", {}).get("description", "") + # Find matching subagent by description + for aid, md in subagents.items(): + if desc and desc in md: + agent_tool_use_map[block["id"]] = aid + + formatted_parts.append(format_tool_use(block)) + elif isinstance(block, dict) and block.get("type") == "text": + import re + text = block["text"] + text = re.sub(r".*?", "", text, flags=re.DOTALL) + text = re.sub(r".*?", "", text, flags=re.DOTALL) + text = text.strip() + if text: + formatted_parts.append(text) + elif isinstance(block, dict) and block.get("type") == "tool_result": + if include_tool_results: + r = format_tool_result(block) + if r: + formatted_parts.append(r) + + formatted = "\n\n".join(formatted_parts) + if formatted.strip(): + lines.append(f"## Assistant\n\n{formatted}\n") + + # Insert subagent conversations after the assistant message that spawned them + if isinstance(content, list): + for block in content: + if ( + isinstance(block, dict) + and block.get("type") == "tool_use" + and block.get("name") == "Agent" + ): + tid = block.get("id", "") + if tid in agent_tool_use_map: + aid = agent_tool_use_map[tid] + lines.append(f"
Subagent Conversation\n") + lines.append(subagents[aid]) + lines.append("
\n") + else: + # Try to find by description match + desc = block.get("input", {}).get("description", "") + for aid, md in subagents.items(): + if desc and desc.lower() in md.lower() and aid not in agent_tool_use_map.values(): + lines.append(f"
Subagent Conversation\n") + lines.append(md) + lines.append("
\n") + agent_tool_use_map[tid] = aid + break + + return "\n".join(lines) + + +def list_sessions(sessions: list[SessionInfo], out: TextIO = sys.stdout) -> None: + """Print a table of discovered sessions.""" + if not sessions: + print("No sessions found.", file=out) + return + + print(f"{'#':<4} {'Date':<20} {'ID':<12} {'Title':<50} {'Project'}", file=out) + print("-" * 120, file=out) + for i, s in enumerate(sessions, 1): + ts = "" + if s.timestamp: + try: + dt = datetime.fromisoformat(s.timestamp.replace("Z", "+00:00")) + ts = dt.strftime("%Y-%m-%d %H:%M") + except ValueError: + ts = s.timestamp[:16] + title = (s.title or "Untitled")[:50] + sid = s.session_id[:10] + ".." + proj = s.display_project + # Shorten project path + parts = proj.split("/") + if len(parts) > 3: + proj = "/".join(parts[-3:]) + print(f"{i:<4} {ts:<20} {sid:<12} {title:<50} {proj}", file=out) + + +def find_session(sessions: list[SessionInfo], query: str) -> SessionInfo | None: + """Find a session by UUID prefix or index.""" + # Try as index + try: + idx = int(query) - 1 + if 0 <= idx < len(sessions): + return sessions[idx] + except ValueError: + pass + + # Try as UUID prefix + for s in sessions: + if s.session_id.startswith(query): + return s + + # Try title substring + for s in sessions: + if s.title and query.lower() in s.title.lower(): + return s + + return None + + +def main() -> None: + parser = argparse.ArgumentParser( + description="Convert Claude Code chat sessions to Markdown", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog=__doc__, + ) + parser.add_argument( + "session", + nargs="?", + help="Session UUID (prefix), index number from --list, or title substring", + ) + parser.add_argument("--list", "-l", action="store_true", help="List all sessions") + parser.add_argument("--latest", action="store_true", help="Convert the most recent session") + parser.add_argument("--all", action="store_true", help="Convert all sessions") + parser.add_argument("--project", "-p", help="Filter by project path substring") + parser.add_argument("--output", "-o", help="Output file path (default: stdout)") + parser.add_argument( + "--no-subagents", action="store_true", help="Exclude subagent conversations" + ) + parser.add_argument( + "--no-tool-results", action="store_true", help="Exclude tool results" + ) + parser.add_argument( + "--output-dir", "-d", help="Output directory (for --all mode)" + ) + + args = parser.parse_args() + sessions = discover_sessions() + + if args.project: + sessions = [s for s in sessions if args.project.lower() in s.project_path.lower()] + + if args.list: + list_sessions(sessions) + return + + if not args.session and not args.latest and not args.all: + parser.print_help() + print("\nUse --list to see available sessions.", file=sys.stderr) + sys.exit(1) + + include_subagents = not args.no_subagents + include_tool_results = not args.no_tool_results + + if args.all: + out_dir = Path(args.output_dir) if args.output_dir else Path(".") + out_dir.mkdir(parents=True, exist_ok=True) + for s in sessions: + md = convert_session(s, include_subagents, include_tool_results) + ts = "" + if s.timestamp: + try: + dt = datetime.fromisoformat(s.timestamp.replace("Z", "+00:00")) + ts = dt.strftime("%Y-%m-%d") + except ValueError: + pass + safe_title = (s.title or "untitled").replace(" ", "-").replace("/", "-")[:50] + filename = f"{ts}-{safe_title}.md" if ts else f"{s.session_id[:8]}-{safe_title}.md" + out_path = out_dir / filename + out_path.write_text(md, encoding='utf-8') + print(f"Wrote {out_path}", file=sys.stderr) + return + + if args.latest: + if not sessions: + print("No sessions found.", file=sys.stderr) + sys.exit(1) + session = sessions[0] + else: + session = find_session(sessions, args.session) + if not session: + print(f"Session not found: {args.session}", file=sys.stderr) + print("Use --list to see available sessions.", file=sys.stderr) + sys.exit(1) + + md = convert_session(session, include_subagents, include_tool_results) + + if args.output: + Path(args.output).write_text(md, encoding='utf-8') + print(f"Wrote {args.output}", file=sys.stderr) + else: + print(md) + + +if __name__ == "__main__": + main()