run & the mini-shell

A run string executes one of two ways, decided by parsing it:

  1. Every word static → the string is split and the command is spawned directly (execvp-style). Fast, fully cross-platform. This is the common path and where tsr beats npm run.

  2. Variables, globs or operators present → the string runs through tsr's own minimal shell.

Local binaries (node_modules/.bin)

For run = "vite" to actually replace npm run dev, a directly-spawned command must find locally-installed binaries. Before spawning, tsr prepends node_modules/.bin to PATH — walking up from the task's directory to the workspace root, nearest first — exactly the lookup npm/bun/yarn/pnpm do. So run = "eslint ." or run = "vite build" resolves the local tool, with no Node startup tax. In a non-JS package it's a no-op.

On Windows these tools are batch shims — npm is really npm.cmd, and node_modules/.bin holds vite.cmd — so tsr also applies PATHEXT when it resolves a bare name. Write run = "vite" and it works the same everywhere; the extension is never something you have to spell out.

Supported — the entire feature set

  • $VAR / ${VAR} — expansion from the merged env.

  • && || ; — sequencing with correct exit-code semantics (&& on 0, || on non-zero, ; always).

  • Quoting — 'single' (literal, no expansion) and "double" (expansion applies). Quoted text is never globbed.

  • Globs — *, ?, [...], ** match real files, relative to the task's dir.

  • NAME=value prefixes — set a variable for one command, as in sh.

  • cd and exit — move the rest of the string to another directory, or end it with a code.

TOML
[tasks.deploy]
run = "build && deploy --target $TARGET || notify 'deploy failed'"

[tasks.e2e]
run = "cd apps/web && NODE_ENV=test playwright test"

NAME=value prefixes

NODE_ENV=production vite build sets NODE_ENV for vite build only. The value is expanded (TOKEN=$SECRET deploy) but never globbed. An assignment on its own (FOO=bar && …) is rejected when the config loads, because variables resolve before the sequence starts and the value could never reach a later command. Use the task's env for that. Prefixes pass through the same guards as env, so LD_PRELOAD=… cmd needs --allow-unsafe-env.

`${...}` takes a plain name

Parameter expansion — ${VAR:-default}, ${#VAR} and friends — is not implemented, and says so rather than failing later with a confusing "not defined". Set the default in [env] instead.

Globbing

Patterns follow sh: * does not cross a / and does not match a leading dot, ** spans directories (including zero of them, so a/**/*.js matches a/x.js), and a pattern that matches nothing stays literal. Two rules keep it predictable:

  • Expanded values are never rescanned. If $FILES holds *.js, it stays the literal string *.js. Only the run string itself can hold a pattern.

  • A glob resolves when its command runs, not when the task is planned — so in build && rm dist/*.map the pattern sees the files build just produced.

  • Matches always use /, Windows included, so a pattern produces the same arguments on every platform.

Variables are the other way round: they resolve before the sequence starts, so an undefined $VAR fails the task cleanly instead of half-way through.

Built-in commands

rm -rf dist has to mean the same thing everywhere — but the coreutils it names are Unix binaries that don't exist on Windows. So tsr implements the file operations tasks actually use, in-process:

BuiltinOptions
rm-r/-R/--recursive, -f/--force
cp-r/-R/--recursive
mv—
mkdir-p/--parents
touch—
cat— (stdin when given no files)
echoleading -n
pwd—
true / false— (operands ignored)
cd <dir>—
exit [N]—
TOML
[tasks.clean]
run = "rm -rf dist/*"

[tasks.build]
run = "mkdir -p dist && vite build && cp public/*.ico dist"

Short options bundle (-rf), -- ends option parsing, and relative paths resolve against the task's dir. A builtin exits 0 on success, 1 on a failed operation, and 2 on a usage error. As in POSIX, rm -f with nothing to remove succeeds silently — which is what makes rm -rf dist/* idempotent once dist is already empty.

cd <dir> changes the directory for the rest of that run string only. The next task, or the next package in a fan-out, starts in its own dir as usual. The target has to exist and stay inside the workspace. exit [N] ends the string right away with code N, or with the previous command's code when N is left out, so run = "exit 3" fails the task with 3.

true and false are here for a reason: cmd || true is the standard "this step must not fail the build" idiom, and on Windows there is no /bin/true to fall back on.

A builtin always wins

On every platform, these names resolve to tsr's own implementation, never to a binary of the same name on PATH. That's the point: one run string, one behaviour. Builtins apply to run only — never to delegate or an auto-detected native runner. Use delegate when a task genuinely needs the platform's own tool.

Rejected — never attempted

These require OS-level plumbing outside the tool's scope, so they are rejected at load time with a specific error and exit code 64:

ConstructInstead
| pipesset shell = true, or use a script file
> >> 2>&1 redirectionset shell = true, or use a script file
$(...) / backtick substitutionset shell = true, or use a script file
& background, ( ) subshellsset shell = true for real shell control
{a,b} brace expansionlist the paths explicitly, or quote the braces
Rejection happens up front

Unsupported constructs are caught before any task runs: a run checks the task and everything it depends on up front, so a broken run string never executes half-way. A broken task the run can't reach doesn't block it.

Detection order

TEXT
parse → every word static           → direct spawn
      → variables / globs / ops     → mini-shell
      → any unsupported construct   → error 64 at load

Classification is a property of the parsed string, and metacharacters inside quotes are literal — so run = "echo 'a | b'" prints a | b, it does not trip the pipe rule, and run = "echo '*.txt'" prints *.txt rather than globbing.

Brace expansion is rejected only when it would actually mean something — a {...} group holding a comma. {} and {json} are ordinary text in sh too, so find . -exec rm {} + and --define:{} stay legal.

The escape hatch

When a run string genuinely needs a real shell, opt in explicitly with shell = true:

TOML
[tasks.pipeline]
run = "cat x | grep y > z"
shell = true

The string goes to sh -c (or cmd /c on Windows) instead of the mini-shell, so pipes, redirects and anything else the shell supports work. The shell expands $VAR itself, with the task's environment. Task args and passthrough reach the command as arguments, never as shell syntax.

shell = true gives up portability

The string means whatever the platform's shell makes of it, so a task that works under sh may not work under cmd. Builtins don't apply either. Only reach for it when the mini-shell can't express the task.

For full control, delegate a binary, or point run at a script file:

TOML
[tasks.pipeline]
delegate = { bin = "bash", args = ["-c", "set -o pipefail; cat x | grep y > z"] }

[tasks.build]
run = "./scripts/build.sh"