diff --git a/src/claude_code_publish/__init__.py b/src/claude_code_publish/__init__.py
index 62aecc6..9ff5092 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,20 +1070,145 @@ 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="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()
+@cli.command("local")
+@click.option(
+ "-o",
+ "--output",
+ type=click.Path(),
+ 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",
+ 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 (default if no -o specified).",
+)
+@click.option(
+ "--limit",
+ default=10,
+ help="Maximum number of sessions to show (default: 10)",
+)
+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
+
+ 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
+
+ # 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 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()) / 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)
+ 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}")
+
+ if open_browser or auto_open:
+ index_url = (output / "index.html").resolve().as_uri()
+ webbrowser.open(index_url)
+
+
+@cli.command("json")
@click.argument("json_file", type=click.Path(exists=True))
@click.option(
"-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",
@@ -954,23 +1229,26 @@ 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).",
)
-def session(json_file, output, repo, gist, include_json, open_browser):
- """Convert a Claude Code session JSON 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
+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 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)
@@ -988,9 +1266,8 @@ def session(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)
@@ -1041,40 +1318,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)
@@ -1262,13 +1505,19 @@ 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",
"--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(
@@ -1293,12 +1542,20 @@ 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 import_session(
- session_id, output, token, org_uuid, repo, gist, include_json, open_browser
+def web_cmd(
+ session_id,
+ output,
+ output_auto,
+ 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.
"""
@@ -1356,16 +1613,23 @@ def import_session(
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)
@@ -1383,9 +1647,8 @@ def import_session(
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/__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
+
+
+
+
+
+
\ 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..db0aff8 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,
)
@@ -336,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."""
@@ -584,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
@@ -616,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
@@ -740,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
@@ -759,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
@@ -790,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",
@@ -855,7 +766,7 @@ def mock_run(*args, **kwargs):
result = runner.invoke(
cli,
[
- "import",
+ "web",
"test-session-id",
"--token",
"test-token",
@@ -923,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
@@ -959,7 +870,7 @@ def mock_open(url):
result = runner.invoke(
cli,
[
- "import",
+ "web",
"test-session-id",
"--token",
"test-token",
@@ -975,3 +886,480 @@ 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_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"
+ 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)
+
+ # 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"])
+
+ assert result.exit_code == 0
+ assert "Loading local sessions" in result.output
+ assert "Generated" in result.output
+
+ 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"
+ 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)
+
+ # 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 "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
+
+
+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()