#!/bin/bash # Launch Claude Code in new Terminal windows for selected project folders # Discovers projects from Claude's own conversation history + filesystem scan # # Usage: # ./launch-claude.sh # Interactive picker (default: history mode) # ./launch-claude.sh --all # Launch all detected projects # ./launch-claude.sh --scan # Filesystem scan mode (ignore history) # ./launch-claude.sh --depth 5 # Scan depth for filesystem mode (default: 3) # ./launch-claude.sh arrio nuc # Launch specific folders directly # ─── CONFIG ─────────────────────────────────────────────────────────── SCAN_ROOT="$HOME/Desktop" MAX_DEPTH=3 CLAUDE_HISTORY="$HOME/.claude/history.jsonl" CLAUDE_PROJECTS="$HOME/.claude/projects" # ────────────────────────────────────────────────────────────────────── MODE="history" # history or scan # Parse flags while [[ $# -gt 0 ]]; do case "$1" in --depth) MAX_DEPTH="$2"; shift 2 ;; --scan) MODE="scan"; shift ;; --all) MODE="all"; shift ;; *) break ;; esac done # ─── HELPERS ────────────────────────────────────────────────────────── is_project() { local dir="$1" [ -d "$dir/.git" ] || [ -f "$dir/package.json" ] || [ -f "$dir/pyproject.toml" ] || \ [ -f "$dir/setup.py" ] || [ -f "$dir/requirements.txt" ] || [ -f "$dir/Cargo.toml" ] || \ [ -f "$dir/go.mod" ] || [ -f "$dir/Gemfile" ] || [ -f "$dir/composer.json" ] || \ [ -f "$dir/CLAUDE.md" ] || [ -f "$dir/Dockerfile" ] || [ -f "$dir/docker-compose.yml" ] || \ [ -f "$dir/docker-compose.yaml" ] } find_projects_recursive() { local dir="$1" depth="$2" [[ $depth -gt $MAX_DEPTH ]] && return for sub in "$dir"/*/; do [[ ! -d "$sub" ]] && continue local name=$(basename "$sub") [[ "$name" == .* || "$name" == "node_modules" || "$name" == "vendor" || \ "$name" == "venv" || "$name" == ".venv" || "$name" == "__pycache__" || \ "$name" == "dist" || "$name" == "build" || "$name" == ".next" || \ "$name" == "target" ]] && continue if is_project "$sub"; then echo "$sub" else find_projects_recursive "$sub" $((depth + 1)) fi done 2>/dev/null } get_tags() { local dir="$1" tags="" [ -d "$dir/.git" ] && tags="${tags}git," [ -f "$dir/package.json" ] && tags="${tags}node," { [ -f "$dir/pyproject.toml" ] || [ -f "$dir/setup.py" ] || [ -f "$dir/requirements.txt" ]; } && tags="${tags}py," [ -f "$dir/Cargo.toml" ] && tags="${tags}rust," [ -f "$dir/go.mod" ] && tags="${tags}go," [ -f "$dir/CLAUDE.md" ] && tags="${tags}claude," { [ -f "$dir/Dockerfile" ] || [ -f "$dir/docker-compose.yml" ] || [ -f "$dir/docker-compose.yaml" ]; } && tags="${tags}docker," echo "${tags%,}" } get_relative_path() { local rel="${1#$SCAN_ROOT/}" echo "${rel%/}" } get_git_branch() { git -C "$1" rev-parse --abbrev-ref HEAD 2>/dev/null || echo "-" } get_last_commit() { git -C "$1" log -1 --format="%ar|%s" 2>/dev/null || echo "-|-" } get_dirty_status() { local dir="$1" git -C "$dir" rev-parse --git-dir &>/dev/null || return local staged=$(git -C "$dir" diff --cached --numstat 2>/dev/null | wc -l | tr -d ' ') local unstaged=$(git -C "$dir" diff --numstat 2>/dev/null | wc -l | tr -d ' ') local untracked=$(git -C "$dir" ls-files --others --exclude-standard 2>/dev/null | wc -l | tr -d ' ') local parts="" [[ "$staged" -gt 0 ]] && parts="${parts}+${staged} " [[ "$unstaged" -gt 0 ]] && parts="${parts}~${unstaged} " [[ "$untracked" -gt 0 ]] && parts="${parts}?${untracked} " echo "${parts% }" } time_ago_epoch_ms() { local ms="$1" [[ "$ms" == "0" || -z "$ms" ]] && echo "never" && return local now_ms=$(($(date +%s) * 1000)) local diff_s=$(( (now_ms - ms) / 1000 )) if (( diff_s < 60 )); then echo "just now" elif (( diff_s < 3600 )); then echo "$((diff_s/60))m ago" elif (( diff_s < 86400 )); then echo "$((diff_s/3600))h ago" elif (( diff_s < 604800 )); then echo "$((diff_s/86400))d ago" elif (( diff_s < 2592000 )); then echo "$((diff_s/604800))w ago" else echo "$((diff_s/2592000))mo ago" fi } launch_folders() { local count=0 for dir in "$@"; do local name=$(get_relative_path "$dir") osascript -e " tell application \"Terminal\" activate do script \"cd '$dir' && claude --dangerously-skip-permissions\" end tell " echo " ✓ $name" ((count++)) sleep 0.3 done echo "" echo "Done! $count terminals launched." } # ─── BUILD PROJECT LIST FROM HISTORY ────────────────────────────────── build_from_history() { python3 -c " import json, os, sys from collections import defaultdict scan_root = '$SCAN_ROOT' projects = defaultdict(lambda: {'sessions': set(), 'msgs': 0, 'last': 0}) with open(os.path.expanduser('$CLAUDE_HISTORY')) as f: for line in f: try: d = json.loads(line) p = d.get('project', '') ts = d.get('timestamp', 0) sid = d.get('sessionId', '') # Only include projects under SCAN_ROOT, exclude SCAN_ROOT itself if p and p.startswith(scan_root + '/') and p != scan_root: projects[p]['sessions'].add(sid) projects[p]['msgs'] += 1 projects[p]['last'] = max(projects[p]['last'], ts) except: pass # Output: path|sessions|msgs|last_timestamp_ms # Only include dirs that still exist for p, info in sorted(projects.items(), key=lambda x: -x[1]['last']): if os.path.isdir(p): print(f\"{p}|{len(info['sessions'])}|{info['msgs']}|{info['last']}\") " 2>/dev/null } # ─── BUILD PROJECT LIST FROM FILESYSTEM ─────────────────────────────── build_from_scan() { find_projects_recursive "$SCAN_ROOT" 1 } # ─── DIRECT LAUNCH MODES ───────────────────────────────────────────── if [[ "$MODE" == "all" ]]; then found=() while IFS= read -r line; do found+=("$line"); done < <(build_from_history | cut -d'|' -f1) if [[ ${#found[@]} -eq 0 ]]; then while IFS= read -r line; do found+=("$line"); done < <(build_from_scan) fi echo "Launching ${#found[@]} projects..." launch_folders "${found[@]}" exit 0 fi if [[ $# -gt 0 ]]; then folders=() for name in "$@"; do dir="$SCAN_ROOT/$name" [[ -d "$dir" ]] && folders+=("$dir") || echo "⚠ Skipping '$name' — not found" done launch_folders "${folders[@]}" exit 0 fi # ─── GATHER PROJECT DATA ───────────────────────────────────────────── clear history_lines=() scan_lines=() if [[ "$MODE" == "history" ]]; then printf "\033[1m Reading Claude history...\033[0m\n" while IFS= read -r line; do history_lines+=("$line"); done < <(build_from_history) else printf "\033[1m Scanning filesystem (depth: %d)...\033[0m\n" "$MAX_DEPTH" while IFS= read -r line; do scan_lines+=("$line"); done < <(build_from_scan) fi projects=() project_display=() project_tags=() project_branches=() project_commit_age=() project_commit_msg=() project_dirty=() project_claude_ago=() project_claude_sessions=() project_claude_msgs=() idx=0 if [[ "$MODE" == "history" ]]; then for entry in "${history_lines[@]}"; do [[ -z "$entry" ]] && continue IFS='|' read -r dir sessions msgs last_ts <<< "$entry" projects+=("$dir") project_display+=("$(get_relative_path "$dir")") project_tags+=("$(get_tags "$dir")") project_branches+=("$(get_git_branch "$dir")") commit_info=$(get_last_commit "$dir") project_commit_age+=("${commit_info%%|*}") cmsg="${commit_info#*|}" [[ ${#cmsg} -gt 30 ]] && cmsg="${cmsg:0:27}..." project_commit_msg+=("$cmsg") project_dirty+=("$(get_dirty_status "$dir")") project_claude_ago+=("$(time_ago_epoch_ms "$last_ts")") project_claude_sessions+=("$sessions") project_claude_msgs+=("$msgs") ((idx++)) printf "\r\033[K\033[2m Loading %d projects...\033[0m" "$idx" done else for dir in "${scan_lines[@]}"; do [[ -z "$dir" ]] && continue projects+=("$dir") project_display+=("$(get_relative_path "$dir")") project_tags+=("$(get_tags "$dir")") project_branches+=("$(get_git_branch "$dir")") commit_info=$(get_last_commit "$dir") project_commit_age+=("${commit_info%%|*}") cmsg="${commit_info#*|}" [[ ${#cmsg} -gt 30 ]] && cmsg="${cmsg:0:27}..." project_commit_msg+=("$cmsg") project_dirty+=("$(get_dirty_status "$dir")") project_claude_ago+=("-") project_claude_sessions+=("-") project_claude_msgs+=("-") ((idx++)) printf "\r\033[K\033[2m Scanned %d projects...\033[0m" "$idx" done fi total=${#projects[@]} if [[ $total -eq 0 ]]; then echo "" echo "No projects found." exit 1 fi # ─── INTERACTIVE PICKER ────────────────────────────────────────────── selected=() for ((i=0; i= scroll_offset + visible)); then scroll_offset=$((cursor - visible + 1)); fi local end=$((scroll_offset + visible)) [[ $end -gt $total ]] && end=$total if ((scroll_offset > 0)); then printf " ${C_DIM} ↑ %d more${C_RESET}\033[K\n" "$scroll_offset" else printf "\033[K\n" fi for ((row=scroll_offset; row 0)); then printf " ${C_DIM} ↓ %d more${C_RESET}\033[K\n" "$remaining" else printf "\033[K\n" fi for ((cl=0; cl<3; cl++)); do printf "\033[K\n"; done } clear draw while true; do IFS= read -rsn1 key case "$key" in $'\x1b') read -rsn2 -t 0.1 seq case "$seq" in '[A') ((cursor > 0)) && ((cursor--)) ;; '[B') ((cursor < total-1)) && ((cursor++)) ;; '[5') read -rsn1 -t 0.1; ((cursor -= visible)); ((cursor < 0)) && cursor=0 ;; '[6') read -rsn1 -t 0.1; ((cursor += visible)); ((cursor >= total)) && cursor=$((total-1)) ;; esac ;; ' ') local_i=${sort_indices[$cursor]} selected[$local_i]=$(( 1 - ${selected[$local_i]} )) ((cursor < total-1)) && ((cursor++)) ;; 'a'|'A') for ((i=0; i/dev/null || echo "0") epoch_pairs+=("$ep:$i") done sorted=$(printf '%s\n' "${epoch_pairs[@]}" | sort -t: -k1 -nr) sort_indices=() while IFS= read -r line; do sort_indices+=("${line#*:}"); done <<< "$sorted" unset epoch_pairs ;; 3) # By most sessions sort_label="most sessions" declare -a sess_pairs=() for ((i=0; i/dev/null || echo "0") epoch_pairs+=("$ep:$i") done sorted=$(printf '%s\n' "${epoch_pairs[@]}" | sort -t: -k1 -nr) sort_indices=() while IFS= read -r line; do sort_indices+=("${line#*:}"); done <<< "$sorted" unset epoch_pairs ;; esac fi cursor=0; scroll_offset=0 ;; ''|$'\n') break ;; 'q'|'Q') tput cnorm; clear echo "Cancelled." exit 0 ;; esac draw done tput cnorm; clear chosen=() for ((i=0; i