diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index e4854a3..8249acc 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -83,6 +83,32 @@ def extract_text_from_content(content): ANTHROPIC_VERSION = "2023-06-01" +def get_title_from_session_data(session_data, max_length=200): + """Extract a title from session data dict. + + Checks for a top-level 'title' field first, then falls back to + the first user message in loglines. + Returns a title string or None if none found. + """ + title = session_data.get("title") + if title: + if len(title) > max_length: + return title[: max_length - 3] + "..." + return title + + loglines = session_data.get("loglines", []) + for entry in loglines: + if entry.get("type") == "user": + msg = entry.get("message", {}) + content = msg.get("content", "") + text = extract_text_from_content(content) + if text: + if len(text) > max_length: + return text[: max_length - 3] + "..." + return text + return None + + def get_session_summary(filepath, max_length=200): """Extract a human-readable summary from a session file. @@ -94,20 +120,9 @@ def get_session_summary(filepath, max_length=200): if filepath.suffix == ".jsonl": return _get_jsonl_summary(filepath, max_length) else: - # For JSON files, try to get first user message with open(filepath, "r", encoding="utf-8") as f: data = json.load(f) - loglines = data.get("loglines", []) - for entry in loglines: - if entry.get("type") == "user": - msg = entry.get("message", {}) - content = msg.get("content", "") - text = extract_text_from_content(content) - if text: - if len(text) > max_length: - return text[: max_length - 3] + "..." - return text - return "(no summary)" + return get_title_from_session_data(data, max_length) or "(no summary)" except Exception: return "(no summary)" @@ -1248,20 +1263,42 @@ def inject_gist_preview_js(output_dir): html_file.write_text(content, encoding="utf-8") -def create_gist(output_dir, public=False): +def _sanitize_filename(name): + """Sanitize a string for use as a filename.""" + # Replace characters that are invalid in filenames + for ch in r'/<>:"\|?*': + name = name.replace(ch, "-") + # Collapse multiple dashes + while "--" in name: + name = name.replace("--", "-") + return name.strip(" -") + + +def create_gist(output_dir, public=False, title=None): """Create a GitHub gist from the HTML files in output_dir. Returns the gist ID on success, or raises click.ClickException on failure. + If title is provided, creates a {title}.md file to name the gist. """ 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.") + # Create a title file to name the gist + title_file = None + if title: + safe_title = _sanitize_filename(title) + title_file = output_dir / f"{safe_title}.md" + title_file.write_text("Empty file to name 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)) + files = sorted(html_files) + if title_file: + files = [title_file] + files + cmd.extend(str(f) for f in files) if public: cmd.append("--public") @@ -1534,7 +1571,9 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit # Build choices for questionary choices = [] + summary_by_path = {} for filepath, summary in results: + summary_by_path[filepath] = summary stat = filepath.stat() mod_time = datetime.fromtimestamp(stat.st_mtime) size_kb = stat.st_size / 1024 @@ -1555,6 +1594,7 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit return session_file = selected + session_title = summary_by_path.get(session_file) # Determine output directory and whether to open browser # If no -o specified, use temp dir and open browser by default @@ -1584,7 +1624,7 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit # Inject gist preview JS and create gist inject_gist_preview_js(output) click.echo("Creating GitHub gist...") - gist_id, gist_url = create_gist(output) + gist_id, gist_url = create_gist(output, title=session_title) preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") @@ -1714,8 +1754,9 @@ def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_brow if gist: # Inject gist preview JS and create gist inject_gist_preview_js(output) + session_title = get_session_summary(json_file_path) click.echo("Creating GitHub gist...") - gist_id, gist_url = create_gist(output) + gist_id, gist_url = create_gist(output, title=session_title) preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") @@ -2085,8 +2126,9 @@ def web_cmd( if gist: # Inject gist preview JS and create gist inject_gist_preview_js(output) + session_title = get_title_from_session_data(session_data) click.echo("Creating GitHub gist...") - gist_id, gist_url = create_gist(output) + gist_id, gist_url = create_gist(output, title=session_title) preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 25c2822..97c23fa 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -657,6 +657,89 @@ def mock_run(*args, **kwargs): assert "gh CLI not found" in str(exc_info.value) + def test_creates_title_file_for_gist(self, output_dir, monkeypatch): + """Test that a title .md file is created and included in the gist.""" + import subprocess + + (output_dir / "index.html").write_text( + "Index", encoding="utf-8" + ) + + captured_cmd = [] + + def mock_run(*args, **kwargs): + captured_cmd.extend(args[0]) + return subprocess.CompletedProcess( + args=args[0], + returncode=0, + stdout="https://gist.github.com/testuser/abc123\n", + stderr="", + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + create_gist(output_dir, title="My Cool Session") + + # The title file should be created in the output directory + title_file = output_dir / "My Cool Session.md" + assert title_file.exists() + assert title_file.read_text() == "Empty file to name gist" + + # The title file should be included in the gh command + assert str(title_file) in captured_cmd + + def test_title_file_not_created_without_title(self, output_dir, monkeypatch): + """Test that no title file is created when title is not provided.""" + import subprocess + + (output_dir / "index.html").write_text( + "Index", encoding="utf-8" + ) + + def mock_run(*args, **kwargs): + return subprocess.CompletedProcess( + args=args[0], + returncode=0, + stdout="https://gist.github.com/testuser/abc123\n", + stderr="", + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + create_gist(output_dir) + + # No .md files should exist + md_files = list(output_dir.glob("*.md")) + assert len(md_files) == 0 + + def test_title_file_sanitizes_filename(self, output_dir, monkeypatch): + """Test that special characters in title are sanitized for filename.""" + import subprocess + + (output_dir / "index.html").write_text( + "Index", encoding="utf-8" + ) + + def mock_run(*args, **kwargs): + return subprocess.CompletedProcess( + args=args[0], + returncode=0, + stdout="https://gist.github.com/testuser/abc123\n", + stderr="", + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + create_gist(output_dir, title="Fix bug: handle /path/to/file") + + # Should sanitize slashes and colons + md_files = list(output_dir.glob("*.md")) + assert len(md_files) == 1 + assert md_files[0].read_text() == "Empty file to name gist" + # Filename should not contain path separators + assert "/" not in md_files[0].name + assert "\\" not in md_files[0].name + class TestSessionGistOption: """Tests for the session command --gist option.""" @@ -977,6 +1060,59 @@ def mock_run(*args, **kwargs): assert "gist.github.com" in result.output assert "gisthost.github.io" in result.output + def test_import_gist_creates_title_file(self, httpx_mock, monkeypatch, tmp_path): + """Test that web --gist creates a title .md file from the session data.""" + from click.testing import CliRunner + from claude_code_transcripts import cli + import subprocess + + # Load sample session to mock API response + fixture_path = Path(__file__).parent / "sample_session.json" + with open(fixture_path) as f: + session_data = json.load(f) + + httpx_mock.add_response( + url="https://api.anthropic.com/v1/session_ingress/session/test-session-id", + json=session_data, + ) + + captured_cmd = [] + + def mock_run(*args, **kwargs): + captured_cmd.extend(args[0]) + return subprocess.CompletedProcess( + args=args[0], + returncode=0, + stdout="https://gist.github.com/testuser/def456\n", + stderr="", + ) + + monkeypatch.setattr(subprocess, "run", mock_run) + + monkeypatch.setattr( + "claude_code_transcripts.tempfile.gettempdir", lambda: str(tmp_path) + ) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "web", + "test-session-id", + "--token", + "test-token", + "--org-uuid", + "test-org", + "--gist", + ], + ) + + assert result.exit_code == 0 + + # A .md title file should have been included in the gh command + md_files = [f for f in captured_cmd if f.endswith(".md")] + assert len(md_files) == 1, f"Expected 1 .md file in command, got: {md_files}" + class TestVersionOption: """Tests for the --version option."""