Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 40 additions & 0 deletions claude_code_log/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
convert_jsonl_to,
convert_jsonl_to_html,
ensure_fresh_cache,
generate_single_session_file,
get_file_extension,
process_projects_hierarchy,
)
Expand Down Expand Up @@ -506,6 +507,11 @@ def _clear_output_files(input_path: Path, all_projects: bool, file_ext: str) ->
default=2000,
help="Maximum messages per page for combined transcript (default: 2000). Sessions are never split across pages.",
)
@click.option(
"--session-id",
default=None,
help="Export a single session by ID (full ID or prefix). Requires a project directory path.",
)
@click.option(
"--debug",
is_flag=True,
Expand All @@ -528,6 +534,7 @@ def main(
output_format: str,
image_export_mode: Optional[str],
page_size: int,
session_id: Optional[str],
debug: bool,
) -> None:
"""Convert Claude transcript JSONL files to HTML or Markdown.
Expand Down Expand Up @@ -648,6 +655,39 @@ def main(
_launch_tui_with_cache_check(input_path)
return

# Handle --session-id: export a single session by ID
if session_id is not None:
if input_path is None:
click.echo(
"Error: --session-id requires a project directory path as argument",
err=True,
)
sys.exit(1)

# Convert project path if needed
assert input_path is not None
if not input_path.exists() or (
input_path.is_dir() and not list(input_path.glob("*.jsonl"))
):
claude_path = convert_project_path_to_claude_dir(
input_path, projects_dir
)
if claude_path.exists():
input_path = claude_path

output_path = generate_single_session_file(
output_format,
input_path,
session_id,
output,
not no_cache,
image_export_mode,
)
click.echo(f"Successfully exported session to {output_path}")
if open_browser:
click.launch(str(output_path))
return

# Handle default case - process all projects hierarchy if no input path and --all-projects flag
if input_path is None:
input_path = projects_dir or get_default_projects_dir()
Expand Down
166 changes: 149 additions & 17 deletions claude_code_log/converter.py
Original file line number Diff line number Diff line change
Expand Up @@ -1469,6 +1469,27 @@ def _collect_project_sessions(messages: list[TranscriptEntry]) -> list[dict[str,
)


def build_session_title(
project_title: str,
session_id: str,
session_cache: Optional[SessionCacheData],
) -> str:
"""Build a display title for a session.

Uses the session summary if available, otherwise the first user message
preview (truncated to 50 chars), falling back to "Session {id[:8]}".
"""
if session_cache:
if session_cache.summary:
return f"{project_title}: {session_cache.summary}"
preview = session_cache.first_user_message
if preview:
if len(preview) > 50:
preview = preview[:50] + "..."
return f"{project_title}: {preview}"
return f"{project_title}: Session {session_id[:8]}"


def _generate_individual_session_files(
format: str,
messages: list[TranscriptEntry],
Expand Down Expand Up @@ -1520,23 +1541,11 @@ def _generate_individual_session_files(
# Generate HTML file for each session
for session_id in session_ids:
# Create session-specific title using cache data if available
if session_id in session_data:
session_cache = session_data[session_id]
if session_cache.summary:
session_title = f"{project_title}: {session_cache.summary}"
else:
# Fall back to first user message preview
preview = session_cache.first_user_message
if preview and len(preview) > 50:
preview = preview[:50] + "..."
session_title = (
f"{project_title}: {preview}"
if preview
else f"{project_title}: Session {session_id[:8]}"
)
else:
# Fall back to basic session title
session_title = f"{project_title}: Session {session_id[:8]}"
session_title = build_session_title(
project_title,
session_id,
session_data.get(session_id),
)

# Add date range if specified
if from_date or to_date:
Expand Down Expand Up @@ -1609,6 +1618,129 @@ def _generate_individual_session_files(
return regenerated_count


def generate_single_session_file(
format: str,
input_path: Path,
session_id: str,
output: Optional[Path] = None,
use_cache: bool = True,
image_export_mode: Optional[str] = None,
) -> Path:
"""Generate a single session output file for the given session ID.

Args:
format: Output format ('html', 'md', 'markdown')
input_path: Project directory containing JSONL files
session_id: Full or 8-char prefix session ID
output: Optional output file path (defaults to session-{id}.{ext} in input_path)
use_cache: Whether to use caching
image_export_mode: Image export mode

Returns:
Path to the generated file

Raises:
ValueError: If session ID not found or ambiguous
FileNotFoundError: If input_path doesn't exist or is not a directory
"""
if not input_path.exists() or not input_path.is_dir():
raise FileNotFoundError(f"Project directory not found: {input_path}")

# Setup cache
cache_manager = None
if use_cache:
try:
cache_manager = CacheManager(input_path, get_library_version())
except Exception as e:
print(f"Warning: Failed to initialize cache manager: {e}")

# Ensure fresh cache
ensure_fresh_cache(input_path, cache_manager, silent=True)

# Load messages from JSONL files
messages = load_directory_transcripts(input_path, cache_manager)

# Collect all known session IDs: from loaded messages + cache metadata
all_session_ids: set[str] = {
getattr(msg, "sessionId")
for msg in messages
if hasattr(msg, "sessionId") and getattr(msg, "sessionId")
}
if cache_manager:
project_cache = cache_manager.get_cached_project_data()
if project_cache:
all_session_ids |= set(project_cache.sessions.keys())

# Resolve short ID prefix to full ID
matched_id: Optional[str] = None
if session_id in all_session_ids:
matched_id = session_id
else:
matches = [sid for sid in all_session_ids if sid.startswith(session_id)]
if len(matches) == 1:
matched_id = matches[0]
elif len(matches) > 1:
raise ValueError(
f"Ambiguous session ID prefix '{session_id}' matches multiple sessions: "
+ ", ".join(sorted(m[:8] for m in matches))
)

if matched_id is None:
raise ValueError(f"Session '{session_id}' not found in {input_path}")

# For archived sessions, load messages from cache if not in JSONL files
session_messages = [
m
for m in messages
if hasattr(m, "sessionId") and getattr(m, "sessionId") == matched_id
]
if not session_messages and cache_manager:
archived = cache_manager.load_session_entries(matched_id)
if archived:
session_messages = archived

session_messages = deduplicate_messages(session_messages)

if not session_messages:
raise ValueError(f"No messages found for session '{matched_id[:8]}'")

# Build session title from cache metadata
session_data: dict[str, Any] = {}
working_directories: list[str] = []
if cache_manager:
project_cache = cache_manager.get_cached_project_data()
if project_cache:
session_data = {s.session_id: s for s in project_cache.sessions.values()}
working_directories = cache_manager.get_working_directories()

project_title = get_project_display_name(input_path.name, working_directories)

session_title = build_session_title(
project_title,
matched_id,
session_data.get(matched_id),
)

# Determine output path
ext = get_file_extension(format)
output_dir = input_path
if output is not None:
output_file = output
output_dir = output.parent
else:
output_file = input_path / f"session-{matched_id}.{ext}"

# Generate content and write
renderer = get_renderer(format, image_export_mode)
session_content = renderer.generate_session(
session_messages, matched_id, session_title, cache_manager, output_dir
)
assert session_content is not None
output_file.write_text(session_content, encoding="utf-8")

return output_file


def _get_cleanup_period_days() -> Optional[int]:
"""Read cleanupPeriodDays from Claude Code settings.

Expand Down
11 changes: 2 additions & 9 deletions claude_code_log/tui.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@

from .cache import CacheManager, SessionCacheData, get_library_version
from .converter import (
build_session_title,
ensure_fresh_cache,
get_file_extension,
load_directory_transcripts,
Expand Down Expand Up @@ -1849,15 +1850,7 @@ def _ensure_session_file(
self.project_path.name,
project_cache.working_directories if project_cache else None,
)
if session_data and session_data.summary:
session_title = f"{project_name}: {session_data.summary}"
elif session_data and session_data.first_user_message:
preview = session_data.first_user_message
if len(preview) > 50:
preview = preview[:50] + "..."
session_title = f"{project_name}: {preview}"
else:
session_title = f"{project_name}: Session {session_id[:8]}"
session_title = build_session_title(project_name, session_id, session_data)

# Generate session content
session_content = renderer.generate_session(
Expand Down
76 changes: 76 additions & 0 deletions test/test_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,82 @@ def test_output_option(
assert output_path.exists()


class TestSessionIdOption:
"""Tests for --session-id CLI option."""

def test_session_id_no_path_errors(
self, monkeypatch: pytest.MonkeyPatch, tmp_path: Path
):
"""--session-id without a project path argument exits with error."""
monkeypatch.setenv("CLAUDE_CODE_LOG_CACHE_PATH", str(tmp_path / "test.db"))
runner = CliRunner()
result = runner.invoke(main, ["--session-id", "abc12345"])
assert result.exit_code != 0
assert "error" in result.output.lower() or "Error" in result.output

def test_session_id_valid_full_id(
self, cli_projects_setup: ProjectsSetup, sample_jsonl_content: list[dict]
):
"""--session-id with a valid full session ID generates file and prints success."""
project_dir = create_project_with_jsonl(
cli_projects_setup.projects_dir, "my-project", sample_jsonl_content
)
runner = CliRunner()
result = runner.invoke(main, [str(project_dir), "--session-id", "session-1"])
assert result.exit_code == 0
assert "Successfully exported session" in result.output
# Generated file must exist
assert len(list(project_dir.glob("session-session-1.*"))) > 0

def test_session_id_prefix(
self, cli_projects_setup: ProjectsSetup, sample_jsonl_content: list[dict]
):
"""--session-id with a unique prefix resolves to the full ID."""
project_dir = create_project_with_jsonl(
cli_projects_setup.projects_dir, "my-project", sample_jsonl_content
)
runner = CliRunner()
# "sess" is a valid prefix of "session-1"
result = runner.invoke(main, [str(project_dir), "--session-id", "sess"])
assert result.exit_code == 0
assert "Successfully exported session" in result.output

def test_session_id_unknown_exits_nonzero(
self, cli_projects_setup: ProjectsSetup, sample_jsonl_content: list[dict]
):
"""--session-id with an unknown ID exits non-zero."""
project_dir = create_project_with_jsonl(
cli_projects_setup.projects_dir, "my-project", sample_jsonl_content
)
runner = CliRunner()
result = runner.invoke(
main, [str(project_dir), "--session-id", "zzzzzzzz-does-not-exist"]
)
assert result.exit_code != 0

def test_session_id_with_output_flag(
self, cli_projects_setup: ProjectsSetup, sample_jsonl_content: list[dict]
):
"""--session-id combined with --output writes to the specified path."""
project_dir = create_project_with_jsonl(
cli_projects_setup.projects_dir, "my-project", sample_jsonl_content
)
output_path = cli_projects_setup.projects_dir / "custom-session.html"
runner = CliRunner()
result = runner.invoke(
main,
[
str(project_dir),
"--session-id",
"session-1",
"--output",
str(output_path),
],
)
assert result.exit_code == 0
assert output_path.exists()


class TestCLIErrorHandling:
"""Tests for CLI error handling paths."""

Expand Down
Loading
Loading