@runfile/cli 0.47.0 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -1,835 +1,807 @@
1
1
  # Runfile
2
2
 
3
- [Quick start](#quick-start) · [Why Runfile?](#why-runfile) · [Features](#what-makes-it-different) · [VS Code](#vs-code-extension) · [Platforms](#platform-support) · [Docs](.github/DOCS.md)
3
+ [Quick start](#quick-start) · [Examples](#what-a-runfile-looks-like) · [Language](#the-language) · [Properties](#properties) · [Commands](#commands) · [Editors](#editor-support)
4
4
 
5
- **One JSON file. One binary. Every OS. Every shell.**
5
+ **One directory. One binary. Every OS.**
6
6
 
7
- A modern command runner that replaces Makefiles, shell scripts, and `npm run` — without the platform headaches.
7
+ A command runner that replaces Makefiles, shell scripts and `npm run` — without the platform headaches.
8
8
 
9
9
  ```bash
10
- $ run dev --port=4000
11
- ```
12
-
13
- ```jsonc
14
- {
15
- "$schema": "https://github.com/JoaaoVerona/runfile/releases/latest/download/v0.schema.json",
16
- "targets": {
17
- "dev": {
18
- "description": "Starts the dev server",
19
- "commands": "vite",
20
- "env": { "PORT": "{{ ARG.port ? '3000' }}" }
21
- },
22
- "build": {
23
- "description": "Type-checks and builds in parallel",
24
- "commands": [
25
- "@type-check",
26
- "vite build",
27
- {
28
- "if": "{{ RUN.os == 'windows' && FLAG.wsl }}",
29
- "then": "wsl --shell-type login -- vite build"
30
- }
31
- ],
32
- "envFiles": [".env", ".env.{{ ARG.env ? 'development' }}"],
33
- "parallel": true
34
- },
35
- "type-check": {
36
- "description": "Type-checks source code",
37
- "commands": "tsc --noEmit"
38
- }
39
- }
40
- }
10
+ run dev --port=4000
41
11
  ```
42
12
 
43
- ---
13
+ Your project's tasks live in a `runfiles/` directory, **one target per file**:
44
14
 
45
- ## Quick start
15
+ ```
16
+ runfiles/
17
+ _shared.run settings every target here inherits
18
+ dev.run → run dev
19
+ build.run → run build
20
+ check.run → run check
21
+ api/deploy.run → run api:deploy
22
+ web/runfiles/build.run → run web:build
23
+ ```
24
+
25
+ A target is a file, its name is its path, and its first comment block is its description. There is no central
26
+ file to merge, so two people adding a task never conflict.
46
27
 
47
- **1. Install:**
28
+ ```sh
29
+ # runfiles/dev.run
30
+ # Start the dev server
48
31
 
49
- npm:
32
+ .env-file = ".env.local"
33
+ .env.PORT = ARG.port ? "3000"
34
+
35
+ $ vite
36
+ ```
50
37
 
51
38
  ```bash
52
- npm install -g @runfile/cli
39
+ $ run :list
40
+ build Type-check and build
41
+ check Non-mutating gate
42
+ dev Start the dev server
53
43
  ```
54
44
 
55
- Linux / macOS:
45
+ ## Quick start
56
46
 
57
47
  ```bash
58
48
  curl -fsSL https://github.com/JoaaoVerona/runfile/releases/latest/download/install.sh | sh
49
+ run :init # creates runfiles/ with an example
50
+ run :list # every target, with descriptions
51
+ run <target> --help # what one target does, and what it reads
52
+ ```
53
+
54
+ ```bash
55
+ run build --env=prod # an argument for the target
56
+ run --dry-run build # print what would run, without running it
57
+ run :completions install bash # tab-completion (or zsh, fish, powershell)
59
58
  ```
60
59
 
61
- Windows (via PowerShell):
60
+ Runner flags go **before** the target name; everything after it belongs to the target. `run build --dry-run`
61
+ passes `--dry-run` to `build` as `FLAG.dry-run` and runs it for real — so the position is the whole meaning.
62
+ `run` warns when a target is handed a flag it never reads, which is what catches that.
63
+
64
+ In CI, the setup action installs `run` and puts it on PATH, so every step after it is a target name:
62
65
 
63
- ```powershell
64
- iwr https://github.com/JoaaoVerona/runfile/releases/latest/download/install.ps1 | iex
66
+ ```yaml
67
+ - name: Setup Runfile
68
+ uses: JoaaoVerona/runfile/.github/actions/setup@v1
69
+
70
+ - run: run check
71
+ - run: run test
65
72
  ```
66
73
 
67
- **2. Generate a starter `Runfile.json`** in your project root:
74
+ `@v1` is the major alias, moved to each release as it ships — so a fix arrives without editing every
75
+ workflow, and a new major never arrives unannounced. Pin `@v1.0.0` instead to hold one exact version.
68
76
 
69
- ```bash
70
- $ run :init
77
+ ## What a runfile looks like
78
+
79
+ These are complete files. Nothing is elided.
80
+
81
+ ### One command that works on three operating systems
82
+
83
+ `match` on `RUN.os` instead of shipping three scripts and a wrapper.
84
+
85
+ ```sh
86
+ # runfiles/setup/system.run
87
+ # Install the OS-level build prerequisites
88
+
89
+ match RUN.os
90
+ case "linux"
91
+ $ sudo apt-get update
92
+ $ sudo apt-get install -y build-essential libssl-dev pkg-config
93
+ case "mac"
94
+ $ xcode-select --install || true
95
+ $ brew install openssl pkg-config
96
+ default
97
+ print("No system setup for {{ RUN.os }} — see docs/prerequisites.md")
98
+ end
71
99
  ```
72
100
 
73
- **3. List and run targets:**
101
+ ### A gate that names the file that failed
74
102
 
75
- ```bash
76
- $ run :list
77
- $ run build
78
- $ run test --release
79
- $ run dev --port=4000
103
+ The loop is the language's, so a failure reports the one command that broke rather than the whole script.
104
+
105
+ ```sh
106
+ # runfiles/check.run
107
+ # Non-mutating gate: every compose file is valid
108
+
109
+ for compose in glob("**/docker-compose.yml")
110
+ $ docker compose -f {{ compose }} config -q
111
+ end
112
+
113
+ print("check: every docker-compose.yml is valid")
80
114
  ```
81
115
 
82
- **4. Add tab completion** (optional):
116
+ ```
117
+ [runfile] error: `docker compose -f observability/loki/docker-compose.yml config -q` exited with status 1
118
+ ```
83
119
 
84
- ```bash
85
- $ run :completions install bash # or zsh, fish, powershell
120
+ ### Waiting for something to come up
121
+
122
+ `retry` replaces the `until … do sleep … done` loop, and counts the attempts for you.
123
+
124
+ ```sh
125
+ # runfiles/db/wait.run
126
+ # Block until Postgres answers, then load the seed data
127
+
128
+ retry 120 every 1
129
+ $ docker exec db pg_isready -U app -h 127.0.0.1
130
+ else
131
+ error("the database never became ready")
132
+ end
133
+
134
+ $ docker exec -i db psql -U app < seed.sql
86
135
  ```
87
136
 
88
- **5. Keep it up to date:**
137
+ ### Doing several things at once
89
138
 
90
- ```bash
91
- $ run :update # update to the latest release in place
92
- $ run :update --version v0.19.0 # or pin a specific release tag
139
+ `.parallel` on a `for` fans out its iterations, and labels every line so you can tell them apart.
140
+
141
+ It is block-scoped, so **where it sits is what it covers**: inside the loop it parallelises the iterations and
142
+ nothing else, and at the top of the file it would cover every command in the target.
143
+
144
+ ```sh
145
+ # runfiles/pull.run
146
+ # Pull every service image
147
+
148
+ for compose in glob("**/docker-compose.yml")
149
+ .parallel
150
+ .ignore-errors
151
+
152
+ $ docker compose -f {{ compose }} pull
153
+ end
154
+ ```
155
+
156
+ ```
157
+ [docker] api Pulling
158
+ [docker] web Pulling
159
+ [docker] postgres Pull complete
93
160
  ```
94
161
 
95
- `:update` re-runs the install script for your platform, replacing the binary where it already lives. npm-managed
96
- installs are handled too: on Linux/macOS `:update` runs `npm install -g @runfile/cli@latest` for you; on Windows it
97
- prints that command to run from a fresh shell (npm can't overwrite the running `run.exe` there).
162
+ ### JSON without the backslashes
98
163
 
99
- ---
164
+ `json … end` is a block of JSON as a value. An interpolation inside becomes **one JSON value** — quoted and
165
+ escaped if it is a string, an array if it is a list — so nothing is escaped by hand.
100
166
 
101
- ## VS Code extension
167
+ ```sh
168
+ # runfiles/bucket/policy.run
169
+ # Apply the storage policy to a bucket
102
170
 
103
- Every release ships a VS Code extension that puts your targets in the editor — no `tasks.json` to generate or keep
104
- in sync. It runs `run :generate task-descriptors` and turns the result into real VS Code tasks plus a
105
- **Runfile → Targets** sidebar, complete with namespace folders and a **Globals** bucket for machine-wide targets.
171
+ let bucket = one_of(ARG.bucket, "app-uploads", "app-backups")
106
172
 
107
- **Install** — download `runfile-vscode-<version>.vsix` from the
108
- [latest release](https://github.com/JoaaoVerona/runfile/releases/latest), then:
173
+ let policy = json
174
+ {
175
+ "Version": "2012-10-17",
176
+ "Statement": [
177
+ {
178
+ "Effect": "Allow",
179
+ "Action": [
180
+ "s3:PutObject",
181
+ "s3:GetObject"
182
+ ],
183
+ "Resource": {{ concat("arn:aws:s3:::", bucket, "/*") }},
184
+ "MaxKeys": {{ number(ARG.limit ? "1000") }}
185
+ }
186
+ ]
187
+ }
188
+ end
109
189
 
110
- ```bash
111
- code --install-extension runfile-vscode-<version>.vsix
112
- ```
113
-
114
- (or, in VS Code: **Extensions → … → Install from VSIX…**)
115
-
116
- Targets then show up in **Terminal → Run Task…** and in the Runfile sidebar. Every task is invoked with
117
- `--stdin-args`, so targets that need input prompt for it right in the task terminal. Nothing is written to your
118
- repository.
119
-
120
- The source lives in [`editors/vscode`](editors/vscode) — see its [README](editors/vscode/README.md) for settings,
121
- commands, and how the sidebar groups targets.
122
-
123
- ---
124
-
125
- ## Why Runfile?
126
-
127
- Other runners assume one platform and one shell. Runfile assumes **none**.
128
-
129
- Sorted from where Runfile is most differentiated to where it lags behind.
130
-
131
- | Feature | Runfile | Make | Just | Taskfile |
132
- |---------------------------------------------------------------------|:-------:|:----:|:----:|:--------:|
133
- | Encrypted env vars, built-in (AES-256-GCM) | ✅ | ❌ | ❌ | ❌ |
134
- | MCP server (AI agent integration) | ✅ | ❌ | ❌ | ❌ |
135
- | `when:` blocks for success/failure/always cleanup | ✅ | ❌ | ❌ | ❌ |
136
- | Inline OS / shell / cwd / paths / debug branching via `{{ RUN.* }}` | ✅ | ❌ | ❌ | ❌ |
137
- | Detached background execution | ✅ | ❌ | ❌ | ❌ |
138
- | IDE task generation (VS Code / Zed / JetBrains) | ✅ | ❌ | ❌ | ❌ |
139
- | Migrate from `package.json` / `Makefile` | ✅ | ❌ | ❌ | ❌ |
140
- | Force-kill process tree on SIGINT (GUI apps) | ✅ | ❌ | ❌ | ❌ |
141
- | Stdio log tailers (`extendStdio`) | ✅ | ❌ | ❌ | ❌ |
142
- | First-class PowerShell / cmd.exe | ✅ | ❌ | ✅ | ❌ |
143
- | Fish shell support | ✅ | ❌ | ✅ | ❌ |
144
- | Per-target shell override | ✅ | ❌ | ✅ | ❌ |
145
- | Strict parsing (typos are errors) | ✅ | ❌ | ✅ | ❌ |
146
- | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
147
- | JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
148
- | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
149
- | Output grouping / prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
150
- | Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
151
- | Shell completions | ✅ | ❌ | ✅ | ✅ |
152
- | Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
153
- | Built-in string functions (upper, replace, trim, regex, base64, …) | ✅ | ❌ | ✅ | ✅ |
154
- | Parallel execution | ✅ | ✅ | ❌ | ✅ |
155
- | Single static binary | ✅ | ✅ | ✅ | ✅ |
156
- | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
157
- | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
158
- | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
159
-
160
- ---
161
-
162
- ## What makes it different
163
-
164
- #### Cross-shell, not just cross-platform
165
-
166
- Auto-detects the shell, or pin one per target. The same Runfile works for a teammate using `bash` on Linux and another
167
- using PowerShell on Windows.
168
-
169
- ```jsonc
170
- {
171
- "targets": {
172
- "ps": { "commands": ["Write-Host 'hi'"], "forceShell": "powershell" },
173
- "bash": { "commands": ["echo $BASH_VERSION"], "forceShell": "bash" }
174
- }
175
- }
190
+ run _aws -- s3api put-bucket-policy --bucket {{ bucket }} --policy {{ policy }}
176
191
  ```
177
192
 
178
- #### Encrypted env vars, baked in
193
+ `run :format` lays the block out, and a missing brace is underlined in your editor as you type — not reported
194
+ by the far end an hour later.
179
195
 
180
- AES-256-GCM secrets you can commit to git. Auto-decrypted at runtime, never written to disk in plaintext. No external
181
- tool, no wrapper.
196
+ ### Any language you have installed
182
197
 
183
- ```bash
184
- $ run :env init .env.production
185
- $ run :env set .env.production DB_PASS "s3cr3t" # auto-encrypted
186
- $ run :env set .env.production DB_PASS # omit value → read from stdin (no shell history, no escaping)
198
+ `exec <command>` runs that command with the block as its **stdin**, so a target can be a Python script, a Node
199
+ script, a SQL file — whatever is already on the machine. The body is that command's language, not this one.
200
+
201
+ ```sh
202
+ # runfiles/i18n/check.run
203
+ # Check that every translation catalogue has the same keys as the reference
204
+
205
+ .env.LOCALES = "src/locales"
206
+
207
+ exec python3
208
+ import json, os, pathlib, sys
209
+
210
+ files = sorted(pathlib.Path(os.environ["LOCALES"]).glob("*.json"))
211
+ catalogues = {f.stem: set(json.loads(f.read_text())) for f in files}
212
+ reference = catalogues.pop("en")
213
+
214
+ for name, keys in catalogues.items():
215
+ missing = reference - keys
216
+ if missing:
217
+ sys.exit(f"{name}: missing {', '.join(sorted(missing))}")
218
+
219
+ print(f"i18n: {len(files)} catalogues agree on {len(reference)} keys")
220
+ end
187
221
  ```
188
222
 
189
- ```bash
190
- RUNFILE_ENCRYPTION_PUBLIC_KEY=9f86d081...
191
- DB_PASS=encrypted:BASE64_CIPHERTEXT...
223
+ ```sh
224
+ # runfiles/deps/list.run
225
+ # Print the first few runtime dependencies
226
+
227
+ .env.LIMIT = ARG.limit ? "10"
228
+
229
+ exec node
230
+ const { readFileSync } = require("node:fs")
231
+
232
+ const pkg = JSON.parse(readFileSync("package.json", "utf8"))
233
+ const deps = Object.keys(pkg.dependencies ?? {})
234
+
235
+ for (const name of deps.slice(0, Number(process.env.LIMIT))) {
236
+ console.log(` ${name}`)
237
+ }
238
+ end
192
239
  ```
193
240
 
194
- You can also use Runfile as a drop-in `dotenvx`/`dotenv` replacement to inject env vars (encrypted or plaintext) into
195
- any command — no Runfile needed:
241
+ ```sh
242
+ # runfiles/db/report.run
243
+ # Row counts for the tables that matter
196
244
 
197
- ```bash
198
- $ run :env inject .env -- node app.js # one file
199
- $ run :env inject .env .env.local -- pnpm dev # multiple files, last wins; parent env always wins
200
- $ run :env inject .env.production -- ./deploy.sh # encrypted values auto-decrypted
245
+ exec psql --quiet {{ ENV.DATABASE_URL }}
246
+ select 'accounts' as table, count(*) from accounts
247
+ union all
248
+ select 'orders', count(*) from orders
249
+ order by 1;
250
+ end
201
251
  ```
202
252
 
203
- In CI, the file path can be supplied via `RUNFILE_ENV_FILE_TARGET` (set automatically by the
204
- [setup action's `env-file-source` input](.github/actions/setup/action.yml) for open-source
205
- repos that keep their encrypted `.env` in a GitHub secret):
253
+ In value position it captures what the command printed, so another language can answer a question this one
254
+ then acts on:
206
255
 
207
- ```bash
208
- $ run :env inject -- node app.js # uses $RUNFILE_ENV_FILE_TARGET
256
+ ```sh
257
+ # runfiles/coverage/gate.run
258
+ # Fail if line coverage dropped below the floor
259
+
260
+ let percent = exec python3
261
+ import pathlib, re
262
+
263
+ text = pathlib.Path("lcov.info").read_text()
264
+ hit = len(re.findall(r"^DA:\d+,[1-9]", text, re.M))
265
+ total = len(re.findall(r"^DA:", text, re.M))
266
+
267
+ print(round(100 * hit / total))
268
+ end
269
+
270
+ if number(percent) < 85
271
+ error("coverage is {{ percent }}%, below the 85% floor")
272
+ end
209
273
  ```
210
274
 
211
- #### Powerful argument substitution
275
+ One thing to know: the body **is** the command's stdin, so a script cannot also read data from stdin — give it
276
+ what it needs through `.env`, a file, or the command's own arguments.
277
+
278
+ An interpolation inside the body arrives **as itself**, unquoted, when the command is not a shell — shell
279
+ quoting is the wrong quoting for anybody else's language, so quote it the way that language wants. A shell
280
+ body still self-quotes, and `$ cp {{ src }} {{ dst }}` is as safe as it ever was.
212
281
 
213
- Positional, named, flags, env vars, and runtime context (`{{ RUN.os }}` / `{{ RUN.arch }}` / `{{ RUN.shell }}` /
214
- `{{ RUN.cwd }}` / `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The
215
- `{{ ... }}` syntax avoids collisions with shell `$(...)` command substitution.
282
+ ### Secrets that never touch the disk
216
283
 
217
- The source prefixes are `{{ ARG.<name> }}` (named arg), `{{ ENV.<name> }}`, `{{ FLAG.<name> }}`,
218
- `{{ VAR.<name> }}`, and `{{ RUN.<key> }}`. Bare `{{ ARGS }}` expands to **all** positional arguments.
284
+ ```sh
285
+ # runfiles/deploy.run
286
+ # Deploy to production
219
287
 
220
- ```jsonc
221
- "PORT": "{{ ARG.port ? ENV.PORT ? '3000' }}",
222
- "OPTS": "{{ FLAG.release ? '--release' : }} {{ FLAG.verbose ? '-v' : }}",
223
- "CARGO_TARGET_DIR": "target-{{ RUN.os }}",
224
- // Inline OS/shell branches — the boolean DSL goes inside a single `{{ ... }}` block:
225
- { "if": "{{ RUN.os == 'windows' }}", "then": ["del /S /Q build"], "else": ["rm -rf build"] },
226
- // User-supplied flags branch through `{{ FLAG.x }}` (resolves to "true" / "false"):
227
- { "if": "{{ FLAG.debug }}", "then": ["./tool --verbose"], "else": ["./tool"] },
228
- // workingDirectory is a free-form path (defaults to {{ RUN.parent }}):
229
- "workingDirectory": "{{ ARG.workdir ? RUN.cwd }}"
288
+ .env-file = ".env.production"
289
+ .env.GOOGLE_APPLICATION_CREDENTIALS = temp_file(base64_decode(ENV.SERVICE_ACCOUNT_B64), "json")
290
+
291
+ confirm("Deploy to production?")
292
+ $ terraform apply -auto-approve
230
293
  ```
231
294
 
232
- Strict format: exactly one space after `{{` and before `}}`, exactly one space around `?` and `:` operators.
233
- **String literals must be wrapped in single quotes** — `'production'`, not `production`. Source references
234
- (`ARG.x`, `VAR.x`, etc.) and function calls remain bare. Use `\{{` / `\}}` to emit a literal `{{` / `}}` in
235
- your output.
295
+ The temp file is deleted when the run ends, however it ends — including a failure half-way, which is exactly
296
+ when a decoded credential must not be left behind.
297
+
298
+ ### A pre-commit hook
236
299
 
237
- #### Boolean conditions inside substitutions
300
+ ```sh
301
+ # runfiles/precommit.run
302
+ # Format staged files and re-stage them
238
303
 
239
- A substitution body containing `==`, `!=`, `&&`, `||`, or unary `!` at top level is evaluated as a boolean
240
- expression — the result is the string `"true"` or `"false"`. This is what powers the new `if`-block syntax,
241
- but you can use it anywhere:
304
+ let staged = $ git diff --cached --name-only --diff-filter=ACMR -- "*.rs"
305
+ let files = lines(staged)
242
306
 
243
- ```jsonc
244
- "commands": [
245
- // Branch on a CLI flag — `if` checks if the substitution resolves to the literal "true":
246
- { "if": "{{ ARG.env == 'production' }}",
247
- "then": ["./deploy-prod.sh"],
248
- "else": ["./deploy-staging.sh"] },
307
+ if length(files) > 0
308
+ $ rustfmt {{ files }}
309
+ $ git add {{ files }}
310
+ end
311
+ ```
249
312
 
250
- // Compose AND/OR/NOT freely:
251
- { "if": "{{ ARG.env != 'development' && ARG.env != 'production' }}",
252
- "then": ["./deploy-staging.sh"] },
313
+ `files` is a list, so `{{ files }}` becomes one argument per file — spaces in names and all.
253
314
 
254
- // Invert any boolean-returning value with unary `!` — including the
255
- // comparison helpers (`less_than`, `is_number`, …) and `contains` etc.:
256
- { "if": "{{ !is_number(ARG.port) }}",
257
- "then": ["echo 'port must be a number' && exit 1"] },
315
+ ### Re-running on change
258
316
 
259
- // FLAG.x works as a bare boolean inside DSL — no `== 'true'` needed:
260
- { "if": "{{ RUN.os == 'windows' && FLAG.wsl }}",
261
- "then": "wsl --shell-type login -- vite build" },
317
+ ```sh
318
+ # runfiles/watch.run
319
+ # Recompile whenever a source file changes
262
320
 
263
- // Inline in a command — useful for passing booleans to other tools:
264
- "my-command --resolve {{ ARG.env == 'production' }}"
265
- ]
321
+ .watch = "src/**/*.rs"
322
+ .watch = "!src/generated/**"
323
+
324
+ run build
266
325
  ```
267
326
 
268
- **Strict boolean rule.** Any value used as a bare boolean — both inside the DSL Truthy check (e.g. `&& FLAG.x`,
269
- `!ARG.y`) and as the entire `if` condition — must resolve to exactly one of:
270
-
271
- - `"true"` → truthy (the `then` branch runs / left arm of `&&` continues)
272
- - `"false"` → falsy
273
- - `""` (empty) → falsy
274
-
275
- Anything else (`"True"`, `"1"`, `"yes"`, `"hello"`, etc.) errors out with a clear message pointing you toward
276
- the explicit comparison form. So `if: "{{ ARG.x }}"` and `{{ ARG.x && ... }}` only work when `ARG.x` is
277
- exactly `"true"` / `"false"` / empty — for any other check, use a comparison: `{{ ARG.x == 'yes' }}`.
278
-
279
- Comparisons (`==` / `!=`) operate on raw strings — *those* don't have the boolean restriction, so
280
- `{{ ARG.env == 'staging' }}` works for any value of `ARG.env`.
281
-
282
- #### Functions: transform values inline
283
-
284
- Wrap any source or chain expression in a function call. Functions resolve at substitution time, can be nested,
285
- and work as full substitution bodies *or* as chain segments:
286
-
287
- ```jsonc
288
- "commands": [
289
- // Built-ins:
290
- // case : to_upper, to_lower, capitalize
291
- // trim : trim, trim_start, trim_end
292
- // inspect : length, starts_with, ends_with, contains, substring
293
- // transform : escape, repeat, replace_all, remove_all
294
- // regex : regex_replace, regex_remove, regex_matches, regex_capture, regex_capture_all
295
- // build : concat, join
296
- // split : nth, first, last, count_parts
297
- // path : basename, dirname, extname, stem, join_path
298
- // math : add, subtract, multiply, divide, modulo, power, min, max, abs, round, floor, ceil
299
- // compare : less_than, less_than_or_equal, greater_than, greater_than_or_equal, is_number
300
- // validate : one_of
301
- // encoding : base64_encode, base64_decode, url_encode, url_decode
302
- // hashing : sha256, md5
303
- // ids/time : uuid, now
304
- // files : read_file, write_file, file_exists, temp_file, temp_dir
305
- // json : json_get, json_set
306
- // control : try, error
307
- // shell : shell_quote, capture
308
- // variables : define
309
- // cwd : set_cwd
310
- "echo deploying-{{ to_upper(ARG.env) }}",
311
- "curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
312
- "echo {{ concat('hello-', ARG.name, '-2026') }}",
313
- "echo {{ join(' AND ', 'flag-1', 'flag-2', ARG.extra) }}",
314
- // Tokenise-and-rejoin-style transforms via replace_all:
315
- "go test {{ replace_all(ARG.flags, ' ', ' -tag=') }}",
316
- // Strip every match of a regex (here: collapse whitespace runs to a single space):
317
- "echo {{ regex_replace(ARG.text, '\\s+', ' ') }}",
318
- // Pull a capture group out of the first regex match — no `(?s)^.*X(...)X.*$`
319
- // greedy-replace trick needed. Group 0 is the whole match; group N is the
320
- // N-th `(...)`. Out-of-bounds returns "" (same convention as `nth`).
321
- "echo version={{ regex_capture(read_file('app/build.gradle.kts'), 'versionName = \"([^\"]+)\"', '1') }}",
322
- // Pull group N out of EVERY match and join with a separator (the "all" variant
323
- // of regex_capture). Here: every quoted dependency name, comma-separated.
324
- "echo deps={{ regex_capture_all(read_file('deps.txt'), 'name = \"([^\"]+)\"', '1', ',') }}",
325
- // Split-by-separator scalar accessors — string in, string out, no list type:
326
- // basename idiom (last segment after `/`); empty string when input ends in `/`.
327
- "echo basename={{ last(ARG.path, '/') }}",
328
- // Pull out the N-th comma-separated field; out-of-bounds returns "".
329
- "echo target={{ nth(ARG.csv, ',', '1') }}",
330
- // Pair `count_parts` with `nth` to bound-check before indexing:
331
- // "if": "{{ count_parts(ARG.csv, ',') == '3' }}"
332
- // Boolean-returning helpers are valid DSL `Truthy` values — use them in `if`:
333
- // "if": "{{ starts_with(ARG.path, '/usr') }}"
334
- // "if": "{{ regex_matches(ARG.tag, '^v[0-9]+\\.[0-9]+$') }}"
335
-
336
- // Safely inline arbitrary content (newlines, quotes, JSON) as a CLI arg —
337
- // `shell_quote` picks the right quoting for the active shell:
338
- "some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
339
-
340
- // Slurp shell-command stdout straight into a substitution. `capture` runs
341
- // the command through the platform's default shell (sh / cmd) and trims
342
- // the trailing newline. Results are memoized per-target so the same
343
- // capture in multiple commands runs once. `--dry-run` substitutes a
344
- // readable placeholder instead of spawning the shell.
345
- "echo built-at={{ capture('date -u +%Y-%m-%dT%H:%M:%SZ') }}",
346
- "{{ define(sha, capture('git rev-parse HEAD')) }}",
347
-
348
- // Arithmetic — variadic (2+ args), coerces strings to numbers, errors on
349
- // non-numeric input. The result is formatted as an integer when whole and
350
- // as a decimal otherwise (so `add('5', '3')` → "8", `add('5', '1.1')` →
351
- // "6.1"). Divide-by-zero errors out.
352
- "echo next-build={{ add(VAR.versionCode, '1') }}",
353
- "echo half={{ divide(VAR.total, '2') }}",
354
- // …plus modulo, power, min, max, abs, round, floor, ceil:
355
- "echo shard={{ modulo(ARG.id, '8') }} clamped={{ max('0', min('100', ARG.pct)) }}",
356
-
357
- // Path helpers (std::path semantics). basename/dirname/extname/stem/join_path:
358
- "echo name={{ basename(ARG.file) }} ext={{ extname(ARG.file) }}",
359
- "cp {{ ARG.file }} {{ join_path(ARG.outdir, basename(ARG.file)) }}",
360
-
361
- // Substring (char-indexed; 3rd arg = length, optional → to end). Short SHA:
362
- "echo short={{ substring(capture('git rev-parse HEAD'), '0', '7') }}",
363
-
364
- // Current UTC time. Formats: unix-timestamp, unix-millis, iso, iso-date,
365
- // iso-time, rfc3339, year/month/day/hour/minute/second.
366
- "echo built-at={{ now('iso') }} tag=build-{{ now('unix-timestamp') }}",
367
-
368
- // UUID (v4-shaped) for unique temp / cache names. `--dry-run` → `<uuid>`.
369
- "echo tmp=/tmp/job-{{ uuid() }}",
370
-
371
- // URL percent-encode / decode (space → %20; symmetric round-trip):
372
- "curl 'https://api/search?q={{ url_encode(ARG.query) }}'",
373
-
374
- // Numeric comparisons — coerce both args to numbers (same rules as the
375
- // arithmetic family, so non-numeric input errors) and return "true"/"false",
376
- // so they work as DSL `Truthy` values in `if` conditions:
377
- // "if": "{{ greater_than(VAR.count, '50') }}"
378
- // "if": "{{ less_than_or_equal(ARG.retries, '3') }}"
379
- // `is_number` tests whether a value parses as a (finite) number — it returns
380
- // "false" instead of erroring on non-numeric input:
381
- // "if": "{{ is_number(ARG.port) }}"
382
-
383
- // Validate a value against a fixed allow-list. Returns the value on
384
- // match; lists every valid option in the error on mismatch. Collapses
385
- // the four-case `match { major: define(part, 'major'), ... }` boilerplate.
386
- "{{ define(part, one_of(ARGS, 'major', 'minor', 'patch', 'build')) }}",
387
-
388
- // Cache keys / content fingerprints. `sha256` for security-sensitive use,
389
- // `md5` for cheap fingerprinting (NOT cryptographically secure).
390
- "echo cache-key={{ sha256(read_file('package-lock.json')) }}",
391
-
392
- // Branch on file presence without forking a shell. `file_exists` returns
393
- // the literal "true" / "false", so it doubles as a DSL truthy value.
394
- // "if": "{{ file_exists('.env.local') }}"
395
-
396
- // Read a file inline — relative paths anchor to the Runfile directory.
397
- // Use `try(...)` to recover from missing files.
398
- "echo version={{ try(read_file('VERSION')) ? '0.0.0-dev' }}",
399
-
400
- // Write a file straight from a substitution — pairs with `read_file` for
401
- // read-modify-write pipelines (version bumps, config edits, …). Returns
402
- // an empty string, so a line containing only this call is dropped silently
403
- // (same convention as `define`). Goes through Rust's `std::fs::write`, so
404
- // it sidesteps the shell entirely — safe for large content, content with
405
- // quotes, embedded newlines, anything that would break a
406
- // `printf > file`-style redirect on Windows. `--dry-run` skips the write.
407
- "{{ write_file('build.gradle.kts', regex_replace(read_file('build.gradle.kts'), 'versionCode = [0-9]+', 'versionCode = 43')) }}",
408
-
409
- // Create a temp file / dir in the OS temp directory, auto-deleted when the
410
- // CLI exits. `temp_file([content], [extension])` writes `content` (if given)
411
- // and appends `.<extension>` (leading dot optional); `temp_dir()` makes an
412
- // empty directory (removed recursively). `--dry-run` → `<temp_file>` /
413
- // `<temp_dir>` placeholders, nothing created. Capture the path once with
414
- // `define` and reuse it:
415
- "{{ define(cfg, temp_file('{\"env\":\"prod\"}', 'json')) }}",
416
- "tool --config {{ VAR.cfg }}",
417
- "{{ define(work, temp_dir()) }}",
418
- "git clone --depth 1 $REPO {{ VAR.work }} && build {{ VAR.work }}",
419
-
420
- // Pull values out of arbitrary JSON without `jq` (works on every shell):
421
- "echo db_host={{ json_get(read_file('config.json'), 'database.host') }}",
422
-
423
- // Modify a JSON document in place (returns the new compact JSON):
424
- "echo {{ json_set('{\"port\":3000}', 'env', 'production') }}",
425
-
426
- // `try(expr)` swallows inner errors. Standalone returns "" on failure;
427
- // chained, the next segment runs as a fallback.
428
- "echo {{ try(base64_decode(ARG.maybe_b64)) ? ARG.maybe_b64 }}",
429
-
430
- // `error('message')` fails the current command on purpose: prints the
431
- // message to stderr and marks the step failed — but the failure flows
432
- // through the normal walker, so `when: failure` / `when: always` steps
433
- // still run and `ignoreErrors` still suppresses it. Great for guard clauses:
434
- // { "if": "{{ ARG.env == 'prod' }}", "then": ["{{ error('refusing to deploy to prod from a laptop') }}"] }
435
-
436
- // Nested:
437
- "echo {{ to_upper(to_lower(ARG.x)) }}",
438
-
439
- // As a chain fallback:
440
- "echo host={{ ARG.host ? to_lower(ENV.HOST) }}",
441
-
442
- // Capture a value with `define(...)` and read it later via `VAR.<name>`:
443
- "{{ define(sdk, ENV.SDK ? '/opt/sdk') }}",
444
- "{{ VAR.sdk }}/bin/build",
445
- "echo using sdk at {{ VAR.sdk }}",
446
-
447
- // `set_cwd(path)` switches the cwd subsequent commands spawn in — like
448
- // shell `cd`, but works on every shell / OS without forking. Relative
449
- // paths chain (matching `cd a; cd b` → `a/b`); absolute paths replace.
450
- "{{ set_cwd(ARG.subproject ? 'packages/api') }}",
451
- "npm install",
452
- "npm run build",
453
-
454
- // Single-quoted strings interpolate nested {{ }} substitutions:
455
- "{{ define(cmd, 'docker compose -f {{ VAR.compose }} pull') }}",
456
- "{{ VAR.cmd }}"
327
+ There is no `--watch` flag: the file already said what it wants, so `run watch` watches.
328
+
329
+ ## The language
330
+
331
+ Line-oriented, with one rule: **the language is the default, the shell is marked.**
332
+
333
+ | Line | Meaning |
334
+ | --- | --- |
335
+ | `# text` | A comment. The leading block is the target's description. |
336
+ | `.name = value` | A property. |
337
+ | `$ echo hi` | Hand this line to a shell. |
338
+ | `exec python3` … `end` | Run a command with the block as its stdin. |
339
+ | `json` … `end` | A block of JSON, as one value. |
340
+ | `let x = 1` | Bind a value. `x = 2` rebinds. |
341
+ | `if` / `else` / `end` | Branch. |
342
+ | `for x in list` / `end` | Loop. |
343
+ | `match` / `case` / `default` / `end` | Dispatch on a value. A label is a quoted string: `case "linux"`. |
344
+ | `retry n [every s]` … `end` | Run the block again while it fails, up to `n` times. |
345
+ | `do` … `end` | A block with no condition, so a property can cover a few commands. |
346
+ | `run other-target` | Run another target, in this process. |
347
+ | `print(…)` | Anything else is an expression, evaluated for its effect. |
348
+
349
+ ### Values
350
+
351
+ Strings, numbers, booleans and lists — **strict, with no coercion.** `"a" + 1` is an error telling you to use
352
+ `concat`. `"1" == 1` is false. `number(ARG.count)` is required before arithmetic. `1 / 2` is `0.5`.
353
+
354
+ Lists nest as deep as you like, which beats packing several fields into one string and splitting it back out:
355
+
356
+ ```sh
357
+ for volume in [
358
+ ["prometheus-data", "65534:65534"],
359
+ ["kvrocks-data", "999:999"],
457
360
  ]
361
+ $ docker run --rm -v {{ volume[0] }}:/v alpine chown -R {{ volume[1] }} /v
362
+ end
458
363
  ```
459
364
 
460
- `define(name, value)` returns an empty string and stores `value` in a run-wide map; a command line that resolves
461
- to only whitespace (i.e. one consisting solely of a `{{ define(...) }}` call) is silently skipped instead of
462
- being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
463
- be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
365
+ `print` writes a line to stdout; `printf` writes exactly what you give it, with `%s`, `%d`, `%f`, `%.Nf` and
366
+ `%%`, and no newline of its own.
464
367
 
465
- #### Declaring variables up front (`vars`)
368
+ Lists are values, and every list function answers with a **new** list, so nothing changes behind another name:
466
369
 
467
- Instead of (or alongside) `define(...)`, you can declare variables directly on a target — or on `globals` to
468
- apply them to every target — and read them as `{{ VAR.<key> }}`. This mirrors `env`: each value is a `{{ ... }}`
469
- template resolved **after** the env is built (so it can reference `{{ ENV.* }}`), plus `{{ ARG.* }}`,
470
- `{{ FLAG.* }}`, `{{ RUN.* }}`, and earlier vars. If a reference has no default and isn't supplied, it errors —
471
- exactly like everywhere else.
370
+ ```sh
371
+ let recent = slice(reverse(sort(tags)), 0, 5)
372
+ let targets = without(RUN.namespaces, "docs")
472
373
 
473
- ```jsonc
474
- {
475
- "globals": {
476
- "vars": { "appName": "myapp" } // available to every target
477
- },
478
- "targets": {
479
- "deploy": {
480
- "env": { "REGION": "us-east-1" },
481
- "vars": {
482
- "region": "{{ ENV.REGION }}", // vars resolve after env
483
- "retries": "3", // numbers/bools are stringified
484
- "tag": "{{ ARG.tag ? 'latest' }}" // chain fallbacks work
485
- },
486
- "commands": ["echo deploying {{ VAR.appName }} to {{ VAR.region }} as {{ VAR.tag }}"]
487
- }
488
- }
489
- }
374
+ for pair in zip(names, owners)
375
+ $ chown {{ pair[1] }} /srv/{{ pair[0] }}
376
+ end
490
377
  ```
491
378
 
492
- Global `vars` are merged into each target's `vars` at parse time (target keys win on conflict). Declared vars are
493
- **scoped per-target like `env`**: a parent's vars are visible inside an `@target` dependency, but a dependency's
494
- own declared vars don't leak back to the parent. A runtime `define(...)` of the same name overrides the declared
495
- value for the rest of the target. Var keys must match `[A-Za-z_][A-Za-z0-9_-]*`.
496
-
497
- `set_cwd(path)` is the cwd analog of `define`: returns an empty string and changes the cwd that subsequent shell
498
- commands in the *current target* spawn in. Behaves like shell `cd`, but works uniformly across every shell / OS
499
- (no forked process, no `cd` binary needed). Resolution rules:
500
-
501
- - **Absolute path** → fully replaces the current override (matches `cd /abs`).
502
- - **Relative path** → joins onto the existing override, or onto the target's `workingDirectory` if no override
503
- has been set yet. So `set_cwd('a'); set_cwd('b')` lands subsequent commands in `<workingDirectory>/a/b`,
504
- matching how `cd a; cd b` chains in a shell.
505
-
506
- `set_cwd` is **per-target**: each `@target` invocation starts with a clean override (the dispatched target sees
507
- its own `workingDirectory`, not the caller's `set_cwd` state). Inside `parallel: true` targets each leaf
508
- captures the override at the moment of its substitution, so siblings don't race on the spawn cwd. Like
509
- `define`, the side effect is skipped on the redacted-logging pass so log lines don't double-apply it. With
510
- `sameShell: true`, only the *final* `set_cwd` value applies (everything joins into one shell invocation) — use
511
- shell `cd` directly between leaves there if you need intermediate cwd changes.
512
-
513
- Function args are separated by `, ` (comma + exactly one space).
514
-
515
- **Quote semantics inside `{{ ... }}`:**
516
-
517
- - **Single quotes (`'...'`) — interpolated string**: surrounding quotes stripped, with any nested `{{ ... }}`
518
- resolved through the regular substitution machinery. Use these for almost every literal — they handle the
519
- full range of values including embedded substitutions: `concat('a, b', ARG.x)`,
520
- `'/var/log/{{ RUN.os }}.log'`, `define(cmd, 'docker -f {{ VAR.compose }} pull')`.
521
- - **Double quotes (`"..."`) — fully literal value**: the quote characters are part of the value (so `"test"` is
522
- the 6-character string `"test"`). No interpolation. Useful when the literal you want to emit really should
523
- contain `"` chars.
524
- - **Plain barewords are rejected** — `{{ ARG.env ? development }}` is a parse-time error. Wrap in quotes.
525
-
526
- #### Conditionals and loops, no shell required
527
-
528
- Drop `if` / `for` / `match` blocks straight into your `commands` array. Conditions, loops, and value dispatch are
529
- evaluated by Runfile itself, so the logic works the same on every shell and platform.
530
-
531
- ```jsonc
532
- "commands": [
533
- { "if": "{{ ARG.env == 'production' }}",
534
- "then": ["./deploy-prod.sh"],
535
- "else": ["./deploy-staging.sh"] },
536
-
537
- { "match": "{{ ARG.tier ? '1' }}", // chain default; case "1" runs when --tier missing
538
- "cases": {
539
- "1": "flutter emulators --launch Tier_1_Android_9",
540
- "2": "flutter emulators --launch Tier_2_Android_11",
541
- "3": "flutter emulators --launch Tier_3_Android_14",
542
- // Case keys wrapped in `/.../` are treated as regex patterns.
543
- // Literal cases always win over a regex that would also match.
544
- "/^v\\d+$/": "echo version-tag",
545
- "/^pr-\\d+$/": "echo preview-build"
546
- } }, // unknown values error out, listing the valid cases
547
-
548
- { "for": "service",
549
- "in": ["api", "web", "worker"],
550
- "parallel": true,
551
- // VAR.<name>_index exposes the 0-based iteration counter.
552
- "do": ["echo [{{ VAR.service_index }}] docker build -t {{ VAR.service }} services/{{ VAR.service }}"] },
553
-
554
- { "for": "file",
555
- "shell": "git diff --name-only HEAD~1",
556
- "do": ["clang-format -i {{ VAR.file }}"] }
557
- ]
379
+ `append`, `prepend`, `concat_lists`, `sort`, `reverse`, `unique`, `slice`, `flatten`, `zip`, `index_of` and
380
+ `without`, beside `first`, `last`, `length` and `join`.
381
+
382
+ There are 78 built-in functions — strings, lists, regex, paths, JSON, hashes, time, files. Your editor lists
383
+ them all with a description and an example; `run :list` is for targets, and the language server is for the
384
+ language.
385
+
386
+ ### Where values come from
387
+
388
+ | Source | From |
389
+ | --- | --- |
390
+ | `ARG.name` | `--name=value` |
391
+ | `FLAG.name` | `--name` (a boolean) |
392
+ | `ARGS` | positional arguments, as a list |
393
+ | `ENV.NAME` | the environment |
394
+ | `RUN.os` `RUN.arch` `RUN.cwd` `RUN.file` `RUN.parent` `RUN.namespaces` `RUN.user` | the run itself |
395
+
396
+ `a ? b` means "`a`, or `b` if `a` is not there", and chains:
397
+
398
+ ```sh
399
+ let port = ARG.port ? ENV.PORT ? "3000"
558
400
  ```
559
401
 
560
- #### Call other targets inline with `@target`
402
+ Arguments are `--key=value`. Writing `--key value` gives you a flag and a positional, because nothing declares
403
+ which names take values — and if you meant an argument, the error says so.
404
+
405
+ **A flag a target cannot read is an error**, not a typo you find out about later. `run` knows exactly what a
406
+ target reads by walking its parsed tree, so `--forse` stops the run and points at `--help`:
561
407
 
562
- Any command string starting with `@` invokes another target. Forward args with `{{ ARGS }}` or pass anything explicit.
563
- Each invocation runs (no dedup), inherits the parent's env, and works inside `if` / `for` blocks. Parallel parents
564
- fan out target calls onto worker threads.
408
+ ```bash
409
+ $ run deploy --help
410
+ run deploy [--env=<value>] --token=<value> [--force]
565
411
 
566
- Prefix with `@?target` to mark the call **optional**: if the (substituted) target doesn't exist, it's silently
567
- skipped instead of erroring. Useful with `for in: "namespaces"` when the dispatched target isn't defined in every
568
- namespace.
412
+ Deploy to an environment.
569
413
 
570
- ```jsonc
571
- "commands": [
572
- "@lint",
573
- "@test --coverage",
574
- "@build {{ ARGS }}",
575
- { "if": "{{ RUN.os == 'windows' }}", "then": "@deploy-win", "else": "@deploy-unix" },
576
- { "for": "ns", "in": "namespaces", "do": "@?{{ VAR.ns }}:adb-forward" } // skip namespaces without the target
577
- ]
414
+ Arguments
415
+ --env=<value> defaults to staging
416
+ --token=<value> required
417
+ --force off unless passed
418
+
419
+ Environment
420
+ DEPLOY_KEY required
578
421
  ```
579
422
 
580
- #### Readable parallel output
581
-
582
- When commands run in parallel (`"parallel": true` on a target or `for` block), each branch's stdout/stderr is
583
- line-buffered, prefixed with a colored bracketed label identifying the branch, and stripped of cursor-control
584
- escapes — so progress-bar redraws (`docker compose pull`, `cargo build`, etc.) become chronological append-only
585
- lines instead of corrupting each other's output. The label shows the full resolved `@target` invocation
586
- (`[@dev --port 5000]`) for target-call branches; for shell branches it shows the first *meaningful* part of the
587
- command, skipping setup like `cd`, `export` and `FOO=bar` assignments — so
588
- `cd ../battle-tanks-api && run api` is labelled `[run api]`, not `[cd /Users/jo]`. Labels are also guaranteed
589
- unique within a batch: two branches that would otherwise share a label are widened until they differ, then
590
- distinguished by the directory they `cd` into (`[battle-tanks-api]` vs `[battle-tanks]`), and finally by an
591
- ordinal suffix (`[npm run dev #1]`). They're padded to a common width so the brackets line up, and each gets one
592
- of six cycling colors so adjacent branches stay distinct.
593
- SGR colors flow through unchanged. The prefix propagates through `@target` invocations too,
594
- so monorepo fan-outs like `{ "for": "ns", "in": "namespaces", "do": "@{{ VAR.ns }}:dev" }` tag every nested shell
595
- with its branch identity. Set `RUNFILE_NO_LINE_PREFIX=1` to opt out and inherit raw stdio.
596
-
597
- ```text
598
- [runfile] (1/3) [parallel] docker compose pull api
599
- [runfile] (2/3) [parallel] docker compose pull web
600
- [runfile] (3/3) [parallel] docker compose pull worker
601
- [1] Pulling api ... 50%
602
- [2] Pulling web ... 30%
603
- [3] Pulling worker ... 100%
604
- [1] Pulling api ... 100%
605
- [2] Pulling web ... 100%
606
- ```
607
-
608
- #### Cleanup on failure / always with `when:` blocks
609
-
610
- Wrap any commands in a `when:` block to run them only after a failure, or always — interleaved with the rest of
611
- `commands`, no separate `before`/`after` arrays.
612
-
613
- ```jsonc
614
- "commands": [
615
- "./run-tests.sh", // failure flips state
616
- "./report.sh", // skipped on failure (default when:success)
617
- { "when": "failure", "commands": ["./post-failure.sh"] }, // runs only after a failure
618
- { "when": "always", "commands": ["@cleanup"] } // runs every time
619
- ]
423
+ Nothing is declared for that — a `?` chain is what makes a value optional, and the literal it ends in is the
424
+ default. `run --stdin-args deploy` asks for the same list, in the same order, **before anything runs**.
425
+
426
+ Everything after a bare `--` is passed through untouched, flags included. That is how a wrapper forwards a
427
+ command line it does not understand:
428
+
429
+ ```sh
430
+ # runfiles/aws.run
431
+ # The AWS CLI, in a container
432
+
433
+ $ docker run --rm amazon/aws-cli {{ ARGS }}
620
434
  ```
621
435
 
622
- **Ctrl+C runs your cleanup too.** Pressing Ctrl+C stops the running command and skips everything still pending,
623
- but `when: failure` and `when: always` blocks still execute before Runfile exits (with code `130`) — so a
624
- container, a lock file, or a dev server started earlier gets torn down instead of leaked. The interrupt is not
625
- suppressible by `ignoreErrors` (that forgives a failed command, not a user asking to stop), it stops `for` loops
626
- from starting further iterations, and it reaches every target on the stack: an interrupted `@target` dependency
627
- runs its own cleanup, then its caller runs the caller's. Cleanup blocks are exempt from the abort, so their own
628
- steps — including a `@teardown` they delegate to — run in full. Press Ctrl+C **again** to quit immediately if a
629
- cleanup block is itself slow or stuck.
436
+ ```bash
437
+ run aws -- s3api list-buckets --output json
438
+ ```
630
439
 
631
- #### Single-shell mode with `sameShell`
440
+ ### Interpolation quotes itself
632
441
 
633
- By default each step runs in its own shell process, so `cd`, exported variables, and other shell state don't carry
634
- over between steps. Set `"sameShell": true` to join every step into a single shell invocation — state changes
635
- persist for free.
442
+ `{{ … }}` in a shell line becomes **exactly one argument** — or, for a list, one argument per item. So this is
443
+ safe with any file name, spaces and quotes included:
636
444
 
637
- ```jsonc
638
- {
639
- "deploy": {
640
- "sameShell": true,
641
- "commands": ["cd ci-scripts/", "./ci-deploy.sh"]
642
- }
643
- }
445
+ ```sh
446
+ $ cp {{ ARG.src }} {{ ARG.dest }}
644
447
  ```
645
448
 
646
- Steps are joined with `&&` so the run stops at the first failure (or `;` / `&` when `ignoreErrors: true`). `if`,
647
- `for`, and `match` blocks are evaluated by Runfile and their chosen branches flow into the same joined invocation.
648
- `@target` calls inside the body are rejected — they have their own shell context and can't share state with the
649
- parent. `sameShell` also composes with `detach: true` (joined command spawns as one detached process) and is
650
- available on `globals` for project-wide defaults.
449
+ **Never wrap an interpolation in shell quotes.** There is no `shell_quote` function because there is nothing
450
+ left to quote: the substitution already did it. The same rule holds one layer up inside a `json` block, where
451
+ an interpolation becomes one JSON value.
651
452
 
652
- #### Watch mode, built in
453
+ ### Asking whether a command worked
653
454
 
654
- Add a `watch` array to any target. No flags, no extra tooling.
455
+ A `$` run may stand as a condition or as a `match` subject. The condition is true when the command exits 0;
456
+ the cases are exit codes. Neither stops the target — a non-zero exit is the answer, not a failure.
655
457
 
656
- ```jsonc
657
- "watch": ["src/**/*.rs", "!target/**"]
458
+ ```sh
459
+ if $ command -v docker
460
+ $ docker info
461
+ else
462
+ error("docker is not on PATH")
463
+ end
464
+
465
+ match $ curl -fsS https://example.com
466
+ case "0"
467
+ print("up")
468
+ case "22"
469
+ error("the endpoint answered 4xx or 5xx")
470
+ default
471
+ error("could not reach it")
472
+ end
658
473
  ```
659
474
 
660
- #### Editor integrations
475
+ `code_of($ cmd)` is the same status as a number, for when you want to keep it — and `code_of(run other)`
476
+ scores another **target** the same way. That is how one target runs both halves of a check and gives a single
477
+ verdict at the end, instead of stopping at the first thing that failed:
661
478
 
662
- Generate native tasks for **VS Code**, **Zed**, and **JetBrains** IDEs from your Runfile:
479
+ ```sh
480
+ # runfiles/coverage.run
481
+ # Coverage for both halves, with one verdict
663
482
 
664
- ```bash
665
- $ run :generate vscode-tasks
666
- $ run :generate zed-tasks
667
- $ run :generate jetbrains-run-configurations
483
+ let rust = code_of(run coverage:rust)
484
+ let web = code_of(run web:coverage)
485
+
486
+ run _verdict --name=Rust --code={{ rust }}
487
+ run _verdict --name=Web --code={{ web }}
488
+
489
+ if rust != 0 || web != 0
490
+ exit(1)
491
+ end
668
492
  ```
669
493
 
670
- Each writes its editor's config to disk (`.vscode/tasks.json`, `.zed/tasks.json`, `.run/*.run.xml`),
671
- merging with any existing file. By default only the local Runfile's own targets are generated; two
672
- independent flags widen the set:
494
+ A dispatched target is scored the way `run` itself would exit: `exit(3)` is 3, any other failure is 1, and the
495
+ error is printed where it happened. An interrupt, or answering no to a `confirm()`, is not a status — that
496
+ stops the caller too.
673
497
 
674
- - `--include-namespaces` also generates entries for targets pulled in via `includes` — namespaced
675
- targets carry their `namespace:` prefixes, exactly as `run :list` shows them (e.g. `run api:build`).
676
- - `--include-globals` also generates entries for the global user-level Runfiles registered with
677
- `run :config global-files` — the same ones `run :list` folds in. Handy when an editor integration
678
- should offer your machine-wide targets (e.g. `run c`, `run backup-git`) alongside a project's own.
498
+ ### Capturing output
679
499
 
680
- ##### Building your own integration: `task-descriptors`
500
+ ```sh
501
+ let branch = $ git rev-parse --abbrev-ref HEAD
681
502
 
682
- For tooling that builds its **own** editor integration (like the
683
- [Runfile VS Code extension](editors/vscode) shipped with this repo), there's an editor-agnostic
684
- alternative that always prints to stdout and never touches disk:
503
+ if branch != "main"
504
+ error("release from main, not {{ branch }}")
505
+ end
506
+ ```
685
507
 
686
- ```bash
687
- $ run :generate task-descriptors
688
- ```
689
-
690
- It always resolves `includes` and merges registered global files (no `--include-*` flags), and emits
691
- a stable JSON document describing every runnable target grouped by its source file, with per-target
692
- provenance so a client can bucket local vs. included vs. global targets itself:
693
-
694
- ```jsonc
695
- {
696
- "formatVersion": 1,
697
- "sources": [
698
- {
699
- "filePath": "/abs/path/Runfile.json",
700
- "kind": "local", // "local" | "included" | "global"
701
- "onlyInDirectories": ["~/w"], // only on globals restricted to certain dirs
702
- "targets": [
703
- {
704
- "name": "api:build", // full canonical invocation name
705
- "namespace": "api", // omitted for un-namespaced targets
706
- "description": "Build the API"
707
- }
708
- ]
709
- }
710
- ]
711
- }
508
+ A `$` capture runs to the end of its line, so it can only be a call's **last** argument, and the `)` has to be
509
+ the last character of the line:
510
+
511
+ ```sh
512
+ let files = lines($ git diff --cached --name-only)
513
+
514
+ for f in lines($ git ls-files '*.sh')
515
+ $ shellcheck {{ f }}
516
+ end
712
517
  ```
713
518
 
714
- `onlyInDirectories` appears only on a `global` source that declared
715
- [`globals.onlyInDirectories`](.github/DOCS.md#onlyindirectories) — meaning it merged in only because the
716
- current directory is inside one of those paths. Such a source is registered machine-wide but scoped in
717
- practice, so clients are expected to treat it as project-local rather than truly global.
718
-
719
- Skip a target from the generated configs by setting `metadata.excludeFromGenerateCommand: true`. The
720
- `metadata` block on `globals` and on each target is **fully open** — any property of any JSON type
721
- (strings, numbers, booleans, arrays, deeply-nested objects) is accepted and round-trips untouched, so
722
- editor extensions and CI scripts can stash arbitrary tooling-specific fields here.
723
-
724
- Every file Runfile writes honors your project's [**`.editorconfig`**](https://editorconfig.org): the
725
- output is formatted to match the settings that apply to each written path — the editor files above
726
- (`.vscode/tasks.json`, `.zed/tasks.json`, `.run/*.run.xml`) as well as the `Runfile.json` produced by
727
- `:init` and `:convert`. `indent_style` / `indent_size` / `tab_width` drive indentation, `end_of_line`
728
- sets the newline sequence, `insert_final_newline` and `trim_trailing_whitespace` are applied, and
729
- `charset = utf-8-bom` prepends a BOM. When no `.editorconfig` applies, the previous defaults are kept.
730
-
731
- #### Preparation targets (like `npm prepare`, but enforced)
732
-
733
- Most projects have a one-time setup step — install git hooks, pull dependencies, generate config.
734
- It's easy to forget, and easy to fall behind when it changes. Point `globals.prepare` at a target and
735
- Runfile makes it a precondition for **every** target in the file:
736
-
737
- ```jsonc
738
- {
739
- "globals": { "prepare": "@setup" },
740
- "targets": {
741
- "setup": { "commands": ["git config core.hooksPath .githooks", "npm ci"] },
742
- "build": { "commands": ["npm run build"] },
743
- "test": { "commands": ["npm test"], "prepare": "@setup-tests" },
744
- "setup-tests": { "commands": ["./scripts/seed-test-db.sh"] }
745
- }
746
- }
519
+ `code_of($ cmd)` and `code_of(run other)` are the same shape: run it and take the status, ignoring how it went
520
+ — which is what `|| true` was doing.
521
+
522
+ ### Blocks that are not shell
523
+
524
+ `$ line` is shorthand for a one-line `exec` with the default shell. Name any other command and the block
525
+ becomes its stdin — see [Any language you have installed](#any-language-you-have-installed).
526
+
527
+ ```sh
528
+ exec python3
529
+ import json, sys
530
+
531
+ print(json.dumps({"ok": True}))
532
+ end
747
533
  ```
748
534
 
749
- Run anything before setup has run and you get a clear stop, not a confusing failure:
535
+ ### Stopping early
750
536
 
751
- ```console
752
- $ run build
753
- Error: target "build" requires preparation steps that haven't been completed:
754
- • run setup (never run)
537
+ `exit()` ends the run with a status; `error("…")` fails with a message; `confirm("…")` asks, and stops if the
538
+ answer is no. Nothing catches any of them: not a `?` fallback, not `.ignore-errors`. A target may forgive a
539
+ command that failed, but being told to stop is not that.
540
+
541
+ `confirm` is a function rather than a property, so the question can depend on what is about to happen — and is
542
+ skipped by `-y`, in CI, and under `--dry-run`, where there is nothing to approve.
543
+
544
+ A line that is only a value — `exit`, `abc`, `35` — is a parse error, since it computes something and throws
545
+ it away. Most often it is a call with the parentheses left off, and the message says so.
546
+
547
+ ## Properties
548
+
549
+ Set at the top of the file, or inside a block where marked.
550
+
551
+ | Property | Effect | In a block? |
552
+ | --- | --- | :-: |
553
+ | `.shell` | Which shell `$` lines use. | ✅ |
554
+ | `.env.NAME` | Set an environment variable. | ✅ |
555
+ | `.workdir` | Where commands run. | ✅ |
556
+ | `.parallel` | Run this block's commands at once. On a `for`, its iterations. | ✅ |
557
+ | `.ignore-errors` | Keep going when a command fails. | ✅ |
558
+ | `.logging` | Announce each command on stderr before it runs. Off unless set. | ✅ |
559
+ | `.env-file` | Load a `.env` file (encrypted values are decrypted in memory). Appends. | ✅ |
560
+ | `.add-path` | Prepend a directory to `PATH`. Appends. | ✅ |
561
+ | `.watch` | Re-run when matching files change. A `!` prefix excludes. | |
562
+ | `.alias` | Another name for this target. | |
563
+ | `.detach` | Start the commands and do not wait. | |
564
+ | `.only-in-directories` | For the machine-wide directory: only offer these targets inside these directories. | |
565
+
566
+ `.env-file` and `.add-path` **append**, so a block adds to what it inherited rather than replacing it, and a
567
+ block's file can be named by something the body worked out — which a header property cannot do, because it
568
+ resolves before any statement runs:
569
+
570
+ ```sh
571
+ # runfiles/deploy.run
572
+ # Deploy to an environment
573
+
574
+ let env = one_of(first(ARGS), "staging", "production")
575
+
576
+ if env == "production"
577
+ .env-file = ".env.production"
578
+ .add-path = "vendor/prod-tools"
579
+
580
+ confirm("Deploy to production?")
581
+ end
582
+
583
+ $ terraform apply -auto-approve
755
584
  ```
756
585
 
757
- Once you `run setup`, Runfile records a hash of its commands (following `@target` calls, so nested
758
- setups count too). Future runs pass silently — **until those commands change**, at which point the
759
- requirement re-triggers and everyone is prompted to re-run it. A target's own `prepare` is *additive*
760
- on top of the global one, so `run test` above requires both `setup` and `setup-tests`.
586
+ **A target whose file name starts with `_` is hidden** from `run :list`, from completion and from generated
587
+ editor tasks — and still runs when something calls it. That is the whole of it; there is no property to
588
+ disagree with the name.
761
589
 
762
- Preparation is a developer-machine concern, so the gate is **skipped automatically in CI** (detected
763
- via `CI`, `GITHUB_ACTIONS`, and friends). Set `RUNFILE_SKIP_PREPARE=1` to bypass it manually.
590
+ ```
591
+ runfiles/_aws.run → run _aws (a helper other targets call)
592
+ runfiles/deploy.run → run deploy
593
+ ```
764
594
 
765
- #### Migrate in seconds
595
+ `do … end` is a block with no condition — somewhere for a property to go when it should cover a few commands
596
+ and not the whole target:
766
597
 
767
- ```bash
768
- $ run :convert package-json # turns package.json scripts into targets
769
- $ run :convert makefile # turns Makefile recipes into targets
598
+ ```sh
599
+ # runfiles/setup.run
600
+ # One-time per clone: hooks, then the web dependencies
601
+
602
+ $ git config core.hooksPath .githooks
603
+
604
+ do
605
+ .workdir = "web"
606
+
607
+ $ pnpm install
608
+ $ pnpm exec playwright install chromium
609
+ end
770
610
  ```
771
611
 
772
- #### Plus
612
+ That is what replaced writing `cd web &&` on every line, which the shell would have needed once per command.
773
613
 
774
- Parallel & detached execution · file includes with optional namespacing for monorepos
775
- (`pnpm --recursive --parallel`-style aggregation) · cycle detection · global Runfiles · path aliases ·
776
- confirmation prompts · `--dry-run` · `--stdin-args` (interactive prompting for missing inputs) · `--timings` ·
777
- force-kill on SIGINT (works for GUI apps like Unity) · stdio tailers · shell completions · MCP server.
614
+ `_shared.run` holds properties — and `let` bindings — that every target in its directory inherits. A nested
615
+ one layers over the directory above it, so `runfiles/api/_shared.run` adds to `runfiles/_shared.run`.
778
616
 
779
- ---
617
+ ```sh
618
+ # runfiles/_shared.run
619
+ # Settings every target here inherits
780
620
 
781
- ## Platform Support
621
+ .add-path = "node_modules/.bin"
622
+ .env.CARGO_TERM_COLOR = "always"
782
623
 
783
- | Platform | Status |
784
- |-----------------------|-----------|
785
- | Linux (x86-64) | Supported |
786
- | Linux (ARM64) | Supported |
787
- | macOS (Apple Silicon) | Supported |
788
- | macOS (Intel) | Supported |
789
- | Windows 10 | Supported |
790
- | Windows 11 | Supported |
791
- | Windows Server | Supported |
624
+ let image = "ghcr.io/example/builder:v2"
625
+ ```
792
626
 
793
- Single static binary. No runtime, no interpreter, no package manager.
627
+ ## How targets are found
794
628
 
795
- ---
629
+ `run` walks **up** from the working directory for the nearest `runfiles/`, then **down** one level for
630
+ subprojects. A nested directory becomes a namespace:
796
631
 
797
- ## Documentation
632
+ ```
633
+ runfiles/build.run → run build
634
+ web/runfiles/build.run → run web:build
635
+ runfiles/api/deploy.run → run api:deploy
636
+ ```
798
637
 
799
- The full reference — every property, every flag, every subcommand — lives in [DOCS.md](.github/DOCS.md).
638
+ Inside a subproject, `run build` means *that* subproject's `build` — so a file reads the same wherever you
639
+ invoke `run` from.
800
640
 
801
- - [CLI usage and subcommands](.github/DOCS.md#cli-usage)
802
- - [Runfile.json reference](.github/DOCS.md#runfilejson-reference)
803
- - [Arguments and substitution](.github/DOCS.md#arguments-and-substitution)
804
- - [Encrypted environment variables](.github/DOCS.md#encrypted-environment-variables)
805
- - [Internal targets](.github/DOCS.md#internal-targets)
806
- - [`when:` blocks](.github/DOCS.md#when-guarded-blocks-when)
807
- - [Watch mode](.github/DOCS.md#watch-mode)
808
- - [Editor integration](.github/DOCS.md#editor-integration)
641
+ `$HOME/.runfiles/` holds machine-wide targets, available in every project. If you would rather see the
642
+ directory than hide it, `$HOME/runfiles/` and `$HOME/Runfiles/` are read too — but only one of the three may
643
+ hold anything.
809
644
 
810
- ---
645
+ Everything relative — `.env-file`, `.add-path`, `glob`, `read_file`, `{{ RUN.parent }}`, the working directory
646
+ — resolves against **the parent of `runfiles/`**. One anchor, one rule.
811
647
 
812
- ## Development
648
+ ## Setup targets
649
+
650
+ Name a target `setup` and every other target in its directory refuses to run until it has:
813
651
 
814
- ```bash
815
- run setup # one-time per clone: activates the committed git hooks (.githooks/)
816
- run build # debug build
817
- run check # non-mutating gate: fmt --check + clippy (deny warnings) + cargo check
818
- run lint # auto-format + clippy
819
- run test # full workspace test suite
820
652
  ```
653
+ $ run build
654
+ error: `setup` has never been run
655
+
656
+ run setup
657
+ ```
658
+
659
+ It re-triggers when `setup.run` itself changes, so a new dependency is not silently missed. `--dry-run` is
660
+ exempt, CI is exempt, and `RUNFILE_SKIP_PREPARE=1` bypasses it.
661
+
662
+ ## Commands
663
+
664
+ | Command | |
665
+ | --- | --- |
666
+ | `run <target> [args…]` | Run a target |
667
+ | `run :list` | List every target (`--names`, `--json` for tooling) |
668
+ | `run :init` | Create `runfiles/` with an example |
669
+ | `run :format [path…]` | Format runfiles in place (`--check`, `--stdout`) |
670
+ | `run :env <sub>` | Manage `.env` files: `init`, `get`, `set`, `encrypt`, `decrypt`, `rotate`, `inject`, `secret-keys` |
671
+ | `run :completions <command> <shell>` | `install`, `uninstall` or `output` a completion script for `bash`, `zsh`, `fish` or `powershell` |
672
+ | `run :generate <editor>` | Task files for `zed`, `jetbrains` or `vscode`, merged into what is there |
673
+ | `run :update` | Update the binary |
821
674
 
822
- The VS Code extension in [`editors/vscode`](editors/vscode) has its own targets, namespaced under `vscode:`:
675
+ | Flag | |
676
+ | --- | --- |
677
+ | `-y`, `--yes` | Skip confirmation prompts |
678
+ | `--stdin-args` | Prompt for every input the target reads, before it runs |
679
+ | `--dry-run` | Print what would run, without running it |
680
+ | `--dir <path>` | Start discovery somewhere else |
681
+ | `-h`, `--help` | Show the help for `run` or any of its commands |
682
+ | `-v`, `--version` | Print the version |
683
+
684
+ Flags belong **before** the target name; everything after it is passed to the target.
685
+
686
+ `run :format` has no settings: one shape, everywhere. It reindents with tabs, spaces expressions, places blank
687
+ lines, lays out `json` blocks, and leaves the three things that are not the language's to touch — strings, the
688
+ text after `$ `, and `exec` bodies — exactly as written. It refuses a file that does not parse, and checks that
689
+ its own output still means the same thing before writing it. `--check` reports what would change and exits 1,
690
+ which is what a CI step wants.
691
+
692
+ ## Encrypted environment variables
693
+
694
+ Values in a `.env` file can be encrypted individually, so the file stays readable and diffable:
695
+
696
+ ```
697
+ DATABASE_URL=encrypted:AAAA...
698
+ PUBLIC_HOST=example.com
699
+ ```
823
700
 
824
701
  ```bash
825
- run vscode:deps # one-time: pnpm install (required before the targets below)
826
- run vscode:compile # type-check + compile to editors/vscode/out
827
- run vscode:watch # recompile on change
828
- run vscode:package # build the .vsix
829
- run vscode:install # package + install into VS Code
702
+ run :env init .env.production # create, with a fresh key
703
+ run :env set .env.production KEY value # encrypts on write
704
+ run :env get .env.production KEY # decrypts on read
705
+ run :env rotate .env.production # re-encrypt under a new key
830
706
  ```
831
707
 
832
- ---
708
+ Keys live in the OS credential store — Keychain, Credential Manager, or Secret Service with a keyutils
709
+ fallback. In CI, pass them as `RUNFILE_PRIVATE_KEYS` (newline-separated) and no credential store is involved.
710
+
711
+ Decryption happens in memory; secrets never reach disk. The credential store is only touched when something
712
+ actually decrypts, so a locked keyring never gets in the way of an unrelated target.
713
+
714
+ ## Editor support
715
+
716
+ `runfile-lsp` ships beside `run` and gives you diagnostics, completion, formatting, go-to-definition and
717
+ documentation on hover — from the same parser `run` itself uses, so your editor never disagrees with what will
718
+ actually happen.
719
+
720
+ Hover anything: a function shows its signature, what it does and a worked example; a property adds whether it
721
+ may sit inside a block; `$`, `exec`, `run`, `retry`, `match` and the rest explain the line form itself.
722
+
723
+ Ctrl+click a `run <target>` to open that target's file, or a variable to jump to where it was bound — including
724
+ into the `_shared.run` above it, which is the one definition you cannot find by reading the file in front of
725
+ you.
726
+
727
+ It also hands `$` lines and shell `exec` bodies to [shellcheck](https://www.shellcheck.net) when it is
728
+ installed, mapping findings back to the lines you wrote.
729
+
730
+ **VS Code** — install the `.vsix` from the [latest release](https://github.com/JoaaoVerona/runfile/releases).
731
+ You get a Run button on every target, a task provider, a sidebar tree, and shell lines coloured exactly as the
732
+ same command in a `.sh` file. Set `"editor.formatOnSave": true` and saving formats.
733
+
734
+ **JetBrains IDEs** read the same grammar: *Settings → Editor → TextMate Bundles*, add the `editors/vscode`
735
+ directory from a checkout.
736
+
737
+ **Zed, Neovim and Helix** use the tree-sitter grammar in `editors/tree-sitter`, with highlight queries
738
+ included. For Neovim with nvim-treesitter:
739
+
740
+ ```lua
741
+ require("nvim-treesitter.parsers").get_parser_configs().runfile = {
742
+ install_info = {
743
+ url = "https://github.com/JoaaoVerona/runfile",
744
+ location = "editors/tree-sitter",
745
+ files = { "src/parser.c", "src/scanner.c" },
746
+ },
747
+ filetype = "runfile",
748
+ }
749
+ vim.filetype.add({ extension = { run = "runfile" } })
750
+ ```
751
+
752
+ `run :generate zed` writes every target into `.zed/tasks.json`, and `run :generate jetbrains` into
753
+ `.idea/runConfigurations/`; both leave entries you wrote yourself alone and replace only their own.
754
+
755
+ ## Why a language
756
+
757
+ The alternative is a config format plus an escape hatch into shell, and every version of that ends up encoding
758
+ control flow in JSON or YAML — or giving up and shipping shell scripts that only run on one platform.
759
+
760
+ A small language means `if`, `for` and `match` read as themselves; values have types, so `"a" + 1` is a mistake
761
+ rather than `"a1"`; and interpolation can be safe by construction, because the language knows a string is one
762
+ argument.
763
+
764
+ Shell is still there. It is just marked.
765
+
766
+ ## How it compares
767
+
768
+ | | Runfile | Make | Just | Taskfile |
769
+ | --- | :-: | :-: | :-: | :-: |
770
+ | One target per file, so a shared task file never conflicts | ✅ | ❌ | ❌ | ❌ |
771
+ | Encrypted env vars, built-in (AES-256-GCM) | ✅ | ❌ | ❌ | ❌ |
772
+ | Inline OS / shell / cwd branching via `RUN.*` | ✅ | ❌ | ❌ | ❌ |
773
+ | Editor diagnostics from the runner's own parser | ✅ | ❌ | ❌ | ❌ |
774
+ | Hover documentation for every function, property and line form | ✅ | ❌ | ❌ | ❌ |
775
+ | One canonical format, in the CLI and on save | ✅ | ❌ | ❌ | ❌ |
776
+ | IDE task generation (VS Code / Zed / JetBrains) | ✅ | ❌ | ❌ | ❌ |
777
+ | Per-target shell override | ✅ | ❌ | ✅ | ❌ |
778
+ | Any interpreter for a block (`exec python3`) | ✅ | ❌ | ✅ | ❌ |
779
+ | Strict parsing (typos are errors) | ✅ | ❌ | ✅ | ❌ |
780
+ | `--help` for one target, built from its own comments | ✅ | ❌ | ✅ | ❌ |
781
+ | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
782
+ | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
783
+ | Shell completions | ✅ | ❌ | ✅ | ✅ |
784
+ | Hidden targets | ✅ | ❌ | ✅ | ✅ |
785
+ | Built-in functions (strings, regex, paths, JSON, hashes, time) | ✅ | ❌ | ✅ | ✅ |
786
+ | Parallel execution | ✅ | ✅ | ❌ | ✅ |
787
+ | Single static binary | ✅ | ✅ | ✅ | ✅ |
788
+ | Native Windows binary (`$` lines use Git Bash) | ✅ | ❌ | ✅ | ✅ |
789
+ | Output prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
790
+ | First-class PowerShell / cmd.exe | ❌ | ❌ | ✅ | ❌ |
791
+ | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
792
+ | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
793
+ | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
794
+
795
+ ## Platform support
796
+
797
+ | | |
798
+ | --- | --- |
799
+ | Linux | x86-64, arm64 |
800
+ | macOS | Intel, Apple Silicon |
801
+ | Windows | x86-64, arm64 |
802
+
803
+ `$` lines use bash where it exists, Git Bash on Windows, and `sh` otherwise — so one file works everywhere. Set
804
+ `.shell` to pin something else.
833
805
 
834
806
  ## License
835
807