Task organisation for dev projects,
based on a pure shell script.


Get started

(Latest version of task runner)

  1. Download task runner
  2. Make it executable, e.g. chmod +x run
  3. Put into path, e.g. mv run /usr/local/bin/run

Needs Bash 3.2+, tested on Linux and macOS.

How it works

Place a task file called run.sh in your project root and make it executable. Let’s say your project is a then your task file could look like this:


# Compile binary
run::compile() {
go build \
-o out/ \

# Install dependencies
run::install() {
go get -t ./...
go mod tidy
# Create bundle
run::bundle() {
./node_modules/.bin/esbuild \
src/index.ts \
--bundle \

# Install dependencies
run::install() {
npm install --strict-peer-deps
# Start web server
run::server() {
PORT=8000 \
CONFIG=/app/config.yml \
python src/server.py

# Install dependencies
run::install() {
pip install \
-r requirements.txt

A run.sh task file is a plain regular shell script. It behaves exactly as you would expect from any other shell script. There is no special magic to it, except that it adheres to the following convention:

These notation rules allow the run task runner to recognise and process the tasks.

Run a task

For example, if you want to execute the install task:

$ run install

Under the hood, the task runner evaluates the task file in a bash subprocess and then invokes the task with the respective name – in this case, the bash function run::install.

Any additional CLI arguments will be passed on to the task as is.

List all available tasks

List all available tasks along with their title:

$ run --list
server    Start web server
install   Install dependencies

If a task has additional lines of commentary below the title, you can print that by running e.g. run --info install.

Usage without task runner

Without the task runner available (e.g. when in a CI or production environment), you can still access your tasks by sourcing the run.sh task file and then directly calling the task commands by their original names.

$ . run.sh
$ run::install

(The task runner effectively does the same thing.)


Shell Cheat Sheet

Here are a few handy shell scripting snippets.

Variable with default fallback value


“Ternary” assignment

PORT=$([[ "$IS_PROD" ]] && echo 443 || echo 8080)

Task input args

run::hello() {
echo "$1" # First arg
echo "$2" # Second arg

Pass on all task input args

run::print() {
echo "$@"

Call other task

run::hello() {

Check, if…

[[ -f file.txt ]] # … file exists
[[ -d folder/ ]] # … directory exists
[[ -z "$VAR" ]] # … var is empty/unset (“zero”)
[[ -n "$VAR" ]] # … var is not empty

Read file contents into variable


Include other shell file

source otherfile.sh
(The path is relative to the shell’s current working directory.)

Get absolute path of script’s location

THIS_DIR=$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" &> /dev/null && pwd)
(This declaration must be placed at top level.)

Use .env files

source .env
# You can also do that conditionally, e.g.: [[ -f .env ]] && source .env
[[ -f .env ]] && source .env || source .env.dev

Exit immediately on failure

set -o errexit
(Beware, this has gotchas.)