This is an Ansible-based dotfiles management system for macOS that automates the setup of a development environment from scratch. The system uses a Python CLI wrapper (via UV and mise) to orchestrate Ansible playbooks that configure system settings, install packages, and manage dotfiles through symlinks.
dotfiles(bash wrapper): Entry point that sets up Python environment via mise/uv, installs dependencies, and delegates to the Python CLIpackages/(UV workspace): Three Python packages managed as a UV workspace:dotfiles_cli/: Main CLI using Click (commands: install, edit, pull, push, sync, completion, profile, secret)dotfiles_profile_discovery/: Shared profile discovery logic used by CLI and Ansible inventory pluginsymlink_dotfiles/: Standalone package for dotfile symlinking
playbook.yml: Main Ansible playbook with four plays (see Playbook Structure)profiles/: Profile configurations (nested up to 3 levels). See profiles.mdansible_plugins/: Custom Ansible plugins:inventory/dotfiles_profiles.py: Dynamic inventory plugin for profile discoverylookup/aggregated_profile_var.py: Aggregates variables from all profile hosts via hostvarsfilter/: Custom Jinja2 filtersaction/: Custom action plugins
roles/: Modular Ansible roles (brew_packages, dotfiles, mas, pip, gem, composer, ssh_config, gitconfig, mcp_servers, etc.)docs/: Detailed documentation for profiles, testing, secrets, CLI, and tools
In order of precedence:
config.yml: Optional local overrides (git-ignored)profiles/{profile}/config.yml: Profile-specific configuration- Built-in topical profiles:
shell(CLI tools),neovim(editor),development(dev tools),macos-desktop(GUI apps)
The main playbook runs four plays in sequence:
- Gather Facts (
localhostonly): Collects minimal facts usinglinearstrategy (bypasses Mitogen's slow fact gathering). Per-profile tasks must access these viahostvars['localhost']['ansible_facts'] - Bootstrap (
localhost): One-time setup — macOS settings, Homebrew installation, brew_packages - Setup dependencies per profile (
allhosts): Per-profile tasks — dotfiles, pipx, mcp_servers - Finalize (
localhost): Aggregation + run-once — mas, ssh_config, gitconfig, pip, gem, composer, chsh, docker, gh_extensions, mise
The playbook uses three patterns:
- Bootstrap Pattern (runs once before profiles):
localhostin Bootstrap play - Per-Profile Pattern (runs once per profile):
hosts: allin "Setup dependencies per profile" play - Aggregation Pattern (collects from all profiles, runs once):
localhostin Finalize play
Why localhost for aggregation? Avoids double-counting when collecting data from hostvars. The aggregation pattern collects variables from all profile hosts via lookup('aggregated_profile_var', 'varname'), merges them, and executes once on localhost.
The Finalize play uses the aggregated_profile_var lookup plugin to collect variables from all profile hosts:
# Aggregates brew_packages from all profiles (shell, development, work, etc.)
brew_packages: "{{ lookup('aggregated_profile_var', 'brew_packages') | community.general.lists_mergeby('name') }}"This pattern is used for: brew_packages, cask_packages, mas_packages, ssh_client_config, git config blocks, gh_extensions, pip_packages, gem_packages, composer_packages, gh_repos, and MCP servers.
The dotfiles role (roles/dotfiles/tasks/main.yml):
- Cleans up dead symlinks in home directory,
~/.config/, and~/.local/bin/ - Symlinks files from profile's
files/dotfiles/to~/.{filename}(excludingconfig/) - Symlinks files from profile's
files/dotfiles/config/to~/.config/{filename} - Symlinks scripts from profile's
files/bin/to~/.local/bin/{filename} - Copies (not symlinks) files from profile's
files/dotfiles-copy/to~/
The Python CLI implements secure sudo authentication:
- Sudo tags (defined in
SUDO_TAGS):mas,chsh,brew,cask - Prompts once (via
getpass) and validates the password before running Ansible - Ansible's own
become: truetasks (chsh,mas, cask uninstall) authenticate viaansible_become_password, passed through ansible-runner's extravars mechanism (written to a short-lived temp file, not visible in process listings) - Some tools shell out to
sudoon their own outside ofbecome(e.g. a Homebrew cask postflight symlinking files into/usr/local/bin). Since Ansible tasks have no attached tty, these would otherwise hang waiting for input that never arrives. For these, the same collected password is exposed asSUDO_ASKPASS(bin/askpass.sh), which tools that check that env var (like Homebrew, viasudo -A) use instead of a tty
After installation, dotfiles is symlinked to ~/.local/bin/dotfiles for easy access. The wrapper script uses mise x -- to execute commands within the project's mise environment.
The system uses profiles to manage different machine configurations:
shell(priority 100): Core CLI tools — fish, zsh, bash, git, fzf, ripgrep, zellij, starship, miseneovim(priority 110): Editor — neovim, NvChad config, vim legacy, stylua, shfmtdevelopment(priority 120): Dev tools — IDEs, languages, DBs, cloud/infra, task runners, containersmacos-desktop(priority 130): GUI — desktop apps, MAS, Alfred, fonts, terminal emulatorswork: Work-specific packages, SSH config, git settingspersonal: Personal packages and configurations- Private profiles can be added to
profiles/private/and are git-ignored by default
Profiles support up to three levels of nesting (e.g., profiles/private/mycompany/work/ -> private-mycompany-work). A directory is only considered a profile if it contains config.yml.
See profiles.md for full documentation.