Skip to content

Commit 6be0003

Browse files
claudesimonw
authored andcommitted
Add URL support to json command
The json command now accepts URLs (http:// or https://) in addition to local file paths. When a URL is provided, the content is fetched and processed as a session file.
1 parent b7669be commit 6be0003

File tree

2 files changed

+180
-8
lines changed

2 files changed

+180
-8
lines changed

src/claude_code_transcripts/__init__.py

Lines changed: 63 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -1452,8 +1452,47 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit
14521452
webbrowser.open(index_url)
14531453

14541454

1455+
def is_url(path):
1456+
"""Check if a path is a URL (starts with http:// or https://)."""
1457+
return path.startswith("http://") or path.startswith("https://")
1458+
1459+
1460+
def fetch_url_to_tempfile(url):
1461+
"""Fetch a URL and save to a temporary file.
1462+
1463+
Returns the Path to the temporary file.
1464+
Raises click.ClickException on network errors.
1465+
"""
1466+
try:
1467+
response = httpx.get(url, timeout=60.0, follow_redirects=True)
1468+
response.raise_for_status()
1469+
except httpx.RequestError as e:
1470+
raise click.ClickException(f"Failed to fetch URL: {e}")
1471+
except httpx.HTTPStatusError as e:
1472+
raise click.ClickException(
1473+
f"Failed to fetch URL: {e.response.status_code} {e.response.reason_phrase}"
1474+
)
1475+
1476+
# Determine file extension from URL
1477+
url_path = url.split("?")[0] # Remove query params
1478+
if url_path.endswith(".jsonl"):
1479+
suffix = ".jsonl"
1480+
elif url_path.endswith(".json"):
1481+
suffix = ".json"
1482+
else:
1483+
suffix = ".jsonl" # Default to JSONL
1484+
1485+
# Extract a name from the URL for the temp file
1486+
url_name = Path(url_path).stem or "session"
1487+
1488+
temp_dir = Path(tempfile.gettempdir())
1489+
temp_file = temp_dir / f"claude-url-{url_name}{suffix}"
1490+
temp_file.write_text(response.text, encoding="utf-8")
1491+
return temp_file
1492+
1493+
14551494
@cli.command("json")
1456-
@click.argument("json_file", type=click.Path(exists=True))
1495+
@click.argument("json_file", type=click.Path())
14571496
@click.option(
14581497
"-o",
14591498
"--output",
@@ -1488,29 +1527,45 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit
14881527
help="Open the generated index.html in your default browser (default if no -o specified).",
14891528
)
14901529
def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_browser):
1491-
"""Convert a Claude Code session JSON/JSONL file to HTML."""
1530+
"""Convert a Claude Code session JSON/JSONL file or URL to HTML."""
1531+
# Handle URL input
1532+
if is_url(json_file):
1533+
click.echo(f"Fetching {json_file}...")
1534+
temp_file = fetch_url_to_tempfile(json_file)
1535+
json_file_path = temp_file
1536+
# Use URL path for naming
1537+
url_name = Path(json_file.split("?")[0]).stem or "session"
1538+
else:
1539+
# Validate that local file exists
1540+
json_file_path = Path(json_file)
1541+
if not json_file_path.exists():
1542+
raise click.ClickException(f"File not found: {json_file}")
1543+
url_name = None
1544+
14921545
# Determine output directory and whether to open browser
14931546
# If no -o specified, use temp dir and open browser by default
14941547
auto_open = output is None and not gist and not output_auto
14951548
if output_auto:
14961549
# Use -o as parent dir (or current dir), with auto-named subdirectory
14971550
parent_dir = Path(output) if output else Path(".")
1498-
output = parent_dir / Path(json_file).stem
1551+
output = parent_dir / (url_name or json_file_path.stem)
14991552
elif output is None:
1500-
output = Path(tempfile.gettempdir()) / f"claude-session-{Path(json_file).stem}"
1553+
output = (
1554+
Path(tempfile.gettempdir())
1555+
/ f"claude-session-{url_name or json_file_path.stem}"
1556+
)
15011557

15021558
output = Path(output)
1503-
generate_html(json_file, output, github_repo=repo)
1559+
generate_html(json_file_path, output, github_repo=repo)
15041560

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

15081564
# Copy JSON file to output directory if requested
15091565
if include_json:
15101566
output.mkdir(exist_ok=True)
1511-
json_source = Path(json_file)
1512-
json_dest = output / json_source.name
1513-
shutil.copy(json_file, json_dest)
1567+
json_dest = output / json_file_path.name
1568+
shutil.copy(json_file_path, json_dest)
15141569
json_size_kb = json_dest.stat().st_size / 1024
15151570
click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)")
15161571

tests/test_all.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,3 +413,120 @@ def test_all_quiet_with_dry_run(self, mock_projects_dir, output_dir):
413413
assert "project-a" not in result.output
414414
# Should not create any files
415415
assert not (output_dir / "index.html").exists()
416+
417+
418+
class TestJsonCommandWithUrl:
419+
"""Tests for the json command with URL support."""
420+
421+
def test_json_command_accepts_url(self, output_dir):
422+
"""Test that json command can accept a URL starting with http:// or https://."""
423+
from unittest.mock import patch, MagicMock
424+
425+
# Sample JSONL content
426+
jsonl_content = (
427+
'{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello from URL"}}\n'
428+
'{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi there!"}]}}\n'
429+
)
430+
431+
# Mock the httpx.get response
432+
mock_response = MagicMock()
433+
mock_response.text = jsonl_content
434+
mock_response.raise_for_status = MagicMock()
435+
436+
runner = CliRunner()
437+
with patch(
438+
"claude_code_transcripts.httpx.get", return_value=mock_response
439+
) as mock_get:
440+
result = runner.invoke(
441+
cli,
442+
[
443+
"json",
444+
"https://example.com/session.jsonl",
445+
"-o",
446+
str(output_dir),
447+
],
448+
)
449+
450+
# Check that the URL was fetched
451+
mock_get.assert_called_once()
452+
call_url = mock_get.call_args[0][0]
453+
assert call_url == "https://example.com/session.jsonl"
454+
455+
# Check that HTML was generated
456+
assert result.exit_code == 0
457+
assert (output_dir / "index.html").exists()
458+
459+
def test_json_command_accepts_http_url(self, output_dir):
460+
"""Test that json command can accept http:// URLs."""
461+
from unittest.mock import patch, MagicMock
462+
463+
jsonl_content = '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello"}}\n'
464+
465+
mock_response = MagicMock()
466+
mock_response.text = jsonl_content
467+
mock_response.raise_for_status = MagicMock()
468+
469+
runner = CliRunner()
470+
with patch(
471+
"claude_code_transcripts.httpx.get", return_value=mock_response
472+
) as mock_get:
473+
result = runner.invoke(
474+
cli,
475+
[
476+
"json",
477+
"http://example.com/session.jsonl",
478+
"-o",
479+
str(output_dir),
480+
],
481+
)
482+
483+
mock_get.assert_called_once()
484+
assert result.exit_code == 0
485+
486+
def test_json_command_url_fetch_error(self, output_dir):
487+
"""Test that json command handles URL fetch errors gracefully."""
488+
from unittest.mock import patch
489+
import httpx
490+
491+
runner = CliRunner()
492+
with patch(
493+
"claude_code_transcripts.httpx.get",
494+
side_effect=httpx.RequestError("Network error"),
495+
):
496+
result = runner.invoke(
497+
cli,
498+
[
499+
"json",
500+
"https://example.com/session.jsonl",
501+
"-o",
502+
str(output_dir),
503+
],
504+
)
505+
506+
assert result.exit_code != 0
507+
assert "error" in result.output.lower() or "Error" in result.output
508+
509+
def test_json_command_still_works_with_local_file(self, output_dir):
510+
"""Test that json command still works with local file paths."""
511+
# Create a temp JSONL file
512+
jsonl_file = output_dir / "test.jsonl"
513+
jsonl_file.write_text(
514+
'{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello local"}}\n'
515+
'{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi!"}]}}\n'
516+
)
517+
518+
html_output = output_dir / "html_output"
519+
520+
runner = CliRunner()
521+
result = runner.invoke(
522+
cli,
523+
[
524+
"json",
525+
str(jsonl_file),
526+
"-o",
527+
str(html_output),
528+
],
529+
)
530+
531+
assert result.exit_code == 0
532+
assert (html_output / "index.html").exists()

0 commit comments

Comments
 (0)