Configuration¶
Statemate reads three levels of configuration:
| File | Purpose |
|---|---|
mate.yaml in the repository |
The configuration itself, committed and shared |
~/.config/statemate/mate.yaml |
Machine-local overrides, not committed |
.mate.yaml in a source directory |
Settings scoped to one source |
The repository config may be YAML or TOML: mate.yaml, mate.yml and
mate.toml are looked for in that order, and the same goes for .mate.yaml in a
source. The local config is always mate.yaml. The directory containing the
config file is the source directory, and relative paths in sources,
var_files and include resolve against it. Other paths, such as
age.identity and secrets_cache, should be absolute or start with ~/.
Unknown keys are ignored without a warning, so a misspelt key simply has no
effect. mate doctor and mate check catch most of the mistakes that matter.
Finding the config¶
statemate does not search parent directories. It uses the first of:
--config/-con the command line$STATEMATE_DIRsource_dirin the local config- the current directory
mate init writes source_dir for you, so after setup mate works from any
directory. mate config source-dir prints the directory in use.
mate.yaml¶
sources: [zsh, nvim, git]
default_source: misc
target_base: "~"
profiles:
work:
extends: base
detection:
hostname: "work-*"
age:
identity: "~/.config/statemate/key.txt"
recipients: ["age1..."]
variables:
email: "you@example.com"
variable_commands:
gpg_key: "gpg --list-secret-keys --with-colons | awk -F: '/^fpr/{print $10; exit}'"
var_files:
- .matedata/secrets.yaml
packages:
brew: [ripgrep, fd]
include:
- packages.yaml
ignore:
- "*.md"
- .DS_Store
editor: nvim
diff_tool: delta
aur_helper: paru
secrets_cache: "~/.local/state/statemate/secrets.age"
Keys¶
| Key | Type | Description |
|---|---|---|
sources |
list | Source directories to scan, relative to the source directory or absolute |
default_source |
string | Source mate add uses when none is given |
target_base |
string | Root to deploy into. Defaults to ~ |
profiles |
map | Profile definitions — see Profiles |
profile |
string | Force a profile, skipping detection |
source_dir |
string | Where to find mate.yaml. Only meaningful in the local config |
editor |
string | Editor for mate edit, with any arguments (code --wait). Overrides $VISUAL and $EDITOR; falls back to vi |
diff_tool |
string | External diff tool for mate diff, with any arguments. Run as <tool> <old> <new> |
age |
map | Encryption settings — see age |
variables |
map | Values available to templates as .Vars |
variable_commands |
map | Variables whose values come from shell commands, run at load |
var_files |
list | YAML/TOML files to load variables from |
packages |
map | Packages to install — see Packages |
hooks |
map | Commands to run when matching files change — see Hooks |
include |
list | Files to merge packages and variables from. Also valid inside a profile |
ignore |
list | gitignore-style patterns excluded from scanning |
aur_helper |
string | AUR helper binary, yay or paru. If unset, yay is used if installed, else paru |
secrets_cache |
string | Path to the encrypted secrets cache |
age¶
age:
identity: "~/.config/statemate/key.txt"
identity_command: "op read op://Private/age/key"
recipients:
- age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
identity is a path to an age private key file, as written by age-keygen, used
for decryption. Alternatively identity_command runs a command that prints the
key on stdout, which is useful when the key lives in a password manager rather
than on disk. If both are set, identity_command wins. Its output must be the
bare AGE-SECRET-KEY-… line, without the comments age-keygen writes.
recipients are the public keys (age1…) files are encrypted to. Include every
key that needs to read them. SSH keys are not supported as recipients.
Encryption walks through creating a key and storing it.
variable_commands¶
Runs a shell command and stores its trimmed output as a variable:
variable_commands:
hostname_short: "hostname -s"
gpg_key: "gpg --card-status | awk '/Signature key/{print $NF}'"
Each command runs through sh -c whenever templates are rendered, which is most
commands, so keep them fast. A failing command aborts the command you ran.
var_files¶
Loads variables from YAML or TOML files, merged in order after variables:
var_files:
- .matedata/vars.yaml
- .matedata/secrets.yaml # resolves .matedata/secrets.yaml#encrypted too
Paths are relative to the source directory, or absolute, or start with ~/. A
missing file is skipped without error, and so is a file that is not .yaml,
.yml or .toml. If the plain path does not exist, statemate looks for the same
path with an #encrypted suffix and decrypts it, so the config need not change
when you encrypt a var file.
include¶
Splits packages and variables across files:
include:
- packages.yaml
- .matedata/secrets.yaml#encrypted
# packages.yaml
packages:
brew: [ripgrep, fd]
variables:
email: "me@example.com"
Only packages and variables are read from an included file; other keys are
ignored. Package lists are appended (duplicates dropped) and variables merged, so
an include adds to what mate.yaml already declares. Where both define the same
variable, the include wins. A missing include file is skipped.
An included file may be #encrypted, and as with var_files the suffix is found
automatically if the plain path does not exist. This is the usual way to keep a
list of secret-bearing variables in the repository.
Profiles accept their own include, merged into that profile only:
profiles:
work:
include: [work-packages.yaml]
Every profile's includes are read when the config loads, including inactive
profiles. An #encrypted include therefore needs a working age identity on every
machine, not only on those that use that profile.
ignore¶
gitignore-style patterns for paths statemate should not treat as managed files:
ignore:
- "*.md"
- "!**/.claude/**/*.md" # but keep these
- .DS_Store
Applies to all sources. For patterns that concern one source only, use ignore in
that source's .mate.yaml, where patterns match the path inside the source.
How patterns differ from git's:
!re-includes a file, with the last matching pattern winning. It cannot re-include anything inside an ignored directory, because statemate does not descend into one.?is a literal character, not a wildcard.- A pattern with a
/in the middle is not anchored:etc/fooalso matchesx/etc/foo. - Top-level patterns match
<source>/<path>, so a leading/anchors at the source name:/zsh/notes.txt.
.git, .matescripts, and .mate.yaml (.yml, .toml) are never deployed,
so they need no pattern. .mateignore files are no longer supported.
Profiles¶
A profile bundles the sources, variables, and packages for a kind of machine.
profiles:
base:
sources: [common, shell]
variables:
email: "me@example.com"
work:
extends: base
detection:
hostname: ["work-laptop", "work-*"]
variables:
email: "me@company.com"
packages:
brew: [slack, awscli]
arch:
extends: base
detection:
mode: and
os: linux
command: "test -f /etc/arch-release"
packages:
pacman: [keyd]
Detection¶
The first matching profile wins. Profiles are sorted by how deep they sit in an
extends chain, deepest first, and alphabetically within the same depth. So
every profile that extends something is tried before every profile that does
not, which makes OS-level profiles natural fallbacks.
| Field | Matches |
|---|---|
hostname |
The hostname. A pattern containing * is a glob; anything else must match exactly. A list matches any entry |
user |
$USER (or $USERNAME), with the same rules as hostname |
os |
linux, darwin, … (Go's GOOS). A single value |
arch |
amd64, arm64, … (Go's GOARCH). A single value |
command |
A shell command, run with sh -c; matches when it exits 0 |
mode |
or (default, where any field matches) or and (all must match) |
A profile with no detection block, or an empty one, is never auto-detected;
select it with --profile or profile: in the config. This is the normal shape
for a base profile that exists only to be extended.
When no profile matches, only the top-level sources and variables apply, and
#profile: files are not filtered at all. Make
sure every machine matches some profile.
Inheritance¶
extends names a single parent, and chains may be several levels deep. Along the
chain:
- sources are added: top-level
sources, then each ancestor's, then the profile's own, without duplicates - variables are merged, the child winning where both define one
- packages are added, like sources
- detection is not inherited
Inheritance is respected everywhere profiles are used: file filtering by
#profile:, script and hook filtering, and source
resolution.
Selecting a profile¶
In precedence order:
--profile/-pon the command lineprofile:in the local config ormate.yaml$STATEMATE_PROFILE- Automatic detection
mate profile shows which profile is active, how it was chosen, and which sources
it resolves to.
Forcing a profile that is not defined is not an error. It selects no profile's
sources or variables, and only files tagged with that exact name pass the
#profile: filter.
Local config¶
~/.config/statemate/mate.yaml (or $XDG_CONFIG_HOME/statemate/mate.yaml) holds
machine-specific settings that should not be committed:
source_dir: "~/dotfiles"
profile: work
editor: nvim
source_dir is what lets mate run from any directory. Without it, statemate
looks for mate.yaml in the current directory.
Only a subset of keys is honoured here: sources, default_source,
target_base, profile, editor, age, variables, var_files,
variable_commands, packages, profiles, diff_tool, and hooks. Others,
including include, ignore, aur_helper and secrets_cache, are ignored.
Each key replaces the repository's value; nothing is merged. A local
variables: with one entry drops all of the repository's top-level variables,
and a local profiles: replaces every profile. The exception is hooks, which
are merged by name: a local hook replaces a repository hook of the same name, and
the rest are kept. See Hooks.
A local config that fails to parse is ignored without a warning. Run
mate doctor if local settings seem to have no effect.
Source directory config¶
A .mate.yaml inside a source directory configures that source alone:
# arch/.mate.yaml
profile: arch
owner: root
group: root
perm: "644"
targets:
etc: /etc
ignore:
- "*.md"
packages:
pacman: [keyd]
generate:
- target: .config/app/generated.conf
mode: "600"
content: |
key = {{ .Vars.api_key }}
| Key | Description |
|---|---|
profile |
Default #profile: for every file in the source that has none of its own |
target_base |
Deploy this source relative to a different root. Disables targets |
targets |
Map a top-level subdirectory to a path |
ignore |
Patterns as for ignore, matched against the path inside this source |
owner |
Default owner for files and directories in the source |
group |
Default group for files and directories |
perm |
Default mode for files, octal string. Directories are not affected |
packages |
Packages this source needs |
generate |
Files created from inline content — see below |
hooks |
Hooks matching only this source's files — see Hooks |
Attributes in file and directory names take precedence over owner, group
and perm.
profile here only tags files. The source's packages, generate entries and
hooks are not filtered by it; to keep a whole source off a machine, list it under
that machine's profile sources instead.
.mate.yaml is itself rendered as a template before it is parsed,
so {{ .Vars.workspace }} works inside it, and so do loops:
# ssh/.mate.yaml: one private key file per name in the sshKeys variable
generate:
{{- range $key := (var "sshKeys") }}
- target: ".ssh/keys.d/id_{{ $key }}"
mode: "0600"
content: |
{{ indent 6 (bitwarden $key "ssh" "private") }}
{{- end }}
A .mate.yaml that fails to parse or render is an error that names the file,
rather than being skipped.
targets¶
Maps a subdirectory of the source onto another path, which is how system files
are managed. Only the first component of a file's path is looked up, so etc
maps the whole etc/ subtree; a key such as etc/keyd never matches. Values may
start with ~ and may use template variables. See
Managing System Files.
targets:
etc: /etc
arch_root/
.mate.yaml
etc/
issue → /etc/issue
keyd/default.conf → /etc/keyd/default.conf
Files outside the mapped directories still deploy to the source's normal target
base. targets is ignored if the same .mate.yaml sets target_base.
Directories outside your writable tree are created with sudo as needed.
generate¶
Creates a file from content in the config rather than from a file in the repository:
generate:
- target: .config/app/config.toml
mode: "600"
profile: work
content: |
token = "{{ bitwarden "app" "field" "token" }}"
| Field | Description |
|---|---|
target |
Path to write, relative to the source's target base, absolute, or starting with ~ |
mode |
File mode, octal string. Default 0644 |
profile |
Only generate under this profile (or one extending it) |
content |
The file content |
content is rendered as part of the whole .mate.yaml, before YAML parsing, so
multi-line values must be indented to stay inside the block scalar. That is what
indent 6 does in the example above. targets and ignore do not apply to
generated files.
Useful for short files that would otherwise need their own source file, and for content assembled from variables or secrets.
Environment variables¶
| Variable | Effect |
|---|---|
STATEMATE_DIR |
Where to find mate.yaml. Overrides source_dir and the current directory, but not --config |
STATEMATE_PROFILE |
Selects a profile, below config but above detection |
XDG_CONFIG_HOME |
Where the local config lives. Default ~/.config |
XDG_DATA_HOME |
Where state.db lives. Default ~/.local/share |
XDG_STATE_HOME |
Where the secrets cache lives. Default ~/.local/state |
VISUAL, EDITOR |
Editor for mate edit, if editor: is unset |
USER, USERNAME |
The user for profile detection and .Username |
STATEMATE_DIR is handy for working against a second repository without touching
your config:
STATEMATE_DIR=~/other-dotfiles mate status
Scripts also receive their own environment variables, and hooks theirs.