My personal dotfiles for macOS, built around Laravel/PHP work at Intilli and Tallieu & Tallieu.
Shell config, Homebrew packages, per client commands, and a version controlled Claude Code setup (skills, agents, and settings). Application preferences are handled separately by Mackup.
On a fresh Mac:
git clone https://github.com/NathanGeerinck/dotfiles.git ~/.dotfiles
cd ~/.dotfiles
bin/installThe script asks for your password once at the start, then installs the Xcode Command Line Tools, Oh My Zsh, Homebrew, everything in the Brewfile, Laravel Valet, and the Ploi CLI. It prompts before installing Claude Code and before applying the macOS system preferences. It is idempotent, so rerunning it is safe.
It does leave one thing to you: run mackup restore to pull your application preferences back from iCloud.
Secrets are handled for you, as long as the 1Password app is installed and unlocked with the CLI integration turned on. See below.
~/.dotfiles
├── bin/ install, update, install-claude, lib.sh
├── config/ Brewfile, claude/, iterm/, phpstorm/
├── home/ .zshrc and the files it sources
├── macos/ set-defaults.sh, .mackup.cfg, the MCP env LaunchAgent
└── work/ intilli/, tallieu/
bin/install creates these. Anything real that is already in the way gets renamed to <name>.backup-<timestamp> rather than deleted.
| Symlink | Points to |
|---|---|
~/.zshrc |
home/.zshrc |
~/.gitconfig |
home/.gitconfig |
~/.global-gitignore |
home/.global-gitignore |
~/.mackup.cfg |
macos/.mackup.cfg (only if absent, see below) |
~/.claude/CLAUDE.md |
config/claude/CLAUDE.md |
~/.claude/laravel-php-guidelines.md |
config/claude/laravel-php-guidelines.md |
~/.claude/settings.json |
config/claude/settings.json |
~/.claude/statusline.sh |
config/claude/statusline.sh |
~/.claude/skills |
config/claude/skills/ |
~/.claude/agents |
config/claude/agents/ |
This repo owns the shell (.zshrc and everything it sources), git (.gitconfig and .global-gitignore), and the Claude Code config. Mackup is told to ignore zsh and git, so it stays out of the way of all of it.
Mackup owns application preferences only, synced through iCloud.
.mackup.cfg is the awkward case. Mackup syncs its own config file, so once you run mackup backup, ~/.mackup.cfg becomes a symlink into iCloud and this repo's copy is no longer what you are reading. The copy here exists only to bootstrap a fresh Mac, where Mackup needs to know the storage engine before it can restore anything. That is why bin/install places it only when nothing is there yet. If you change macos/.mackup.cfg, mirror it into ~/.mackup.cfg, or the change only reaches a machine that has never run Mackup.
Git used to be Mackup's. There is a stale .gitconfig sitting in the iCloud Mackup folder from before the switch to SSH commit signing, which is why git is now in applications_to_ignore: without that, mackup restore would overwrite the real config with the old one.
~/.zshrc is the entry point for shells, and it sources the rest explicitly in this order:
home/env.zshexports everything in.env.- Oh My Zsh loads, which also runs
compinit. home/path.zshsets up$PATH.home/aliases.zshdefines the aliases and sourceswork/intilli/intilli.zshandwork/tallieu/tnt.zsh.
Order matters in two places. Anything using compdef (the tnt and intilli completions) has to come after Oh My Zsh, and aliases come last so they win over the ones Oh My Zsh plugins define.
It is not the only way into the environment any more. Apps started by launchd never run ~/.zshrc, so a second path exists for them: macos/mcp-env.sh, run at login by a LaunchAgent. It sources home/env.zsh, the same file listed above, so the two cannot drift. See MCP servers for why.
Secrets live in 1Password, never in this repo and never in iCloud. .env is generated from .env.tpl, which is committed and holds 1Password secret references instead of values:
NGROK_AUTHTOKEN_INTILLI="{{ op://Intilli/Ngrok/authtoken }}"
bin/install generates .env on a new machine. Regenerate it yourself after rotating a token:
op inject -f -i .env.tpl -o .envThis needs the 1Password app unlocked with the CLI integration enabled (Settings, Developer, Integrate with 1Password CLI). bin/install skips this step with a warning when op is missing or signed out, so the rest of the install still completes.
.env itself is gitignored and stays an ordinary file, so home/env.zsh sources it with set -a and every key is exported automatically. There is no per shell call to op and no startup cost. To add a secret, put it in 1Password, add a line to .env.tpl, and regenerate.
One quirk worth knowing: the Tallieu & Tallieu vault is referenced by ID rather than name, because op rejects the & in "Tallieu & Tallieu" as an illegal character in a secret reference.
A missing field is not a soft failure. op inject aborts on the whole template rather than skipping the one line it cannot resolve: it exits 1 and writes nothing, leaving any existing .env untouched. So a bad reference does not destroy your secrets, but it does mean the file silently stays stale. Add the field in 1Password first, then add the reference here.
config/claude/ is version controlled and symlinked into ~/.claude, so skills, agents, and settings survive a machine rebuild. Install or relink it on its own with:
bin/install-claudeAdd a skill with npx skills add <owner/repo> (it lands in config/claude/skills/), then commit it. Browse more at skills.sh.
MCP servers are the one part of the Claude Code setup that cannot be symlinked. Claude Code stores them in ~/.claude.json, which also holds machine specific state (startup counts, project history, per project notes), so the file is no use in a repo. bin/install-claude registers them with claude mcp add instead, which gets the same reproducibility without version controlling any of that noise.
Registered right now:
| Server | Transport | Notes |
|---|---|---|
ploi |
HTTP, https://ploi.io/api/mcp |
Needs a Ploi Pro plan or higher. OAuth |
larabug |
HTTP, https://www.larabug.com/mcp |
OAuth |
nodux |
HTTP, https://intilli.be/backend/mcp |
Bearer token from $INTILLI_MCP_TOKEN |
shortcut |
stdio, npx @shortcut/mcp |
API token from $SHORTCUT_KEY_TNT |
obsidian |
stdio, npx obsidian-mcp |
Local vault, no credential |
ray |
HTTP, http://localhost:2411/mcp |
Served by the Ray desktop app, no credential. Only answers while Ray is open |
Authenticating the OAuth ones is manual and once per machine, because the flow opens a browser. Start Claude Code, run /mcp, select the server, then choose Authenticate. The tokens go to the macOS Keychain, so they never touch this repo or ~/.claude.json.
nodux and shortcut authenticate with a token rather than OAuth. Those are registered with a ${VAR} reference instead of a value, so ~/.claude.json holds Bearer ${INTILLI_MCP_TOKEN} and nothing secret. Claude Code expands the reference from its own process environment when it connects.
That works from iTerm, where ~/.zshrc has already exported everything in .env. It does not work when Claude Code is started by a GUI app such as Bloom. Those are launched by launchd with no interactive shell in between, zsh only reads ~/.zshrc for interactive shells, and there is no ~/.zshenv. The variable is simply absent, the header goes out as the literal string Bearer ${INTILLI_MCP_TOKEN}, and the server answers 401. The stdio case is quieter and worse: the server starts fine under npx, so it looks connected, and only the first real API call fails.
macos/be.intilli.mcp-env.plist fixes it. At login it runs macos/mcp-env.sh, which sources home/env.zsh and then launchctl setenvs every variable the MCP config references. The list is read out of ~/.claude.json rather than hardcoded, so registering a server with a new ${VAR} needs no edit. bin/install-claude symlinks the plist into ~/Library/LaunchAgents and loads it.
The tradeoff. Anything launchctl setenv publishes is readable by every process running as you, through launchctl getenv. That is wider exposure than a variable that only exists inside a shell. It is accepted deliberately, because a GUI launched Claude Code has no other way to see these values. The narrower alternative, putting the exports in ~/.zshenv, was rejected: it runs for every shell including non interactive ones, and it would add a second shell entry point next to ~/.zshrc, which is exactly the indirection How the shell loads exists to avoid. Only tokens already registered in ~/.claude.json are published, so the exposure never grows past what Claude Code itself is holding.
To check it without rebooting, print the length and never the value:
launchctl getenv INTILLI_MCP_TOKEN | wc -c
To add another server, register it with claude mcp add --scope user ..., then mirror the command into bin/install-claude so a rebuild picks it up.
Be deliberate about which servers you add. Ploi's get-site-env tool returns raw .env contents, secrets included, to whatever client is connected.
Ploi hosts the servers, and there are two separate integrations here.
The CLI is installed globally by bin/install (composer global require ploi/cli) and lands on $PATH through ~/.composer/vendor/bin. It needs PHP 8.2 or higher. Useful commands:
ploi init # Link the current directory to a Ploi site
ploi deploy # Deploy, optionally streaming the log
ploi ssh # SSH into a server
ploi list # Show every commandThe MCP server gives Claude Code the same reach. See MCP servers above.
They authenticate separately, and neither one shares the other's credentials.
The CLI takes its token from PLOI_API_TOKEN, which .env provides from 1Password. This is worth knowing because it is not the documented flow: the docs tell you to run ploi token, which writes the token in plaintext to ~/.ploi/config.php. The CLI checks the environment variable first and prefers it, so the token stays in the vault and a rebuild needs no manual step.
Rotating it, or setting it up on a machine that has never had it:
# Create a key at https://ploi.io/profile/api-keys, then store it
op item edit ploi.io --vault Intilli api-token=<token>
op inject -f -i .env.tpl -o .env
exec zshop inject fails on the whole template while that field is absent, so create the field before regenerating. Verify with ploi server:list.
reloadReload the ZSH configurationphpstormOpen the current directory in PhpStormemptytrashEmpty the trash and clear system logs
switch-php84,switch-php83,switch-php82,switch-php81,switch-php80,switch-php74
paShortcut forphp artisan
pull,push,commitQuick git shortcuts
dcuRundocker compose updpsRundocker psdssh <service>Open a bash shell in a running container
intilli startup # Start Valet and PHP Monitor
intilli shutdown # Stop Valet and quit PHP Monitor
intilli share # Run valet share
intilli help # Show help messagetnt open <project> # Open a project in PhpStorm
tnt clone <repo> [-gh] # Clone repo from Bitbucket (or GitHub with -gh)
tnt pull-notes # Pull Obsidian notes
tnt sync-notes [message] # Sync Obsidian notes
tnt share [args] # Run ngrok with T&T credentials (e.g. tnt share http 80)
tnt startup # Start Docker
tnt shutdown # Quit Docker Desktop
tnt help # Show help messageBoth commands support tab completion.
bin/updatePulls this repo, then updates Homebrew, reapplies the Brewfile, and updates global Composer packages.
Everything lives in config/Brewfile. To add a tool:
echo "brew 'neovim'" >> config/Brewfile
brew bundle --file=config/Brewfilemacos/set-defaults.sh writes a long list of system preferences (originally from Mathias Bynens). bin/install offers to run it. It restarts Finder, Dock, and several other apps, so run it when you are not in the middle of something.
Inspiration from these excellent dotfiles repositories: