New script that launches Claude Code (via claude-profile) inside a detached tmux session with --remote-control enabled, in a project's directory, named after the project (Title-cased, auto-incrementing #N on collision). Lets a session be started for later attach over VPN or control from the Claude app. Spec-first per repo conventions: specs/claude-tmux.spec.md, script, and tests/test-claude-tmux.sh (18 dryrun assertions, all passing). Claude-Session: https://claude.ai/code/session_014oa7T7z1h5n34vu8vX9zGT
157 lines
5.2 KiB
Bash
Executable File
157 lines
5.2 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# claude-tmux — Spawn a detached tmux session running Claude Code with Remote Control
|
|
#
|
|
# Usage:
|
|
# claude-tmux <project> # spawn a Remote-Control session for a project
|
|
# claude-tmux # prompt for project (TTY only)
|
|
# claude-tmux <project> --mode <mode> # override engagement mode
|
|
# claude-tmux <project> --profile <name> # override profile (default: oreillyit-anthropic)
|
|
# claude-tmux <project> --name <display> # override session / Remote-Control name
|
|
# claude-tmux <project> --attach # attach (or switch-client) after spawning
|
|
# claude-tmux <project> --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
|