Skip to content

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 itself
# 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/....

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).

{{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 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.

{{ expr }} evaluates an expression and substitutes the result. Use {{{{ for a literal brace.

src := "src"
build:
@echo "compiling from {{src}}/"
# Variable assignment:
out := "dist"
bin := out / "app" # `/` joins paths (forward slash on every OS)
# Settings:
set export # export Runefile variables into task environments
set shell := ["bash", "-euo", "pipefail"] # override the shell for (sh) bodies
set secrets := ["DEPLOY_CFG"] # mask these variables' values in all output
set unmasked := ["OAUTH_METHOD"] # exempt from built-in secret-name patterns

A bare set name is shorthand for set name := true.

The default interpretation never changes under you. Opt into versioned semantics per file:

set rune_version := "1"

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.

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 miss
set unmasked := ["OAUTH_METHOD"] # exempt a false positive

See the secret masking guide for the exact rules and the guarantee’s limits (verbatim occurrences only; values shorter than 4 bytes are not masked).

The expression sublanguage supports string literals, concatenation, path-join, conditionals, comparisons, and function calls.

# Concatenation (+) and path-join (/):
greeting := "hello, " + name
path := "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.

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)}}

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 needed
hello:
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).

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 -c with no code after it (a habit from just) 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 -c that 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 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.

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 bare rune overview, 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.

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.

import "common.rune" # inline another Runefile's tasks/vars
import? "optional.rune" # ignore if the file is missing
mod tools "tools.rune" # namespace: invoke as `rune tools::build`

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.

Rune is its own formatter. Reformat a Runefile canonically in place:

Terminal window
rune --fmt
  • 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