Management Interface¶
How you drive this repository: a dotfiles CLI that works from anywhere, a task
front door for work inside the repo, and one shared implementation underneath both.
Layering¶
Two front doors sit over the same scripts, so neither can drift from the other:
dotfiles <verb> task <verb>
\ /
install/ops/*.sh composite operations
update.sh / install.sh the two standalone drivers
symlinks/ · parse_packages.py · install/common/*
install/ops/ holds the operations that take more than one step — resolving the
platform, layering common under a platform overlay, ordering an unlink, running the
WSL shell sync, guarding a test suite, running mkdocs through uv. Anything that is
already a single command is invoked directly by both front doors with no wrapper.
Platform detection belongs to those scripts, which source
install/platform-detection.sh. Do not reintroduce it into Taskfile.yml.
The dotfiles CLI¶
apps/common/dotfiles, deployed to ~/.local/bin by the symlink manager along with
every other app. It resolves the repository by following its own symlink, so every
verb works from any directory.
dotfiles # help
dotfiles update [GROUP...] # see "Selective updates" below
dotfiles install --machine NAME
dotfiles link | relink # aliases for the two symlink verbs actually typed
dotfiles symlinks <verb> # link, relink, unlink, check, show
dotfiles doctor # broken symlinks + package-manifest drift
dotfiles test [SUITE] # all, unit, integration, watch
dotfiles docs <verb> # serve, build, deploy
dotfiles windows <verb> # WSL only: setup, bundle, offline, sync
dotfiles pull # pull, relinking when deployed files changed
dotfiles status | path | edit
dotfiles pull relinks automatically when the pulled diff touched apps/, configs/,
or shell/. A pull that adds a config without relinking leaves the machine stale, and
relink is idempotent, so there is no reason to defer it.
The command table at the top of the script is the single source of truth for both help
output and dispatch; tests/apps/dotfiles.bats fails if the two disagree.
First-run bootstrap on a bare machine still uses ./install.sh directly — the CLI's
symlink does not exist until the repo has been deployed once.
Selective updates¶
update.sh owns a registry of phases, each belonging to a group. The CLI passes its
arguments straight through, so task update -- --no-system behaves identically.
| Group | Contents | Notes |
|---|---|---|
system |
brew/mas, apt, pacman/yay/flatpak | Needs sudo, dominates the runtime |
languages |
Go toolchain, rustup, uv | The managers themselves |
tools |
go, cargo, uv, npm, GitHub releases, custom installers | The installed binaries |
plugins |
shell, tmux, Neovim |
dotfiles update # everything
dotfiles update tools plugins # named groups only
dotfiles update --no-system # skip the sudo-gated, slowest group
dotfiles update --mine # only tools owned by datapointchris
dotfiles update --list # every group and its phases
dotfiles update --dry-run
--mine narrows each phase to packages whose GitHub owner is datapointchris, and
skips the phases that have no owner to filter on rather than silently running them in
full. Ownership is derived from whichever field carries it — repo, github_repo, or a
Go import path in package — not from a personal tag, because a tag has to be
remembered on every new tool and silently excludes whatever it misses.
Updates are manifest-aware when MACHINE is set in ~/.env, so a machine only updates
what it actually installs. Failures are collected through run_installer into a report
printed at the end, the same machinery install.sh uses.
The phase registry is also the seam the unit tests use: sourcing update.sh exposes
selected_phase_names without running anything — main is guarded on
BASH_SOURCE[0] == $0 — so selection is tested without resolving package lists.
What a phase 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. Each of those phases snapshots the installed version
through install/common/lib/installed-versions.sh before and after, and reports
already at latest, updated: <before> → <after>, or a failure from the difference.
A phase-level line reports only that the phase completed, and is worded so — Homebrew
update completed, not Homebrew packages updated — because a system package manager
offers no cheap way to tell a no-op from real work.
Where a tool already reports its own outcome accurately, the installer delegates instead
of re-deriving one. theme.sh --update and font.sh --update run theme upgrade /
font upgrade, let their output through, and propagate the exit code. The earlier
version matched a sentinel string against their output and always missed, printing
theme upgraded on every run; it also ended in an unconditional exit 0, so a genuine
failure never reached the report.
Why there is a CLI now¶
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 later 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. This is structural, not cosmetic — no amount of Taskfile work fixes it.
update.sh had no argument surface at all. Its final line was a bare main, so
there was no way to skip the sudo-gated system phase or to refresh only your own tools.
Selective update did not exist anywhere in the repository.
It is bash, in this repository, rather than a Go binary with a release pipeline. The
CLI's entire job is invoking scripts that only exist inside the cloned repo, so a
separately-distributed binary could never function on its own — goreleaser and
goselfupdate would buy a distribution channel that cannot be used. git pull is
already the update mechanism, which is what dotfiles pull wraps.
Ownership¶
| Concern | Owner |
|---|---|
| Machine bootstrap | install.sh (--machine, sudo; inherently one-shot) |
| Updating | update.sh — phase registry, groups, manifest and owner filtering |
| Composite operations | install/ops/ — shared by both front doors |
| Symlink management | symlinks/cli.py |
| Package queries | install/parse_packages.py — types, manifests, owners |
| Manifest drift | packages verify |
| Tool discovery | toolbox (across all installed tools) |
| Cross-repo operations | forge |
The apps/ scripts (menu, notes, backmeup, safekeep, …) remain independent
user tools with their own identity and toolbox discovery. Folding them into
dotfiles <subcommand> would be a regression, not a consolidation.