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
15 changes: 15 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,21 @@ claude-code-transcripts all -o ./my-archive
claude-code-transcripts all --include-agents
```

### Publishing an existing directory

If you've already generated HTML transcripts and want to publish them later:

```bash
claude-code-transcripts publish <directory>
```

This uploads the directory to a GitHub Gist and prints a shareable preview URL.

Options:

- `--public` — Create a public gist (default is secret/unlisted)
- `--open` — Open the preview URL in your browser

## Development

To contribute to this tool, first checkout the code. You can run the tests using `uv run`:
Expand Down
39 changes: 37 additions & 2 deletions src/claude_code_transcripts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1236,11 +1236,15 @@ def render_message(log_type, message_json, timestamp):


def inject_gist_preview_js(output_dir):
"""Inject gist preview JavaScript into all HTML files in the output directory."""
"""Inject gist preview JavaScript into all HTML files in the output directory.

Idempotent: skips files that already contain the script.
"""
output_dir = Path(output_dir)
for html_file in output_dir.glob("*.html"):
content = html_file.read_text(encoding="utf-8")
# Insert the gist preview JS before the closing </body> tag
if GIST_PREVIEW_JS in content:
continue
if "</body>" in content:
content = content.replace(
"</body>", f"<script>{GIST_PREVIEW_JS}</script>\n</body>"
Expand Down Expand Up @@ -2220,5 +2224,36 @@ def on_progress(project_name, session_name, current, total):
webbrowser.open(index_url)


@cli.command("publish")
@click.argument("directory", type=click.Path(exists=True, file_okay=False))
@click.option(
"--public",
is_flag=True,
help="Create a public gist (default is secret).",
)
@click.option(
"--open",
"open_browser",
is_flag=True,
help="Open the gist preview URL in your default browser.",
)
def publish_cmd(directory, public, open_browser):
"""Publish an existing HTML transcript directory as a GitHub Gist."""
output = Path(directory)
html_files = list(output.glob("*.html"))
if not html_files:
raise click.ClickException(f"No HTML files found in {output.resolve()}")

inject_gist_preview_js(output)
click.echo("Creating GitHub gist...")
gist_id, gist_url = create_gist(output, public=public)
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:
webbrowser.open(preview_url)


def main():
cli()
85 changes: 85 additions & 0 deletions tests/test_publish.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
"""Tests for the publish subcommand and gist preview JS idempotency."""

import tempfile
from pathlib import Path
from unittest.mock import patch

import pytest
from click.testing import CliRunner

from claude_code_transcripts import (
cli,
inject_gist_preview_js,
GIST_PREVIEW_JS,
)


@pytest.fixture
def output_dir():
"""Create a temporary output directory."""
with tempfile.TemporaryDirectory() as tmpdir:
yield Path(tmpdir)


class TestInjectGistPreviewJsIdempotency:
"""Tests for inject_gist_preview_js idempotency behaviour."""

def test_running_inject_twice_does_not_duplicate_script(self, output_dir):
"""Running inject twice does not duplicate the script."""
html = output_dir / "index.html"
html.write_text("<html><body></body></html>")
inject_gist_preview_js(output_dir)
inject_gist_preview_js(output_dir)
content = html.read_text()
assert content.count(GIST_PREVIEW_JS) == 1


class TestPublishCommand:
"""Tests for the publish CLI subcommand."""

def test_errors_when_directory_does_not_exist(self):
"""publish errors when directory does not exist."""
runner = CliRunner()
result = runner.invoke(cli, ["publish", "/nonexistent/path"])
assert result.exit_code != 0
assert "does not exist" in result.output or "Error" in result.output

def test_errors_when_no_html_files(self, output_dir):
"""publish errors when directory has no HTML files."""
runner = CliRunner()
result = runner.invoke(cli, ["publish", str(output_dir)])
assert result.exit_code != 0
assert "No HTML files" in result.output

def test_injects_js_and_creates_gist(self, output_dir):
"""publish injects JS and creates gist."""
runner = CliRunner()
(output_dir / "index.html").write_text("<html><body></body></html>")
(output_dir / "page-001.html").write_text("<html><body></body></html>")

with patch("claude_code_transcripts.subprocess.run") as mock_run:
mock_run.return_value.stdout = "https://gist.github.com/user/abc123\n"
mock_run.return_value.returncode = 0
result = runner.invoke(cli, ["publish", str(output_dir)])

assert result.exit_code == 0
assert "gisthost.github.io" in result.output
assert "abc123" in result.output

# Verify JS was injected
content = (output_dir / "index.html").read_text()
assert GIST_PREVIEW_JS in content

def test_open_flag_opens_browser(self, output_dir, mock_webbrowser_open):
"""publish --open opens the preview URL in a browser."""
runner = CliRunner()
(output_dir / "index.html").write_text("<html><body></body></html>")

with patch("claude_code_transcripts.subprocess.run") as mock_run:
mock_run.return_value.stdout = "https://gist.github.com/user/abc123\n"
mock_run.return_value.returncode = 0
result = runner.invoke(cli, ["publish", "--open", str(output_dir)])

assert result.exit_code == 0
assert len(mock_webbrowser_open) == 1
assert "gisthost.github.io" in mock_webbrowser_open[0]