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