From a24851dc480db0b0df62bfd0fb301f4a238cbc7e Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 29 Dec 2025 06:12:35 +0000 Subject: [PATCH] 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. --- src/claude_code_transcripts/__init__.py | 71 ++++++++++++-- tests/test_all.py | 117 ++++++++++++++++++++++++ 2 files changed, 180 insertions(+), 8 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 6318120..8729fad 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -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", @@ -1488,19 +1527,36 @@ 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()}") @@ -1508,9 +1564,8 @@ def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_brow # 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)") diff --git a/tests/test_all.py b/tests/test_all.py index 2a9c9e1..7e4e601 100644 --- a/tests/test_all.py +++ b/tests/test_all.py @@ -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()