Runefile Language Guide
A Runefile is a small, line-oriented file describing tasks (named blocks of commands)
plus a few settings and variables. If you know make or just, you’ll recognize it at a
glance. This guide is a practical, example-driven tour; the formal grammar lives in
GRAMMAR.md (the parser is the source of truth).
New to Rune? Read What is Rune? and Getting started first. For task-oriented deep dives on a single capability, see the guides; for runnable starting points, the examples.
The Runefile expression language is intentionally total (no loops, no recursion). Real logic belongs in task bodies; the configuration stays readable and statically analyzable.
The core unit. A task has a name, an optional parameter list, an optional executor, an optional dependency list, and an indented body:
# The comment directly above a task is its documentation (shown by `rune --list`).build: go build ./... @echo "done"Bodies use significant indentation. Each body line is a command. Two prefixes:
@— do not echo the command before running it.-— continue even if this line fails (ignore its error).
clean: -rm -rf dist # ignore failure if dist/ doesn't exist @echo "cleaned" # don't print this echo line itselfParameters
Section titled “Parameters”# Defaulted parameter:greet name="world": @echo "hello "{{quote(name)}}
# Required (no default):deploy env: @echo "deploying to "{{quote(env)}}
# Variadic — one-or-more (+) and zero-or-more (*):test +packages: go test {{quote_each(packages)}}
lint *paths: golangci-lint run {{quote_each(paths)}}Run them: rune greet Ada, rune deploy prod, rune test ./... ./cmd/....
Typed parameters
Section titled “Typed parameters”A parameter can carry an inline type annotation — name:kind, written with
no spaces around the colon (a spaced colon is always the task-header colon):
# Deploy the service.deploy env:enum("staging","prod") replicas:number="2": ./deploy.sh {{env}} {{replicas}}
build target:path="./dist" verbose:boolean="false": go build -o {{quote(target)}} ./...
test +packages:path: go test {{quote_each(packages)}}
# Extra flags, split into words on purpose: rune vet "-v -race"vet flags:raw="": go vet {{flags}} ./...The kinds are string (the default, spelled out), number (integers and
decimals), boolean (exactly true/false), path (any non-empty string —
a role marker, never an existence check), raw (any value, split into shell
words on purpose; see Quoting parameters), and
enum("v1","v2") (a closed,
case-sensitive set of static string values; variadic values are checked one by
one). Every invocation path enforces the constraint before anything runs —
CLI arguments, dependency arguments like (deploy "staging"), and MCP tool
calls — with an error naming the parameter, the rejected value, and the
accepted set. Agents see the constraints in the tool schema (see
mcp.md), so they compose valid calls on the first
attempt. Mistakes in the annotations themselves (unknown kind, empty or
duplicate enum values, a default that violates its own constraint) are static
errors with caret diagnostics (RUNE1006, RUNE2012–RUNE2015; see
diagnostics).
Quoting parameters
Section titled “Quoting parameters”{{p}} pastes a value into the command as it is, so the shell reads any space,
quote, or ; in it as shell syntax. Write {{quote(p)}}, or {{quote_each(p)}}
for a variadic, outside any shell quotes, so the value reaches the command as
exactly one argument, or one argument per element:
# Deploy to one environment.deploy env: ./deploy.sh {{quote(env)}}
# Lint the given paths.lint *paths: golangci-lint run {{quote_each(paths)}}Shell quotes around an interpolation do not make it safe: in "{{p}}" or
'{{p}}', a value that contains the same quote character ends the quoted
string early. rune analyze and the editor warn (RUNE2017, see
diagnostics) when a string or path parameter reaches a
shell body outside quote() and quote_each(). Shell bodies are the default
shell, a set shell or [script] that names a known shell, and an executor
named after one, such as (bash); python, node, and agent bodies are not
checked. Declare the parameter :raw when you want its value split into words.
Quoting does not stop a value that starts with - from being read as an
option, so pass -- before it where the command supports that.
Dependencies and post-hooks
Section titled “Dependencies and post-hooks”Dependencies run before the task; post-hooks (after &&) run after it succeeds;
failure hooks (after ||) run after it fails — the place to run a diagnostic and
turn a raw exit code into a post-mortem. Each task runs at most once per invocation
(failure hooks are the one exception: they run once per failing task).
deploy: build test && notify @echo "deploying"
build: go build ./...
test: || diagnose go test ./...
notify: @echo "deployed ✓"
diagnose: @echo "post-mortem for $RUNE_FAILED_TASK (exit $RUNE_FAILED_EXIT_CODE)" go vet ./...Clause order is fixed: dependencies, then &&, then ||. Failure hooks fire only when
the task’s own body fails (a failing dependency fires its own hooks instead), never
change the exit code, and never chain — a hook’s own || hooks are ignored while it
runs as a hook. The hook body sees the failed task’s name and exit code in
RUNE_FAILED_TASK and RUNE_FAILED_EXIT_CODE. On the CLI the hook’s output streams
under a diagnosing: header; over MCP it reaches the agent as a fix suggestion
section in the same tool response as the failure (masked and size-capped) — see
mcp.md. Cancelling with Ctrl-C aborts everything, hooks included.
Pass arguments to a dependency with the parenthesized form:
release: (build "release") @echo "releasing"
build target="debug": go build -tags {{quote(target)}} ./...A variadic parameter passed on by its bare name keeps its elements. In
ci *pkgs: (test pkgs), test’s variadic receives each of ci’s arguments as
its own element, so {{quote_each(pkgs)}} in test quotes them one by one. An
empty list expands to nothing there too, even when test declares +pkgs. Passed
to a scalar parameter, the variadic gives its space-joined text. Either way the
text {{pkgs}} produces is the same as before, and a call that passed the
target’s checks before still passes. The same rule applies to && post-hooks and
|| failure hooks.
Interpolation
Section titled “Interpolation”{{ expr }} evaluates an expression and substitutes the result. Use {{{{ for a literal
brace.
src := "src"build: @echo "compiling from {{src}}/"Variables and settings
Section titled “Variables and settings”# Variable assignment:out := "dist"bin := out / "app" # `/` joins paths (forward slash on every OS)
# Settings:set export # export Runefile variables into task environmentsset shell := ["bash", "-euo", "pipefail"] # override the shell for (sh) bodiesset secrets := ["DEPLOY_CFG"] # mask these variables' values in all outputset unmasked := ["OAUTH_METHOD"] # exempt from built-in secret-name patternsA bare set name is shorthand for set name := true.
Opt-in backward-compatibility pragma
Section titled “Opt-in backward-compatibility pragma”The default interpretation never changes under you. Opt into versioned semantics per file:
set rune_version := "1"Minimum Rune version
Section titled “Minimum Rune version”Pin the minimum Rune binary release your project needs (distinct from
rune_version, which is the Runefile language version):
set minimum_version := "0.8.0"The value must be a static semantic version and means “requires Rune ≥ this
version”. Rune checks it before running anything and refuses an older binary with
a clear diagnostic (required vs installed + an upgrade link), executing nothing.
Only the root Runefile’s requirement is effective — imported files cannot change
it. Pass rune --ignore-version to bypass the check (a warning is printed), and
rune version --check (add --json for machine-readable output) to report
whether the installed binary is compatible.
Secret masking
Section titled “Secret masking”Values of sensitive environment variables are masked as *** in everything
Rune emits — task stdout/stderr, echoed command lines, Rune’s own status
lines, and MCP tool results. Variables are detected by name (any name
containing TOKEN, SECRET, PASSWORD, PASSWD, APIKEY, API_KEY,
PRIVATE_KEY, ACCESS_KEY, CREDENTIAL, or AUTH, case-insensitive);
masking is always on and the task itself still receives the real value:
set secrets := ["DEPLOY_CFG"] # also mask names the patterns missset unmasked := ["OAUTH_METHOD"] # exempt a false positiveSee the secret masking guide for the exact rules and the guarantee’s limits (verbatim occurrences only; values shorter than 4 bytes are not masked).
Expressions
Section titled “Expressions”The expression sublanguage supports string literals, concatenation, path-join, conditionals, comparisons, and function calls.
# Concatenation (+) and path-join (/):greeting := "hello, " + namepath := "src" / "main.go"
# Conditional with comparisons (== != =~, where =~ is regex match):mode := if os() == "linux" { "lin" } else if os() == "windows" { "win" } else { "other" }String literals come in single ('...'), double ("..."), and triple-quoted
('''...''', """...""") forms; triple-quoted strings are de-dented.
Built-in functions
Section titled “Built-in functions”Rune ships a set of pure, total helper functions (mirroring the just family):
| Category | Functions |
|---|---|
| Host | os(), arch(), os_family(), num_cpus() |
| Environment | env("NAME"), env("NAME", "default") |
| Paths | join(...), clean(p), extension(p), file_name(p), file_stem(p), parent_dir(p), absolute_path(p) |
| Strings | uppercase, lowercase, capitalize, trim, trim_start, trim_end, replace(s, from, to), replace_regex(s, re, to) |
| Filesystem | path_exists(p), read(p) |
| Misc | uuid(), datetime(fmt), quote(s), quote_each(list), which(name), require(name), error(msg) |
home := env("HOME", "/tmp")name := capitalize(trim(raw_name))build: @echo "on {{os()}}/{{arch()}} with {{num_cpus()}} CPUs"quote(s) quotes its argument as one shell word. quote_each(list) quotes each
element of a variadic parameter as its own word, so every file name below reaches
prettier intact, spaces included. An empty list expands to nothing. Given
anything other than a variadic parameter, quote_each is the same as quote.
fmt *files: prettier --write {{quote_each(files)}}Executors
Section titled “Executors”A task body runs under an executor named in parentheses after the signature. The default
is sh — a pure-Go, cross-platform shell (mvdan.cc/sh) that behaves the same on
Linux, macOS, and Windows without invoking the system shell.
# default (sh) — no parentheses neededhello: echo "hi"
# Python body (shells out to the real python3 via a temp file):analyze (python): print("analyzing")
# Node body:bundle (node): console.log("bundling")
# Agent body — drives an installed AI-agent CLI (see docs/mcp.md):summarize (agent): Summarize the latest git changes.python/node/custom executors shell out to the real interpreter, so those runtimes must
be installed (see the Docker guide for the container caveat).
Shell override
Section titled “Shell override”set shell := [...] runs default (sh) bodies with a system shell instead of the built-in
one. Rune writes the interpolated body to a temporary script and runs the list with the
script’s path appended:
- The whole body runs as one script in one shell process, so variables and the current directory set on one line are visible on later lines.
- A failing line stops the task only when the shell’s exit-on-error flag (
-e) is set. Without it, later lines still run, and the task’s status is the last command’s. - Lines are not echoed, and the
@and-prefixes have no effect. Under-e, a-line that fails still stops the task. - A shell’s
-cwith no code after it (a habit fromjust) is accepted and dropped, so["bash", "-euo", "pipefail", "-c"]runs the same as["bash", "-euo", "pipefail"]. The same holds for a[script("...")]command. - A list that gives the shell its own code, such as
["bash", "-c", "exec bash -eu $0"], runs as written, with the script’s path as$0. A-cthat belongs to a wrapper or to a program that is not a shell (nix develop -c,node -c) is passed through unchanged.
Prefer the built-in shell for portability; see Executors.
Attributes
Section titled “Attributes”Attributes sit on their own line(s) above a task in [...]:
[private] # hidden from --list and MCP; callable only as a dependency[confirm("Really clean?")] # prompt at a terminal before running (--yes approves otherwise)[parallel] # run this task's dependencies concurrently[group("build")] # group label in listings[linux] [macos] [windows] [unix] # restrict to an OS (see below)[no-cd] # don't change into the Runefile's directory[network] # marks the task as network-using (MCP openWorldHint)[no-exit-message] # suppress the trailing error banner on failure[context] # project-health hook: output is injected into agent context[working-directory("./sub")] # run the body from a specific directory[env("KEY", "value")] # set an environment variable for the body[doc("Custom one-line doc")] # override the doc string[returns("JSON array of IDs")] # outcome description: shown to agents and in --list[param-doc("env", "Target environment")] # describe one parameter for the tool schema[script("/usr/bin/env python3")] # run the whole body as a script under this interpreter[cache(inputs = ["go.mod", "src/**/*.go"], outputs = ["dist/app"])] # content-hash caching[confirm] asks before a destructive task runs, and only a person at a terminal can
answer. When stdin is a pipe or a file (a script, a CI job, or an AI agent with a shell),
the task is declined without reading stdin and the run fails with a message naming
--yes, the deliberate way to approve [confirm] tasks unattended. Over MCP,
[confirm] tasks are refused unless the operator names them with --allow-destructive; see
Using Rune with AI Agents.
[context] marks at most one task as the project’s context hook: Rune runs it
when an agent session starts and injects its output into the agent’s context — see
Using Rune with AI Agents. The hook must run
unattended, so it cannot carry [confirm], take a parameter without a default, or
use the agent executor — and neither can any task in its dependency chain. It is
never exposed as an MCP tool, but stays runnable by name from the CLI.
OS availability
Section titled “OS availability”The OS attributes make a task exist only on the platforms it names. Several
attributes combine as OR ([linux, windows] means either), and [unix]
matches every platform except Windows. On a non-matching host the task is:
- hidden from
--list, the bareruneoverview, the interactive picker, shell completion, and the MCP tool list — an AI agent never sees it; - not runnable: invoking it by name fails before anything executes, with a diagnostic naming the required OS (exit 3);
- skipped silently as a dependency or post-hook, which turns per-OS tasks
into a dispatch pattern — one cross-platform task can depend on a
[linux], a[macos], and a[windows]variant, and only the matching one runs (a||failure hook is the exception: skipping it emits a one-line warning, because a silently missing diagnostic would hide why no post-mortem appeared):
# Install the toolchain for this platform.setup: setup-nix setup-win @echo "toolchain ready"
[unix]setup-nix: @echo "apt-get/brew install ..."
[windows]setup-win: @echo "choco install ..."rune --dump --format json reports the computed verdict per task in an
available field, alongside the raw attribute names.
Caching (opt-in)
Section titled “Caching (opt-in)”Rune never skips work based on timestamps. Caching is an explicit, per-task opt-in via
[cache]. A fingerprint is computed over the declared inputs, the task body, the resolved
variables, and the executor; a cache hit is logged, never silent.
[cache(inputs = ["go.mod", "go.sum", "**/*.go"], outputs = ["dist/app"])]build: go build -o dist/app ./...Clear the cache with rune --clear-cache.
Imports and modules
Section titled “Imports and modules”import "common.rune" # inline another Runefile's tasks/varsimport? "optional.rune" # ignore if the file is missing
mod tools "tools.rune" # namespace: invoke as `rune tools::build`Dotenv
Section titled “Dotenv”A project .env file is loaded into task environments (parsed via the same pure-Go shell),
so secrets and config come from the environment — never hard-coded in the Runefile.
Formatting
Section titled “Formatting”Rune is its own formatter. Reformat a Runefile canonically in place:
rune --fmtSee also
Section titled “See also”- Guides — task-oriented deep dives (dependencies, caching, executors, …)
- Examples — runnable starting points by use case
- CLI reference — flags, subcommands, exit codes
- Troubleshooting — errors, diagnostics, and exit codes
- GRAMMAR.md — the formal grammar
- MCP guide — exposing tasks to AI agents