From e6294d3631136becdc671388d5c16979bfb79642 Mon Sep 17 00:00:00 2001 From: paulkchen Date: Fri, 6 Feb 2026 13:29:34 -0800 Subject: [PATCH 1/2] Add title file to gist uploads to name the gist When uploading a gist, creates a {session_title}.md file with placeholder content so GitHub uses the session title as the gist name. The title is sourced from the session summary (local/json) or session title (web). Co-Authored-By: Claude Opus 4.6 --- src/claude_code_transcripts/__init__.py | 37 +++++++++-- tests/test_generate_html.py | 83 +++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 5 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index e4854a3..3a12d5c 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1248,20 +1248,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 +1556,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 +1579,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 +1609,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 +1739,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 +2111,9 @@ def web_cmd( if gist: # Inject gist preview JS and create gist inject_gist_preview_js(output) + session_title = session_data.get("title") 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..f6716cc 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.""" From 8dcb961277d5ded4982190a2761e67cb357a9726 Mon Sep 17 00:00:00 2001 From: paulkchen Date: Fri, 6 Feb 2026 13:46:08 -0800 Subject: [PATCH 2/2] Fix web --gist title: extract from loglines, not missing top-level field The session detail API response doesn't have a top-level "title" field, so session_data.get("title") was returning None. Extracted common logic into get_title_from_session_data() which checks for a title field first, then falls back to the first user message in loglines. Co-Authored-By: Claude Opus 4.6 --- src/claude_code_transcripts/__init__.py | 41 +++++++++++++------ tests/test_generate_html.py | 53 +++++++++++++++++++++++++ 2 files changed, 81 insertions(+), 13 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 3a12d5c..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)" @@ -2111,7 +2126,7 @@ def web_cmd( if gist: # Inject gist preview JS and create gist inject_gist_preview_js(output) - session_title = session_data.get("title") + session_title = get_title_from_session_data(session_data) click.echo("Creating GitHub gist...") gist_id, gist_url = create_gist(output, title=session_title) preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index f6716cc..97c23fa 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -1060,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."""