This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
LLM-maintained dotfiles for multi-machine sync. A chezmoi-managed repo for macOS (Apple Silicon) where Claude Code is the primary maintenance interface. The home/ directory is the chezmoi source state -- it maps to $HOME on the target machine. The install.sh script bootstraps a fresh Mac from zero.
Default workflow: When a user opens this repo and wants to apply, sync, update, or ask what to do with their dotfiles, suggest the /dotfiles-sync skill first. It handles drift detection, classification (core vs local), selective apply, and the full sync workflow.
User-facing customization flows (how to change Brewfile, secrets, editors, etc.) live in docs/guide.md. When a user asks "how do I change X", point them there rather than reinventing.
These principles govern the repo's architecture. Don't violate them without explicit user buy-in.
-
The LLM does bookkeeping, the user makes decisions. Never auto-sync, auto-promote, auto-demote, or auto-rotate without user confirmation. The whole point is that the user's hands stay on the wheel for choices; the LLM only handles mechanics.
-
Three-way classification: core / local / skip. Every new package, extension, or config is one of these. Core = shared across all machines (committed). Local = this machine only (gitignored
.localfiles). Skip = don't track. When in doubt, ask -- don't assume "all core" or "all local." -
Multi-machine is a first-class use case. Every change should consider: how does this behave when N machines run it? Per-machine state stays per-machine; shared state stays shared.
-
Secrets live in 1Password; Keychain is a per-machine cache. Never commit secret values. Never use iCloud Keychain for these (per-machine isolation is a feature, not a limitation).
chezmoi applyMUST NOT trigger 1P popups -- secrets resolve lazily at shell startup viasecret-cache-read. -
Apply must be idempotent and silent. Running
chezmoi apply100 times in a row should produce the same final state with zero interactive prompts (no 1P popups, no sudo prompts unless genuinely needed). If a script needs interactivity, it's likely the wrong abstraction. -
Sync log is the audit trail. Every meaningful change appends an entry to
docs/sync-log.md, hostname-tagged (@ <hostname>). This is more discoverable than git log for "what did I change on which machine."
chezmoi apply # Deploy all configs + run scripts
chezmoi apply --dry-run # Preview what would change
chezmoi diff # Show pending diffs
chezmoi apply --refresh-externals # Force re-download fish plugins
chezmoi managed # List all managed files
chezmoi edit ~/.config/fish/config.fish # Edit a managed filechezmoi uses filename prefixes to encode target attributes:
dot_→ hidden file (e.g.,dot_gitconfig→~/.gitconfig).tmplsuffix → Go template, rendered at apply timeprivate_→ mode 0600encrypted_→ encrypted in repo, decrypted on apply
1Password (primary):
- Apply-time resolution (
onepasswordReadin Go templates) — resolves atchezmoi apply, bakes secret into rendered file. Triggers 1P popup on every apply. Used only where the secret must be present at file write time (e.g.,dot_gitconfig.tmpl,dot_config/zed/settings.json.tmpl). - Lazy-resolution + Keychain cache (preferred for env vars) — the
secrets.fish.tmplemits calls to~/.local/bin/secret-cache-read, which checks macOS Keychain first and only callsop readon cache miss. Result:chezmoi applynever touches 1Password; only the first shell on a new machine triggers popups.
Register auto-loaded env vars via:
dotfiles secret add VAR "op://Vault/Item/field" # register
dotfiles secret rm VAR # unregister
dotfiles secret list # show bindings + cache status
dotfiles secret refresh VAR # invalidate Keychain cache
dotfiles secret refresh --all # invalidate all
Never hand-edit secrets.fish.tmpl or .chezmoidata/secrets.toml — use the
subcommands.
macOS Keychain - via keyring template function or runtime fish functions:
{{ keyring "service-name" .chezmoi.username }}
Template variables are prompted once during chezmoi init and cached:
.name,.email- git identity.editor- chosen editor (code --wait,zed --wait,nvim,vim).headless- boolean, skips GUI apps and dev tools on servers.use_1password- boolean, gates all 1Password sections.op_account,.op_vault- only prompted ifuse_1passwordis true
Packages are classified during /dotfiles-sync:
- Core - shared across all machines, committed to
home/dot_Brewfile.tmpl - Local - this machine only, stored in
~/.Brewfile.local(never committed)
~/.Brewfile.local is sourced by ~/.Brewfile via Ruby eval(). It uses the
same DSL (brew "pkg", cask "app"). Similarly, ~/.config/code/extensions.local.txt
holds machine-specific VS Code extensions. Both .local files are listed in
.chezmoiignore so chezmoi never manages them.
The sync workflow asks the user to classify each new package as core, local, or skip. If the user says "do it all" without classifying, default to local.
Config files with native include support: git ([include]), SSH (Include config.d/*),
fish (source ~/.config/fish/config.local.fish at end of config.fish), tmux (source-file -q
at end of tmux.conf). All .local paths are in .chezmoiignore.
Promoting/demoting between core and local:
dotfiles local list # show all .local files
dotfiles local promote <type> <name> # local → core (repo)
dotfiles local demote <type> <name> # core → local
# type: brew, cask, ext
The fish function auto-commits to the repo; local file changes are never committed.
Design rationale and full cross-machine test plan:
Scripts in .chezmoiscripts/ run during chezmoi apply:
run_before_aa-init.sh- resets apply log file (~/.cache/dotfiles-apply.log)run_before_ab-1password-check.sh- validates 1Password CLI setup (if enabled)run_onchange_before_brew-bundle.sh- triggers whendot_Brewfilecontent changes (sha256 hash in comment)- Files are deployed (including
~/.config/dotfiles/lib.sh) run_once_after_*- one-time setup (fish shell, macOS defaults, mas apps, toolchains)run_onchange_after_*- triggers when VS Code settings/extensions or Zed config changesrun_after_zz-summary.sh- prints styled apply summary with warnings/errors/next steps
run_once_ scripts track execution in chezmoi's state DB and won't re-run unless the script content changes. run_onchange_ scripts re-run when the hash comment inside them changes (chezmoi evaluates the template, hashes the output).
Fish plugins and completions are pulled from GitHub URLs (no plugin manager). Cached with 30-day refresh. These files don't exist in home/ - they're defined as URLs and downloaded at apply time.
The ignore file is itself a Go template. macOS-only configs (Ghostty, Zed, Brewfile) are skipped on Linux. GUI configs are skipped in GitHub Codespaces.
- Never commit secrets. API keys go in 1Password with
op://references in.tmplfiles. The rendered output (with real secrets) only exists on the target machine, never in git. - Zed settings (
settings.json.tmpl) contains 5 MCP server configs with 1Password-injected API keys. When adding MCP servers, use the sameonepasswordReadpattern. - VS Code settings live in
dot_config/code/(not the Library path). Therun_onchange_after_vscode.shscript copies them to~/Library/Application Support/Code/User/at apply time. - Brewfile changes auto-apply - editing
dot_Brewfileand runningchezmoi applytriggersbrew bundleautomatically. - Fish functions in
home/dot_config/fish/functions/are auto-loaded by fish (one function per file, filename = function name). - Error message library (
~/.config/dotfiles/lib.sh) is sourced by allrun_*_after_*scripts. Usesgum log/gum stylefor styled output with ANSI fallback. Functions:info,warn "what" "why" "fix",err,die,require_cmd,section,script_ok. All warnings/errors log to~/.cache/dotfiles-apply.log. - Template guards - every
.tmplfile validates required variables withhasKey/failat the top. Missing variables produceFix: chezmoi initinstead of cryptic Go template errors. - Apply summary -
run_after_zz-summary.shprints a gum-styled status box at the end of every apply with OK/warning/failure counts, details, and actionable next steps.
.claude/settings.json- project-level permissions (allow lint/verify, deny destructive) + PostToolUse hook that auto-runs shellcheck on.shandfish -non.fishafter every edit..claude/agents/verify-dotfiles.md- QA subagent. Runs 5 checks: shellcheck, fish syntax, chezmoi dry-run, file existence, managed file count. Use proactively after implementing any feature..claude/commands/implement-feature.md- Slash command:/implement-feature S-24reads the spec, implements, verifies via subagent, fixes, commits.home/dot_claude/- chezmoi-managed Claude Code user config (keybindings.json, statusline script, commands/). Deployed to~/.claude/on apply. Skipped on headless/Codespaces.~/.claude/settings.jsonis a two-layer construction (S-36):- Security layer - owned by claude-guardrails. Installed via
home/.chezmoiscripts/run_onchange_after_claude-guardrails.sh.tmpl, which runsnpx -y github:dwarvesf/claude-guardrails#<tag> install <variant>. Pinned to a git tag (not an npm version) so every upstream release + tag push is installable with no npm publish step. Bumping theREF="v0.Y.Z"line is the only way to upgrade, by design. Variant prompted onchezmoi initas.guardrails_variant(lite|full|none). - Personal overlay -
home/dot_claude/modify_settings.json(a chezmoimodify_script). Reads the live file on stdin, guarantees personal fields (statusLine, learning-capture Stop hook, enabledPlugins, skipDangerousModePermissionPrompt) are present, emits on stdout. Never touches$schema,permissions.deny,hooks.PreToolUse, orhooks.UserPromptSubmit- those are the guardrails layer's job. Both layers use additive jq merges, so they are idempotent and order-independent. Do NOT convertmodify_settings.jsonback to a regular file - that would re-introduce the "whole file owned by dotfiles" conflict S-36 fixed.
- Security layer - owned by claude-guardrails. Installed via
After implementing any feature from docs/specs/S-*.md:
- Run the relevant verification commands (see table below)
- If ANY check fails, fix the issue and re-run
- Repeat until all checks pass or you've made 5 fix attempts
- Do NOT ask the user to verify. Run verification yourself.
- Only report back when all checks are green, or when you've hit the 5-attempt limit and need human help
- When reporting, include the actual test output, not a summary of what you think happened
| File pattern | Command | What it checks |
|---|---|---|
*.sh |
shellcheck --severity=warning FILE |
Shell script lint |
*.fish |
fish -n FILE |
Fish syntax check |
*.tmpl |
chezmoi execute-template < FILE > /dev/null |
Template rendering |
home/dot_Brewfile* |
chezmoi apply --dry-run 2>&1 | head -50 |
Brewfile validity |
| Any chezmoi file | chezmoi apply --dry-run --verbose 2>&1 | tail -20 |
Full dry run |
install.sh |
bash -n install.sh && shellcheck install.sh |
Syntax + lint |
# 1. Lint all shell scripts
find . -name "*.sh" -not -path "./.git/*" | xargs shellcheck --severity=warning
# 2. Syntax check all fish files
find home -name "*.fish" | while read f; do fish -n "$f" || echo "FAIL: $f"; done
# 3. Dry run chezmoi
chezmoi apply --dry-run 2>&1 | tail -20
# 4. Check managed file list is not empty
test $(chezmoi managed | wc -l) -gt 10 || echo "FAIL: too few managed files"- Commit each feature separately with conventional commit format
- Commit message:
feat(S-XX): short description - Do NOT batch multiple features into one commit
- Run verification BEFORE committing, not after