DotCLI

A terminal-UI dotfiles manager in Go that treats each tool as a self-contained module, resolves inter-module dependencies in topological order, and installs the selection — packages, setup scripts, and dotfile symlinks — from a single keystroke.

Repository

What is it?

A single-binary terminal UI that turns a ~/dotfiles/modules/ directory into a dependency-aware installer. Each module is a self-contained folder — a config.yaml declaring its packages, custom commands, dotfile→home mappings, and its dependencies on other modules, plus an optional install.sh and a dotfiles/ subtree. The tool scans that directory, lets you browse and select modules in a Bubble Tea list, resolves the dependency graph into a topological install order, and then installs each module in turn: host packages through the detected package manager (brew/apt), the module’s install.sh, custom commands, and finally symlinks the dotfiles into $HOME. There is no database and no lockfile — the filesystem is the only source of truth, so a module is portable by cp.

scan

resolve deps

~/dotfiles/modules

config.yaml · install.sh · dotfiles/

Interactive TUI

browse · select · create/edit

Install order

(topological)

Packages

brew / apt

install.sh

+ custom commands

Dotfile symlinks

→ $HOME

Context & Challenge

Most dotfiles “managers” are a pile of symlinks plus a bootstrap shell script. That holds up until two things happen: modules start depending on each other (the editor module needs the shell module in place first, because its config sources a shell function), and you re-run the script on a machine that is already half-configured. A flat script has no notion of ordering or idempotency — it reinstalls packages that are already present, and a hand-maintained install order silently rots the first time you add a module in the wrong place. The constraint here was to install an arbitrary subset of modules in an order that always respects declared dependencies, reject a dependency cycle before touching the filesystem, and skip work that is already done — all without a central manifest, so each module stays a copy-pasteable folder rather than an entry in some global registry.

Decisions

Dependency resolution is a depth-first topological sort with three-state tracking. Each module is marked unvisited, visiting (on the current recursion stack), or visited; recursing into a node that is still visiting is a back-edge, which is exactly a cycle, reported as circular dependency detected: X before any package is installed. The visiting set is what separates a real cycle from a harmless diamond — a module reached twice through two different paths is fine, a module reached while still on the stack is not. I chose recursive DFS over Kahn’s in-degree algorithm because it maps directly onto the natural sentence “resolve this module’s dependencies, then the module itself,” and gives the cycle check a single obvious home.

The filesystem is the source of truth — no database, no lockfile, no registry. The scanner just lists modules/*/, unmarshals each config.yaml, and skips (with a warning, never fatally) any module that fails to parse. The tradeoff is deliberate: with no global “what is installed” state, idempotency is delegated downward — to the package manager’s own “already installed” check and to symlink creation being naturally repeatable — in exchange for modules that carry everything they need and move between machines untouched.

The install engine is decoupled from the UI through a status channel. The Bubble Tea model only records intent — which modules, and whether export mode is on — then quits; main.go drives a goroutine that installs each module and streams InstallationStatus values (each with a progress fraction) over a channel the foreground ranges over and prints. This keeps the alt-screen TUI runtime out of the install path, so you watch real brew/apt-get output instead of fighting the renderer, and the first error breaks the loop with a non-zero exit.

cycle

missing dep

yes

no

yes

no

all done

User presses Enter

GetInstallationOrder

add missing deps

ResolveDependencies

DFS topological sort

circular dependency detected

module not found

for each module

export mode?

InstallDotfilesOnly

(symlinks only)

packages → install.sh

→ commands → symlinks

emit InstallationStatus

error?

print error · exit 1

completed

The package-manager layer detects brew then apt and filters each module’s per-manager “specific” packages down to the one that is present, so a module carrying both a brew name and an apt name installs the right one and silently no-ops the other. Export mode is a second install path (InstallDotfilesOnly) that still resolves the full dependency order but runs only the symlink step — for a machine where the software is already there and you only want your configuration applied.

Performance

There are no formal benchmarks here, and inventing them would be dishonest: the working set is small by nature — a personal dotfiles collection is dozens of modules, not thousands — so the interesting cost is algorithmic, not throughput. Dependency resolution is O(V + E) over modules and their declared edges, with recursion depth bounded by the longest dependency chain; on any realistic tree it is negligible. Installation is serial by necessity rather than by oversight: a dependency must finish before its dependent starts, so modules install one at a time in a single goroutine. The only concurrency is between that goroutine and the foreground thread draining the status channel. The dominant wall-clock cost is entirely external — brew/apt-get shelling out and hitting the network — which the tool does not try to parallelize precisely because the dependency order forbids it. Re-running a selection is cheap, since already-installed packages are skipped and symlink creation is idempotent.

Lessons

The sharpest lesson is that importing an existing file is destructive in a way that is not crash-safe. ImportDotfileWithDestination copies the file into the module, deletes the original with os.RemoveAll, and only then creates the symlink back. Between the delete and the symlink there is a window where a crash — or a symlink failure whose best-effort restore also fails — leaves the file living only inside the module, with nothing at its original path. The restore attempt exists, but it is not atomic. The correct shape is symlink-first-then-remove, or stage into a temp path and rename into place; copy/remove/symlink optimizes for the wrong invariant.

ok

fail

i — import a path

copy into module

dotfiles/<dest>

os.RemoveAll original ⚠️ danger window

create symlink:

original → module

register in config.yaml

best-effort restore copy

return error

Two smaller lessons followed the same theme of state drifting out of sync with reality. The README advertised five package managers (brew/apt/pacman/yum/snap) while the installer only ever wired up two — documentation rots into a lie unless something forces it to track the code. And the create/edit forms cap “specific packages” and “specific commands” at two slots each, so editing a module whose config.yaml declares more silently drops the extras on save, because the form rewrites the config from its own fields. A form is a lossy editor over a richer file format; the honest fix is to render every entry dynamically, or to refuse to save a module the form cannot fully represent.

Quick Start

# Build the binary
go build -o bin/dotcli .

# Run it (manages ~/dotfiles/modules by default; created on first run)
./bin/dotcli

# Point it at a throwaway tree to experiment safely
DOTFILES_PATH=/tmp/my-dotfiles ./bin/dotcli

Inside the TUI: Space selects a module (auto-selecting its dependencies), / filters, x toggles export mode, Enter installs the selection, and ? shows every keybinding.