diff --git a/.flake8 b/.flake8 new file mode 100644 index 0000000..8332de3 --- /dev/null +++ b/.flake8 @@ -0,0 +1,3 @@ +[flake8] +max-line-length = 120 +extend-ignore = E203 diff --git a/README.md b/README.md index 15cdc88..b8b0d1e 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # Claude Conversation Extractor - Export Claude Code Conversations to Markdown | Save Chat History -> š **The ONLY tool to export Claude Code conversations**. Extract Claude chat history from ~/.claude/projects, search through logs, and backup your AI programming sessions. +> š **The ONLY tool to export Claude Code conversations**. Extract Claude chat history from ~/.claude/projects and backup your AI programming sessions. ## š® Two Ways to Use -- **`claude-start`** - Interactive UI with ASCII art logo, real-time search, and menu-driven interface (recommended) +- **`claude-start`** - Interactive UI with ASCII art logo and menu-driven interface (recommended) - **`claude-extract`** - Plain CLI for command-line operations and scripting [](https://www.python.org/downloads/) @@ -27,7 +27,6 @@ **Claude Code has no export button.** Your conversations are trapped in `~/.claude/projects/` as undocumented JSONL files. You need: - ā **Export Claude Code conversations** before they're deleted -- ā **Search Claude Code chat history** to find that solution from last week - ā **Backup Claude Code logs** for documentation or sharing - ā **Convert Claude JSONL to Markdown** for readable archives @@ -36,20 +35,17 @@ This is the **ONLY tool that exports Claude Code conversations**: - ā **Finds Claude Code logs** automatically in ~/.claude/projects - ā **Extracts Claude conversations** to clean Markdown files -- ā **Searches Claude chat history** with real-time results - ā **Backs up all Claude sessions** with one command - ā **Works on Windows, macOS, Linux** - wherever Claude Code runs ## ⨠Features for Claude Code Users -- **š Real-Time Search**: Search Claude conversations as you type - no flags needed - **š Claude JSONL to Markdown**: Clean export without terminal artifacts -- **ā” Find Any Chat**: Search by content, date, or conversation name - **š¦ Bulk Export**: Extract all Claude Code conversations at once - **šÆ Zero Config**: Just run `claude-extract` - we find everything automatically - **š No Dependencies**: Pure Python - no external packages required - **š„ļø Cross-Platform**: Export Claude Code logs on any OS -- **š 97% Test Coverage**: Reliable extraction you can trust +- **š Tested**: Core extraction functionality tested and reliable ## š¦ Install Claude Conversation Extractor @@ -109,15 +105,12 @@ pipx install claude-conversation-extractor ### Quick Start - Export Claude Conversations ```bash -# Run the interactive UI with ASCII art logo and real-time search +# Run the interactive UI with ASCII art logo claude-start # Run the standard CLI interface claude-extract -# Search for specific content directly -claude-search "API integration" - # Note: claude-logs also works for backward compatibility ``` @@ -151,7 +144,7 @@ claude-extract --all claude-extract --output ~/my-claude-backups ``` -### š Export Formats - NEW in v1.1.1! +### š Export Formats - NEW in v1.2.0! Export conversations in multiple formats: @@ -162,6 +155,17 @@ claude-extract --format json --extract 1 # Export as HTML with beautiful formatting claude-extract --format html --all +# Export as PDF (requires optional dependency) +pip install claude-conversation-extractor[pdf] +claude-extract --format pdf --extract 1 + +# Export as DOCX (requires optional dependency) +pip install claude-conversation-extractor[docx] +claude-extract --format docx --all + +# Install all optional export formats +pip install claude-conversation-extractor[all] + # Include tool use, MCP responses, and system messages claude-extract --detailed --extract 1 @@ -173,6 +177,8 @@ claude-extract --format html --detailed --recent 5 - **Markdown** (default) - Clean, readable text format - **JSON** - Structured data for analysis and processing - **HTML** - Beautiful web-viewable format with syntax highlighting +- **PDF** - Portable document format (optional: `pip install .[pdf]`) +- **DOCX** - Microsoft Word format (optional: `pip install .[docx]`) **Detailed Mode (`--detailed`):** Includes complete conversation transcript with: @@ -182,28 +188,6 @@ Includes complete conversation transcript with: - Terminal command outputs - All metadata from the conversation -### š Search Claude Code Chat History - -Search across all your Claude conversations: - -```bash -# Method 1: Direct search command -claude-search # Prompts for search term -claude-search "zig build" # Search for specific term -claude-search "error handling" # Multi-word search - -# Method 2: From interactive menu -claude-extract -# Select "Search conversations" for real-time search -``` - -**Search features:** -- Fast full-text search across all conversations -- Case-insensitive by default -- Finds exact matches, partial matches, and patterns -- Shows match previews and conversation context -- Option to extract matching sessions directly - ## š Where Are Claude Code Logs Stored? ### Claude Code Default Locations: @@ -235,9 +219,6 @@ This gives you the complete conversation as seen in Claude's Ctrl+R view. ### Where does Claude Code store conversations? Claude Code saves all chats in `~/.claude/projects/` as JSONL files. There's no built-in export feature - that's why this tool exists. -### Can I search my Claude Code history? -Yes! Run `claude-search` or select "Search conversations" from the menu. Type anything and see results instantly. - ### How to backup all Claude Code sessions? Run `claude-extract --all` to export every conversation at once, or use the interactive menu option "Export all conversations". @@ -245,11 +226,13 @@ Run `claude-extract --all` to export every conversation at once, or use the inte No, this tool specifically exports Claude Code (desktop app) conversations. Claude.ai has its own export feature in settings. ### Can I convert Claude JSONL to other formats? -Yes! Version 1.1.1 supports multiple export formats: +Yes! Version 1.2.0 supports multiple export formats: - **Markdown** - Default clean text format -- **JSON** - Structured data with timestamps and metadata +- **JSON** - Structured data with timestamps and metadata - **HTML** - Beautiful web-viewable format with modern styling -Use `--format json` or `--format html` when extracting. +- **PDF** - Portable document format (requires: `pip install claude-conversation-extractor[pdf]`) +- **DOCX** - Microsoft Word format (requires: `pip install claude-conversation-extractor[docx]`) +Use `--format json`, `--format html`, `--format pdf`, or `--format docx` when extracting. ### Is this tool official? No, this is an independent open-source tool. It reads the local Claude Code files on your computer - no API or internet required. @@ -260,7 +243,6 @@ No, this is an independent open-source tool. It reads the local Claude Code file |---------|------------------------------|-------------|------------------| | Works with Claude Code | ā Full support | ā Tedious | ā Different product | | Bulk export | ā All conversations | ā One at a time | ā N/A | -| Search capability | ā Real-time search | ā None | ā N/A | | Clean formatting | ā Perfect Markdown | ā Terminal artifacts | ā N/A | | Zero configuration | ā Auto-detects | ā Manual process | ā N/A | | Cross-platform | ā Win/Mac/Linux | ā Manual works | ā N/A | @@ -273,20 +255,12 @@ No, this is an independent open-source tool. It reads the local Claude Code file 2. **Parses undocumented format**: Handles Claude's internal data structure 3. **Extracts conversations**: Preserves user inputs and Claude responses 4. **Converts to Markdown**: Clean format without terminal escape codes -5. **Enables search**: Indexes content for instant searching ### Requirements - Python 3.8+ (works with 3.9, 3.10, 3.11, 3.12) - Claude Code installed with existing conversations - No external dependencies for core features -### Optional: Advanced Search with spaCy -```bash -# For semantic search capabilities -pip install spacy -python -m spacy download en_core_web_sm -``` - ## š¤ Contributing Help make the best Claude Code export tool even better! See [CONTRIBUTING.md](docs/development/CONTRIBUTING.md). @@ -337,17 +311,16 @@ See [INSTALL.md](docs/user/INSTALL.md) for: ## š Roadmap for Claude Code Export Tool -### ā Completed in v1.1.1 +### ā Completed in v1.2.0 - [x] Export Claude Code conversations to Markdown -- [x] Real-time search for Claude chat history - [x] Bulk export all Claude sessions - [x] Export to JSON format with metadata - [x] Export to HTML with beautiful formatting - [x] Detailed transcript mode with tool use/MCP responses -- [x] Direct search command (`claude-search`) +- [x] Export to PDF format (optional dependency) +- [x] Export to DOCX format (optional dependency) ### š§ Planned Features -- [ ] Export to PDF format - [ ] Automated daily backups of Claude conversations - [ ] Integration with Obsidian, Notion, Roam - [ ] Watch mode for auto-export of new conversations diff --git a/config/.flake8 b/config/.flake8 deleted file mode 100644 index 649773a..0000000 --- a/config/.flake8 +++ /dev/null @@ -1,14 +0,0 @@ -[flake8] -max-line-length = 100 -exclude = - venv, - dist, - build, - .egg-info, - .git, - __pycache__ -ignore = - # E203 whitespace before ':' - conflicts with black - E203, - # W503 line break before binary operator - conflicts with black - W503 \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml index d774cb3..c87113c 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "claude-conversation-extractor" -version = "1.1.2" +version = "1.2.0" description = "Export Claude Code conversations from ~/.claude/projects. Extract, search, and backup Claude chat history to markdown files." readme = "README.md" authors = [ @@ -42,6 +42,11 @@ keywords = [ requires-python = ">=3.8" dependencies = [] +[project.optional-dependencies] +pdf = ["reportlab>=4.0"] +docx = ["python-docx>=1.0"] +all = ["reportlab>=4.0", "python-docx>=1.0"] + [project.urls] Homepage = "https://github.com/ZeroSumQuant/claude-conversation-extractor" Documentation = "https://github.com/ZeroSumQuant/claude-conversation-extractor#readme" @@ -52,10 +57,9 @@ Issues = "https://github.com/ZeroSumQuant/claude-conversation-extractor/issues" claude-extract = "extract_claude_logs:launch_interactive" claude-logs = "extract_claude_logs:launch_interactive" claude-start = "extract_claude_logs:launch_interactive" -claude-search = "search_cli:main" [tool.setuptools] -py-modules = ["extract_claude_logs", "interactive_ui", "search_conversations", "realtime_search", "search_cli"] +py-modules = ["constants", "extract_claude_logs", "interactive_ui"] [tool.setuptools.package-dir] "" = "src" diff --git a/setup.py b/setup.py deleted file mode 100644 index 70a559b..0000000 --- a/setup.py +++ /dev/null @@ -1,105 +0,0 @@ -#!/usr/bin/env python3 -"""Setup script for Claude Conversation Extractor""" - -import atexit -from pathlib import Path - -from setuptools import setup -from setuptools.command.install import install - - -class PostInstallCommand(install): - """Post-installation for installation mode.""" - - def run(self): - install.run(self) - - # Print helpful messages after installation - def print_success_message(): - print("\nš Installation complete!") - print("\nš Quick Start Commands:") - print(" claude-start # Interactive UI with logo & real-time search") - print(" claude-extract # CLI for extraction & searching") - print(" claude-search # Search and view conversations") - print("\nā If you find this tool helpful, please star us on GitHub:") - print(" https://github.com/ZeroSumQuant/claude-conversation-extractor") - print("\nThank you for using Claude Conversation Extractor! š\n") - - # Register to run after pip finishes - atexit.register(print_success_message) - - -# Read the README for long description -this_directory = Path(__file__).parent -long_description = (this_directory / "README.md").read_text(encoding="utf-8") - -setup( - name="claude-conversation-extractor", - version="1.1.2", - author="Dustin Kirby", - author_email="", # Contact via GitHub - description=( - "Export Claude Code conversations from ~/.claude/projects. " - "Extract, search, and backup Claude chat history to markdown files." - ), - long_description=long_description, - long_description_content_type="text/markdown", - url="https://github.com/ZeroSumQuant/claude-conversation-extractor", - project_urls={ - "Bug Tracker": ( - "https://github.com/ZeroSumQuant/claude-conversation-extractor/issues" - ), - "Documentation": ( - "https://github.com/ZeroSumQuant/claude-conversation-extractor#readme" - ), - "Source": "https://github.com/ZeroSumQuant/claude-conversation-extractor", - }, - classifiers=[ - "Development Status :: 5 - Production/Stable", - "Intended Audience :: Developers", - "Intended Audience :: End Users/Desktop", - "Topic :: Software Development :: Libraries :: Python Modules", - "Topic :: Text Processing :: Markup :: Markdown", - "Topic :: Communications :: Chat", - "Topic :: System :: Archiving :: Backup", - "Topic :: Utilities", - "License :: OSI Approved :: MIT License", - "Programming Language :: Python :: 3", - "Programming Language :: Python :: 3.8", - "Programming Language :: Python :: 3.9", - "Programming Language :: Python :: 3.10", - "Programming Language :: Python :: 3.11", - "Programming Language :: Python :: 3.12", - "Operating System :: OS Independent", - "Environment :: Console", - ], - python_requires=">=3.8", - package_dir={"": "src"}, - py_modules=[ - "extract_claude_logs", - "interactive_ui", - "search_conversations", - "realtime_search", - "search_cli", - ], - entry_points={ - "console_scripts": [ - "claude-extract=extract_claude_logs:launch_interactive", # Primary command - "claude-logs=extract_claude_logs:launch_interactive", # Kept for backward compatibility - "claude-start=extract_claude_logs:launch_interactive", # Alternative alias - "claude-search=search_cli:main", # Direct search command - ], - }, - cmdclass={ - "install": PostInstallCommand, - }, - install_requires=[], # No dependencies! - keywords=( - "export-claude-code-conversations claude-conversation-extractor " - "claude-code-export-tool backup-claude-code-logs save-claude-chat-history " - "claude-jsonl-to-markdown extract-claude-sessions claude-code-no-export-button " - "where-are-claude-code-logs-stored claude-terminal-logs anthropic-claude-code " - "search-claude-conversations claude-code-logs-location ~/.claude/projects " - "export-claude-conversations extract-claude-code backup-claude-sessions" - ), -) diff --git a/src/__init__.py b/src/__init__.py index 1948461..48a1836 100644 --- a/src/__init__.py +++ b/src/__init__.py @@ -4,6 +4,5 @@ __author__ = "Dustin Kirby" from .extract_claude_logs import ClaudeConversationExtractor -from .search_conversations import ConversationSearcher -__all__ = ["ClaudeConversationExtractor", "ConversationSearcher"] \ No newline at end of file +__all__ = ["ClaudeConversationExtractor"] diff --git a/src/constants.py b/src/constants.py new file mode 100644 index 0000000..065f2d0 --- /dev/null +++ b/src/constants.py @@ -0,0 +1,87 @@ +#!/usr/bin/env python3 +""" +Shared constants for Claude Conversation Extractor + +This module centralizes all magic numbers and configuration values +to improve maintainability and consistency across the codebase. +""" + +# ============================================================================= +# Display Constants +# ============================================================================= + +# Separator widths for console output +MAJOR_SEPARATOR_WIDTH = 60 +MINOR_SEPARATOR_WIDTH = 40 +LIST_SEPARATOR_WIDTH = 80 + +# JSON formatting +INDENT_NUMBER = 2 + +# Session ID display +SESSION_ID_MAX_LENGTH = 8 + +# ============================================================================= +# Message Display Constants +# ============================================================================= + +LINES_SHOWN_MESSAGE = 8 +LINES_PER_PAGE_MESSAGE = 30 +MAX_LINES_PER_MESSAGE_DISPLAY = 50 +MAX_LINE_LENGTH_DISPLAY = 100 + +# ============================================================================= +# Preview and Truncation Constants +# ============================================================================= + +MIN_PREVIEW_TEXT_LENGTH = 3 +PREVIEW_TEXT_TRUNCATE_LENGTH = 100 +PREVIEW_ERROR_TRUNCATE_LENGTH = 30 +MAX_PREVIEW_LENGTH = 60 +MAX_CONTENT_LENGTH = 200 + +# ============================================================================= +# Search Constants +# ============================================================================= + +SEARCH_MAX_RESULTS_DEFAULT = 30 +DEFAULT_MAX_RESULTS = 20 +DEFAULT_CONTEXT_SIZE = 150 + +# ============================================================================= +# UI Constants +# ============================================================================= + +SESSION_DISPLAY_LIMIT = 20 +PROJECT_LENGTH = 30 +PROGRESS_BAR_WIDTH = 40 +RECENT_SESSIONS_LIMIT = 5 + +# ============================================================================= +# Search Relevance Constants +# ============================================================================= + +RELEVANCE_THRESHOLD = 0.1 +MIN_RELEVANCE_COMPARED = 1.0 +MATCH_FACTOR_FOR_RELEVANCE = 0.2 +MATCH_BONUS = 0.5 +MIN_RELEVANCE_MULTIPLE_OCCURRENCES = 0.3 +MATCH_FACTOR_MULTIPLE_OCCURRENCES = 0.1 +MIN_RELEVANCE_OVERLAP = 0.4 +MATCH_FACTOR_OVERLAP = 0.4 +PROXIMITY_BONUS = 0.1 +MIN_TOKENS_FOR_PROXIMITY_BONUS = 1 +PROXIMITY_WINDOW_MULTIPLIER = 2 +ADDITIONAL_BOOST_EXACT_MATCH = 0.3 +MATCH_CONTEXT_STEP = 100 +SEMANTIC_SIMILARITY_THRESHOLD = 0.3 +CONTEXT_FALLBACK_MULTIPLIER = 2 + +# ============================================================================= +# Topic Extraction Constants +# ============================================================================= + +DEFAULT_MAX_TOPICS = 5 +CONTENT_LENGTH_PROCESSING = 10 +MAX_NOUN_PHRASES_LENGTH = 3 +MIN_TOPIC_PHRASE_COUNT = 1 diff --git a/src/extract_claude_logs.py b/src/extract_claude_logs.py index 8430825..b9f26d2 100644 --- a/src/extract_claude_logs.py +++ b/src/extract_claude_logs.py @@ -13,6 +13,59 @@ from pathlib import Path from typing import Dict, List, Optional, Tuple +# Optional dependencies for additional export formats +try: + from reportlab.lib.pagesizes import letter + from reportlab.lib.styles import getSampleStyleSheet, ParagraphStyle + from reportlab.lib.units import inch + from reportlab.platypus import SimpleDocTemplate, Paragraph, Spacer, HRFlowable + from reportlab.lib.colors import HexColor + + PDF_AVAILABLE = True +except ImportError: + PDF_AVAILABLE = False + +try: + from docx import Document + from docx.shared import Pt, RGBColor + from docx.enum.text import WD_PARAGRAPH_ALIGNMENT + + DOCX_AVAILABLE = True +except ImportError: + DOCX_AVAILABLE = False + +# Import shared constants +try: + from .constants import ( + INDENT_NUMBER, + MAJOR_SEPARATOR_WIDTH, + MINOR_SEPARATOR_WIDTH, + SESSION_ID_MAX_LENGTH, + LINES_SHOWN_MESSAGE, + LINES_PER_PAGE_MESSAGE, + MAX_LINES_PER_MESSAGE_DISPLAY, + MAX_LINE_LENGTH_DISPLAY, + MIN_PREVIEW_TEXT_LENGTH, + PREVIEW_TEXT_TRUNCATE_LENGTH, + PREVIEW_ERROR_TRUNCATE_LENGTH, + LIST_SEPARATOR_WIDTH, + ) +except ImportError: + from constants import ( + INDENT_NUMBER, + MAJOR_SEPARATOR_WIDTH, + MINOR_SEPARATOR_WIDTH, + SESSION_ID_MAX_LENGTH, + LINES_SHOWN_MESSAGE, + LINES_PER_PAGE_MESSAGE, + MAX_LINES_PER_MESSAGE_DISPLAY, + MAX_LINE_LENGTH_DISPLAY, + MIN_PREVIEW_TEXT_LENGTH, + PREVIEW_TEXT_TRUNCATE_LENGTH, + PREVIEW_ERROR_TRUNCATE_LENGTH, + LIST_SEPARATOR_WIDTH, + ) + class ClaudeConversationExtractor: """Extract and convert Claude Code conversations from JSONL to markdown.""" @@ -65,12 +118,15 @@ def find_sessions(self, project_path: Optional[str] = None) -> List[Path]: sessions.append(jsonl_file) return sorted(sessions, key=lambda x: x.stat().st_mtime, reverse=True) - def extract_conversation(self, jsonl_path: Path, detailed: bool = False) -> List[Dict[str, str]]: + def extract_conversation( + self, jsonl_path: Path, detailed: bool = False, include_todo: bool = False + ) -> List[Dict[str, str]]: """Extract conversation messages from a JSONL file. - + Args: jsonl_path: Path to the JSONL file detailed: If True, include tool use, MCP responses, and system messages + include_todo: If True, include todo lists from planning stages """ conversation = [] @@ -101,7 +157,9 @@ def extract_conversation(self, jsonl_path: Path, detailed: bool = False) -> List msg = entry["message"] if isinstance(msg, dict) and msg.get("role") == "assistant": content = msg.get("content", []) - text = self._extract_text_content(content, detailed=detailed) + text = self._extract_text_content( + content, detailed=detailed, include_todo=include_todo + ) if text and text.strip(): conversation.append( @@ -111,7 +169,7 @@ def extract_conversation(self, jsonl_path: Path, detailed: bool = False) -> List "timestamp": entry.get("timestamp", ""), } ) - + # Include tool use and system messages if detailed mode elif detailed: # Extract tool use events @@ -122,15 +180,18 @@ def extract_conversation(self, jsonl_path: Path, detailed: bool = False) -> List conversation.append( { "role": "tool_use", - "content": f"š§ Tool: {tool_name}\nInput: {json.dumps(tool_input, indent=2)}", + "content": f"š§ Tool: {tool_name}\nInput: " + f"{json.dumps(tool_input, indent=INDENT_NUMBER, ensure_ascii=False)}", "timestamp": entry.get("timestamp", ""), } ) - + # Extract tool results elif entry.get("type") == "tool_result": result = entry.get("result", {}) - output = result.get("output", "") or result.get("error", "") + output = result.get("output", "") or result.get( + "error", "" + ) conversation.append( { "role": "tool_result", @@ -138,7 +199,7 @@ def extract_conversation(self, jsonl_path: Path, detailed: bool = False) -> List "timestamp": entry.get("timestamp", ""), } ) - + # Extract system messages elif entry.get("type") == "system" and "message" in entry: msg = entry.get("message", "") @@ -162,12 +223,15 @@ def extract_conversation(self, jsonl_path: Path, detailed: bool = False) -> List return conversation - def _extract_text_content(self, content, detailed: bool = False) -> str: + def _extract_text_content( + self, content, detailed: bool = False, include_todo: bool = False + ) -> str: """Extract text from various content formats Claude uses. - + Args: content: The content to extract from detailed: If True, include tool use blocks and other metadata + include_todo: If True, include todo lists from tool use inputs """ if isinstance(content, str): return content @@ -178,40 +242,123 @@ def _extract_text_content(self, content, detailed: bool = False) -> str: if isinstance(item, dict): if item.get("type") == "text": text_parts.append(item.get("text", "")) - elif detailed and item.get("type") == "tool_use": - # Include tool use details in detailed mode + elif item.get("type") == "tool_use": tool_name = item.get("name", "unknown") tool_input = item.get("input", {}) - text_parts.append(f"\nš§ Using tool: {tool_name}") - text_parts.append(f"Input: {json.dumps(tool_input, indent=2)}\n") + + # Check for tasks/todo in input + is_todo = ( + "Planner" in tool_name + or "tasks" in tool_input + or "todo" in tool_input + ) + + if detailed: + text_parts.append(f"\nš§ Using tool: {tool_name}") + text_parts.append( + f"Input: {json.dumps(tool_input, indent=INDENT_NUMBER, ensure_ascii=False)}\n" + ) + elif include_todo and is_todo: + # Format todo list nicely + tasks = tool_input.get("tasks", []) or tool_input.get( + "todo", [] + ) + if isinstance(tasks, list) and tasks: + text_parts.append("\nš Todo List:") + for task in tasks: + if isinstance(task, str): + text_parts.append(f"- [ ] {task}") + elif isinstance(task, dict) and "description" in task: + # Handle structured tasks + status = "x" if task.get("completed") else " " + text_parts.append(f"- [{status}] {task['description']}") + return "\n".join(text_parts) else: return str(content) - def display_conversation(self, jsonl_path: Path, detailed: bool = False) -> None: + def _parse_timestamp(self, conversation: List[Dict[str, str]]) -> Tuple[str, str]: + """Parse timestamp from conversation to get date and time strings. + + Args: + conversation: List of message dictionaries + + Returns: + Tuple of (date_str, time_str) + """ + if not conversation: + return datetime.now().strftime("%Y-%m-%d"), "" + + first_timestamp = conversation[0].get("timestamp", "") + if first_timestamp: + try: + dt = datetime.fromisoformat(first_timestamp.replace("Z", "+00:00")) + return dt.strftime("%Y-%m-%d"), dt.strftime("%H:%M:%S") + except Exception: + pass + return datetime.now().strftime("%Y-%m-%d"), "" + + def _clean_project_name(self, project_name: str) -> str: + """Clean project name for use in filenames. + + Args: + project_name: Raw project name + + Returns: + Sanitized project name safe for filenames + """ + if not project_name: + return "" + return project_name.replace("/", "-").replace("\\", "-").replace(" ", "-") + + def _generate_filename( + self, project_name: str, date_str: str, session_id: str, extension: str + ) -> str: + """Generate a filename for exported conversation. + + Args: + project_name: Cleaned project name (can be empty) + date_str: Date string in YYYY-MM-DD format + session_id: Session identifier + extension: File extension (e.g., 'md', 'json', 'html') + + Returns: + Generated filename + """ + clean_project = self._clean_project_name(project_name) + if clean_project: + return f"claude-conversation-{clean_project}-{date_str}-{session_id[:SESSION_ID_MAX_LENGTH]}.{extension}" + return f"claude-conversation-{date_str}-{session_id[:SESSION_ID_MAX_LENGTH]}.{extension}" + + def display_conversation( + self, jsonl_path: Path, detailed: bool = False, include_todo: bool = False + ) -> None: """Display a conversation in the terminal with pagination. - + Args: jsonl_path: Path to the JSONL file detailed: If True, include tool use and system messages + include_todo: If True, include todo lists """ try: # Extract conversation - messages = self.extract_conversation(jsonl_path, detailed=detailed) - + messages = self.extract_conversation( + jsonl_path, detailed=detailed, include_todo=include_todo + ) + if not messages: print("ā No messages found in conversation") return - + # Get session info session_id = jsonl_path.stem - + # Clear screen and show header print("\033[2J\033[H", end="") # Clear screen - print("=" * 60) + print("=" * MAJOR_SEPARATOR_WIDTH) print(f"š Viewing: {jsonl_path.parent.name}") - print(f"Session: {session_id[:8]}...") - + print(f"Session: {session_id[:SESSION_ID_MAX_LENGTH]}...") + # Get timestamp from first message first_timestamp = messages[0].get("timestamp", "") if first_timestamp: @@ -220,93 +367,86 @@ def display_conversation(self, jsonl_path: Path, detailed: bool = False) -> None print(f"Date: {dt.strftime('%Y-%m-%d %H:%M:%S')}") except Exception: pass - - print("=" * 60) + + print("=" * MAJOR_SEPARATOR_WIDTH) print("āā to scroll ⢠Q to quit ⢠Enter to continue\n") - + # Display messages with pagination - lines_shown = 8 # Header lines - lines_per_page = 30 - + lines_shown = LINES_SHOWN_MESSAGE # Header lines + lines_per_page = LINES_PER_PAGE_MESSAGE + for i, msg in enumerate(messages): role = msg["role"] content = msg["content"] - + # Format role display if role == "user" or role == "human": - print(f"\n{'ā' * 40}") - print(f"š¤ HUMAN:") - print(f"{'ā' * 40}") + print(f"\n{'ā' * MINOR_SEPARATOR_WIDTH}") + print("š¤ HUMAN:") + print(f"{'ā' * MINOR_SEPARATOR_WIDTH}") elif role == "assistant": - print(f"\n{'ā' * 40}") - print(f"š¤ CLAUDE:") - print(f"{'ā' * 40}") + print(f"\n{'ā' * MINOR_SEPARATOR_WIDTH}") + print("š¤ CLAUDE:") + print(f"{'ā' * MINOR_SEPARATOR_WIDTH}") elif role == "tool_use": - print(f"\nš§ TOOL USE:") + print("\nš§ TOOL USE:") elif role == "tool_result": - print(f"\nš¤ TOOL RESULT:") + print("\nš¤ TOOL RESULT:") elif role == "system": - print(f"\nā¹ļø SYSTEM:") + print("\nā¹ļø SYSTEM:") else: print(f"\n{role.upper()}:") - + # Display content (limit very long messages) - lines = content.split('\n') - max_lines_per_msg = 50 - + lines = content.split("\n") + max_lines_per_msg = MAX_LINES_PER_MESSAGE_DISPLAY + for line_idx, line in enumerate(lines[:max_lines_per_msg]): # Wrap very long lines - if len(line) > 100: - line = line[:97] + "..." + if len(line) > MAX_LINE_LENGTH_DISPLAY: + line = line[: (MAX_LINE_LENGTH_DISPLAY - 3)] + "..." print(line) lines_shown += 1 - + # Check if we need to paginate if lines_shown >= lines_per_page: - response = input("\n[Enter] Continue ⢠[Q] Quit: ").strip().upper() + response = ( + input("\n[Enter] Continue ⢠[Q] Quit: ").strip().upper() + ) if response == "Q": print("\nš Stopped viewing") return # Clear screen for next page print("\033[2J\033[H", end="") lines_shown = 0 - + if len(lines) > max_lines_per_msg: - print(f"... [{len(lines) - max_lines_per_msg} more lines truncated]") + print( + f"... [{len(lines) - max_lines_per_msg} more lines truncated]" + ) lines_shown += 1 - - print("\n" + "=" * 60) + + print("\n" + "=" * MAJOR_SEPARATOR_WIDTH) print("š End of conversation") - print("=" * 60) + print("=" * MAJOR_SEPARATOR_WIDTH) input("\nPress Enter to continue...") - + except Exception as e: print(f"ā Error displaying conversation: {e}") input("\nPress Enter to continue...") def save_as_markdown( - self, conversation: List[Dict[str, str]], session_id: str + self, + conversation: List[Dict[str, str]], + session_id: str, + project_name: str = "", ) -> Optional[Path]: """Save conversation as clean markdown file.""" if not conversation: return None - # Get timestamp from first message - first_timestamp = conversation[0].get("timestamp", "") - if first_timestamp: - try: - # Parse ISO timestamp - dt = datetime.fromisoformat(first_timestamp.replace("Z", "+00:00")) - date_str = dt.strftime("%Y-%m-%d") - time_str = dt.strftime("%H:%M:%S") - except Exception: - date_str = datetime.now().strftime("%Y-%m-%d") - time_str = "" - else: - date_str = datetime.now().strftime("%Y-%m-%d") - time_str = "" - - filename = f"claude-conversation-{date_str}-{session_id[:8]}.md" + date_str, time_str = self._parse_timestamp(conversation) + filename = self._generate_filename(project_name, date_str, session_id, "md") output_path = self.output_dir / filename with open(output_path, "w", encoding="utf-8") as f: @@ -320,7 +460,7 @@ def save_as_markdown( for msg in conversation: role = msg["role"] content = msg["content"] - + if role == "user": f.write("## š¤ User\n\n") f.write(f"{content}\n\n") @@ -342,26 +482,19 @@ def save_as_markdown( f.write("---\n\n") return output_path - + def save_as_json( - self, conversation: List[Dict[str, str]], session_id: str + self, + conversation: List[Dict[str, str]], + session_id: str, + project_name: str = "", ) -> Optional[Path]: """Save conversation as JSON file.""" if not conversation: return None - # Get timestamp from first message - first_timestamp = conversation[0].get("timestamp", "") - if first_timestamp: - try: - dt = datetime.fromisoformat(first_timestamp.replace("Z", "+00:00")) - date_str = dt.strftime("%Y-%m-%d") - except Exception: - date_str = datetime.now().strftime("%Y-%m-%d") - else: - date_str = datetime.now().strftime("%Y-%m-%d") - - filename = f"claude-conversation-{date_str}-{session_id[:8]}.json" + date_str, _ = self._parse_timestamp(conversation) + filename = self._generate_filename(project_name, date_str, session_id, "json") output_path = self.output_dir / filename # Create JSON structure @@ -369,36 +502,26 @@ def save_as_json( "session_id": session_id, "date": date_str, "message_count": len(conversation), - "messages": conversation + "messages": conversation, } with open(output_path, "w", encoding="utf-8") as f: - json.dump(output, f, indent=2, ensure_ascii=False) + json.dump(output, f, indent=INDENT_NUMBER, ensure_ascii=False) return output_path - + def save_as_html( - self, conversation: List[Dict[str, str]], session_id: str + self, + conversation: List[Dict[str, str]], + session_id: str, + project_name: str = "", ) -> Optional[Path]: """Save conversation as HTML file with syntax highlighting.""" if not conversation: return None - # Get timestamp from first message - first_timestamp = conversation[0].get("timestamp", "") - if first_timestamp: - try: - dt = datetime.fromisoformat(first_timestamp.replace("Z", "+00:00")) - date_str = dt.strftime("%Y-%m-%d") - time_str = dt.strftime("%H:%M:%S") - except Exception: - date_str = datetime.now().strftime("%Y-%m-%d") - time_str = "" - else: - date_str = datetime.now().strftime("%Y-%m-%d") - time_str = "" - - filename = f"claude-conversation-{date_str}-{session_id[:8]}.html" + date_str, time_str = self._parse_timestamp(conversation) + filename = self._generate_filename(project_name, date_str, session_id, "html") output_path = self.output_dir / filename # HTML template with modern styling @@ -407,7 +530,7 @@ def save_as_html(
-