phoinixi@workstation
UTC+1 · --:--:-- _

A macOS dev setup for humans and AI agents

My dotfiles now provision two runtimes side by side: my shell (Ghostty, Zed, zsh) and my Claude Code agent harness. One install.sh handles both.

I’ve kept a dotfiles repo for years. For most of that time it was the predictable stuff: brew, zsh, a Brewfile, some macOS defaults, the occasional alias I forgot I added. Then last year I started using Claude Code daily, and for a while my agent config was the one part of my setup that wasn’t versioned anywhere. By the time I’d duct-taped my third machine into something workable, I gave up and rewrote the repo around a new premise: my dev environment runs on two co-equal runtimes now, the human shell and the agent harness, and both should come up from a single ./install.sh.

This post is what that looks like a year in.

The sane parts

Most of it is what you’d expect. Ghostty replaced Hyper somewhere along the way (it’s faster and not Electron, no further justification needed). Zed is my main editor, with VS Code kept around for the rare moment I need an extension that hasn’t been ported yet. The shell side is zsh on Oh My Zsh with the usual modern coreutils replacements, plus two tools I’d actually fight for: atuin for synced shell history and zoxide because I’d forgotten how much typing cd was costing me. Node lives behind fnm, packages through pnpm and npm. None of this is novel. The Brewfile declares all of it, and re-running brew bundle from the install script is idempotent, so I don’t have to think about what’s already installed.

The one bit of structural opinion is that the installer is modular. There’s a numbered install/ directory and a dispatcher that runs everything in order, but you can also do ./install.sh --only claude or --only ghostty when you just want to refresh one piece. I use that constantly, mostly when I’m tweaking the agent setup and don’t want to wait for brew to think about itself.

The agent harness

This is the half that didn’t exist a year ago. Inside the repo there’s a claude/ directory that gets symlinked to ~/.claude/. That’s where Claude Code reads its global config from: settings.json, CLAUDE.md (the memory file loaded into every session), and four directories called agents/, commands/, hooks/, and output-styles/ that hold the cognitive bits I’ve authored over time. Slash commands I use enough to keep, hooks that gate certain actions, output styles I switch between depending on the project. There’s also a tiny plugins/installed_plugins.json that just lists which plugins are on (caveman, vercel, coderabbit at the moment).

The hard part wasn’t getting it to symlink, it was figuring out what to track. Claude Code’s live settings.json is a mix of two things. There’s the durable stuff: which plugins I want, my editor mode, the generic permission patterns I’ve decided are fine on every project (Bash(git push:*), that kind of thing). And then there’s the machine-specific noise: per-project absolute-path allowlists like Bash(node /Users/phoinixi/workspace/some-client/script.js) that drift in every time I approve a permission prompt on a new repo. If I just committed the live file, every machine would step on every other machine’s allowlist within a week.

So I split it. The repo only tracks the durable bits. The local stuff lives in ~/.claude/settings.local.json, which is gitignored, and the install script knows not to touch it. Before I commit anything pulled from a live machine, I run ./scripts/sync-claude-settings.sh, which diffs the two and tells me exactly which lines it would strip. The first time I ran it I was about to commit twelve hardcoded paths into the public repo, so the script has already paid for itself.

scaffold-ai

The other piece I rely on every week is a small CLI called scaffold-ai that the install puts on $PATH. From inside any new project:

scaffold-ai .

It writes out .claude/settings.json, .claude/commands/, .mcp.json, AGENTS.md, an editorconfig, and a gitignore, with {{PROJECT}} replaced by the directory name. Three seconds and the repo is agent-ready, with whatever default slash commands and MCP servers I want preconfigured. I used to just copy these files from the last project, which is exactly the kind of small wound that bleeds for a year before you do anything about it.

Secrets

Stored in macOS Keychain, accessed through a one-liner in .zshrc:

kc OPENAI_API_KEY        # prints to stdout
$(kc GITHUB_TOKEN)       # inline as a value

That’s it. No .env files in random project directories, no extra vault CLI to install, no story about what happens when I switch machines. If I ever want to swap to Bitwarden or doppler I rewrite kc and nothing else changes.

Why I bothered

The honest answer is that I was getting bitten by small inconsistencies. A slash command that worked on my work laptop but not on my personal one. A hook I’d written, forgotten, then re-written slightly differently three months later. A project where the MCP servers I needed were configured on one machine and not the other. None of these are catastrophes individually, but together they were eating an hour a week, and the fix was just to treat the agent harness like I treat everything else: source-controlled, declarative, idempotent.

If you want to fork it, the repo is here, and the rules I want any agent (or human) to follow when modifying it are in AGENTS.md. Steal whatever’s useful.