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
This commit is contained in:
@@ -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 |
|
||||
|
||||
156
scripts/claude-tmux
Executable file
156
scripts/claude-tmux
Executable file
@@ -0,0 +1,156 @@
|
||||
#!/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
|
||||
105
specs/claude-tmux.spec.md
Normal file
105
specs/claude-tmux.spec.md
Normal file
@@ -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 <name>` | profile's `last-mode`, else `quick` | Engagement mode passed to `claude-profile` |
|
||||
| `--profile <name>` | `oreillyit-anthropic` | Claude Code profile (resolves to `~/.claude-<name>`, or `~/.claude` for `default`) |
|
||||
| `--name <display>` | 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-<profile>`, 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/<PROJECT>`, `~/dev/claude/projects/<PROJECT>`.
|
||||
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 <profile> --mode <mode> --project <basename> -- --remote-control "<name>"`
|
||||
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
|
||||
```
|
||||
113
tests/test-claude-tmux.sh
Executable file
113
tests/test-claude-tmux.sh
Executable file
@@ -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 </dev/null 2>&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 ]]
|
||||
Reference in New Issue
Block a user