dek
Declarative environment setup. One TOML, any machine.
Install
# or
# setup completions
Usage
All commands have short aliases: apply, check, plan, run, state, test, dx (exec).
Config is loaded from: ./dek.toml, ./dek/, or $XDG_CONFIG_HOME/dek/ (fallback).
Config
# Packages
[] # auto-detects: pacman, apt, brew
= ["curl", "git", "htop"]
[]
= ["build-essential"]
[] # falls back to yay for AUR packages
= ["base-devel", "yay"]
[]
= ["bat", "eza", "ripgrep"]
[]
= ["github.com/junegunn/fzf@latest"]
[]
= ["prettier", "typescript"]
[]
= ["httpie", "tldr"]
[]
= ["poetry", "black"]
[]
= ["jq", "yq"]
# Systemd services
[[]]
= "docker"
= "active"
= true
# User services (systemctl --user, no sudo)
[[]]
= "syncthing"
= "active"
= true
= "user"
# Files
[]
= "~/.zshrc"
[]
= "~/.bashrc"
= { = "~/.config/app/config.json", = "1h" }
[]
= "~/.config/nvim"
[]
= ["export PATH=$HOME/.local/bin:$PATH"]
# Structured line management
[[]]
= "/etc/needrestart/needrestart.conf"
= "$nrconf{restart} = 'l';"
= "#$nrconf{restart} = 'i';"
= "replace"
[[]]
= "/etc/ssh/sshd_config"
= "PermitRootLogin no"
= "^#?PermitRootLogin\\s+"
= "replace"
# Shell
[]
= "ls -larth"
= "git"
[]
= "nvim"
# System
= "Europe/Istanbul"
= "workstation"
# Scripts (installed to ~/.local/bin)
[]
= "scripts/cleanup.sh"
# Custom commands
[[]]
= "setup-db"
= "psql -c 'SELECT 1 FROM pg_database WHERE datname=mydb'"
= "createdb mydb"
# Assertions
[[]]
= "dotty up to date"
= "git -C ~/dotty fetch -q && test $(git -C ~/dotty rev-list --count HEAD..@{upstream}) -eq 0"
= "dotty has remote changes"
[[]]
= "note conflicts"
= "rg --files ~/Sync/vault 2>/dev/null | grep conflict | sed 's|.*/||'"
[[]]
= "stow"
= "for p in common nvim tmux; do stow -d ~/dotty -n -v $p 2>&1 | grep -q LINK && echo $p; done"
File Fetch
Download files from URLs. Results are cached at ~/.cache/dek/url/. Use ttl to control cache expiry:
[]
# Cache forever (re-fetches only when cache is cleared)
= "~/.bashrc"
# Cache for 1 hour — re-fetches if older
= { = "~/.config/app/config.json", = "1h" }
Supported TTL units: s (seconds), m (minutes), h (hours), d (days). Can be combined: 1h30m.
Vars
Runtime variables defined in meta.toml, set in the process environment before anything runs. Available to all providers, commands, scripts — locally and remotely.
# meta.toml
[]
= "myapp"
= "/opt/default"
# Scoped by @label — applied when that label is selected
[]
= "/opt/staging"
= "staging-db"
[]
= "/opt/production"
= "prod-db"
# Scoped by config key
[]
= "true"
Base vars are always set. Scoped vars overlay when their selector is active:
Vars are inherited by all child processes, so [[command]] check/apply, [script], and remote dek apply all see them.
Cache Key
Skip steps when a value hasn't changed since last successful apply. Works on [[command]], [[service]], and [[file.line]].
cache_key — a string value (supports $VAR expansion):
[[]]
= "generate test data"
= "test -f /opt/test/input.csv"
= "generate-data.sh"
= "$INPUT_FILE_SIZE_MB" # only re-runs when size changes
cache_key_cmd — a command whose stdout is the cache key:
[[]]
= "deploy jar"
= "test -f /opt/dpi/jar/dpi.jar"
= "cp build/dpi.jar /opt/dpi/jar/"
= "sha256sum build/dpi.jar" # only re-deploys when jar changes
Cache state is stored in ~/.cache/dek/state/. The provider's check always runs — if the state is missing (e.g. file deleted), apply runs regardless of cache. When check passes and the cache key is unchanged, apply is skipped. When the cache key changes (e.g. a $VAR in meta.toml was updated), apply re-runs even if check still passes — this lets you force re-apply by changing a var.
Assertions
Assertions are check-only items — they report issues but don't change anything. Two modes:
check — pass if command exits 0:
[[]]
= "docker running"
= "docker info >/dev/null 2>&1"
= "docker daemon is not running"
= "some regex" # optional: also match stdout
foreach — each stdout line is a finding (zero lines = pass):
[[]]
= "stow packages"
= "for p in common nvim; do stow -n -v $p 2>&1 | grep -q LINK && echo $p; done"
In dek check, assertions show as ✓/✗. In dek apply, failing assertions show as issues (not "changed") and don't block other items.
Conditional Execution
Any item supports run_if — a shell command that gates execution (skip if non-zero):
[]
= ["base-devel"]
= "command -v pacman"
[[]]
= "desktop stow"
= "echo $(uname -n) | grep -qE 'marko|bender'"
= "..."
[]
= "test -d /etc/apt" # skip entire config file
Package:Binary Syntax
When package and binary names differ:
[]
= ["ripgrep:rg", "fd-find:fd", "bottom:btm"]
Installs ripgrep, checks for rg in PATH.
Split Config
dek/
├── meta.toml # project metadata + defaults
├── banner.txt # optional banner (shown on apply/help)
├── inventory.ini # remote hosts (one per line)
├── 00-packages.toml
├── 10-services.toml
├── 20-dotfiles.toml
└── optional/
└── extra.toml # only applied when explicitly selected
Files merged alphabetically. Use dek apply extra to include optional configs.
meta.toml
= "myproject"
= "Project deployment"
= "1.0"
= "0.1.28" # auto-update dek if older
= ["@setup", "@deploy"] # default selectors for apply
= "../devops/inventory.ini" # custom inventory path
= true # symlink dek + config on remote hosts
[]
= "ubuntu:22.04"
= true
= ["./data:/opt/data"] # bind mounts for test container
Labels & Selectors
Tag configs with labels for grouped selection:
# 10-deps.toml
[]
= "Dependencies"
= ["setup"]
[]
= ["curl", "git"]
# 20-deploy.toml
[]
= "Deploy"
= ["deploy"]
[]
= "/opt/app/app.jar"
When defaults is set in meta.toml, a bare dek apply applies only those selectors. Without defaults, it applies all non-optional configs (backward compatible).
Run Commands
Define reusable commands:
[]
= "Deploy the application"
= ["os.rsync"]
= "rsync -av ./dist/ server:/var/www/"
[]
= "Backup database"
= "scripts/backup.sh" # relative to config dir
[]
= "systemctl restart myapp"
= true # prompt before running
[]
= "journalctl -fu myapp"
= true # interactive, uses ssh -t
Remote Run
Run commands on remote hosts without deploying dek — just SSH the command directly:
-t— single host, prints output directly. Withtty: true, usesssh -tfor interactive commands.-r— multi-host from inventory, runs in parallel with progress spinners.tty: truecommands are rejected (can't attach TTY to multiple hosts).confirm: true— prompts[y/N]before running (works both locally and remotely).- Vars — base vars from
meta.toml[vars]are exported to the remote shell automatically, so$VARreferences in remote commands resolve correctly.
Shell Library
Put shared shell functions in data/functions.sh under your config directory. dek automatically sources it before every cmd, check, apply, run, and assert script:
~/.config/dek/
data/
functions.sh ← sourced automatically
10-tools.toml
20-apps.toml
# data/functions.sh
[[]]
= "laptop-brightness"
= "is_laptop && has_display && cat /sys/class/backlight/*/brightness | grep -q ."
= "is_laptop && brightnessctl set 50%"
[[]]
= "laptop"
= "is_laptop && echo yes || echo no"
Remote
Apply to remote hosts via SSH:
Use -q/--quiet to suppress banners (auto-enabled for multi-host). Use --color always|never|auto to control colored output.
Multi-host with Inventory
Ansible-style inventory.ini (one host per line, [groups] and ;comments ignored):
# inventory.ini
[web]
web-01
web-02
web-03
[db]
db-master
Hosts are deployed in parallel. Override inventory path in meta.toml:
= "../devops/inventory.ini"
Remote Install
With remote_install = true in meta.toml, dek symlinks itself and the config on remote hosts after deploy:
~/.cache/dek/remote/dek ← binary (persists across reboots)
~/.cache/dek/remote/config/ ← config
~/.config/dek → ~/.cache/dek/remote/config/
/usr/local/bin/dek → ~/.cache/dek/remote/dek (root)
~/.local/bin/dek → ~/.cache/dek/remote/dek (non-root)
This lets you run dek directly on the remote (e.g. dek apply, dek run) without re-deploying. Re-deploying updates the cached binary and config in-place, so the symlinks stay valid.
Deploy Workflow
Use [[artifact]] to build locally before shipping to remotes or baking:
[[]]
= "app.jar"
= "mvn package -DskipTests -q"
= ["src", "pom.xml"] # skip build if unchanged
= "target/app-1.0.jar" # build output
= "artifacts/app.jar" # placed in config for shipping
[]
= "/opt/app/app.jar"
[[]]
= "app"
= "active"
# 1. Builds artifact locally (skips if watch hash unchanged)
# 2. Packages config + artifact into tarball
# 3. Ships to all app-* hosts in parallel
# 4. Copies jar, restarts service
# Artifact is built and included in the baked binary
Artifacts are resolved before any config processing — they work with apply, apply -r, and bake.
Freshness can be determined two ways:
watch— list of files/directories to hash (path + size + mtime). Build is skipped when the hash matches the previous run. Best for source trees.check— shell command that exits 0 if the artifact is fresh. Use for custom logic (e.g.,test target/app.jar -nt pom.xml).
deps — local dependencies needed before build. Ensures build tools exist on the machine running the build:
[[]]
= "app.jar"
= "mvn package -DskipTests -q"
= ["apt.default-jdk:java", "apt.maven:mvn"]
= "target/app-1.0.jar"
= "artifacts/app.jar"
Format: "package:binary" — installs package if binary isn't in PATH. Prefix with package manager (apt., pacman., brew.) to force a specific one, or omit for auto-detection (os.).
Auto Update
Set min_version in meta.toml to ensure all users/hosts run a compatible version:
= "0.1.28"
If the running dek is older, it auto-updates via cargo-binstall (preferred) or cargo install, then exits with a prompt to rerun.
Inline
Quick installs without a config file:
Test
Bakes config into the binary and runs it in a container. The baked dek inside the container is fully functional — apply, list, run all work.
Containers are kept by default and named dek-test-{name} (from meta.toml name or directory). On subsequent runs, dek rebakes the binary, copies it into the existing container, reapplies config, and drops into a shell — installed packages and files persist.
Exec
Run commands directly in the test container:
Configure defaults in meta.toml:
[]
= "ubuntu:22.04"
= ["./data:/opt/data", "/host/path:/container/path"]
Mounts are bind-mounted into the test container. Relative host paths are resolved against the config directory.
CLI flags override meta.toml (-i/--image, -r/--rm).
Completions
Dynamic completions for configs, @labels, and run commands.
Manual
Via dek config
Add to any config (e.g., 15-shell.toml):
[[]]
= "dek completions"
= "dek setup"
= "dek _complete check"
Completions support all aliases (a, c, p, r, t, dx) and dynamically complete config keys, @labels, and run command names from whatever config is in the current directory.
State
Query system state via shell commands with optional rewrite rules, named templates, and dependencies. Probes run in parallel (respecting dependency order).
[[]]
= "machine"
= "uname -n"
[[]]
= "screen"
= "hyprctl -j monitors | jq -r '.[].description'"
= "1h" # cache probe output for 1 hour
= [
{ = "Samsung.*0x01000E00", = "tv"},
{ = "C49RG9x", = "ultrawide"},
]
= { = "{{ raw[:2] }}", = "{% if raw == 'tv' %}T{% else %}U{% endif %}" }
[[]]
= "hour"
= "date +%H"
= [
{ = "^(2[0-3]|0[0-7])$", = "night"},
{ = ".*", = "day"},
]
# Computed state — no cmd, just deps + templates
[[]]
= "summary"
= ["machine", "screen", "hour"]
= { = "{{ machine.raw }}/{{ screen.raw }}/{{ hour.raw }}" }
Rewrite rules are checked in order against raw stdout. First regex match wins, output replaced with value. No match = raw output. When a rewrite matches, the pre-rewrite value is preserved as original.
Templates
Named Jinja templates rendered after cmd+rewrite. Context includes raw, original, and all dependency values (dep.raw, dep.original, dep.<template>).
The fromjson filter parses JSON strings into objects for field access:
[[]]
= "weather"
= "curl -s 'https://api.example.com/weather'"
= "30m"
= "{{ (raw | fromjson).text }}"
= "{{ (raw | fromjson).tooltip }}"
TTL
Cache slow probe commands so they don't re-run every time. Cached output is stored in ~/.cache/dek/url/ and reused until the TTL expires. The raw command output is cached (before rewrites/templates), so rewrites and templates always re-evaluate.
[[]]
= "screen"
= "hyprctl -j monitors | jq -r '.[].description'"
= "1h" # re-run cmd only after 1 hour
No ttl = no caching (runs every time). Supported units: s, m, h, d (combinable: 1h30m).
Expressions
expr is a Jinja template rendered with dependency values to produce the raw value — an alternative to cmd for computed states. Rewrites apply to the result, so you can combine deps into a matchable string:
[[]]
= "network"
= ["machine", "ssid", "networktype"]
= "{{ machine.raw }}:{{ networktype.raw }}:{{ ssid.raw }}"
= [
{ = "marko:ethernet", = "home"},
{ = "bender:ethernet", = "ng"},
{ = ".*:wifi:home", = "home"},
{ = ".*:wifi:office", = "ng"},
{ = ".*", = "other"},
]
Dependencies
States can depend on other states via deps. Dependencies are evaluated first (topologically sorted), and their results are available in templates. States without cmd are computed purely from deps+templates.
Alias: s. Useful in scripts:
&&
&&
theme=
icon=
File Templates
Render Jinja template files with state values, built-in variables, and vars files. Templates are checked/applied like any other file provider.
[[]]
= "screen"
= "hyprctl -j monitors | jq -r '.[].description'"
= "1h"
= [{ = "Samsung.*", = "tv"}]
= { = "{% if raw == 'tv' %}T{% else %}U{% endif %}" }
[[]]
= "hour"
= "date +%H"
[[]]
= "templates/waybar.json.j2"
= "~/.config/waybar/config.json"
= ["screen", "hour"]
templates/waybar.json.j2:
// Generated on {{ hostname }} by {{ user }}
{
"output": "{{ screen.raw }}",
"icon": "{{ screen.icon }}",
"mode": "{{ hour.raw }}"
}
Template Context
Built-ins (always available): hostname, user, os, arch
States (from states field): each state is an object with .raw, .original, and any template variant keys (e.g. screen.icon).
Only states listed in states (and their transitive dependencies) are evaluated.
Missing variables render as empty strings (lenient mode).
Vars Files
Load external variable files (YAML or TOML) into the template context — like Ansible's vars files. Supports nested maps, arrays, and complex structures.
Shared vars — available to all templates:
[]
= ["vars/common.yaml", "vars/defaults.toml"]
Per-template vars — merged on top of shared vars (overrides):
[[]]
= "templates/app.conf.j2"
= "~/.config/app/config"
= ["vars/site.yaml"]
= ["screen"]
File format is detected by extension: .yaml/.yml for YAML, .toml for TOML. All top-level keys become template variables.
Example vars/site.yaml:
site_vars:
kafka_server:
- 169.254.0.10:9092
- 169.254.0.11:9092
site_id: 1
In templates: {{ site_vars.site_id }}, {% for s in site_vars.kafka_server %}{{ s }}{% endfor %}.
Bake
Embed config into a standalone binary: