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:

  1. 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/hosts file first.
  2. First-class support for macOS (Darwin), Debian/Ubuntu (Debian), and Arch Linux (Archlinux).
  3. Full idempotency and --check mode compatibility. Running the playbook repeatedly yields changed=0 after 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:

ToolBinaryReplacesPurpose
ripgreprggrepFast recursive regex search that respects .gitignore
fdfdfindFast, intuitive file finder
batbatcatSyntax highlighting, line numbers, and Git gutter indicators
lsdlsdlsFiletype icons, tree view, and colorized permissions
fzffzf—Interactive fuzzy finder for files, history, and processes
zoxidezcdFrecency-based directory jumper
btopbtoptop / htopInteractive CPU, memory, disk, and network monitor
glowglow—Terminal Markdown viewer for reading documentation over SSH
starshipstarshipPS1Fast, minimal cross-shell prompt
fastfetchfastfetchneofetchFast hardware and OS diagnostic summary
vortixvortixmanual VPN CLITerminal 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:

  1. Envy Code R Nerd Font Mono: Automatically selects ~/Library/Fonts on macOS or ~/.local/share/fonts/EnvyCodeR-Nerd-Font on Linux, extracts the Regular, Bold, and Italic TTF weights from the Nerd Fonts archive, and runs fc-cache -f on Linux only when changes occur.
  2. Neovim + lazy.nvim: Renders ~/.config/nvim/init.lua from init.lua.j2 and pre-clones lazy.nvim (stable branch, --depth 1) so Neovim starts cleanly on first launch.
  3. tmux + TPM: Renders ~/.tmux.conf and pre-clones both TPM (tmux-plugins/tpm) and tokyo-night-tmux. Passing -e "theme_variant=night" at runtime switches both Neovim and tmux between Tokyo Night variants (storm, night, moon, day).