# `config.toml` reference
Full field-by-field reference for `~/.config/zenops/config.toml`. For a
gentler introduction, see the [README](../README.md).
The top-level sections are:
- [`[user]`](#user) — identity (name, email)
- [`[shell]`](#shell) — shell environment, aliases
- [`[conditions]`](#conditions) — named host predicates referenced by `pkg.*.when`
- [`[pkg.*]`](#pkg) — package definitions (detect, install_hint, shell hooks, dotfiles)
- [`[ssh]`](#ssh) — allowed signers for SSH commit signing
- [`[git]`](#git) — `~/.gitconfig` management, including signing
All sections are optional. Unknown top-level keys are rejected at load time.
---
## `[user]`
Identity fields that aren't git-specific. Both are optional, but setting
them enables the matching features (git identity, template expansion).
```toml
[user]
name = "Ada Lovelace"
email = "ada@example.com"
```
| `name` | string | none | Also exposed as `${user.name}` for template expansion. |
| `email` | string | none | Also exposed as `${user.email}`. |
See [Template variables](#template-variables) for where `${user.*}` can be used.
---
## `[shell]`
Controls the shell zenops manages. The `type` field is tagged:
```toml
[shell]
type = "bash"
[shell.environment]
EDITOR = "hx"
PAGER = "less -R"
[shell.alias]
ll = "ls -la"
gs = "git status"
```
| `type` | `"none"` / `"bash"` / `"zsh"` | `"none"` | Selects the shell. With `"none"`, zenops doesn't touch shell config. |
| `environment` | map of string → string | `{}` | Emitted as `export NAME=value` in the generated init script. Only when `type` is `"bash"` or `"zsh"`. |
| `alias` | map of string → string | `{}` | Emitted as `alias name=value`. Only when `type` is `"bash"` or `"zsh"`. |
Per-pkg shell hooks (env init, login init, interactive init) are configured under
`[pkg.*.shell]` — see [pkg shell hooks](#shell-hooks).
---
## `[conditions]`
Named host predicates. Defined once under `[conditions]`, referenced from a
pkg's `when` field (or from another condition) by name. Lets you express
"this pkg only applies when X" without repeating the predicate at every
use site.
```toml
[conditions]
work_host = { hostname = "^work-.*" }
work_macos = { all = ["macos", "work_host"] } # built-in `macos` + user-defined `work_host`
not_zsh = { not = "zsh" }
```
Each entry is a TOML table with **exactly one** of the following keys —
the key both names the predicate kind and carries its argument, so an
entry self-documents what it checks. Unknown keys, multiple keys, and
empty tables are rejected at load.
| `os` | `"linux"`, `"macos"`, or a distro id | the host matches — see [Host patterns](#host-patterns) |
| `shell` | `"bash"` or `"zsh"` | the configured `[shell].type` matches |
| `hostname` | regex (string) | the regex matches the host's hostname |
| `file_exists` | path (with `~` and `${...}` support) | the path exists on disk |
| `all` | array of names or inline conditions | every child matches |
| `any` | array of names or inline conditions | some child matches |
| `not` | a name or inline condition | the child does not match |
Children of `all` / `any` / `not` (and the value of `pkg.*.when`) are
either a **string** (a name from `[conditions]`) or an **inline table**
(an unnamed condition).
### Host patterns
The argument to `os` is a single string. `linux` and `macos` match a
whole platform; anything else is read as a Linux distro id, matched
against the host's `/etc/os-release`.
```toml
[conditions]
any_linux = { os = "linux" }
any_mac = { os = "macos" }
fedora = { os = "fedora" } # any Fedora release
fedora_42 = { os = "fedora-42" } # that release only
ubuntu_lts = { os = "ubuntu-24.04" }
arch_like = { os = "arch" } # also EndeavourOS, CachyOS, …
tumbleweed = { os = "opensuse-tumbleweed" } # ids may contain dashes
```
- The id is matched against `ID` **and** every entry in the `ID_LIKE`
chain, so `ubuntu` accepts Pop!\_OS and `fedora` accepts Bazzite.
- Append a `VERSION_ID` after a dash to pin a release. The comparison is
literal — `fedora-42` matches `VERSION_ID=42` and nothing else; there
is no `>=`. A trailing segment only counts as a version when it looks
like one (digits and dots), which is why `opensuse-tumbleweed` stays a
single id.
- Ids zenops has never heard of load fine and simply never match. Your
config isn't rejected because zenops hasn't met your distro; a
`when = "debian"` pkg is just inert everywhere else.
- Matching is case-sensitive against the raw os-release value.
### Built-in conditions
These names are always available and can be referenced without declaring
them. User entries with the same name override.
| `linux` | `{ os = "linux" }` |
| `macos` | `{ os = "macos" }` |
| `bash` | `{ shell = "bash" }` |
| `zsh` | `{ shell = "zsh" }` |
### Validation
References resolve at load time: an unknown name or a cycle in the
reference graph fails the load with a message naming the offender.
Hostname regexes are compiled at load too — a malformed pattern fails
loudly rather than at first evaluation.
---
## `[pkg.*]`
A pkg is a tool zenops knows about: how to install it, how to detect whether
it's present, which shell init lines it needs, and which dotfiles it owns.
Each pkg is a map entry keyed by an arbitrary identifier:
```toml
[pkg.helix]
description = "modal editor"
install_hint.brew.packages = ["helix"]
detect.which = "hx"
```
### Core fields
| `name` | string | map key | Display label. Useful when two condition-gated entries (e.g. `brew-linux` / `brew-macos`) should share a single user-facing name. |
| `description` | string | none | Free-form human description. |
| `enable` | `"on"` / `"detect"` / `"disabled"` | `"on"` | See [enable states](#enable-states). |
| `when` | condition name or inline table | none | Host-level gate: a name from `[conditions]` or an inline condition. Absent means "applies on every host". See [conditions](#conditions). |
| `detect` | detect strategy | none | See [detect strategies](#detect-strategies). |
| `install_hint` | object | `{}` | Per-manager install commands. Optional; omitted managers default to "no install path under this manager". See [install hints](#install-hints). |
| `inputs` | map of string → string | `{}` | Template variables scoped to this pkg. Shadow system inputs with the same key. See [template variables](#template-variables). |
| `shell` | object | `{}` | Shell init hooks. See [shell hooks](#shell-hooks). |
| `configs` | array | `[]` | Dotfiles owned by this pkg. See [configs](#configs). |
### Enable states
- **`on`** (default) — "I expect this pkg to be here." Runs the detect
check; if it misses, `zenops apply` and `zenops status` surface
`<pkg> is missing — install with: …` so you notice the drift. A bare
`[pkg.x]` reads as "I want this."
- **`detect`** — Use the pkg when detect matches, silent otherwise. Right
variant for tooling you may or may not have installed (a miss is a
non-event).
- **`disabled`** — Skip the pkg entirely. Never installed, never surfaces.
A pkg's shell hooks and configs only take effect when the pkg is considered
installed — either detect matches on the current host, or there's no
`detect` field at all (which is right for config-only or PATH-only pkgs).
### Detect strategies
`detect` expresses "is this pkg present on this host?" with five kinds.
Each `[detect]` table has exactly one of `exists`, `which`, `any`, `all`,
or `when` (the last is paired with `then`). The leaf and combinator kinds
(`exists`, `which`, `any`, `all`) are pure presence checks. The `when`
kind gates a subtree on a host-level condition — use it inside `any` to
express OS-divergent detect paths in a single pkg, instead of splitting
into two `[pkg.*]` entries.
Pkg-level `when` (gating the whole pkg) is still the right place for
"this pkg only applies on host X." Detect-level `when` is for "this
pkg is one thing, but how to find it differs by host."
**`which`** — binary is on `PATH`:
```toml
[pkg.sk]
[pkg.sk.detect]
which = "sk"
```
**`exists`** — a path exists. Supports `${...}` expansion and leading `~`
(expanded to `$HOME`):
```toml
[pkg.cargo]
[pkg.cargo.detect]
exists = "~/.cargo/bin/cargo"
```
**`any`** — matches when *any* child strategy matches (short-circuits):
```toml
[pkg.editor]
[pkg.editor.detect]
any = [
{ which = "nvim" },
{ which = "vim" },
]
```
**`all`** — matches when *every* child matches. An empty array is
vacuously true; prefer omitting `detect` entirely to express "no check
required".
```toml
[pkg.toolchain]
[pkg.toolchain.detect]
all = [
{ which = "clang" },
{ which = "lld" },
]
```
**`when`** — gates an inner strategy on a host condition. Takes a `when`
(a `[conditions]` name or an inline condition table — see
[conditions](#conditions)) and a `then` (the strategy to evaluate when
the gate is satisfied). When the gate fails, the whole node evaluates as
`false` — it is **not** dropped from a parent combinator, so an `all`
whose children are all gated to a different host evaluates `false`, not
empty-set-vacuously `true`.
Typical use: one pkg with OS-divergent detect paths.
```toml
[pkg.brew]
[pkg.brew.detect]
any = [
{ when = "macos", then = { exists = "/opt/homebrew/bin/brew" } },
{ when = "linux", then = { exists = "/home/linuxbrew/.linuxbrew/bin/brew" } },
]
```
### Install hints
`install_hint` tells `zenops pkg` (and the "<pkg> is missing" warning) how
to install the pkg. Five managers ship today: Homebrew, dnf (on
Fedora), Apt (on Ubuntu), Pacman (on Arch Linux), and
`cargo install` for Rust crates. Every per-manager field is optional —
omit any you don't have an install path under, and it defaults to "no
hint via this manager". Empty arrays mean the same thing as omission;
both shapes are valid:
```toml
# Minimal: only the manager(s) the pkg actually installs under.
[pkg.helix]
install_hint.brew.packages = ["helix"]
```
```toml
# Verbose form: enumerate every manager explicitly under a single
# `[pkg.x.install_hint]` section header. Built-in pkgs ship in this
# shape so adding a new manager forces a deliberate per-pkg update.
[pkg.ripgrep]
[pkg.ripgrep.install_hint]
brew.packages = ["ripgrep"]
dnf.packages = ["ripgrep"]
apt.packages = ["ripgrep"]
pacman.packages = ["ripgrep"]
cargo.packages = ["ripgrep"]
```
| `brew.packages` | array of string | Homebrew formula names. |
| `dnf.packages` | array of string | dnf (Fedora) package names. |
| `apt.packages` | array of string | Apt (Debian / Ubuntu) package names. |
| `pacman.packages` | array of string | Pacman (Arch Linux) package names. |
| `cargo.packages` | array of string | crates.io crate names. Cargo is a supplementary manager — used for Rust tools that aren't in the system repos (e.g. `skim` on Fedora). |
`brew`, `dnf`, `apt`, and `pacman` are primary managers (one wins as the
install path on a given host); `cargo` is supplementary and surfaces
alongside the primary when both apply. Future package managers (zypper,
apk, etc.) will live alongside these under `install_hint`.
### Shell hooks
Shell init actions, grouped by stage and keyed by shell. Only emitted when
the pkg is considered installed (i.e. `when` evaluates true and `detect`
matches if present) and the user's configured shell has actions registered
for the given stage.
```toml
[pkg.starship]
install_hint.brew.packages = ["starship"]
detect.which = "starship"
[[pkg.starship.shell.interactive_init.bash]]
type = "eval_output"
command = ["starship", "init", "bash"]
[[pkg.starship.shell.interactive_init.zsh]]
type = "eval_output"
command = ["starship", "init", "zsh"]
```
Stages (run in this order in the generated init script):
| `env_init` | Environment-only setup — sourced early, before login. |
| `login_init` | Login-shell setup — after env, before interactive. |
| `interactive_init` | Interactive shell only — prompts, keybindings, completion. |
Each stage has per-shell arrays: `bash` and `zsh`.
Every action entry has an optional `optional` flag:
| `optional` | bool | `false` | When `true`, a `${...}` placeholder that doesn't resolve skips the action silently instead of failing the run. |
Plus one of the kinds below (tagged by `type`):
| `comment` | `text` | `# <text>` |
| `source` | `path` | `. "<path>"` (leading `~/` → `$HOME/`) |
| `eval_output` | `command` (array) | `eval "$(<cmd>)"` |
| `source_output` | `command` (array) | `source <(<cmd>)` |
| `export` | `name`, `value` | `export NAME="VALUE"` |
| `line` | `line` | Literal line, no wrapping. |
| `path_prepend` | `value` | `export PATH="<value>:$PATH"` |
| `path_append` | `value` | `export PATH="$PATH:<value>"` |
All string fields support `${...}` expansion. `path`, `path_prepend.value`,
and `path_append.value` also accept `~/…` (translated to `$HOME/…` in the
emitted script).
### Configs
`configs` lists dotfiles the pkg owns. Each entry targets either
`~/.config/<name>/` or `~/<dir>/`:
```toml
[pkg.helix]
install_hint.brew.packages = ["helix"]
[[pkg.helix.configs]]
type = ".config"
source = "configs/helix"
symlinks = [
"config.toml",
"languages.toml",
"themes/onedark-boh.toml",
]
```
**`.config` variant** — lands at `~/.config/<name>/`:
| `type` | `".config"` | — | Tag. |
| `name` | single path component | pkg key | Override when the pkg key and the config dir differ (e.g. pkg `neovim` whose dir is `nvim`). |
| `source` | safe relative path | — | Path inside the zenops config repo to pull files from. |
| `symlinks` | array of safe relative paths | `[]` | Files listed here are symlinked; every other file under `source` is copied as a generated file. |
**`home` variant** — lands at `~/<dir>/`:
```toml
[pkg.starship]
install_hint.brew.packages = ["starship"]
[[pkg.starship.configs]]
type = "home"
dir = ".config/starship"
source = "configs/starship"
symlinks = ["starship.toml"]
```
| `type` | `"home"` | — | Tag. |
| `dir` | safe relative path | — | Directory under `~/`. |
| `source` | safe relative path | — | Path inside the zenops config repo. |
| `symlinks` | array of safe relative paths | `[]` | Listed = symlink; rest = generated file. |
Safe relative paths reject `..` traversal at parse time.
The listed-vs-generated split lets you keep the frequently edited files as
live symlinks into the config repo (edits survive `zenops apply`) while
letting zenops regenerate the derived ones (the shell init script is
rendered per-host; it wouldn't make sense to edit it in place).
---
## `[ssh]`
Manages `~/.ssh/allowed_signers`, the file git consults when verifying
SSH-signed commits (`git config gpg.ssh.allowedSignersFile`). Regenerated
on every `zenops apply`.
```toml
[[ssh.allowed_signers]]
type = "github"
username = "octocat"
principal = "octocat@example.com"
[[ssh.allowed_signers]]
type = "manual"
principal = "bob@example.com"
key_type = "ssh-ed25519"
key = "AAAAC3NzaC1lZDI1NTE5AAAAIExampleKeyMaterial"
```
Two entry shapes (tagged by `type`):
**`github`** — zenops fetches the user's SSH *signing* keys from
`https://api.github.com/users/<username>/ssh_signing_keys` via `curl`. Note
this is a different endpoint from `https://github.com/<username>.keys`,
which lists SSH *authentication* keys.
| `username` | string | GitHub username. |
| `principal` | string | Principal git records for the signature (commonly an email). |
Requires `curl` on `PATH`. A failed fetch aborts the apply — switch to
`manual` entries for offline stability.
**`manual`** — the full key material is in the config:
| `principal` | string | Principal. |
| `key_type` | string | e.g. `"ssh-ed25519"`, `"ssh-rsa"`. |
| `key` | string | Public key material (the base64 blob). |
---
## `[git]`
Generates `~/.gitconfig` from `[git]` plus `[user]`. Only writes the file
when there's something to record — identity, signing, or both.
Enable commit signing via `[git.signing]`, tagged by backend.
**SSH signing** (git 2.34+):
```toml
[git.signing]
type = "ssh"
key = "~/.ssh/id_ed25519-github.pub"
```
**Classic OpenPGP signing**:
```toml
[git.signing]
type = "gpg"
key = "ABCD1234DEADBEEF"
```
| `ssh` | `key` | Path to an SSH public key. Passed through verbatim — git expands `~` itself. |
| `gpg` | `key` | OpenPGP key ID or full fingerprint. |
Setting `[git.signing]` also writes `commit.gpgsign = true` and the matching
`gpg.format` (`ssh` or `openpgp`). With `type = "ssh"` *and* at least one
`[[ssh.allowed_signers]]` entry, zenops also writes
`gpg.ssh.allowedSignersFile = ~/.ssh/allowed_signers` so verification
picks up the managed file automatically.
---
## Template variables
Any field documented as supporting `${...}` expansion goes through the same
lookup. Two scopes:
**System inputs** (auto-populated):
| `${os}` | `"linux"` or `"macos"` (the current host). |
| `${brew_prefix}` | Homebrew install root, if detected (e.g. `/opt/homebrew`, `/usr/local`, `/home/linuxbrew/.linuxbrew`). Absent on hosts without Homebrew. |
| `${user.name}` | `[user].name`, when set. |
| `${user.email}` | `[user].email`, when set. |
**Per-pkg inputs** — every key-value pair in `[pkg.<name>.inputs]` is a
template variable visible inside that pkg's detect, shell actions, and
nested inputs:
```toml
[pkg.rustup]
[pkg.rustup.inputs]
bin_dir = "~/.cargo/bin"
[pkg.rustup.detect]
exists = "${bin_dir}/rustup"
```
**Shadowing** — when a per-pkg input and a system input share a key, the
per-pkg value wins. This lets a pkg override `${os}`, `${brew_prefix}`, etc.
for its own detect/init logic without affecting other pkgs.
**Unresolved placeholders** — a detect check with an unresolved `${...}`
reports "not installed" (the pkg silently misses, same as a failed leaf
check). For shell actions, an unresolved placeholder aborts the run unless
the action is marked `optional = true`, in which case the action is skipped.