Automated setup and maintenance for macOS development environments using Ansible. This repository helps you quickly set up a new Mac or keep your existing setup up-to-date with a single command.
- 🚀 One-command setup for new Macs
- 🔄 Automated updates for all installed packages
- 🎯 Separate profiles for personal and work environments
- 🔐 Secure handling of API keys and private keys
- ✅ Idempotent operations - run safely multiple times
- 🛡️ Pre-flight validation and backup of existing configs
- 📦 Comprehensive toolset including modern AI tools
-
Clone this repository:
git clone https://github.com/your-username/mac-dev-setup.git cd mac-dev-setup -
Run the bootstrap script:
./new-mac.sh
-
Install everything:
make # For personal setup # OR make work # For work setup
Update all installed packages:
make update| Command | Description |
|---|---|
make |
Complete personal setup (all tools + personal apps) |
make work |
Complete work setup (essential tools only) |
make update |
Update all installed packages |
make check |
Dry run to preview changes |
make permissions |
Verify/request the macOS permissions Homebrew needs (runs automatically before installs and updates) |
make cli |
Install command-line tools only |
make gui |
Install GUI applications only |
make osx |
Configure macOS system preferences |
make dock |
Configure dock items |
make dotfiles |
Sync dotfiles from repository |
make herdr |
Set up Herdr completely: state integrations, the agent skill, and the stowed config |
make git |
Configure git identity, aliases, and GPG signing |
make fonts |
Install developer fonts |
make themes |
Install terminal themes |
make app-store |
Install Mac App Store apps |
make keys |
Install private keys (requires vault password) |
make gpg |
Configure GPG signing for git (auto-detects key) |
make gpg-setup |
Interactive YubiKey GPG key setup wizard |
make node |
Install Node.js tooling only |
make work-remove |
Remove work-only packages |
- Languages: Node.js (via nvm), Go, Rust, Python, Deno
- Package Managers: Homebrew, npm, pnpm, yarn, cargo
- Version Control: Git, GitHub CLI, Sourcetree
- Containers: Docker, Colima, lazydocker
- Databases: Redis Insight, TablePlus
- Terminals: iTerm2, Alacritty, Ghostty
- Shells: Zsh with Oh My Zsh, Starship prompt
- Multiplexers: tmux, Zellij
- Editors: Neovim, VS Code, Cursor
- CLI Tools: fzf, ripgrep, bat, eza, zoxide, and more
- AI Assistants: ChatGPT, Claude
- AI Development: Ollama for local LLMs
- API Testing: Bruno, Postman, HTTPie
- HTTP Debugging: Proxyman
- Transcription & Dictation: Talat, FluidVoice
- Agent Workspace: Herdr, with per-agent state integrations (see below)
Herdr is a terminal workspace manager whose sidebar shows what each coding agent is doing — working, blocked, idle — which is what makes several concurrent agents legible at a glance.
Left alone, Herdr infers that state by pattern-matching the terminal screen, so it
breaks whenever an agent changes how it renders. make herdr installs a per-agent
integration instead, and the agent reports its own state over the Herdr socket.
The agents covered are listed in herdr_integrations in defaults.yaml; run
herdr integration install --help to see every supported target.
make herdr is meant to be the single command for Herdr, so it also:
- installs the herdr agent skill for every agent in
herdr_skill_agents, which lets an agent drive the workspace it runs inside — split a pane, run a command in it, read the output back, wait on a sibling agent.install-claude.shinstalls this too as one of its external skill sources; both are idempotent and the overlap is deliberate, somake herdrdoes not depend on having run the full framework installer. - stows the herdr config, backing up a hand-written
~/.config/herdr/config.tomlfirst, because stow refuses to overwrite a real file and aborts the whole invocation if it finds one.
The stow step needs the package to exist in ~/.dotfiles. A clone predating it
is reported rather than treated as a failure — run make dotfiles to refresh the
clone, which runs these same steps afterwards.
The hook scripts are versioned with the herdr binary, so they are installed by
herdr integration install rather than checked into the dotfiles repo, where they
would go stale on every upgrade. Herdr owns what it writes into
~/.claude/settings.json and ~/.codex/. Herdr's own configuration is a stow
package in the dotfiles repo.
This runs after the dotfiles task on purpose: that task copies
claude/.claude/settings.json over ~/.claude/settings.json, which drops the
hook registration the Claude integration adds there. Running afterwards restores
it on every converge.
Installing the integration is not sufficient for Codex. Codex gates hooks behind
a trust prompt, because a hook can run outside its sandbox. The first Codex
launch after make herdr shows:
Hooks need review
Hooks can run outside the sandbox after you trust them.
> Trust all and continue
Review hooks
Continue without trusting (hooks won't run)
Choose Trust all and continue. Codex records the decision in
~/.codex/config.toml:
[hooks.state."/Users/<you>/.codex/hooks.json:session_start:0:0"]
trusted_hash = "sha256:..."Trusting is too late for the session you are in. The hook is a SessionStart
hook, so it has already been skipped by the time you answer the prompt. Restart
that Codex pane once more and the state reporting begins.
This step is deliberately left manual. The trusted_hash is a sha256 over an
internal Codex representation of the hook — not over hooks.json, the hook
script, the command string, or the JSON entry, all four of which were tested and
none match — so Ansible cannot pre-seed it reliably. It would also change on
every herdr integration bump and potentially on Codex releases. And pre-seeding
it would defeat the point: the prompt is consent for a script to run outside the
sandbox, which is a decision worth making rather than baking into a repo.
Claude Code has no equivalent gate; its hook works as soon as the session restarts.
Verify either agent with:
herdr agent list # a reporting pane has a populated agent_session- System Monitoring: Stats, glances, htop
- App Cleanup: Pearcleaner (uninstalls apps and their leftover files)
- Security: KnockKnock persistence scanner, SSH key management, GPG signing with YubiKey
- Productivity: Raycast, Obsidian, Fantastical
- Menu Bar Toolkit: Vorssaint (keep-awake, system monitor, per-app volume, clipboard history and more)
The setup includes comprehensive Git configuration with productivity-enhancing aliases and modern diff tools.
The following Git configurations are automatically applied:
- Editor: Neovim as default editor
- Pull strategy: Rebase by default
- Auto-prune: Fetch automatically prunes deleted remote branches
- Auto-setup remote: Automatically sets upstream on push
- Default branch:
mainfor new repositories - Diff tool: Difftastic for syntax-aware diffs
git co- checkoutgit br- branchgit ci- commitgit st- statusgit last- show last commitgit unstage- unstage filesgit lg- compact log with graph
git dlog- log with difftastic diffsgit dshow- show commit with difftasticgit ddiff- diff with difftasticgit dl- log with patches using difftasticgit ds- short alias for dshowgit dft- short alias for ddiff
git amend- amend last commit without editing messagegit undo- undo last commit, keeping changesgit wip- quick work-in-progress commitgit cleanup [N]- interactive rebase last N commits (default: 10)git fixup <commit>- create fixup commitgit recent- show recently worked branchesgit aliases- list all configured aliases
git branches- show all branches with tracking infogit gone- list branches whose remotes are gonegit prune-branches- delete branches whose remotes are gonegit main- checkout the main/master branch
git stash-all- stash including untracked filesgit pop- pop latest stash
git contributors- show contributor statisticsgit graph- pretty graph of commit historygit today- show your commits from todaygit yesterday- show your commits from yesterday
git untrack <file>- stop tracking filegit ignored- show ignored filesgit modified- list modified files only
git find <text>- search commit messagesgit who <file>- enhanced blame with move/copy detection
These aliases provide an interactive interface using fzf (fuzzy finder) with live previews, making git operations more visual and intuitive.
git fco- Fuzzy checkout branch - Search and checkout any branch (local or remote) with commit previewgit fcoc- Checkout any commit - Browse entire commit history and checkout with previewgit fbr- Branch switcher - Switch between local branches with commit history previewgit ftag- Tag browser - Browse and checkout tags with full diff preview
git fadd- Stage files interactively - Select files to stage with diff preview (supports multi-select with TAB)git funstage- Unstage files - Select staged files to unstage with previewgit fdiff- Diff browser - Select modified files to diff interactively
git fshow- Commit browser - Browse commits and show details with full diff previewgit flog- Log explorer - Browse and select multiple commits (returns commit SHAs)git fcherry- Cherry-pick commits - Select commits to cherry-pick with preview (multi-select with TAB)git ffix- Create fixup commits - Select target commit for fixup with previewgit frebase- Interactive rebase from point - Select commit to start interactive rebase from
git fstash- Stash browser - Browse stashes with full diff preview and apply selectedgit fbrm- Delete branches - Select branches to delete (supports multi-select with TAB)
git fgrep [pattern]- Grep in tracked files - Select file to search with bat preview, then grep for pattern
Tips for FZF aliases:
- Use
TABto multi-select items where supported - Use
Ctrl-CorESCto cancel without making changes - Type to fuzzy search through the list
- Arrow keys or
Ctrl-J/Kto navigate Enterto confirm selection
Some Homebrew versions wrote empty install receipts, which makes brew upgrade forget how to remove the old app before installing the new one.
Repair the receipts (no downloads involved) and re-run the update:
scripts/fix-cask-receipts.py
make updateIf the script reports casks from untrusted third-party taps, run the
suggested brew trust <tap> command and re-run it.
macOS requires the App Management permission to modify app bundles in
/Applications, and TCC permissions cannot be granted programmatically
without MDM enrollment — that is by design. The setup automates everything
that macOS allows: scripts/ensure-mac-permissions.sh runs before every
install/update target, detects missing permissions with a harmless probe,
clears any recorded denial, triggers the system prompt, opens the exact
System Settings pane, and waits for the one-time grant. After you grant
App Management and Automation (System Events) to your terminal
once, every subsequent run is fully hands-off.
If an individual app still fails quarantine release after the permission is
granted, its bundle likely carries a com.apple.macl attribute (check with
xattr /Applications/<App>.app), which macOS never lets other processes
modify. Fix with a clean reinstall: brew reinstall --cask <name>.
Apps installed ad hoc with brew install --cask are invisible to
make update and won't exist on a freshly provisioned machine. To list
everything installed that no Brewfile tracks (personal machine), run this
from the repository root:
brew bundle cleanup --file=<(printf 'instance_eval File.read("Brewfile.common")\ninstance_eval File.read("Brewfile.personal")\n')Piping the Brewfiles in on stdin doesn't work, because Brewfile.common
loads its parts relative to its own location.
Nothing is removed without --force, and you shouldn't add it: the list
also includes the fonts and dockutil, which Ansible tasks install outside
the Brewfiles. For each other listed item, either add it to the appropriate
Brewfile or add it to a removal list in defaults.yaml.
Most package inventory now lives in Brewfiles:
# Brewfile.cli
brew "your-favorite-cli-tool"
# Brewfile.gui
cask "your-favorite-app", greedy: true
# Brewfile.app-store
mas "Your App", id: 123456789
# Brewfile.personal
cask "personal-only-app", greedy: trueBrewfile.common aggregates the shared CLI, GUI, and App Store inventory.
Brewfile.work is available for work-only additions and is currently empty.
The Brewfiles are the source of truth, and every run makes the machine match them:
- Missing apps are reinstalled, even when Homebrew still thinks they are
installed. Deleting an app outside Homebrew leaves brew's install record
behind, and
brew bundlewould skip it. Before each GUI bundle,scripts/clear-stale-cask-receipts.pyclears such records so the install runs. Run it with--dry-runand a Brewfile to see which casks are affected. - Existing copies are overwritten. Bundles run with
--force, so an app already in/Applicationsthat Homebrew didn't install is replaced by the cask's copy. - Deleting an entry does not uninstall it. To remove a package, delete it
from its Brewfile and add it to the matching removal list in
defaults.yaml:cli_packages_to_remove_if_installed,gui_packages_to_remove_if_installed, orapp_store_apps_to_remove_if_installed(by App Store id). Usegui_packages_to_zap_if_installedinstead to also delete the app's settings and support files. A blanketbrew bundle cleanup --forceisn't used because it would also uninstall the fonts anddockutil, which Ansible tasks install outside the Brewfiles. - Apps outside Homebrew have their own task. Talat isn't on Homebrew or
the App Store, so
ansible/tasks/talat.yamlinstalls it from its release feed when it's missing, after checking it's notarized and signed by its developer. Talat updates itself from then on.
Update the dotfiles_repo in defaults.yaml:
dotfiles_repo: https://github.com/yourusername/dotfiles.gitThis setup supports signing git commits with a GPG key stored on a YubiKey for enhanced security. Multiple YubiKeys with different keys are supported - git automatically detects which YubiKey is currently inserted and uses the correct key.
- YubiKey with OpenPGP support (YubiKey 5 series recommended)
- GPG and YubiKey tools (installed automatically via
make cli)
GPG signing is automatically configured when you run make git (or make / make work, which include it). The setup:
- Imports GPG public keys from
ansible/files/gpg/public-keys.ascinto your local keyring (required for GPG to sign with YubiKey-stored private keys) - Detects the signing key from the currently-inserted YubiKey (tries keyring first, falls back to parsing
gpg --card-statusfingerprint) - Configures git with the detected key, enables commit signing, and installs the
gpg-auto-signwrapper script
-
Install prerequisites:
make cli
This installs
gnupg,pinentry-mac, andykman. -
Set up your GPG key on YubiKey (if not already done):
make gpg-setup
This interactive wizard will:
- Check if your YubiKey is connected
- Detect existing GPG keys on the YubiKey
- Guide you through generating a new key or importing an existing one
-
Export your public key and add it to this repo:
gpg --armor --export YOUR_KEY_ID >> ansible/files/gpg/public-keys.ascThis ensures fresh machines can import the public key before configuring signing.
-
Configure git to sign commits:
make git
This automatically:
- Imports stored public keys into the local GPG keyring
- Detects your GPG key ID from the inserted YubiKey
- Configures git to use it for signing
- Enables commit signing by default
-
Add your public key to GitHub:
gpg --armor --export YOUR_KEY_ID
Copy the output and add it at: https://github.com/settings/keys
After setup, git commits are automatically signed. The 8-hour cache means you'll enter your YubiKey PIN once per workday.
# Test signing works
git commit --allow-empty -m "Test signed commit"
git log --show-signature -1
# Verify YubiKey is detected
gpg --card-statusImportant: Keys generated directly on a YubiKey cannot be extracted. Plan for backup BEFORE generating keys.
When running make gpg-setup and choosing to generate a new key:
- When asked "Make off-card backup of encryption key?", choose Yes
- GPG will create an encrypted backup file in
~/.gnupg/ - Store this backup securely (encrypted USB, password manager, safe)
To restore to a backup YubiKey:
# Import the backup key
gpg --import ~/.gnupg/sk_XXXXX.gpg
# Insert backup YubiKey
# Move the key to the new YubiKey
gpg --edit-key YOUR_KEY_ID
keytocard
# Select slot 1 for Signature, 2 for Encryption, 3 for Authentication
saveFor maximum flexibility with multiple YubiKeys:
# 1. Generate a master key on your computer (NOT on YubiKey)
gpg --full-generate-key
# Choose RSA (sign only), 4096 bits, set expiration
# 2. Add subkeys for signing, encryption, authentication
gpg --edit-key YOUR_KEY_ID
addkey # Add signing subkey
addkey # Add encryption subkey
addkey # Add authentication subkey
save
# 3. Export backup BEFORE moving to YubiKey
gpg --armor --export-secret-keys YOUR_KEY_ID > master-key-backup.asc
gpg --armor --export-secret-subkeys YOUR_KEY_ID > subkeys-backup.asc
# Store these securely!
# 4. Move subkeys to primary YubiKey
gpg --edit-key YOUR_KEY_ID
key 1 # Select first subkey
keytocard
key 1 # Deselect
key 2 # Select second subkey
keytocard
# Repeat for all subkeys
save
# 5. For backup YubiKey, reimport and repeat
gpg --delete-secret-keys YOUR_KEY_ID
gpg --import subkeys-backup.asc
# Insert backup YubiKey
gpg --edit-key YOUR_KEY_ID
# Move keys to backup YubiKey using keytocardWith the same key on both YubiKeys:
# Remove cached key stub and re-detect
gpg-connect-agent "scd serialno" "learn --force" /bye
# Or fully reset the agent
gpgconf --kill gpg-agent
gpg --card-statusWith different keys on each YubiKey:
This setup includes automatic key detection via a wrapper script (~/.local/bin/gpg-auto-sign). When you run make gpg, git is configured to use this wrapper which:
- Detects which YubiKey is currently inserted
- Reads the key ID from that YubiKey
- Uses that key for signing, regardless of what's in
user.signingkey
This means you can simply swap YubiKeys and git will automatically use the correct key - no manual switching required.
Note: You'll need to add each key's public key to GitHub separately at https://github.com/settings/keys
-
"No secret key" errors:
- Ensure your YubiKey is inserted
- Run
gpgconf --kill gpg-agentto restart the agent - Run
gpg --card-statusto verify YubiKey detection
-
PIN prompt doesn't appear:
- Ensure
pinentry-macis installed:brew list pinentry-mac - Check gpg-agent config includes
pinentry-program
- Ensure
-
Signing fails in terminal:
- Ensure
GPG_TTYis set:export GPG_TTY=$(tty) - Add this to your
.zshrcif not already present
- Ensure
The setup includes several safety features:
- Pre-flight checks: Validates system requirements before running
- Backup creation: Backs up existing SSH keys before modification
- Disk space check: Warns if disk space is low
- Internet connectivity: Verifies connection before downloading
- Dry run mode: Preview changes with
make check
-
"Homebrew not found" after new-mac.sh
- Restart your terminal or run:
source ~/.zshrc
- Restart your terminal or run:
-
"Permission denied" errors
- The makefile prompts once for the sudo password when needed
- Homebrew cask installs use a temporary askpass helper so installers should not ask repeatedly
- Some operations require admin access
-
App Store apps fail to install
- Ensure you're signed into the Mac App Store
- Run
mas signin your-apple-id@example.comfirst
-
Ansible Galaxy certificate errors
- We've removed the insecure
ignore_certssetting - If you have certificate issues, fix your system certificates
- We've removed the insecure
- Run with verbose output:
ansible-playbook local.yaml -vvv - Check specific task:
make cliormake gui - Validate syntax:
ansible-playbook local.yaml --syntax-check - Run the helper-script tests:
python3 -m unittest discover -s scripts -p 'test_*.py'
.
├── makefile # Main interface for all commands
├── new-mac.sh # Bootstrap script for fresh installs
├── Brewfile.* # Homebrew Bundle package inventories
├── defaults.yaml # Non-Brewfile configuration and removal lists
├── local.yaml # Main Ansible playbook
├── update.yaml # Update playbook
├── scripts/ # Helper scripts (e.g., gpg-auto-sign.sh)
├── ansible/
│ ├── files/ # Static files deployed to target machine
│ │ └── gpg/ # GPG public keys for import
│ ├── tasks/ # Individual task files
│ └── templates/ # Configuration templates
- Fork the repository
- Create a feature branch
- Test your changes with
make check - Submit a pull request
- macOS (tested on macOS 15.0 arm64)
- Internet connection
- Apple ID (for App Store apps)
This project is licensed under the MIT License - see the LICENSE file for details.
This setup is inspired by various dotfiles repositories and automation scripts from the developer community.