Raw shell, but idempotent, previewable, and fast.
shellf runs configuration as plain shell — but structured so every action is idempotent (skip when already in the desired state), previewable (a dry-run shows what would change, without touching anything), and fast. It is agentless: a single static binary pushes an ephemeral agent over SSH that evaluates the plan on the target, then vanishes. Nothing stays installed.
Status: experimental (0.1.0). Ships a stdlib of instructions (
apt.install,service,dir-ensure,file-*,ufw.*,docker.*, …) written as shellfdefs, plusfile-copyand a rawshellform; you write the plan and inventory. User-supplied instruction libraries (imports), cross-distro, and cross-arch agents are not there yet. Debian/systemd targets,linux/amd64control host.
CGO_ENABLED=0 go build -o shellf ./cmd/shellf
A static binary. The same binary is what gets pushed to targets as the agent.
Describe your hosts in an inventory file (hosts.shellf):
defaults = { user: "root", port: "22" }
host web1 = { address: "10.0.0.1" }
host web2 = { address: "10.0.0.2" }
group web = [web1, web2]
Authentication uses your ssh-agent (SSH_AUTH_SOCK) by default, so an encrypted
key never leaves the agent. To pin a specific key instead, add key: "~/.ssh/id_…"
to defaults or a host (it is an optional override).
Describe what to do in a plan file (plan.shellf):
on web {
apt.install("nginx")
file-copy("/tmp/nginx.conf", "/etc/nginx/nginx.conf")
service("nginx", "true", "true") # running now, enabled at boot
}
Preview first — this touches nothing, and reports what would change:
shellf run --inventory hosts.shellf --check plan.shellf
Then apply:
shellf run --inventory hosts.shellf plan.shellf
Hosts run in parallel; steps run in order per host. A re-run is idempotent
(ok.alreadyInstalled, ok.alreadyConverged, …).
| Construct | Form |
|---|---|
| Defaults | defaults = { user: "…", port: "…" } |
| Host | host <alias> = { address: "…", user: "…", port: "…" } |
| Group | group <name> = [<alias>, <alias>] |
Omitted host fields fall back to defaults, then to 22 for the port. Only
address is required. A host may belong to several groups. key: "…" is an
optional field (a pinned ssh key); without it, authentication uses the ssh-agent.
A host with local: "true" is provisioned on the control host itself, with no
SSH — host self = { local: "true" } (no address needed). Same agent, plan, and
reports as a remote target; as root still goes through sudo.
| Construct | Form |
|---|---|
| Block | on <group-or-host> { <steps> } — blocks run sequentially |
| Parallel | parallel { <steps> } — branches run concurrently on one host |
Blocks target a group (or a single host). A host that errors is dropped from later blocks.
A worked tour. Runnable examples live under examples/ — start with
webserver/, then the containerized blog/
for user defs, imports, secrets, templates, and docker/ufw; see
examples/README.md. The shared inventory sits at
examples/inventory.shellf, outside the plan directories
(a plan's directory is its def package, ADR-0014).
Variables come from the inventory (per-host) or --set. Use them as bare
identifiers, or ${name} inside strings:
host web = { address: "10.0.0.1", pkg: "nginx", webroot: "/var/www/app" }
on web {
apt.install(pkg) # `pkg` resolves per host
dir-ensure(webroot)
}
Secrets (ADR-0018) come from a file or an env var — never the command line
(so not in ps/history) and never the plan (so not committed):
shellf run plan.shellf --secret-file rclone_pass=./secret --secret-env db=DB_PW
A secret is a variable like any other (${rclone_pass}), but shellf redacts
its value (***) from every report, --check, and status. Honest limit: the
secret still reaches the target (in the request file, 0600, and the process
env) — root there can read it; at-rest secrecy is not yet solved.
Control flow. if takes an instruction (or a captured result); the branch is
taken on its outcome. dir-exists is a read-only question, so it stays honest
in --check:
if dir-exists("/opt/app") { service("app", "true", "true") }
if !dir-exists("/opt/app") { dir-ensure("/opt/app") } # act only when absent
Capture and match outcomes. An instruction returns a Result: a tagged
outcome (ok/err + a tag), plus a changed flag.
x = file-write("/etc/app.conf", cfg)
if x { restart() } # `if x` = it succeeded; `if !x` = it failed
if x == ok.written { reload() } # match a specific tag
if x.changed { … } # did it actually act (not a converged skip)?
Error handling. By default the first err halts the host. To handle a
specific error, mark the instruction ? and test it — testing == err without
a ? is a compile error (the branch would be unreachable):
x = apt.install("nginx")?
if x == err.dbLocked { retry() } else { report() }
Privilege escalation. shellf runs as the SSH user. as <user> escalates a
block (via sudo/doas); many stdlib defs (apt.install, service, …) declare
as root themselves and escalate on their own:
on web {
apt.install("nginx") # escalates itself (intrinsic `as root`)
as root { # escalate a block of generic instructions
dir-ensure("/opt/app")
file-write("/etc/app.conf", cfg)
}
}
Custom instructions are defs written in shellf. A def has phases (observe
read-only, apply effectful) and returns an outcome. observe reports the
current state as a state(...) record; shellf compares each field to the
arguments and runs apply only on a mismatch — no hand-written skip. Inside a
def, a shell {} is a struct — read .exit/.stdout, and if r / if !r test
its success:
def install(pkg: str) as root {
observe {
return state(installed: shell { dpkg -s "$pkg" >/dev/null 2>&1 }.exit == 0)
}
apply {
r = shell { apt-get install -y "$pkg" }
if !r { return err.runtime(r) }
return ok.installed
}
}
A field with no same-named argument (like installed) must simply hold;
fields that match a parameter (service → running, git-clone → url) are
compared to it. See ADR-0013.
Preview, then apply. --check runs only the read-only phases, never mutates,
and prints would.<tag> for what would change. A second real run is idempotent
(everything reads ok.already). shellf status reports the same observed state
as current → desired, without acting.
Most instructions are defs written in shellf and embedded in the binary; only
shell and file-copy are Go builtins. All are idempotent (observe skips
apply when the desired state already holds).
- Packages & services —
apt.install(pkg)·apt.update()·service(name, running, enabled)(running/enabled are"true"/"false"; a.timerunit works as the name) ·service-restart(name)·service-reload(name)·systemd-daemon-reload()·user-group(user, group)·user-ensure(name, shell) - Files & directories —
file-copy(src, dst)(target-side copy) ·template(src, dst)(render a control-host file's@{var}and deliver it) ·dir-copy(src, dst)(deliver a control-host tree verbatim, binary-safe) ·file-write(path, content)·file-mode(path, mode)·file-replace(path, key, value)(akey=valueline) ·file-line(path, line)·file-delete(path)·file-download(url, dst, sha256)·dir-ensure(path)·dir-owner(path, owner)·archive-extract(src, dst)·archive-extract-member(src, dst, member)(one file out of a tarball) ·git-clone(url, dst)·git-sync(url, dst, ref)(update to a pinned ref) - Questions (read-only, deterministic in
--check) —dir-exists(path)·file-exists(path)·http-check(url, status)·wait-for(url, timeout)(retries until ready) - Firewall —
ufw.enable()·ufw.default(incoming, outgoing)·ufw.open(port, proto) - Docker —
docker.install()·docker.network(name)·docker.compose-up(dir, build)(build"true"rebuilds local images; always re-applies —up -dis idempotent)
Write your own — see Writing shellf.
The first-class citizen: run anything the builtins don't cover. shell is a
special form, not a name(args) call.
on server {
shell docker compose up -d # one-line: ends at the newline
shell { # block: raw, verbatim (heredocs work)
curl -fsSL https://get.docker.com | sh
}
# idempotent + previewable: gate the effect on a read-only test
if !shell { docker network inspect web } {
shell { docker network create web }
}
}
- A bare
shellalways runs (raw, like bash; not previewable). - To make it idempotent and previewable, gate it:
if !shell { <test> } { shell { <cmd> } }— the test is read-only, so--checkruns only the test, never the command. - Escalate with
shell as root { … }(see Writing shellf). - A block ends at its balanced
}. A lone unbalanced}in a string ends it early — use a heredoc or the one-line form.
shellf run --inventory <hosts.shellf> [flags] <plan.shellf>
| Flag | Meaning |
|---|---|
--inventory <file> |
inventory file (required) |
--check |
dry-run: decide and preview, never mutate |
--known-hosts <file> |
host-key file (default ~/.ssh/known_hosts) |
--insecure |
skip host-key verification (dev only) |
One static binary. The control host orchestrates; pushed over SSH, it re-runs as an ephemeral agent that executes the plan locally on each target — then vanishes.
Two planes, kept separate. The orchestration plane (control host) decides
which hosts run what, in what order. The execution plane is the agent: the
binary is pushed over SSH (cached by hash, skipped on re-runs), evaluates the
plan on the target, and stays resident between jobs — then self-erases after
an idle TTL, leaving nothing after a reboot. Guards are read-only, which is what
makes --check honest across a whole fleet.
Shell values are passed to the target via the environment, never concatenated
into commands — so a value like nginx; rm -rf / cannot inject a command.
