Skip to content

Management Interface

Drive this repository through the dotfiles CLI, which works from any directory. task keeps what belongs to working in the repo — setup, the test suite and the docs site — and never renames a dotfiles command, so one operation has one name. The grammar is noun-verb with three reconcile verbs, Terraform-shaped, and all three sit at the top level and again under each resource, so narrowing to one part of the machine is the same sentence with a noun in it.

Why there is a CLI at all

A standalone dotfiles CLI was considered in July 2026 and deliberately not built, on the grounds that it would "only re-wrap existing scripts for a cosmetic rename — maintenance cost with no capability gain." That reasoning was reversed, because two capabilities turned out to be missing rather than merely renamed.

task cannot run from outside the repository. Task discovers Taskfile.yml by walking up from the working directory, so every management action was gated behind a cd. That is structural rather than cosmetic, and no amount of Taskfile work fixes it.

update.sh had no argument surface at all. Its final line was a bare main. Nothing could skip the sudo-gated system phase or refresh only personal tools. Selective update did not exist anywhere in the repository.

The constraint that kept the tool in bash is real and unchanged: a separately-distributed binary could never work on its own, because the CLI's job is invoking things that exist only inside the cloned repo. uv tool install --editable satisfies that constraint. The installed tool is the checkout, so there is no distribution channel to buy and nothing to keep in sync.

check answers a different question from plan

A package a version behind is drift: expected, benign, and exactly what apply is for. A machine-local value nobody set, a file only safekeep can restore, a declaration that will not validate, a checker that crashed — those need attention.

One verb answering both meant one exit code carrying both, and the scheduled unit sat permanently failed on a machine whose only fault was being a version behind. The distinction was already in the data: Repair says who can fix a change, so plan keeps what apply can fix and check keeps what it cannot. ExitCode in src/dotfiles/vocabulary.py says why three is separate from one. The periodic timer runs check and leaves its verdict in the state file.

The split does not reach the network, though. All three verbs measure: they ask GitHub what each declared release is at, the package managers what they are holding back, and every plugin clone whether its remote has moved. Being invoked is the reason to give a current answer. --cached declines the lot, and commands.MEASURES_UPSTREAM argues why that is the flag rather than the default.

The resource doors say the same thing. dotfiles system plan and dotfiles plugins plan take the flag and default to measuring exactly as the composite verbs do, because a narrower door answering from a cache while the wide one measures is two front doors disagreeing about one dataset.

How a run renders on screen is src/dotfiles/output.py, which argues its own choices. None of that reaches --json, which a caller parses instead of the screen; Observability has its shape.

The bootstrap prints two commands and runs neither

./install.sh --machine NAME gets a bare machine as far as this CLI, and then prints the plan and apply that converge it. It validates the manifest name itself — the only check in the system that does — because the CLI that would answer the question does not exist yet.

Printing rather than running is deliberate. Ending in exec dotfiles apply meant a bare ./install.sh on a WSL box whose ~/.env already named it went straight into a half-hour networked run nobody had asked to start, and stalled on a blocked cargo download behind the work firewall. Getting the CLI onto a machine answers "can this box run any of this at all". Converging it answers "what should this box become". One question is cheap and always right; the other is long and worth planning first. Fusing them means the cheap one cannot be asked alone, and the expensive one cannot be declined. --offline stays on the bootstrap, because it decides where uv and the wheels come from, and that is settled before any CLI exists to be told.

dotfiles update updates this installation

update means here what it means for every other tool in the fleet: update this tool. It pulls --ff-only and repairs the two things a pull can invalidate, and src/dotfiles/commands/manage.py holds that sequence. An editable install goes stale only on a changed pyproject.toml or uv.lock, because uv points at the working tree, so code changes are the new code and never need a rebuild.

Repairs start from the last commit update repaired, not from its own pull. Commits reach the checkout before update runs by three routes: a commit made in it, a scheduled job, or a hand-run git pull. Measured from its own pull, update then had nothing to do, and the machine ran those commits unrepaired. The commit lives in $XDG_STATE_HOME/dotfiles/repaired-<host>.json, beside the check status. Rejected: falling back to the HEAD before the pull on a machine with no record. That assumes everything before it was repaired, so it keeps the gap open once on every machine.

The output lists every change since the last update as one set, and never says which route a commit took. The route is how update keeps its record, and nothing a reader does depends on it. Rejected: streaming git's own output. Its Already up to date. sits directly above a list of changes and reads as a contradiction, so the pull runs --quiet and update reports the outcome itself.

pyselfupdate is deliberately not used, and its refusal is correct rather than a gap. It declines to reinstall over a directory/path/editable requirement, which is the right answer. This repo publishes no releases, its source of truth is the working tree, and installing a release over it would destroy the thing being updated. File nothing to add releases here on the strength of it.

For the same reason there is no update notice: there is no published version to compare against. Both read-only verbs end with the honest equivalent — where the checkout sits against the last-fetched origin/main, and how long ago that fetch was. src/dotfiles/checkout.py reads .git and never the network, so the answer is free at a prompt and correct offline, which is why it dates itself.

The machine environment (~/.env)

~/.env is the first thing .zshrc sources, and where every command not given --machine learns which machine it is on. It answers which machine this is (MACHINE) and which features it wants running (the flags from install/flags.yml), and it carries secrets that must never be checked in. dotfiles env maintains it at the first stage of every apply, and src/dotfiles/envfile.py holds the OVERRIDES split that makes regenerating a file full of API tokens safe.

MACHINE names a manifest and the resolver derives everything else from it. No coordinate reaches this file, for the reasons the architecture overview gives.

Placing this file by hand is therefore the whole of the pre-install bootstrap. session.resolve_machine reads the file when $MACHINE is unset, so a rebuild that restores ~/.env first never needs --machine on anything. Reading the file rather than only the environment is what makes that work outside a login shell: a scheduled check inherits no ~/.env at all.

That bootstrap is genuinely circular, since the manifest ~/.env names is what generates ~/.env. A bare machine therefore has one line to type by hand, and only MACHINE=; the first dotfiles apply --machine NAME fills in the rest. Hand-authoring the whole file is what left NVIM_AI_ENABLED set on every machine and read by nothing, with no way to say which had drifted.

Which gates are flags

A flag is for behavior that is present and cheap, where the only question is whether this machine wants it running. Payload that is expensive to install stays a manifest tool list. Config a program discovers by path and cannot branch on (hyprland, waybar, ghostty) stays a coordinate variant under configs/. A command -v fzf guard is not a flag and should not become one, because fzf has no reason to be installed and unwanted. A gate earns a flag only when installed but off is a state worth having.

The shell plugins qualify, because they carry both a preference and a real startup cost. The manifests use that: linux-lxc-server and scheduler turn off the whole interactive plugin set, because a box that exists to be SSHed into for diagnosis wants a fast shell rather than a personal one. The plugins are still installed there, so turning one back on mid-debugging is a ~/.env edit rather than a reinstall. What each flag costs on this machine is zsh-startup flags.

A plugin must be sourced at top level, never from inside a helper function. A function scope changes what a plugin's own typeset calls do, so the loads stay inline and slightly repetitive rather than factored into a load_plugin helper. The other ordering hazard is zsh-vi-mode overwriting the whole keymap, and .zshrc explains beside apply_shell_keybindings why that binding lives in a named function with two callers.

Beyond the shell

Neovim reads none of this vocabulary, and the gate it had is the argument for keeping the rest small. core/profiles.lua derived a minimal plugin set from DOTFILES_CAPACITY == 'server', with NVIM_PROFILE as the override. Before the axes split it read PLATFORM == 'linux', which was the right answer for the wrong reason — linux happened to be the one headless platform, so a graphical Ubuntu desktop would have got the lean set.

Following the coordinate was the correct fix and the profile still did not earn its keep. Exactly one machine ever reached it, the LXC, and the price was a hand-kept allowlist of editing essentials maintained forever for one box nobody sits at. Deleting it leaves one question — whether Neovim is embedded in VSCode — which no coordinate answers, because it is a fact about the process rather than about the machine.

plugins/typos.lua is the counter-example, and worth keeping in mind before reaching for this vocabulary at all. It was PLATFORM ~= 'wsl', then MACHINE_ROLE ~= 'work', then a test for ~/notes — each one a label standing in for a condition that turned out not to need expressing. It is an unconditional remote spec: capture is scoped to notes_root, so a machine without one writes nothing, and setup() creates no directories. The gate existed only because a dir = local checkout errors on every startup when the directory is absent, which is a fact about local specs rather than about machines.

core/options.lua keeps its check and spells it WSL_DISTRO_NAME, which WSL sets itself. It read DOTFILES_HOST == 'wsl' first, on the reasoning that win32yank genuinely is a fact about the host rather than a stand-in for one — true, and still the wrong source, because a declared coordinate is a second description of something the runtime already states, and tmux.conf was solving the identical problem the direct way three files over. The test survives the correction and gains a clause. If the condition can be stated as something the code needs, state that. If the machine already announces it, read the announcement. Reach for a coordinate only when the difference really is which kind of machine this is, and no process on it says so.

tmux gets nothing. Every conditional in tmux.conf is real runtime detection — is this pane running vim, is $WSL_DISTRO_NAME set, does the theme file exist, is tpm cloned — and none of them is a preference in disguise. A flag with no consumer is how NVIM_AI_ENABLED happened, so the mechanism waits until something needs it.

Selective installs and updates

There is no phase registry. dotfiles apply measures the whole plan, then acts on it in Stage order. Keeping a hand-written order beside that is what let system/manager, the OS package upgrade, sit at a stage no phase named and never run at all.

A row can read what an earlier stage of the same run writes, so a stage is measured again once an earlier one has changed the machine. Without that, a fresh fleet machine's first apply installed no check timer: the switch for it is in a config the symlink pass deploys, and the row was measured before the pass ran. Having that one row read the repo's copy of the config instead of the deployed one was rejected. It fixes the row and leaves the next row that reads an earlier stage's output to install one apply late.

A selector is a resource, or one provider inside one, and dotfiles apply --help lists what each flag takes. Four hand-maintained groups (system, languages, tools, plugins) were the rejected alternative, because their membership was a fifth list to keep in step. A resource and a provider are what the provider registry already knows, so there is nothing to maintain. The one group worth its own name is system, because it needs sudo and dominates the runtime, and --skip system is that group derived rather than declared.

Every selector reads as well as it writes. plan answers "what would apply change", so a scope the write accepts and the read cannot express is not a narrower preview but no preview at all. For a while apply could be narrowed to one section with no way to rehearse it, which is the case a preview is worth most in. What stays write-only describes how to write rather than what to cover: installing from a staged bundle, or forcing an entry past what measuring concluded. tests/cli/test_conformance.py asserts the split across the whole tree, because nothing else notices a selector added to one verb and forgotten on its sibling.

Narrowing belongs to the resolver rather than to each installer. A hand-rolled filter block per installer is how --mine came to run cargo, uv and npm in full while claiming to filter, because only the Go one honored the owner. The rules that fall out of doing it once are Selection in src/dotfiles/engine.py: why a misspelt address is a usage error, why the narrowing is per resource, and why a section brings the runtime it declared it needs.

Ownership is derived from whichever field carries it — repo, github_repo, or a Go import path in package — never from a personal tag, because a tag has to be remembered on every new tool and silently excludes whatever it misses. --owner matters most in practice, because a newly released personal tool has to be installed before any self-updater can maintain it, and those tools span four sections at once.

Scope and force are different flags. --entry narrows and --reinstall overrules what measuring concluded. One flag carrying both — --reinstall lazygit — puts a scope somewhere no other resource can see, so none of them can honor the force. Bare, --reinstall covers the whole run, which is expensive and not dangerous. Scope is structural — the argument's presence selects it, never a flag — and an absent argument is what sanctions a set-wide act.

The narrowing is spelled for the rows it names. One filter matches a row's name in any resource. Those rows are packages, runtimes, group memberships, systemd units, managed files and steps. So plan, apply, toolchains and system take --entry, and only the packages noun says --package, because every row it reaches is one. A toolchain row is a runtime the registry derives from the tool sections, and two of the four have no declared row at all, so it is an entry rather than a package. Rejected: one spelling everywhere, which makes system apply --package etc-hosts-sudo-grant call a step a package. No hidden alias keeps the other spelling, because a tool with one operator takes a clean break over a deprecation window.

Install and update are one act

There is no update verb for the machine. apply installs what is missing and reinstalls what differs from the newest release, and which of those a given row needs is the verdict's answer rather than the caller's. Differs, not is behind — src/dotfiles/resources/packages.py holds why a version above the newest release is drift too, and what a downward re-version stranded.

Whether an update installs a newly declared tool must not depend on which section of packages.yml it was added to. go install @latest, cargo binstall and the release installers all create as a side effect of upgrading, while uv tool upgrade and <tool> update cannot. A MISSING row and a STALE row are different verdicts with different repairs, and both are reported before either is acted on.

dotfiles packages check answers that on demand, and is deliberately separate from dotfiles machines check, which runs on every commit: that one compares packages.yml against the manifests and what can install them, and a machine part-way through a rollout is not a repo defect that should fail a commit.

What a provider is allowed to claim

A per-tool line must be derived from observed state: a version or ref that changed, or a non-zero exit. It may never be derived from "the command returned", because uv tool upgrade, cargo binstall, npm update -g and git pull --quiet all exit 0 whether or not anything changed. The Change supplies that, and its Outcome names what was repaired. The one place a before-and-after snapshot survives is clone.pull, which reports the commit either side, because git pull --quiet on a current clone prints nothing at all.

Where a tool already reports its own outcome accurately, src/dotfiles/providers/custom.py delegates rather than re-deriving one: theme and font run their own update and propagate the exit code. Matching a sentinel string against that output is the trap. It always missed, printing theme updated on every run, over an unconditional exit 0 that kept a genuine failure out of the report.

Logins are checked and never repaired

A converged machine is not necessarily a working one. Every binary can be installed, every symlink deployed and every flag set while the CLIs that do the work sit logged out. That has happened here, with learning, meso, nomad and atuin all unauthenticated and dotfiles check reporting a screen of converged rows. src/dotfiles/resources/auth.py closes that gap, and its module docstring carries the decisions inside it: why the manifest names which tools while the module says how each is asked, why every probe is local and which measurement picked it, and why a finding is Repair.BY_HAND that apply can never reach.

A machine that will never hold an atuin account does not declare one. auth: is the roster of logins a machine has to be able to make, so a permanent no belongs there rather than in a row explaining itself on every run. The nonfleet box is that machine: its copy of the config is the trust/nonfleet one, sync is off because a git-only node behind a corporate firewall is the wrong place to send shell history from, and scheduler.yml leaves atuin out for the same reason in its own words.

What lives in apps/ and what leaves it

The apps/ scripts (notes, packup, …) are independent user tools with their own identity and doit find discovery. Folding them into dotfiles <subcommand> would be a regression, not a consolidation.

An app leaves this directory when its data outgrows a file. patterns captured timestamped fragments into a per-machine JSONL that reached no other desk, and a tool for finding patterns over time needs every entry rather than one box's — so it became icb patterns against the ichrisbirch API. The test is the store, not the script: a file-backed app whose file has to roam is already an API client that has not been written yet.

Everything else is answered from the source: dotfiles --help names the resources, and each module under src/dotfiles/resources/ says how its resource is measured.