Last active
July 9, 2026 20:25
-
-
Save heyalexej/2419b1bae9170502166fb079fe4cf47b to your computer and use it in GitHub Desktop.
Install Homebrew and iTerm2 on a clean MacBook Air
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| #!/usr/bin/env bash | |
| # Bootstrap a clean MacBook Air in two stages: | |
| # 1. Run in Apple's Terminal: installs Homebrew + iTerm2, then opens iTerm2. | |
| # 2. Run the same command in iTerm2: installs VS Code, GitHub CLI, | |
| # Pi Coding Agent, logs into GitHub, then optionally stores your DeepSeek API key for Pi. | |
| # | |
| # Usage: | |
| # /bin/bash -c "$(curl -fsSL 'https://gist.githubusercontent.com/heyalexej/2419b1bae9170502166fb079fe4cf47b/raw/macbook-air-homebrew-iterm2.sh?cachebust=1')" | |
| set -euo pipefail | |
| RAW_URL_BASE="https://gist.githubusercontent.com/heyalexej/2419b1bae9170502166fb079fe4cf47b/raw/macbook-air-homebrew-iterm2.sh" | |
| RAW_URL="$RAW_URL_BASE?cachebust=$(date +%s)" | |
| RUN_CMD="/bin/bash -c \"\$(curl -fsSL '$RAW_URL_BASE?cachebust=$(date +%s)')\"" | |
| BREW="" | |
| log() { | |
| printf '\n\033[1;34m==> %s\033[0m\n' "$*" | |
| } | |
| warn() { | |
| printf '\n\033[1;33mWARNING: %s\033[0m\n' "$*" | |
| } | |
| fail() { | |
| printf '\n\033[1;31mERROR: %s\033[0m\n' "$*" >&2 | |
| exit 1 | |
| } | |
| is_macos() { | |
| [[ "$(uname -s)" == "Darwin" ]] | |
| } | |
| in_iterm2() { | |
| [[ "${TERM_PROGRAM:-}" == "iTerm.app" ]] | |
| } | |
| ensure_command_line_tools() { | |
| log "Checking Apple Command Line Tools" | |
| if xcode-select -p >/dev/null 2>&1; then | |
| log "Command Line Tools already installed" | |
| return | |
| fi | |
| warn "Apple Command Line Tools are missing. A macOS installer popup will open." | |
| xcode-select --install >/dev/null 2>&1 || true | |
| printf "Click 'Install' in the popup. Waiting for installation to finish" | |
| attempts=0 | |
| until xcode-select -p >/dev/null 2>&1; do | |
| attempts=$((attempts + 1)) | |
| if [[ "$attempts" -ge 90 ]]; then | |
| fail "Command Line Tools did not finish within 30 minutes. Re-run this script after installing them." | |
| fi | |
| printf "." | |
| sleep 20 | |
| done | |
| printf "\n" | |
| } | |
| find_brew() { | |
| if [[ -x /opt/homebrew/bin/brew ]]; then | |
| BREW=/opt/homebrew/bin/brew | |
| elif [[ -x /usr/local/bin/brew ]]; then | |
| BREW=/usr/local/bin/brew | |
| else | |
| BREW="$(command -v brew || true)" | |
| fi | |
| } | |
| ensure_homebrew() { | |
| if command -v brew >/dev/null 2>&1; then | |
| find_brew | |
| log "Homebrew already installed: $BREW" | |
| else | |
| log "Installing Homebrew" | |
| # The official installer may ask for the Mac login password for sudo. | |
| NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)" | |
| find_brew | |
| fi | |
| if [[ -z "${BREW:-}" || ! -x "$BREW" ]]; then | |
| fail "Homebrew installed, but brew was not found. Open a new Terminal/iTerm2 window and try again." | |
| fi | |
| log "Adding Homebrew to ~/.zprofile" | |
| touch "$HOME/.zprofile" | |
| if ! grep -Fq "$BREW shellenv" "$HOME/.zprofile"; then | |
| { | |
| printf '\n# Homebrew\n' | |
| printf 'eval "$(%s shellenv)"\n' "$BREW" | |
| } >> "$HOME/.zprofile" | |
| fi | |
| eval "$($BREW shellenv)" | |
| log "Updating Homebrew" | |
| "$BREW" update | |
| } | |
| install_cask() { | |
| local cask="$1" | |
| local label="$2" | |
| log "Installing $label" | |
| if "$BREW" list --cask "$cask" >/dev/null 2>&1; then | |
| log "$label already installed" | |
| else | |
| "$BREW" install --cask "$cask" | |
| fi | |
| } | |
| install_formula() { | |
| local formula="$1" | |
| local label="$2" | |
| log "Installing $label" | |
| if "$BREW" list --formula "$formula" >/dev/null 2>&1; then | |
| log "$label already installed" | |
| else | |
| "$BREW" install "$formula" | |
| fi | |
| } | |
| ensure_iterm2() { | |
| install_cask "iterm2" "iTerm2" | |
| } | |
| ensure_vscode() { | |
| install_cask "visual-studio-code" "Visual Studio Code" | |
| } | |
| ensure_gh() { | |
| install_formula "gh" "GitHub CLI" | |
| } | |
| github_auth_present() { | |
| command -v gh >/dev/null 2>&1 && gh auth status --hostname github.com >/dev/null 2>&1 | |
| } | |
| configure_git_identity_from_github() { | |
| github_auth_present || return | |
| local login="" | |
| local name="" | |
| local id="" | |
| login="$(gh api user --jq '.login' 2>/dev/null || true)" | |
| name="$(gh api user --jq '.name // .login' 2>/dev/null || true)" | |
| id="$(gh api user --jq '.id' 2>/dev/null || true)" | |
| [[ -n "$login" ]] || return | |
| [[ -n "$name" && "$name" != "null" ]] || name="$login" | |
| if ! git config --global user.name >/dev/null 2>&1; then | |
| git config --global user.name "$name" | |
| fi | |
| if ! git config --global user.email >/dev/null 2>&1 && [[ -n "$id" ]]; then | |
| git config --global user.email "$id+$login@users.noreply.github.com" | |
| fi | |
| git config --global init.defaultBranch main | |
| } | |
| login_github() { | |
| ensure_gh | |
| if github_auth_present; then | |
| log "GitHub CLI already logged in" | |
| else | |
| log "Starting GitHub login" | |
| cat <<'EOF' | |
| A browser window will open. | |
| Sign in with the existing GitHub account and approve "GitHub CLI". | |
| The one-time device code is copied to the clipboard automatically. | |
| EOF | |
| gh auth login \ | |
| --hostname github.com \ | |
| --web \ | |
| --clipboard \ | |
| --git-protocol https \ | |
| --scopes "repo,read:org,gist,workflow" | |
| fi | |
| gh auth setup-git --hostname github.com || true | |
| configure_git_identity_from_github | |
| } | |
| node_is_new_enough_for_pi() { | |
| command -v node >/dev/null 2>&1 && node -e 'const [maj,min,patch] = process.versions.node.split(".").map(Number); process.exit(maj > 22 || (maj === 22 && (min > 19 || (min === 19 && patch >= 0))) ? 0 : 1)' >/dev/null 2>&1 | |
| } | |
| ensure_node_for_pi() { | |
| if node_is_new_enough_for_pi; then | |
| log "Node.js already new enough for Pi: $(node --version)" | |
| return | |
| fi | |
| log "Installing/updating Node.js for Pi" | |
| if "$BREW" list node >/dev/null 2>&1; then | |
| "$BREW" upgrade node || true | |
| else | |
| "$BREW" install node | |
| fi | |
| hash -r | |
| if ! node_is_new_enough_for_pi; then | |
| fail "Pi requires Node.js 22.19.0 or newer, but the active node is: $(command -v node || echo missing) $(node --version 2>/dev/null || true)" | |
| fi | |
| } | |
| ensure_local_bin_on_path() { | |
| mkdir -p "$HOME/.local/bin" | |
| case ":$PATH:" in | |
| *":$HOME/.local/bin:"*) ;; | |
| *) export PATH="$HOME/.local/bin:$PATH" ;; | |
| esac | |
| touch "$HOME/.zprofile" | |
| if ! grep -Fq 'export PATH="$HOME/.local/bin:$PATH"' "$HOME/.zprofile"; then | |
| { | |
| printf '\n# User-local command line tools\n' | |
| printf 'export PATH="$HOME/.local/bin:$PATH"\n' | |
| } >> "$HOME/.zprofile" | |
| fi | |
| } | |
| install_pi() { | |
| ensure_local_bin_on_path | |
| if command -v pi >/dev/null 2>&1; then | |
| log "Pi Coding Agent already installed: $(command -v pi)" | |
| pi --version || true | |
| return | |
| fi | |
| log "Installing Pi Coding Agent" | |
| curl -fsSL https://pi.dev/install.sh | sh | |
| hash -r | |
| ensure_local_bin_on_path | |
| if ! command -v pi >/dev/null 2>&1; then | |
| fail "Pi installer finished, but 'pi' is not on PATH. Open a new iTerm2 window and run: pi --version" | |
| fi | |
| } | |
| deepseek_auth_present() { | |
| local auth_path="$HOME/.pi/agent/auth.json" | |
| [[ -f "$auth_path" ]] || return 1 | |
| node -e 'const fs=require("fs"); const p=process.argv[1]; try { const a=JSON.parse(fs.readFileSync(p,"utf8")); process.exit(a.deepseek && a.deepseek.type === "api_key" && a.deepseek.key ? 0 : 1); } catch { process.exit(1); }' "$auth_path" | |
| } | |
| prompt_for_deepseek_key() { | |
| if [[ -n "${DEEPSEEK_API_KEY:-}" ]]; then | |
| printf '%s' "$DEEPSEEK_API_KEY" | |
| return | |
| fi | |
| if deepseek_auth_present; then | |
| return | |
| fi | |
| if [[ ! -r /dev/tty ]]; then | |
| warn "No interactive terminal available; skipping DeepSeek API key prompt. You can later run: export DEEPSEEK_API_KEY=... && pi" | |
| return | |
| fi | |
| printf '\nPaste your DeepSeek API key for Pi (input hidden; leave empty to skip): ' > /dev/tty | |
| stty -echo < /dev/tty | |
| local key="" | |
| IFS= read -r key < /dev/tty || true | |
| stty echo < /dev/tty | |
| printf '\n' > /dev/tty | |
| printf '%s' "$key" | |
| } | |
| configure_deepseek_for_pi() { | |
| mkdir -p "$HOME/.pi/agent" | |
| chmod 700 "$HOME/.pi" "$HOME/.pi/agent" 2>/dev/null || true | |
| if deepseek_auth_present && [[ -z "${DEEPSEEK_API_KEY:-}" ]]; then | |
| log "DeepSeek API key already configured for Pi" | |
| return | |
| fi | |
| local key | |
| key="$(prompt_for_deepseek_key)" | |
| if [[ -z "$key" ]]; then | |
| warn "Skipping DeepSeek API key setup. Pi also accepts DEEPSEEK_API_KEY in the environment." | |
| return | |
| fi | |
| log "Storing DeepSeek API key in ~/.pi/agent/auth.json" | |
| PI_AUTH_JSON="$HOME/.pi/agent/auth.json" PI_DEEPSEEK_API_KEY="$key" node <<'NODE' | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const authPath = process.env.PI_AUTH_JSON; | |
| const key = process.env.PI_DEEPSEEK_API_KEY; | |
| const dir = path.dirname(authPath); | |
| fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); | |
| let auth = {}; | |
| if (fs.existsSync(authPath)) { | |
| const text = fs.readFileSync(authPath, 'utf8').trim(); | |
| if (text) { | |
| try { | |
| auth = JSON.parse(text); | |
| } catch (error) { | |
| const backup = `${authPath}.backup-${new Date().toISOString().replace(/[:.]/g, '-')}`; | |
| fs.copyFileSync(authPath, backup); | |
| console.error(`Existing auth.json was invalid; backed up to ${backup}`); | |
| auth = {}; | |
| } | |
| } | |
| } | |
| auth.deepseek = { type: 'api_key', key }; | |
| fs.writeFileSync(authPath, `${JSON.stringify(auth, null, 2)}\n`, { mode: 0o600 }); | |
| fs.chmodSync(authPath, 0o600); | |
| NODE | |
| } | |
| configure_pi_settings() { | |
| log "Configuring Pi defaults" | |
| local has_deepseek=0 | |
| if deepseek_auth_present; then | |
| has_deepseek=1 | |
| fi | |
| PI_SETTINGS_JSON="$HOME/.pi/agent/settings.json" PI_HAS_DEEPSEEK="$has_deepseek" node <<'NODE' | |
| const fs = require('fs'); | |
| const path = require('path'); | |
| const settingsPath = process.env.PI_SETTINGS_JSON; | |
| const hasDeepSeek = process.env.PI_HAS_DEEPSEEK === '1'; | |
| const dir = path.dirname(settingsPath); | |
| fs.mkdirSync(dir, { recursive: true, mode: 0o700 }); | |
| let settings = {}; | |
| if (fs.existsSync(settingsPath)) { | |
| const text = fs.readFileSync(settingsPath, 'utf8').trim(); | |
| if (text) { | |
| try { | |
| settings = JSON.parse(text); | |
| } catch (error) { | |
| const backup = `${settingsPath}.backup-${new Date().toISOString().replace(/[:.]/g, '-')}`; | |
| fs.copyFileSync(settingsPath, backup); | |
| console.error(`Existing settings.json was invalid; backed up to ${backup}`); | |
| settings = {}; | |
| } | |
| } | |
| } | |
| settings.externalEditor ??= 'code --wait'; | |
| if (hasDeepSeek) { | |
| settings.defaultProvider ??= 'deepseek'; | |
| settings.defaultModel ??= 'deepseek-v4-pro'; | |
| settings.defaultThinkingLevel ??= 'medium'; | |
| } | |
| fs.writeFileSync(settingsPath, `${JSON.stringify(settings, null, 2)}\n`, { mode: 0o600 }); | |
| fs.chmodSync(settingsPath, 0o600); | |
| NODE | |
| } | |
| stop_after_stage_one_if_not_in_iterm2() { | |
| if in_iterm2 || [[ "${RUN_FULL_SETUP:-0}" == "1" ]]; then | |
| return | |
| fi | |
| log "Stage 1 complete: Homebrew and iTerm2 are installed" | |
| if command -v pbcopy >/dev/null 2>&1; then | |
| printf '%s\n' "$RUN_CMD" | pbcopy || true | |
| log "Copied the next command to the clipboard" | |
| fi | |
| open -a iTerm >/dev/null 2>&1 || true | |
| cat <<EOF | |
| Next step: | |
| 1. Open iTerm2 (it should be opening now). | |
| 2. Run the same command again: | |
| $RUN_CMD | |
| That second run will install Visual Studio Code, GitHub CLI, Pi Coding Agent, | |
| start GitHub login, and ask for your DeepSeek API key. | |
| EOF | |
| exit 0 | |
| } | |
| print_summary() { | |
| log "Done" | |
| printf 'Homebrew: %s\n' "$("$BREW" --version | head -n 1)" | |
| printf 'iTerm2: /Applications/iTerm.app\n' | |
| printf 'VS Code: /Applications/Visual Studio Code.app\n' | |
| printf 'GitHub: %s\n' "$(gh api user --jq '.login' 2>/dev/null || echo 'not logged in')" | |
| printf 'Pi: %s\n' "$(command -v pi 2>/dev/null || echo 'not found')" | |
| if deepseek_auth_present; then | |
| printf 'DeepSeek: configured in ~/.pi/agent/auth.json\n' | |
| printf '\nTry Pi now:\n pi --provider deepseek --model deepseek-v4-pro\n' | |
| else | |
| printf 'DeepSeek: not configured; later use /login in Pi or set DEEPSEEK_API_KEY.\n' | |
| fi | |
| } | |
| main() { | |
| is_macos || fail "This script is for macOS only." | |
| ensure_command_line_tools | |
| ensure_homebrew | |
| ensure_iterm2 | |
| stop_after_stage_one_if_not_in_iterm2 | |
| ensure_vscode | |
| login_github | |
| ensure_node_for_pi | |
| install_pi | |
| configure_deepseek_for_pi | |
| configure_pi_settings | |
| print_summary | |
| } | |
| main "$@" |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment