@runfile/cli 0.12.0 → 0.13.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.
@@ -111,16 +111,16 @@ Sorted from where Runfile is most differentiated to where it lags behind.
111
111
  | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
112
112
  | JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
113
113
  | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
114
+ | Output grouping / prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
114
115
  | Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
115
- | Parallel execution | ✅ | ✅ | ❌ | ✅ |
116
116
  | Shell completions | ✅ | ❌ | ✅ | ✅ |
117
117
  | Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
118
+ | Built-in string functions (upper, replace, trim, regex, base64, …) | ✅ | ❌ | ✅ | ✅ |
119
+ | Parallel execution | ✅ | ✅ | ❌ | ✅ |
118
120
  | Single static binary | ✅ | ✅ | ✅ | ✅ |
119
121
  | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
120
- | Output grouping / prefixing in parallel mode | ❌ | ❌ | ❌ | ✅ |
121
122
  | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
122
123
  | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
123
- | Built-in string functions (upper, replace, trim, …) | ❌ | ❌ | ✅ | ✅ |
124
124
 
125
125
  ---
126
126
 
@@ -167,9 +167,9 @@ $ run :env inject -f .env.production -- ./deploy.sh # encrypted values aut
167
167
 
168
168
  #### Powerful argument substitution
169
169
 
170
- Positional, named, flags, env vars, and runtime context (`{{ RUN.os }}` / `{{ RUN.shell }}` / `{{ RUN.cwd }}` /
171
- `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The `{{ ... }}` syntax
172
- avoids collisions with shell `$(...)` command substitution.
170
+ Positional, named, flags, env vars, and runtime context (`{{ RUN.os }}` / `{{ RUN.arch }}` / `{{ RUN.shell }}` /
171
+ `{{ RUN.cwd }}` / `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The
172
+ `{{ ... }}` syntax avoids collisions with shell `$(...)` command substitution.
173
173
 
174
174
  ```jsonc
175
175
  "PORT": "{{ ARGS.port ? ENV.PORT ? '3000' }}",
@@ -242,9 +242,15 @@ and work as full substitution bodies *or* as chain segments:
242
242
  // transform : escape, repeat, replace_all, remove_all
243
243
  // regex : regex_replace, regex_remove, regex_matches
244
244
  // build : concat, join
245
+ // split : nth, first, last, count_parts
245
246
  // encoding : base64_encode, base64_decode
247
+ // hashing : sha256, md5
248
+ // files : read_file, file_exists
249
+ // json : json_get, json_set
250
+ // error : try
246
251
  // shell : shell_quote
247
252
  // variables : define
253
+ // cwd : set_cwd
248
254
  "echo deploying-{{ to_upper(ARGS.env) }}",
249
255
  "curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
250
256
  "echo {{ concat('hello-', ARGS.name, '-2026') }}",
@@ -253,6 +259,13 @@ and work as full substitution bodies *or* as chain segments:
253
259
  "go test {{ replace_all(ARGS.flags, ' ', ' -tag=') }}",
254
260
  // Strip every match of a regex (here: collapse whitespace runs to a single space):
255
261
  "echo {{ regex_replace(ARGS.text, '\\s+', ' ') }}",
262
+ // Split-by-separator scalar accessors — string in, string out, no list type:
263
+ // basename idiom (last segment after `/`); empty string when input ends in `/`.
264
+ "echo basename={{ last(ARGS.path, '/') }}",
265
+ // Pull out the N-th comma-separated field; out-of-bounds returns "".
266
+ "echo target={{ nth(ARGS.csv, ',', '1') }}",
267
+ // Pair `count_parts` with `nth` to bound-check before indexing:
268
+ // "if": "{{ count_parts(ARGS.csv, ',') == '3' }}"
256
269
  // Boolean-returning helpers are valid DSL `Truthy` values — use them in `if`:
257
270
  // "if": "{{ starts_with(ARGS.path, '/usr') }}"
258
271
  // "if": "{{ regex_matches(ARGS.tag, '^v[0-9]+\\.[0-9]+$') }}"
@@ -261,6 +274,28 @@ and work as full substitution bodies *or* as chain segments:
261
274
  // `shell_quote` picks the right quoting for the active shell:
262
275
  "some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
263
276
 
277
+ // Cache keys / content fingerprints. `sha256` for security-sensitive use,
278
+ // `md5` for cheap fingerprinting (NOT cryptographically secure).
279
+ "echo cache-key={{ sha256(read_file('package-lock.json')) }}",
280
+
281
+ // Branch on file presence without forking a shell. `file_exists` returns
282
+ // the literal "true" / "false", so it doubles as a DSL truthy value.
283
+ // "if": "{{ file_exists('.env.local') }}"
284
+
285
+ // Read a file inline — relative paths anchor to the Runfile directory.
286
+ // Use `try(...)` to recover from missing files.
287
+ "echo version={{ try(read_file('VERSION')) ? '0.0.0-dev' }}",
288
+
289
+ // Pull values out of arbitrary JSON without `jq` (works on every shell):
290
+ "echo db_host={{ json_get(read_file('config.json'), 'database.host') }}",
291
+
292
+ // Modify a JSON document in place (returns the new compact JSON):
293
+ "echo {{ json_set('{\"port\":3000}', 'env', 'production') }}",
294
+
295
+ // `try(expr)` swallows inner errors. Standalone returns "" on failure;
296
+ // chained, the next segment runs as a fallback.
297
+ "echo {{ try(base64_decode(ARGS.maybe_b64)) ? ARGS.maybe_b64 }}",
298
+
264
299
  // Nested:
265
300
  "echo {{ to_upper(to_lower(ARGS.x)) }}",
266
301
 
@@ -272,6 +307,13 @@ and work as full substitution bodies *or* as chain segments:
272
307
  "{{ VARS.sdk }}/bin/build",
273
308
  "echo using sdk at {{ VARS.sdk }}",
274
309
 
310
+ // `set_cwd(path)` switches the cwd subsequent commands spawn in — like
311
+ // shell `cd`, but works on every shell / OS without forking. Relative
312
+ // paths chain (matching `cd a; cd b` → `a/b`); absolute paths replace.
313
+ "{{ set_cwd(ARGS.subproject ? 'packages/api') }}",
314
+ "npm install",
315
+ "npm run build",
316
+
275
317
  // Single-quoted strings interpolate nested {{ }} substitutions:
276
318
  "{{ define(cmd, 'docker compose -f {{ VARS.compose }} pull') }}",
277
319
  "{{ VARS.cmd }}"
@@ -283,6 +325,22 @@ to only whitespace (i.e. one consisting solely of a `{{ define(...) }}` call) is
283
325
  being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
284
326
  be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
285
327
 
328
+ `set_cwd(path)` is the cwd analog of `define`: returns an empty string and changes the cwd that subsequent shell
329
+ commands in the *current target* spawn in. Behaves like shell `cd`, but works uniformly across every shell / OS
330
+ (no forked process, no `cd` binary needed). Resolution rules:
331
+
332
+ - **Absolute path** → fully replaces the current override (matches `cd /abs`).
333
+ - **Relative path** → joins onto the existing override, or onto the target's `workingDirectory` if no override
334
+ has been set yet. So `set_cwd('a'); set_cwd('b')` lands subsequent commands in `<workingDirectory>/a/b`,
335
+ matching how `cd a; cd b` chains in a shell.
336
+
337
+ `set_cwd` is **per-target**: each `@target` invocation starts with a clean override (the dispatched target sees
338
+ its own `workingDirectory`, not the caller's `set_cwd` state). Inside `parallel: true` targets each leaf
339
+ captures the override at the moment of its substitution, so siblings don't race on the spawn cwd. Like
340
+ `define`, the side effect is skipped on the redacted-logging pass so log lines don't double-apply it. With
341
+ `sameShell: true`, only the *final* `set_cwd` value applies (everything joins into one shell invocation) — use
342
+ shell `cd` directly between leaves there if you need intermediate cwd changes.
343
+
286
344
  Function args are separated by `, ` (comma + exactly one space).
287
345
 
288
346
  **Quote semantics inside `{{ ... }}`:**
@@ -311,13 +369,18 @@ evaluated by Runfile itself, so the logic works the same on every shell and plat
311
369
  "cases": {
312
370
  "1": "flutter emulators --launch Tier_1_Android_9",
313
371
  "2": "flutter emulators --launch Tier_2_Android_11",
314
- "3": "flutter emulators --launch Tier_3_Android_14"
372
+ "3": "flutter emulators --launch Tier_3_Android_14",
373
+ // Case keys wrapped in `/.../` are treated as regex patterns.
374
+ // Literal cases always win over a regex that would also match.
375
+ "/^v\\d+$/": "echo version-tag",
376
+ "/^pr-\\d+$/": "echo preview-build"
315
377
  } }, // unknown values error out, listing the valid cases
316
378
 
317
379
  { "for": "service",
318
380
  "in": ["api", "web", "worker"],
319
381
  "parallel": true,
320
- "do": ["docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
382
+ // VARS.<name>_index exposes the 0-based iteration counter.
383
+ "do": ["echo [{{ VARS.service_index }}] docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
321
384
 
322
385
  { "for": "file",
323
386
  "shell": "git diff --name-only HEAD~1",
@@ -379,6 +442,27 @@ Wrap any commands in a `when:` block to run them only after a failure, or always
379
442
  ]
380
443
  ```
381
444
 
445
+ #### Single-shell mode with `sameShell`
446
+
447
+ By default each step runs in its own shell process, so `cd`, exported variables, and other shell state don't carry
448
+ over between steps. Set `"sameShell": true` to join every step into a single shell invocation — state changes
449
+ persist for free.
450
+
451
+ ```jsonc
452
+ {
453
+ "deploy": {
454
+ "sameShell": true,
455
+ "commands": ["cd ci-scripts/", "./ci-deploy.sh"]
456
+ }
457
+ }
458
+ ```
459
+
460
+ Steps are joined with `&&` so the run stops at the first failure (or `;` / `&` when `ignoreErrors: true`). `if`,
461
+ `for`, and `match` blocks are evaluated by Runfile and their chosen branches flow into the same joined invocation.
462
+ `@target` calls inside the body are rejected — they have their own shell context and can't share state with the
463
+ parent. `sameShell` also composes with `detach: true` (joined command spawns as one detached process) and is
464
+ available on `globals` for project-wide defaults.
465
+
382
466
  #### Watch mode, built in
383
467
 
384
468
  Add a `watch` array to any target. No flags, no extra tooling.
@@ -397,6 +481,11 @@ $ run :generate zed-tasks
397
481
  $ run :generate jetbrains-run-configurations
398
482
  ```
399
483
 
484
+ Skip a target from the generated configs by setting `metadata.excludeFromGenerateCommand: true`. The
485
+ `metadata` block on `globals` and on each target is **fully open** — any property of any JSON type
486
+ (strings, numbers, booleans, arrays, deeply-nested objects) is accepted and round-trips untouched, so
487
+ editor extensions and CI scripts can stash arbitrary tooling-specific fields here.
488
+
400
489
  #### Migrate in seconds
401
490
 
402
491
  ```bash
Binary file
@@ -111,16 +111,16 @@ Sorted from where Runfile is most differentiated to where it lags behind.
111
111
  | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
112
112
  | JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
113
113
  | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
114
+ | Output grouping / prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
114
115
  | Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
115
- | Parallel execution | ✅ | ✅ | ❌ | ✅ |
116
116
  | Shell completions | ✅ | ❌ | ✅ | ✅ |
117
117
  | Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
118
+ | Built-in string functions (upper, replace, trim, regex, base64, …) | ✅ | ❌ | ✅ | ✅ |
119
+ | Parallel execution | ✅ | ✅ | ❌ | ✅ |
118
120
  | Single static binary | ✅ | ✅ | ✅ | ✅ |
119
121
  | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
120
- | Output grouping / prefixing in parallel mode | ❌ | ❌ | ❌ | ✅ |
121
122
  | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
122
123
  | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
123
- | Built-in string functions (upper, replace, trim, …) | ❌ | ❌ | ✅ | ✅ |
124
124
 
125
125
  ---
126
126
 
@@ -167,9 +167,9 @@ $ run :env inject -f .env.production -- ./deploy.sh # encrypted values aut
167
167
 
168
168
  #### Powerful argument substitution
169
169
 
170
- Positional, named, flags, env vars, and runtime context (`{{ RUN.os }}` / `{{ RUN.shell }}` / `{{ RUN.cwd }}` /
171
- `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The `{{ ... }}` syntax
172
- avoids collisions with shell `$(...)` command substitution.
170
+ Positional, named, flags, env vars, and runtime context (`{{ RUN.os }}` / `{{ RUN.arch }}` / `{{ RUN.shell }}` /
171
+ `{{ RUN.cwd }}` / `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The
172
+ `{{ ... }}` syntax avoids collisions with shell `$(...)` command substitution.
173
173
 
174
174
  ```jsonc
175
175
  "PORT": "{{ ARGS.port ? ENV.PORT ? '3000' }}",
@@ -242,9 +242,15 @@ and work as full substitution bodies *or* as chain segments:
242
242
  // transform : escape, repeat, replace_all, remove_all
243
243
  // regex : regex_replace, regex_remove, regex_matches
244
244
  // build : concat, join
245
+ // split : nth, first, last, count_parts
245
246
  // encoding : base64_encode, base64_decode
247
+ // hashing : sha256, md5
248
+ // files : read_file, file_exists
249
+ // json : json_get, json_set
250
+ // error : try
246
251
  // shell : shell_quote
247
252
  // variables : define
253
+ // cwd : set_cwd
248
254
  "echo deploying-{{ to_upper(ARGS.env) }}",
249
255
  "curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
250
256
  "echo {{ concat('hello-', ARGS.name, '-2026') }}",
@@ -253,6 +259,13 @@ and work as full substitution bodies *or* as chain segments:
253
259
  "go test {{ replace_all(ARGS.flags, ' ', ' -tag=') }}",
254
260
  // Strip every match of a regex (here: collapse whitespace runs to a single space):
255
261
  "echo {{ regex_replace(ARGS.text, '\\s+', ' ') }}",
262
+ // Split-by-separator scalar accessors — string in, string out, no list type:
263
+ // basename idiom (last segment after `/`); empty string when input ends in `/`.
264
+ "echo basename={{ last(ARGS.path, '/') }}",
265
+ // Pull out the N-th comma-separated field; out-of-bounds returns "".
266
+ "echo target={{ nth(ARGS.csv, ',', '1') }}",
267
+ // Pair `count_parts` with `nth` to bound-check before indexing:
268
+ // "if": "{{ count_parts(ARGS.csv, ',') == '3' }}"
256
269
  // Boolean-returning helpers are valid DSL `Truthy` values — use them in `if`:
257
270
  // "if": "{{ starts_with(ARGS.path, '/usr') }}"
258
271
  // "if": "{{ regex_matches(ARGS.tag, '^v[0-9]+\\.[0-9]+$') }}"
@@ -261,6 +274,28 @@ and work as full substitution bodies *or* as chain segments:
261
274
  // `shell_quote` picks the right quoting for the active shell:
262
275
  "some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
263
276
 
277
+ // Cache keys / content fingerprints. `sha256` for security-sensitive use,
278
+ // `md5` for cheap fingerprinting (NOT cryptographically secure).
279
+ "echo cache-key={{ sha256(read_file('package-lock.json')) }}",
280
+
281
+ // Branch on file presence without forking a shell. `file_exists` returns
282
+ // the literal "true" / "false", so it doubles as a DSL truthy value.
283
+ // "if": "{{ file_exists('.env.local') }}"
284
+
285
+ // Read a file inline — relative paths anchor to the Runfile directory.
286
+ // Use `try(...)` to recover from missing files.
287
+ "echo version={{ try(read_file('VERSION')) ? '0.0.0-dev' }}",
288
+
289
+ // Pull values out of arbitrary JSON without `jq` (works on every shell):
290
+ "echo db_host={{ json_get(read_file('config.json'), 'database.host') }}",
291
+
292
+ // Modify a JSON document in place (returns the new compact JSON):
293
+ "echo {{ json_set('{\"port\":3000}', 'env', 'production') }}",
294
+
295
+ // `try(expr)` swallows inner errors. Standalone returns "" on failure;
296
+ // chained, the next segment runs as a fallback.
297
+ "echo {{ try(base64_decode(ARGS.maybe_b64)) ? ARGS.maybe_b64 }}",
298
+
264
299
  // Nested:
265
300
  "echo {{ to_upper(to_lower(ARGS.x)) }}",
266
301
 
@@ -272,6 +307,13 @@ and work as full substitution bodies *or* as chain segments:
272
307
  "{{ VARS.sdk }}/bin/build",
273
308
  "echo using sdk at {{ VARS.sdk }}",
274
309
 
310
+ // `set_cwd(path)` switches the cwd subsequent commands spawn in — like
311
+ // shell `cd`, but works on every shell / OS without forking. Relative
312
+ // paths chain (matching `cd a; cd b` → `a/b`); absolute paths replace.
313
+ "{{ set_cwd(ARGS.subproject ? 'packages/api') }}",
314
+ "npm install",
315
+ "npm run build",
316
+
275
317
  // Single-quoted strings interpolate nested {{ }} substitutions:
276
318
  "{{ define(cmd, 'docker compose -f {{ VARS.compose }} pull') }}",
277
319
  "{{ VARS.cmd }}"
@@ -283,6 +325,22 @@ to only whitespace (i.e. one consisting solely of a `{{ define(...) }}` call) is
283
325
  being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
284
326
  be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
285
327
 
328
+ `set_cwd(path)` is the cwd analog of `define`: returns an empty string and changes the cwd that subsequent shell
329
+ commands in the *current target* spawn in. Behaves like shell `cd`, but works uniformly across every shell / OS
330
+ (no forked process, no `cd` binary needed). Resolution rules:
331
+
332
+ - **Absolute path** → fully replaces the current override (matches `cd /abs`).
333
+ - **Relative path** → joins onto the existing override, or onto the target's `workingDirectory` if no override
334
+ has been set yet. So `set_cwd('a'); set_cwd('b')` lands subsequent commands in `<workingDirectory>/a/b`,
335
+ matching how `cd a; cd b` chains in a shell.
336
+
337
+ `set_cwd` is **per-target**: each `@target` invocation starts with a clean override (the dispatched target sees
338
+ its own `workingDirectory`, not the caller's `set_cwd` state). Inside `parallel: true` targets each leaf
339
+ captures the override at the moment of its substitution, so siblings don't race on the spawn cwd. Like
340
+ `define`, the side effect is skipped on the redacted-logging pass so log lines don't double-apply it. With
341
+ `sameShell: true`, only the *final* `set_cwd` value applies (everything joins into one shell invocation) — use
342
+ shell `cd` directly between leaves there if you need intermediate cwd changes.
343
+
286
344
  Function args are separated by `, ` (comma + exactly one space).
287
345
 
288
346
  **Quote semantics inside `{{ ... }}`:**
@@ -311,13 +369,18 @@ evaluated by Runfile itself, so the logic works the same on every shell and plat
311
369
  "cases": {
312
370
  "1": "flutter emulators --launch Tier_1_Android_9",
313
371
  "2": "flutter emulators --launch Tier_2_Android_11",
314
- "3": "flutter emulators --launch Tier_3_Android_14"
372
+ "3": "flutter emulators --launch Tier_3_Android_14",
373
+ // Case keys wrapped in `/.../` are treated as regex patterns.
374
+ // Literal cases always win over a regex that would also match.
375
+ "/^v\\d+$/": "echo version-tag",
376
+ "/^pr-\\d+$/": "echo preview-build"
315
377
  } }, // unknown values error out, listing the valid cases
316
378
 
317
379
  { "for": "service",
318
380
  "in": ["api", "web", "worker"],
319
381
  "parallel": true,
320
- "do": ["docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
382
+ // VARS.<name>_index exposes the 0-based iteration counter.
383
+ "do": ["echo [{{ VARS.service_index }}] docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
321
384
 
322
385
  { "for": "file",
323
386
  "shell": "git diff --name-only HEAD~1",
@@ -379,6 +442,27 @@ Wrap any commands in a `when:` block to run them only after a failure, or always
379
442
  ]
380
443
  ```
381
444
 
445
+ #### Single-shell mode with `sameShell`
446
+
447
+ By default each step runs in its own shell process, so `cd`, exported variables, and other shell state don't carry
448
+ over between steps. Set `"sameShell": true` to join every step into a single shell invocation — state changes
449
+ persist for free.
450
+
451
+ ```jsonc
452
+ {
453
+ "deploy": {
454
+ "sameShell": true,
455
+ "commands": ["cd ci-scripts/", "./ci-deploy.sh"]
456
+ }
457
+ }
458
+ ```
459
+
460
+ Steps are joined with `&&` so the run stops at the first failure (or `;` / `&` when `ignoreErrors: true`). `if`,
461
+ `for`, and `match` blocks are evaluated by Runfile and their chosen branches flow into the same joined invocation.
462
+ `@target` calls inside the body are rejected — they have their own shell context and can't share state with the
463
+ parent. `sameShell` also composes with `detach: true` (joined command spawns as one detached process) and is
464
+ available on `globals` for project-wide defaults.
465
+
382
466
  #### Watch mode, built in
383
467
 
384
468
  Add a `watch` array to any target. No flags, no extra tooling.
@@ -397,6 +481,11 @@ $ run :generate zed-tasks
397
481
  $ run :generate jetbrains-run-configurations
398
482
  ```
399
483
 
484
+ Skip a target from the generated configs by setting `metadata.excludeFromGenerateCommand: true`. The
485
+ `metadata` block on `globals` and on each target is **fully open** — any property of any JSON type
486
+ (strings, numbers, booleans, arrays, deeply-nested objects) is accepted and round-trips untouched, so
487
+ editor extensions and CI scripts can stash arbitrary tooling-specific fields here.
488
+
400
489
  #### Migrate in seconds
401
490
 
402
491
  ```bash
Binary file
@@ -111,16 +111,16 @@ Sorted from where Runfile is most differentiated to where it lags behind.
111
111
  | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
112
112
  | JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
113
113
  | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
114
+ | Output grouping / prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
114
115
  | Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
115
- | Parallel execution | ✅ | ✅ | ❌ | ✅ |
116
116
  | Shell completions | ✅ | ❌ | ✅ | ✅ |
117
117
  | Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
118
+ | Built-in string functions (upper, replace, trim, regex, base64, …) | ✅ | ❌ | ✅ | ✅ |
119
+ | Parallel execution | ✅ | ✅ | ❌ | ✅ |
118
120
  | Single static binary | ✅ | ✅ | ✅ | ✅ |
119
121
  | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
120
- | Output grouping / prefixing in parallel mode | ❌ | ❌ | ❌ | ✅ |
121
122
  | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
122
123
  | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
123
- | Built-in string functions (upper, replace, trim, …) | ❌ | ❌ | ✅ | ✅ |
124
124
 
125
125
  ---
126
126
 
@@ -167,9 +167,9 @@ $ run :env inject -f .env.production -- ./deploy.sh # encrypted values aut
167
167
 
168
168
  #### Powerful argument substitution
169
169
 
170
- Positional, named, flags, env vars, and runtime context (`{{ RUN.os }}` / `{{ RUN.shell }}` / `{{ RUN.cwd }}` /
171
- `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The `{{ ... }}` syntax
172
- avoids collisions with shell `$(...)` command substitution.
170
+ Positional, named, flags, env vars, and runtime context (`{{ RUN.os }}` / `{{ RUN.arch }}` / `{{ RUN.shell }}` /
171
+ `{{ RUN.cwd }}` / `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The
172
+ `{{ ... }}` syntax avoids collisions with shell `$(...)` command substitution.
173
173
 
174
174
  ```jsonc
175
175
  "PORT": "{{ ARGS.port ? ENV.PORT ? '3000' }}",
@@ -242,9 +242,15 @@ and work as full substitution bodies *or* as chain segments:
242
242
  // transform : escape, repeat, replace_all, remove_all
243
243
  // regex : regex_replace, regex_remove, regex_matches
244
244
  // build : concat, join
245
+ // split : nth, first, last, count_parts
245
246
  // encoding : base64_encode, base64_decode
247
+ // hashing : sha256, md5
248
+ // files : read_file, file_exists
249
+ // json : json_get, json_set
250
+ // error : try
246
251
  // shell : shell_quote
247
252
  // variables : define
253
+ // cwd : set_cwd
248
254
  "echo deploying-{{ to_upper(ARGS.env) }}",
249
255
  "curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
250
256
  "echo {{ concat('hello-', ARGS.name, '-2026') }}",
@@ -253,6 +259,13 @@ and work as full substitution bodies *or* as chain segments:
253
259
  "go test {{ replace_all(ARGS.flags, ' ', ' -tag=') }}",
254
260
  // Strip every match of a regex (here: collapse whitespace runs to a single space):
255
261
  "echo {{ regex_replace(ARGS.text, '\\s+', ' ') }}",
262
+ // Split-by-separator scalar accessors — string in, string out, no list type:
263
+ // basename idiom (last segment after `/`); empty string when input ends in `/`.
264
+ "echo basename={{ last(ARGS.path, '/') }}",
265
+ // Pull out the N-th comma-separated field; out-of-bounds returns "".
266
+ "echo target={{ nth(ARGS.csv, ',', '1') }}",
267
+ // Pair `count_parts` with `nth` to bound-check before indexing:
268
+ // "if": "{{ count_parts(ARGS.csv, ',') == '3' }}"
256
269
  // Boolean-returning helpers are valid DSL `Truthy` values — use them in `if`:
257
270
  // "if": "{{ starts_with(ARGS.path, '/usr') }}"
258
271
  // "if": "{{ regex_matches(ARGS.tag, '^v[0-9]+\\.[0-9]+$') }}"
@@ -261,6 +274,28 @@ and work as full substitution bodies *or* as chain segments:
261
274
  // `shell_quote` picks the right quoting for the active shell:
262
275
  "some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
263
276
 
277
+ // Cache keys / content fingerprints. `sha256` for security-sensitive use,
278
+ // `md5` for cheap fingerprinting (NOT cryptographically secure).
279
+ "echo cache-key={{ sha256(read_file('package-lock.json')) }}",
280
+
281
+ // Branch on file presence without forking a shell. `file_exists` returns
282
+ // the literal "true" / "false", so it doubles as a DSL truthy value.
283
+ // "if": "{{ file_exists('.env.local') }}"
284
+
285
+ // Read a file inline — relative paths anchor to the Runfile directory.
286
+ // Use `try(...)` to recover from missing files.
287
+ "echo version={{ try(read_file('VERSION')) ? '0.0.0-dev' }}",
288
+
289
+ // Pull values out of arbitrary JSON without `jq` (works on every shell):
290
+ "echo db_host={{ json_get(read_file('config.json'), 'database.host') }}",
291
+
292
+ // Modify a JSON document in place (returns the new compact JSON):
293
+ "echo {{ json_set('{\"port\":3000}', 'env', 'production') }}",
294
+
295
+ // `try(expr)` swallows inner errors. Standalone returns "" on failure;
296
+ // chained, the next segment runs as a fallback.
297
+ "echo {{ try(base64_decode(ARGS.maybe_b64)) ? ARGS.maybe_b64 }}",
298
+
264
299
  // Nested:
265
300
  "echo {{ to_upper(to_lower(ARGS.x)) }}",
266
301
 
@@ -272,6 +307,13 @@ and work as full substitution bodies *or* as chain segments:
272
307
  "{{ VARS.sdk }}/bin/build",
273
308
  "echo using sdk at {{ VARS.sdk }}",
274
309
 
310
+ // `set_cwd(path)` switches the cwd subsequent commands spawn in — like
311
+ // shell `cd`, but works on every shell / OS without forking. Relative
312
+ // paths chain (matching `cd a; cd b` → `a/b`); absolute paths replace.
313
+ "{{ set_cwd(ARGS.subproject ? 'packages/api') }}",
314
+ "npm install",
315
+ "npm run build",
316
+
275
317
  // Single-quoted strings interpolate nested {{ }} substitutions:
276
318
  "{{ define(cmd, 'docker compose -f {{ VARS.compose }} pull') }}",
277
319
  "{{ VARS.cmd }}"
@@ -283,6 +325,22 @@ to only whitespace (i.e. one consisting solely of a `{{ define(...) }}` call) is
283
325
  being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
284
326
  be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
285
327
 
328
+ `set_cwd(path)` is the cwd analog of `define`: returns an empty string and changes the cwd that subsequent shell
329
+ commands in the *current target* spawn in. Behaves like shell `cd`, but works uniformly across every shell / OS
330
+ (no forked process, no `cd` binary needed). Resolution rules:
331
+
332
+ - **Absolute path** → fully replaces the current override (matches `cd /abs`).
333
+ - **Relative path** → joins onto the existing override, or onto the target's `workingDirectory` if no override
334
+ has been set yet. So `set_cwd('a'); set_cwd('b')` lands subsequent commands in `<workingDirectory>/a/b`,
335
+ matching how `cd a; cd b` chains in a shell.
336
+
337
+ `set_cwd` is **per-target**: each `@target` invocation starts with a clean override (the dispatched target sees
338
+ its own `workingDirectory`, not the caller's `set_cwd` state). Inside `parallel: true` targets each leaf
339
+ captures the override at the moment of its substitution, so siblings don't race on the spawn cwd. Like
340
+ `define`, the side effect is skipped on the redacted-logging pass so log lines don't double-apply it. With
341
+ `sameShell: true`, only the *final* `set_cwd` value applies (everything joins into one shell invocation) — use
342
+ shell `cd` directly between leaves there if you need intermediate cwd changes.
343
+
286
344
  Function args are separated by `, ` (comma + exactly one space).
287
345
 
288
346
  **Quote semantics inside `{{ ... }}`:**
@@ -311,13 +369,18 @@ evaluated by Runfile itself, so the logic works the same on every shell and plat
311
369
  "cases": {
312
370
  "1": "flutter emulators --launch Tier_1_Android_9",
313
371
  "2": "flutter emulators --launch Tier_2_Android_11",
314
- "3": "flutter emulators --launch Tier_3_Android_14"
372
+ "3": "flutter emulators --launch Tier_3_Android_14",
373
+ // Case keys wrapped in `/.../` are treated as regex patterns.
374
+ // Literal cases always win over a regex that would also match.
375
+ "/^v\\d+$/": "echo version-tag",
376
+ "/^pr-\\d+$/": "echo preview-build"
315
377
  } }, // unknown values error out, listing the valid cases
316
378
 
317
379
  { "for": "service",
318
380
  "in": ["api", "web", "worker"],
319
381
  "parallel": true,
320
- "do": ["docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
382
+ // VARS.<name>_index exposes the 0-based iteration counter.
383
+ "do": ["echo [{{ VARS.service_index }}] docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
321
384
 
322
385
  { "for": "file",
323
386
  "shell": "git diff --name-only HEAD~1",
@@ -379,6 +442,27 @@ Wrap any commands in a `when:` block to run them only after a failure, or always
379
442
  ]
380
443
  ```
381
444
 
445
+ #### Single-shell mode with `sameShell`
446
+
447
+ By default each step runs in its own shell process, so `cd`, exported variables, and other shell state don't carry
448
+ over between steps. Set `"sameShell": true` to join every step into a single shell invocation — state changes
449
+ persist for free.
450
+
451
+ ```jsonc
452
+ {
453
+ "deploy": {
454
+ "sameShell": true,
455
+ "commands": ["cd ci-scripts/", "./ci-deploy.sh"]
456
+ }
457
+ }
458
+ ```
459
+
460
+ Steps are joined with `&&` so the run stops at the first failure (or `;` / `&` when `ignoreErrors: true`). `if`,
461
+ `for`, and `match` blocks are evaluated by Runfile and their chosen branches flow into the same joined invocation.
462
+ `@target` calls inside the body are rejected — they have their own shell context and can't share state with the
463
+ parent. `sameShell` also composes with `detach: true` (joined command spawns as one detached process) and is
464
+ available on `globals` for project-wide defaults.
465
+
382
466
  #### Watch mode, built in
383
467
 
384
468
  Add a `watch` array to any target. No flags, no extra tooling.
@@ -397,6 +481,11 @@ $ run :generate zed-tasks
397
481
  $ run :generate jetbrains-run-configurations
398
482
  ```
399
483
 
484
+ Skip a target from the generated configs by setting `metadata.excludeFromGenerateCommand: true`. The
485
+ `metadata` block on `globals` and on each target is **fully open** — any property of any JSON type
486
+ (strings, numbers, booleans, arrays, deeply-nested objects) is accepted and round-trips untouched, so
487
+ editor extensions and CI scripts can stash arbitrary tooling-specific fields here.
488
+
400
489
  #### Migrate in seconds
401
490
 
402
491
  ```bash
Binary file
@@ -111,16 +111,16 @@ Sorted from where Runfile is most differentiated to where it lags behind.
111
111
  | Argument substitution with chained fallbacks | ✅ | ❌ | ✅ | ❌ |
112
112
  | JSON Schema autocomplete in editors | ✅ | ❌ | ❌ | ✅ |
113
113
  | Watch mode, built-in | ✅ | ❌ | ❌ | ✅ |
114
+ | Output grouping / prefixing in parallel mode | ✅ | ❌ | ❌ | ✅ |
114
115
  | Native Windows (no WSL / msys2 needed) | ✅ | ❌ | ✅ | ✅ |
115
- | Parallel execution | ✅ | ✅ | ❌ | ✅ |
116
116
  | Shell completions | ✅ | ❌ | ✅ | ✅ |
117
117
  | Private / internal targets (`_name`) | ✅ | ❌ | ✅ | ✅ |
118
+ | Built-in string functions (upper, replace, trim, regex, base64, …) | ✅ | ❌ | ✅ | ✅ |
119
+ | Parallel execution | ✅ | ✅ | ❌ | ✅ |
118
120
  | Single static binary | ✅ | ✅ | ✅ | ✅ |
119
121
  | Pattern rules (`%.o: %.c`) | ❌ | ✅ | ❌ | ❌ |
120
- | Output grouping / prefixing in parallel mode | ❌ | ❌ | ❌ | ✅ |
121
122
  | Preconditions / status checks | ❌ | ❌ | ❌ | ✅ |
122
123
  | Incremental builds (sources / timestamps / checksums) | ❌ | ✅ | ❌ | ✅ |
123
- | Built-in string functions (upper, replace, trim, …) | ❌ | ❌ | ✅ | ✅ |
124
124
 
125
125
  ---
126
126
 
@@ -167,9 +167,9 @@ $ run :env inject -f .env.production -- ./deploy.sh # encrypted values aut
167
167
 
168
168
  #### Powerful argument substitution
169
169
 
170
- Positional, named, flags, env vars, and runtime context (`{{ RUN.os }}` / `{{ RUN.shell }}` / `{{ RUN.cwd }}` /
171
- `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The `{{ ... }}` syntax
172
- avoids collisions with shell `$(...)` command substitution.
170
+ Positional, named, flags, env vars, and runtime context (`{{ RUN.os }}` / `{{ RUN.arch }}` / `{{ RUN.shell }}` /
171
+ `{{ RUN.cwd }}` / `{{ RUN.file }}` / `{{ RUN.parent }}`) — with chained fallbacks and required values. The
172
+ `{{ ... }}` syntax avoids collisions with shell `$(...)` command substitution.
173
173
 
174
174
  ```jsonc
175
175
  "PORT": "{{ ARGS.port ? ENV.PORT ? '3000' }}",
@@ -242,9 +242,15 @@ and work as full substitution bodies *or* as chain segments:
242
242
  // transform : escape, repeat, replace_all, remove_all
243
243
  // regex : regex_replace, regex_remove, regex_matches
244
244
  // build : concat, join
245
+ // split : nth, first, last, count_parts
245
246
  // encoding : base64_encode, base64_decode
247
+ // hashing : sha256, md5
248
+ // files : read_file, file_exists
249
+ // json : json_get, json_set
250
+ // error : try
246
251
  // shell : shell_quote
247
252
  // variables : define
253
+ // cwd : set_cwd
248
254
  "echo deploying-{{ to_upper(ARGS.env) }}",
249
255
  "curl -H \"X-Auth: {{ base64_encode(ENV.TOKEN) }}\" ...",
250
256
  "echo {{ concat('hello-', ARGS.name, '-2026') }}",
@@ -253,6 +259,13 @@ and work as full substitution bodies *or* as chain segments:
253
259
  "go test {{ replace_all(ARGS.flags, ' ', ' -tag=') }}",
254
260
  // Strip every match of a regex (here: collapse whitespace runs to a single space):
255
261
  "echo {{ regex_replace(ARGS.text, '\\s+', ' ') }}",
262
+ // Split-by-separator scalar accessors — string in, string out, no list type:
263
+ // basename idiom (last segment after `/`); empty string when input ends in `/`.
264
+ "echo basename={{ last(ARGS.path, '/') }}",
265
+ // Pull out the N-th comma-separated field; out-of-bounds returns "".
266
+ "echo target={{ nth(ARGS.csv, ',', '1') }}",
267
+ // Pair `count_parts` with `nth` to bound-check before indexing:
268
+ // "if": "{{ count_parts(ARGS.csv, ',') == '3' }}"
256
269
  // Boolean-returning helpers are valid DSL `Truthy` values — use them in `if`:
257
270
  // "if": "{{ starts_with(ARGS.path, '/usr') }}"
258
271
  // "if": "{{ regex_matches(ARGS.tag, '^v[0-9]+\\.[0-9]+$') }}"
@@ -261,6 +274,28 @@ and work as full substitution bodies *or* as chain segments:
261
274
  // `shell_quote` picks the right quoting for the active shell:
262
275
  "some-tool --json {{ shell_quote(base64_decode(ENV.SECRET_BASE64)) }}",
263
276
 
277
+ // Cache keys / content fingerprints. `sha256` for security-sensitive use,
278
+ // `md5` for cheap fingerprinting (NOT cryptographically secure).
279
+ "echo cache-key={{ sha256(read_file('package-lock.json')) }}",
280
+
281
+ // Branch on file presence without forking a shell. `file_exists` returns
282
+ // the literal "true" / "false", so it doubles as a DSL truthy value.
283
+ // "if": "{{ file_exists('.env.local') }}"
284
+
285
+ // Read a file inline — relative paths anchor to the Runfile directory.
286
+ // Use `try(...)` to recover from missing files.
287
+ "echo version={{ try(read_file('VERSION')) ? '0.0.0-dev' }}",
288
+
289
+ // Pull values out of arbitrary JSON without `jq` (works on every shell):
290
+ "echo db_host={{ json_get(read_file('config.json'), 'database.host') }}",
291
+
292
+ // Modify a JSON document in place (returns the new compact JSON):
293
+ "echo {{ json_set('{\"port\":3000}', 'env', 'production') }}",
294
+
295
+ // `try(expr)` swallows inner errors. Standalone returns "" on failure;
296
+ // chained, the next segment runs as a fallback.
297
+ "echo {{ try(base64_decode(ARGS.maybe_b64)) ? ARGS.maybe_b64 }}",
298
+
264
299
  // Nested:
265
300
  "echo {{ to_upper(to_lower(ARGS.x)) }}",
266
301
 
@@ -272,6 +307,13 @@ and work as full substitution bodies *or* as chain segments:
272
307
  "{{ VARS.sdk }}/bin/build",
273
308
  "echo using sdk at {{ VARS.sdk }}",
274
309
 
310
+ // `set_cwd(path)` switches the cwd subsequent commands spawn in — like
311
+ // shell `cd`, but works on every shell / OS without forking. Relative
312
+ // paths chain (matching `cd a; cd b` → `a/b`); absolute paths replace.
313
+ "{{ set_cwd(ARGS.subproject ? 'packages/api') }}",
314
+ "npm install",
315
+ "npm run build",
316
+
275
317
  // Single-quoted strings interpolate nested {{ }} substitutions:
276
318
  "{{ define(cmd, 'docker compose -f {{ VARS.compose }} pull') }}",
277
319
  "{{ VARS.cmd }}"
@@ -283,6 +325,22 @@ to only whitespace (i.e. one consisting solely of a `{{ define(...) }}` call) is
283
325
  being dispatched to the shell. `define`s in a parent target are visible to `@target` children. The `name` MUST
284
326
  be a bareword identifier matching `[A-Za-z_][A-Za-z0-9_-]*` — quotes are NOT allowed on the name.
285
327
 
328
+ `set_cwd(path)` is the cwd analog of `define`: returns an empty string and changes the cwd that subsequent shell
329
+ commands in the *current target* spawn in. Behaves like shell `cd`, but works uniformly across every shell / OS
330
+ (no forked process, no `cd` binary needed). Resolution rules:
331
+
332
+ - **Absolute path** → fully replaces the current override (matches `cd /abs`).
333
+ - **Relative path** → joins onto the existing override, or onto the target's `workingDirectory` if no override
334
+ has been set yet. So `set_cwd('a'); set_cwd('b')` lands subsequent commands in `<workingDirectory>/a/b`,
335
+ matching how `cd a; cd b` chains in a shell.
336
+
337
+ `set_cwd` is **per-target**: each `@target` invocation starts with a clean override (the dispatched target sees
338
+ its own `workingDirectory`, not the caller's `set_cwd` state). Inside `parallel: true` targets each leaf
339
+ captures the override at the moment of its substitution, so siblings don't race on the spawn cwd. Like
340
+ `define`, the side effect is skipped on the redacted-logging pass so log lines don't double-apply it. With
341
+ `sameShell: true`, only the *final* `set_cwd` value applies (everything joins into one shell invocation) — use
342
+ shell `cd` directly between leaves there if you need intermediate cwd changes.
343
+
286
344
  Function args are separated by `, ` (comma + exactly one space).
287
345
 
288
346
  **Quote semantics inside `{{ ... }}`:**
@@ -311,13 +369,18 @@ evaluated by Runfile itself, so the logic works the same on every shell and plat
311
369
  "cases": {
312
370
  "1": "flutter emulators --launch Tier_1_Android_9",
313
371
  "2": "flutter emulators --launch Tier_2_Android_11",
314
- "3": "flutter emulators --launch Tier_3_Android_14"
372
+ "3": "flutter emulators --launch Tier_3_Android_14",
373
+ // Case keys wrapped in `/.../` are treated as regex patterns.
374
+ // Literal cases always win over a regex that would also match.
375
+ "/^v\\d+$/": "echo version-tag",
376
+ "/^pr-\\d+$/": "echo preview-build"
315
377
  } }, // unknown values error out, listing the valid cases
316
378
 
317
379
  { "for": "service",
318
380
  "in": ["api", "web", "worker"],
319
381
  "parallel": true,
320
- "do": ["docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
382
+ // VARS.<name>_index exposes the 0-based iteration counter.
383
+ "do": ["echo [{{ VARS.service_index }}] docker build -t {{ VARS.service }} services/{{ VARS.service }}"] },
321
384
 
322
385
  { "for": "file",
323
386
  "shell": "git diff --name-only HEAD~1",
@@ -379,6 +442,27 @@ Wrap any commands in a `when:` block to run them only after a failure, or always
379
442
  ]
380
443
  ```
381
444
 
445
+ #### Single-shell mode with `sameShell`
446
+
447
+ By default each step runs in its own shell process, so `cd`, exported variables, and other shell state don't carry
448
+ over between steps. Set `"sameShell": true` to join every step into a single shell invocation — state changes
449
+ persist for free.
450
+
451
+ ```jsonc
452
+ {
453
+ "deploy": {
454
+ "sameShell": true,
455
+ "commands": ["cd ci-scripts/", "./ci-deploy.sh"]
456
+ }
457
+ }
458
+ ```
459
+
460
+ Steps are joined with `&&` so the run stops at the first failure (or `;` / `&` when `ignoreErrors: true`). `if`,
461
+ `for`, and `match` blocks are evaluated by Runfile and their chosen branches flow into the same joined invocation.
462
+ `@target` calls inside the body are rejected — they have their own shell context and can't share state with the
463
+ parent. `sameShell` also composes with `detach: true` (joined command spawns as one detached process) and is
464
+ available on `globals` for project-wide defaults.
465
+
382
466
  #### Watch mode, built in
383
467
 
384
468
  Add a `watch` array to any target. No flags, no extra tooling.
@@ -397,6 +481,11 @@ $ run :generate zed-tasks
397
481
  $ run :generate jetbrains-run-configurations
398
482
  ```
399
483
 
484
+ Skip a target from the generated configs by setting `metadata.excludeFromGenerateCommand: true`. The
485
+ `metadata` block on `globals` and on each target is **fully open** — any property of any JSON type
486
+ (strings, numbers, booleans, arrays, deeply-nested objects) is accepted and round-trips untouched, so
487
+ editor extensions and CI scripts can stash arbitrary tooling-specific fields here.
488
+
400
489
  #### Migrate in seconds
401
490
 
402
491
  ```bash
package/bin/linux-x64/run CHANGED
Binary file
Binary file
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runfile/cli",
3
- "version": "0.12.0",
3
+ "version": "0.13.0",
4
4
  "description": "Runfile CLI — an easy-to-use, batteries-included task runner",
5
5
  "repository": "https://github.com/Skiley/runfile",
6
6
  "homepage": "https://github.com/Skiley/runfile",