Why I Built Another Task Runner
Source: Dev.to
The Problem with Existing Task Runners
Yes, I know—another task runner in 2026. Let me explain why.
I’m not a Make expert, but I’ve become the go‑to person when someone needs to add a task to our Makefile. Typical questions:
- “How do I pass an environment variable to this target?”
- “Why does this only work if I add a tab character?”
- “I forgot to add a task to
.PHONY.”
Every time I’m Googling the same things they could be Googling. Our build process includes snippets like:
deploy-%: guard-% check-git-clean
@$(eval ENV := $(word 2,$(subst -, ,$@)))
./scripts/deploy.sh $(ENV)
If you understand $(word 2,$(subst -, ,$@)) without looking it up, congratulations—you’re in the 1 % of developers who have memorised Make’s arcane variable‑substitution syntax.
Other repos in the company use npm scripts or custom Python CLI packages run with uv. Each project has its own approach, and the npm‑script repos hit the same cross‑platform issues:
{
"clean": "rm -rf dist || rmdir /s /q dist",
"build": "NODE_ENV=production webpack || set NODE_ENV=production && webpack"
}
Ugly. Fragile. And it still breaks in random edge cases.
What I Wanted
-
Write tasks the way I think about them:
# Just deploy the thing deploy() { ./scripts/deploy.sh $1 } -
Use Python when I need real logic (instead of Bash’s string‑manipulation nightmare).
-
Use Node when working with JSON or async operations.
-
Have cross‑platform support without conditional hell.
-
Provide something AI agents could actually use (more on this shortly).
Introducing run
run lets you define tasks as simple functions in a Runfile. No YAML, no TOML, no $(word …) tricks.
Example Runfile
# Shell for simple stuff
build() cargo build --release
# Python when you need it
analyze() {
#!/usr/bin/env python
import json, sys
with open(sys.argv[1]) as f:
data = json.load(f)
print(f"Processed {len(data)} records")
}
# Node works too
process() {
#!/usr/bin/env node
const fs = require('fs');
console.log('Processing...');
}
# Platform‑specific versions
# @os windows
deploy() {
.\scripts\deploy.ps1 $1
}
# @os linux darwin
deploy() {
./scripts/deploy.sh $1
}
No extra configuration files. Just functions.
Model Context Protocol (MCP) Support
run includes a built‑in MCP server, enabling AI agents (e.g., Claude Code) to discover and execute your project’s tools automatically.
Add metadata to a task:
# @desc Deploy to specified environment
# @arg 1:environment string Target environment (staging|prod)
deploy() {
./scripts/deploy.sh $1
}
Now an MCP‑compatible agent knows exactly what the tool does and how to invoke it—no guessing, no verbose Markdown explanations.
Additional Benefits
- Single Rust binary—no runtime, no
package.jsonwith hundreds of dependencies. - Shell completions out of the box for Bash, Zsh, Fish, and PowerShell.
- Tab completion for all tasks without extra setup.
Getting Started
If you’re happy with Make, Just, or npm scripts, that’s fine. But if you’ve ever thought “there has to be a simpler way,” give run a try:
brew install nihilok/tap/runfile
or
cargo install run
The source code is on GitHub. It solved my problems; maybe it’ll solve yours too.