11"""Convert Claude Code session JSON to a clean mobile-friendly HTML page with pagination."""
22
3- import argparse
43import json
54import html
65import re
76from pathlib import Path
87
8+ import click
9+ from click_default_group import DefaultGroup
910import 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
1720PROMPTS_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
6065def 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
7380def 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
105114def 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
124135def 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
135150def 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; }
337362body { 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 = """
454479document.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
485510def 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
506534def 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
523551def 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+
702758def 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