commit c79c5eef4a3679dd18a71220cf3c792b11167bcc Author: Ryan T. Murphy Date: Thu Jan 22 18:46:03 2026 -0500 Initial commit from B2 archive diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..28640b1 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +# Claude Code project instructions +CLAUDE.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..a0dab40 --- /dev/null +++ b/README.md @@ -0,0 +1,120 @@ +# project-selector + +FZF-based project selector for Claude Code. Navigate to projects by category, auto-create CLAUDE.md files, link skills from a marketplace, and manage running Claude sessions to prevent CPU overload. + +## Installation + +```bash +# Add to your .bashrc or .zshrc +source /path/to/project-selector/cw.sh +``` + +## Usage + +```bash +cw # Launch project selector +cw --resume # Launch with args passed to claude +``` + +## Features + +### Category-Based Navigation + +Select from organized project categories: + +| Category | Paths | +|----------|-------| +| Sales | `/opt/clients/client-upfrontops/*` | +| Admin | `/opt/personal/*`, `/opt/docker-vpn` | +| Fun | `/opt/fun/*` | +| Pre-Revenue | `/opt/pre-revenue/*` | +| Clients | `/opt/clients/*` | +| Personal | `/opt/personal/*` | +| Infrastructure | `/opt/infra/*`, `/opt/docker-vpn`, `/opt/marketplace`, `/opt/tools` | +| All Repos | Scans all `.git` directories under `/opt` | +| Manual Selection | Drill down from `/opt` | + +### Session Management + +Prevents CPU overload from too many concurrent Claude instances: + +- **Info Display**: Shows running Claude sessions at startup (load <60%, <4 sessions) +- **Hard Block**: Must kill a session to proceed (load >=60% OR >=4 sessions) +- **Orphan Detection**: Flags sessions with no controlling terminal as `(ORPHAN)` +- **Graceful Termination**: SIGTERM with 5-second wait, then SIGKILL if needed + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + Load: 34% (5-min avg) | Sessions: 2 running + /opt/clients/acme-corp (2h 15m, 47% CPU) + /opt/pre-revenue/newapp (45m, 23% CPU) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +### CLAUDE.md Scaffolding + +When navigating to a directory without a CLAUDE.md file: + +1. Prompts to create one +2. Asks for project description +3. Asks for common commands +4. Generates structured CLAUDE.md + +### Skill Linking + +Links skills from `/opt/marketplace/skills/` to projects: + +1. Multi-select skills with fzf (Tab to toggle) +2. Creates `.claude/skills/` directory +3. Symlinks selected skills + +### Git Integration + +- Auto-detects git repos (stops drill-down at `.git`) +- Prompts to initialize git for non-repo directories +- Shows parent repo context when in subdirectory + +## Navigation Controls + +| Action | Key | +|--------|-----| +| Select | Enter | +| Cancel | Esc | +| Go back | Select `[GO BACK]` | +| Change category | Select `[CHANGE CATEGORY]` | +| Start here | Select `[START HERE]` | +| Create folder | Select `[NEW FOLDER]` | +| Multi-select (skills) | Tab | + +## Dependencies + +- `fzf` - Fuzzy finder for selection UI +- `claude` - Claude Code CLI (auto-detects `~/.local/bin/claude` for native installer) + +## Configuration + +Categories and paths are defined in `cw.sh`. Edit the case statement in the `cw()` function to customize. + +## Session Management Thresholds + +| Condition | Behavior | +|-----------|----------| +| Load <60% AND sessions <4 | Show info, continue | +| Load >=60% OR sessions >=4 | Must kill a session | + +Load is calculated as 5-minute load average divided by CPU count. + +## File Structure + +``` +project-selector/ +├── cw.sh # Main bash function +├── README.md # This file +├── .gitignore # Ignores CLAUDE.md +└── docs/ + └── plans/ # Design documents +``` + +## License + +MIT diff --git a/cw.sh b/cw.sh new file mode 100644 index 0000000..64609bd --- /dev/null +++ b/cw.sh @@ -0,0 +1,605 @@ +#!/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" +} diff --git a/docs/plans/2026-01-22-session-manager-design.md b/docs/plans/2026-01-22-session-manager-design.md new file mode 100644 index 0000000..1c8d3f6 --- /dev/null +++ b/docs/plans/2026-01-22-session-manager-design.md @@ -0,0 +1,179 @@ +# Claude Session Manager for `cw` + +## Overview + +Add a pre-flight check at the start of `cw` that monitors running Claude sessions and enforces a tiered response based on system load and session count. Prevents CPU overload from too many concurrent Claude instances and helps identify/clean orphaned processes. + +## Problem Statement + +- Multiple Claude instances running simultaneously cause sustained 100% CPU +- User intentionally multitasks across projects but loses track of session count +- Orphaned Claude processes (from closed terminals) pile up and waste resources + +## Tiered Logic + +``` +┌─────────────────────────────────────────────────────────────┐ +│ cw() starts │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ +┌─────────────────────────────────────────────────────────────┐ +│ Count running Claude processes + get their details │ +│ Get system 5-min load average │ +└─────────────────────────────────────────────────────────────┘ + │ + ▼ + ┌─────────────────────┐ + │ Sessions >= 4 OR │ + │ Load avg >= 60%? │ + └─────────┬───────────┘ + yes │ no + ┌───────────────┴───────────────┐ + ▼ ▼ + ┌──────────────────┐ ┌─────────────────┐ + │ HARD BLOCK │ │ INFO ONLY │ + │ Must kill one │ │ Show sessions │ + │ to proceed │ │ (if any) │ + └──────────────────┘ │ Continue auto │ + └─────────────────┘ +``` + +### Rules Summary + +| Condition | Behavior | +|-----------|----------| +| Load < 60% AND sessions < 4 | Show running sessions as FYI, continue to category menu | +| Load >= 60% OR sessions >= 4 | Hard block - must kill a session to proceed | + +## Session Display Format + +Each session shown as: +``` +/opt/clients/acme-corp (2h 15m, 47% CPU) +/opt/infra/project-selector (ORPHAN) (45m, 12% CPU) +``` + +Orphans flagged with `(ORPHAN)` tag so user knows they're safe to kill. + +## Implementation Details + +### Detecting Claude Sessions + +```bash +# Get Claude processes with: PID, CPU%, elapsed time, controlling TTY, working directory +ps -eo pid,pcpu,etime,tty,args | grep -E "^[0-9].*claude$" | grep -v grep +``` + +For each PID, get the working directory via: +```bash +readlink -f /proc//cwd +``` + +### Detecting Orphans + +A Claude process is orphaned if: +- Its controlling TTY is `?` (no terminal attached), OR +- Its parent PID is 1 (adopted by init) + +Check parent PID via: +```bash +ps -o ppid= -p +``` + +### Load Average + +Use the 5-minute load average from `/proc/loadavg`, normalized to CPU count: + +```bash +load=$(awk '{print $2}' /proc/loadavg) +cpus=$(nproc) +percent=$(awk "BEGIN {printf \"%.0f\", ($load / $cpus) * 100}") +``` + +### Kill Flow + +1. User selects session from fzf menu +2. Send `SIGTERM` to PID +3. Wait up to 5 seconds, checking if process died +4. If still alive, send `SIGKILL` +5. Confirm death, refresh session list + +## User Experience Flow + +### Hard Block Scenario (>=4 sessions OR >=60% load) + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +⚠️ Cannot start new Claude session + Load: 73% (5-min avg) | Sessions: 4 running +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ + +Select a session to kill (or Esc to cancel): + +> /opt/clients/acme-corp (2h 15m, 47% CPU) + /opt/pre-revenue/newapp (45m, 23% CPU) + /opt/infra/project-selector (ORPHAN) (1h 02m, 3% CPU) + /opt/fun/gamedev (12m, 18% CPU) +``` + +After selection: +``` +Stopping /opt/clients/acme-corp (PID 10764)... +Terminated gracefully. + +Proceeding to project selection... +``` + +### Info Only Scenario (<4 sessions AND <60% load) + +``` +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +📊 Load: 34% (5-min avg) | Sessions: 2 running + • /opt/clients/acme-corp (2h 15m, 47% CPU) + • /opt/pre-revenue/newapp (45m, 23% CPU) +━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━ +``` + +Then immediately shows the category menu (no user action needed). + +### No Sessions Running + +Skip the status display entirely, go straight to category menu. + +## Code Structure + +### New Helper Functions + +| Function | Purpose | +|----------|---------| +| `_cw_get_claude_sessions` | Returns array of running Claude PIDs with metadata | +| `_cw_format_session` | Formats a session for display (dir, runtime, CPU%, orphan tag) | +| `_cw_get_load_percent` | Returns 5-min load avg as percentage of CPU capacity | +| `_cw_kill_session` | Graceful kill with SIGTERM→wait→SIGKILL fallback | +| `_cw_session_check` | Main orchestrator: gathers data, decides tier, shows UI | + +### Integration Point + +At the very top of `cw()`, after dependency checks: + +```bash +cw() { + # Dependency checks (existing) + ... + + # NEW: Session/load check + _cw_session_check || return 0 + + # Rest of existing code + ... +} +``` + +`_cw_session_check` returns: +- `0` (success) - proceed with `cw` +- `1` (failure) - user cancelled at hard block, exit `cw` + +### File Changes + +Only `cw.sh` needs modification - all new functions added to the same file. diff --git a/install.sh b/install.sh new file mode 100755 index 0000000..3ab168a --- /dev/null +++ b/install.sh @@ -0,0 +1,88 @@ +#!/bin/bash +# One-shot installer for project-selector +# Run after backup restore: curl -fsSL https://git.upfrontops.cloud/UpfrontOps/project-selector/raw/branch/main/install.sh | bash +# Or locally: /opt/infra/project-selector/install.sh + +set -e + +INSTALL_DIR="/opt/infra/project-selector" +REPO_URL="https://git.upfrontops.cloud/UpfrontOps/project-selector.git" +BASHRC="$HOME/.bashrc" +SOURCE_LINE="source $INSTALL_DIR/cw.sh" + +echo "=== Project Selector Installer ===" +echo "" + +# Check dependencies +echo "Checking dependencies..." + +if ! command -v fzf &>/dev/null; then + echo "Installing fzf..." + if command -v apt-get &>/dev/null; then + sudo apt-get update && sudo apt-get install -y fzf + elif command -v brew &>/dev/null; then + brew install fzf + else + echo "Error: Cannot install fzf. Please install manually." >&2 + exit 1 + fi +fi +echo " fzf: OK" + +if ! command -v git &>/dev/null; then + echo "Error: git is required but not installed" >&2 + exit 1 +fi +echo " git: OK" + +# Check for Node.js (required for npm claude) +if ! command -v node &>/dev/null; then + echo "Installing Node.js..." + if command -v apt-get &>/dev/null; then + curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - + sudo apt-get install -y nodejs + elif command -v brew &>/dev/null; then + brew install node + else + echo "Error: Cannot install Node.js. Please install manually." >&2 + exit 1 + fi +fi +echo " node: OK ($(node --version))" + +# Install Claude Code via npm (NOT native installer) +if ! command -v claude &>/dev/null; then + echo "Installing Claude Code via npm..." + sudo npm install -g @anthropic-ai/claude-code +fi +echo " claude: OK (npm)" + +# Clone or update repo +echo "" +if [ -d "$INSTALL_DIR/.git" ]; then + echo "Updating existing installation..." + cd "$INSTALL_DIR" + git fetch origin + git reset --hard origin/main 2>/dev/null || git reset --hard origin/master +else + echo "Cloning project-selector..." + mkdir -p "$(dirname "$INSTALL_DIR")" + git clone "$REPO_URL" "$INSTALL_DIR" +fi + +# Add to bashrc if not already present +echo "" +if grep -qF "$SOURCE_LINE" "$BASHRC" 2>/dev/null; then + echo "Already in $BASHRC" +else + echo "Adding to $BASHRC..." + echo "" >> "$BASHRC" + echo "# Project Selector for Claude Code" >> "$BASHRC" + echo "$SOURCE_LINE" >> "$BASHRC" +fi + +echo "" +echo "=== Installation Complete ===" +echo "" +echo "Run 'source ~/.bashrc' or start a new terminal, then use 'cw' to start." +echo ""