Writing Your Manifest - josemiguelo/loadout GitHub Wiki

Writing your manifest

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.

1. The smallest possible loadout

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.

2. Installers: the mechanics, shipped

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.yaml

Mechanics 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.

Declaring your own

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/.

Commands that ask for sudo, or anything else

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: true

sudo: 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.

Mechanisms that need a value per program

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.

3. When a program deviates: variants

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 derives

The 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.

Custom outdated sources

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.

4. Scripts: the non-package half of a loadout

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 scripts list, 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
  • modes scopes execution surfaces: [setup] for one-time bootstraps that should never clutter the home screen's scripts picker, [maintain] for recurring upkeep that setup-new-machine shouldn't run, omitted for both. status observes everything opted-in regardless.

  • A script runs in its own file's folder, with LOADOUT_REPO, LOADOUT_FRAGMENT_DIR, LOADOUT_MACHINE and LOADOUT_OS set, 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.json commit β€” asked of Lazy itself, since a cond = false plugin 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.

5. Machines and profiles: loadouts shared per kind

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_with lists programs under the variant each uses, a key of its install mapping: 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 holds scripts. A program that opts into scripts carries its own install_with: <key> there instead (zsh above), 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_with per program, scripts by name, data table by table. Two profiles of one extends list 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 in loadout.yaml's data:, 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.

6. Growing without losing the plot

  • Fragments go wherever layout.fragments says. A file named <name>.yaml is one program, named by its file, as in section 1. A *.loadout.yaml file holds named sections instead β€” installers:, programs:, scripts: and outdated: β€” 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 in programs/ and config-less scripts in maintenance/, 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-version in loadout.yaml in the same commit. Older binaries refuse the whole repo with the fix in the error (run: loadout self-upgrade) instead of silently misreading it.

The invariant to keep

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
⚠️ **GitHub.com Fallback** ⚠️