Decisions
ADR-020: One command grammar for the dot family
Status: Accepted 2026-10-18; implemented in the monorepo (N0): registries, manifest schema, plugin manifests, the CLI-contract lint, the runtime alias table, the generated help, man pages, completions and cmdlets, and the single update path (see Enforcement). The old names stopped working in 0.2.538: each exits 127 with its new spelling.
Context
dot has 105 routes in one flat table (lib/dot/data/routes.txt). Names grew one at a time: secrets-init, ssh-key, load-bench-pty, ai-query, dns-doh. Some are verbs, some nouns, some both; doctor, health and health-check overlap; dot -v prints the version where GNU tools mean verbose. The family is about to split into one repository per domain (plan v3), and every repository would copy whatever convention it finds. A name is cheapest to fix before it ships in eighteen places.
Three conventions solve different parts of the problem. macOS names a tool with one lowercase word for what it manages (defaults, diskutil). systemd gives every manager the same verbs (start, stop, status). PowerShell keeps a closed list of approved verbs, so a user can guess a command they have never seen.
Decision
Grammar
dot <domain> [<object>] <verb> [args] [flags]: at most three words before the arguments. A domain is one lowercase ASCII word of 2 to 8 letters, singular, with no digits, hyphens or underscores (N1). Acronyms only where they are the industry word: ai, mcp, cli, ui. Objects are singular nouns declared in the plugin manifest (N3).
Domains and repositories
Seventeen domains, listed in schemas/cli/domains.json with their help group and one-line summary. Repository dot-<domain> provides exactly dot <domain> and the entry binary dot-<domain> (N2). The distro dotfiles and the infrastructure repositories (.github, plugin-template, homebrew-tap, scoop-bucket) provide no command. This renames two repositories from plan v2: the host dot becomes dot-cli (matching the AUR package dot-cli-git), and dot-secrets becomes dot-secret. All repositories live under the personal GitHub account sebastienrousseau; signer identities are pinned to it.
Verbs
A closed list of 37 verbs in schemas/cli/verbs.json (N4). Each has one meaning, one side-effect class (read, change, or any for verbs that launch a process), a PowerShell approved verb, and the words it replaces. There are no synonyms: remove, never rm or delete; list, never ls (N6). Adding a verb needs a new ADR and a PowerShell mapping.
A domain or object with no verb does the read-only thing: show, list, status, the select picker, or the domain's TUI (N5). Anything that changes the system needs an explicit verb.
Built-ins and aliases
The host builds in only dot help, dot version and dot cli (N8). dot help <term> searches. (Status note: today bin/dot handles help and version itself, and dot cli ships as the host's own plugin, plugins/cli/.) Aliases are of two kinds only (N7):
- Seven daily shortcuts:
dot apply,sync,diff,statusandedit(todot config …),dot update(todot system update) anddot cd(todot config path, shipped as a shell function by dot-shell). - Deprecated names, which work for one family release (about one week at the weekly cadence), print the new spelling once per shell session, and then exit 127 with a hint.
Flags and exit codes
GNU long options with POSIX short options, listed in schemas/cli/flags.json (N9). Family-wide meanings never change: -h/--help, -V/--version, -v/--verbose, -q/--quiet, -y/--yes, -n/--dry-run, --json, --plain, --no-color, --force, --no-motion. This breaks dot -v, which prints the version today; it becomes --verbose after one release of warnings.
Exit codes (N10): 0 ok, 1 warning or check failed, 2 critical, 64 usage error, 75 temporary failure such as a held lock, 127 unknown command, removed name or missing plugin, 130 interrupted.
Windows
PowerShell cmdlets are generated as <Verb>-Dot<Domain>[<Object>] from the verb map (N11): dot ai list becomes Get-DotAi, dot secret key new becomes New-DotSecretKey. Where two verbs share a PowerShell verb, the registry declares the rule: list and show are one Get cmdlet (show takes -Name); status appends Status to the noun; set and apply, and start and serve, never appear on the same object, and the CLI-contract lint rejects any other pair. dot ai serve is therefore dot ai gateway serve (Start-DotAiGateway): it serves the gateway, and dot ai start starts an agent. Planned commands get no cmdlet until built, and a destructive one asks first (ShouldProcess).
Files, identifiers and output
- Paths (N12): config
$XDG_CONFIG_HOME/dot/<domain>.toml, state$XDG_STATE_HOME/dot/<domain>/, cache$XDG_CACHE_HOME/dot/<domain>/;%APPDATA%\dot\on Windows; helpers inlibexec/dot/<domain>/; man pagesdot(1)anddot-<domain>(1). - Identifiers (N13): environment
DOT_<DOMAIN>_<NAME>; schema ids<domain>/<name>.v<N>; Go modulesgithub.com/sebastienrousseau/dot-<domain>; Homebrew formula and aqua iddot-<domain>; log source tag = domain. - Output (N14): results on stdout, logs on stderr;
--jsonwith a versioned schema on every read command; no colour when piped, underNO_COLORor withTERM=dumb; something printed within 100 ms. - Configuration precedence (N15): flags, then
DOT_*environment, then$XDG_CONFIG_HOME/dot/<domain>.toml, then the distro's defaults, then built-ins. - Side-effect class (N16): every command declares
read,writeordestructive. The class drives help labels, confirmation, whatdot agentallows in plan mode, and which commands become MCP tools (read only). - Locking (N17):
writeanddestructivecommands take a per-domain lock in$XDG_STATE_HOME/dot/<domain>/lock, and a second run waits or exits 75 with the holder's PID. bin/dot takes it before the command runs (lib/dot/lock.sh): a symlink naming the holder's PID, held until that process exits, so a crash leaves a stale link the next run takes over. It waitsDOT_LOCK_WAITseconds (default 0) before exiting 75, and adotcommand run by one holding the lock does not wait on its parent. Awritecommand that runs the user's own process (an agent session, an editor) declareslock = false: it would otherwise hold the domain for as long as that process runs.
Manifest v1
Each plugin declares its names in dot-plugin.toml, validated by schemas/cli/manifest.v1.schema.json: domain, default, ps_noun, objects, commands (each an optional object, a verb, an effect and an optional --json output schema id) and aliases (shortcut, deprecated with an until release, or removed). schemas/cli/examples/dot-plugin.toml shows the secret domain. The plugin host, trust and hook fields are reserved in the schema and defined by the plan's P2 work.
The Graphviz collision
Graphviz also installs dot. The family keeps the name because it already ships. dot doctor warns when another dot comes first on PATH, the Homebrew formula dot-cli notes conflicts_with "graphviz", and dot-cli is documented as an alternative entry point.
Status note: only the first part is in place. dot doctor warns when command -v dot is not ~/.local/bin/dot (scripts/diagnostics/doctor.sh). The Homebrew formula (pkg/brew/dot.rb) has no conflicts_with "graphviz", and there is no dot-cli entry point (dot-cli is only the AUR package name). Both are planned.
Consequences
- Every one of today's 105 routes gets a new spelling (plan v3, command map). Old names keep working for one family release.
- Help, man pages, completions for five shells and PowerShell cmdlets can be generated from manifests, so no completion list is written by hand.
- Agents get one machine-readable command tree (
dot help --json) whose side-effect classes match whatdot agentanddot mcpenforce. dot -vchanges meaning. Scripts that parse it must move todot --versionordot version.
Enforcement
| Step | Gate | State |
|---|---|---|
| Registries and manifest schema | tests/unit/dot-cli/test_naming_registry.py in Core and delivery contracts | done |
| CLI-contract lint over manifests, routes, flags and exit codes | tools/ci/check-cli-contract.py in Core and delivery contracts; tests/unit/dot-cli/test_cli_contract_lint.py plants each violation | done; the runtime baseline is empty |
| Alias table from the command map | lib/dot/data/commands.txt, generated by tools/ci/cli_command_table.py and checked by the lint; tests/unit/dot-cli/test_dot_command_table.sh | done: every alias target runs, deprecated names warn, removed and expired ones exit 127 |
| Help, man pages, completions and cmdlets generated from manifests | lib/dot/help.sh (dot help, --json), tools/docs/gen_manpages.py (dot(1), dot-<domain>(1)), tools/docs/gen_completions.py (five shells), tools/docs/gen_cmdlets.py; each drift-checked in Doc Drift and pre-commit | done: no hand-written completion list remains |
One update path, dot system update | tests/unit/dot-cli/test_upgrade_globals.sh; tests/integration/test_system_schedule_live.sh in Cross-Platform Test has launchd and the systemd user manager run dot system schedule run | done: topgrade, the update script and the update/upd functions are gone, and the job exits 0 on macOS and Linux |
Each domain declares its names in plugins/<domain>/dot-plugin.toml, and every route in lib/dot/data/routes.txt has an alias there. Writing them corrected spellings in the plan's command map that broke N3, N4 or N5: dot secret audit is dot secret check, dot secret get is dot secret show, dot shell refresh is dot shell update (a bare object may not regenerate anything), dot cli search is dot cli list --available beside dot help <term>, and the objects conflicts and metrics are singular. dot agent delegate had no approved verb; a delegate is a noun, so it is an object of dot agent: dot agent delegate list (the default) and dot agent delegate run <name> <command>.
Until each domain is extracted, a manifest says which of today's routes runs each command (legacy, on the domain and on each command). bin/dot reads the generated table through lib/dot/names.sh:
- A new spelling runs its route; a command with no route yet is declared but planned, and
dot <domain>lists it as such and exits 127 when it is called. - A domain with a route of its own (
ai,agent,attest,doctor,fleet,mcp,system,theme,tool,top) keeps its bare behaviour, and a word that is not one of its verbs or objects goes to that route, so today's subcommands keep working. - A deprecated name runs and prints its new spelling once per shell session; from its
untilrelease it exits 127 with that spelling, as a removed name does. dot updatenow runsdot system update(the olddot upgrade); the dotfiles-only update isdot config update.- From P2.1 a plugin can run its own commands: a manifest with an
[entry]routesdot <domain> ...toplugins/<domain>/dot-<domain>with the words after the domain, and names nolegacyroute; a command not built yet is markedplanned. Withfallback = truethe domain word alone, and words its table does not take, go to the entry as they went to the domain's route (theme,attest,fleet,ai,agent,mcp,top,tool,doctor,system); without it they get the domain's help or exit 127, as before.themewas the first; thensecurity,attest,secretandfleet; thenai,agent,mcpandtop; thencliandtool; thendoctorandsystem; thenconfigandshell, after which routes.txt holds only the host's built-ins (help,version) and route lines for words the command table now marks removed (search,manual,docs,learn,keys,chaos,smoke-test). Each removed word exits 127 with its new spelling:dot help <term>,dot help --manual,dot help --readme,dot help tourordot doctor. - Each plugin ships its route fragment,
plugins/<domain>/commands.txt, generated from its manifest: itsdom,obj,cmd,flag,argandaliasrecords, andrunrecords for its own aliases, in the pipe-separated formattools/ci/cli_command_table.pydocuments. The family'sgrprecords arelib/dot/data/groups.txt.lib/dot/table-merge.awkmerges the groups and the fragments, in domain order, into the command table:grp, everydom/obj/cmd, the firstrunof each invocation, alegrecord per legacy route (a route that starts with@is an entry),flag/arg, thenalias. The merge of the bundled fragments is exactlylib/dot/data/commands.txt. This fragment format is the contract a loader reads, the bash host's now and the Go host's later. bin/dotbuilds its table from the fragments at run time (lib/dot/names.sh), into$XDG_CACHE_HOME/dot/cli/commands.txt. The cache's first line is its key (format, version, source tree, fragment list) and it is replaced whole; it is rebuilt when the key differs or a fragment is newer. Release archives date every file 1970, so the version in the key, not mtime, catches an upgrade.--helpreads the cache but never writes it. A tree with no fragments, or a merge that fails, readslib/dot/data/commands.txt, which the build tools (completions, man pages, the lint) keep reading.- A plugin declares lifecycle hooks in its manifest (
[[hooks]]: anevent, arunscript under itslibexec/, and asummary). The generator writes them to its fragment ashook|<event>|<domain>|<run>|<summary>records, merged after the aliases, andlib/dot/hooks.shruns every hook for an event in domain order, under its summary; one that fails is reported and the caller carries on. The first event ison-apply, run bydot config applyafter chezmoi; the ai plugin's on-apply hook lists the roster's tools and offers to install the missing ones, whichchezmoi-apply.shdid itself from its own list before P2.2. - Only the bundled plugins' fragments are merged. User plugin directories (
$XDG_DATA_HOME/dot/plugins/) are not read: they come with P2.3, and only through ADR-016's trust path (an explicit, signed registry entry). Merging a user-writable directory into dispatch before then would let any process running as the user change what adot ...command runs. Dispatch is closed too. For a word that is not in the table, the user commands from before plugins (${XDG_CONFIG_HOME:-~/.config}/dotfiles/commands/<name>.sh, thendot-<name>besidebin/dotor onPATH) run only withDOT_ALLOW_PATH_PLUGINS=1, with a deprecation warning (_dot_user_commandinlib/dot/names.sh). Without it,dotnames the file it found and the word is unknown (exit 127). A word that is not a plain lowercase name never reaches either. -Vprints the version and-vsetsDOT_VERBOSE=1. Until 0.2.538,dot -valone printed the version with a warning; from 0.2.538 it is--verbosewith no command, which prints the overview.
Some security aliases were corrected because the old command applies a change: dot firewall is dot security firewall apply, and so on for telemetry, DNS, lock screen and USB, and dot snapshot is dot doctor snapshot new. dot chaos, dot smoke-test and dot keys were marked removed, which would have made them exit 127 with no release of warning; they were deprecated until 0.2.538 like every other old name. dot keys sign-check moved into dot doctor, which reports Git commit signing (0.2.537); the keybindings catalog dot keys showed has no command; the keybindings stay in docs/security/KEYS.md.
Related: ADR-004 (CLI wrapper), ADR-016 (plugin trust), ADR-021 (versioning), ADR-022 (language).