ansible-dotfiles-setup
This skill should be used when the user asks to run, update, or troubleshoot the Ansible dotfiles playbook, add packages/casks, configure work vs personal profiles, manage SOPS secrets, or bootstrap a
SKILL.md.claude/skills/ansible-dotfiles-setup/SKILL.md
GitHubAnsible Dotfiles Setup
Structure
ansible/
ansible.cfg # inventory, roles_path, vars_plugins_enabled
inventory.yml # work-mac + personal-mac (both ansible_connection: local)
requirements.yml # community.general >= 9.0.0, community.sops >= 1.6.0
.sops.yaml # age recipient for vault encryption
playbooks/site.yml # main playbook, --limit selects profile (hosts: target_hosts|default(all))
playbooks/bootstrap.yml # interactive wrapper: prompts profile, imports site.yml
group_vars/
all/main.yml # common packages, feature toggles, brew lists
all/vault.sops.yaml # SOPS-encrypted, BOTH profiles: ssh_homelab_hosts
work/main.yml # machine_profile, expected_hostname, brew_packages_extra
work/vault.sops.yaml # SOPS-encrypted, work only: work_git_email, cf_access_client_id/secret
personal/main.yml # machine_profile, expected_hostname, enable_docs
roles/
homebrew/ # taps → trust → formulae → casks
shell/ # OMZ, plugins, Bun, TPM, fzf-git
stow/ # XDG + home-target stow, gitconfig-local template
macos/ # karabiner, colima, kanata, yazi, docs, ssh homelab block
Run commands
SOPS_AGE_KEY_FILE is exported in zsh/exports.zsh — no need to prefix commands manually.
# Simplest — auto-detects profile from hostname, prompts if unknown
./setup.sh
# From ansible/ directory — install + stow only (no sudo required)
ansible-playbook playbooks/site.yml --limit work-mac --tags install,stow
# Full run (requires sudo for kanata LaunchDaemon)
ansible-playbook playbooks/site.yml --limit work-mac --ask-become-pass
# Dry-run (check mode)
ansible-playbook playbooks/site.yml --limit work-mac --check --diff
setup.sh hostname auto-detection
setup.sh reads expected_hostname from group_vars/{work,personal}/main.yml and compares with the current machine's hostname (via ansible -m setup ... filter=ansible_hostname).
- Known hostname → selects profile silently, no prompt
- Unknown hostname → prints a warning with the known hostnames, asks
workorpersonal, then updatesexpected_hostnamein the chosengroup_varsfile automatically
On a new Mac, just run ./setup.sh once — it self-registers the hostname.
Tags
| Tag | What it runs |
|---|---|
install |
homebrew role (taps, packages, casks) |
shell |
shell role (OMZ, plugins, Bun, TPM) |
stow |
stow role (symlinks, gitconfig-local) |
macos |
macos role (karabiner, colima, kanata, yazi, docs) |
become |
tasks requiring sudo (kanata, KE agents) |
Adding packages
- Common formulae:
group_vars/all/main.yml→brew_packages - Common casks:
group_vars/all/main.yml→brew_casks - Work-only formulae:
group_vars/work/main.yml→brew_packages_extra - Work-only casks:
group_vars/work/main.yml→brew_casks_extra - Personal-only formulae:
group_vars/personal/main.yml→brew_packages_extra
Moving packages between profiles
To move a formula from common (all) to work-only:
- Remove from
group_vars/all/main.yml(brew_packagesorbrew_packages_tap) - Add to
group_vars/work/main.yml→brew_packages_extra
brew_packages_extra accepts full tap-prefixed names: keidarcy/tap/e1s.
Leaving the tap in brew_taps (all) without its formula is harmless — the tap is just added, nothing installed from it.
Playbooks
playbooks/site.yml— main playbook.hosts: "{{ target_hosts | default('all') }}". Run directly with--limit work-mac/--limit personal-mac. Used bysetup.sh.playbooks/bootstrap.yml— interactive wrapper for a new Mac:vars_promptfor the profile, thenimport_playbook: site.ymlwithtarget_hostsset to the chosen<profile>-mac. Skip the prompt with-e mac_profile=personal.
No duplication between them — bootstrap.yml holds only the prompt; all pre_tasks/roles live in site.yml.
Linting (pre-commit + yamllint + ansible syntax-check)
Tools installed via brew_packages_dev (pre-commit, yamllint, ansible-lint). The stow role runs pre-commit install --install-hooks (idempotent via creates: .git/hooks/pre-commit). The hook blocks commits on failure.
- Config:
.pre-commit-config.yaml(root),.yamllint.yml,.ansible-lint. - Run manually:
pre-commit run --all-files. - ansible-lint profile
production(strictest — idempotency, FQCN, role var prefixes, no command-instead-of-module). yamllint owns YAML style separately. - ansible-lint is NOT used in the pre-commit hook. It ships its own embedded
ansible-corethat does not share collections with the system ansible.community.general/community.sopsare invisible to it, andsyntax-check[unknown-module]is unskippable. The hook usesansible-playbook --syntax-checkinstead — it uses the system ansible with all collections. Runansible-lintmanually for full linting. - Role register vars must be prefixed with the role name (
macos_,homebrew_, ...) —var-naming[no-role-prefix]. E.g.register: macos_ya_pkg, notya_pkg. - Legitimate exceptions use a targeted
# noqa: <rule>on the taskname:line with a comment explaining why (seeshell/tasks/main.yml:latest[git]on intentional default-branch clones,command-instead-of-moduleongit submodule update). - Gotcha — ANSIBLE_CONFIG: the pre-commit hook sets
ANSIBLE_CONFIG=ansible/ansible.cfgso roles resolve from repo root. Without it: role not found. - Gotcha — braces conflict: yamllint must use
braces.min-spaces-inside: 0(not 1) or ansible-lint's embedded yamllint rejects the config as incompatible.max-spaces-inside: 1still allows idiomatic{{ var }}. - Excludes:
*.sops.yaml,docs/,yazi/flavors/,tmux/plugins/(third-party submodules, read-only),nvim/lazy-lock.json.
Known gotchas
Homebrew 4.x tap trust
Third-party taps (FelixKratz, nikitabobko, etc.) require explicit trust before their formulae/casks install. The homebrew role runs brew trust <taps> after homebrew_tap. If a new tap is added to brew_taps, trust is applied automatically on next run.
changed_when must match 'Already trusted tap:' (capital A) — Homebrew's actual output. Using lowercase 'already trusted' never matches and the task always reports changed.
kanata daemon idempotency
Do NOT run launchctl bootout unconditionally before launchctl bootstrap. The correct pattern:
launchctl print system/com.lucatrifilio.kanata→ register result,failed_when: false,changed_when: falselaunchctl bootstraponlywhen: macos_kanata_loaded.rc != 0(daemon not loaded)- Reload on plist change → handled by the
reload kanata daemonhandler (bootout + bootstrap in handlers/main.yml)
Running bootout unconditionally causes always-changed because bootstrap always follows it.
karabiner symlink — always-changed is expected
Karabiner-Elements overwrites the ~/.config/karabiner symlink with a real directory on every launch. Ansible correctly detects this (via stat + islnk check) and recreates the symlink each run. This changed is legitimate — do not suppress with changed_when: false.
Casks that install to /Applications/ require sudo
community.general.homebrew_cask does not support become. Casks like notion-calendar that copy .app to /Applications/ need --ask-become-pass at the playbook level. Run the full playbook with --ask-become-pass rather than --tags install alone.
karabiner symlink must use ln -sfn
ansible.builtin.file: state=link, force=true refuses to replace a non-empty directory. Use ansible.builtin.command: ln -sfn <src> <dest> — this replaces the directory entry without touching contents, matching the original setup.sh behavior.
bootstrap.yml vs site.yml — always --limit with bootstrap.yml
bootstrap.yml is an interactive wrapper that imports site.yml with target_hosts set to the chosen profile. When run on a single machine, it still evaluates pre_tasks for all hosts in inventory — the hostname assertion on the other profile will fail. Always pass --limit <profile>-mac when using bootstrap.yml from the CLI:
ansible-playbook playbooks/bootstrap.yml --ask-become-pass --tags macos,become --limit work-mac
Prefer site.yml directly for non-interactive runs — it requires --limit by design and avoids the issue entirely.
colima homebrew_services: use state=present not state=started
state=started is not a valid value for community.general.homebrew_services. Use state=present to register the service for autostart.
SOPS vault in --check mode
Add ignore_errors: "{{ ansible_check_mode }}" to the community.sops.load_vars task. Without it, --check fails if the vault references variables that haven't been loaded.
stow simulate+apply pattern
Two-task pattern for --check compatibility:
- Simulate:
stow --simulate,check_mode: false,changed_when: "'LINK:' in result.stderr" - Apply:
stow(no simulate),when: not ansible_check_mode
ansible/ must be ignored in .stowrc
Add --ignore=^ansible$ to .stowrc to prevent stow from symlinking ansible/ into ~/.config/.
Orphan taps cause brew link failures
Taps present on the machine but not in brew_taps trigger Homebrew's "untrusted taps" warning, which causes brew link to fail for unrelated packages. All homebrew install tasks set HOMEBREW_NO_REQUIRE_TAP_TRUST: "1" as an env var as a blanket guard. To audit and clean up orphan taps:
brew tap-info --json <tap> | python3 -c "import json,sys; d=json.load(sys.stdin); print([f for f in d[0]['formula_names']])"
brew untap <tap> # if nothing installed from it
LaunchAgent (GUI domain) idempotency
User LaunchAgents in ~/Library/LaunchAgents/ use gui/UID domain — different from kanata's system domain. No become needed.
- Check:
launchctl print gui/{{ ansible_user_uid }}/<label>→failed_when: false,changed_when: false - Bootstrap:
launchctl bootstrap gui/{{ ansible_user_uid }} ~/Library/LaunchAgents/<label>.plistonlywhen: rc != 0 - Reload handler:
launchctl bootout gui/{{ ansible_user_uid }}/<label>→launchctl bootstrap gui/{{ ansible_user_uid }} <plist>
Contrast with kanata: system domain requires become: true; GUI domain does not.
Idempotency patterns for tricky tasks
brew trust: alwayschanged_when: false— output varies and can't reliably detect "already trusted"ln -sfn(karabiner dir symlink): stat the path first, thenchanged_when: not stat.islnk or stat.lnk_source != expected_pathlaunchctl bootstrap: checklaunchctl print system/<label>first (rc == 0= already loaded); addwhen: macos_kanata_status.rc != 0to skip bootstrap if running
Adding a personal-only automated task
Pattern for features active only on personal-mac (e.g. obsidian vault auto-push):
group_vars/all/main.yml— add toggleenable_X: false+ empty-default varsgroup_vars/personal/main.yml— overrideenable_X: true+ real values- All tasks gated with
when: enable_X - Scripts deployed via
ansible.builtin.templateto~/.scripts/(mode0755) - LaunchAgent plist via template to
~/Library/LaunchAgents/withnotify: reload X agent - Handler: bootout gui/UID/label → bootstrap gui/UID plist (see GUI domain pattern above)
Example vars for obsidian backup: enable_obsidian_backup, obsidian_vault_path, obsidian_backup_interval (default 1800).
obsidian-git-backup.sh.j2 strategy: LiveSync (CouchDB) is the source of truth for vault content; git is backup only, personal-mac only. The script does commit → fetch → rebase -X ours origin/main → conflict-marker guard → push origin → push forgejo. Both pushes are blocking (ERROR + exit 1 on failure). Never use git push --force (silent data loss across devices) and never merge without committing first (leaves <<<<<<< markers in notes — the original bug). The conflict-marker guard (git grep -lE '^(<<<<<<< |=======$|>>>>>>> )' HEAD -- '*.md') blocks pushing corrupted notes.
Bootstrapping a new Mac
git clone git@github.com:luca-trifilio/dotfiles.git ~/Progetti/dotfiles- Copy age key from existing Mac (AirDrop or scp):
~/.config/sops/age/keys.txt - Verify the hostname ansible will see (use this, not just
hostname— avoids.localFQDN confusion):cd ansible && ansible -m setup localhost -a 'filter=ansible_hostname' # → "ansible_hostname": "MacBook-Pro-M4-Max-di-Luca" - Update
expected_hostnameingroup_vars/{work,personal}/main.ymlwith that value - Install ansible collections:
cd ansible && ansible-galaxy collection install -r requirements.yml - Dry run (no sudo needed — become tasks are guarded with
when: not ansible_check_mode):SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt \ ansible-playbook playbooks/site.yml --limit personal-mac --check --diff - Full run:
SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt \ ansible-playbook playbooks/site.yml --limit personal-mac --ask-become-pass - Manual steps (cannot be automated): Input Monitoring + Accessibility permissions for Karabiner, Driver Extension approval,
prefix + Iin tmux for TPM plugins
SOPS vault
Encrypted with age. Key at ~/.config/sops/age/keys.txt. Two vaults:
group_vars/all/vault.sops.yaml— both profiles. Loaded unconditionally insite.ymlpre_tasks (gated only on sops being present). Containsssh_homelab_hosts(homelab SSH host data: internal IPs, Tailscale addresses, usernames).group_vars/work/vault.sops.yaml— work only (when: 'work' in group_names). Containswork_git_email,cf_access_client_id,cf_access_client_secret.
# Edit a vault (sops opens decrypted in $EDITOR, re-encrypts on save)
SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt sops ansible/group_vars/all/vault.sops.yaml
# Verify decryption
SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt sops --decrypt ansible/group_vars/all/vault.sops.yaml
# Insert/update a single key without opening the editor (scripting-safe)
SOPS_AGE_KEY_FILE=~/.config/sops/age/keys.txt sops --set '["key_name"] "value"' ansible/group_vars/all/vault.sops.yaml
Creation-rule gotcha: .sops.yaml matches on path_regex: \.sops\.yaml$ against the input filename. To sops --encrypt a new file from scratch, name it *.sops.yaml and use --in-place (or --config ansible/.sops.yaml). Encrypting a /tmp/foo.yaml fails with "no matching creation rules" and a > redirect will clobber the destination with empty output. Prefer sops <file> (edit-in-place) for existing vaults.
SSH homelab block (~/.ssh/config)
The macos role manages a blockinfile-marked region in ~/.ssh/config (marker # {mark} ANSIBLE MANAGED BLOCK — homelab), rendered from ssh_homelab_hosts (in the shared vault) via roles/macos/templates/ssh_homelab.j2. Toggle: enable_ssh_homelab in group_vars/all/main.yml (default true).
- Only the marked region is managed — colima
Includelines and thegithub.com/github-personalidentity blocks are untouched. Host patterns are disjoint, so the block can sit anywhere in the file without shadowing. - The task tightens perms to
0600and is gated onssh_homelab_hosts is defined(skipped with a reminder if the vault wasn't loaded, e.g. before sops is installed). proxy_commandis stored as a literal absolute path (/opt/homebrew/bin/cloudflared) —blockinfile'sblock:lookup does NOT recursively re-template var values, so{{ homebrew_prefix }}inside a vault string would leak verbatim into the file.- Verify the effective result with
ssh -G macserver/ssh -G vps-homelab(resolves config without connecting).