From 8fb04d7cdf47e422906426b45bb4c16639715bc6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 06:28:55 +0000 Subject: [PATCH 1/5] Add SQLite database module for transcript storage Introduces db.py with schema creation, session import with dedup, page retrieval, and project/session listing queries. Uses stdlib sqlite3 with WAL mode for future Turso compatibility. https://claude.ai/code/session_019CmHK1C5R4AHwmLAsw2PEt --- src/claude_code_transcripts/db.py | 195 ++++++++++++++++ tests/test_db.py | 364 ++++++++++++++++++++++++++++++ 2 files changed, 559 insertions(+) create mode 100644 src/claude_code_transcripts/db.py create mode 100644 tests/test_db.py diff --git a/src/claude_code_transcripts/db.py b/src/claude_code_transcripts/db.py new file mode 100644 index 0000000..d6b0cec --- /dev/null +++ b/src/claude_code_transcripts/db.py @@ -0,0 +1,195 @@ +"""SQLite database operations for storing Claude Code transcripts.""" + +import sqlite3 +from pathlib import Path + +DEFAULT_DB_PATH = Path.home() / ".claude" / "transcripts.db" + +SCHEMA_SQL = """ +CREATE TABLE IF NOT EXISTS sessions ( + session_id TEXT PRIMARY KEY, + project_name TEXT NOT NULL, + project_key TEXT NOT NULL, + summary TEXT, + session_json TEXT NOT NULL, + github_repo TEXT, + total_pages INTEGER NOT NULL DEFAULT 0, + total_prompts INTEGER NOT NULL DEFAULT 0, + total_messages INTEGER NOT NULL DEFAULT 0, + total_tool_calls INTEGER NOT NULL DEFAULT 0, + total_commits INTEGER NOT NULL DEFAULT 0, + file_mtime REAL, + file_size INTEGER, + created_at TEXT, + imported_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE TABLE IF NOT EXISTS session_pages ( + session_id TEXT NOT NULL, + page_name TEXT NOT NULL, + html_content TEXT NOT NULL, + PRIMARY KEY (session_id, page_name), + FOREIGN KEY (session_id) REFERENCES sessions(session_id) +); +""" + + +def get_db(db_path=None): + """Open or create the SQLite database and ensure schema exists. + + Returns a sqlite3.Connection with WAL mode enabled. + """ + if db_path is None: + db_path = DEFAULT_DB_PATH + db_path = Path(db_path) + db_path.parent.mkdir(parents=True, exist_ok=True) + + conn = sqlite3.connect(str(db_path)) + conn.execute("PRAGMA journal_mode=WAL") + conn.execute("PRAGMA foreign_keys=ON") + conn.executescript(SCHEMA_SQL) + conn.commit() + return conn + + +def session_exists(conn, session_id): + """Check if a session already exists in the database.""" + cursor = conn.execute("SELECT 1 FROM sessions WHERE session_id = ?", (session_id,)) + return cursor.fetchone() is not None + + +def import_session( + conn, + session_id, + project_name, + project_key, + summary, + session_json, + pages, + meta, + file_mtime=None, + file_size=None, + created_at=None, +): + """Import a session and its HTML pages into the database. + + Skips if session_id already exists (dedup). + + Args: + conn: sqlite3.Connection + session_id: Unique session identifier (filename stem) + project_name: Human-readable project name + project_key: Raw folder name for the project + summary: First user message preview + session_json: Raw JSONL content + pages: dict of {page_name: html_content} + meta: dict with total_pages, total_prompts, total_messages, + total_tool_calls, total_commits, github_repo + file_mtime: Original file modification time (unix timestamp) + file_size: Original file size in bytes + created_at: Earliest timestamp from session + """ + if session_exists(conn, session_id): + return + + conn.execute( + """INSERT INTO sessions + (session_id, project_name, project_key, summary, session_json, + github_repo, total_pages, total_prompts, total_messages, + total_tool_calls, total_commits, file_mtime, file_size, created_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)""", + ( + session_id, + project_name, + project_key, + summary, + session_json, + meta.get("github_repo"), + meta.get("total_pages", 0), + meta.get("total_prompts", 0), + meta.get("total_messages", 0), + meta.get("total_tool_calls", 0), + meta.get("total_commits", 0), + file_mtime, + file_size, + created_at, + ), + ) + + for page_name, html_content in pages.items(): + conn.execute( + "INSERT INTO session_pages (session_id, page_name, html_content) VALUES (?, ?, ?)", + (session_id, page_name, html_content), + ) + + conn.commit() + + +def get_session_page(conn, session_id, page_name): + """Fetch a single HTML page for a session. + + Returns the HTML content string, or None if not found. + """ + cursor = conn.execute( + "SELECT html_content FROM session_pages WHERE session_id = ? AND page_name = ?", + (session_id, page_name), + ) + row = cursor.fetchone() + return row[0] if row else None + + +def list_projects(conn): + """List all projects with session counts. + + Returns a list of dicts with keys: name, project_key, session_count, latest_import. + Sorted by most recent import. + """ + cursor = conn.execute( + """SELECT project_name, project_key, COUNT(*) as session_count, + MAX(imported_at) as latest_import + FROM sessions + GROUP BY project_name + ORDER BY latest_import DESC""" + ) + return [ + { + "name": row[0], + "project_key": row[1], + "session_count": row[2], + "latest_import": row[3], + } + for row in cursor.fetchall() + ] + + +def list_sessions(conn, project_name): + """List all sessions for a given project. + + Returns a list of dicts sorted by imported_at descending. + """ + cursor = conn.execute( + """SELECT session_id, summary, total_pages, total_prompts, + total_messages, total_tool_calls, total_commits, + github_repo, file_mtime, file_size, created_at, imported_at + FROM sessions + WHERE project_name = ? + ORDER BY imported_at DESC""", + (project_name,), + ) + return [ + { + "session_id": row[0], + "summary": row[1], + "total_pages": row[2], + "total_prompts": row[3], + "total_messages": row[4], + "total_tool_calls": row[5], + "total_commits": row[6], + "github_repo": row[7], + "file_mtime": row[8], + "file_size": row[9], + "created_at": row[10], + "imported_at": row[11], + } + for row in cursor.fetchall() + ] diff --git a/tests/test_db.py b/tests/test_db.py new file mode 100644 index 0000000..77922b2 --- /dev/null +++ b/tests/test_db.py @@ -0,0 +1,364 @@ +"""Tests for SQLite database operations.""" + +import sqlite3 +import tempfile +from pathlib import Path + +import pytest + +from claude_code_transcripts.db import ( + get_db, + session_exists, + import_session, + get_session_page, + list_projects, + list_sessions, +) + + +@pytest.fixture +def db_path(): + """Create a temporary database file.""" + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "test.db" + + +@pytest.fixture +def db(db_path): + """Create and return a database connection.""" + conn = get_db(db_path) + yield conn + conn.close() + + +class TestGetDb: + def test_creates_database_file(self, db_path): + conn = get_db(db_path) + assert db_path.exists() + conn.close() + + def test_creates_sessions_table(self, db): + cursor = db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='sessions'" + ) + assert cursor.fetchone() is not None + + def test_creates_session_pages_table(self, db): + cursor = db.execute( + "SELECT name FROM sqlite_master WHERE type='table' AND name='session_pages'" + ) + assert cursor.fetchone() is not None + + def test_enables_wal_mode(self, db): + cursor = db.execute("PRAGMA journal_mode") + mode = cursor.fetchone()[0] + assert mode == "wal" + + def test_idempotent(self, db_path): + """Calling get_db twice on the same path doesn't error.""" + conn1 = get_db(db_path) + conn1.close() + conn2 = get_db(db_path) + conn2.close() + + def test_creates_parent_directories(self): + with tempfile.TemporaryDirectory() as tmpdir: + deep_path = Path(tmpdir) / "a" / "b" / "test.db" + conn = get_db(deep_path) + assert deep_path.exists() + conn.close() + + +class TestSessionExists: + def test_returns_false_for_missing_session(self, db): + assert session_exists(db, "nonexistent") is False + + def test_returns_true_for_existing_session(self, db): + import_session( + db, + session_id="abc123", + project_name="myproject", + project_key="-home-user-projects-myproject", + summary="Hello world", + session_json='{"type": "user"}', + pages={"index.html": "index"}, + meta={ + "total_pages": 1, + "total_prompts": 1, + "total_messages": 2, + "total_tool_calls": 0, + "total_commits": 0, + "github_repo": None, + }, + ) + assert session_exists(db, "abc123") is True + + +class TestImportSession: + def test_inserts_session_row(self, db): + import_session( + db, + session_id="abc123", + project_name="myproject", + project_key="-home-user-projects-myproject", + summary="Hello world", + session_json='{"type": "user"}', + pages={"index.html": "index"}, + meta={ + "total_pages": 1, + "total_prompts": 1, + "total_messages": 2, + "total_tool_calls": 5, + "total_commits": 1, + "github_repo": "owner/repo", + }, + ) + row = db.execute( + "SELECT session_id, project_name, project_key, summary, session_json, " + "github_repo, total_pages, total_prompts, total_messages, total_tool_calls, total_commits " + "FROM sessions WHERE session_id = ?", + ("abc123",), + ).fetchone() + assert row is not None + assert row[0] == "abc123" + assert row[1] == "myproject" + assert row[2] == "-home-user-projects-myproject" + assert row[3] == "Hello world" + assert row[4] == '{"type": "user"}' + assert row[5] == "owner/repo" + assert row[6] == 1 # total_pages + assert row[7] == 1 # total_prompts + assert row[8] == 2 # total_messages + assert row[9] == 5 # total_tool_calls + assert row[10] == 1 # total_commits + + def test_inserts_page_rows(self, db): + pages = { + "index.html": "index", + "page-001.html": "page 1", + "page-002.html": "page 2", + } + import_session( + db, + session_id="abc123", + project_name="myproject", + project_key="-home-user-projects-myproject", + summary="Hello", + session_json="{}", + pages=pages, + meta={ + "total_pages": 2, + "total_prompts": 1, + "total_messages": 2, + "total_tool_calls": 0, + "total_commits": 0, + "github_repo": None, + }, + ) + rows = db.execute( + "SELECT page_name, html_content FROM session_pages WHERE session_id = ? ORDER BY page_name", + ("abc123",), + ).fetchall() + assert len(rows) == 3 + assert rows[0] == ("index.html", "index") + assert rows[1] == ("page-001.html", "page 1") + assert rows[2] == ("page-002.html", "page 2") + + def test_skips_duplicate_session(self, db): + """Importing the same session_id twice should skip the second import.""" + kwargs = dict( + session_id="abc123", + project_name="myproject", + project_key="-home-user-projects-myproject", + summary="Hello", + session_json="{}", + pages={"index.html": "v1"}, + meta={ + "total_pages": 1, + "total_prompts": 1, + "total_messages": 2, + "total_tool_calls": 0, + "total_commits": 0, + "github_repo": None, + }, + ) + import_session(db, **kwargs) + # Second import with different content should be skipped + kwargs["pages"] = {"index.html": "v2"} + import_session(db, **kwargs) + + # Should still have original content + html = get_session_page(db, "abc123", "index.html") + assert html == "v1" + + def test_stores_optional_metadata(self, db): + import_session( + db, + session_id="abc123", + project_name="myproject", + project_key="key", + summary="Hello", + session_json="{}", + pages={"index.html": ""}, + meta={ + "total_pages": 1, + "total_prompts": 1, + "total_messages": 2, + "total_tool_calls": 0, + "total_commits": 0, + "github_repo": None, + }, + file_mtime=1704067200.0, + file_size=1024, + created_at="2025-01-01T10:00:00.000Z", + ) + row = db.execute( + "SELECT file_mtime, file_size, created_at FROM sessions WHERE session_id = ?", + ("abc123",), + ).fetchone() + assert row[0] == 1704067200.0 + assert row[1] == 1024 + assert row[2] == "2025-01-01T10:00:00.000Z" + + +class TestGetSessionPage: + def test_returns_html_content(self, db): + import_session( + db, + session_id="abc123", + project_name="myproject", + project_key="key", + summary="Hello", + session_json="{}", + pages={ + "index.html": "index", + "page-001.html": "page 1", + }, + meta={ + "total_pages": 1, + "total_prompts": 1, + "total_messages": 2, + "total_tool_calls": 0, + "total_commits": 0, + "github_repo": None, + }, + ) + assert get_session_page(db, "abc123", "index.html") == "index" + assert get_session_page(db, "abc123", "page-001.html") == "page 1" + + def test_returns_none_for_missing_page(self, db): + assert get_session_page(db, "abc123", "index.html") is None + + def test_returns_none_for_missing_session(self, db): + assert get_session_page(db, "nonexistent", "index.html") is None + + +class TestListProjects: + def test_returns_empty_for_empty_db(self, db): + assert list_projects(db) == [] + + def test_returns_projects_with_session_counts(self, db): + for sid, pname, pkey in [ + ("s1", "project-a", "key-a"), + ("s2", "project-a", "key-a"), + ("s3", "project-b", "key-b"), + ]: + import_session( + db, + session_id=sid, + project_name=pname, + project_key=pkey, + summary="Hello", + session_json="{}", + pages={"index.html": ""}, + meta={ + "total_pages": 1, + "total_prompts": 1, + "total_messages": 2, + "total_tool_calls": 0, + "total_commits": 0, + "github_repo": None, + }, + ) + + projects = list_projects(db) + assert len(projects) == 2 + # Should be sorted by most recent import + names = [p["name"] for p in projects] + assert "project-a" in names + assert "project-b" in names + # Check session counts + proj_a = next(p for p in projects if p["name"] == "project-a") + proj_b = next(p for p in projects if p["name"] == "project-b") + assert proj_a["session_count"] == 2 + assert proj_b["session_count"] == 1 + + +class TestListSessions: + def test_returns_empty_for_no_sessions(self, db): + assert list_sessions(db, "nonexistent") == [] + + def test_returns_sessions_for_project(self, db): + import_session( + db, + session_id="s1", + project_name="myproject", + project_key="key", + summary="First session", + session_json="{}", + pages={"index.html": ""}, + meta={ + "total_pages": 1, + "total_prompts": 3, + "total_messages": 10, + "total_tool_calls": 5, + "total_commits": 2, + "github_repo": "owner/repo", + }, + file_size=2048, + ) + import_session( + db, + session_id="s2", + project_name="myproject", + project_key="key", + summary="Second session", + session_json="{}", + pages={"index.html": ""}, + meta={ + "total_pages": 1, + "total_prompts": 1, + "total_messages": 4, + "total_tool_calls": 0, + "total_commits": 0, + "github_repo": None, + }, + file_size=512, + ) + + sessions = list_sessions(db, "myproject") + assert len(sessions) == 2 + s1 = next(s for s in sessions if s["session_id"] == "s1") + assert s1["summary"] == "First session" + assert s1["total_prompts"] == 3 + assert s1["file_size"] == 2048 + + def test_does_not_return_sessions_from_other_projects(self, db): + import_session( + db, + session_id="s1", + project_name="project-a", + project_key="key-a", + summary="A session", + session_json="{}", + pages={"index.html": ""}, + meta={ + "total_pages": 1, + "total_prompts": 1, + "total_messages": 2, + "total_tool_calls": 0, + "total_commits": 0, + "github_repo": None, + }, + ) + assert list_sessions(db, "project-b") == [] From 068d182fc9e1827faf00d140a5115e158226f980 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 06:30:40 +0000 Subject: [PATCH 2/5] Extract render_session_pages() to eliminate duplicate HTML generation Refactors generate_html() and generate_html_from_session_data() to both use a shared render_session_pages() that returns pages in memory as a dict. Also adds write_pages_to_dir() helper. This enables the upcoming SQLite storage flow where HTML is generated in memory and stored in the database. https://claude.ai/code/session_019CmHK1C5R4AHwmLAsw2PEt --- src/claude_code_transcripts/__init__.py | 250 +++++++----------------- 1 file changed, 74 insertions(+), 176 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index e4854a3..d3a2bae 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1295,24 +1295,23 @@ def generate_index_pagination_html(total_pages): return _macros.index_pagination(total_pages) -def generate_html(json_path, output_dir, github_repo=None): - output_dir = Path(output_dir) - output_dir.mkdir(exist_ok=True) +def render_session_pages(data, github_repo=None): + """Render session data to HTML pages in memory. - # Load session file (supports both JSON and JSONL) - data = parse_session_file(json_path) + Args: + data: Parsed session data dict with "loglines" key + github_repo: Optional "owner/repo" string for commit links + Returns dict with: + "pages": {"index.html": str, "page-001.html": str, ...} + "meta": {"total_pages": int, "total_prompts": int, "total_messages": int, + "total_tool_calls": int, "total_commits": int, "github_repo": str|None} + """ loglines = data.get("loglines", []) # Auto-detect GitHub repo if not provided if github_repo is None: github_repo = detect_github_repo(loglines) - if github_repo: - print(f"Auto-detected GitHub repo: {github_repo}") - else: - print( - "Warning: Could not auto-detect GitHub repo. Commit links will be disabled." - ) # Set module-level variable for render functions global _github_repo @@ -1354,6 +1353,8 @@ def generate_html(json_path, output_dir, github_repo=None): total_convs = len(conversations) total_pages = (total_convs + PROMPTS_PER_PAGE - 1) // PROMPTS_PER_PAGE + pages = {} + for page_num in range(1, total_pages + 1): start_idx = (page_num - 1) * PROMPTS_PER_PAGE end_idx = min(start_idx + PROMPTS_PER_PAGE, total_convs) @@ -1379,10 +1380,7 @@ def generate_html(json_path, output_dir, github_repo=None): pagination_html=pagination_html, messages_html="".join(messages_html), ) - (output_dir / f"page-{page_num:03d}.html").write_text( - page_content, encoding="utf-8" - ) - print(f"Generated page-{page_num:03d}.html") + pages[f"page-{page_num:03d}.html"] = page_content # Calculate overall stats and collect all commits for timeline total_tool_counts = {} @@ -1463,10 +1461,57 @@ def generate_html(json_path, output_dir, github_repo=None): total_pages=total_pages, index_items_html="".join(index_items), ) - index_path = output_dir / "index.html" - index_path.write_text(index_content, encoding="utf-8") + pages["index.html"] = index_content + + return { + "pages": pages, + "meta": { + "total_pages": total_pages, + "total_prompts": prompt_num, + "total_messages": total_messages, + "total_tool_calls": total_tool_calls, + "total_commits": total_commits, + "github_repo": github_repo, + }, + } + + +def write_pages_to_dir(pages, output_dir): + """Write rendered pages dict to an output directory.""" + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True, parents=True) + for filename, content in pages.items(): + (output_dir / filename).write_text(content, encoding="utf-8") + + +def generate_html(json_path, output_dir, github_repo=None): + output_dir = Path(output_dir) + output_dir.mkdir(exist_ok=True) + + # Load session file (supports both JSON and JSONL) + data = parse_session_file(json_path) + + # Auto-detect GitHub repo if not provided + if github_repo is None: + detected = detect_github_repo(data.get("loglines", [])) + if detected: + github_repo = detected + print(f"Auto-detected GitHub repo: {github_repo}") + else: + print( + "Warning: Could not auto-detect GitHub repo. Commit links will be disabled." + ) + + result = render_session_pages(data, github_repo=github_repo) + write_pages_to_dir(result["pages"], output_dir) + + total_convs = result["meta"]["total_prompts"] + total_pages = result["meta"]["total_pages"] + for page_name in sorted(result["pages"]): + if page_name != "index.html": + print(f"Generated {page_name}") print( - f"Generated {index_path.resolve()} ({total_convs} prompts, {total_pages} pages)" + f"Generated {(output_dir / 'index.html').resolve()} ({total_convs} prompts, {total_pages} pages)" ) @@ -1777,168 +1822,21 @@ def format_session_for_display(session_data): def generate_html_from_session_data(session_data, output_dir, github_repo=None): """Generate HTML from session data dict (instead of file path).""" - output_dir = Path(output_dir) - output_dir.mkdir(exist_ok=True, parents=True) - - loglines = session_data.get("loglines", []) - - # Auto-detect GitHub repo if not provided if github_repo is None: - github_repo = detect_github_repo(loglines) - if github_repo: + detected = detect_github_repo(session_data.get("loglines", [])) + if detected: + github_repo = detected click.echo(f"Auto-detected GitHub repo: {github_repo}") - # Set module-level variable for render functions - global _github_repo - _github_repo = github_repo - - conversations = [] - current_conv = None - for entry in loglines: - log_type = entry.get("type") - timestamp = entry.get("timestamp", "") - is_compact_summary = entry.get("isCompactSummary", False) - message_data = entry.get("message", {}) - if not message_data: - continue - # Convert message dict to JSON string for compatibility with existing render functions - message_json = json.dumps(message_data) - is_user_prompt = False - user_text = None - if log_type == "user": - content = message_data.get("content", "") - text = extract_text_from_content(content) - if text: - is_user_prompt = True - user_text = text - if is_user_prompt: - if current_conv: - conversations.append(current_conv) - current_conv = { - "user_text": user_text, - "timestamp": timestamp, - "messages": [(log_type, message_json, timestamp)], - "is_continuation": bool(is_compact_summary), - } - elif current_conv: - current_conv["messages"].append((log_type, message_json, timestamp)) - if current_conv: - conversations.append(current_conv) - - total_convs = len(conversations) - total_pages = (total_convs + PROMPTS_PER_PAGE - 1) // PROMPTS_PER_PAGE + result = render_session_pages(session_data, github_repo=github_repo) + write_pages_to_dir(result["pages"], output_dir) - for page_num in range(1, total_pages + 1): - start_idx = (page_num - 1) * PROMPTS_PER_PAGE - end_idx = min(start_idx + PROMPTS_PER_PAGE, total_convs) - page_convs = conversations[start_idx:end_idx] - messages_html = [] - for conv in page_convs: - is_first = True - for log_type, message_json, timestamp in conv["messages"]: - msg_html = render_message(log_type, message_json, timestamp) - if msg_html: - # Wrap continuation summaries in collapsed details - if is_first and conv.get("is_continuation"): - msg_html = f'
Session continuation summary{msg_html}
' - messages_html.append(msg_html) - is_first = False - pagination_html = generate_pagination_html(page_num, total_pages) - page_template = get_template("page.html") - page_content = page_template.render( - css=CSS, - js=JS, - page_num=page_num, - total_pages=total_pages, - pagination_html=pagination_html, - messages_html="".join(messages_html), - ) - (output_dir / f"page-{page_num:03d}.html").write_text( - page_content, encoding="utf-8" - ) - click.echo(f"Generated page-{page_num:03d}.html") - - # Calculate overall stats and collect all commits for timeline - total_tool_counts = {} - total_messages = 0 - all_commits = [] # (timestamp, hash, message, page_num, conv_index) - for i, conv in enumerate(conversations): - total_messages += len(conv["messages"]) - stats = analyze_conversation(conv["messages"]) - for tool, count in stats["tool_counts"].items(): - total_tool_counts[tool] = total_tool_counts.get(tool, 0) + count - page_num = (i // PROMPTS_PER_PAGE) + 1 - for commit_hash, commit_msg, commit_ts in stats["commits"]: - all_commits.append((commit_ts, commit_hash, commit_msg, page_num, i)) - total_tool_calls = sum(total_tool_counts.values()) - total_commits = len(all_commits) - - # Build timeline items: prompts and commits merged by timestamp - timeline_items = [] - - # Add prompts - prompt_num = 0 - for i, conv in enumerate(conversations): - if conv.get("is_continuation"): - continue - if conv["user_text"].startswith("Stop hook feedback:"): - continue - prompt_num += 1 - page_num = (i // PROMPTS_PER_PAGE) + 1 - msg_id = make_msg_id(conv["timestamp"]) - link = f"page-{page_num:03d}.html#{msg_id}" - rendered_content = render_markdown_text(conv["user_text"]) - - # Collect all messages including from subsequent continuation conversations - # This ensures long_texts from continuations appear with the original prompt - all_messages = list(conv["messages"]) - for j in range(i + 1, len(conversations)): - if not conversations[j].get("is_continuation"): - break - all_messages.extend(conversations[j]["messages"]) - - # Analyze conversation for stats (excluding commits from inline display now) - stats = analyze_conversation(all_messages) - tool_stats_str = format_tool_stats(stats["tool_counts"]) - - long_texts_html = "" - for lt in stats["long_texts"]: - rendered_lt = render_markdown_text(lt) - long_texts_html += _macros.index_long_text(rendered_lt) - - stats_html = _macros.index_stats(tool_stats_str, long_texts_html) - - item_html = _macros.index_item( - prompt_num, link, conv["timestamp"], rendered_content, stats_html - ) - timeline_items.append((conv["timestamp"], "prompt", item_html)) - - # Add commits as separate timeline items - for commit_ts, commit_hash, commit_msg, page_num, conv_idx in all_commits: - item_html = _macros.index_commit( - commit_hash, commit_msg, commit_ts, _github_repo - ) - timeline_items.append((commit_ts, "commit", item_html)) - - # Sort by timestamp - timeline_items.sort(key=lambda x: x[0]) - index_items = [item[2] for item in timeline_items] - - index_pagination = generate_index_pagination_html(total_pages) - index_template = get_template("index.html") - index_content = index_template.render( - css=CSS, - js=JS, - pagination_html=index_pagination, - prompt_num=prompt_num, - total_messages=total_messages, - total_tool_calls=total_tool_calls, - total_commits=total_commits, - total_pages=total_pages, - index_items_html="".join(index_items), - ) - index_path = output_dir / "index.html" - index_path.write_text(index_content, encoding="utf-8") + total_convs = result["meta"]["total_prompts"] + total_pages = result["meta"]["total_pages"] + for page_name in sorted(result["pages"]): + if page_name != "index.html": + click.echo(f"Generated {page_name}") + index_path = Path(output_dir) / "index.html" click.echo( f"Generated {index_path.resolve()} ({total_convs} prompts, {total_pages} pages)" ) From ce3396292d612c0953383df2b3af3e2de66948a5 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 06:32:19 +0000 Subject: [PATCH 3/5] Add sync command to import sessions into SQLite New CLI command: `claude-code-transcripts sync` scans ~/.claude/projects, renders HTML in memory, and stores sessions in SQLite with dedup. Skips sessions already present by session_id. https://claude.ai/code/session_019CmHK1C5R4AHwmLAsw2PEt --- src/claude_code_transcripts/__init__.py | 116 ++++++++++++++++ tests/test_sync.py | 169 ++++++++++++++++++++++++ 2 files changed, 285 insertions(+) create mode 100644 tests/test_sync.py diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index d3a2bae..38ef8d7 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -2118,5 +2118,121 @@ def on_progress(project_name, session_name, current, total): webbrowser.open(index_url) +@cli.command("sync") +@click.option( + "-s", + "--source", + type=click.Path(), + help="Source directory containing Claude projects (default: ~/.claude/projects).", +) +@click.option( + "--db", + "db_path", + type=click.Path(), + help="SQLite database path (default: ~/.claude/transcripts.db).", +) +@click.option( + "--include-agents", + is_flag=True, + help="Include agent-* session files (excluded by default).", +) +@click.option( + "-q", + "--quiet", + is_flag=True, + help="Suppress progress output (only show final summary).", +) +def sync_cmd(source, db_path, include_agents, quiet): + """Sync local Claude Code sessions into the SQLite database. + + Scans ~/.claude/projects for JSONL session files, renders HTML, + and stores everything in a SQLite database. Sessions already in the + database are skipped (dedup by session ID). + """ + from claude_code_transcripts.db import get_db, session_exists, import_session + + # Default source folder + if source is None: + source = Path.home() / ".claude" / "projects" + else: + source = Path(source) + + if not source.exists(): + click.echo("No sessions found.") + return + + if not quiet: + click.echo(f"Scanning {source}...") + + projects = find_all_sessions(source, include_agents=include_agents) + + if not projects: + click.echo("No sessions found.") + return + + # Open/create database + conn = get_db(db_path) + + new_count = 0 + skip_count = 0 + fail_count = 0 + total_sessions = sum(len(p["sessions"]) for p in projects) + + for project in projects: + for session in project["sessions"]: + session_id = session["path"].stem + + if session_exists(conn, session_id): + skip_count += 1 + continue + + try: + # Read raw JSONL content + session_json = session["path"].read_text(encoding="utf-8") + + # Parse and render HTML + data = parse_session_file(session["path"]) + result = render_session_pages(data) + + # Extract first timestamp as created_at + loglines = data.get("loglines", []) + created_at = None + if loglines: + created_at = loglines[0].get("timestamp") + + stat = session["path"].stat() + + import_session( + conn, + session_id=session_id, + project_name=project["name"], + project_key=session["path"].parent.name, + summary=session["summary"], + session_json=session_json, + pages=result["pages"], + meta=result["meta"], + file_mtime=stat.st_mtime, + file_size=stat.st_size, + created_at=created_at, + ) + new_count += 1 + + if not quiet: + click.echo(f" Imported {project['name']}/{session_id}") + except Exception as e: + fail_count += 1 + if not quiet: + click.echo(f" Failed {project['name']}/{session_id}: {e}") + + conn.close() + + parts = [f"Synced {new_count} new sessions"] + if skip_count: + parts.append(f"{skip_count} already present") + if fail_count: + parts.append(f"{fail_count} failed") + click.echo(", ".join(parts) + ".") + + def main(): cli() diff --git a/tests/test_sync.py b/tests/test_sync.py new file mode 100644 index 0000000..dbbf7b9 --- /dev/null +++ b/tests/test_sync.py @@ -0,0 +1,169 @@ +"""Tests for the sync CLI command.""" + +import sqlite3 +import tempfile +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from claude_code_transcripts import cli +from claude_code_transcripts.db import ( + get_db, + list_projects, + list_sessions, + get_session_page, +) + + +@pytest.fixture +def mock_projects_dir(): + """Create a mock ~/.claude/projects structure with test sessions.""" + with tempfile.TemporaryDirectory() as tmpdir: + projects_dir = Path(tmpdir) + + # Create project-a with 2 sessions + project_a = projects_dir / "-home-user-projects-project-a" + project_a.mkdir(parents=True) + + session_a1 = project_a / "abc123.jsonl" + session_a1.write_text( + '{"type": "user", "timestamp": "2025-01-01T10:00:00.000Z", "message": {"role": "user", "content": "Hello from project A"}}\n' + '{"type": "assistant", "timestamp": "2025-01-01T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Hi there!"}]}}\n' + ) + + session_a2 = project_a / "def456.jsonl" + session_a2.write_text( + '{"type": "user", "timestamp": "2025-01-02T10:00:00.000Z", "message": {"role": "user", "content": "Second session in project A"}}\n' + '{"type": "assistant", "timestamp": "2025-01-02T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Got it!"}]}}\n' + ) + + # Create project-b with 1 session + project_b = projects_dir / "-home-user-projects-project-b" + project_b.mkdir(parents=True) + + session_b1 = project_b / "ghi789.jsonl" + session_b1.write_text( + '{"type": "user", "timestamp": "2025-01-04T10:00:00.000Z", "message": {"role": "user", "content": "Hello from project B"}}\n' + '{"type": "assistant", "timestamp": "2025-01-04T10:00:05.000Z", "message": {"role": "assistant", "content": [{"type": "text", "text": "Welcome!"}]}}\n' + ) + + # Create warmup session (should be skipped) + warmup = project_b / "warmup123.jsonl" + warmup.write_text( + '{"type": "user", "timestamp": "2025-01-05T10:00:00.000Z", "message": {"role": "user", "content": "warmup"}}\n' + ) + + yield projects_dir + + +@pytest.fixture +def db_path(): + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "test.db" + + +class TestSyncCommand: + def test_imports_sessions_into_sqlite(self, mock_projects_dir, db_path): + runner = CliRunner() + result = runner.invoke( + cli, + ["sync", "--source", str(mock_projects_dir), "--db", str(db_path)], + ) + assert result.exit_code == 0 + assert "Synced 3 new sessions" in result.output + + # Verify database contents + conn = get_db(db_path) + projects = list_projects(conn) + assert len(projects) == 2 + conn.close() + + def test_dedup_on_second_run(self, mock_projects_dir, db_path): + runner = CliRunner() + # First run + runner.invoke( + cli, + ["sync", "--source", str(mock_projects_dir), "--db", str(db_path)], + ) + # Second run should find 0 new + result = runner.invoke( + cli, + ["sync", "--source", str(mock_projects_dir), "--db", str(db_path)], + ) + assert result.exit_code == 0 + assert "Synced 0 new sessions" in result.output + assert "3 already present" in result.output + + def test_stores_html_pages(self, mock_projects_dir, db_path): + runner = CliRunner() + runner.invoke( + cli, + ["sync", "--source", str(mock_projects_dir), "--db", str(db_path)], + ) + conn = get_db(db_path) + # Each session should have at least an index.html and a page-001.html + html = get_session_page(conn, "abc123", "index.html") + assert html is not None + assert " Date: Mon, 2 Mar 2026 06:34:28 +0000 Subject: [PATCH 4/5] Add serve command to browse transcripts from SQLite via HTTP New serve.py module with HTTP handler that routes URLs to SQLite queries. Master index and project index pages are rendered dynamically from DB. Session pages are served directly from the session_pages table. URL routing: / -> master index, // -> project index, ///.html -> session page content. https://claude.ai/code/session_019CmHK1C5R4AHwmLAsw2PEt --- src/claude_code_transcripts/__init__.py | 49 +++++++ src/claude_code_transcripts/serve.py | 157 ++++++++++++++++++++++ tests/test_serve.py | 166 ++++++++++++++++++++++++ 3 files changed, 372 insertions(+) create mode 100644 src/claude_code_transcripts/serve.py create mode 100644 tests/test_serve.py diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 38ef8d7..52d01ed 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -2118,6 +2118,55 @@ def on_progress(project_name, session_name, current, total): webbrowser.open(index_url) +@cli.command("serve") +@click.option( + "--db", + "db_path", + type=click.Path(), + help="SQLite database path (default: ~/.claude/transcripts.db).", +) +@click.option( + "-p", + "--port", + default=8080, + help="Port number (default: 8080).", +) +@click.option( + "--host", + default="127.0.0.1", + help="Bind address (default: 127.0.0.1).", +) +def serve_cmd(db_path, port, host): + """Serve transcripts from the SQLite database via HTTP. + + Starts a local web server that serves transcript HTML pages + directly from the SQLite database. + """ + from http.server import HTTPServer + from claude_code_transcripts.db import get_db, DEFAULT_DB_PATH + from claude_code_transcripts.serve import make_handler + + if db_path is None: + db_path = DEFAULT_DB_PATH + db_path = Path(db_path) + + if not db_path.exists(): + raise click.ClickException( + f"Database not found: {db_path}\nRun 'claude-code-transcripts sync' first." + ) + + click.echo(f"Serving transcripts from {db_path}") + click.echo(f"http://{host}:{port}/") + + handler = make_handler(db_path) + server = HTTPServer((host, port), handler) + try: + server.serve_forever() + except KeyboardInterrupt: + click.echo("\nShutting down.") + server.shutdown() + + @cli.command("sync") @click.option( "-s", diff --git a/src/claude_code_transcripts/serve.py b/src/claude_code_transcripts/serve.py new file mode 100644 index 0000000..be0c3cb --- /dev/null +++ b/src/claude_code_transcripts/serve.py @@ -0,0 +1,157 @@ +"""HTTP server for serving transcripts from SQLite.""" + +import sqlite3 +from http.server import BaseHTTPRequestHandler, HTTPServer +from pathlib import Path +from urllib.parse import unquote + +from claude_code_transcripts.db import ( + get_db, + get_session_page, + list_projects, + list_sessions, +) + + +def _render_master_index(conn): + """Render the master index page listing all projects.""" + from claude_code_transcripts import get_template, CSS + + projects = list_projects(conn) + if not projects: + return None + + template = get_template("master_index.html") + + projects_data = [] + for project in projects: + sessions = list_sessions(conn, project["name"]) + projects_data.append( + { + "name": project["name"], + "sessions": [ + { + "name": s["session_id"], + "summary": s["summary"] or "", + "date": s["imported_at"] or "", + "size_kb": (s["file_size"] or 0) / 1024, + } + for s in sessions + ], + } + ) + + return template.render( + projects=projects_data, + total_projects=len(projects_data), + total_sessions=sum(p["session_count"] for p in projects), + css=CSS, + ) + + +def _render_project_index(conn, project_name): + """Render the project index page listing sessions.""" + from claude_code_transcripts import get_template, CSS + + sessions = list_sessions(conn, project_name) + if not sessions: + return None + + template = get_template("project_index.html") + + sessions_data = [] + for s in sessions: + sessions_data.append( + { + "name": s["session_id"], + "summary": s["summary"] or "", + "date": s["imported_at"] or "", + "size_kb": (s["file_size"] or 0) / 1024, + } + ) + + return template.render( + project_name=project_name, + sessions=sessions_data, + session_count=len(sessions_data), + css=CSS, + ) + + +def make_handler(db_path): + """Create a request handler class bound to a specific database.""" + + class TranscriptHandler(BaseHTTPRequestHandler): + def do_GET(self): + path = unquote(self.path) + + # Strip query string + if "?" in path: + path = path.split("?")[0] + + # Route: / -> master index + if path == "/" or path == "": + conn = get_db(db_path) + try: + html = _render_master_index(conn) + finally: + conn.close() + if html: + self._send_html(200, html) + else: + self._send_html(404, "

No transcripts found

") + return + + # Parse path segments + parts = [p for p in path.strip("/").split("/") if p] + + # Route: // -> project index + if len(parts) == 1: + project_name = parts[0] + conn = get_db(db_path) + try: + html = _render_project_index(conn, project_name) + finally: + conn.close() + if html: + self._send_html(200, html) + else: + self._send_html(404, "

Project not found

") + return + + # Route: /// -> redirect to index.html + if len(parts) == 2: + self.send_response(302) + self.send_header("Location", f"/{parts[0]}/{parts[1]}/index.html") + self.end_headers() + return + + # Route: ///.html -> session page + if len(parts) == 3: + project_name, session_id, page_name = parts + conn = get_db(db_path) + try: + html = get_session_page(conn, session_id, page_name) + finally: + conn.close() + if html: + self._send_html(200, html) + else: + self._send_html(404, "

Page not found

") + return + + self._send_html(404, "

Not found

") + + def _send_html(self, status, content): + self.send_response(status) + self.send_header("Content-Type", "text/html; charset=utf-8") + encoded = content.encode("utf-8") + self.send_header("Content-Length", str(len(encoded))) + self.end_headers() + self.wfile.write(encoded) + + def log_message(self, format, *args): + # Suppress default stderr logging during tests + pass + + return TranscriptHandler diff --git a/tests/test_serve.py b/tests/test_serve.py new file mode 100644 index 0000000..ff99825 --- /dev/null +++ b/tests/test_serve.py @@ -0,0 +1,166 @@ +"""Tests for the serve CLI command.""" + +import sqlite3 +import tempfile +import threading +import time +import urllib.request +from pathlib import Path + +import pytest +from click.testing import CliRunner + +from claude_code_transcripts import cli +from claude_code_transcripts.db import get_db, import_session +from claude_code_transcripts.serve import make_handler + + +@pytest.fixture +def db_path(): + with tempfile.TemporaryDirectory() as tmpdir: + yield Path(tmpdir) / "test.db" + + +@pytest.fixture +def populated_db(db_path): + """Create a DB with some test sessions.""" + conn = get_db(db_path) + import_session( + conn, + session_id="abc123", + project_name="project-a", + project_key="-home-user-projects-project-a", + summary="Hello from project A", + session_json='{"loglines": []}', + pages={ + "index.html": "Session index", + "page-001.html": "Page 1 content", + }, + meta={ + "total_pages": 1, + "total_prompts": 1, + "total_messages": 2, + "total_tool_calls": 0, + "total_commits": 0, + "github_repo": None, + }, + file_size=1024, + ) + import_session( + conn, + session_id="def456", + project_name="project-a", + project_key="-home-user-projects-project-a", + summary="Second session", + session_json='{"loglines": []}', + pages={ + "index.html": "Session 2 index", + "page-001.html": "Session 2 page 1", + }, + meta={ + "total_pages": 1, + "total_prompts": 1, + "total_messages": 2, + "total_tool_calls": 0, + "total_commits": 0, + "github_repo": None, + }, + file_size=512, + ) + import_session( + conn, + session_id="ghi789", + project_name="project-b", + project_key="-home-user-projects-project-b", + summary="Hello from project B", + session_json='{"loglines": []}', + pages={ + "index.html": "Project B session", + }, + meta={ + "total_pages": 1, + "total_prompts": 1, + "total_messages": 2, + "total_tool_calls": 0, + "total_commits": 0, + "github_repo": None, + }, + ) + conn.close() + return db_path + + +class TestTranscriptHandler: + """Test the HTTP handler using a real HTTP server on a random port.""" + + @pytest.fixture(autouse=True) + def setup_server(self, populated_db): + """Start a test server on a random port.""" + from http.server import HTTPServer + + handler = make_handler(populated_db) + self.server = HTTPServer(("127.0.0.1", 0), handler) + self.port = self.server.server_address[1] + self.base_url = f"http://127.0.0.1:{self.port}" + self.thread = threading.Thread(target=self.server.serve_forever) + self.thread.daemon = True + self.thread.start() + yield + self.server.shutdown() + + def _get(self, path): + """Make a GET request and return (status_code, body).""" + url = f"{self.base_url}{path}" + try: + resp = urllib.request.urlopen(url) + return resp.status, resp.read().decode("utf-8") + except urllib.error.HTTPError as e: + return e.code, e.read().decode("utf-8") + + def test_root_returns_master_index(self): + status, body = self._get("/") + assert status == 200 + assert "project-a" in body + assert "project-b" in body + + def test_project_page(self): + status, body = self._get("/project-a/") + assert status == 200 + assert "abc123" in body + assert "def456" in body + + def test_session_index_page(self): + status, body = self._get("/project-a/abc123/index.html") + assert status == 200 + assert "Session index" in body + + def test_session_page(self): + status, body = self._get("/project-a/abc123/page-001.html") + assert status == 200 + assert "Page 1 content" in body + + def test_session_dir_redirects_to_index(self): + # Accessing /project/session/ should redirect to index.html + url = f"{self.base_url}/project-a/abc123/" + req = urllib.request.Request(url) + # Don't follow redirects + import http.client + + conn = http.client.HTTPConnection("127.0.0.1", self.port) + conn.request("GET", "/project-a/abc123/") + resp = conn.getresponse() + assert resp.status in (301, 302) + assert "index.html" in resp.getheader("Location") + conn.close() + + def test_nonexistent_session_returns_404(self): + status, body = self._get("/project-a/nonexistent/index.html") + assert status == 404 + + def test_nonexistent_project_returns_404(self): + status, body = self._get("/nonexistent/") + assert status == 404 + + def test_nonexistent_page_returns_404(self): + status, body = self._get("/project-a/abc123/page-999.html") + assert status == 404 From 6bf0d86834f93c2704f82a71547f3763722a7d19 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 2 Mar 2026 06:37:31 +0000 Subject: [PATCH 5/5] Migrate local, json, and web commands to use SQLite by default Commands now import sessions into SQLite instead of writing static HTML files. The -o/--output and --gist flags still produce file output as an escape hatch. Also adds import_session_file_to_db() and import_session_data_to_db() helpers for the common import flow. https://claude.ai/code/session_019CmHK1C5R4AHwmLAsw2PEt --- src/claude_code_transcripts/__init__.py | 362 ++++++++++++++++-------- tests/test_generate_html.py | 2 +- 2 files changed, 252 insertions(+), 112 deletions(-) diff --git a/src/claude_code_transcripts/__init__.py b/src/claude_code_transcripts/__init__.py index 52d01ed..b01f532 100644 --- a/src/claude_code_transcripts/__init__.py +++ b/src/claude_code_transcripts/__init__.py @@ -1484,6 +1484,117 @@ def write_pages_to_dir(pages, output_dir): (output_dir / filename).write_text(content, encoding="utf-8") +def import_session_file_to_db( + session_path, project_name=None, project_key=None, db_path=None, github_repo=None +): + """Import a single session file into the SQLite database. + + Returns (session_id, project_name, result) where result is the render_session_pages output. + Skips if already present. Returns None for result if skipped. + """ + from claude_code_transcripts.db import ( + get_db, + session_exists, + import_session, + DEFAULT_DB_PATH, + ) + + session_path = Path(session_path) + session_id = session_path.stem + + if project_key is None: + project_key = session_path.parent.name + if project_name is None: + project_name = get_project_display_name(project_key) + + conn = get_db(db_path) + try: + if session_exists(conn, session_id): + return session_id, project_name, None + + session_json = session_path.read_text(encoding="utf-8") + data = parse_session_file(session_path) + result = render_session_pages(data, github_repo=github_repo) + + loglines = data.get("loglines", []) + created_at = loglines[0].get("timestamp") if loglines else None + summary = get_session_summary(session_path) + stat = session_path.stat() + + import_session( + conn, + session_id=session_id, + project_name=project_name, + project_key=project_key, + summary=summary, + session_json=session_json, + pages=result["pages"], + meta=result["meta"], + file_mtime=stat.st_mtime, + file_size=stat.st_size, + created_at=created_at, + ) + return session_id, project_name, result + finally: + conn.close() + + +def import_session_data_to_db( + session_data, + session_id, + project_name="web", + project_key="web", + db_path=None, + github_repo=None, +): + """Import session data dict into the SQLite database. + + Returns (session_id, project_name, result) where result is the render_session_pages output. + Skips if already present. Returns None for result if skipped. + """ + from claude_code_transcripts.db import ( + get_db, + session_exists, + import_session, + DEFAULT_DB_PATH, + ) + + conn = get_db(db_path) + try: + if session_exists(conn, session_id): + return session_id, project_name, None + + result = render_session_pages(session_data, github_repo=github_repo) + + loglines = session_data.get("loglines", []) + created_at = loglines[0].get("timestamp") if loglines else None + + # Extract summary from first user message + summary = None + for entry in loglines: + if entry.get("type") == "user": + content = entry.get("message", {}).get("content", "") + text = extract_text_from_content(content) + if text: + summary = text[:200] + break + + import_session( + conn, + session_id=session_id, + project_name=project_name, + project_key=project_key, + summary=summary, + session_json=json.dumps(session_data), + pages=result["pages"], + meta=result["meta"], + created_at=created_at, + ) + return session_id, project_name, result + finally: + conn.close() + + def generate_html(json_path, output_dir, github_repo=None): output_dir = Path(output_dir) output_dir.mkdir(exist_ok=True) @@ -1601,42 +1712,52 @@ def local_cmd(output, output_auto, repo, gist, include_json, open_browser, limit session_file = selected - # Determine output directory and whether to open browser - # If no -o specified, use temp dir and open browser by default - auto_open = output is None and not gist and not output_auto - if output_auto: - # Use -o as parent dir (or current dir), with auto-named subdirectory - parent_dir = Path(output) if output else Path(".") - output = parent_dir / session_file.stem - elif output is None: - output = Path(tempfile.gettempdir()) / f"claude-session-{session_file.stem}" + # If -o is specified, use legacy file-based output + use_file_output = output is not None or output_auto or gist - output = Path(output) - generate_html(session_file, output, github_repo=repo) - - # Show output directory - click.echo(f"Output: {output.resolve()}") - - # Copy JSONL file to output directory if requested - if include_json: - output.mkdir(exist_ok=True) - json_dest = output / session_file.name - shutil.copy(session_file, json_dest) - json_size_kb = json_dest.stat().st_size / 1024 - click.echo(f"JSONL: {json_dest} ({json_size_kb:.1f} KB)") - - if gist: - # Inject gist preview JS and create gist - inject_gist_preview_js(output) - click.echo("Creating GitHub gist...") - gist_id, gist_url = create_gist(output) - preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" - click.echo(f"Gist: {gist_url}") - click.echo(f"Preview: {preview_url}") - - if open_browser or auto_open: - index_url = (output / "index.html").resolve().as_uri() - webbrowser.open(index_url) + if use_file_output: + auto_open = output is None and not gist and not output_auto + if output_auto: + parent_dir = Path(output) if output else Path(".") + output = parent_dir / session_file.stem + elif output is None: + output = Path(tempfile.gettempdir()) / f"claude-session-{session_file.stem}" + + output = Path(output) + generate_html(session_file, output, github_repo=repo) + click.echo(f"Output: {output.resolve()}") + + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / session_file.name + shutil.copy(session_file, json_dest) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSONL: {json_dest} ({json_size_kb:.1f} KB)") + + if gist: + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + + if open_browser or auto_open: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) + else: + # Default: import to SQLite + session_id, project_name, result = import_session_file_to_db( + session_file, github_repo=repo + ) + if result is None: + click.echo(f"Session {session_id} already in database.") + else: + click.echo(f"Imported {session_id} into database.") + click.echo(f"View: http://127.0.0.1:8080/{project_name}/{session_id}/") + + if open_browser: + webbrowser.open(f"http://127.0.0.1:8080/{project_name}/{session_id}/") def is_url(path): @@ -1720,54 +1841,62 @@ def json_cmd(json_file, output, output_auto, repo, gist, include_json, open_brow click.echo(f"Fetching {json_file}...") temp_file = fetch_url_to_tempfile(json_file) json_file_path = temp_file - # Use URL path for naming url_name = Path(json_file.split("?")[0]).stem or "session" else: - # Validate that local file exists json_file_path = Path(json_file) if not json_file_path.exists(): raise click.ClickException(f"File not found: {json_file}") url_name = None - # Determine output directory and whether to open browser - # If no -o specified, use temp dir and open browser by default - auto_open = output is None and not gist and not output_auto - if output_auto: - # Use -o as parent dir (or current dir), with auto-named subdirectory - parent_dir = Path(output) if output else Path(".") - output = parent_dir / (url_name or json_file_path.stem) - elif output is None: - output = ( - Path(tempfile.gettempdir()) - / f"claude-session-{url_name or json_file_path.stem}" + # If -o is specified, use legacy file-based output + use_file_output = output is not None or output_auto or gist + + if use_file_output: + auto_open = output is None and not gist and not output_auto + if output_auto: + parent_dir = Path(output) if output else Path(".") + output = parent_dir / (url_name or json_file_path.stem) + elif output is None: + output = ( + Path(tempfile.gettempdir()) + / f"claude-session-{url_name or json_file_path.stem}" + ) + + output = Path(output) + generate_html(json_file_path, output, github_repo=repo) + click.echo(f"Output: {output.resolve()}") + + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / json_file_path.name + shutil.copy(json_file_path, json_dest) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") + + if gist: + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + + if open_browser or auto_open: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) + else: + # Default: import to SQLite + session_id, project_name, result = import_session_file_to_db( + json_file_path, github_repo=repo ) + if result is None: + click.echo(f"Session {session_id} already in database.") + else: + click.echo(f"Imported {session_id} into database.") + click.echo(f"View: http://127.0.0.1:8080/{project_name}/{session_id}/") - output = Path(output) - generate_html(json_file_path, output, github_repo=repo) - - # Show output directory - click.echo(f"Output: {output.resolve()}") - - # Copy JSON file to output directory if requested - if include_json: - output.mkdir(exist_ok=True) - json_dest = output / json_file_path.name - shutil.copy(json_file_path, json_dest) - json_size_kb = json_dest.stat().st_size / 1024 - click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") - - if gist: - # Inject gist preview JS and create gist - inject_gist_preview_js(output) - click.echo("Creating GitHub gist...") - gist_id, gist_url = create_gist(output) - preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" - click.echo(f"Gist: {gist_url}") - click.echo(f"Preview: {preview_url}") - - if open_browser or auto_open: - index_url = (output / "index.html").resolve().as_uri() - webbrowser.open(index_url) + if open_browser: + webbrowser.open(f"http://127.0.0.1:8080/{project_name}/{session_id}/") def resolve_credentials(token, org_uuid): @@ -1954,44 +2083,55 @@ def web_cmd( except httpx.RequestError as e: raise click.ClickException(f"Network error: {e}") - # Determine output directory and whether to open browser - # If no -o specified, use temp dir and open browser by default - auto_open = output is None and not gist and not output_auto - if output_auto: - # Use -o as parent dir (or current dir), with auto-named subdirectory - parent_dir = Path(output) if output else Path(".") - output = parent_dir / session_id - elif output is None: - output = Path(tempfile.gettempdir()) / f"claude-session-{session_id}" + # If -o is specified, use legacy file-based output + use_file_output = output is not None or output_auto or gist - output = Path(output) - click.echo(f"Generating HTML in {output}/...") - generate_html_from_session_data(session_data, output, github_repo=repo) - - # Show output directory - click.echo(f"Output: {output.resolve()}") - - # Save JSON session data if requested - if include_json: - output.mkdir(exist_ok=True) - json_dest = output / f"{session_id}.json" - with open(json_dest, "w") as f: - json.dump(session_data, f, indent=2) - json_size_kb = json_dest.stat().st_size / 1024 - click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") - - if gist: - # Inject gist preview JS and create gist - inject_gist_preview_js(output) - click.echo("Creating GitHub gist...") - gist_id, gist_url = create_gist(output) - preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" - click.echo(f"Gist: {gist_url}") - click.echo(f"Preview: {preview_url}") - - if open_browser or auto_open: - index_url = (output / "index.html").resolve().as_uri() - webbrowser.open(index_url) + if use_file_output: + auto_open = output is None and not gist and not output_auto + if output_auto: + parent_dir = Path(output) if output else Path(".") + output = parent_dir / session_id + elif output is None: + output = Path(tempfile.gettempdir()) / f"claude-session-{session_id}" + + output = Path(output) + click.echo(f"Generating HTML in {output}/...") + generate_html_from_session_data(session_data, output, github_repo=repo) + click.echo(f"Output: {output.resolve()}") + + if include_json: + output.mkdir(exist_ok=True) + json_dest = output / f"{session_id}.json" + with open(json_dest, "w") as f: + json.dump(session_data, f, indent=2) + json_size_kb = json_dest.stat().st_size / 1024 + click.echo(f"JSON: {json_dest} ({json_size_kb:.1f} KB)") + + if gist: + inject_gist_preview_js(output) + click.echo("Creating GitHub gist...") + gist_id, gist_url = create_gist(output) + preview_url = f"https://gisthost.github.io/?{gist_id}/index.html" + click.echo(f"Gist: {gist_url}") + click.echo(f"Preview: {preview_url}") + + if open_browser or auto_open: + index_url = (output / "index.html").resolve().as_uri() + webbrowser.open(index_url) + else: + # Default: import to SQLite + project_name = "web" + sid, project_name, result = import_session_data_to_db( + session_data, session_id, project_name=project_name, github_repo=repo + ) + if result is None: + click.echo(f"Session {session_id} already in database.") + else: + click.echo(f"Imported {session_id} into database.") + click.echo(f"View: http://127.0.0.1:8080/{project_name}/{session_id}/") + + if open_browser: + webbrowser.open(f"http://127.0.0.1:8080/{project_name}/{session_id}/") @cli.command("all") diff --git a/tests/test_generate_html.py b/tests/test_generate_html.py index 25c2822..b21e18d 100644 --- a/tests/test_generate_html.py +++ b/tests/test_generate_html.py @@ -1317,7 +1317,7 @@ def ask(self): assert result.exit_code == 0 assert "Loading local sessions" in result.output - assert "Generated" in result.output + assert "Imported" in result.output or "already in database" in result.output def test_no_args_runs_local_command(self, tmp_path, monkeypatch): """Test that running with no arguments runs local command."""