Last active
January 21, 2026 13:31
-
-
Save peterhartree/490b849614848dcc961e522bfa5de98a to your computer and use it in GitHub Desktop.
Claude Code project switcher - launch Claude in any project with single keypress
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env python3 | |
| """Unified Claude Code launcher with project selection and recent directory tracking. | |
| Displays: | |
| - Current directory with Enter to continue | |
| - Numbered/lettered list of projects from projects.yaml | |
| - Numbered/lettered list of recent directories (excluding project dirs) | |
| Options 1-9 use digits, options 10+ use letters (a-z). | |
| All selections are instant (single keypress). Menu actions use Ctrl+key. | |
| Returns selected directory path to stdout for shell integration. | |
| """ | |
| import os | |
| import sys | |
| import tty | |
| import termios | |
| from pathlib import Path | |
| import yaml | |
| PROJECTS_DIR = Path.home() / "Documents" / "Projects" | |
| PROJECTS_FILE = PROJECTS_DIR / "projects.yaml" | |
| RECENT_DIRS_FILE = Path.home() / ".cc_recent_dirs" | |
| RECENT_DIRS_DISPLAY_LIMIT = 10 | |
| RECENT_DIRS_FILE_LIMIT = 50 | |
| # Letters for options 10+ (full alphabet) | |
| OPTION_LETTERS = "abcdefghijklmnopqrstuvwxyz" # 26 letters for options 10-35 | |
| def index_to_key(idx: int) -> str: | |
| """Convert 1-based index to display key (1-9, then a-z).""" | |
| if 1 <= idx <= 9: | |
| return str(idx) | |
| elif 10 <= idx <= 9 + len(OPTION_LETTERS): | |
| return OPTION_LETTERS[idx - 10] | |
| else: | |
| return "?" | |
| def key_to_index(key: str) -> int | None: | |
| """Convert keypress to 1-based index, or None if invalid.""" | |
| if key.isdigit() and key != "0": | |
| return int(key) | |
| elif key.lower() in OPTION_LETTERS: | |
| return 10 + OPTION_LETTERS.index(key.lower()) | |
| return None | |
| def getch() -> str: | |
| """Read a single character from stdin without waiting for Enter.""" | |
| fd = sys.stdin.fileno() | |
| old_settings = termios.tcgetattr(fd) | |
| try: | |
| tty.setraw(fd) | |
| ch = sys.stdin.read(1) | |
| finally: | |
| termios.tcsetattr(fd, termios.TCSADRAIN, old_settings) | |
| return ch | |
| def load_projects() -> list[dict]: | |
| """Load projects from the index file.""" | |
| if not PROJECTS_FILE.exists(): | |
| return [] | |
| with open(PROJECTS_FILE) as f: | |
| data = yaml.safe_load(f) | |
| return data.get("projects", []) if data else [] | |
| def get_project_paths(projects: list[dict]) -> set[str]: | |
| """Get set of all project directory paths.""" | |
| paths = set() | |
| for p in projects: | |
| folder = p.get("folder", "") | |
| if folder: | |
| path = PROJECTS_DIR / folder | |
| paths.add(str(path)) | |
| return paths | |
| def load_recent_dirs(exclude_paths: set[str]) -> list[str]: | |
| """Load recent directories, excluding project paths, deduplicated, most recent first.""" | |
| if not RECENT_DIRS_FILE.exists(): | |
| return [] | |
| with open(RECENT_DIRS_FILE) as f: | |
| lines = [line.strip() for line in f if line.strip()] | |
| # Reverse to get most recent first, deduplicate while preserving order | |
| seen = set() | |
| recent = [] | |
| for d in reversed(lines): | |
| if d not in seen and d not in exclude_paths and Path(d).exists(): | |
| seen.add(d) | |
| recent.append(d) | |
| return recent | |
| def trim_recent_dirs_file(): | |
| """Trim the recent dirs file to the limit.""" | |
| if not RECENT_DIRS_FILE.exists(): | |
| return | |
| with open(RECENT_DIRS_FILE) as f: | |
| lines = [line.strip() for line in f if line.strip()] | |
| if len(lines) > RECENT_DIRS_FILE_LIMIT: | |
| # Keep the most recent entries | |
| lines = lines[-RECENT_DIRS_FILE_LIMIT:] | |
| with open(RECENT_DIRS_FILE, 'w') as f: | |
| f.write('\n'.join(lines) + '\n') | |
| def display_menu( | |
| current_dir: str, | |
| projects: list[dict], | |
| recent_dirs: list[str], | |
| show_archived: bool = False, | |
| show_all_recent: bool = False, | |
| total_recent: int = 0 | |
| ) -> tuple[list[dict], list[str]]: | |
| """Display the menu and return (displayed_projects, displayed_recent_dirs).""" | |
| # Clear screen and move cursor to top | |
| print("\033[2J\033[H", file=sys.stderr, end="") | |
| # Current directory | |
| print(f"Current: {current_dir}", file=sys.stderr) | |
| print("Press Enter to start here, or select:\n", file=sys.stderr) | |
| # Filter projects | |
| if show_archived: | |
| displayed_projects = projects # Preserve YAML order | |
| else: | |
| displayed_projects = [p for p in projects if p.get("status") == "active"] | |
| # Projects section | |
| print("Projects:", file=sys.stderr) | |
| for i, p in enumerate(displayed_projects, 1): | |
| key = index_to_key(i) | |
| name = p.get("name", p.get("folder", "Unnamed")) | |
| status = "" if p.get("status") == "active" else " (archived)" | |
| print(f" {key}. {name}{status}", file=sys.stderr) | |
| # Recent directories section | |
| if recent_dirs: | |
| print("\nRecent:", file=sys.stderr) | |
| start_idx = len(displayed_projects) + 1 | |
| if show_all_recent: | |
| displayed_recent = recent_dirs | |
| else: | |
| displayed_recent = recent_dirs[:RECENT_DIRS_DISPLAY_LIMIT] | |
| for i, d in enumerate(displayed_recent): | |
| idx = start_idx + i | |
| key = index_to_key(idx) | |
| print(f" {key}. {d}", file=sys.stderr) | |
| if not show_all_recent and total_recent > RECENT_DIRS_DISPLAY_LIMIT: | |
| print(f" ... (showing {RECENT_DIRS_DISPLAY_LIMIT} of {total_recent})", file=sys.stderr) | |
| else: | |
| displayed_recent = [] | |
| # Footer | |
| print(file=sys.stderr) | |
| archived_count = len([p for p in projects if p.get("status") == "archived"]) | |
| hints = [] | |
| if not show_all_recent and total_recent > RECENT_DIRS_DISPLAY_LIMIT: | |
| hints.append("^A all recent") | |
| if not show_archived and archived_count > 0: | |
| hints.append(f"^Z archived ({archived_count})") | |
| hints.append("^C quit") | |
| print(f" {' '.join(hints)}", file=sys.stderr) | |
| print(file=sys.stderr) | |
| return displayed_projects, displayed_recent | |
| def select_option(idx: int, displayed_projects: list[dict], displayed_recent: list[str]) -> bool: | |
| """Try to select option by index. Returns True if successful (exits), False otherwise.""" | |
| total_options = len(displayed_projects) + len(displayed_recent) | |
| if not (1 <= idx <= total_options): | |
| return False | |
| if idx <= len(displayed_projects): | |
| # Project selection | |
| folder = displayed_projects[idx - 1].get("folder", "") | |
| name = displayed_projects[idx - 1].get("name", folder) | |
| path = PROJECTS_DIR / folder | |
| if path.exists(): | |
| print(f"\n Opening {name}...\n", file=sys.stderr) | |
| print(path) | |
| sys.exit(0) | |
| else: | |
| print(f"Directory not found: {path}", file=sys.stderr) | |
| return False | |
| else: | |
| # Recent dir selection | |
| recent_idx = idx - len(displayed_projects) - 1 | |
| if recent_idx < len(displayed_recent): | |
| path = displayed_recent[recent_idx] | |
| print(f"\n Opening {path}...\n", file=sys.stderr) | |
| print(path) | |
| sys.exit(0) | |
| return False | |
| def main(): | |
| current_dir = sys.argv[1] if len(sys.argv) > 1 else os.getcwd() | |
| # Trim recent dirs file periodically | |
| trim_recent_dirs_file() | |
| # Load data | |
| projects = load_projects() | |
| project_paths = get_project_paths(projects) | |
| all_recent_dirs = load_recent_dirs(project_paths) | |
| total_recent = len(all_recent_dirs) | |
| show_archived = False | |
| show_all_recent = False | |
| displayed_projects, displayed_recent = display_menu( | |
| current_dir, projects, all_recent_dirs, show_archived, show_all_recent, total_recent | |
| ) | |
| while True: | |
| try: | |
| ch = getch() | |
| except (EOFError, KeyboardInterrupt): | |
| print(file=sys.stderr) | |
| sys.exit(1) | |
| # Handle Ctrl+C, Ctrl+D, Ctrl+Q - quit | |
| if ch in ('\x03', '\x04', '\x11'): | |
| print(file=sys.stderr) | |
| sys.exit(1) | |
| # Enter - use current directory | |
| if ch in ('\n', '\r'): | |
| print(f"\n Using current directory...\n", file=sys.stderr) | |
| print(current_dir) | |
| sys.exit(0) | |
| # Ctrl+A - show all recent | |
| if ch == '\x01' and not show_all_recent and total_recent > RECENT_DIRS_DISPLAY_LIMIT: | |
| show_all_recent = True | |
| displayed_projects, displayed_recent = display_menu( | |
| current_dir, projects, all_recent_dirs, show_archived, show_all_recent, total_recent | |
| ) | |
| continue | |
| # Ctrl+Z - show archived | |
| if ch == '\x1a' and not show_archived: | |
| show_archived = True | |
| displayed_projects, displayed_recent = display_menu( | |
| current_dir, projects, all_recent_dirs, show_archived, show_all_recent, total_recent | |
| ) | |
| continue | |
| # Try to select by key (digit 1-9 or letter b-y) | |
| idx = key_to_index(ch) | |
| if idx is not None: | |
| select_option(idx, displayed_projects, displayed_recent) | |
| # If select_option returns (didn't exit), the selection failed - just continue | |
| if __name__ == "__main__": | |
| main() |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment