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('')
for i, conv in enumerate(conversations):
- conv_id = conv["info"].get("conversation_id", f"conv-{i+1}")
+ conv_id = conv["info"].get("conversation_id", f"conv-{i + 1}")
start_time = conv["info"].get("start_time", "Unknown time")
- html_parts.append(f'- 📝 Conversation {i+1} ({start_time})
')
+ html_parts.append(f'- 📝 Conversation {i + 1} ({start_time})
')
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" },