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 · 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).