Skip to content

Instantly share code, notes, and snippets.

@troykelly
Last active February 15, 2026 06:25
Show Gist options
  • Select an option

  • Save troykelly/1301b1d422827069ae27730ebcea8319 to your computer and use it in GitHub Desktop.

Select an option

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.
#!/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