Initial scaffold: cli_modern Ansible role for Fish + Starship

This commit is contained in:
Travis Herbranson 2026-04-23 12:15:26 -04:00
commit ac7e575957
16 changed files with 601 additions and 0 deletions

7
.gitignore vendored Normal file
View File

@ -0,0 +1,7 @@
*.retry
*.pyc
__pycache__/
.vagrant/
*.log
.env
vault_password

57
CLAUDE.md Normal file
View File

@ -0,0 +1,57 @@
# CLI Standardization
Ansible project to deploy a modern CLI environment (Fish + Starship + modern coreutils)
to Arch and Ubuntu/Debian hosts.
## What it installs
- **Fish shell** 4.x (PPA on Ubuntu, pacman on Arch)
- **Starship** prompt (official binary installer)
- **fzf** — fuzzy finder
- **zoxide** — smarter cd
- **bat** — cat with syntax highlighting
- **eza** — modern ls
- **fd** — modern find
- **ripgrep** — fast grep
- **Fisher** plugin manager + plugins
## Dev workflow
```bash
# Lint
ansible-lint playbook.yml
# Syntax check
ansible-playbook playbook.yml --syntax-check
# Dry run
ansible-playbook playbook.yml -i inventory.yml --check --diff
# Deploy
ansible-playbook playbook.yml -i inventory.yml
ansible-playbook playbook.yml -i inventory.yml --limit manjaro-dev
```
## Role structure
```
roles/cli_modern/
├── defaults/main.yml # All configurable variables
├── tasks/
│ ├── main.yml # Task dispatcher
│ ├── packages-archlinux.yml # Arch/Manjaro packages
│ ├── packages-debian.yml # Ubuntu/Debian packages + Fish PPA
│ ├── starship.yml # Starship binary install
│ ├── fisher.yml # Fisher + plugin sync
│ ├── config.yml # Deploy config templates
│ └── default-shell.yml # Set Fish as login shell
└── templates/
├── config.fish.j2 # Fish shell config
├── starship.toml.j2 # Starship prompt config
└── fish_plugins.j2 # Fisher plugin list
```
## Configuration
All options are in `roles/cli_modern/defaults/main.yml`. Override per-host
in `host_vars/` or per-group in `group_vars/`.

9
ansible.cfg Normal file
View File

@ -0,0 +1,9 @@
[defaults]
inventory = inventory.yml
roles_path = roles
host_key_checking = false
retry_files_enabled = false
[privilege_escalation]
become = true
become_method = sudo

28
inventory.yml Normal file
View File

@ -0,0 +1,28 @@
---
# CLI Standardization inventory
# Add target hosts here. Group by OS family for role routing.
#
# Example:
#
# all:
# children:
# arch:
# hosts:
# manjaro-dev:
# ansible_host: 192.168.1.100
# ansible_user: herby
# debian:
# hosts:
# proxmox-01:
# ansible_host: 192.168.1.10
# ansible_user: root
# ubuntu-nas:
# ansible_host: 192.168.1.20
# ansible_user: herby
all:
children:
arch:
hosts: {}
debian:
hosts: {}

15
playbook.yml Normal file
View File

@ -0,0 +1,15 @@
---
# CLI Standardization — deploy modern shell tooling to target hosts.
#
# Usage:
# ansible-playbook playbook.yml -i inventory.yml
# ansible-playbook playbook.yml -i inventory.yml --limit proxmox
# ansible-playbook playbook.yml -i inventory.yml --tags cli
- name: Deploy modern CLI environment
hosts: all
gather_facts: true
tags: [cli]
roles:
- role: cli_modern

View File

@ -0,0 +1,53 @@
---
# cli_modern role defaults
# User to configure (shell, dotfiles, etc.)
cli_modern_user: "{{ ansible_user }}"
cli_modern_user_group: "{{ ansible_user }}"
cli_modern_user_home: "/home/{{ cli_modern_user }}"
# Set Fish as the default login shell
cli_modern_set_default_shell: true
# Fish shell
cli_modern_install_fish: true
# Minimum Fish version required (Ubuntu PPA added if system package is older)
cli_modern_fish_min_version: "4.0"
# Starship prompt
cli_modern_install_starship: true
# Install method: "binary" uses the official install script, "package" uses system repos
cli_modern_starship_install_method: "binary"
# fzf — fuzzy finder
cli_modern_install_fzf: true
# zoxide — smarter cd
cli_modern_install_zoxide: true
# bat — cat with syntax highlighting
cli_modern_install_bat: true
# eza — modern ls replacement
cli_modern_install_eza: true
# fd — modern find replacement
cli_modern_install_fd: true
# ripgrep — fast grep
cli_modern_install_ripgrep: true
# Fisher plugin manager — auto-installed on first Fish launch
cli_modern_install_fisher: true
# Fisher plugins to install
cli_modern_fisher_plugins:
- "jorgebucaran/fisher"
- "PatrickF1/fzf.fish"
- "jethrokuan/z"
- "meaningful-ooo/sponge"
- "jorgebucaran/autopair.fish"
# Deploy config templates
cli_modern_deploy_fish_config: true
cli_modern_deploy_starship_config: true

View File

@ -0,0 +1,39 @@
---
# Deploy Fish and Starship configuration files.
- name: Ensure Fish config directory exists
ansible.builtin.file:
path: "{{ cli_modern_user_home }}/.config/fish/conf.d"
state: directory
owner: "{{ cli_modern_user }}"
group: "{{ cli_modern_user_group }}"
mode: "0755"
- name: Ensure Starship config directory exists
ansible.builtin.file:
path: "{{ cli_modern_user_home }}/.config"
state: directory
owner: "{{ cli_modern_user }}"
group: "{{ cli_modern_user_group }}"
mode: "0755"
when: cli_modern_deploy_starship_config
- name: Deploy Fish config
ansible.builtin.template:
src: config.fish.j2
dest: "{{ cli_modern_user_home }}/.config/fish/config.fish"
owner: "{{ cli_modern_user }}"
group: "{{ cli_modern_user_group }}"
mode: "0644"
backup: true
when: cli_modern_deploy_fish_config
- name: Deploy Starship config
ansible.builtin.template:
src: starship.toml.j2
dest: "{{ cli_modern_user_home }}/.config/starship.toml"
owner: "{{ cli_modern_user }}"
group: "{{ cli_modern_user_group }}"
mode: "0644"
backup: true
when: cli_modern_deploy_starship_config

View File

@ -0,0 +1,20 @@
---
# Set Fish as the default login shell for the target user.
- name: Get Fish binary path
ansible.builtin.command: which fish
register: cli_modern_fish_path
changed_when: false
- name: Ensure Fish is in /etc/shells
become: true
ansible.builtin.lineinfile:
path: /etc/shells
line: "{{ cli_modern_fish_path.stdout }}"
state: present
- name: Set Fish as default shell for {{ cli_modern_user }}
become: true
ansible.builtin.user:
name: "{{ cli_modern_user }}"
shell: "{{ cli_modern_fish_path.stdout }}"

View File

@ -0,0 +1,46 @@
---
# Install Fisher plugin manager and configured plugins.
# Fisher bootstraps itself on first run — we just need the fish_plugins file.
- name: Ensure Fish config directory exists
ansible.builtin.file:
path: "{{ cli_modern_user_home }}/.config/fish"
state: directory
owner: "{{ cli_modern_user }}"
group: "{{ cli_modern_user_group }}"
mode: "0755"
- name: Ensure Fish functions directory exists
ansible.builtin.file:
path: "{{ cli_modern_user_home }}/.config/fish/functions"
state: directory
owner: "{{ cli_modern_user }}"
group: "{{ cli_modern_user_group }}"
mode: "0755"
- name: Download Fisher
ansible.builtin.get_url:
url: https://raw.githubusercontent.com/jorgebucaran/fisher/main/functions/fisher.fish
dest: "{{ cli_modern_user_home }}/.config/fish/functions/fisher.fish"
owner: "{{ cli_modern_user }}"
group: "{{ cli_modern_user_group }}"
mode: "0644"
- name: Deploy fish_plugins list
ansible.builtin.template:
src: fish_plugins.j2
dest: "{{ cli_modern_user_home }}/.config/fish/fish_plugins"
owner: "{{ cli_modern_user }}"
group: "{{ cli_modern_user_group }}"
mode: "0644"
- name: Install Fisher plugins
become: true
become_user: "{{ cli_modern_user }}"
ansible.builtin.command: >
fish -c "fisher update"
args:
chdir: "{{ cli_modern_user_home }}"
register: cli_modern_fisher_install
changed_when: "'Installing' in cli_modern_fisher_install.stdout or 'Updating' in cli_modern_fisher_install.stdout"
failed_when: false

View File

@ -0,0 +1,26 @@
---
# cli_modern — main task dispatcher
# Routes to OS-specific package installation, then shared config tasks.
- name: Gather OS family facts
ansible.builtin.setup:
filter: ansible_os_family
when: ansible_os_family is not defined
- name: Include OS-specific package tasks
ansible.builtin.include_tasks: "packages-{{ ansible_os_family | lower }}.yml"
- name: Install Starship via official script
ansible.builtin.include_tasks: starship.yml
when: cli_modern_install_starship
- name: Install Fisher and plugins
ansible.builtin.include_tasks: fisher.yml
when: cli_modern_install_fisher
- name: Deploy shell configuration
ansible.builtin.include_tasks: config.yml
- name: Set Fish as default shell
ansible.builtin.include_tasks: default-shell.yml
when: cli_modern_set_default_shell

View File

@ -0,0 +1,22 @@
---
# Arch Linux / Manjaro package installation
# All tools are in the official repos or AUR — no PPA needed.
- name: Install CLI tools (pacman)
become: true
community.general.pacman:
name: "{{ cli_modern_arch_packages }}"
state: present
update_cache: true
vars:
cli_modern_arch_packages: >-
{{
(['fish'] if cli_modern_install_fish else []) +
(['fzf'] if cli_modern_install_fzf else []) +
(['zoxide'] if cli_modern_install_zoxide else []) +
(['bat'] if cli_modern_install_bat else []) +
(['eza'] if cli_modern_install_eza else []) +
(['fd'] if cli_modern_install_fd else []) +
(['ripgrep'] if cli_modern_install_ripgrep else [])
}}
when: cli_modern_arch_packages | length > 0

View File

@ -0,0 +1,89 @@
---
# Debian / Ubuntu package installation
# Adds the Fish shell PPA to get 4.x on older Ubuntu releases.
- name: Install prerequisites for PPA management
become: true
ansible.builtin.apt:
name:
- software-properties-common
- apt-transport-https
- gnupg
- curl
state: present
update_cache: true
# --- Fish PPA (Ubuntu only, needed for 4.x on < 24.10) ---
- name: Check current Fish version
ansible.builtin.command: fish --version
register: cli_modern_fish_version_check
changed_when: false
failed_when: false
- name: Extract Fish version number
ansible.builtin.set_fact:
cli_modern_fish_current_ver: >-
{{ cli_modern_fish_version_check.stdout
| default('')
| regex_search('(\d+\.\d+)', '\1')
| first
| default('0') }}
- name: Determine if Fish PPA is needed
ansible.builtin.set_fact:
cli_modern_need_fish_ppa: >-
{{ cli_modern_install_fish and (
cli_modern_fish_version_check.rc != 0 or
cli_modern_fish_current_ver is version(cli_modern_fish_min_version, '<')
) }}
- name: Add Fish shell PPA
become: true
ansible.builtin.apt_repository:
repo: "ppa:fish-shell/release-4"
state: present
update_cache: true
when: cli_modern_need_fish_ppa | bool
# --- Install packages ---
- name: Install CLI tools (apt)
become: true
ansible.builtin.apt:
name: "{{ cli_modern_deb_packages }}"
state: present
update_cache: true
vars:
cli_modern_deb_packages: >-
{{
(['fish'] if cli_modern_install_fish else []) +
(['fzf'] if cli_modern_install_fzf else []) +
(['zoxide'] if cli_modern_install_zoxide else []) +
(['bat'] if cli_modern_install_bat else []) +
(['eza'] if cli_modern_install_eza else []) +
(['fd-find'] if cli_modern_install_fd else []) +
(['ripgrep'] if cli_modern_install_ripgrep else [])
}}
when: cli_modern_deb_packages | length > 0
# On Debian/Ubuntu, fd is packaged as fd-find with binary named fdfind.
# Create a symlink so "fd" works everywhere.
- name: Symlink fdfind to fd
become: true
ansible.builtin.file:
src: /usr/bin/fdfind
dest: /usr/local/bin/fd
state: link
when: cli_modern_install_fd
failed_when: false
# On older Ubuntu, bat is packaged as batcat.
- name: Symlink batcat to bat
become: true
ansible.builtin.file:
src: /usr/bin/batcat
dest: /usr/local/bin/bat
state: link
when: cli_modern_install_bat
failed_when: false

View File

@ -0,0 +1,29 @@
---
# Install Starship prompt via official install script.
# This method works on both Arch and Debian without repo differences.
- name: Check if Starship is already installed
ansible.builtin.command: starship --version
register: cli_modern_starship_check
changed_when: false
failed_when: false
- name: Download Starship install script
ansible.builtin.get_url:
url: https://starship.rs/install.sh
dest: /tmp/starship-install.sh
mode: "0755"
when: cli_modern_starship_check.rc != 0
- name: Install Starship via official installer
become: true
ansible.builtin.command: /tmp/starship-install.sh --yes
args:
creates: /usr/local/bin/starship
when: cli_modern_starship_check.rc != 0
- name: Clean up Starship installer
ansible.builtin.file:
path: /tmp/starship-install.sh
state: absent
when: cli_modern_starship_check.rc != 0

View File

@ -0,0 +1,57 @@
# {{ ansible_managed }}
# Fish shell configuration — deployed by cli_modern role
if status is-interactive
# ── Prompt ──────────────────────────────────────────────
{% if cli_modern_install_starship %}
starship init fish | source
{% endif %}
# ── Navigation ──────────────────────────────────────────
{% if cli_modern_install_zoxide %}
zoxide init fish | source
{% endif %}
{% if cli_modern_install_fzf %}
# fzf.fish plugin handles keybindings (Ctrl+R history, Ctrl+F files)
# Set fzf default options
set -gx FZF_DEFAULT_OPTS "--height 40% --layout=reverse --border --info=inline"
{% endif %}
# ── Aliases ─────────────────────────────────────────────
{% if cli_modern_install_eza %}
alias ls "eza --icons --group-directories-first"
alias ll "eza -la --icons --group-directories-first --git"
alias lt "eza --tree --level=2 --icons"
{% endif %}
{% if cli_modern_install_bat %}
alias cat "bat --paging=never"
alias catp "bat"
{% endif %}
# Git shortcuts
alias gs "git status"
alias gd "git diff"
alias gl "git log --oneline -20"
alias gp "git pull"
# Docker shortcuts
alias dc "docker compose"
alias dps "docker ps --format 'table {{ '{{' }}.Names{{ '}}' }}\t{{ '{{' }}.Status{{ '}}' }}\t{{ '{{' }}.Ports{{ '}}' }}'"
alias dlogs "docker compose logs -f"
# Safety nets
alias rm "rm -i"
alias mv "mv -i"
alias cp "cp -i"
# ── Environment ─────────────────────────────────────────
set -gx EDITOR vim
set -gx VISUAL vim
# Suppress Fish greeting
set -g fish_greeting
end

View File

@ -0,0 +1,6 @@
# {{ ansible_managed }}
# Fisher plugins — managed by cli_modern role
# Run `fisher update` to sync after changes.
{% for plugin in cli_modern_fisher_plugins %}
{{ plugin }}
{% endfor %}

View File

@ -0,0 +1,98 @@
# {{ ansible_managed }}
# Starship prompt configuration — deployed by cli_modern role
# Docs: https://starship.rs/config/
# Timeout for commands that starship runs (ms)
command_timeout = 1000
# Insert blank line between prompts
add_newline = true
# ── Prompt Format ───────────────────────────────────────────
format = """
$username\
$hostname\
$directory\
$git_branch\
$git_status\
$python\
$golang\
$nodejs\
$docker_context\
$cmd_duration\
$line_break\
$character"""
# ── Character ───────────────────────────────────────────────
[character]
success_symbol = "[](bold green)"
error_symbol = "[](bold red)"
vimcmd_symbol = "[](bold green)"
# ── Directory ───────────────────────────────────────────────
[directory]
truncation_length = 4
truncation_symbol = "…/"
truncate_to_repo = true
style = "bold cyan"
# ── Git ─────────────────────────────────────────────────────
[git_branch]
format = "[$symbol$branch(:$remote_branch)]($style) "
symbol = " "
style = "bold purple"
[git_status]
format = '([\[$all_status$ahead_behind\]]($style) )'
style = "bold red"
conflicted = "="
ahead = "⇡${count}"
behind = "⇣${count}"
diverged = "⇕⇡${ahead_count}⇣${behind_count}"
untracked = "?${count}"
stashed = "*"
modified = "!${count}"
staged = "+${count}"
deleted = "✘${count}"
# ── Languages ───────────────────────────────────────────────
[python]
format = '[${symbol}${pyenv_prefix}(${version} )(\($virtualenv\) )]($style)'
symbol = " "
style = "yellow"
[golang]
format = "[$symbol($version )]($style)"
symbol = " "
style = "bold cyan"
[nodejs]
format = "[$symbol($version )]($style)"
symbol = " "
style = "bold green"
# ── Docker ──────────────────────────────────────────────────
[docker_context]
format = "[$symbol$context]($style) "
symbol = " "
style = "blue bold"
only_with_files = true
# ── Command Duration ────────────────────────────────────────
[cmd_duration]
min_time = 2_000
format = "[$duration]($style) "
style = "bold yellow"
# ── Username & Hostname (show on SSH sessions only) ─────────
[username]
show_always = false
format = "[$user]($style)@"
style_user = "bold green"
style_root = "bold red"
[hostname]
ssh_only = true
format = "[$ssh_symbol$hostname]($style) "
ssh_symbol = "🌐 "
style = "bold green"