Skip to content
Merged
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
71 changes: 63 additions & 8 deletions src/claude_code_transcripts/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -1452,8 +1452,47 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit
webbrowser.open(index_url)


def is_url(path):
"""Check if a path is a URL (starts with http:// or https://)."""
return path.startswith("http://") or path.startswith("https://")


def fetch_url_to_tempfile(url):
"""Fetch a URL and save to a temporary file.

Returns the Path to the temporary file.
Raises click.ClickException on network errors.
"""
try:
response = httpx.get(url, timeout=60.0, follow_redirects=True)
response.raise_for_status()
except httpx.RequestError as e:
raise click.ClickException(f"Failed to fetch URL: {e}")
except httpx.HTTPStatusError as e:
raise click.ClickException(
f"Failed to fetch URL: {e.response.status_code} {e.response.reason_phrase}"
)

# Determine file extension from URL
url_path = url.split("?")[0] # Remove query params
if url_path.endswith(".jsonl"):
suffix = ".jsonl"
elif url_path.endswith(".json"):
suffix = ".json"
else:
suffix = ".jsonl" # Default to JSONL

# Extract a name from the URL for the temp file
url_name = Path(url_path).stem or "session"

temp_dir = Path(tempfile.gettempdir())
temp_file = temp_dir / f"claude-url-{url_name}{suffix}"
temp_file.write_text(response.text, encoding="utf-8")
return temp_file


@cli.command("json")
@click.argument("json_file", type=click.Path(exists=True))
@click.argument("json_file", type=click.Path())
@click.option(
"-o",
"--output",
Expand Down Expand Up @@ -1488,29 +1527,45 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit
help="Open the generated index.html in your default browser (default if no -o specified).",
)
def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_browser):
"""Convert a Claude Code session JSON/JSONL file to HTML."""
"""Convert a Claude Code session JSON/JSONL file or URL to HTML."""
# Handle URL input
if is_url(json_file):
click.echo(f"Fetching {json_file}...")
temp_file = fetch_url_to_tempfile(json_file)
json_file_path = temp_file
# Use URL path for naming
url_name = Path(json_file.split("?")[0]).stem or "session"
else:
# Validate that local file exists
json_file_path = Path(json_file)
if not json_file_path.exists():
raise click.ClickException(f"File not found: {json_file}")
url_name = None

# Determine output directory and whether to open browser
# If no -o specified, use temp dir and open browser by default
auto_open = output is None and not gist and not output_auto
if output_auto:
# Use -o as parent dir (or current dir), with auto-named subdirectory
parent_dir = Path(output) if output else Path(".")
output = parent_dir / Path(json_file).stem
output = parent_dir / (url_name or json_file_path.stem)
elif output is None:
output = Path(tempfile.gettempdir()) / f"claude-session-{Path(json_file).stem}"
output = (
Path(tempfile.gettempdir())
/ f"claude-session-{url_name or json_file_path.stem}"
)

output = Path(output)
generate_html(json_file, output, github_repo=repo)
generate_html(json_file_path, output, github_repo=repo)

# Show output directory
click.echo(f"Output: {output.resolve()}")

# Copy JSON file to output directory if requested
if include_json:
output.mkdir(exist_ok=True)
json_source = Path(json_file)
json_dest = output / json_source.name
shutil.copy(json_file, json_dest)
json_dest = output / json_file_path.name
shutil.copy(json_file_path, json_dest)
json_size_kb = json_dest.stat().st_size / 1024
click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)")

Expand Down
117 changes: 117 additions & 0 deletions tests/test_all.py
Original file line number Diff line number Diff line change
Expand Up @@ -413,3 +413,120 @@ def test_all_quiet_with_dry_run(self, mock_projects_dir, output_dir):
assert "project-a" not in result.output
# Should not create any files
assert not (output_dir / "index.html").exists()


class TestJsonCommandWithUrl:
"""Tests for the json command with URL support."""

def test_json_command_accepts_url(self, output_dir):
"""Test that json command can accept a URL starting with http:// or https://."""
from unittest.mock import patch, MagicMock

# Sample JSONL content
jsonl_content = (
'{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello from URL"}}\n'
'{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi there!"}]}}\n'
)

# Mock the httpx.get response
mock_response = MagicMock()
mock_response.text = jsonl_content
mock_response.raise_for_status = MagicMock()

runner = CliRunner()
with patch(
"claude_code_transcripts.httpx.get", return_value=mock_response
) as mock_get:
result = runner.invoke(
cli,
[
"json",
"https://example.com/session.jsonl",
"-o",
str(output_dir),
],
)

# Check that the URL was fetched
mock_get.assert_called_once()
call_url = mock_get.call_args[0][0]
assert call_url == "https://example.com/session.jsonl"

# Check that HTML was generated
assert result.exit_code == 0
assert (output_dir / "index.html").exists()

def test_json_command_accepts_http_url(self, output_dir):
"""Test that json command can accept http:// URLs."""
from unittest.mock import patch, MagicMock

jsonl_content = '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n'

mock_response = MagicMock()
mock_response.text = jsonl_content
mock_response.raise_for_status = MagicMock()

runner = CliRunner()
with patch(
"claude_code_transcripts.httpx.get", return_value=mock_response
) as mock_get:
result = runner.invoke(
cli,
[
"json",
"http://example.com/session.jsonl",
"-o",
str(output_dir),
],
)

mock_get.assert_called_once()
assert result.exit_code == 0

def test_json_command_url_fetch_error(self, output_dir):
"""Test that json command handles URL fetch errors gracefully."""
from unittest.mock import patch
import httpx

runner = CliRunner()
with patch(
"claude_code_transcripts.httpx.get",
side_effect=httpx.RequestError("Network error"),
):
result = runner.invoke(
cli,
[
"json",
"https://example.com/session.jsonl",
"-o",
str(output_dir),
],
)

assert result.exit_code != 0
assert "error" in result.output.lower() or "Error" in result.output

def test_json_command_still_works_with_local_file(self, output_dir):
"""Test that json command still works with local file paths."""
# Create a temp JSONL file
jsonl_file = output_dir / "test.jsonl"
jsonl_file.write_text(
'{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello local"}}\n'
'{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi!"}]}}\n'
)

html_output = output_dir / "html_output"

runner = CliRunner()
result = runner.invoke(
cli,
[
"json",
str(jsonl_file),
"-o",
str(html_output),
],
)

assert result.exit_code == 0
assert (html_output / "index.html").exists()