diff --git a/README.md b/README.md index 294a639..ee27f13 100644 --- a/README.md +++ b/README.md @@ -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 +``` + +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`: diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index e4854a3..8d42f46 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -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 tag + if GIST_PREVIEW_JS in content: + continue if "" in content: content = content.replace( "", f"\n" @@ -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() diff --git a/tests/test_publish.py b/tests/test_publish.py new file mode 100644 index 0000000..2af1d13 --- /dev/null +++ b/tests/test_publish.py @@ -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("") + 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("") + (output_dir / "page-001.html").write_text("") + + 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("") + + 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]