Writing Your Manifest - josemiguelo/loadout GitHub Wiki
The manifest is where you declare loadouts. This page grows one from a few lines to a full fleet config, one capability at a time. Every pattern links to a live example in josemiguelo/.dotfiles, a real repo running two machines, dotfiles included. Where the files go is Repo layout's subject; this page is about what goes in them.
A loadout needs three things: a program, a way to install it, and a way to prove it's there. loadout already knows how dnf, brew, apt and friends install and check things, so all three fit in a few lines:
# loadout.yaml
meta:
name: my-loadout
layout:
fragments: ['programs/**/*.yaml']
machines: machines
state: state# programs/ripgrep.yaml β one program, named by its file
via: [dnf, brew]# machines/laptop.yaml β which install entry THIS machine uses
install_with:
dnf: [ripgrep]That's a working repo: loadout status observes it, loadout setup-new-machine
converges it. via lists the mechanisms this program is truthfully
available through β one install entry each β and the machine file picks the
one this machine uses. Ask what a name means before trusting it:
$ loadout installers dnf
installer dnf built-in
probe dnf
install sudo dnf install -y {pkg}
upgrade sudo dnf upgrade --refresh -y
check rpm -q {pkg}
regex ([0-9]+\.[0-9][0-9.]*)
outdated-all dnf -q --cacheonly check-update | awk β¦The check is not decoration β it is the definition of "installed". The version in status is rpm's truth, not the binary's self-report.
The mapping is also membership: a program a machine doesn't map simply isn't part of that machine's loadout. One manifest serves machines with different subsets β no conditionals, no OS detection, just presence in the machine file.
An installer is a mechanism β how to install, how to check, how to ask
what's newer β defined once with {pkg} standing for the package id. Ten
ship with loadout:
| Installer | What it is | Oracle for outdated
|
upgrade |
|---|---|---|---|
dnf |
Fedora/RHEL packages | batch | sudo dnf upgrade --refresh -y |
dnf-repo |
a vendor rpm repository (repofile) |
batch | same transaction as dnf
|
dnf-copr |
a COPR (copr) |
batch | same transaction as dnf
|
brew Β· brew-cask
|
Homebrew formulae and casks | batch |
brew upgrade Β· brew upgrade --cask
|
flatpak |
Flathub apps, user level | batch | flatpak --user update -y |
pacman |
Arch packages | batch (checkupdates, from pacman-contrib) |
sudo pacman -Syu --noconfirm |
omarchy |
Arch packages the Omarchy way (omarchy pkg add) |
batch (checkupdates) |
omarchy update -y |
omarchy-aur |
AUR packages the Omarchy way (omarchy pkg aur add) |
batch (yay -Qua) |
same as omarchy
|
apt |
Debian packages | none yet | apt-get upgrade -y |
The upgrade column is one command per mechanism, with no package in it:
loadout upgrade dnf moves everything dnf manages, and there is no way to
ask for one package (see the day page
for why). Installers sharing a command run it once. An installer without
upgrade still installs and checks; its rows just can't be selected for
upgrading.
loadout installers # what's available here, and where each came from
loadout installers dnf # the exact commands that will run
loadout installers --eject # copy them into programs/installers/builtin.loadout.yamlMechanics belong to the tool; your repo says which programs you want and where each machine gets them. Nothing is detected: loadout never picks an installer for you β the machine file does, always.
Write an installers: entry when a mechanism isn't shipped, or when a
shipped one is wrong for your machines. It goes in a *.loadout.yaml
fragment or the root loadout.yaml. A repo definition replaces the
built-in of that name outright (not field by field). Its commands run in its
own file's folder, so a helper script sits right beside it:
# programs/brew/brew.loadout.yaml β brew by absolute path, through brew.sh beside it
installers:
brew:
install: 'file:brew.sh install {pkg}'
upgrade: 'file:brew.sh upgrade'
check: 'file:brew.sh list --versions {pkg}'
regex: '([0-9]+\.[0-9][0-9.]*)'loadout explain <program> always says which one won:
[installer: brew (built-in)] or [installer: brew (repo)]. Live:
programs/brew/.
Every command runs in your terminal, from the home screen too, so sudo prompts and other questions reach you as in a shell.
A run asks for your sudo password once, before its first step, when one of
its steps needs it: a command that says sudo (sudo dnf install -y {pkg}), or one that declares sudo: true because it calls sudo from
inside (omarchy pkg add, Homebrew's installer, your own script). After
that, every step finds sudo ready, so a long setup-new-machine doesn't
stop halfway for the password:
scripts:
t2-fan-curve:
file: fan-curve.sh # writes /etc/t2fand.conf with sudo
sudo: truesudo: true goes on a script, an install variant (it replaces its
installer's), an installer or an outdated source. The built-in omarchy
and omarchy-aur installers already declare it. A step that needs sudo
without saying so still works: it just asks in the middle of the run, when
sudo wants the password again.
Some mechanisms need more than a package id β a COPR name, a vendor's
repository. Those declare params, and each program supplies them in a
with mapping:
# a *.loadout.yaml fragment
programs:
kitty:
install:
dnf-copr:
with:
copr: solopasha/kitty
code:
install:
dnf-repo:
with:
repofile: 'file:repos/vscode.repo' # a file beside this one; or the vendor's URL{copr} and {repofile} substitute into every pattern the installer
defines, exactly like {pkg}. A value marked file: is a path next to the
file that declares it, checked on every load like any other path. A declared
param nobody supplies β or a with key the installer never declared β is a
load error, so a half-substituted command can't reach a shell.
This is what a family of near-identical install scripts collapses into: the
live repo once carried seven scripts that imported a key, wrote a .repo
file and ran dnf install; they are now dnf-repo variants plus
vendor .repo files
β data, not code. Declaring your own parameterized installer works the same
way: params: [repo, key] and an install pattern that uses them.
An install variant is a mapping keyed by a label of your choosing; every field is optional and falls back to its installer. Override exactly the field that deviates:
# a *.loadout.yaml fragment: several programs in one file
programs:
obsidian:
install:
flatpak:
pkg: md.obsidian.Obsidian # package id β program name; rest derives
workmux:
install:
brew:
command: brew install raine/workmux/workmux # tap-qualified; check derives
zlib-devel:
install:
dnf:
check: rpm -q --whatprovides zlib-devel # virtual provide; rest derivesThe blueprint list, most-common first β match top-down, first fit wins:
| Your case | Write | Live example |
|---|---|---|
| standard package | via: [...] |
ripgrep |
| different package id | variant with pkg
|
obsidian |
| special command, same mechanism | variant with command
|
workmux |
| vendor rpm repository |
dnf-repo + with.repofile
|
code |
| a COPR |
dnf-copr + with.copr
|
kitty |
| mechanism needs a value per program | installer with params, variant with with
|
(the two above) |
| needs a prior step (tap, repo) | separate program + depends-on
|
tpack |
| only one mechanism needs that step |
depends-on on that variant |
kitty |
| install is a repo script, lands in a pm | variant keyed by that pm, command: 'file:...'
|
anydesk |
| truth isn't in a package db | override check (two-mode script) |
virtualization |
| no package manager at all |
script variant + program version
|
ngrok |
Longer install logic goes in a repo script referenced with the file:
prefix β its existence is validated on every manifest load, on every
machine, so a typo fails immediately instead of on the one fresh machine
that needed it.
loadout outdated asks the installers' oracles β and anything else you
declare. An outdated: entry runs one command that prints an
<item> <current> <candidate> [noteβ¦] line per outdated thing; the rows
join the table tagged with the entry's name, and the optional note tail
renders as a dim annotation (21 commit(s) behind). A URL in that tail
becomes the row's link: the home screen marks it β and K opens it in
your browser β the live oracles print the GitHub compare page for the two
shas, so K on a plugin row shows exactly the commits you don't have yet,
the way K does in lazy.nvim:
# configs/private_dot_config/tmux/.loadout.yaml β beside tmux.conf
outdated:
tmux-plugins:
command: 'file:.loadout/outdated-plugins.sh'
upgrade: 'file:.loadout/outdated-plugins.sh update {item}'The optional upgrade is what makes a source's rows actionable: loadout
runs it once per item you pick, {item} being the row's name β the
opposite shape from an installer's whole-mechanism sweep, because a pin in a
file or a plugin clone is independent of its neighbours and one failure
shouldn't take the rest down. Leave it out and the rows stay read-only
([β] on the home screen) β the source reports, and the fix stays yours.
loadout upgrade --item <source>/<name> runs the same update from the
command line. When another tool owns the thing, hand the update to that tool
rather than writing its files behind its back: the live repo's
nvim-plugins
asks lazy.nvim to move one plugin, so Lazy remains the only writer of
lazy-lock.json. Unlike command, whose output is the contract,
upgrade is judged by its exit code alone β print what you like.
Oracles are a different species from programs and scripts: questions
about the outside world, not part of any machine's loadout β no machine
opts into them, no check observes them, no state records them. Their
answers appear only in outdated (remote candidates), never in status
(machine drift). They live with the thing they ask about β tmux's beside
tmux's config, the Omarchy plugins' with the plugin script. One rule governs
what a source prints: only outdated items speak β current, offline, or
not-set-up-here all mean silence.
The live repo runs a whole family of them, one per pinned surface of the dotfiles β each is a worked example of a pattern you can copy:
| Question | Pattern | update {item} |
Live example |
|---|---|---|---|
| version pins vs upstream releases | list the tool's versions, keep the pin's major, newest of those (mise latest alone jumps majors) |
install it, rewrite the pin | mise-tools |
| local clones vs their remotes | fetch each clone, rev-list --count for the note |
git pull --ff-only in that clone, or the owning tool's own update |
tmux-plugins Β· antidote Β· antidote-plugins Β· omarchy-plugins |
| pins another tool owns | ask that tool (headless nvim β lazy.nvim's own check) | ask it to update that one plugin, so it keeps writing its own lockfile |
nvim-plugins (outdated and update modes of the maintain script: one headless-nvim helper serves every question) |
| a binary no package manager knows | the vendor's "latest" endpoint vs --version
|
the binary's own updater | claude |
Every script is two-mode: with no arguments it is the oracle, with
update <item> it is the upgrade β one file per concern, so the question
and its answer can't drift apart. The last row is the pattern for any
program installed by a script variant (recipe 7): its installer has no
oracle, so the program itself gets an outdated: block in its own file
(command prints the newest version, read with the program's version
regex; upgrade updates that program alone, run as loadout upgrade <program>)
β the same shape as loadout's own self-check. The clone-walking oracles are thin
wrappers over a shared
git-clones-behind.sh
engine (reached as "$LOADOUT_REPO/maintenance/lib/git-clones-behind.sh"):
fetch every clone in parallel, print a row only when it is behind its remote
tip (provably ahead-or-equal clones stay silent), annotate with the
commits-behind count when history can answer, and fast-forward exactly the
clone named on update. (omarchy-plugins updates through omarchy plugin update instead, which also validates the plugin and reloads the shell.)
In every case the pinned shas and versions are intent, so upstream
movement is a candidate, not drift β which is exactly the outdated/status
boundary.
A loadout isn't only packages. Dotfiles, service enablement, installing
mise's pinned versions β anything with nothing to "have" is a scripts
step, and the rule is the same: it must carry a check, because the check
is the only way to observe it.
# configs/private_dot_config/chezmoi/.loadout.yaml β paths relative to it
scripts:
dotfiles-pull:
description: pull dotfiles source when behind its origin
file: '.loadout/pull.sh'
check: 'file:.loadout/pull.sh check'
modes: [maintain]-
Machines opt in per script, in a program's
scriptslist, or in a script-only key's list (see section 5); entries can carry arguments (setup-ssh id_ed25519), which reach the script and its check as positional parameters. A long list of arguments can span lines with a folded string:shell: scripts: - >- omarchy-plugins https://github.com/evindor/keystroke.git https://github.com/jankeesvw/omarchy-notification-center.git
-
modesscopes execution surfaces:[setup]for one-time bootstraps that should never clutter the home screen's scripts picker,[maintain]for recurring upkeep thatsetup-new-machineshouldn't run, omitted for both.statusobserves everything opted-in regardless. -
A script runs in its own file's folder, with
LOADOUT_REPO,LOADOUT_FRAGMENT_DIR,LOADOUT_MACHINEandLOADOUT_OSset, so it reads its siblings by name and a shared helper by"$LOADOUT_REPO/maintenance/lib/β¦". -
Split concerns by their truth. The live dotfiles checkout's git side is two scripts β pull (am I behind the remote? β throttled fetch, offline-safe) and push (does the remote lack anything from here?) β because those are two different questions with two different converges. Whether the files match is loadout's own question: configs. mise-tools answers one more: is every version mise's config pins installed? A pin moved on another machine arrives here as one missing version, installed by one script. Every git-clone family follows the same rule β the disk must match its own configuration file: nvim-plugins (every plugin the spec enables, at its
lazy-lock.jsoncommit β asked of Lazy itself, since acond = falseplugin stays in the lock but is never cloned), tmux-plugins and zsh-plugins (no lockfile there, so presence: every declared plugin or bundle has a clone). Whether a clone is behind its remote is a different question, and stays with the oracles.
A machine file lists the programs it carries under the install variant
each uses (install_with), and opts into scripts. Machines of the same kind mostly carry the same loadout, so the shared part
lives in profiles β small files, one subject each β and a machine
extends a list of them, keeping only its differences:
# profiles/omarchy.yaml β every Omarchy machine
data:
omarchy: true
install_with:
omarchy:
- fzf
- ripgrep
- starship
brew-cask:
- tpack
zsh:
install_with: omarchy
scripts:
- default-shell
- zsh-plugins
tmux:
install_with: omarchy
scripts:
- tmux-plugins# machines/t2-macbook-omarchy.yaml β this machine's additions
extends: [omarchy]
install_with:
omarchy:
- t2fanrd
ssh:
scripts:
- setup-ssh id_ed25519
data:
kitty:
opacity: 0.99- The top-level
install_withlists programs under the variant each uses, a key of itsinstallmapping: one list per installer instead of a two-line entry per program. A machine maps every program it carries; a program it doesn't map isn't in its loadout. - Any other key is a program, or just a label (
ssh) when it only holdsscripts. A program that opts into scripts carries its owninstall_with: <key>there instead (zshabove), so everything about it reads in one place. Naming a program's variant twice, in both places or under two variants, is a load error. - The machine wins over its profiles:
install_withper program, scripts by name,datatable by table. Two profiles of oneextendslist setting the same thing differently is an error, so their order never changes the result. Profiles may extend profiles; machines extend profiles only. There is deliberately no subtraction: a profile's entry is a promise every machine extending it keeps. -
data:holds per-machine settings your dotfile templates read. Every key is declared with its default inloadout.yaml'sdata:, so a typo in a machine file is a load error instead of a silently missing setting (Repo layout).
Live: profiles/omarchy.yaml Β· profiles/macos.yaml Β· profiles/fedora.yaml Β· a three-line machine.
-
Fragments go wherever
layout.fragmentssays. A file named<name>.yamlis one program, named by its file, as in section 1. A*.loadout.yamlfile holds named sections instead βinstallers:,programs:,scripts:andoutdated:β which is how one file carries several programs. The live repo puts a tool's fragment beside its dotfiles (configs/private_dot_config/tmux/), config-less programs inprograms/and config-less scripts inmaintenance/, one folder per concern. Everything merges before validation; duplicates are errors naming the file. See Repo layout. -
Version floor: when you adopt a manifest feature that needs a newer
loadout, bump
meta.min-tool-versioninloadout.yamlin the same commit. Older binaries refuse the whole repo with the fix in the error (run: loadout self-upgrade) instead of silently misreading it.
Every capability above is the same idea wearing different clothes: declare
the loadout, and give every piece a check the tool can re-ask. If you're
unsure where a check belongs β package database, version output, or a
hand-written script β the resolution chain decides for you: variant check
β installer check β program version, and only when none of those hold
the truth do you write one yourself.
Verify anything you write with:
$ loadout explain <name> # exactly what the engine resolved
$ loadout install <name> --dry-run
$ loadout status