My personal macOS setup — everything needed to take a fresh machine to a fully configured one.
| File | What it does |
|---|---|
Brewfile |
Every formula, cask, and Mac App Store app to install |
init.zsh |
Bootstrap script — Homebrew, dotfiles, SSH key, apps |
launchd/ |
Scheduled background jobs (plist + script), symlinked into place by init.zsh |
README.md |
This guide, including the manual steps that can't be scripted |
-
Restore data
- Copy
~/Projectsover from the old machine - Import Raycast settings
- Fix ownership if needed:
chown -R ${USER}:staff ~/Projects
- Copy
-
Run the bootstrap — step through
init.zshinteractively (it prompts for Bitwarden, GitHub, and a couple of GUI installers along the way). -
Apply the manual tweaks below — the settings macOS won't let a script touch.
Open with
Option+Command+D.
- Desktop & Dock → Windows — disable
Close windows when quitting an application(for iTerm) - Desktop & Dock → Hot Corners… — set
Right Bottomto- - General → Language & Region
First day of week→MondayNumber format→1 234 567.89
- Storage — enable
Empty Trash automatically - Menu Bar → Menu Bar Controls — disable
Spotlight - Trackpad → More Gestures — set
App ExposétoSwipe Down with Three Fingers - Keyboard → Keyboard Shortcuts…
Spotlight→ disableShow Spotlight SearchSpotlight→ disableShow Finder search windowInput Sources→ disableSelect the previous input sourceInput Sources→ disableSelect the next input sourceServices→ disableSearchingServices→ disableText
- Keyboard → Text Input → Input Sources → Edit… → All Input Sources —
enable
Use the Caps Lock key to switch to and from ABC
- Settings… → General
Show these items on the desktop:→ disableExternal disksNew Finder windows show:→~/Projects
- Settings… → Advanced — enable
Show all filename extensions
- Set Google Chrome as default browser
- Sync settings
- Press
Control+Shift+Command+\to set iTerm as the default terminal - Set profile
exarus(Profile → Profile Names) as default - General → Selection — enable
Applications in terminal may access clipboard - Advanced → Mouse — set
Scroll wheel sends arrow keys when in alternate screen modetoYes
- Settings… — enable:
Unlock with PINUnlock with Touch IDUnlock with Touch ID → Ask for Touch ID on app startAllow browser integration
- Selective sync with default settings (
MEGA → ~/MEGA)
- Preferences → Interface — disable
Run Steam when my computer starts
🛠️ TODO — init.zsh improvements
Found by a Claude Code review of init.zsh, not yet applied.
Likely-breaking bugs
- oh-my-zsh install will hijack the script. The official installer execs into a new interactive
zsh -lshell at the end unlessRUNZSH=no(and typicallyCHSH=no KEEP_ZSHRC=yes) is set. If this script is ever run top-to-bottom rather than pasted line-by-line, everything after the oh-my-zsh line (SSH key, chezmoi, ghost-complete, pnpm, iTerm schemes, Keka, Battle.net,gh auth login) never executes — you just land in a fresh nested shell. - No
set -e/set -euo pipefail. Every command's failure is silently swallowed and the script marches on. Given this pipeline writes a signing/auth SSH key and then immediately does agit-over-SSH clone with it, a quiet failure early on (bad Bitwarden item, network blip) turns into a confusing failure several steps later instead of stopping where the real problem is.
Working-directory / path fragility
3. brew bundle has no --file=Brewfile. It relies on the script being run with cwd = repo root. If invoked from elsewhere it either uses the wrong file or errors.
4. iTerm2 color scheme clone happens into the current working directory, not a temp dir. If the script is re-run after a partial failure, git clone fails because ./iTerm2-Color-Schemes already exists from the aborted run — and since cleanup only runs if the import step succeeds and nothing traps failures, a half-finished clone can be left behind indefinitely.
5. Consider resolving the script's own directory (${0:A:h} in zsh) up front so brew bundle and any relative paths work regardless of invocation cwd.
Missing safety nets around the SSH/git step
6. No ssh-keyscan github.com >> ~/.ssh/known_hosts (or StrictHostKeyChecking=accept-new) before the first SSH connection to GitHub (chezmoi init --apply [email protected]:...) — first connection will hit an interactive host-key confirmation prompt that isn't called out anywhere.
7. No post-setup verification step (e.g. ssh -T [email protected]) to confirm the restored key actually authenticates before depending on it for the rest of the bootstrap.
Inconsistent interactivity/cleanup handling
8. The Keka block blocks with open -W and then uninstalls the helper cask — but the Battle.net block fires open without -W, so the script (in a hypothetical full run) would race ahead to gh auth login while the Battle.net installer window is still open. Worth deciding if that's intentional or an oversight.
9. open -W /Applications/KekaExternalHelper.app hardcodes an exact app path/name; if the cask ever installs under a slightly different bundle name, this fails silently (no error handling, see #2).
Lower-priority / stylistic
10. Mixed interpreters for the two installer one-liners (/bin/bash -c for Homebrew, sh -c for oh-my-zsh) vs. the #!/bin/zsh shebang — harmless but inconsistent.
11. No idempotency guard on the Homebrew install itself (command -v brew check) — harmless on a truly fresh Mac, but means the script can't be safely re-run partway through without re-triggering the installer.
12. ghost-complete install may need Accessibility/Input Monitoring permissions granted manually (typical for text-expansion tools) — if so, that's currently undocumented in the "Manual configuration" section above.
13. No section banners/echoes (==> doing X) — if the intent really is "run this file straight through" rather than "paste block by block" (this doc says "step through interactively," which is a bit ambiguous given the file is executable with a shebang), some visible progress markers would make failures easier to locate.
The single highest-impact one is #1 (oh-my-zsh's RUNZSH behavior), since it silently truncates every run of the script as currently written.
📌 Backlog — possible Brewfile additions
brew 'fd'
brew 'fx'
brew 'kubernetes-cli'
brew 'magic-wormhole'
brew 'pyenv'
brew 'rsync'
cask 'android-platform-tools'
cask 'balenaetcher'
cask 'bluestacks' # gaming
cask 'background-music'
cask 'crossover' # gaming
cask 'figma'
cask 'handbrake-app'
cask 'jordanbaird-ice'
cask 'homebrew/cask-drivers/logitech-g-hub' # gaming
cask 'monitorcontrol'
cask 'grishka/grishka/neardrop'
cask 'parsec'
cask 'postman'
cask 'slack'
cask 'tradingview'
mas 'MEGA VPN', id: 6456784858
mas 'Windows App', id: 1295203466🗄️ History — init.zsh restores the rental-search API keys (2026-10)
The rental search in ~/MEGA/Projects/Housing - Rental Property Search runs
scripts that need a Google Maps key, an OpenRouteService key and a Google
Sheets service-account JSON. They live in ~/.config/rental-search/ (never in
the MEGA folder, which syncs to the cloud and is read by AI agents), and the
master copy is two Bitwarden Secure Notes: rental-search sheets-sa.json and
rental-search .env.
restore_rental_secrets runs right after restore_ssh_key and reuses its
unlocked session. It looks the notes up by exact name, and follows the same
refuse-to-write rule: the JSON must be a service account with a private key,
and the env note must set both keys. Files are written under umask 077.
Verified against a stubbed bw: locked, missing note, wrong JSON and
incomplete env all leave nothing written; the happy path writes a 0700
directory with 0600 files.
🗄️ History — qbittorrent moved to a third-party tap (2026-09)
Homebrew disabled the official qbittorrent cask on 2026-09-01
(fails_gatekeeper_check: the app isn't Developer ID–signed), so brew upgrade stopped updating it. It now installs from
thedavidweng/unsigned-tap,
which carries the same cask plus a postflight that strips
com.apple.quarantine.
Accepted supply-chain risk, knowingly: the tap is new, small, and
auto-updated nightly by its owner, and the weekly sysup job runs brew upgrade unattended — so its cask code runs here unreviewed. To limit that,
only the qbittorrent cask is brew trusted, not the whole tap (~600
casks). No other unsigned casks should be added.
🗄️ History — weekly sysup moved from ad hoc to `launchd/` (2026-09)
sysup (Oh My Zsh + chezmoi + brew update/upgrade/autoremove/cleanup,
defined in the dotfiles repo's functions.zsh) existed but was only ever run
by hand, so claude-code@latest and everything else in the Brewfile could
still drift for weeks between runs — which is exactly how the stale-claude
bug in the entry below happened.
Added launchd/sysup.zsh (sources ~/.zshrc so sysup() is in scope, since
launchd jobs don't start a login shell) and launchd/com.exarus.sysup.plist
(runs it every Monday 9am via StartCalendarInterval; only fires while
logged in and awake — a missed slot runs at next login, it doesn't queue).
init.zsh now symlinks both into ~/.local/share/scheduled-tasks/ and
~/Library/LaunchAgents/ and does a launchctl unload/load, so a fresh Mac
gets the schedule automatically and any later edit to the files in this repo
takes effect after the next launchctl load — no re-copying needed. Output
logs to ~/.local/share/scheduled-tasks/sysup.log.
🗄️ History — Brewfile reconciled with installed apps (2026-09)
Added the adguard, anydesk and google-chrome casks and the maven and
openjdk@21 formulae, which were installed but untracked, so a fresh Mac set
up from this repo gets them. Removed the comet cask, so fresh setups no
longer install it. Chrome is now the default browser in the manual setup step.
mas is deliberately not listed: brew bundle installs the mas CLI itself
because the Brewfile has mas entries (App Store IDs; needs an App Store
sign-in). brew bundle cleanup still reports it as untracked — don't run it
with --force, or it would uninstall mas.
🗄️ History — claude-code tracks Anthropic's `latest` release channel (2026-09)
Hit a stale-version bug where claude didn't pick up AGENTS.md support —
the Homebrew cask was several releases behind. Anthropic publishes two
release channels for the Claude Code binary
(downloads.claude.ai/claude-code-releases/stable vs. .../latest), and
Homebrew ships both as separate cask tokens with conflicts_with between
them: claude-code tracks stable, claude-code@latest tracks latest.
Switched the Brewfile entry from claude-code to claude-code@latest to get
new releases as soon as Anthropic cuts them instead of whenever they promote
to stable.
Checked whether the same applies to the other AI tools in the Brewfile
(claude, chatgpt, codex, google-gemini, antigravity,
antigravity-cli, muse, muse-code, mistral-vibe): none of them ship an
equivalent @latest/@nightly cask token in homebrew-cask. claude,
chatgpt, google-gemini, antigravity, antigravity-cli, and muse are
all Homebrew-auto_updates apps — they self-update to the actual latest
release in the background regardless of what version Homebrew last
installed, so there's no freshness gap to close for them. muse-code (Meta's
CLI coding agent, dev.meta.ai) checks a muse-stable channel via
api.meta.ai; there's also a muse-canary channel, but as of this check it
resolves to an older build than stable, so unlike Anthropic's split it's
not a faster stream and wasn't worth switching to. codex and mistral-vibe
(formula) do not self-update and have no faster channel to opt into; staying
current on those just means running brew upgrade reasonably often.
🗄️ History — init.zsh no longer trusts the Bitwarden CLI blindly (2026-09)
init.zsh used to restore the SSH key with a bare pipeline:
bw get item <id> | jq -r .sshKey.privateKey > ~/.ssh/id_ed25519Nothing checked that the CLI was logged in or unlocked. On a logged-out or
locked bw, the error goes to stderr and stdout is empty, jq -r turns that
into the literal string null, and the redirect writes null into
~/.ssh/id_ed25519 — which then fails several steps later at the chezmoi
clone, looking like a network or GitHub problem rather than a vault problem.
This is not hypothetical: the Bitwarden CLI dropped its own auth state
mid-session on 2026-09-19 (bw status went from locked to unauthenticated
with the account still present in data.json), which is exactly the state that
produces the silent corruption.
The step is now a restore_ssh_key function that checks bw login --check and
bw unlock --check first, prompts for whichever is missing, uses jq -e so a
missing field is an error rather than null, and refuses to write anything that
does not start with -----BEGIN OPENSSH PRIVATE KEY-----. It writes under
umask 077, and returns rather than exits so a failure doesn't kill the shell
when the file is pasted into a live session instead of executed. Verified
against stubbed logged-out / locked / error-payload / wrong-value cases: all
four leave an existing key untouched, and the happy path writes mode 0600.
🗄️ History — ghost-complete is a PTY proxy, not a shell plugin (2026-09)
ghost-complete (Brewfile, StanMarek/tap) does more than source a script.
ghost-complete install puts a block at the top of ~/.zshrc that sources
~/.config/ghost-complete/shell/init.zsh, which runs exec ghost-complete
whenever TERM_PROGRAM names a supported terminal — iTerm2 included. So on a
machine set up from this repo, every interactive shell runs inside a second
pty, with the binary sitting between the terminal and zsh. That is why ps
shows dozens of ghost-complete processes, one per session.
Worth remembering when debugging anything at the terminal-protocol layer: key encodings, escape sequences, mouse reporting, bracketed paste. The proxy parses input and re-emits it, so what a program receives is not necessarily what the terminal sent.
It already cost one debugging session. Arrow keys in application-cursor mode
(DECCKM, \e[?1h) were reaching programs as ESC [ B instead of ESC O B,
because the proxy decoded the SS3 and CSI arrow forms to the same internal event
and could only re-emit the CSI one. From inside the proxy this is
indistinguishable from the terminal ignoring DECCKM — the terminal's mode state
and its DECRQM replies are all correct, and only the bytes reaching the program
are wrong. It was filed against iTerm2 as
gnachman/iterm2#12939;
iTerm2 was correct the whole time. Fix submitted upstream as
StanMarek/ghost-complete#173.
To take the proxy out of the picture while debugging, start a shell that never
reads ~/.zshrc:
/bin/zsh -fThen compare against a normal shell. If the two disagree about bytes, the proxy is in the path.
🗄️ History — GPG commit signing removed (migrated to SSH, 2026-07)
Git commit/tag signing used to run through GnuPG with a Touch ID pinentry:
gnupg + pinentry-mac + jorgelbg/tap/pinentry-touchid in the Brewfile,
and init.zsh imported a private key from a Bitwarden item
(ad501fa8-3b2e-4dce-92dc-b2ad00998c1c) via
gpg --pinentry-mode loopback --import, then ran pinentry-touchid -fix.
Worked fine for commits typed by hand, but pinentry-touchid pops a macOS
GUI Secure Enclave prompt — it hangs forever with no human at the keyboard
(background jobs, scheduled tasks, agentic/CI commits). Switched to git's
native SSH-format signing (gpg.format = ssh, git 2.34+) instead: the same
SSH key already restored above (~/.ssh/id_ed25519) doubles as the signing
key, no GnuPG stack needed. ~/.gitconfig (gpg.format, user.signingkey,
gpg.ssh.allowedSignersFile) and ~/.ssh/allowed_signers are managed by
chezmoi now, so chezmoi init --apply in init.zsh sets it all up — no
separate GPG import step required. The key was also registered as a
"signing key" (in addition to "authentication") on GitHub so commits still
show as Verified.
If GPG is ever needed again for something other than commits (e.g.
encrypted email): brew install gnupg pinentry-mac, then put
pinentry-program /opt/homebrew/bin/pinentry-mac (or -touchid) in
~/.gnupg/gpg-agent.conf. The Bitwarden GPG key item above was left alone
in the vault, just no longer pulled during bootstrap.
CLI commands that modified dot files
pnpm setup
uv tool update-shell