Caution
This is work in progress, use on your own risk.
A Makefile replacement written in pure bash. No DSL, no runtime dependencies, no build step — just scripts.
curl -fsSL https://raw.githubusercontent.com/imgproxy/run.sh/main/run -o run && chmod +x runThis downloads the task dispatcher. Then:
- Create
bin/directory and add task files (one file per task,bin/<name>.sh). - Optionally create
.runrcfor shared helpers and variables (entirely optional; sourced before any task runs if present). - Run
./runto list tasks, or./run <task>to execute a task.
./run # list available tasks
./run <task> [args...]
./run help <task> # show a task's help text./run with no arguments lists every task found in bin/, using each
task's description() for the one-line summary. Task names are derived
from the filename (bin/deploy.sh → ./run deploy).
./run install-global [bash|zsh|fish] [--force]Installs a run shell function to your shell rc file (~/.bashrc,
~/.zshrc, or ~/.config/fish/config.fish). The function walks up from
your current directory to find the nearest run.sh project (like git
finds .git), so you can run run <task> from any subdirectory instead
of ../../run <task>.
- With no argument, the shell is detected from
$SHELL. - Pass
bash,zsh, orfishexplicitly to override detection. - Pass
--forceto reinstall without the "already installed" prompt (useful for picking up a newer version of the installed function).--forceand the shell name can be given in either order, e.g. bothinstall-global --force zshandinstall-global zsh --forcework.
The installation is idempotent — running ./run install-global twice will
only append the function once (you'll be prompted before it's overwritten,
unless --force is given).
A task is a file at bin/<name>.sh defining three functions:
#!/usr/bin/env bash
# Sourced by ./run — do not execute directly.
description() { echo "One-line summary shown in ./run's task list"; }
help() {
cat <<'EOF'
Usage: ./run <name> [args...]
EOF
}
main() {
echo "task logic goes here, receives \"\$@\""
}description()— must echo a single line; shown next to the task name when you run./runwith no arguments. Required — a task missing it fails at listing time with a clear error.help()— must print (usually viacat <<'EOF' ... EOF') a usage message; shown by./run help <name>. Required.main()— the task's actual logic; receives the task's arguments as"$@", exactly as typed after the task name (e.g../run deploy --env=prod→mainreceives--env=prod). Required. Its exit code becomes./run's exit code.
Task files are sourced, not executed as subprocesses, so they run in the
same shell as ./run and have access to PROJECT_ROOT, TASK_DIR, every
helper described below, and anything defined in .runrc.
See examples/task.sh for a fuller example demonstrating flags, positional
args, validation, and task composition patterns. Copy it into bin/ and
trim it down to what you need.
./run sets a few variables and provides these helpers before any task runs.
They're all defined in the "available to your tasks" section at the top of
the run script itself, ahead of a clearly marked divider, and named
run::<thing>. Everything below that divider is internal dispatch
machinery named run::_<thing> — tasks and .runrc shouldn't call it
directly.
PROJECT_ROOT— absolute path to the project root (the directory containing./run). Use it to build paths that don't depend on the caller's current working directory, e.g."$PROJECT_ROOT/config/app.yml".TASK_DIR— absolute path to the task directory ($PROJECT_ROOT/bin).
-
run::require_tool <cmd> <message>— exits the whole./runinvocation witherror: <message>on stderr if<cmd>is not found on$PATH. Use it at the top of a task that shells out to an external program, so a missing dependency fails fast with a clear message instead of a raw "command not found" further down.run::require_tool jq "jq is required (https://jqlang.github.io/jq/)" run::require_tool docker "docker is required to build images"
-
run::require_arg <flag-name> "$value"— returns 1 witherror: missing value for <flag-name>on stderr if<value>is empty. Unlikerun::require_tool, it returns instead of exiting, so it composes naturally with manual flag parsing inmain():--env=*) env="${1#*=}"; shift ;; --env) run::require_arg --env "${2:-}"; env="$2"; shift 2 ;;
-
run::prompt <color> <question> [choices]— single-keypress prompt. Prints<question>in<color>, waits for one key (no Enter needed), and echoes the matched choice to stdout.choicesis a/-separated list of single letters, default"y/n". Capitalize one letter to make it the default (picked by pressing Enter).- An unmatched key is ignored — no error, just waits for another keypress.
- 2-way choices (
y/n-style): the exit code doubles as the answer — 0 if the match is the first-listed choice, 1 otherwise. That's what letsrun::prompt cyan "..." "y/N" && do_thingread as "if yes, do the thing," with no need to capture or compare stdout. - 3+-way choices: the exit code still follows the same rule (0 only for
the first-listed choice) but isn't meaningful here — you have three-plus
outcomes to distinguish, not two. Capture stdout and switch on it instead.
Tasks run under
set -e, so guard the capture with|| true; otherwise a non-first pick returns 1 and aborts the task before thecaseruns.
run::prompt cyan "Deploy to production?" "y/N" && deploy # default: no if run::prompt cyan "Overwrite existing file?" "Y/n"; then ...; fi choice="$(run::prompt magenta "Deploy to: (s)taging/(p)roduction/(c)anary" "s/p/c")" || true case "$choice" in s) ... ;; p) ... ;; c) ... ;; esac
-
run::user_input <color> <label>— prints<label>in<color>and reads one line of free-form text from the user, echoing it to stdout. Use this instead ofrun::promptwhen you need arbitrary text rather than a fixed choice.name="$(run::user_input cyan "Enter your name:")" echo "Hello, $name!"
-
run::color_echo <color> <text>— prints<text>in<color>(no trailing newline), or plain<text>when colors are disabled. Supported colors:red,green,blue,cyan,magenta,yellow,gray,neon.run::color_echo green "OK"; printf '\n'
-
run::colors_enabled— returns 0 when colored output should be used (stdout is a terminal, it supports at least 8 colors, andNO_COLORis unset).run::color_echoand therun::msg_*helpers use this internally; call it yourself only if you're printing raw ANSI escapes. -
run::icon <name>— echoes the glyph for a status kind, with no trailing newline:skip→◇,success→✓,error→✕,info→◆,warning→■. This is the single source of truth for the glyphs therun::msg_*helpers print; call it directly when you want the bare glyph instead of a full formatted message.printf '%s deploy complete\n' "$(run::icon success)" run::color_echo green "$(run::icon success) done"; printf '\n'
-
run::msg_ok <text>/run::msg_skip <text>/run::msg_info <text>/run::msg_warn <text>/run::msg_err <text>— print a status line with a colored icon (seerun::iconabove):✓(green, ok),◇(gray, skip),◆(yellow, info),■(yellow, warn), orerror:(run::msg_err, uncolored). All butrun::msg_errwrite to stdout;run::msg_errwrites to stderr, matchingrun::require_tool/run::require_arg.run::require_tool git "git is required" && run::msg_ok "git found" [ -f dist/app ] && run::msg_skip "already built" || run::msg_info "building..."
-
NO_COLOR— set this environment variable (to any non-empty value) to disable color output globally, regardless of terminal support.
-
run::depends_on <task>...— runs each named task in order (as$PROJECT_ROOT/run <task>), stopping and propagating the exit code at the first failure. Use it inside a task'smain()to build tasks out of other tasks.main() { run::depends_on build test; echo "deploying..."; }
.runrc, if present at the project root, is sourced once before any task
runs, so helpers and variables defined there are also available to tasks.
.runrc is entirely optional — tasks work fine without it.
run.sh itself has no runtime dependencies, but your tasks can use any tool you already have installed. Install libraries the same way you normally would (Homebrew, apt, npm, pip, etc.) and call them from your task files.
For reusable helpers, source them from .runrc:
# .runrc
. "$PROJECT_ROOT/vendor/some-lib.sh"Use the built-in run::require_tool helper to fail early with a friendly message
when a required command is missing:
main() {
run::require_tool jq "jq is required (https://jqlang.github.io/jq/)"
jq '.version' package.json
}- No external dependencies — it's bash, all the way down.
runis a single file you download once and keep; your logic lives inbin/and (optionally).runrc.
MIT