|
#!/usr/bin/env bash |
|
# ============================================================================= |
|
# setup-claude-vm.sh — Claude Code agent VM bootstrap |
|
# Ubuntu 24.04 LTS · Idempotent (safe to re-run for updates) |
|
# |
|
# Run as root → prompted to create/select a user, then re-execs as that user |
|
# Run as user → installs / updates everything in user's home directory |
|
# ============================================================================= |
|
set -euo pipefail |
|
|
|
# ── Colours & icons ────────────────────────────────────────────────────────── |
|
GR='\033[0;32m'; YE='\033[1;33m'; RE='\033[0;31m' |
|
CY='\033[0;36m'; BL='\033[0;34m'; MA='\033[0;35m'; BO='\033[1m'; NC='\033[0m' |
|
|
|
log() { echo -e "${GR} ✓${NC} $1"; } |
|
info() { echo -e "${BL} →${NC} $1"; } |
|
skip() { echo -e "${CY} ·${NC} $1"; } |
|
warn() { echo -e "${YE} !${NC} $1"; } |
|
die() { echo -e "${RE} ✗${NC} $1"; exit 1; } |
|
section() { |
|
echo "" |
|
echo -e "${BO}${CY}┌─────────────────────────────────────────────────────┐${NC}" |
|
printf "${BO}${CY}│ %-51s│${NC}\n" "$1" |
|
echo -e "${BO}${CY}└─────────────────────────────────────────────────────┘${NC}" |
|
} |
|
ask() { |
|
echo "" |
|
echo -e "${BO}${MA} ▶ $1${NC}" |
|
printf "${MA} › ${NC}" |
|
} |
|
ask_yn() { # ask_yn "Question" [Y|N] → returns 0=yes 1=no |
|
local q="$1" def="${2:-Y}" |
|
local hint; [[ "$def" == "Y" ]] && hint="[Y/n]" || hint="[y/N]" |
|
echo "" |
|
echo -e "${BO}${MA} ▶ ${q} ${hint}${NC}" |
|
printf "${MA} › ${NC}" |
|
local ans; read -r ans |
|
ans="${ans:-$def}" |
|
[[ "${ans,,}" == "y" || "${ans,,}" == "yes" ]] |
|
} |
|
|
|
# ── Change tracker ─────────────────────────────────────────────────────────── |
|
CHANGED=() |
|
mark() { CHANGED+=("$1"); } |
|
|
|
# ── Helpers ─────────────────────────────────────────────────────────────────── |
|
have() { command -v "$1" &>/dev/null; } |
|
|
|
# nvm_run <args…> |
|
# NVM's internal scripts reference variables that may be unset in certain code |
|
# paths (e.g. PROVIDED_VERSION). Running with set -u causes bash to treat |
|
# those as fatal errors. Temporarily suspend -u for every nvm invocation. |
|
nvm_run() { |
|
set +u |
|
nvm "$@" |
|
local rc=$? |
|
set -u |
|
return $rc |
|
} |
|
|
|
# ============================================================================= |
|
# PREAMBLE — user management |
|
# Guarded by _CLAUDE_SETUP_USER so it only runs once (not on re-exec). |
|
# ============================================================================= |
|
if [[ -z "${_CLAUDE_SETUP_USER:-}" ]]; then |
|
|
|
# ── Banner ────────────────────────────────────────────────────────────────── |
|
clear |
|
echo -e "${BO}${CY}" |
|
echo " ╔═══════════════════════════════════════════════════════╗" |
|
echo " ║ Claude Code — VM Setup & Update ║" |
|
echo " ║ Ubuntu 24.04 LTS · Idempotent ║" |
|
echo " ╚═══════════════════════════════════════════════════════╝" |
|
echo -e "${NC}" |
|
echo -e " This script installs and configures:" |
|
echo -e " ${GR}✓${NC} System packages (tmux, screen, git, build tools)" |
|
echo -e " ${GR}✓${NC} Developer CLI tools (ripgrep · fd · bat · eza · fzf · direnv)" |
|
echo -e " ${GR}✓${NC} Docker CE (official repository)" |
|
echo -e " ${GR}✓${NC} kubectl (official Kubernetes apt repo — auto-updates per minor)" |
|
echo -e " ${GR}✓${NC} Helm (official Buildkite apt repo — auto-updates)" |
|
echo -e " ${GR}✓${NC} k9s (GitHub Releases .deb — update by re-running this script)" |
|
echo -e " ${GR}✓${NC} Kubernetes tools (yq · kubectx · kubens · stern)" |
|
echo -e " ${GR}✓${NC} Git (global identity + smart defaults + delta diffs)" |
|
echo -e " ${GR}✓${NC} git-delta (syntax-highlighted side-by-side diffs)" |
|
echo -e " ${GR}✓${NC} lazygit (terminal UI for git — alias: lg)" |
|
echo -e " ${GR}✓${NC} gh (GitHub CLI — official apt repo, auto-updates)" |
|
echo -e " ${GR}✓${NC} glab (GitLab CLI — gitlab.com releases, update by re-running)" |
|
echo -e " ${GR}✓${NC} psql (PostgreSQL client — PGDG apt repo, auto-updates)" |
|
echo -e " ${GR}✓${NC} mariadb (MariaDB client — official apt repo, auto-updates)" |
|
echo -e " ${GR}✓${NC} terraform (HashiCorp apt repo, auto-updates)" |
|
echo -e " ${GR}✓${NC} ansible (ansible/ansible PPA, auto-updates)" |
|
echo -e " ${GR}✓${NC} node / npm (NodeSource LTS apt repo, auto-updates to latest LTS)" |
|
echo -e " ${GR}✓${NC} gemini (Google Gemini CLI — ~/.local/bin, updated each run)" |
|
echo -e " ${GR}✓${NC} codex (OpenAI Codex CLI — ~/.local/bin, updated each run)" |
|
echo -e " ${GR}✓${NC} vibe (Mistral Vibe CLI — uv tool, skip if present)" |
|
echo -e " ${GR}✓${NC} Node.js via NVM (LTS + project .nvmrc detection)" |
|
echo -e " ${GR}✓${NC} Go via GVM (latest stable + go.mod detection)" |
|
echo -e " ${GR}✓${NC} Python via uv" |
|
echo -e " ${GR}✓${NC} Claude Code (native Bun installer, auto-updates)" |
|
echo -e " ${GR}✓${NC} Claude Code bash tab completion (flags, models, slash commands)" |
|
echo -e " ${GR}✓${NC} Starship prompt (git · runtime versions · k8s context)" |
|
echo -e " ${GR}✓${NC} fzf shell integration (Ctrl+R history · Ctrl+T file picker)" |
|
echo -e " ${GR}✓${NC} direnv (per-project .envrc auto-loading)" |
|
echo -e " ${GR}✓${NC} tmux 'claude' session — auto-attach on SSH login" |
|
echo -e " ${GR}✓${NC} Smart aliases (ls→eza · cat→bat · grep→rg · lg→lazygit)" |
|
echo -e " ${GR}✓${NC} ~/.claude/settings.json (no first-run permission dialogs)" |
|
echo -e " ${GR}✓${NC} Unattended security updates (no auto-reboot)" |
|
echo "" |
|
echo -e " ${YE}Safe to re-run — existing installations are updated, not replaced.${NC}" |
|
echo "" |
|
|
|
# ────────────────────────────────────────────────────────────────────────── |
|
# CASE A: root |
|
# ────────────────────────────────────────────────────────────────────────── |
|
if [[ $EUID -eq 0 ]]; then |
|
|
|
echo -e "${YE} Running as root. Dev tools must live in a regular user's home.${NC}" |
|
echo "" |
|
|
|
# List existing non-system users as suggestions |
|
EXISTING_USERS=$(awk -F: '$3>=1000 && $3<65534 && $1!="nobody" {print " • "$1}' /etc/passwd) |
|
if [[ -n "$EXISTING_USERS" ]]; then |
|
echo -e " ${BL}Existing non-system users:${NC}" |
|
echo "$EXISTING_USERS" |
|
echo "" |
|
fi |
|
|
|
ask "Enter username to set up (existing user or new name to create):" |
|
read -r TARGET_USER |
|
TARGET_USER="${TARGET_USER// /}" |
|
[[ -n "$TARGET_USER" ]] || die "Username cannot be empty." |
|
|
|
# Create user if needed |
|
if id "$TARGET_USER" &>/dev/null; then |
|
TARGET_HOME=$(getent passwd "$TARGET_USER" | cut -d: -f6) |
|
echo "" |
|
info "User '${BO}${TARGET_USER}${NC}${BL}' already exists (home: ${TARGET_HOME})" |
|
else |
|
echo "" |
|
info "User '${TARGET_USER}' not found — creating..." |
|
useradd --create-home --shell /bin/bash \ |
|
--comment "Claude agent user" "$TARGET_USER" |
|
TARGET_HOME=$(getent passwd "$TARGET_USER" | cut -d: -f6) |
|
# Set a random password (discarded immediately; access via sudo or SSH key) |
|
TMP_PW=$(tr -dc 'A-Za-z0-9!@#%^' </dev/urandom | head -c 24) |
|
echo "${TARGET_USER}:${TMP_PW}" | chpasswd |
|
unset TMP_PW |
|
log "User '${TARGET_USER}' created (home: ${TARGET_HOME})" |
|
mark "user '${TARGET_USER}' created" |
|
fi |
|
|
|
# Passwordless sudo |
|
SUDOERS_FILE="/etc/sudoers.d/claude-${TARGET_USER}" |
|
if [[ -f "$SUDOERS_FILE" ]]; then |
|
skip "Passwordless sudo for '${TARGET_USER}' already configured." |
|
else |
|
echo "" |
|
echo -e " ${BL}Claude Code agent sessions run sudo commands unattended${NC}" |
|
echo -e " ${BL}(apt installs, Docker, system config). A password prompt${NC}" |
|
echo -e " ${BL}would cause them to hang. Configuring passwordless sudo.${NC}" |
|
echo -e " ${BL}File: ${SUDOERS_FILE}${NC}" |
|
echo -e " ${BL}Revert at any time: ${BO}sudo rm ${SUDOERS_FILE}${NC}" |
|
echo "${TARGET_USER} ALL=(ALL) NOPASSWD: ALL" > "$SUDOERS_FILE" |
|
chmod 0440 "$SUDOERS_FILE" |
|
visudo -cf "$SUDOERS_FILE" \ |
|
|| { rm -f "$SUDOERS_FILE"; die "sudoers syntax error — aborted."; } |
|
log "Passwordless sudo configured for '${TARGET_USER}'" |
|
mark "passwordless sudo for '${TARGET_USER}'" |
|
fi |
|
|
|
# SSH public key |
|
echo "" |
|
if ask_yn "Add an SSH public key for '${TARGET_USER}'?" N; then |
|
echo "" |
|
echo -e " ${BL}Paste the public key (one line, starts with ssh-rsa / ssh-ed25519 / ecdsa-…):${NC}" |
|
printf "${MA} › ${NC}" |
|
read -r SSH_PUBKEY |
|
if [[ "$SSH_PUBKEY" =~ ^(ssh-|ecdsa-|sk-) ]]; then |
|
SSH_DIR="${TARGET_HOME}/.ssh" |
|
AUTH="${SSH_DIR}/authorized_keys" |
|
mkdir -p "$SSH_DIR" |
|
chmod 700 "$SSH_DIR" |
|
if grep -qF "$SSH_PUBKEY" "$AUTH" 2>/dev/null; then |
|
skip "SSH key already present in authorized_keys." |
|
else |
|
echo "$SSH_PUBKEY" >> "$AUTH" |
|
chmod 600 "$AUTH" |
|
chown -R "${TARGET_USER}:${TARGET_USER}" "$SSH_DIR" |
|
log "SSH public key added." |
|
mark "SSH key added for '${TARGET_USER}'" |
|
fi |
|
else |
|
warn "Key doesn't look valid — skipping." |
|
fi |
|
fi |
|
|
|
# Copy script to a temp file the target user can read, then re-exec |
|
SCRIPT_COPY=$(mktemp /tmp/setup-claude-vm-XXXXXX.sh) |
|
cp "$0" "$SCRIPT_COPY" |
|
chmod 755 "$SCRIPT_COPY" |
|
chown "${TARGET_USER}:${TARGET_USER}" "$SCRIPT_COPY" |
|
|
|
echo "" |
|
log "Handing off to user '${TARGET_USER}' via su — continuing setup…" |
|
echo "" |
|
exec su -l "$TARGET_USER" -c \ |
|
"_CLAUDE_SETUP_USER=1 bash ${SCRIPT_COPY}" |
|
# exec replaces this process — nothing below runs as root. |
|
|
|
# ────────────────────────────────────────────────────────────────────────── |
|
# CASE B: normal user |
|
# ────────────────────────────────────────────────────────────────────────── |
|
else |
|
have sudo || die "sudo not found. Ask an admin to install it and add you to sudoers." |
|
sudo -v 2>/dev/null || die "$(whoami) has no sudo access." |
|
|
|
if sudo -n true 2>/dev/null; then |
|
skip "Passwordless sudo already active for $(whoami)." |
|
else |
|
echo "" |
|
echo -e " ${YE}┌──────────────────────────────────────────────────────────┐${NC}" |
|
echo -e " ${YE}│ Passwordless sudo │${NC}" |
|
echo -e " ${YE}│ │${NC}" |
|
echo -e " ${YE}│ Claude Code runs sudo commands unattended. Without │${NC}" |
|
echo -e " ${YE}│ passwordless sudo, agent tasks may hang on a password │${NC}" |
|
echo -e " ${YE}│ prompt that never gets answered. │${NC}" |
|
echo -e " ${YE}│ │${NC}" |
|
echo -e " ${YE}│ Will write: /etc/sudoers.d/claude-$(whoami) │${NC}" |
|
echo -e " ${YE}│ Revert with: sudo rm /etc/sudoers.d/claude-$(whoami) │${NC}" |
|
echo -e " ${YE}└──────────────────────────────────────────────────────────┘${NC}" |
|
|
|
if ask_yn "Configure passwordless sudo for $(whoami)?" Y; then |
|
SF="/etc/sudoers.d/claude-$(whoami)" |
|
sudo bash -c " |
|
echo '$(whoami) ALL=(ALL) NOPASSWD: ALL' > '${SF}' |
|
chmod 0440 '${SF}' |
|
visudo -cf '${SF}' |
|
" || warn "Failed to configure passwordless sudo — continuing." |
|
log "Passwordless sudo configured." |
|
mark "passwordless sudo for $(whoami)" |
|
else |
|
warn "Skipping. Agent tasks requiring sudo may hang." |
|
warn "Enable later: echo '$(whoami) ALL=(ALL) NOPASSWD: ALL' | sudo tee /etc/sudoers.d/claude-$(whoami)" |
|
fi |
|
fi |
|
fi |
|
|
|
fi # end preamble |
|
|
|
# ============================================================================= |
|
# From here we are always a normal user with (passwordless) sudo. |
|
# ============================================================================= |
|
|
|
# ── Banner if running as normal user directly ───────────────────────────────── |
|
if [[ -z "${_CLAUDE_SETUP_USER:-}" ]]; then |
|
clear |
|
echo -e "${BO}${CY}" |
|
echo " ╔═══════════════════════════════════════════════════════╗" |
|
echo " ║ Claude Code — VM Setup & Update ║" |
|
echo " ╚═══════════════════════════════════════════════════════╝" |
|
echo -e "${NC}" |
|
fi |
|
|
|
echo -e " ${BL}Running as:${NC} ${BO}$(whoami)${NC} • Home: ${BO}${HOME}${NC}" |
|
echo -e " ${BL}Working dir:${NC} ${BO}$(pwd)${NC}" |
|
echo "" |
|
|
|
# ============================================================================= |
|
# 1. SYSTEM PACKAGES |
|
# ============================================================================= |
|
section "1 · System packages" |
|
|
|
sudo apt-get update -qq |
|
# Enable universe repo for eza, bat, ripgrep, fd-find, fzf, direnv |
|
sudo add-apt-repository universe -y -q 2>/dev/null || true |
|
sudo DEBIAN_FRONTEND=noninteractive 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 \ |
|
ripgrep fd-find bat fzf direnv eza |
|
|
|
# ── Convenience symlinks for Ubuntu's renamed binaries ─────────────────────── |
|
# Ubuntu ships bat as 'batcat' and fd as 'fdfind' to avoid conflicts. |
|
if have batcat && ! have bat; then |
|
sudo ln -sf "$(command -v batcat)" /usr/local/bin/bat |
|
log "Symlinked batcat → /usr/local/bin/bat" |
|
fi |
|
if have fdfind && ! have fd; then |
|
sudo ln -sf "$(command -v fdfind)" /usr/local/bin/fd |
|
log "Symlinked fdfind → /usr/local/bin/fd" |
|
fi |
|
|
|
sudo DEBIAN_FRONTEND=noninteractive apt-get upgrade -y |
|
mark "system packages upgraded" |
|
log "System packages up to date." |
|
|
|
# ============================================================================= |
|
# 2. DOCKER (official repository) |
|
# ============================================================================= |
|
section "2 · Docker CE (official)" |
|
|
|
_docker_repo_present() { |
|
[[ -f /etc/apt/sources.list.d/docker.list ]] |
|
} |
|
|
|
if ! have docker; then |
|
info "Docker not found — adding 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 "Docker (fresh install)" |
|
log "Docker $(docker --version | awk '{print $3}' | tr -d ',') installed." |
|
else |
|
info "Docker present — upgrading via apt…" |
|
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 "Docker upgraded" |
|
log "Docker $(docker --version | awk '{print $3}' | tr -d ',') up to date." |
|
fi |
|
|
|
# ============================================================================= |
|
# 3. KUBECTL (official Kubernetes apt repository) |
|
# Uses the per-minor-version repo (e.g. /kubernetes-apt-repository/stable/v1.32) |
|
# so unattended-upgrades will apply patch releases automatically within the |
|
# pinned minor version. To follow a new minor, re-run this script — it will |
|
# detect the latest stable minor from dl.k8s.io and update the repo if needed. |
|
# ============================================================================= |
|
section "3 · kubectl (Kubernetes CLI)" |
|
|
|
# Determine latest stable minor version from the Kubernetes release API |
|
K8S_STABLE=$(curl -fsSL https://dl.k8s.io/release/stable.txt) # e.g. v1.32.3 |
|
K8S_MINOR=$(echo "$K8S_STABLE" | grep -oP 'v\d+\.\d+') # e.g. v1.32 |
|
K8S_REPO_URL="https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb" |
|
K8S_KEYRING="/etc/apt/keyrings/kubernetes-apt-keyring.gpg" |
|
K8S_LIST="/etc/apt/sources.list.d/kubernetes.list" |
|
|
|
# Check whether the installed repo already points at this minor version |
|
REPO_CURRENT=$(grep -oP 'stable:/v[0-9]+\.[0-9]+' "$K8S_LIST" 2>/dev/null || echo "none") |
|
REPO_WANTED="stable:/${K8S_MINOR}" |
|
|
|
if have kubectl && [[ "$REPO_CURRENT" == "$REPO_WANTED" ]]; then |
|
info "kubectl repo already at ${K8S_MINOR} — upgrading package…" |
|
sudo apt-get install -y --only-upgrade kubectl 2>/dev/null || true |
|
mark "kubectl upgraded" |
|
log "kubectl $(kubectl version --client --short 2>/dev/null || kubectl version --client) up to date." |
|
else |
|
if [[ "$REPO_CURRENT" != "none" && "$REPO_CURRENT" != "$REPO_WANTED" ]]; then |
|
info "kubectl repo is at ${REPO_CURRENT} — updating to ${K8S_MINOR}…" |
|
else |
|
info "kubectl not found — adding official Kubernetes repo (${K8S_MINOR})…" |
|
fi |
|
|
|
# Add GPG key (idempotent — overwrites if already present) |
|
sudo install -m 0755 -d /etc/apt/keyrings |
|
curl -fsSL "https://pkgs.k8s.io/core:/stable:/${K8S_MINOR}/deb/Release.key" \ |
|
| sudo gpg --dearmor --yes -o "$K8S_KEYRING" |
|
sudo chmod a+r "$K8S_KEYRING" |
|
|
|
# Write apt source list |
|
echo "deb [signed-by=${K8S_KEYRING}] ${K8S_REPO_URL}/ /" \ |
|
| sudo tee "$K8S_LIST" > /dev/null |
|
|
|
sudo apt-get update -qq |
|
sudo apt-get install -y kubectl |
|
mark "kubectl ${K8S_STABLE} (fresh install)" |
|
log "kubectl $(kubectl version --client --short 2>/dev/null || kubectl version --client) installed." |
|
fi |
|
|
|
# ============================================================================= |
|
# 4. HELM (Buildkite apt repository — official, replaces old Balto repo) |
|
# The Buildkite repo carries a single 'helm' package that tracks latest stable. |
|
# unattended-upgrades will handle patch and minor updates automatically. |
|
# ============================================================================= |
|
section "4 · Helm (Kubernetes package manager)" |
|
|
|
HELM_KEYRING="/usr/share/keyrings/helm.gpg" |
|
HELM_LIST="/etc/apt/sources.list.d/helm-stable-debian.list" |
|
HELM_REPO="https://packages.buildkite.com/helm-linux/helm-debian/any/ any main" |
|
HELM_GPG="https://packages.buildkite.com/helm-linux/helm-debian/gpgkey" |
|
|
|
if have helm && [[ -f "$HELM_LIST" ]]; then |
|
info "Helm found — upgrading via apt…" |
|
sudo apt-get install -y --only-upgrade helm 2>/dev/null || true |
|
mark "Helm upgraded" |
|
log "Helm $(helm version --short 2>/dev/null) up to date." |
|
else |
|
info "Installing Helm from official Buildkite apt repo…" |
|
curl -fsSL "$HELM_GPG" | gpg --dearmor | sudo tee "$HELM_KEYRING" > /dev/null |
|
sudo chmod a+r "$HELM_KEYRING" |
|
echo "deb [signed-by=${HELM_KEYRING}] ${HELM_REPO}" \ |
|
| sudo tee "$HELM_LIST" > /dev/null |
|
sudo apt-get update -qq |
|
sudo apt-get install -y helm |
|
mark "Helm $(helm version --short 2>/dev/null)" |
|
log "Helm $(helm version --short 2>/dev/null) installed." |
|
fi |
|
|
|
# ============================================================================= |
|
# 5. K9S (GitHub Releases .deb — no official apt repo exists) |
|
# k9s does not publish an apt repository (upstream declined, issue #1390). |
|
# We fetch the latest .deb from GitHub Releases, compare against installed |
|
# version, and install only if newer. Re-run this script to update k9s. |
|
# unattended-upgrades CANNOT update k9s — this is a known limitation. |
|
# ============================================================================= |
|
section "5 · k9s (Kubernetes TUI)" |
|
|
|
# Detect architecture for the right .deb asset name |
|
case "$(dpkg --print-architecture)" in |
|
amd64) K9S_ARCH="amd64" ;; |
|
arm64) K9S_ARCH="arm64" ;; |
|
*) warn "Unsupported architecture for k9s: $(dpkg --print-architecture) — skipping." |
|
K9S_ARCH="" ;; |
|
esac |
|
|
|
if [[ -n "$K9S_ARCH" ]]; then |
|
K9S_LATEST=$(curl -fsSL https://api.github.com/repos/derailed/k9s/releases/latest \ |
|
| jq -r '.tag_name') |
|
K9S_CURRENT=$(k9s version --short 2>/dev/null | grep -oP 'v[0-9.]+' | head -1 || echo "none") |
|
|
|
if [[ "$K9S_CURRENT" == "$K9S_LATEST" ]]; then |
|
skip "k9s ${K9S_LATEST} already installed." |
|
else |
|
info "Installing k9s ${K9S_LATEST} (${K9S_CURRENT} → ${K9S_LATEST})…" |
|
K9S_DEB="k9s_linux_${K9S_ARCH}.deb" |
|
K9S_URL="https://github.com/derailed/k9s/releases/download/${K9S_LATEST}/${K9S_DEB}" |
|
K9S_TMP=$(mktemp /tmp/k9s-XXXXXX.deb) |
|
curl -fsSL "$K9S_URL" -o "$K9S_TMP" |
|
sudo apt-get install -y "$K9S_TMP" |
|
rm -f "$K9S_TMP" |
|
mark "k9s ${K9S_LATEST}" |
|
log "k9s $(k9s version --short 2>/dev/null) installed." |
|
info "Note: k9s has no apt repo — re-run setup-claude-vm.sh to update it." |
|
fi |
|
fi |
|
|
|
# ============================================================================= |
|
# 5b. KUBERNETES COMPANION TOOLS (yq · kubectx · kubens · stern) |
|
# ============================================================================= |
|
section "5b · Kubernetes companion tools" |
|
|
|
# ── Helper: install a binary from GitHub releases ──────────────────────────── |
|
_gh_binary() { |
|
# _gh_binary <name> <repo> <url-template> <version-flag> |
|
# url-template: use {VERSION} and {ARCH} placeholders |
|
local name="$1" repo="$2" url_tmpl="$3" ver_flag="${4:---version}" |
|
local latest current arch |
|
arch=$(dpkg --print-architecture) |
|
|
|
latest=$(curl -fsSL "https://api.github.com/repos/${repo}/releases/latest" \ |
|
| jq -r '.tag_name') |
|
current=$(${name} ${ver_flag} 2>/dev/null | grep -oP 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1 \ |
|
|| echo "none") |
|
|
|
if [[ "$current" == "$latest" || "$current" == "${latest#v}" ]]; then |
|
skip "${name} ${latest} already installed." |
|
return 0 |
|
fi |
|
|
|
local url; url="${url_tmpl//\{VERSION\}/${latest}}" |
|
url="${url//\{ARCH\}/${arch}}" |
|
info "Installing ${name} ${latest}…" |
|
local tmp; tmp=$(mktemp /tmp/${name}-XXXXXX) |
|
curl -fsSL "$url" -o "$tmp" |
|
sudo install -m 755 "$tmp" /usr/local/bin/${name} |
|
rm -f "$tmp" |
|
mark "${name} ${latest}" |
|
log "${name} ${latest} installed." |
|
} |
|
|
|
# ── yq (YAML processor) ────────────────────────────────────────────────────── |
|
_gh_binary yq mikefarah/yq \ |
|
"https://github.com/mikefarah/yq/releases/download/{VERSION}/yq_linux_{ARCH}" \ |
|
--version |
|
|
|
# ── kubectx + kubens ───────────────────────────────────────────────────────── |
|
# kubectx and kubens are single binaries in separate archives |
|
_kubectl_companion() { |
|
local name="$1" |
|
local latest current |
|
latest=$(curl -fsSL https://api.github.com/repos/ahmetb/kubectx/releases/latest \ |
|
| jq -r '.tag_name') |
|
current=$(${name} --help 2>&1 | grep -oP 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "none") |
|
|
|
if [[ "$current" == "${latest}" || "$current" == "${latest#v}" ]]; then |
|
skip "${name} ${latest} already installed." |
|
return 0 |
|
fi |
|
|
|
# kubectx archives use x86_64, not amd64 (unlike most tools) |
|
local deb_arch; deb_arch=$(dpkg --print-architecture) |
|
local arch |
|
case "$deb_arch" in |
|
amd64) arch="x86_64" ;; |
|
arm64) arch="arm64" ;; |
|
*) warn "kubectx: unsupported architecture ${deb_arch} — skipping."; return 0 ;; |
|
esac |
|
local url="https://github.com/ahmetb/kubectx/releases/download/${latest}/${name}_${latest}_linux_${arch}.tar.gz" |
|
info "Installing ${name} ${latest}…" |
|
local tmp; tmp=$(mktemp -d /tmp/${name}-XXXXXX) |
|
curl -fsSL "$url" | tar -xz -C "$tmp" |
|
sudo install -m 755 "${tmp}/${name}" /usr/local/bin/${name} |
|
rm -rf "$tmp" |
|
mark "${name} ${latest}" |
|
log "${name} installed." |
|
} |
|
|
|
_kubectl_companion kubectx |
|
_kubectl_companion kubens |
|
|
|
# ── stern (multi-pod log tailing) ──────────────────────────────────────────── |
|
_stern_install() { |
|
local latest current arch |
|
latest=$(curl -fsSL https://api.github.com/repos/stern/stern/releases/latest \ |
|
| jq -r '.tag_name') |
|
current=$(stern --version 2>/dev/null | grep -oP 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "none") |
|
if [[ "$current" == "$latest" || "$current" == "${latest#v}" ]]; then |
|
skip "stern ${latest} already installed." |
|
return 0 |
|
fi |
|
arch=$(dpkg --print-architecture) |
|
local url="https://github.com/stern/stern/releases/download/${latest}/stern_${latest#v}_linux_${arch}.tar.gz" |
|
info "Installing stern ${latest}…" |
|
local tmp; tmp=$(mktemp -d /tmp/stern-XXXXXX) |
|
curl -fsSL "$url" | tar -xz -C "$tmp" |
|
sudo install -m 755 "${tmp}/stern" /usr/local/bin/stern |
|
rm -rf "$tmp" |
|
mark "stern ${latest}" |
|
log "stern installed." |
|
} |
|
|
|
_stern_install |
|
|
|
# ============================================================================= |
|
# 5c. DEVELOPER CLI TOOLS (git-delta · lazygit) |
|
# ============================================================================= |
|
section "5c · Developer CLI tools (delta · lazygit)" |
|
|
|
# ── git-delta ──────────────────────────────────────────────────────────────── |
|
_delta_install() { |
|
local arch latest current |
|
arch=$(dpkg --print-architecture) |
|
latest=$(curl -fsSL https://api.github.com/repos/dandavison/delta/releases/latest \ |
|
| jq -r '.tag_name') |
|
current=$(delta --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "none") |
|
if [[ "$current" == "${latest}" ]]; then |
|
skip "git-delta ${latest} already installed." |
|
return 0 |
|
fi |
|
local url="https://github.com/dandavison/delta/releases/download/${latest}/git-delta_${latest}_${arch}.deb" |
|
info "Installing git-delta ${latest}…" |
|
local tmp; tmp=$(mktemp /tmp/delta-XXXXXX.deb) |
|
curl -fsSL "$url" -o "$tmp" |
|
sudo apt-get install -y "$tmp" -q |
|
rm -f "$tmp" |
|
mark "git-delta ${latest}" |
|
log "delta $(delta --version 2>/dev/null) installed." |
|
} |
|
|
|
_delta_install |
|
|
|
# ── lazygit ────────────────────────────────────────────────────────────────── |
|
_lazygit_install() { |
|
local arch latest current |
|
arch=$(dpkg --print-architecture) |
|
latest=$(curl -fsSL https://api.github.com/repos/jesseduffield/lazygit/releases/latest \ |
|
| jq -r '.tag_name') |
|
# latest is "v0.x.y" — strip leading v for download URL |
|
current=$(lazygit --version 2>/dev/null | grep -oP 'version=[0-9.]+' | grep -oP '[0-9.]+' || echo "none") |
|
if [[ "$current" == "${latest#v}" ]]; then |
|
skip "lazygit ${latest} already installed." |
|
return 0 |
|
fi |
|
local url="https://github.com/jesseduffield/lazygit/releases/download/${latest}/lazygit_${latest#v}_Linux_${arch}.tar.gz" |
|
# Lazygit uses x86_64 not amd64 in its archive names |
|
if [[ "$arch" == "amd64" ]]; then |
|
url="https://github.com/jesseduffield/lazygit/releases/download/${latest}/lazygit_${latest#v}_Linux_x86_64.tar.gz" |
|
elif [[ "$arch" == "arm64" ]]; then |
|
url="https://github.com/jesseduffield/lazygit/releases/download/${latest}/lazygit_${latest#v}_Linux_arm64.tar.gz" |
|
fi |
|
info "Installing lazygit ${latest}…" |
|
local tmp; tmp=$(mktemp -d /tmp/lazygit-XXXXXX) |
|
curl -fsSL "$url" | tar -xz -C "$tmp" |
|
sudo install -m 755 "${tmp}/lazygit" /usr/local/bin/lazygit |
|
rm -rf "$tmp" |
|
mark "lazygit ${latest}" |
|
log "lazygit installed." |
|
} |
|
|
|
_lazygit_install |
|
|
|
# ============================================================================= |
|
# 5d. FORGE CLI TOOLS (gh — GitHub CLI · glab — GitLab CLI) |
|
# ============================================================================= |
|
section "5d · Forge CLI tools (gh · glab)" |
|
|
|
# ── gh (GitHub CLI) — official apt repository ────────────────────────────────────────── |
|
# Maintained by GitHub; all updates handled automatically by unattended-upgrades. |
|
# Keyring: /etc/apt/keyrings/githubcli-archive-keyring.gpg |
|
# Sources: /etc/apt/sources.list.d/github-cli.list |
|
GH_KEYRING="/etc/apt/keyrings/githubcli-archive-keyring.gpg" |
|
GH_SOURCES="/etc/apt/sources.list.d/github-cli.list" |
|
|
|
if [[ -f "$GH_SOURCES" ]]; then |
|
skip "GitHub CLI apt repo already configured." |
|
else |
|
info "Adding GitHub CLI apt repository…" |
|
sudo mkdir -p -m 755 /etc/apt/keyrings |
|
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \ |
|
| sudo tee "$GH_KEYRING" > /dev/null |
|
sudo chmod go+r "$GH_KEYRING" |
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=${GH_KEYRING}] https://cli.github.com/packages stable main" \ |
|
| sudo tee "$GH_SOURCES" > /dev/null |
|
sudo apt-get update -qq |
|
mark "GitHub CLI apt repo added" |
|
log "GitHub CLI apt repo configured." |
|
fi |
|
|
|
if dpkg -s gh &>/dev/null; then |
|
skip "gh $(gh --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo installed) already installed (apt manages updates)." |
|
else |
|
info "Installing gh…" |
|
sudo apt-get install -y gh |
|
mark "gh $(gh --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo installed) installed" |
|
log "gh installed." |
|
fi |
|
|
|
|
|
# ── glab (GitLab CLI) — gitlab.com releases ────────────────────────────────────────── |
|
# Official source: https://gitlab.com/gitlab-org/cli |
|
# Version discovery: GitLab public API (no auth required for public projects). |
|
# Download URL: https://gitlab.com/gitlab-org/cli/-/releases/<tag>/downloads/<file> |
|
# GitLab serves these via a 302 redirect to the real artifact — curl -L follows it. |
|
# Asset naming: glab_<version>_linux_<arch>.deb (all lowercase, dpkg arch names) |
|
_glab_install() { |
|
local latest current |
|
# GitLab API: public project, no token needed. Returns array sorted newest-first. |
|
latest=$(curl -fsSL \ |
|
"https://gitlab.com/api/v4/projects/gitlab-org%2Fcli/releases?per_page=1" \ |
|
| jq -r '.[0].tag_name') |
|
|
|
if [[ -z "$latest" || "$latest" == "null" ]]; then |
|
warn "glab: could not determine latest version from GitLab API — skipping." |
|
return 0 |
|
fi |
|
|
|
current=$(glab --version 2>/dev/null | grep -oP 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "none") |
|
if [[ "$current" == "${latest}" || "$current" == "${latest#v}" || "v${current}" == "${latest}" ]]; then |
|
skip "glab ${latest} already installed." |
|
return 0 |
|
fi |
|
|
|
local deb_arch; deb_arch=$(dpkg --print-architecture) |
|
case "$deb_arch" in |
|
amd64|arm64|armv6|386|s390x|ppc64le) : ;; # all have a .deb on gitlab.com |
|
*) warn "glab: unsupported architecture ${deb_arch} — skipping."; return 0 ;; |
|
esac |
|
|
|
local ver="${latest#v}" # strip v: 1.86.0 |
|
local tag="v${ver}" # ensure v-prefix: v1.86.0 (GitLab URL requires it) |
|
local url="https://gitlab.com/gitlab-org/cli/-/releases/${tag}/downloads/glab_${ver}_linux_${deb_arch}.deb" |
|
info "Installing glab ${latest}…" |
|
local tmp; tmp=$(mktemp /tmp/glab-XXXXXX.deb) |
|
curl -fsSL -L -o "$tmp" "$url" # -L follows the 302 redirect to the real artifact |
|
sudo dpkg -i "$tmp" |
|
rm -f "$tmp" |
|
mark "glab ${latest}" |
|
log "glab installed." |
|
} |
|
|
|
_glab_install |
|
|
|
|
|
# ============================================================================= |
|
# 5e. DATABASE CLIENTS (PostgreSQL — PGDG · MariaDB — official apt repo) |
|
# Client tools only; no server packages. Both auto-update via apt. |
|
# ============================================================================= |
|
section "5e · Database clients (psql · mariadb)" |
|
|
|
# ── PostgreSQL client — PGDG official apt repo ──────────────────────────────── |
|
# Keyring: /usr/share/postgresql-common/pgdg/apt.postgresql.org.asc |
|
# Sources: /etc/apt/sources.list.d/pgdg.list |
|
# Package: postgresql-client (meta-package, always tracks the latest major version) |
|
# Source: https://www.postgresql.org/download/linux/ubuntu/ |
|
PGDG_ASC="/usr/share/postgresql-common/pgdg/apt.postgresql.org.asc" |
|
PGDG_SOURCES="/etc/apt/sources.list.d/pgdg.list" |
|
|
|
if [[ -f "$PGDG_SOURCES" ]]; then |
|
skip "PGDG apt repo already configured." |
|
else |
|
info "Adding PostgreSQL PGDG apt repository…" |
|
sudo apt-get install -y -qq curl ca-certificates |
|
sudo install -d /usr/share/postgresql-common/pgdg |
|
sudo curl -fsSL -o "$PGDG_ASC" https://www.postgresql.org/media/keys/ACCC4CF8.asc |
|
. /etc/os-release |
|
sudo sh -c "echo 'deb [signed-by=${PGDG_ASC}] https://apt.postgresql.org/pub/repos/apt ${VERSION_CODENAME}-pgdg main' > ${PGDG_SOURCES}" |
|
sudo apt-get update -qq |
|
mark "PGDG apt repo added" |
|
log "PostgreSQL PGDG apt repo configured." |
|
fi |
|
|
|
if dpkg -s postgresql-client &>/dev/null; then |
|
skip "postgresql-client $(psql --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+' | head -1 || echo installed) already installed (apt manages updates)." |
|
else |
|
info "Installing postgresql-client…" |
|
sudo apt-get install -y postgresql-client |
|
mark "postgresql-client $(psql --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+' | head -1 || echo installed) installed" |
|
log "postgresql-client installed." |
|
fi |
|
|
|
# ── MariaDB client — official MariaDB apt repo ──────────────────────────────── |
|
# Setup script: https://r.mariadb.com/downloads/mariadb_repo_setup |
|
# Configures /etc/apt/sources.list.d/mariadb.list with the rolling (latest |
|
# current stable (12.rolling) channel. We then install only mariadb-client, not mariadb-server. |
|
# NOTE: --skip-server is intentionally NOT used — that flag also skips the client repo. |
|
# --skip-maxscale drops the MaxScale proxy repo which we have no use for. |
|
# Source: https://mariadb.com/docs/server/server-management/install-and-upgrade-mariadb/ |
|
MARIADB_SOURCES="/etc/apt/sources.list.d/mariadb.list" |
|
|
|
if [[ -f "$MARIADB_SOURCES" ]] && ! grep -q 'maxscale' "$MARIADB_SOURCES" 2>/dev/null; then |
|
skip "MariaDB apt repo already configured." |
|
else |
|
info "Adding MariaDB apt repository…" |
|
curl -fsSL https://r.mariadb.com/downloads/mariadb_repo_setup \ |
|
| sudo bash -s -- --mariadb-server-version="mariadb-12.rolling" --skip-maxscale |
|
sudo apt-get update -qq |
|
mark "MariaDB apt repo added" |
|
log "MariaDB apt repo configured." |
|
fi |
|
|
|
if dpkg -s mariadb-client &>/dev/null; then |
|
skip "mariadb-client $(mariadb --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo installed) already installed (apt manages updates)." |
|
else |
|
info "Installing mariadb-client…" |
|
sudo apt-get install -y mariadb-client |
|
mark "mariadb-client $(mariadb --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo installed) installed" |
|
log "mariadb-client installed." |
|
fi |
|
|
|
# ============================================================================= |
|
# 5f. INFRASTRUCTURE TOOLS (terraform · ansible) |
|
# ============================================================================= |
|
section "5f · Infrastructure tools (terraform · ansible)" |
|
|
|
# ── Terraform — HashiCorp official apt repo ───────────────────────────────── |
|
# Keyring: /etc/apt/keyrings/hashicorp-archive-keyring.gpg |
|
# Sources: /etc/apt/sources.list.d/hashicorp.list |
|
# Source: https://developer.hashicorp.com/terraform/install#linux |
|
HCP_KEYRING="/etc/apt/keyrings/hashicorp-archive-keyring.gpg" |
|
HCP_SOURCES="/etc/apt/sources.list.d/hashicorp.list" |
|
|
|
if [[ -f "$HCP_SOURCES" ]]; then |
|
skip "HashiCorp apt repo already configured." |
|
else |
|
info "Adding HashiCorp apt repository…" |
|
sudo mkdir -p -m 755 /etc/apt/keyrings |
|
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o "$HCP_KEYRING" |
|
sudo chmod go+r "$HCP_KEYRING" |
|
. /etc/os-release |
|
echo "deb [arch=$(dpkg --print-architecture) signed-by=${HCP_KEYRING}] https://apt.releases.hashicorp.com ${VERSION_CODENAME} main" \ |
|
| sudo tee "$HCP_SOURCES" > /dev/null |
|
sudo apt-get update -qq |
|
mark "HashiCorp apt repo added" |
|
log "HashiCorp apt repo configured." |
|
fi |
|
|
|
if dpkg -s terraform &>/dev/null; then |
|
skip "terraform $(terraform version -json 2>/dev/null | jq -r '.terraform_version' 2>/dev/null || terraform version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo installed) already installed (apt manages updates)." |
|
else |
|
info "Installing terraform…" |
|
sudo apt-get install -y terraform |
|
mark "terraform $(terraform version -json 2>/dev/null | jq -r '.terraform_version' 2>/dev/null || echo installed) installed" |
|
log "terraform installed." |
|
fi |
|
|
|
# ── Ansible — ansible/ansible official PPA ──────────────────────────────── |
|
# PPA maintained by the Ansible team; always provides the latest stable release. |
|
# Source: https://launchpad.net/~ansible/+archive/ubuntu/ansible |
|
if find /etc/apt/sources.list.d/ -name 'ansible*' 2>/dev/null | grep -q .; then |
|
skip "Ansible PPA already configured." |
|
else |
|
info "Adding ansible/ansible PPA…" |
|
sudo apt-get install -y -qq software-properties-common |
|
sudo add-apt-repository -y ppa:ansible/ansible |
|
sudo apt-get update -qq |
|
mark "ansible PPA added" |
|
log "Ansible PPA configured." |
|
fi |
|
|
|
if dpkg -s ansible &>/dev/null; then |
|
skip "ansible $(ansible --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo installed) already installed (apt manages updates)." |
|
else |
|
info "Installing ansible…" |
|
sudo apt-get install -y ansible |
|
mark "ansible $(ansible --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo installed) installed" |
|
log "ansible installed." |
|
fi |
|
|
|
# ============================================================================= |
|
# 5g. AI CODING TOOLS (Node.js LTS · gemini-cli · codex) |
|
# Node.js from NodeSource LTS repo. AI tools installed as npm globals |
|
# into ~/.local (--prefix flag, no ~/.npmrc entry), so binaries land |
|
# ~/.local/bin and survive Node.js version upgrades. |
|
# Both tools self-update; script skips if already installed. |
|
# ============================================================================= |
|
section "5g · AI coding tools (node · gemini · codex)" |
|
|
|
# ── Node.js — NodeSource LTS apt repo ─────────────────────────────────── |
|
# setup_lts.x always configures the current LTS major (e.g. 22 → 24 when it |
|
# becomes LTS). Re-running it on every script execution keeps the sources file |
|
# up to date automatically — the script is idempotent and fast. |
|
info "Configuring NodeSource LTS repository…" |
|
curl -fsSL https://deb.nodesource.com/setup_lts.x | sudo -E bash - 2>&1 | grep -E 'Running|success|already' || true |
|
sudo apt-get install -y nodejs 2>&1 | grep -E 'already|upgraded|newly' || true |
|
mark "node $(node --version 2>/dev/null || echo '?') npm $(npm --version 2>/dev/null || echo '?')" |
|
log "Node.js installed/updated." |
|
|
|
# ── AI CLI tools (gemini + codex) ─────────────────────────────────────── |
|
# Installed with --prefix ~/.local so binaries land in ~/.local/bin and |
|
# node_modules in ~/.local/lib — no ~/.npmrc prefix entry needed (which |
|
# would conflict with nvm's prefix management). |
|
# Both tools self-update; skip if already installed. Re-run to force reinstall. |
|
mkdir -p "$HOME/.local/bin" "$HOME/.local/lib" |
|
|
|
if [[ -x "$HOME/.local/bin/gemini" ]]; then |
|
skip "gemini-cli already installed at ~/.local/bin/gemini (self-updates)." |
|
else |
|
info "Installing @google/gemini-cli…" |
|
npm install -g --prefix "$HOME/.local" @google/gemini-cli@latest |
|
mark "gemini $($HOME/.local/bin/gemini --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo installed) installed" |
|
log "gemini-cli installed." |
|
fi |
|
|
|
if [[ -x "$HOME/.local/bin/codex" ]]; then |
|
skip "codex already installed at ~/.local/bin/codex (self-updates)." |
|
else |
|
info "Installing @openai/codex…" |
|
npm install -g --prefix "$HOME/.local" @openai/codex@latest |
|
mark "codex $($HOME/.local/bin/codex --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo installed) installed" |
|
log "codex installed." |
|
fi |
|
|
|
# ============================================================================= |
|
# 5h. MISTRAL VIBE CLI |
|
# Python-based CLI coding agent powered by Devstral. Installed via |
|
# uv tool install (isolated env, shim in ~/.local/bin). |
|
# Skipped if already present — Vibe ships continuous self-updates. |
|
# API key: https://console.mistral.ai → set MISTRAL_API_KEY in ~/.vibe/.env |
|
# ============================================================================= |
|
section "5h · Mistral Vibe CLI" |
|
|
|
if [[ -x "$HOME/.local/bin/vibe" ]]; then |
|
skip "Mistral Vibe already installed at ~/.local/bin/vibe (self-updates)." |
|
else |
|
info "Installing mistral-vibe via uv tool…" |
|
uv tool install mistral-vibe |
|
mark "vibe $($HOME/.local/bin/vibe --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo installed) installed" |
|
log "Mistral Vibe installed." |
|
fi |
|
|
|
# ============================================================================= |
|
# 6. GIT — global identity + smart defaults |
|
# ============================================================================= |
|
section "6 · Git configuration" |
|
|
|
GIT_NAME_CUR=$(git config --global user.name 2>/dev/null || true) |
|
GIT_MAIL_CUR=$(git config --global user.email 2>/dev/null || true) |
|
|
|
if [[ -n "$GIT_NAME_CUR" && -n "$GIT_MAIL_CUR" ]]; then |
|
skip "Git identity already set:" |
|
skip " name = ${GIT_NAME_CUR}" |
|
skip " email = ${GIT_MAIL_CUR}" |
|
else |
|
echo "" |
|
echo -e " ${BL}Git needs your identity to sign commits. These values are written${NC}" |
|
echo -e " ${BL}to ~/.gitconfig and used by every repo on this machine.${NC}" |
|
|
|
if [[ -z "$GIT_NAME_CUR" ]]; then |
|
ask "Your full name (for git commits):" |
|
read -r GIT_NAME_NEW |
|
[[ -n "$GIT_NAME_NEW" ]] && git config --global user.name "$GIT_NAME_NEW" |
|
else |
|
skip "user.name already set to '${GIT_NAME_CUR}'" |
|
fi |
|
|
|
if [[ -z "$GIT_MAIL_CUR" ]]; then |
|
ask "Your email (for git commits):" |
|
read -r GIT_MAIL_NEW |
|
[[ -n "$GIT_MAIL_NEW" ]] && git config --global user.email "$GIT_MAIL_NEW" |
|
else |
|
skip "user.email already set to '${GIT_MAIL_CUR}'" |
|
fi |
|
fi |
|
|
|
# Smart git defaults — only set if not already configured |
|
_git_default() { # _git_default key value description |
|
local cur |
|
cur=$(git config --global "$1" 2>/dev/null || true) |
|
if [[ -z "$cur" ]]; then |
|
git config --global "$1" "$2" |
|
info "git config: ${1} = ${2} (${3})" |
|
else |
|
skip "git ${1} = ${cur} (already set)" |
|
fi |
|
} |
|
|
|
_git_default init.defaultBranch main "new repos use 'main'" |
|
_git_default pull.rebase false "merge on pull (not rebase)" |
|
_git_default push.autoSetupRemote true "auto-track remote branch on first push" |
|
_git_default color.ui auto "coloured output" |
|
_git_default core.autocrlf false "no CRLF conversion (Linux)" |
|
_git_default core.editor vim "default commit editor" |
|
_git_default fetch.prune true "auto-remove stale remote refs" |
|
_git_default diff.algorithm histogram "better diff algorithm" |
|
_git_default rerere.enabled true "remember conflict resolutions" |
|
|
|
# ── delta as git pager (syntax-highlighted diffs) ──────────────────────────── |
|
if have delta; then |
|
_git_default core.pager "delta" "syntax-highlighted diffs" |
|
_git_default interactive.diffFilter "delta --color-only" "delta in interactive mode" |
|
_git_default delta.navigate true "n/N to jump between diff sections" |
|
_git_default delta.light false "dark terminal theme" |
|
_git_default delta.side-by-side true "side-by-side diffs" |
|
_git_default delta.line-numbers true "line numbers in diffs" |
|
_git_default delta.syntax-theme "Catppuccin Mocha" "colour scheme" |
|
_git_default merge.conflictstyle diff3 "show base in conflicts" |
|
_git_default diff.colorMoved default "highlight moved lines" |
|
log "delta configured as git pager." |
|
fi |
|
|
|
log "Git configured." |
|
|
|
# ============================================================================= |
|
# 4. NVM — install or update |
|
# ============================================================================= |
|
section "7 · NVM (Node Version Manager)" |
|
|
|
NVM_LATEST=$(curl -fsSL https://api.github.com/repos/nvm-sh/nvm/releases/latest \ |
|
| jq -r '.tag_name') |
|
|
|
if [[ -d "$HOME/.nvm" ]]; then |
|
NVM_CUR=$(jq -r '.version // "?"' "$HOME/.nvm/package.json" 2>/dev/null || echo "?") |
|
if [[ "v${NVM_CUR}" == "$NVM_LATEST" ]]; then |
|
skip "NVM ${NVM_LATEST} already up to date — re-running installer to confirm." |
|
else |
|
info "NVM v${NVM_CUR} → ${NVM_LATEST} (re-running installer performs git pull)…" |
|
fi |
|
else |
|
info "NVM not found — installing ${NVM_LATEST}…" |
|
fi |
|
|
|
set +u |
|
curl -fsSo- "https://raw.githubusercontent.com/nvm-sh/nvm/${NVM_LATEST}/install.sh" | bash |
|
mark "NVM ${NVM_LATEST}" |
|
|
|
export NVM_DIR="$HOME/.nvm" |
|
# shellcheck source=/dev/null |
|
[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" |
|
set -u |
|
log "NVM $(nvm_run --version) ready." |
|
|
|
# ============================================================================= |
|
# 5. NODE.JS LTS + project version-file detection |
|
# ============================================================================= |
|
section "8 · Node.js" |
|
|
|
CURRENT_NODE=$(node --version 2>/dev/null || echo "none") |
|
LATEST_LTS=$(nvm_run version-remote --lts 2>/dev/null || echo "unknown") |
|
|
|
# ── Install / upgrade LTS ──────────────────────────────────────────────────── |
|
if [[ "$CURRENT_NODE" == "$LATEST_LTS" ]]; then |
|
skip "Node.js LTS ${CURRENT_NODE} is already the latest." |
|
elif [[ "$CURRENT_NODE" == "none" ]]; then |
|
info "Installing Node.js LTS ${LATEST_LTS}…" |
|
nvm_run install --lts |
|
mark "Node.js ${LATEST_LTS}" |
|
else |
|
info "Upgrading Node.js: ${CURRENT_NODE} → ${LATEST_LTS} (global packages migrated)…" |
|
nvm_run install --lts --reinstall-packages-from=default |
|
mark "Node.js ${CURRENT_NODE} → ${LATEST_LTS}" |
|
fi |
|
nvm_run use --lts |
|
nvm_run alias default 'lts/*' |
|
log "Node $(node --version) / npm $(npm --version)" |
|
|
|
# ── Version-file detection ─────────────────────────────────────────────────── |
|
_detect_node_version_file() { |
|
# Returns version string from .nvmrc or .node-version in CWD, or empty |
|
if [[ -f ".nvmrc" ]]; then cat .nvmrc | tr -d '[:space:]' |
|
elif [[ -f ".node-version" ]]; then cat .node-version | tr -d '[:space:]' |
|
fi |
|
} |
|
|
|
NODE_PIN=$(_detect_node_version_file || true) |
|
if [[ -n "$NODE_PIN" ]]; then |
|
echo "" |
|
echo -e " ${BL}Found version pin file in $(pwd):${NC}" |
|
[[ -f ".nvmrc" ]] && echo -e " ${BO}.nvmrc${NC} → Node.js ${BO}${NODE_PIN}${NC}" |
|
[[ -f ".node-version" ]] && echo -e " ${BO}.node-version${NC} → Node.js ${BO}${NODE_PIN}${NC}" |
|
|
|
# Check if already installed |
|
if nvm_run ls "$NODE_PIN" 2>/dev/null | grep -qv 'N/A'; then |
|
skip "Node.js ${NODE_PIN} is already installed. Run 'nvm use' in this project." |
|
else |
|
echo "" |
|
echo -e " ${BL}Node.js ${NODE_PIN} is not yet installed.${NC}" |
|
echo -e " ${BL}NVM will respect this file automatically when you enter the project directory.${NC}" |
|
if ask_yn "Install Node.js ${NODE_PIN} now?" Y; then |
|
nvm_run install "$NODE_PIN" |
|
mark "Node.js ${NODE_PIN} (from version file)" |
|
log "Node.js ${NODE_PIN} installed." |
|
info "Run 'nvm use' inside the project directory to activate it." |
|
fi |
|
fi |
|
else |
|
skip "No .nvmrc or .node-version found in $(pwd)." |
|
info "Tip: run this script from a project directory to auto-detect version pins." |
|
fi |
|
|
|
# ============================================================================= |
|
# 6. GVM — install or update |
|
# NOTE: set -euo pipefail is suspended for the entire GVM+Go block. |
|
# GVM's init script and sub-commands probe the environment with commands that |
|
# intentionally return non-zero (unset vars, missing versions, etc.). |
|
# -e, -u, and -o pipefail all conflict with this; we restore them afterwards. |
|
# ============================================================================= |
|
section "9 · GVM (Go Version Manager)" |
|
|
|
set +euo pipefail # ── suspend strict mode for all GVM work ───────────────── |
|
|
|
if [[ -d "$HOME/.gvm" ]]; then |
|
info "GVM found — pulling latest from master…" |
|
# Run in a subshell so any unexpected exit is contained. |
|
( cd "$HOME/.gvm" && git pull --quiet 2>/dev/null ) |
|
if [[ $? -eq 0 ]]; then |
|
log "GVM updated." |
|
else |
|
warn "GVM git pull failed — continuing with existing version." |
|
fi |
|
mark "GVM updated" |
|
else |
|
info "GVM not found — installing…" |
|
# Run in a subshell: the GVM installer calls `exit` at the end of its script, |
|
# which would kill the parent process if run directly. |
|
( bash < <(curl -fsSL https://raw.githubusercontent.com/moovweb/gvm/master/binscripts/gvm-installer) ) |
|
mark "GVM fresh install" |
|
fi |
|
|
|
# shellcheck source=/dev/null |
|
source "$HOME/.gvm/scripts/gvm" |
|
|
|
# ============================================================================= |
|
# 7. GO (latest stable) + project version-file detection |
|
# Still running with strict mode suspended (set above). |
|
# ============================================================================= |
|
section "10 · Go" |
|
|
|
GO_LATEST=$(curl -fsSL 'https://go.dev/dl/?mode=json' | jq -r '.[0].version') |
|
|
|
# ── Install latest if missing ──────────────────────────────────────────────── |
|
if gvm list 2>/dev/null | grep -qw "${GO_LATEST}"; then |
|
skip "${GO_LATEST} already installed." |
|
else |
|
info "Installing ${GO_LATEST} (binary)…" |
|
gvm install "${GO_LATEST}" --binary |
|
if [[ $? -eq 0 ]]; then |
|
mark "Go ${GO_LATEST}" |
|
else |
|
warn "Go ${GO_LATEST} install failed — check GVM output above." |
|
fi |
|
fi |
|
gvm use "${GO_LATEST}" --default |
|
log "$(go version)" |
|
|
|
# ── Version-file detection ─────────────────────────────────────────────────── |
|
_detect_go_version_file() { |
|
# go.mod: 'go 1.21' or 'go 1.21.3' |
|
# .go-version: '1.21.0' |
|
if [[ -f "go.mod" ]]; then |
|
grep '^go ' go.mod | awk '{print $2}' | head -1 |
|
elif [[ -f ".go-version" ]]; then |
|
cat .go-version | tr -d '[:space:]' |
|
fi |
|
} |
|
|
|
_go_version_to_gvm() { |
|
# gvm uses 'go1.21.3' style; go.mod may give '1.21' without patch |
|
local v="$1" |
|
# Strip leading 'go' if present |
|
v="${v#go}" |
|
# If only major.minor (e.g. 1.21), resolve to latest patch via go.dev |
|
if [[ "$v" =~ ^[0-9]+\.[0-9]+$ ]]; then |
|
curl -fsSL 'https://go.dev/dl/?mode=json' \ |
|
| jq -r --arg prefix "go${v}." '[.[] | select(.version | startswith($prefix))][0].version // empty' |
|
else |
|
echo "go${v}" |
|
fi |
|
} |
|
|
|
GO_PIN_RAW=$(_detect_go_version_file) |
|
if [[ -n "$GO_PIN_RAW" ]]; then |
|
GO_PIN_GVM=$(_go_version_to_gvm "$GO_PIN_RAW") |
|
echo "" |
|
echo -e " ${BL}Found Go version pin in $(pwd):${NC}" |
|
[[ -f "go.mod" ]] && echo -e " ${BO}go.mod${NC} → Go ${BO}${GO_PIN_RAW}${NC} (resolves to ${GO_PIN_GVM})" |
|
[[ -f ".go-version" ]] && echo -e " ${BO}.go-version${NC} → Go ${BO}${GO_PIN_RAW}${NC} (resolves to ${GO_PIN_GVM})" |
|
|
|
if [[ -z "$GO_PIN_GVM" ]]; then |
|
warn "Could not resolve Go ${GO_PIN_RAW} to a known release — skipping." |
|
elif [[ "$GO_PIN_GVM" == "$GO_LATEST" ]]; then |
|
skip "Go version pin matches the latest (${GO_LATEST}) — already active." |
|
elif gvm list 2>/dev/null | grep -qw "${GO_PIN_GVM}"; then |
|
skip "${GO_PIN_GVM} is already installed. Switch with: gvm use ${GO_PIN_GVM}" |
|
else |
|
echo "" |
|
echo -e " ${BL}${GO_PIN_GVM} is not yet installed.${NC}" |
|
if ask_yn "Install ${GO_PIN_GVM} for this project?" Y; then |
|
gvm install "${GO_PIN_GVM}" --binary |
|
mark "Go ${GO_PIN_GVM} (from version file)" |
|
log "${GO_PIN_GVM} installed." |
|
info "Switch to it with: gvm use ${GO_PIN_GVM}" |
|
fi |
|
fi |
|
else |
|
skip "No go.mod or .go-version found in $(pwd)." |
|
info "Tip: run this script from a project directory to auto-detect version pins." |
|
fi |
|
|
|
set -euo pipefail # ── restore strict mode now that GVM/Go work is complete ── |
|
|
|
# ============================================================================= |
|
# 8. UV + PYTHON |
|
# ============================================================================= |
|
section "11 · uv + Python" |
|
|
|
export PATH="$HOME/.local/bin:$PATH" |
|
|
|
if have uv; then |
|
UV_BEFORE=$(uv --version) |
|
info "uv found (${UV_BEFORE}) — updating…" |
|
uv self update 2>/dev/null || true |
|
UV_AFTER=$(uv --version) |
|
if [[ "$UV_BEFORE" != "$UV_AFTER" ]]; then |
|
mark "uv ${UV_BEFORE} → ${UV_AFTER}" |
|
log "uv updated: ${UV_BEFORE} → ${UV_AFTER}" |
|
else |
|
skip "uv is already up to date (${UV_AFTER})." |
|
fi |
|
else |
|
info "uv not found — installing…" |
|
curl -LsSf https://astral.sh/uv/install.sh | sh |
|
mark "uv installed" |
|
fi |
|
|
|
uv python install 2>/dev/null || true |
|
PY_VER=$(uv python list 2>/dev/null | grep -oP 'cpython-\K[0-9.]+' | sort -V | tail -1 || echo "?") |
|
log "uv $(uv --version) / Python ${PY_VER}" |
|
|
|
# ============================================================================= |
|
# 9. CLAUDE CODE (native Bun installer — official, auto-updates) |
|
# ============================================================================= |
|
section "12 · Claude Code" |
|
|
|
CLAUDE_BEFORE=$(claude --version 2>/dev/null || echo "not installed") |
|
|
|
echo "" |
|
echo -e " ${BL}Using the official native installer (Bun single-file executable).${NC}" |
|
echo -e " ${BL}This is the recommended method — not npm (deprecated).${NC}" |
|
echo -e " ${BL}Installs to: ${BO}~/.local/bin/claude${NC}${BL} — auto-updates on every launch.${NC}" |
|
echo "" |
|
|
|
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 "Claude Code ${CLAUDE_BEFORE} → ${CLAUDE_AFTER}" |
|
log "Claude Code: ${CLAUDE_BEFORE} → ${CLAUDE_AFTER}" |
|
else |
|
skip "Claude Code ${CLAUDE_AFTER} already up to date." |
|
fi |
|
|
|
# ============================================================================= |
|
# 10. CLAUDE CODE BASH COMPLETION |
|
# Claude Code has no native `claude completion bash` command (open issue #7738). |
|
# We ship a comprehensive completion script covering: |
|
# • CLI flags and their accepted values |
|
# • Subcommands (mcp, doctor, update, …) |
|
# • Slash commands (typed as the first argument starting with /) |
|
# • Custom commands auto-discovered from ~/.claude/commands/ |
|
# • File-path completion for flags that accept paths |
|
# ============================================================================= |
|
section "13 · Bash completion for Claude Code" |
|
|
|
# Ensure bash-completion infrastructure is present |
|
sudo apt-get install -y bash-completion -qq |
|
|
|
COMPLETION_DIR="/etc/bash_completion.d" |
|
COMPLETION_FILE="${COMPLETION_DIR}/claude" |
|
|
|
sudo tee "$COMPLETION_FILE" > /dev/null << 'COMPEOF' |
|
# Claude Code — bash completion |
|
# Covers: flags, values, subcommands, slash commands, custom commands. |
|
# Regenerate by re-running setup-claude-vm.sh. |
|
# |
|
# Claude Code has no built-in completion command yet (upstream issue #7738). |
|
# This script is maintained manually and updated alongside setup-claude-vm.sh. |
|
|
|
_claude_completion() { |
|
local cur prev words cword |
|
_init_completion || return |
|
|
|
# ── Top-level subcommands ──────────────────────────────────────────────── |
|
local subcommands="mcp doctor update login logout config" |
|
|
|
# ── All CLI flags ──────────────────────────────────────────────────────── |
|
local flags=" |
|
--help -h |
|
--version |
|
--print -p |
|
--verbose |
|
--no-verbose |
|
--model |
|
--output-format |
|
--permission-mode |
|
--system-prompt |
|
--system-prompt-file |
|
--append-system-prompt |
|
--append-system-prompt-file |
|
--allowedTools |
|
--disallowedTools |
|
--dangerously-skip-permissions |
|
--add-dir |
|
--max-turns |
|
--agents |
|
--continue -c |
|
--resume |
|
--no-update |
|
--ide |
|
" |
|
|
|
# ── Models ─────────────────────────────────────────────────────────────── |
|
local models=" |
|
claude-opus-4-6 |
|
claude-sonnet-4-6 |
|
claude-haiku-4-5-20251001 |
|
opus |
|
sonnet |
|
haiku |
|
" |
|
|
|
# ── Output formats ─────────────────────────────────────────────────────── |
|
local output_formats="text json stream-json" |
|
|
|
# ── Permission modes ───────────────────────────────────────────────────── |
|
local permission_modes="default acceptEdits bypassPermissions plan" |
|
|
|
# ── Built-in slash commands (as first positional argument) ─────────────── |
|
local slash_commands=" |
|
/help |
|
/clear |
|
/compact |
|
/config |
|
/context |
|
/cost |
|
/exit /quit |
|
/memory |
|
/model |
|
/permissions |
|
/review |
|
/status |
|
/todo |
|
/vim |
|
/add-dir |
|
/approved-tools |
|
/bug |
|
/doctor |
|
/feedback |
|
/ide |
|
/init |
|
/login /logout |
|
/mcp |
|
/migrate-installer |
|
/new |
|
/pr_comments |
|
/release-notes |
|
/reset |
|
/terminal |
|
/terminal-setup |
|
" |
|
|
|
# ── Flag-value completions ─────────────────────────────────────────────── |
|
case "$prev" in |
|
--model|-m) |
|
# shellcheck disable=SC2207 |
|
COMPREPLY=( $(compgen -W "$models" -- "$cur") ) |
|
return 0 |
|
;; |
|
--output-format) |
|
# shellcheck disable=SC2207 |
|
COMPREPLY=( $(compgen -W "$output_formats" -- "$cur") ) |
|
return 0 |
|
;; |
|
--permission-mode) |
|
# shellcheck disable=SC2207 |
|
COMPREPLY=( $(compgen -W "$permission_modes" -- "$cur") ) |
|
return 0 |
|
;; |
|
--system-prompt-file|--append-system-prompt-file|--add-dir) |
|
# File/directory path completion |
|
_filedir |
|
return 0 |
|
;; |
|
--resume) |
|
# Could be a session ID — no useful completions, fall through |
|
;; |
|
esac |
|
|
|
# ── Slash commands: trigger when cur starts with / ─────────────────────── |
|
if [[ "$cur" == /* ]]; then |
|
# Combine built-in slash commands with custom commands from ~/.claude/commands/ |
|
local custom_cmds="" |
|
if [[ -d "$HOME/.claude/commands" ]]; then |
|
custom_cmds=$(find "$HOME/.claude/commands" -name '*.md' -maxdepth 1 2>/dev/null \ |
|
| sed 's|.*/|/|; s|\.md$||') |
|
fi |
|
# Also check project-local .claude/commands/ |
|
if [[ -d ".claude/commands" ]]; then |
|
local proj_cmds |
|
proj_cmds=$(find ".claude/commands" -name '*.md' -maxdepth 1 2>/dev/null \ |
|
| sed 's|.*/|/|; s|\.md$||') |
|
custom_cmds="${custom_cmds} ${proj_cmds}" |
|
fi |
|
# shellcheck disable=SC2207 |
|
COMPREPLY=( $(compgen -W "${slash_commands} ${custom_cmds}" -- "$cur") ) |
|
return 0 |
|
fi |
|
|
|
# ── Flags: trigger when cur starts with - ──────────────────────────────── |
|
if [[ "$cur" == -* ]]; then |
|
# shellcheck disable=SC2207 |
|
COMPREPLY=( $(compgen -W "$flags" -- "$cur") ) |
|
return 0 |
|
fi |
|
|
|
# ── First positional argument: subcommands or slash commands ───────────── |
|
# Count non-flag words before cursor to determine position |
|
local non_flag_count=0 |
|
local w |
|
for w in "${words[@]:1:$cword-1}"; do |
|
[[ "$w" != -* ]] && (( non_flag_count++ )) || true |
|
done |
|
|
|
if (( non_flag_count == 0 )); then |
|
# shellcheck disable=SC2207 |
|
COMPREPLY=( $(compgen -W "$subcommands $flags" -- "$cur") ) |
|
return 0 |
|
fi |
|
|
|
# ── mcp subcommands ─────────────────────────────────────────────────────── |
|
if [[ "${words[1]}" == "mcp" ]]; then |
|
local mcp_subs="add remove list get serve" |
|
case "$prev" in |
|
mcp) |
|
# shellcheck disable=SC2207 |
|
COMPREPLY=( $(compgen -W "$mcp_subs" -- "$cur") ) |
|
return 0 |
|
;; |
|
esac |
|
fi |
|
|
|
# Default: no completion |
|
COMPREPLY=() |
|
} |
|
|
|
complete -F _claude_completion claude |
|
COMPEOF |
|
|
|
log "Completion script written → ${COMPLETION_FILE}" |
|
|
|
# Verify the bash-completion infrastructure will pick it up in new shells. |
|
# /etc/bash_completion.d/ is sourced automatically when bash-completion is |
|
# active — nothing extra needed in .bashrc. |
|
BASH_COMP_MAIN="/usr/share/bash-completion/bash_completion" |
|
BASHRC_COMP_MARKER="# bash-completion init" |
|
if [[ -f "$BASH_COMP_MAIN" ]]; then |
|
if ! grep -q "$BASHRC_COMP_MARKER" "$HOME/.bashrc" 2>/dev/null; then |
|
cat >> "$HOME/.bashrc" << 'BASHEOF' |
|
|
|
# bash-completion init |
|
# Loads /etc/bash_completion.d/* including Claude Code tab completion. |
|
# This block is only needed for non-login interactive shells (e.g. tmux panes). |
|
if [[ -f /usr/share/bash-completion/bash_completion ]]; then |
|
source /usr/share/bash-completion/bash_completion |
|
fi |
|
BASHEOF |
|
log "bash-completion sourced in ~/.bashrc (for tmux panes / non-login shells)." |
|
mark "bash-completion configured in ~/.bashrc" |
|
else |
|
skip "bash-completion already in ~/.bashrc." |
|
fi |
|
else |
|
warn "bash-completion package not found at expected path — tab completion may not load in non-login shells." |
|
fi |
|
|
|
mark "Claude Code bash completion installed" |
|
log "Completion active in new shells. Test with: claude --<TAB> or claude /<TAB>" |
|
|
|
# ============================================================================= |
|
# 11. TMUX — configuration + SSH auto-session |
|
# ============================================================================= |
|
section "14 · tmux — 'claude' session + SSH auto-attach" |
|
|
|
# ── Catppuccin tmux plugins — manual install (no TPM, avoids name conflicts) ─ |
|
# |
|
# Two repos are git-cloned into ~/.config/tmux/plugins/: |
|
# catppuccin/tmux — theme + status modules |
|
# tmux-plugins/tmux-cpu — #{cpu_percentage} / #{ram_percentage} tokens |
|
# |
|
# On re-runs: existing clones are updated with `git pull --ff-only`. |
|
# To force a full reinstall: rm -rf ~/.config/tmux/plugins && re-run script. |
|
# ───────────────────────────────────────────────────────────────────────────── |
|
TMUX_PLUGINS_DIR="$HOME/.config/tmux/plugins" |
|
mkdir -p "$TMUX_PLUGINS_DIR" |
|
|
|
_clone_or_update() { |
|
local url="$1" # e.g. https://github.com/catppuccin/tmux.git |
|
local dest="$2" # e.g. $TMUX_PLUGINS_DIR/catppuccin/tmux |
|
local tag="${3:-}" # optional: git tag to checkout (e.g. v2.1.3) |
|
local label="$4" # human-readable name for log messages |
|
|
|
if [[ -d "$dest/.git" ]]; then |
|
info "$label already cloned — pulling updates…" |
|
git -C "$dest" pull --ff-only --quiet 2>/dev/null \ |
|
&& log "$label updated." \ |
|
|| warn "$label pull failed (offline? detached HEAD?). Skipping update." |
|
else |
|
log "Cloning $label…" |
|
mkdir -p "$(dirname "$dest")" |
|
if [[ -n "$tag" ]]; then |
|
git clone --depth 1 --branch "$tag" "$url" "$dest" --quiet \ |
|
&& log "$label cloned at $tag." \ |
|
|| warn "$label clone failed." |
|
else |
|
git clone --depth 1 "$url" "$dest" --quiet \ |
|
&& log "$label cloned." \ |
|
|| warn "$label clone failed." |
|
fi |
|
fi |
|
} |
|
|
|
_clone_or_update \ |
|
"https://github.com/catppuccin/tmux.git" \ |
|
"$TMUX_PLUGINS_DIR/catppuccin/tmux" \ |
|
"v2.1.3" \ |
|
"catppuccin/tmux" |
|
|
|
_clone_or_update \ |
|
"https://github.com/tmux-plugins/tmux-cpu.git" \ |
|
"$TMUX_PLUGINS_DIR/tmux-plugins/tmux-cpu" \ |
|
"" \ |
|
"tmux-plugins/tmux-cpu" |
|
|
|
# ── ram_module.sh — register @catppuccin_status_ram via bash script ────────── |
|
# All tmux-config-file approaches to custom modules fail in our setup: |
|
# - %hidden + ${VAR}: only works when catppuccin.tmux's own loader sources the file |
|
# - source-file + d:current_file: d:current_file not set when called from .tmux.conf |
|
# - status_module.conf source: requires tmux's native conf parser with %hidden context |
|
# |
|
# Solution: a plain bash script that sets @catppuccin_status_ram directly via |
|
# `tmux set`. It runs asynchronously via `run` after catppuccin.tmux, but |
|
# @catppuccin_status_ram is only evaluated at status BAR RENDER TIME (no -F flag), |
|
# so by then the script has always completed. Hardcoded mocha hex colors avoid |
|
# any read-before-write timing issues with catppuccin's @thm_* variables. |
|
|
|
# Clean up broken ram.conf from previous script versions |
|
rm -f "$TMUX_PLUGINS_DIR/catppuccin/tmux/status/ram.conf" |
|
_RAM_MODULE="$HOME/.config/tmux/ram_module.sh" |
|
# Always rewrite — ensures stale versions (previous failed approaches) are replaced. |
|
cat > "$_RAM_MODULE" << 'RAM_EOF' |
|
#!/usr/bin/env bash |
|
# Registers @catppuccin_status_ram for catppuccin/tmux (mocha flavor). |
|
# Uses hardcoded mocha colors — change hex values here if switching flavor. |
|
# RAM percentage via direct script call (#{ram_percentage} token resolves empty |
|
# outside the status bar render cycle; #(script) is evaluated at render time). |
|
YELLOW="#f9e2af" # @thm_yellow (mocha) |
|
CRUST="#11111b" # @thm_crust (mocha) |
|
FG="#cdd6f4" # @thm_fg (mocha) — matches text in other modules |
|
RAM_SCRIPT="$HOME/.config/tmux/plugins/tmux-plugins/tmux-cpu/scripts/ram_percentage.sh" |
|
LEFT=$'\ue0b6' # U+E0B6 — powerline left rounded cap |
|
tmux set -g "@catppuccin_status_ram" \ |
|
"#[fg=${YELLOW},bg=default]${LEFT}#[fg=${CRUST},bg=${YELLOW}] #[fg=${YELLOW},bg=default]#[fg=${FG},bg=default] #(${RAM_SCRIPT}) " |
|
RAM_EOF |
|
chmod +x "$_RAM_MODULE" |
|
log "Written ~/.config/tmux/ram_module.sh" |
|
mark "catppuccin ram_module.sh written" |
|
|
|
mark "tmux catppuccin plugins installed" |
|
|
|
# ── .tmux.conf ─────────────────────────────────────────────────────────────── |
|
TMUX_CONF="$HOME/.tmux.conf" |
|
if [[ -f "$TMUX_CONF" ]]; then |
|
skip "~/.tmux.conf already exists — not overwriting." |
|
info "Remove it and re-run to get the default Claude VM config." |
|
|
|
# ── Enforce mouse off regardless of whether we own the config ──────────── |
|
# An existing config with `set -g mouse on` steals native selection/copy. |
|
# We patch just the mouse line; everything else is left untouched. |
|
if grep -qP '^\s*set\s+(-g\s+)?mouse\s+on' "$TMUX_CONF" 2>/dev/null; then |
|
sed -i -E 's/^(\s*set\s+(-g\s+)?mouse\s+)on/\1off/' "$TMUX_CONF" |
|
warn "Patched 'mouse on' → 'mouse off' in existing ~/.tmux.conf" |
|
mark "tmux mouse disabled in existing config" |
|
elif ! grep -qP '^\s*set\s+(-g\s+)?mouse' "$TMUX_CONF" 2>/dev/null; then |
|
# No mouse setting at all — append one so it is explicit |
|
printf '\n# Claude VM — disable mouse to preserve native terminal selection/copy\nset -g mouse off\n' >> "$TMUX_CONF" |
|
info "Appended 'set -g mouse off' to existing ~/.tmux.conf" |
|
mark "tmux mouse off appended to existing config" |
|
else |
|
skip "tmux mouse already set to off in ~/.tmux.conf." |
|
fi |
|
|
|
# ── Append catppuccin status bar block if not already present ──────────── |
|
# Strip any previous catppuccin block (any version) by deleting from the |
|
# marker line to end-of-file — these blocks are always appended at the end. |
|
if grep -q '^# catppuccin-tmux — claude-vm' "$TMUX_CONF" 2>/dev/null; then |
|
# Find the line number of the first catppuccin marker and truncate there |
|
_marker_line=$(grep -n '^# catppuccin-tmux — claude-vm' "$TMUX_CONF" | head -1 | cut -d: -f1) |
|
# Keep everything before the marker (subtract 1 to also drop the blank line before it) |
|
_keep=$(( _marker_line - 1 )) |
|
[[ $_keep -lt 1 ]] && _keep=1 |
|
head -n "$_keep" "$TMUX_CONF" > "${TMUX_CONF}.tmp" && mv "${TMUX_CONF}.tmp" "$TMUX_CONF" |
|
warn "Removed old catppuccin block from ~/.tmux.conf — will re-append v4." |
|
fi |
|
if ! grep -q '# catppuccin-tmux — claude-vm v4' "$TMUX_CONF" 2>/dev/null; then |
|
cat >> "$TMUX_CONF" << 'CATP_EOF' |
|
|
|
# catppuccin-tmux — claude-vm v4 (appended by setup-claude-vm.sh) |
|
set -g @catppuccin_flavor "mocha" |
|
set -g @catppuccin_window_status_style "rounded" |
|
set -g status-position top |
|
set -g status-interval 5 |
|
run ~/.config/tmux/plugins/catppuccin/tmux/catppuccin.tmux |
|
run ~/.config/tmux/ram_module.sh |
|
set -g status-left-length 100 |
|
set -g status-right-length 100 |
|
set -g status-left "" |
|
set -g status-right "#{E:@catppuccin_status_application}" |
|
set -agF status-right "#{E:@catppuccin_status_cpu}" |
|
set -ag status-right "#{E:@catppuccin_status_ram}" |
|
set -ag status-right "#{E:@catppuccin_status_session}" |
|
set -ag status-right "#{E:@catppuccin_status_uptime}" |
|
# tmux-cpu loads last — provides #{cpu_percentage}/#{ram_percentage} tokens |
|
run ~/.config/tmux/plugins/tmux-plugins/tmux-cpu/cpu.tmux |
|
CATP_EOF |
|
log "Appended catppuccin status bar block to existing ~/.tmux.conf" |
|
mark "catppuccin appended to existing tmux config" |
|
else |
|
skip "catppuccin v3 block already present in ~/.tmux.conf" |
|
fi |
|
else |
|
cat > "$TMUX_CONF" << 'TMUXEOF' |
|
# ── Claude VM — tmux configuration ────────────────────────────────────────── |
|
# Prefix: Ctrl+b (default — safe, no conflicts) |
|
|
|
# True colour + italics |
|
set -g default-terminal "tmux-256color" |
|
set -ag terminal-overrides ",xterm-256color:RGB" |
|
|
|
# Mouse support disabled — preserves native terminal selection/copy behaviour |
|
# on macOS (Terminal, iTerm2) and Windows (Windows Terminal, PuTTY). |
|
# To enable: `tmux set -g mouse on` or add `set -g mouse on` to ~/.tmux.conf |
|
set -g mouse off |
|
|
|
# Generous scrollback |
|
set -g history-limit 100000 |
|
|
|
# Reduce escape-key delay (important for vim users) |
|
set -sg escape-time 10 |
|
|
|
# Window / pane numbering from 1 (not 0) |
|
set -g base-index 1 |
|
setw -g pane-base-index 1 |
|
set -g renumber-windows on |
|
|
|
# Keep window names as set (don't auto-rename to running command) |
|
setw -g allow-rename off |
|
setw -g automatic-rename off |
|
|
|
# Activity alerts |
|
set -g monitor-activity on |
|
set -g visual-activity off |
|
|
|
# ── Catppuccin status bar ───────────────────────────────────────────────────── |
|
# Theme + window style |
|
set -g @catppuccin_flavor "mocha" |
|
set -g @catppuccin_window_status_style "rounded" |
|
|
|
run ~/.config/tmux/plugins/catppuccin/tmux/catppuccin.tmux |
|
run ~/.config/tmux/ram_module.sh |
|
|
|
# Status bar layout |
|
set -g status-position top |
|
set -g status-interval 5 |
|
set -g status-left-length 100 |
|
set -g status-right-length 100 |
|
set -g status-left "" |
|
set -g status-right "#{E:@catppuccin_status_application}" |
|
set -agF status-right "#{E:@catppuccin_status_cpu}" |
|
set -ag status-right "#{E:@catppuccin_status_ram}" |
|
set -ag status-right "#{E:@catppuccin_status_session}" |
|
set -ag status-right "#{E:@catppuccin_status_uptime}" |
|
|
|
# Split panes with | and - (keep in current directory) |
|
bind "|" split-window -h -c "#{pane_current_path}" |
|
bind "-" split-window -v -c "#{pane_current_path}" |
|
|
|
# New window in current directory |
|
bind "c" new-window -c "#{pane_current_path}" |
|
|
|
# Reload config |
|
bind "r" source-file ~/.tmux.conf \; display "tmux.conf reloaded." |
|
|
|
# Ctrl+b Ctrl+b → send prefix to nested session |
|
bind "C-b" send-prefix |
|
|
|
# ── Copy mode — beginner-friendly, no trailing spaces ──────────────────────── |
|
# |
|
# THE PROBLEM with terminal-native selection over SSH+tmux: |
|
# tmux renders a fixed-width grid. Every row is padded with spaces to the |
|
# terminal width. Native drag-select copies those spaces and adds a hard |
|
# newline at every visual row-break — even when the actual content is one |
|
# long wrapped line. Multi-line pastes arrive full of junk whitespace. |
|
# |
|
# THE SOLUTION: use tmux's own copy mode, which understands the virtual grid |
|
# and copies clean text without trailing spaces or false line breaks. |
|
# |
|
# HOW TO USE: |
|
# prefix + [ enter copy mode (prefix is Ctrl+b by default) |
|
# Space start selection (or just move cursor first) |
|
# Enter copy selection → system clipboard, exit copy mode |
|
# prefix + ] paste |
|
# q or Escape exit copy mode without copying |
|
# Arrow keys move cursor one character at a time |
|
# Shift+Arrow (if terminal forwards it) — use arrow keys instead |
|
# word jump: b / w back / forward one word |
|
# line start: 0 go to start of line |
|
# line end: $ go to end of line |
|
# page up: Ctrl+u scroll up half a page |
|
# page down: Ctrl+d scroll down half a page |
|
# search: / search forward (n next, N previous) |
|
# |
|
# KEY SETTINGS: |
|
# set clipboard on tmux auto-copies to OSC 52 (system clipboard via SSH) |
|
# mode-keys emacs arrow keys + Ctrl shortcuts (beginner friendly) |
|
# switch to 'vi' if you prefer hjkl navigation |
|
|
|
# Use emacs-style keys in copy mode — arrow keys work, no modal confusion |
|
set -g mode-keys emacs |
|
|
|
# Strip trailing whitespace when copying — the main fix for the space problem |
|
set -g copy-command '' # reset; clipboard handled per-bind below |
|
|
|
# OSC 52 clipboard passthrough — copies directly to your LOCAL clipboard over SSH. |
|
# Requires terminal support: |
|
# ✓ iTerm2 (macOS) — works out of the box |
|
# ✓ Windows Terminal — works out of the box |
|
# ✓ WezTerm, Alacritty, Kitty — works out of the box |
|
# ✗ macOS Terminal.app — does NOT support OSC 52; use iTerm2 instead, |
|
# or pipe via pbcopy with the bind below. |
|
# |
|
# set-clipboard is a server option (-s), not a global window option (-g). |
|
set -s set-clipboard on |
|
|
|
# macOS Terminal.app fallback — pipe Enter-to-copy through pbcopy. |
|
# Uncomment if you use Terminal.app instead of iTerm2: |
|
# bind -T copy-mode Enter send -X copy-pipe-and-cancel "pbcopy" |
|
|
|
# Enter copy mode with prefix+[ (default) — also bind Page Up for scrollback |
|
bind -n PPage copy-mode -u # Page Up enters copy mode + scrolls |
|
bind [ copy-mode |
|
|
|
# Space → begin selection; Enter → copy clean text (no trailing spaces) + exit |
|
bind -T copy-mode Space send -X begin-selection |
|
bind -T copy-mode Enter send -X copy-selection-and-cancel \; run "tmux show-buffer | sed 's/[[:space:]]*$//' | tmux load-buffer -" |
|
|
|
# Escape or q → exit copy mode without copying |
|
bind -T copy-mode Escape send -X cancel |
|
bind -T copy-mode q send -X cancel |
|
|
|
# Double-click → select word (works when mouse is off if terminal sends events) |
|
# Arrow keys already work in emacs mode; these are just explicit for clarity |
|
bind -T copy-mode Up send -X cursor-up |
|
bind -T copy-mode Down send -X cursor-down |
|
bind -T copy-mode Left send -X cursor-left |
|
bind -T copy-mode Right send -X cursor-right |
|
|
|
# Ctrl+a / Ctrl+e — line start / end (emacs style, very natural) |
|
bind -T copy-mode C-a send -X start-of-line |
|
bind -T copy-mode C-e send -X end-of-line |
|
|
|
# Ctrl+w — copy word backwards (emacs style) |
|
bind -T copy-mode C-w send -X copy-selection-and-cancel |
|
|
|
# Mouse scroll enters copy mode automatically and scrolls back |
|
bind -T root WheelUpPane if-shell -F '#{||:#{pane_in_mode},#{mouse_button_flag}}' 'send -M' 'copy-mode -u' |
|
|
|
# ── Load tmux-cpu plugin (order matters) ──────────────────────────────────── |
|
# tmux-cpu runs AFTER status-right is defined — provides #{cpu_percentage} / |
|
# #{ram_percentage} tokens that catppuccin's cpu/ram modules wrap. |
|
run ~/.config/tmux/plugins/tmux-plugins/tmux-cpu/cpu.tmux |
|
TMUXEOF |
|
log "~/.tmux.conf written with sensible defaults." |
|
mark "~/.tmux.conf created" |
|
fi |
|
|
|
# ── Apply mouse off to any running tmux server immediately ─────────────────── |
|
# Config file changes only take effect after source-file or a new session. |
|
# If a server is already running, push the setting live so native copy/paste |
|
# works without requiring a tmux restart. |
|
if tmux list-sessions &>/dev/null 2>&1; then |
|
tmux set -g mouse off 2>/dev/null && \ |
|
log "Applied 'mouse off' to running tmux server." || true |
|
fi |
|
|
|
# ── SSH auto-attach to 'claude' session ───────────────────────────────────── |
|
# |
|
# Behaviour: |
|
# SSH login → automatically attach to (or create) a tmux session named 'claude'. |
|
# Exiting tmux exits the SSH session (exec replaces the shell). |
|
# |
|
# Bypass options (documented below and in CLAUDE.md): |
|
# 1. NOTMUX=1 ssh user@host (requires AcceptEnv in sshd — set up below) |
|
# 2. ssh -t user@host env NOTMUX=1 bash -l (no server config needed) |
|
# 3. ssh user@host <command> (non-interactive — skips .bashrc entirely) |
|
# |
|
BASHRC_TMUX_MARKER="# claude-tmux auto-session" |
|
if grep -q "$BASHRC_TMUX_MARKER" "$HOME/.bashrc" 2>/dev/null; then |
|
skip "tmux SSH auto-session hook already in ~/.bashrc." |
|
else |
|
cat >> "$HOME/.bashrc" << 'BASHEOF' |
|
|
|
# claude-tmux auto-session |
|
# Attach to (or create) the 'claude' tmux session on every interactive SSH login. |
|
# Bypass: set NOTMUX=1 before connecting, or connect with: |
|
# ssh -t user@host env NOTMUX=1 bash -l |
|
if [[ -n "${SSH_TTY:-}" ]] && \ |
|
[[ -z "${TMUX:-}" ]] && \ |
|
[[ "${NOTMUX:-0}" != "1" ]] && \ |
|
command -v tmux &>/dev/null; then |
|
exec tmux new-session -A -s claude |
|
fi |
|
BASHEOF |
|
log "SSH auto-attach hook added to ~/.bashrc." |
|
mark "tmux SSH auto-attach configured" |
|
fi |
|
|
|
# ── sshd: AcceptEnv NOTMUX — enables NOTMUX=1 ssh user@host bypass ────────── |
|
SSHD_DROP="/etc/ssh/sshd_config.d/99-claude-vm.conf" |
|
if [[ -f "$SSHD_DROP" ]] && grep -q "AcceptEnv.*NOTMUX" "$SSHD_DROP" 2>/dev/null; then |
|
skip "sshd AcceptEnv NOTMUX already configured." |
|
else |
|
echo "" |
|
echo -e " ${BL}To bypass tmux with just:${NC} ${BO}NOTMUX=1 ssh user@host${NC}" |
|
echo -e " ${BL}sshd must be told to accept the NOTMUX environment variable.${NC}" |
|
echo -e " ${BL}This adds a drop-in file: ${SSHD_DROP}${NC}" |
|
echo -e " ${BL}(Does not touch your main sshd_config)${NC}" |
|
|
|
if ask_yn "Configure sshd to accept NOTMUX env var?" Y; then |
|
echo "# Claude VM — allow client to pass NOTMUX=1 to skip tmux auto-attach" \ |
|
| sudo tee "$SSHD_DROP" > /dev/null |
|
echo "AcceptEnv NOTMUX" \ |
|
| sudo tee -a "$SSHD_DROP" > /dev/null |
|
|
|
# Validate and reload sshd |
|
if sudo sshd -t 2>/dev/null; then |
|
sudo systemctl reload ssh 2>/dev/null || sudo systemctl reload sshd 2>/dev/null || true |
|
log "sshd reloaded — NOTMUX accepted from client." |
|
mark "sshd AcceptEnv NOTMUX" |
|
echo "" |
|
echo -e " ${GR}To use the bypass, add this to your local ~/.ssh/config:${NC}" |
|
echo -e " ${BO} Host $(hostname)${NC}" |
|
echo -e " ${BO} SendEnv NOTMUX${NC}" |
|
echo "" |
|
echo -e " ${GR}Then bypass with:${NC} ${BO}NOTMUX=1 ssh $(whoami)@$(hostname)${NC}" |
|
echo -e " ${GR}Or without SSH config:${NC} ${BO}ssh -t $(whoami)@$(hostname) env NOTMUX=1 bash -l${NC}" |
|
else |
|
warn "sshd config validation failed — reverting." |
|
sudo rm -f "$SSHD_DROP" |
|
fi |
|
else |
|
info "Skipping sshd config. You can still bypass tmux with:" |
|
info " ssh -t user@host env NOTMUX=1 bash -l" |
|
fi |
|
fi |
|
|
|
# ============================================================================= |
|
# 14a · STARSHIP PROMPT |
|
# ============================================================================= |
|
section "14a · Starship prompt" |
|
|
|
if have starship; then |
|
STARSHIP_CURRENT=$(starship --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
skip "Starship ${STARSHIP_CURRENT} already installed." |
|
else |
|
info "Installing Starship via official installer…" |
|
curl -fsSL https://starship.rs/install.sh | sudo sh -s -- --yes |
|
mark "starship $(starship --version 2>/dev/null | head -1)" |
|
log "Starship installed." |
|
fi |
|
|
|
# ── Starship config (~/.config/starship.toml) ───────────────────────────────── |
|
STARSHIP_CFG="$HOME/.config/starship.toml" |
|
mkdir -p "$HOME/.config" |
|
if [[ -f "$STARSHIP_CFG" ]]; then |
|
skip "~/.config/starship.toml already exists — not overwriting." |
|
info "Remove it and re-run to get the default Claude VM starship config." |
|
else |
|
cat > "$STARSHIP_CFG" << 'STAREOF' |
|
# ── Claude VM — Starship prompt ─────────────────────────────────────────────── |
|
# Designed for dark terminals. Shows: git, runtimes, k8s context, last exit code. |
|
# Reference: https://starship.rs/config/ |
|
|
|
add_newline = true |
|
|
|
format = """ |
|
$os$username$hostname$directory$git_branch$git_state$git_status\ |
|
$nodejs$go$python$rust$container\ |
|
$kubernetes$cmd_duration |
|
$character""" |
|
|
|
[os] |
|
disabled = false |
|
style = "bold blue" |
|
|
|
[os.symbols] |
|
Ubuntu = " " |
|
Linux = " " |
|
Macos = " " |
|
|
|
[username] |
|
show_always = false # only show if root or SSH |
|
style_user = "bold green" |
|
style_root = "bold red" |
|
format = "[$user]($style) " |
|
|
|
[hostname] |
|
ssh_only = true |
|
style = "bold yellow" |
|
format = "[@$hostname]($style) " |
|
|
|
[directory] |
|
truncation_length = 4 |
|
truncate_to_repo = true |
|
style = "bold cyan" |
|
read_only = " " |
|
|
|
[git_branch] |
|
format = "[$symbol$branch(:$remote_branch)]($style) " |
|
symbol = " " |
|
style = "bold purple" |
|
|
|
[git_status] |
|
format = "([$all_status$ahead_behind]($style) )" |
|
style = "bold yellow" |
|
conflicted = "⚡" |
|
ahead = "⇡${count}" |
|
behind = "⇣${count}" |
|
diverged = "⇕⇡${ahead_count}⇣${behind_count}" |
|
untracked = "?" |
|
stashed = "≡" |
|
modified = "!" |
|
staged = "+" |
|
renamed = "»" |
|
deleted = "✘" |
|
|
|
[nodejs] |
|
format = "[ $version]($style) " |
|
style = "bold green" |
|
detect_files = [".nvmrc", ".node-version", "package.json"] |
|
|
|
[go] |
|
format = "[ $version]($style) " |
|
style = "bold cyan" |
|
detect_files = ["go.mod", ".go-version"] |
|
|
|
[python] |
|
format = "[ $version]($style) " |
|
style = "bold yellow" |
|
detect_files = ["pyproject.toml", "requirements.txt", ".python-version", "uv.lock"] |
|
|
|
[rust] |
|
format = "[ $version]($style) " |
|
style = "bold orange" |
|
|
|
[kubernetes] |
|
disabled = false |
|
format = "[$symbol$context( \\($namespace\\))]($style) " |
|
symbol = " " |
|
style = "bold blue" |
|
detect_files = ["k8s", "*.yaml", "*.yml", "Chart.yaml", "kustomization.yaml"] |
|
|
|
[kubernetes.context_aliases] |
|
# Add your cluster aliases here, e.g.: |
|
# "long-cluster-name" = "short" |
|
|
|
[cmd_duration] |
|
min_time = 2_000 # show duration for commands taking >2s |
|
format = "[$duration]($style) " |
|
style = "bold yellow" |
|
show_notifications = false |
|
|
|
[character] |
|
success_symbol = "[❯](bold green)" |
|
error_symbol = "[❯](bold red)" |
|
vimcmd_symbol = "[❮](bold green)" |
|
|
|
[container] |
|
format = "[$symbol \\[$name\\]]($style) " |
|
STAREOF |
|
log "~/.config/starship.toml written." |
|
mark "starship config created" |
|
fi |
|
|
|
# ============================================================================= |
|
# 11. AUTOMATIC UPDATES |
|
# ============================================================================= |
|
section "15 · Automatic security updates" |
|
|
|
sudo apt-get install -y unattended-upgrades apt-listchanges update-notifier-common -qq |
|
|
|
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 "Unattended upgrades configured (no auto-reboot)." |
|
|
|
# ============================================================================= |
|
# 12. REBOOT-REQUIRED NOTIFICATION |
|
# ============================================================================= |
|
section "16 · Reboot-required notification" |
|
|
|
sudo tee /etc/update-motd.d/98-reboot-required > /dev/null << 'EOF' |
|
#!/bin/sh |
|
if [ -f /run/reboot-required ]; then |
|
echo "" |
|
echo " ╔══════════════════════════════════════════════════════╗" |
|
echo " ║ *** SYSTEM REBOOT REQUIRED *** ║" |
|
echo " ║ Updates were applied that require a restart. ║" |
|
if [ -f /run/reboot-required.pkgs ]; then |
|
echo " ║ Packages: ║" |
|
while IFS= read -r pkg; do |
|
printf " ║ %-50s║\n" "• $pkg" |
|
done < /run/reboot-required.pkgs |
|
fi |
|
echo " ║ ║" |
|
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 notification configured (MOTD + all bash sessions)." |
|
|
|
# ============================================================================= |
|
# 13. SHELL INITIALISATION |
|
# ============================================================================= |
|
section "17 · 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/bin (Claude Code binary lives here) |
|
export PATH="$HOME/.local/bin:$PATH" |
|
|
|
# Editors |
|
export EDITOR="${EDITOR:-vim}" |
|
export VISUAL="${VISUAL:-vim}" |
|
export KUBE_EDITOR="${KUBE_EDITOR:-vim}" # used by k9s 'e' key and kubectl edit |
|
EOF |
|
|
|
BASHRC_TOOLS_MARKER="# claude-tools init" |
|
if ! grep -q "$BASHRC_TOOLS_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" |
|
export EDITOR="${EDITOR:-vim}" |
|
export VISUAL="${VISUAL:-vim}" |
|
export KUBE_EDITOR="${KUBE_EDITOR:-vim}" |
|
|
|
# ── fzf shell integration ──────────────────────────────────────────────────── |
|
# Ctrl+R → fuzzy history search |
|
# Ctrl+T → fuzzy file picker (inserts path into command line) |
|
# Alt+C → fuzzy cd |
|
if command -v fzf &>/dev/null; then |
|
eval "$(fzf --bash 2>/dev/null)" || { |
|
# Fallback for older fzf versions |
|
[ -f /usr/share/bash-completion/completions/fzf ] && \ |
|
source /usr/share/bash-completion/completions/fzf |
|
[ -f /usr/share/doc/fzf/examples/key-bindings.bash ] && \ |
|
source /usr/share/doc/fzf/examples/key-bindings.bash |
|
} |
|
# Use ripgrep as fzf backend (respects .gitignore, much faster) |
|
if command -v rg &>/dev/null; then |
|
export FZF_DEFAULT_COMMAND='rg --files --hidden --follow --glob "!.git"' |
|
export FZF_CTRL_T_COMMAND="$FZF_DEFAULT_COMMAND" |
|
fi |
|
export FZF_DEFAULT_OPTS=' |
|
--height 40% --layout=reverse --border --info=inline |
|
--color=bg+:#313244,bg:#1e1e2e,spinner:#f5e0dc,hl:#f38ba8 |
|
--color=fg:#cdd6f4,header:#f38ba8,info:#cba6f7,pointer:#f5e0dc |
|
--color=marker:#b4befe,fg+:#cdd6f4,prompt:#cba6f7,hl+:#f38ba8 |
|
--bind "ctrl-/:toggle-preview" |
|
' |
|
fi |
|
|
|
# ── direnv ──────────────────────────────────────────────────────────────────── |
|
# Auto-loads/unloads .envrc when entering/leaving project directories. |
|
# Run `direnv allow` in a project to enable it. |
|
command -v direnv &>/dev/null && eval "$(direnv hook bash)" |
|
|
|
# ── Starship prompt ─────────────────────────────────────────────────────────── |
|
command -v starship &>/dev/null && eval "$(starship init bash)" |
|
|
|
# ── Smart aliases ───────────────────────────────────────────────────────────── |
|
# bat (syntax-highlighted cat) |
|
command -v bat &>/dev/null && alias cat='bat --paging=never' |
|
command -v batcat &>/dev/null && ! command -v bat &>/dev/null && \ |
|
alias cat='batcat --paging=never' |
|
|
|
# eza (modern ls replacement — shows git status, icons, tree) |
|
if command -v eza &>/dev/null; then |
|
alias ls='eza --icons --group-directories-first' |
|
alias ll='eza --icons --group-directories-first -lh --git' |
|
alias la='eza --icons --group-directories-first -lha --git' |
|
alias lt='eza --icons --tree --level=2 --group-directories-first' |
|
alias ltt='eza --icons --tree --level=3 --group-directories-first' |
|
fi |
|
|
|
# fd (modern find replacement) |
|
command -v fd &>/dev/null || (command -v fdfind &>/dev/null && alias fd='fdfind') |
|
|
|
# ripgrep shorthands |
|
command -v rg &>/dev/null && alias grep='rg' |
|
|
|
# lazygit |
|
command -v lazygit &>/dev/null && alias lg='lazygit' |
|
|
|
# kubectl shorthands (k → kubectl, kns/kctx already separate binaries) |
|
command -v kubectl &>/dev/null && { |
|
alias k='kubectl' |
|
complete -o default -F __start_kubectl k 2>/dev/null || true |
|
} |
|
|
|
# yq / jq combination alias — pretty-print YAML |
|
command -v yq &>/dev/null && alias yqp='yq --prettyPrint' |
|
BASHEOF |
|
log "Shell init written to ~/.bashrc." |
|
else |
|
skip "Shell init already in ~/.bashrc." |
|
fi |
|
|
|
# ── .claude/settings.json — first-run defaults ──────────────────────────────── |
|
mkdir -p "$HOME/.claude" |
|
CLAUDE_SETTINGS="$HOME/.claude/settings.json" |
|
if [[ -f "$CLAUDE_SETTINGS" ]]; then |
|
skip "~/.claude/settings.json already exists — not overwriting." |
|
else |
|
cat > "$CLAUDE_SETTINGS" << 'SETTEOF' |
|
{ |
|
"permissions": { |
|
"allow": [ |
|
"Bash(*)", |
|
"Read(*)", |
|
"Write(*)", |
|
"Edit(*)", |
|
"MultiEdit(*)" |
|
], |
|
"deny": [] |
|
}, |
|
"env": { |
|
"EDITOR": "vim", |
|
"KUBE_EDITOR": "vim" |
|
} |
|
} |
|
SETTEOF |
|
log "~/.claude/settings.json written (default permissions, no first-run dialogs)." |
|
mark "~/.claude/settings.json created" |
|
fi |
|
|
|
|
|
# ============================================================================= |
|
# 14. GLOBAL CLAUDE.md |
|
# ============================================================================= |
|
section "18 · 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") |
|
PYTHONVER=$(uv python list 2>/dev/null \ |
|
| grep -oP 'cpython-\K[0-9.]+' | sort -V | tail -1 || echo "unknown") |
|
DOCKERVER=$(docker --version 2>/dev/null \ |
|
| awk '{print $3}' | tr -d ',' || echo "unknown") |
|
COMPOSEVER=$(docker compose version 2>/dev/null || echo "unknown") |
|
KUBECTLVER=$(kubectl version --client --short 2>/dev/null \ |
|
| grep -oP 'v[0-9.]+' | head -1 || \ |
|
kubectl version --client 2>/dev/null | grep -oP 'v[0-9.]+' | head -1 || echo "unknown") |
|
HELMVER=$(helm version --short 2>/dev/null | grep -oP 'v[0-9.]+' | head -1 || echo "unknown") |
|
K9SVER=$(k9s version --short 2>/dev/null | grep -oP 'v[0-9.]+' | head -1 || echo "unknown") |
|
CLAUDEVER=$(claude --version 2>/dev/null || echo "run: claude auth") |
|
SUDOERS_F="/etc/sudoers.d/claude-$(whoami)" |
|
TMUX_VER=$(tmux -V 2>/dev/null || echo "tmux") |
|
YQVER=$(yq --version 2>/dev/null | grep -oP 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
DELTAVER=$(delta --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
STARSHIPVER=$(starship --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
LAZYGITVER=$(lazygit --version 2>/dev/null | grep -oP 'version=[0-9.]+' | grep -oP '[0-9.]+' || echo "unknown") |
|
GH_VER_DOC=$(gh --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
GLAB_VER_DOC=$(glab --version 2>/dev/null | grep -oP 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
PSQL_VER_DOC=$(psql --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
MARIADB_VER_DOC=$(mariadb --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
TF_VER_DOC=$(terraform version -json 2>/dev/null | jq -r '.terraform_version' 2>/dev/null || terraform version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
ANSIBLE_VER_DOC=$(ansible --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
NODE_VER_DOC=$(node --version 2>/dev/null || echo "unknown") |
|
GEMINI_VER_DOC=$($HOME/.local/bin/gemini --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
CODEX_VER_DOC=$($HOME/.local/bin/codex --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
VIBE_VER_DOC=$($HOME/.local/bin/vibe --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
KUBECTXVER=$(kubectx --version 2>/dev/null | grep -oP 'v?[0-9]+\.[0-9]+\.[0-9]+' || echo "unknown") |
|
STERNVER=$(stern --version 2>/dev/null | grep -oP 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "unknown") |
|
|
|
cat > "$HOME/.claude/CLAUDE.md" << EOF |
|
# Claude Agent Environment — $(hostname) |
|
|
|
Generated: $(date -u '+%Y-%m-%d %H:%M UTC') by setup-claude-vm.sh |
|
Re-run the script to update versions and regenerate this file. |
|
|
|
--- |
|
|
|
## System |
|
- OS: Ubuntu 24.04 LTS (Noble Numbat) |
|
- Kernel: $(uname -r) |
|
- Architecture: $(uname -m) |
|
- User: $(whoami) |
|
- Home: $HOME |
|
- Sudo: passwordless (${SUDOERS_F}) — revert: \`sudo rm ${SUDOERS_F}\` |
|
|
|
--- |
|
|
|
## Claude Code |
|
- Version: ${CLAUDEVER} |
|
- Binary: ~/.local/bin/claude (Bun native, auto-updates on launch) |
|
- Auth: \`claude auth\` |
|
- Usage: \`claude\` (interactive) · \`claude -p "prompt"\` (one-shot) |
|
- Docs: https://docs.claude.com/en/docs/claude-code/overview |
|
- This file (global CLAUDE.md) is read before any project-level CLAUDE.md |
|
|
|
--- |
|
|
|
## Runtimes |
|
|
|
### Go |
|
| Property | Value | |
|
|----------------|-------| |
|
| Version mgr | GVM (~/.gvm/) | |
|
| Active version | ${GOVERSION} | |
|
| Update GVM | \`cd ~/.gvm && git pull\` | |
|
| 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) | |
|
|
|
**Version pin files supported:** \`go.mod\` (directive) · \`.go-version\` |
|
Running setup-claude-vm.sh from a project dir will offer to install the pinned version. |
|
|
|
### Node.js |
|
| Property | Value | |
|
|----------------|-------| |
|
| Version mgr | NVM (~/.nvm/) | |
|
| Active version | ${NODEVERSION} / npm ${NPMVERSION} | |
|
| Update NVM | re-run setup-claude-vm.sh | |
|
| Switch version | \`nvm use <version>\` | |
|
| List installed | \`nvm list\` | |
|
| Install new | \`nvm install <version>\` | |
|
| Migrate globals| \`nvm install <v> --reinstall-packages-from=default\` | |
|
|
|
**Version pin files supported:** \`.nvmrc\` · \`.node-version\` |
|
NVM automatically uses the pinned version when you \`cd\` into a project. |
|
|
|
### Python |
|
| Property | Value | |
|
|----------------|-------| |
|
| Manager | uv (~/.local/bin/uv) | |
|
| uv version | ${UVVERSION} | |
|
| Python version | CPython ${PYTHONVER} | |
|
| Update uv | \`uv self update\` | |
|
|
|
Commands: |
|
\`\`\`bash |
|
uv venv .venv # create virtualenv |
|
source .venv/bin/activate # activate |
|
uv pip install <pkg> # install package |
|
uv run script.py # run with inline deps |
|
uv python install 3.12 # install specific Python |
|
\`\`\` |
|
**⚠ Never use pip or modify /usr/bin/python3 — it belongs to apt.** |
|
|
|
### Docker |
|
| Property | Value | |
|
|------------|-------| |
|
| Version | ${DOCKERVER} | |
|
| Compose | ${COMPOSEVER} | |
|
| Command | \`docker compose\` (plugin — NOT docker-compose) | |
|
| Group | $(whoami) is in the \`docker\` group (needs re-login to activate) | |
|
|
|
\`\`\`bash |
|
docker compose up -d |
|
docker buildx build --platform linux/amd64,linux/arm64 . |
|
sudo systemctl start|stop|status docker |
|
\`\`\` |
|
|
|
### kubectl |
|
- Version: $(kubectl version --client --short 2>/dev/null || kubectl version --client 2>/dev/null || echo "unknown") |
|
- Repo: /etc/apt/sources.list.d/kubernetes.list (${K8S_MINOR} channel) |
|
- Updates: automatic via unattended-upgrades (patch releases within ${K8S_MINOR}) |
|
- New minor version: re-run setup-claude-vm.sh to update the repo channel |
|
|
|
\`\`\`bash |
|
kubectl version --client |
|
kubectl get nodes |
|
kubectl config get-contexts |
|
kubectl config use-context <name> |
|
\`\`\` |
|
|
|
|
|
### Helm |
|
- Version: ${HELMVER} |
|
- Repo: /etc/apt/sources.list.d/helm-stable-debian.list (Buildkite) |
|
- Updates: automatic via unattended-upgrades |
|
|
|
\`\`\`bash |
|
helm repo add bitnami https://charts.bitnami.com/bitnami && helm repo update |
|
helm search repo <chart> |
|
helm install <release> <repo>/<chart> -n <namespace> |
|
helm list -A |
|
helm upgrade <release> <repo>/<chart> |
|
helm uninstall <release> |
|
\`\`\` |
|
|
|
### k9s |
|
- Version: ${K9SVER} |
|
- Install: GitHub Releases .deb (no apt repo — upstream declined to publish one) |
|
- Updates: re-run setup-claude-vm.sh (unattended-upgrades CANNOT update k9s) |
|
|
|
\`\`\`bash |
|
k9s # open TUI — uses current kubeconfig context |
|
k9s --context <n> # open in a specific context |
|
k9s -n <namespace> # scope to a namespace |
|
k9s --readonly # disable all write operations |
|
\`\`\` |
|
|
|
k9s key bindings: \`:\` command mode (pods/nodes/deploy…) · \`/\` filter · \`d\` describe · \`l\` logs · \`e\` edit · \`s\` shell · \`ctrl+d\` delete · \`?\` help |
|
--- |
|
|
|
## Terminal Multiplexers |
|
${TMUX_VER} is configured with a persistent \`claude\` session. |
|
|
|
| Tool | Create session | Attach | List | |
|
|--------|--------------------|----------------------|---------------| |
|
| tmux | \`tmux new -s name\` | \`tmux attach -t name\`| \`tmux ls\` | |
|
| screen | \`screen -S name\` | \`screen -r name\` | \`screen -ls\` | |
|
|
|
**SSH auto-attach:** Every SSH login automatically attaches to the \`claude\` tmux |
|
session (created if missing). Exit tmux to close the SSH session. |
|
|
|
**Bypass tmux on SSH:** |
|
\`\`\`bash |
|
# Option 1 — if sshd AcceptEnv NOTMUX is configured (server-side): |
|
NOTMUX=1 ssh $(whoami)@$(hostname) # add SendEnv NOTMUX to ~/.ssh/config first |
|
|
|
# Option 2 — no server config needed (always works): |
|
ssh -t $(whoami)@$(hostname) env NOTMUX=1 bash -l |
|
|
|
# Option 3 — run a direct command (non-interactive, skips .bashrc): |
|
ssh $(whoami)@$(hostname) <command> |
|
\`\`\` |
|
|
|
**Copying text — why native drag-select adds spaces and fake newlines:** |
|
tmux renders a fixed-width grid. Every row is padded with spaces to the terminal |
|
width. Native terminal drag-select copies those padding spaces and adds a hard |
|
newline at every visual row — even for a single long wrapped command. |
|
Use tmux copy mode instead, which understands the grid and copies clean text. |
|
|
|
**Copy mode — the right way to copy text from tmux:** |
|
|
|
| Key | Action | |
|
|------------------|--------| |
|
| \`prefix + [\` | Enter copy mode (prefix = Ctrl+b) | |
|
| \`Page Up\` | Enter copy mode and scroll up (shortcut) | |
|
| Arrow keys | Move cursor | |
|
| \`Space\` | Start selection | |
|
| \`Enter\` | Copy selection to clipboard, exit copy mode | |
|
| \`Ctrl+a\` | Jump to start of line | |
|
| \`Ctrl+e\` | Jump to end of line | |
|
| \`b\` / \`f\` | Back / forward one word (Ctrl+b / Ctrl+f also work) | |
|
| \`Ctrl+u\` | Scroll up half page | |
|
| \`Ctrl+d\` | Scroll down half page | |
|
| \`/\` | Search forward (\`n\` next, \`N\` previous) | |
|
| \`q\` or Escape | Exit copy mode without copying | |
|
| \`prefix + ]\` | Paste from tmux buffer | |
|
|
|
Clipboard integration: \`set -s set-clipboard on\` uses OSC 52 to send copied |
|
text directly to your local clipboard over SSH. Works with iTerm2, Windows |
|
Terminal, WezTerm, Alacritty, and Kitty. **macOS Terminal.app does not support |
|
OSC 52** — use iTerm2, or uncomment the \`pbcopy\` bind in \`~/.tmux.conf\`. |
|
|
|
--- |
|
|
|
## Git (global config) |
|
\`\`\` |
|
user.name = $(git config --global user.name 2>/dev/null || echo "(not set)") |
|
user.email = $(git config --global user.email 2>/dev/null || echo "(not set)") |
|
init.defaultBranch = $(git config --global init.defaultBranch 2>/dev/null || echo "main") |
|
pull.rebase = $(git config --global pull.rebase 2>/dev/null || echo "false") |
|
push.autoSetupRemote = $(git config --global push.autoSetupRemote 2>/dev/null || echo "true") |
|
diff.algorithm = $(git config --global diff.algorithm 2>/dev/null || echo "histogram") |
|
rerere.enabled = $(git config --global rerere.enabled 2>/dev/null || echo "true") |
|
\`\`\` |
|
|
|
--- |
|
|
|
## Shell initialisation |
|
| File | Purpose | |
|
|-----------------------------------|---------| |
|
| /etc/profile.d/claude-tools.sh | login shells (SSH, su -l) | |
|
| ~/.bashrc | interactive bash, tmux panes, screen | |
|
|
|
Non-interactive scripts must explicitly: |
|
\`\`\`bash |
|
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" |
|
\`\`\` |
|
|
|
--- |
|
|
|
## Maintenance |
|
\`\`\`bash |
|
# Update everything (run from a project dir to also handle version pins) |
|
bash ~/setup-claude-vm.sh |
|
|
|
# Manual per-tool updates |
|
cd ~/.gvm && git pull # GVM itself |
|
gvm install go<version> --binary # new Go version |
|
nvm install --lts # newer Node LTS |
|
uv self update # uv |
|
claude update # Claude Code |
|
|
|
# Pending system updates |
|
sudo apt list --upgradable |
|
sudo apt upgrade |
|
|
|
# Reboot status |
|
cat /run/reboot-required.pkgs # which packages need it |
|
sudo reboot |
|
\`\`\` |
|
|
|
--- |
|
|
|
## Bash Tab Completion |
|
\`claude\` has full tab completion installed at \`/etc/bash_completion.d/claude\`. |
|
Active in all new shells (login and non-login via ~/.bashrc). |
|
|
|
| What completes | How to trigger | |
|
|----------------------------|----------------| |
|
| CLI flags | \`claude --<TAB>\` | |
|
| \`--model\` values | \`claude --model <TAB>\` | |
|
| \`--output-format\` values | \`claude --output-format <TAB>\` | |
|
| \`--permission-mode\` values | \`claude --permission-mode <TAB>\` | |
|
| File paths (prompt files) | \`claude --system-prompt-file <TAB>\` | |
|
| Slash commands | \`claude /<TAB>\` | |
|
| Custom commands | \`claude /my-<TAB>\` (from ~/.claude/commands/) | |
|
| MCP subcommands | \`claude mcp <TAB>\` | |
|
|
|
Update completion: re-run setup-claude-vm.sh. |
|
|
|
--- |
|
|
|
## Agent session notes |
|
- Python: always use \`uv\`, never system pip |
|
- Go: check \`go.mod\` version requirement; switch with \`gvm use\` |
|
- NVM/GVM: not available in non-interactive shells without explicit sourcing |
|
- Docker: needs re-login for group membership (or \`newgrp docker\` for current shell) |
|
- Long tasks: run inside the \`claude\` tmux session — survives disconnection |
|
- Tool check: \`which <tool>\` or \`command -v <tool>\` |
|
|
|
--- |
|
|
|
## Developer CLI Tools |
|
|
|
### git-delta (${DELTAVER}) — syntax-highlighted git diffs |
|
- Configured as git pager automatically |
|
- Side-by-side diffs: \`git diff\` or \`git show\` |
|
- Navigate diff sections: \`n\` / \`N\` |
|
- Override for one command: \`GIT_PAGER=less git log\` |
|
|
|
### fzf — fuzzy finder |
|
| Keybinding | Action | |
|
|-------------|--------| |
|
| \`Ctrl+R\` | Fuzzy search shell history | |
|
| \`Ctrl+T\` | Fuzzy file picker (insert path into command) | |
|
| \`Alt+C\` | Fuzzy \`cd\` | |
|
| \`Ctrl+/\` | Toggle preview pane | |
|
|
|
In scripts: \`find . | fzf\`, \`kubectl get pods | fzf\` |
|
|
|
### yq (${YQVER}) — YAML processor (like jq for YAML) |
|
- \`yq '.key' file.yaml\` — query |
|
- \`yq -i '.key = "value"' file.yaml\` — in-place edit |
|
- \`yq eval-all 'select(.kind == "Deployment")' *.yaml\` — filter k8s manifests |
|
- \`cat file.json | yq -P\` — convert JSON → YAML |
|
|
|
### ripgrep (rg) — fast grep (default \`grep\` alias) |
|
- \`rg pattern\` — search current dir recursively (respects .gitignore) |
|
- \`rg pattern --type go\` — filter by file type |
|
- \`rg -l pattern\` — list matching files only |
|
|
|
### fd — fast find (faster than \`find\`) |
|
- \`fd pattern\` — find files by name |
|
- \`fd -e go\` — find by extension |
|
- \`fd -t d\` — find directories only |
|
|
|
### bat — syntax-highlighted cat (default \`cat\` alias) |
|
- \`bat file.go\` — view with syntax highlighting + line numbers |
|
- \`bat --paging=always file\` — with paging |
|
- \`bat -A file\` — show non-printable characters |
|
|
|
### eza — modern ls (default \`ls\` alias) |
|
| Alias | Command | |
|
|-------|---------| |
|
| \`ls\` | \`eza --icons --group-directories-first\` | |
|
| \`ll\` | long listing with git status | |
|
| \`la\` | long listing including hidden files | |
|
| \`lt\` | tree view (2 levels) | |
|
| \`ltt\` | tree view (3 levels) | |
|
|
|
### lazygit (${LAZYGITVER}) — terminal UI for git (alias: \`lg\`) |
|
| Key | Action | |
|
|-----------|--------| |
|
| Arrow keys | Navigate | |
|
| \`space\` | Stage/unstage file | |
|
| \`c\` | Commit | |
|
| \`p\` | Push | |
|
| \`P\` | Pull | |
|
| \`b\` | Branches panel | |
|
| \`?\` | Help | |
|
|
|
### gh (${GH_VER_DOC}) — GitHub CLI |
|
Authenticate: \`gh auth login\` |
|
\`\`\`bash |
|
gh repo clone owner/repo # clone without copying URLs |
|
gh pr create # create pull request from current branch |
|
gh pr list # list open PRs |
|
gh pr checkout <number> # check out a PR locally |
|
gh issue create -t "Title" # create issue |
|
gh issue list # list issues |
|
gh run list # list CI workflow runs |
|
gh run watch # watch a running workflow |
|
gh release create v1.0.0 # create a release |
|
gh gist create file.txt # create a gist |
|
\`\`\` |
|
For CI/CD automation: set \`GITHUB_TOKEN\` environment variable — \`gh\` picks it up automatically. |
|
|
|
### glab (${GLAB_VER_DOC}) — GitLab CLI |
|
Authenticate: \`glab auth login\` (needs a personal access token with \`api\` + \`write_repository\` scopes) |
|
\`\`\`bash |
|
glab repo clone owner/repo # clone a repo |
|
glab mr create # create merge request from current branch |
|
glab mr list # list open merge requests |
|
glab mr checkout <number> # check out an MR locally |
|
glab issue create -t "Title" # create issue |
|
glab issue list # list issues |
|
glab ci view # view CI/CD pipeline status |
|
glab ci retry # retry failed pipeline jobs |
|
glab release create v1.0.0 # create a release |
|
\`\`\` |
|
For self-managed instances: \`glab auth login --hostname git.example.com\` |
|
For CI/CD automation: set \`GITLAB_TOKEN\` environment variable. |
|
|
|
|
|
## Database Clients |
|
|
|
### psql (${PSQL_VER_DOC}) — PostgreSQL client |
|
Connect: \`psql -h <host> -p <port> -U <user> -d <database>\` |
|
\`\`\`bash |
|
psql -h db.example.com -U app_user -d mydb # interactive shell |
|
psql -h host -U user -d db -c "SELECT version();" # one-liner |
|
psql -h host -U user -d db -f script.sql # run SQL file |
|
\l # list databases (inside psql) |
|
\c dbname # switch database |
|
\dt # list tables |
|
\d tablename # describe table |
|
\q # quit |
|
\`\`\` |
|
Connection via URL: \`psql "postgresql://user:pass@host:5432/dbname"\` |
|
For non-interactive scripts set \`PGPASSWORD\` env var or use a \`~/.pgpass\` file. |
|
|
|
### mariadb (${MARIADB_VER_DOC}) — MariaDB / MySQL client |
|
Connect: \`mariadb -h <host> -P <port> -u <user> -p<password> <database>\` |
|
\`\`\`bash |
|
mariadb -h db.example.com -u app_user -p mydb # interactive shell (prompts for password) |
|
mariadb -h host -u user -p -e "SELECT VERSION();" # one-liner |
|
mariadb -h host -u user -p dbname < script.sql # run SQL file |
|
mysqldump -h host -u user -p dbname > dump.sql # dump database (mysqldump also available) |
|
SHOW DATABASES; # list databases (inside mariadb shell) |
|
USE dbname; # switch database |
|
SHOW TABLES; # list tables |
|
DESCRIBE tablename; # describe table |
|
exit # quit |
|
\`\`\` |
|
Note: \`-p\` with no space before the password is intentional for scripting (\`-pmypassword\`). |
|
For non-interactive scripts set \`MYSQL_PWD\` env var (convenient but visible in process list — prefer \`~/.my.cnf\` with \`[client]\` section for production). |
|
|
|
## Infrastructure Tools |
|
|
|
### terraform (${TF_VER_DOC}) — HashiCorp Terraform |
|
\`\`\`bash |
|
terraform init # initialise working directory (download providers) |
|
terraform plan # show execution plan (dry run) |
|
terraform apply # apply changes |
|
terraform apply -auto-approve # apply without confirmation prompt |
|
terraform destroy # tear down managed infrastructure |
|
terraform output # show output values |
|
terraform state list # list resources in state |
|
terraform fmt # format .tf files |
|
terraform validate # validate configuration syntax |
|
\`\`\` |
|
Credentials: set provider-specific env vars (e.g. \`AWS_ACCESS_KEY_ID\`, \`ARM_CLIENT_ID\`). |
|
State: by default local \`terraform.tfstate\` — use remote backends (S3, GCS, Terraform Cloud) for shared environments. |
|
|
|
### ansible (${ANSIBLE_VER_DOC}) — Automation / configuration management |
|
\`\`\`bash |
|
ansible all -i inventory -m ping # connectivity check |
|
ansible all -i inventory -m command -a "uptime" # ad-hoc command |
|
ansible-playbook -i inventory playbook.yml # run a playbook |
|
ansible-playbook -i inventory playbook.yml --check # dry run |
|
ansible-playbook -i inventory playbook.yml -l host # limit to specific host |
|
ansible-playbook playbook.yml -e "var=value" # pass extra variables |
|
ansible-vault encrypt secrets.yml # encrypt secrets file |
|
ansible-vault decrypt secrets.yml # decrypt secrets file |
|
ansible-vault edit secrets.yml # edit encrypted file |
|
ansible-doc -l # list all modules |
|
ansible-doc <module> # module documentation |
|
\`\`\` |
|
Inventory: pass a file with \`-i hosts.ini\` or use a dynamic inventory script. |
|
SSH: Ansible uses your default SSH key (\`~/.ssh/id_*\`); override with \`--private-key\`. |
|
|
|
### direnv — per-project environment variables |
|
1. Create \`.envrc\` in project root: \`echo 'export API_KEY=xxx' > .envrc\` |
|
2. Allow it: \`direnv allow\` |
|
3. Variables load/unload automatically on \`cd\` |
|
- Never commit \`.envrc\` with secrets — add to \`.gitignore\` |
|
|
|
--- |
|
|
|
## AI Coding Tools |
|
|
|
Node.js ${NODE_VER_DOC} — installed from NodeSource LTS repo. |
|
npm globals live in \`~/.local/lib/node_modules\` with binaries symlinked to \`~/.local/bin\`. |
|
Tools are updated to \`@latest\` on every script re-run. |
|
|
|
### gemini (${GEMINI_VER_DOC}) — Google Gemini CLI |
|
Authenticate: \`gemini auth login\` or set \`GEMINI_API_KEY\` env var. |
|
\`\`\`bash |
|
gemini # interactive chat |
|
gemini -p "explain this code" # one-shot prompt |
|
gemini run script.py # run with AI assistance |
|
\`\`\` |
|
Docs: https://github.com/google-gemini/gemini-cli |
|
|
|
### codex (${CODEX_VER_DOC}) — OpenAI Codex CLI |
|
Authenticate: set \`OPENAI_API_KEY\` env var. |
|
\`\`\`bash |
|
codex # interactive mode |
|
codex "refactor this function" # one-shot prompt |
|
codex --approval-mode full-auto # autonomous mode (no confirmations) |
|
\`\`\` |
|
Docs: https://github.com/openai/codex |
|
|
|
### vibe (${VIBE_VER_DOC}) — Mistral Vibe CLI |
|
Authenticate: \`vibe --setup\` or set \`MISTRAL_API_KEY\` in \`~/.vibe/.env\`. |
|
\`\`\`bash |
|
vibe # interactive session |
|
vibe --prompt "fix the auth bug" # one-shot prompt |
|
vibe --agent plan # read-only planning mode (no edits) |
|
vibe --agent auto-approve # autonomous mode (no confirmations) |
|
vibe --max-price 0.50 # cap session cost at $0.50 |
|
\`\`\` |
|
Config: \`~/.vibe/config.toml\` — models, tool permissions, themes. |
|
Telemetry off: set \`enable_telemetry = false\` in config.toml. |
|
Docs: https://docs.mistral.ai/mistral-vibe/introduction |
|
|
|
## Kubernetes Companion Tools |
|
|
|
### kubectx (${KUBECTXVER}) — switch cluster contexts |
|
- \`kubectx\` — list contexts |
|
- \`kubectx my-cluster\` — switch to cluster |
|
- \`kubectx -\` — switch to previous context |
|
- With fzf: just run \`kubectx\` for interactive selection |
|
|
|
### kubens — switch namespaces |
|
- \`kubens\` — list namespaces (interactive with fzf) |
|
- \`kubens my-namespace\` — switch namespace |
|
- \`kubens -\` — switch to previous namespace |
|
|
|
### stern (${STERNVER}) — multi-pod log tailing |
|
- \`stern pod-name-prefix\` — tail all matching pods |
|
- \`stern . -n my-namespace\` — all pods in namespace |
|
- \`stern app=myapp --selector\` — by label selector |
|
- \`stern pod --container sidecar\` — specific container |
|
- \`stern pod --since 15m\` — last 15 minutes only |
|
- \`stern pod --output json | jq '.'\` — structured output |
|
|
|
--- |
|
|
|
## Starship Prompt (${STARSHIPVER}) |
|
- Config: \`~/.config/starship.toml\` |
|
- Shows: git branch + status, Node/Go/Python versions (per-project), k8s context, command duration (>2s) |
|
- Customize: https://starship.rs/config/ |
|
- Reload: open new shell or \`exec bash\` |
|
EOF |
|
|
|
log "CLAUDE.md written → $HOME/.claude/CLAUDE.md" |
|
|
|
# Clean up temp copy if re-exec'd by root |
|
if [[ -n "${_CLAUDE_SETUP_USER:-}" && "$0" == /tmp/setup-claude-vm-*.sh ]]; then |
|
rm -f "$0" |
|
fi |
|
|
|
# ============================================================================= |
|
# SUMMARY |
|
# ============================================================================= |
|
GOVERSION_SHORT=$(go version 2>/dev/null | awk '{print $3}' || echo "?") |
|
NODE_SHORT=$(node --version 2>/dev/null || echo "?") |
|
PY_SHORT=$(uv python list 2>/dev/null \ |
|
| grep -oP 'cpython-\K[0-9.]+' | sort -V | tail -1 || echo "?") |
|
DOCKER_SHORT=$(docker --version 2>/dev/null | awk '{print $3}' | tr -d ',' || echo "?") |
|
CLAUDE_SHORT=$(claude --version 2>/dev/null || echo "installed") |
|
DELTA_SHORT=$(delta --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
LAZYGIT_SHORT=$(lazygit --version 2>/dev/null | grep -oP 'version=[0-9.]+' | grep -oP '[0-9.]+' || echo "?") |
|
GH_SHORT=$(gh --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
GLAB_SHORT=$(glab --version 2>/dev/null | grep -oP 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
PSQL_SHORT=$(psql --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
MARIADB_SHORT=$(mariadb --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
TF_SHORT=$(terraform version -json 2>/dev/null | jq -r '.terraform_version' 2>/dev/null || terraform version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
ANSIBLE_SHORT=$(ansible --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
NODE_SHORT=$(node --version 2>/dev/null || echo "?") |
|
GEMINI_SHORT=$($HOME/.local/bin/gemini --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
CODEX_SHORT=$($HOME/.local/bin/codex --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
VIBE_SHORT=$($HOME/.local/bin/vibe --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
STARSHIP_SHORT=$(starship --version 2>/dev/null | grep -oP '[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
YQ_SHORT=$(yq --version 2>/dev/null | grep -oP 'v?[0-9]+\.[0-9]+\.[0-9]+' | head -1 || echo "?") |
|
|
|
echo "" |
|
echo -e "${BO}${GR}" |
|
echo " ╔═══════════════════════════════════════════════════════╗" |
|
echo " ║ All done! ║" |
|
echo " ╚═══════════════════════════════════════════════════════╝" |
|
echo -e "${NC}" |
|
|
|
printf " ${GR}✓${NC} %-28s %s\n" "User:" "$(whoami)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "Node.js:" "${NODE_SHORT} (NVM)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "Go:" "${GOVERSION_SHORT} (GVM)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "Python:" "CPython ${PY_SHORT} (uv)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "Docker:" "${DOCKER_SHORT}" |
|
printf " ${GR}✓${NC} %-28s %s\n" "kubectl:" "${KUBECTLVER} (k8s apt repo ${K8S_MINOR})" |
|
printf " ${GR}✓${NC} %-28s %s\n" "Helm:" "${HELMVER} (Buildkite apt repo)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "k9s:" "${K9SVER} (update: re-run script)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "yq:" "${YQ_SHORT}" |
|
printf " ${GR}✓${NC} %-28s %s\n" "kubectx / kubens:" "${KUBECTXVER}" |
|
printf " ${GR}✓${NC} %-28s %s\n" "stern:" "${STERNVER}" |
|
printf " ${GR}✓${NC} %-28s %s\n" "git-delta:" "${DELTA_SHORT}" |
|
printf " ${GR}✓${NC} %-28s %s\n" "lazygit:" "${LAZYGIT_SHORT} (alias: lg)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "gh:" "${GH_SHORT} (GitHub CLI — auto-updates via apt)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "glab:" "${GLAB_SHORT} (GitLab CLI — update: re-run script)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "psql:" "${PSQL_SHORT} (PostgreSQL client — auto-updates via apt)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "mariadb:" "${MARIADB_SHORT} (MariaDB client — auto-updates via apt)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "terraform:" "${TF_SHORT} (HashiCorp apt repo — auto-updates via apt)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "ansible:" "${ANSIBLE_SHORT} (ansible/ansible PPA — auto-updates via apt)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "node:" "${NODE_SHORT} (NodeSource LTS — auto-updates via apt)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "gemini:" "${GEMINI_SHORT} (Google Gemini CLI — updated each run)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "codex:" "${CODEX_SHORT} (OpenAI Codex CLI — updated each run)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "vibe:" "${VIBE_SHORT} (Mistral Vibe CLI — skip if present)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "fzf:" "$(fzf --version 2>/dev/null | head -1 || echo '?') (Ctrl+R · Ctrl+T · Alt+C)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "ripgrep:" "$(rg --version 2>/dev/null | head -1 | grep -oP '[0-9.]+' | head -1 || echo '?')" |
|
printf " ${GR}✓${NC} %-28s %s\n" "bat:" "$(bat --version 2>/dev/null | grep -oP '[0-9.]+' | head -1 || batcat --version 2>/dev/null | grep -oP '[0-9.]+' | head -1 || echo '?')" |
|
printf " ${GR}✓${NC} %-28s %s\n" "eza:" "$(eza --version 2>/dev/null | grep -oP 'v[0-9.]+' | head -1 || echo '?')" |
|
printf " ${GR}✓${NC} %-28s %s\n" "direnv:" "$(direnv version 2>/dev/null || echo '?')" |
|
printf " ${GR}✓${NC} %-28s %s\n" "Starship:" "${STARSHIP_SHORT}" |
|
printf " ${GR}✓${NC} %-28s %s\n" "Claude Code:" "${CLAUDE_SHORT}" |
|
printf " ${GR}✓${NC} %-28s %s\n" "tmux session:" "'claude' (auto-attach on SSH)" |
|
printf " ${GR}✓${NC} %-28s %s\n" "CLAUDE.md:" "$HOME/.claude/CLAUDE.md" |
|
printf " ${GR}✓${NC} %-28s %s\n" "settings.json:" "$HOME/.claude/settings.json" |
|
echo "" |
|
|
|
if [[ ${#CHANGED[@]} -gt 0 ]]; then |
|
echo -e " ${CY}Changes this run:${NC}" |
|
for item in "${CHANGED[@]}"; do |
|
echo -e " ${CY}•${NC} $item" |
|
done |
|
echo "" |
|
fi |
|
|
|
echo -e " ${YE}Next steps:${NC}" |
|
echo -e " ${BO}1.${NC} Run: ${BO}claude auth${NC} → authenticate Claude Code" |
|
echo -e " ${BO}2.${NC} Log out and back in → docker group + tmux auto-attach take effect" |
|
echo -e " ${BO}3.${NC} Run: ${BO}source ~/.bashrc${NC} → activate tools in this session" |
|
echo -e " ${BO}4.${NC} Add to your local ${BO}~/.ssh/config${NC} to enable NOTMUX bypass:" |
|
echo -e " ${BL}Host $(hostname)${NC}" |
|
echo -e " ${BL} SendEnv NOTMUX${NC}" |
|
echo "" |
|
echo -e " ${YE}AI tool authentication:${NC}" |
|
echo -e " ${BO}5.${NC} Gemini: ${BO}gemini auth login${NC} → browser OAuth" |
|
echo -e " or: ${BO}export GEMINI_API_KEY=<key>${NC} → API key (console.cloud.google.com)" |
|
echo -e " ${BO}6.${NC} Codex: ${BO}export OPENAI_API_KEY=<key>${NC} → API key (platform.openai.com)" |
|
echo -e " add to ${BO}~/.bashrc${NC} or ${BO}~/.profile${NC} to persist" |
|
echo -e " ${BO}7.${NC} Vibe: ${BO}vibe --setup${NC} → interactive setup" |
|
echo -e " or: ${BO}echo 'MISTRAL_API_KEY=<key>' >> ~/.vibe/.env${NC} → API key (console.mistral.ai)" |
|
echo "" |