Skip to content

Commit c1ad777

Browse files
committed
Move to click and click-default-group for argument parsing
1 parent caf7c93 commit c1ad777

File tree

3 files changed

+164
-89
lines changed

3 files changed

+164
-89
lines changed

pyproject.toml

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,11 @@ authors = [
88
{ name = "Simon Willison" }
99
]
1010
requires-python = ">=3.10"
11-
dependencies = ["markdown"]
11+
dependencies = [
12+
"click",
13+
"click-default-group",
14+
"markdown",
15+
]
1216

1317
[project.urls]
1418
Homepage = "https://github.com/simonw/claude-code-publish"

src/claude_code_publish/__init__.py

Lines changed: 98 additions & 59 deletions
Original file line numberDiff line numberDiff line change
@@ -1,21 +1,26 @@
11
"""Convert Claude Code session JSON to a clean mobile-friendly HTML page with pagination."""
22

3-
import argparse
43
import json
54
import html
65
import re
76
from pathlib import Path
87

8+
import click
9+
from click_default_group import DefaultGroup
910
import markdown
1011

1112
# Regex to match git commit output: [branch hash] message
12-
COMMIT_PATTERN = re.compile(r'\[[\w\-/]+ ([a-f0-9]{7,})\] (.+?)(?:\n|$)')
13+
COMMIT_PATTERN = re.compile(r"\[[\w\-/]+ ([a-f0-9]{7,})\] (.+?)(?:\n|$)")
1314

1415
# Regex to detect GitHub repo from git push output (e.g., github.com/owner/repo/pull/new/branch)
15-
GITHUB_REPO_PATTERN = re.compile(r'github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/new/')
16+
GITHUB_REPO_PATTERN = re.compile(
17+
r"github\.com/([a-zA-Z0-9_-]+/[a-zA-Z0-9_-]+)/pull/new/"
18+
)
1619

1720
PROMPTS_PER_PAGE = 5
18-
LONG_TEXT_THRESHOLD = 1000 # Characters - text blocks longer than this are shown in index
21+
LONG_TEXT_THRESHOLD = (
22+
1000 # Characters - text blocks longer than this are shown in index
23+
)
1924

2025
# Module-level variable for GitHub repo (set by generate_html)
2126
_github_repo = None
@@ -54,7 +59,7 @@ def format_json(obj):
5459
formatted = json.dumps(obj, indent=2, ensure_ascii=False)
5560
return f'<pre class="json">{html.escape(formatted)}</pre>'
5661
except (json.JSONDecodeError, TypeError):
57-
return f'<pre>{html.escape(str(obj))}</pre>'
62+
return f"<pre>{html.escape(str(obj))}</pre>"
5863

5964

6065
def render_markdown_text(text):
@@ -67,7 +72,9 @@ def is_json_like(text):
6772
if not text or not isinstance(text, str):
6873
return False
6974
text = text.strip()
70-
return (text.startswith("{") and text.endswith("}")) or (text.startswith("[") and text.endswith("]"))
75+
return (text.startswith("{") and text.endswith("}")) or (
76+
text.startswith("[") and text.endswith("]")
77+
)
7178

7279

7380
def render_todo_write(tool_input, tool_id):
@@ -84,7 +91,9 @@ def render_todo_write(tool_input, tool_id):
8491
icon, status_class = "→", "todo-in-progress"
8592
else:
8693
icon, status_class = "○", "todo-pending"
87-
items_html.append(f'<li class="todo-item {status_class}"><span class="todo-icon">{icon}</span><span class="todo-content">{html.escape(content)}</span></li>')
94+
items_html.append(
95+
f'<li class="todo-item {status_class}"><span class="todo-icon">{icon}</span><span class="todo-content">{html.escape(content)}</span></li>'
96+
)
8897
return f'<div class="todo-list" data-tool-id="{html.escape(tool_id)}"><div class="todo-header"><span class="todo-header-icon">☰</span> Task List</div><ul class="todo-items">{"".join(items_html)}</ul></div>'
8998

9099

@@ -95,11 +104,11 @@ def render_write_tool(tool_input, tool_id):
95104
# Extract filename from path
96105
filename = file_path.split("/")[-1] if "/" in file_path else file_path
97106
content_preview = html.escape(content)
98-
return f'''<div class="file-tool write-tool" data-tool-id="{html.escape(tool_id)}">
107+
return f"""<div class="file-tool write-tool" data-tool-id="{html.escape(tool_id)}">
99108
<div class="file-tool-header write-header"><span class="file-tool-icon">📝</span> Write <span class="file-tool-path">{html.escape(filename)}</span></div>
100109
<div class="file-tool-fullpath">{html.escape(file_path)}</div>
101110
<div class="truncatable"><div class="truncatable-content"><pre class="file-content">{content_preview}</pre></div><button class="expand-btn">Show more</button></div>
102-
</div>'''
111+
</div>"""
103112

104113

105114
def render_edit_tool(tool_input, tool_id):
@@ -110,26 +119,32 @@ def render_edit_tool(tool_input, tool_id):
110119
replace_all = tool_input.get("replace_all", False)
111120
# Extract filename from path
112121
filename = file_path.split("/")[-1] if "/" in file_path else file_path
113-
replace_note = ' <span class="edit-replace-all">(replace all)</span>' if replace_all else ""
114-
return f'''<div class="file-tool edit-tool" data-tool-id="{html.escape(tool_id)}">
122+
replace_note = (
123+
' <span class="edit-replace-all">(replace all)</span>' if replace_all else ""
124+
)
125+
return f"""<div class="file-tool edit-tool" data-tool-id="{html.escape(tool_id)}">
115126
<div class="file-tool-header edit-header"><span class="file-tool-icon">✏️</span> Edit <span class="file-tool-path">{html.escape(filename)}</span>{replace_note}</div>
116127
<div class="file-tool-fullpath">{html.escape(file_path)}</div>
117128
<div class="truncatable"><div class="truncatable-content">
118129
<div class="edit-section edit-old"><div class="edit-label">−</div><pre class="edit-content">{html.escape(old_string)}</pre></div>
119130
<div class="edit-section edit-new"><div class="edit-label">+</div><pre class="edit-content">{html.escape(new_string)}</pre></div>
120131
</div><button class="expand-btn">Show more</button></div>
121-
</div>'''
132+
</div>"""
122133

123134

124135
def render_bash_tool(tool_input, tool_id):
125136
"""Render Bash tool calls with command as plain text."""
126137
command = tool_input.get("command", "")
127138
description = tool_input.get("description", "")
128-
desc_html = f'<div class="tool-description">{html.escape(description)}</div>' if description else ""
129-
return f'''<div class="tool-use bash-tool" data-tool-id="{html.escape(tool_id)}">
139+
desc_html = (
140+
f'<div class="tool-description">{html.escape(description)}</div>'
141+
if description
142+
else ""
143+
)
144+
return f"""<div class="tool-use bash-tool" data-tool-id="{html.escape(tool_id)}">
130145
<div class="tool-header"><span class="tool-icon">$</span> Bash</div>
131146
{desc_html}<div class="truncatable"><div class="truncatable-content"><pre class="bash-command">{html.escape(command)}</pre></div><button class="expand-btn">Show more</button></div>
132-
</div>'''
147+
</div>"""
133148

134149

135150
def render_content_block(block):
@@ -153,7 +168,11 @@ def render_content_block(block):
153168
if tool_name == "Bash":
154169
return render_bash_tool(tool_input, tool_id)
155170
description = tool_input.get("description", "")
156-
desc_html = f'<div class="tool-description">{html.escape(description)}</div>' if description else ""
171+
desc_html = (
172+
f'<div class="tool-description">{html.escape(description)}</div>'
173+
if description
174+
else ""
175+
)
157176
display_input = {k: v for k, v in tool_input.items() if k != "description"}
158177
return f'<div class="tool-use" data-tool-id="{html.escape(tool_id)}"><div class="tool-header"><span class="tool-icon">⚙</span> {html.escape(tool_name)}</div>{desc_html}<div class="truncatable"><div class="truncatable-content">{format_json(display_input)}</div><button class="expand-btn">Show more</button></div></div>'
159178
elif block_type == "tool_result":
@@ -170,25 +189,31 @@ def render_content_block(block):
170189
last_end = 0
171190
for match in commits_found:
172191
# Add any content before this commit
173-
before = content[last_end:match.start()].strip()
192+
before = content[last_end : match.start()].strip()
174193
if before:
175-
parts.append(f'<pre>{html.escape(before)}</pre>')
194+
parts.append(f"<pre>{html.escape(before)}</pre>")
176195

177196
commit_hash = match.group(1)
178197
commit_msg = match.group(2)
179198
if _github_repo:
180-
github_link = f'https://github.com/{_github_repo}/commit/{commit_hash}'
181-
parts.append(f'<div class="commit-card"><a href="{github_link}"><span class="commit-card-hash">{commit_hash[:7]}</span> {html.escape(commit_msg)}</a></div>')
199+
github_link = (
200+
f"https://github.com/{_github_repo}/commit/{commit_hash}"
201+
)
202+
parts.append(
203+
f'<div class="commit-card"><a href="{github_link}"><span class="commit-card-hash">{commit_hash[:7]}</span> {html.escape(commit_msg)}</a></div>'
204+
)
182205
else:
183-
parts.append(f'<div class="commit-card"><span class="commit-card-hash">{commit_hash[:7]}</span> {html.escape(commit_msg)}</div>')
206+
parts.append(
207+
f'<div class="commit-card"><span class="commit-card-hash">{commit_hash[:7]}</span> {html.escape(commit_msg)}</div>'
208+
)
184209
last_end = match.end()
185210

186211
# Add any remaining content after last commit
187212
after = content[last_end:].strip()
188213
if after:
189-
parts.append(f'<pre>{html.escape(after)}</pre>')
214+
parts.append(f"<pre>{html.escape(after)}</pre>")
190215

191-
content_html = ''.join(parts)
216+
content_html = "".join(parts)
192217
else:
193218
content_html = f"<pre>{html.escape(content)}</pre>"
194219
elif isinstance(content, list) or is_json_like(content):
@@ -331,7 +356,7 @@ def render_message(log_type, message_json, timestamp):
331356
return f'<div class="message {role_class}" id="{html.escape(msg_id)}"><div class="message-header"><span class="role-label">{role_label}</span><a href="#{html.escape(msg_id)}" class="timestamp-link"><time datetime="{html.escape(timestamp)}" data-timestamp="{html.escape(timestamp)}">{html.escape(timestamp)}</time></a></div><div class="message-content">{content_html}</div></div>'
332357

333358

334-
CSS = '''
359+
CSS = """
335360
:root { --bg-color: #f5f5f5; --card-bg: #ffffff; --user-bg: #e3f2fd; --user-border: #1976d2; --assistant-bg: #f5f5f5; --assistant-border: #9e9e9e; --thinking-bg: #fff8e1; --thinking-border: #ffc107; --thinking-text: #666; --tool-bg: #f3e5f5; --tool-border: #9c27b0; --tool-result-bg: #e8f5e9; --tool-error-bg: #ffebee; --text-color: #212121; --text-muted: #757575; --code-bg: #263238; --code-text: #aed581; }
336361
* { box-sizing: border-box; }
337362
body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif; background: var(--bg-color); color: var(--text-color); margin: 0; padding: 16px; line-height: 1.6; }
@@ -448,9 +473,9 @@ def render_message(log_type, message_json, timestamp):
448473
.index-item-long-text .truncatable.truncated::after { background: linear-gradient(to bottom, transparent, var(--card-bg)); }
449474
.index-item-long-text-content { color: var(--text-color); }
450475
@media (max-width: 600px) { body { padding: 8px; } .message, .index-item { border-radius: 8px; } .message-content, .index-item-content { padding: 12px; } pre { font-size: 0.8rem; padding: 8px; } }
451-
'''
476+
"""
452477

453-
JS = '''
478+
JS = """
454479
document.querySelectorAll('time[data-timestamp]').forEach(function(el) {
455480
const timestamp = el.getAttribute('data-timestamp');
456481
const date = new Date(timestamp);
@@ -479,13 +504,16 @@ def render_message(log_type, message_json, timestamp):
479504
});
480505
}
481506
});
482-
'''
507+
"""
483508

484509

485510
def generate_pagination_html(current_page, total_pages):
486511
if total_pages <= 1:
487512
return '<div class="pagination"><a href="index.html" class="index-link">Index</a></div>'
488-
parts = ['<div class="pagination">', '<a href="index.html" class="index-link">Index</a>']
513+
parts = [
514+
'<div class="pagination">',
515+
'<a href="index.html" class="index-link">Index</a>',
516+
]
489517
if current_page > 1:
490518
parts.append(f'<a href="page-{current_page-1:03d}.html">← Prev</a>')
491519
else:
@@ -499,8 +527,8 @@ def generate_pagination_html(current_page, total_pages):
499527
parts.append(f'<a href="page-{current_page+1:03d}.html">Next →</a>')
500528
else:
501529
parts.append('<span class="disabled">Next →</span>')
502-
parts.append('</div>')
503-
return '\n'.join(parts)
530+
parts.append("</div>")
531+
return "\n".join(parts)
504532

505533

506534
def generate_index_pagination_html(total_pages):
@@ -516,8 +544,8 @@ def generate_index_pagination_html(total_pages):
516544
parts.append('<a href="page-001.html">Next →</a>')
517545
else:
518546
parts.append('<span class="disabled">Next →</span>')
519-
parts.append('</div>')
520-
return '\n'.join(parts)
547+
parts.append("</div>")
548+
return "\n".join(parts)
521549

522550

523551
def generate_html(json_path, output_dir, github_repo=None):
@@ -536,7 +564,9 @@ def generate_html(json_path, output_dir, github_repo=None):
536564
if github_repo:
537565
print(f"Auto-detected GitHub repo: {github_repo}")
538566
else:
539-
print("Warning: Could not auto-detect GitHub repo. Commit links will be disabled.")
567+
print(
568+
"Warning: Could not auto-detect GitHub repo. Commit links will be disabled."
569+
)
540570

541571
# Set module-level variable for render functions
542572
global _github_repo
@@ -593,7 +623,7 @@ def generate_html(json_path, output_dir, github_repo=None):
593623
messages_html.append(msg_html)
594624
is_first = False
595625
pagination_html = generate_pagination_html(page_num, total_pages)
596-
page_content = f'''<!DOCTYPE html>
626+
page_content = f"""<!DOCTYPE html>
597627
<html lang="en">
598628
<head>
599629
<meta charset="UTF-8">
@@ -610,7 +640,7 @@ def generate_html(json_path, output_dir, github_repo=None):
610640
</div>
611641
<script>{JS}</script>
612642
</body>
613-
</html>'''
643+
</html>"""
614644
(output_dir / f"page-{page_num:03d}.html").write_text(page_content)
615645
print(f"Generated page-{page_num:03d}.html")
616646

@@ -656,8 +686,10 @@ def generate_html(json_path, output_dir, github_repo=None):
656686
rendered_lt = render_markdown_text(lt)
657687
long_texts_html += f'<div class="index-item-long-text"><div class="truncatable"><div class="truncatable-content"><div class="index-item-long-text-content">{rendered_lt}</div></div><button class="expand-btn">Show more</button></div></div>'
658688

659-
stats_line = f'<span>{tool_stats_str}</span>' if tool_stats_str else ""
660-
stats_html = f'<div class="index-item-stats">{stats_line}{long_texts_html}</div>'
689+
stats_line = f"<span>{tool_stats_str}</span>" if tool_stats_str else ""
690+
stats_html = (
691+
f'<div class="index-item-stats">{stats_line}{long_texts_html}</div>'
692+
)
661693

662694
item_html = f'<div class="index-item"><a href="{html.escape(link)}"><div class="index-item-header"><span class="index-item-number">#{prompt_num}</span><time datetime="{html.escape(conv["timestamp"])}" data-timestamp="{html.escape(conv["timestamp"])}">{html.escape(conv["timestamp"])}</time></div><div class="index-item-content">{rendered_content}</div></a>{stats_html}</div>'
663695
timeline_items.append((conv["timestamp"], "prompt", item_html))
@@ -666,17 +698,17 @@ def generate_html(json_path, output_dir, github_repo=None):
666698
for commit_ts, commit_hash, commit_msg, page_num, conv_idx in all_commits:
667699
if _github_repo:
668700
github_link = f"https://github.com/{_github_repo}/commit/{commit_hash}"
669-
item_html = f'''<div class="index-commit"><a href="{github_link}"><div class="index-commit-header"><span class="index-commit-hash">{commit_hash[:7]}</span><time datetime="{html.escape(commit_ts)}" data-timestamp="{html.escape(commit_ts)}">{html.escape(commit_ts)}</time></div><div class="index-commit-msg">{html.escape(commit_msg)}</div></a></div>'''
701+
item_html = f"""<div class="index-commit"><a href="{github_link}"><div class="index-commit-header"><span class="index-commit-hash">{commit_hash[:7]}</span><time datetime="{html.escape(commit_ts)}" data-timestamp="{html.escape(commit_ts)}">{html.escape(commit_ts)}</time></div><div class="index-commit-msg">{html.escape(commit_msg)}</div></a></div>"""
670702
else:
671-
item_html = f'''<div class="index-commit"><div class="index-commit-header"><span class="index-commit-hash">{commit_hash[:7]}</span><time datetime="{html.escape(commit_ts)}" data-timestamp="{html.escape(commit_ts)}">{html.escape(commit_ts)}</time></div><div class="index-commit-msg">{html.escape(commit_msg)}</div></div>'''
703+
item_html = f"""<div class="index-commit"><div class="index-commit-header"><span class="index-commit-hash">{commit_hash[:7]}</span><time datetime="{html.escape(commit_ts)}" data-timestamp="{html.escape(commit_ts)}">{html.escape(commit_ts)}</time></div><div class="index-commit-msg">{html.escape(commit_msg)}</div></div>"""
672704
timeline_items.append((commit_ts, "commit", item_html))
673705

674706
# Sort by timestamp
675707
timeline_items.sort(key=lambda x: x[0])
676708
index_items = [item[2] for item in timeline_items]
677709

678710
index_pagination = generate_index_pagination_html(total_pages)
679-
index_content = f'''<!DOCTYPE html>
711+
index_content = f"""<!DOCTYPE html>
680712
<html lang="en">
681713
<head>
682714
<meta charset="UTF-8">
@@ -694,27 +726,34 @@ def generate_html(json_path, output_dir, github_repo=None):
694726
</div>
695727
<script>{JS}</script>
696728
</body>
697-
</html>'''
729+
</html>"""
698730
(output_dir / "index.html").write_text(index_content)
699731
print(f"Generated index.html ({total_convs} prompts, {total_pages} pages)")
700732

701733

734+
@click.group(cls=DefaultGroup, default="session", default_if_no_args=False)
735+
def cli():
736+
"""Convert Claude Code session JSON to mobile-friendly HTML pages."""
737+
pass
738+
739+
740+
@cli.command()
741+
@click.argument("json_file", type=click.Path(exists=True))
742+
@click.option(
743+
"-o",
744+
"--output",
745+
default=".",
746+
type=click.Path(),
747+
help="Output directory (default: current directory)",
748+
)
749+
@click.option(
750+
"--repo",
751+
help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified.",
752+
)
753+
def session(json_file, output, repo):
754+
"""Convert a Claude Code session JSON file to HTML."""
755+
generate_html(json_file, output, github_repo=repo)
756+
757+
702758
def main():
703-
parser = argparse.ArgumentParser(
704-
description="Convert Claude Code session JSON to mobile-friendly HTML pages."
705-
)
706-
parser.add_argument(
707-
"json_file",
708-
help="Path to the Claude Code session JSON file"
709-
)
710-
parser.add_argument(
711-
"-o", "--output",
712-
default=".",
713-
help="Output directory (default: current directory)"
714-
)
715-
parser.add_argument(
716-
"--repo",
717-
help="GitHub repo (owner/name) for commit links. Auto-detected from git push output if not specified."
718-
)
719-
args = parser.parse_args()
720-
generate_html(args.json_file, args.output, github_repo=args.repo)
759+
cli()

0 commit comments

Comments
 (0)