Initial commit from B2 archive
This commit is contained in:
2
.gitignore
vendored
Normal file
2
.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
# Claude Code project instructions
|
||||
CLAUDE.md
|
||||
120
README.md
Normal file
120
README.md
Normal 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
605
cw.sh
Normal 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"
|
||||
}
|
||||
179
docs/plans/2026-01-22-session-manager-design.md
Normal file
179
docs/plans/2026-01-22-session-manager-design.md
Normal 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
88
install.sh
Executable 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 ""
|
||||
Reference in New Issue
Block a user