diff --git a/.gitignore b/.gitignore
index a4a9ce6..7be3335 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,3 +2,4 @@
__pycache__
uv.lock
.playwright-mcp/
+.worktrees/
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:
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..7522675 100644
--- a/src/claude_code_transcripts/__init__.py
+++ b/src/claude_code_transcripts/__init__.py
@@ -840,6 +840,109 @@ 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."""
+ 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):
+ 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{_md_fence(content)}"
+
+ 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)*"
+ 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", "")
+ description = tool_input.get("description", "")
+ header = f"**Bash**"
+ if description:
+ header += f": {description}"
+ 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)
+ description = tool_input.get("description", "")
+ header = f"**{tool_name}**"
+ if description:
+ header += f": {description}"
+ return f"{header}\n\n{_md_fence(input_json, 'json')}"
+
+ 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}{_md_fence(content)}"
+ 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}{_md_fence(result)}"
+ else:
+ return f"{prefix}{_md_fence(json.dumps(content, indent=2, ensure_ascii=False))}"
+
+ else:
+ return _md_fence(json.dumps(block, indent=2, ensure_ascii=False), "json")
+
+
def render_user_message_content(message_data):
content = message_data.get("content", "")
if isinstance(content, str):
@@ -1248,20 +1351,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")
@@ -1318,38 +1423,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
@@ -1470,6 +1544,110 @@ 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.
+ """
+ data = parse_session_file(json_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):
+ """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", [])
+ 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():
@@ -1516,7 +1694,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"
@@ -1567,31 +1753,59 @@ 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)
+ _handle_markdown_output(md_path, output, gist, open_browser, auto_open)
+ else:
+ generate_html(session_file, output, github_repo=repo)
+
+ # Show output directory
+ click.echo(f"Output: {output.resolve()}")
+
+ # 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 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 open_in_editor(file_path):
+ """Open a file in the user's preferred editor, or system default."""
+ import shlex
+
+ editor = os.environ.get("VISUAL") or os.environ.get("EDITOR")
+ if editor:
+ parts = shlex.split(editor)
+ subprocess.run(parts + [str(file_path)])
+ else:
+ click.launch(str(file_path))
- # 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)")
+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:
- # 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"
+ _gist_id, gist_url = create_gist(output_dir, file_glob="*.md")
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)
+ elif open_browser or auto_open:
+ open_in_editor(md_path.resolve())
def is_url(path):
@@ -1668,7 +1882,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):
@@ -1698,31 +1920,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()}")
+ if use_markdown:
+ md_path = generate_markdown(json_file_path, output, github_repo=repo)
+ _handle_markdown_output(md_path, output, gist, open_browser, auto_open)
+ else:
+ generate_html(json_file_path, output, github_repo=repo)
- # 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)")
+ # 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 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 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 resolve_credentials(token, org_uuid):
@@ -1792,38 +2019,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
@@ -1983,6 +2179,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,
@@ -1993,6 +2195,7 @@ def web_cmd(
gist,
include_json,
open_browser,
+ use_markdown,
):
"""Select and convert a web session from the Claude API to HTML.
@@ -2067,33 +2270,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 use_markdown:
+ md_path = generate_markdown_from_session_data(
+ session_data, output, github_repo=repo
+ )
+ _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)
- 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)
+ # 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")
diff --git a/tests/test_all.py b/tests/test_all.py
index 8215acd..aa01555 100644
--- a/tests/test_all.py
+++ b/tests/test_all.py
@@ -698,3 +698,163 @@ 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()
+
+ 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.run",
+ lambda args, **kw: launched.append(args),
+ )
+
+ 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")
+
+ 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 25c2822..42d006f 100644
--- a/tests/test_generate_html.py
+++ b/tests/test_generate_html.py
@@ -27,6 +27,8 @@
parse_session_file,
get_session_summary,
find_local_sessions,
+ render_content_block_markdown,
+ generate_markdown,
)
@@ -568,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."""
@@ -609,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."""
@@ -1638,3 +1681,241 @@ 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
+
+ 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."""
+
+ 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