A Day With Loadout - josemiguelo/loadout GitHub Wiki

A day with loadout

What living with declared loadouts actually looks like โ€” a composite day across a small fleet (an Omarchy laptop, a work Mac), using the author's live repo as the setting.

Morning: where does this machine stand?

Bare loadout on a terminal opens the home screen โ€” the question answered in five rows (four in a repo without dotfiles):

 loadout 2.0.0  โ”‚ t2-macbook-omarchy โ”‚ linux/omarchy ยท x86_64
  !   programs   59 installed ยท 1 missing                  โ†’ install what's missing
  !   scripts    44 done ยท 1 pending                         run what's pending
  !   configs    16 applied ยท 1 drifted                      apply what's drifted
  ยท   remote     โ ‡                                           review them
  !   fleet      13 program(s) drifted across 2 machines     compare the fleet
 โ†‘โ†“/jk move  ยท  [ ] jump to next list  ยท  l/โ†’ open  ยท  enter act on this line
 r re-check  ยท  S sync  ยท  U upgrade loadout  ยท  C set up this machine  ยท  t theme  ยท  q quit

It opens on the last observed state, then re-checks the machine (like status, a few seconds) and asks the remotes (like outdated) โ€” both land as they finish, the row spinning until its answer is in. l opens a row's detail in place (the missing programs, the scripts picker, the configs picker, the outdated table, the fleet diff); h closes it; enter acts โ€” it never opens or closes anything, so it can't surprise you: inside a picker (missing programs, maintenance scripts, drifted configs, outdated mechanisms) it asks first, then runs what you ticked in your terminal and brings the screen back as you left it. With nothing to open (remotes unreachable, fleet in sync) it says so and stays.

The screen takes the whole window, like any full-screen app, and so do the commands it runs. When a command finishes, one key decides: Enter goes back to the screen, v pages through everything the command printed (from its first line, even what scrolled off), q quits. Quitting gives your terminal back exactly as it was before you opened loadout.

โ†‘โ†“/jk walk the whole screen, open lists included: keep going up from a table's first row and you land on its own row, then on the one above it, with the table still open behind you. Open as many as you like โ€” a row with its list showing is marked โ–พ, and only h or esc on the one you're in closes it โ€” esc never leaves the screen, q does. Close a long table to read something else and l puts you back on the row you were on. pgup/pgdn move a page at a time, and when there is more than fits, the footer says where you are (1-24 of 57).

[ and ] go over the rows instead of through them, landing on the next thing that heads a list: inside the remote table that's the next group โ€” brew, then mise-tools, then antidote-plugins โ€” and once its groups run out, the row below.

The capitals are machine-wide verbs that also run in your terminal: S sync, C set up this machine, U self-upgrade (after U, run loadout again to open the new version). Every row is a command's answer and every key runs a command, so the rest of this page is those commands.

t flips between the light and dark palettes when loadout reads your terminal wrong. Some terminals โ€” a tmux popup, for one โ€” never tell anyone their background colour, and there loadout would get it wrong every time. Tell it once instead:

export LOADOUT_THEME=light   # or dark

The status behind the programs and scripts rows:

$ loadout status
machine t2-macbook-omarchy โ”‚ linux/omarchy โ”‚ x86_64

  PROGRAM               STATUS       VERSION
  โœ”  git                installed    2.55.0
  โœ”  kitty              installed    0.48.2
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ โœ˜  workmux            missing      -       โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

  SCRIPT                STATUS
  โœ”  dotfiles-push      done
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ โœ˜  dotfiles-pull      pending                                 โ”‚
โ”‚      missing: dotfiles source is 2 commit(s) behind origin    โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ
  โœ”  setup-git          done

  CONFIG                STATUS
  โœ”  kitty              applied
โ•ญโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ โœ˜  zsh                drifted โ”‚
โ”‚      .config/zsh/.zshrc       โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

State written to โ€ฆ/state/t2-macbook-omarchy.json

Rows that aren't settled are boxed, amber for "not yet", red for "missing" โ€” consecutive ones share a box, and a failing check's explanation rides inside the box with the row it explains. diff does the same for drift across the fleet.

The CONFIG table is your dotfiles, when the repo holds them (see Repo layout): a config is drifted when loadout apply would change one of its files, and the box lists which.

A third verdict exists for the case where loadout could not ask at all. If the tool a check goes through isn't there โ€” brew missing from this shell's PATH, say โ€” those programs are not checked, never "missing", and one line after the table says why:

 โš   brew is not on PATH โ€” 22 program(s) not checked  (atuin, bat, fd, โ€ฆ)

The difference matters: "missing" would offer to install 22 things that are already installed.

One table answers "is this machine carrying its declared loadout?" โ€” every program's check re-asked (concurrently, a few seconds), every script's check too, with failing checks explaining what exactly is off. Nothing here touched the network except the checks you explicitly wrote to (the dotfiles-pull fetch is throttled to once per 6h and silent offline). The state file is written as a side effect โ€” that's the machine publishing its reality.

Two kinds of drift just surfaced: a program this machine should carry but doesn't (workmux was added to the fleet's loadout yesterday, on the other machine), and dotfiles edited elsewhere that haven't landed here.

Converge it

The missing program โ€” l on the programs row lists it with what would install it, ticked; enter plans it (dependencies first, like install) and, on your yes, installs it in your terminal:

  โœ˜   programs   59 installed ยท 1 missing                  โ–พ install what's missing
        โฏ [x] โœ˜ workmux  [brew]  brew install raine/workmux/workmux

From a shell, the same act is the targeted installer:

$ loadout install workmux
Plan for t2-macbook-omarchy:
  = tmux      3.7
  + workmux   [brew] brew install raine/workmux/workmux

Proceed? [y/N] y

The drifted scripts โ€” the home screen's scripts row. l opens it as a picker over this machine's maintenance loadout (setup-only bootstraps never appear), each script with the verdict the last observation wrote down and the unfinished ones already ticked:

  !   scripts    44 done ยท 1 pending                       โ–พ run what's pending
          [ ] โœ” setup-git         done
        โฏ [x] ! dotfiles-pull     pending
          [ ] โœ” dotfiles-push     done

space ticks or unticks the focused script โ€” a done one too, which is how you force a re-run โ€” a ticks them all, u unticks them all, and enter asks before running them in manifest order (dotfiles-push is declared after dotfiles-pull, so ticking both pulls, then pushes):

โ•ญโ”€ run this? โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ These scripts will run, in order:                โ”‚
โ”‚                                                  โ”‚
โ”‚ dotfiles-pull   Pull the dotfiles repo           โ”‚
โ”‚                                                  โ”‚
โ”‚              Run        Cancel                   โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

โ†/โ†’ pick a button and enter presses it; y runs, n or esc cancels. Run hands your terminal to loadout run dotfiles-pull --force:

โ”โ” loadout run dotfiles-pull --force โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
 โœ”  ran dotfiles-pull (exit 0)
State updated.
 !  still not done: dotfiles-pull (missing: dotfiles source is 1 commit(s) behind origin)
โ”โ” โœ” done โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
enter returns to loadout ยท v views the full output ยท q quits

The check decides, not the exit code: a script that still fails its check is named, with what the check printed. Enter brings the screen back with the row updated.

Then publish, so the fleet sees this machine is whole again:

$ loadout sync      # pull --rebase โ†’ apply configs โ†’ refresh state โ†’ commit ONLY state/<me>.json โ†’ push

sync is cron-safe: when nothing changed, nothing is committed. A config file you edited on this machine stops it before anything is written, since only you know which version wins:

error: 1 config file(s) edited on this machine: .config/zsh/.zshrc โ€” keep your edits with `chezmoi re-add`, or replace them with `loadout apply --force`

Both converge commands take --all when you want the whole membership rather than names: loadout install --all installs every program this machine maps (no scripts โ€” that's setup-new-machine's job), and loadout run --all runs every script it opted into, still check-gated. Neither upgrades anything: converging installs what's missing, and moving an installed program to a newer version is a different verb โ€” upgrade, in the weekly section below.

Adding gear to the loadout

A new tool crosses your feed. The loop, start to finish:

  1. Declare it โ€” one fragment, usually a few lines (workmux's is a handful of lines: a description, a depends-on, and a tap-qualified brew command; check and probe derive from the installer).

  2. Equip the fleet โ€” list it under brew in the install_with of the profile(s) it belongs to: profiles/omarchy.yaml and profiles/macos.yaml. Every current and future machine extending them now carries it.

  3. Trust nothing โ€” loadout explain workmux shows exactly what the engine resolved (including whether its installer is one of loadout's built-ins or your repo's own โ€” loadout installers brew prints the mechanics either way); loadout install workmux --dry-run shows exactly what would run.

    $ loadout explain workmux
    program workmux  โ€” git worktrees + tmux windows for parallel agent sessions
      depends-on    tmux, brew-taps
      install.brew  sh "$LOADOUT_REPO/programs/brew/brew.sh" install raine/workmux/workmux  [installer: brew (repo)]   <- t2-macbook-omarchy
      observed      missing  (state/t2-macbook-omarchy.json)
    
  4. loadout install workmux, then commit the manifest change. Other machines pick it up at their next sync and show it missing until they converge โ€” visible drift, never silent divergence.

Weekly-ish: what do the remotes offer?

$ loadout outdated
 โ†‘  omarchy  168 updates ยท 2 in your loadout  loadout upgrade omarchy  โ†’  omarchy update -y
 โ†‘  brew     6 updates ยท 4 in your loadout    loadout upgrade brew  โ†’  brew upgrade

 PROGRAM   CURRENT  CANDIDATE  SOURCE
 โ†‘  brave  1.82.1   -> 1.82.3  [omarchy-aur]
 โ†‘  kitty  0.48.2   -> 0.49.0  [omarchy]
 โ†‘  loadout  2.0.0  -> 2.0.1   [release]

outdated is the one command whose job is asking the outside world โ€” each installer's batch oracle in a single call, your repo's own custom outdated sources, plus the tool itself as a row (loadout is part of the loadout). The top lines count each package manager's whole answer, and the table lists only what your loadout declares. Converging never moves these: a newer version of something already installed is not setup-new-machine's or install --all's job, since both only install what's missing. Status also nags in its footer when the binary falls behind, so even this is hard to miss.

Acting on it

Programs move a whole mechanism at a time:

$ loadout upgrade omarchy omarchy-aur brew
Upgrade on t2-macbook-omarchy:
  โ†‘ [omarchy] omarchy update -y
      covers 45 mapped program(s): 1password, anydesk, atuin, bat, โ€ฆ
  โ†‘ [brew] brew upgrade
      covers 6 mapped program(s): awscli, just, scrcpy, โ€ฆ

  This upgrades everything each mechanism manages โ€” including packages loadout doesn't declare.
Proceed? [y/N]

There is deliberately no loadout upgrade kitty: partial upgrades are unsupported on Arch, discouraged on Fedora and pointless elsewhere, since the package manager resolves its own transaction anyway โ€” naming a program is an error pointing at its mechanism. Mechanisms that share a command (omarchy and omarchy-aur both run omarchy update -y) are one step, named by the tool you know โ€” [omarchy]; --all takes every mechanism this machine maps, and only those โ€” a machine whose loadout never touches brew can't run brew upgrade by accident. Afterwards every mapped program is re-checked and the state file rewritten, because the transaction moved what it moved.

Custom sources go the other way. A pin in mise's config or a plugin clone shares nothing with its neighbours, so a source that declares upgrade = "... {item}" updates one row at a time โ€” the mise-tools oracle moves a single pin to the newest release within its major (ruby 3.4.8 โ†’ 3.4.10, never 4.x โ€” crossing a major is a decision, not an update). When another tool owns the pins, the row hands the update back to it: the live repo's nvim-plugins asks lazy.nvim to move one plugin, lockfile and all. A source with no upgrade at all keeps read-only rows โ€” it reports, and the fix is yours.

The home screen puts both under one table, grouped by what will act. Open remote with l: each package manager gets a line with everything it reported โ€” not only your programs โ€” and the exact sweep it would run; your declared programs sit under it, and an amber line names what else the sweep would touch โ€” enter on it opens the whole list in a scrollable pane. That line is the point: ticking omarchy means those 168, and the screen says so before you press enter. Custom sources follow, one tick per item or the heading's tick for all of them at once; rows that nothing can move show [โ€“]. space ticks, a ticks everything, u clears:

  !   remote     omarchy 168 ยท brew 6 ยท 3 more ยท mise-tools 2  โ–พ review them
        โฏ [x] omarchy   168 updates ยท 2 in your loadout    omarchy update -y
              โ†‘ brave     1.82.1   -> 1.82.3
              โ†‘ kitty     0.48.2   -> 0.49.0
              + 166 more not in your loadout ยท enter lists them
          [ ] brew      6 updates ยท 4 in your loadout      brew upgrade
              โ†‘ just      1.40.0   -> 1.41.0
              โ†‘ yq        4.47.1   -> 4.47.2
              + 2 more not in your loadout ยท enter lists them
          [ ] tmux-plugins
            [ ] โ†‘ tpack     27172d5  -> 1f8b694   2 commit(s) behind  โ†—
          [ ] mise-tools
            [x] โ†‘ ruby      3.4.8    -> 3.4.10
            [ ] โ†‘ node      22.11.0  -> 22.12.0

          [ ] nvim-plugins
            [ ] โ†‘ gitsigns.nvim  5eb287f  -> 8d79f24   2 commit(s)  โ†—

The remote line leads with the managers that have work, because each of those is one command; everything that takes an update of its own is counted after it.

A row marked โ†— has a page about the change โ€” for a git plugin, the GitHub compare of the two commits โ€” and K opens it in your browser before you decide, the way K does in lazy.nvim. A long table folds: h on any row of a group tucks it behind its heading (omarchy โ–ธ), l on the heading opens it again, and h again on a folded heading closes the whole remote row โ€” as does esc from anywhere in the table.

loadout outdated prints the same per-tool lines first, so the shell answer to "how far behind is this machine, all of it?" is one command.

Enter asks first, one line per step:

โ•ญโ”€ run this? โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฎ
โ”‚ These will upgrade, in order:                    โ”‚
โ”‚                                                  โ”‚
โ”‚ omarchy     all 168 packages ยท 2 in your loadout โ”‚
โ”‚ mise-tools  ruby 3.4.8 โ†’ 3.4.10                  โ”‚
โ”‚                                                  โ”‚
โ”‚              Run        Cancel                   โ”‚
โ•ฐโ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ•ฏ

Run hands your terminal to loadout upgrade (a custom source's rows go as --item mise-tools/ruby), which prints the exact commands first:

โ”โ” loadout upgrade omarchy-aur omarchy --item mise-tools/ruby --yes โ”โ”โ”โ”โ”โ”โ”โ”
Upgrade on t2-macbook-omarchy:
  โ†‘ [omarchy] omarchy update -y
      covers 45 mapped program(s): 1password, anydesk, atuin, bat, โ€ฆ
  โ†‘ [mise-tools: ruby] sh '.loadout/outdated-tools.sh' update ruby

A step needs your sudo password.
[sudo] password for jose:

==> upgrading omarchy
โ€ฆ
==> upgrading mise-tools: ruby
โ€ฆ
 โœ”  2 declared program(s) changed version
      brave  1.82.1 -> 1.82.3
      kitty  0.48.2 -> 0.49.0
โ”โ” โœ” done โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”โ”
enter returns to loadout ยท v views the full output ยท q quits

It's a real terminal: sudo asks for its password, once, before the first step that needs it, and a package manager's own questions (a reboot prompt, a conflict) wait for your answer. Ctrl-C stops the command, not loadout. v pages through the whole output; Enter returns to the screen as you left it, with the upgraded ticks cleared and the remote row asking again.

The tool itself is loadout self-upgrade (or U on the home screen): it needs no repo, so it works even when a version floor refuses everything else.

New machine day

The payoff for all the declaring. On a fresh Mac:

$ curl -fsSL https://raw.githubusercontent.com/josemiguelo/loadout/master/install.sh | sh
$ git clone [email protected]:you/dotfiles.git ~/.local/share/chezmoi
$ export LOADOUT_REPO=$HOME/.local/share/chezmoi     # into your shell profile
$ echo 'extends: [macos]' > $LOADOUT_REPO/machines/$(hostname).yaml
$ loadout setup-new-machine
$ loadout sync

One line of config, because the machine is standard issue: its whole loadout comes from the macos profile. setup-new-machine refuses to start if anything is unmapped or a required package manager is missing โ€” errors before actions, with the fix in the message. Setup-only bootstraps (like the first dotfiles clone) run here and then never bother you again.

When the fleet needs a newer tool

Raising min-tool-version in the repo is how you roll the fleet forward deliberately โ€” a repo that leans on a newer binary's built-in installers, or on any newer manifest feature, declares the floor in the same commit. A machine that pulls the new floor with an old binary refuses everything with one instruction โ€” run: loadout self-upgrade โ€” and that command needs no repo, so it works even mid-refusal. Machines that keep up never see the refusal at all: the status footer and outdated had already nagged them past it.

The loop, compressed

declare (edit repo, commit)  โ†’  converge (setup-new-machine / install / run)
        โ†‘                                          โ†“
    diff (fleet view)   โ†   publish (sync)   โ†   observe (status)  โ†  upgrade (outdated โ†’ upgrade)

Bare loadout is that loop as a screen: observe on open, converge or upgrade on enter, publish on S.

Every arrow is explicit, every state is inspectable, and every "is it really?" has a check answering it. That's the whole idea: a machine is never mysterious โ€” it's just carrying its loadout, or visibly not.