Why I Built Another Task Runner

Published: (January 17, 2026 at 08:24 AM EST)
3 min read
Source: Dev.to

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.json with 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.

Back to Blog

Related posts

Read more »

What is Code Integration?

What is Integration? In software engineering, integration is the process of combining different code changes from multiple developers into a single, cohesive s...

Improved environment variables UI

The environment variables UI is now easier to manage across shared and project environment variables. You can spend less time scrolling, use larger hit targets,...