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