Add dev_env role: zsh/tmux/nvim for workstation-class hosts

A new role (separate from base) that gives workstation-class hosts (ubongo
now, mamba later) a clean interactive environment: zsh + oh-my-zsh +
oh-my-posh, tmux + TPM plugins, and neovim. Dotfiles are real files deployed
via GNU stow (not templated); pinned nvim v0.12.2 + oh-my-posh 29.0.1.

Configs re-derived (ADR-013) from AnsibleBaobabV4 + the operator's fisi setup
on boma's terms: no Nerd Font (headless host), no system LSP suite (nvim uses
mason), versions pinned (V4 tracks latest). Applied via playbooks/workstation.yml
to the control group for users sjat + claude. Lint + Molecule (idempotent) green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
sjat 2026-06-11 13:50:11 +02:00
parent b9daf2a0ad
commit f3f382ae69
36 changed files with 1807 additions and 0 deletions

View file

@ -0,0 +1,58 @@
# `dev_env` Role — Implementation Plan (iteration 1)
> Built in the same 2026-06-11 session as the `ubongo` bring-up. A developer
> interactive environment (zsh/tmux/nvim) for **workstation-class** hosts.
**Goal:** Give `ubongo` (and future `mamba`) a clean interactive shell/editor setup,
reproducibly, as a boma-native Ansible role — so the operator (and the `claude` agent
user) can work comfortably over SSH.
## Decisions
- **Separate role, never part of `base`.** `base` is the security/infra baseline for
*every* host; a dev environment is only for human workstation-class hosts. Servers and
service VMs must never get it.
- **Stow, not templating.** Dotfiles are **real files** under `files/dotfiles/{zsh,tmux,nvim}/`
(re-derived `$HOME`-relative from `fisi`'s live configs), symlinked into `~` with GNU
stow. No Jinja-templated dotfiles (they rot; you'd edit templates not configs).
- **Users:** `dev_env__users` (default `[]`). Set to `[sjat, claude]` for `ubongo` in
`group_vars/control`.
- **V4 (ADR-013):** configs/package-lists/install-mechanism *consulted* from V4 and
**re-derived on boma's terms** — not its structure. V4 identifiers stripped from the
dotfiles.
## Re-derivations vs V4
- **No Nerd Font** on `ubongo` — it's headless; fonts are a client-side concern.
- **No system-wide LSP suite** — the operator's nvim uses **mason**, which self-installs
LSPs/formatters inside nvim (needs only nvim + git + a C compiler + node).
- **Pinned versions** (ADR-014): nvim `v0.12.2`, oh-my-posh `29.0.1` (V4 tracks "latest").
- **Plugins self-bootstrap**: lazy.nvim installs nvim plugins on first launch; the role
only lays down config + pre-clones omz/tmux plugins.
## Tasks (role: `roles/dev_env/`)
- `tasks/main.yml` — apt packages (`packages` tag) → include `neovim.yml`, `oh_my_posh.yml`
→ loop `per_user.yml` over `dev_env__users`.
- `tasks/neovim.yml` — install pinned nvim release to `/opt`, symlink, version sentinel.
- `tasks/oh_my_posh.yml` — install pinned oh-my-posh binary + deploy `zen.toml` to `/etc`.
- `tasks/per_user.yml` — set login shell to zsh (`users`); clone oh-my-zsh + custom
plugins + tmux/TPM plugins; copy dotfiles to `~/.dotfiles`; `stow` into `~` (`config`).
- `defaults/main.yml`, `meta/main.yml`, `README.md`, `requirements.yml`.
- `molecule/default/{converge,verify}.yml` — create a `tester` user, apply, assert
packages + nvim/omp/zen present + shell=zsh + dotfiles stowed (symlinks).
- `playbooks/workstation.yml` — apply `dev_env` to the `control` group (ubongo).
- `inventories/production/group_vars/control/vars.yml``dev_env__users: [sjat, claude]`.
## Verify / apply
- `make lint`; `make test ROLE=dev_env` (Molecule, Debian 13) must pass.
- Apply to `ubongo`: `make check`/`deploy PLAYBOOK=workstation` from a host that can SSH
to `ubongo` as `sjat` with `--ask-become-pass` (the Ansible-manages-ubongo connection
isn't bootstrapped yet — handle at apply time).
## Deferred (iteration 2+)
- A proper `workstations` inventory group (when `mamba` joins) instead of reusing `control`.
- lazygit, extra CLI tooling, any system LSP/formatters mason can't cover.
- Pinning tmux plugins to commits (currently `master` except catppuccin `v1.0.3`).

View file

@ -0,0 +1,7 @@
---
# Workstation-class control node (ubongo, ADR-015) — developer-environment users.
# The operator and the dedicated AI-worker user both get the dev_env role (dotfiles,
# zsh/tmux/nvim), so `sudo -iu claude` lands in the same clean shell.
dev_env__users:
- sjat
- claude

10
playbooks/workstation.yml Normal file
View file

@ -0,0 +1,10 @@
---
# workstation.yml — developer environment for workstation-class hosts.
# Targets the `control` group (ubongo) for now; generalizes to a dedicated
# `workstations` group when mamba joins. Run: make deploy PLAYBOOK=workstation
- name: Apply developer environment to workstation-class hosts
hosts: control
become: true
roles:
- role: dev_env
tags: [dev_env]

50
roles/dev_env/README.md Normal file
View file

@ -0,0 +1,50 @@
# dev_env
Interactive developer environment for **workstation-class** boma hosts (`ubongo`, and
later `mamba`). Gives the operator — and the `claude` agent user — a clean shell/editor
setup over SSH: zsh + oh-my-zsh + oh-my-posh, tmux, and neovim.
**This is not part of `base`.** `base` is the security/infra baseline every host gets;
`dev_env` is only for human workstation-class hosts and must never be applied to servers
or service VMs.
## What it does
- Installs packages: `zsh, tmux, git, stow, build-essential, curl, ca-certificates,
fzf, ripgrep, direnv, nodejs, npm` (`dev_env__packages`).
- Installs **pinned** neovim (`dev_env__nvim_version`) and oh-my-posh
(`dev_env__omp_version`) from GitHub releases, and the system-wide oh-my-posh theme
`/etc/oh-my-posh/zen.toml`.
- For each user in `dev_env__users`: sets the login shell to zsh, clones oh-my-zsh +
custom plugins and the tmux/TPM plugins, and **stows** the dotfiles into `~`.
## Dotfiles
Real files under `files/dotfiles/{zsh,tmux,nvim}/`, deployed to `~/.dotfiles/` and
symlinked into the home directory with GNU **stow** (not Jinja templates — so they stay
editable as live configs). nvim plugins self-bootstrap via lazy.nvim on first launch;
LSPs/formatters self-install via mason (no system LSP packages needed).
## Variables
| Variable | Default | Purpose |
|---|---|---|
| `dev_env__users` | `[]` | Users to configure. Set per group, e.g. `group_vars/control → [sjat, claude]`. Empty = no per-user work. |
| `dev_env__nvim_version` | `v0.12.2` | Pinned neovim release. |
| `dev_env__omp_version` | `29.0.1` | Pinned oh-my-posh release. |
| `dev_env__packages` | see defaults | APT packages. |
| `dev_env__omz_custom_plugins` | autosuggestions, syntax-highlighting | Cloned into `~/.oh-my-zsh/custom/plugins`. |
| `dev_env__tmux_plugins` | tpm, tmux-sensible, vim-tmux-navigator, catppuccin@v1.0.3 | Cloned into `~/.tmux/plugins`. |
## Apply
```bash
make test ROLE=dev_env # Molecule (Debian 13)
make deploy PLAYBOOK=workstation # applies to the control group (ubongo)
```
## Provenance
Configs re-derived (ADR-013) from the heritage `AnsibleBaobabV4` repo and the operator's
live `fisi` setup, on boma's terms — V4's structure was not imported. No Nerd Font is
installed (headless host; fonts are a client-side concern).

View file

@ -0,0 +1,51 @@
---
# dev_env — interactive developer environment (zsh/tmux/nvim) for workstation-class
# hosts. Its own role, never part of `base`. See the role README and ADR-015.
# Users who receive the environment. Empty = no-op. Set in group_vars for the
# workstation-class group (e.g. group_vars/control → [sjat, claude]).
dev_env__users: []
# APT packages. nvim + oh-my-posh are installed separately from pinned releases.
# (nvim uses mason internally for LSPs, so no system LSP packages are needed; node is
# present so mason's node-based servers work. direnv is referenced by the .zshrc.)
dev_env__packages:
- zsh
- tmux
- git
- stow
- build-essential
- curl
- ca-certificates
- fzf
- ripgrep
- direnv
- nodejs
- npm
# Pinned tool versions (ADR-014 — pin, don't track "latest").
dev_env__nvim_version: "v0.12.2"
dev_env__omp_version: "29.0.1"
# oh-my-zsh custom plugins (cloned per user into ~/.oh-my-zsh/custom/plugins).
dev_env__omz_custom_plugins:
- name: zsh-autosuggestions
repo: "https://github.com/zsh-users/zsh-autosuggestions.git"
- name: zsh-syntax-highlighting
repo: "https://github.com/zsh-users/zsh-syntax-highlighting.git"
# tmux plugins (pre-cloned per user into ~/.tmux/plugins; TPM loads them on tmux start).
# catppuccin is pinned to the v1 API the .tmux.conf targets.
dev_env__tmux_plugins:
- name: tpm
repo: "https://github.com/tmux-plugins/tpm.git"
version: master
- name: tmux-sensible
repo: "https://github.com/tmux-plugins/tmux-sensible.git"
version: master
- name: vim-tmux-navigator
repo: "https://github.com/christoomey/vim-tmux-navigator.git"
version: master
- name: tmux
repo: "https://github.com/catppuccin/tmux.git"
version: v1.0.3

View file

@ -0,0 +1,26 @@
-- Neovim configuration — managed by boma dev_env role (stow)
-- Bootstrap lazy.nvim plugin manager
local lazypath = vim.fn.stdpath("data") .. "/lazy/lazy.nvim"
if not vim.loop.fs_stat(lazypath) then
vim.fn.system({
"git",
"clone",
"--filter=blob:none",
"https://github.com/folke/lazy.nvim.git",
"--branch=stable",
lazypath,
})
end
vim.opt.rtp:prepend(lazypath)
-- Set leader key before loading plugins
vim.g.mapleader = " "
vim.g.maplocalleader = " "
-- Load core configuration
require("options")
require("keymaps")
-- Load lazy.nvim and plugin configuration
require("lazy-setup")

View file

@ -0,0 +1,28 @@
{
"Comment.nvim": { "branch": "master", "commit": "e30b7f2008e52442154b66f7c519bfd2f1e32acb" },
"LuaSnip": { "branch": "master", "commit": "0abc8f390b278c3b4aabc4c004ac8a088b65cf24" },
"catppuccin": { "branch": "main", "commit": "8edd468af4d63212b84d69b2ddb5ffc9023ef5eb" },
"cmp-buffer": { "branch": "main", "commit": "b74fab3656eea9de20a9b8116afa3cfc4ec09657" },
"cmp-nvim-lsp": { "branch": "main", "commit": "cbc7b02bb99fae35cb42f514762b89b5126651ef" },
"cmp-path": { "branch": "main", "commit": "c642487086dbd9a93160e1679a1327be111cbc25" },
"cmp_luasnip": { "branch": "master", "commit": "98d9cb5c2c38532bd9bdb481067b20fea8f32e90" },
"conform.nvim": { "branch": "master", "commit": "619363c30309d29ffa631e67c8183f2a72caa373" },
"gitsigns.nvim": { "branch": "main", "commit": "dd3f588bacbeb041be6facf1742e42097f62165d" },
"indent-blankline.nvim": { "branch": "master", "commit": "d28a3f70721c79e3c5f6693057ae929f3d9c0a03" },
"lazy.nvim": { "branch": "main", "commit": "306a05526ada86a7b30af95c5cc81ffba93fef97" },
"lualine.nvim": { "branch": "master", "commit": "131a558e13f9f28b15cd235557150ccb23f89286" },
"neo-tree.nvim": { "branch": "v3.x", "commit": "ebd66767191714e008ce73b769518a763ff31bdc" },
"nui.nvim": { "branch": "main", "commit": "de740991c12411b663994b2860f1a4fd0937c130" },
"nvim-autopairs": { "branch": "master", "commit": "7b9923abad60b903ece7c52940e1321d39eccc79" },
"nvim-cmp": { "branch": "main", "commit": "a1d504892f2bc56c2e79b65c6faded2fd21f3eca" },
"nvim-lspconfig": { "branch": "master", "commit": "a4ed4e761c400849e8c9f8bda33e5083f890268c" },
"nvim-surround": { "branch": "main", "commit": "2e93e154de9ff326def6480a4358bfc149d5da2c" },
"nvim-tmux-navigation": { "branch": "main", "commit": "4898c98702954439233fdaf764c39636681e2861" },
"nvim-treesitter": { "branch": "master", "commit": "cf12346a3414fa1b06af75c79faebe7f76df080a" },
"nvim-web-devicons": { "branch": "master", "commit": "0d7d35fa946837b8738b17c18d1faa1ac351e7f9" },
"plenary.nvim": { "branch": "master", "commit": "74b06c6c75e4eeb3108ec01852001636d85a932b" },
"telescope.nvim": { "branch": "0.1.x", "commit": "a0bbec21143c7bc5f8bb02e0005fa0b982edc026" },
"toggleterm.nvim": { "branch": "main", "commit": "50ea089fc548917cc3cc16b46a8211833b9e3c7c" },
"which-key.nvim": { "branch": "main", "commit": "3aab2147e74890957785941f0c1ad87d0a44c15a" },
"zen-mode.nvim": { "branch": "main", "commit": "8564ce6d29ec7554eb9df578efa882d33b3c23a7" }
}

View file

@ -0,0 +1,78 @@
-- Neovim keymaps configuration
-- Ported from vimrc.j2 and enhanced
local keymap = vim.keymap.set
local opts = { noremap = true, silent = true }
-- Set leader key
vim.g.mapleader = " "
vim.g.maplocalleader = " "
-- Quick save (Ctrl+s in normal and insert mode)
keymap('n', '<C-s>', ':w<CR>', opts)
keymap('i', '<C-s>', '<Esc>:w<CR>a', opts)
-- Leader shortcuts
keymap('n', '<leader>w', ':w<CR>', opts) -- Save
keymap('n', '<leader>q', ':q<CR>', opts) -- Quit
keymap('n', '<leader>e', ':Ex<CR>', opts) -- File explorer (netrw)
-- Clear search highlighting
keymap('n', '<Esc>', ':nohlsearch<CR>', opts)
-- Better indenting (keep selection in visual mode)
keymap('v', '<', '<gv', opts)
keymap('v', '>', '>gv', opts)
-- Move text up and down in visual mode
keymap('v', 'J', ":m '>+1<CR>gv=gv", opts)
keymap('v', 'K', ":m '<-2<CR>gv=gv", opts)
-- Keep cursor centered when scrolling
keymap('n', '<C-d>', '<C-d>zz', opts)
keymap('n', '<C-u>', '<C-u>zz', opts)
keymap('n', 'n', 'nzzzv', opts)
keymap('n', 'N', 'Nzzzv', opts)
-- Buffer cycling (inner layer — mirrors tmux window cycling)
keymap('n', '<C-Tab>', ':bnext<CR>', opts)
keymap('n', '<C-PageDown>', ':bnext<CR>', opts)
keymap('n', '<C-PageUp>', ':bprevious<CR>', opts)
-- Better paste (don't yank replaced text in visual mode)
keymap('x', '<leader>p', '"_dP', opts)
-- Explicit yank-to-system-clipboard. Default y/Y already mirror to + via
-- the TextYankPost autocmd in options.lua, so these are redundant but kept
-- as muscle-memory aliases. Paste counterparts removed — `p` already pastes
-- correctly and `"+p` would route through OSC52 paste, which most terminals
-- refuse (silent E353).
keymap('n', '<leader>y', '"+y', { noremap = true, silent = true, desc = 'Yank to system clipboard' })
keymap('v', '<leader>y', '"+y', { noremap = true, silent = true, desc = 'Yank to system clipboard' })
keymap('n', '<leader>Y', '"+Y', { noremap = true, silent = true, desc = 'Yank line to system clipboard' })
-- Delete to black hole register
keymap('n', '<leader>d', '"_d', opts)
keymap('v', '<leader>d', '"_d', opts)
-- Window management — arrow direction = where new pane opens (mirrors kitty and tmux)
keymap('n', '<leader><Right>', ':vsplit<CR>', opts)
keymap('n', '<leader><Down>', ':split<CR>', opts)
keymap('n', '<leader>sx', ':close<CR>', opts)
keymap('n', '<leader>sv', ':vsplit<CR>', opts)
keymap('n', '<leader>sh', ':split<CR>', opts)
-- Buffer navigation
keymap('n', '<leader>bn', ':bnext<CR>', opts) -- Next buffer
keymap('n', '<leader>bp', ':bprevious<CR>', opts) -- Previous buffer
keymap('n', '<leader>bd', ':bdelete<CR>', opts) -- Delete buffer
-- Quick fix list navigation
keymap('n', '<leader>j', ':cnext<CR>', opts)
keymap('n', '<leader>k', ':cprev<CR>', opts)
-- Diagnostic keymaps (will be overridden by LSP if active)
keymap('n', '<A-k>', vim.diagnostic.goto_prev, opts) -- Alt+k = up = prev diagnostic
keymap('n', '<A-j>', vim.diagnostic.goto_next, opts) -- Alt+j = down = next diagnostic
keymap('n', '<leader>dl', vim.diagnostic.setloclist, opts)
keymap('n', '<leader>df', vim.diagnostic.open_float, opts)

View file

@ -0,0 +1,61 @@
-- Lazy.nvim setup
-- Loads all plugins from lua/plugins/ directory
require("lazy").setup("plugins", {
-- Use lockfile for deterministic installations
lockfile = vim.fn.stdpath("config") .. "/lazy-lock.json",
-- Disable automatic checking for plugin updates (managed by Ansible)
checker = {
enabled = false, -- Updates only via Ansible
notify = false,
},
-- Disable change detection (we control updates via Ansible)
change_detection = {
enabled = false,
notify = false,
},
-- Performance optimizations
performance = {
cache = {
enabled = true,
},
rtp = {
-- Debian puts bundled treesitter parsers (vimdoc etc.) under /usr/lib/nvim
-- which lazy's rtp reset would otherwise drop.
paths = vim.fn.isdirectory("/usr/lib/nvim") == 1 and { "/usr/lib/nvim" } or {},
-- Disable unused built-in plugins
disabled_plugins = {
"gzip",
"matchit",
"matchparen",
"netrwPlugin",
"tarPlugin",
"tohtml",
"tutor",
"zipPlugin",
},
},
},
-- UI configuration
ui = {
border = "rounded",
icons = {
cmd = "",
config = "🛠",
event = "📅",
ft = "📂",
init = "",
keys = "🗝",
plugin = "🔌",
runtime = "💻",
source = "📄",
start = "🚀",
task = "📌",
lazy = "💤 ",
},
},
})

View file

@ -0,0 +1,119 @@
-- Neovim options configuration
-- Ported from vimrc.j2 and enhanced with Neovim best practices
local opt = vim.opt
-- Line numbers
opt.number = true -- Show line numbers
opt.relativenumber = true -- Show relative line numbers
opt.cursorline = true -- Highlight the current line
-- Tabs and indentation
opt.expandtab = true -- Use spaces instead of tabs
opt.tabstop = 4 -- Number of spaces for a tab
opt.shiftwidth = 4 -- Number of spaces for indentation
opt.softtabstop = 4 -- Number of spaces for soft tab
opt.autoindent = true -- Enable auto-indentation
opt.smartindent = true -- Smart indentation
-- Search
opt.hlsearch = true -- Highlight search results
opt.incsearch = true -- Incremental search
opt.ignorecase = true -- Case-insensitive search
opt.smartcase = true -- Override ignorecase if search contains uppercase
-- UI
opt.showmatch = true -- Show matching brackets
opt.mouse = 'a' -- Enable mouse support
opt.termguicolors = true -- Enable 24-bit RGB colors
opt.laststatus = 2 -- Always show status line
opt.ruler = true -- Show cursor position
opt.wildmenu = true -- Enhanced command-line completion
opt.linebreak = true -- Wrap lines at word boundaries
-- Clipboard — OSC52 tunnels clipboard through SSH/tmux to local kitty.
-- We only wire the *copy* half: OSC52 paste requires the terminal to read
-- back its own clipboard, which most terminals refuse for security. Pairing
-- it with unnamedplus made `p` route through OSC52 paste and silently fail
-- (E353). Instead, leave the unnamed register alone so `p` uses vim's
-- internal buffer, and mirror every yank into `+` via TextYankPost so the
-- system clipboard still gets updated.
vim.g.clipboard = {
name = 'OSC 52',
copy = {
['+'] = require('vim.ui.clipboard.osc52').copy('+'),
['*'] = require('vim.ui.clipboard.osc52').copy('*'),
},
paste = {
['+'] = require('vim.ui.clipboard.osc52').paste('+'),
['*'] = require('vim.ui.clipboard.osc52').paste('*'),
},
}
vim.api.nvim_create_autocmd('TextYankPost', {
pattern = '*',
callback = function()
if vim.v.event.operator == 'y' then
vim.fn.setreg('+', vim.v.event.regcontents, vim.v.event.regtype)
end
end,
})
-- Spell-check for markdown buffers (English + Danish). Nvim auto-downloads
-- missing spellfiles on first use from neovim's spell repo.
vim.api.nvim_create_autocmd('FileType', {
pattern = 'markdown',
callback = function()
vim.opt_local.spell = true
vim.opt_local.spelllang = { 'en', 'da' }
end,
})
-- Files and backups
opt.backup = false -- Disable backup files
opt.swapfile = false -- Disable swap files
opt.undofile = true -- Enable persistent undo
opt.undodir = vim.fn.expand('~/.local/state/nvim/undo') -- Neovim undo directory
-- Auto-reload buffers when files change on disk (e.g. external edits by Claude Code).
-- Nvim only checks on certain events; the autocmd below pokes it more aggressively.
opt.autoread = true
vim.api.nvim_create_autocmd({ 'FocusGained', 'BufEnter', 'CursorHold', 'CursorHoldI' }, {
pattern = '*',
command = 'checktime',
})
vim.api.nvim_create_autocmd('FileChangedShellPost', {
pattern = '*',
callback = function()
vim.notify('File changed on disk — buffer reloaded', vim.log.levels.WARN)
end,
})
-- Performance
opt.updatetime = 300 -- Faster completion and updates
-- Encoding
opt.encoding = 'utf-8' -- Set encoding to UTF-8
opt.fileformat = 'unix' -- Use Unix file format
-- Folding
opt.foldmethod = 'indent' -- Fold based on indentation
opt.foldlevel = 99 -- Start with all folds open
-- Split behavior
opt.splitbelow = true -- Horizontal splits below
opt.splitright = true -- Vertical splits to the right
-- Sign column (for LSP diagnostics and git signs)
opt.signcolumn = 'yes' -- Always show sign column
-- Completion menu
opt.completeopt = { 'menu', 'menuone', 'noselect' } -- Better completion experience
-- Scroll offset
opt.scrolloff = 8 -- Keep 8 lines visible above/below cursor
opt.sidescrolloff = 8 -- Keep 8 columns visible left/right of cursor
-- Command line
opt.cmdheight = 1 -- Command line height
-- Color scheme is set by catppuccin plugin in lua/plugins/ui.lua

View file

@ -0,0 +1,72 @@
-- Autocompletion (nvim-cmp)
return {
"hrsh7th/nvim-cmp",
event = "InsertEnter",
dependencies = {
"hrsh7th/cmp-nvim-lsp", -- LSP source
"hrsh7th/cmp-buffer", -- Buffer words source
"hrsh7th/cmp-path", -- Filesystem path source
"L3MON4D3/LuaSnip", -- Snippet engine
"saadparwaiz1/cmp_luasnip", -- Snippet source
},
config = function()
local cmp = require("cmp")
local luasnip = require("luasnip")
cmp.setup({
snippet = {
expand = function(args)
luasnip.lsp_expand(args.body)
end,
},
mapping = cmp.mapping.preset.insert({
["<C-j>"] = cmp.mapping.select_next_item(),
["<C-k>"] = cmp.mapping.select_prev_item(),
["<C-b>"] = cmp.mapping.scroll_docs(-4),
["<C-f>"] = cmp.mapping.scroll_docs(4),
["<C-e>"] = cmp.mapping.abort(),
["<CR>"] = cmp.mapping.confirm({ select = false }),
["<Tab>"] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_next_item()
elseif luasnip.expand_or_jumpable() then
luasnip.expand_or_jump()
else
fallback()
end
end, { "i", "s" }),
["<S-Tab>"] = cmp.mapping(function(fallback)
if cmp.visible() then
cmp.select_prev_item()
elseif luasnip.jumpable(-1) then
luasnip.jump(-1)
else
fallback()
end
end, { "i", "s" }),
}),
sources = cmp.config.sources({
{ name = "nvim_lsp", priority = 1000 },
{ name = "luasnip", priority = 750 },
{ name = "buffer", priority = 500 },
{ name = "path", priority = 250 },
}),
formatting = {
format = function(entry, item)
local source_labels = {
nvim_lsp = "[LSP]",
luasnip = "[Snip]",
buffer = "[Buf]",
path = "[Path]",
}
item.menu = source_labels[entry.source.name] or ""
return item
end,
},
window = {
completion = cmp.config.window.bordered(),
documentation = cmp.config.window.bordered(),
},
})
end,
}

View file

@ -0,0 +1,49 @@
-- File browser (neo-tree.nvim)
return {
"nvim-neo-tree/neo-tree.nvim",
branch = "v3.x",
dependencies = {
"nvim-lua/plenary.nvim",
"nvim-tree/nvim-web-devicons",
"MunifTanjim/nui.nvim",
},
cmd = "Neotree",
keys = {
{ "<leader>ee", "<cmd>Neotree toggle<cr>", desc = "Toggle file browser" },
{ "<leader>ef", "<cmd>Neotree focus<cr>", desc = "Focus file browser" },
{ "<leader>eg", "<cmd>Neotree git_status<cr>", desc = "Git status tree" },
},
config = function()
require("neo-tree").setup({
close_if_last_window = true,
window = {
width = 30,
mappings = {
["<space>"] = "none", -- don't steal leader
},
},
filesystem = {
filtered_items = {
hide_dotfiles = false,
hide_gitignored = true,
},
follow_current_file = {
enabled = true,
},
},
git_status = {
symbols = {
added = "",
modified = "",
deleted = "",
renamed = "󰁕",
untracked = "",
ignored = "",
unstaged = "󰄱",
staged = "",
conflict = "",
},
},
})
end,
}

View file

@ -0,0 +1,91 @@
-- Formatting configuration (conform.nvim)
return {
"stevearc/conform.nvim",
event = { "BufWritePre" },
cmd = { "ConformInfo" },
config = function()
local conform = require("conform")
conform.setup({
formatters_by_ft = {
python = function(bufnr)
-- Prefer ruff if available, fallback to black
if vim.fn.executable("ruff") == 1 then
return { "ruff_format", "ruff_fix" }
elseif vim.fn.executable("black") == 1 then
return { "black" }
end
return {}
end,
javascript = { "prettier" },
typescript = { "prettier" },
javascriptreact = { "prettier" },
typescriptreact = { "prettier" },
html = { "prettier" },
css = { "prettier" },
scss = { "prettier" },
json = { "prettier" },
markdown = { "prettier" },
php = { "php_cs_fixer" },
yaml = { "yamlfmt" },
},
-- Format on save configuration
format_on_save = function(bufnr)
-- Graceful fallback: disable if no formatter available
local formatters = conform.list_formatters(bufnr)
if #formatters == 0 then
return nil
end
return {
timeout_ms = 500,
lsp_fallback = true,
async = false,
quiet = false,
}
end,
-- Custom formatters
formatters = {
ruff_format = {
command = "ruff",
args = { "format", "--stdin-filename", "$FILENAME", "-" },
stdin = true,
},
ruff_fix = {
command = "ruff",
args = { "check", "--fix", "--stdin-filename", "$FILENAME", "-" },
stdin = true,
},
php_cs_fixer = {
command = "php-cs-fixer",
args = { "fix", "$FILENAME" },
stdin = false,
},
},
})
-- Keymap for manual formatting
vim.keymap.set({ "n", "v" }, "<leader>fm", function()
conform.format({
lsp_fallback = true,
async = false,
timeout_ms = 500,
})
end, { desc = "Format file or range (in visual mode)" })
-- Command to show formatter info
vim.api.nvim_create_user_command("FormatInfo", function()
local formatters = conform.list_formatters(0)
if #formatters == 0 then
print("No formatters available for this filetype")
else
print("Available formatters:")
for _, formatter in ipairs(formatters) do
print(" - " .. formatter.name)
end
end
end, {})
end,
}

View file

@ -0,0 +1,85 @@
-- Git integration (gitsigns.nvim)
return {
"lewis6991/gitsigns.nvim",
event = { "BufReadPre", "BufNewFile" },
config = function()
require("gitsigns").setup({
signs = {
add = { text = '' },
change = { text = '' },
delete = { text = '_' },
topdelete = { text = '' },
changedelete = { text = '~' },
untracked = { text = '' },
},
signcolumn = true, -- Toggle with `:Gitsigns toggle_signs`
numhl = false, -- Toggle with `:Gitsigns toggle_numhl`
linehl = false, -- Toggle with `:Gitsigns toggle_linehl`
word_diff = false, -- Toggle with `:Gitsigns toggle_word_diff`
watch_gitdir = {
interval = 1000,
follow_files = true,
},
attach_to_untracked = true,
current_line_blame = false, -- Toggle with `:Gitsigns toggle_current_line_blame`
current_line_blame_opts = {
virt_text = true,
virt_text_pos = 'eol', -- 'eol' | 'overlay' | 'right_align'
delay = 1000,
ignore_whitespace = false,
},
sign_priority = 6,
update_debounce = 100,
status_formatter = nil, -- Use default
max_file_length = 40000,
preview_config = {
-- Options passed to nvim_open_win
border = 'rounded',
style = 'minimal',
relative = 'cursor',
row = 0,
col = 1,
},
on_attach = function(bufnr)
local gs = package.loaded.gitsigns
local function map(mode, l, r, opts)
opts = opts or {}
opts.buffer = bufnr
vim.keymap.set(mode, l, r, opts)
end
-- Navigation (j = down = next, k = up = prev)
map('n', '<leader>hj', function()
if vim.wo.diff then return ']c' end
vim.schedule(function() gs.next_hunk() end)
return '<Ignore>'
end, {expr=true, desc="Next git hunk"})
map('n', '<leader>hk', function()
if vim.wo.diff then return '[c' end
vim.schedule(function() gs.prev_hunk() end)
return '<Ignore>'
end, {expr=true, desc="Previous git hunk"})
-- Actions
map('n', '<leader>hs', gs.stage_hunk, {desc="Stage hunk"})
map('n', '<leader>hr', gs.reset_hunk, {desc="Reset hunk"})
map('v', '<leader>hs', function() gs.stage_hunk {vim.fn.line('.'), vim.fn.line('v')} end, {desc="Stage hunk"})
map('v', '<leader>hr', function() gs.reset_hunk {vim.fn.line('.'), vim.fn.line('v')} end, {desc="Reset hunk"})
map('n', '<leader>hS', gs.stage_buffer, {desc="Stage buffer"})
map('n', '<leader>hu', gs.undo_stage_hunk, {desc="Undo stage hunk"})
map('n', '<leader>hR', gs.reset_buffer, {desc="Reset buffer"})
map('n', '<leader>hp', gs.preview_hunk, {desc="Preview hunk"})
map('n', '<leader>hb', function() gs.blame_line{full=true} end, {desc="Blame line"})
map('n', '<leader>tb', gs.toggle_current_line_blame, {desc="Toggle line blame"})
map('n', '<leader>hd', gs.diffthis, {desc="Diff this"})
map('n', '<leader>hD', function() gs.diffthis('~') end, {desc="Diff this ~"})
map('n', '<leader>td', gs.toggle_deleted, {desc="Toggle deleted"})
-- Text object
map({'o', 'x'}, 'ih', ':<C-U>Gitsigns select_hunk<CR>', {desc="Select hunk"})
end,
})
end,
}

View file

@ -0,0 +1,27 @@
-- Visual indent guides (indent-blankline.nvim)
return {
"lukas-reineke/indent-blankline.nvim",
main = "ibl",
event = { "BufReadPost", "BufNewFile" },
config = function()
require("ibl").setup({
indent = {
char = "",
},
scope = {
enabled = true,
show_start = false,
show_end = false,
},
exclude = {
filetypes = {
"help",
"neo-tree",
"lazy",
"mason",
"TelescopePrompt",
},
},
})
end,
}

View file

@ -0,0 +1,111 @@
-- LSP configuration (nvim 0.11+ native API)
return {
{
"neovim/nvim-lspconfig",
config = function()
-- Diagnostic configuration
vim.diagnostic.config({
virtual_text = true,
signs = {
text = {
[vim.diagnostic.severity.ERROR] = "",
[vim.diagnostic.severity.WARN] = "",
[vim.diagnostic.severity.HINT] = "",
[vim.diagnostic.severity.INFO] = "»",
},
},
underline = true,
update_in_insert = false,
severity_sort = true,
float = {
border = "rounded",
source = true,
},
})
-- Global LSP keybindings applied when any server attaches
vim.lsp.config('*', {
on_attach = function(client, bufnr)
local opts = { buffer = bufnr, noremap = true, silent = true }
vim.keymap.set('n', 'gD', vim.lsp.buf.declaration, opts)
vim.keymap.set('n', 'gd', vim.lsp.buf.definition, opts)
vim.keymap.set('n', 'K', vim.lsp.buf.hover, opts)
vim.keymap.set('n', 'gi', vim.lsp.buf.implementation, opts)
vim.keymap.set('n', '<leader>K', vim.lsp.buf.signature_help, opts)
vim.keymap.set('n', '<leader>rn', vim.lsp.buf.rename, opts)
vim.keymap.set('n', '<leader>ca', vim.lsp.buf.code_action, opts)
vim.keymap.set('n', 'gr', vim.lsp.buf.references, opts)
vim.keymap.set('n', '<leader>f', function() vim.lsp.buf.format({ async = true }) end, opts)
end,
})
-- Python: pyright (prefer) or pylsp (fallback)
if vim.fn.executable("pyright-langserver") == 1 then
vim.lsp.config('pyright', {
settings = {
python = {
analysis = {
typeCheckingMode = "basic",
autoSearchPaths = true,
useLibraryCodeForTypes = true,
},
},
},
})
vim.lsp.enable('pyright')
elseif vim.fn.executable("pylsp") == 1 then
vim.lsp.enable('pylsp')
end
-- PHP: intelephense
if vim.fn.executable("intelephense") == 1 then
vim.lsp.enable('intelephense')
end
-- JavaScript/TypeScript: typescript-language-server
if vim.fn.executable("typescript-language-server") == 1 then
vim.lsp.enable('ts_ls')
end
-- HTML
if vim.fn.executable("vscode-html-language-server") == 1 then
vim.lsp.enable('html')
end
-- CSS
if vim.fn.executable("vscode-css-language-server") == 1 then
vim.lsp.enable('cssls')
end
-- JSON
if vim.fn.executable("vscode-json-language-server") == 1 then
vim.lsp.enable('jsonls')
end
-- YAML
if vim.fn.executable("yaml-language-server") == 1 then
vim.lsp.config('yamlls', {
settings = {
yaml = {
schemas = {
["https://json.schemastore.org/github-workflow.json"] = "/.github/workflows/*",
["https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/ansible.json#/$defs/playbook"] = "/*play*.{yml,yaml}",
["https://raw.githubusercontent.com/ansible/ansible-lint/main/src/ansiblelint/schemas/ansible.json#/$defs/tasks"] = "/tasks/*.{yml,yaml}",
},
},
},
})
vim.lsp.enable('yamlls')
end
-- Markdown: marksman
if vim.fn.executable("marksman") == 1 then
vim.lsp.enable('marksman')
end
-- Bash: bash-language-server
if vim.fn.executable("bash-language-server") == 1 then
vim.lsp.enable('bashls')
end
end,
},
}

View file

@ -0,0 +1,22 @@
-- tadmccorkle/markdown.nvim — buffer-local helpers for markdown editing.
-- Defaults already wire emphasis (gs/gss/ds/cs), links (gl/gx) and heading
-- nav (]c/]p/]]/[[). List-item/checkbox features are user commands only,
-- so on_attach adds <localleader> bindings for them.
return {
"tadmccorkle/markdown.nvim",
ft = "markdown",
opts = {
on_attach = function(bufnr)
local map = function(mode, lhs, rhs, desc)
vim.keymap.set(mode, lhs, rhs, { buffer = bufnr, silent = true, desc = desc })
end
map("n", "<localleader>o", "<Cmd>MDListItemBelow<CR>", "Insert list item below")
map("n", "<localleader>O", "<Cmd>MDListItemAbove<CR>", "Insert list item above")
map("n", "<localleader>ch", "<Cmd>MDTaskToggle<CR>", "Toggle checkbox")
-- <localleader>n (not rn): the LSP's on_attach binds <leader>rn to
-- vim.lsp.buf.rename buffer-local and overrides ours.
map("n", "<localleader>n", "<Cmd>%MDResetListNumbering<CR>", "Renumber ordered list")
map("x", "<localleader>ch", ":MDTaskToggle<CR>", "Toggle checkbox (range)")
end,
},
}

View file

@ -0,0 +1,21 @@
-- echasnovski/mini.move — move lines/selections with Alt+arrows in any
-- filetype. Uses arrow keys to avoid clashing with existing <A-j>/<A-k>
-- diagnostic mappings in keymaps.lua.
return {
"echasnovski/mini.move",
event = "VeryLazy",
opts = {
mappings = {
-- visual selection
left = "<M-Left>",
right = "<M-Right>",
down = "<M-Down>",
up = "<M-Up>",
-- current line in normal mode
line_left = "<M-Left>",
line_right = "<M-Right>",
line_down = "<M-Down>",
line_up = "<M-Up>",
},
},
}

View file

@ -0,0 +1,12 @@
return {
"alexghergh/nvim-tmux-navigation",
event = "VeryLazy",
config = function()
local nav = require("nvim-tmux-navigation")
nav.setup({ disable_when_zoomed = true })
vim.keymap.set("n", "<C-h>", nav.NvimTmuxNavigateLeft, { noremap = true, silent = true, desc = "Navigate left" })
vim.keymap.set("n", "<C-j>", nav.NvimTmuxNavigateDown, { noremap = true, silent = true, desc = "Navigate down" })
vim.keymap.set("n", "<C-k>", nav.NvimTmuxNavigateUp, { noremap = true, silent = true, desc = "Navigate up" })
vim.keymap.set("n", "<C-l>", nav.NvimTmuxNavigateRight, { noremap = true, silent = true, desc = "Navigate right" })
end,
}

View file

@ -0,0 +1,61 @@
-- Telescope fuzzy finder configuration
return {
"nvim-telescope/telescope.nvim",
branch = "0.1.x",
dependencies = {
"nvim-lua/plenary.nvim",
},
cmd = "Telescope",
keys = {
{ "<leader>ff", "<cmd>Telescope find_files<cr>", desc = "Find files" },
{ "<leader>fg", "<cmd>Telescope live_grep<cr>", desc = "Live grep" },
{ "<leader>fb", "<cmd>Telescope buffers<cr>", desc = "Find buffers" },
{ "<leader>fh", "<cmd>Telescope help_tags<cr>", desc = "Help tags" },
{ "<leader>fr", "<cmd>Telescope oldfiles<cr>", desc = "Recent files" },
{ "<leader>fc", "<cmd>Telescope commands<cr>", desc = "Commands" },
{ "<leader>fk", "<cmd>Telescope keymaps<cr>", desc = "Keymaps" },
},
config = function()
local telescope = require("telescope")
local actions = require("telescope.actions")
telescope.setup({
defaults = {
prompt_prefix = "🔍 ",
selection_caret = "",
path_display = { "truncate" },
sorting_strategy = "ascending",
layout_config = {
horizontal = {
prompt_position = "top",
preview_width = 0.55,
results_width = 0.8,
},
vertical = {
mirror = false,
},
width = 0.87,
height = 0.80,
preview_cutoff = 120,
},
mappings = {
i = {
["<C-k>"] = actions.move_selection_previous,
["<C-j>"] = actions.move_selection_next,
["<C-q>"] = actions.send_selected_to_qflist + actions.open_qflist,
["<Esc>"] = actions.close,
},
n = {
["q"] = actions.close,
},
},
},
pickers = {
find_files = {
hidden = false,
find_command = { "rg", "--files", "--hidden", "--glob", "!.git/*" },
},
},
})
end,
}

View file

@ -0,0 +1,41 @@
-- Terminal management (toggleterm.nvim)
-- <leader>tt floating shell
-- <leader>gg lazygit
return {
"akinsho/toggleterm.nvim",
version = "*",
keys = {
{ "<leader>tt", desc = "Toggle floating terminal" },
{ "<leader>gg", desc = "Open lazygit" },
},
config = function()
require("toggleterm").setup({
direction = "float",
float_opts = {
border = "curved",
width = function() return math.floor(vim.o.columns * 0.9) end,
height = function() return math.floor(vim.o.lines * 0.9) end,
},
close_on_exit = true,
})
local Terminal = require("toggleterm.terminal").Terminal
local shell_term = Terminal:new({ direction = "float" })
vim.keymap.set("n", "<leader>tt", function()
shell_term:toggle()
end, { desc = "Toggle floating terminal" })
local lazygit = Terminal:new({
cmd = "lazygit",
direction = "float",
close_on_exit = true,
on_open = function(_)
vim.cmd("startinsert!")
end,
})
vim.keymap.set("n", "<leader>gg", function()
lazygit:toggle()
end, { desc = "Open lazygit" })
end,
}

View file

@ -0,0 +1,60 @@
-- Treesitter configuration
-- Pin to legacy `master` branch: upstream made `main` the default, but `main`
-- is a rewrite that removed the `nvim-treesitter.configs` module this config
-- depends on. `master` is still maintained and exposes the classic API.
return {
"nvim-treesitter/nvim-treesitter",
branch = "master",
build = ":TSUpdate",
config = function()
-- Check if treesitter is available
local status_ok, treesitter_configs = pcall(require, "nvim-treesitter.configs")
if not status_ok then
vim.notify("nvim-treesitter not loaded", vim.log.levels.WARN)
return
end
treesitter_configs.setup({
-- Install parsers for these languages.
-- Omit parsers bundled with nvim 0.10 (c, lua, markdown, markdown_inline,
-- vim, vimdoc) — they live in /usr/lib/nvim/parser/ and do not need building.
ensure_installed = {
"bash",
"css",
"html",
"javascript",
"json",
"php",
"python",
"typescript",
"yaml",
},
-- Install parsers synchronously (only applied to `ensure_installed`)
sync_install = false,
-- Automatically install missing parsers when entering buffer
auto_install = true,
highlight = {
enable = true,
additional_vim_regex_highlighting = false,
},
indent = {
enable = true,
},
-- Incremental selection
incremental_selection = {
enable = true,
keymaps = {
init_selection = "<C-space>",
node_incremental = "<C-space>",
scope_incremental = false,
node_decremental = "<C-backspace>",
},
},
})
end,
}

View file

@ -0,0 +1,140 @@
-- UI and editing quality-of-life plugins
return {
-- ---- Catppuccin colorscheme (priority 1000 — loads before other UI) ----
{
"catppuccin/nvim",
name = "catppuccin",
priority = 1000,
config = function()
require("catppuccin").setup({
flavour = "mocha",
transparent_background = false,
term_colors = true,
integrations = {
treesitter = true,
telescope = { enabled = true },
gitsigns = true,
which_key = true,
cmp = true,
native_lsp = { enabled = true },
},
})
vim.cmd.colorscheme("catppuccin-mocha")
end,
},
-- ---- Lualine status line ----
{
"nvim-lualine/lualine.nvim",
dependencies = { "nvim-tree/nvim-web-devicons" },
event = "VeryLazy",
config = function()
require("lualine").setup({
options = {
theme = "catppuccin-mocha",
globalstatus = true,
component_separators = { left = "", right = "" },
section_separators = { left = "", right = "" },
},
})
end,
},
-- ---- Zen mode ----
{
"folke/zen-mode.nvim",
cmd = "ZenMode",
keys = { { "<leader>z", "<cmd>ZenMode<cr>", desc = "Zen Mode" } },
config = function()
require("zen-mode").setup({
window = {
width = 90,
height = 1,
options = { signcolumn = "no", number = false, relativenumber = false },
},
plugins = { tmux = { enabled = true } },
})
end,
},
-- ---- Which-key (v3 API) ----
{
"folke/which-key.nvim",
event = "VeryLazy",
config = function()
local wk = require("which-key")
wk.setup({ preset = "modern", delay = 500 })
wk.add({
{ "<leader>f", group = "Find (Telescope)" },
{ "<leader>h", group = "Git Hunks" },
{ "<leader>z", desc = "Zen Mode" },
})
end,
},
-- Auto-close brackets, quotes, etc.
{
"windwp/nvim-autopairs",
event = "InsertEnter",
config = function()
require("nvim-autopairs").setup({
check_ts = true, -- Enable treesitter integration
ts_config = {
lua = { "string" }, -- Don't add pairs in lua string treesitter nodes
javascript = { "template_string" },
java = false, -- Don't check treesitter on java
},
disable_filetype = { "TelescopePrompt", "vim" },
fast_wrap = {
map = "<M-e>",
chars = { "{", "[", "(", '"', "'" },
pattern = [=[[%'%"%>%]%)%}%,]]=],
end_key = "$",
keys = "qwertyuiopzxcvbnmasdfghjkl",
check_comma = true,
highlight = "Search",
highlight_grey = "Comment",
},
})
end,
},
-- Surround text objects
{
"kylechui/nvim-surround",
event = "VeryLazy",
config = function()
require("nvim-surround").setup()
end,
},
-- Smart commenting
{
"numToStr/Comment.nvim",
event = "VeryLazy",
config = function()
require("Comment").setup({
padding = true,
sticky = true,
ignore = "^$", -- Ignore empty lines
toggler = {
line = "gcc",
block = "gbc",
},
opleader = {
line = "gc",
block = "gb",
},
extra = {
above = "gcO",
below = "gco",
eol = "gcA",
},
mappings = {
basic = true,
extra = true,
},
})
end,
},
}

View file

@ -0,0 +1,88 @@
# tmux configuration — managed by boma dev_env role (stow)
# Prefix
unbind C-b
set-option -g prefix C-a
bind-key C-a send-prefix
# General
set -g default-terminal "screen-256color"
set -ag terminal-overrides ",xterm-256color:RGB"
set -g mouse on
set -g history-limit 50000
set -g display-time 4000
set -g status-interval 5
set -g focus-events on
set -s extended-keys off
set -s set-clipboard on
set -g allow-passthrough on
set -as terminal-features ',xterm-256color:clipboard'
# Base index
set -g base-index 1
set -g pane-base-index 1
set-window-option -g pane-base-index 1
set-option -g renumber-windows on
# Vi mode in copy mode
set-window-option -g mode-keys vi
bind-key -T copy-mode-vi v send-keys -X begin-selection
bind-key -T copy-mode-vi C-v send-keys -X rectangle-toggle
bind-key -T copy-mode-vi y send-keys -X copy-selection-and-cancel
# Splits — arrow direction = where new pane opens (mirrors kitty and nvim)
bind Right split-window -h -c "#{pane_current_path}"
bind Down split-window -v -c "#{pane_current_path}"
unbind '"'
unbind %
unbind v
unbind -
# Pane resize (repeatable)
bind -r H resize-pane -L 5
bind -r J resize-pane -D 5
bind -r K resize-pane -U 5
bind -r L resize-pane -R 5
# Window cycling — no prefix needed; mirrors nvim buffer cycling (Ctrl = inner layer)
bind -n C-Tab next-window
bind -n C-NPage next-window
bind -n C-PPage previous-window
# New window — t = tab (mirrors kitty Ctrl+Shift+T); keep c as alias
bind t new-window -c "#{pane_current_path}"
bind c new-window -c "#{pane_current_path}"
# Close pane without confirmation prompt
bind x kill-pane
# Reload config
bind r source-file ~/.tmux.conf \; display "Config reloaded!"
# =============================================================
# Plugins (TPM)
# Pin catppuccin to v1 API to avoid v2 breaking changes
# =============================================================
set -g @plugin 'tmux-plugins/tpm'
set -g @plugin 'tmux-plugins/tmux-sensible'
set -g @plugin 'christoomey/vim-tmux-navigator'
set -g @plugin 'catppuccin/tmux#v1.0.3'
# Catppuccin theme (v1 API)
set -g @catppuccin_flavour 'mocha'
set -g @catppuccin_window_left_separator ""
set -g @catppuccin_window_right_separator " "
set -g @catppuccin_window_middle_separator " █"
set -g @catppuccin_window_number_position "right"
set -g @catppuccin_window_default_fill "number"
set -g @catppuccin_window_current_fill "number"
set -g @catppuccin_window_current_text "#{pane_current_path}"
set -g @catppuccin_status_modules_right "directory session"
set -g @catppuccin_status_left_separator " "
set -g @catppuccin_status_right_separator ""
set -g @catppuccin_status_fill "icon"
set -g @catppuccin_status_connect_separator "no"
set -g @catppuccin_directory_text "#{pane_current_path}"
# Initialize TPM (keep at very bottom)
run '~/.tmux/plugins/tpm/tpm'

View file

@ -0,0 +1,55 @@
# ---- managed by boma dev_env role (stow) ----
# If Oh My Zsh is installed, point ZSH and load it; otherwise pure zsh.
export ZSH="$HOME/.oh-my-zsh"
ZSH_THEME="robbyrussell"
plugins=(git history sudo zsh-autosuggestions zsh-syntax-highlighting)
if [ -d "$ZSH" ]; then
source "$ZSH/oh-my-zsh.sh"
fi
# Oh My Posh prompt (preferred)
if command -v oh-my-posh >/dev/null 2>&1; then
eval "$(oh-my-posh init zsh --config /etc/oh-my-posh/zen.toml)"
fi
# Terminal title
function set_terminal_title {
echo -ne "\033]0;${USER}@${HOSTNAME}: ${PWD}\007"
}
precmd() { set_terminal_title }
# Aliases and basics
alias ll="ls -lh"
alias la="ls -lha"
alias ..="cd .."
alias update="sudo apt update && sudo apt upgrade -y"
alias rclone="/usr/bin/rclone"
# Use neovim for vim/vi commands
alias vim='nvim'
alias vi='nvim'
# History
HISTFILE="$HOME/.zsh_history"
HISTSIZE=10000
SAVEHIST=10000
setopt inc_append_history share_history
# Completion/globbing
autoload -Uz compinit
compinit
setopt autocd correct globdots no_beep
# Editor & PATH
export EDITOR="/usr/local/bin/nvim"
export VISUAL="/usr/local/bin/nvim"
export PATH="$HOME/.local/bin:$HOME/bin:$PATH"
# Ensure USER is set (edge cases)
export USER=$(whoami)
# Extras from inventory
# Enable direnv for automatic virtualenv activation
eval "$(direnv hook zsh)"

View file

@ -0,0 +1,78 @@
#:schema https://raw.githubusercontent.com/JanDeDobbeleer/oh-my-posh/main/themes/schema.json
version = 3
final_space = true
console_title_template = '{{ .Shell }} in {{ .Folder }}'
[[blocks]]
type = 'prompt'
alignment = 'left'
newline = true
[[blocks.segments]]
type = 'text'
style = 'plain'
foreground = 'cyan'
background = 'transparent'
template = '{{ .UserName }}@{{ .HostName }}:'
[[blocks.segments]]
type = 'path'
style = 'plain'
foreground = 'blue'
background = 'transparent'
template = '{{ .Path }}'
[blocks.segments.properties]
style = 'full'
[[blocks.segments]]
type = 'text'
style = 'plain'
foreground = 'magenta'
background = 'transparent'
template = '>'
[[blocks.segments]]
type = 'git'
style = 'plain'
foreground = 'p:grey'
background = 'transparent'
template = ' {{ .HEAD }}{{ if or (.Working.Changed) (.Staging.Changed) }}*{{ end }} <cyan>{{ if gt .Behind 0 }}⇣{{ end }}{{ if gt .Ahead 0 }}⇡{{ end }}</>'
[[blocks]]
type = 'rprompt'
overflow = 'hidden'
[[blocks.segments]]
type = 'executiontime'
style = 'plain'
foreground = 'yellow'
background = 'transparent'
template = '{{ .FormattedMs }}'
[blocks.segments.properties]
threshold = 5000
[[blocks]]
type = 'prompt'
alignment = 'left'
newline = true
[[blocks.segments]]
type = 'text'
style = 'plain'
foreground_templates = [
"{{if gt .Code 0}}red{{end}}",
"{{if eq .Code 0}}magenta{{end}}",
]
background = 'transparent'
template = ''
[transient_prompt]
foreground_templates = [
"{{if gt .Code 0}}red{{end}}",
"{{if eq .Code 0}}magenta{{end}}",
]
background = 'transparent'
template = ' '
[secondary_prompt]
foreground = 'magenta'
background = 'transparent'
template = ' '

View file

@ -0,0 +1 @@
---

View file

@ -0,0 +1,13 @@
---
galaxy_info:
author: sjat
description: >-
Interactive developer environment (zsh/oh-my-zsh/oh-my-posh, tmux, neovim) for
workstation-class boma hosts (Debian 13). Dotfiles deployed via GNU stow.
license: MIT
min_ansible_version: "2.17"
platforms:
- name: Debian
versions:
- trixie
dependencies: []

View file

@ -0,0 +1,15 @@
---
- name: Converge
hosts: all
become: true
gather_facts: true
vars:
dev_env__users:
- tester
pre_tasks:
- name: Create a test user to receive the environment
ansible.builtin.user:
name: tester
create_home: true
roles:
- role: dev_env

View file

@ -0,0 +1,31 @@
---
dependency:
name: galaxy
options:
requirements-file: ../../requirements.yml
driver:
name: docker
platforms:
- name: instance
# Project-owned image built from .docker/molecule-debian13/Dockerfile
# and hosted in the Forgejo container registry.
# Build/push with: make molecule-image / make molecule-image-push
image: forgejo.nyumbani.baobab.band/sjat/molecule-debian13:latest
pre_build_image: true
privileged: true # required for systemd
cgroupns_mode: host
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
command: /lib/systemd/systemd
provisioner:
name: ansible
inventory:
host_vars:
instance:
ansible_user: root
verifier:
name: ansible

View file

@ -0,0 +1,73 @@
---
- name: Verify
hosts: all
become: true
gather_facts: false
tasks:
- name: Gather installed-package facts
ansible.builtin.package_facts:
manager: apt
- name: Assert core packages are present
ansible.builtin.assert:
that:
- "'zsh' in ansible_facts.packages"
- "'tmux' in ansible_facts.packages"
- "'stow' in ansible_facts.packages"
- "'direnv' in ansible_facts.packages"
fail_msg: core dev_env packages missing
- name: Stat system binaries and theme
ansible.builtin.stat:
path: "{{ item }}"
loop:
- /usr/local/bin/nvim
- /usr/local/bin/oh-my-posh
- /etc/oh-my-posh/zen.toml
register: dev_env__sys
loop_control:
label: "{{ item }}"
- name: Assert system tools are installed
ansible.builtin.assert:
that:
- dev_env__sys.results[0].stat.exists
- dev_env__sys.results[1].stat.exists
- dev_env__sys.results[2].stat.exists
fail_msg: nvim/oh-my-posh/zen.toml missing
- name: Look up the test user
ansible.builtin.getent:
database: passwd
key: tester
- name: Assert tester login shell is zsh
ansible.builtin.assert:
that:
- "getent_passwd['tester'][5] == '/usr/bin/zsh'"
fail_msg: tester login shell was not set to zsh
- name: Stat tester dotfiles and frameworks
ansible.builtin.stat:
path: "{{ item }}"
loop:
- /home/tester/.zshrc
- /home/tester/.tmux.conf
- /home/tester/.config/nvim/init.lua
- /home/tester/.oh-my-zsh
- /home/tester/.tmux/plugins/tpm
register: dev_env__dots
loop_control:
label: "{{ item }}"
- name: Assert dotfiles are stowed (symlinks) and frameworks cloned
ansible.builtin.assert:
that:
- dev_env__dots.results[0].stat.exists
- dev_env__dots.results[0].stat.islnk
- dev_env__dots.results[1].stat.exists
- dev_env__dots.results[1].stat.islnk
- dev_env__dots.results[2].stat.exists
- dev_env__dots.results[3].stat.exists
- dev_env__dots.results[4].stat.exists
fail_msg: dotfiles not stowed or omz/tpm not cloned

View file

@ -0,0 +1,4 @@
---
# No extra Galaxy collections beyond the repo-level requirements.yml — dev_env uses
# only ansible.builtin modules. Present so the Molecule galaxy dependency step resolves.
collections: []

View file

@ -0,0 +1,23 @@
---
- name: Install developer-environment packages
ansible.builtin.apt:
name: "{{ dev_env__packages }}"
state: present
update_cache: true
cache_valid_time: 3600
tags: [packages]
- name: Install Neovim (pinned release)
ansible.builtin.include_tasks: neovim.yml
tags: [packages]
- name: Install oh-my-posh prompt (pinned release)
ansible.builtin.include_tasks: oh_my_posh.yml
tags: [packages]
- name: Configure each developer user
ansible.builtin.include_tasks: per_user.yml
loop: "{{ dev_env__users }}"
loop_control:
loop_var: dev_env__user
label: "{{ dev_env__user }}"

View file

@ -0,0 +1,52 @@
---
- name: Neovim | Read installed-version sentinel
ansible.builtin.slurp:
src: /etc/nvim_installed_version
register: dev_env__nvim_sentinel
failed_when: false
- name: Neovim | Determine installed version
ansible.builtin.set_fact:
dev_env__nvim_installed: >-
{{ (dev_env__nvim_sentinel.content | b64decode | trim)
if dev_env__nvim_sentinel.content is defined else '' }}
- name: Neovim | Install pinned release
when: dev_env__nvim_installed != dev_env__nvim_version
block:
- name: Neovim | Download release tarball
ansible.builtin.get_url:
url: "https://github.com/neovim/neovim/releases/download/{{ dev_env__nvim_version }}/nvim-linux-x86_64.tar.gz"
dest: "/tmp/nvim-{{ dev_env__nvim_version }}.tar.gz"
mode: "0644"
- name: Neovim | Create versioned install directory
ansible.builtin.file:
path: "/opt/nvim-{{ dev_env__nvim_version }}"
state: directory
mode: "0755"
- name: Neovim | Extract tarball
ansible.builtin.unarchive:
src: "/tmp/nvim-{{ dev_env__nvim_version }}.tar.gz"
dest: "/opt/nvim-{{ dev_env__nvim_version }}"
remote_src: true
extra_opts: ["--strip-components=1"]
- name: Neovim | Symlink into PATH
ansible.builtin.file:
src: "/opt/nvim-{{ dev_env__nvim_version }}/bin/nvim"
dest: /usr/local/bin/nvim
state: link
force: true
- name: Neovim | Write version sentinel
ansible.builtin.copy:
content: "{{ dev_env__nvim_version }}"
dest: /etc/nvim_installed_version
mode: "0644"
- name: Neovim | Remove downloaded tarball
ansible.builtin.file:
path: "/tmp/nvim-{{ dev_env__nvim_version }}.tar.gz"
state: absent

View file

@ -0,0 +1,25 @@
---
- name: Oh-my-posh | Check installed version
ansible.builtin.command: oh-my-posh --version
register: dev_env__omp_check
changed_when: false
failed_when: false
- name: Oh-my-posh | Install pinned binary
ansible.builtin.get_url:
url: "https://github.com/JanDeDobbeleer/oh-my-posh/releases/download/v{{ dev_env__omp_version }}/posh-linux-amd64"
dest: /usr/local/bin/oh-my-posh
mode: "0755"
when: (dev_env__omp_check.stdout | default('') | trim) != dev_env__omp_version
- name: Oh-my-posh | Ensure theme directory
ansible.builtin.file:
path: /etc/oh-my-posh
state: directory
mode: "0755"
- name: Oh-my-posh | Deploy zen.toml theme (system-wide)
ansible.builtin.copy:
src: oh-my-posh/zen.toml
dest: /etc/oh-my-posh/zen.toml
mode: "0644"

View file

@ -0,0 +1,69 @@
---
- name: Look up account for {{ dev_env__user }}
ansible.builtin.getent:
database: passwd
key: "{{ dev_env__user }}"
- name: Resolve home directory for {{ dev_env__user }}
ansible.builtin.set_fact:
dev_env__home: "{{ getent_passwd[dev_env__user][4] }}"
- name: Set login shell to zsh for {{ dev_env__user }}
ansible.builtin.user:
name: "{{ dev_env__user }}"
shell: /usr/bin/zsh
tags: [users]
- name: Clone oh-my-zsh for {{ dev_env__user }}
become: true
become_user: "{{ dev_env__user }}"
ansible.builtin.git:
repo: https://github.com/ohmyzsh/ohmyzsh.git
dest: "{{ dev_env__home }}/.oh-my-zsh"
version: master
depth: 1
update: false
- name: Clone oh-my-zsh custom plugins for {{ dev_env__user }}
become: true
become_user: "{{ dev_env__user }}"
ansible.builtin.git:
repo: "{{ item.repo }}"
dest: "{{ dev_env__home }}/.oh-my-zsh/custom/plugins/{{ item.name }}"
version: master
depth: 1
update: false
loop: "{{ dev_env__omz_custom_plugins }}"
loop_control:
label: "{{ item.name }}"
- name: Clone tmux plugins (incl. TPM) for {{ dev_env__user }}
become: true
become_user: "{{ dev_env__user }}"
ansible.builtin.git:
repo: "{{ item.repo }}"
dest: "{{ dev_env__home }}/.tmux/plugins/{{ item.name }}"
version: "{{ item.version }}"
depth: 1
update: false
loop: "{{ dev_env__tmux_plugins }}"
loop_control:
label: "{{ item.name }}"
- name: Install dotfiles into ~/.dotfiles for {{ dev_env__user }}
become: true
become_user: "{{ dev_env__user }}"
ansible.builtin.copy:
src: dotfiles/
dest: "{{ dev_env__home }}/.dotfiles/"
mode: preserve
tags: [config]
- name: Stow dotfiles into home for {{ dev_env__user }}
become: true
become_user: "{{ dev_env__user }}"
ansible.builtin.command:
cmd: "stow --no-folding -v -d {{ dev_env__home }}/.dotfiles -t {{ dev_env__home }} zsh tmux nvim"
register: dev_env__stow
changed_when: "'LINK:' in dev_env__stow.stderr or 'LINK:' in dev_env__stow.stdout"
tags: [config]