#!/usr/bin/env bash
# =============================================================================
# setup-claude-vm.sh
# Prepares an Ubuntu 24.04 VM for Claude agent/coding sessions.
# Idempotent: safe to re-run; updates existing installations.
# Run as a regular user with sudo access (NOT as root).
# =============================================================================
set -euo pipefail
GREEN='\033[0;32m'; YELLOW='\033[1;33m'; RED='\033[0;31m'; CYAN='\033[0;36m'; BLUE='\033[0;34m'; NC='\033[0m'
log() { echo -e "${GREEN}[+]${NC} $1"; }
info() { echo -e "${BLUE}[~]${NC} $1"; }
section() { echo -e "\n${CYAN}══════════════════════════════════════════${NC}"; \
echo -e "${CYAN} $1${NC}"; \
echo -e "${CYAN}══════════════════════════════════════════${NC}"; }
warn() { echo -e "${YELLOW}[!]${NC} $1"; }
die() { echo -e "${RED}[✗]${NC} $1"; exit 1; }
[[ $EUID -eq 0 ]] && die "Do not run as root. Use a regular user with sudo access."
# Track what actually changed for the summary
CHANGED=()
mark_changed() { CHANGED+=("$1"); }
# =============================================================================
# 1. SYSTEM PACKAGES (including tmux and screen)
# =============================================================================
section "1. System packages"
log "Updating apt package lists..."
sudo apt-get update -qq
log "Installing / upgrading system packages..."
sudo apt-get install -y \
build-essential \
curl wget git \
python3 python3-pip \
unzip zip jq \
ca-certificates \
gnupg lsb-release \
software-properties-common \
mercurial bison \
gcc make \
lsof htop \
tmux screen \
vim nano
# Upgrade only already-installed packages (safe, no new installs)
sudo apt-get upgrade -y
mark_changed "system packages (apt upgraded)"
log "System packages up to date."
# =============================================================================
# 2. DOCKER (official repository)
# =============================================================================
section "2. Docker (official repository)"
if ! command -v docker &>/dev/null; then
log "Docker not found — installing from official repository..."
sudo apt-get remove -y docker.io docker-doc docker-compose docker-compose-v2 \
podman-docker containerd runc 2>/dev/null || true
sudo install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg \
| sudo gpg --dearmor -o /etc/apt/keyrings/docker.gpg
sudo chmod a+r /etc/apt/keyrings/docker.gpg
echo \
"deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu \
$(. /etc/os-release && echo "$VERSION_CODENAME") stable" \
| sudo tee /etc/apt/sources.list.d/docker.list > /dev/null
sudo apt-get update -qq
sudo apt-get install -y \
docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin
sudo usermod -aG docker "$USER"
sudo systemctl enable --now docker
mark_changed "Docker (fresh install)"
log "Docker $(docker --version) installed."
else
info "Docker already installed — upgrading..."
sudo apt-get install -y --only-upgrade \
docker-ce docker-ce-cli containerd.io \
docker-buildx-plugin docker-compose-plugin 2>/dev/null || true
mark_changed "Docker (upgraded)"
log "Docker $(docker --version) up to date."
fi
# =============================================================================
# 3. NVM — update or install
# Updating NVM: re-running the install script is the official method.
# It detects ~/.nvm and does a git pull instead of a fresh clone.
# =============================================================================
section "3. NVM"
NVM_LATEST=$(curl -fsSL https://api.github.com/repos/nvm-sh/nvm/releases/latest \
| jq -r '.tag_name')
if [ -d "$HOME/.nvm" ]; then
NVM_CURRENT=$(cat "$HOME/.nvm/package.json" 2>/dev/null | jq -r '.version // "unknown"' || echo "unknown")
info "NVM already installed (v${NVM_CURRENT}) — updating to ${NVM_LATEST}..."
else
info "NVM not found — installing ${NVM_LATEST}..."
fi
curl -o- "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_LATEST}/install.sh" | bash
mark_changed "NVM ${NVM_LATEST}"
export NVM_DIR="$HOME/.nvm"
# shellcheck source=/dev/null
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
log "NVM $(nvm --version) ready."
# =============================================================================
# 4. NODE.JS LTS — install latest LTS or upgrade if newer one is available
# nvm install --lts --reinstall-packages-from=default migrates global packages.
# =============================================================================
section "4. Node.js LTS"
CURRENT_NODE=$(node --version 2>/dev/null || echo "none")
LATEST_LTS=$(nvm version-remote --lts 2>/dev/null || echo "unknown")
if [ "$CURRENT_NODE" = "$LATEST_LTS" ]; then
info "Node.js ${CURRENT_NODE} is already the latest LTS — skipping."
else
if [ "$CURRENT_NODE" = "none" ]; then
log "Installing Node.js LTS ${LATEST_LTS}..."
nvm install --lts
else
log "Upgrading Node.js from ${CURRENT_NODE} to ${LATEST_LTS} (migrating global packages)..."
nvm install --lts --reinstall-packages-from=default
fi
mark_changed "Node.js ${LATEST_LTS}"
fi
nvm use --lts
nvm alias default 'lts/*'
log "Node $(node --version), npm $(npm --version)"
# =============================================================================
# 5. GVM — update or install
# GVM lives in ~/.gvm which is a plain git checkout.
# Updating: git pull on the repo. New Go versions are installed separately.
# =============================================================================
section "5. GVM"
if [ -d "$HOME/.gvm" ]; then
info "GVM already installed — pulling latest changes..."
(cd "$HOME/.gvm" && git pull --quiet origin master 2>/dev/null) \
&& log "GVM updated." \
|| warn "GVM git pull failed — continuing with existing version."
mark_changed "GVM (git pull)"
else
info "GVM not found — installing..."
bash < <(curl -fsSL https://raw.githubusercontent.com/moovweb/gvm/master/binscripts/gvm-installer)
mark_changed "GVM (fresh install)"
fi
# shellcheck source=/dev/null
source "$HOME/.gvm/scripts/gvm"
# =============================================================================
# 6. GO — install latest stable if not already present
# =============================================================================
section "6. Go (latest stable)"
GO_LATEST=$(curl -fsSL 'https://go.dev/dl/?mode=json' | jq -r '.[0].version')
# gvm list prints installed versions; grep for the exact version string
if gvm list 2>/dev/null | grep -qw "${GO_LATEST}"; then
info "Go ${GO_LATEST} already installed — switching to it as default."
else
log "Installing ${GO_LATEST} (binary)..."
gvm install "${GO_LATEST}" --binary
mark_changed "Go ${GO_LATEST}"
fi
gvm use "${GO_LATEST}" --default
log "$(go version)"
# =============================================================================
# 7. UV — update or install
# uv has a built-in self-update command.
# =============================================================================
section "7. uv + Python"
export PATH="$HOME/.local/bin:$PATH"
if command -v uv &>/dev/null; then
UV_BEFORE=$(uv --version)
info "uv already installed (${UV_BEFORE}) — updating..."
uv self update
UV_AFTER=$(uv --version)
if [ "$UV_BEFORE" != "$UV_AFTER" ]; then
mark_changed "uv ${UV_AFTER}"
log "uv updated: ${UV_BEFORE} → ${UV_AFTER}"
else
info "uv is already up to date."
fi
else
log "Installing uv..."
curl -LsSf https://astral.sh/uv/install.sh | sh
mark_changed "uv (fresh install)"
fi
log "Installing / updating latest stable CPython via uv..."
uv python install
log "$(uv --version)"
uv python list
# =============================================================================
# 8. CLAUDE CODE — native Bun installer (idempotent, handles updates)
# Re-running the official install script updates if a newer version is available.
# =============================================================================
section "8. Claude Code"
CLAUDE_BEFORE=$(claude --version 2>/dev/null || echo "not installed")
log "Running native Claude Code installer (installs or updates)..."
curl -fsSL https://claude.ai/install.sh | bash
export PATH="$HOME/.local/bin:$PATH"
CLAUDE_AFTER=$(claude --version 2>/dev/null || echo "unknown")
if [ "$CLAUDE_BEFORE" != "$CLAUDE_AFTER" ]; then
mark_changed "Claude Code ${CLAUDE_AFTER}"
log "Claude Code: ${CLAUDE_BEFORE} → ${CLAUDE_AFTER}"
else
info "Claude Code ${CLAUDE_AFTER} already up to date."
fi
# =============================================================================
# 9. AUTOMATIC UPDATES (unattended-upgrades)
# Config is written every run — it's idempotent, same content each time.
# =============================================================================
section "9. Automatic security updates"
sudo apt-get install -y unattended-upgrades apt-listchanges update-notifier-common
sudo tee /etc/apt/apt.conf.d/50unattended-upgrades > /dev/null << 'EOF'
Unattended-Upgrade::Allowed-Origins {
"${distro_id}:${distro_codename}";
"${distro_id}:${distro_codename}-security";
"${distro_id}ESMApps:${distro_codename}-apps-security";
"${distro_id}ESM:${distro_codename}-infra-security";
"${distro_id}:${distro_codename}-updates";
};
Unattended-Upgrade::Package-Blacklist {};
Unattended-Upgrade::DevRelease "false";
Unattended-Upgrade::AutoFixInterruptedDpkg "true";
Unattended-Upgrade::MinimalSteps "true";
Unattended-Upgrade::Remove-Unused-Kernel-Packages "true";
Unattended-Upgrade::Remove-New-Unused-Dependencies "true";
Unattended-Upgrade::Remove-Unused-Dependencies "true";
Unattended-Upgrade::Automatic-Reboot "false";
Unattended-Upgrade::Automatic-Reboot-WithUsers "false";
EOF
sudo tee /etc/apt/apt.conf.d/20auto-upgrades > /dev/null << 'EOF'
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Download-Upgradeable-Packages "1";
APT::Periodic::AutocleanInterval "7";
APT::Periodic::Unattended-Upgrade "1";
EOF
sudo systemctl enable --now unattended-upgrades
log "Automatic updates config applied."
# =============================================================================
# 10. REBOOT-REQUIRED NOTIFICATION ON LOGIN
# =============================================================================
section "10. Reboot-required login notification"
sudo tee /etc/update-motd.d/98-reboot-required > /dev/null << 'EOF'
#!/bin/sh
if [ -f /run/reboot-required ]; then
echo ""
echo "╔══════════════════════════════════════════════════════╗"
echo "║ *** REBOOT REQUIRED *** ║"
echo "║ A system restart is needed to apply updates. ║"
if [ -f /run/reboot-required.pkgs ]; then
echo "║ Packages requiring reboot: ║"
while IFS= read -r pkg; do
printf "║ - %-48s║\n" "$pkg"
done < /run/reboot-required.pkgs
fi
echo "║ Run: sudo reboot ║"
echo "╚══════════════════════════════════════════════════════╝"
echo ""
fi
EOF
sudo chmod +x /etc/update-motd.d/98-reboot-required
BASHRC_REBOOT_MARKER="# reboot-required check"
if ! grep -q "$BASHRC_REBOOT_MARKER" "$HOME/.bashrc" 2>/dev/null; then
cat >> "$HOME/.bashrc" << 'BASHEOF'
# reboot-required check
if [ -f /run/reboot-required ]; then
echo -e "\033[1;31m[!] REBOOT REQUIRED — run: sudo reboot\033[0m"
fi
BASHEOF
fi
log "Reboot-required notification configured."
# =============================================================================
# 11. SHELL INITIALISATION
# Markers prevent duplicate entries on re-runs.
# =============================================================================
section "11. Shell initialisation"
sudo tee /etc/profile.d/claude-tools.sh > /dev/null << 'EOF'
# Claude VM — tool initialisation (sourced by all interactive login shells)
# NVM
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
# GVM
[ -s "$HOME/.gvm/scripts/gvm" ] && source "$HOME/.gvm/scripts/gvm"
# uv + local bins (includes Claude Code native binary)
export PATH="$HOME/.local/bin:$PATH"
EOF
BASHRC_MARKER="# claude-tools init"
if ! grep -q "$BASHRC_MARKER" "$HOME/.bashrc" 2>/dev/null; then
cat >> "$HOME/.bashrc" << 'BASHEOF'
# claude-tools init
export NVM_DIR="$HOME/.nvm"
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh"
[ -s "$HOME/.gvm/scripts/gvm" ] && source "$HOME/.gvm/scripts/gvm"
export PATH="$HOME/.local/bin:$PATH"
BASHEOF
log "Shell init written to ~/.bashrc."
else
info "Shell init already in ~/.bashrc — skipping."
fi
# =============================================================================
# 12. GLOBAL CLAUDE.md (regenerated on every run with current versions)
# =============================================================================
section "12. Global CLAUDE.md"
mkdir -p "$HOME/.claude"
GOVERSION=$(go version 2>/dev/null || echo "unknown")
NODEVERSION=$(node --version 2>/dev/null || echo "unknown")
NPMVERSION=$(npm --version 2>/dev/null || echo "unknown")
UVVERSION=$(uv --version 2>/dev/null || echo "unknown")
PYTHONLATEST=$(uv python list 2>/dev/null | grep -oP 'cpython-\K[0-9.]+' | sort -V | tail -1 || echo "unknown")
DOCKERVERSION=$(docker --version 2>/dev/null || echo "unknown")
COMPOSEVERSION=$(docker compose version 2>/dev/null || echo "unknown")
CLAUDEVERSION=$(claude --version 2>/dev/null || echo "run: claude auth")
cat > "$HOME/.claude/CLAUDE.md" << EOF
# Claude Agent Environment — $(hostname)
Generated: $(date -u '+%Y-%m-%d %H:%M UTC') by setup-claude-vm.sh
## Operating System
- Distribution: Ubuntu 24.04 LTS (Noble Numbat)
- Kernel: $(uname -r)
- Architecture: $(uname -m)
- User: $(whoami)
- Home: $HOME
## Claude Code (this tool)
- Version: ${CLAUDEVERSION}
- Binary: ~/.local/bin/claude (native Bun installer, auto-updates on launch)
- Authenticate: \`claude auth\`
- Interactive: \`claude\`
- One-shot: \`claude -p "your prompt"\`
- This file is the global CLAUDE.md, read before any project-level CLAUDE.md
## Available Runtimes
### Go
- Version manager: GVM (~/.gvm/) — updated via: cd ~/.gvm && git pull
- Active: ${GOVERSION}
- Switch version: \`gvm use go<version>\`
- List installed: \`gvm list\`
- Install new: \`gvm install go<version> --binary\`
- GOPATH: \$(go env GOPATH)
- GOROOT: \$(go env GOROOT)
### Node.js
- Version manager: NVM (~/.nvm/) — updated via: re-run install script
- Active: ${NODEVERSION} / npm ${NPMVERSION}
- Switch version: \`nvm use <version>\`
- List installed: \`nvm list\`
- Install new: \`nvm install <version>\`
- Migrate globals: \`nvm install <version> --reinstall-packages-from=default\`
- Global npm installs work without sudo (user-space under NVM)
### Python
- Manager: uv (~/.local/bin/uv) — updated via: uv self update
- uv version: ${UVVERSION}
- Latest managed CPython: ${PYTHONLATEST}
- Create venv: \`uv venv .venv\`
- Activate venv: \`source .venv/bin/activate\`
- Install pkg: \`uv pip install <pkg>\`
- Run script: \`uv run script.py\`
- System Python: /usr/bin/python3 — DO NOT MODIFY (used by apt/system tools)
### Docker
- Version: ${DOCKERVERSION}
- Compose: ${COMPOSEVERSION}
- Compose command: \`docker compose\` (plugin syntax, NOT docker-compose)
- User $(whoami) is in the docker group — needs re-login to use without sudo
- Buildx: \`docker buildx build --platform linux/amd64,linux/arm64 .\`
- Daemon: \`sudo systemctl start|stop|status docker\`
## Terminal Multiplexers
- tmux $(tmux -V): \`tmux new -s <n>\` | attach: \`tmux attach -t <n>\` | list: \`tmux ls\`
- screen $(screen --version | head -1 | awk '{print $1,$2,$3}'): \`screen -S <n>\` | attach: \`screen -r <n>\`
- Recommended for long-running agent tasks — survives SSH disconnections
## Shell Initialisation
Tools are sourced via:
- /etc/profile.d/claude-tools.sh (all login shells)
- ~/.bashrc (interactive bash, tmux/screen panes)
Non-interactive scripts must explicitly source ~/.bashrc or manually set
NVM_DIR, source gvm scripts, and add ~/.local/bin to PATH.
## Updating this environment
Re-run setup-claude-vm.sh — it is idempotent and will:
- apt upgrade all system packages
- Pull latest NVM (git pull via official installer)
- Pull latest GVM (git pull on ~/.gvm)
- Install newer Node.js LTS if available (migrates global npm packages)
- Install newer Go if available via go.dev API
- uv self update
- Update Claude Code via native installer
- Regenerate this CLAUDE.md with current versions
## Automatic Updates
- Security and system updates applied nightly via unattended-upgrades
- Auto-reboot DISABLED — reboot manually when prompted
- Check pending reboot: \`cat /run/reboot-required.pkgs\`
- Check pending upgrades: \`sudo apt list --upgradable\`
- Apply reboot: \`sudo reboot\`
## Package Managers Summary
| Runtime | Manager | Notes |
|-----------|-------------------|-------------------------------------|
| System | apt / dpkg | sudo required |
| Go | go mod | standard modules |
| Node | npm | no sudo needed (NVM user-space) |
| Python | uv pip / uv add | never pip on system Python |
| Containers| docker compose | plugin syntax |
## Sudo Access
- User $(whoami) has sudo access
- Use \`sudo apt install\` for system-level packages
## Agent Session Notes
- uv for all Python work; never pip install on system Python
- For Go projects: check go.mod required version, switch with gvm if needed
- NVM/GVM not available in non-interactive shells without explicit sourcing
- Docker without sudo requires re-login after initial setup
- Use tmux/screen for tasks that may outlive a connection
- Verify tool availability: \`which <tool>\` or \`command -v <tool>\`
EOF
log "CLAUDE.md regenerated at $HOME/.claude/CLAUDE.md"
# =============================================================================
# DONE — Summary
# =============================================================================
GOVERSION_SHORT=$(go version 2>/dev/null | awk '{print $3}' || echo "unknown")
echo ""
echo -e "${GREEN}╔══════════════════════════════════════════════════════╗${NC}"
echo -e "${GREEN}║ Done! ║${NC}"
echo -e "${GREEN}╚══════════════════════════════════════════════════════╝${NC}"
echo ""
printf " %-20s %s\n" "Node.js:" "$(node --version) via NVM ${NVM_LATEST}"
printf " %-20s %s\n" "Go:" "${GOVERSION_SHORT} via GVM"
printf " %-20s %s\n" "Python:" "CPython ${PYTHONLATEST} via $(uv --version)"
printf " %-20s %s\n" "Docker:" "$(docker --version | awk '{print $3}' | tr -d ',')"
printf " %-20s %s\n" "Claude Code:" "$(claude --version 2>/dev/null || echo 'installed')"
printf " %-20s %s\n" "tmux:" "$(tmux -V)"
printf " %-20s %s\n" "CLAUDE.md:" "$HOME/.claude/CLAUDE.md"
echo ""
if [ ${#CHANGED[@]} -gt 0 ]; then
echo -e "${CYAN} Changes applied this run:${NC}"
for item in "${CHANGED[@]}"; do
echo " • $item"
done
echo ""
fi
warn "NEXT STEPS (first run only):"
warn " 1. Log out and back in → docker group takes effect (no sudo needed)"
warn " 2. Run: claude auth → authenticate Claude Code"
warn " 3. Run: source ~/.bashrc → activate tools in this current session"