diff --git a/.gitignore b/.gitignore index b2e5db8..9ad6a08 100644 --- a/.gitignore +++ b/.gitignore @@ -55,3 +55,4 @@ Thumbs.db test-*.html example-*.html *.jsonl +nul diff --git a/src/claude_notes/__main__.py b/src/claude_notes/__main__.py index f3b9262..0ba19ba 100644 --- a/src/claude_notes/__main__.py +++ b/src/claude_notes/__main__.py @@ -1,10 +1,25 @@ """Entry point for the claude-notes CLI.""" +import os +import sys + from claude_notes.cli import cli def main(): """Main entry point.""" + # Fix Windows console encoding issues + if sys.platform == "win32": + # Try to set UTF-8 encoding for stdout/stderr + try: + if sys.stdout.encoding.lower() != "utf-8": + sys.stdout.reconfigure(encoding="utf-8") + if sys.stderr.encoding.lower() != "utf-8": + sys.stderr.reconfigure(encoding="utf-8") + except (AttributeError, OSError): + # Fallback: set environment variable + os.environ.setdefault("PYTHONIOENCODING", "utf-8") + cli() diff --git a/src/claude_notes/cli.py b/src/claude_notes/cli.py index 853de30..d21ea77 100644 --- a/src/claude_notes/cli.py +++ b/src/claude_notes/cli.py @@ -18,13 +18,47 @@ def get_claude_projects_dir() -> Path: return Path.home() / ".claude" / "projects" +def _decode_segments(encoded: str, separator: str) -> str: + """Decode dash-separated segments, where '--' represents a literal dash.""" + decoded_parts: list[str] = [] + i = 0 + while i < len(encoded): + char = encoded[i] + if char == "-": + if i + 1 < len(encoded) and encoded[i + 1] == "-": + decoded_parts.append("-") + i += 2 + else: + decoded_parts.append(separator) + i += 1 + else: + decoded_parts.append(char) + i += 1 + return "".join(decoded_parts) + + +def _encode_segments(path: str) -> str: + """Encode path segments by escaping literal dashes.""" + return path.replace("-", "--").replace("/", "-") + + def decode_project_path(encoded_name: str) -> str: """Decode the project folder name to actual path.""" - # Remove leading dash and replace dashes with slashes + # Windows path (e.g., "C--Users-projects-my--project") + if len(encoded_name) >= 3 and encoded_name[1:3] == "--" and encoded_name[0].isalpha(): + drive = encoded_name[0] + rest_encoded = encoded_name[3:] + rest = _decode_segments(rest_encoded, "/") + return f"{drive}:/{rest}" if rest else f"{drive}:/" + + # Unix/Linux path (e.g., "-home-user-my--project") if encoded_name.startswith("-"): - encoded_name = encoded_name[1:] + encoded_body = encoded_name[1:] + decoded_body = _decode_segments(encoded_body, "/") + return "/" + decoded_body - return "/" + encoded_name.replace("-", "/") + # Fallback: return as-is if it doesn't match expected encodings + return encoded_name def list_projects() -> list[tuple[str, Path, int]]: @@ -37,9 +71,18 @@ def list_projects() -> list[tuple[str, Path, int]]: projects = [] for project_folder in projects_dir.iterdir(): - if project_folder.is_dir() and project_folder.name.startswith("-"): + if not project_folder.is_dir(): + continue + + # Check if it's a valid project folder + # Unix/Linux: starts with "-" (e.g., "-home-user-project") + # Windows: starts with drive letter (e.g., "c--Users-Jack-project" or "C--Users-Jack-project") + name = project_folder.name + is_valid = name.startswith("-") or (len(name) >= 3 and name[1:3] == "--" and name[0].isalpha()) + + if is_valid: # Decode the project path - actual_path = decode_project_path(project_folder.name) + actual_path = decode_project_path(name) # Count JSONL files (transcripts) jsonl_files = list(project_folder.glob("*.jsonl")) @@ -84,10 +127,21 @@ def list_projects_cmd(): def encode_project_path(path: str) -> str: """Encode a project path to Claude folder name format.""" - # Remove leading slash and replace slashes with dashes - if path.startswith("/"): - path = path[1:] - return "-" + path.replace("/", "-") + normalized = path.replace("\\", "/") + + # Windows path with drive letter (e.g., C:/Users/...) + if len(normalized) >= 2 and normalized[1] == ":" and normalized[0].isalpha(): + drive = normalized[0] + rest = normalized[2:] + if rest.startswith("/"): + rest = rest[1:] + encoded_rest = _encode_segments(rest) + return f"{drive}--{encoded_rest}" + + # Unix/Linux path (leading slash) + normalized = normalized.lstrip("/") + encoded_body = _encode_segments(normalized) + return "-" + encoded_body def find_project_folder(project_path: Path) -> Path | None: @@ -96,8 +150,19 @@ def find_project_folder(project_path: Path) -> Path | None: encoded_name = encode_project_path(str(project_path)) project_folder = projects_dir / encoded_name + # Try exact match first if project_folder.exists() and project_folder.is_dir(): return project_folder + + # On Windows, try case-insensitive match (drive letter might be uppercase or lowercase) + if not projects_dir.exists(): + return None + + encoded_lower = encoded_name.lower() + for folder in projects_dir.iterdir(): + if folder.is_dir() and folder.name.lower() == encoded_lower: + return folder + return None @@ -263,9 +328,9 @@ def show( html_parts.append("

Conversations

") html_parts.append('") html_parts.append("") diff --git a/src/claude_notes/pager.py b/src/claude_notes/pager.py index 0a7d61a..475a88f 100644 --- a/src/claude_notes/pager.py +++ b/src/claude_notes/pager.py @@ -1,8 +1,6 @@ """Pager implementation for progressive content display like 'less' CLI.""" import sys -import termios -import tty from typing import Any from rich.console import Console @@ -10,6 +8,13 @@ from claude_notes.formatters.terminal import TerminalFormatter +# Handle Windows vs Unix terminal input +if sys.platform == "win32": + import msvcrt +else: + import termios + import tty + class Pager: """A pager that displays content progressively like the 'less' command.""" @@ -177,51 +182,100 @@ def _show_end_status(self) -> None: def _get_user_input(self) -> str: """Get user input for pager controls.""" - try: - # Save terminal settings - fd = sys.stdin.fileno() - old_settings = termios.tcgetattr(fd) + if sys.platform == "win32": + # Windows implementation using msvcrt + try: + # Read single character without echo + ch = msvcrt.getch() + + # msvcrt.getch() returns bytes, decode to string + if isinstance(ch, bytes): + try: + ch = ch.decode("utf-8") + except UnicodeDecodeError: + # Handle special keys (arrow keys, etc.) + if ch == b"\xe0": # Special key prefix + ch2 = msvcrt.getch() + if ch2 == b"H": # Up arrow + return "prev_line" + elif ch2 == b"P": # Down arrow + return "next_line" + elif ch2 == b"I": # Page Up + return "prev_page" + elif ch2 == b"Q": # Page Down + return "next_page" + return "next_page" + + # Handle different key presses + if ch == "\r" or ch == " ": # Enter or Space + return "next_page" + elif ch == "q": + return "quit" + elif ch == "j": # j for next line + return "next_line" + elif ch == "k": # k for previous line + return "prev_line" + elif ch == "b": # Back one page + return "prev_page" + elif ch == "g": # Go to top + return "top" + elif ch == "G": # Go to bottom (capital G) + return "bottom" + elif ch == "h": # Help + self._show_help() + return "help" + else: + # Default to next page for any other key + return "next_page" + except (OSError, KeyboardInterrupt): + return "quit" + else: + # Unix implementation using termios + try: + # Save terminal settings + fd = sys.stdin.fileno() + old_settings = termios.tcgetattr(fd) - # Set terminal to raw mode for single character input - tty.setraw(sys.stdin.fileno()) + # Set terminal to raw mode for single character input + tty.setraw(sys.stdin.fileno()) - # Read single character - ch = sys.stdin.read(1) + # Read single character + ch = sys.stdin.read(1) - # Restore terminal settings - termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) + # Restore terminal settings + termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) - # Handle different key presses - if ch == "\n" or ch == "\r" or ch == " ": # Enter or Space - return "next_page" - elif ch.lower() == "q": - return "quit" - elif ch.lower() == "j": # j for next line - return "next_line" - elif ch.lower() == "k": # k for previous line - return "prev_line" - elif ch.lower() == "b": # Back one page - return "prev_page" - elif ch.lower() == "g": # Go to top - return "top" - elif ch.lower() == "G": # Go to bottom - return "bottom" - elif ch.lower() == "h": # Help - self._show_help() - return "help" - else: - # Default to next page for any other key - return "next_page" - - except (termios.error, OSError): - # Fallback for environments that don't support raw input - try: - line = input() - if line.lower().startswith("q"): + # Handle different key presses + if ch == "\n" or ch == "\r" or ch == " ": # Enter or Space + return "next_page" + elif ch == "q": + return "quit" + elif ch == "j": # j for next line + return "next_line" + elif ch == "k": # k for previous line + return "prev_line" + elif ch == "b": # Back one page + return "prev_page" + elif ch == "g": # Go to top + return "top" + elif ch == "G": # Go to bottom + return "bottom" + elif ch == "h": # Help + self._show_help() + return "help" + else: + # Default to next page for any other key + return "next_page" + + except (termios.error, OSError): + # Fallback for environments that don't support raw input + try: + line = input() + if line.lower().startswith("q"): + return "quit" + return "next_page" + except (EOFError, KeyboardInterrupt): return "quit" - return "next_page" - except (EOFError, KeyboardInterrupt): - return "quit" def _show_help(self) -> None: """Show help message.""" diff --git a/uv.lock b/uv.lock index c6de857..a88a64a 100644 --- a/uv.lock +++ b/uv.lock @@ -1,5 +1,5 @@ version = 1 -revision = 2 +revision = 3 requires-python = ">=3.11" [[package]] @@ -13,7 +13,7 @@ wheels = [ [[package]] name = "claude-notes" -version = "0.1.1" +version = "0.1.2" source = { editable = "." } dependencies = [ { name = "click" },