Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
76 changes: 59 additions & 17 deletions src/claude_code_transcripts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand All @@ -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)"

Expand Down Expand Up @@ -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")

Expand Down Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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}")
Expand Down Expand Up @@ -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}")
Expand Down
136 changes: 136 additions & 0 deletions tests/test_generate_html.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
"<html><body>Index</body></html>", 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(
"<html><body>Index</body></html>", 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(
"<html><body>Index</body></html>", 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."""
Expand Down Expand Up @@ -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."""
Expand Down