Something went wrong. Try again.
🏠 sweet ~/
Something went wrong. Try again.
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247#!/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}" </dev/null 2>&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 >/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 assetvia_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 binnamevia_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 binnamevia_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: packagevia_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 || rc=$? rm -f "$tmp" return "$rc"}
# Build tree-sitter-cli from source. Used when the prebuilt release binary# can't run here — typically because it's linked against a newer glibc than# the system provides. Prints a reason and returns nonzero if the Rust# toolchain isn't available.install_tree_sitter_cargo() { if ! command -v cargo >/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 >/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 forwardedinstall_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_claudemkdir -p "$HOME/.local/state/gh-csd"touch "$HOME/.local/state/gh-csd/setup-ready"
install_tool "Neovim" nvim --version via_nviminstall_tool "tree-sitter" tree-sitter --version install_tree_sitterinstall_tool "tmux" tmux -V via_apt tmuxinstall_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" fzfinstall_tool "lazygit" lazygit --version via_tarball_bin jesseduffield/lazygit "lazygit_*_linux_${ARCH_ALT}.tar.gz" lazygitinstall_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)."