diff --git a/.github/workflows/bootstrap.yml b/.github/workflows/bootstrap.yml index 5aea1e9..484033d 100644 --- a/.github/workflows/bootstrap.yml +++ b/.github/workflows/bootstrap.yml @@ -5,41 +5,41 @@ on: branches: [master] pull_request: +permissions: {} # default-deny; no job here needs the GITHUB_TOKEN + jobs: ubuntu: runs-on: ubuntu-latest + permissions: + contents: read # checkout steps: - - uses: actions/checkout@v6 + # Submodules are left to mise's pre-dotfiles hook so that the hook is + # exercised rather than bypassed. + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 with: - submodules: recursive + persist-credentials: false - - name: Run bootstrap - env: - FORCE_OVERWRITE: "1" - SKIP_TPM: "1" - SKIP_DEFAULTS: "1" - SKIP_NVIM: "1" - SKIP_MISE: "1" - run: bash bootstrap.sh + - name: Install mise + run: | + curl -fsSL https://mise.run | sh + echo "$HOME/.local/bin" >> "$GITHUB_PATH" - - name: Verify dotfiles symlinked + - name: Run bootstrap run: | set -e - for f in .zshrc .gitconfig .tmux.conf .fzf.zsh .npmrc; do - test -L "$HOME/$f" || { echo "missing symlink: ~/$f"; exit 1; } - done - test -L "$HOME/.gem/credentials" - test -L "$HOME/.gitignore" - test -L "$HOME/.config/nvim" - test -L "$HOME/.config/mise/config.toml" - test -L "$HOME/.config/lsd/colors.yaml" - test -L "$HOME/.config/lsd/config.yaml" - test -L "$HOME/.config/gitleaks/config.toml" - test -L "$HOME/.tmux/loadavg.sh" - test -L "$HOME/.tmux/theme-sync.sh" - test -L "$HOME/.tmux/tmuxline.dark.conf" - test -L "$HOME/.tmux/tmuxline.light.conf" - test -L "$HOME/.tmux/extrakto" + mise trust + # tools: the runtimes in [tools] take far longer than this job is + # worth; task: bootstrap:nvim compiles every treesitter parser. + mise bootstrap -y --force-dotfiles --skip tools,task + # The one task that is cheap and Linux-specific — it creates the + # fd/bat shims the next step asserts on. + mise run bootstrap:linux-shims + # ~/.config/mise/config.toml is a symlink into the repo as of the + # dotfiles step above, so it needs trusting before it can be read. + mise trust ~/.config/mise/config.toml + + - name: Verify dotfiles converged + run: mise bootstrap dotfiles status --missing - name: Verify CLI tools installed run: | @@ -50,20 +50,26 @@ jobs: test -x "$HOME/.local/bin/fd" test -x "$HOME/.local/bin/bat" - - name: Verify dry-run is a no-op + - name: Verify re-running is a no-op run: | set -e before=$(find "$HOME" -maxdepth 3 -type l 2>/dev/null | sort | sha256sum) - bash bootstrap.sh --dry-run > /dev/null + mise bootstrap -n --skip tools,task > /dev/null after=$(find "$HOME" -maxdepth 3 -type l 2>/dev/null | sort | sha256sum) - [[ "$before" == "$after" ]] || { echo "dry-run modified state"; exit 1; } + [ "$before" = "$after" ] || { echo "dry-run modified state"; exit 1; } shellcheck: runs-on: ubuntu-latest + permissions: + contents: read # checkout steps: - - uses: actions/checkout@v6 + - uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1 + with: + persist-credentials: false + # .claude/scripts/statusline.sh is not covered yet — it has pre-existing + # warnings that want fixing on their own. - name: Run shellcheck run: | sudo apt-get update -qq sudo apt-get install -y -qq shellcheck - shellcheck bootstrap.sh defaults.sh + shellcheck .tmux/*.sh diff --git a/.gitignore b/.gitignore index 39c4fb6..0a777cd 100644 --- a/.gitignore +++ b/.gitignore @@ -1,10 +1,9 @@ .bundle .zshrc.local -.vimrc.local -.vim/.undo -.vim/pack/minpac -.vim/.netrwhist .tmux/plugins +# mise.toml is ignored by the global gitignore (per-project configs stay +# local); this repo's copy is the machine setup itself, so track it +!/mise.toml # ~/.claude is ignored by the global gitignore; re-include only what we track !/.claude/ /.claude/* diff --git a/.gitmodules b/.gitmodules index d0d61b1..4144f05 100644 --- a/.gitmodules +++ b/.gitmodules @@ -1,6 +1,3 @@ -[submodule ".vim/pack/minpac/opt/minpac"] - path = .vim/pack/minpac/opt/minpac - url = https://github.com/k-takata/minpac [submodule ".tmux/extrakto"] path = .tmux/extrakto url = https://github.com/laktak/extrakto diff --git a/.tmux.conf b/.tmux.conf index b2b0ecd..e000f1b 100644 --- a/.tmux.conf +++ b/.tmux.conf @@ -119,7 +119,7 @@ bind-key -T copy-mode-vi C-f send-keys -X page-down # Make session chooser start from 1 instead of 0. See: https://unix.stackexchange.com/a/755474. bind-key s choose-tree -ZsK '#{?#{e|<:#{line},9},#{e|+:1,#{line}},#{?#{e|<:#{line},35},M-#{a:#{e|+:97,#{e|-:#{line},9}}},}}' -# Switch windows via Ctrl+number. For iTerm2: https://unix.stackexchange.com/a/314150 +# Switch windows via Ctrl+number (needs a terminal that sends CSI-u keys) bind -n C-1 select-window -t 1 bind -n C-2 select-window -t 2 bind -n C-3 select-window -t 3 diff --git a/README.md b/README.md index 52c59fb..8cb469b 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,25 @@ Personal dotfiles optimized for macOS and zsh. Features modern shell tools, comp ## Installation ```sh -bash -c "$(curl -sL https://raw.github.com/ericboehs/dotfiles/master/bootstrap.sh)" +curl -fsSL https://mise.run | sh +git clone https://github.com/ericboehs/dotfiles ~/Code/github.com/ericboehs/dotfiles +cd ~/Code/github.com/ericboehs/dotfiles && mise trust && mise bootstrap ``` +Setup is declared in [mise.toml](mise.toml) rather than scripted, so it +converges — re-running only changes what has drifted. Requires mise 2026.8.4 +or newer for the per-package `os` filters; older versions say so and stop. +Useful variations: + +```sh +mise bootstrap -n # preview every change, touch nothing +mise bootstrap --only dotfiles # just the $HOME symlinks +mise bootstrap --skip macos-defaults # leave system preferences alone +mise bootstrap dotfiles status # what's linked, and what has drifted +``` + +Add `--force-dotfiles` if $HOME already has real files where symlinks belong. + Configure git with your personal information: ```sh cp ~/.gitconfig.private.example ~/.gitconfig.private @@ -45,10 +61,13 @@ $EDITOR ~/.gitconfig.private - Custom status line with zoom indicator - Auto-renumber windows -### Version Management +### Version Management and Setup - **Tool**: [mise](https://mise.jdx.dev/) (replaces asdf) - Manages Node.js, Ruby, Python, and other language runtimes +- Also drives machine setup — packages, `$HOME` symlinks, macOS defaults and + git checkouts are all declared in [mise.toml](mise.toml) and applied with + `mise bootstrap` ### Fuzzy Finder @@ -108,7 +127,7 @@ Collection of utility scripts in `bin/` including: ├── .gitconfig # Git configuration ├── .tmux.conf # Tmux configuration ├── .zshrc # Zsh initialization -└── bootstrap.sh # Installation script +└── mise.toml # Declarative machine setup (`mise bootstrap`) ``` ## License diff --git a/bin/claude-man b/bin/claude-man index ff34155..ac266c1 100755 --- a/bin/claude-man +++ b/bin/claude-man @@ -251,7 +251,7 @@ monitor_pane() { if [[ "$NOTIFY_ALWAYS" == true ]]; then terminal-notifier -title "Claude Session Idle" -message "Session $pane ($pane_title) is now idle" else - terminal-notifier -title "Claude Session Idle" -message "Session $pane ($pane_title) is now idle" -sender com.googlecode.iterm2 + terminal-notifier -title "Claude Session Idle" -message "Session $pane ($pane_title) is now idle" -sender com.mitchellh.ghostty fi else echo "Warning: terminal-notifier not found, skipping notification" @@ -339,7 +339,7 @@ monitor_pane() { if [[ "$NOTIFY_ALWAYS" == true ]]; then terminal-notifier -title "Claude Low Context" -message "Only $context_percent% context remaining in $pane" else - terminal-notifier -title "Claude Low Context" -message "Only $context_percent% context remaining in $pane" -sender com.googlecode.iterm2 + terminal-notifier -title "Claude Low Context" -message "Only $context_percent% context remaining in $pane" -sender com.mitchellh.ghostty fi fi fi diff --git a/bin/code-editor b/bin/code-editor index d666b21..98dccbe 100755 --- a/bin/code-editor +++ b/bin/code-editor @@ -1,4 +1,4 @@ #! /usr/bin/env bash -open -a iTerm +open -a Ghostty tmux switch-client -t 👨🏼‍💻 diff --git a/bin/monitor_tmux_pane b/bin/monitor_tmux_pane index 3ada582..b918567 100755 --- a/bin/monitor_tmux_pane +++ b/bin/monitor_tmux_pane @@ -85,7 +85,7 @@ send_notification() { local duration="$2" if [[ "$USE_TERMINAL_NOTIFIER" == true ]]; then - terminal-notifier -title "Claude Monitor" -message "Claude pane $pane idle for $duration seconds" -sender com.googlecode.iterm2 + terminal-notifier -title "Claude Monitor" -message "Claude pane $pane idle for $duration seconds" -sender com.mitchellh.ghostty else # Default OS9 notification via tmux printf '\ePtmux;\e\e]9;Claude pane %s idle for %d seconds\a\e\\' "$pane" "$duration" diff --git a/bootstrap.sh b/bootstrap.sh deleted file mode 100755 index ef6656a..0000000 --- a/bootstrap.sh +++ /dev/null @@ -1,329 +0,0 @@ -#!/usr/bin/env bash -# bootstrap.sh — install Eric's dotfiles, package deps, fonts, plugins. -# -# Usage: -# ./bootstrap.sh # full install -# ./bootstrap.sh --dry-run # preview without changes -# -# Skip individual steps by setting any of these env vars to a non-empty value: -# SKIP_DEPS — brew/apt package install -# SKIP_FONTS — Inconsolata Nerd Font (macOS only) -# SKIP_ITERM — Catppuccin Mocha import (macOS only) -# SKIP_DEFAULTS — defaults.sh (macOS only) -# SKIP_LINK — symlink dotfiles into $HOME -# SKIP_MISE — mise trust + install runtime versions -# SKIP_TPM — tmux plugin manager + plugin install -# SKIP_NVIM — Lazy.nvim plugin sync -# SKIP_GCD — clone gcd (github.com/ericboehs/gcd; sourced by .zshrc) -# -# FORCE_OVERWRITE=1 auto-yes on existing-file overwrite prompts. - -set -eo pipefail - -DRY_RUN=false -[[ "${1:-}" == "--dry-run" ]] && DRY_RUN=true - -log() { printf "\033[1;34m==>\033[0m %s\n" "$*"; } -skip() { printf "\033[1;33m--\033[0m %s (skipped)\n" "$*"; } - -# Run a command, or print it under --dry-run. Caller passes args individually -# (no shell metacharacters); use explicit `$DRY_RUN || cmd` for pipes/globs. -run() { - if $DRY_RUN; then printf " \033[2;37m[dry] %s\033[0m\n" "$*" - else "$@" - fi -} - -resolve_dotfiles_dir() { - local d - d="$(cd "$(dirname "${BASH_SOURCE[0]:-$0}")" && pwd)" - if [[ -d "$d/.git" ]]; then - echo "$d"; return - fi - d="$HOME/Code/github.com/ericboehs/dotfiles" - mkdir -p "$(dirname "$d")" - [[ -e "$d" ]] || git clone https://github.com/ericboehs/dotfiles "$d" - echo "$d" -} - -install_deps() { - [[ -n "${SKIP_DEPS:-}" ]] && { skip "package deps"; return; } - if [[ "$OSTYPE" == darwin* ]]; then - # SSH non-interactive sessions don't load ~/.zprofile, so brew may not be - # on PATH yet. Find it at the standard Apple Silicon / Intel locations. - if ! command -v brew >/dev/null; then - for p in /opt/homebrew/bin /usr/local/bin; do - [[ -x "$p/brew" ]] && export PATH="$p:$PATH" && break - done - fi - if ! command -v brew >/dev/null; then - log "Homebrew not found; installing" - $DRY_RUN && printf " \033[2;37m[dry] /bin/bash -c \"\$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)\"\033[0m\n" - $DRY_RUN || NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" - [[ -x /opt/homebrew/bin/brew ]] && export PATH="/opt/homebrew/bin:$PATH" - fi - log "Installing Homebrew dependencies" - # tree-sitter (the lib) and tree-sitter-cli are separate formulas; - # nvim-treesitter needs the CLI for parser compilation. - run brew install mise neovim git lsd zoxide fzf \ - zsh-autosuggestions gpg tmux ripgrep fd lua gh terminal-notifier delta \ - tree-sitter-cli - elif [[ "$OSTYPE" == linux-gnu* ]]; then - log "Installing apt dependencies" - run sudo apt-get update -qq - run sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq \ - zsh neovim zoxide fzf zsh-autosuggestions \ - gnupg tmux ripgrep fd-find bat lua5.4 gh git-delta - # lsd is in universe on 22.04+ but missing on minimal images; - # fall back to the upstream .deb from GitHub releases. - if ! sudo DEBIAN_FRONTEND=noninteractive apt-get install -y -qq lsd 2>/dev/null; then - log "lsd not in apt; installing from GitHub release" - local lsd_tmp; lsd_tmp=$(mktemp -d) - $DRY_RUN && printf " \033[2;37m[dry] install lsd .deb from GitHub\033[0m\n" - $DRY_RUN || { - curl -fsSL -o "$lsd_tmp/lsd.deb" \ - "https://github.com/lsd-rs/lsd/releases/latest/download/lsd-musl_$(dpkg --print-architecture).deb" - sudo dpkg -i "$lsd_tmp/lsd.deb" - rm -rf "$lsd_tmp" - } - fi - if ! command -v mise >/dev/null; then - log "Installing mise" - $DRY_RUN && printf " \033[2;37m[dry] curl -fsSL https://mise.run | sh\033[0m\n" - $DRY_RUN || curl -fsSL https://mise.run | sh - fi - log "Linking ~/.local/bin shims for fd/bat (Debian ships fdfind/batcat)" - run mkdir -p "$HOME/.local/bin" - [[ -x /usr/bin/fdfind ]] && run ln -sf /usr/bin/fdfind "$HOME/.local/bin/fd" - [[ -x /usr/bin/batcat ]] && run ln -sf /usr/bin/batcat "$HOME/.local/bin/bat" - if [[ "$SHELL" != "$(command -v zsh)" ]]; then - log "Setting zsh as default shell" - run sudo chsh -s "$(command -v zsh)" "$USER" - fi - fi -} - -install_fonts() { - [[ "$OSTYPE" != darwin* ]] && return - [[ -n "${SKIP_FONTS:-}" ]] && { skip "fonts"; return; } - if [[ -f ~/Library/Fonts/InconsolataNerdFontMono-Regular.ttf ]]; then - skip "Inconsolata Nerd Font (already installed)"; return - fi - log "Installing Inconsolata Nerd Font" - local tmp; tmp=$(mktemp -d) - run curl -fsSL -o "$tmp/Inconsolata.zip" \ - https://github.com/ryanoasis/nerd-fonts/releases/download/v3.4.0/Inconsolata.zip - run unzip -q "$tmp/Inconsolata.zip" -d "$tmp" - run mkdir -p ~/Library/Fonts - $DRY_RUN && printf " \033[2;37m[dry] cp %s/*.ttf ~/Library/Fonts/\033[0m\n" "$tmp" - $DRY_RUN || cp "$tmp"/*.ttf ~/Library/Fonts/ 2>/dev/null || true - run rm -rf "$tmp" -} - -import_iterm_theme() { - [[ "$OSTYPE" != darwin* ]] && return - [[ -n "${SKIP_ITERM:-}" ]] && { skip "iTerm2 theme"; return; } - if [[ ! -d /Applications/iTerm.app && ! -d "$HOME/Applications/iTerm.app" ]]; then - skip "iTerm2 not installed"; return - fi - if plutil -extract 'Custom Color Presets.Catppuccin Mocha' xml1 -o - \ - ~/Library/Preferences/com.googlecode.iterm2.plist >/dev/null 2>&1; then - skip "Catppuccin Mocha (already imported)"; return - fi - log "Importing Catppuccin Mocha for iTerm2" - local tmp; tmp=$(mktemp -d) - run curl -fsSL -o "$tmp/Catppuccin Mocha.itermcolors" \ - "https://raw.githubusercontent.com/mbadolato/iTerm2-Color-Schemes/master/schemes/Catppuccin%20Mocha.itermcolors" - run open "$tmp/Catppuccin Mocha.itermcolors" - run sleep 2 - run rm -rf "$tmp" -} - -apply_macos_defaults() { - [[ "$OSTYPE" != darwin* ]] && return - [[ -n "${SKIP_DEFAULTS:-}" ]] && { skip "macOS defaults"; return; } - [[ ! -x "$DOTFILES_DIR/defaults.sh" ]] && return - log "Applying macOS defaults" - if $DRY_RUN; then - printf " \033[2;37m[dry] %s/defaults.sh\033[0m\n" "$DOTFILES_DIR" - else - "$DOTFILES_DIR/defaults.sh" - fi -} - -link_dotfiles() { - [[ -n "${SKIP_LINK:-}" ]] && { skip "symlinks"; return; } - log "Linking dotfiles into \$HOME" - pushd "$DOTFILES_DIR" > /dev/null - - # Iterate only tracked top-level dotfiles (avoids picking up .DS_Store, - # .claude/, .ruby-lsp/, .zshrc.local, etc. that are gitignored locally). - local files f source_file target_file - files="$(git ls-files -z | tr '\0' '\n' | awk -F/ '/^\./ {print $1}' | sort -u) .ssh/config" - for f in $files; do - case "$f" in - .|..|.git|.gitignore|.gitmodules|.config|.ssh|bootstrap.sh|defaults.sh|README.md|.github|.tmux|.gem|.claude) - continue ;; - esac - source_file="$DOTFILES_DIR/$f" - target_file="$HOME/$(dirname "$f")/$(basename "$f")" - - case "$f" in - .gitconfig.private.example) - [[ "$USER" == "ericboehs" ]] && run ln -fs "$source_file" ~/.gitconfig.private - continue ;; - .gitconfig.va-ghe.example) - [[ "$USER" == "ericboehs" ]] && run ln -fs "$source_file" ~/.gitconfig.va-ghe - continue ;; - .gitignore.global) - run ln -fs "$source_file" ~/.gitignore - continue ;; - esac - - if [[ -e "$HOME/$f" ]]; then - [[ "$(readlink "$HOME/$f")" == "$DOTFILES_DIR/$f" ]] && continue - if [[ -z "${FORCE_OVERWRITE:-}" ]] && ! $DRY_RUN; then - read -r -p "\$HOME/$f exists. Overwrite? [Yn] " yn - case "$yn" in [Nn]*) continue ;; esac - fi - run ln -fs "$source_file" "$HOME/" - else - run ln -fs "$source_file" "$target_file" - fi - done - - run mkdir -p ~/.config - log "Linking nvim config" - run ln -fns "$DOTFILES_DIR/.config/nvim" ~/.config/nvim - log "Linking mise config" - run mkdir -p ~/.config/mise - run ln -fs "$DOTFILES_DIR/.config/mise/config.toml" ~/.config/mise/config.toml - log "Linking lsd config" - run mkdir -p ~/.config/lsd - run ln -fs "$DOTFILES_DIR/.config/lsd/colors.yaml" ~/.config/lsd/colors.yaml - run ln -fs "$DOTFILES_DIR/.config/lsd/config.yaml" ~/.config/lsd/config.yaml - log "Linking gitleaks config" - run mkdir -p ~/.config/gitleaks - run ln -fs "$DOTFILES_DIR/.config/gitleaks/config.toml" ~/.config/gitleaks/config.toml - # btop rewrites btop.conf on exit, so link only the themes. - log "Linking btop themes" - run mkdir -p ~/.config/btop/themes - for theme in "$DOTFILES_DIR"/.config/btop/themes/*.theme; do - run ln -fs "$theme" ~/.config/btop/themes/"$(basename "$theme")" - done - # ~/.claude/ is Claude Code runtime state, so link individual scripts - # rather than the whole directory. - log "Linking Claude Code statusline" - run mkdir -p ~/.claude/scripts - run ln -fs "$DOTFILES_DIR/.claude/scripts/statusline.sh" ~/.claude/scripts/statusline.sh - # ~/.gem/ is runtime state for `gem` (it writes credentials here on push), - # so symlink only the placeholder file rather than the whole directory. - log "Linking gem credentials placeholder" - run mkdir -p ~/.gem - run ln -fs "$DOTFILES_DIR/.gem/credentials" ~/.gem/credentials - if [[ "$OSTYPE" == darwin* ]]; then - log "Linking ghostty config" - run ln -fns "$DOTFILES_DIR/.config/ghostty" ~/.config/ghostty - fi - - # ~/.tmux/ is shared with TPM (~/.tmux/plugins/), so we link individual - # helper scripts rather than the whole directory. extrakto is a submodule - # — link the whole subdir. - run mkdir -p ~/.tmux - log "Linking tmux helpers" - run ln -fs "$DOTFILES_DIR/.tmux/loadavg.sh" ~/.tmux/loadavg.sh - run ln -fs "$DOTFILES_DIR/.tmux/theme-sync.sh" ~/.tmux/theme-sync.sh - run ln -fs "$DOTFILES_DIR/.tmux/tmuxline.dark.conf" ~/.tmux/tmuxline.dark.conf - run ln -fs "$DOTFILES_DIR/.tmux/tmuxline.light.conf" ~/.tmux/tmuxline.light.conf - run ln -fns "$DOTFILES_DIR/.tmux/extrakto" ~/.tmux/extrakto - - popd > /dev/null -} - -trust_mise_config() { - [[ -n "${SKIP_MISE:-}" ]] && { skip "mise trust + install"; return; } - local mise_bin - mise_bin="$(command -v mise || true)" - [[ -z "$mise_bin" && -x "$HOME/.local/bin/mise" ]] && mise_bin="$HOME/.local/bin/mise" - [[ ! -x "$mise_bin" ]] && return - log "Trusting mise config" - run "$mise_bin" trust ~/.config/mise/config.toml - log "Installing mise runtimes (this may take a while)" - run "$mise_bin" install -} - -install_gcd() { - [[ -n "${SKIP_GCD:-}" ]] && { skip "gcd"; return; } - # .zshrc sources gcd from this fixed path; clone it there if missing. Leave an - # existing checkout alone (it may be a working copy with local changes). - local gcd_dir="$HOME/Code/github.com/ericboehs/gcd" - if [[ -d "$gcd_dir/.git" ]]; then - skip "gcd (already cloned)"; return - fi - log "Cloning gcd" - run mkdir -p "$(dirname "$gcd_dir")" - run git clone https://github.com/ericboehs/gcd "$gcd_dir" -} - -install_tpm() { - [[ -n "${SKIP_TPM:-}" ]] && { skip "TPM"; return; } - if [[ ! -d ~/.tmux/plugins/tpm ]]; then - log "Installing TPM" - run git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm - fi - log "Installing tmux plugins" - run ~/.tmux/plugins/tpm/bin/install_plugins -} - -install_nvim_plugins() { - [[ -n "${SKIP_NVIM:-}" ]] && { skip "nvim plugins"; return; } - command -v nvim >/dev/null || { skip "nvim plugins (nvim not installed)"; return; } - log "Syncing Lazy.nvim plugins (headless)" - # `Lazy! sync` is non-interactive (no UI prompts). Stderr can be noisy - # during first install (treesitter parsers compiling); keep it visible. - run nvim --headless "+Lazy! sync" +qa - log "Pre-compiling all treesitter parsers (5–10 min on first run)" - # Fully load nvim-treesitter (LazyVim lazy-loads it by filetype, but - # headless never triggers a filetype event), then block on install. - run nvim --headless "+Lazy! load nvim-treesitter" \ - "+lua require('nvim-treesitter').install('all'):wait(600000)" +qa -} - -# ---- main ---- -DOTFILES_DIR="$(resolve_dotfiles_dir)" -$DRY_RUN && log "DRY RUN — no changes will be made" -log "Dotfiles dir: $DOTFILES_DIR" -mkdir -p ~/.ssh - -pushd "$DOTFILES_DIR" > /dev/null -run git submodule init -run git submodule update -popd > /dev/null - -install_deps -install_fonts -import_iterm_theme -link_dotfiles -install_gcd -trust_mise_config -apply_macos_defaults -install_tpm -install_nvim_plugins - -log "All done. Enjoy your shell." - -# Surface manual steps that bootstrap can't do for you. Skipped under -# --dry-run so the dry-run output still looks like a single dry-run. -if ! $DRY_RUN; then - needs=() - command -v gh >/dev/null && ! gh auth status >/dev/null 2>&1 && \ - needs+=("gh auth login # octo.nvim, gh CLI") - command -v op >/dev/null || \ - needs+=("brew install --cask 1password-cli # for fnox + secrets") - if [[ ${#needs[@]} -gt 0 ]]; then - log "Manual steps still needed:" - for n in "${needs[@]}"; do printf " %s\n" "$n"; done - fi - log "Note: nvim-treesitter installs more parsers on demand the first time" - log " you open files of new types — that's expected, not a bug." -fi diff --git a/defaults.sh b/defaults.sh deleted file mode 100755 index 0b8ec08..0000000 --- a/defaults.sh +++ /dev/null @@ -1,79 +0,0 @@ -#!/usr/bin/env bash -# defaults.sh — opinionated macOS system tweaks. Idempotent. -# Some changes require app restart or logout to take effect. - -set -eo pipefail - -[[ "$OSTYPE" != darwin* ]] && exit 0 - -log() { printf "\033[1;34m==>\033[0m %s\n" "$*"; } - -# Quit System Settings so it doesn't override our writes. -osascript -e 'tell application "System Settings" to quit' 2>/dev/null || true - -log "Finder: show extensions, hidden files, path/status bar, list view" -defaults write NSGlobalDomain AppleShowAllExtensions -bool true -defaults write com.apple.finder AppleShowAllFiles -bool true -defaults write com.apple.finder ShowPathbar -bool true -defaults write com.apple.finder ShowStatusBar -bool true -defaults write com.apple.finder FXPreferredViewStyle -string "Nlsv" -defaults write com.apple.finder FXDefaultSearchScope -string "SCcf" -defaults write com.apple.finder _FXShowPosixPathInTitle -bool true -defaults write com.apple.finder _FXSortFoldersFirst -bool true -defaults write NSGlobalDomain NSNavPanelExpandedStateForSaveMode -bool true -defaults write NSGlobalDomain NSNavPanelExpandedStateForSaveMode2 -bool true - -log "Finder: disable .DS_Store on network/USB volumes" -defaults write com.apple.desktopservices DSDontWriteNetworkStores -bool true -defaults write com.apple.desktopservices DSDontWriteUSBStores -bool true - -log "Screenshots: ~/Pictures/Screenshots, PNG, no shadow" -mkdir -p ~/Pictures/Screenshots -defaults write com.apple.screencapture location -string "$HOME/Pictures/Screenshots" -defaults write com.apple.screencapture type -string "png" -defaults write com.apple.screencapture disable-shadow -bool true - -log "Keyboard: faster repeat, no press-and-hold, full keyboard nav" -defaults write NSGlobalDomain InitialKeyRepeat -int 15 -defaults write NSGlobalDomain KeyRepeat -int 2 -defaults write NSGlobalDomain ApplePressAndHoldEnabled -bool false -defaults write NSGlobalDomain AppleKeyboardUIMode -int 3 - -log "Disable autocorrect / smart quotes / smart dashes / period substitution" -defaults write NSGlobalDomain NSAutomaticSpellingCorrectionEnabled -bool false -defaults write NSGlobalDomain NSAutomaticDashSubstitutionEnabled -bool false -defaults write NSGlobalDomain NSAutomaticQuoteSubstitutionEnabled -bool false -defaults write NSGlobalDomain NSAutomaticPeriodSubstitutionEnabled -bool false - -log "Dock: smaller, no recents, no bouncing, scale minimize" -defaults write com.apple.dock tilesize -int 36 -defaults write com.apple.dock show-recents -bool false -defaults write com.apple.dock no-bouncing -bool true -defaults write com.apple.dock mineffect -string "scale" - -log "Mission Control: don't auto-rearrange spaces" -defaults write com.apple.dock mru-spaces -bool false - -log "Trackpad: tap to click" -defaults write com.apple.driver.AppleBluetoothMultitouch.trackpad Clicking -bool true -defaults write com.apple.AppleMultitouchTrackpad Clicking -bool true -defaults -currentHost write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 - -log "Safari: full URL, dev menu, do-not-track" -defaults write com.apple.Safari ShowFullURLInSmartSearchField -bool true -defaults write com.apple.Safari IncludeDevelopMenu -bool true -defaults write com.apple.Safari WebKitDeveloperExtrasEnabledPreferenceKey -bool true -defaults write com.apple.Safari "com.apple.Safari.ContentPageGroupIdentifier.WebKit2DeveloperExtrasEnabled" -bool true -defaults write com.apple.Safari SendDoNotTrackHTTPHeader -bool true - -log "TextEdit: plain text default, UTF-8" -defaults write com.apple.TextEdit RichText -int 0 -defaults write com.apple.TextEdit PlainTextEncoding -int 4 -defaults write com.apple.TextEdit PlainTextEncodingForWrite -int 4 - -log "Restart affected apps (Finder, Dock, SystemUIServer)" -for app in Finder Dock SystemUIServer; do - killall "$app" >/dev/null 2>&1 || true -done - -log "Done. Some changes may require logout to fully apply." diff --git a/mise.toml b/mise.toml new file mode 100644 index 0000000..26559a7 --- /dev/null +++ b/mise.toml @@ -0,0 +1,304 @@ +# mise.toml — declarative machine setup, run with `mise bootstrap` from this dir. +# +# mise bootstrap -n # preview everything +# mise bootstrap # converge +# mise bootstrap --only dotfiles # just the symlinks +# mise bootstrap --skip macos-defaults # everything but the system prefs +# +# Prerequisites (chicken-and-egg — mise has to exist before it can bootstrap): +# curl -fsSL https://mise.run | sh +# git clone https://github.com/ericboehs/dotfiles ~/Code/github.com/ericboehs/dotfiles +# cd ~/Code/github.com/ericboehs/dotfiles && mise trust && mise bootstrap + +# [bootstrap.packages] entries below use per-package `os` filters, which older +# mise rejects with a bare "invalid type: map, expected a string". +min_version = "2026.8.4" + +[env] +REPO_PATH = "ericboehs/dotfiles" + +# --------------------------------------------------------------------------- +# System packages +# --------------------------------------------------------------------------- +# Anything that must exist before `mise install` can build runtimes belongs +# here rather than in [tools]. +# +# The manager prefix is NOT a platform filter in both directions: apt is +# absent on macOS so those entries skip themselves, but Homebrew runs on Linux +# too, so an unfiltered brew list gets installed there as well — duplicating +# the apt list and hard-failing on macOS-only formulas like terminal-notifier, +# which mise tries to build from source and dies on xcodebuild. Hence explicit +# `os` on every entry. + +[bootstrap.packages] +# macOS — brew +"brew:neovim" = { version = "latest", os = ["macos"] } +"brew:git" = { version = "latest", os = ["macos"] } +"brew:lsd" = { version = "latest", os = ["macos"] } +"brew:zoxide" = { version = "latest", os = ["macos"] } +"brew:fzf" = { version = "latest", os = ["macos"] } +"brew:zsh-autosuggestions" = { version = "latest", os = ["macos"] } +"brew:gnupg" = { version = "latest", os = ["macos"] } +"brew:tmux" = { version = "latest", os = ["macos"] } +"brew:ripgrep" = { version = "latest", os = ["macos"] } +"brew:fd" = { version = "latest", os = ["macos"] } +"brew:lua" = { version = "latest", os = ["macos"] } +"brew:gh" = { version = "latest", os = ["macos"] } +"brew:terminal-notifier" = { version = "latest", os = ["macos"] } +# `brew install delta` resolves to git-delta; name it canonically. +"brew:git-delta" = { version = "latest", os = ["macos"] } +# tree-sitter (the lib) and tree-sitter-cli are separate formulas; +# nvim-treesitter needs the CLI for parser compilation. +"brew:tree-sitter-cli" = { version = "latest", os = ["macos"] } +"brew-cask:font-inconsolata-nerd-font" = { version = "latest", os = ["macos"] } + +# Linux — apt +"apt:zsh" = { version = "latest", os = ["linux"] } +"apt:neovim" = { version = "latest", os = ["linux"] } +"apt:zoxide" = { version = "latest", os = ["linux"] } +"apt:fzf" = { version = "latest", os = ["linux"] } +"apt:zsh-autosuggestions" = { version = "latest", os = ["linux"] } +"apt:gnupg" = { version = "latest", os = ["linux"] } +"apt:tmux" = { version = "latest", os = ["linux"] } +"apt:ripgrep" = { version = "latest", os = ["linux"] } +"apt:fd-find" = { version = "latest", os = ["linux"] } +"apt:bat" = { version = "latest", os = ["linux"] } +"apt:lua5.4" = { version = "latest", os = ["linux"] } +"apt:gh" = { version = "latest", os = ["linux"] } +"apt:git-delta" = { version = "latest", os = ["linux"] } +# lsd is in universe on 22.04+ but missing on minimal images; the +# bootstrap:linux-shims task installs the upstream .deb if this no-ops. +"apt:lsd" = { version = "latest", os = ["linux"] } + +# --------------------------------------------------------------------------- +# Git checkouts +# --------------------------------------------------------------------------- +# TPM is deliberately NOT here: repos are cloned before dotfiles, so cloning +# into ~/.tmux/plugins/ would create a real ~/.tmux and block the symlink +# below. It lives in the bootstrap:tpm task, which runs last. + +[bootstrap.repos] +# .zsh/functions.zsh sources gcd from this fixed path. +"~/Code/github.com/ericboehs/gcd" = { url = "https://github.com/ericboehs/gcd" } + +# --------------------------------------------------------------------------- +# Dotfiles +# --------------------------------------------------------------------------- +# Sources are relative to this file. Modes: +# symlink — one link for the whole file or directory (the default) +# symlink-each — real directory, one link per entry, recursing into +# subdirectories; use it where something outside the repo +# also writes into the target directory + +[dotfiles] +"~/.aprc" = ".aprc" +"~/.bundle" = ".bundle" +"~/.default-gems" = ".default-gems" +"~/.fzf.zsh" = ".fzf.zsh" +"~/.gemrc" = ".gemrc" +"~/.gitconfig" = ".gitconfig" +"~/.npmrc" = ".npmrc" +"~/.p10k.zsh" = ".p10k.zsh" +"~/.rgignore" = ".rgignore" +"~/.tmux.conf" = ".tmux.conf" +"~/.zprofile" = ".zprofile" +"~/.zshrc" = ".zshrc" +# Contains the zsh plugin submodules; .zshrc sources them from $HOME/.zsh/. +"~/.zsh" = ".zsh" +# Renames: the repo tracks .example/.global names, $HOME wants the real ones. +"~/.gitignore" = ".gitignore.global" +"~/.gitconfig.private" = ".gitconfig.private.example" +"~/.gitconfig.va-ghe" = ".gitconfig.va-ghe.example" +# TPM installs into ~/.tmux/plugins/, which .gitignore excludes, so the whole +# directory can be one link — which is also what gets every helper script +# .tmux.conf binds (agenda.sh, btop.sh, goto-window.sh, move-window.sh, +# session-jump.sh, theme-sync.sh) onto a fresh machine. +"~/.tmux" = ".tmux" + +"~/.config/nvim" = ".config/nvim" +"~/.config/mise/config.toml" = ".config/mise/config.toml" +# btop rewrites btop.conf on exit, so only the themes directory is managed. +"~/.config/btop/themes" = { source = ".config/btop/themes", mode = "symlink-each" } +"~/.config/lsd" = { source = ".config/lsd", mode = "symlink-each" } +"~/.config/gitleaks" = { source = ".config/gitleaks", mode = "symlink-each" } +# Ghostty writes nothing here, but $HOME already has a real directory. +"~/.config/ghostty" = { source = ".config/ghostty", mode = "symlink-each" } + +# ~/.claude/ is Claude Code runtime state and ~/.gem/ is runtime state for +# `gem` (it writes credentials there on push), so link individual files. +"~/.claude/scripts/statusline.sh" = ".claude/scripts/statusline.sh" +"~/.gem/credentials" = ".gem/credentials" + +# --------------------------------------------------------------------------- +# macOS defaults +# --------------------------------------------------------------------------- +# Curated sections compile down to the same `defaults write` calls but read +# better; raw [bootstrap.macos.defaults] covers everything else. + +[bootstrap.macos.finder] +show_all_files = true +show_pathbar = true +show_status_bar = true +preferred_view_style = "list" + +[bootstrap.macos.dock] +tilesize = 36 +show_recents = false +mru_spaces = false + +[bootstrap.macos.keyboard] +initial_key_repeat = 15 +key_repeat = 2 +press_and_hold = false + +[bootstrap.macos.trackpad] +tap_to_click = true + +[bootstrap.macos.defaults.NSGlobalDomain] +AppleShowAllExtensions = true +AppleKeyboardUIMode = 3 +NSNavPanelExpandedStateForSaveMode = true +NSNavPanelExpandedStateForSaveMode2 = true +NSAutomaticSpellingCorrectionEnabled = false +NSAutomaticDashSubstitutionEnabled = false +NSAutomaticQuoteSubstitutionEnabled = false +NSAutomaticPeriodSubstitutionEnabled = false + +[bootstrap.macos.defaults."com.apple.finder"] +FXDefaultSearchScope = "SCcf" +_FXShowPosixPathInTitle = true +_FXSortFoldersFirst = true + +[bootstrap.macos.defaults."com.apple.desktopservices"] +DSDontWriteNetworkStores = true +DSDontWriteUSBStores = true + +# `location` is set in the pre-defaults hook instead: values here are written +# literally, with no template expansion or tilde handling, so there's no way +# to spell $HOME. +[bootstrap.macos.defaults."com.apple.screencapture"] +type = "png" +disable-shadow = true + +[bootstrap.macos.defaults."com.apple.dock"] +no-bouncing = true +mineffect = "scale" + +[bootstrap.macos.defaults."com.apple.Safari"] +ShowFullURLInSmartSearchField = true +IncludeDevelopMenu = true +WebKitDeveloperExtrasEnabledPreferenceKey = true +"com.apple.Safari.ContentPageGroupIdentifier.WebKit2DeveloperExtrasEnabled" = true +SendDoNotTrackHTTPHeader = true + +[bootstrap.macos.defaults."com.apple.TextEdit"] +RichText = 0 +PlainTextEncoding = 4 +PlainTextEncodingForWrite = 4 + +# --------------------------------------------------------------------------- +# Hooks — the imperative bits that have no declarative equivalent +# --------------------------------------------------------------------------- +# Hooks fire regardless of platform, so each guards itself on `uname`. + +[bootstrap.hooks] +pre-packages = ''' +if [ "$(uname)" = Darwin ] && ! command -v brew >/dev/null; then + # SSH non-interactive sessions don't load ~/.zprofile, so look in the + # standard Apple Silicon / Intel locations before installing. + for p in /opt/homebrew/bin /usr/local/bin; do + [ -x "$p/brew" ] && export PATH="$p:$PATH" && break + done + if ! command -v brew >/dev/null; then + NONINTERACTIVE=1 /bin/bash -c \ + "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" + fi +fi +if [ "$(uname)" = Linux ] && command -v apt-get >/dev/null; then + sudo apt-get update -qq +fi +''' + +# Submodules back .zsh/{powerlevel10k,fast-syntax-highlighting, +# zsh-autosuggestions} and .tmux/extrakto, all of which are symlinked into +# $HOME as part of their parent directory — so they must be populated first. +pre-dotfiles = "git submodule update --init --recursive" + +pre-defaults = ''' +[ "$(uname)" = Darwin ] || exit 0 +# Quit System Settings so it doesn't override our writes. +osascript -e 'tell application "System Settings" to quit' 2>/dev/null || true +mkdir -p ~/Pictures/Screenshots +defaults write com.apple.screencapture location -string "$HOME/Pictures/Screenshots" +''' + +post-defaults = ''' +[ "$(uname)" = Darwin ] || exit 0 +# Per-host, so it can't be expressed in [bootstrap.macos.defaults]. +defaults -currentHost write NSGlobalDomain com.apple.mouse.tapBehavior -int 1 +for app in Finder Dock SystemUIServer; do + killall "$app" >/dev/null 2>&1 || true +done +''' + +final = ''' +command -v gh >/dev/null && ! gh auth status >/dev/null 2>&1 && \ + echo " gh auth login # octo.nvim, gh CLI" +command -v op >/dev/null || \ + echo " brew install --cask 1password-cli # for fnox + secrets" +echo "Note: nvim-treesitter installs more parsers on demand the first time" +echo " you open files of new types — that's expected, not a bug." +''' + +# --------------------------------------------------------------------------- +# Tasks — run last, after packages, dotfiles and [tools] are all in place +# --------------------------------------------------------------------------- + +[tasks.bootstrap] +description = "Post-converge setup that needs the installed tools on PATH" +depends = ["bootstrap:linux-shims", "bootstrap:tpm", "bootstrap:nvim"] + +[tasks."bootstrap:linux-shims"] +description = "Debian binary-name shims, lsd fallback, zsh login shell" +run = ''' +[ "$(uname)" = Linux ] || exit 0 +# Debian ships fd as fdfind and bat as batcat. +mkdir -p "$HOME/.local/bin" +[ -x /usr/bin/fdfind ] && ln -sf /usr/bin/fdfind "$HOME/.local/bin/fd" +[ -x /usr/bin/batcat ] && ln -sf /usr/bin/batcat "$HOME/.local/bin/bat" +# apt:lsd silently no-ops on minimal images; fall back to the GitHub release. +if ! command -v lsd >/dev/null; then + tmp=$(mktemp -d) + curl -fsSL -o "$tmp/lsd.deb" \ + "https://github.com/lsd-rs/lsd/releases/latest/download/lsd-musl_$(dpkg --print-architecture).deb" + sudo dpkg -i "$tmp/lsd.deb" + rm -rf "$tmp" +fi +# [bootstrap.user].login_shell takes one absolute path and can't vary by +# platform; macOS already defaults to zsh, so only Linux needs this. +if [ "$SHELL" != "$(command -v zsh)" ]; then + sudo chsh -s "$(command -v zsh)" "$USER" +fi +''' + +[tasks."bootstrap:tpm"] +description = "Install tmux plugin manager and its plugins" +run = ''' +[ -d ~/.tmux/plugins/tpm ] || \ + git clone https://github.com/tmux-plugins/tpm ~/.tmux/plugins/tpm +~/.tmux/plugins/tpm/bin/install_plugins +''' + +[tasks."bootstrap:nvim"] +description = "Sync Lazy.nvim plugins and pre-compile treesitter parsers" +run = ''' +command -v nvim >/dev/null || exit 0 +# `Lazy! sync` is non-interactive (no UI prompts). Stderr can be noisy +# during first install (treesitter parsers compiling); keep it visible. +nvim --headless "+Lazy! sync" +qa +# Fully load nvim-treesitter (LazyVim lazy-loads it by filetype, but +# headless never triggers a filetype event), then block on install. +# 5-10 min on first run. +nvim --headless "+Lazy! load nvim-treesitter" \ + "+lua require('nvim-treesitter').install('all'):wait(600000)" +qa +'''