From 3d3b42a108c3580751cd22ef72a46e8139f1a15d Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Dec 2025 14:45:24 +0000 Subject: [PATCH 1/3] Add support for local JSONL session format - Add parse_session_file() abstraction to handle both JSON and JSONL formats - Add get_session_summary() to extract summaries from session files - Add find_local_sessions() to discover JSONL files in ~/.claude/projects - Add list-local command to show local sessions - Change default behavior: running with no args now lists local sessions - Add comprehensive tests for all new functionality - Include sample JSONL test fixture and snapshot tests --- src/claude_code_publish/__init__.py | 209 ++++++++++++++- ...SessionFile.test_jsonl_generates_html.html | 175 ++++++++++++ tests/sample_session.jsonl | 8 + tests/test_generate_html.py | 251 ++++++++++++++++++ 4 files changed, 639 insertions(+), 4 deletions(-) create mode 100644 tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html create mode 100644 tests/sample_session.jsonl diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index 62aecc6..55e1bb6 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -38,6 +38,157 @@ ANTHROPIC_VERSION = "2023-06-01" +def get_session_summary(filepath, max_length=200): + """Extract a human-readable summary from a session file. + + Supports both JSON and JSONL formats. + Returns a summary string or "(no summary)" if none found. + """ + filepath = Path(filepath) + try: + if filepath.suffix == ".jsonl": + return _get_jsonl_summary(filepath, max_length) + else: + # For JSON files, try to get first user message + with open(filepath, "r", encoding="utf-8") as f: + data = json.load(f) + loglines = data.get("loglines", []) + for entry in loglines: + if entry.get("type") == "user": + msg = entry.get("message", {}) + content = msg.get("content", "") + if isinstance(content, str) and content.strip(): + if len(content) > max_length: + return content[: max_length - 3] + "..." + return content + return "(no summary)" + except Exception: + return "(no summary)" + + +def _get_jsonl_summary(filepath, max_length=200): + """Extract summary from JSONL file.""" + try: + with open(filepath, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + # First priority: summary type entries + if obj.get("type") == "summary" and obj.get("summary"): + summary = obj["summary"] + if len(summary) > max_length: + return summary[: max_length - 3] + "..." + return summary + except json.JSONDecodeError: + continue + + # Second pass: find first non-meta user message + with open(filepath, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + if ( + obj.get("type") == "user" + and not obj.get("isMeta") + and obj.get("message", {}).get("content") + ): + content = obj["message"]["content"] + if isinstance(content, str): + content = content.strip() + if content and not content.startswith("<"): + if len(content) > max_length: + return content[: max_length - 3] + "..." + return content + except json.JSONDecodeError: + continue + except Exception: + pass + + return "(no summary)" + + +def find_local_sessions(folder, limit=10): + """Find recent JSONL session files in the given folder. + + Returns a list of (Path, summary) tuples sorted by modification time. + Excludes agent files and warmup/empty sessions. + """ + folder = Path(folder) + if not folder.exists(): + return [] + + results = [] + for f in folder.glob("**/*.jsonl"): + if f.name.startswith("agent-"): + continue + summary = get_session_summary(f) + # Skip boring/empty sessions + if summary.lower() == "warmup" or summary == "(no summary)": + continue + results.append((f, summary)) + + # Sort by modification time, most recent first + results.sort(key=lambda x: x[0].stat().st_mtime, reverse=True) + return results[:limit] + + +def parse_session_file(filepath): + """Parse a session file and return normalized data. + + Supports both JSON and JSONL formats. + Returns a dict with 'loglines' key containing the normalized entries. + """ + filepath = Path(filepath) + + if filepath.suffix == ".jsonl": + return _parse_jsonl_file(filepath) + else: + # Standard JSON format + with open(filepath, "r", encoding="utf-8") as f: + return json.load(f) + + +def _parse_jsonl_file(filepath): + """Parse JSONL file and convert to standard format.""" + loglines = [] + + with open(filepath, "r", encoding="utf-8") as f: + for line in f: + line = line.strip() + if not line: + continue + try: + obj = json.loads(line) + entry_type = obj.get("type") + + # Skip non-message entries + if entry_type not in ("user", "assistant"): + continue + + # Convert to standard format + entry = { + "type": entry_type, + "timestamp": obj.get("timestamp", ""), + "message": obj.get("message", {}), + } + + # Preserve isCompactSummary if present + if obj.get("isCompactSummary"): + entry["isCompactSummary"] = True + + loglines.append(entry) + except json.JSONDecodeError: + continue + + return {"loglines": loglines} + + class CredentialsError(Exception): """Raised when credentials cannot be obtained.""" @@ -730,9 +881,8 @@ def generate_html(json_path, output_dir, github_repo=None): output_dir = Path(output_dir) output_dir.mkdir(exist_ok=True) - # Load JSON file - with open(json_path, "r") as f: - data = json.load(f) + # Load session file (supports both JSON and JSONL) + data = parse_session_file(json_path) loglines = data.get("loglines", []) @@ -920,13 +1070,64 @@ def generate_html(json_path, output_dir, github_repo=None): ) -@click.group(cls=DefaultGroup, default="session", default_if_no_args=False) +@click.group(cls=DefaultGroup, default="list-local", default_if_no_args=True) @click.version_option(None, "-v", "--version", package_name="claude-code-publish") def cli(): """Convert Claude Code session JSON to mobile-friendly HTML pages.""" pass +@cli.command("list-local") +@click.option( + "--limit", + default=10, + help="Maximum number of sessions to show (default: 10)", +) +def list_local(limit): + """List available local Claude Code sessions.""" + projects_folder = Path.home() / ".claude" / "projects" + + if not projects_folder.exists(): + click.echo(f"Projects folder not found: {projects_folder}") + click.echo("No local Claude Code sessions available.") + return + + click.echo("Loading local sessions...") + results = find_local_sessions(projects_folder, limit=limit) + + if not results: + click.echo("No local sessions found.") + return + + # Calculate terminal width for formatting + try: + term_width = shutil.get_terminal_size().columns + except Exception: + term_width = 80 + + # Fixed width: date(16) + spaces(2) + size(8) + spaces(2) = 28 + fixed_width = 28 + summary_width = max(20, term_width - fixed_width - 1) + + click.echo("") + click.echo("Recent local sessions:") + click.echo("") + + from datetime import datetime + + for filepath, summary in results: + stat = filepath.stat() + mod_time = datetime.fromtimestamp(stat.st_mtime) + size_kb = stat.st_size / 1024 + date_str = mod_time.strftime("%Y-%m-%d %H:%M") + + # Truncate summary if needed + if len(summary) > summary_width: + summary = summary[: summary_width - 3] + "..." + + click.echo(f"{date_str} {size_kb:6.0f} KB {summary}") + + @cli.command() @click.argument("json_file", type=click.Path(exists=True)) @click.option( diff --git a/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html new file mode 100644 index 0000000..6483aef --- /dev/null +++ b/tests/__snapshots__/test_generate_html/TestParseSessionFile.test_jsonl_generates_html.html @@ -0,0 +1,175 @@ + + + + + + Claude Code transcript - Index + + + +
+

Claude Code transcript

+ +

2 prompts · 7 messages · 2 tool calls · 1 commits · 1 pages

+
abc1234
Add hello function
+ +
+ + + \ No newline at end of file diff --git a/tests/sample_session.jsonl b/tests/sample_session.jsonl new file mode 100644 index 0000000..8d4070e --- /dev/null +++ b/tests/sample_session.jsonl @@ -0,0 +1,8 @@ +{"type":"summary","summary":"Test session for JSONL parsing","leafUuid":"test-leaf-uuid"} +{"type":"user","timestamp":"2025-12-24T10:00:00.000Z","sessionId":"test-session-id","cwd":"/project","gitBranch":"main","message":{"role":"user","content":"Create a hello world function"},"uuid":"msg-001"} +{"type":"assistant","timestamp":"2025-12-24T10:00:05.000Z","sessionId":"test-session-id","message":{"role":"assistant","content":[{"type":"text","text":"I'll create that function for you."},{"type":"tool_use","id":"toolu_001","name":"Write","input":{"file_path":"/project/hello.py","content":"def hello():\n return 'Hello, World!'\n"}}]},"uuid":"msg-002"} +{"type":"user","timestamp":"2025-12-24T10:00:10.000Z","sessionId":"test-session-id","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_001","content":"File written successfully"}]},"uuid":"msg-003"} +{"type":"assistant","timestamp":"2025-12-24T10:00:15.000Z","sessionId":"test-session-id","message":{"role":"assistant","content":[{"type":"tool_use","id":"toolu_002","name":"Bash","input":{"command":"git add . && git commit -m 'Add hello function'","description":"Commit changes"}}]},"uuid":"msg-004"} +{"type":"user","timestamp":"2025-12-24T10:00:20.000Z","sessionId":"test-session-id","message":{"role":"user","content":[{"type":"tool_result","tool_use_id":"toolu_002","content":"[main abc1234] Add hello function\n 1 file changed"}]},"uuid":"msg-005"} +{"type":"user","timestamp":"2025-12-24T10:01:00.000Z","sessionId":"test-session-id","message":{"role":"user","content":"Now add a goodbye function"},"uuid":"msg-006"} +{"type":"assistant","timestamp":"2025-12-24T10:01:05.000Z","sessionId":"test-session-id","message":{"role":"assistant","content":[{"type":"text","text":"Done! The hello function is ready."}]},"uuid":"msg-007"} diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index cb0b962..2773f51 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -24,6 +24,9 @@ inject_gist_preview_js, create_gist, GIST_PREVIEW_JS, + parse_session_file, + get_session_summary, + find_local_sessions, ) @@ -975,3 +978,251 @@ def mock_open(url): assert len(opened_urls) == 1 assert "index.html" in opened_urls[0] assert opened_urls[0].startswith("file://") + + +class TestParseSessionFile: + """Tests for parse_session_file which abstracts both JSON and JSONL formats.""" + + def test_parses_json_format(self): + """Test that standard JSON format is parsed correctly.""" + fixture_path = Path(__file__).parent / "sample_session.json" + result = parse_session_file(fixture_path) + + assert "loglines" in result + assert len(result["loglines"]) > 0 + # Check first entry + first = result["loglines"][0] + assert first["type"] == "user" + assert "timestamp" in first + assert "message" in first + + def test_parses_jsonl_format(self): + """Test that JSONL format is parsed and converted to standard format.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result = parse_session_file(fixture_path) + + assert "loglines" in result + assert len(result["loglines"]) > 0 + # Check structure matches JSON format + for entry in result["loglines"]: + assert "type" in entry + # Skip summary entries which don't have message + if entry["type"] in ("user", "assistant"): + assert "timestamp" in entry + assert "message" in entry + + def test_jsonl_skips_non_message_entries(self): + """Test that summary and file-history-snapshot entries are skipped.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result = parse_session_file(fixture_path) + + # None of the loglines should be summary or file-history-snapshot + for entry in result["loglines"]: + assert entry["type"] in ("user", "assistant") + + def test_jsonl_preserves_message_content(self): + """Test that message content is preserved correctly.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + result = parse_session_file(fixture_path) + + # Find the first user message + user_msg = next(e for e in result["loglines"] if e["type"] == "user") + assert user_msg["message"]["content"] == "Create a hello world function" + + def test_jsonl_generates_html(self, output_dir, snapshot_html): + """Test that JSONL files can be converted to HTML.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + generate_html(fixture_path, output_dir) + + index_html = (output_dir / "index.html").read_text() + assert "hello world" in index_html.lower() + assert index_html == snapshot_html + + +class TestGetSessionSummary: + """Tests for get_session_summary which extracts summary from session files.""" + + def test_gets_summary_from_jsonl(self): + """Test extracting summary from JSONL file.""" + fixture_path = Path(__file__).parent / "sample_session.jsonl" + summary = get_session_summary(fixture_path) + assert summary == "Test session for JSONL parsing" + + def test_gets_first_user_message_if_no_summary(self, tmp_path): + """Test falling back to first user message when no summary entry.""" + jsonl_file = tmp_path / "test.jsonl" + jsonl_file.write_text( + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello world test"}}\n' + ) + summary = get_session_summary(jsonl_file) + assert summary == "Hello world test" + + def test_returns_no_summary_for_empty_file(self, tmp_path): + """Test handling empty or invalid files.""" + jsonl_file = tmp_path / "empty.jsonl" + jsonl_file.write_text("") + summary = get_session_summary(jsonl_file) + assert summary == "(no summary)" + + def test_truncates_long_summaries(self, tmp_path): + """Test that long summaries are truncated.""" + jsonl_file = tmp_path / "long.jsonl" + long_text = "x" * 300 + jsonl_file.write_text(f'{{"type":"summary","summary":"{long_text}"}}\n') + summary = get_session_summary(jsonl_file, max_length=100) + assert len(summary) <= 100 + assert summary.endswith("...") + + +class TestFindLocalSessions: + """Tests for find_local_sessions which discovers local JSONL files.""" + + def test_finds_jsonl_files(self, tmp_path): + """Test finding JSONL files in projects directory.""" + # Create mock .claude/projects structure + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + # Create a session file + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Test session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + results = find_local_sessions(tmp_path / ".claude" / "projects", limit=10) + assert len(results) == 1 + assert results[0][0] == session_file + assert results[0][1] == "Test session" + + def test_excludes_agent_files(self, tmp_path): + """Test that agent- prefixed files are excluded.""" + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + # Create agent file (should be excluded) + agent_file = projects_dir / "agent-123.jsonl" + agent_file.write_text('{"type":"user","message":{"content":"test"}}\n') + + # Create regular file (should be included) + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Real session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + results = find_local_sessions(tmp_path / ".claude" / "projects", limit=10) + assert len(results) == 1 + assert "agent-" not in results[0][0].name + + def test_excludes_warmup_sessions(self, tmp_path): + """Test that warmup sessions are excluded.""" + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + # Create warmup file (should be excluded) + warmup_file = projects_dir / "warmup-session.jsonl" + warmup_file.write_text('{"type":"summary","summary":"warmup"}\n') + + # Create regular file + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Real session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + results = find_local_sessions(tmp_path / ".claude" / "projects", limit=10) + assert len(results) == 1 + assert results[0][1] == "Real session" + + def test_sorts_by_modification_time(self, tmp_path): + """Test that results are sorted by modification time, newest first.""" + import time + + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + # Create files with different mtimes + file1 = projects_dir / "older.jsonl" + file1.write_text( + '{"type":"summary","summary":"Older"}\n{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"test"}}\n' + ) + + time.sleep(0.1) # Ensure different mtime + + file2 = projects_dir / "newer.jsonl" + file2.write_text( + '{"type":"summary","summary":"Newer"}\n{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"test"}}\n' + ) + + results = find_local_sessions(tmp_path / ".claude" / "projects", limit=10) + assert len(results) == 2 + assert results[0][1] == "Newer" # Most recent first + assert results[1][1] == "Older" + + def test_respects_limit(self, tmp_path): + """Test that limit parameter is respected.""" + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + # Create 5 files + for i in range(5): + f = projects_dir / f"session-{i}.jsonl" + f.write_text( + f'{{"type":"summary","summary":"Session {i}"}}\n{{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{{"role":"user","content":"test"}}}}\n' + ) + + results = find_local_sessions(tmp_path / ".claude" / "projects", limit=3) + assert len(results) == 3 + + +class TestLocalSessionCLI: + """Tests for CLI behavior with local sessions.""" + + def test_list_local_shows_sessions(self, tmp_path, monkeypatch): + """Test that 'list-local' command shows local sessions.""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Create mock .claude/projects structure + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Test local session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + # Mock Path.home() to return our tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + runner = CliRunner() + result = runner.invoke(cli, ["list-local"]) + + assert result.exit_code == 0 + assert "Test local session" in result.output + + def test_no_args_lists_local_sessions(self, tmp_path, monkeypatch): + """Test that running with no arguments lists local sessions.""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Create mock .claude/projects structure + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Test default session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + # Mock Path.home() to return our tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + runner = CliRunner() + result = runner.invoke(cli, []) + + assert result.exit_code == 0 + assert "Test default session" in result.output From c4e9b0c77a23f9e39c78605ccd22a375741a70db Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Dec 2025 17:19:45 +0000 Subject: [PATCH 2/3] Restructure CLI commands per feedback - Rename 'session' command to 'json' for direct file path access - Rename 'import' command to 'web' for web session selection - Update 'list-local' to 'local' with interactive picker and conversion - Remove 'list-web' command (now integrated into 'web' command) - Make 'local' the default command when run with no arguments New CLI structure: - claude-code-publish local - select from local JSONL sessions - claude-code-publish web - select from web sessions - claude-code-publish json - provide direct file path All commands support --gist, --json, --open, and -o options. --- src/claude_code_publish/__init__.py | 150 +++++++++++++---------- tests/test_generate_html.py | 180 +++++++++++----------------- 2 files changed, 159 insertions(+), 171 deletions(-) diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index 55e1bb6..85f0511 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -1070,21 +1070,50 @@ def generate_html(json_path, output_dir, github_repo=None): ) -@click.group(cls=DefaultGroup, default="list-local", default_if_no_args=True) +@click.group(cls=DefaultGroup, default="local", default_if_no_args=True) @click.version_option(None, "-v", "--version", package_name="claude-code-publish") def cli(): """Convert Claude Code session JSON to mobile-friendly HTML pages.""" pass -@cli.command("list-local") +@cli.command("local") +@click.option( + "-o", + "--output", + type=click.Path(), + help="Output directory (default: temp dir, or '.' with -o .)", +) +@click.option( + "--repo", + help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified.", +) +@click.option( + "--gist", + is_flag=True, + help="Upload to GitHub Gist and output a gistpreview.github.io URL.", +) +@click.option( + "--json", + "include_json", + is_flag=True, + help="Include the original JSONL session file in the output directory.", +) +@click.option( + "--open", + "open_browser", + is_flag=True, + help="Open the generated index.html in your default browser.", +) @click.option( "--limit", default=10, help="Maximum number of sessions to show (default: 10)", ) -def list_local(limit): - """List available local Claude Code sessions.""" +def local_cmd(output, repo, gist, include_json, open_browser, limit): + """Select and convert a local Claude Code session to HTML.""" + from datetime import datetime + projects_folder = Path.home() / ".claude" / "projects" if not projects_folder.exists(): @@ -1099,36 +1128,63 @@ def list_local(limit): click.echo("No local sessions found.") return - # Calculate terminal width for formatting - try: - term_width = shutil.get_terminal_size().columns - except Exception: - term_width = 80 - - # Fixed width: date(16) + spaces(2) + size(8) + spaces(2) = 28 - fixed_width = 28 - summary_width = max(20, term_width - fixed_width - 1) - - click.echo("") - click.echo("Recent local sessions:") - click.echo("") - - from datetime import datetime - + # Build choices for questionary + choices = [] for filepath, summary in results: stat = filepath.stat() mod_time = datetime.fromtimestamp(stat.st_mtime) size_kb = stat.st_size / 1024 date_str = mod_time.strftime("%Y-%m-%d %H:%M") + # Truncate summary if too long + if len(summary) > 50: + summary = summary[:47] + "..." + display = f"{date_str} {size_kb:5.0f} KB {summary}" + choices.append(questionary.Choice(title=display, value=filepath)) + + selected = questionary.select( + "Select a session to convert:", + choices=choices, + ).ask() + + if selected is None: + click.echo("No session selected.") + return + + session_file = selected + + # Determine output directory + if (gist or open_browser) and output is None: + output = Path(tempfile.gettempdir()) / session_file.stem + elif output is None: + output = Path(tempfile.gettempdir()) / session_file.stem - # Truncate summary if needed - if len(summary) > summary_width: - summary = summary[: summary_width - 3] + "..." + output = Path(output) + generate_html(session_file, output, github_repo=repo) + + # Copy JSONL file to output directory if requested + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / session_file.name + shutil.copy(session_file, json_dest) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSONL: {json_dest} ({json_size_kb:.1f} KB)") + + if gist: + # Inject gist preview JS and create gist + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + click.echo(f"Files: {output}") - click.echo(f"{date_str} {size_kb:6.0f} KB {summary}") + if open_browser: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) -@cli.command() +@cli.command("json") @click.argument("json_file", type=click.Path(exists=True)) @click.option( "-o", @@ -1157,8 +1213,8 @@ def list_local(limit): is_flag=True, help="Open the generated index.html in your default browser.", ) -def session(json_file, output, repo, gist, include_json, open_browser): - """Convert a Claude Code session JSON file to HTML.""" +def json_cmd(json_file, output, repo, gist, include_json, open_browser): + """Convert a Claude Code session JSON/JSONL file to HTML.""" # Determine output directory if (gist or open_browser) and output is None: # Extract session ID from JSON file for temp directory name @@ -1242,40 +1298,6 @@ def format_session_for_display(session_data): return f"{session_id} {created_at[:19] if created_at else 'N/A':19} {title}" -@cli.command("list-web") -@click.option("--token", help="API access token (auto-detected from keychain on macOS)") -@click.option( - "--org-uuid", help="Organization UUID (auto-detected from ~/.claude.json)" -) -def list_web(token, org_uuid): - """List available sessions from the Claude API.""" - try: - token, org_uuid = resolve_credentials(token, org_uuid) - except click.ClickException: - raise - - try: - sessions_data = fetch_sessions(token, org_uuid) - except httpx.HTTPStatusError as e: - raise click.ClickException( - f"API request failed: {e.response.status_code} {e.response.text}" - ) - except httpx.RequestError as e: - raise click.ClickException(f"Network error: {e}") - - sessions = sessions_data.get("data", []) - if not sessions: - click.echo("No sessions found.") - return - - # Print header - click.echo(f"{'Session ID':<35} {'Created':<19} Name") - click.echo("-" * 80) - - for session_data in sessions: - click.echo(format_session_for_display(session_data)) - - def generate_html_from_session_data(session_data, output_dir, github_repo=None): """Generate HTML from session data dict (instead of file path).""" output_dir = Path(output_dir) @@ -1463,7 +1485,7 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): ) -@cli.command("import") +@cli.command("web") @click.argument("session_id", required=False) @click.option( "-o", @@ -1496,10 +1518,10 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): is_flag=True, help="Open the generated index.html in your default browser.", ) -def import_session( +def web_cmd( session_id, output, token, org_uuid, repo, gist, include_json, open_browser ): - """Import a session from the Claude API and convert to HTML. + """Select and convert a web session from the Claude API to HTML. If SESSION_ID is not provided, displays an interactive picker to select a session. """ diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 2773f51..46ff34a 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -339,98 +339,6 @@ def test_rejects_empty(self): assert is_tool_result_message({"content": "string"}) is False -class TestListWebCommand: - """Tests for the list-web command.""" - - def test_list_web_displays_sessions(self, httpx_mock): - """Test that list-web displays sessions from the API.""" - from click.testing import CliRunner - from claude_code_publish import cli - - # Mock the API response with realistic data - mock_response = { - "data": [ - { - "id": "session_01ABC123", - "title": "Build a CLI tool", - "created_at": "2025-12-24T10:30:00Z", - "updated_at": "2025-12-24T11:00:00Z", - "type": "web", - "session_status": "completed", - "environment_id": "env_123", - "session_context": {}, - }, - { - "id": "session_02DEF456", - "title": "Fix authentication bug", - "created_at": "2025-12-23T14:00:00Z", - "updated_at": "2025-12-23T15:30:00Z", - "type": "web", - "session_status": "completed", - "environment_id": "env_123", - "session_context": {}, - }, - ], - "has_more": False, - "first_id": "session_01ABC123", - "last_id": "session_02DEF456", - } - - httpx_mock.add_response( - url="https://api.anthropic.com/v1/sessions", - json=mock_response, - ) - - runner = CliRunner() - result = runner.invoke( - cli, - ["list-web", "--token", "test-token", "--org-uuid", "test-org-uuid"], - ) - - assert result.exit_code == 0 - assert "session_01ABC123" in result.output - assert "session_02DEF456" in result.output - assert "Build a CLI tool" in result.output - assert "Fix authentication bug" in result.output - assert "2025-12-24T10:30:00" in result.output - - def test_list_web_no_sessions(self, httpx_mock): - """Test list-web when no sessions are found.""" - from click.testing import CliRunner - from claude_code_publish import cli - - httpx_mock.add_response( - url="https://api.anthropic.com/v1/sessions", - json={"data": [], "has_more": False}, - ) - - runner = CliRunner() - result = runner.invoke( - cli, - ["list-web", "--token", "test-token", "--org-uuid", "test-org-uuid"], - ) - - assert result.exit_code == 0 - assert "No sessions found" in result.output - - def test_list_web_requires_token_on_non_macos(self, monkeypatch): - """Test that list-web requires --token on non-macOS platforms.""" - from click.testing import CliRunner - from claude_code_publish import cli - - # Pretend we're on Linux - monkeypatch.setattr("claude_code_publish.platform.system", lambda: "Linux") - - runner = CliRunner() - result = runner.invoke( - cli, - ["list-web", "--org-uuid", "test-org-uuid"], - ) - - assert result.exit_code != 0 - assert "must provide --token" in result.output - - class TestInjectGistPreviewJs: """Tests for the inject_gist_preview_js function.""" @@ -587,7 +495,7 @@ def mock_run(*args, **kwargs): runner = CliRunner() result = runner.invoke( cli, - ["session", str(fixture_path), "--gist"], + ["json", str(fixture_path), "--gist"], ) assert result.exit_code == 0 @@ -619,7 +527,7 @@ def mock_run(*args, **kwargs): runner = CliRunner() result = runner.invoke( cli, - ["session", str(fixture_path), "-o", str(output_dir), "--gist"], + ["json", str(fixture_path), "-o", str(output_dir), "--gist"], ) assert result.exit_code == 0 @@ -743,7 +651,7 @@ def test_session_json_copies_file(self, output_dir): runner = CliRunner() result = runner.invoke( cli, - ["session", str(fixture_path), "-o", str(output_dir), "--json"], + ["json", str(fixture_path), "-o", str(output_dir), "--json"], ) assert result.exit_code == 0 @@ -762,7 +670,7 @@ def test_session_json_preserves_original_name(self, output_dir): runner = CliRunner() result = runner.invoke( cli, - ["session", str(fixture_path), "-o", str(output_dir), "--json"], + ["json", str(fixture_path), "-o", str(output_dir), "--json"], ) assert result.exit_code == 0 @@ -793,7 +701,7 @@ def test_import_json_saves_session_data(self, httpx_mock, output_dir): result = runner.invoke( cli, [ - "import", + "web", "test-session-id", "--token", "test-token", @@ -858,7 +766,7 @@ def mock_run(*args, **kwargs): result = runner.invoke( cli, [ - "import", + "web", "test-session-id", "--token", "test-token", @@ -926,7 +834,7 @@ def mock_open(url): runner = CliRunner() result = runner.invoke( cli, - ["session", str(fixture_path), "-o", str(output_dir), "--open"], + ["json", str(fixture_path), "-o", str(output_dir), "--open"], ) assert result.exit_code == 0 @@ -962,7 +870,7 @@ def mock_open(url): result = runner.invoke( cli, [ - "import", + "web", "test-session-id", "--token", "test-token", @@ -1179,10 +1087,11 @@ def test_respects_limit(self, tmp_path): class TestLocalSessionCLI: """Tests for CLI behavior with local sessions.""" - def test_list_local_shows_sessions(self, tmp_path, monkeypatch): - """Test that 'list-local' command shows local sessions.""" + def test_local_shows_sessions_and_converts(self, tmp_path, monkeypatch): + """Test that 'local' command shows sessions and converts selected one.""" from click.testing import CliRunner from claude_code_publish import cli + import questionary # Create mock .claude/projects structure projects_dir = tmp_path / ".claude" / "projects" / "test-project" @@ -1197,16 +1106,28 @@ def test_list_local_shows_sessions(self, tmp_path, monkeypatch): # Mock Path.home() to return our tmp_path monkeypatch.setattr(Path, "home", lambda: tmp_path) + # Mock questionary.select to return the session file + class MockSelect: + def __init__(self, *args, **kwargs): + pass + + def ask(self): + return session_file + + monkeypatch.setattr(questionary, "select", MockSelect) + runner = CliRunner() - result = runner.invoke(cli, ["list-local"]) + result = runner.invoke(cli, ["local"]) assert result.exit_code == 0 - assert "Test local session" in result.output + assert "Loading local sessions" in result.output + assert "Generated" in result.output - def test_no_args_lists_local_sessions(self, tmp_path, monkeypatch): - """Test that running with no arguments lists local sessions.""" + def test_no_args_runs_local_command(self, tmp_path, monkeypatch): + """Test that running with no arguments runs local command.""" from click.testing import CliRunner from claude_code_publish import cli + import questionary # Create mock .claude/projects structure projects_dir = tmp_path / ".claude" / "projects" / "test-project" @@ -1221,8 +1142,53 @@ def test_no_args_lists_local_sessions(self, tmp_path, monkeypatch): # Mock Path.home() to return our tmp_path monkeypatch.setattr(Path, "home", lambda: tmp_path) + # Mock questionary.select to return the session file + class MockSelect: + def __init__(self, *args, **kwargs): + pass + + def ask(self): + return session_file + + monkeypatch.setattr(questionary, "select", MockSelect) + runner = CliRunner() result = runner.invoke(cli, []) assert result.exit_code == 0 - assert "Test default session" in result.output + assert "Loading local sessions" in result.output + + def test_local_handles_cancelled_selection(self, tmp_path, monkeypatch): + """Test that local command handles cancelled selection gracefully.""" + from click.testing import CliRunner + from claude_code_publish import cli + import questionary + + # Create mock .claude/projects structure + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + session_file = projects_dir / "session-123.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Test session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + # Mock Path.home() to return our tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Mock questionary.select to return None (cancelled) + class MockSelect: + def __init__(self, *args, **kwargs): + pass + + def ask(self): + return None + + monkeypatch.setattr(questionary, "select", MockSelect) + + runner = CliRunner() + result = runner.invoke(cli, ["local"]) + + assert result.exit_code == 0 + assert "No session selected" in result.output From 6c518ec03d3fd80761cc6c82eb748dd4dfd1c5e4 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 25 Dec 2025 17:29:16 +0000 Subject: [PATCH 3/3] Add -a/--output-auto flag to all commands This flag creates output in a subdirectory named after: - The session ID for web command - The file stem for json and local commands Uses -o as parent directory if specified, otherwise current directory. When -a is used, auto-open browser is disabled. --- src/claude_code_publish/__init__.py | 102 ++++++++++++----- tests/test_generate_html.py | 171 ++++++++++++++++++++++++++++ 2 files changed, 242 insertions(+), 31 deletions(-) diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py index 85f0511..9ff5092 100644 --- a/src/claude_code_publish/__init__.py +++ b/src/claude_code_publish/__init__.py @@ -1082,7 +1082,13 @@ def cli(): "-o", "--output", type=click.Path(), - help="Output directory (default: temp dir, or '.' with -o .)", + help="Output directory. If not specified, writes to temp dir and opens in browser.", +) +@click.option( + "-a", + "--output-auto", + is_flag=True, + help="Auto-name output subdirectory based on session filename (uses -o as parent, or current dir).", ) @click.option( "--repo", @@ -1103,14 +1109,14 @@ def cli(): "--open", "open_browser", is_flag=True, - help="Open the generated index.html in your default browser.", + help="Open the generated index.html in your default browser (default if no -o specified).", ) @click.option( "--limit", default=10, help="Maximum number of sessions to show (default: 10)", ) -def local_cmd(output, repo, gist, include_json, open_browser, limit): +def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit): """Select and convert a local Claude Code session to HTML.""" from datetime import datetime @@ -1152,15 +1158,22 @@ def local_cmd(output, repo, gist, include_json, open_browser, limit): session_file = selected - # Determine output directory - if (gist or open_browser) and output is None: - output = Path(tempfile.gettempdir()) / session_file.stem + # 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 / session_file.stem elif output is None: - output = Path(tempfile.gettempdir()) / session_file.stem + output = Path(tempfile.gettempdir()) / f"claude-session-{session_file.stem}" output = Path(output) generate_html(session_file, output, github_repo=repo) + # Show output directory + click.echo(f"Output: {output.resolve()}") + # Copy JSONL file to output directory if requested if include_json: output.mkdir(exist_ok=True) @@ -1177,9 +1190,8 @@ def local_cmd(output, repo, gist, include_json, open_browser, limit): preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") - click.echo(f"Files: {output}") - if open_browser: + if open_browser or auto_open: index_url = (output / "index.html").resolve().as_uri() webbrowser.open(index_url) @@ -1190,7 +1202,13 @@ def local_cmd(output, repo, gist, include_json, open_browser, limit): "-o", "--output", type=click.Path(), - help="Output directory (default: current directory, or temp dir with --gist/--open)", + help="Output directory. If not specified, writes to temp dir and opens in browser.", +) +@click.option( + "-a", + "--output-auto", + is_flag=True, + help="Auto-name output subdirectory based on filename (uses -o as parent, or current dir).", ) @click.option( "--repo", @@ -1211,23 +1229,26 @@ def local_cmd(output, repo, gist, include_json, open_browser, limit): "--open", "open_browser", is_flag=True, - help="Open the generated index.html in your default browser.", + help="Open the generated index.html in your default browser (default if no -o specified).", ) -def json_cmd(json_file, output, repo, gist, include_json, open_browser): +def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_browser): """Convert a Claude Code session JSON/JSONL file to HTML.""" - # Determine output directory - if (gist or open_browser) and output is None: - # Extract session ID from JSON file for temp directory name - with open(json_file, "r") as f: - data = json.load(f) - session_id = data.get("sessionId", Path(json_file).stem) - output = Path(tempfile.gettempdir()) / session_id + # 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 elif output is None: - output = "." + output = Path(tempfile.gettempdir()) / f"claude-session-{Path(json_file).stem}" output = Path(output) generate_html(json_file, 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) @@ -1245,9 +1266,8 @@ def json_cmd(json_file, output, repo, gist, include_json, open_browser): preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") - click.echo(f"Files: {output}") - if open_browser: + if open_browser or auto_open: index_url = (output / "index.html").resolve().as_uri() webbrowser.open(index_url) @@ -1491,7 +1511,13 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): "-o", "--output", type=click.Path(), - help="Output directory (default: creates folder with session ID, or temp dir with --gist/--open)", + help="Output directory. If not specified, writes to temp dir and opens in browser.", +) +@click.option( + "-a", + "--output-auto", + is_flag=True, + help="Auto-name output subdirectory based on session ID (uses -o as parent, or current dir).", ) @click.option("--token", help="API access token (auto-detected from keychain on macOS)") @click.option( @@ -1516,10 +1542,18 @@ def generate_html_from_session_data(session_data, output_dir, github_repo=None): "--open", "open_browser", is_flag=True, - help="Open the generated index.html in your default browser.", + help="Open the generated index.html in your default browser (default if no -o specified).", ) def web_cmd( - session_id, output, token, org_uuid, repo, gist, include_json, open_browser + session_id, + output, + output_auto, + token, + org_uuid, + repo, + gist, + include_json, + open_browser, ): """Select and convert a web session from the Claude API to HTML. @@ -1579,16 +1613,23 @@ def web_cmd( except httpx.RequestError as e: raise click.ClickException(f"Network error: {e}") - # Determine output directory - if (gist or open_browser) and output is None: - output = Path(tempfile.gettempdir()) / session_id + # 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 / session_id elif output is None: - output = session_id + output = Path(tempfile.gettempdir()) / f"claude-session-{session_id}" output = Path(output) click.echo(f"Generating HTML in {output}/...") generate_html_from_session_data(session_data, output, github_repo=repo) + # Show output directory + click.echo(f"Output: {output.resolve()}") + # Save JSON session data if requested if include_json: output.mkdir(exist_ok=True) @@ -1606,9 +1647,8 @@ def web_cmd( preview_url = f"https://gistpreview.github.io/?{gist_id}/index.html" click.echo(f"Gist: {gist_url}") click.echo(f"Preview: {preview_url}") - click.echo(f"Files: {output}") - if open_browser: + if open_browser or auto_open: index_url = (output / "index.html").resolve().as_uri() webbrowser.open(index_url) diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 46ff34a..db0aff8 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -1192,3 +1192,174 @@ def ask(self): assert result.exit_code == 0 assert "No session selected" in result.output + + +class TestOutputAutoOption: + """Tests for the -a/--output-auto flag.""" + + def test_json_output_auto_creates_subdirectory(self, tmp_path): + """Test that json -a creates output subdirectory named after file stem.""" + from click.testing import CliRunner + from claude_code_publish import cli + + fixture_path = Path(__file__).parent / "sample_session.json" + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(fixture_path), "-a", "-o", str(tmp_path)], + ) + + assert result.exit_code == 0 + # Output should be in tmp_path/sample_session/ + expected_dir = tmp_path / "sample_session" + assert expected_dir.exists() + assert (expected_dir / "index.html").exists() + + def test_json_output_auto_uses_cwd_when_no_output(self, tmp_path, monkeypatch): + """Test that json -a uses current directory when -o not specified.""" + from click.testing import CliRunner + from claude_code_publish import cli + import os + + fixture_path = Path(__file__).parent / "sample_session.json" + + # Change to tmp_path + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(fixture_path), "-a"], + ) + + assert result.exit_code == 0 + # Output should be in ./sample_session/ + expected_dir = tmp_path / "sample_session" + assert expected_dir.exists() + assert (expected_dir / "index.html").exists() + + def test_json_output_auto_no_browser_open(self, tmp_path, monkeypatch): + """Test that json -a does not auto-open browser.""" + from click.testing import CliRunner + from claude_code_publish import cli + + fixture_path = Path(__file__).parent / "sample_session.json" + + # Track webbrowser.open calls + opened_urls = [] + + def mock_open(url): + opened_urls.append(url) + return True + + monkeypatch.setattr("claude_code_publish.webbrowser.open", mock_open) + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(fixture_path), "-a", "-o", str(tmp_path)], + ) + + assert result.exit_code == 0 + assert len(opened_urls) == 0 # No browser opened + + def test_local_output_auto_creates_subdirectory(self, tmp_path, monkeypatch): + """Test that local -a creates output subdirectory named after file stem.""" + from click.testing import CliRunner + from claude_code_publish import cli + import questionary + + # Create mock .claude/projects structure + projects_dir = tmp_path / ".claude" / "projects" / "test-project" + projects_dir.mkdir(parents=True) + + session_file = projects_dir / "my-session-file.jsonl" + session_file.write_text( + '{"type":"summary","summary":"Test local session"}\n' + '{"type":"user","timestamp":"2025-01-01T00:00:00Z","message":{"role":"user","content":"Hello"}}\n' + ) + + output_parent = tmp_path / "output" + output_parent.mkdir() + + # Mock Path.home() to return our tmp_path + monkeypatch.setattr(Path, "home", lambda: tmp_path) + + # Mock questionary.select to return the session file + class MockSelect: + def __init__(self, *args, **kwargs): + pass + + def ask(self): + return session_file + + monkeypatch.setattr(questionary, "select", MockSelect) + + runner = CliRunner() + result = runner.invoke(cli, ["local", "-a", "-o", str(output_parent)]) + + assert result.exit_code == 0 + # Output should be in output_parent/my-session-file/ + expected_dir = output_parent / "my-session-file" + assert expected_dir.exists() + assert (expected_dir / "index.html").exists() + + def test_web_output_auto_creates_subdirectory(self, httpx_mock, tmp_path): + """Test that web -a creates output subdirectory named after session ID.""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Load sample session to mock API response + fixture_path = Path(__file__).parent / "sample_session.json" + with open(fixture_path) as f: + session_data = json.load(f) + + httpx_mock.add_response( + url="https://api.anthropic.com/v1/session_ingress/session/my-web-session-id", + json=session_data, + ) + + runner = CliRunner() + result = runner.invoke( + cli, + [ + "web", + "my-web-session-id", + "--token", + "test-token", + "--org-uuid", + "test-org", + "-a", + "-o", + str(tmp_path), + ], + ) + + assert result.exit_code == 0 + # Output should be in tmp_path/my-web-session-id/ + expected_dir = tmp_path / "my-web-session-id" + assert expected_dir.exists() + assert (expected_dir / "index.html").exists() + + def test_output_auto_with_jsonl_uses_stem(self, tmp_path, monkeypatch): + """Test that -a with JSONL file uses file stem (without .jsonl extension).""" + from click.testing import CliRunner + from claude_code_publish import cli + + # Create a JSONL file + fixture_path = Path(__file__).parent / "sample_session.jsonl" + + monkeypatch.chdir(tmp_path) + + runner = CliRunner() + result = runner.invoke( + cli, + ["json", str(fixture_path), "-a"], + ) + + assert result.exit_code == 0 + # Output should be in ./sample_session/ (not ./sample_session.jsonl/) + expected_dir = tmp_path / "sample_session" + assert expected_dir.exists() + assert (expected_dir / "index.html").exists()