Last active
February 15, 2026 06:25
-
-
Save troykelly/1301b1d422827069ae27730ebcea8319 to your computer and use it in GitHub Desktop.
devcontainer-tmux: Start or attach tmux sessions inside running devcontainers. Auto-detects user, container, shell. Supports multiple repo roots.
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 | |
| # devcontainer-tmux — Start or attach a tmux session inside a running devcontainer | |
| # Usage: devcontainer-tmux <org>/<repo> [session-suffix] [--repo-root PATH] | |
| # | |
| # Examples: | |
| # devcontainer-tmux troykelly/openclaw-projects | |
| # devcontainer-tmux troykelly/openclaw-projects 002 | |
| # devcontainer-tmux troykelly/openclaw-projects fix-tests --repo-root ~/github | |
| # | |
| # Behaviour: | |
| # 1. Finds the repo directory (searches multiple roots) | |
| # 2. Reads .devcontainer config for the expected user | |
| # 3. Finds the running devcontainer (by image name or label) | |
| # 4. Starts the devcontainer if not running (optional) | |
| # 5. Creates or attaches to a tmux session that execs into the container | |
| set -euo pipefail | |
| # --- Configuration ----------------------------------------------------------- | |
| # Repo search paths (in priority order) | |
| DEFAULT_REPO_ROOTS=( | |
| "$HOME/claw/repos" # new convention: ~/claw/repos/<ORG>/<REPO> | |
| "$HOME/github" # legacy: ~/github/<ORG>/<REPO> or ~/github/<REPO> | |
| "$HOME/repos" # fallback | |
| ) | |
| TMUX_PREFIX="dev" | |
| # --- Argument parsing -------------------------------------------------------- | |
| usage() { | |
| cat >&2 << EOF | |
| Usage: $(basename "$0") <org/repo> [session-suffix] [OPTIONS] | |
| Arguments: | |
| org/repo Repository in org/repo format (e.g. troykelly/openclaw-projects) | |
| session-suffix Optional suffix for the tmux session name (e.g. 002, fix-tests) | |
| Options: | |
| --repo-root PATH Override repo search path (can be repeated) | |
| --start Start devcontainer if not running (requires devcontainer CLI) | |
| --stop Stop the devcontainer (kills tmux sessions first) | |
| --kill Kill the tmux session(s) only (leave container running) | |
| --shell SHELL Override shell (default: auto-detect from container) | |
| --user USER Override container user (default: auto-detect from .devcontainer) | |
| --list List running devcontainers and exit | |
| --dry-run Show what would be done without executing | |
| -h, --help Show this help | |
| Examples: | |
| $(basename "$0") troykelly/openclaw-projects | |
| $(basename "$0") aperim/careswap 002 | |
| $(basename "$0") troykelly/meme-themusical fix-file --start | |
| $(basename "$0") --list | |
| EOF | |
| exit 1 | |
| } | |
| ORG_REPO="" | |
| SESSION_SUFFIX="" | |
| REPO_ROOTS=() | |
| START_IF_STOPPED=false | |
| STOP_CONTAINER=false | |
| KILL_SESSION=false | |
| OVERRIDE_SHELL="" | |
| OVERRIDE_USER="" | |
| LIST_MODE=false | |
| DRY_RUN=false | |
| while [[ $# -gt 0 ]]; do | |
| case "$1" in | |
| --repo-root) REPO_ROOTS+=("$2"); shift 2 ;; | |
| --start) START_IF_STOPPED=true; shift ;; | |
| --stop) STOP_CONTAINER=true; shift ;; | |
| --kill) KILL_SESSION=true; shift ;; | |
| --shell) OVERRIDE_SHELL="$2"; shift 2 ;; | |
| --user) OVERRIDE_USER="$2"; shift 2 ;; | |
| --list) LIST_MODE=true; shift ;; | |
| --dry-run) DRY_RUN=true; shift ;; | |
| -h|--help) usage ;; | |
| -*) echo "Unknown option: $1" >&2; usage ;; | |
| *) | |
| if [[ -z "$ORG_REPO" ]]; then | |
| ORG_REPO="$1" | |
| elif [[ -z "$SESSION_SUFFIX" ]]; then | |
| SESSION_SUFFIX="$1" | |
| else | |
| echo "Unexpected argument: $1" >&2; usage | |
| fi | |
| shift | |
| ;; | |
| esac | |
| done | |
| [[ ${#REPO_ROOTS[@]} -eq 0 ]] && REPO_ROOTS=("${DEFAULT_REPO_ROOTS[@]}") | |
| # --- Helpers ----------------------------------------------------------------- | |
| info() { echo -e "\033[1;34m→\033[0m $*"; } | |
| ok() { echo -e "\033[1;32m✓\033[0m $*"; } | |
| warn() { echo -e "\033[1;33m⚠\033[0m $*" >&2; } | |
| err() { echo -e "\033[1;31m✗\033[0m $*" >&2; exit 1; } | |
| # --- List mode --------------------------------------------------------------- | |
| if $LIST_MODE; then | |
| info "Running devcontainers:" | |
| docker ps --format "table {{.Names}}\t{{.Image}}\t{{.Status}}" | grep -i -E "vsc-|devcontainer" || echo " (none found)" | |
| echo | |
| info "Active tmux sessions:" | |
| tmux ls 2>/dev/null || echo " (none)" | |
| exit 0 | |
| fi | |
| [[ -z "$ORG_REPO" ]] && usage | |
| # --- Parse org/repo ---------------------------------------------------------- | |
| if [[ "$ORG_REPO" == */* ]]; then | |
| ORG="${ORG_REPO%%/*}" | |
| REPO="${ORG_REPO##*/}" | |
| else | |
| ORG="" | |
| REPO="$ORG_REPO" | |
| fi | |
| # --- Find repo directory ----------------------------------------------------- | |
| REPO_DIR="" | |
| for root in "${REPO_ROOTS[@]}"; do | |
| expanded_root="${root/#\~/$HOME}" | |
| # Try org/repo structure | |
| if [[ -n "$ORG" && -d "$expanded_root/$ORG/$REPO" ]]; then | |
| REPO_DIR="$expanded_root/$ORG/$REPO" | |
| break | |
| fi | |
| # Try flat structure (legacy ~/github/<REPO>) | |
| if [[ -d "$expanded_root/$REPO" ]]; then | |
| REPO_DIR="$expanded_root/$REPO" | |
| break | |
| fi | |
| # Try flat with org prefix (legacy ~/github/<ORG>-<REPO> or similar) | |
| if [[ -n "$ORG" && -d "$expanded_root/${ORG}-${REPO}" ]]; then | |
| REPO_DIR="$expanded_root/${ORG}-${REPO}" | |
| break | |
| fi | |
| done | |
| [[ -z "$REPO_DIR" ]] && err "Repository not found: $ORG_REPO (searched: ${REPO_ROOTS[*]})" | |
| ok "Found repo: $REPO_DIR" | |
| # --- Detect container user from .devcontainer -------------------------------- | |
| detect_user() { | |
| local devcontainer_json="" | |
| # Check .devcontainer/devcontainer.json | |
| if [[ -f "$REPO_DIR/.devcontainer/devcontainer.json" ]]; then | |
| devcontainer_json="$REPO_DIR/.devcontainer/devcontainer.json" | |
| elif [[ -f "$REPO_DIR/.devcontainer.json" ]]; then | |
| devcontainer_json="$REPO_DIR/.devcontainer.json" | |
| fi | |
| if [[ -n "$devcontainer_json" ]]; then | |
| # Extract remoteUser, then containerUser, then fall back | |
| local user | |
| if command -v jq &>/dev/null; then | |
| user=$(jq -r '.remoteUser // .containerUser // empty' "$devcontainer_json" 2>/dev/null || true) | |
| else | |
| # Fallback: grep for remoteUser or containerUser without jq | |
| user=$(grep -oP '"remoteUser"\s*:\s*"\K[^"]+' "$devcontainer_json" 2>/dev/null || grep -oP '"containerUser"\s*:\s*"\K[^"]+' "$devcontainer_json" 2>/dev/null || true) | |
| fi | |
| if [[ -n "$user" && "$user" != "null" ]]; then | |
| echo "$user" | |
| return | |
| fi | |
| # Check if there's a Dockerfile that might hint at the user | |
| local dockerfile="" | |
| if command -v jq &>/dev/null; then | |
| dockerfile=$(jq -r '.build.dockerfile // .dockerFile // empty' "$devcontainer_json" 2>/dev/null || true) | |
| else | |
| dockerfile=$(grep -oP '"dockerfile"\s*:\s*"\K[^"]+' "$devcontainer_json" 2>/dev/null || grep -oP '"dockerFile"\s*:\s*"\K[^"]+' "$devcontainer_json" 2>/dev/null || true) | |
| fi | |
| if [[ -n "$dockerfile" && -f "$REPO_DIR/.devcontainer/$dockerfile" ]]; then | |
| local docker_user | |
| docker_user=$(grep -i '^USER ' "$REPO_DIR/.devcontainer/$dockerfile" | tail -1 | awk '{print $2}' || true) | |
| if [[ -n "$docker_user" ]]; then | |
| echo "$docker_user" | |
| return | |
| fi | |
| fi | |
| fi | |
| # Default fallback | |
| echo "vscode" | |
| } | |
| if [[ -n "$OVERRIDE_USER" ]]; then | |
| CONTAINER_USER="$OVERRIDE_USER" | |
| else | |
| CONTAINER_USER=$(detect_user) | |
| fi | |
| info "Container user: $CONTAINER_USER" | |
| # --- Find running devcontainer ----------------------------------------------- | |
| # --- Kill session mode -------------------------------------------------------- | |
| if $KILL_SESSION; then | |
| # Build the base session name to find matching sessions | |
| SAFE_ORG=$(echo "$ORG" | tr "[:upper:]" "[:lower:]" | sed "s/[^[:alnum:]-]/-/g; s/--*/-/g; s/^-//; s/-$//") | |
| SAFE_REPO=$(echo "$REPO" | tr "[:upper:]" "[:lower:]" | sed "s/[^[:alnum:]-]/-/g; s/--*/-/g; s/^-//; s/-$//") | |
| if [[ -n "$SAFE_ORG" ]]; then | |
| SESSION_PATTERN="${TMUX_PREFIX}-${SAFE_ORG}-${SAFE_REPO}" | |
| else | |
| SESSION_PATTERN="${TMUX_PREFIX}-${SAFE_REPO}" | |
| fi | |
| [[ -n "$SESSION_SUFFIX" ]] && SESSION_PATTERN="${SESSION_PATTERN}-${SESSION_SUFFIX}" | |
| # Find and kill matching sessions | |
| found=false | |
| while IFS= read -r sess; do | |
| [[ -z "$sess" ]] && continue | |
| sess_name="${sess%%:*}" | |
| if [[ "$sess_name" == "${SESSION_PATTERN}"* ]]; then | |
| if $DRY_RUN; then | |
| info "[dry-run] Would kill tmux session: $sess_name" | |
| else | |
| tmux kill-session -t "$sess_name" 2>/dev/null && ok "Killed session: $sess_name" || warn "Failed to kill: $sess_name" | |
| fi | |
| found=true | |
| fi | |
| done < <(tmux ls 2>/dev/null || true) | |
| if ! $found; then | |
| warn "No tmux sessions found matching: ${SESSION_PATTERN}*" | |
| fi | |
| exit 0 | |
| fi | |
| # --- Stop container mode ----------------------------------------------------- | |
| if $STOP_CONTAINER; then | |
| # First kill any tmux sessions | |
| SAFE_ORG=$(echo "$ORG" | tr "[:upper:]" "[:lower:]" | sed "s/[^[:alnum:]-]/-/g; s/--*/-/g; s/^-//; s/-$//") | |
| SAFE_REPO=$(echo "$REPO" | tr "[:upper:]" "[:lower:]" | sed "s/[^[:alnum:]-]/-/g; s/--*/-/g; s/^-//; s/-$//") | |
| if [[ -n "$SAFE_ORG" ]]; then | |
| SESSION_PATTERN="${TMUX_PREFIX}-${SAFE_ORG}-${SAFE_REPO}" | |
| else | |
| SESSION_PATTERN="${TMUX_PREFIX}-${SAFE_REPO}" | |
| fi | |
| while IFS= read -r sess; do | |
| [[ -z "$sess" ]] && continue | |
| sess_name="${sess%%:*}" | |
| if [[ "$sess_name" == "${SESSION_PATTERN}"* ]]; then | |
| if $DRY_RUN; then | |
| info "[dry-run] Would kill tmux session: $sess_name" | |
| else | |
| tmux kill-session -t "$sess_name" 2>/dev/null && ok "Killed session: $sess_name" | |
| fi | |
| fi | |
| done < <(tmux ls 2>/dev/null || true) | |
| # Then stop the devcontainer | |
| if ! command -v devcontainer &>/dev/null; then | |
| err "devcontainer CLI not found. Install: npm install -g @devcontainers/cli" | |
| fi | |
| if $DRY_RUN; then | |
| info "[dry-run] Would run: devcontainer down --workspace-folder $REPO_DIR" | |
| else | |
| info "Stopping devcontainer for $REPO..." | |
| # devcontainer CLI doesn't have 'down'; use docker compose | |
| if [[ -f "$REPO_DIR/.devcontainer/docker-compose.yml" || -f "$REPO_DIR/.devcontainer/docker-compose.devcontainer.yml" ]]; then | |
| compose_file="" | |
| for f in docker-compose.devcontainer.yml docker-compose.yml; do | |
| if [[ -f "$REPO_DIR/.devcontainer/$f" ]]; then | |
| compose_file="$REPO_DIR/.devcontainer/$f" | |
| break | |
| fi | |
| done | |
| if [[ -n "$compose_file" ]]; then | |
| docker compose -f "$compose_file" down && ok "Container stopped" || warn "docker compose down failed" | |
| fi | |
| else | |
| # Single container mode — find and stop it | |
| container='' | |
| container=$(docker ps --format '{{.Names}} {{.Image}}' | grep -i "${REPO}" | awk '{print \$1}' | head -1) | |
| if [[ -n "$container" ]]; then | |
| docker stop "$container" && ok "Stopped: $container" || warn "Failed to stop: $container" | |
| else | |
| warn "No running container found for $REPO" | |
| fi | |
| fi | |
| fi | |
| exit 0 | |
| fi | |
| find_container() { | |
| local candidates=() | |
| # Search all running containers - check both name and image for repo match | |
| while IFS=$' ' read -r cname cimage; do | |
| [[ -z "$cname" ]] && continue | |
| # Check if name or image contains the repo name (case-insensitive) | |
| if echo "$cname $cimage" | grep -qi "${REPO}"; then | |
| candidates+=("$cname") | |
| fi | |
| done < <(docker ps --format '{{.Names}} {{.Image}}' 2>/dev/null) | |
| # Also check compose project labels | |
| if [[ ${#candidates[@]} -eq 0 ]]; then | |
| while IFS= read -r name; do | |
| [[ -n "$name" ]] && candidates+=("$name") | |
| done < <(docker ps --filter "label=com.docker.compose.project=${REPO}" --format '{{.Names}}' 2>/dev/null || true) | |
| while IFS= read -r name; do | |
| [[ -n "$name" ]] && candidates+=("$name") | |
| done < <(docker ps --filter "label=com.docker.compose.project=${REPO//-/_}" --format '{{.Names}}' 2>/dev/null || true) | |
| fi | |
| # Among candidates, prefer workspace/dev containers over sidecars (postgres, redis, seaweedfs) | |
| local sidecars="postgres|redis|seaweedfs|mongo|mysql|minio|elasticsearch|rabbitmq|memcached" | |
| for c in "${candidates[@]}"; do | |
| local image | |
| image=$(docker inspect "$c" --format '{{.Config.Image}}' 2>/dev/null || true) | |
| # Skip obvious sidecars | |
| if echo "$c $image" | grep -qiE "$sidecars"; then | |
| continue | |
| fi | |
| # Prefer containers with devcontainer-like names/images | |
| if echo "$c $image" | grep -qiE "vsc-|workspace|devcontainer|app"; then | |
| echo "$c" | |
| return | |
| fi | |
| done | |
| # If no preferred container found, return first non-sidecar candidate | |
| for c in "${candidates[@]}"; do | |
| if ! echo "$c" | grep -qiE "$sidecars"; then | |
| echo "$c" | |
| return | |
| fi | |
| done | |
| # Last resort: return first candidate | |
| if [[ ${#candidates[@]} -gt 0 ]]; then | |
| echo "${candidates[0]}" | |
| fi | |
| } | |
| CONTAINER_NAME=$(find_container) | |
| if [[ -z "$CONTAINER_NAME" ]]; then | |
| if $START_IF_STOPPED; then | |
| info "No running container found. Starting devcontainer..." | |
| if ! command -v devcontainer &>/dev/null; then | |
| err "devcontainer CLI not found. Install: npm install -g @devcontainers/cli" | |
| fi | |
| if $DRY_RUN; then | |
| info "[dry-run] Would run: devcontainer up --workspace-folder $REPO_DIR" | |
| exit 0 | |
| fi | |
| devcontainer up --workspace-folder "$REPO_DIR" || err "Failed to start devcontainer" | |
| CONTAINER_NAME=$(find_container) | |
| [[ -z "$CONTAINER_NAME" ]] && err "Container started but could not find it" | |
| else | |
| err "No running devcontainer found for $REPO. Use --start to start one, or start it from VS Code." | |
| fi | |
| fi | |
| ok "Found container: $CONTAINER_NAME" | |
| # --- Validate user exists in container --------------------------------------- | |
| if ! docker exec "$CONTAINER_NAME" id "$CONTAINER_USER" &>/dev/null; then | |
| warn "User '$CONTAINER_USER' not found in container. Checking alternatives..." | |
| for try_user in vscode node root; do | |
| if docker exec "$CONTAINER_NAME" id "$try_user" &>/dev/null; then | |
| CONTAINER_USER="$try_user" | |
| ok "Using user: $CONTAINER_USER" | |
| break | |
| fi | |
| done | |
| docker exec "$CONTAINER_NAME" id "$CONTAINER_USER" &>/dev/null || err "No valid user found in container" | |
| fi | |
| # --- Detect shell in container ----------------------------------------------- | |
| if [[ -n "$OVERRIDE_SHELL" ]]; then | |
| SHELL_CMD="$OVERRIDE_SHELL" | |
| else | |
| SHELL_CMD=$(docker exec -u "$CONTAINER_USER" "$CONTAINER_NAME" sh -c 'echo $SHELL' 2>/dev/null || echo "/bin/bash") | |
| if ! docker exec "$CONTAINER_NAME" test -x "$SHELL_CMD" 2>/dev/null; then | |
| SHELL_CMD="/bin/bash" | |
| docker exec "$CONTAINER_NAME" test -x "$SHELL_CMD" 2>/dev/null || SHELL_CMD="/bin/sh" | |
| fi | |
| fi | |
| # --- Build tmux session name ------------------------------------------------- | |
| SAFE_ORG=$(echo "$ORG" | tr "[:upper:]" "[:lower:]" | sed "s/[^[:alnum:]-]/-/g; s/--*/-/g; s/^-//; s/-$//") | |
| SAFE_REPO=$(echo "$REPO" | tr "[:upper:]" "[:lower:]" | sed "s/[^[:alnum:]-]/-/g; s/--*/-/g; s/^-//; s/-$//") | |
| if [[ -n "$SAFE_ORG" ]]; then | |
| SESSION_NAME="${TMUX_PREFIX}-${SAFE_ORG}-${SAFE_REPO}" | |
| else | |
| SESSION_NAME="${TMUX_PREFIX}-${SAFE_REPO}" | |
| fi | |
| [[ -n "$SESSION_SUFFIX" ]] && SESSION_NAME="${SESSION_NAME}-${SESSION_SUFFIX}" | |
| # --- Check for existing tmux session ----------------------------------------- | |
| if tmux has-session -t "$SESSION_NAME" 2>/dev/null; then | |
| ok "Tmux session '$SESSION_NAME' already exists" | |
| if [[ -t 0 ]]; then | |
| info "Attaching..." | |
| $DRY_RUN && { info "[dry-run] Would attach to: $SESSION_NAME"; exit 0; } | |
| exec tmux attach-session -t "$SESSION_NAME" | |
| else | |
| info "Not a terminal — session is running in background" | |
| tmux capture-pane -t "$SESSION_NAME" -p | tail -10 | |
| exit 0 | |
| fi | |
| fi | |
| # --- Create tmux session ----------------------------------------------------- | |
| EXEC_CMD="docker exec -it -u $CONTAINER_USER $CONTAINER_NAME $SHELL_CMD" | |
| if $DRY_RUN; then | |
| info "[dry-run] Would create tmux session: $SESSION_NAME" | |
| info "[dry-run] Command: $EXEC_CMD" | |
| exit 0 | |
| fi | |
| info "Creating tmux session: $SESSION_NAME" | |
| info "Container: $CONTAINER_NAME (user: $CONTAINER_USER, shell: $SHELL_CMD)" | |
| tmux new-session -d -s "$SESSION_NAME" "$EXEC_CMD" | |
| # Wait for shell to be ready | |
| sleep 2 | |
| # Verify the session is alive | |
| if ! tmux has-session -t "$SESSION_NAME" 2>/dev/null; then | |
| err "Session died immediately. The container may have exited. Check: docker exec -u $CONTAINER_USER $CONTAINER_NAME $SHELL_CMD" | |
| fi | |
| ok "Session '$SESSION_NAME' created and running" | |
| # Show initial output | |
| tmux capture-pane -t "$SESSION_NAME" -p | tail -5 | |
| # Attach if interactive | |
| if [[ -t 0 ]]; then | |
| info "Attaching (detach with Ctrl-b d)..." | |
| exec tmux attach-session -t "$SESSION_NAME" | |
| fi |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment