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.

One Command Surface per Repository A task runner in the centre exposes the same core verbs to developers, CI and documentation. One Command Surface per Repository task runner up, down start and stop stack test, lint same as CI doctor environment checks reset destructive, confirms generate codegen, clients
The same verbs in every repository mean onboarding to the second repo takes minutes.

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). just 1.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.toml or devbox.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.

make vs just vs Task Table comparing make, just and Task on installation, syntax, arguments, Windows support and up-to-date checks. make vs just vs Task Aspect make just Task Preinstalled Linux, macOS no no Syntax Makefile, tabs Make-like YAML Arguments awkward first-class CLI_ARGS Windows needs shell needs shell built-in shell Up-to-date checks timestamps none checksums
All three are fine; pick by platform mix and whether you need file-based up-to-date checks.

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.

  1. List the platforms the team uses, including CI runners.
  2. Decide whether any task genuinely benefits from skipping work when inputs have not changed.
  3. 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
Anatomy of a Well-Behaved Task Layers of a single task from its name and description down to its checks and cleanup. Anatomy of a Well-Behaved Task Name a verb from the shared list Description one line in --list output Preconditions tools and services checked Action the actual commands Safety confirm if destructive
Each layer answers a question a new developer would otherwise ask a colleague.

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.

Making Tasks Portable Decision diagram choosing a portability strategy based on whether Windows developers use WSL2. Making Tasks Portable Do Windows developers use WSL2? Yes, always Bash tasks, WSL2 only No, native Windows Task or script tasks
Pick one strategy and document it; mixing them is where portability breaks.

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.

Where a Command Is Resolved Flow showing the internal CLI dispatching repository verbs to the local task runner and shared verbs to its own implementation. Where a Command Is Resolved acme up developer types dispatcher shared or repo? repo task just, task, make shared cmd secrets, new
Repository work stays in the repository; only shared operations live in the CLI.

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 .ONESHELL and --output-sync. Either write Makefiles for 3.81 or install make from Homebrew and call it gmake consistently.

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 -m rather than assuming x86_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.

Every guide in this topic