From 8fa60c37d0c7e030f62ed5af63e1d3be4882e53c Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:17:31 +0800 Subject: [PATCH 01/14] docs: add implementation plan for markdown export feature --- docs/plans/2026-03-01-export-to-markdown.md | 684 ++++++++++++++++++++ 1 file changed, 684 insertions(+) create mode 100644 docs/plans/2026-03-01-export-to-markdown.md diff --git a/docs/plans/2026-03-01-export-to-markdown.md b/docs/plans/2026-03-01-export-to-markdown.md new file mode 100644 index 0000000..bdb797f --- /dev/null +++ b/docs/plans/2026-03-01-export-to-markdown.md @@ -0,0 +1,684 @@ +# Export to Markdown Implementation Plan + +> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. + +**Goal:** Add a `--markdown` flag that outputs session transcripts as a single `.md` file instead of paginated HTML. + +**Architecture:** Add a `generate_markdown()` function parallel to `generate_html()` that reuses the same parsing/conversation-grouping logic but renders each content block to Markdown text instead of HTML. Each CLI command (`local`, `json`, `web`) gets a `--markdown` flag that switches to the markdown codepath. Output is a single `.md` file (no pagination). + +**Tech Stack:** Python, Click (CLI), existing `parse_session_file()` and `extract_text_from_content()` functions. + +--- + +### Task 1: Add `render_content_block_markdown()` function + +**Files:** +- Modify: `src/claude_code_transcripts/__init__.py` +- Test: `tests/test_generate_html.py` + +**Step 1: Write the failing tests** + +Add a new test class `TestMarkdownRendering` to `tests/test_generate_html.py`: + +```python +from claude_code_transcripts import render_content_block_markdown + + +class TestMarkdownRendering: + """Tests for Markdown rendering of content blocks.""" + + def test_text_block(self): + block = {"type": "text", "text": "Hello **world**"} + result = render_content_block_markdown(block) + assert result == "Hello **world**" + + def test_thinking_block(self): + block = {"type": "thinking", "thinking": "Let me think about this"} + result = render_content_block_markdown(block) + assert "
" in result + assert "Thinking" in result + assert "Let me think about this" in result + + def test_tool_use_write(self): + block = { + "type": "tool_use", + "id": "toolu_001", + "name": "Write", + "input": {"file_path": "/tmp/hello.py", "content": "print('hi')"}, + } + result = render_content_block_markdown(block) + assert "Write" in result + assert "/tmp/hello.py" in result + assert "print('hi')" in result + + def test_tool_use_edit(self): + block = { + "type": "tool_use", + "id": "toolu_002", + "name": "Edit", + "input": { + "file_path": "/tmp/hello.py", + "old_string": "print('hi')", + "new_string": "print('hello')", + }, + } + result = render_content_block_markdown(block) + assert "Edit" in result + assert "/tmp/hello.py" in result + assert "print('hi')" in result + assert "print('hello')" in result + + def test_tool_use_bash(self): + block = { + "type": "tool_use", + "id": "toolu_003", + "name": "Bash", + "input": {"command": "ls -la", "description": "List files"}, + } + result = render_content_block_markdown(block) + assert "Bash" in result + assert "ls -la" in result + + def test_tool_use_generic(self): + block = { + "type": "tool_use", + "id": "toolu_004", + "name": "Glob", + "input": {"pattern": "**/*.py"}, + } + result = render_content_block_markdown(block) + assert "Glob" in result + assert "**/*.py" in result + + def test_tool_result_string(self): + block = { + "type": "tool_result", + "tool_use_id": "toolu_001", + "content": "File written successfully", + } + result = render_content_block_markdown(block) + assert "File written successfully" in result + + def test_tool_result_error(self): + block = { + "type": "tool_result", + "tool_use_id": "toolu_001", + "content": "Error: file not found", + "is_error": True, + } + result = render_content_block_markdown(block) + assert "Error" in result + + def test_tool_result_with_list_content(self): + block = { + "type": "tool_result", + "tool_use_id": "toolu_001", + "content": [{"type": "text", "text": "some output"}], + } + result = render_content_block_markdown(block) + assert "some output" in result + + def test_image_block(self): + block = { + "type": "image", + "source": {"media_type": "image/png", "data": "abc123base64"}, + } + result = render_content_block_markdown(block) + assert "[Image" in result + + def test_todo_write(self): + block = { + "type": "tool_use", + "id": "toolu_005", + "name": "TodoWrite", + "input": { + "todos": [ + {"content": "First task", "status": "completed"}, + {"content": "Second task", "status": "pending"}, + ] + }, + } + result = render_content_block_markdown(block) + assert "First task" in result + assert "Second task" in result +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_generate_html.py::TestMarkdownRendering -v` +Expected: FAIL with `ImportError: cannot import name 'render_content_block_markdown'` + +**Step 3: Write minimal implementation** + +Add to `src/claude_code_transcripts/__init__.py` (after the existing `render_content_block` function): + +```python +def render_content_block_markdown(block): + """Render a single content block as Markdown text.""" + if not isinstance(block, dict): + return str(block) + + block_type = block.get("type", "") + + if block_type == "text": + return block.get("text", "") + + elif block_type == "thinking": + thinking = block.get("thinking", "") + return f"
\nThinking\n\n{thinking}\n\n
" + + elif block_type == "image": + return "[Image embedded in original transcript]" + + elif block_type == "tool_use": + tool_name = block.get("name", "Unknown tool") + tool_input = block.get("input", {}) + + if tool_name == "TodoWrite": + todos = tool_input.get("todos", []) + lines = [f"**TodoWrite**\n"] + for todo in todos: + status = todo.get("status", "pending") + content = todo.get("content", "") + if status == "completed": + lines.append(f"- [x] {content}") + elif status == "in_progress": + lines.append(f"- [ ] {content} *(in progress)*") + else: + lines.append(f"- [ ] {content}") + return "\n".join(lines) + + if tool_name == "Write": + file_path = tool_input.get("file_path", "Unknown file") + content = tool_input.get("content", "") + return f"**Write** `{file_path}`\n\n```\n{content}\n```" + + if tool_name == "Edit": + file_path = tool_input.get("file_path", "Unknown file") + old_string = tool_input.get("old_string", "") + new_string = tool_input.get("new_string", "") + replace_all = tool_input.get("replace_all", False) + header = f"**Edit** `{file_path}`" + if replace_all: + header += " *(replace all)*" + return f"{header}\n\n```diff\n- {old_string}\n+ {new_string}\n```" + + if tool_name == "Bash": + command = tool_input.get("command", "") + description = tool_input.get("description", "") + header = f"**Bash**" + if description: + header += f": {description}" + return f"{header}\n\n```bash\n{command}\n```" + + # Generic tool + display_input = {k: v for k, v in tool_input.items() if k != "description"} + input_json = json.dumps(display_input, indent=2, ensure_ascii=False) + description = tool_input.get("description", "") + header = f"**{tool_name}**" + if description: + header += f": {description}" + return f"{header}\n\n```json\n{input_json}\n```" + + elif block_type == "tool_result": + content = block.get("content", "") + is_error = block.get("is_error", False) + prefix = "**Error:**\n" if is_error else "" + + if isinstance(content, str): + return f"{prefix}```\n{content}\n```" + elif isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + if item.get("type") == "text": + parts.append(item.get("text", "")) + elif item.get("type") == "image": + parts.append("[Image embedded in original transcript]") + else: + parts.append(json.dumps(item, indent=2, ensure_ascii=False)) + else: + parts.append(str(item)) + result = "\n".join(parts) + return f"{prefix}```\n{result}\n```" + else: + return f"{prefix}```\n{json.dumps(content, indent=2, ensure_ascii=False)}\n```" + + else: + return f"```json\n{json.dumps(block, indent=2, ensure_ascii=False)}\n```" +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_generate_html.py::TestMarkdownRendering -v` +Expected: PASS + +**Step 5: Commit** + +```bash +uv run black . +git add src/claude_code_transcripts/__init__.py tests/test_generate_html.py +git commit -m "feat: add render_content_block_markdown for Markdown export" +``` + +--- + +### Task 2: Add `generate_markdown()` function + +**Files:** +- Modify: `src/claude_code_transcripts/__init__.py` +- Test: `tests/test_generate_html.py` + +**Step 1: Write the failing tests** + +Add a new test class `TestGenerateMarkdown` to `tests/test_generate_html.py`: + +```python +from claude_code_transcripts import generate_markdown + + +class TestGenerateMarkdown: + """Tests for generate_markdown function.""" + + def test_generates_markdown_file(self, output_dir): + """Test that generate_markdown creates a .md file.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + assert result_path.exists() + assert result_path.suffix == ".md" + + def test_markdown_contains_user_messages(self, output_dir): + """Test that user messages appear in the Markdown output.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + content = result_path.read_text() + assert "Create a hello world function" in content + + def test_markdown_contains_assistant_messages(self, output_dir): + """Test that assistant messages appear in the Markdown output.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + content = result_path.read_text() + assert "I'll create that function for you" in content + + def test_markdown_contains_tool_calls(self, output_dir): + """Test that tool calls appear in the Markdown output.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + content = result_path.read_text() + assert "Write" in content + assert "hello.py" in content + + def test_markdown_has_role_headers(self, output_dir): + """Test that messages have User/Assistant headers.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + content = result_path.read_text() + assert "### User" in content + assert "### Assistant" in content + + def test_markdown_has_timestamps(self, output_dir): + """Test that timestamps appear in the output.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + content = result_path.read_text() + assert "2025-12-24" in content +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_generate_html.py::TestGenerateMarkdown -v` +Expected: FAIL with `ImportError: cannot import name 'generate_markdown'` + +**Step 3: Write minimal implementation** + +Add to `src/claude_code_transcripts/__init__.py` (after `generate_html`): + +```python +def generate_markdown(json_path, output_dir, github_repo=None): + """Generate a single Markdown file from a session file. + + Returns the Path to the generated .md file. + """ + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True) + + data = parse_session_file(json_path) + loglines = data.get("loglines", []) + + if github_repo is None: + github_repo = detect_github_repo(loglines) + + conversations = _group_conversations(loglines) + md_parts = [] + + for conv in conversations: + for log_type, message_json, timestamp in conv["messages"]: + if not message_json: + continue + try: + message_data = json.loads(message_json) + except json.JSONDecodeError: + continue + + if log_type == "user": + content = message_data.get("content", "") + if is_tool_result_message(message_data): + role = "Tool reply" + else: + role = "User" + md_parts.append(f"### {role}") + md_parts.append(f"*{timestamp}*\n") + md_parts.append(_render_message_content_markdown(message_data)) + elif log_type == "assistant": + md_parts.append("### Assistant") + md_parts.append(f"*{timestamp}*\n") + md_parts.append(_render_message_content_markdown(message_data)) + + md_parts.append("---\n") + + markdown_content = "\n\n".join(md_parts) + md_path = output_dir / "transcript.md" + md_path.write_text(markdown_content, encoding="utf-8") + return md_path +``` + +Also add these helper functions: + +```python +def _group_conversations(loglines): + """Group loglines into conversations. Shared by HTML and Markdown generators.""" + conversations = [] + current_conv = None + for entry in loglines: + log_type = entry.get("type") + timestamp = entry.get("timestamp", "") + is_compact_summary = entry.get("isCompactSummary", False) + message_data = entry.get("message", {}) + if not message_data: + continue + message_json = json.dumps(message_data) + is_user_prompt = False + user_text = None + if log_type == "user": + content = message_data.get("content", "") + text = extract_text_from_content(content) + if text: + is_user_prompt = True + user_text = text + if is_user_prompt: + if current_conv: + conversations.append(current_conv) + current_conv = { + "user_text": user_text, + "timestamp": timestamp, + "messages": [(log_type, message_json, timestamp)], + "is_continuation": bool(is_compact_summary), + } + elif current_conv: + current_conv["messages"].append((log_type, message_json, timestamp)) + if current_conv: + conversations.append(current_conv) + return conversations + + +def _render_message_content_markdown(message_data): + """Render a message's content blocks as Markdown.""" + content = message_data.get("content", "") + if isinstance(content, str): + return content + elif isinstance(content, list): + parts = [] + for block in content: + rendered = render_content_block_markdown(block) + if rendered: + parts.append(rendered) + return "\n\n".join(parts) + return str(content) +``` + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_generate_html.py::TestGenerateMarkdown -v` +Expected: PASS + +**Step 5: Commit** + +```bash +uv run black . +git add src/claude_code_transcripts/__init__.py tests/test_generate_html.py +git commit -m "feat: add generate_markdown function for single-file markdown export" +``` + +--- + +### Task 3: Refactor `generate_html` to use `_group_conversations` + +**Files:** +- Modify: `src/claude_code_transcripts/__init__.py` + +**Step 1: Refactor `generate_html()` and `generate_html_from_session_data()` to use the shared `_group_conversations()` helper** + +Replace the inline conversation-grouping loop in both functions with a call to `_group_conversations(loglines)`. + +In `generate_html()`, replace lines ~1321-1352 with: +```python + conversations = _group_conversations(loglines) +``` + +In `generate_html_from_session_data()`, replace lines ~1795-1826 with: +```python + conversations = _group_conversations(loglines) +``` + +**Step 2: Run all existing tests to verify nothing broke** + +Run: `uv run pytest -v` +Expected: All existing tests PASS + +**Step 3: Commit** + +```bash +uv run black . +git add src/claude_code_transcripts/__init__.py +git commit -m "refactor: extract _group_conversations helper shared by HTML and Markdown generators" +``` + +--- + +### Task 4: Add `--markdown` flag to `local` and `json` commands + +**Files:** +- Modify: `src/claude_code_transcripts/__init__.py` +- Test: `tests/test_all.py` + +**Step 1: Write the failing tests** + +Add to `tests/test_all.py`: + +```python +class TestMarkdownFlag: + """Tests for --markdown flag on CLI commands.""" + + def test_json_command_markdown_flag(self, output_dir): + """Test that json command with --markdown produces a .md file.""" + jsonl_file = output_dir / "test.jsonl" + jsonl_file.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' + '{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi there!"}]}}\n' + ) + md_output = output_dir / "md_output" + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(jsonl_file), "-o", str(md_output), "--markdown"], + ) + assert result.exit_code == 0 + assert (md_output / "transcript.md").exists() + content = (md_output / "transcript.md").read_text() + assert "Hello" in content + assert "Hi there!" in content + + def test_json_command_markdown_no_html(self, output_dir): + """Test that --markdown does not produce HTML files.""" + jsonl_file = output_dir / "test.jsonl" + jsonl_file.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' + ) + md_output = output_dir / "md_output" + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(jsonl_file), "-o", str(md_output), "--markdown"], + ) + assert result.exit_code == 0 + assert not (md_output / "index.html").exists() +``` + +**Step 2: Run tests to verify they fail** + +Run: `uv run pytest tests/test_all.py::TestMarkdownFlag -v` +Expected: FAIL (no such option `--markdown`) + +**Step 3: Add `--markdown` flag to both commands** + +Add `@click.option("--markdown", "use_markdown", is_flag=True, help="Output as a single Markdown file instead of HTML.")` to both `local_cmd` and `json_cmd`. + +In `json_cmd`, add the branching logic: + +```python + if use_markdown: + md_path = generate_markdown(json_file_path, output, github_repo=repo) + click.echo(f"Generated {md_path.resolve()}") + else: + generate_html(json_file_path, output, github_repo=repo) +``` + +Same pattern in `local_cmd`. When `--markdown` is used, skip `--gist` and `--open` (they don't apply to markdown output). + +**Step 4: Run tests to verify they pass** + +Run: `uv run pytest tests/test_all.py::TestMarkdownFlag -v` +Expected: PASS + +**Step 5: Commit** + +```bash +uv run black . +git add src/claude_code_transcripts/__init__.py tests/test_all.py +git commit -m "feat: add --markdown flag to local and json commands" +``` + +--- + +### Task 5: Add `--markdown` flag to `web` command + +**Files:** +- Modify: `src/claude_code_transcripts/__init__.py` + +**Step 1: Add a `generate_markdown_from_session_data()` function** + +This parallels `generate_html_from_session_data()`: + +```python +def generate_markdown_from_session_data(session_data, output_dir, github_repo=None): + """Generate Markdown from session data dict (instead of file path). + + Returns the Path to the generated .md file. + """ + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True, parents=True) + + loglines = session_data.get("loglines", []) + + if github_repo is None: + github_repo = detect_github_repo(loglines) + + conversations = _group_conversations(loglines) + md_parts = [] + + for conv in conversations: + for log_type, message_json, timestamp in conv["messages"]: + if not message_json: + continue + try: + message_data = json.loads(message_json) + except json.JSONDecodeError: + continue + + if log_type == "user": + if is_tool_result_message(message_data): + role = "Tool reply" + else: + role = "User" + md_parts.append(f"### {role}") + md_parts.append(f"*{timestamp}*\n") + md_parts.append(_render_message_content_markdown(message_data)) + elif log_type == "assistant": + md_parts.append("### Assistant") + md_parts.append(f"*{timestamp}*\n") + md_parts.append(_render_message_content_markdown(message_data)) + + md_parts.append("---\n") + + markdown_content = "\n\n".join(md_parts) + md_path = output_dir / "transcript.md" + md_path.write_text(markdown_content, encoding="utf-8") + return md_path +``` + +**Step 2: Add `--markdown` flag to `web_cmd`** + +Same `@click.option` decorator. Branch in the command body: + +```python + if use_markdown: + md_path = generate_markdown_from_session_data(session_data, output, github_repo=repo) + click.echo(f"Generated {md_path.resolve()}") + else: + generate_html_from_session_data(session_data, output, github_repo=repo) +``` + +**Step 3: Run all tests** + +Run: `uv run pytest -v` +Expected: All PASS + +**Step 4: Commit** + +```bash +uv run black . +git add src/claude_code_transcripts/__init__.py +git commit -m "feat: add --markdown flag to web command" +``` + +--- + +### Task 6: Manual smoke test and final cleanup + +**Step 1: Run the tool against a real session** + +```bash +uv run claude-code-transcripts json tests/sample_session.jsonl -o /tmp/md-test --markdown +cat /tmp/md-test/transcript.md +``` + +Verify the output looks good — user prompts, assistant responses, tool calls, and tool results all render sensibly. + +**Step 2: Run all tests one final time** + +Run: `uv run pytest -v` +Expected: All PASS + +**Step 3: Run black** + +```bash +uv run black . +``` + +**Step 4: Final commit if any cleanup was needed** + +```bash +git add -A +git commit -m "chore: final cleanup for markdown export feature" +``` From 6077aade55e70d44dc7df29bf65180a4cd369cd8 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:20:06 +0800 Subject: [PATCH 02/14] chore: add .worktrees/ to .gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index a4a9ce6..7be3335 100644 --- a/.gitignore +++ b/.gitignore @@ -2,3 +2,4 @@ __pycache__ uv.lock .playwright-mcp/ +.worktrees/ From bcaa6e522b093927cb062e18bdbdcb9ee8bb5e1e Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:32:53 +0800 Subject: [PATCH 03/14] feat: add render_content_block_markdown for Markdown export --- pyproject.toml | 1 + src/claude_code_transcripts/__init__.py | 96 +++++++++++++++++++ tests/test_generate_html.py | 120 ++++++++++++++++++++++++ 3 files changed, 217 insertions(+) diff --git a/pyproject.toml b/pyproject.toml index 02c1fc0..4b3565d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -32,6 +32,7 @@ build-backend = "uv_build" [dependency-groups] dev = [ + "black>=26.1.0", "pytest>=9.0.2", "pytest-httpx>=0.35.0", "syrupy>=5.0.0", diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index e4854a3..34bd396 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -840,6 +840,102 @@ def render_content_block(block): return format_json(block) +def render_content_block_markdown(block): + """Render a single content block as Markdown text.""" + if not isinstance(block, dict): + return str(block) + + block_type = block.get("type", "") + + if block_type == "text": + return block.get("text", "") + + elif block_type == "thinking": + thinking = block.get("thinking", "") + return f"
\nThinking\n\n{thinking}\n\n
" + + elif block_type == "image": + return "[Image embedded in original transcript]" + + elif block_type == "tool_use": + tool_name = block.get("name", "Unknown tool") + tool_input = block.get("input", {}) + + if tool_name == "TodoWrite": + todos = tool_input.get("todos", []) + lines = [f"**TodoWrite**\n"] + for todo in todos: + status = todo.get("status", "pending") + content = todo.get("content", "") + if status == "completed": + lines.append(f"- [x] {content}") + elif status == "in_progress": + lines.append(f"- [ ] {content} *(in progress)*") + else: + lines.append(f"- [ ] {content}") + return "\n".join(lines) + + if tool_name == "Write": + file_path = tool_input.get("file_path", "Unknown file") + content = tool_input.get("content", "") + return f"**Write** `{file_path}`\n\n```\n{content}\n```" + + if tool_name == "Edit": + file_path = tool_input.get("file_path", "Unknown file") + old_string = tool_input.get("old_string", "") + new_string = tool_input.get("new_string", "") + replace_all = tool_input.get("replace_all", False) + header = f"**Edit** `{file_path}`" + if replace_all: + header += " *(replace all)*" + return f"{header}\n\n```diff\n- {old_string}\n+ {new_string}\n```" + + if tool_name == "Bash": + command = tool_input.get("command", "") + description = tool_input.get("description", "") + header = f"**Bash**" + if description: + header += f": {description}" + return f"{header}\n\n```bash\n{command}\n```" + + display_input = {k: v for k, v in tool_input.items() if k != "description"} + input_json = json.dumps(display_input, indent=2, ensure_ascii=False) + description = tool_input.get("description", "") + header = f"**{tool_name}**" + if description: + header += f": {description}" + return f"{header}\n\n```json\n{input_json}\n```" + + elif block_type == "tool_result": + content = block.get("content", "") + is_error = block.get("is_error", False) + prefix = "**Error:**\n" if is_error else "" + + if isinstance(content, str): + return f"{prefix}```\n{content}\n```" + elif isinstance(content, list): + parts = [] + for item in content: + if isinstance(item, dict): + if item.get("type") == "text": + parts.append(item.get("text", "")) + elif item.get("type") == "image": + parts.append("[Image embedded in original transcript]") + else: + parts.append(json.dumps(item, indent=2, ensure_ascii=False)) + else: + parts.append(str(item)) + result = "\n".join(parts) + return f"{prefix}```\n{result}\n```" + else: + return ( + f"{prefix}```\n{json.dumps(content, indent=2, ensure_ascii=False)}\n```" + ) + + else: + return f"```json\n{json.dumps(block, indent=2, ensure_ascii=False)}\n```" + + def render_user_message_content(message_data): content = message_data.get("content", "") if isinstance(content, str): diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 25c2822..d49e9ef 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -27,6 +27,7 @@ parse_session_file, get_session_summary, find_local_sessions, + render_content_block_markdown, ) @@ -1638,3 +1639,122 @@ def test_search_total_pages_available(self, output_dir): # Total pages should be embedded for JS to know how many pages to fetch assert "totalPages" in index_html or "total_pages" in index_html + + +class TestMarkdownRendering: + """Tests for Markdown rendering of content blocks.""" + + def test_text_block(self): + block = {"type": "text", "text": "Hello **world**"} + result = render_content_block_markdown(block) + assert result == "Hello **world**" + + def test_thinking_block(self): + block = {"type": "thinking", "thinking": "Let me think about this"} + result = render_content_block_markdown(block) + assert "
" in result + assert "Thinking" in result + assert "Let me think about this" in result + + def test_tool_use_write(self): + block = { + "type": "tool_use", + "id": "toolu_001", + "name": "Write", + "input": {"file_path": "/tmp/hello.py", "content": "print('hi')"}, + } + result = render_content_block_markdown(block) + assert "Write" in result + assert "/tmp/hello.py" in result + assert "print('hi')" in result + + def test_tool_use_edit(self): + block = { + "type": "tool_use", + "id": "toolu_002", + "name": "Edit", + "input": { + "file_path": "/tmp/hello.py", + "old_string": "print('hi')", + "new_string": "print('hello')", + }, + } + result = render_content_block_markdown(block) + assert "Edit" in result + assert "/tmp/hello.py" in result + assert "print('hi')" in result + assert "print('hello')" in result + + def test_tool_use_bash(self): + block = { + "type": "tool_use", + "id": "toolu_003", + "name": "Bash", + "input": {"command": "ls -la", "description": "List files"}, + } + result = render_content_block_markdown(block) + assert "Bash" in result + assert "ls -la" in result + + def test_tool_use_generic(self): + block = { + "type": "tool_use", + "id": "toolu_004", + "name": "Glob", + "input": {"pattern": "**/*.py"}, + } + result = render_content_block_markdown(block) + assert "Glob" in result + assert "**/*.py" in result + + def test_tool_result_string(self): + block = { + "type": "tool_result", + "tool_use_id": "toolu_001", + "content": "File written successfully", + } + result = render_content_block_markdown(block) + assert "File written successfully" in result + + def test_tool_result_error(self): + block = { + "type": "tool_result", + "tool_use_id": "toolu_001", + "content": "Error: file not found", + "is_error": True, + } + result = render_content_block_markdown(block) + assert "Error" in result + + def test_tool_result_with_list_content(self): + block = { + "type": "tool_result", + "tool_use_id": "toolu_001", + "content": [{"type": "text", "text": "some output"}], + } + result = render_content_block_markdown(block) + assert "some output" in result + + def test_image_block(self): + block = { + "type": "image", + "source": {"media_type": "image/png", "data": "abc123base64"}, + } + result = render_content_block_markdown(block) + assert "[Image" in result + + def test_todo_write(self): + block = { + "type": "tool_use", + "id": "toolu_005", + "name": "TodoWrite", + "input": { + "todos": [ + {"content": "First task", "status": "completed"}, + {"content": "Second task", "status": "pending"}, + ] + }, + } + result = render_content_block_markdown(block) + assert "First task" in result + assert "Second task" in result From 4ec1ad68deb21191180442863cb4c62218cd59ce Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:34:06 +0800 Subject: [PATCH 04/14] feat: add generate_markdown function for single-file markdown export --- src/claude_code_transcripts/__init__.py | 98 +++++++++++++++++++++++++ tests/test_generate_html.py | 49 +++++++++++++ 2 files changed, 147 insertions(+) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 34bd396..53c4f33 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1566,6 +1566,104 @@ def generate_html(json_path, output_dir, github_repo=None): ) +def _group_conversations(loglines): + """Group loglines into conversations. Shared by HTML and Markdown generators.""" + conversations = [] + current_conv = None + for entry in loglines: + log_type = entry.get("type") + timestamp = entry.get("timestamp", "") + is_compact_summary = entry.get("isCompactSummary", False) + message_data = entry.get("message", {}) + if not message_data: + continue + message_json = json.dumps(message_data) + is_user_prompt = False + user_text = None + if log_type == "user": + content = message_data.get("content", "") + text = extract_text_from_content(content) + if text: + is_user_prompt = True + user_text = text + if is_user_prompt: + if current_conv: + conversations.append(current_conv) + current_conv = { + "user_text": user_text, + "timestamp": timestamp, + "messages": [(log_type, message_json, timestamp)], + "is_continuation": bool(is_compact_summary), + } + elif current_conv: + current_conv["messages"].append((log_type, message_json, timestamp)) + if current_conv: + conversations.append(current_conv) + return conversations + + +def _render_message_content_markdown(message_data): + """Render a message's content blocks as Markdown.""" + content = message_data.get("content", "") + if isinstance(content, str): + return content + elif isinstance(content, list): + parts = [] + for block in content: + rendered = render_content_block_markdown(block) + if rendered: + parts.append(rendered) + return "\n\n".join(parts) + return str(content) + + +def generate_markdown(json_path, output_dir, github_repo=None): + """Generate a single Markdown file from a session file. + + Returns the Path to the generated .md file. + """ + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True) + + data = parse_session_file(json_path) + loglines = data.get("loglines", []) + + if github_repo is None: + github_repo = detect_github_repo(loglines) + + conversations = _group_conversations(loglines) + md_parts = [] + + for conv in conversations: + for log_type, message_json, timestamp in conv["messages"]: + if not message_json: + continue + try: + message_data = json.loads(message_json) + except json.JSONDecodeError: + continue + + if log_type == "user": + if is_tool_result_message(message_data): + role = "Tool reply" + else: + role = "User" + md_parts.append(f"### {role}") + md_parts.append(f"*{timestamp}*\n") + md_parts.append(_render_message_content_markdown(message_data)) + elif log_type == "assistant": + md_parts.append("### Assistant") + md_parts.append(f"*{timestamp}*\n") + md_parts.append(_render_message_content_markdown(message_data)) + + md_parts.append("---\n") + + markdown_content = "\n\n".join(md_parts) + md_path = output_dir / "transcript.md" + md_path.write_text(markdown_content, encoding="utf-8") + return md_path + + @click.group(cls=DefaultGroup, default="local", default_if_no_args=True) @click.version_option(None, "-v", "--version", package_name="claude-code-transcripts") def cli(): diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index d49e9ef..eebdbf5 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -28,6 +28,7 @@ get_session_summary, find_local_sessions, render_content_block_markdown, + generate_markdown, ) @@ -1758,3 +1759,51 @@ def test_todo_write(self): result = render_content_block_markdown(block) assert "First task" in result assert "Second task" in result + + +class TestGenerateMarkdown: + """Tests for generate_markdown function.""" + + def test_generates_markdown_file(self, output_dir): + """Test that generate_markdown creates a .md file.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + assert result_path.exists() + assert result_path.suffix == ".md" + + def test_markdown_contains_user_messages(self, output_dir): + """Test that user messages appear in the Markdown output.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + content = result_path.read_text() + assert "Create a hello world function" in content + + def test_markdown_contains_assistant_messages(self, output_dir): + """Test that assistant messages appear in the Markdown output.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + content = result_path.read_text() + assert "I'll create that function for you" in content + + def test_markdown_contains_tool_calls(self, output_dir): + """Test that tool calls appear in the Markdown output.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + content = result_path.read_text() + assert "Write" in content + assert "hello.py" in content + + def test_markdown_has_role_headers(self, output_dir): + """Test that messages have User/Assistant headers.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + content = result_path.read_text() + assert "### User" in content + assert "### Assistant" in content + + def test_markdown_has_timestamps(self, output_dir): + """Test that timestamps appear in the output.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result_path = generate_markdown(fixture_path, output_dir) + content = result_path.read_text() + assert "2025-12-24" in content From c1c3477268ddf312986f7fdf2b537eac3b09bdd2 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:36:03 +0800 Subject: [PATCH 05/14] refactor: extract _group_conversations helper shared by HTML and Markdown generators --- src/claude_code_transcripts/__init__.py | 66 +------------------------ 1 file changed, 2 insertions(+), 64 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 53c4f33..6f11a87 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1414,38 +1414,7 @@ def generate_html(json_path, output_dir, github_repo=None): global _github_repo _github_repo = github_repo - conversations = [] - current_conv = None - for entry in loglines: - log_type = entry.get("type") - timestamp = entry.get("timestamp", "") - is_compact_summary = entry.get("isCompactSummary", False) - message_data = entry.get("message", {}) - if not message_data: - continue - # Convert message dict to JSON string for compatibility with existing render functions - message_json = json.dumps(message_data) - is_user_prompt = False - user_text = None - if log_type == "user": - content = message_data.get("content", "") - text = extract_text_from_content(content) - if text: - is_user_prompt = True - user_text = text - if is_user_prompt: - if current_conv: - conversations.append(current_conv) - current_conv = { - "user_text": user_text, - "timestamp": timestamp, - "messages": [(log_type, message_json, timestamp)], - "is_continuation": bool(is_compact_summary), - } - elif current_conv: - current_conv["messages"].append((log_type, message_json, timestamp)) - if current_conv: - conversations.append(current_conv) + conversations = _group_conversations(loglines) total_convs = len(conversations) total_pages = (total_convs + PROMPTS_PER_PAGE - 1) // PROMPTS_PER_PAGE @@ -1986,38 +1955,7 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): global _github_repo _github_repo = github_repo - conversations = [] - current_conv = None - for entry in loglines: - log_type = entry.get("type") - timestamp = entry.get("timestamp", "") - is_compact_summary = entry.get("isCompactSummary", False) - message_data = entry.get("message", {}) - if not message_data: - continue - # Convert message dict to JSON string for compatibility with existing render functions - message_json = json.dumps(message_data) - is_user_prompt = False - user_text = None - if log_type == "user": - content = message_data.get("content", "") - text = extract_text_from_content(content) - if text: - is_user_prompt = True - user_text = text - if is_user_prompt: - if current_conv: - conversations.append(current_conv) - current_conv = { - "user_text": user_text, - "timestamp": timestamp, - "messages": [(log_type, message_json, timestamp)], - "is_continuation": bool(is_compact_summary), - } - elif current_conv: - current_conv["messages"].append((log_type, message_json, timestamp)) - if current_conv: - conversations.append(current_conv) + conversations = _group_conversations(loglines) total_convs = len(conversations) total_pages = (total_convs + PROMPTS_PER_PAGE - 1) // PROMPTS_PER_PAGE From 1c009e2870c9b49637ce3b67fcc4d782205c99e1 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:46:20 +0800 Subject: [PATCH 06/14] feat: add --markdown flag to local and json commands --- src/claude_code_transcripts/__init__.py | 116 +++++++++++++++--------- tests/test_all.py | 38 ++++++++ 2 files changed, 109 insertions(+), 45 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 6f11a87..1077b82 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1679,7 +1679,15 @@ def cli(): default=10, help="Maximum number of sessions to show (default: 10)", ) -def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit): +@click.option( + "--markdown", + "use_markdown", + is_flag=True, + help="Output as a single Markdown file instead of HTML.", +) +def local_cmd( + output, output_auto, repo, gist, include_json, open_browser, limit, use_markdown +): """Select and convert a local Claude Code session to HTML.""" projects_folder = Path.home() / ".claude" / "projects" @@ -1730,31 +1738,36 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit output = Path(tempfile.gettempdir()) / f"claude-session-{session_file.stem}" output = Path(output) - generate_html(session_file, output, github_repo=repo) - # Show output directory - click.echo(f"Output: {output.resolve()}") + if use_markdown: + md_path = generate_markdown(session_file, output, github_repo=repo) + click.echo(f"Generated {md_path.resolve()}") + else: + generate_html(session_file, output, github_repo=repo) - # Copy JSONL file to output directory if requested - if include_json: - output.mkdir(exist_ok=True) - json_dest = output / session_file.name - shutil.copy(session_file, json_dest) - json_size_kb = json_dest.stat().st_size / 1024 - click.echo(f"JSONL: {json_dest} ({json_size_kb:.1f} KB)") + # Show output directory + click.echo(f"Output: {output.resolve()}") - if gist: - # Inject gist preview JS and create gist - inject_gist_preview_js(output) - click.echo("Creating GitHub gist...") - gist_id, gist_url = create_gist(output) - preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" - click.echo(f"Gist: {gist_url}") - click.echo(f"Preview: {preview_url}") + # Copy JSONL file to output directory if requested + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / session_file.name + shutil.copy(session_file, json_dest) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSONL: {json_dest} ({json_size_kb:.1f} KB)") - if open_browser or auto_open: - index_url = (output / "index.html").resolve().as_uri() - webbrowser.open(index_url) + if gist: + # Inject gist preview JS and create gist + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + + if open_browser or auto_open: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) def is_url(path): @@ -1831,7 +1844,15 @@ def fetch_url_to_tempfile(url): is_flag=True, help="Open the generated index.html in your default browser (default if no -o specified).", ) -def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_browser): +@click.option( + "--markdown", + "use_markdown", + is_flag=True, + help="Output as a single Markdown file instead of HTML.", +) +def json_cmd( + json_file, output, output_auto, repo, gist, include_json, open_browser, use_markdown +): """Convert a Claude Code session JSON/JSONL file or URL to HTML.""" # Handle URL input if is_url(json_file): @@ -1861,31 +1882,36 @@ def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_brow ) output = Path(output) - generate_html(json_file_path, output, github_repo=repo) - - # Show output directory - click.echo(f"Output: {output.resolve()}") - # Copy JSON file to output directory if requested - if include_json: - output.mkdir(exist_ok=True) - json_dest = output / json_file_path.name - shutil.copy(json_file_path, json_dest) - json_size_kb = json_dest.stat().st_size / 1024 - click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") + if use_markdown: + md_path = generate_markdown(json_file_path, output, github_repo=repo) + click.echo(f"Generated {md_path.resolve()}") + else: + generate_html(json_file_path, output, github_repo=repo) - if gist: - # Inject gist preview JS and create gist - inject_gist_preview_js(output) - click.echo("Creating GitHub gist...") - gist_id, gist_url = create_gist(output) - preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" - click.echo(f"Gist: {gist_url}") - click.echo(f"Preview: {preview_url}") + # Show output directory + click.echo(f"Output: {output.resolve()}") - if open_browser or auto_open: - index_url = (output / "index.html").resolve().as_uri() - webbrowser.open(index_url) + # Copy JSON file to output directory if requested + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / json_file_path.name + shutil.copy(json_file_path, json_dest) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") + + if gist: + # Inject gist preview JS and create gist + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + + if open_browser or auto_open: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) def resolve_credentials(token, org_uuid): diff --git a/tests/test_all.py b/tests/test_all.py index 8215acd..37681e5 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -698,3 +698,41 @@ def test_format_session_for_display_without_repo(self): # Should show (no repo) placeholder assert "(no repo)" in display assert "Fix the bug" in display + + +class TestMarkdownFlag: + """Tests for --markdown flag on CLI commands.""" + + def test_json_command_markdown_flag(self, output_dir): + """Test that json command with --markdown produces a .md file.""" + jsonl_file = output_dir / "test.jsonl" + jsonl_file.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' + '{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi there!"}]}}\n' + ) + md_output = output_dir / "md_output" + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(jsonl_file), "-o", str(md_output), "--markdown"], + ) + assert result.exit_code == 0 + assert (md_output / "transcript.md").exists() + content = (md_output / "transcript.md").read_text() + assert "Hello" in content + assert "Hi there!" in content + + def test_json_command_markdown_no_html(self, output_dir): + """Test that --markdown does not produce HTML files.""" + jsonl_file = output_dir / "test.jsonl" + jsonl_file.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' + ) + md_output = output_dir / "md_output" + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(jsonl_file), "-o", str(md_output), "--markdown"], + ) + assert result.exit_code == 0 + assert not (md_output / "index.html").exists() From 4e606b53f14fa3a1ef4f47a968f054b04ed2c23a Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 15:49:55 +0800 Subject: [PATCH 07/14] feat: add --markdown flag to web command --- src/claude_code_transcripts/__init__.py | 114 ++++++++++++++++++------ 1 file changed, 87 insertions(+), 27 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 1077b82..4d596ac 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1633,6 +1633,52 @@ def generate_markdown(json_path, output_dir, github_repo=None): return md_path +def generate_markdown_from_session_data(session_data, output_dir, github_repo=None): + """Generate Markdown from session data dict (instead of file path). + + Returns the Path to the generated .md file. + """ + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True, parents=True) + + loglines = session_data.get("loglines", []) + + if github_repo is None: + github_repo = detect_github_repo(loglines) + + conversations = _group_conversations(loglines) + md_parts = [] + + for conv in conversations: + for log_type, message_json, timestamp in conv["messages"]: + if not message_json: + continue + try: + message_data = json.loads(message_json) + except json.JSONDecodeError: + continue + + if log_type == "user": + if is_tool_result_message(message_data): + role = "Tool reply" + else: + role = "User" + md_parts.append(f"### {role}") + md_parts.append(f"*{timestamp}*\n") + md_parts.append(_render_message_content_markdown(message_data)) + elif log_type == "assistant": + md_parts.append("### Assistant") + md_parts.append(f"*{timestamp}*\n") + md_parts.append(_render_message_content_markdown(message_data)) + + md_parts.append("---\n") + + markdown_content = "\n\n".join(md_parts) + md_path = output_dir / "transcript.md" + md_path.write_text(markdown_content, encoding="utf-8") + return md_path + + @click.group(cls=DefaultGroup, default="local", default_if_no_args=True) @click.version_option(None, "-v", "--version", package_name="claude-code-transcripts") def cli(): @@ -2141,6 +2187,12 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): is_flag=True, help="Open the generated index.html in your default browser (default if no -o specified).", ) +@click.option( + "--markdown", + "use_markdown", + is_flag=True, + help="Output as a single Markdown file instead of HTML.", +) def web_cmd( session_id, output, @@ -2151,6 +2203,7 @@ def web_cmd( gist, include_json, open_browser, + use_markdown, ): """Select and convert a web session from the Claude API to HTML. @@ -2225,33 +2278,40 @@ def web_cmd( output = Path(tempfile.gettempdir()) / f"claude-session-{session_id}" output = Path(output) - click.echo(f"Generating HTML in {output}/...") - generate_html_from_session_data(session_data, output, github_repo=repo) - - # Show output directory - click.echo(f"Output: {output.resolve()}") - - # Save JSON session data if requested - if include_json: - output.mkdir(exist_ok=True) - json_dest = output / f"{session_id}.json" - with open(json_dest, "w") as f: - json.dump(session_data, f, indent=2) - json_size_kb = json_dest.stat().st_size / 1024 - click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") - - if gist: - # Inject gist preview JS and create gist - inject_gist_preview_js(output) - click.echo("Creating GitHub gist...") - gist_id, gist_url = create_gist(output) - preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" - click.echo(f"Gist: {gist_url}") - click.echo(f"Preview: {preview_url}") - - if open_browser or auto_open: - index_url = (output / "index.html").resolve().as_uri() - webbrowser.open(index_url) + + if use_markdown: + md_path = generate_markdown_from_session_data( + session_data, output, github_repo=repo + ) + click.echo(f"Generated {md_path.resolve()}") + else: + click.echo(f"Generating HTML in {output}/...") + generate_html_from_session_data(session_data, output, github_repo=repo) + + # Show output directory + click.echo(f"Output: {output.resolve()}") + + # Save JSON session data if requested + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / f"{session_id}.json" + with open(json_dest, "w") as f: + json.dump(session_data, f, indent=2) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") + + if gist: + # Inject gist preview JS and create gist + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + + if open_browser or auto_open: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) @cli.command("all") From ee17e71f07a61041b0a3de230455aa745d19ad77 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:10:58 +0800 Subject: [PATCH 08/14] fix: use dynamic fence length in markdown export to prevent nested backtick breakage When tool results or file contents contain triple backticks, the markdown fences would nest and break formatting. The new _md_fence() helper scans content for backtick runs and uses a longer fence. --- src/claude_code_transcripts/__init__.py | 29 ++++++---- tests/test_generate_html.py | 71 +++++++++++++++++++++++++ 2 files changed, 90 insertions(+), 10 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 4d596ac..5e4b6a1 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -840,6 +840,16 @@ def render_content_block(block): return format_json(block) +def _md_fence(content, lang=""): + """Return a fenced code block, using enough backticks to avoid breaking on inner fences.""" + import re + + runs = re.findall(r"`{3,}", content) + max_run = max((len(r) for r in runs), default=0) + ticks = "`" * max(3, max_run + 1) + return f"{ticks}{lang}\n{content}\n{ticks}" + + def render_content_block_markdown(block): """Render a single content block as Markdown text.""" if not isinstance(block, dict): @@ -878,7 +888,7 @@ def render_content_block_markdown(block): if tool_name == "Write": file_path = tool_input.get("file_path", "Unknown file") content = tool_input.get("content", "") - return f"**Write** `{file_path}`\n\n```\n{content}\n```" + return f"**Write** `{file_path}`\n\n{_md_fence(content)}" if tool_name == "Edit": file_path = tool_input.get("file_path", "Unknown file") @@ -888,7 +898,8 @@ def render_content_block_markdown(block): header = f"**Edit** `{file_path}`" if replace_all: header += " *(replace all)*" - return f"{header}\n\n```diff\n- {old_string}\n+ {new_string}\n```" + diff_body = f"- {old_string}\n+ {new_string}" + return f"{header}\n\n{_md_fence(diff_body, 'diff')}" if tool_name == "Bash": command = tool_input.get("command", "") @@ -896,7 +907,7 @@ def render_content_block_markdown(block): header = f"**Bash**" if description: header += f": {description}" - return f"{header}\n\n```bash\n{command}\n```" + return f"{header}\n\n{_md_fence(command, 'bash')}" display_input = {k: v for k, v in tool_input.items() if k != "description"} input_json = json.dumps(display_input, indent=2, ensure_ascii=False) @@ -904,7 +915,7 @@ def render_content_block_markdown(block): header = f"**{tool_name}**" if description: header += f": {description}" - return f"{header}\n\n```json\n{input_json}\n```" + return f"{header}\n\n{_md_fence(input_json, 'json')}" elif block_type == "tool_result": content = block.get("content", "") @@ -912,7 +923,7 @@ def render_content_block_markdown(block): prefix = "**Error:**\n" if is_error else "" if isinstance(content, str): - return f"{prefix}```\n{content}\n```" + return f"{prefix}{_md_fence(content)}" elif isinstance(content, list): parts = [] for item in content: @@ -926,14 +937,12 @@ def render_content_block_markdown(block): else: parts.append(str(item)) result = "\n".join(parts) - return f"{prefix}```\n{result}\n```" + return f"{prefix}{_md_fence(result)}" else: - return ( - f"{prefix}```\n{json.dumps(content, indent=2, ensure_ascii=False)}\n```" - ) + return f"{prefix}{_md_fence(json.dumps(content, indent=2, ensure_ascii=False))}" else: - return f"```json\n{json.dumps(block, indent=2, ensure_ascii=False)}\n```" + return _md_fence(json.dumps(block, indent=2, ensure_ascii=False), "json") def render_user_message_content(message_data): diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index eebdbf5..d769390 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -1760,6 +1760,77 @@ def test_todo_write(self): assert "First task" in result assert "Second task" in result + def test_tool_result_containing_code_block(self): + """Tool result with triple backticks must not break the markdown fence.""" + block = { + "type": "tool_result", + "tool_use_id": "toolu_001", + "content": "Here is code:\n```python\nprint('hello')\n```\nDone.", + } + result = render_content_block_markdown(block) + assert "print('hello')" in result + # The outer fence must use more backticks than the inner fence + lines = result.split("\n") + fence_lines = [ + l + for l in lines + if l.strip().startswith("`") + and l.strip().rstrip("`") == l.strip().rstrip("`").rstrip() + ] + # Simpler check: the result should start with >3 backticks if content has ``` + first_line = lines[0].strip() + assert first_line.startswith( + "````" + ), f"Outer fence should use >=4 backticks, got: {first_line}" + + def test_tool_result_list_containing_code_block(self): + """Tool result (list form) with triple backticks must not break the fence.""" + block = { + "type": "tool_result", + "tool_use_id": "toolu_001", + "content": [{"type": "text", "text": "```\nsome code\n```"}], + } + result = render_content_block_markdown(block) + assert "some code" in result + first_line = result.split("\n")[0].strip() + assert first_line.startswith( + "````" + ), f"Outer fence should use >=4 backticks, got: {first_line}" + + def test_tool_use_write_containing_code_block(self): + """Write tool with file content containing backticks must not break the fence.""" + block = { + "type": "tool_use", + "id": "toolu_001", + "name": "Write", + "input": { + "file_path": "/tmp/readme.md", + "content": "# Hello\n\n```python\nprint('hi')\n```\n", + }, + } + result = render_content_block_markdown(block) + assert "print('hi')" in result + # Find the opening fence line (after the **Write** header) + lines = result.split("\n") + fence_lines = [ + l for l in lines if l.strip().startswith("`") and len(l.strip()) > 0 + ] + outer_fence = fence_lines[0].strip() + assert ( + len(outer_fence.rstrip()) >= 4 + ), f"Outer fence should use >=4 backticks, got: {outer_fence}" + + def test_tool_use_bash_containing_code_block(self): + """Bash tool with command containing backticks must not break the fence.""" + block = { + "type": "tool_use", + "id": "toolu_001", + "name": "Bash", + "input": {"command": "echo '```hello```'"}, + } + result = render_content_block_markdown(block) + assert "echo" in result + class TestGenerateMarkdown: """Tests for generate_markdown function.""" From 48965596a5d6f1eca27ed4d3df8ed3a95b5094a3 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:15:46 +0800 Subject: [PATCH 09/14] feat: support --open flag with --markdown to open in $EDITOR When --open (or auto-open) is used with --markdown, the generated markdown file is opened in $EDITOR/$VISUAL, falling back to the system default application via click.launch(). --- src/claude_code_transcripts/__init__.py | 15 ++++++++ tests/test_all.py | 51 +++++++++++++++++++++++++ 2 files changed, 66 insertions(+) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 5e4b6a1..4b23f55 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1797,6 +1797,8 @@ def local_cmd( if use_markdown: md_path = generate_markdown(session_file, output, github_repo=repo) click.echo(f"Generated {md_path.resolve()}") + if open_browser or auto_open: + open_in_editor(md_path.resolve()) else: generate_html(session_file, output, github_repo=repo) @@ -1825,6 +1827,15 @@ def local_cmd( webbrowser.open(index_url) +def open_in_editor(file_path): + """Open a file in the user's preferred editor, or system default.""" + editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") + if editor: + subprocess.Popen([editor, str(file_path)]) + else: + click.launch(str(file_path)) + + def is_url(path): """Check if a path is a URL (starts with http:// or https://).""" return path.startswith("http://") or path.startswith("https://") @@ -1941,6 +1952,8 @@ def json_cmd( if use_markdown: md_path = generate_markdown(json_file_path, output, github_repo=repo) click.echo(f"Generated {md_path.resolve()}") + if open_browser or auto_open: + open_in_editor(md_path.resolve()) else: generate_html(json_file_path, output, github_repo=repo) @@ -2293,6 +2306,8 @@ def web_cmd( session_data, output, github_repo=repo ) click.echo(f"Generated {md_path.resolve()}") + if open_browser or auto_open: + open_in_editor(md_path.resolve()) else: click.echo(f"Generating HTML in {output}/...") generate_html_from_session_data(session_data, output, github_repo=repo) diff --git a/tests/test_all.py b/tests/test_all.py index 37681e5..ebc905f 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -736,3 +736,54 @@ def test_json_command_markdown_no_html(self, output_dir): ) assert result.exit_code == 0 assert not (md_output / "index.html").exists() + + def test_json_markdown_open_uses_editor(self, output_dir, monkeypatch): + """Test that --markdown --open opens the markdown file with $EDITOR.""" + jsonl_file = output_dir / "test.jsonl" + jsonl_file.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' + ) + md_output = output_dir / "md_output" + + launched = [] + monkeypatch.setenv("EDITOR", "vim") + monkeypatch.setattr( + "claude_code_transcripts.subprocess.Popen", + lambda args, **kw: launched.append(args) or type("P", (), {"pid": 1})(), + ) + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(jsonl_file), "-o", str(md_output), "--markdown", "--open"], + ) + assert result.exit_code == 0 + assert len(launched) == 1 + assert launched[0][0] == "vim" + assert launched[0][1].endswith(".md") + + def test_json_markdown_open_falls_back_to_click_launch( + self, output_dir, monkeypatch + ): + """Test that --markdown --open falls back to click.launch when no $EDITOR.""" + jsonl_file = output_dir / "test.jsonl" + jsonl_file.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' + ) + md_output = output_dir / "md_output" + + launched = [] + monkeypatch.delenv("EDITOR", raising=False) + monkeypatch.delenv("VISUAL", raising=False) + monkeypatch.setattr( + "claude_code_transcripts.click.launch", lambda path: launched.append(path) + ) + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(jsonl_file), "-o", str(md_output), "--markdown", "--open"], + ) + assert result.exit_code == 0 + assert len(launched) == 1 + assert launched[0].endswith(".md") From b021b5faeee0569919cdd5c9393296580684b7c6 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:20:26 +0800 Subject: [PATCH 10/14] fix: use subprocess.run instead of Popen for terminal editors Popen launches the editor detached without a proper TTY connection, causing garbled escape sequences in terminal editors like neovim. subprocess.run inherits the parent's terminal and blocks until the editor exits, which is the expected behavior. --- src/claude_code_transcripts/__init__.py | 2 +- tests/test_all.py | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 4b23f55..97230e0 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1831,7 +1831,7 @@ def open_in_editor(file_path): """Open a file in the user's preferred editor, or system default.""" editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") if editor: - subprocess.Popen([editor, str(file_path)]) + subprocess.run([editor, str(file_path)]) else: click.launch(str(file_path)) diff --git a/tests/test_all.py b/tests/test_all.py index ebc905f..50d142f 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -748,8 +748,8 @@ def test_json_markdown_open_uses_editor(self, output_dir, monkeypatch): launched = [] monkeypatch.setenv("EDITOR", "vim") monkeypatch.setattr( - "claude_code_transcripts.subprocess.Popen", - lambda args, **kw: launched.append(args) or type("P", (), {"pid": 1})(), + "claude_code_transcripts.subprocess.run", + lambda args, **kw: launched.append(args), ) runner = CliRunner() From bcbcbd7b9406cea3508612c59398d05e31242e96 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 16:25:00 +0800 Subject: [PATCH 11/14] chore: remove completed markdown export implementation plan --- docs/plans/2026-03-01-export-to-markdown.md | 684 -------------------- 1 file changed, 684 deletions(-) delete mode 100644 docs/plans/2026-03-01-export-to-markdown.md diff --git a/docs/plans/2026-03-01-export-to-markdown.md b/docs/plans/2026-03-01-export-to-markdown.md deleted file mode 100644 index bdb797f..0000000 --- a/docs/plans/2026-03-01-export-to-markdown.md +++ /dev/null @@ -1,684 +0,0 @@ -# Export to Markdown Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** Add a `--markdown` flag that outputs session transcripts as a single `.md` file instead of paginated HTML. - -**Architecture:** Add a `generate_markdown()` function parallel to `generate_html()` that reuses the same parsing/conversation-grouping logic but renders each content block to Markdown text instead of HTML. Each CLI command (`local`, `json`, `web`) gets a `--markdown` flag that switches to the markdown codepath. Output is a single `.md` file (no pagination). - -**Tech Stack:** Python, Click (CLI), existing `parse_session_file()` and `extract_text_from_content()` functions. - ---- - -### Task 1: Add `render_content_block_markdown()` function - -**Files:** -- Modify: `src/claude_code_transcripts/__init__.py` -- Test: `tests/test_generate_html.py` - -**Step 1: Write the failing tests** - -Add a new test class `TestMarkdownRendering` to `tests/test_generate_html.py`: - -```python -from claude_code_transcripts import render_content_block_markdown - - -class TestMarkdownRendering: - """Tests for Markdown rendering of content blocks.""" - - def test_text_block(self): - block = {"type": "text", "text": "Hello **world**"} - result = render_content_block_markdown(block) - assert result == "Hello **world**" - - def test_thinking_block(self): - block = {"type": "thinking", "thinking": "Let me think about this"} - result = render_content_block_markdown(block) - assert "
" in result - assert "Thinking" in result - assert "Let me think about this" in result - - def test_tool_use_write(self): - block = { - "type": "tool_use", - "id": "toolu_001", - "name": "Write", - "input": {"file_path": "/tmp/hello.py", "content": "print('hi')"}, - } - result = render_content_block_markdown(block) - assert "Write" in result - assert "/tmp/hello.py" in result - assert "print('hi')" in result - - def test_tool_use_edit(self): - block = { - "type": "tool_use", - "id": "toolu_002", - "name": "Edit", - "input": { - "file_path": "/tmp/hello.py", - "old_string": "print('hi')", - "new_string": "print('hello')", - }, - } - result = render_content_block_markdown(block) - assert "Edit" in result - assert "/tmp/hello.py" in result - assert "print('hi')" in result - assert "print('hello')" in result - - def test_tool_use_bash(self): - block = { - "type": "tool_use", - "id": "toolu_003", - "name": "Bash", - "input": {"command": "ls -la", "description": "List files"}, - } - result = render_content_block_markdown(block) - assert "Bash" in result - assert "ls -la" in result - - def test_tool_use_generic(self): - block = { - "type": "tool_use", - "id": "toolu_004", - "name": "Glob", - "input": {"pattern": "**/*.py"}, - } - result = render_content_block_markdown(block) - assert "Glob" in result - assert "**/*.py" in result - - def test_tool_result_string(self): - block = { - "type": "tool_result", - "tool_use_id": "toolu_001", - "content": "File written successfully", - } - result = render_content_block_markdown(block) - assert "File written successfully" in result - - def test_tool_result_error(self): - block = { - "type": "tool_result", - "tool_use_id": "toolu_001", - "content": "Error: file not found", - "is_error": True, - } - result = render_content_block_markdown(block) - assert "Error" in result - - def test_tool_result_with_list_content(self): - block = { - "type": "tool_result", - "tool_use_id": "toolu_001", - "content": [{"type": "text", "text": "some output"}], - } - result = render_content_block_markdown(block) - assert "some output" in result - - def test_image_block(self): - block = { - "type": "image", - "source": {"media_type": "image/png", "data": "abc123base64"}, - } - result = render_content_block_markdown(block) - assert "[Image" in result - - def test_todo_write(self): - block = { - "type": "tool_use", - "id": "toolu_005", - "name": "TodoWrite", - "input": { - "todos": [ - {"content": "First task", "status": "completed"}, - {"content": "Second task", "status": "pending"}, - ] - }, - } - result = render_content_block_markdown(block) - assert "First task" in result - assert "Second task" in result -``` - -**Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_generate_html.py::TestMarkdownRendering -v` -Expected: FAIL with `ImportError: cannot import name 'render_content_block_markdown'` - -**Step 3: Write minimal implementation** - -Add to `src/claude_code_transcripts/__init__.py` (after the existing `render_content_block` function): - -```python -def render_content_block_markdown(block): - """Render a single content block as Markdown text.""" - if not isinstance(block, dict): - return str(block) - - block_type = block.get("type", "") - - if block_type == "text": - return block.get("text", "") - - elif block_type == "thinking": - thinking = block.get("thinking", "") - return f"
\nThinking\n\n{thinking}\n\n
" - - elif block_type == "image": - return "[Image embedded in original transcript]" - - elif block_type == "tool_use": - tool_name = block.get("name", "Unknown tool") - tool_input = block.get("input", {}) - - if tool_name == "TodoWrite": - todos = tool_input.get("todos", []) - lines = [f"**TodoWrite**\n"] - for todo in todos: - status = todo.get("status", "pending") - content = todo.get("content", "") - if status == "completed": - lines.append(f"- [x] {content}") - elif status == "in_progress": - lines.append(f"- [ ] {content} *(in progress)*") - else: - lines.append(f"- [ ] {content}") - return "\n".join(lines) - - if tool_name == "Write": - file_path = tool_input.get("file_path", "Unknown file") - content = tool_input.get("content", "") - return f"**Write** `{file_path}`\n\n```\n{content}\n```" - - if tool_name == "Edit": - file_path = tool_input.get("file_path", "Unknown file") - old_string = tool_input.get("old_string", "") - new_string = tool_input.get("new_string", "") - replace_all = tool_input.get("replace_all", False) - header = f"**Edit** `{file_path}`" - if replace_all: - header += " *(replace all)*" - return f"{header}\n\n```diff\n- {old_string}\n+ {new_string}\n```" - - if tool_name == "Bash": - command = tool_input.get("command", "") - description = tool_input.get("description", "") - header = f"**Bash**" - if description: - header += f": {description}" - return f"{header}\n\n```bash\n{command}\n```" - - # Generic tool - display_input = {k: v for k, v in tool_input.items() if k != "description"} - input_json = json.dumps(display_input, indent=2, ensure_ascii=False) - description = tool_input.get("description", "") - header = f"**{tool_name}**" - if description: - header += f": {description}" - return f"{header}\n\n```json\n{input_json}\n```" - - elif block_type == "tool_result": - content = block.get("content", "") - is_error = block.get("is_error", False) - prefix = "**Error:**\n" if is_error else "" - - if isinstance(content, str): - return f"{prefix}```\n{content}\n```" - elif isinstance(content, list): - parts = [] - for item in content: - if isinstance(item, dict): - if item.get("type") == "text": - parts.append(item.get("text", "")) - elif item.get("type") == "image": - parts.append("[Image embedded in original transcript]") - else: - parts.append(json.dumps(item, indent=2, ensure_ascii=False)) - else: - parts.append(str(item)) - result = "\n".join(parts) - return f"{prefix}```\n{result}\n```" - else: - return f"{prefix}```\n{json.dumps(content, indent=2, ensure_ascii=False)}\n```" - - else: - return f"```json\n{json.dumps(block, indent=2, ensure_ascii=False)}\n```" -``` - -**Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/test_generate_html.py::TestMarkdownRendering -v` -Expected: PASS - -**Step 5: Commit** - -```bash -uv run black . -git add src/claude_code_transcripts/__init__.py tests/test_generate_html.py -git commit -m "feat: add render_content_block_markdown for Markdown export" -``` - ---- - -### Task 2: Add `generate_markdown()` function - -**Files:** -- Modify: `src/claude_code_transcripts/__init__.py` -- Test: `tests/test_generate_html.py` - -**Step 1: Write the failing tests** - -Add a new test class `TestGenerateMarkdown` to `tests/test_generate_html.py`: - -```python -from claude_code_transcripts import generate_markdown - - -class TestGenerateMarkdown: - """Tests for generate_markdown function.""" - - def test_generates_markdown_file(self, output_dir): - """Test that generate_markdown creates a .md file.""" - fixture_path = Path(__file__).parent / "sample_session.jsonl" - result_path = generate_markdown(fixture_path, output_dir) - assert result_path.exists() - assert result_path.suffix == ".md" - - def test_markdown_contains_user_messages(self, output_dir): - """Test that user messages appear in the Markdown output.""" - fixture_path = Path(__file__).parent / "sample_session.jsonl" - result_path = generate_markdown(fixture_path, output_dir) - content = result_path.read_text() - assert "Create a hello world function" in content - - def test_markdown_contains_assistant_messages(self, output_dir): - """Test that assistant messages appear in the Markdown output.""" - fixture_path = Path(__file__).parent / "sample_session.jsonl" - result_path = generate_markdown(fixture_path, output_dir) - content = result_path.read_text() - assert "I'll create that function for you" in content - - def test_markdown_contains_tool_calls(self, output_dir): - """Test that tool calls appear in the Markdown output.""" - fixture_path = Path(__file__).parent / "sample_session.jsonl" - result_path = generate_markdown(fixture_path, output_dir) - content = result_path.read_text() - assert "Write" in content - assert "hello.py" in content - - def test_markdown_has_role_headers(self, output_dir): - """Test that messages have User/Assistant headers.""" - fixture_path = Path(__file__).parent / "sample_session.jsonl" - result_path = generate_markdown(fixture_path, output_dir) - content = result_path.read_text() - assert "### User" in content - assert "### Assistant" in content - - def test_markdown_has_timestamps(self, output_dir): - """Test that timestamps appear in the output.""" - fixture_path = Path(__file__).parent / "sample_session.jsonl" - result_path = generate_markdown(fixture_path, output_dir) - content = result_path.read_text() - assert "2025-12-24" in content -``` - -**Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_generate_html.py::TestGenerateMarkdown -v` -Expected: FAIL with `ImportError: cannot import name 'generate_markdown'` - -**Step 3: Write minimal implementation** - -Add to `src/claude_code_transcripts/__init__.py` (after `generate_html`): - -```python -def generate_markdown(json_path, output_dir, github_repo=None): - """Generate a single Markdown file from a session file. - - Returns the Path to the generated .md file. - """ - output_dir = Path(output_dir) - output_dir.mkdir(exist_ok=True) - - data = parse_session_file(json_path) - loglines = data.get("loglines", []) - - if github_repo is None: - github_repo = detect_github_repo(loglines) - - conversations = _group_conversations(loglines) - md_parts = [] - - for conv in conversations: - for log_type, message_json, timestamp in conv["messages"]: - if not message_json: - continue - try: - message_data = json.loads(message_json) - except json.JSONDecodeError: - continue - - if log_type == "user": - content = message_data.get("content", "") - if is_tool_result_message(message_data): - role = "Tool reply" - else: - role = "User" - md_parts.append(f"### {role}") - md_parts.append(f"*{timestamp}*\n") - md_parts.append(_render_message_content_markdown(message_data)) - elif log_type == "assistant": - md_parts.append("### Assistant") - md_parts.append(f"*{timestamp}*\n") - md_parts.append(_render_message_content_markdown(message_data)) - - md_parts.append("---\n") - - markdown_content = "\n\n".join(md_parts) - md_path = output_dir / "transcript.md" - md_path.write_text(markdown_content, encoding="utf-8") - return md_path -``` - -Also add these helper functions: - -```python -def _group_conversations(loglines): - """Group loglines into conversations. Shared by HTML and Markdown generators.""" - conversations = [] - current_conv = None - for entry in loglines: - log_type = entry.get("type") - timestamp = entry.get("timestamp", "") - is_compact_summary = entry.get("isCompactSummary", False) - message_data = entry.get("message", {}) - if not message_data: - continue - message_json = json.dumps(message_data) - is_user_prompt = False - user_text = None - if log_type == "user": - content = message_data.get("content", "") - text = extract_text_from_content(content) - if text: - is_user_prompt = True - user_text = text - if is_user_prompt: - if current_conv: - conversations.append(current_conv) - current_conv = { - "user_text": user_text, - "timestamp": timestamp, - "messages": [(log_type, message_json, timestamp)], - "is_continuation": bool(is_compact_summary), - } - elif current_conv: - current_conv["messages"].append((log_type, message_json, timestamp)) - if current_conv: - conversations.append(current_conv) - return conversations - - -def _render_message_content_markdown(message_data): - """Render a message's content blocks as Markdown.""" - content = message_data.get("content", "") - if isinstance(content, str): - return content - elif isinstance(content, list): - parts = [] - for block in content: - rendered = render_content_block_markdown(block) - if rendered: - parts.append(rendered) - return "\n\n".join(parts) - return str(content) -``` - -**Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/test_generate_html.py::TestGenerateMarkdown -v` -Expected: PASS - -**Step 5: Commit** - -```bash -uv run black . -git add src/claude_code_transcripts/__init__.py tests/test_generate_html.py -git commit -m "feat: add generate_markdown function for single-file markdown export" -``` - ---- - -### Task 3: Refactor `generate_html` to use `_group_conversations` - -**Files:** -- Modify: `src/claude_code_transcripts/__init__.py` - -**Step 1: Refactor `generate_html()` and `generate_html_from_session_data()` to use the shared `_group_conversations()` helper** - -Replace the inline conversation-grouping loop in both functions with a call to `_group_conversations(loglines)`. - -In `generate_html()`, replace lines ~1321-1352 with: -```python - conversations = _group_conversations(loglines) -``` - -In `generate_html_from_session_data()`, replace lines ~1795-1826 with: -```python - conversations = _group_conversations(loglines) -``` - -**Step 2: Run all existing tests to verify nothing broke** - -Run: `uv run pytest -v` -Expected: All existing tests PASS - -**Step 3: Commit** - -```bash -uv run black . -git add src/claude_code_transcripts/__init__.py -git commit -m "refactor: extract _group_conversations helper shared by HTML and Markdown generators" -``` - ---- - -### Task 4: Add `--markdown` flag to `local` and `json` commands - -**Files:** -- Modify: `src/claude_code_transcripts/__init__.py` -- Test: `tests/test_all.py` - -**Step 1: Write the failing tests** - -Add to `tests/test_all.py`: - -```python -class TestMarkdownFlag: - """Tests for --markdown flag on CLI commands.""" - - def test_json_command_markdown_flag(self, output_dir): - """Test that json command with --markdown produces a .md file.""" - jsonl_file = output_dir / "test.jsonl" - jsonl_file.write_text( - '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' - '{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi there!"}]}}\n' - ) - md_output = output_dir / "md_output" - runner = CliRunner() - result = runner.invoke( - cli, - ["json", str(jsonl_file), "-o", str(md_output), "--markdown"], - ) - assert result.exit_code == 0 - assert (md_output / "transcript.md").exists() - content = (md_output / "transcript.md").read_text() - assert "Hello" in content - assert "Hi there!" in content - - def test_json_command_markdown_no_html(self, output_dir): - """Test that --markdown does not produce HTML files.""" - jsonl_file = output_dir / "test.jsonl" - jsonl_file.write_text( - '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' - ) - md_output = output_dir / "md_output" - runner = CliRunner() - result = runner.invoke( - cli, - ["json", str(jsonl_file), "-o", str(md_output), "--markdown"], - ) - assert result.exit_code == 0 - assert not (md_output / "index.html").exists() -``` - -**Step 2: Run tests to verify they fail** - -Run: `uv run pytest tests/test_all.py::TestMarkdownFlag -v` -Expected: FAIL (no such option `--markdown`) - -**Step 3: Add `--markdown` flag to both commands** - -Add `@click.option("--markdown", "use_markdown", is_flag=True, help="Output as a single Markdown file instead of HTML.")` to both `local_cmd` and `json_cmd`. - -In `json_cmd`, add the branching logic: - -```python - if use_markdown: - md_path = generate_markdown(json_file_path, output, github_repo=repo) - click.echo(f"Generated {md_path.resolve()}") - else: - generate_html(json_file_path, output, github_repo=repo) -``` - -Same pattern in `local_cmd`. When `--markdown` is used, skip `--gist` and `--open` (they don't apply to markdown output). - -**Step 4: Run tests to verify they pass** - -Run: `uv run pytest tests/test_all.py::TestMarkdownFlag -v` -Expected: PASS - -**Step 5: Commit** - -```bash -uv run black . -git add src/claude_code_transcripts/__init__.py tests/test_all.py -git commit -m "feat: add --markdown flag to local and json commands" -``` - ---- - -### Task 5: Add `--markdown` flag to `web` command - -**Files:** -- Modify: `src/claude_code_transcripts/__init__.py` - -**Step 1: Add a `generate_markdown_from_session_data()` function** - -This parallels `generate_html_from_session_data()`: - -```python -def generate_markdown_from_session_data(session_data, output_dir, github_repo=None): - """Generate Markdown from session data dict (instead of file path). - - Returns the Path to the generated .md file. - """ - output_dir = Path(output_dir) - output_dir.mkdir(exist_ok=True, parents=True) - - loglines = session_data.get("loglines", []) - - if github_repo is None: - github_repo = detect_github_repo(loglines) - - conversations = _group_conversations(loglines) - md_parts = [] - - for conv in conversations: - for log_type, message_json, timestamp in conv["messages"]: - if not message_json: - continue - try: - message_data = json.loads(message_json) - except json.JSONDecodeError: - continue - - if log_type == "user": - if is_tool_result_message(message_data): - role = "Tool reply" - else: - role = "User" - md_parts.append(f"### {role}") - md_parts.append(f"*{timestamp}*\n") - md_parts.append(_render_message_content_markdown(message_data)) - elif log_type == "assistant": - md_parts.append("### Assistant") - md_parts.append(f"*{timestamp}*\n") - md_parts.append(_render_message_content_markdown(message_data)) - - md_parts.append("---\n") - - markdown_content = "\n\n".join(md_parts) - md_path = output_dir / "transcript.md" - md_path.write_text(markdown_content, encoding="utf-8") - return md_path -``` - -**Step 2: Add `--markdown` flag to `web_cmd`** - -Same `@click.option` decorator. Branch in the command body: - -```python - if use_markdown: - md_path = generate_markdown_from_session_data(session_data, output, github_repo=repo) - click.echo(f"Generated {md_path.resolve()}") - else: - generate_html_from_session_data(session_data, output, github_repo=repo) -``` - -**Step 3: Run all tests** - -Run: `uv run pytest -v` -Expected: All PASS - -**Step 4: Commit** - -```bash -uv run black . -git add src/claude_code_transcripts/__init__.py -git commit -m "feat: add --markdown flag to web command" -``` - ---- - -### Task 6: Manual smoke test and final cleanup - -**Step 1: Run the tool against a real session** - -```bash -uv run claude-code-transcripts json tests/sample_session.jsonl -o /tmp/md-test --markdown -cat /tmp/md-test/transcript.md -``` - -Verify the output looks good — user prompts, assistant responses, tool calls, and tool results all render sensibly. - -**Step 2: Run all tests one final time** - -Run: `uv run pytest -v` -Expected: All PASS - -**Step 3: Run black** - -```bash -uv run black . -``` - -**Step 4: Final commit if any cleanup was needed** - -```bash -git add -A -git commit -m "chore: final cleanup for markdown export feature" -``` From f6cf40bce1b64bbaabe9d95a90ecba189808d3f6 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 21:38:37 +0800 Subject: [PATCH 12/14] feat: support --gist flag with --markdown output Previously --gist was silently ignored when combined with --markdown. Now all three commands (local, json, web) upload the markdown file as a GitHub Gist and output the gist URL. No preview URL is needed since GitHub renders markdown natively. --- src/claude_code_transcripts/__init__.py | 32 +++++++---- tests/test_all.py | 71 +++++++++++++++++++++++++ tests/test_generate_html.py | 43 ++++++++++++++- 3 files changed, 136 insertions(+), 10 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 97230e0..2352dc6 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1353,20 +1353,22 @@ def inject_gist_preview_js(output_dir): html_file.write_text(content, encoding="utf-8") -def create_gist(output_dir, public=False): - """Create a GitHub gist from the HTML files in output_dir. +def create_gist(output_dir, public=False, file_glob="*.html"): + """Create a GitHub gist from files in output_dir matching file_glob. Returns the gist ID on success, or raises click.ClickException on failure. """ output_dir = Path(output_dir) - html_files = list(output_dir.glob("*.html")) - if not html_files: - raise click.ClickException("No HTML files found to upload to gist.") + files = list(output_dir.glob(file_glob)) + if not files: + raise click.ClickException( + f"No files matching {file_glob} found to upload to gist." + ) # Build the gh gist create command # gh gist create file1 file2 ... --public/--private cmd = ["gh", "gist", "create"] - cmd.extend(str(f) for f in sorted(html_files)) + cmd.extend(str(f) for f in sorted(files)) if public: cmd.append("--public") @@ -1797,7 +1799,11 @@ def local_cmd( if use_markdown: md_path = generate_markdown(session_file, output, github_repo=repo) click.echo(f"Generated {md_path.resolve()}") - if open_browser or auto_open: + if gist: + click.echo("Creating GitHub gist...") + _gist_id, gist_url = create_gist(output, file_glob="*.md") + click.echo(f"Gist: {gist_url}") + elif open_browser or auto_open: open_in_editor(md_path.resolve()) else: generate_html(session_file, output, github_repo=repo) @@ -1952,7 +1958,11 @@ def json_cmd( if use_markdown: md_path = generate_markdown(json_file_path, output, github_repo=repo) click.echo(f"Generated {md_path.resolve()}") - if open_browser or auto_open: + if gist: + click.echo("Creating GitHub gist...") + _gist_id, gist_url = create_gist(output, file_glob="*.md") + click.echo(f"Gist: {gist_url}") + elif open_browser or auto_open: open_in_editor(md_path.resolve()) else: generate_html(json_file_path, output, github_repo=repo) @@ -2306,7 +2316,11 @@ def web_cmd( session_data, output, github_repo=repo ) click.echo(f"Generated {md_path.resolve()}") - if open_browser or auto_open: + if gist: + click.echo("Creating GitHub gist...") + _gist_id, gist_url = create_gist(output, file_glob="*.md") + click.echo(f"Gist: {gist_url}") + elif open_browser or auto_open: open_in_editor(md_path.resolve()) else: click.echo(f"Generating HTML in {output}/...") diff --git a/tests/test_all.py b/tests/test_all.py index 50d142f..aa01555 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -787,3 +787,74 @@ def test_json_markdown_open_falls_back_to_click_launch( assert result.exit_code == 0 assert len(launched) == 1 assert launched[0].endswith(".md") + + def test_json_markdown_gist_creates_gist(self, output_dir, monkeypatch): + """Test that --markdown --gist generates markdown and uploads as gist.""" + import subprocess + + jsonl_file = output_dir / "test.jsonl" + jsonl_file.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' + '{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi there!"}]}}\n' + ) + md_output = output_dir / "md_output" + + mock_result = subprocess.CompletedProcess( + args=["gh", "gist", "create"], + returncode=0, + stdout="https://gist.github.com/testuser/md789\n", + stderr="", + ) + + def mock_run(*args, **kwargs): + return mock_result + + monkeypatch.setattr(subprocess, "run", mock_run) + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(jsonl_file), "-o", str(md_output), "--markdown", "--gist"], + ) + assert result.exit_code == 0 + assert (md_output / "transcript.md").exists() + assert "Creating GitHub gist" in result.output + assert "gist.github.com" in result.output + assert "gisthost.github.io" not in result.output + + def test_json_markdown_gist_does_not_open_editor(self, output_dir, monkeypatch): + """Test that --markdown --gist does not open editor.""" + import subprocess + + jsonl_file = output_dir / "test.jsonl" + jsonl_file.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n' + ) + md_output = output_dir / "md_output" + + mock_result = subprocess.CompletedProcess( + args=["gh", "gist", "create"], + returncode=0, + stdout="https://gist.github.com/testuser/md789\n", + stderr="", + ) + + launched = [] + + def mock_run(*args, **kwargs): + cmd = args[0] if args else kwargs.get("args", []) + if cmd and cmd[0] == "gh": + return mock_result + launched.append(cmd) + return subprocess.CompletedProcess(args=cmd, returncode=0) + + monkeypatch.setattr(subprocess, "run", mock_run) + monkeypatch.setenv("EDITOR", "vim") + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(jsonl_file), "-o", str(md_output), "--markdown", "--gist"], + ) + assert result.exit_code == 0 + assert not any("vim" in str(c) for c in launched) diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index d769390..42d006f 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -570,6 +570,47 @@ def test_gist_preview_js_runs_on_dom_content_loaded(self): assert "DOMContentLoaded" in GIST_PREVIEW_JS +class TestCreateGistMarkdown: + """Tests for create_gist with file_glob parameter for markdown files.""" + + def test_creates_gist_with_markdown_files(self, output_dir, monkeypatch): + """Test that create_gist uploads .md files when file_glob='*.md'.""" + import subprocess + + (output_dir / "transcript.md").write_text( + "# Transcript\n\nHello world", encoding="utf-8" + ) + + captured_cmd = [] + mock_result = subprocess.CompletedProcess( + args=["gh", "gist", "create"], + returncode=0, + stdout="https://gist.github.com/testuser/md123\n", + stderr="", + ) + + def mock_run(*args, **kwargs): + captured_cmd.extend(args[0] if args else kwargs.get("args", [])) + return mock_result + + monkeypatch.setattr(subprocess, "run", mock_run) + + gist_id, gist_url = create_gist(output_dir, file_glob="*.md") + + assert gist_id == "md123" + assert gist_url == "https://gist.github.com/testuser/md123" + assert any("transcript.md" in str(c) for c in captured_cmd) + + def test_raises_on_no_markdown_files(self, output_dir): + """Test that error is raised when no .md files exist.""" + import click + + with pytest.raises(click.ClickException) as exc_info: + create_gist(output_dir, file_glob="*.md") + + assert "No files matching" in str(exc_info.value) + + class TestCreateGist: """Tests for the create_gist function.""" @@ -611,7 +652,7 @@ def test_raises_on_no_html_files(self, output_dir): with pytest.raises(click.ClickException) as exc_info: create_gist(output_dir) - assert "No HTML files found" in str(exc_info.value) + assert "No files matching *.html" in str(exc_info.value) def test_raises_on_gh_cli_error(self, output_dir, monkeypatch): """Test that error is raised when gh CLI fails.""" From a557e36b8614498cbd162b7334fe2f9cbfa54303 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Sun, 1 Mar 2026 22:20:01 +0800 Subject: [PATCH 13/14] refactor: deduplicate markdown generation and extract shared helper - Have generate_markdown delegate to generate_markdown_from_session_data - Remove unused github_repo auto-detection from markdown generation - Extract _handle_markdown_output to replace triplicated CLI output blocks - Remove redundant `import re` in _md_fence (already at module level) - Fix open_in_editor to use shlex.split for editors with args (e.g. "code --wait") - Fix VISUAL/EDITOR priority to follow POSIX convention --- src/claude_code_transcripts/__init__.py | 90 ++++++------------------- 1 file changed, 22 insertions(+), 68 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 2352dc6..7522675 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -842,8 +842,6 @@ def render_content_block(block): def _md_fence(content, lang=""): """Return a fenced code block, using enough backticks to avoid breaking on inner fences.""" - import re - runs = re.findall(r"`{3,}", content) max_run = max((len(r) for r in runs), default=0) ticks = "`" * max(3, max_run + 1) @@ -1602,46 +1600,10 @@ def generate_markdown(json_path, output_dir, github_repo=None): Returns the Path to the generated .md file. """ - output_dir = Path(output_dir) - output_dir.mkdir(exist_ok=True) - data = parse_session_file(json_path) - loglines = data.get("loglines", []) - - if github_repo is None: - github_repo = detect_github_repo(loglines) - - conversations = _group_conversations(loglines) - md_parts = [] - - for conv in conversations: - for log_type, message_json, timestamp in conv["messages"]: - if not message_json: - continue - try: - message_data = json.loads(message_json) - except json.JSONDecodeError: - continue - - if log_type == "user": - if is_tool_result_message(message_data): - role = "Tool reply" - else: - role = "User" - md_parts.append(f"### {role}") - md_parts.append(f"*{timestamp}*\n") - md_parts.append(_render_message_content_markdown(message_data)) - elif log_type == "assistant": - md_parts.append("### Assistant") - md_parts.append(f"*{timestamp}*\n") - md_parts.append(_render_message_content_markdown(message_data)) - - md_parts.append("---\n") - - markdown_content = "\n\n".join(md_parts) - md_path = output_dir / "transcript.md" - md_path.write_text(markdown_content, encoding="utf-8") - return md_path + return generate_markdown_from_session_data( + data, output_dir, github_repo=github_repo + ) def generate_markdown_from_session_data(session_data, output_dir, github_repo=None): @@ -1653,10 +1615,6 @@ def generate_markdown_from_session_data(session_data, output_dir, github_repo=No output_dir.mkdir(exist_ok=True, parents=True) loglines = session_data.get("loglines", []) - - if github_repo is None: - github_repo = detect_github_repo(loglines) - conversations = _group_conversations(loglines) md_parts = [] @@ -1798,13 +1756,7 @@ def local_cmd( if use_markdown: md_path = generate_markdown(session_file, output, github_repo=repo) - click.echo(f"Generated {md_path.resolve()}") - if gist: - click.echo("Creating GitHub gist...") - _gist_id, gist_url = create_gist(output, file_glob="*.md") - click.echo(f"Gist: {gist_url}") - elif open_browser or auto_open: - open_in_editor(md_path.resolve()) + _handle_markdown_output(md_path, output, gist, open_browser, auto_open) else: generate_html(session_file, output, github_repo=repo) @@ -1835,13 +1787,27 @@ def local_cmd( def open_in_editor(file_path): """Open a file in the user's preferred editor, or system default.""" - editor = os.environ.get("EDITOR") or os.environ.get("VISUAL") + import shlex + + editor = os.environ.get("VISUAL") or os.environ.get("EDITOR") if editor: - subprocess.run([editor, str(file_path)]) + parts = shlex.split(editor) + subprocess.run(parts + [str(file_path)]) else: click.launch(str(file_path)) +def _handle_markdown_output(md_path, output_dir, gist, open_browser, auto_open): + """Handle CLI output after generating a markdown file.""" + click.echo(f"Generated {md_path.resolve()}") + if gist: + click.echo("Creating GitHub gist...") + _gist_id, gist_url = create_gist(output_dir, file_glob="*.md") + click.echo(f"Gist: {gist_url}") + elif open_browser or auto_open: + open_in_editor(md_path.resolve()) + + def is_url(path): """Check if a path is a URL (starts with http:// or https://).""" return path.startswith("http://") or path.startswith("https://") @@ -1957,13 +1923,7 @@ def json_cmd( if use_markdown: md_path = generate_markdown(json_file_path, output, github_repo=repo) - click.echo(f"Generated {md_path.resolve()}") - if gist: - click.echo("Creating GitHub gist...") - _gist_id, gist_url = create_gist(output, file_glob="*.md") - click.echo(f"Gist: {gist_url}") - elif open_browser or auto_open: - open_in_editor(md_path.resolve()) + _handle_markdown_output(md_path, output, gist, open_browser, auto_open) else: generate_html(json_file_path, output, github_repo=repo) @@ -2315,13 +2275,7 @@ def web_cmd( md_path = generate_markdown_from_session_data( session_data, output, github_repo=repo ) - click.echo(f"Generated {md_path.resolve()}") - if gist: - click.echo("Creating GitHub gist...") - _gist_id, gist_url = create_gist(output, file_glob="*.md") - click.echo(f"Gist: {gist_url}") - elif open_browser or auto_open: - open_in_editor(md_path.resolve()) + _handle_markdown_output(md_path, output, gist, open_browser, auto_open) else: click.echo(f"Generating HTML in {output}/...") generate_html_from_session_data(session_data, output, github_repo=repo) From 5ea5c67277d1de104c89c48dfd5d0febe71baac1 Mon Sep 17 00:00:00 2001 From: Duc Nguyen <33684291+hellovietduc@users.noreply.github.com> Date: Mon, 2 Mar 2026 09:31:14 +0800 Subject: [PATCH 14/14] docs: document --markdown flag in README --- README.md | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/README.md b/README.md index 294a639..9a166f3 100644 --- a/README.md +++ b/README.md @@ -52,6 +52,7 @@ All commands support these options: - `-o, --output DIRECTORY` - output directory (default: writes to temp dir and opens browser) - `-a, --output-auto` - auto-name output subdirectory based on session ID or filename - `--repo OWNER/NAME` - GitHub repo for commit links (auto-detected if not specified). For `web` command, also filters the session list. +- `--markdown` - output as a single Markdown file instead of HTML - `--open` - open the generated `index.html` in your default browser (default if no `-o` specified) - `--gist` - upload the generated HTML files to a GitHub Gist and output a preview URL - `--json` - include the original session file in the output directory @@ -134,6 +135,24 @@ claude-code-transcripts json session.json -o ./my-transcript --gist **Requirements:** The `--gist` option requires the [GitHub CLI](https://cli.github.com/) (`gh`) to be installed and authenticated (`gh auth login`). +### Markdown output + +Use the `--markdown` flag to generate a single Markdown file instead of multi-page HTML: + +```bash +claude-code-transcripts --markdown +claude-code-transcripts json session.json --markdown -o ./output +claude-code-transcripts web SESSION_ID --markdown +``` + +When `--open` is used with `--markdown`, the file opens in your `$VISUAL` or `$EDITOR` instead of a browser. If neither is set, it falls back to the system default application. + +The `--gist` flag works with `--markdown` too — since GitHub renders Markdown natively, no preview URL is needed: + +```bash +claude-code-transcripts json session.json --markdown --gist +``` + ### Auto-naming output directories Use `-a/--output-auto` to automatically create a subdirectory named after the session: