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.

TOML
[tasks.deploy]
run = "build && deploy --target $TARGET || notify 'deploy failed'"
`${...}` 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)
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.

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
| pipesuse delegate or a script file
> >> 2>&1 redirectionuse a script file
$(...) / backtick substitutionuse a script file
& background, ( ) subshellsuse delegate for real shell control
{a,b} brace expansionlist the paths explicitly, or quote the braces
Rejection happens up front

Unsupported constructs are caught when the config loads — before any task runs — so a broken run string never executes half-way.

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 delegate, or point run at a script file:

TOML
[tasks.pipeline]
delegate = { bin = "sh", args = ["-c", "cat x | grep y > z"] }

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