Skip to content

Commit e94b12d

Browse files
zackman0010claude
andcommitted
Extract shared logic from all/project commands into commands/__init__.py
Adds collect_raw_projects, build_project, and copy_jsonl_files helpers to eliminate duplication between the two batch conversion commands. Updates test patch targets to match new import locations. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 2ffe69c commit e94b12d

File tree

5 files changed

+308
-144
lines changed

5 files changed

+308
-144
lines changed

src/claude_code_transcripts/commands/__init__.py

Lines changed: 92 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,98 @@
1-
"""Shared Click option decorators for commands."""
1+
"""Shared Click option decorators and utilities for commands."""
2+
3+
import shutil
4+
from pathlib import Path
25

36
import click
47

8+
from claude_code_transcripts.html_generation import Project, Session
9+
from claude_code_transcripts.parser import parse_session_file
10+
from claude_code_transcripts.sessions import find_all_sessions, find_cowork_sessions
11+
12+
13+
def collect_raw_projects(source, include_agents=False):
14+
"""Discover raw project dicts from the given source.
15+
16+
Args:
17+
source: 'code', 'cowork', None (both), or a path string.
18+
include_agents: Whether to include agent-* session files.
19+
20+
Returns:
21+
List of raw project dicts, each with 'name' and 'sessions'.
22+
"""
23+
raw_projects = []
24+
25+
if source != "cowork":
26+
code_folder = (
27+
Path.home() / ".claude" / "projects"
28+
if source in (None, "code")
29+
else Path(source)
30+
)
31+
if not code_folder.exists():
32+
raise click.ClickException(f"Source directory not found: {code_folder}")
33+
raw_projects = find_all_sessions(code_folder, include_agents=include_agents)
34+
35+
if source in (None, "cowork"):
36+
raw_cowork = find_cowork_sessions()
37+
if raw_cowork:
38+
cowork_sessions = []
39+
for session in raw_cowork:
40+
jsonl_path = session["jsonl_path"]
41+
stat = jsonl_path.stat()
42+
cowork_sessions.append(
43+
{
44+
"path": jsonl_path,
45+
"summary": session["title"],
46+
"mtime": session["mtime"],
47+
"size": stat.st_size,
48+
"transcript_label": "Claude Cowork",
49+
}
50+
)
51+
raw_projects.append({"name": "Cowork", "sessions": cowork_sessions})
52+
53+
return raw_projects
54+
55+
56+
def build_project(raw_project, output):
57+
"""Build a Project object (with Sessions) from a raw project dict.
58+
59+
Args:
60+
raw_project: Dict with 'name' and 'sessions' (each having 'path', 'size', etc.).
61+
output: Base output directory; project files go in output/name/.
62+
63+
Returns:
64+
A Project instance ready for generate_batch_html.
65+
"""
66+
project_dir = output / raw_project["name"]
67+
sessions = []
68+
for raw_session in raw_project["sessions"]:
69+
session_name = raw_session["path"].stem
70+
loglines = parse_session_file(raw_session["path"])
71+
sessions.append(
72+
Session(
73+
name=session_name,
74+
session_dir=project_dir / session_name,
75+
loglines=loglines,
76+
size_kb=raw_session["size"] / 1024,
77+
transcript_label=raw_session.get("transcript_label", "Claude Code"),
78+
)
79+
)
80+
return Project(name=raw_project["name"], project_dir=project_dir, sessions=sessions)
81+
82+
83+
def copy_jsonl_files(raw_project, output):
84+
"""Copy source JSONL files into each session's output directory.
85+
86+
Args:
87+
raw_project: Dict with 'name' and 'sessions'.
88+
output: Base output directory matching what was passed to generate_batch_html.
89+
"""
90+
project_dir = output / raw_project["name"]
91+
for raw_session in raw_project["sessions"]:
92+
session_name = raw_session["path"].stem
93+
session_dir = project_dir / session_name
94+
shutil.copy(raw_session["path"], session_dir / raw_session["path"].name)
95+
596

697
def output_options(func):
798
"""Apply the common output-related options to a command.

src/claude_code_transcripts/commands/all.py

Lines changed: 15 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,3 @@
1-
import shutil
21
import webbrowser
32
from datetime import datetime
43
from pathlib import Path
@@ -7,14 +6,14 @@
76

87
from claude_code_transcripts.cli import cli
98

10-
from claude_code_transcripts.html_generation import (
11-
Project,
12-
Session,
13-
generate_batch_html,
9+
from claude_code_transcripts.html_generation import generate_batch_html
10+
from claude_code_transcripts.commands import (
11+
build_project,
12+
collect_raw_projects,
13+
copy_jsonl_files,
14+
output_options,
15+
source_option,
1416
)
15-
from claude_code_transcripts.commands import output_options, source_option
16-
from claude_code_transcripts.parser import parse_session_file
17-
from claude_code_transcripts.sessions import find_all_sessions, find_cowork_sessions
1817

1918

2019
@cli.command("all")
@@ -44,43 +43,15 @@ def all_cmd(output, include_json, open_browser, source, include_agents, dry_run,
4443
- Per-project pages listing sessions
4544
- Individual session transcripts
4645
"""
47-
output = Path(output) if output else Path("./claude-archive")
48-
raw_projects = []
49-
50-
if source != "cowork":
46+
if not quiet and source != "cowork":
5147
code_folder = (
5248
Path.home() / ".claude" / "projects"
5349
if source in (None, "code")
5450
else Path(source)
5551
)
56-
if not code_folder.exists():
57-
raise click.ClickException(f"Source directory not found: {code_folder}")
58-
if not quiet:
59-
click.echo(f"Scanning {code_folder}...")
60-
raw_projects = find_all_sessions(code_folder, include_agents=include_agents)
61-
62-
if source in (None, "cowork"):
63-
raw_cowork = find_cowork_sessions()
64-
if raw_cowork:
65-
cowork_sessions = []
66-
for session in raw_cowork:
67-
jsonl_path = session["jsonl_path"]
68-
stat = jsonl_path.stat()
69-
cowork_sessions.append(
70-
{
71-
"path": jsonl_path,
72-
"summary": session["title"],
73-
"mtime": session["mtime"],
74-
"size": stat.st_size,
75-
"transcript_label": "Claude Cowork",
76-
}
77-
)
78-
raw_projects.append(
79-
{
80-
"name": "Cowork",
81-
"sessions": cowork_sessions,
82-
}
83-
)
52+
click.echo(f"Scanning {code_folder}...")
53+
54+
raw_projects = collect_raw_projects(source, include_agents=include_agents)
8455

8556
if not raw_projects:
8657
if not quiet:
@@ -108,31 +79,12 @@ def all_cmd(output, include_json, open_browser, source, include_agents, dry_run,
10879
click.echo(f" ... and {len(project['sessions']) - 3} more")
10980
return
11081

82+
output = Path(output) if output else Path("./claude-archive")
83+
11184
if not quiet:
11285
click.echo(f"\nParsing sessions...")
11386

114-
projects = []
115-
for raw_project in raw_projects:
116-
project_dir = output / raw_project["name"]
117-
sessions = []
118-
for raw_session in raw_project["sessions"]:
119-
session_name = raw_session["path"].stem
120-
session_dir = project_dir / session_name
121-
loglines = parse_session_file(raw_session["path"])
122-
sessions.append(
123-
Session(
124-
name=session_name,
125-
session_dir=session_dir,
126-
loglines=loglines,
127-
size_kb=raw_session["size"] / 1024,
128-
transcript_label=raw_session.get("transcript_label", "Claude Code"),
129-
)
130-
)
131-
projects.append(
132-
Project(
133-
name=raw_project["name"], project_dir=project_dir, sessions=sessions
134-
)
135-
)
87+
projects = [build_project(raw, output) for raw in raw_projects]
13688

13789
if not quiet:
13890
click.echo(f"Generating archive in {output}...")
@@ -149,11 +101,7 @@ def on_progress(project_name, session_name, current, total):
149101

150102
if include_json:
151103
for raw_project in raw_projects:
152-
for raw_session in raw_project["sessions"]:
153-
session_name = raw_session["path"].stem
154-
session_dir = output / raw_project["name"] / session_name
155-
json_dest = session_dir / raw_session["path"].name
156-
shutil.copy(raw_session["path"], json_dest)
104+
copy_jsonl_files(raw_project, output)
157105

158106
if stats["failed_sessions"]:
159107
click.echo(f"\nWarning: {len(stats['failed_sessions'])} session(s) failed:")
Lines changed: 12 additions & 67 deletions
Original file line numberDiff line numberDiff line change
@@ -1,60 +1,26 @@
1-
import shutil
21
import webbrowser
32
from pathlib import Path
43

54
import click
65
import questionary
76

87
from claude_code_transcripts.cli import cli
9-
from claude_code_transcripts.commands import output_options, source_option
10-
from claude_code_transcripts.html_generation import (
11-
Project,
12-
Session,
13-
generate_batch_html,
8+
from claude_code_transcripts.commands import (
9+
build_project,
10+
collect_raw_projects,
11+
copy_jsonl_files,
12+
output_options,
13+
source_option,
1414
)
15-
from claude_code_transcripts.parser import parse_session_file
16-
from claude_code_transcripts.sessions import find_all_sessions, find_cowork_sessions
15+
from claude_code_transcripts.html_generation import generate_batch_html
1716

1817

1918
@cli.command("project")
2019
@output_options
2120
@source_option
2221
def project_cmd(output, include_json, open_browser, source):
2322
"""Select a project and convert all its sessions to a browsable HTML archive."""
24-
raw_projects = []
25-
26-
if source != "cowork":
27-
code_folder = (
28-
Path.home() / ".claude" / "projects"
29-
if source in (None, "code")
30-
else Path(source)
31-
)
32-
if not code_folder.exists():
33-
raise click.ClickException(f"Source directory not found: {code_folder}")
34-
raw_projects = find_all_sessions(code_folder)
35-
36-
if source in (None, "cowork"):
37-
raw_cowork = find_cowork_sessions()
38-
if raw_cowork:
39-
cowork_sessions = []
40-
for session in raw_cowork:
41-
jsonl_path = session["jsonl_path"]
42-
stat = jsonl_path.stat()
43-
cowork_sessions.append(
44-
{
45-
"path": jsonl_path,
46-
"summary": session["title"],
47-
"mtime": session["mtime"],
48-
"size": stat.st_size,
49-
"transcript_label": "Claude Cowork",
50-
}
51-
)
52-
raw_projects.append(
53-
{
54-
"name": "Cowork",
55-
"sessions": cowork_sessions,
56-
}
57-
)
23+
raw_projects = collect_raw_projects(source)
5824

5925
if not raw_projects:
6026
click.echo("No projects found.")
@@ -75,35 +41,14 @@ def project_cmd(output, include_json, open_browser, source):
7541
return
7642

7743
output = Path(output) if output else Path("./claude-archive")
78-
project_dir = output / selected["name"]
7944

80-
sessions = []
81-
for raw_session in selected["sessions"]:
82-
session_name = raw_session["path"].stem
83-
session_dir = project_dir / session_name
84-
loglines = parse_session_file(raw_session["path"])
85-
sessions.append(
86-
Session(
87-
name=session_name,
88-
session_dir=session_dir,
89-
loglines=loglines,
90-
size_kb=raw_session["size"] / 1024,
91-
transcript_label=raw_session.get("transcript_label", "Claude Code"),
92-
)
93-
)
94-
95-
projects = [
96-
Project(name=selected["name"], project_dir=project_dir, sessions=sessions)
97-
]
45+
project = build_project(selected, output)
9846

9947
click.echo(f"Generating archive in {output}...")
100-
stats = generate_batch_html(projects, output)
48+
stats = generate_batch_html([project], output)
10149

10250
if include_json:
103-
for raw_session in selected["sessions"]:
104-
session_name = raw_session["path"].stem
105-
session_dir = project_dir / session_name
106-
shutil.copy(raw_session["path"], session_dir / raw_session["path"].name)
51+
copy_jsonl_files(selected, output)
10752

10853
if stats["failed_sessions"]:
10954
click.echo(f"\nWarning: {len(stats['failed_sessions'])} session(s) failed:")
@@ -119,5 +64,5 @@ def project_cmd(output, include_json, open_browser, source):
11964
click.echo(f"Output: {output.resolve()}")
12065

12166
if open_browser:
122-
index_url = (project_dir / "index.html").resolve().as_uri()
67+
index_url = (project.project_dir / "index.html").resolve().as_uri()
12368
webbrowser.open(index_url)

tests/test_all.py

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -485,7 +485,7 @@ def test_all_processes_cowork_sessions(self, tmp_path):
485485

486486
runner = CliRunner()
487487
with patch(
488-
"claude_code_transcripts.commands.all.find_cowork_sessions"
488+
"claude_code_transcripts.commands.find_cowork_sessions"
489489
) as mock_cowork:
490490
mock_cowork.return_value = [cowork_session]
491491
result = runner.invoke(
@@ -546,9 +546,9 @@ def test_source_code_skips_cowork(self, mock_projects_dir):
546546
"""--source code queries Code sessions and never calls find_cowork_sessions."""
547547
runner = CliRunner()
548548
with (
549-
patch("claude_code_transcripts.commands.all.find_all_sessions") as mock_all,
549+
patch("claude_code_transcripts.commands.find_all_sessions") as mock_all,
550550
patch(
551-
"claude_code_transcripts.commands.all.find_cowork_sessions"
551+
"claude_code_transcripts.commands.find_cowork_sessions"
552552
) as mock_cowork,
553553
):
554554
mock_all.return_value = []
@@ -562,9 +562,9 @@ def test_source_cowork_skips_code(self):
562562
"""--source cowork queries Cowork sessions and never calls find_all_sessions."""
563563
runner = CliRunner()
564564
with (
565-
patch("claude_code_transcripts.commands.all.find_all_sessions") as mock_all,
565+
patch("claude_code_transcripts.commands.find_all_sessions") as mock_all,
566566
patch(
567-
"claude_code_transcripts.commands.all.find_cowork_sessions"
567+
"claude_code_transcripts.commands.find_cowork_sessions"
568568
) as mock_cowork,
569569
):
570570
mock_cowork.return_value = []
@@ -578,9 +578,9 @@ def test_source_path_uses_that_path_and_skips_cowork(self, mock_projects_dir):
578578
"""--source <path> searches Code at that path and never calls find_cowork_sessions."""
579579
runner = CliRunner()
580580
with (
581-
patch("claude_code_transcripts.commands.all.find_all_sessions") as mock_all,
581+
patch("claude_code_transcripts.commands.find_all_sessions") as mock_all,
582582
patch(
583-
"claude_code_transcripts.commands.all.find_cowork_sessions"
583+
"claude_code_transcripts.commands.find_cowork_sessions"
584584
) as mock_cowork,
585585
):
586586
mock_all.return_value = []
@@ -598,9 +598,9 @@ def test_no_source_searches_both(self, tmp_path, monkeypatch):
598598

599599
runner = CliRunner()
600600
with (
601-
patch("claude_code_transcripts.commands.all.find_all_sessions") as mock_all,
601+
patch("claude_code_transcripts.commands.find_all_sessions") as mock_all,
602602
patch(
603-
"claude_code_transcripts.commands.all.find_cowork_sessions"
603+
"claude_code_transcripts.commands.find_cowork_sessions"
604604
) as mock_cowork,
605605
):
606606
mock_all.return_value = []

0 commit comments

Comments
 (0)