Skip to content

Instantly share code, notes, and snippets.

@peterhartree
Last active January 21, 2026 13:31
Show Gist options
  • Select an option

  • Save peterhartree/490b849614848dcc961e522bfa5de98a to your computer and use it in GitHub Desktop.

Select an option

Save peterhartree/490b849614848dcc961e522bfa5de98a to your computer and use it in GitHub Desktop.
Claude Code project switcher - launch Claude in any project with single keypress
#!/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