Skip to content

Commit 3d3b42a

Browse files
committed
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
1 parent b3e038a commit 3d3b42a

4 files changed

Lines changed: 639 additions & 4 deletions

File tree

src/claude_code_publish/__init__.py

Lines changed: 205 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,157 @@
3838
ANTHROPIC_VERSION = "2023-06-01"
3939

4040

41+
def get_session_summary(filepath, max_length=200):
42+
"""Extract a human-readable summary from a session file.
43+
44+
Supports both JSON and JSONL formats.
45+
Returns a summary string or "(no summary)" if none found.
46+
"""
47+
filepath = Path(filepath)
48+
try:
49+
if filepath.suffix == ".jsonl":
50+
return _get_jsonl_summary(filepath, max_length)
51+
else:
52+
# For JSON files, try to get first user message
53+
with open(filepath, "r", encoding="utf-8") as f:
54+
data = json.load(f)
55+
loglines = data.get("loglines", [])
56+
for entry in loglines:
57+
if entry.get("type") == "user":
58+
msg = entry.get("message", {})
59+
content = msg.get("content", "")
60+
if isinstance(content, str) and content.strip():
61+
if len(content) > max_length:
62+
return content[: max_length - 3] + "..."
63+
return content
64+
return "(no summary)"
65+
except Exception:
66+
return "(no summary)"
67+
68+
69+
def _get_jsonl_summary(filepath, max_length=200):
70+
"""Extract summary from JSONL file."""
71+
try:
72+
with open(filepath, "r", encoding="utf-8") as f:
73+
for line in f:
74+
line = line.strip()
75+
if not line:
76+
continue
77+
try:
78+
obj = json.loads(line)
79+
# First priority: summary type entries
80+
if obj.get("type") == "summary" and obj.get("summary"):
81+
summary = obj["summary"]
82+
if len(summary) > max_length:
83+
return summary[: max_length - 3] + "..."
84+
return summary
85+
except json.JSONDecodeError:
86+
continue
87+
88+
# Second pass: find first non-meta user message
89+
with open(filepath, "r", encoding="utf-8") as f:
90+
for line in f:
91+
line = line.strip()
92+
if not line:
93+
continue
94+
try:
95+
obj = json.loads(line)
96+
if (
97+
obj.get("type") == "user"
98+
and not obj.get("isMeta")
99+
and obj.get("message", {}).get("content")
100+
):
101+
content = obj["message"]["content"]
102+
if isinstance(content, str):
103+
content = content.strip()
104+
if content and not content.startswith("<"):
105+
if len(content) > max_length:
106+
return content[: max_length - 3] + "..."
107+
return content
108+
except json.JSONDecodeError:
109+
continue
110+
except Exception:
111+
pass
112+
113+
return "(no summary)"
114+
115+
116+
def find_local_sessions(folder, limit=10):
117+
"""Find recent JSONL session files in the given folder.
118+
119+
Returns a list of (Path, summary) tuples sorted by modification time.
120+
Excludes agent files and warmup/empty sessions.
121+
"""
122+
folder = Path(folder)
123+
if not folder.exists():
124+
return []
125+
126+
results = []
127+
for f in folder.glob("**/*.jsonl"):
128+
if f.name.startswith("agent-"):
129+
continue
130+
summary = get_session_summary(f)
131+
# Skip boring/empty sessions
132+
if summary.lower() == "warmup" or summary == "(no summary)":
133+
continue
134+
results.append((f, summary))
135+
136+
# Sort by modification time, most recent first
137+
results.sort(key=lambda x: x[0].stat().st_mtime, reverse=True)
138+
return results[:limit]
139+
140+
141+
def parse_session_file(filepath):
142+
"""Parse a session file and return normalized data.
143+
144+
Supports both JSON and JSONL formats.
145+
Returns a dict with 'loglines' key containing the normalized entries.
146+
"""
147+
filepath = Path(filepath)
148+
149+
if filepath.suffix == ".jsonl":
150+
return _parse_jsonl_file(filepath)
151+
else:
152+
# Standard JSON format
153+
with open(filepath, "r", encoding="utf-8") as f:
154+
return json.load(f)
155+
156+
157+
def _parse_jsonl_file(filepath):
158+
"""Parse JSONL file and convert to standard format."""
159+
loglines = []
160+
161+
with open(filepath, "r", encoding="utf-8") as f:
162+
for line in f:
163+
line = line.strip()
164+
if not line:
165+
continue
166+
try:
167+
obj = json.loads(line)
168+
entry_type = obj.get("type")
169+
170+
# Skip non-message entries
171+
if entry_type not in ("user", "assistant"):
172+
continue
173+
174+
# Convert to standard format
175+
entry = {
176+
"type": entry_type,
177+
"timestamp": obj.get("timestamp", ""),
178+
"message": obj.get("message", {}),
179+
}
180+
181+
# Preserve isCompactSummary if present
182+
if obj.get("isCompactSummary"):
183+
entry["isCompactSummary"] = True
184+
185+
loglines.append(entry)
186+
except json.JSONDecodeError:
187+
continue
188+
189+
return {"loglines": loglines}
190+
191+
41192
class CredentialsError(Exception):
42193
"""Raised when credentials cannot be obtained."""
43194

@@ -730,9 +881,8 @@ def generate_html(json_path, output_dir, github_repo=None):
730881
output_dir = Path(output_dir)
731882
output_dir.mkdir(exist_ok=True)
732883

733-
# Load JSON file
734-
with open(json_path, "r") as f:
735-
data = json.load(f)
884+
# Load session file (supports both JSON and JSONL)
885+
data = parse_session_file(json_path)
736886

737887
loglines = data.get("loglines", [])
738888

@@ -920,13 +1070,64 @@ def generate_html(json_path, output_dir, github_repo=None):
9201070
)
9211071

9221072

923-
@click.group(cls=DefaultGroup, default="session", default_if_no_args=False)
1073+
@click.group(cls=DefaultGroup, default="list-local", default_if_no_args=True)
9241074
@click.version_option(None, "-v", "--version", package_name="claude-code-publish")
9251075
def cli():
9261076
"""Convert Claude Code session JSON to mobile-friendly HTML pages."""
9271077
pass
9281078

9291079

1080+
@cli.command("list-local")
1081+
@click.option(
1082+
"--limit",
1083+
default=10,
1084+
help="Maximum number of sessions to show (default: 10)",
1085+
)
1086+
def list_local(limit):
1087+
"""List available local Claude Code sessions."""
1088+
projects_folder = Path.home() / ".claude" / "projects"
1089+
1090+
if not projects_folder.exists():
1091+
click.echo(f"Projects folder not found: {projects_folder}")
1092+
click.echo("No local Claude Code sessions available.")
1093+
return
1094+
1095+
click.echo("Loading local sessions...")
1096+
results = find_local_sessions(projects_folder, limit=limit)
1097+
1098+
if not results:
1099+
click.echo("No local sessions found.")
1100+
return
1101+
1102+
# Calculate terminal width for formatting
1103+
try:
1104+
term_width = shutil.get_terminal_size().columns
1105+
except Exception:
1106+
term_width = 80
1107+
1108+
# Fixed width: date(16) + spaces(2) + size(8) + spaces(2) = 28
1109+
fixed_width = 28
1110+
summary_width = max(20, term_width - fixed_width - 1)
1111+
1112+
click.echo("")
1113+
click.echo("Recent local sessions:")
1114+
click.echo("")
1115+
1116+
from datetime import datetime
1117+
1118+
for filepath, summary in results:
1119+
stat = filepath.stat()
1120+
mod_time = datetime.fromtimestamp(stat.st_mtime)
1121+
size_kb = stat.st_size / 1024
1122+
date_str = mod_time.strftime("%Y-%m-%d %H:%M")
1123+
1124+
# Truncate summary if needed
1125+
if len(summary) > summary_width:
1126+
summary = summary[: summary_width - 3] + "..."
1127+
1128+
click.echo(f"{date_str} {size_kb:6.0f} KB {summary}")
1129+
1130+
9301131
@cli.command()
9311132
@click.argument("json_file", type=click.Path(exists=True))
9321133
@click.option(

0 commit comments

Comments
 (0)