Created
September 8, 2025 13:23
-
-
Save yasn77/472571ca00fab6e757f3ece62762a2c1 to your computer and use it in GitHub Desktop.
Vibe Code Bash script to SSH to multiple hosts with tmux
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # ============================================================== | |
| # tmux-sync-ssh.sh | |
| # | |
| # 1. Finds the real tmux binary (ignores aliases, functions, etc.). | |
| # 2. Verifies tmux is ≥ 2.4. | |
| # 3. Creates a tmux session with one pane per host, splits panes, | |
| # turns on synchronized input, and attaches to the session. | |
| # | |
| # 4. Accepts SSH options: | |
| # -i <identity_file> (passed to ssh) | |
| # -o <ssh_option> (can be given multiple times) | |
| # | |
| # Usage: | |
| # tmux-sync-ssh.sh [-s session] [-u user] [-i idfile] [-o ssh_opt] | |
| # [-v] [-k] [-h hostfile] host1 host2 … | |
| # -------------------------------------------------------------- | |
| # -s session Name of the tmux session (default: cluster) | |
| # -u user SSH user (default: current user) | |
| # -i idfile SSH identity file (passed as -i <file> to ssh) | |
| # -o ssh_opt Pass arbitrary ssh option (e.g. StrictHostKeyChecking=no). | |
| # Can be supplied more than once. | |
| # -v Split panes vertically (default horizontal) | |
| # -k Keep an existing session; just attach to it | |
| # -h file One host per line in *file* (overrides the host list on the command line) | |
| # -? Show this help | |
| # ============================================================== | |
| set -euo pipefail | |
| # -------------------------------------------------------------- | |
| # 1. Locate the real tmux binary (ignore aliases, functions) | |
| # -------------------------------------------------------------- | |
| find_tmux() { | |
| local p | |
| # Bash-specific: type -p returns only the pathname | |
| p=$(type -p tmux 2>/dev/null) | |
| [[ -n "$p" && -x "$p" ]] && echo "$p" && return | |
| # Generic: command -v (POSIX) – might return alias definition | |
| p=$(command -v tmux 2>/dev/null) | |
| [[ -n "$p" && -x "$p" ]] && echo "$p" && return | |
| # Fallback: which -a, strip alias lines | |
| p=$(which -a tmux 2>/dev/null | grep -v '^alias' | head -n1) | |
| [[ -n "$p" && -x "$p" ]] && echo "$p" && return | |
| # Nothing found | |
| echo "" | |
| } | |
| TMUX_BIN=$(find_tmux) | |
| if [[ -z "$TMUX_BIN" ]]; then | |
| echo "Error: tmux not found. Please install tmux and try again." >&2 | |
| exit 1 | |
| fi | |
| # -------------------------------------------------------------- | |
| # 2. Verify tmux version – require ≥ 2.4 | |
| # -------------------------------------------------------------- | |
| TMUX_VER=$("$TMUX_BIN" -V 2>/dev/null | awk '{print $2}') | |
| if [[ $TMUX_VER =~ ^([0-9]+)\.([0-9]+) ]]; then | |
| MAJOR=${BASH_REMATCH[1]} | |
| MINOR=${BASH_REMATCH[2]} | |
| if ((MAJOR < 2 || (MAJOR == 2 && MINOR < 4))); then | |
| echo "Error: tmux version 2.4 or newer is required. Detected $TMUX_VER." >&2 | |
| exit 1 | |
| fi | |
| else | |
| echo "Warning: could not parse tmux version from '$TMUX_VER'. Continuing anyway." >&2 | |
| fi | |
| # -------------------------------------------------------------- | |
| # 3. Default options | |
| # -------------------------------------------------------------- | |
| SESSION_NAME="cluster" | |
| SSH_USER="${USER}" | |
| SSH_IDENTITY="" | |
| SSH_OPTIONS=() | |
| VERTICAL_SPLIT=false | |
| KEEP_EXISTING=false | |
| HOSTFILE="" | |
| HOSTS=() | |
| # -------------------------------------------------------------- | |
| # 4. Helper functions | |
| # -------------------------------------------------------------- | |
| usage() { | |
| cat <<'EOF' | |
| Usage: tmux-sync-ssh.sh [-s session] [-u user] [-i idfile] [-o ssh_opt] | |
| [-v] [-k] [-h hostfile] host1 host2 … | |
| -s session Name of the tmux session (default: cluster) | |
| -u user SSH user (default: current user) | |
| -i idfile SSH identity file (passed to ssh as -i <file>) | |
| -o ssh_opt Pass arbitrary ssh option (e.g. StrictHostKeyChecking=no). | |
| Can be supplied more than once. | |
| -v Split panes vertically (default horizontal) | |
| -k Keep an existing session; just attach to it | |
| -h file Use this file as the host list (one host per line) | |
| -? Show this help | |
| EOF | |
| exit 1 | |
| } | |
| error() { | |
| printf '\e[31mError:\e[0m %s\n' "$1" >&2 | |
| exit 1 | |
| } | |
| # -------------------------------------------------------------- | |
| # 5. Parse command‑line options | |
| # -------------------------------------------------------------- | |
| while getopts ":s:u:vkhi:o:h?" opt; do | |
| case $opt in | |
| s) SESSION_NAME="$OPTARG" ;; | |
| u) SSH_USER="$OPTARG" ;; | |
| i) SSH_IDENTITY="$OPTARG" ;; | |
| o) SSH_OPTIONS+=("$OPTARG") ;; | |
| v) VERTICAL_SPLIT=true ;; | |
| k) KEEP_EXISTING=true ;; | |
| h) HOSTFILE="$OPTARG" ;; | |
| ?) usage ;; | |
| esac | |
| done | |
| shift $((OPTIND - 1)) | |
| # Build SSH command components | |
| SSH_CMD="ssh" | |
| if [[ -n "$SSH_IDENTITY" ]]; then | |
| SSH_CMD+=" -i \"$SSH_IDENTITY\"" | |
| fi | |
| # Handle empty array safely with set -u | |
| if [[ ${#SSH_OPTIONS[@]} -gt 0 ]]; then | |
| for opt in "${SSH_OPTIONS[@]}"; do | |
| SSH_CMD+=" -o \"$opt\"" | |
| done | |
| fi | |
| # -------------------------------------------------------------- | |
| # 6. Load host list | |
| # -------------------------------------------------------------- | |
| if [[ -n "$HOSTFILE" ]]; then | |
| [[ ! -r "$HOSTFILE" ]] && error "Cannot read hostfile: $HOSTFILE" | |
| mapfile -t HOSTS <"$HOSTFILE" | |
| else | |
| [[ $# -eq 0 ]] && usage | |
| HOSTS=("$@") | |
| fi | |
| # Keep only "real" host names: strip comments/blank lines | |
| HOSTS=($(printf '%s\n' "${HOSTS[@]}" | grep -E '^[[:alnum:].@_-]+$')) | |
| ((${#HOSTS[@]} == 0)) && error "No valid hosts supplied" | |
| # -------------------------------------------------------------- | |
| # 7. tmux logic | |
| # -------------------------------------------------------------- | |
| # If the session already exists, either keep it or kill it | |
| if "$TMUX_BIN" has-session -t "$SESSION_NAME" 2>/dev/null; then | |
| if $KEEP_EXISTING; then | |
| echo "Session \"$SESSION_NAME\" already exists – attaching." | |
| "$TMUX_BIN" attach-session -t "$SESSION_NAME" || exit | |
| exit 0 | |
| else | |
| echo "Killing existing session \"$SESSION_NAME\"." | |
| "$TMUX_BIN" kill-session -t "$SESSION_NAME" | |
| fi | |
| fi | |
| # Create the session with a shell first (not SSH directly) | |
| FIRST_HOST="${HOSTS[0]}" | |
| echo "Creating session '$SESSION_NAME' with window name '$FIRST_HOST'..." | |
| "$TMUX_BIN" new-session -d -s "$SESSION_NAME" -n "$FIRST_HOST" | |
| # Verify the session and window were created with better debugging | |
| sleep 0.5 | |
| if ! "$TMUX_BIN" has-session -t "$SESSION_NAME" 2>/dev/null; then | |
| error "Failed to create tmux session '$SESSION_NAME'" | |
| fi | |
| # Debug: Show what windows exist | |
| echo "Checking for windows in session '$SESSION_NAME'..." | |
| WINDOWS=$("$TMUX_BIN" list-windows -t "$SESSION_NAME" 2>/dev/null || echo "") | |
| echo "Windows found: $WINDOWS" | |
| # More flexible window check - look for any window, not just window 0 | |
| if [[ -z "$WINDOWS" ]]; then | |
| error "No windows found in session '$SESSION_NAME'" | |
| fi | |
| # Get the actual window identifier (might not be 0) | |
| WINDOW_ID=$("$TMUX_BIN" list-windows -t "$SESSION_NAME" -F "#{window_index}" | head -n1) | |
| echo "Using window ID: $WINDOW_ID" | |
| # Send SSH command to the first pane | |
| echo "Connecting to $SSH_USER@$FIRST_HOST..." | |
| "$TMUX_BIN" send-keys -t "$SESSION_NAME:$WINDOW_ID" "$SSH_CMD $SSH_USER@$FIRST_HOST" Enter | |
| # Create remaining panes and send SSH commands | |
| for i in "${!HOSTS[@]}"; do | |
| ((i == 0)) && continue | |
| HOST="${HOSTS[$i]}" | |
| echo "Creating pane for $SSH_USER@$HOST..." | |
| # Split the window to create a new pane | |
| # Note: tmux terminology is confusing: | |
| # -h = horizontal split (creates panes side-by-side) | |
| # -v = vertical split (creates panes top-bottom) | |
| if $VERTICAL_SPLIT; then | |
| # User wants vertical splits (top-bottom arrangement) | |
| "$TMUX_BIN" split-window -t "$SESSION_NAME:$WINDOW_ID" -v | |
| else | |
| # Default: horizontal splits (side-by-side arrangement) | |
| "$TMUX_BIN" split-window -t "$SESSION_NAME:$WINDOW_ID" -h | |
| fi | |
| # Send SSH command to the newly created pane | |
| "$TMUX_BIN" send-keys -t "$SESSION_NAME:$WINDOW_ID" "$SSH_CMD $SSH_USER@$HOST" Enter | |
| done | |
| # Enable synchronized input | |
| "$TMUX_BIN" setw -t "$SESSION_NAME:$WINDOW_ID" synchronize-panes on | |
| # Choose a tidy layout (works on tmux ≥ 2.4) | |
| if $VERTICAL_SPLIT; then | |
| "$TMUX_BIN" select-layout -t "$SESSION_NAME:$WINDOW_ID" even-vertical | |
| else | |
| "$TMUX_BIN" select-layout -t "$SESSION_NAME:$WINDOW_ID" even-horizontal | |
| fi | |
| echo "Session \"$SESSION_NAME\" created – attaching now." | |
| "$TMUX_BIN" attach-session -t "$SESSION_NAME" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment