diff --git a/README.md b/README.md index 294a639f..d03f3cbb 100644 --- a/README.md +++ b/README.md @@ -70,7 +70,16 @@ claude-code-transcripts claude-code-transcripts local ``` -Use `--limit` to control how many sessions are shown (default: 10): +Both `local` and `all` use `$CLAUDE_CONFIG_DIR/projects` when `CLAUDE_CONFIG_DIR` is set. +Pass `-s` or `--source` to select a projects directory explicitly. Repeat it to browse +multiple profiles together: + +```bash +claude-code-transcripts -s ~/.claude/projects -s ~/.claude-personal/projects +``` + +The picker groups sessions by source. Use `--limit` to control how many recent sessions +are shown per source (default 10): ```bash claude-code-transcripts local --limit 20 @@ -188,7 +197,7 @@ This creates a directory structure with: Options: -- `-s, --source DIRECTORY` - source directory (default: `~/.claude/projects`) +- `-s, --source DIRECTORY` - projects directory, repeatable (default: `$CLAUDE_CONFIG_DIR/projects` if set, otherwise `~/.claude/projects`) - `-o, --output DIRECTORY` - output directory (default: `./claude-archive`) - `--include-agents` - include agent session files (excluded by default) - `--dry-run` - show what would be converted without creating files @@ -204,6 +213,9 @@ claude-code-transcripts all --dry-run # Convert all sessions and open in browser claude-code-transcripts all --open +# Build one index linking to a separate project archive for each source +claude-code-transcripts all -s ~/.claude/projects -s ~/.claude-personal/projects + # Convert to a specific directory claude-code-transcripts all -o ./my-archive diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index e4854a3b..d847b7c8 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1,5 +1,6 @@ """Convert Claude Code session JSON to a clean mobile-friendly HTML page with pagination.""" +import hashlib import json import html import os @@ -322,12 +323,12 @@ def generate_batch_html( Returns statistics dict with total_projects, total_sessions, failed_sessions, output_dir. """ - source_folder = Path(source_folder) - output_dir = Path(output_dir) - output_dir.mkdir(parents=True, exist_ok=True) - - # Find all sessions projects = find_all_sessions(source_folder, include_agents=include_agents) + return _write_batch_html(projects, Path(output_dir), progress_callback) + + +def _write_batch_html(projects, output_dir, progress_callback, source_label=None): + output_dir.mkdir(parents=True, exist_ok=True) # Calculate total for progress tracking total_session_count = sum(len(p["sessions"]) for p in projects) @@ -370,7 +371,7 @@ def generate_batch_html( _generate_project_index(project, project_dir) # Generate master index - _generate_master_index(projects, output_dir) + _generate_master_index(projects, output_dir, source_label) return { "total_projects": len(projects), @@ -409,7 +410,7 @@ def _generate_project_index(project, output_dir): output_path.write_text(html_content, encoding="utf-8") -def _generate_master_index(projects, output_dir): +def _generate_master_index(projects, output_dir, source_label): """Generate master index.html listing all projects.""" template = get_template("master_index.html") @@ -438,6 +439,7 @@ def _generate_master_index(projects, output_dir): html_content = template.render( projects=projects_data, + source_label=source_label, total_projects=len(projects), total_sessions=total_sessions, css=CSS, @@ -1477,7 +1479,27 @@ def cli(): pass +source_option = click.option( + "-s", + "--source", + "sources", + multiple=True, + type=click.Path(exists=True, file_okay=False, path_type=Path), + help="Projects directory (repeatable). Defaults to $CLAUDE_CONFIG_DIR/projects or ~/.claude/projects.", +) + + +def project_sources(sources): + if sources: + return list(dict.fromkeys(source.resolve() for source in sources)) + config = os.environ.get("CLAUDE_CONFIG_DIR") + return [ + (Path(config).expanduser() if config else Path.home() / ".claude") / "projects" + ] + + @cli.command("local") +@source_option @click.option( "-o", "--output", @@ -1514,37 +1536,38 @@ def cli(): @click.option( "--limit", default=10, - help="Maximum number of sessions to show (default: 10)", + type=click.IntRange(min=0), + help="Maximum number of sessions to show per source (default: 10)", ) -def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit): +def local_cmd( + sources, output, output_auto, repo, gist, include_json, open_browser, limit +): """Select and convert a local Claude Code session to HTML.""" - 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 - + sources = project_sources(sources) click.echo("Loading local sessions...") - results = find_local_sessions(projects_folder, limit=limit) - - if not results: + choices = [] + for source in sources: + if not source.is_dir(): + click.echo(f"Projects folder not found: {source}") + continue + results = find_local_sessions(source, limit=limit) + if results and len(sources) > 1: + choices.append(questionary.Separator(str(source))) + 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)) + + if not choices: 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, @@ -2096,13 +2119,17 @@ def web_cmd( webbrowser.open(index_url) +def archive_source_label(source, sources): + """Use the shortest path suffix that identifies this projects directory.""" + for length in range(1, len(source.parts) + 1): + suffix = source.parts[-length:] + if all(other == source or other.parts[-length:] != suffix for other in sources): + return str(Path(*suffix)) + return str(source) + + @cli.command("all") -@click.option( - "-s", - "--source", - type=click.Path(exists=True), - help="Source directory containing Claude projects (default: ~/.claude/projects).", -) +@source_option @click.option( "-o", "--output", @@ -2132,7 +2159,7 @@ def web_cmd( is_flag=True, help="Suppress all output except errors.", ) -def all_cmd(source, output, include_agents, dry_run, open_browser, quiet): +def all_cmd(sources, output, include_agents, dry_run, open_browser, quiet): """Convert all local Claude Code sessions to a browsable HTML archive. Creates a directory structure with: @@ -2140,22 +2167,17 @@ def all_cmd(source, output, include_agents, dry_run, open_browser, quiet): - Per-project pages listing sessions - Individual session transcripts """ - # Default source folder - if source is None: - source = Path.home() / ".claude" / "projects" - else: - source = Path(source) - - if not source.exists(): - raise click.ClickException(f"Source directory not found: {source}") - + sources = project_sources(sources) output = Path(output) + groups = {} + for source in sources: + if not source.is_dir(): + raise click.ClickException(f"Source directory not found: {source}") + if not quiet: + click.echo(f"Scanning {source}...") + groups[source] = find_all_sessions(source, include_agents=include_agents) - if not quiet: - click.echo(f"Scanning {source}...") - - projects = find_all_sessions(source, include_agents=include_agents) - + projects = [project for group in groups.values() for project in group] if not projects: if not quiet: click.echo("No sessions found.") @@ -2171,17 +2193,20 @@ def all_cmd(source, output, include_agents, dry_run, open_browser, quiet): # Dry-run always outputs (it's the point of dry-run), but respects --quiet if not quiet: click.echo("\nDry run - would convert:") - for project in projects: - click.echo( - f"\n {project['name']} ({len(project['sessions'])} sessions)" - ) - for session in project["sessions"][:3]: # Show first 3 - mod_time = datetime.fromtimestamp(session["mtime"]) + for source, group in groups.items(): + if len(groups) > 1: + click.echo(str(source)) + for project in group: click.echo( - f" - {session['path'].stem} ({mod_time.strftime('%Y-%m-%d')})" + f"\n {project['name']} ({len(project['sessions'])} sessions)" ) - if len(project["sessions"]) > 3: - click.echo(f" ... and {len(project['sessions']) - 3} more") + for session in project["sessions"][:3]: # Show first 3 + mod_time = datetime.fromtimestamp(session["mtime"]) + click.echo( + f" - {session['path'].stem} ({mod_time.strftime('%Y-%m-%d')})" + ) + if len(project["sessions"]) > 3: + click.echo(f" ... and {len(project['sessions']) - 3} more") return if not quiet: @@ -2192,26 +2217,35 @@ def on_progress(project_name, session_name, current, total): if not quiet and current % 10 == 0: click.echo(f" Processed {current}/{total} sessions...") - # Generate the archive using the library function - stats = generate_batch_html( - source, - output, - include_agents=include_agents, - progress_callback=on_progress, - ) - - # Report any failures - if stats["failed_sessions"]: - click.echo(f"\nWarning: {len(stats['failed_sessions'])} session(s) failed:") - for failure in stats["failed_sessions"]: + source_links = [] + successful_sessions = 0 + for source, group in groups.items(): + destination = output + label = None + if len(groups) > 1: + # Keep source links stable when the order of CLI arguments changes. + key = hashlib.sha256(os.fsencode(source.resolve())).hexdigest()[:16] + destination = output / f"source-{key}" + label = archive_source_label(source, sources) + source_links.append((label, destination.name)) + stats = _write_batch_html(group, destination, on_progress, label) + successful_sessions += stats["total_sessions"] + if stats["failed_sessions"]: click.echo( - f" {failure['project']}/{failure['session']}: {failure['error']}" + f"\nWarning: {len(stats['failed_sessions'])} session(s) failed in {source}:" ) + for failure in stats["failed_sessions"]: + click.echo( + f" {failure['project']}/{failure['session']}: {failure['error']}" + ) + + if source_links: + index = get_template("sources_index.html").render(sources=source_links, css=CSS) + (output / "index.html").write_text(index, encoding="utf-8") if not quiet: click.echo( - f"\nGenerated archive with {stats['total_projects']} projects, " - f"{stats['total_sessions']} sessions" + f"\nGenerated archive with {len(projects)} projects, {successful_sessions} sessions" ) click.echo(f"Output: {output.resolve()}") diff --git a/src/claude_code_transcripts/templates/master_index.html b/src/claude_code_transcripts/templates/master_index.html index 8b7b8c50..32924f17 100644 --- a/src/claude_code_transcripts/templates/master_index.html +++ b/src/claude_code_transcripts/templates/master_index.html @@ -3,12 +3,12 @@ {% block title %}Claude Code Archive{% endblock %} {% block content %} -

Claude Code Archive

+

{% if source_label %}Claude Code Archive / {{ source_label }}{% else %}Claude Code Archive{% endif %}

{{ total_projects }} projects · {{ total_sessions }} sessions

{% for project in projects %}
- +
{{ project.name }} diff --git a/src/claude_code_transcripts/templates/project_index.html b/src/claude_code_transcripts/templates/project_index.html index 3fc57501..3a4a260b 100644 --- a/src/claude_code_transcripts/templates/project_index.html +++ b/src/claude_code_transcripts/templates/project_index.html @@ -8,7 +8,7 @@

Claud {% for session in sessions %}
- +
{{ session.date }} {{ "%.0f"|format(session.size_kb) }} KB diff --git a/src/claude_code_transcripts/templates/sources_index.html b/src/claude_code_transcripts/templates/sources_index.html new file mode 100644 index 00000000..a4bd378a --- /dev/null +++ b/src/claude_code_transcripts/templates/sources_index.html @@ -0,0 +1,15 @@ +{% extends "base.html" %} +{% block title %}Claude Code Archive{% endblock %} +{% block content %} +

Claude Code Archive

+

Sources

+ {% for label, directory in sources %} +
+ {% endfor %} +{%- endblock %} diff --git a/tests/test_sources.py b/tests/test_sources.py new file mode 100644 index 00000000..74e2f3cc --- /dev/null +++ b/tests/test_sources.py @@ -0,0 +1,323 @@ +import json +import os +import re +from unittest.mock import Mock +from urllib.parse import quote + +import pytest +import questionary +from click.testing import CliRunner + +from claude_code_transcripts import cli + + +@pytest.fixture +def sources(tmp_path, monkeypatch): + monkeypatch.setenv("HOME", str(tmp_path)) + roots = [tmp_path / name / "projects" for name in (".claude", ".claude-personal")] + monkeypatch.setenv("CLAUDE_CONFIG_DIR", str(roots[1].parent)) + for root in roots: + project = root / "shared" + project.mkdir(parents=True) + for i in range(11): + entry = { + "type": "user", + "timestamp": "2025-01-01T10:00:00Z", + "message": { + "role": "user", + "content": f"Hello from {root.parent.name} session {i}", + }, + } + (project / f"{i}.jsonl").write_text(json.dumps(entry) + "\n") + return roots + + +@pytest.mark.parametrize("args,limit", [(["local"], 10), (["--limit", "2"], 2)]) +def test_grouped_picker(sources, args, limit, tmp_path, monkeypatch): + def select(message, choices): + offset = limit + 1 + assert len(choices) == 2 * offset + for index, source in enumerate(sources): + start = index * offset + assert isinstance(choices[start], questionary.Separator) + assert source.parent.name in choices[start].title + assert all( + c.value.parent.parent == source + for c in choices[start + 1 : start + offset] + ) + return Mock(ask=lambda: choices[offset + 1].value) + + monkeypatch.setattr(questionary, "select", select) + output = tmp_path / "result" + result = CliRunner().invoke( + cli, [*args, "-s", sources[0], "--source", sources[1], "-o", output] + ) + assert result.exit_code == 0, (result.output, result.exception) + assert ( + "Hello from .claude-personal session" in (output / "page-001.html").read_text() + ) + + +def test_archive_links(sources, tmp_path): + out = tmp_path / "archive" + result = CliRunner().invoke( + cli, ["all", "-s", sources[0], "-s", sources[1], "-o", out] + ) + assert result.exit_code == 0, result.output + links = re.findall(r'\s*([^<]+)', + (out / "index.html").read_text(), + ) + assert len(set(labels)) == 2 + + +@pytest.mark.parametrize("command", ["all", "local"]) +@pytest.mark.parametrize("invalid", ["missing", "file"]) +def test_invalid_second_source_aborts_before_output( + command, invalid, sources, tmp_path +): + bad = tmp_path / invalid + if invalid == "file": + bad.touch() + out = tmp_path / "output" + result = CliRunner().invoke(cli, [command, "-s", sources[0], "-s", bad, "-o", out]) + assert result.exit_code == 2 + assert str(bad) in result.output + assert not out.exists() + + +@pytest.mark.parametrize("command", ["all", "local"]) +def test_repeated_source_and_symlink_are_one_source( + command, sources, tmp_path, monkeypatch +): + root = sources[0] + alias = tmp_path / "alias" + alias.symlink_to(root, target_is_directory=True) + out = tmp_path / "output" + + def select(message, choices): + assert len(choices) == 10 + assert all(c.value.parent.parent == root for c in choices) + return Mock(ask=lambda: None) + + monkeypatch.setattr(questionary, "select", select) + result = CliRunner().invoke( + cli, [command, "-s", root, "-s", root, "-s", alias, "-o", out] + ) + assert result.exit_code == 0, (result.output, result.exception) + if command == "all": + assert "1 projects, 11 sessions" in result.output + assert 'href="shared/index.html"' in (out / "index.html").read_text() + else: + assert "No session selected" in result.output + assert not out.exists() + + +@pytest.mark.parametrize("command", ["all", "local"]) +def test_empty_sources(command, tmp_path): + roots = [tmp_path / "empty1", tmp_path / "empty2"] + for root in roots: + root.mkdir() + out = tmp_path / "output" + result = CliRunner().invoke( + cli, [command, "-s", roots[0], "-s", roots[1], "-o", out] + ) + assert result.exit_code == 0, result.output + assert "sessions found" in result.output + assert not out.exists() + + +def test_picker_skips_empty_source_and_filters_before_limit( + sources, tmp_path, monkeypatch +): + empty = tmp_path / "empty" + empty.mkdir() + for root in sources: + project = root / "shared" + newest = project / "5.jsonl" + for file in project.iterdir(): + os.utime(file, (1, 1)) + os.utime(newest, (2, 2)) + (project / "agent-new.jsonl").write_bytes(newest.read_bytes()) + (project / "empty.jsonl").touch() + + def select(message, choices): + assert [c.title for c in choices if isinstance(c, questionary.Separator)] == [ + str(root) for root in sources + ] + assert [ + c.value for c in choices if not isinstance(c, questionary.Separator) + ] == [root / "shared/5.jsonl" for root in sources] + return Mock(ask=lambda: None) + + monkeypatch.setattr(questionary, "select", select) + result = CliRunner().invoke( + cli, + [ + "-s", + str(empty), + "-s", + str(sources[0]), + "-s", + str(sources[1]), + "--limit", + "1", + ], + ) + assert result.exit_code == 0, (result.output, result.exception) + assert "No session selected" in result.output + + +@pytest.mark.parametrize("limit,code", [("0", 0), ("-1", 2)]) +def test_picker_limit_boundary(sources, tmp_path, limit, code): + out = tmp_path / "output" + result = CliRunner().invoke( + cli, ["local", "-s", sources[0], "-s", sources[1], "--limit", limit, "-o", out] + ) + assert result.exit_code == code, result.output + assert ("No local sessions found" if code == 0 else "--limit") in result.output + assert not out.exists() + + +@pytest.mark.parametrize("quiet", [False, True]) +def test_multi_source_dry_run(sources, tmp_path, quiet, mock_webbrowser_open): + out = tmp_path / "output" + args = ["--quiet"] if quiet else [] + result = CliRunner().invoke( + cli, + [ + "all", + "-s", + sources[0], + "-s", + sources[1], + "-o", + out, + "--open", + "--dry-run", + *args, + ], + ) + assert result.exit_code == 0, result.output + if quiet: + assert result.output == "" + else: + preview = result.output.split("Dry run - would convert:")[1] + assert all(str(source) in preview for source in sources) + assert preview.count("shared (11 sessions)") == 2 + assert not out.exists() + assert not mock_webbrowser_open + + +def test_archive_stable_links_with_empty_source_and_reordered_args( + sources, tmp_path, mock_webbrowser_open +): + empty = tmp_path / "empty" + empty.mkdir() + out = tmp_path / "output" + previous = [] + for roots in ([empty, sources[1]], [sources[1], empty]): + result = CliRunner().invoke( + cli, ["all", "-s", roots[0], "-s", roots[1], "-o", out, "--open"] + ) + assert result.exit_code == 0, result.output + links = re.findall(r'