When you move between a macOS laptop, Ubuntu servers, Proxmox LXC containers, and Arch Linux workstations, keeping your shell environment and CLI toolchain consistent becomes a chore. Shell bootstrap scripts always start simple and eventually turn into a brittle mess of if [[ "$OSTYPE" == "darwin"* ]] conditionals that fail halfway through a fresh setup.
I built two self-contained Ansible playbooks (common/tools and common/interface) around three rules:
- Zero static inventory required. You can clone the playbook onto a brand-new laptop or point it at an ad-hoc IP address without editing an
inventory/hostsfile first. - First-class support for macOS (
Darwin), Debian/Ubuntu (Debian), and Arch Linux (Archlinux). - Full idempotency and
--checkmode compatibility. Running the playbook repeatedly yieldschanged=0after the first pass.
The Zero-Inventory Pattern (-i "localhost,")
Passing a trailing comma in -i tells Ansible to parse the argument as an inline host list rather than looking for an inventory file on disk. Combined with hosts: all in playbook.yml:
---
- name: Install Common Developer CLI Tools
hosts: all
gather_facts: true
roles:
- role: common_tools
You can run the playbook locally on your current Mac or Linux box:
# Run locally on the current machine (macOS or Linux)
ansible-playbook playbook.yml -c local -i "localhost,"
# Dry-run check without modifying the system
ansible-playbook playbook.yml -c local -i "localhost," --check
Or run it against any remote host over SSH on the fly:
ansible-playbook playbook.yml -i "192.168.1.50," -u goobey
Normalizing the CLI Toolchain Across Homebrew, APT, and Pacman
The common_tools role installs a consistent set of modern CLI utilities across every machine:
| Tool | Binary | Replaces | Purpose |
|---|---|---|---|
| ripgrep | rg | grep | Fast recursive regex search that respects .gitignore |
| fd | fd | find | Fast, intuitive file finder |
| bat | bat | cat | Syntax highlighting, line numbers, and Git gutter indicators |
| lsd | lsd | ls | Filetype icons, tree view, and colorized permissions |
| fzf | fzf | — | Interactive fuzzy finder for files, history, and processes |
| zoxide | z | cd | Frecency-based directory jumper |
| btop | btop | top / htop | Interactive CPU, memory, disk, and network monitor |
| glow | glow | — | Terminal Markdown viewer for reading documentation over SSH |
| starship | starship | PS1 | Fast, minimal cross-shell prompt |
| fastfetch | fastfetch | neofetch | Fast hardware and OS diagnostic summary |
| vortix | vortix | manual VPN CLI | Terminal UI for managing WireGuard, OpenVPN, and NetBird tunnels |
Dispatching by os_family
Instead of repeating when: conditions on every task, roles/common_tools/tasks/main.yml dispatches cleanly by ansible_facts['os_family']:
---
- name: Install tools on Debian / Ubuntu
ansible.builtin.include_tasks: debian.yml
when: ansible_facts['os_family'] == 'Debian'
- name: Install tools on Arch Linux
ansible.builtin.include_tasks: arch.yml
when: ansible_facts['os_family'] == 'Archlinux'
- name: Install tools on macOS
ansible.builtin.include_tasks: macos.yml
when: ansible_facts['os_family'] == 'Darwin'
On macOS (macos.yml), Homebrew refuses to run as root, so become: false is explicit on the community.general.homebrew task, while debian.yml and arch.yml elevate with become: true.
Fixing Debian & Ubuntu’s batcat and fdfind Naming Quirks
Because of legacy package name collisions in Debian and Ubuntu, APT installs bat as /usr/bin/batcat and fd-find as /usr/bin/fdfind. Relying on shell aliases breaks non-interactive scripts, Neovim plugins, and fzf preview commands.
To make bat and fd work identically everywhere, the role creates user-local symlinks inside ~/.local/bin:
- name: Ensure ~/.local/bin exists for user symlinks
ansible.builtin.file:
path: "{{ user_bin_dir }}"
state: directory
mode: "0755"
when: create_convenience_symlinks | bool
- name: Create convenience symlink for bat (batcat -> bat) on Debian/Ubuntu
ansible.builtin.file:
src: /usr/bin/batcat
dest: "{{ user_bin_dir }}/bat"
state: link
force: false
when:
- ansible_facts['os_family'] == 'Debian'
- create_convenience_symlinks | bool
- name: Create convenience symlink for fd (fdfind -> fd) on Debian/Ubuntu
ansible.builtin.file:
src: /usr/bin/fdfind
dest: "{{ user_bin_dir }}/fd"
state: link
force: false
when:
- ansible_facts['os_family'] == 'Debian'
- create_convenience_symlinks | bool
Installing GitHub Release Binaries Idempotently (vortix)
For tools distributed via GitHub Releases rather than distro repos—such as vortix—the playbook checks whether the binary is already on PATH before downloading the architecture-matched package:
- name: Check if vortix is installed
ansible.builtin.command: which vortix
register: vortix_check
failed_when: false
changed_when: false
- name: Download Vortix .deb package (GitHub Releases)
ansible.builtin.get_url:
url: "https://github.com/Harry-kp/vortix/releases/download/v{{ vortix_version }}/vortix_{{ vortix_version }}_{{ 'amd64' if ansible_architecture == 'x86_64' else 'arm64' }}.deb"
dest: "/tmp/vortix_{{ vortix_version }}.deb"
mode: "0644"
when:
- install_vortix | bool
- vortix_check.rc != 0
Bootstrapping Tokyo Night, Envy Code R, Neovim, and tmux
The companion playbook (common/interface) sets up the terminal interface:
- Envy Code R Nerd Font Mono: Automatically selects
~/Library/Fontson macOS or~/.local/share/fonts/EnvyCodeR-Nerd-Fonton Linux, extracts the Regular, Bold, and Italic TTF weights from the Nerd Fonts archive, and runsfc-cache -fon Linux only when changes occur. - Neovim +
lazy.nvim: Renders~/.config/nvim/init.luafrominit.lua.j2and pre-cloneslazy.nvim(stablebranch,--depth 1) so Neovim starts cleanly on first launch. tmux+ TPM: Renders~/.tmux.confand pre-clones both TPM (tmux-plugins/tpm) andtokyo-night-tmux. Passing-e "theme_variant=night"at runtime switches both Neovim andtmuxbetween Tokyo Night variants (storm,night,moon,day).