From 448091ecf47724a65335afe2a204ccd00847cc18 Mon Sep 17 00:00:00 2001 From: Paul O'Reilly Date: Sat, 25 Jul 2026 16:29:46 +1200 Subject: [PATCH] feat(claude-tmux): spawn detached tmux Claude Code session with Remote Control 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 --- README.md | 1 + scripts/claude-tmux | 156 ++++++++++++++++++++++++++++++++++++++ specs/claude-tmux.spec.md | 105 +++++++++++++++++++++++++ tests/test-claude-tmux.sh | 113 +++++++++++++++++++++++++++ 4 files changed, 375 insertions(+) create mode 100755 scripts/claude-tmux create mode 100644 specs/claude-tmux.spec.md create mode 100755 tests/test-claude-tmux.sh diff --git a/README.md b/README.md index 720c6a8..9245930 100644 --- a/README.md +++ b/README.md @@ -16,6 +16,7 @@ This project uses **spec-driven development** (OpenSpec) as a testbed for agent- |--------|---------|--------| | `check-skills` | Verify Claude Code skill symlinks match source repo | Done | | `claude-profile` | Claude Code profile + engagement-mode launcher | Done | +| `claude-tmux` | Spawn a detached tmux Claude Code session with Remote Control, named after a project (wraps `claude-profile`) | Done | | `gen-secret` | Generate bash/YAML/JSON-safe random strings | Done | | `git-status-report` | Recursive git repo status with diff stats | Done | | `md-to-docx` | Markdown to DOCX conversion | Done | diff --git a/scripts/claude-tmux b/scripts/claude-tmux new file mode 100755 index 0000000..1e05760 --- /dev/null +++ b/scripts/claude-tmux @@ -0,0 +1,156 @@ +#!/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 diff --git a/specs/claude-tmux.spec.md b/specs/claude-tmux.spec.md new file mode 100644 index 0000000..53b3b22 --- /dev/null +++ b/specs/claude-tmux.spec.md @@ -0,0 +1,105 @@ +# claude-tmux + +## Purpose + +Spawn a detached `tmux` session running Claude Code (via `claude-profile`) with +Remote Control enabled, in a project's directory, with the session named after +the project so it is easy to `tmux attach` to and easy to identify in the Claude +app. + +## Usage + +``` +claude-tmux [PROJECT] [OPTIONS] +``` + +### Arguments + +| Argument | Default | Description | +|----------|---------|-------------| +| `PROJECT` | *(prompt if TTY)* | Project name (folder under `~/dev/claude` or `~/dev/claude/projects`), or an explicit path to a directory | + +### Flags + +| Flag | Default | Description | +|------|---------|-------------| +| `--mode ` | profile's `last-mode`, else `quick` | Engagement mode passed to `claude-profile` | +| `--profile ` | `oreillyit-anthropic` | Claude Code profile (resolves to `~/.claude-`, or `~/.claude` for `default`) | +| `--name ` | Title-cased project name | Override the session / Remote-Control name | +| `--attach` | off | Attach (or `switch-client` if already inside tmux) after spawning | +| `--dryrun`, `-n` | off | Preview all actions without creating anything | +| `--help`, `-h` | | Show usage information | + +## Behaviour + +1. Resolve the profile directory (`~/.claude-`, or `~/.claude` for + `default`). Error if it does not exist. +2. Resolve the project. If `PROJECT` is empty and stdin is a TTY, prompt for it; + if empty and not a TTY, error. Resolve the folder by trying, in order: an + existing path, `~/dev/claude/`, `~/dev/claude/projects/`. + Error if none exist. +3. Resolve the engagement mode. If `--mode` is not given, read the profile's + `last-mode` file, falling back to `quick`. The mode is **always** resolved + explicitly and passed to `claude-profile` — the launcher must never leave + `claude-profile` to show its interactive mode picker, because Remote Control + only registers in the app once `claude` is actually running. +4. Derive the base session name: Title-Case the project folder basename + (`agent-runtimes` → `Agent Runtimes`), unless `--name` overrides it. +5. Auto-increment: if a tmux session with the base name already exists, append + ` #2`, ` #3`, … until a free name is found. The first session gets no suffix. +6. Create a detached tmux session named after the resolved name, with its working + directory set to the project folder. +7. Send the launch command into the session's pane (sent literally, followed by + Enter): + `claude-profile --mode --project -- --remote-control ""` +8. Print the attach hint and the Remote-Control name. If `--attach` is set, + attach (or `switch-client` when already inside tmux). + +`claude-profile` is located via `PATH`, falling back to a sibling of this script. + +## Dryrun Behaviour + +When `--dryrun` / `-n` is passed, no tmux session is created and nothing is sent. +Output (values substituted): + +``` +[dryrun] Project: agent-runtimes (/home/paul/dev/claude/projects/agent-runtimes) +[dryrun] Profile: oreillyit-anthropic (/home/paul/.claude-oreillyit-anthropic) +[dryrun] Mode: deep +[dryrun] Session name: Agent Runtimes +[dryrun] Would run: tmux new-session -d -s "Agent Runtimes" -c "/home/paul/dev/claude/projects/agent-runtimes" +[dryrun] Would send: claude-profile oreillyit-anthropic --mode deep --project agent-runtimes -- --remote-control Agent\ Runtimes +[dryrun] Attach with: tmux attach -t "Agent Runtimes" +``` + +## Edge Cases + +| Case | Handling | +|------|----------| +| No PROJECT and stdin is a TTY | Prompt `Project name:` | +| No PROJECT and not a TTY (e.g. run by an agent) | Error: "No project given…", exit 1 | +| Project folder not found | Error naming the searched locations, exit 1 | +| Profile directory not found | Error, exit 1 | +| Session name already taken | Auto-increment with ` #N` suffix | +| Project name has underscores | Treated like hyphens for Title-Casing | +| `claude-profile` not found | Error, exit 1 | +| Two concurrent sessions on the same profile | Both work: `claude-profile` rewrites the shared `active-mode.env`, but each session's **cwd takes precedence** for project auto-selection, so the correct project is still picked | + +## Examples + +```bash +# Spawn a Remote-Control session for agent-runtimes (Title-cased, mode from last-mode) +$ claude-tmux agent-runtimes +Spawned tmux session: Agent Runtimes + attach: tmux attach -t "Agent Runtimes" + +# A second one auto-increments +$ claude-tmux agent-runtimes +Spawned tmux session: Agent Runtimes #2 + +# Override the mode, and attach immediately +$ claude-tmux cluster-bootstrap --mode deep --attach + +# Preview only +$ claude-tmux dns-manager --dryrun +``` diff --git a/tests/test-claude-tmux.sh b/tests/test-claude-tmux.sh new file mode 100755 index 0000000..6746e78 --- /dev/null +++ b/tests/test-claude-tmux.sh @@ -0,0 +1,113 @@ +#!/usr/bin/env bash +set -uo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$0")/.." && pwd)" +CLAUDE_TMUX="$SCRIPT_DIR/scripts/claude-tmux" + +RED='\033[0;31m'; GREEN='\033[0;32m'; NC='\033[0m' +pass=0; fail=0 + +assert_eq() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" == "$actual" ]]; then + echo -e "${GREEN}PASS${NC}: $desc"; ((pass++)) + else + echo -e "${RED}FAIL${NC}: $desc"; echo " expected: $expected"; echo " actual: $actual"; ((fail++)) + fi +} +assert_contains() { + local desc="$1" needle="$2" haystack="$3" + if [[ "$haystack" == *"$needle"* ]]; then + echo -e "${GREEN}PASS${NC}: $desc"; ((pass++)) + else + echo -e "${RED}FAIL${NC}: $desc"; echo " needle: $needle"; echo " haystack: $haystack"; ((fail++)) + fi +} +assert_exit() { + local desc="$1" expected="$2" actual="$3" + if [[ "$expected" -eq "$actual" ]]; then + echo -e "${GREEN}PASS${NC}: $desc"; ((pass++)) + else + echo -e "${RED}FAIL${NC}: $desc"; echo " expected exit: $expected"; echo " actual exit: $actual"; ((fail++)) + fi +} + +# === Hermetic environment === +TEST_HOME="$(mktemp -d)" +STUB_BIN="$(mktemp -d)" +cleanup() { + rm -rf "$TEST_HOME" "$STUB_BIN" + tmux kill-session -t "=Agent Runtimes" 2>/dev/null || true +} +trap cleanup EXIT + +# Stub claude-profile on PATH so LAUNCH_CMD renders deterministically. +cat > "$STUB_BIN/claude-profile" <<'EOF' +#!/usr/bin/env bash +exit 0 +EOF +chmod +x "$STUB_BIN/claude-profile" + +export HOME="$TEST_HOME" +export PATH="$STUB_BIN:$PATH" +mkdir -p "$HOME/.claude-oreillyit-anthropic" +echo "deep" > "$HOME/.claude-oreillyit-anthropic/last-mode" +mkdir -p "$HOME/dev/claude/projects/agent-runtimes" +mkdir -p "$HOME/dev/claude/octopus" + +echo "=== claude-tmux tests ===" +echo + +# --- Basic dryrun: title-case name, mode from last-mode --- +out=$("$CLAUDE_TMUX" agent-runtimes --dryrun 2>&1) +assert_contains "dryrun shows title-cased session name" "Session name: Agent Runtimes" "$out" +assert_contains "dryrun mode from last-mode (deep)" "Mode: deep" "$out" +assert_contains "dryrun resolves projects/ folder" "Project: agent-runtimes (" "$out" +assert_contains "dryrun launch cmd has remote-control name" '--remote-control Agent\ Runtimes' "$out" +assert_contains "dryrun launch cmd passes --project basename" "--project agent-runtimes" "$out" +assert_contains "dryrun attach hint quotes the name" 'tmux attach -t "Agent Runtimes"' "$out" + +# --- Mode override --- +out=$("$CLAUDE_TMUX" agent-runtimes --mode quick --dryrun 2>&1) +assert_contains "dryrun honours --mode override" "Mode: quick" "$out" + +# --- Name override --- +out=$("$CLAUDE_TMUX" agent-runtimes --name "Custom Name" --dryrun 2>&1) +assert_contains "dryrun honours --name override" "Session name: Custom Name" "$out" + +# --- Top-level (non-projects) folder resolves --- +out=$("$CLAUDE_TMUX" octopus --dryrun 2>&1) +assert_contains "dryrun resolves top-level ~/dev/claude folder" "Session name: Octopus" "$out" + +# --- Unknown project errors --- +out=$("$CLAUDE_TMUX" no-such-project --dryrun 2>&1); rc=$? +assert_exit "unknown project exits 1" 1 "$rc" +assert_contains "unknown project error mentions search locations" "Project folder not found" "$out" + +# --- No project, non-TTY errors --- +out=$("$CLAUDE_TMUX" --dryrun &1); rc=$? +assert_exit "no project (non-TTY) exits 1" 1 "$rc" +assert_contains "no project error is helpful" "No project given" "$out" + +# --- Missing profile errors --- +out=$("$CLAUDE_TMUX" agent-runtimes --profile no-such-profile --dryrun 2>&1); rc=$? +assert_exit "missing profile exits 1" 1 "$rc" +assert_contains "missing profile error names dir" "Profile directory not found" "$out" + +# --- Help --- +out=$("$CLAUDE_TMUX" --help 2>&1); rc=$? +assert_exit "--help exits 0" 0 "$rc" +assert_contains "--help shows script name" "claude-tmux" "$out" + +# --- Auto-increment against a live session --- +if tmux new-session -d -s "Agent Runtimes" -c /tmp 2>/dev/null; then + out=$("$CLAUDE_TMUX" agent-runtimes --dryrun 2>&1) + assert_contains "auto-increments to #2 when base name taken" "Session name: Agent Runtimes #2" "$out" + tmux kill-session -t "=Agent Runtimes" 2>/dev/null || true +else + echo -e "${GREEN}SKIP${NC}: auto-increment (tmux server unavailable)" +fi + +echo +echo "=== $pass passed, $fail failed ===" +[[ "$fail" -eq 0 ]]