diff --git a/README.md b/README.md index 25572c9..6043d37 100644 --- a/README.md +++ b/README.md @@ -51,6 +51,7 @@ All commands support these options: - `--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 +- `--output-mode` - control how much detail is shown: `full` (default), `compact`, or `conversation` The generated output includes: - `index.html` - an index page with a timeline of prompts and commits @@ -157,6 +158,33 @@ JSON: ./my-transcript/session_ABC.json (245.3 KB) This is useful for archiving the source data alongside the HTML output. +### Filtering output + +Use `--output-mode` to control how much detail is included in the generated HTML: + +```bash +# Show everything (default) +claude-code-transcripts json session.json --output-mode full + +# Hide tool results but keep tool call headers (useful for hiding sensitive output) +claude-code-transcripts json session.json --output-mode compact + +# Show only user prompts and assistant text (no tools, no thinking) +claude-code-transcripts json session.json --output-mode conversation +``` + +The three modes are: + +| Mode | User text | Assistant text | Tool calls | Tool results | Thinking | +|------|-----------|---------------|------------|-------------|----------| +| `full` | Yes | Yes | Yes | Yes | Yes | +| `compact` | Yes | Yes | Yes | No | Yes | +| `conversation` | Yes | Yes | No | No | No | + +`compact` mode is useful when tool results might contain sensitive information (environment variables, file contents, etc.) but you still want to see what tools were invoked. `conversation` mode gives a clean "what did we discuss?" view. + +This option works with all commands (`local`, `web`, `json`, `all`). + ### Converting from JSON/JSONL files Convert a specific session file directly: diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index e4854a3..80f6a9c 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -9,6 +9,7 @@ import subprocess import tempfile import webbrowser +from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -78,6 +79,41 @@ def extract_text_from_content(content): # Module-level variable for GitHub repo (set by generate_html) _github_repo = None + +@dataclass +class OutputFilter: + """Controls which content types are included in the rendered output. + + Individual flags can be set directly, or use from_mode() for presets. + """ + + hide_tool_calls: bool = False + hide_tool_results: bool = False + hide_thinking: bool = False + + def should_hide(self, block_type: str) -> bool: + """Check whether a content block type should be hidden.""" + if block_type == "thinking": + return self.hide_thinking + if block_type == "tool_use": + return self.hide_tool_calls + if block_type == "tool_result": + return self.hide_tool_results + return False + + @classmethod + def from_mode(cls, mode: str) -> "OutputFilter": + if mode == "compact": + return cls(hide_tool_results=True) + elif mode == "conversation": + return cls(hide_tool_calls=True, hide_tool_results=True, hide_thinking=True) + else: # "full" + return cls() + + +# Module-level variable for output filtering (set by generate_html) +_output_filter = OutputFilter() + # API constants API_BASE_URL = "https://api.anthropic.com/v1" ANTHROPIC_VERSION = "2023-06-01" @@ -304,7 +340,11 @@ def find_all_sessions(folder, include_agents=False): def generate_batch_html( - source_folder, output_dir, include_agents=False, progress_callback=None + source_folder, + output_dir, + include_agents=False, + progress_callback=None, + output_filter=None, ): """Generate HTML archive for all sessions in a Claude projects folder. @@ -319,6 +359,7 @@ def generate_batch_html( include_agents: Whether to include agent-* session files progress_callback: Optional callback(project_name, session_name, current, total) called after each session is processed + output_filter: Optional OutputFilter controlling content visibility Returns statistics dict with total_projects, total_sessions, failed_sessions, output_dir. """ @@ -347,7 +388,9 @@ def generate_batch_html( # Generate transcript HTML with error handling try: - generate_html(session["path"], session_dir) + generate_html( + session["path"], session_dir, output_filter=output_filter + ) successful_sessions += 1 except Exception as e: failed_sessions.append( @@ -747,6 +790,8 @@ def render_content_block(block): if not isinstance(block, dict): return f"

{html.escape(str(block))}

" block_type = block.get("type", "") + if _output_filter.should_hide(block_type): + return "" if block_type == "image": source = block.get("source", {}) media_type = source.get("media_type", "image/png") @@ -885,7 +930,7 @@ def analyze_conversation(messages): continue block_type = block.get("type", "") - if block_type == "tool_use": + if block_type == "tool_use" and not _output_filter.should_hide("tool_use"): tool_name = block.get("name", "Unknown") tool_counts[tool_name] = tool_counts.get(tool_name, 0) + 1 elif block_type == "tool_result": @@ -954,12 +999,14 @@ def render_message(log_type, message_json, timestamp): except json.JSONDecodeError: return "" if log_type == "user": - content_html = render_user_message_content(message_data) # Check if this is a tool result message if is_tool_result_message(message_data): + if _output_filter.should_hide("tool_result"): + return "" role_class, role_label = "tool-reply", "Tool reply" else: role_class, role_label = "user", "User" + content_html = render_user_message_content(message_data) elif log_type == "assistant": content_html = render_assistant_message(message_data) role_class, role_label = "assistant", "Assistant" @@ -1295,7 +1342,7 @@ def generate_index_pagination_html(total_pages): return _macros.index_pagination(total_pages) -def generate_html(json_path, output_dir, github_repo=None): +def generate_html(json_path, output_dir, github_repo=None, output_filter=None): output_dir = Path(output_dir) output_dir.mkdir(exist_ok=True) @@ -1314,9 +1361,11 @@ def generate_html(json_path, output_dir, github_repo=None): "Warning: Could not auto-detect GitHub repo. Commit links will be disabled." ) - # Set module-level variable for render functions + # Set module-level variables for render functions global _github_repo _github_repo = github_repo + global _output_filter + _output_filter = output_filter or OutputFilter() conversations = [] current_conv = None @@ -1516,7 +1565,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( + "--output-mode", + type=click.Choice(["full", "compact", "conversation"]), + default="full", + help="Output detail level: full (default), compact (hide tool results), conversation (text only).", +) +def local_cmd( + output, output_auto, repo, gist, include_json, open_browser, limit, output_mode +): """Select and convert a local Claude Code session to HTML.""" projects_folder = Path.home() / ".claude" / "projects" @@ -1567,7 +1624,12 @@ 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) + generate_html( + session_file, + output, + github_repo=repo, + output_filter=OutputFilter.from_mode(output_mode), + ) # Show output directory click.echo(f"Output: {output.resolve()}") @@ -1668,7 +1730,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( + "--output-mode", + type=click.Choice(["full", "compact", "conversation"]), + default="full", + help="Output detail level: full (default), compact (hide tool results), conversation (text only).", +) +def json_cmd( + json_file, output, output_auto, repo, gist, include_json, open_browser, output_mode +): """Convert a Claude Code session JSON/JSONL file or URL to HTML.""" # Handle URL input if is_url(json_file): @@ -1698,7 +1768,12 @@ 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) + generate_html( + json_file_path, + output, + github_repo=repo, + output_filter=OutputFilter.from_mode(output_mode), + ) # Show output directory click.echo(f"Output: {output.resolve()}") @@ -1775,7 +1850,9 @@ def format_session_for_display(session_data): return f"{repo_display:30} {date_display:19} {title}" -def generate_html_from_session_data(session_data, output_dir, github_repo=None): +def generate_html_from_session_data( + session_data, output_dir, github_repo=None, output_filter=None +): """Generate HTML from session data dict (instead of file path).""" output_dir = Path(output_dir) output_dir.mkdir(exist_ok=True, parents=True) @@ -1788,9 +1865,11 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): if github_repo: click.echo(f"Auto-detected GitHub repo: {github_repo}") - # Set module-level variable for render functions + # Set module-level variables for render functions global _github_repo _github_repo = github_repo + global _output_filter + _output_filter = output_filter or OutputFilter() conversations = [] current_conv = None @@ -1983,6 +2062,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( + "--output-mode", + type=click.Choice(["full", "compact", "conversation"]), + default="full", + help="Output detail level: full (default), compact (hide tool results), conversation (text only).", +) def web_cmd( session_id, output, @@ -1993,6 +2078,7 @@ def web_cmd( gist, include_json, open_browser, + output_mode, ): """Select and convert a web session from the Claude API to HTML. @@ -2068,7 +2154,12 @@ def web_cmd( output = Path(output) click.echo(f"Generating HTML in {output}/...") - generate_html_from_session_data(session_data, output, github_repo=repo) + generate_html_from_session_data( + session_data, + output, + github_repo=repo, + output_filter=OutputFilter.from_mode(output_mode), + ) # Show output directory click.echo(f"Output: {output.resolve()}") @@ -2132,7 +2223,13 @@ def web_cmd( is_flag=True, help="Suppress all output except errors.", ) -def all_cmd(source, output, include_agents, dry_run, open_browser, quiet): +@click.option( + "--output-mode", + type=click.Choice(["full", "compact", "conversation"]), + default="full", + help="Output detail level: full (default), compact (hide tool results), conversation (text only).", +) +def all_cmd(source, output, include_agents, dry_run, open_browser, quiet, output_mode): """Convert all local Claude Code sessions to a browsable HTML archive. Creates a directory structure with: @@ -2198,6 +2295,7 @@ def on_progress(project_name, session_name, current, total): output, include_agents=include_agents, progress_callback=on_progress, + output_filter=OutputFilter.from_mode(output_mode), ) # Report any failures diff --git a/tests/test_all.py b/tests/test_all.py index 8215acd..78389c8 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -281,10 +281,10 @@ def test_handles_failed_session_gracefully(self, output_dir): # Patch generate_html to fail on one specific session original_generate_html = __import__("claude_code_transcripts").generate_html - def mock_generate_html(json_path, output_dir, github_repo=None): + def mock_generate_html(json_path, output_dir, github_repo=None, **kwargs): if "session1" in str(json_path): raise RuntimeError("Simulated failure") - return original_generate_html(json_path, output_dir, github_repo) + return original_generate_html(json_path, output_dir, github_repo, **kwargs) with patch( "claude_code_transcripts.generate_html", side_effect=mock_generate_html diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 25c2822..d341cd6 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, + OutputFilter, ) @@ -1638,3 +1639,344 @@ 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 TestOutputFilter: + """Tests for the OutputFilter dataclass and output mode filtering.""" + + def test_from_mode_full(self): + """Test that 'full' mode produces default (no filtering).""" + f = OutputFilter.from_mode("full") + assert f.hide_tool_calls is False + assert f.hide_tool_results is False + assert f.hide_thinking is False + + def test_from_mode_compact(self): + """Test that 'compact' mode hides tool results only.""" + f = OutputFilter.from_mode("compact") + assert f.hide_tool_calls is False + assert f.hide_tool_results is True + assert f.hide_thinking is False + + def test_from_mode_conversation(self): + """Test that 'conversation' mode hides tools, results, and thinking.""" + f = OutputFilter.from_mode("conversation") + assert f.hide_tool_calls is True + assert f.hide_tool_results is True + assert f.hide_thinking is True + + def test_compact_mode_hides_tool_results(self, tmp_path): + """Test that compact mode hides tool results but keeps tool calls.""" + session_data = { + "loglines": [ + { + "type": "user", + "timestamp": "2025-01-01T10:00:00.000Z", + "message": {"content": "Run the tests", "role": "user"}, + }, + { + "type": "assistant", + "timestamp": "2025-01-01T10:00:05.000Z", + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "I'll run the tests now."}, + { + "type": "tool_use", + "name": "Bash", + "id": "tool-1", + "input": { + "command": "pytest", + "description": "Run tests", + }, + }, + { + "type": "tool_result", + "content": "PASSED: 5 tests in 1.2s\nSECRET_KEY=abc123", + "is_error": False, + }, + ], + }, + }, + ] + } + + session_file = tmp_path / "test_session.json" + session_file.write_text(json.dumps(session_data), encoding="utf-8") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + generate_html( + session_file, + output_dir, + output_filter=OutputFilter.from_mode("compact"), + ) + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + + # Tool call should still be visible + assert "pytest" in page_html + # Assistant text should be visible + assert "run the tests now" in page_html + # Tool result content should be hidden + assert "SECRET_KEY" not in page_html + assert "PASSED: 5 tests" not in page_html + + def test_compact_mode_hides_tool_reply_messages(self, tmp_path): + """Test that compact mode hides standalone 'Tool reply' messages.""" + session_data = { + "loglines": [ + { + "type": "user", + "timestamp": "2025-01-01T10:00:00.000Z", + "message": {"content": "Run something", "role": "user"}, + }, + { + "type": "assistant", + "timestamp": "2025-01-01T10:00:05.000Z", + "message": { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "name": "Bash", + "id": "tool-1", + "input": {"command": "echo hello"}, + }, + ], + }, + }, + # This is a "Tool reply" message - user type but only tool_result content + { + "type": "user", + "timestamp": "2025-01-01T10:00:10.000Z", + "message": { + "role": "user", + "content": [ + { + "type": "tool_result", + "content": "hello\nSENSITIVE_DATA_HERE", + } + ], + }, + }, + { + "type": "assistant", + "timestamp": "2025-01-01T10:00:15.000Z", + "message": { + "role": "assistant", + "content": [ + {"type": "text", "text": "The command ran successfully."}, + ], + }, + }, + ] + } + + session_file = tmp_path / "test_session.json" + session_file.write_text(json.dumps(session_data), encoding="utf-8") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + generate_html( + session_file, + output_dir, + output_filter=OutputFilter.from_mode("compact"), + ) + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + + # Tool reply content should be hidden + assert "SENSITIVE_DATA_HERE" not in page_html + # But assistant text should still be there + assert "command ran successfully" in page_html + + def test_conversation_mode_hides_everything_except_text(self, tmp_path): + """Test that conversation mode shows only user/assistant text.""" + session_data = { + "loglines": [ + { + "type": "user", + "timestamp": "2025-01-01T10:00:00.000Z", + "message": {"content": "Help me write code", "role": "user"}, + }, + { + "type": "assistant", + "timestamp": "2025-01-01T10:00:05.000Z", + "message": { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "Let me think about the approach...", + }, + {"type": "text", "text": "Here's the code you need."}, + { + "type": "tool_use", + "name": "Write", + "id": "tool-1", + "input": { + "file_path": "/tmp/test.py", + "content": "print('hello')", + }, + }, + { + "type": "tool_result", + "content": "File written successfully", + "is_error": False, + }, + ], + }, + }, + ] + } + + session_file = tmp_path / "test_session.json" + session_file.write_text(json.dumps(session_data), encoding="utf-8") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + generate_html( + session_file, + output_dir, + output_filter=OutputFilter.from_mode("conversation"), + ) + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + + # User text and assistant text should be visible + assert "Help me write code" in page_html + assert "the code you need" in page_html + # Thinking should be hidden + assert "think about the approach" not in page_html + # Tool call should be hidden + assert "test.py" not in page_html + # Tool result should be hidden + assert "File written successfully" not in page_html + + def test_full_mode_shows_everything(self, tmp_path): + """Test that full mode (default) shows all content types.""" + session_data = { + "loglines": [ + { + "type": "user", + "timestamp": "2025-01-01T10:00:00.000Z", + "message": {"content": "Do something", "role": "user"}, + }, + { + "type": "assistant", + "timestamp": "2025-01-01T10:00:05.000Z", + "message": { + "role": "assistant", + "content": [ + { + "type": "thinking", + "thinking": "Thinking content here.", + }, + {"type": "text", "text": "Assistant response."}, + { + "type": "tool_use", + "name": "Bash", + "id": "tool-1", + "input": {"command": "ls -la"}, + }, + { + "type": "tool_result", + "content": "total 42\ndrwxr-xr-x", + "is_error": False, + }, + ], + }, + }, + ] + } + + session_file = tmp_path / "test_session.json" + session_file.write_text(json.dumps(session_data), encoding="utf-8") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + # Default (no filter) should show everything + generate_html(session_file, output_dir) + + page_html = (output_dir / "page-001.html").read_text(encoding="utf-8") + + assert "Do something" in page_html + assert "Thinking content here" in page_html + assert "Assistant response" in page_html + assert "ls -la" in page_html + assert "total 42" in page_html + + def test_conversation_mode_hides_tool_stats_in_index(self, tmp_path): + """Test that conversation mode hides tool stats from the index page.""" + session_data = { + "loglines": [ + { + "type": "user", + "timestamp": "2025-01-01T10:00:00.000Z", + "message": {"content": "Run tests", "role": "user"}, + }, + { + "type": "assistant", + "timestamp": "2025-01-01T10:00:05.000Z", + "message": { + "role": "assistant", + "content": [ + { + "type": "tool_use", + "name": "Bash", + "id": "tool-1", + "input": {"command": "pytest"}, + }, + ], + }, + }, + ] + } + + session_file = tmp_path / "test_session.json" + session_file.write_text(json.dumps(session_data), encoding="utf-8") + + output_dir = tmp_path / "output" + output_dir.mkdir() + + generate_html( + session_file, + output_dir, + output_filter=OutputFilter.from_mode("conversation"), + ) + + index_html = (output_dir / "index.html").read_text(encoding="utf-8") + + # Tool stats like "1 bash" should not appear in conversation mode + assert "1 bash" not in index_html + + def test_output_mode_cli_option(self, tmp_path): + """Test that --output-mode CLI option works.""" + from click.testing import CliRunner + from claude_code_transcripts import cli + + fixture_path = Path(__file__).parent / "sample_session.json" + + output_dir = tmp_path / "output" + output_dir.mkdir() + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "json", + str(fixture_path), + "-o", + str(output_dir), + "--output-mode", + "compact", + ], + ) + + assert result.exit_code == 0 + assert (output_dir / "index.html").exists()