#!/bin/bash # # Install CLI tools GitHub Codespaces doesn't ship by default: # neovim, ripgrep, bat, fzf, lazygit, diff-so-fancy, tree-sitter, tmux, # claude. # This is the "provision" phase — it only installs binaries. Populating # app plugin state (Neovim + tmux) is a separate lifecycle phase handled by # sync-codespace-plugins. Invoked by `make install-codespaces`. Safe to # re-run — each tool no-ops if it's already installed and runnable. set -euo pipefail mkdir -p "$HOME/.local/state/dotfiles" # Capture tee's PID so we can wait on it specifically at exit. # The process substitution keeps tee alive until stdout closes, which # happens naturally when the script exits — no explicit wait needed # for correctness, but we want the final echo to flush before the # shell tears down the pipe. exec > >(tee -i "$HOME/.local/state/dotfiles/bootstrap.log") exec 2>&1 echo "==> Running Codespaces bootstrap..." ARCH=$(dpkg --print-architecture 2>/dev/null || echo "amd64") case "$ARCH" in amd64) ARCH_ALT="x86_64" ; ARCH_GO="amd64" ;; arm64) ARCH_ALT="arm64" ; ARCH_GO="arm64" ;; *) echo "Unsupported architecture: $ARCH" && exit 1 ;; esac LOCAL_BIN="$HOME/.local/bin" mkdir -p "$LOCAL_BIN" # Setup runs before any shell profile has added ~/.local/bin, so put it on PATH # here; otherwise the readiness and version probes can't see tools installed there. export PATH="$LOCAL_BIN:$PATH" # Print the first line of a tool's version output, or "unknown" if it can't # be read. `flag` defaults to --version; tools that use a different flag # (e.g. tmux's -V) pass it explicitly. tool_version() { local ver ver=$("$1" "${2:---version}" &1 | head -1 || true) echo "${ver:-unknown}" } # Return 0 (true) only if a tool is installed AND actually usable. `probe` # is a version flag the binary must run successfully (e.g. --version, -V), # which also catches a binary that's present but broken at runtime — e.g. # linked against a newer glibc than the system provides ("version # `GLIBC_2.xx' not found"), which `command -v` alone can't detect. Pass # "none" for tools with no version flag (checked by presence only). tool_ready() { local cmd=$1 probe=$2 command -v "$cmd" >/dev/null 2>&1 || return 1 if [ "$probe" = none ]; then return 0 fi "$cmd" "$probe" /dev/null 2>&1 } # Echo a " (version[, extra])" suffix for result lines, omitting the version # when the tool has none ("none" probe) or it can't be read. version_paren() { local cmd=$1 probe=$2 extra=${3:-} ver="" if [ "$probe" != none ]; then ver=$(tool_version "$cmd" "$probe") [ "$ver" = unknown ] && ver="" fi if [ -n "$ver" ] && [ -n "$extra" ]; then printf ' (%s, %s)' "$ver" "$extra" elif [ -n "$ver" ]; then printf ' (%s)' "$ver" elif [ -n "$extra" ]; then printf ' (%s)' "$extra" fi } # --- installers: each downloads and installs one tool, returning nonzero # --- (a "skip") when the download is unavailable. The install_tool driver # --- wraps these with the shared guard/timing/logging. # neovim: extract the release tarball to /opt/nvim and symlink onto PATH (sudo). via_nvim() { local asset="nvim-linux-${ARCH_ALT}.tar.gz" gh release download --clobber --dir /tmp/ --repo neovim/neovim --pattern "$asset" || return 1 echo " sudo: extracting to /opt/nvim..." sudo mkdir -p /opt/nvim sudo tar -xzf "/tmp/$asset" --strip-components=1 -C /opt/nvim sudo ln -sf /opt/nvim/bin/nvim /usr/local/bin/nvim rm -f "/tmp/$asset" } # Download a .deb and install it (sudo). Args: repo asset via_deb() { local repo=$1 asset=$2 tmp=/tmp/pkg.deb gh release download --clobber --output "$tmp" --repo "$repo" --pattern "$asset" || return 1 echo " sudo: installing .deb package..." sudo dpkg -i "$tmp" rm -f "$tmp" } # Download a .tar.gz and install one extracted binary to ~/.local/bin. # Args: repo asset binname via_tarball_bin() { local repo=$1 asset=$2 binname=$3 tmp=/tmp/dl.tar.gz gh release download --clobber --output "$tmp" --repo "$repo" --pattern "$asset" || return 1 tar -xzf "$tmp" -C /tmp/ install "/tmp/$binname" "$LOCAL_BIN/$binname" rm -f "$tmp" "/tmp/$binname" } # Download a single raw file and install it to ~/.local/bin. # Args: repo asset binname via_raw_bin() { local repo=$1 asset=$2 binname=$3 local tmp="/tmp/$binname" gh release download --clobber --output "$tmp" --repo "$repo" --pattern "$asset" || return 1 install "$tmp" "$LOCAL_BIN/$binname" rm -f "$tmp" } # Install a package via apt (sudo). Args: package via_apt() { echo " sudo: installing via apt-get..." sudo apt-get install -yq "$1" >/dev/null 2>&1 } # Claude Code: the official native installer drops the binary in ~/.local/bin. # Run it from a file with stdin closed so its `claude install` step can never # wait on input (piping into bash would hand it the script as stdin instead). via_claude() { local tmp=/tmp/claude-install.sh curl -fsSL --output "$tmp" https://claude.ai/install.sh || return 1 local rc=0 bash "$tmp" /dev/null 2>&1; then echo " cargo not found; install Rust to build tree-sitter from source" return 1 fi if ! dpkg -s libclang-dev >/dev/null 2>&1; then echo " sudo: installing libclang-dev (needed by bindgen)..." sudo apt-get install -yq libclang-dev >/dev/null 2>&1 fi cargo install tree-sitter-cli --root "$HOME/.local" } # Install the prebuilt tree-sitter release binary into ~/.local/bin. install_tree_sitter_release() { local ts_arch case "$ARCH_GO" in amd64) ts_arch="x64" ;; arm64) ts_arch="arm64" ;; esac gh release download --clobber --output /tmp/tree-sitter.gz --repo tree-sitter/tree-sitter \ --pattern "tree-sitter-linux-${ts_arch}.gz" || return 1 gunzip -f /tmp/tree-sitter.gz install /tmp/tree-sitter "$LOCAL_BIN/tree-sitter" rm -f /tmp/tree-sitter } # tree-sitter: prebuilt release binary, with a from-source cargo build as the # fallback. nvim-treesitter shells out to `tree-sitter build` for every parser # it installs, so the CLI is a hard requirement, not a nicety. # # The prebuilt binary's glibc floor keeps rising (GLIBC_2.39 as of tree-sitter # 0.26), which rules out comparing the system glibc against a hardcoded # threshold — the threshold silently goes stale with each release and installs # a binary the system can't run. Instead, install the prebuilt binary and run # it once: if it executes, it works here; if it doesn't, build from source. # That test stays correct however high the floor climbs, at the cost of one # wasted download on systems too old to run it. install_tree_sitter() { if install_tree_sitter_release && \ "$LOCAL_BIN/tree-sitter" --version /dev/null 2>&1; then return fi echo " prebuilt tree-sitter won't run here (glibc too old for it) — building from source via cargo..." rm -f "$LOCAL_BIN/tree-sitter" install_tree_sitter_cargo } # Install one tool, centralizing the shared bootstrap boilerplate: skip if # already usable, warn and reinstall if present-but-broken (e.g. a glibc # mismatch), time the work, and log a uniform result. Args: # display human-readable name for log lines # cmd command to probe on PATH # probe version flag proving the tool runs (--version, -V, ...), or # "none" to check by presence only (tools with no version flag) # installer function that performs the install; extra args are forwarded install_tool() { local display=$1 cmd=$2 probe=$3 installer=$4 shift 4 if tool_ready "$cmd" "$probe"; then echo " ✓ $display already installed$(version_paren "$cmd" "$probe")" return 0 fi if [ "$probe" != none ] && command -v "$cmd" >/dev/null 2>&1; then echo " ⚠ $display is on PATH but won't run (likely glibc mismatch) — reinstalling..." fi local start=$SECONDS echo " Installing $display..." if "$installer" "$@"; then # The readiness probe above cached the old path (e.g. the base image's # broken /usr/local/bin/tree-sitter); forget it so the version check # runs the binary just installed. hash -r echo " ✓ $display installed$(version_paren "$cmd" "$probe" "$((SECONDS - start))s")" else echo " ⚠ $display skipped" fi } echo "==> Installing tools (arch: $ARCH)..." # Dotfiles run after the codespace's own lifecycle commands, so once Claude # Code is in place the codespace is ready for gh csd. Mark it # here, before the slow tools (the tree-sitter cargo build), which keep # installing after `up` returns. install_tool "Claude Code" claude --version via_claude mkdir -p "$HOME/.local/state/gh-csd" touch "$HOME/.local/state/gh-csd/setup-ready" install_tool "Neovim" nvim --version via_nvim install_tool "tree-sitter" tree-sitter --version install_tree_sitter install_tool "tmux" tmux -V via_apt tmux install_tool "ripgrep" rg --version via_deb BurntSushi/ripgrep "ripgrep_*_${ARCH_GO}.deb" install_tool "bat" bat --version via_deb sharkdp/bat "bat_*_${ARCH}.deb" install_tool "fzf" fzf --version via_tarball_bin junegunn/fzf "fzf-*-linux_${ARCH_GO}.tar.gz" fzf install_tool "lazygit" lazygit --version via_tarball_bin jesseduffield/lazygit "lazygit_*_linux_${ARCH_ALT}.tar.gz" lazygit install_tool "diff-so-fancy" diff-so-fancy none via_raw_bin so-fancy/diff-so-fancy diff-so-fancy diff-so-fancy echo "" echo "==> Tools installed! Plugin sync runs next (sync-codespace-plugins)."