Keyboard shortcuts

Press or to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Non-admin · new Mac (Apple Silicon)

You have no admin rights, but Homebrew works here when installed into your home directory (~/brew). Set up your terminal + font first, then let brew handle the rest — GUI apps (casks → ~/Applications, no admin) and the non-problematic formulae — and manual-tools.sh install the manual-install list (coursier, direnv, bat, awscli, tinytex) as prebuilt binaries. (Those would otherwise build from source under ~/brew’s custom prefix — slow or failing — which is why they’re manual.) Goal: maximum no-admin, no password prompts.

1. Install Homebrew (no admin)

git clone https://github.com/Homebrew/brew ~/brew

Add to ~/.zprofile, then restart your terminal:

eval "$("$HOME"/brew/bin/brew shellenv)"
export HOMEBREW_CASK_OPTS="--appdir=$HOME/Applications"   # casks -> ~/Applications, no admin

HOMEBREW_CASK_OPTS=--appdir is the key: it makes brew install --cask … drop apps into ~/Applications instead of /Applications, so no app install asks for an admin password.

2. Terminal + font first

Install iTerm2 and the FiraCode Nerd Font before anything else, then configure iTerm and do the rest of the setup from inside it:

brew install --cask iterm2 font-fira-code-nerd-font

iTerm2 configuration

(Once iTerm2 is installed — by brew cask on the admin path, or into ~/Applications by manual-tools.sh on the non-admin paths — configure it as below.)

Global options (non-admin friendly)

show tabs

hide from dock

New profile (visor, font, colors, scrollback, keybindings)

create new profile

name the profile when created

name visor profile

set visor profile to default

set color presets

change font

change window style

unlimited scrollback

z / alt_c binding + hotkey button

hotkey popup options

3. The rest via brew

curl -L -o Brewfile https://raw.githubusercontent.com/teeckoo/mac-setup/main/Brewfile
brew bundle --verbose      # casks (GUI + font) + the non-problematic formulae
rm Brewfile

brew bundle installs the GUI apps as casks (VS Code, IntelliJ, Chrome, Firefox, Bruno, Slack — into ~/Applications; re-lists iTerm2 + font, already-installed casks are skipped) and the rest of the CLI tools (gkit, rg, gum, jq, node, sdk, starship, fzf, antidote, plus containers/cloud/git tools…). All no admin.

Why not everything via brew? Your ~/brew is a custom prefix, so Homebrew can’t use bottles and builds formulae from source. Casks are unaffected (prebuilt downloads), and the “rest” formulae build fast enough — but a handful are slow or fail to build from source. Those are the manual-install list, handled next.

4. The “install manually” list (prebuilt, not brew)

curl -fsSL https://raw.githubusercontent.com/teeckoo/mac-setup/main/manual-tools.sh | bash

manual-tools.sh installs the manual-install list as prebuilt binaries (so they don’t source-build under ~/brew): coursier (cs), direnv, bat, awscli, tinytex (no brew formula), R (the CRAN build relocated into ~/.local — no admin, since the r cask prompts for one), mdbook (a Rust build that ~/brew would compile from source), and container devcolima + lima + the docker CLI (colima start --vm-type vz, no admin — colima is the Docker engine). It prints guidance for eza (no macOS binary — ls/cargo install eza), wget (use curl), and gnupg (SSH commit signing).

Shell config (~/.zprofile + ~/.zshrc)

Homebrew is on PATH already (its shellenv), so you only need the interactive block.

~/.zshrc:

# ---- System ----
ulimit -n 4096            # prevent "too many open files"
setopt AUTO_CD            # cd by typing a folder name
chpwd() { ls -C; }        # listing after each cd
export DIRENV_LOG_FORMAT=""

# ---- Completions (before plugins/SDKMAN, which call compdef) ----
autoload -Uz compinit
compinit -i

# ---- Zsh plugins (antidote) ----
source "$(brew --prefix)/opt/antidote/share/antidote/antidote.zsh"
antidote load < ~/.zsh_plugins.txt

# ---- Prompt (starship) ----
eval "$(starship init zsh)"

# ---- fzf ----
[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh

# ---- direnv ----
command -v direnv >/dev/null 2>&1 && eval "$(direnv hook zsh)"

# ---- SDKMAN ----
[ -d ~/.sdkman ] && export SDKMAN_DIR="$HOME/.sdkman"
[ -d ~/.sdkman ] || export SDKMAN_DIR="$(brew --prefix sdkman-cli)/libexec"
[[ -s "$SDKMAN_DIR/bin/sdkman-init.sh" ]] && source "$SDKMAN_DIR/bin/sdkman-init.sh"

Open a new shell to auto-install the plugins.

starship prompt

Create ~/.config/starship.toml:

format = """
${env_var.OPTIONAL_PROMPT_HEADER}\
$directory $git_branch$git_status$all${env_var.OPTIONAL_PROMPT_FOOTER}
$character"""

[line_break]
disabled = false

[directory]
truncation_length = 0
truncate_to_repo = false

[env_var.OPTIONAL_PROMPT_HEADER]
variable = "OPTIONAL_PROMPT_HEADER"
# The \n is placed inside the string format so it only triggers if text exists
format = "[$env_value]($style)\n"
style = "bold blue"

[env_var.OPTIONAL_PROMPT_FOOTER]
variable = "OPTIONAL_PROMPT_FOOTER"
format = "[$env_value]($style)\n" # Newline at the START
style = "bold yellow"

(starship reads this automatically; the ~/.zshrc block below runs starship init zsh.)

Zsh plugins (antidote)

Create ~/.zsh_plugins.txt:

zsh-users/zsh-autosuggestions
zsh-users/zsh-syntax-highlighting

antidote loads this on every interactive shell (see the ~/.zshrc block). Add more plugins here as you like.

fzf (fuzzy finder)

When the fzf installer prompts, answer:

PromptAnswer
fuzzy auto-completionyes
key bindingsyes
update shell config filesno

(“no” on the last one because the ~/.zshrc block already sources ~/.fzf.zsh.)

git log pager

Keep git log on one screen (git ships with the Xcode Command Line Tools):

git config --global --replace-all core.pager "less -F -X"

Troubleshooting

Gatekeeper “cannot be opened”. macOS may quarantine a downloaded binary on first run. Clear it:

xattr -dr com.apple.quarantine ~/.local/bin

compinit prompts every shell / command not found: compdef. A leftover Homebrew site-functions dir owned by the old admin makes a bare compinit refuse to run. The ~/.zshrc block uses compinit -i (ignore insecure dirs) before the plugin/SDKMAN lines — keep that order.

npm i -g permission denied. Global installs are redirected to ~/.local (npm config set prefix ~/.local), so packages land in ~/.local/bin (already on PATH). After a major Node LTS bump, packages with native bindings may need a npm i -g reinstall to match the new ABI.

Re-running is safe (idempotent)

Both perfect-bottles.sh and manual-tools.sh re-run safely — to finish a partial install or refresh to latest. CLI binaries and vendor installers overwrite in place (no duplicates); Node wipes/reinstalls latest LTS (global packages survive); GUI apps are replaced in place (a failed download leaves the old .app); antidote/fzf are left alone if present; an unreachable source keeps its existing copy and is logged. There’s no local cache, so a refresh re-downloads (~200 MB Node, ~2 GB GUI apps).

Admin · new Mac (Homebrew)

You have admin rights on this Mac, so Homebrew does everything — CLI tools, GUI apps (casks), and fonts — from one Brewfile. Set up your terminal + font first, then install the rest from inside iTerm.

1. Terminal + font first

brew install --cask iterm2 font-fira-code-nerd-font

iTerm2 configuration

(Once iTerm2 is installed — by brew cask on the admin path, or into ~/Applications by manual-tools.sh on the non-admin paths — configure it as below.)

Global options (non-admin friendly)

show tabs

hide from dock

New profile (visor, font, colors, scrollback, keybindings)

create new profile

name the profile when created

name visor profile

set visor profile to default

set color presets

change font

change window style

unlimited scrollback

z / alt_c binding + hotkey button

hotkey popup options

2. Install everything — all brew

base=https://raw.githubusercontent.com/teeckoo/mac-setup/main
curl -L -o Brewfile       "$base/Brewfile"
curl -L -o Brewfile.admin "$base/Brewfile.admin"
brew bundle --verbose                          # the Brewfile ("the rest"): casks + formulae
brew bundle --verbose --file=Brewfile.admin    # extras: coursier, direnv, wget, awscli, bat,
                                               #   gnupg, eza, mdbook, colima, docker, MacTeX
rm Brewfile Brewfile.admin

100% brew, no manual steps. The Brewfile is “the rest”; Brewfile.admin holds the packages kept out of it for the non-admin path (they’d source-build under a non-admin ~/brew, but bottle fine here). Container dev: colima start --vm-type vz, then docker …. LaTeX comes from the mactex-no-gui cask (TinyTeX has no Homebrew formula). The no-admin helper scripts (perfect-bottles.sh, manual-tools.sh) are not needed on this path.

Shell config (~/.zprofile + ~/.zshrc)

Homebrew is on PATH already (its shellenv), so you only need the interactive block.

~/.zshrc:

# ---- System ----
ulimit -n 4096            # prevent "too many open files"
setopt AUTO_CD            # cd by typing a folder name
chpwd() { ls -C; }        # listing after each cd
export DIRENV_LOG_FORMAT=""

# ---- Completions (before plugins/SDKMAN, which call compdef) ----
autoload -Uz compinit
compinit -i

# ---- Zsh plugins (antidote) ----
source "$(brew --prefix)/opt/antidote/share/antidote/antidote.zsh"
antidote load < ~/.zsh_plugins.txt

# ---- Prompt (starship) ----
eval "$(starship init zsh)"

# ---- fzf ----
[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh

# ---- direnv ----
command -v direnv >/dev/null 2>&1 && eval "$(direnv hook zsh)"

# ---- SDKMAN ----
[ -d ~/.sdkman ] && export SDKMAN_DIR="$HOME/.sdkman"
[ -d ~/.sdkman ] || export SDKMAN_DIR="$(brew --prefix sdkman-cli)/libexec"
[[ -s "$SDKMAN_DIR/bin/sdkman-init.sh" ]] && source "$SDKMAN_DIR/bin/sdkman-init.sh"

Open a new shell to auto-install the plugins.

starship prompt

Create ~/.config/starship.toml:

format = """
${env_var.OPTIONAL_PROMPT_HEADER}\
$directory $git_branch$git_status$all${env_var.OPTIONAL_PROMPT_FOOTER}
$character"""

[line_break]
disabled = false

[directory]
truncation_length = 0
truncate_to_repo = false

[env_var.OPTIONAL_PROMPT_HEADER]
variable = "OPTIONAL_PROMPT_HEADER"
# The \n is placed inside the string format so it only triggers if text exists
format = "[$env_value]($style)\n"
style = "bold blue"

[env_var.OPTIONAL_PROMPT_FOOTER]
variable = "OPTIONAL_PROMPT_FOOTER"
format = "[$env_value]($style)\n" # Newline at the START
style = "bold yellow"

(starship reads this automatically; the ~/.zshrc block below runs starship init zsh.)

Zsh plugins (antidote)

Create ~/.zsh_plugins.txt:

zsh-users/zsh-autosuggestions
zsh-users/zsh-syntax-highlighting

antidote loads this on every interactive shell (see the ~/.zshrc block). Add more plugins here as you like.

fzf (fuzzy finder)

When the fzf installer prompts, answer:

PromptAnswer
fuzzy auto-completionyes
key bindingsyes
update shell config filesno

(“no” on the last one because the ~/.zshrc block already sources ~/.fzf.zsh.)

git log pager

Keep git log on one screen (git ships with the Xcode Command Line Tools):

git config --global --replace-all core.pager "less -F -X"

Troubleshooting

Gatekeeper “cannot be opened”. macOS may quarantine a downloaded binary on first run. Clear it:

xattr -dr com.apple.quarantine ~/.local/bin

compinit prompts every shell / command not found: compdef. A leftover Homebrew site-functions dir owned by the old admin makes a bare compinit refuse to run. The ~/.zshrc block uses compinit -i (ignore insecure dirs) before the plugin/SDKMAN lines — keep that order.

npm i -g permission denied. Global installs are redirected to ~/.local (npm config set prefix ~/.local), so packages land in ~/.local/bin (already on PATH). After a major Node LTS bump, packages with native bindings may need a npm i -g reinstall to match the new ABI.

Re-running is safe (idempotent)

Both perfect-bottles.sh and manual-tools.sh re-run safely — to finish a partial install or refresh to latest. CLI binaries and vendor installers overwrite in place (no duplicates); Node wipes/reinstalls latest LTS (global packages survive); GUI apps are replaced in place (a failed download leaves the old .app); antidote/fzf are left alone if present; an unreachable source keeps its existing copy and is logged. There’s no local cache, so a refresh re-downloads (~200 MB Node, ~2 GB GUI apps).

Non-admin · old Intel (no Homebrew)

An Intel (x86_64) Mac with no admin rights, where Homebrew is impractical (it compiles from source). Everything installs into your home directory — no brew, no admin — via two scripts: perfect-bottles.sh (CLI tools as prebuilt binaries) and manual-tools.sh (GUI apps + a few extras).

1. Terminal + font first (no admin)

Get iTerm2 and the FiraCode Nerd Font into your home directory, configure iTerm, and switch to it — then install everything else from inside it. (No /tmp on this box, so downloads go under ~/.cache.)

mkdir -p ~/Applications ~/Library/Fonts ~/.cache/dl
# iTerm2 -> ~/Applications (no admin)
curl -fsSL https://iterm2.com/downloads/stable/latest -o ~/.cache/dl/iterm.zip
ditto -x -k ~/.cache/dl/iterm.zip ~/Applications/
xattr -dr com.apple.quarantine ~/Applications/iTerm.app
# FiraCode Nerd Font -> ~/Library/Fonts
curl -fsSL https://github.com/ryanoasis/nerd-fonts/releases/latest/download/FiraCode.zip -o ~/.cache/dl/FiraCode.zip
unzip -qo ~/.cache/dl/FiraCode.zip -d ~/.cache/dl/fc && cp ~/.cache/dl/fc/*.ttf ~/Library/Fonts/

iTerm2 configuration

(Once iTerm2 is installed — by brew cask on the admin path, or into ~/Applications by manual-tools.sh on the non-admin paths — configure it as below.)

Global options (non-admin friendly)

show tabs

hide from dock

New profile (visor, font, colors, scrollback, keybindings)

create new profile

name the profile when created

name visor profile

set visor profile to default

set color presets

change font

change window style

unlimited scrollback

z / alt_c binding + hotkey button

hotkey popup options

2. Install everything else

From your configured iTerm:

base=https://raw.githubusercontent.com/teeckoo/mac-setup/main
curl -fsSL "$base/perfect-bottles.sh" | bash     # CLI tools (+ refreshes the font) -> $HOME
curl -fsSL "$base/manual-tools.sh"    | bash     # GUI apps + extras (+ refreshes iTerm2)

perfect-bottles.sh installs gkit, rg, gum, glow, pandoc, mdbook, jq, git-filter-repo, uv, starship, claude, node+npm/npx, sdk (SDKMAN), antidote, fzf (and the FiraCode font). It auto-loads the shared lib-no-brew.sh (local copy if present, else fetched), so the one-liner works with no clone. The scripts re-install iTerm2 + the font from step 1 in place — harmless.

GUI apps (no admin password)

manual-tools.sh installs these into ~/Applications by copying the .app out of the vendor .dmg/.zip and clearing the Gatekeeper quarantine flag — no sudo, no admin prompt (unlike brew casks, which install to /Applications and ask for an admin password):

AppSourceArch
iTerm2iterm2.com/downloads/stable/latestuniversal
VS Codeupdate.code.visualstudio.com/latest/<darwin|darwin-arm64>/stableper-arch
IntelliJ IDEAdownload.jetbrains.com/product?code=IIU&latest&distribution=<mac|macM1>per-arch
Google Chromedl.google.com/.../googlechrome.dmguniversal
Firefoxdownload.mozilla.org/?product=firefox-latest-ssl&os=osxuniversal
Brunolatest GitHub release, *_<x64_mac|arm64_mac>.dmgper-arch
Slackslack.com/ssb/download-osxuniversal

A failed download is logged and skipped — re-run to retry; existing apps are replaced in place. (Opera and Chromium aren’t automated — no clean signed download; grab them by hand if needed.)

On Intel, manual-tools.sh also installs coursier (cs), direnv, bat as prebuilt binaries, awscli via the no-admin user pkg, R (the CRAN build — its .pkg is expanded and R.framework relocated into ~/.local, with R/Rscript on PATH; no admin), and container dev (colima + lima + the docker CLI — colima start --vm-type vz, no admin). It prints guidance for tinytex (its installer prompts for admin on Ventura), eza (no macOS binary — use ls), wget (use curl), and gnupg (no no-admin path — consider SSH commit signing).

3. Shell config (~/.zprofile + ~/.zshrc)

perfect-bottles.sh appends both blocks for you (marker-guarded; your own content is left untouched). Shown here for reference / manual paste:

~/.zprofile:

# ---- no-brew base config (perfect-bottles.sh) ----
export PATH="$HOME/.local/bin:$HOME/.cargo/bin:$PATH"
export DIRENV_LOG_FORMAT=""

~/.zshrc:

. "$HOME/.cargo/env"

. "$HOME/.local/bin/env"

# ---- no-brew base config (perfect-bottles.sh) ----

# ---- System ----
ulimit -n 4096            # prevent "too many open files"
setopt AUTO_CD            # cd by typing a folder name

# ---- Unified Directory Change Hooks ----
autoload -U add-zsh-hook

# Global path tracking marker (safely preserved)
typeset -g _LAST_LISTED_DIR=""

# 1. Your implicit local environment script processor
_chpwd_trigger_lenvrc() {
  if [[ -f ".dir.session" ]]; then
    source ".dir.session"
  fi
}
add-zsh-hook chpwd _chpwd_trigger_lenvrc

# ---- Completions (must run before SDKMAN/plugins, which call compdef) ----
autoload -Uz compinit
compinit -i               # -i: ignore "insecure" fpath dirs instead of prompting

# ---- Zsh plugins (antidote, cloned to ~/.antidote) ----
source "$HOME/.antidote/antidote.zsh"
antidote load < ~/.zsh_plugins.txt

# ---- Prompt (starship) ----
eval "$(starship init zsh)"

# ---- fzf (installed to ~/.fzf) ----
[ -f ~/.fzf.zsh ] && source ~/.fzf.zsh

# ---- direnv (installed by manual-tools.sh; guarded so this is a no-op if absent) ----
command -v direnv >/dev/null 2>&1 && eval "$(direnv hook zsh)"

#THIS MUST BE AT THE END OF THE FILE FOR SDKMAN TO WORK!!!
export SDKMAN_DIR="$HOME/.sdkman"
[[ -s "$HOME/.sdkman/bin/sdkman-init.sh" ]] && source "$HOME/.sdkman/bin/sdkman-init.sh"

SDKMAN. perfect-bottles.sh installs SDKMAN into ~/.sdkman by running its official installer under zsh — SDKMAN’s bash-4 gate only fires under bash, and stock Intel macOS has only bash 3.2. SDKMAN’s installer appends its own sdkman-init.sh block to the end of ~/.zshrc (after compinit -i, where it must be) — don’t add one yourself. Just need a JDK? cs java --setup (coursier).

starship prompt

Create ~/.config/starship.toml:

format = """
${env_var.OPTIONAL_PROMPT_HEADER}\
$directory $git_branch$git_status$all${env_var.OPTIONAL_PROMPT_FOOTER}
$character"""

[line_break]
disabled = false

[directory]
truncation_length = 0
truncate_to_repo = false

[env_var.OPTIONAL_PROMPT_HEADER]
variable = "OPTIONAL_PROMPT_HEADER"
# The \n is placed inside the string format so it only triggers if text exists
format = "[$env_value]($style)\n"
style = "bold blue"

[env_var.OPTIONAL_PROMPT_FOOTER]
variable = "OPTIONAL_PROMPT_FOOTER"
format = "[$env_value]($style)\n" # Newline at the START
style = "bold yellow"

(starship reads this automatically; the ~/.zshrc block below runs starship init zsh.)

global direnv to add support for source_up_sdkmanrc

Create ~/.config/direnv/direnvrc:

# ==============================================================================
# 1. Fully Generic SDKMAN! Environment Loader (Local Folder Scope)
# ==============================================================================
layout_sdkman() {
  : "${SDKMAN_DIR:=$HOME/.sdkman}"
  
  if [[ -f ".sdkmanrc" && -f "$SDKMAN_DIR/bin/sdkman-init.sh" ]]; then
    local zsh_safeguards='export ZSH_DISABLE_COMPFIX="true"; autoload -U compinit; compinit -u;'
    direnv_load "$SHELL" -c "$zsh_safeguards source '$SDKMAN_DIR/bin/sdkman-init.sh' && sdk env >/dev/null 2>&1 && direnv dump"
  fi
}

# ==============================================================================
# 2. Agnostic Ancestor Discovery Hook (Strict Explicit Execution)
# ==============================================================================
source_up_sdkmanrc() {
  : "${SDKMAN_DIR:=$HOME/.sdkman}"
  
  # Start hunting from the parent folder upward
  local current_dir
  current_dir=$(dirname "$PWD")
  
  # Climb all the way to the filesystem root (/) until a file is found
  while [[ "$current_dir" != "/" ]]; do
    if [[ -f "$current_dir/.sdkmanrc" ]]; then
      if [[ -f "$SDKMAN_DIR/bin/sdkman-init.sh" ]]; then
        
        # Explicit Logging: Always print because this line is explicitly uncommented here
        echo "direnv: loading $current_dir/.sdkmanrc" >&2

        # Force evaluation inside that specific directory layer using an isolated subshell
        local zsh_safeguards='export ZSH_DISABLE_COMPFIX="true"; autoload -U compinit; compinit -u;'
        direnv_load "$SHELL" -c "cd '$current_dir' && $zsh_safeguards source '$SDKMAN_DIR/bin/sdkman-init.sh' && sdk env >/dev/null 2>&1 && direnv dump"
        
        # Mark that the local context explicitly requested and applied an SDK toolchain
        _LOCAL_SDKMAN_APPLIED="true"
      fi
      return 0
    fi
    current_dir=$(dirname "$current_dir")
  done
}

# ==============================================================================
# 3. Safe Global Overwrite: Protect Child Contexts During Tree Traversal
# ==============================================================================
source_up() {
  # 1. Take a clean snapshot of all environment variables BEFORE climbing the tree
  local env_snapshot
  env_snapshot=$(env | grep -v '^DIRENV_' | grep -v '^direnv_')

  # 2. Find and execute the parent configuration natively without relying on 'source_up' function hooks
  local parent_envrc
  parent_envrc=$(find_up .envrc)
  if [[ -n "$parent_envrc" && "$parent_envrc" != "$PWD/.envrc" ]]; then
    source_env "$parent_envrc"
  fi

  # 3. THE USER PERSPECTIVE ISOLATION ENFORCEMENT:
  #    If 'source_up_sdkmanrc' is NOT explicitly uncommented and executed in this folder,
  #    we restore the toolchain variables back to your original baseline shell state.
  if [[ "$_LOCAL_SDKMAN_APPLIED" != "true" && ! -f ".sdkmanrc" ]]; then
    while read -r line; do
      if [[ "$line" == *=* ]]; then
        local key="${line%%=*}"
        local val="${line#*=}"
        # If a variable ends with _HOME or was modified by SDKMAN, revert it
        if [[ "$key" == *_HOME ]] || [[ "$key" == sdkman_* ]]; then
          export "$key"="$val"
        fi
      fi
    done <<< "$env_snapshot"

    # Also wipe out any new software candidates added to the path by parent folders
    PATH_rm "$SDKMAN_DIR/candidates/*/*" 2>/dev/null
    PATH_rm "$HOME/.sdkman/candidates/*/*" 2>/dev/null
  fi
}

# ==============================================================================
# 4. Automated Local Tool Placement Trigger
# ==============================================================================
auto_load_local_tools() {
  if [[ -f ".sdkmanrc" ]]; then
    layout_sdkman
  fi
}
auto_load_local_tools

Zsh plugins (antidote)

Create ~/.zsh_plugins.txt:

zsh-users/zsh-autosuggestions
zsh-users/zsh-syntax-highlighting

antidote loads this on every interactive shell (see the ~/.zshrc block). Add more plugins here as you like.

fzf (fuzzy finder)

When the fzf installer prompts, answer:

PromptAnswer
fuzzy auto-completionyes
key bindingsyes
update shell config filesno

(“no” on the last one because the ~/.zshrc block already sources ~/.fzf.zsh.)

git log pager

Keep git log on one screen (git ships with the Xcode Command Line Tools):

git config --global --replace-all core.pager "less -F -X"

Troubleshooting

Gatekeeper “cannot be opened”. macOS may quarantine a downloaded binary on first run. Clear it:

xattr -dr com.apple.quarantine ~/.local/bin

compinit prompts every shell / command not found: compdef. A leftover Homebrew site-functions dir owned by the old admin makes a bare compinit refuse to run. The ~/.zshrc block uses compinit -i (ignore insecure dirs) before the plugin/SDKMAN lines — keep that order.

npm i -g permission denied. Global installs are redirected to ~/.local (npm config set prefix ~/.local), so packages land in ~/.local/bin (already on PATH). After a major Node LTS bump, packages with native bindings may need a npm i -g reinstall to match the new ABI.

Re-running is safe (idempotent)

Both perfect-bottles.sh and manual-tools.sh re-run safely — to finish a partial install or refresh to latest. CLI binaries and vendor installers overwrite in place (no duplicates); Node wipes/reinstalls latest LTS (global packages survive); GUI apps are replaced in place (a failed download leaves the old .app); antidote/fzf are left alone if present; an unreachable source keeps its existing copy and is logged. There’s no local cache, so a refresh re-downloads (~200 MB Node, ~2 GB GUI apps).