Skip to content

Instantly share code, notes, and snippets.

@madalinignisca
Last active February 24, 2026 00:57
Show Gist options
  • Select an option

  • Save madalinignisca/f95af1dc22afc9b1cd12fe56a9ae9f8b to your computer and use it in GitHub Desktop.

Select an option

Save madalinignisca/f95af1dc22afc9b1cd12fe56a9ae9f8b to your computer and use it in GitHub Desktop.
Setup a clean cloud Ubuntu vm for Claude to work in it

Claude Code VM Setup

Idempotent bootstrap script for an Ubuntu 24.04 LTS development VM
optimised for Claude Code agent sessions.

Run it once to install everything. Run it again to update. Safe, transparent, and documented at every step.


Why this script?

Cloud VMs arrive as a blank slate. Getting a machine production-ready for Claude Code agent work means installing and configuring a lot of moving parts correctly and in the right order. This script handles the entire process, produces a self-describing ~/.claude/CLAUDE.md so the agent knows exactly what is available, and stays safe to re-run as an update mechanism.

The goal is that the first git diff you run looks beautiful, the first cd into a project automatically loads the right environment, and the first time you reach for a tool it is already there.


What it installs and configures

# Component Detail
1 System packages build-essential, git, curl, jq, tmux, and modern CLI replacements
1a ripgrep · fd · bat · fzf · eza · direnv Installed via apt; Ubuntu symlinks created automatically
2 Docker CE Official Docker repository — CE + Compose plugin + Buildx
3 kubectl Official Kubernetes apt repo — per-minor channel, patch auto-updates
4 Helm Official Buildkite apt repo — fully automatic updates
5 k9s GitHub Releases .deb — idempotent version check
5b yq · kubectx · kubens · stern GitHub Releases binaries — idempotent version checks
5c git-delta · lazygit GitHub Releases — delta as .deb, lazygit as .tar.gz
5d glab GitLab CLI — official .deb from GitLab releases API
5e psql · mariadb client PGDG apt repo (latest PostgreSQL) + MariaDB 12 official repo
5f Terraform · Ansible HashiCorp apt repo + official Ansible PPA
5g Node.js · gemini-cli · codex NodeSource LTS + npm globals in ~/.local — skip if present
5h Mistral Vibe CLI uv tool install mistral-vibe — skip if present, self-updates
6 Git Global identity + smart defaults + delta as pager
7 NVM Node Version Manager — always the latest release
8 Node.js Latest LTS via NVM + project version-file detection
9 GVM Go Version Manager — updated via git pull
10 Go Latest stable binary + project version-file detection
11 uv + Python Fast Python manager; system Python untouched
12 Claude Code Native Bun installer (not npm, which is deprecated)
13 Bash completion Comprehensive Claude Code CLI tab-completion
14 tmux Persistent claude session — auto-attach on SSH login
14a Starship prompt Git status, runtimes, k8s context, command duration
15 Unattended upgrades Nightly security updates, no automatic reboots
16 Reboot notification MOTD + shell prompt when a restart is pending
17 Shell init fzf keybindings, direnv hook, starship, smart aliases, EDITOR vars
18 CLAUDE.md Global agent context file — regenerated on every run

Requirements

  • Ubuntu 24.04 LTS (Noble Numbat)
  • SSH access as root or a regular user who can sudo
  • Internet access from the VM

Quick start

Download the script and make it executable:

curl -fsSL https://example.com/setup-claude-vm.sh -o setup-claude-vm.sh
chmod +x setup-claude-vm.sh

Then run it:

# As root (recommended for fresh VMs)
bash setup-claude-vm.sh

# As a regular user with sudo
./setup-claude-vm.sh

After setup completes, authenticate Claude Code:

claude auth

Running as root vs. running as a user

As root

Fresh cloud VMs often only have a root account. The script detects this and guides you through setting up a proper non-root user before continuing:

  1. Lists existing non-system accounts as suggestions
  2. Asks which user to configure (existing or new name to create)
  3. Creates the user if they do not exist
  4. Configures passwordless sudo for that user (see below)
  5. Optionally adds an SSH public key to authorized_keys
  6. Re-executes itself as that user via su -l — so all tools install into the correct home directory

Nothing is installed as root. The exec su -l call replaces the root process entirely.

As a regular user

If your account already has sudo access, the script checks whether it requires a password:

  • Already passwordless — continues silently
  • Password required — explains why passwordless sudo is recommended and asks for consent before configuring it

Passwordless sudo

Claude Code agent sessions run commands unattended. A sudo password prompt inside an agent task will hang indefinitely. The script configures passwordless sudo by writing a drop-in file:

/etc/sudoers.d/claude-<username>

containing:

<username> ALL=(ALL) NOPASSWD: ALL

The file is validated with visudo -c before being committed. If validation fails, the file is removed and the script aborts cleanly.

To revert at any time:

sudo rm /etc/sudoers.d/claude-$(whoami)

Developer CLI tools

These are installed via apt and are available immediately in every shell, with no version manager involved.

ripgrep (rg) — fast grep

Replaces grep as the default grep alias. Respects .gitignore automatically, which makes it the ideal search backend for Claude Code agents working in large codebases. Also used as the fzf file-listing backend for speed.

rg pattern                    # recursive search, respects .gitignore
rg pattern --type go          # filter by language
rg -l pattern                 # list matching files only

fd — fast find

Replaces find. Ubuntu ships it as fdfind; the script creates a fd symlink automatically.

fd pattern                    # find files by name
fd -e go                      # by extension
fd -t d                       # directories only

bat — syntax-highlighted cat

Replaces cat as the default cat alias. Ubuntu ships it as batcat; the script creates a bat symlink automatically.

bat file.go                   # syntax highlighting + line numbers
bat --paging=always file      # with paging

eza — modern ls

Replaces ls with a set of aliases. Shows icons, git status, and file metadata in a clean layout.

Alias Expands to
ls eza --icons --group-directories-first
ll long listing with git status column
la long listing including hidden files
lt tree view, 2 levels
ltt tree view, 3 levels

fzf — fuzzy finder

Integrates with bash for interactive selection everywhere.

Keybinding Action
Ctrl+R Fuzzy search shell history — replaces the standard reverse-search
Ctrl+T Fuzzy file picker — inserts a path at the cursor
Alt+C Fuzzy cd — navigate to any subdirectory
Ctrl+/ Toggle preview pane

In scripts and pipelines: kubectl get pods | fzf, git branch | fzf, etc. kubectx and kubens also use fzf automatically when it is installed.

direnv — per-project environment variables

Automatically loads and unloads .envrc files when you cd into and out of a directory.

echo 'export DATABASE_URL=postgres://...' > .envrc
direnv allow          # one-time trust for this directory
# Variables are now set in this shell; unset when you cd out

Typical uses: API keys per project, KUBECONFIG per cluster, NODE_ENV, database URLs. Never commit .envrc files containing secrets — add them to .gitignore.


Git configuration

The script sets a global git identity (name and email) if not already configured, then applies smart defaults — each one skipped individually if already set:

Setting Value Reason
init.defaultBranch main Modern convention
pull.rebase false Merge on pull — predictable history
push.autoSetupRemote true No more --set-upstream on first push
fetch.prune true Auto-remove stale remote-tracking refs
diff.algorithm histogram Produces cleaner, more readable diffs
rerere.enabled true Remembers conflict resolutions
color.ui auto Coloured output in terminal
core.autocrlf false No CRLF conversion on Linux
core.pager delta Syntax-highlighted diffs (if delta is installed)
delta.side-by-side true Side-by-side diff layout
delta.navigate true n/N jump between diff sections
delta.syntax-theme Catppuccin Mocha Colour scheme matching tmux and starship

git-delta

delta is configured as the git pager automatically. Every git diff, git show, git log -p, and merge conflict view uses it. Side-by-side with line numbers and syntax highlighting — a significant quality-of-life upgrade over the default pager.

lazygit

lazygit is a full terminal UI for git, aliased to lg. Stage files, commit, branch, rebase, resolve conflicts, and view history — all without leaving the terminal or typing long git commands.

Key Action
Arrow keys / hjkl Navigate
Space Stage / unstage file
c Commit
p Push
P Pull
b Branches panel
? Help / all keybindings

Additional developer tools

glab — GitLab CLI

glab brings GitLab workflows into the terminal. Installed as a .deb from the GitLab releases API — no personal token needed for version discovery.

glab auth login                  # authenticate (web or token)
glab mr create                   # open a merge request
glab mr list                     # list open MRs
glab issue create                # create an issue
glab ci view                     # watch pipeline status
glab repo clone owner/repo       # clone via glab

PostgreSQL client (psql)

Installed from the PGDG official apt repository — always tracks the latest PostgreSQL major version. Only the client package is installed; no server or systemd units.

psql -h host -p 5432 -U user -d dbname
PGPASSWORD=secret psql -h host -U user dbname
psql "postgresql://user:pass@host:5432/db"
\l              # list databases
\dt             # list tables
\q              # quit

MariaDB client

Installed from the official MariaDB apt repository at the 12.rolling (current stable) channel. Only mariadb-client is installed — no server, no Galera, no systemd units. Backward-compatible with older MariaDB and MySQL servers.

mariadb -h host -P 3306 -u user -p dbname
mysqldump -h host -u user -p dbname > backup.sql
MYSQL_PWD=secret mariadb -h host -u user dbname

Terraform

Installed from the HashiCorp apt repository. Patch updates via unattended-upgrades; new major versions via re-run.

terraform init
terraform plan -out=tfplan
terraform apply tfplan
terraform destroy
terraform state list

Ansible

Installed from the official Ansible PPA. Patch updates via unattended-upgrades.

ansible all -i inventory -m ping
ansible-playbook -i inventory playbook.yml
ansible-playbook playbook.yml --vault-password-file .vault_pass

AI coding tools

gemini-cli (Google)

Installed via npm install -g --prefix ~/.local on first run, then skipped — Gemini ships its own update mechanism. No ~/.npmrc prefix entry is written, keeping NVM happy.

gemini                           # interactive session
gemini -p "explain this code"    # one-shot prompt

Authenticate: gemini auth login or set GEMINI_API_KEY. Docs: https://github.com/google-gemini/gemini-cli

codex (OpenAI)

Same install pattern as gemini-cli — npm install -g --prefix ~/.local, skipped if already present.

codex                              # interactive session
codex "refactor this function"     # one-shot prompt
codex --approval-mode full-auto    # autonomous mode

Authenticate: set OPENAI_API_KEY. Docs: https://github.com/openai/codex

Mistral Vibe CLI

Installed via uv tool install mistral-vibe on first run, then skipped — Vibe ships continuous self-updates. Isolated Python environment managed by uv; binary shim lands in ~/.local/bin/vibe.

vibe                               # interactive session
vibe --prompt "fix the auth bug"   # one-shot prompt
vibe --agent plan                  # read-only, no edits
vibe --agent auto-approve          # full autonomy, no confirmations
vibe --max-price 0.50              # cap session cost at $0.50

Authenticate: vibe --setup or set MISTRAL_API_KEY in ~/.vibe/.env.
Config: ~/.vibe/config.toml — models, tool permissions, themes, MCP servers.
Telemetry: set enable_telemetry = false in config.toml.
Docs: https://docs.mistral.ai/mistral-vibe/introduction


Kubernetes toolchain

kubectl

Installed via the official Kubernetes apt repository, using a per-minor-version channel. The script queries dl.k8s.io/release/stable.txt at runtime to determine the current stable release (e.g. v1.32.3), extracts the minor version (v1.32), and configures the matching channel.

  • Patch updates (v1.32.3v1.32.4): automatic via unattended-upgrades
  • Minor upgrades (v1.32v1.33): re-run the script — it detects the channel mismatch and updates

Helm

Installed via the official Buildkite apt repository (migrated from Balto in August 2025). All releases auto-update via unattended-upgrades — no manual intervention needed.

k9s

Installed as a .deb from GitHub Releases. There is no official apt repository for k9s (upstream issue #1390). The script compares the installed version against the latest GitHub release and updates if needed. Re-run setup-claude-vm.sh to update k9s.

yq

yq is jq for YAML, installed as a static binary. Invaluable when working with Kubernetes manifests and Helm values.

yq '.spec.replicas' deployment.yaml
yq -i '.image.tag = "v2.1.0"' values.yaml
cat file.json | yq -P            # convert JSON → pretty YAML

kubectx and kubens

Single binaries from ahmetb/kubectx that make switching contexts and namespaces a one-word operation. With fzf installed, running them without arguments opens an interactive selection menu.

kubectx                          # interactive context picker (with fzf)
kubectx production               # switch to 'production' context
kubectx -                        # switch back to previous context
kubens                           # interactive namespace picker (with fzf)
kubens kube-system               # switch namespace

stern

stern tails logs from multiple pods simultaneously, something kubectl logs cannot do. Essential for multi-replica deployments and microservices.

stern api                        # all pods whose name contains "api"
stern . -n staging               # all pods in the staging namespace
stern app --selector app=worker  # by label selector
stern api --since 15m            # last 15 minutes only
stern api --output json | jq '.' # structured log output

Starship prompt

Starship replaces the default bash prompt with one that displays contextual information without cluttering the screen. It only shows segments relevant to the current directory.

A tuned ~/.config/starship.toml is written if one does not already exist. What it shows:

Segment When shown
Git branch + status Inside any git repo
Node.js version Project has .nvmrc, .node-version, or package.json
Go version Project has go.mod or .go-version
Python version Project has pyproject.toml, requirements.txt, or uv.lock
Rust version Project has Cargo.toml
Kubernetes context + namespace Project has *.yaml or Chart.yaml
Command duration Commands taking more than 2 seconds
Exit code indicator in green (success) or red (error)

Customise by editing ~/.config/starship.toml. Delete the file and re-run the script to restore the defaults.


Version managers

Node.js — NVM

NVM installs entirely in user space (~/.nvm), so npm install -g never needs sudo. The script updates NVM on every run via git pull on ~/.nvm, then compares the active Node.js version against the latest LTS. If a newer LTS is available it installs it with --reinstall-packages-from=default, which migrates all global npm packages automatically.

Go — GVM

GVM lives at ~/.gvm. Updates are a git pull on that directory. Go versions are installed as pre-built binaries (--binary), so no bootstrap Go release is needed. The latest stable version is determined at runtime by querying the go.dev download API.

Python — uv

uv replaces pip, venv, pyenv, and pipx in one fast tool. It manages Python versions independently of the system Python at /usr/bin/python3, which is left completely untouched. uv self update runs on every script invocation.


Project version-file detection

Run the script from inside a project directory and it will detect version pins and offer to install them.

Node.js

File Format Example
.nvmrc version string 20.11.0 or lts/iron
.node-version version string 20.11.0

Go

File Format Example
go.mod go directive go 1.21 or go 1.21.3
.go-version version string 1.21.3

go.mod often specifies only major.minor. The script resolves this to the latest patch by querying the go.dev API before installing.


tmux — persistent Claude session

The script configures tmux with a claude named session that persists across SSH connections. Claude Code agent tasks that would be killed by a dropped connection continue running inside tmux.

SSH auto-attach

A hook is added to ~/.bashrc that fires when the shell is connected via SSH, tmux is not already running, and the bypass variable is not set:

exec tmux new-session -A -s claude

The -A flag means attach if the session exists, create it if it doesn't.

Bypassing the auto-attach

Option 1 — Environment variable (cleanest):

Requires AcceptEnv NOTMUX in sshd — configured via /etc/ssh/sshd_config.d/99-claude-vm.conf. Add to your local ~/.ssh/config:

Host your-vm-hostname
    SendEnv NOTMUX

Then connect without tmux:

NOTMUX=1 ssh user@your-vm

Option 2 — Inline env (no server config needed):

ssh -t user@your-vm env NOTMUX=1 bash -l

Option 3 — Direct command (non-interactive, skips .bashrc entirely):

ssh user@your-vm <command>

tmux configuration

A ~/.tmux.conf is written if one does not already exist (existing configs are never overwritten). If a config already exists, the catppuccin status bar block is appended to it (guarded by a marker so re-runs never duplicate it). Highlights:

  • Mouse disabled — preserves native terminal selection/copy on macOS and Windows. Enable with set -g mouse on if preferred.
  • 100,000 line scrollback buffer
  • | splits vertically, - splits horizontally, both in the current directory
  • New windows open in the current directory
  • prefix + r reloads config without restarting

Catppuccin status bar

The script installs three plugins via git clone into ~/.config/tmux/plugins/ (no TPM required, avoids TPM naming conflicts):

Plugin Purpose
catppuccin/tmux v2.1.3 Theme + status module framework
tmux-plugins/tmux-cpu #{cpu_percentage} and #{ram_percentage} tokens

The status bar sits at the top and shows (right side): current application → CPU% → RAM% → session name → uptime.

Plugin load order matters: tmux-cpu must be run before catppuccin.tmux so catppuccin can read the CPU/RAM tokens at load time.

Re-running the script does a git pull --ff-only on each plugin to keep them current. To force a full reinstall: rm -rf ~/.config/tmux/plugins and re-run.

Nerd Font — required for status bar icons

The catppuccin status bar uses Nerd Font glyphs for icons (app logo, CPU, RAM, session, etc.). Without a Nerd Font the icons render as boxes or question marks.

Recommended: JetBrains Mono Nerd Font — clean, geometric, closest in feel and proportions to SF Mono (the macOS system terminal font), no ligatures by default.

brew install --cask font-jetbrains-mono-nerd-font

Then set JetBrainsMono Nerd Font Mono as the font in your terminal's profile settings.

Other well-regarded options, all installable the same way:

Font Character Install cask
JetBrains Mono NF ✓ recommended Clean, geometric, SF Mono-like font-jetbrains-mono-nerd-font
Hack NF Neutral, purpose-built for terminals font-hack-nerd-font
Fira Code NF Like Fira Code but with Nerd glyphs, has ligatures font-fira-code-nerd-font
CaskaydiaCove NF Patched Cascadia Code (Windows Terminal's font) font-caskaydia-cove-nerd-font
MesloLGS NF Recommended by powerlevel10k, Powerline-only subset font-meslo-lg-nerd-font

macOS 26 Tahoe note: Terminal.app on Tahoe gained native support for basic Powerline glyphs (arrows/separators) via SF Mono, but does not include the full Nerd Font glyph set. A separate Nerd Font install is still required for the catppuccin icon modules.

Clipboard integration over SSH

set -s set-clipboard on is configured in ~/.tmux.conf. This sends copied text as an OSC 52 escape sequence, which your local terminal receives and writes directly to the system clipboard — no pbcopy pipe needed, works transparently over SSH.

Terminal support:

Terminal OSC 52 Notes
iTerm2 (macOS) Works out of the box
WezTerm Works out of the box
Alacritty Works out of the box
Kitty Works out of the box
Windows Terminal Works out of the box
macOS Terminal.app Does not support OSC 52

macOS Terminal.app workaround: uncomment this line in ~/.tmux.conf:

# bind -T copy-mode Enter send -X copy-pipe-and-cancel "pbcopy"

This pipes the copied selection through pbcopy instead of OSC 52.


Shell initialisation

Tools are sourced in two places to cover all session types:

File When it runs
/etc/profile.d/claude-tools.sh Login shells (SSH, su -l)
~/.bashrc Interactive bash, tmux panes, screen windows

Both files are written with idempotency markers — re-running the script never duplicates entries.

Note: The NVM and GVM installers unconditionally append their own init lines to ~/.bashrc on every run. The script detects and removes these installer-injected duplicates before writing (or checking for) the consolidated # claude-tools init block, so re-running never accumulates stale entries.

The ~/.bashrc block sets up, in order:

  1. NVM, GVM, and ~/.local/bin on PATH
  2. EDITOR, VISUAL, and KUBE_EDITOR (vim — used by k9s e key)
  3. fzf shell integration (Ctrl+R, Ctrl+T, Alt+C) with ripgrep as backend
  4. direnv hook
  5. Starship prompt
  6. Aliases: ls→eza, ll/la/lt/ltt, cat→bat, grep→rg, lg→lazygit, k→kubectl

Non-interactive scripts (cron jobs, CI pipelines) must source tools explicitly:

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"

Claude Code defaults — ~/.claude/settings.json

The script writes a settings.json that pre-authorises the tool types Claude Code uses most commonly (Bash, Read, Write, Edit, MultiEdit). Without this file, the first agent session in any new project triggers interactive permission dialogs that pause execution.

The file is only written if it does not already exist, so your customisations are preserved on re-runs.


CLAUDE.md — agent context file

The script generates ~/.claude/CLAUDE.md on every run, replacing it with current version information. Claude Code reads this file automatically before any project-level CLAUDE.md, giving the agent a complete picture of what is installed and how to use it.

The file documents: system details, Claude Code version and auth, all runtimes and version managers, Docker and Compose, kubectl/Helm/k9s/kubectx/stern, git configuration and delta usage, fzf keybindings, yq/lazygit/direnv quick references, Starship config location, tmux session management, shell initialisation paths for non-interactive scripts, and how to re-run the setup script to update everything.


Automatic updates

unattended-upgrades is configured to apply security and general updates nightly. Automatic reboots are disabled — you decide when to restart.

What updates automatically

Tool Method
System packages unattended-upgrades (nightly)
Docker CE unattended-upgrades (official Docker apt repo)
kubectl unattended-upgrades — patch only; re-run script for minor upgrades
Helm unattended-upgrades (all releases)
ripgrep, fd, bat, fzf, eza, direnv unattended-upgrades
Claude Code Auto-updates on every launch
uv uv self update (runs on each script execution)
PostgreSQL client unattended-upgrades (PGDG repo)
MariaDB client unattended-upgrades (MariaDB 12 repo)
Terraform unattended-upgrades (HashiCorp repo)
Ansible unattended-upgrades (Ansible PPA)
gemini-cli Self-updates on launch
codex Self-updates on launch
Mistral Vibe Self-updates on launch

What requires re-running the script

Tool Reason
k9s No apt repo — GitHub Releases only
yq, kubectx, kubens, stern No apt repo — GitHub Releases only
git-delta, lazygit No apt repo — GitHub Releases only
glab No apt repo — GitLab Releases only
kubectl minor upgrade Per-minor apt channel — script updates the channel
NVM, GVM, Starship User-space installers
Node.js, Go Version manager decisions

Reboot notification

When a reboot is pending after an update, you are notified in two places:

  1. MOTD — shown at SSH login, lists which packages triggered the requirement
  2. Shell prompt — every new bash session shows a red warning
  [!] REBOOT REQUIRED — run: sudo reboot

Updating

Re-run the script at any time:

bash setup-claude-vm.sh

Every tool is checked and updated if a newer version is available. A summary of what actually changed is printed at the end of each run.


Manual update commands

# Node.js — install newer LTS and migrate globals
nvm install --lts --reinstall-packages-from=default

# Go — install a specific version
gvm install go1.23.0 --binary && gvm use go1.23.0 --default

# Python / uv
uv self update

# Claude Code
claude update

# Mistral Vibe (normally self-updates on launch)
uv tool upgrade mistral-vibe

# gemini-cli / codex (normally self-update on launch)
npm install -g --prefix ~/.local @google/gemini-cli@latest @openai/codex@latest

# k9s / yq / delta / lazygit / kubectx / stern
bash setup-claude-vm.sh          # version-checks and updates all of them

# kubectl minor upgrade (e.g. v1.32 → v1.33)
bash setup-claude-vm.sh          # detects channel mismatch, re-configures repo

# System packages
sudo apt update && sudo apt upgrade

# Check pending reboot
cat /run/reboot-required.pkgs
sudo reboot

Security notes

  • Root is never used for tool installation. The exec su -l handoff replaces the root process before any developer tooling is installed.
  • Passwordless sudo is scoped to a single drop-in file and can be reverted instantly with sudo rm /etc/sudoers.d/claude-<username>.
  • SSH keys are written to authorized_keys only if the key passes basic format validation and is not already present.
  • The sshd drop-in (AcceptEnv NOTMUX) is validated with sshd -t before the daemon is reloaded. If validation fails, the file is removed.
  • sudoers entries are validated with visudo -c before being committed. On failure the file is removed and the script aborts.
  • Docker group membership requires a re-login to activate. Until then, sudo docker works.
  • direnv requires explicit direnv allow per directory — it never loads .envrc silently from untrusted sources.

Project layout after setup

~/
├── .bashrc                     ← tool init, fzf, direnv, starship, aliases, tmux auto-attach
├── .tmux.conf                  ← tmux config (only written if missing)
├── .gitconfig                  ← git identity + smart defaults + delta config
├── .config/
│   └── starship.toml           ← Starship prompt config (only written if missing)
├── .nvm/                       ← NVM + all Node.js versions
├── .gvm/                       ← GVM + all Go versions
├── .local/
│   └── bin/
│       ├── claude              ← Claude Code binary (Bun native)
│       ├── uv                  ← uv binary
│       ├── gemini              ← Google Gemini CLI (npm global)
│       ├── codex               ← OpenAI Codex CLI (npm global)
│       └── vibe                ← Mistral Vibe CLI (uv tool shim)
└── .claude/
    ├── CLAUDE.md               ← global agent context (regenerated on each run)
    └── settings.json           ← default permissions (only written if missing)

/usr/local/bin/
├── bat                         ← symlink → /usr/bin/batcat
├── fd                          ← symlink → /usr/bin/fdfind
├── yq                          ← static binary (GitHub Releases)
├── kubectx                     ← binary (GitHub Releases)
├── kubens                      ← binary (GitHub Releases)
├── stern                       ← binary (GitHub Releases)
├── lazygit                     ← binary (GitHub Releases)
└── starship                    ← binary (official installer)

/etc/
├── apt/apt.conf.d/
│   ├── 20auto-upgrades         ← enables nightly upgrades
│   └── 50unattended-upgrades   ← upgrade policy (no auto-reboot)
├── bash_completion.d/
│   └── claude                  ← Claude Code CLI tab-completion
├── profile.d/
│   └── claude-tools.sh         ← NVM + GVM + PATH + EDITOR for login shells
├── ssh/sshd_config.d/
│   └── 99-claude-vm.conf       ← AcceptEnv NOTMUX (if configured)
├── sudoers.d/
│   └── claude-<username>       ← NOPASSWD: ALL (revert: sudo rm this file)
└── update-motd.d/
    └── 98-reboot-required      ← reboot-pending notice at login

Licence

MIT — do whatever you like with it.


Generated to accompany setup-claude-vm.sh.
Tested on Ubuntu 24.04 LTS (Noble Numbat) — x86_64 and arm64.

#!/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 ""
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment