#!/bin/bash # Project Selector for Claude Code # Usage: cw [args passed to claude] # Source this file in your .bashrc cw() { # Dependency checks if ! command -v fzf &>/dev/null; then echo "Error: fzf is required but not installed" >&2 return 1 fi local claude_bin claude_bin=$(command -v claude 2>/dev/null) if [ -z "$claude_bin" ]; then echo "Error: claude is required but not installed" >&2 echo "Install via npm: sudo npm install -g @anthropic-ai/claude-code" >&2 return 1 fi # Session/load check - may block or show info _cw_session_check || return 0 local dir="" local category="" # Outer loop to allow returning to category selection while true; do # Category selection category=$(printf '%s\n' \ "💼 Sales" \ "📋 Admin" \ "🎮 Fun" \ "🚀 Pre-Revenue" \ "👥 Clients" \ "🏠 Personal" \ "🔧 Infrastructure" \ "🔍 All Repos" \ "📂 Manual Selection" \ | fzf --height=14 --border --header="What are we working on today?") [ -z "$category" ] && return 0 # Helper to build category menu with [CHANGE CATEGORY] and [NEW FOLDER] _cw_category_menu() { local base_path="$1" shift local items=("$@") printf '%s\n' "[CHANGE CATEGORY]" "[NEW FOLDER]" if [ ${#items[@]} -gt 0 ]; then printf '%s\n' "${items[@]}" fi } # Map category to folder(s) and show projects case "$category" in "💼 Sales") local -a sales_dirs=() while IFS= read -r d; do [ -n "$d" ] && sales_dirs+=("${d%/}") done < <(ls -d /opt/clients/client-upfrontops/*/ 2>/dev/null) dir=$(_cw_category_menu "/opt/clients/client-upfrontops" "${sales_dirs[@]}" \ | fzf --height=15 --border --header="Select sales area (or NEW FOLDER)") [ "$dir" = "[NEW FOLDER]" ] && { _cw_new_folder "/opt/clients/client-upfrontops" && dir="$_CW_NEW_DIR" || continue; } ;; "📋 Admin") # Admin includes personal + docker-vpn local -a admin_dirs=() while IFS= read -r d; do [ -n "$d" ] && admin_dirs+=("${d%/}") done < <(ls -d /opt/personal/*/ 2>/dev/null) admin_dirs+=("/opt/docker-vpn") dir=$(_cw_category_menu "/opt/personal" "${admin_dirs[@]}" \ | fzf --height=15 --border --header="Select admin area (or NEW FOLDER)") [ "$dir" = "[NEW FOLDER]" ] && { _cw_new_folder "/opt/personal" && dir="$_CW_NEW_DIR" || continue; } ;; "🎮 Fun") local -a fun_dirs=() while IFS= read -r d; do [ -n "$d" ] && fun_dirs+=("${d%/}") done < <(ls -d /opt/fun/*/ 2>/dev/null) if [ ${#fun_dirs[@]} -eq 0 ]; then echo "No projects in /opt/fun/ yet" fi dir=$(_cw_category_menu "/opt/fun" "${fun_dirs[@]}" \ | fzf --height=15 --border --header="Select fun project (or NEW FOLDER)") [ "$dir" = "[NEW FOLDER]" ] && { _cw_new_folder "/opt/fun" && dir="$_CW_NEW_DIR" || continue; } ;; "🚀 Pre-Revenue") local -a prerev_dirs=() while IFS= read -r d; do [ -n "$d" ] && prerev_dirs+=("${d%/}") done < <(ls -d /opt/pre-revenue/*/ 2>/dev/null) dir=$(_cw_category_menu "/opt/pre-revenue" "${prerev_dirs[@]}" \ | fzf --height=15 --border --header="Select pre-revenue project (or NEW FOLDER)") [ "$dir" = "[NEW FOLDER]" ] && { _cw_new_folder "/opt/pre-revenue" && dir="$_CW_NEW_DIR" || continue; } ;; "👥 Clients") local -a client_dirs=() while IFS= read -r d; do [ -n "$d" ] && client_dirs+=("${d%/}") done < <(ls -d /opt/clients/*/ 2>/dev/null) dir=$(_cw_category_menu "/opt/clients" "${client_dirs[@]}" \ | fzf --height=15 --border --header="Select client (or NEW FOLDER)") [ "$dir" = "[NEW FOLDER]" ] && { _cw_new_folder "/opt/clients" && dir="$_CW_NEW_DIR" || continue; } ;; "🏠 Personal") local -a personal_dirs=() while IFS= read -r d; do [ -n "$d" ] && [[ "$d" != *"/legal/"* ]] && personal_dirs+=("${d%/}") done < <(ls -d /opt/personal/*/ 2>/dev/null) dir=$(_cw_category_menu "/opt/personal" "${personal_dirs[@]}" \ | fzf --height=15 --border --header="Select personal project (or NEW FOLDER)") [ "$dir" = "[NEW FOLDER]" ] && { _cw_new_folder "/opt/personal" && dir="$_CW_NEW_DIR" || continue; } ;; "🔧 Infrastructure") # Infrastructure scans /opt/infra + known infrastructure dirs local -a infra_dirs=() while IFS= read -r d; do [ -n "$d" ] && infra_dirs+=("${d%/}") done < <(ls -d /opt/infra/*/ 2>/dev/null) # Add other infra locations infra_dirs+=("/opt/docker-vpn" "/opt/marketplace" "/opt/tools") [ -d "/opt/docker-parlay-edge" ] && infra_dirs+=("/opt/docker-parlay-edge") dir=$(_cw_category_menu "/opt/infra" "${infra_dirs[@]}" \ | fzf --height=15 --border --header="Select infrastructure (or NEW FOLDER)") [ "$dir" = "[NEW FOLDER]" ] && { _cw_new_folder "/opt/infra" && dir="$_CW_NEW_DIR" || continue; } ;; "🔍 All Repos") local -a repo_dirs=() while IFS= read -r d; do [ -n "$d" ] && repo_dirs+=("$d") done < <(find /opt -name ".git" -type d 2>/dev/null | sed 's/\/.git$//' | sort) dir=$(printf '%s\n' "[CHANGE CATEGORY]" "${repo_dirs[@]}" \ | fzf --height=40% --border --header="All git repos (type to search)") ;; "📂 Manual Selection") dir="/opt" ;; esac # Handle [CHANGE CATEGORY] or cancel [ -z "$dir" ] && return 0 [ "$dir" = "[CHANGE CATEGORY]" ] && continue dir="${dir%/}" # Remove trailing slash # Manual drill-down mode while true; do # Build menu: [START HERE], [NEW FOLDER], [GO BACK] if not at /opt, [CHANGE CATEGORY], then subdirs local -a menu_items=("[START HERE]" "[NEW FOLDER]") [ "$dir" != "/opt" ] && menu_items+=("[GO BACK]") menu_items+=("[CHANGE CATEGORY]") # Only show subdirs if NOT a git repo (don't drill into repos) if [ ! -d "$dir/.git" ]; then local -a subdirs=() while IFS= read -r subdir; do [ -n "$subdir" ] && [ "$subdir" != "containerd" ] && subdirs+=("$subdir") done < <(ls -d "$dir"/*/ 2>/dev/null | xargs -r -n1 basename 2>/dev/null | sort) menu_items+=("${subdirs[@]}") fi local choice choice=$(printf '%s\n' "${menu_items[@]}" | fzf --prompt="$dir/ " --height=40% --border --header="Select folder, START HERE, or NEW FOLDER") # Cancelled [ -z "$choice" ] && return 0 # Go back one level if [ "$choice" = "[GO BACK]" ]; then dir=$(dirname "$dir") continue fi # Return to category selection if [ "$choice" = "[CHANGE CATEGORY]" ]; then break fi # Create new folder at current level if [ "$choice" = "[NEW FOLDER]" ]; then if _cw_new_folder "$dir"; then dir="$_CW_NEW_DIR" fi continue fi # Selected current dir (or auto-selected git repo) if [ "$choice" = "[START HERE]" ]; then if ! cd "$dir" 2>/dev/null; then echo "Error: Cannot access $dir" >&2 return 1 fi # Check for CLAUDE.md if [ -f "CLAUDE.md" ]; then # CLAUDE.md exists - just offer to manage skills if no .claude/skills if [ ! -d ".claude/skills" ]; then echo "" read -p "Add skills to this project? [y/N] " yn if [[ "$yn" =~ ^[Yy]$ ]]; then _cw_link_skills "$(basename "$dir")" fi fi else _cw_create_claude_md "$dir" fi # Check git repo status local git_root git_root=$(git rev-parse --show-toplevel 2>/dev/null) if [ -z "$git_root" ]; then echo "" echo "Not inside a git repo" read -p "Initialize git repo here? [y/N] " yn if [[ "$yn" =~ ^[Yy]$ ]]; then git init echo "Initialized git repo" fi elif [ "$git_root" != "$dir" ]; then echo "" echo "Inside repo: $git_root" fi # Re-check session/load right before launch (prevents race condition) _cw_session_check || return 0 "$claude_bin" "$@" return 0 fi # Drill down into selected subdirectory dir="$dir/$choice" done done # End outer category loop } # Helper: Get 5-min load average as percentage of CPU capacity _cw_get_load_percent() { local load cpus load=$(awk '{print $2}' /proc/loadavg) cpus=$(nproc) awk "BEGIN {printf \"%.0f\", ($load / $cpus) * 100}" } # Helper: Get running Claude sessions with metadata # Output format per line: PID|CPU%|ELAPSED|CWD|ORPHAN _cw_get_claude_sessions() { local pid cpu elapsed tty cwd ppid is_orphan while read -r pid cpu elapsed tty _; do [ -z "$pid" ] && continue # Get working directory cwd=$(readlink -f "/proc/$pid/cwd" 2>/dev/null) || continue # Check if orphan (no TTY or parent is init) is_orphan="" if [ "$tty" = "?" ]; then is_orphan="ORPHAN" else ppid=$(ps -o ppid= -p "$pid" 2>/dev/null | tr -d ' ') [ "$ppid" = "1" ] && is_orphan="ORPHAN" fi echo "$pid|$cpu|$elapsed|$cwd|$is_orphan" done < <(ps aux 2>/dev/null | awk '/[c]laude/ && !/awk|grep|ripgrep/ {print $2, $3, $10, $7}') } # Helper: Format session for display # Input: PID|CPU%|ELAPSED|CWD|ORPHAN # Output: /path/to/project (ORPHAN) (1h 02m, 47% CPU) _cw_format_session() { local line="$1" local pid cpu elapsed cwd orphan IFS='|' read -r pid cpu elapsed cwd orphan <<< "$line" # Format elapsed time (from [[DD-]HH:]MM:SS to human readable) local formatted_time="" if [[ "$elapsed" =~ ^([0-9]+)-([0-9]+):([0-9]+):([0-9]+)$ ]]; then formatted_time="${BASH_REMATCH[1]}d ${BASH_REMATCH[2]}h" elif [[ "$elapsed" =~ ^([0-9]+):([0-9]+):([0-9]+)$ ]]; then local hours="${BASH_REMATCH[1]}" local mins="${BASH_REMATCH[2]}" if [ "$hours" -gt 0 ]; then formatted_time="${hours}h ${mins}m" else formatted_time="${mins}m" fi elif [[ "$elapsed" =~ ^([0-9]+):([0-9]+)$ ]]; then formatted_time="${BASH_REMATCH[1]}m" else formatted_time="$elapsed" fi # Build output local output="$cwd" [ -n "$orphan" ] && output="$output (ORPHAN)" output="$output ($formatted_time, ${cpu}% CPU)" echo "$output" } # Helper: Kill a Claude session gracefully # Returns 0 on success, 1 on failure _cw_kill_session() { local pid="$1" local cwd="$2" echo "Stopping $cwd (PID $pid)..." # Send SIGTERM kill -TERM "$pid" 2>/dev/null # Wait up to 5 seconds for graceful termination local waited=0 while [ $waited -lt 5 ]; do if ! kill -0 "$pid" 2>/dev/null; then echo "Terminated gracefully." return 0 fi sleep 1 ((waited++)) done # Still alive, force kill echo "Process didn't stop, forcing..." kill -KILL "$pid" 2>/dev/null sleep 0.5 if ! kill -0 "$pid" 2>/dev/null; then echo "Terminated forcefully." return 0 else echo "Error: Failed to kill process $pid" >&2 return 1 fi } # Helper: Session check orchestrator # Returns 0 to proceed, 1 to abort _cw_session_check() { local load_percent session_count local -a sessions=() local -a orphans=() local -a formatted=() # Gather data load_percent=$(_cw_get_load_percent) while IFS= read -r line; do [ -n "$line" ] && sessions+=("$line") done < <(_cw_get_claude_sessions) session_count=${#sessions[@]} # No sessions running - skip display entirely [ "$session_count" -eq 0 ] && return 0 # Auto-kill orphans first local orphan_count=0 local -a live_sessions=() for session in "${sessions[@]}"; do local pid cwd orphan IFS='|' read -r pid _ _ cwd orphan <<< "$session" if [ "$orphan" = "ORPHAN" ]; then echo "Killing orphaned session: $cwd (PID $pid)" kill -KILL "$pid" 2>/dev/null ((orphan_count++)) else live_sessions+=("$session") fi done if [ "$orphan_count" -gt 0 ]; then echo "Cleaned up $orphan_count orphaned session(s)" echo "" fi # Update sessions to only live ones sessions=("${live_sessions[@]}") session_count=${#sessions[@]} # No live sessions remaining - skip display [ "$session_count" -eq 0 ] && return 0 # Format sessions for display for session in "${sessions[@]}"; do formatted+=("$(_cw_format_session "$session")") done # Determine tier local hard_block=false if [ "$session_count" -ge 4 ] || [ "$load_percent" -ge 60 ]; then hard_block=true fi if [ "$hard_block" = true ]; then # HARD BLOCK - must kill sessions until below threshold # Need to get to <4 sessions AND <60% load local sessions_to_kill=0 if [ "$session_count" -ge 4 ]; then sessions_to_kill=$((session_count - 3)) # Get down to 3 fi # If load is the issue, need to kill at least 1 if [ "$load_percent" -ge 60 ] && [ "$sessions_to_kill" -lt 1 ]; then sessions_to_kill=1 fi echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " Cannot start new Claude session" echo " Load: ${load_percent}% (5-min avg) | Sessions: $session_count running" if [ "$sessions_to_kill" -gt 1 ]; then echo " Must kill $sessions_to_kill sessions to continue" fi echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" local killed=0 while [ "$killed" -lt "$sessions_to_kill" ]; do local remaining=$((sessions_to_kill - killed)) local header="Select a session to kill ($remaining more required, Esc to cancel)" [ "$remaining" -eq 1 ] && header="Select a session to kill (Esc to cancel)" local choice choice=$(printf '%s\n' "${formatted[@]}" | fzf --height=15 --border --header="$header") [ -z "$choice" ] && return 1 # Find the matching session to get its PID local idx=0 for f in "${formatted[@]}"; do if [ "$f" = "$choice" ]; then local session_data="${sessions[$idx]}" local pid cwd IFS='|' read -r pid _ _ cwd _ <<< "$session_data" echo "" if _cw_kill_session "$pid" "$cwd"; then ((killed++)) # Remove from arrays unset 'sessions[idx]' unset 'formatted[idx]' sessions=("${sessions[@]}") formatted=("${formatted[@]}") else echo "Try selecting another session." fi break fi ((idx++)) done done echo "" echo "Proceeding to project selection..." sleep 1 return 0 else # INFO ONLY - show sessions and continue echo "" echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo " Load: ${load_percent}% (5-min avg) | Sessions: $session_count running" for f in "${formatted[@]}"; do echo " $f" done echo "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" echo "" return 0 fi } # Helper: Create new folder # Sets $_CW_NEW_DIR on success _cw_new_folder() { local parent="$1" _CW_NEW_DIR="" # Ensure parent exists if [ ! -d "$parent" ]; then echo "Creating parent directory: $parent" mkdir -p "$parent" || { echo "Error: Cannot create $parent" >&2; return 1; } fi echo "" read -p "New folder name in $parent/: " folder_name # Validate folder name if [ -z "$folder_name" ]; then echo "Cancelled" return 1 fi # Remove problematic characters folder_name=$(echo "$folder_name" | tr -cd 'a-zA-Z0-9_-') if [ -z "$folder_name" ]; then echo "Error: Invalid folder name" >&2 return 1 fi local new_path="$parent/$folder_name" if [ -d "$new_path" ]; then echo "Folder already exists: $new_path" _CW_NEW_DIR="$new_path" return 0 fi if mkdir -p "$new_path"; then echo "Created: $new_path" _CW_NEW_DIR="$new_path" return 0 else echo "Error: Cannot create $new_path" >&2 return 1 fi } # Helper: Link skills using fzf multi-select _cw_link_skills() { local project_name="$1" if [ ! -d "/opt/marketplace/skills" ]; then echo "No skills directory found at /opt/marketplace/skills" return 1 fi echo "Select skills (Tab to toggle, Enter to confirm):" local -a selected_skills=() while IFS= read -r skill; do [ -n "$skill" ] && selected_skills+=("$skill") done < <(ls /opt/marketplace/skills/ 2>/dev/null | fzf --multi --height=50% --border --header="Select skills for $project_name (Tab=toggle, Enter=done)") if [ ${#selected_skills[@]} -eq 0 ]; then return 0 fi mkdir -p .claude/skills for skill in "${selected_skills[@]}"; do if [ -d "/opt/marketplace/skills/$skill" ]; then ln -sf "/opt/marketplace/skills/$skill" ".claude/skills/$skill" echo "Linked skill: $skill" else echo "Skill not found: $skill" >&2 fi done } # Helper: Create CLAUDE.md with safe input handling _cw_create_claude_md() { local dir="$1" local project_name project_name=$(basename "$dir") echo "" echo "No CLAUDE.md found in $dir" read -p "Create CLAUDE.md? [y/N] " yn if [[ ! "$yn" =~ ^[Yy]$ ]]; then return 0 fi echo "" read -p "Project description (1-2 sentences): " proj_desc echo "" read -p "Common commands (or press Enter to skip): " proj_cmds echo "" # Create CLAUDE.md safely with printf (no variable expansion issues) { printf '%s\n' "# $project_name" printf '\n' printf '%s\n' "## Project Overview" printf '%s\n' "$proj_desc" printf '\n' printf '%s\n' "## Quick Reference" printf '%s\n' "- **Timezone:** EST (America/New_York)" if [ -n "$proj_cmds" ]; then printf '\n' printf '%s\n' "## Common Commands" printf '%s\n' '```bash' printf '%s\n' "$proj_cmds" printf '%s\n' '```' fi } > CLAUDE.md echo "Created CLAUDE.md" # Link skills _cw_link_skills "$project_name" }