Initial commit from B2 archive

This commit is contained in:
Ryan T. Murphy
2026-01-22 18:46:03 -05:00
commit c79c5eef4a
5 changed files with 994 additions and 0 deletions

2
.gitignore vendored Normal file
View File

@@ -0,0 +1,2 @@
# Claude Code project instructions
CLAUDE.md

120
README.md Normal file
View File

@@ -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

605
cw.sh Normal file
View File

@@ -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"
}

View File

@@ -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/<PID>/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 <PID>
```
### 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.

88
install.sh Executable file
View File

@@ -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 ""