AGENTIC MAC / FIELD GUIDE

matth@mac : ~/dotfiles

Your agentic engineering system, explained.

A practical reference for understanding, changing, maintaining, and recovering the Agentic Mac.

Determinate Nix nix-darwin 26.05 Home Manager 26.05 WezTerm + Herdr Claude + Codex + Pi + OpenCode Hermes + First Mate Automic Vault
WezTerm using the Rose Pine Moon theme with the dotfiles Git prompt
The finished terminal: Rosé Pine Moon, Hack Nerd Font, Starship, Zsh. Source of truth: ~/dotfiles

01 / System map

The mental model

The setup is not one tool. It is a set of layers with clear ownership. Most confusion disappears once you know which layer owns a change.

INSTALLER LAYER

Determinate Nix

Owns the Nix installation and daemon. This is why nix.enable = false in nix-darwin.

SYSTEM LAYER

nix-darwin

Applies macOS preferences, system configuration, and the Homebrew declaration.

USER LAYER

Home Manager

Installs user CLI tools and manages Zsh, Starship, environment variables, and links.

MAC APP LAYER

nix-homebrew

Connects the existing /opt/homebrew installation to nix-darwin.

CONFIG LAYER

Dotfiles

The Git repository is the durable source. Home paths point back to files in this repo.

WORK LAYER

WezTerm + Herdr

WezTerm renders the terminal. Herdr keeps workspaces, panes, and agent sessions alive.

SECRETS LAYER

Automic Vault

Keeps tokens in the Keychain and gates every authenticated use behind an approval prompt. Section 14 covers it.

The core idea: edit the declaration, inspect the diff, apply it, verify the result, then commit it. Do not treat the generated files in ~/.zshrc or the Nix store as the source.
File Owns Apply method
flake.nix Pinned inputs and module wiring ./rebuild.sh
configuration.nix macOS defaults, Homebrew formulae and casks ./rebuild.sh
home.nix Nix packages, Zsh, Starship, aliases, symlinks ./rebuild.sh
home/.config/wezterm/wezterm.lua Terminal appearance and startup behavior Mostly hot-reloads; startup changes need Cmd+Q
home/.config/herdr/config.toml Herdr keys and UI preferences Restart or reload Herdr when needed
home/AGENTS.md Shared behavior for Claude, Codex, and OpenCode Direct link; new agent sessions read it
home/.claude/settings.json Claude theme, TUI, status line, hooks, permission mode Restart Claude when needed
home/.claude/statusline.sh The status line Claude renders under the prompt Direct link; takes effect on the next render
home/.pi/agent/ Pi theme, local extensions, model overrides, settings Direct links; /reload inside Pi
home/skills/ Hand-authored agent skills, shared by all three harnesses Direct link; new agent sessions read it
bin/ Blessed Automic Vault launchers (cc, fm-claude, no-mistakes-daemon) Run directly; editing one voids its blessing
herdr-plugins/ Herdr startup plugins, installed into Herdr itself Reinstall or restart Herdr

Why the symlinks matter

Home Manager creates managed paths such as ~/.config/wezterm, but they ultimately point into ~/dotfiles through the stable ~/.dotfiles link. An application reads its normal home-directory path while Git records the real file in the repository.

~/dotfiles |-- flake.nix # inputs and module graph |-- flake.lock # exact pinned revisions |-- configuration.nix # macOS defaults, Homebrew, taps |-- home.nix # user environment and symlinks |-- bootstrap.sh # first-machine setup |-- rebuild.sh # apply future changes |-- AGENTS.md # repo-local notes (CLAUDE.md links here) |-- bin/ # blessed vault launchers | |-- cc # claude, with GH_TOKEN injected | |-- fm-claude # First Mate crewmate launcher | `-- no-mistakes-daemon # foreground pipeline daemon |-- herdr-plugins/ | `-- nms-autostart/ # restores the daemon pane after a restore |-- tests/ # shell tests for the Pi Calm extension |-- docs/ # this field guide, published by GitHub Pages `-- home/ |-- AGENTS.md # shared agent policy for all three harnesses |-- skills/bro/ # hand-authored agent skill |-- .claude/ | |-- settings.json | `-- statusline.sh |-- .pi/agent/ # theme, extensions, models, settings `-- .config/ |-- wezterm/wezterm.lua `-- herdr/config.toml

02 / Daily loop

The safe change cycle

Use this loop for almost every configuration change. It keeps each decision visible and gives Git a clean recovery point.

Start from the repository

Confirm you are editing the durable source, not a generated file.

Edit one focused thing

Use Micro and keep the change narrow enough to understand from the diff.

Inspect before applying

git diff --check catches whitespace errors. The normal diff shows exactly what behavior changed.

Apply and verify

Run the rebuild only for Nix-owned files. Test the behavior as an end user would experience it.

Commit the proven result

Stage only the intended files, write a factual message, push, and confirm the tree is clean.

Standard change cycle
cd "$HOME/dotfiles"
git status --short

micro configuration.nix

git diff --check
git --no-pager diff -- configuration.nix

./rebuild.sh

git add configuration.nix
git commit -m "Describe the behavior changed"
git push origin main
git status --short
Expected clean result: the last git status --short prints nothing. Silence is success for that command.
Check before you switch: when an edit is large or unfamiliar, prove it builds without touching the machine first.
Dry run
nix flake check --no-build
nix build .#darwinConfigurations.mac.system --dry-run
One checkpoint at a time: explanations are not terminal commands. Only paste fenced command blocks into the shell.

03 / What to edit

Does this need a rebuild?

The answer depends on whether Nix generates the result or an application reads a linked file directly.

Rebuild required

Nix-owned declarations

  • Add or remove a package in home.nix
  • Add a Homebrew formula or cask
  • Change a macOS default
  • Add or remove a managed symlink
  • Change flake inputs or module wiring
Usually direct

Linked application config

  • WezTerm appearance changes hot-reload
  • Herdr reads its linked TOML configuration
  • Claude settings and the status line script are already linked
  • Shared agent instructions and skills are already linked
  • Pi themes, extensions, and settings are already linked (/reload)
  • Startup-only behavior needs the app restarted
New files and flakes: Git-backed flakes can ignore untracked files. If a new Nix file is referenced and the build cannot see it, run git add path/to/new-file before rebuilding. You do not need to commit it first.

04 / Install software

Put software in the right list

Use Nix for ordinary command-line tools when they exist in nixpkgs. Use Homebrew casks for macOS applications. Keep Homebrew formulae for tools that are Homebrew-specific or integrate better there.

Example: add a terminal CLI tool

Edit home.nix and add the nixpkgs attribute under home.packages.

home.nix
home.packages = with pkgs; [
  bat
  fd
  fzf
  git
  hermes-agent.packages.${pkgs.stdenv.hostPlatform.system}.default
  htop
  jq
  lazygit
  micro
  opencode
  ripgrep
  rsync
  shellcheck
  tmux
  tree
  treehouse.packages.${pkgs.stdenv.hostPlatform.system}.default
  typescript
  uv
  wget
  cowsay  # example new package
  nerd-fonts.hack
];

Find the correct attribute at NixOS Package Search, then rebuild and verify with command -v tree.

Apply and verify

After editing either file
cd "$HOME/dotfiles"
git diff --check
git --no-pager diff
./rebuild.sh
rehash
command -v NAME || echo "NAME is missing"

Removing software

Delete the declaration, rebuild, verify, and commit. Nix packages disappear from the active user profile. Homebrew items are removed on the next rebuild, because the cleanup policy is set to uninstall.

Your current migration policy: homebrew.onActivation.cleanup = "uninstall". Every rebuild removes any Homebrew formula or cask that isn't declared in configuration.nix, so a fresh Mac reproduces exactly this set. Removed apps' data and config files are left in place; wiping those too would need "zap".
Currently declared Items
Nix user packages bat, fd, fzf, git, Hermes Agent, htop, jq, lazygit, micro, OpenCode, ripgrep, rsync, shellcheck, tmux, tree, Treehouse (flake input), typescript, uv, wget, Hack Nerd Font
Homebrew taps automic-vault/isotopes, my-monkeys/tap (both declared trusted)
Homebrew formulae age, gh-cli (Automic Vault's hardened GitHub CLI), herdr, node, pi-coding-agent, docker (CLI only), ollama, llama.cpp
Homebrew casks Automic Vault, WezTerm, OrbStack, Claude Code, Codex, OpenSuperWhisper, Mos, Obsidian, VeraCrypt, macFUSE
Why Node and Pi are on Homebrew, not Nix: the nixpkgs-26.05-darwin release branch freezes Pi at an old version while Pi ships releases continuously, so pi-coding-agent comes from homebrew-core instead. Its bottle carries native modules that are ABI-bound to the Node major it was built against, and /opt/homebrew/bin precedes the Nix profiles on PATH, so a second Nix nodejs would only shadow the one Pi needs. Homebrew's node is therefore the machine's single Node, and it serves the ~/.npm-global CLIs too.

The container layer

OrbStack is the container and Linux VM runtime, not Docker Desktop. The docker formula is only the CLI client; the daemon it talks to comes from OrbStack, which registers an orbstack Docker context and makes it current on first launch. The Homebrew client ships no subcommand plugins, so home.nix links ~/.docker/cli-plugins/docker-compose and docker-buildx straight at the binaries inside OrbStack.app. That keeps docker compose working without OrbStack's GUI onboarding step, which is what would otherwise create ~/.orbstack/bin and never runs on a headless setup.

The npm layer (not managed by Nix)

home.nix sets NPM_CONFIG_PREFIX to ~/.npm-global and puts its bin on the PATH, so npm globals land in a writable prefix rather than alongside the runtime. First Mate's worker CLIs are installed there imperatively: gh-axi, chrome-devtools-axi, lavish-axi, tasks-axi, quota-axi, and gnhf. The no-mistakes validation pipeline lives at ~/.no-mistakes/bin (also on the PATH from home.nix); point NO_MISTAKES_LINK_DIR at that same directory when installing so it skips the one step that wants sudo. On a fresh Mac these need one reinstall pass; the README's "Agent worker tooling" section has the exact commands.

The no-mistakes daemon runs in the foreground from a herdr pane, not from its launchd agent. Start it with the nms alias (~/dotfiles/bin/no-mistakes-daemon) and leave it in its own pane; herdr keeps it across disconnects and restarts, and the nms-autostart herdr plugin recreates that pane after every session restore.

Never run no-mistakes daemon start or daemon restart. Both silently break the pipeline's GitHub access, with no error to tell you. The alias is nms, not nm: nm is binutils' symbol lister.

When a rebuild changes the PATH, existing terminal sessions keep the old one: home-manager guards its session variables with __HM_SESS_VARS_SOURCED, and every nested shell inherits the guard, so agents launched from a pre-rebuild shell see command not found for tools that are actually installed. Open a fresh WezTerm tab (or reboot) and restart long-lived hosts like the herdr server from it.

05 / WezTerm + Micro

Your working surface

WezTerm owns the terminal window. Zsh owns the shell inside it. Starship draws the prompt. Micro is the default text editor.

WezTerm

Window

Opens maximized as a normal macOS window. The frame is reduced to resizing, so standard title buttons are not shown.

Zsh + Starship

Prompt

Autosuggestions, syntax highlighting, Git branch state, command duration, and a compact prompt.

Micro

Editing

A conventional terminal editor with familiar selection, copy, paste, undo, and search behavior.

WezTerm settings in plain language

Lua setting Current value Effect
color_scheme rose-pine-moon The terminal color palette
font Hack Nerd Font Text and prompt symbols
font_size 17.5 Your enlarged default text size
window_background_opacity 0.85 85 percent opaque, 15 percent transparent
macos_window_background_blur 50 Blur behind transparent terminal areas
window_decorations RESIZE Frameless appearance while retaining resize support
hide_tab_bar_if_only_one_tab true No tab strip until a second tab exists
gui-startup maximize() Uses the available desktop without macOS full-screen mode
window-focus-changed dim to 62% opacity Unfocused windows fade their text and background so the focused one is obvious
mouse_bindings PrimarySelection only Mouse selection never writes the system clipboard, so Universal Clipboard content is not clobbered
Copying is explicit here: highlighting text with the mouse does not put it on the clipboard. Press Cmd+C to copy a selection - that binding is untouched.
Close current windowCmd W
Quit WezTerm completelyCmd Q
Minimize normal windowCmd M
Toggle macOS full screenCtrl Cmd F
Hot reload: ordinary WezTerm appearance edits take effect when the linked Lua file is saved. The startup maximize handler runs only when the GUI starts, so use Cmd+Q and reopen WezTerm to test it.

Micro essentials

Open a filemicro FILE
SaveCtrl S
QuitCtrl Q
FindCtrl F
Select allCtrl A
Undo / redoCtrl Z / Y
Copy / paste in MicroCtrl C / V
Terminal selection copyCmd C

06 / ripgrep + fzf

Find text, then choose a result

These are two separate tools that become especially useful together. rg, the ripgrep command, searches file contents. fzf gives you an interactive way to narrow and select lines from any list.

ripgrep / rg

Searches content

Ask: "Which files contain this word or pattern?" It recursively searches from the current directory and normally respects .gitignore.

fzf

Selects from a list

Ask: "Which one of these results do I want?" Type a few characters, move through the narrowed list, and press Enter.

Start with ripgrep

Useful rg searches
cd "$HOME/dotfiles"

rg "home.packages"                 # search every normal text file
rg -n "home.packages" home.nix     # search one file with line numbers
rg -i "wezterm"                    # ignore upper/lower case
rg -F 'a.literal.value'            # treat punctuation literally
rg -g '*.nix' "homebrew"           # search only Nix files
rg -l "claude"                     # print matching filenames only
rg --files                         # list searchable project files
How to read a result: output usually has the form path:line-number:matching text. Start broad, then add a file path or -g filter when there are too many results.

When a search finds nothing

By default, ripgrep skips hidden files, binary files, and ignored paths. Include hidden files while still excluding Git internals with:

Search hidden configuration
rg --hidden -g '!.git' "statusLine" "$HOME/.claude"

Use fzf on its own

Narrow the listType letters
Move selectionArrow keys
Choose selected itemEnter
Cancel without choosingEsc
Choose a project file
cd "$HOME/dotfiles"
rg --files | fzf

The pipe character | sends the file list produced by rg --files into fzf. Choosing a line prints that filename back to the terminal.

Open the selected file in Micro

Search, choose, edit
cd "$HOME/dotfiles"
file="$(rg --files | fzf)"
[ -n "$file" ] && micro "$file"

Search file contents, then choose a matching file

Find content and open its file
cd "$HOME/dotfiles"
file="$(rg -l "homebrew" | fzf)"
[ -n "$file" ] && micro "$file"
Current setup detail: both commands are installed. The optional fzf shell shortcuts such as fuzzy command history are not declared in home.nix yet, so this guide only relies on the working fzf command.

07 / Herdr

Persistent agent workspaces

Herdr sits inside WezTerm and keeps terminal work alive independently of the window. Closing WezTerm does not need to terminate your running agents.

Structure

Workspace -> tab -> pane

A workspace groups a project. Tabs group tasks. Panes let you run an agent beside logs, Git, or another agent.

Agent awareness

Working, waiting, done

The side panel detects supported agents such as Claude and Codex and exposes their current state.

Start, leave, and stop

Herdr lifecycle
herdr                 # start or reattach

# Inside Herdr: Ctrl-b, release, then q to detach

herdr server stop     # actually stop the session and its panes
Prefix means two steps: press Ctrl B, release both keys, then press the action key. It is not one simultaneous chord.

Your configured keys

New tabPrefix C
Close tabPrefix &
Split top / bottomPrefix "
Split left / rightPrefix %
Focus left / downPrefix H / J
Focus up / rightPrefix K / L
Workspace pickerPrefix W
Go to targetPrefix G
Copy modePrefix Y
Show active helpPrefix ?
You can use the mouse: Herdr is mouse-native. Click tabs and panes, drag split borders, and use context menus while the keyboard model becomes familiar.

Two non-key settings also live in home/.config/herdr/config.toml: onboarding = false skips the first-run tour, and agent_panel_sort = "spaces" groups the agents panel by space rather than by priority. Copy mode's own internal keys (v / Space to select, y / Enter to copy, q / Esc to cancel) are fixed by Herdr and not configurable; only the key that enters copy mode is.

The startup plugin

herdr-plugins/nms-autostart is a Herdr startup plugin kept in this repo. Herdr runs it once after it restores a session and its API socket is ready. Session restore brings panes back as empty shells, so the plugin checks whether the no-mistakes daemon is running and, if not, recreates an NMS tab in the dotfiles workspace and starts the blessed launcher in it. That is why the pipeline daemon survives a full restart without you doing anything.

A useful project layout

Pane Purpose Example
Left Primary implementation agent cc or co
Top-right Tests, server, or logs npm test, docker compose logs -f
Bottom-right Human review and Git state git diff, git status --short

08 / Agents

Shared rules, different permission models

Claude, Codex, and OpenCode share one global instruction file and one skills directory. Pi and Hermes are separate agents with their own state, and First Mate is a writable agent distro that coordinates crews across registered projects.

Claude Code

cc

Runs bin/cc, a blessed Automic Vault launcher that hands Claude a scoped GH_TOKEN and then execs claude --dangerously-skip-permissions. Claude acts without approval prompts and is not protected by the Codex workspace sandbox.

Codex

co

Runs codex --sandbox workspace-write --ask-for-approval never. It can modify the active workspace, while access outside that sandbox is denied.

OpenCode

opencode

Installed as a Nix package, with no launcher alias and no provider key preconfigured. Authenticate it yourself if you use it; nothing in this repo stores or injects a key for it.

Pi

pi

Installed from the homebrew-core pi-coding-agent formula. This repo owns only its theme, local extensions, model overrides, and generic settings - see "Pi's selective setup" below.

Hermes Agent

hermes

Installed from the official Hermes Nix flake. Run hermes setup once to select a provider, then run hermes for its terminal interface. Its credentials and mutable state remain local under ~/.hermes.

First Mate

cd ~/firstmate && claude

The repository itself is the distro, so bootstrap clones it instead of putting it in the read-only Nix store. Launch a supported harness from that directory, register projects in chat, and approve crew dispatch only after considering the parallel work it will start.

Security boundary: the cc and co aliases are intentionally autonomous. Start them only in a repository you trust. The planned sandbox network isolation is a separate security project and is not implemented by these dotfiles.

One instruction source

Managed links
# shared policy: one file, three harnesses
~/.claude/CLAUDE.md            -> ~/dotfiles/home/AGENTS.md
~/.codex/AGENTS.md             -> ~/dotfiles/home/AGENTS.md
~/.config/opencode/AGENTS.md   -> ~/dotfiles/home/AGENTS.md

# hand-authored skills: one directory, three roots
~/.agents/skills/bro           -> ~/dotfiles/home/skills/bro
~/.claude/skills/bro           -> ~/dotfiles/home/skills/bro
~/.codex/skills/bro            -> ~/dotfiles/home/skills/bro

# Claude's own configuration
~/.claude/settings.json        -> ~/dotfiles/home/.claude/settings.json

Edit home/AGENTS.md when a preference should apply to Claude, Codex, and OpenCode. Use project-local instruction files when a rule only belongs to one repository. Pi, Hermes, and First Mate maintain their own operational state and instructions.

Only authored skills are linked. ~/.agents/skills is the shared skills root that the skills CLI installs into from GitHub, tracked in its own .skill-lock.json. Linking just the skills written here leaves that CLI free to manage the rest. OpenCode needs no link of its own because it also scans ~/.agents/skills and ~/.claude/skills; confirm with opencode debug skill.

First Mate project workflow

Register first, then dispatch
gh auth login
cd "$HOME/firstmate"
claude

# In chat:
Add https://github.com/OWNER/REPO as a no-mistakes project.
Do not start any work yet.

First Mate keeps its managed clones under ~/firstmate/projects and gives workers isolated worktrees. Use no-mistakes for its full validation and PR pipeline, direct-PR for a faster PR path, or local-only when no remote is required.

Its worker tooling is fully installed on this machine: tmux hosts each worker session, Treehouse provides the isolated worktree pool, tasks-axi is the durable task queue, quota-axi checks usage headroom before dispatch, gh-axi / chrome-devtools-axi / lavish-axi cover GitHub, browser, and report work, and no-mistakes gates deliveries. The lavish and gnhf agent skills are installed globally with the skills CLI; no-mistakes ships its own.

Crewmates launch through bin/fm-claude, a blessed launcher wired in via First Mate's untracked config/claude-launcher. It is a deliberate pure passthrough to claude "$@": First Mate's spawn script owns the flags, the model and effort selection, and the encoded launch brief, so rewriting the arguments here would break its adapter.

The no-mistakes daemon is a single shared instance serving every lane, so restarting it kills other lanes' in-flight pipeline runs. Leave it to First Mate; restart it by hand only when nothing is running.

Pi's selective setup

Home Manager deliberately does not own ~/.pi/agent itself. It links exactly four things - the themes and extensions directories, plus models.json and settings.json as individual files - so Pi's authentication, sessions, trust decisions, caches, and downloaded package trees all stay local and untracked. Run /reload inside Pi after editing a linked file.

The local extensions directory is for repository-authored extensions only; third-party package code never belongs there. Two extensions live there today: a terminal-title extension that shows a spinner while Pi is working, and Pi Calm, a conversation-only presentation mode toggled with /calm and off by default. Calm hides collapsed thinking and the call/result shells for Pi's seven built-in tools, and replaces the working row with an animated widget; it never changes prompts, tool execution, model context, session data, or ordering, and /share and /export still use the complete stock transcript. Its on/off choice is stored locally, not in this repo. tests/pi-calm.test.sh is the shell test suite that proves all of that.

Claude status line and settings

home/.claude/statusline.sh reads the JSON payload Claude pipes to it and renders model | directory | branch | context% | effort | output style. The branch is not in that payload, so the script asks Git for it. This is why jq is part of the Nix user package list.

home/.claude/settings.json also registers three SessionStart hooks - gh-axi, chrome-devtools-axi, and lavish-axi - which print ambient context into each new session, and sets the permission mode, TUI mode, and theme.

Two keys in that file are intentionally not version controlled. Claude Code rewrites model and effortLevel in place whenever you use /config or /effort, and the live file is the repo file. A Git clean filter declared in .gitattributes strips those two keys on the way into a commit, so day-to-day model switching never shows up as a diff. Git refuses to take filter definitions from the repo itself, so bootstrap.sh configures the filter per clone - do not re-add the keys to the tracked file.

Authentication is deliberately not in Git

OAuth sessions, tokens, keychain entries, auth.json, histories, logs, and local plugin cache state should remain local. Configuration is reproducible; credentials must be restored interactively.

09 / Git + Lazygit

Maintain the source of truth

Git records each proven setup change. Lazygit is a visual terminal interface for reviewing and operating that same Git repository. GitHub protects the checkpoints from loss and lets the fork receive upstream improvements.

What Lazygit is showing you

Unstaged

Edited, not selected

The file changed on disk, but its changes are not yet selected for the next commit.

Staged

Selected for commit

These are the exact changes Git will include when you create the next checkpoint.

Committed

Saved in history

The snapshot has a message and ID. It remains local until pushed to GitHub.

Lazygit does not replace Git: pressing a key in Lazygit runs an ordinary Git operation. Learning the three states above matters more than memorizing the interface.

Your first Lazygit session

Open it inside a repository
cd "$HOME/dotfiles"
lazygit

Go to the Files panel

Use the arrow keys to focus changed files. The large pane shows the diff for the selected file.

Read the diff before staging

Red lines are removed; green lines are added. Confirm every line belongs in the same checkpoint.

Press Space to stage the selected file

Press Space again to unstage it. Enter opens the staging view when you need to select smaller hunks or lines.

Press lowercase C to commit staged changes

Enter a factual message and use the confirmation key shown in the interface. Current macOS releases commonly use Cmd Enter.

Press Shift-P to push, then Q to quit

After leaving Lazygit, confirm the repository is clean with git status --short.

Contextual key help?
Move through itemsArrow keys
Stage / unstage selected fileSpace
Open line staging viewEnter
Commit staged changesc
Fetch remote statef
Pull current branchp
Push current branchP / Shift P
Quit Lazygitq
Cancel a dialogEsc
Be careful with discard: D opens destructive options for working-tree changes. Do not use it until you have reviewed the diff and intentionally want to lose those edits.

Lazygit action to Git command

Lazygit action Underlying idea
Stage a file git add path/to/file
Unstage a file git restore --staged path/to/file
Commit git commit -m "Message"
Fetch git fetch
Pull git pull
Push git push

Normal maintenance

Review, commit, publish
cd "$HOME/dotfiles"
git status --short
git diff --check
git --no-pager diff

git add path/to/changed-file
git commit -m "Describe the behavior changed"
git push origin main
git status --short
Stage explicitly: the add alias runs git add ., which stages everything. Prefer git add path when you want a controlled checkpoint.

Understand the remote

This repository has exactly one remote, and it is an SSH remote on purpose. An HTTPS remote plus the osxkeychain credential helper would let any process on the machine retrieve the GitHub token through git credential fill; an SSH remote is not extractable that way.

Remote Repository Purpose
origin git@github.com:MattH-ca/m.dotfiles.git The only remote. Push commits here; GitHub Pages publishes docs/ from it.

There is no upstream. If you forked this repo and want to track it, add one yourself with git remote add upstream https://github.com/MattH-ca/m.dotfiles.git, then compare before merging:

Read-only upstream check (forks only)
git fetch upstream
git log --oneline --left-right main...upstream/main

Lines beginning with < are unique to your fork. Lines beginning with > are new upstream. Review before merging, because your editor, Dock, packages, and safety choices will intentionally differ. Use a normal merge and resolve conflicts deliberately; avoid git reset --hard and avoid force-pushing a customized main.

This repo takes no outside contributions. A GitHub Action comments on and auto-closes any PR opened by someone other than the owner, Issues are turned off, and CONTRIBUTING.md says so plainly. That is deliberate: a personal setup only stays useful while it keeps matching one machine. Fork it freely and make it yours.
One diff will look strange. home/.claude/settings.json passes through a Git clean filter that strips model and effortLevel, so those two keys never appear in a commit even though they are in the file on disk. That is the filter working. Section 08 explains why.

Small recoveries

Situation Command
Leave Git's (END) pager Q
Discard one uncommitted file change git restore path/to/file
Unstage without discarding edits git restore --staged path/to/file
Undo a published commit safely git revert COMMIT_SHA
Confirm GitHub authentication gh auth status

10 / Updates

Update intentionally

Pinned inputs give you repeatability. Updating is a deliberate change that should be reviewed, rebuilt, tested, and committed.

Nix inputs

Advance pinned revisions
cd "$HOME/dotfiles"
git status --short
nix flake update
git --no-pager diff -- flake.lock
./rebuild.sh
git add flake.lock
git commit -m "Update Nix flake inputs"
git push origin main

The release branches remain 26.05, while flake.lock advances to newer exact revisions on those branches.

Homebrew-managed software

configuration.nix sets homebrew.onActivation.autoUpdate = true, so each rebuild refreshes Homebrew's formula and cask data on its own. Upgrading the installed packages is still a separate, deliberate step.

Inspect before upgrading
brew outdated

# Upgrade a specific declared tool after reviewing the list
brew upgrade herdr
brew upgrade --cask wezterm claude-code codex

Homebrew itself is pinned by Nix

The trap: Homebrew's own code comes from the brew-src flake input through nix-homebrew and lives in a read-only Nix store path. brew update can therefore never upgrade Homebrew - it only refreshes formula and cask data from the API. So a current cask definition can land on a months-old Homebrew that cannot parse it, and the failure never says the version is the problem. It surfaces as unknown install step: <stanza>, a symlink source that "is not there", or artifacts installed in the wrong order.
The fix is a flake update, not a cask workaround
cd "$HOME/dotfiles"
nix flake update nix-homebrew
./rebuild.sh
Keep updates small: update one layer at a time when possible. It makes failures easier to attribute and rollback.

11 / Recovery

Rebuild the setup on another Mac

The repository reproduces declared configuration. Authentication, private data, and undeclared Homebrew extras remain separate.

Prepare macOS

Use the correct macOS account name, sign in to GitHub, and confirm Apple Silicon architecture where expected.

Clone your fork

Put the repository at ~/dotfiles. The bootstrap also maintains the stable ~/.dotfiles link.

Run the bootstrap

Five steps: install or detect Determinate Nix, link the repo to ~/.dotfiles, configure the settings.json clean filter, check the configured username against yours, run the first nix-darwin switch, and clone First Mate into ~/firstmate.

Reinstall the npm worker layer

The axi CLIs, gnhf, and no-mistakes are imperative and not managed by Nix. The README's "Agent worker tooling" section has the exact commands.

Authenticate and re-bless

GitHub, Claude, Codex, Pi, Hermes providers, and any private services need an interactive login. Re-save the vault secrets and bless the bin/ launchers - none of that is in the repository.

Audit the result

Check commands, links, macOS defaults, Git state, and the actual UI.

Fresh-machine path
cd "$HOME"
git clone https://github.com/MattH-ca/m.dotfiles.git dotfiles
cd "$HOME/dotfiles"
./bootstrap.sh
Home Manager collisions: if activation says a file would be clobbered, stop and inspect that file. Move or delete it only after confirming it is disposable. Do not blindly force overwrites.

What is not reproduced automatically

  • GitHub, Claude, Codex, Pi, OpenCode, and Hermes login sessions or provider keys
  • SSH private keys and Keychain contents, including everything stored in Automic Vault
  • The blessings on the bin/ launchers - each one needs av bless again on a new machine
  • Repositories other than this one and the fresh First Mate clone, plus First Mate's untracked local config
  • The npm-installed worker CLIs (see the README's "Agent worker tooling" section for the reinstall commands)
  • Skills installed by the skills CLI - only the hand-authored ones under home/skills/ are in this repo
  • The nms-autostart Herdr plugin, which lives here but has to be installed into Herdr
  • Pi's downloaded npm and git package trees, and Micro's own ~/.config/micro
  • Wallpaper choice and other intentionally manual personal content
  • The planned isolated sandbox network

12 / Troubleshooting

Known fixes from this build

These are the failures we actually encountered and the reasoning behind each fix.

nix: command not found in an older shell

The shell started before Nix was added to its environment. Load the daemon profile, then open a fresh login shell.

Refresh Nix environment
source /nix/var/nix/profiles/default/etc/profile.d/nix-daemon.sh
exec zsh -l
nix --version
sudo: darwin-rebuild: command not found

sudo did not inherit the user path. The repaired rebuild.sh calls the absolute system path, so use the helper:

Correct rebuild path
cd "$HOME/dotfiles"
./rebuild.sh
Home Manager says an existing file would be clobbered

A real file already occupies a path Home Manager wants to manage. Inspect it, back it up if valuable, then remove or relocate it before rebuilding. During this setup, the conflicts were ~/.zshrc and ~/.config/starship.toml.

Zsh reports a missing Homebrew _brew completion

A stale symlink can remain after completion packages change. Confirm it is a broken symlink before removing it, then regenerate the Zsh completion cache.

Repair stale completion state
ls -l /opt/homebrew/share/zsh/site-functions/_brew
test -L /opt/homebrew/share/zsh/site-functions/_brew && \
  rm /opt/homebrew/share/zsh/site-functions/_brew
find "$HOME" -maxdepth 1 -name '.zcompdump*' -delete
exec zsh -l
WezTerm appearance changes, but startup behavior does not

Most Lua settings hot-reload. The gui-startup handler only runs when the WezTerm GUI starts. Use Cmd Q, then reopen WezTerm.

The Dock or menu bar does not match the declaration

Read the active macOS values. A value of 1 means auto-hide is enabled; 0 means disabled.

Inspect macOS UI defaults
printf 'Menu bar autohide: '
defaults read NSGlobalDomain _HIHideMenuBar

printf 'Dock autohide: '
defaults read com.apple.dock autohide

configuration.nix declares both as auto-hiding, so both should read 1. The other declared defaults are dark mode, fast key repeat (KeyRepeat = 2, InitialKeyRepeat = 15), always-visible file extensions, Finder list view, no desktop icons, and tap to click.

Herdr appears stuck or you cannot get back to the normal shell

Detach with Ctrl B, release, then Q. Run herdr later to reattach. Use herdr server stop only when you intend to terminate the session.

An agent plugin MCP fails because node is missing

Node comes from the Homebrew node formula declared in configuration.nix, because the Pi formula's native modules are ABI-bound to it. After a rebuild, verify command -v node resolves to /opt/homebrew/bin/node. Do not install another ad hoc Node copy, and do not add a Nix nodejs back to home.nix - it would only shadow the one Pi needs.

A tool is installed, but the shell says command not found

This happens after a rebuild that changes the PATH. Home Manager applies its session variables once and sets a guard, __HM_SESS_VARS_SOURCED, that every nested shell inherits - so a shell that started before the rebuild keeps the old PATH forever, and any agent launched from it inherits that too.

Open a fresh WezTerm tab (or reboot), and restart long-lived hosts like the Herdr server from that fresh tab, not just the Herdr TUI. Restarting only the TUI leaves the old server environment in place.

Confirm which PATH a shell has
printf '%s\n' "$PATH" | tr ':' '\n'
command -v THE_MISSING_TOOL
A cask fails with unknown install step or a missing symlink source

Homebrew's own code is pinned by the brew-src flake input and cannot be upgraded by brew update, so a current cask definition has met a Homebrew too old to parse it. Update the input rather than working around the cask.

Advance the pinned Homebrew
cd "$HOME/dotfiles"
nix flake update nix-homebrew
./rebuild.sh
Something you installed by hand disappeared after a rebuild

That is homebrew.onActivation.cleanup = "uninstall" doing its job: every switch removes any Homebrew formula or cask not declared in configuration.nix. Declare it in the right list and rebuild. The setting stays at "uninstall" rather than "zap" precisely so an accidental omission costs you the package, not its data.

nm: command not found, or the daemon will not start

The alias is nms, not nm - nm is binutils' symbol lister. Run nms in a herdr pane and leave it in the foreground. Never reach for no-mistakes daemon start or daemon restart as a fix; both silently break the pipeline's GitHub access without printing an error.

An Automic Vault prompt appears on every launch of cc or the daemon

Expected. The launchers in bin/ are blessed without launcher endorsement, so each launch raises one approval dialog on purpose. If a launcher stops working entirely instead, check whether the file was edited - any edit voids its blessing, and it has to be re-blessed with av bless against the exact path.

Git output shows (END)

You are in Git's pager, not a frozen terminal. Press Q. Use git --no-pager diff when you want output printed directly.

13 / Audit

Run a system check

This audit is read-only. It checks repository cleanliness, important command paths, macOS behavior, managed links, GitHub authentication, and services.

Read-only audit
cd "$HOME/dotfiles"

printf 'Repository: '
test -z "$(git status --porcelain)" && echo CLEAN || git status --short

printf 'Menu bar autohide: '
defaults read NSGlobalDomain _HIHideMenuBar

printf 'Dock autohide: '
defaults read com.apple.dock autohide

for cmd in nix brew darwin-rebuild micro node fzf jq rg lazygit gh herdr hermes \
           claude codex opencode pi wezterm av treehouse tmux uv shellcheck \
           tsc docker no-mistakes
do
  printf '%-16s %s\n' "$cmd" "$(command -v "$cmd" || echo MISSING)"
done

printf '\nManaged instruction links:\n'
ls -l "$HOME/.claude/CLAUDE.md" "$HOME/.codex/AGENTS.md" "$HOME/.config/opencode/AGENTS.md"

printf '\nManaged skill links:\n'
ls -l "$HOME/.agents/skills/bro" "$HOME/.claude/skills/bro" "$HOME/.codex/skills/bro"

printf '\nManaged Pi links:\n'
ls -l "$HOME/.pi/agent/themes" "$HOME/.pi/agent/extensions" \
      "$HOME/.pi/agent/models.json" "$HOME/.pi/agent/settings.json"

printf '\nVault health:\n'
av doctor

printf '\nLauncher keys in the vault:\n'
grep -ho '+[A-Z_][A-Z0-9_]*' "$HOME"/dotfiles/bin/* | tr -d '+' | sort -u |
while read -r k; do
  printf '%-24s ' "$k"
  av list | grep -qx "$k" && echo present || echo MISSING
done

printf '\nFirst Mate clone:\n'
git -C "$HOME/firstmate" status --short --branch

printf '\nno-mistakes daemon:\n'
no-mistakes daemon status

printf '\nGitHub authentication:\n'
gh auth status

printf '\nHomebrew services:\n'
brew services list
Read-only means read-only: if no-mistakes daemon status reports the daemon is not running, start it with nms in a herdr pane. Do not "fix" it with no-mistakes daemon start.

Expected results

14 / Automic Vault

Gate secret access, especially for agents

This is the load-bearing security layer of the whole setup, and the one piece that makes the rest of it safe to run. Everything else here is about giving agents more autonomy; this is the counterweight.

The problem it solves: an autonomous agent runs shell commands as you, with your environment and your file access. Anything that can read a token from a dotfile, a .env, or an ambient credential helper can read it too - and an agent that reads one has no obligation to tell you. Declaring cc and co as no-prompt aliases only makes sense because this layer exists underneath them.

Automic Vault answers that by moving secrets into the macOS Keychain and putting a human-approval gate between your tools and those secrets. It ships in this setup as a Homebrew tap, a hardened gh, and a menu bar app.

Vault

Secrets in the Keychain

Tokens and keys are stored in the macOS login Keychain instead of plaintext config files, so they never sit on disk for a process to read.

Wrapper

Hardened tools

A hardened tool is replaced on PATH by a codesigned build that only it can unlock, and every authenticated use routes through the gate. Here, gh is hardened.

Gate

Approval prompts

When a gated secret is requested and the current trust level cannot auto-approve it, the menu bar app raises a notification for you to approve or deny.

The command is av (the app is spelled "Automic Vault," not "Atomic"). av help lists everything; av doctor reports whether the hardened tools are healthy.

How it is wired here

Three declarations install it, plus one PATH entry so the av command resolves. A fresh rebuild.sh brings all of this along, which is why an agent's first gh call can surface an approval prompt.

configuration.nix and home.nix
# configuration.nix - trusted tap, hardened gh, the app
taps  = [ { name = "automic-vault/isotopes"; trusted = true; } ];
brews = [ "automic-vault/isotopes/gh-cli" ];
casks = [ "automic-vault" ];

# home.nix - put the av CLI on PATH
home.sessionPath = [ "/Applications/Automic Vault.app/Contents/MacOS" ];

The blessed launchers in bin/

Four scripts in this repo start a tool with exactly one secret already in its environment. Each has an av inject shebang, so the secret exists only inside that process and never touches disk or a command line.

Launcher Starts Why it exists
bin/cc Claude Code Gives Claude a scoped GitHub token up front, so routine gh calls do not each raise a prompt.
bin/fm-claude A First Mate crewmate Same token for spawned workers; a pure passthrough so First Mate keeps owning the flags.
bin/no-mistakes-daemon The validation pipeline daemon Runs it in the foreground from a herdr pane, which is the only supported way to start it.
Editing a launcher voids its blessing. Blessing approves that exact file, byte for byte. After any change, re-run av bless against the same path - and note that this is also the protection working: an agent cannot append a line to a blessed script and keep its secret access.

The whole command surface

av help prints this list; it is short enough to learn in one sitting.

Command What it does
av save <key> Prompt for a secret value and store it in the login Keychain. The value is never typed on a command line.
av list List the names of stored secrets. Never prints values.
av inject +KEY [--] <cmd> Run one command with those secrets in its environment, and nowhere else.
av inject -- <cmd> Run an already-approved script.
av bless <path> Review and approve one exact file for secret access.
av harden <tool> Replace a tool with a hardened build and migrate its credentials into the Keychain.
av unharden brew Temporarily restore stock Homebrew - needed for some cask migrations.
av doctor [<tool>] Verify the installed hardening is healthy.
av scan Audit secrets and configuration, reporting findings by severity.
av hardeners --json Show every available hardener and which ones apply to this machine.
av open [--secret-gate <id>] Open the menu bar app, optionally straight to one tool's gate settings.

Reading the two audit commands

av doctor

Is the hardening intact?

Prints one line per tool it manages and ends with No problems found when everything is healthy. Run it after any rebuild that touched Homebrew, and on a new machine before trusting the setup.

av scan

What is exposed right now?

A severity-ranked report, not a pass/fail gate. A wall of HIGH findings is the report doing its job. On a Nix and Homebrew Mac the PATH-ordering findings are inherent and expected.

Hardening a tool

A hardener does more than wrap a binary: it migrates that tool's existing credentials out of its plaintext config and into the Keychain, then leaves a small launcher on PATH in its place. Automic Vault ships around fifty of them, covering the usual credential-bearing CLIs - aws, docker, brew, gh, stripe, supabase, node, sudo and many more. This config declares the hardened GitHub CLI as a Homebrew formula so a rebuild installs it; anything else is an interactive choice.

See what applies, then decide
av hardeners --json | jq -r '.hardeners[]
  | select(.applicable) | "\(.name)\t hardened=\(.hardened)"'

av harden sudo     # e.g. approve sudo with Touch ID instead of a password
av doctor          # confirm it is healthy afterwards

Store and inject your own secrets

Instead of exporting a key in a dotfile or a .env, keep it in the vault and hand it to one command for the duration of that command only.

save, then inject
av save OPENAI_API_KEY                       # prompts for the value; stores it in the Keychain
av inject +OPENAI_API_KEY -- python train.py # the key exists only inside this process

For a script you run often, put av inject in its shebang and av bless the file - the same pattern the bin/ launchers above use.

a blessed script
#!/usr/local/bin/av inject +OPENAI_API_KEY /bin/zsh -f
python eval.py

# then approve the exact file once:
av bless ./run-eval.sh
How often you get prompted is a setting. The hardened gh uses a Secret Gate with per-app trust levels, managed in the menu bar app (av open --secret-gate gh). Higher trust means fewer prompts for routine reads, at the cost of a checkpoint you would otherwise have seen - so choose it deliberately, per app, rather than reaching for the most permissive level. Raw token extraction (gh auth token) always prompts regardless.

Standing this up on a new machine

The rebuild installs the tap, the hardened gh, the app, and the PATH entry. Everything after that is deliberately manual, because it is exactly the part that must not be reproducible from a public repository.

Launch the app once

It needs to be running for approvals to reach you. Confirm the CLI resolves with command -v av.

Re-save every secret

av save KEY for each one. Keychain contents do not travel with the repo, and you should be the one typing the values.

Re-bless the launchers

Blessings are per-file and per-machine. Every script in bin/ needs av bless again before its alias works.

Verify

av doctor should report no problems, and av list should show the names you expect.

Re-bless the three launchers
for f in cc fm-claude no-mistakes-daemon; do
  av bless "$HOME/dotfiles/bin/$f"
done
av doctor

Keep GitHub tokens out of reach

An HTTPS remote plus the osxkeychain credential helper lets any process retrieve your GitHub token through git credential fill. Prefer SSH remotes, which are not extractable that way.

Switch a repo to SSH
git remote set-url origin git@github.com:OWNER/REPO.git
printf 'protocol=https\nhost=github.com\n\n' | git credential reject  # drop the cached token

15 / References

Standing on other people's work

Almost nothing here was invented for this repository. It is an assembly of other people's projects, and the parts that feel clever are usually theirs. This section credits what it was built from, then points at the documentation worth reading.

Where this started: this repo began as a working copy of kunchenguid/dotfiles - Kun Chen's dotfiles for agentic engineering - and grew from there. The walkthrough video is what made the whole approach click. The structure of the flake, the bootstrap, and the symlink model are all downstream of that work.

The agent layer

A large share of the orchestration tooling is also Kun Chen's. First Mate coordinates the crews, Treehouse gives each worker an isolated worktree, no-mistakes gates every delivery, and the axi CLIs are what the agents actually reach for.

The tools this setup is made of

Documentation

No matching section

Try a broader term such as Nix, Git, Herdr, or rebuild.