Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
28 KiB
Mac Setup Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: Turn the stock MacBook Pro into a quiet, bash/GNU-feeling, Ableton-first machine via one idempotent script run over SSH, with nightly rsync backup to tank.
Architecture: Repo ~/bin/mac on steel141 is rsync'd to ~/mac on the Mac by scripts/run.sh, which then runs scripts/setup.sh there as seth over ssh -tt, priming sudo from $HOMELAB_PASSWORD on stdin (verified 2026-09-15: a forced pty keys the sudo ticket on the tty, so setup.sh and Homebrew's internal sudo calls reuse it; without a pty the ticket is keyed on parent pid and children re-prompt). setup.sh is a list of sections that each check state, print [skip] or make the change, and back up any defaults domain before writing it. Dotfiles and kitty config are included from ~/mac/config/ rather than copied, so editing the repo edits the machine. scripts/tank-side.sh runs on pve173 to create the backup dataset, add it to sanoid, and install the Mac's rsync-only key.
Tech Stack: bash 5 (Homebrew), Homebrew + Brewfile, defaults, pmset, scutil, networksetup, launchd user agent, rsync 3.x (Homebrew on Mac; rrsync on pve173), sanoid.
Spec: docs/plans/2026-09-15-mac-setup-design.md
Global Constraints
- Target: MacBookPro18,1, arm64, macOS 26.2 Tahoe. Refuse to run elsewhere.
- Hostname:
mac. Mac LAN IP 192.168.0.94, userseth, uid 501. Aliasssh macexists on steel141. - Tank host pve173 = 192.168.0.173 (never .200). Backup dataset
tank/backups/mac, sanoid templatetank_media. - SIP, Gatekeeper, FileVault stay on. Nothing under
/System/Applicationsis removed. No Cmd/Ctrl swap. - Every
defaultsdomain is exported to~/.mac-setup-backup/before first write;run.shpulls that dir back to~/bin/mac/.backup/mac/(gitignored). - Every section is idempotent: second run prints only
[skip]lines and exits 0. - Homebrew prefix
/opt/homebrew. GNU tools unprefixed via gnubin on PATH. - Conventional commits, push after every commit (
gitea push). - Deviations from spec, decided while planning: (a)
displaysleepon AC = 30 min not 0 — display sleep does not stop Live's audio, system sleep does; (b) nolaunchctl disableof Apple agents (incl.photoanalysisd, which idles with no Photos library) — they idle when their GUI feature is off and macOS re-enables them on update; the GUI toggles are in the manual checklist instead. - Cask names
tailscale-appandfont-jetbrains-monoare from memory; ifbrew bundlerejects one,brew search <name>on the Mac and fix the Brewfile — don't skip the package.
Task 1: Brewfile + config files
Files:
- Create:
Brewfile - Create:
config/bashrc - Create:
config/kitty.conf - Create:
config/ssh_homelab - Modify:
.gitignore(addBrewfile.lock.json)
Interfaces:
-
Produces:
Brewfileconsumed bybrew bundle --file=~/mac/Brewfilein Task 2;config/*included by the marker blocks Task 2 writes (~/.bash_profilesources~/mac/config/bashrc;~/.config/kitty/kitty.confincludes~/mac/config/kitty.conf;~/.ssh/configincludes~/mac/config/ssh_homelab). -
Step 1: Write
Brewfile
# Applied by scripts/setup.sh via `brew bundle`. Ableton Live is NOT here — licensed download.
brew "bash"
brew "coreutils"
brew "findutils"
brew "gnu-sed"
brew "grep"
brew "gawk"
brew "gnu-tar"
brew "iproute2mac" # gives `ip a`
brew "rsync" # macOS ships openrsync; backup.sh needs real rsync
brew "git"
brew "tmux"
brew "htop"
brew "ripgrep"
brew "fd"
brew "jq"
brew "wget"
brew "tree"
cask "kitty"
cask "rectangle"
cask "tailscale-app"
cask "font-jetbrains-mono"
- Step 2: Write
config/bashrc
# ~/mac/config/bashrc — sourced by ~/.bash_profile (marker block written by setup.sh)
eval "$(/opt/homebrew/bin/brew shellenv)"
for d in coreutils findutils gnu-sed grep gawk gnu-tar; do
PATH="/opt/homebrew/opt/$d/libexec/gnubin:$PATH"
done
export PATH
export EDITOR=vim
export CLICOLOR=1
alias ls='ls --color=auto'
alias ll='ls -lah'
alias grep='grep --color=auto'
alias opus='claude --dangerously-skip-permissions'
HISTSIZE=50000; HISTFILESIZE=100000; shopt -s histappend
PS1='\[\e[1;33m\]\u@\h\[\e[0m\]:\[\e[1;34m\]\w\[\e[0m\]\$ '
- Step 3: Write
config/kitty.conf
Copy /home/claude/bin/kitty-web/config/kitty.conf verbatim (76 lines: JetBrains Mono 16, #0a0a0a/#D35400 theme, powerline tabs), then append:
# --- macOS ---
macos_option_as_alt yes
hide_window_decorations titlebar-only
macos_quit_when_last_window_closed yes
Command: cp /home/claude/bin/kitty-web/config/kitty.conf config/kitty.conf && printf '\n# --- macOS ---\nmacos_option_as_alt yes\nhide_window_decorations titlebar-only\nmacos_quit_when_last_window_closed yes\n' >> config/kitty.conf
(hide_window_decorations yes from the Linux file is overridden by the later titlebar-only line — last write wins in kitty.)
- Step 4: Write
config/ssh_homelab
# Included from ~/.ssh/config. Key ~/.ssh/id_ed25519 is generated by setup.sh;
# only pve173 is authorized so far (rrsync-restricted, for backup.sh).
Host pve173
HostName 192.168.0.173
User root
Host pve112
HostName 192.168.0.112
User root
Host pve197
HostName 192.168.0.197
User root
Host pve241
HostName 192.168.0.241
User root
Host steel141
HostName 192.168.0.141
User seth
Host bedroom
HostName 192.168.0.235
User seth
Host *
IdentityFile ~/.ssh/id_ed25519
ServerAliveInterval 30
- Step 5: Ignore the Brewfile lock
echo 'Brewfile.lock.json' >> .gitignore
- Step 6: Syntax check + commit
Run: bash -n config/bashrc && echo OK
Expected: OK
git add Brewfile config .gitignore
git commit -m "feat: Brewfile and included config files (bashrc, kitty, ssh homelab aliases)"
gitea push
Task 2: scripts/setup.sh — helpers, preflight, brew, shell, hostname, dotfile includes
Files:
- Create:
scripts/setup.sh
Interfaces:
-
Consumes:
~/mac/Brewfile,~/mac/config/{bashrc,kitty.conf,ssh_homelab}(Task 1). -
Produces: helper functions
log,setd,backup_domain,install_marker,CHANGEDused by Task 3 sections appended to this same file.BK=~/.mac-setup-backup,TSepoch. Generates~/.ssh/id_ed25519and prints the pubkey (consumed by Task 4tank-side.sh). -
Step 1: Write the file
#!/bin/bash
# setup.sh — run ON the Mac as seth, via scripts/run.sh from steel141.
# Idempotent: every section prints [skip] when already in the desired state.
set -euo pipefail
REPO="$HOME/mac"
BK="$HOME/.mac-setup-backup"; TS=$(date +%s); mkdir -p "$BK"
CHANGED=0
log(){ printf '\033[1;33m[%s]\033[0m %s\n' "$1" "$2"; }
# ---------- preflight ----------
[[ $(uname -m) == arm64 && $(sw_vers -productVersion) == 26.* ]] || { echo "not the Mac this was written for"; exit 1; }
[[ ${HOSTNAME_WANT:-} ]] || { echo "HOSTNAME_WANT=<name> required"; exit 1; }
sudo -v # ticket already primed by run.sh over the pty; prompts if run by hand
( while true; do sudo -n true; sleep 50; done ) &
KEEPALIVE=$!; trap 'kill $KEEPALIVE 2>/dev/null' EXIT
# ---------- helpers ----------
backup_domain(){ # once per domain per run
local d=$1 f="$BK/${d//\//_}-$TS.plist"
[[ -e $f || -e $f.absent ]] && return 0
defaults export "$d" "$f" 2>/dev/null || : > "$f.absent"
}
setd(){ # setd <domain> <key> <bool|int|string> <value>
local d=$1 k=$2 t=$3 v=$4 cur want=$4
[[ $t == bool ]] && { [[ $v == true ]] && want=1 || want=0; }
cur=$(defaults read "$d" "$k" 2>/dev/null || echo __unset__)
if [[ $cur == "$want" ]]; then log skip "$d $k=$v"; return 0; fi
backup_domain "$d"; defaults write "$d" "$k" "-$t" "$v"; log set "$d $k=$v"; CHANGED=1
}
install_marker(){ # install_marker <file> <line> — ensure a line exists in file (create if absent)
local f=$1 line=$2
if [[ -f $f ]] && grep -qxF "$line" "$f"; then log skip "$f has include"; return 0; fi
[[ -f $f ]] && cp "$f" "$BK/$(basename "$f")-$TS"
mkdir -p "$(dirname "$f")"; printf '%s\n' "$line" >> "$f"; log set "$f += $line"; CHANGED=1
}
# ---------- brew ----------
if [[ -x /opt/homebrew/bin/brew ]]; then log skip "homebrew installed"; else
log set "installing homebrew (installs Xcode CLT headless first; 5-15 min)"
NONINTERACTIVE=1 /bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
CHANGED=1
fi
eval "$(/opt/homebrew/bin/brew shellenv)"
if brew bundle check --file="$REPO/Brewfile" >/dev/null 2>&1; then log skip "brew bundle satisfied"; else
log set "brew bundle"; brew bundle --file="$REPO/Brewfile"; CHANGED=1
fi
# ---------- shell ----------
grep -qx /opt/homebrew/bin/bash /etc/shells || { echo /opt/homebrew/bin/bash | sudo tee -a /etc/shells >/dev/null; log set "/etc/shells += homebrew bash"; }
if [[ $(dscl . -read "/Users/$USER" UserShell | awk '{print $2}') == /opt/homebrew/bin/bash ]]; then log skip "login shell bash5"; else
sudo chsh -s /opt/homebrew/bin/bash "$USER"; log set "login shell -> homebrew bash"; CHANGED=1
fi
install_marker "$HOME/.bash_profile" '[ -f ~/mac/config/bashrc ] && . ~/mac/config/bashrc # mac-setup'
install_marker "$HOME/.config/kitty/kitty.conf" "include $HOME/mac/config/kitty.conf"
# ---------- hostname ----------
for k in ComputerName LocalHostName HostName; do
if [[ $(scutil --get $k 2>/dev/null || true) == "$HOSTNAME_WANT" ]]; then log skip "$k=$HOSTNAME_WANT"; else
sudo scutil --set $k "$HOSTNAME_WANT"; log set "$k=$HOSTNAME_WANT"; CHANGED=1
fi
done
# ---------- ssh (homelab) ----------
mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"
[[ -f $HOME/.ssh/id_ed25519 ]] && log skip "ssh key exists" || { ssh-keygen -t ed25519 -N '' -C "seth@$HOSTNAME_WANT" -f "$HOME/.ssh/id_ed25519" >/dev/null; log set "generated ~/.ssh/id_ed25519"; CHANGED=1; }
if [[ -f $HOME/.ssh/config ]] && grep -q '^Include ~/mac/config/ssh_homelab' "$HOME/.ssh/config"; then log skip "ssh config include"; else
[[ -f $HOME/.ssh/config ]] && cp "$HOME/.ssh/config" "$BK/ssh_config-$TS"
{ echo 'Include ~/mac/config/ssh_homelab'; [[ -f $HOME/.ssh/config ]] && cat "$HOME/.ssh/config"; } > "$HOME/.ssh/config.new"
mv "$HOME/.ssh/config.new" "$HOME/.ssh/config"; chmod 600 "$HOME/.ssh/config"; log set "ssh config include"; CHANGED=1
fi
ssh-keygen -F 192.168.0.173 >/dev/null || { ssh-keyscan -t ed25519 192.168.0.173 >> "$HOME/.ssh/known_hosts" 2>/dev/null; log set "known_hosts += pve173"; }
# (Task 3 sections go here)
# ---------- done ----------
echo; log pubkey "$(cat "$HOME/.ssh/id_ed25519.pub")"
[[ $CHANGED == 1 ]] && log note "some changes (key repeat, scroll direction) apply fully after logout/login"
exit 0
Include must be the first line of ~/.ssh/config (OpenSSH applies Host * blocks above it otherwise) — that's why the block rewrites the file with the include on top instead of appending.
- Step 2: Syntax + lint
Run: bash -n scripts/setup.sh && shellcheck -s bash scripts/setup.sh; echo rc=$?
Expected: no errors; warnings about $USER/$HOME are acceptable. rc=0.
- Step 3: Commit
chmod +x scripts/setup.sh
git add scripts/setup.sh
git commit -m "feat: setup.sh — preflight, homebrew, bash5 shell, hostname, dotfile includes"
gitea push
Task 3: scripts/setup.sh — defaults (Linux feel, debloat, Dock), SparkFun, power, apply
Files:
- Modify:
scripts/setup.sh— replace the line# (Task 3 sections go here)with the block below.
Interfaces:
-
Consumes:
log,setd,backup_domain,CHANGED,BK,TSfrom Task 2. -
Produces: nothing new for other tasks.
-
Step 1: Insert the sections
# ---------- defaults: Linux feel ----------
G=NSGlobalDomain
setd $G KeyRepeat int 2
setd $G InitialKeyRepeat int 15
setd $G ApplePressAndHoldEnabled bool false # hold-key repeats instead of accent popup
setd $G com.apple.swipescrolldirection bool false # "natural" scrolling off
setd $G NSAutomaticWindowAnimationsEnabled bool false
setd $G AppleShowAllExtensions bool true
F=com.apple.finder
setd $F ShowPathbar bool true
setd $F ShowStatusBar bool true
setd $F _FXShowPosixPathInTitle bool true
setd $F FXPreferredViewStyle string Nlsv # list view
setd $F NewWindowTarget string PfHm # new windows open at ~
setd $F NewWindowTargetPath string "file://$HOME/"
setd com.apple.desktopservices DSDontWriteNetworkStores bool true
setd com.apple.desktopservices DSDontWriteUSBStores bool true
# ---------- defaults: debloat / Dock ----------
D=com.apple.dock
setd $D autohide bool true
setd $D autohide-delay int 0
setd $D show-recents bool false
setd $D tilesize int 48
for c in tl tr bl br; do setd $D wvous-$c-corner int 1; done # 1 = no action (stock br = Quick Note)
# Dock apps: only what exists. Finder + Trash are implicit.
dock_want=()
for app in /Applications/kitty.app "/Applications/Ableton Live 12"*.app "/System/Applications/System Settings.app"; do
[[ -d $app ]] && dock_want+=("file://${app// /%20}/")
done
dock_cur=$(defaults read $D persistent-apps 2>/dev/null | grep -oE '_CFURLString" = "[^"]+' | sed 's/.*= "//' | tr '\n' ' ' || true)
if [[ "$dock_cur" == "${dock_want[*]} " ]]; then log skip "dock apps"; else
backup_domain $D
defaults write $D persistent-apps -array
for u in "${dock_want[@]}"; do
defaults write $D persistent-apps -array-add "<dict><key>tile-data</key><dict><key>file-data</key><dict><key>_CFURLString</key><string>$u</string><key>_CFURLStringType</key><integer>15</integer></dict></dict><key>tile-type</key><string>file-tile</string></dict>"
done
log set "dock apps = ${dock_want[*]}"; CHANGED=1
fi
# ---------- stray network service ----------
if networksetup -listallnetworkservices | grep -qx 'SparkFun Pro Micro'; then
sudo networksetup -removenetworkservice 'SparkFun Pro Micro'; log set "removed SparkFun Pro Micro PPP service"; CHANGED=1
else log skip "no SparkFun service"; fi
# ---------- power (DAW) — AC profile only ----------
pm_cur=$(pmset -g custom | awk '/AC Power/{f=1;next} /Battery Power/{f=0} f && $1 ~ /^(sleep|displaysleep|disksleep|powernap)$/{printf "%s=%s ", $1, $2}')
if [[ $pm_cur == *"sleep=0 "* && $pm_cur == *"displaysleep=30 "* && $pm_cur == *"disksleep=0 "* && $pm_cur == *"powernap=0 "* ]]; then log skip "pmset AC profile"; else
pmset -g custom > "$BK/pmset-$TS.txt"
sudo pmset -c sleep 0 displaysleep 30 disksleep 0 powernap 0; log set "pmset -c sleep 0 displaysleep 30 disksleep 0 powernap 0"; CHANGED=1
fi
setd com.ableton.live NSAppSleepDisabled bool true # App Nap off for Live (domain exists before install; harmless)
# ---------- apply ----------
[[ $CHANGED == 1 ]] && { killall Dock Finder SystemUIServer 2>/dev/null || true; log set "restarted Dock/Finder"; }
- Step 2: Syntax + lint
Run: bash -n scripts/setup.sh && shellcheck -s bash scripts/setup.sh; echo rc=$?
Expected: rc=0 (SC2086 on $G/$F/$D unquoted is fine — they hold no spaces; add # shellcheck disable=SC2086 at the top of the defaults block if it's noisy).
- Step 3: Dry-check the pmset parser locally against the inventory
Run on steel141:
printf 'Battery Power:\n sleep 1\nAC Power:\n sleep 1\n displaysleep 10\n disksleep 10\n powernap 1\n' | awk '/AC Power/{f=1;next} /Battery Power/{f=0} f && $1 ~ /^(sleep|displaysleep|disksleep|powernap)$/{printf "%s=%s ", $1, $2}'
Expected: sleep=1 displaysleep=10 disksleep=10 powernap=1 (so the mismatch branch fires on first run).
- Step 4: Commit
git add scripts/setup.sh
git commit -m "feat: setup.sh — Linux-feel defaults, Dock/debloat, SparkFun removal, DAW power profile"
gitea push
Task 4: Backup — scripts/backup.sh, launchd agent, scripts/tank-side.sh
Files:
- Create:
scripts/backup.sh - Create:
config/xyz.sethpc.mac-backup.plist - Create:
scripts/tank-side.sh - Modify:
scripts/setup.sh— insert a# ---------- backup agent ----------section just before# ---------- apply ----------.
Interfaces:
-
Consumes:
install_marker-style pattern;/opt/homebrew/bin/rsync(Task 1 Brewfile); Mac pubkey printed by Task 2. -
Produces:
tank-side.sh <pubkey-line>run on pve173 byrun.sh(Task 5). -
Step 1: Write
scripts/backup.sh
#!/opt/homebrew/bin/bash
# Nightly by launchd (config/xyz.sethpc.mac-backup.plist). Mirrors the DAW-relevant
# dirs to tank. History comes from sanoid snapshots of tank/backups/mac on pve173,
# which is what makes `--delete` safe.
set -euo pipefail
src=()
for d in "$HOME/Music/Ableton" "$HOME/Documents"; do [[ -d $d ]] && src+=("$d"); done
[[ ${#src[@]} -gt 0 ]] || { echo "nothing to back up yet"; exit 0; }
# Remote path is relative to the rrsync root (/tank/backups/mac) set in root's authorized_keys on pve173.
exec /opt/homebrew/bin/rsync -a --delete -e 'ssh -o BatchMode=yes' "${src[@]}" root@192.168.0.173:/
- Step 2: Write
config/xyz.sethpc.mac-backup.plist
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>xyz.sethpc.mac-backup</string>
<key>ProgramArguments</key><array><string>/Users/seth/mac/scripts/backup.sh</string></array>
<key>StartCalendarInterval</key><dict><key>Hour</key><integer>3</integer><key>Minute</key><integer>30</integer></dict>
<key>StandardOutPath</key><string>/Users/seth/Library/Logs/mac-backup.log</string>
<key>StandardErrorPath</key><string>/Users/seth/Library/Logs/mac-backup.log</string>
</dict></plist>
- Step 3: Write
scripts/tank-side.sh(runs on pve173 as root; arg = the Mac's pubkey line)
#!/bin/bash
# Run on pve173: ssh pve173 'bash -s' -- "<pubkey>" < scripts/tank-side.sh
set -euo pipefail
PUB=${1:?pubkey line required}
[[ $PUB == ssh-ed25519* ]] || { echo "not a pubkey: $PUB"; exit 1; }
DS=tank/backups/mac; TS=$(date +%s)
zfs list "$DS" >/dev/null 2>&1 && echo "[skip] dataset $DS" || { zfs create "$DS"; echo "[set] created $DS"; }
if grep -q "^\[$DS\]" /etc/sanoid/sanoid.conf; then echo "[skip] sanoid stanza"; else
mkdir -p /etc/sanoid/.backup; cp /etc/sanoid/sanoid.conf "/etc/sanoid/.backup/sanoid.conf-$TS"
printf '\n[%s]\n\tuse_template = tank_media\n' "$DS" >> /etc/sanoid/sanoid.conf; echo "[set] sanoid stanza"
fi
KEYLINE="restrict,command=\"/usr/bin/rrsync /$DS\" $PUB"
if grep -qF "$PUB" /root/.ssh/authorized_keys; then echo "[skip] key present"; else
cp /root/.ssh/authorized_keys "/root/.ssh/authorized_keys.bak-$TS"
echo "$KEYLINE" >> /root/.ssh/authorized_keys; echo "[set] rrsync-restricted key added"
fi
- Step 4: Add the launchd section to
setup.sh(before# ---------- apply ----------)
# ---------- backup agent ----------
PL="$HOME/Library/LaunchAgents/xyz.sethpc.mac-backup.plist"
if [[ -f $PL ]] && cmp -s "$REPO/config/xyz.sethpc.mac-backup.plist" "$PL"; then log skip "backup launchd agent"; else
mkdir -p "$HOME/Library/LaunchAgents"; cp "$REPO/config/xyz.sethpc.mac-backup.plist" "$PL"
launchctl bootout "gui/$(id -u)/xyz.sethpc.mac-backup" 2>/dev/null || true
launchctl bootstrap "gui/$(id -u)" "$PL"; log set "backup launchd agent (03:30 nightly)"; CHANGED=1
fi
- Step 5: Lint all three
Run: chmod +x scripts/backup.sh scripts/tank-side.sh && for f in scripts/*.sh; do bash -n $f && shellcheck -s bash $f; done; plutil -lint config/*.plist 2>/dev/null || xmllint --noout config/xyz.sethpc.mac-backup.plist; echo rc=$?
Expected: rc=0 (plutil is macOS-only; xmllint is the Linux fallback).
- Step 6: Commit
git add scripts/backup.sh scripts/tank-side.sh scripts/setup.sh config/xyz.sethpc.mac-backup.plist
git commit -m "feat: nightly rsync backup to tank with rrsync-restricted key and sanoid history"
gitea push
Task 5: scripts/run.sh (steel141 side) + docs/manual-checklist.md
Files:
- Create:
scripts/run.sh - Create:
docs/manual-checklist.md - Modify:
CLAUDE.md— Conventions: add the run command and checklist pointer.
Interfaces:
-
Consumes: everything above.
run.shis the single entry point from steel141. -
Step 1: Write
scripts/run.sh
#!/bin/bash
# From steel141: sync repo to the Mac, run setup there, pull backups back, then do the tank side.
# Usage: scripts/run.sh [--no-tank]
# sudo on the Mac is primed from $HOMELAB_PASSWORD over a forced pty (ssh -tt) so this works from a
# non-interactive session; with the var unset it falls back to an interactive prompt.
set -euo pipefail
cd "$(dirname "$0")/.."
rsync -a --delete --exclude .git --exclude .backup --exclude Brewfile.lock.json ./ mac:mac/
if [[ -n ${HOMELAB_PASSWORD:-} ]]; then
printf '%s\n' "$HOMELAB_PASSWORD" | ssh -tt mac 'sudo -S -v && HOSTNAME_WANT=mac ~/mac/scripts/setup.sh; exit' | tr -d '\r'
else
ssh -t mac 'HOSTNAME_WANT=mac ~/mac/scripts/setup.sh'
fi
mkdir -p .backup/mac && rsync -a mac:.mac-setup-backup/ .backup/mac/
[[ ${1:-} == --no-tank ]] && exit 0
PUB=$(ssh mac cat .ssh/id_ed25519.pub)
ssh pve173 'bash -s' -- "$PUB" < scripts/tank-side.sh
echo "tank side done; test: ssh mac ~/mac/scripts/backup.sh"
- Step 2: Write
docs/manual-checklist.md
# Manual checklist — GUI-only steps on macOS 26 Tahoe
Do these once, in order, after `scripts/run.sh` has completed. Tick and date.
## Apply the shell-level changes
- [ ] Log out / log in once (key repeat, press-and-hold, scroll direction only fully apply to a fresh session).
## Cloud + privacy
- [ ] System Settings > [your name] > iCloud > Drive (or "Saved to iCloud" > Drive): **Desktop & Documents Folders = OFF**. Keep account signed in.
- [ ] System Settings > General > AirDrop & Handoff: Handoff OFF, AirDrop = Contacts Only.
- [ ] System Settings > Screen Time: App & Website Activity OFF.
- [ ] System Settings > Spotlight: untick Siri Suggestions and any web/store result types; "Help Apple Improve Search" OFF.
- [ ] System Settings > Notifications: Allow notifications when mirroring/sharing OFF (keeps popups off a projector).
## DAW Focus
- [ ] System Settings > Focus > "+" > Custom > name **DAW**, icon of your choice. Allowed notifications: none. Turn on "Share across devices" OFF. Tick "Show in Control Center" is automatic — toggle it from the menu-bar moon before a session.
## Apps needing a first-launch grant
- [ ] Launch **Rectangle** once -> grant Accessibility when prompted. Set your snap keys.
- [ ] Launch **kitty** once. Gatekeeper prompt -> Open.
- [ ] Launch **Tailscale** -> log in to the tailnet. Confirm `tailscale status` in kitty and that `ssh mac` from bebop resolves via MagicDNS later.
- [ ] Gitea: `cat ~/.ssh/id_ed25519.pub` -> https://git.sethpc.xyz/user/settings/keys -> Add Key. Then `git clone git@git.sethpc.xyz:Seth/mac.git` works from the Mac if you ever want to edit there.
## Ableton
- [ ] ableton.com > account > download Live 12 (Suite/trial) -> install to /Applications -> authorize.
- [ ] Re-run `scripts/run.sh --no-tank` from steel141 so the Dock picks up Live (the script only adds it if installed).
- [ ] Live > Browser > Packs: download the Suite packs you want (they're part of the license — do not torrent them).
- [ ] Third-party packs: Finder > Go > Connect to Server > `smb://192.168.0.173/tank` (user seth, tick "Remember in Keychain") -> copy from `Downloads/Software/Milkie/` to `~/Music/Ableton/Samples/` (local disk — never run samples off SMB).
- [ ] Live > Settings > Audio: once an interface is attached, pick it, 48 kHz, buffer 128 (raise if crackle). Test with the DAW Focus on and the lid open on AC for 15 min: no sleep, no notification.
## Backup
- [ ] `ssh mac ~/mac/scripts/backup.sh` by hand once -> confirm `/tank/backups/mac/Ableton` appears on pve173 and `~/Library/Logs/mac-backup.log` exists.
- Step 3: Update
CLAUDE.mdConventions
Append to the Conventions list:
- **Apply everything:** `scripts/run.sh` from steel141 (one sudo prompt at the lid). Second run must be all `[skip]`.
- GUI-only steps live in `docs/manual-checklist.md` — read when something "didn't apply" (it's probably on that list).
- Step 4: Lint + commit
Run: chmod +x scripts/run.sh && bash -n scripts/run.sh && shellcheck -s bash scripts/run.sh; echo rc=$?
Expected: rc=0
git add scripts/run.sh docs/manual-checklist.md CLAUDE.md
git commit -m "feat: run.sh entry point and Tahoe manual checklist"
gitea push
Task 6: First run on the Mac
Files: none created; .backup/mac/ populated (gitignored).
- Step 1: Seth at the lid, run from steel141
Run: cd ~/bin/mac && scripts/run.sh --no-tank (10-20 min; run it in the background and poll the output file — Homebrew's CLT install is the long part).
Expected: Password: echoed once (primed from env), Homebrew installs CLT + itself, [set] lines for every section, ends with [pubkey] ssh-ed25519 .... Exit 0.
If Homebrew's CLT install fails headless: run xcode-select --install over ssh mac, click Install on the Mac's screen, re-run.
- Step 2: Verify Linux feel over SSH (SSH is non-login on macOS -> source the profile explicitly)
Run: ssh mac 'source ~/.bash_profile; echo $BASH_VERSION; ip a | head -3; sed --version | head -1; ls --color=auto -d /Applications; hostname'
Expected: 5.x, an ip listing, sed (GNU sed), coloured path, mac.
- Step 3: Verify defaults + power
Run: ssh mac 'defaults read com.apple.dock persistent-apps | grep -c tile-type; defaults read com.apple.dock wvous-br-corner; pmset -g custom | sed -n "/AC Power/,/^$/p"; networksetup -listallnetworkservices | grep -c SparkFun'
Expected: 2 (kitty + System Settings; 3 once Live is installed), 1, AC block with sleep 0 ... displaysleep 30 ... powernap 0, 0.
- Step 4: Idempotency
Run: scripts/run.sh --no-tank 2>&1 | grep -c '\[set\]'
Expected: 0
- Step 5: Record
git commit --allow-empty -m "chore: first setup.sh run on the Mac 2026-09-15 — verified idempotent" then gitea push. Backups stay in .backup/mac/ (gitignored).
Task 7: Tank side + backup proof
- Step 1: Run the tank side
Run: cd ~/bin/mac && PUB=$(ssh mac cat .ssh/id_ed25519.pub) && ssh pve173 'bash -s' -- "$PUB" < scripts/tank-side.sh
Expected: [set] created tank/backups/mac, [set] sanoid stanza, [set] rrsync-restricted key added.
- Step 2: Prove the restriction
Run: ssh mac 'ssh -o BatchMode=yes root@192.168.0.173 id'
Expected: non-zero exit / rrsync error (an interactive command is refused). That's the point.
- Step 3: Dry run then real run
Run: ssh mac 'mkdir -p ~/Documents/backup-probe && date > ~/Documents/backup-probe/ts && ~/mac/scripts/backup.sh' then ssh pve173 'ls -la /tank/backups/mac/Documents/backup-probe/ && zfs list -t snapshot -r tank/backups/mac | tail -2'
Expected: ts present on tank; at least one autosnap snapshot within the hour (sanoid timer runs every minute, hourly policy).
- Step 4: Clean the probe + confirm
--deletepropagates
Run: ssh mac 'rm -r ~/Documents/backup-probe && ~/mac/scripts/backup.sh' && ssh pve173 'ls /tank/backups/mac/Documents/ | grep -c backup-probe'
Expected: 0
- Step 5: Record decision + commit
Append to DECISIONS.md Implementation: - 2026-09-15: Mac's key on pve173 is rrsync-restricted to /tank/backups/mac — a travelling laptop's key must not be root on the tank host.
git add DECISIONS.md && git commit -m "docs: record rrsync restriction decision" && gitea push
Task 8: Handoff
- Step 1: Update
CLAUDE.mdCurrent State: Phase ->shipping (setup applied; manual checklist pending), remove "No changes made yet". - Step 2:
/session-handoff— capture: what ran, what's on the manual checklist for Seth, Live not yet installed, interface unknown, S4 MK1 macOS support unverified. - Step 3: Commit + push.