Architecture¶
Omni is a single Go binary. It owns tool and dotfile configuration; APM owns all steady-state agent desired and runtime state. This page is the structural reference: what the system talks to, what it stores, and which package owns which decision. For the durable file layout see State And Files; for the ownership rules see Core Concepts.
System context¶
Omni sits between one operator and the package managers, dotfiles repository, and agent tooling already installed on a machine. It installs nothing itself: every mutation is delegated to a tool that already owns that domain.
graph TB
operator["Operator<br/><i>one person, many machines</i>"]
subgraph fleet["A single machine"]
omni["<b>Omni</b><br/>tool, dotfile and agent-resource management"]
end
pm["Package managers<br/><i>brew, apt, apk, dnf, pacman,<br/>zypper, cargo, uv, pip, bun, pnpm, npm</i>"]
dotrepo["Dotfiles repository<br/><i>git, stow-shaped tree</i>"]
apm["<b>APM</b><br/><i>agent primitive manager</i>"]
clients["Agent clients<br/><i>claude, codex, cursor, gemini,<br/>windsurf, kiro, opencode, copilot, hermes</i>"]
gh["GitHub<br/><i>release metadata, marketplaces</i>"]
operator -->|CLI and TUI| omni
omni -->|install, upgrade, remove| pm
omni -->|link, adopt, back up, commit| dotrepo
omni -->|dispatch| apm
apm -->|deploy primitives| clients
omni -->|read native state| clients
omni -->|version and update checks| gh
apm -->|resolve packages| gh
Omni reads agent clients directly for exactly one purpose — reporting native artifacts APM does not manage — and never writes them outside the TUI's explicit per-artifact removal. See Native items APM does not manage.
Containers¶
There is no server and no daemon. The binary is the only executable, and the three stores below it are independent.
graph TB
subgraph binary["omni binary"]
cliC["CLI<br/><i>Cobra</i>"]
tuiC["TUI<br/><i>Bubbletea</i>"]
appC["App<br/><i>orchestration</i>"]
end
settings[("settings.json<br/><i>durable config, versioned schema</i>")]
cache[("omni.db<br/><i>SQLite cache and trace log</i>")]
state[("State dir<br/><i>private durable state</i>")]
apmstore[("~/.apm/<br/><i>apm.yml, apm.lock.yaml,<br/>marketplaces.json</i>")]
cliC --> appC
tuiC --> appC
appC -->|read-modify-write under a lock| settings
appC -->|cache only, safe to delete| cache
appC --> state
appC -.->|APM owns it| apmstore
settings.json is the single source of truth; omni.db is a cache that can be
deleted at any time — tool and agent update checks are recorded there so a view
can show the last known answer before a fresh check returns; the state directory
holds private durable state such as migration wrappers. Omni keeps no parallel agent manifest, ownership ledger, or
runtime deployment model — ~/.apm/ is APM's, and Omni's only write to it is
described under Agent manifest boundary.
Both front ends enter through the same object. main calls cli.Execute, which
builds the Cobra root, constructs one app.App in PersistentPreRunE, and hands
it to every subcommand. A bare omni and omni ui both construct
tui.New(ctx, app) over that same App, so the TUI is a second front end over
the orchestration layer rather than a parallel implementation.
Components¶
Roughly 95k non-test lines across 35 packages. internal/app and internal/tui
carry two thirds of it.
graph TB
subgraph frontends["Front ends"]
cli["internal/cli<br/><i>Cobra commands</i>"]
tui["internal/tui<br/><i>Bubbletea model</i>"]
actions["internal/actions<br/><i>action catalog</i>"]
end
app["internal/app<br/><i>App: orchestration and policy</i>"]
subgraph engines["Engines"]
sync["internal/sync<br/><i>Syncer</i>"]
dots["internal/dots<br/><i>stow-backed link engine</i>"]
prov["internal/provider<br/><i>Provider registry</i>"]
apmpkg["internal/apm<br/><i>APM adapter</i>"]
end
subgraph platform["Platform"]
cfg["internal/config"]
db["internal/database"]
exec["internal/executor"]
flock["internal/flock"]
end
cli --> app
tui --> app
cli -.->|labels, keys, commands| actions
tui -.->|labels, keys, commands| actions
app --> sync
app --> dots
app --> prov
app --> apmpkg
sync --> prov
sync --> db
app --> cfg
app --> db
prov --> exec
dots --> exec
apmpkg --> exec
cfg --> flock
apmpkg --> flock
| Package | Owns |
|---|---|
internal/app |
Orchestration and policy. Holds the config path, cache and state dirs, the provider registry, the database handle, and the fallback executor. Every mutating flow is a method here. |
internal/cli |
Cobra command tree. Commands are thin: they parse flags, call one App method, and print. |
internal/tui |
Bubbletea model, one tab per domain. Reads the same App methods the CLI calls. The five list tabs share one row composer and scroll window (view_table.go), one column shrink ladder (view_columns.go), one cursor model (nav.go), and one screen-line-to-row map for the mouse (view_hit.go), so behavior cannot drift per tab. |
internal/actions |
The action catalog: one registry entry per product-visible action, carrying label, description, TUI key binding, and CLI command variants. |
internal/provider |
The Provider interface, the registry, and one subpackage per package manager. |
internal/sync |
The Syncer: decides per tool whether to install, skip, prune, or report unavailable. |
internal/dots |
The dotfile engine: classification, stow-shaped symlinks, adoption, backup. |
internal/apm |
The APM adapter and its workspace lock. |
internal/config |
settings.json load, migration, versioned schema, and the write lock. |
internal/database |
SQLite via Bun. Cache tables plus the command trace log. |
internal/executor |
External command execution, with a tracing decorator and a mock. |
internal/flock |
Advisory file locks. |
internal/testguard |
The test sandbox. |
internal/testflow |
The flow catalog validator. |
Providers¶
A provider is anything that can install, upgrade, remove, and list packages. The
interface is deliberately small — Name, Description, Available, Install,
Uninstall, Upgrade, IsInstalled, ListInstalled — and everything beyond it
is an optional capability interface the caller type-asserts for: bulk installed
checks, outdated checks, manager-level install and uninstall, pin detection,
descriptors, privilege planning, and error advice. A provider that cannot do
something simply does not implement that interface, so no provider carries stub
methods it does not mean.
Concrete providers register themselves. Each calls RegisterConcrete from its
own init(), and internal/provider/all is a fifteen-line file of blank imports
whose only job is to run those registrations. BuildConcreteProviders then
instantiates every registered factory against one executor.
graph LR
subgraph ecosystems["Ecosystem providers"]
py["python"]
node["node"]
end
subgraph backends["Resolved backends"]
uv["uv"]
pip3["pip3"]
pip["pip"]
bun["bun"]
pnpm["pnpm"]
npm["npm"]
end
py -->|hint from settings,<br/>else first on PATH| uv
py --> pip3
py --> pip
node -->|settings.node_manager| bun
node --> pnpm
node --> npm
An ecosystem provider (python, node) owns no packages itself: it resolves
to a concrete manager at call time. Resolution prefers the pinned hint from
settings.ecosystems.<name>.manager and otherwise walks a fixed preference order,
taking the first binary present on PATH. ResolvedName reports which backend
won, which is how the Syncer records what a tool was actually installed with.
This is why omni consolidate <ecosystem> <manager> takes two words: the pair is
resolved to a provider name plus settings through lookup tables in the app layer.
Sync¶
The Syncer holds nothing but a provider registry and a database handle. It never
constructs providers — it looks them up by name and type-asserts the optional
interfaces it needs.
flowchart TB
start(["omni tools sync"]) --> filter["Filter configured tools<br/><i>provider scope, ignore list, retry-failed</i>"]
filter --> avail["Check provider availability<br/><i>parallel, one per unique provider</i>"]
avail --> bulk["Read installed state<br/><i>bulk where supported,<br/>else per tool</i>"]
bulk --> decide{"Per-tool decision"}
decide -->|missing| install["Install"]
decide -->|present| skip["Already installed"]
decide -->|no provider| unavail["Provider unavailable"]
decide -->|ignored| ign["Ignored"]
install --> record["Record outcome in omni.db"]
skip --> record
unavail --> record
ign --> record
The two read phases run in parallel per unique provider under an errgroup; the
install and uninstall executions themselves are sequential per tool, so a failing
package cannot race another provider's mutation. Failures, privilege
requirements, and successes are all written back to tool_cache, which is what
makes --retry-failed and the privilege prompts possible on the next run.
Dotfiles¶
Entries follow the GNU stow convention: the repository holds the real file at
<stow-root>/<package>/<path-relative-to-home>, and $HOME holds a symlink
pointing at it. Classification crosses "does the repo side exist" with what
lstat finds at the target, producing one of fourteen states — synced,
missing, broken, conflict, modified, local-only, repo-only,
no-source, untracked-linked, untracked-conflict, ignored, inactive,
disabled, ambiguous.
Symlink writes are atomic: a temporary symlink is created and renamed over the
target, and both the temp path and the home-relative target path are validated
before the rename. Every destructive path copies the local file into
~/dotfiles.bkp first, and the sync path snapshots the repository worktree onto
a dedicated refs/heads/omni/backup ref using a scratch index, so the snapshot
never touches HEAD, the index, or the worktree.
Groups and variants are resolved before the engine sees anything: internal/dots
receives a flat list of already-resolved entries and has no concept of either.
Agent manifest boundary¶
The steady-state agent boundary is a thin adapter around APM: Omni selects and invokes APM commands, then presents their output in the CLI and TUI. APM owns manifests, resolution, lockfiles, package installation, marketplaces, plugins, MCP, and target deployment.
Omni's only write on the agent side is a whole-file copy: sync materializes the
optional host template ~/.config/omni/apm.yml over ~/.apm/apm.yml, then
invokes APM install. Omni never edits manifest fields, so the manifest stays a
dotfile-managed artifact and APM stays the sole owner of everything the install
produces.
sequenceDiagram
participant User
participant Omni
participant APM
participant Manifest as ~/.apm/apm.yml
User->>Omni: omni agents sync
Omni->>Manifest: Compare live hash with last applied
alt Unseen or edited outside Omni
Omni-->>User: Warn, require --force-template
else Matches
Omni->>Manifest: Copy host template over it
end
Omni->>APM: install -g
APM-->>Omni: Resolution, lockfile, deployment
Omni->>Omni: Record the normalized manifest hash
The recorded hash is Omni's whole state for this surface. A pre-APM host's old
declarations live in a read-only snapshot committed in dotfiles;
omni agents migrate previews the rendered manifest, while --write publishes
verified local wrappers and updates only the marked host template.
Configuration¶
settings.json carries an explicit version. Loading a file below
config.CurrentVersion migrates it forward in memory, and every released schema
version is frozen under spec/ so an older document stays checkable. A removed
field is not silently ignored: the loader rejects it by name and points at the
command that replaces it, which is how the retired agents declarations became a
hard failure rather than a quiet no-op.
Read-modify-write cycles on the file are serialized by a mutex in the App and an advisory lock on disk, so a TUI action and a concurrent CLI invocation cannot interleave a lost update. Read-only loads take neither.
Command execution and tracing¶
Every external process — brew, git, apm, claude, codex — goes through
one Executor interface whose base method is Run(ctx, name, args...). Optional
capability interfaces add environment, working directory, and stdin variants, and
free functions degrade gracefully when an implementation lacks one.
Three implementations exist: the real one, which augments PATH per invocation
rather than mutating the process environment so version-manager shims resolve; a
mock for tests; and a tracing decorator that records every call — command,
duration, exit code, and truncated output — into the command_traces table with
secret-shaped values redacted. That table is what omni trace list and the TUI's
trace log read, and it is retention-pruned rather than unbounded.
Each command leads its own process group, and cancelling one signals the group.
Commands delegate — apm resolves through git, a provider shells out — and
signalling only the process omni started leaves that work running, to be orphaned
when omni exits. Omni cancels its root context on the way out, so quitting during
an install stops the whole tree. Nothing omni spawns reads the terminal: output
is captured, stdin is a byte reader, and privileged calls use sudo -n, so the
commands lose no interactivity by sitting outside omni's own group.
Locking¶
Three independent advisory locks guard three independent files. There is no global lock and no cross-lock ordering to observe, because no flow holds two of them for different resources at once:
| Lock | Guards | Mode |
|---|---|---|
| Config write lock | settings.json read-modify-write |
Blocking exclusive |
| APM workspace lock | The global APM workspace | Non-blocking; fails fast rather than queueing |
| Installed-state lock | The provider-scan state file | Shared for reads, exclusive for writes |
SQLite is confined to a single connection with a busy timeout and WAL mode, since it is single-writer; multi-row writes run in one transaction so a crash leaves the cache either fully updated or untouched.
The action catalog¶
One registry entry per product-visible action ties together what the CLI and TUI each expose. An entry carries the label, description, whether it mutates, whether it needs confirmation, its TUI key binding, and its CLI command variants.
An action is dual-surface, CLI-only, or TUI-only, and the third state must be
declared rather than inferred: a CLI-only action sets CLIOnlyReason and may not
also carry a TUI binding; a TUI-only action sets TUIOnlyReason and may carry no
CLI binding at all. Tests enforce that every action has one of the three shapes,
that no two actions claim the same CLI command plus required-flag combination,
and that every mutating tool and dots action reaches both surfaces.
This is why adding a key to the TUI is not a local change. A key bound to an action whose CLI command only previews would be a parity violation, caught by the catalog rather than by review.
Test architecture¶
Two mechanisms sit underneath the test suite, and both fail closed.
The sandbox. internal/testguard activates from its own init() whenever the
process is a Go test binary. It builds a disposable HOME, XDG directories, and
TMPDIR, symlink-farms only an approved allowlist of tools onto PATH, and
installs an HTTP transport that refuses every non-loopback dial. Filesystem
writes are checked against a nonce-stamped sandbox root. That protects the test
binary — but the go test driver that launches it would still run with the real
environment, which is why bash scripts/run-test-safe.sh is required: it runs
go test under env -i with an explicit safe environment and validates the flags
it was given. Plain go test ./internal/... fails spuriously by design.
The flow catalog. test/flows.json declares one flow per product-visible
capability, and validation refuses to pass if any registered action is not mapped
to a flow. A flow's requirements are levelled — unit, component, integration,
CLI black-box, TUI black-box, parity — and each required level must cite evidence
that is statically checked against real Go test functions and real testscript
fixtures. A requirement with no evidence must instead be declared a gap with a
reason and a target stage; it cannot simply be absent.
A flow that reaches both surfaces must declare parity, naming either the
semantic state a mutating flow asserts on both paths or the semantic query a
read-only flow compares. A flow that reaches the TUI must carry tui_blackbox
evidence. Together these are what keep the CLI and TUI from drifting into two
different products.