Blazingly Fast Development Setup 🚀
- Clone this repo into
~/.config/dotfiles
The whole workspace is declared in flake.nix and nix/: packages, GUI apps, links into this repository, macOS settings and the setup steps that are not packages. Every install, change and upgrade goes through that declaration. Nothing is installed with brew, mise, npm -g, go install or a curl installer by hand.
- Set up a machine:
./setup.sh, on macOS or on any Linux distribution (NixOS is not needed). It installs Nix, and on macOS the Xcode command line tools, if they are missing, then runs the first switch: nix-darwin with the configuration for this Mac's hostname (darwinConfigurationsinflake.nix), or home-manager on Linux. The platform comes fromuname;--macosor--linuxforces it. Every step skips itself when already done, so it also repairs a Mac whose Nix was reinstalled. On a Linux box without the clone:curl -fsSL https://raw.githubusercontent.com/sjdonado/dotfiles/main/setup.sh | bash, which clones this repository first. - Apply a change to the declaration:
~/.config/dotfiles/sync.sh. It switches (nix run .#sync), then checks the result: declared tools run, links resolve into this repository, settings hold, and non-interactive shells find the tools. Nix reads this repository as a git flake, so a new file must be staged (git add) before a sync sees it. - Upgrade everything:
~/.config/dotfiles/sync.sh --upgrade. It updates every pinned input inflake.lock(nixpkgs, home-manager, nix-darwin, nix-homebrew and each Homebrew tap), switches and checks. Review and commit theflake.lockdiff. - Check without changing anything:
~/.config/dotfiles/sync.sh --check. To see what a switch would run without running it:nix run ~/.config/dotfiles#sync -- --dry-run(#upgradetakes it too). - Verify a change before it reaches a machine:
verify/linux.shprovisions a throwaway Ubuntu container withsetup.shand checks it withsync.sh, andverify/macos.shevaluates or builds the macOS configuration without applying it. Seeverify/README.md.
| what | where |
|---|---|
| command-line tools on both platforms, links, setup steps | nix/home/common.nix |
shell files on Linux (~/.profile, ~/.bashrc, ~/.zshenv, ~/.zshrc) |
nix/home/linux.nix |
| macOS command-line tools, launch agents, macOS setup steps | nix/home/darwin.nix |
| macOS defaults, login shell, Nix settings | nix/darwin/system.nix |
| casks, the Homebrew formulas Nix lacks, taps (App Store apps are listed there, commented out) | nix/darwin/homebrew.nix |
| tools packaged here from their upstream binaries | nix/pkgs/ |
sync and upgrade |
nix/apps.nix |
The repository is the source of truth. Configs reach it through live links, and everything else through nix/ and a sync.
- Edit a config without a sync. Every tracked config (
~/.zshrcon macOS, Zed,~/.gitconfig, Claude Code'ssettings.json, OpenCode) is a link into this clone, so editing the file here or through its link is the same edit, and the tool reads it on its next start. Tools that write their own settings (Zed, OpenCode'skv.json, Pi) write into the repository, so their changes show up ingit status. Commit them like code. - Change what is installed or linked through
nix/, then run~/.config/dotfiles/sync.sh. That covers a tool, an app, a new config link, a macOS setting or a setup step.- A command-line tool: find its nixpkgs name with
nix search nixpkgs <name>, then add it tohome.packagesinnix/home/darwin.nix(macOS only) ornix/home/common.nix(both platforms). - Only when nixpkgs lacks it: add it to
brewsinnix/darwin/homebrew.nix. A formula from a third-party tap also needs the tap, as a flake input inflake.nixand an entry innix-homebrew.taps. - A GUI app: add its cask to
casksinnix/darwin/homebrew.nix. GUI apps stay casks even when nixpkgs packages them, because casks install real bundles into /Applications, which Launch Services, Spotlight and Raycast index. - Removing works the same way: the sync uninstalls Homebrew formulas and casks the file does not list (
cleanup = "uninstall"), but never deletes their app data.
- A command-line tool: find its nixpkgs name with
- Do not run
brew installorbrew tapby hand, from any account. Homebrew has one writer: the sync, which runsbrewas the primary user, who owns/opt/homebrew. A formula installed by hand is uninstalled by the next sync, the taps are a read-only link to the pinned inputs, andbrew updateis disabled. - Upgrade with
~/.config/dotfiles/sync.sh --upgradeand commit theflake.lockdiff. - Files Nix builds change only through
nix/and a sync.~/.local/bin/claudeand~/.local/bin/codexare the exception: their own installers put them there and they update themselves (T3 Code's update button runsclaude updateandcodex update); the sync only installs whichever is missing. - An app that saves by replacing a file (an atomic save) turns its link into a regular file, which then stops tracking the repository.
sync.sh --checkreports it as "is not a link", and the next sync restores the link and keeps the replaced file as<path>.pre-nix.
A Mac has one nix-darwin system, so every account on it is listed in flake.nix, in the darwin [ ... ] list for the Mac's hostname. Any number of accounts works the same way; this setup is used with two. The first is the primary account: it owns Nix's setup, Homebrew and the system settings, and setup.sh and the switch run only from it (from another account, sync.sh only checks), so removing any other account leaves the Mac working. One sync applies the configuration to every listed account.
- Every account has its own clone at
~/.config/dotfiles, and its links point into that clone. A config change reaches another account when that account runsgit pull, with no sync. - A sync applies the declaration from the primary account's clone, so
git pullthere first. - Packages live once in the shared
/nix/store, readable by every account, which is why secrets stay outside Nix.sudo nix-collect-garbage -dfrees what no account's generation still uses. - To add an account: add it to the list in
flake.nix, clone this repository into its~/.config/dotfileson the same branch, then runsync.shfrom the primary account, which switches and checks every listed account. The sync sets its login shell to zsh. - To change the primary account: move it to the front of the list, then run
setup.shonce from it. It hands/opt/homebrew, and the app bundles other accounts installed in/Applications, to the new primary account; the sync refuses to run until it has. - To remove an account: drop it from the list and sync from the primary account before deleting the macOS account.
Handy's preferences are tracked in macos/handy/settings_store.json, linked to ~/Library/Application Support/com.pais.handy/settings_store.json for each account. Close Handy before the first sync installs the link. Afterward, changes made in Handy update the tracked file: quit Handy, review, commit and push the preferences, then close Handy in the other account before pulling and reopening it. The shared onboarding_completed flag starts false and Handy saves it back to the shared file when onboarding finishes, so onboarding completion carries across accounts; use Handy's model settings to download the selected model separately in each account. Models, recordings, history and macOS microphone and Accessibility permissions remain local to each account. The settings file also holds post-processing API keys; keep those empty in the tracked configuration.
zsh is every account's login shell. Its interactive setup (PATH order, EDITOR, the bat themes, the wt* aliases, ctrl+f to cd into a repository) is zsh/.zshrc, linked to ~/.zshrc. On Linux the login shell stays POSIX for Coder, interactive terminals exec zsh, and home-manager's ~/.zshrc sources the same zsh/.zshrc.
flake.lock pins every input, so two machines switched from the same commit get the same versions. Most tools come from nixpkgs-unstable. OpenCode comes from its upstream release binary, in nix/pkgs/, because nixpkgs still has v1. Claude Code and Codex are not pinned: the sync runs their own installers once and they update themselves, so T3 Code can update them. Two macOS tools come from Homebrew formulas, because nixpkgs lacks them: browser-router and cliclick. gcloud-cli's six formula dependencies are listed too, because cleanup keeps the dependencies of listed formulas but not of casks. Homebrew itself is pinned by nix-homebrew, and every tap is a flake input, so a cask version moves only when flake.lock does. The exception is casks that update themselves (1Password, Chrome, Slack, Zed): they can move ahead of the pin between switches.
Homebrew kept some versions off PATH (keg-only) because they share command names with the default version. Those are linked under ~/.local/opt/ instead: jdk11, jdk17, llvm, openssl-4, zig-0_15. GNU tools that would shadow the macOS ones keep Homebrew's names: gsed, gawk, the g-prefixed coreutils, and telnet alone from inetutils.
The sync cannot do these. Everything else is declared.
- Choose default apps by hand, in each account: a file's Get Info, then Open with, then Change All.
dutiexits successfully but changes nothing on macOS 26 and later, somacos/default-apps.dutirecords the intent only. - Install App Store apps by hand from the App Store. Their IDs stay listed, commented out, under
masAppsinnix/darwin/homebrew.nix; uncommenting them makes the sync install them, which needs an App Store sign-in. - Open
~/Applications/BrowserRouter.apponce and accept it as the default browser. - The Safari web apps (Gmail, Calendar, Google Meet) are created in Safari with File > Add to Dock. BrowserRouter routes to them by the IDs Safari generates, so a new web app needs its ID updated in
macos/browser-router.json. - Claude Code URL Handler is created by Claude Code, and Safari is part of macOS.
- Secrets and logins, in each account:
.envvalues, SSH keys and tool logins (OpenCode providers and MCP servers, Claude Code, Codex,gh). - On a Linux box without systemd (a container or a typical Coder workspace), nothing restarts the Nix daemon after a restart. Run
setup.shagain, which Coder does on workspace start.
OpenCode is the primary agent for daily work. Claude Code, Codex, and Pi remain installed for experiments. The agents share skills and global instructions from agents/; the sync links the managed configuration into each harness.
Authenticate OpenCode's provider with /connect or the CLI. The configured MCP server (ios-simulator) starts automatically; authenticate any server that requires it:
opencode auth login
opencode mcp auth <server>OpenCode defaults to hosted GPT-6.1 Sol through OpenAI, with native code mode, background shell jobs, built-in Terminals, animations, and the ios-simulator MCP server enabled at startup. LSP, automatic formatters, and snapshots are disabled. See opencode/README.md for setup and agents/README.md for harness details.
Experimental harness setup is documented in agents/README.md and pi/README.md. pi-agent remains available for interactive use. Harness benchmarks use direct gpt-6-luna subagents; see evals/README.md.
When experimenting with Codex, select the built-in ansi syntax theme with /theme. It uses the terminal's ANSI palette, so syntax colors follow the terminal's live dark/light theme switch instead of staying pinned to a dark or light TextMate theme.
The editor is Zed, used with T3 Code. Neovim is installed plain, without nvim/, as $EDITOR for git. The full Neovim setup (the configuration in nvim/, its language servers and formatters, lazygit and tree-sitter) is commented out in nix/home/common.nix, ready to restore by uncommenting it and syncing.
Links are routed by BrowserRouter, its own repository now rather than a folder in here: it is the system default browser, matches every link against regexes and hands it to the browser that rule names. Nothing resident, no UI. Config reference and measured footprint are in that README.
What is local to this setup: the rules live at macos/browser-router.json, linked to ~/.config/browser-router/config.json, so a routing change is a commit rather than a machine-local edit. Local dev servers and Cloudflare previews go to Helium. Linear links go to the Linear app. Gmail, Calendar and Google Meet go to their Safari web apps. Everything else goes to a Safari tab. The sync links that config, installs the app from the sjdonado/tap formula built from source, and registers the bundle with LaunchServices.
Making it the default browser is a system prompt, and so is letting it control Safari the first time. Neither can be answered by a script, which is why the sync stops at registration: the bundle is built and registered by then, and answering that prompt is the one step left.
open ~/Applications/BrowserRouter.app