#!/usr/bin/env bash # claude-tmux — Spawn a detached tmux session running Claude Code with Remote Control # # Usage: # claude-tmux # spawn a Remote-Control session for a project # claude-tmux # prompt for project (TTY only) # claude-tmux --mode # override engagement mode # claude-tmux --profile # override profile (default: oreillyit-anthropic) # claude-tmux --name # override session / Remote-Control name # claude-tmux --attach # attach (or switch-client) after spawning # claude-tmux --dryrun # preview, create nothing # # Spec: specs/claude-tmux.spec.md set -euo pipefail # === Constants === DEFAULT_PROFILE="oreillyit-anthropic" CLAUDE_ROOT="$HOME/dev/claude" PROFILE_DIR_PREFIX="$HOME/.claude-" SCRIPT_PATH="$(readlink -f "$0")" SCRIPT_DIR="$(dirname "$SCRIPT_PATH")" # === Colours === RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m' err() { echo -e "${RED}Error:${NC} $*" >&2; exit 1; } # === CLI parsing === PROJECT="" MODE="" PROFILE="$DEFAULT_PROFILE" NAME_OVERRIDE="" DRYRUN=0 ATTACH=0 usage() { sed -n '2,13p' "$SCRIPT_PATH" | sed 's/^# \?//'; } while [[ $# -gt 0 ]]; do case "$1" in --mode) MODE="${2:-}"; shift 2 ;; --profile) PROFILE="${2:-}"; shift 2 ;; --name) NAME_OVERRIDE="${2:-}"; shift 2 ;; --attach) ATTACH=1; shift ;; --dryrun|-n) DRYRUN=1; shift ;; --help|-h) usage; exit 0 ;; -*) err "Unknown flag: $1" ;; *) if [[ -z "$PROJECT" ]]; then PROJECT="$1"; else err "Unexpected argument: $1"; fi shift ;; esac done # === Resolve profile dir === if [[ "$PROFILE" == "default" ]]; then PROFILE_DIR="$HOME/.claude" else PROFILE_DIR="${PROFILE_DIR_PREFIX}${PROFILE}" fi [[ -d "$PROFILE_DIR" ]] || err "Profile directory not found: $PROFILE_DIR" # === Resolve project (prompt if TTY, else error) === if [[ -z "$PROJECT" ]]; then if [[ -t 0 ]]; then read -rp "Project name: " PROJECT fi [[ -n "$PROJECT" ]] || err "No project given. Pass a project name as the first argument." fi resolve_project_dir() { local p="$1" if [[ -d "$p" ]]; then (cd "$p" && pwd); return 0; fi if [[ -d "$CLAUDE_ROOT/$p" ]]; then echo "$CLAUDE_ROOT/$p"; return 0; fi if [[ -d "$CLAUDE_ROOT/projects/$p" ]]; then echo "$CLAUDE_ROOT/projects/$p"; return 0; fi return 1 } PROJECT_DIR=$(resolve_project_dir "$PROJECT") \ || err "Project folder not found for '$PROJECT' (looked in $CLAUDE_ROOT/ and $CLAUDE_ROOT/projects/)" PROJECT_BASENAME="$(basename "$PROJECT_DIR")" # === Resolve mode (always explicit — never let claude-profile show its picker) === if [[ -z "$MODE" ]]; then [[ -f "$PROFILE_DIR/last-mode" ]] && MODE="$(cat "$PROFILE_DIR/last-mode")" MODE="${MODE:-quick}" fi # === Locate claude-profile === if command -v claude-profile >/dev/null 2>&1; then CLAUDE_PROFILE_BIN="$(command -v claude-profile)" else CLAUDE_PROFILE_BIN="$SCRIPT_DIR/claude-profile" fi [[ -x "$CLAUDE_PROFILE_BIN" ]] \ || err "claude-profile not found (looked on PATH and at $SCRIPT_DIR/claude-profile)" # === Derive display name (Title Case) with auto-increment === title_case() { echo "$1" | tr '_-' ' ' \ | awk '{ for (i=1; i<=NF; i++) $i = toupper(substr($i,1,1)) substr($i,2); print }' } session_exists() { tmux has-session -t "=$1" 2>/dev/null; } if [[ -n "$NAME_OVERRIDE" ]]; then BASE_NAME="$NAME_OVERRIDE" else BASE_NAME="$(title_case "$PROJECT_BASENAME")" fi NAME="$BASE_NAME" n=2 while session_exists "$NAME"; do NAME="$BASE_NAME #$n" n=$((n + 1)) done # === Build launch command (shell-safe) === LAUNCH_CMD=$(printf '%q %q --mode %q --project %q -- --remote-control %q' \ "$CLAUDE_PROFILE_BIN" "$PROFILE" "$MODE" "$PROJECT_BASENAME" "$NAME") # Prefer the short PATH name in display/execution when it resolved via PATH. if [[ "$CLAUDE_PROFILE_BIN" == "$(command -v claude-profile 2>/dev/null)" ]]; then LAUNCH_CMD=$(printf 'claude-profile %q --mode %q --project %q -- --remote-control %q' \ "$PROFILE" "$MODE" "$PROJECT_BASENAME" "$NAME") fi # === Dryrun === if (( DRYRUN )); then echo "[dryrun] Project: $PROJECT_BASENAME ($PROJECT_DIR)" echo "[dryrun] Profile: $PROFILE ($PROFILE_DIR)" echo "[dryrun] Mode: $MODE" echo "[dryrun] Session name: $NAME" echo "[dryrun] Would run: tmux new-session -d -s \"$NAME\" -c \"$PROJECT_DIR\"" echo "[dryrun] Would send: $LAUNCH_CMD" echo "[dryrun] Attach with: tmux attach -t \"$NAME\"" exit 0 fi # === Spawn === tmux new-session -d -s "$NAME" -c "$PROJECT_DIR" # send-keys pane-target does not accept the "=" exact-match prefix; a bare name # still prefers an exact session match, so this is unambiguous. tmux send-keys -t "$NAME" -l "$LAUNCH_CMD" tmux send-keys -t "$NAME" Enter echo -e "${GREEN}Spawned${NC} tmux session: $NAME" echo " dir: $PROJECT_DIR" echo " profile: $PROFILE / mode: $MODE" echo " attach: tmux attach -t \"$NAME\"" echo " app: Remote Control name \"$NAME\"" if (( ATTACH )); then if [[ -n "${TMUX:-}" ]]; then tmux switch-client -t "=$NAME" else tmux attach -t "=$NAME" fi fi