Task Runners and an Internal Developer CLI
Every repository accumulates operational knowledge: how to start the stack, reset the database, regenerate API clients, run only the fast tests, rotate a local certificate, tail the worker logs. When that knowledge lives in a wiki, a Slack thread or one senior engineer's shell history, onboarding means rediscovering it. A task runner turns it into a named, discoverable, executable command — just db-reset, task generate, make doctor — that works the same for everyone and can be read to learn what it does. This topic, part of developer onboarding architecture and friction mapping, covers choosing a task runner, designing the command surface, making it work on every platform the team uses, and growing it into an internal developer CLI when one repository is no longer enough.
The existing README-driven automation topic establishes the principle — the README names commands, the commands do the work — using Make. This topic goes further: when Make is the wrong tool, how to structure dozens of tasks so people can find them, and what changes when tasks must span many repositories.
A good command surface has four properties. It is discoverable: running the tool with no arguments lists every task with a one-line description. It is consistent: the same verbs mean the same thing in every repository (up, down, test, lint, doctor, reset). It is safe: destructive tasks say so and ask, and nothing depends on state the developer cannot see. And it is portable: it runs on macOS, Linux and Windows or WSL2 without a separate set of instructions per platform. The sections below take each property in turn, with runnable examples for the three most common tools.
Prerequisites
- A decision on the tool. GNU Make 4.x is preinstalled on Linux and available through Xcode command-line tools on macOS (which ships Make 3.81 — old but workable).
just1.30+ and Task (go-task) 3.38+ are single static binaries installable through Homebrew, Scoop, winget, cargo, go or a toolchain manager. - The tool pinned alongside other tools, for example in
mise.tomlordevbox.json, so everyone runs the same version. The toolchain version management topic covers the options. - A list of what developers actually do. Grep the last month of shell history from two or three volunteers, the README, and CI workflows; every repeated multi-step command is a candidate task.
- Agreement on the core verbs across repositories, even if each repository implements them differently.
A quick inventory of what already exists:
#!/usr/bin/env bash
set -euo pipefail
ls Makefile justfile Justfile Taskfile.yml Taskfile.yaml package.json 2>/dev/null || true
[ -f Makefile ] && grep -E '^[a-zA-Z0-9_-]+:' Makefile | cut -d: -f1 | sort -u | tr '\n' ' ' && echo
[ -f package.json ] && jq -r '.scripts | keys | join(" ")' package.json
grep -rhoE 'run: .+' .github/workflows 2>/dev/null | sort | uniq -c | sort -rn | head -10
Choosing between make and just and Taskfile
Make was designed to build files from other files, and its dependency model — targets rebuilt only when inputs are newer — is still unmatched for that job. As a general command runner it has sharp edges: tabs are mandatory, each recipe line runs in a separate shell, variables expand in surprising ways, arguments are awkward to pass, and .PHONY must be declared or a file named test silently disables the test target. just is a command runner that keeps Make's familiar syntax but drops the build-system semantics: recipes run in one shell per line by default but support shebang recipes, arguments are first-class, and just --list documents itself. Task uses YAML, runs commands through a portable shell interpreter built into the binary (so recipes work on Windows without Bash), and supports up-to-date checks through sources and generates fingerprints.
A reasonable default: keep Make where it already works and the team is on macOS and Linux; choose just for new repositories that want a friendlier command runner; choose Task where native Windows support or fingerprint-based skipping matters. The make vs just vs Taskfile comparison has worked examples of the same tasks in all three.
- List the platforms the team uses, including CI runners.
- Decide whether any task genuinely benefits from skipping work when inputs have not changed.
- Pick one tool per repository and write the choice into the contributing guide.
Designing a discoverable command surface
The command surface is an interface, and it deserves the same care as an API: stable names, clear descriptions, no hidden prerequisites. In just, a comment above a recipe becomes its description in just --list, and groups organise long lists:
set dotenv-load := true
set shell := ["bash", "-euo", "pipefail", "-c"]
# List available recipes
default:
@just --list --unsorted
[group('stack')]
# Start every service and wait until healthy
up:
docker compose up -d --wait
[group('stack')]
# Stop services, keep data
down:
docker compose down
[group('checks')]
# Run the same tests CI runs
test *ARGS:
npm test -- {{ARGS}}
[group('checks')]
# Verify tools, ports and services
doctor:
./scripts/doctor.sh
[group('data')]
[confirm('This deletes the local database. Continue?')]
# Drop and reseed the local database
db-reset:
docker compose rm -sfv db
docker compose up -d --wait db
npm run db:migrate && npm run db:seed
[confirm] makes the destructive recipe ask before running, which is the "safe" property in practice. test *ARGS passes extra arguments through, so just test --grep checkout works without a second recipe. Running just alone prints the grouped list, which is the first thing to show a new hire.
Naming is where most command surfaces go wrong over time. Tasks accumulate as people need them — run-local, start2, dev-with-mocks, fix-db — and the list becomes a record of history rather than an interface. A short, shared vocabulary prevents that: verbs for lifecycle (up, down, restart), verbs for quality (test, lint, fmt), verbs for data (db-reset, db-seed, db-shell), and doctor for diagnostics. Variants become arguments or flags on an existing verb rather than new tasks, so just up --profile mocks replaces dev-with-mocks. Reviewing new task names in pull requests the same way API names are reviewed keeps the surface small enough to learn in an afternoon.
The drift diagnostic for this section compares the task list with what CI runs. Tasks CI does not call tend to rot; CI steps not exposed as tasks are knowledge developers cannot easily reproduce:
#!/usr/bin/env bash
set -euo pipefail
just --summary | tr ' ' '\n' | sort > /tmp/tasks.txt
grep -rhoE 'just [a-z0-9-]+' .github/workflows | awk '{print $2}' | sort -u > /tmp/ci-tasks.txt
echo "tasks CI never runs:"; comm -23 /tmp/tasks.txt /tmp/ci-tasks.txt
Cross-platform tasks
The fastest way to lose Windows developers is a task file full of Bash-isms — $(...), && chains with rm -rf, sed -i with GNU flags. There are three workable strategies. Run everything inside WSL2 or a dev container, where the shell is always Bash, and document that as the supported path. Use Task, whose embedded shell interpreter (mvdan/sh) runs POSIX shell syntax on Windows without Bash installed. Or keep task bodies trivial and move logic into scripts in a language every platform has — Node or Python — which the task invokes.
version: '3'
tasks:
clean:
desc: Remove build output on any OS
cmds:
- rm -rf dist .cache
generate:
desc: Regenerate API clients when the spec changes
sources: [openapi/*.yaml]
generates: [src/generated/**/*.ts]
cmds:
- npx --yes @openapitools/openapi-generator-[email protected] generate -i openapi/api.yaml -g typescript-fetch -o src/generated
up:
desc: Start the stack
cmds:
- docker compose up -d --wait
rm -rf works on Windows here because Task's interpreter implements it. The sources/generates pair makes task generate a no-op when the spec has not changed, which the Taskfile up-to-date checks guide covers in detail. The Windows guide lists the constructs that break most often and their portable equivalents.
From tasks to an internal developer CLI
Task runners are per repository. Once an organisation has dozens of repositories, cross-cutting operations appear that belong to none of them: creating a new service from a template, fetching development secrets for any project, checking every repository a developer has cloned for outdated toolchains, opening the right dashboard for a service. Copying those tasks into every justfile duplicates them and lets them drift. A small internal CLI — acme dev up, acme secrets pull, acme doctor — packages them once, is versioned and distributed like any other tool, and delegates to each repository's task runner for repository-specific work.
#!/usr/bin/env bash
set -euo pipefail
# acme: thin internal CLI that dispatches to repo tasks and shared commands
cmd="${1:-help}"; shift || true
case "$cmd" in
up|down|test|doctor)
if [ -f justfile ]; then exec just "$cmd" "$@"
elif [ -f Taskfile.yml ]; then exec task "$cmd" -- "$@"
elif [ -f Makefile ]; then exec make "$cmd"
else echo "no task runner found in $(pwd)"; exit 1; fi ;;
secrets) exec "$(dirname "$0")/acme-secrets" "$@" ;;
new) exec "$(dirname "$0")/acme-new-service" "$@" ;;
help|*) echo "usage: acme {up|down|test|doctor|secrets|new} [args]" ;;
esac
Distribution matters as much as code. An internal CLI that each developer installs by copying a script goes stale immediately; one installed through the same channel as other tools — a Homebrew tap, a package in the toolchain manager, or a binary the bootstrap script downloads at a pinned version — can be updated deliberately and rolled back. Print the CLI's version in acme doctor output, so a support request immediately shows whether the developer is running a release from last week or last year.
That dispatcher is deliberately thin: repositories keep ownership of their tasks, and the CLI adds only what is genuinely shared. The internal developer CLI guide grows it into a proper tool with subcommands, self-update and telemetry on which commands fail most.
Keeping tasks honest in CI
A task developers run but CI does not is a task that silently breaks. The simplest rule is that CI calls the same tasks: just lint, just test, just build. Then a broken task fails the pull request that broke it, and "works in CI but not locally" disappears as a category because they run the same command. Two refinements help. Run just --list (or task --list) in CI and fail if any task lacks a description, so the discoverable surface stays complete. And run the doctor task in a clean container on a schedule, which catches tasks that depend on tools missing from the documented setup.
name: tasks
on: [pull_request]
jobs:
tasks:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v4
- uses: extractions/setup-just@v2
with: { just-version: '1.35.0' }
- run: just --list --unsorted
- run: just lint
- run: just test
This also changes how CI configuration reads. A workflow file full of inline shell — installing tools, exporting variables, running five commands in sequence — is knowledge only CI has. A workflow whose steps are just lint, just test and just build is a thin scheduler around tasks anyone can run on a laptop, and debugging a CI failure starts with running the same task locally rather than reverse-engineering YAML. When a CI step genuinely needs something developers do not, such as uploading coverage, keep it as a separate, clearly named workflow step so the boundary is visible.
Pinning the task runner version in CI to the same version developers use closes the last gap: syntax added in a newer just release would otherwise pass locally and fail in CI, or the reverse.
Platform caveats
macOS: the system Make is GNU Make 3.81 from 2006, which lacks features such as
.ONESHELLand--output-sync. Either write Makefiles for 3.81 or installmakefrom Homebrew and call itgmakeconsistently.
WSL2: task runners behave as on Linux, but tasks that open a browser or copy to the clipboard need WSL-aware helpers (
wslview,clip.exe). Keep those behind a small helper script rather than inside tasks.
Windows (native): just needs a shell (Git Bash, PowerShell with
set windows-shell); Task runs its built-in interpreter; Make requires installing a POSIX environment. This is often the deciding factor between them.
Apple Silicon (ARM64): all three tools publish native arm64 builds. Tasks that download binaries should detect architecture with
uname -mrather than assumingx86_64.
Rollback and recovery
Introducing a task runner is additive: existing scripts and commands keep working, and tasks usually just wrap them. To back out, delete the task file and restore the README's direct commands. If a task change breaks developers' workflows, revert the task file like any other code — the commands it wraps are unchanged:
#!/usr/bin/env bash
set -euo pipefail
git log --oneline -5 -- justfile
git revert --no-edit "$(git log -1 --format=%H -- justfile)"
just --list
Frequently Asked Questions
Should we replace Make with just or Task?
Not automatically. If Make works for the team's platforms and the Makefile is readable, keep it. Switch when the pain points — argument passing, tab errors, .PHONY mistakes, native Windows — are costing time, and do it one repository at a time.
How many tasks is too many?
When just --list no longer fits on one screen, group tasks and hide internal helpers (prefix with _ in just, internal: true in Task). Developers should see the ten or so tasks they use daily without scrolling.
Should package.json scripts or a task runner be the entry point?
For JavaScript-only repositories, npm scripts are fine. In polyglot repositories, a task runner is a better single entry point, and it can call npm scripts where they already exist.
When is an internal CLI worth building?
When the same cross-repository operation is copied into several task files, or new services take days to set up by hand. Start with a thin dispatcher and grow it only as shared operations appear.
Related
- Compare make, just and Taskfile on the same tasks
- Build a one-command bootstrap target
- Grow shared tasks into an internal CLI
- Run the same lint checks locally and in CI
Every guide in this topic
- Building an Internal Developer CLI for Common WorkflowsPackage cross-repository chores into one versioned internal CLI: subcommands, delegation to repo tasks, self-update, doctor checks and usage telemetry.
- make vs just vs Taskfile for Onboarding ScriptsThe same bootstrap, doctor and reset tasks written in make, just and Task side by side, compared on readability, arguments, Windows support and skipping work.
- Making Task Runner Targets Work on WindowsFix tasks that fail on Windows with 'rm is not recognized', CRLF shebang errors and path quoting problems: WSL2, portable shells, and moving logic into scripts.
- Replacing Makefiles With just for Project TasksMigrate a task-style Makefile to a justfile: fix missing separator and .PHONY bugs, pass arguments cleanly, load .env and keep make as a shim during rollout.
- Writing a Taskfile With Up-to-Date ChecksSkip codegen, installs and builds when nothing changed using Task's sources, generates and status checks, and fix tasks that always rerun or never rerun.