Introduction
CJTaskrunner is a small task runner for project-local commands. Put the
commands a project needs in a cjtasks file, add short descriptions where they help, and run them with cj <taskname>.
The language of the cjtasks file is inspired by YAML, but is actually a purpose-made Domain Specific Language. It is similar to make but without all of the declarative and build features.
Because of this it offers an excellent set of ergonomic features such as a dead-simple syntax, flat directives, and bareword strings.
check:
@desc run the project checks
cargo test --locked
frontend:
@desc run the frontend preview
npm run
> cj check
CJTaskrunner is meant to be a command catalog, not a replacement for Cargo, npm, Python, Docker, or shell scripts. It gives those tools short, discoverable project names.
Start Here
- Install - get the
cjexecutable. - Manual - common taskfile patterns.
- Integrations - wrap npm, Cargo, Python, Docker, and CI commands.
- Ecosystem - editors, install paths, and examples.
Reference
Explanation
Install
CJTaskrunner has one executable: cj.
Shell Installer (recommended)
You should never run arbitrary shell scripts that you download off the internet.
That said, the easiest and recommended way to install CJTaskrunner is by doenloading and running the install script in one line here:
curl -fsSL https://raw.githubusercontent.com/jgusta/cjtaskrunner/main/install.sh | bash
The installer downloads the latest release for Linux x86_64, macOS Intel, or
macOS Apple Silicon, verifies it against SHA256SUMS, and installs cj into $CJ_INSTALL_DIR or $HOME/.local/bin.
Make sure the install directory is in your PATH.
For Bash or Zsh:
export PATH="$HOME/.local/bin:$PATH"
For Fish shell:
fish_add_path "$HOME/.local/bin"
From Source
cargo install --git https://github.com/jgusta/cjtaskrunner.git cjtaskrunner
Homebrew
Homebrew no longer supports intel-based macs. So if you are on an intel mac, this formula will build from scratch.
brew install jgusta/cjtaskrunner/cj
Check The Install
cj --help
Manual
First Taskfile
Create a taskfile:
cj --init
Add a task:
hello:
@echo hello
Run it:
cj hello
Run cj without a task name to list visible tasks.
See CLI Reference and Taskfile Reference.
Descriptions And Help
Use @desc for the one-line summary shown by cj.
check:
@desc run the test suite
cargo test --locked
Use @help: for block-form help text shown by cj help and cj help <task>.
Tasks beginning with _ stay hidden from summary mode.
_clean-cache:
rm -rf .cache
See @desc, @help:, and @selfhelp.
Commands
Ordinary commands run directly, without shell parsing.
check:
cargo test --locked
Use @shell when you need redirection, pipes, glob expansion, or command chaining.
bundle:
@shell mkdir -p dist && cat src/*.js > dist/app.js
See Taskfile Reference and @shell.
Nested Tasks
Nested tasks are addressed with colon-separated names. Tasks can only be nested one level.
build:
@desc build commands
cli:
@desc build the CLI
cargo build --release
cj build:cli
See Taskfile Reference.
Task Calls
Use @task when one task should run another task.
ci:
@task fmt
@task test
fmt:
cargo fmt --check
test:
cargo test --locked
See @task.
Arguments
Declare required arguments beside the task name.
greet (NAME):
@echo hello $NAME
cj greet Ada
Arguments are required. Optional, default, and variadic arguments are not part of the format.
See Taskfile Reference.
Working directory
- Always starts in the same directory of the taskfile, no matter where it is called from.
- Directory changes via
@cdpersist for later lines in the same task context. - Called tasks receive the caller’s current directory as their baseline, but their directory changes reset when they return.
Variable definition
CJTaskrunner runs in an isolated snapshot of your environment which inherits all your environment variables. They can be used in your tasks by prefixing their name with a dollar sign ($) and CJTaskrunner will replace them with their value before doing anything else.
Define and set new variables using @set.
dev:
@set foo BAR
@echo foo $foo
Use its block form, @set VAR: to capture the output of commands:
branch:
@set BRANCH:
git rev-parse --abbrev-ref HEAD
@echo $BRANCH
In order to persist the variable across taskrunner tasks and make it visible to child processes, use @export.
run:
@export NODE_ENV production
node server.js
@exportcan be used with the variable name and no value assignment and it will export whatever the existing value is.
Use @env: to set multiple taskfile-wide values. These are automatically exported. A ? means only set if not yet set.
@env:
PORT?: 5173
MYHOST?: http://localhost
NODE_ENV?: production
dev:
npm run dev -- --port $PORT --host $MYHOST
Variable usage
You can use variables by prefixing the name with a $. This is done before the task is parsed. You can substitute variable for a directive argument.
You can set a default for a variable or make a value mandatory when using them. Use ${NAME} when a value must exist, and ${NAME?fallback} for local fallback values.
You cannot use a variable as the name of a command in a task definition, nor can you substitute the name of a directive using a variable.
Directives that require the name of a variable (@if-set, @set, @export) expect a string value; if you include a $ in front of a variable name, it will resolve the value as the name.
-
Unexported variables set with
@setare only accessible from the same task context. Subordinate tasks receive them as a snapshot, but changes do not propagate back. -
Setting an environment variable using the
@env:directive, or using@exporton a variable will affect the taskfile execution context. -
Pre-existing environment variables will be restored after the task runs. Variables and environment variable changes within the taskrunner context will be discarded after the task run.
See Variables Reference.
Parallel Tasks
Use @await when independent task branches can run at the same time.
dev:
client:
npm run dev
server:
node server.js
@await dev:client dev:server
@open http://localhost:5173
@or
@echo startup failed
@stop
@await waits for all named tasks. Its block runs only when all awaited tasks succeed.
See @await.
Watch Tasks
Use @watch to restart one long-running line when files change.
docs:
@watch docs
mdbook serve
See @watch.
Conditional Logic
Use @if, @else, @and, and @or for status-based flow.
cond:
@set MODE release
@if $MODE == release
cargo build --release
@else
cargo build
Use @if-in for list membership.
compile (TARGET):
@if-in $TARGET linux macos windows
@echo supported target
Every @if directive has a matching negative form such as @if-not or @if-not-in.
Versions
Declare component versions with @version, then bump them with @patch, @minor, @major, @pre, or @release.
@version cli 0.1.0
bump:
@patch cli
@echo $VERSION_CLI
See SemVer Tools.
Ecosystem
Command-line client
The cj executable discovers and runs tasks, formats taskfiles, generates
shell completions, and exposes the language server. See
Getting started and the taskfile format.
Built-in LSP
CJTaskrunner serves its language protocol implementation through cj --lsp.
It provides diagnostics, completion, hover information, document symbols,
task definitions, and formatting without requiring a second executable.
VS Code extension
Provides a CJTASKS panel syntax highlighting and directive reference.
Installation
See Install.
Shell completion
cj can generate or install completions for Bash, Zsh, and Fish:
cj --completions zsh
cj --install-completions zsh
Replace zsh with bash or fish as needed.
See CLI Reference.
Example projects
The example_tasks directory contains taskfiles
for Rust, Node, Python, Docker, and other common project layouts.
Syntax Highlighting
VSCode extension
The official extension brings syntax highlighting, formatting, taskfile comprehension and more to Visual Studio Code.
Integrations
Auto discover
cj --auto imports common task definitions from package.json, deno.json, Makefile, and argument-free Justfile recipes. It creates cjtasks when no base taskfile exists, otherwise it appends missing tasks to cjtasks.
package.json scripts are considered first, followed by Deno, Make, and Just tasks. Each wrapper takes the shortest available normalized name. Name or directory conflicts add a number without a separator: build, build2, build3. Existing CJ tasks are never overwritten. This command is not idempotent; you probably don’t want to run it more than once as it will create new tasks each time it runs.
For package.json scripts, a single colon in a script name creates a nested task. For example, build:site becomes build:site under a build group. Script names with more than one colon stay flat and have their colons removed from the imported task name.
See CLI Reference.
Python virtual environments
CJTaskrunner adjusts PATH when it finds an active or project-local Python
virtual environment. Detection uses VIRTUAL_ENV, then CJ_VENV, then a
.venv directory beside the taskfile.
Tasks run with the Python executable from inside the virtual environment.
setup:
python -m venv .venv
python -m pip install -e .
test:
python -m pytest
See the Python virtual environment example.
Node and Vite
Node package scripts remain the source of truth. CJTaskrunner can group them with setup or validation steps:
setup:
npm install
dev:
@task setup
npm run dev
check:
npm run build
See the Vite example.
Rust and Cargo
Cargo commands can be exposed alongside the rest of a repository’s workflows:
check:
cargo fmt --check
cargo test --locked
cargo clippy --all-targets --all-features --locked -- -D warnings
See the Rust CLI example.
Editors
The Visual Studio Code extension can be found in the VSCode Extensions panel or downloaded here
See Ecosystem.
CLI
cj [task] [arguments...]
cj <taskfile-or-directory>
cj <taskfile-or-directory> <task> [arguments...]
cj --version
cj --init
cj --auto
cj -e
cj --format [taskfile-or-directory]
cj --run <line>
cj --directives
cj --completions <bash|zsh|fish>
cj --install-completions <bash|zsh|fish>
cj --lsp
cj --version prints the installed CLI version without requiring a taskfile.
Running Tasks
From a valid CJTaskruner context:
cj check
cj greet Ada
cj example_tasks/docker-basic check
A valid CJTaskrunner context is a directory in which a valid task file is found.
If the first operand is an existing directory or recognized taskfile, it selects that taskfile location. Otherwise the first operand is the task name.
Listing mode
Aside from running named tasks, CJTaskrunner can also display information about the available tasks via “listing mode”.
In a valid CJTaskrunner context, type cj without any arguments to list the available tasks and their descriptions (provided by the @desc directive).
CJTaskrunner context
Your CJTaskrunner context depends on your working directory and whether or not it has a valid task file at the same level. For most purposes, you will want to use the default context, but you can also run CJTaskrunner in another context.
Default context
The default CJTaskrunner context is your current working directory when there is a valid task file in it.
When you run the cj executable from a default context you simply specify the name of a task to run as the first argument to run it. In default context, running cj with no argument engages listing mode.
Listing mode in default context:
cj
Non-default context
Specify a directory or file path as first argument. The directory must have a task file in its top level. Paths can be relative, ending slash is optional.
cj subdirectory
cj ../parentdir/cjtasks
cj /home/project/development.cjtasks
Running a task named “mytask” in another context:
cj subdirectory mytask
cj ../parentdir/cjtasks mytask
cj development.cjtasks mytask
Standard task file cascade rules applies to all contexts.
Task names cannot share names with a directory
You cannot give a task the same name as a directory in the same context.
For instance, if you have a cjtasks file in your project root but there is a folder named src in the same project, then you cannot name your task src. Additionally, you can’t name a task after a task file.
By design, the cli syntax is designed to minimize typing. The lazy ability to specify a different context as an argument comes with this one major tradeoff. In a CJTaskrunner context, you cannot have a task that shares a name with a directory.
The first argument is always checked if it is a valid directory or file name. Then task names are assumed.
This rule eliminates ambiguity. If you try to run a CJTaskrunner task from a context containing a directory with the same name, there will be an error. Relative and absolute paths can be used, trailing slashes on directory names are optional
Passing an existing directory or recognized taskfile by itself also lists that location’s visible tasks.
cj --run <line> executes one task line in the current directory without requiring a taskfile. The line must not contain newlines, task labels, or block directives such as @if, @switch, @and, @or, @help:, or @set NAME:.
Creating Taskfiles
cj --init creates an empty cjtasks file in the current directory.
cj --auto imports common task definitions from package.json, deno.json, Makefile, and argument-free Justfile recipes. It creates cjtasks when no base taskfile exists, otherwise it appends missing tasks to cjtasks.
package.json scripts are considered first, followed by Deno, Make, and Just tasks. Each wrapper takes the shortest available normalized name. Name or directory conflicts add a number without a separator: build, build2, build3. Existing CJ tasks are never overwritten. This command is not idempotent; you probably don’t want to run it more than once as it will create new tasks each time it runs.
For package.json scripts, a single colon in a script name creates a nested task. For example, build:site becomes build:site under a build group. Script names with more than one colon stay flat and have their colons removed from the imported task name.
Editing
cj -e
cj -e opens the detected taskfile in $EDITOR. If cjtasks exists, it is
opened. Otherwise CJTaskrunner opens the highest-precedence overlay taskfile it
can find.
Formatting
cj --format
cj --format path/to/project
Note this command targets the directory, not the file itself.
Formatting normalizes indentation and trailing whitespace while preserving blank lines. Formatter output always uses spaces for leading indentation.
Shell Completion
cj --completions zsh
cj --install-completions zsh
bash, fish and zsh are supported.
Help
cj --cli-help prints CLI usage.
cj --directives prints supported directives and a brief description for each one.
Language server
cj --lsp starts the language server over stdio. The language server is part of the main executable; there is no separate LSP binary.
cj --lsp
Miscellaneous
CJTaskrunner may use ANSI color in its own help, listing, and directive output. A non-empty NO_COLOR environment variable disables ANSI color.
The language server is built into the cj executable and runs over stdio.
See Taskfile Format for discovery and invocation details.
Definitions
Environment
State of your computer from which commands are executed.
- consists of the name of your current directory, your current user, its permissions, the variables accessible to it and the commands it can run at the moment.
Environment variable
A variable that can be read by executables and CJTaskrunner.
- Set in your shell before CJTaskrunner runs, or
- Set using the
@envdirective. - Globally available within a taskfile execution context.
Runtime variable
A variable whose value can be read only by CJTaskrunner inside of a task.
- Set in a task using the
@setdirective. - Accessible only in CJTaskrunner.
- Lasts the duration of the task context and is passed to subordinate tasks as a snapshot.
- Will override other variables with the same name for the duration of the task. Most recent definition wins.
- Can be promoted to an exported variable.
Exported variable
A variable that has the same properties of an environment variable but originated as a runtime variable.
- A runtime variable that is used in an
@exportdirective. - As a shortcut can be defined directly in the
@exportdirective. - Exported values are shared across the taskfile execution context. A task can still shadow the same name with
@setinside its own task context, or overwrite the shared value with another@export.
Task context
The environment that spans the execution of a single task.
- This is the narrowest and most isolated context.
- It starts as a copy of the taskfile execution context from which the task is run.
- A copy of it is propagated to subordinate task runs using
@taskand@await. - Unexported variable changes are discarded when the task ends, as well as working directory changes.
Taskfile execution context
the environment that exists from the moment you run a CJTaskrunner task until it finishes.
- Starting as a snapshot of your shell environment, it holds any environment variable references and exported variables and makes them available to task contexts.
- Ultimately, all environment changes made in a taskfile execution context are discarded after CJTaskrunner completes its run.
Variable
A named container holding a value.
- You refer to a variable by its name, such as MR_VAR.
- Variables are further divided into runtime variables, environment variables, and exported variables.
- The name must consist of ascii letters, digits or an underscore. It cannot start with a digit.
- If you put a
$in front of the variable name, CJTaskrunner will exchange the whole thing for the variable’s value. This is what is called “interpolating” or “dereferencing”.
Directives
Directives only exist to save time. They are optional and not needed to run tasks.
Directive names begin with @. Some accept arguments, in which case the arguments are names, paths, or user-facing text. Directives have no options.
When using a directive, the line begins with the directive name. Directives must always be the first thing on the line (except for indenting). This has several consequences:
- The syntax unambiguos
- Directives cannot be used in the middle of other lines
- Directives do not “return” usable values other than exit codes
- The ‘@’ symbol can be used in strings freely
Some directives are ‘block’ directives, meaning that the lines after them are indented.
Some directives capture the string expression or standard out of lines indented below them. These directive lines end with a colon. See @help: and @set:. One more special case is @env:.
Other than those three, all block directives have no colon at the end of the line.
Directive lines
Index
Commands And Composition
@shell- run a command through the platform shell.@open- open an HTTP or HTTPS URL.@task- call another task.@await- run tasks in parallel and wait for their statuses.@watch- restart one line when files change.@echo- print text.@return- return a derived or block status.@success- return status0.@fail- return status1.@stop- print an optional message and return status1.
Flow Control
@and- run a block after success.@or- run a block after failure.@if- test values or their inverse.@if-in- test whether a value is in a list.@else- alternate block for an@if.@if-exists- test whether a path exists.@if-set- test whether a variable exists.@if-version- test a component version.@if-bumped- test whether a version was bumped.@if-patch- test whether a version received a patch bump.@if-minor- test whether a version received a minor bump.@if-major- test whether a version received a major bump.@if-pre- test whether a version received a prerelease bump.@if-release- test whether a version received a release bump.@switch- select a case by value.@case- define a switch case.@default- define the switch fallback.
Variables And Versions
@env:- declare taskfile environment entries.@set- set a runtime variable or capture block output.@export- export a runtime variable to child processes.@unset- remove a runtime variable and export.@version- declare a component version.@patch- patch-bump a declared component version.@minor- minor-bump a declared component version.@major- major-bump a declared component version.@pre- prerelease-bump a declared component version.@release- remove prerelease from a declared component version.
Files And Directories
@cd- change the current working directory.@back- undo one@cd.@clean- remove a file or directory.@mkdir- create directories.@cp- copy files.@cpdir- copy directories.@zip- create a zip archive.@unzip- extract a zip archive.@rename- rename a file or directory.@regex-replace- replace text matching a regular expression.
Documentation Metadata
@desc- add a summary description to a task.@help:- add taskfile or task help text.@selfhelp- print the current task’s help and return.
Nested directive bodies use two additional spaces of indentation. See the taskfile format for parsing, interpolation, and status propagation rules.
Commands And Composition
@shell
Runs the interpolated command through /bin/sh -c on Unix-like environments and MacOS.
bundle:
@shell mkdir -p dist && cat src/*.js > dist/app.js
CJTaskrunner is generally concerned with your relationship to your scripts and executables, invoking the commands directly executables.
As such, shell-specific features such as globbing (*[1,3]), parameter expansion (${VAR##/*/}), inline flow control ( &&, ||, ;, \) and piping (<, |, >&2) are not supported in ordinary task definitions.
But fear not; should the need to for example, process strings directly in a shell via terse-but-not-readable cleverness not be abated you can use the @shell directive to run any nonsense through whatever your system calls /bin/sh… one line at a time.
Variable interpolation is still observed before the command is sent to the shell, so if you need to send a dollar sign ($) to the shell, you must escape it in your task definition with a backslash: \$.
As a reminder, there are many standard command line built-in utilities that offer fantastic string manipuation capabilities, such as awk, sed and grep that can be invoked directly through CJTaskrunner. Additionally, several routine tasks such as making directories or deleting things can be done with CJTaskrunner.
Ultimately, you may find that it is best to use a script in a .sh file and use CJTaskrunner to call that file. That is the idiomatic CJTaskrunning way.
@open
dev:
@open http://localhost:5173
Opens one interpolated HTTP or HTTPS URL with the system browser. The URL must begin with http:// or https://.
Platform opener commands:
- macOS:
open <url> - Windows:
cmd /C start "" <url> - Linux and other Unix systems:
xdg-open <url>
@task
ci:
@task fmt
@task image assets/logo.svg png
image (INPUT, FORMAT):
image-tool $INPUT --format $FORMAT
Runs another task from the same taskfile with the current working directory and runtime variable state. Values after the task name are interpolated and passed as positional arguments.
Recursive task cycles are errors.
The called task inherits runtime variables and the current working directory as
a snapshot. Runtime variables and directory changes made inside the called task
reset when it returns. Values changed with @export are shared with the calling
task and later child processes.
@await
dev:
client:
@echo client starts
sleep 1
@success
server:
@echo server starts
sleep 2
@success
@await dev:server dev:client
@task browser-opens
@or
@echo awaited tasks failed
browser-opens:
@echo browser opens
@await task... runs task branches in parallel and returns their status. When @await has an indented block, the block runs only after all awaited tasks succeed. If any awaited task fails, the block is skipped and @await returns the failing status, so normal @or and @and chaining applies.
Rules:
@awaitis an executable directive and may appear wherever a task line can appear.- Awaited tasks run in parallel when possible.
- Transitive awaits run before their waiting tasks.
- Shared awaited tasks run at most once per
@awaitdirective. @awaitwithout a block returns awaited task status directly.@awaitwith a block returns awaited task failure status or the block status.- Await cycles are parse errors.
- Missing awaited tasks are parse errors.
- Tasks with declared arguments cannot be named directly in
@await; use an argument-free wrapper that calls them with@task.
Awaited tasks run with cloned runtime variables and current-directory state.
Runtime variables set inside awaited tasks never leak back to the parent task.
Values changed with @export or @unset are shared with the parent task and
later child processes. Tasks reachable through @await cannot use version bump
directives. This validation also follows static @task name calls inside
awaited tasks.
Awaited tasks may still mutate the filesystem through ordinary commands or filesystem directives.
The await parallelism limit defaults to the machine’s available parallelism. Set CJ_JOBS to a positive integer to override it.
When CJTaskrunner receives Ctrl-C, it interrupts active child processes, including child process groups started by awaited tasks.
@watch
docs:
@watch docs
mdbook serve
@watch path... runs its single indented line immediately and watches the named
files or directories while that line is running. When a change is detected, it
waits three seconds, collapses any other changes during that window into the
same rebuild signal, then stops and restarts the line.
After each restart, watching starts again. If the watched line exits before any
change is detected, @watch exits with that line’s status. Directory watches are
recursive.
@watch requires at least one path and exactly one indented line. @await is
not allowed in the watched line or in a static @task called from it.
@echo
build:
@echo building $VERSION_APP
@echo text interpolates its arguments and writes the resulting text followed
by a newline. It returns status 0. Unlike running echo as a task command, it doesn’t spawn a process. It is also cross-platform and consistent.
You can also just use the echo (no @) task if you need the specific command behavior.
@return
check:
@return
test -f Cargo.toml
fail:
@return 1
@return value returns a status derived from the value.
true and other non-empty non-numeric strings return 0; false and an empty string return 1; numeric text returns that status code.
With an indented block, @return runs the block and returns its final status.
@success
ready:
@echo ready
@success
@success takes no arguments and returns status 0.
@fail
unsupported:
@echo unsupported configuration
@fail
@fail takes no arguments and returns status 1.
@stop
sync:
@if-not-set API_TOKEN
@stop API_TOKEN is required
@stop [text] writes the optional text followed by a newline, then returns
status 1, stopping normal task execution unless a same-level @or handles it.
Flow Control
@and
build:
cargo build
@and
@echo build succeeded
@and runs its indented block only when the previous same-level expression
returned status 0. It takes no arguments. When skipped, it returns status 1.
@or
build:
cargo build
@or
@stop build failed
@or runs its indented block only when the previous same-level expression
returned non-zero. It takes no arguments. When skipped, it returns status 0.
@if
build:
@if $MODE == release
cargo build --release
@if $VERBOSE
@echo verbose mode
@if-not $SKIP_TESTS
cargo test
Supported forms are @if value, @if left == right, and @if left != right.
Empty text, 0, and false are false; other values are true. The indented
block runs only when the condition matches. @if-not accepts the same forms and
runs its indented block when the condition does not match.
@if-in
compile (TARGET):
@if-in $TARGET linux macos windows
@echo supported target
@if-not-in $TARGET linux macos windows
@stop unsupported target
@if-in needle candidate... runs its block when the needle exactly matches one
candidate. @if-not-in needle candidate... runs its block when the needle does
not match any candidate. Quote a value or candidate when it contains spaces.
@else
build:
@if $MODE == release
cargo build --release
@else
cargo build
@else runs its block when the immediately associated same-level @if family
condition did not run. It takes no arguments and must follow a compatible
conditional.
@if-exists
install:
@if-exists package-lock.json
npm ci
setup:
@if-not-exists .venv
python -m venv .venv
@if-exists path runs its block when the path exists. @if-not-exists path
runs its block when the path does not exist. Relative paths resolve from the
current working directory.
@if-set
private:
@if-set API_TOKEN
./fetch-private-data
@if-not-set API_TOKEN
@stop API_TOKEN is required
@if-set NAME runs its block when the runtime variable exists. NAME may be
interpolated. @if-not-set NAME runs its block when the runtime variable is
absent. A present empty string is set, not unset.
@if-version
publish:
@if-version app prerelease
@stop refusing to publish a prerelease
@if-version app >= 1.0.0
@echo stable API
@if-not-version app prerelease
@echo release version
@if-version name operator version compares SemVer precedence using ==,
!=, <, <=, >, or >=. The forms @if-version name prerelease and
@if-version name release test whether a prerelease suffix is present.
@if-not-version accepts the same forms and runs its block when the version
condition is false.
@if-bumped
release:
@patch app
@if-bumped
@echo at least one version changed
@if-not-bumped docs
@echo docs version did not change
@if-patch app
@echo app received a patch bump
@if-bumped runs its block when any version was bumped during the current
invocation. @if-not-bumped runs its block when no version was bumped during
the current invocation. Both forms accept an optional version name to narrow the
check to one version.
@if-patch
release:
@patch app
@if-patch app
@echo patch release
@if-not-patch docs
@echo docs did not receive a patch bump
@if-patch name runs its block when the named version received a patch bump
during the current invocation. @if-not-patch name runs its block when the
named version did not receive a patch bump.
@if-minor
@if-minor name runs its block when the named version received a minor bump
during the current invocation. @if-not-minor name runs its block when the
named version did not receive a minor bump.
@if-major
@if-major name runs its block when the named version received a major bump
during the current invocation. @if-not-major name runs its block when the
named version did not receive a major bump.
@if-pre
@if-pre name runs its block when the named version received a prerelease bump
during the current invocation. @if-not-pre name runs its block when the named
version did not receive a prerelease bump.
@if-release
@if-release name runs its block when the named version received a release bump
during the current invocation. @if-not-release name runs its block when the
named version did not receive a release bump.
@switch
build:
@switch $MODE
@case release
cargo build --release
@default
cargo build
@switch value selects at most one directly nested @case block. When no case
matches, a directly nested @default block runs when present.
@case
build:
@switch $MODE
@case debug
cargo build
@case release
cargo build --release
@case value defines a branch directly inside @switch. At most one matching
case runs. A case outside a switch, a duplicate matching structure, or a case
without one value is an error.
@default
build:
@switch $MODE
@case release
cargo build --release
@default
cargo build
@default defines the fallback branch directly inside @switch. It runs only
when no @case matches and takes no arguments.
Variables And Versions
@env:
@env:
MODE: development
PORT?: 3000
serve:
@echo $MODE on $PORT
@env: declares taskfile environment entries. It is valid only at the top
level, before task definitions.
NAME: valueoverrides an inherited value.NAME?: valuesupplies a value only when the variable is absent.- Values may be empty and may be wrapped in matching single or double quotes.
- Inline
#text remains part of the value. - Duplicate or invalid environment names are errors.
@set
build:
@set MODE release
@set COMMIT:
git rev-parse --short HEAD
@echo building $MODE at $COMMIT
@set NAME value sets a CJTaskrunner runtime variable. It does not export the
value to child processes.
@set NAME: runs its indented block with stdout capture enabled, trims trailing
line endings, and stores the captured text. Capture fails when the block’s final
status is non-zero.
Runtime values are passed to sequential @task calls and @await tasks as a
snapshot. Changes made with @set do not persist back to the calling task.
@export
serve:
@set PORT 3000
@export PORT
node server.js
@export NAME exports an existing runtime variable to later child processes.
@export NAME value sets and exports the value in one step. Export changes are
shared with later task contexts, including calling tasks after subordinate
@task or @await work returns.
@unset
build:
@set MODE release
@export MODE
@unset MODE
@unset NAME removes the runtime variable and any export overlay for later
commands. NAME may be interpolated. It does not modify the parent process
environment.
@version
@version cli 0.1.0
@version extension 0.0.1-beta.1
show:
@echo $VERSION_CLI
@version name value declares a top-level component version and creates
$VERSION_NAME, uppercasing the name and converting hyphens to underscores.
Values use SemVer MAJOR.MINOR.PATCH with an optional prerelease suffix. Build
metadata is not supported. Version headers must appear before task definitions.
@patch
@version cli 0.1.0
@version app 1.2.3-beta.1
bump-cli:
@patch cli
@pre app beta.
@patch name increments patch and removes prerelease. The version name is
interpolated before validation.
@minor
@minor name increments minor, then sets patch to 0 and removes prerelease.
@major
@major name increments major, then sets minor and patch to 0 and removes
prerelease.
@pre
@pre name prerelease sets or increments the prerelease part. The version name
and prerelease identifier are interpolated before validation.
Prerelease bump rules:
@pre app alphasets the version prerelease toalpha.@pre app alpha.sets the prerelease toalpha.0, or increments the trailing number when the current prerelease is alreadyalpha.N.
@release
@release name removes the prerelease part.
Each version can be bumped at most once per invocation. Bump state is shared
with sequential @task calls. Tasks reached through @await are isolated and
cannot use version bump directives.
Files And Directories
@cd
build-docs:
@cd docs
npm run build
@back
@cd pathchanges the current working directory.- Relative
@cdpaths resolve from the current working directory.
@back
build-docs:
@cd docs
npm run build
@back
@backundoes one@cdin the current scope.@backdoes nothing at the root directory for the current scope.- Directory changes persist for later commands in the same block.
- Nested blocks inherit the parent directory and restore their starting directory when the block ends.
- Tasks inherit the caller’s current directory and restore their starting directory when they return.
@clean
clean:
@clean dist
@clean path removes one file or directory relative to the current working
directory. A missing path is not an error.
@mkdir
package:
@mkdir dist dist/assets
@mkdir path... creates one or more directories, including missing parent
directories. Existing directories are not errors.
@cp
package:
@mkdir dist
@cp README.md LICENSE dist
@cp source... destination copies files. With multiple sources, the
destination must be a directory. Paths resolve from the current working
directory.
@cpdir
package:
@cpdir assets dist
@cpdir source... destination copies directories recursively. A trailing slash
on a source copies that directory’s contents into the destination. Multiple
sources require a directory destination.
@zip
@zip source... output creates output.zip (unless the name already ends in
.zip). Directory contents are placed at the archive root. Files matching certain filename patterns are ignored. These names are built-in to CJTaskrunner. You can see this list at the ignored names list
@unzip
@unzip archive.zip [destination] extracts into a directory named after the
archive (without .zip). With no destination, this directory is placed beside
the archive; otherwise it is placed inside the given destination. A single
top-level directory in the archive is flattened into the named destination.
@rename
package:
@rename dist/app.tmp dist/app
@rename source destination renames one file or directory. Source and
destination must be in the same directory; use copy directives when moving
between directories.
@regex-replace
dates:
@regex-replace dates.txt '(\d{4})-(\d{2})-(\d{2})' '{2}/{3}/{1}'
@regex-replace FILENAME FIND REPLACE reads a UTF-8 text file, replaces every
non-overlapping match of FIND with REPLACE, and writes the result back to
the same file. Quote patterns and replacements that contain spaces so each is
passed as one argument. Use {1}, {2}, and so on for numbered capture
references; {0} refers to the full match. Braces mark the end of each group
number, so {1}0 means capture group 1 followed by a literal 0. Write \{
to output a literal {. The $1 form also works. The pattern supports
look-around and backreferences. Invalid patterns, matching errors, and file
read or write errors fail the directive.
Documentation Metadata
@desc
build:
@desc compile project
cargo build
Defines task description metadata. cj shows it when run without a task name.
Variables are not allowed in the description. Use \$NAME or \${NAME} to
display literal variable-shaped text. @desc does not run a command.
@help:
@help:
Project task help.
build:
@help:
Build help.
cargo build
It’s like @desc but indented, multiline and only prints when specifically asked for with cj help.
The entire block is read as a string. Variables are not allowed on any help-text line. However, for your sanity, the parser will catch whenever something looks like an attempt at using a variable. Use \$NAME or \${NAME} to display literal variable-shaped text.
cj help prints top-level help text when present, followed by the task listing and available help sections.
cj help <task> prints the task name, its @desc and @help: text, direct child tasks, and available help sections.
Top-level @help: defines taskfile help. Task-level @help: defines help for
that task. Plain @help is invalid; help: without the @ defines an ordinary
task named help.
Tasks whose name or any nested task segment starts with _ are hidden from the no-argument summary listing. They can still be run directly and viewed with cj help <task>.
@selfhelp
cli:
@desc cli commands
@help:
CLI help.
@selfhelp
Prints the same output as cj help <current-task>, then stops the current task successfully. @selfhelp takes no arguments.
The purpose of this directive is to serve as an indicator that there are no commands in this task, useful if it is just a container for subtasks. It is the closest thing to a
SemVer Tools
CJTaskrunner assists with versioning through built-in directives.
@version name value declares a top-level component version and creates
$VERSION_NAME, uppercasing the name and converting hyphens to underscores.
@version cli 0.1.0
@version lsp 0.0.1-alpha.1
Version names must contain only ASCII letters, digits, hyphens, and underscores.
Each declaration creates an override environment entry named VERSION_<NAME>,
where the name is uppercased and hyphens are converted to underscores.
Version values must be SemVer 2.x MAJOR.MINOR.PATCH values with an optional
prerelease suffix. Build metadata is not supported.
@version is only valid as a top-level header in cjtasks.
Use @patch, @minor, @major, @pre, or @release inside tasks to change a version.
Examples:
@version cli 0.1.0creates$VERSION_CLIwith value0.1.0.@version language-server 0.0.1creates$VERSION_LANGUAGE_SERVERwith value0.0.1.@version app 1.2.3-beta.1creates$VERSION_APPwith value1.2.3-beta.1.@patch appincrements$VERSION_APPby one patch version.@pre app alphasets the version prerelease toalpha.@pre app alpha.sets the prerelease toalpha.0, or increments the trailing number when the current prerelease is alreadyalpha.N.
Duplicate version variables are errors.
Other rules
Each version can be bumped at most once per invocation. Bump state is shared
with normal sequential @task calls. Tasks reached through @await are
isolated and cannot use version bump directives.
Overlay files may use version conditionals and may call a base task that
performs a bump, but they may not contain @version or version bump directives.
A bump always updates cjtasks on disk.
Version conditionals
There is a dedicated set of conditional directives for versioning:
@if-bumped
@if-patch
@if-minor
@if-major
@if-pre
@if-release
Taskfile Format
Filesystem
A CJTaskrunner taskfile is a plain-text file named cjtasks.
The CLI does not search parent or child directories.
CJTaskrunner uses the taskfile’s directory as the project root. Relative paths inside the taskfile are resolved from that directory.
File syntax
The simplest taskfile defines a task name and a command:
hello:
echo "hello world"
Run the task from the same directory:
cj hello
Running cj without arguments displays the available tasks:
> cj
Tasks in /Users/me/my-project/cjtasks:
hello
Adding @desc to a task displays a short description in the summary:
In the cjtasks file:
hello:
@desc prints "hello world" and exits
echo "hello world"
goodbye:
@desc prints "goodbye" and exits
echo "goodbye"
Command Lines
Tasks are bash-like in that they resemble bash commands, and most of the time
you should be able to just use any bash command as a task. However, CJTaskrunner is not a shell, nor does it run commands through the shell. Insteads, commands are split
into arguments before invoking them on the named executable. Variables are
substituted before the command is run, so the command will only see the resolved
value.
Glob arguments will not work because they are not expanded by CJTaskrunner and
they will not be seen by a shell. So you cannot have a task for example ls *
because the * is a bash token that expands to a list of file names before
being sent to ls.
However, if you do need to use shell-specific features, you can use the @shell directive. This should be avoided whenever possible as it couples your taskfiles to your shell. Instead we recommend either putting your shell-specific commands into a shell script, or if your needs are simple enough, use CJTaskrunner directives.
CJTaskrunner uses a small domain-specific language, so taskfiles require no
language runtime unless you use @shell in which case it depends on the operating system shell.
The language is line-based and indentation-sensitive. The official format uses two spaces per indentation level. The parser also accepts one leading tab per level when the whole file uses tabs consistently. Indented lines beneath a header form a block. Task definitions may nest one level.
Top-level entries are task definitions, @help:, @env:, or @version
headers.
Blank lines are ignored. Comment-only lines start with optional indentation
followed by # and are ignored.
Top-level entries are either:
@version <name> <value>@help:@env:<task-name>:<task-name> (<argument>, ...):
Indented entries must use full indentation levels. A file may not mix leading
spaces and leading tabs for indentation. Run cj --format to normalize
indentation to spaces.
Inline comments are not stripped. echo # hi passes # and hi as command arguments.
Taskfile Discovery
When discovering inside a directory, CJTaskrunner loads cjtasks as the base
file when it exists. It then loads existing overlays from lowest to highest
precedence:
cjtasksproduction.cjtasksstaging.cjtasksdevelopment.cjtaskslocal.cjtasks
Before validation and execution, CJTaskrunner flattens the selected files into one effective taskfile.
Task definitions from a higher precedence replace lower ones, but must have the same number of arguments. Tasks not replaced by a higher layer remain available, and cross-layer task references are valid.
The bottom file must be named cjtasks and is the base layer if it exists. This
is the only layer that can hold @version directives, and any version related
directives use the @version from the base layer.
You can overwrite a parent task alone and it will still inherit subtasks. You can overwrite subtasks as well, but due to the way they are defined, you will also need to overwrite the parent task. A syntax to extend subtasks may be added in the future.
Environment entries replace lower entries by name. Optionality (: or ?:) is overwritten as well.
The highest-precedence @help: block wins.
Tasks
Task keys are top-level lines, or nested task headings inside another task:
build:
cargo build
ext:
@desc extension tasks
build:
@desc build extension assets
cargo build
The nested build: task above is addressed as ext:build. Task definitions are
limited to one nested level.
Tasks may declare required named positional arguments:
say (NAME, PUNCTUATION):
echo hello $NAME$PUNCTUATION
Argument values become runtime variables only for the called task. Previous values with the same names are restored when the task returns. Missing, extra, or undeclared arguments are errors before task execution begins.
Task names must match:
^[A-Za-z0-9_-]+(:[A-Za-z0-9_-]+)*$
env and help are valid task names. The directives are distinguished by
their @ prefix.
Task names must not match a directory in the selected taskfile’s directory.
Task lines are non-empty indented lines under a task. They run in order.
Execution stops on first non-zero status unless a same-level @or handles it.
Execution stops with an error after 100,000 task steps as possible infinite-loop protection.
Semicolons split multiple same-level expressions on one physical line. Semicolons inside single or double quotes are preserved.
Semicolons cannot replace a newline where an indentation increase follows immediately.
run:
@set MODE prod; @if $MODE == prod
cargo build --release
A taskfile line ending in \ immediately before a line break continues on the
next physical line. The next line’s leading spaces and tabs are removed before
the lines are joined. Continuations can span multiple lines:
announce:
@echo This message is continued \
onto the next line
This produces one task expression: @echo This message is continued onto the next line.
@if accepts a truthy value, ==, and != string comparisons. Use @if-in
or @if-not-in for exact case-sensitive list membership:
compile (TARGET):
@if-in $TARGET linux macos windows
@echo supported target
Membership requires at least one candidate. Quoted values remain one word, so
@if-in $TARGET linux "mac os" windows can match mac os.
Ordinary Commands
Ordinary task lines execute directly as argv commands through the platform process API.
build:
cargo build --release
This executes as:
["cargo", "build", "--release"]
CJTaskrunner is not a shell. Ordinary commands do not interpret shell syntax:
- pipes
- redirects
- globbing
- command chaining
- shell builtins
~home-directory expansionNAME=value commandenvironment prefixes
Use @shell for other shell behavior. Backslash line continuation is handled
by the taskfile parser before ordinary commands are executed.
For command-specific environment variables, prefer exported task variables:
electron-install:
rm -rf node_modules
rm -rf $HOME/.electron-gyp/9.4.4
@export npm_config_runtime electron
@export npm_config_target 9.4.4
@export npm_config_disturl https://electronjs.org/headers
@export npm_config_arch x64
@export npm_config_force_process_config true
npm install
Or keep the shell syntax on a single @shell line:
electron-install:
rm -rf node_modules
rm -rf $HOME/.electron-gyp/9.4.4
@shell npm_config_runtime=electron npm_config_target=9.4.4 npm_config_disturl=https://electronjs.org/headers npm_config_arch=x64 npm_config_force_process_config=true npm install
Child stdin, stdout, and stderr are inherited unless a command is being captured
by @set NAME:.
Word Splitting
CJTaskrunner splits command and directive arguments with shell-like quote handling:
- Whitespace separates words outside quotes.
- Single and double quotes group text.
- Quotes are removed.
- Backslash can escape the next character.
- Unterminated quotes are errors.
This is not a full shell parser.
Interpolation
Interpolation applies to ordinary command argv tokens, @shell text, and directive arguments.
Supported forms:
$NAME
${NAME}
${NAME?}
${NAME?fallback}
${NAME?"fallback value"}
Rules:
$NAMEreads the current CJTaskrunner variable value and errors when absent.${NAME}reads the current variable value and errors when absent.${NAME?}reads the current variable value or an empty string when absent.${NAME?fallback}and${NAME?"fallback value"}use the fallback whenNAMEis absent.\$NAMEand\${NAME}escape interpolation.- Variable references are invalid in
@desctext and every line of an@help:block. Escape the dollar sign to include literal variable-shaped text there; the escape is not displayed. - Directive operands are interpolated as strings before validation, including operands that name variables.
- In ordinary commands, an interpolated value remains one argv value.
- In
@shell, interpolated values are shell-quoted before/bin/sh -c.
CJTaskrunner does not support shell-style command substitution, arithmetic expansion, pattern replacement, or nested expansion.
Variables
Variables are used inside task definitions using the dollar sign $. Tasks you run will not be aware of variables used like this because CJTaskrunner swaps them out for their values before sending arguments to the task.
Variables in CJTaskrunner are runtime values, exported, or environment variables.
Environment variables can be used the same way as runtime variables, and are also visible to child processes. For example, $PATH is an environment variable that programs read from the shell environment.
@set NAME value sets a CJTaskrunner runtime variable.
Runtime values are passed into subordinate @task and @await calls as a snapshot, but runtime variables set inside of a task do not persist to the calling task.
@set NAME: runs its indented block with stdout capture enabled, trims trailing line endings, and stores the captured text. Capture fails when the block’s final status is non-zero.
branch:
@set BRANCH:
git rev-parse --abbrev-ref HEAD
@echo $BRANCH
@export also exposes the value to child processes, making it an environment variable for processes within the taskfile execution context.
@unset removes a runtime value and export.
Runtime variables are reset after the task exits.
Variables are not allowed in @desc or @help: text.
Environment
Set multiple variables at once using an @env: block:
@env:
NAME: value
FALLBACK?: fallback value
NAME: value overrides an inherited value. NAME?: value only applies when the variable is absent.
Additionally, you can use @export to set environment variables or turn a runtime variable into an environment variable.
CJTaskrunner runs in an isolated snapshot of your environment, which we refer to as the taskfile execution context.
- Existing environment variables can be used within taskfiles.
- If you set a variable using an existing variable name in your taskfile, that new value will take precedence for the duration of the task context.
- The overridden environment variable will be restored after the task context ends unless exported.
- Variables that are exported will propagate to other tasks within the same taskfile execution context, for example when tasks call other tasks. These propagate back to a calling task as well.
- Exported variables work essentially the same way as environment variables. In case of a conflict, exported variables take precedence.
Interpolation
Variables can be used in directives or task commands. There is only simple interpolation available.
Variables are resolved before sending to commands.
Directive operands are treated as strings after interpolation; when a directive expects a variable name, the interpolated string is validated as the name.
show:
@echo $NAME
@echo "${NAME}"
@echo ${OPTIONAL?}
@echo ${PORT?5173}
@echo ${MESSAGE?"hello world"}
Rules:
$NAMEexpands to the current value and errors when absent.${NAME}expands to the current value and errors when absent.${NAME?}expands to the current value or an empty string when absent.${NAME?fallback}usesfallbackwhen the variable is missing.${NAME?"fallback value"}supports a quoted fallback.- In ordinary commands, an interpolated value remains one argv value.
- In
@shell, interpolated values are shell-quoted before/bin/sh -c.
Version Variables
@version cli 0.1.0
Version declarations create variables named VERSION_<NAME>, with the name uppercased and hyphens converted to underscores. The example above creates $VERSION_CLI. See SemVer Tools for more info.
CJTaskrunner is not a shell
CJTaskrunner is not a shell, nor does it require a shell to run.
You cannot use shell-specific syntax in your task definitions in your cjtasks files. This includes:
- “globbing” - not supported by CJTaskrunner. Wildcard matching such as using the asterisk character (
*) to indicate all the files in a directory or a question mark (?) to match a single character in a file name. - “brace expansion” or “ranges” - not supported by CJTaskrunner. Such as
a{1,2,3}or{1..3} - “subshells” - not supported by CJTaskrunner. Running commands in an isolated process by using parentheses
(cd /tmp && touch file.txt) - “command substitution” - not supported by CJTaskrunner. Running a command and injecting its output in a command or string
current_dir=$(pwd) - “inline variable assignment” - setting an environment variable for the duration of a command
NODE_ENV=production node app.js. - “pipes” - not supported.
- “redirects” - Bash features that redirect standard output using
>or<are not supported. - “command chaining” - Symbols such as
&&or||are bash features and not supported. But command chaining can be achieved using@and,@orand@if - “shell builtins” - are pretty rare in reality, but things like
local,kill,eval,testare builtins for bash. However CJTaskrunner includes its own@echo,@cd,@mkdirand@cpdirectives. ~“home-directory expansion” - This is not supported in path names.
CJTaskrunner can do some of these things using directives, however.
-
Use
@exportto change an enviroment variable before running a command -
Capture the stdout of a command using
@set:
The irony of the statement at the top of this page is that, like a shell, CJTaskrunner runs executables directly, supplying arguments. So you can always use env in your task if you really need to do the inline syntax:
mytask:
env NODE_ENV=production node app.js
CJTaskrunner can run any commands that are discrete executables. Much of what you might think of as bash syntax may actually be a program.
And finally if you REALLY need to, you can use @shell to run things in your system shell. This will void your CJTaskrunner warranty however.
Philosophy
Core principle: “never be a problem.”
With this principle in mind, these are the goals of this project…
CJTaskrunner
- Should reduce cognitive load.
- Should minimize typing without hiding behavior.
- Should work with existing tools instead of replacing them.
- Should never do anything that cannot be done without it.
- Should always be optional.
- Should have a discoverable interface.
- Should be strict in its syntax, but
- Should tell you how to fix errors when they happen.
- Should run tasks as if the user is running them directly.
Taskfiles
- Are minimal; basic syntax conveyable in one sentence.
- Are imperative; describing workflows rather than build targets.
- Are readable; syntax should be understandable to non-users.
- Are shallow; maximum nesting of one level.
- Are descriptive; descriptions are easy to add and easy to find.
- Are predictable; a taskfile’s directory is its project root.
- Are catalog-like; serve as a repository’s catalog of useful commands.
Directives
- Are limited; they do one thing.
- Are optional; they are not needed to simply run tasks.
- Are useful; they only exist where they can save time.
- Are explicit; they are the first thing on their line and never expand into values.
- Are minimal; they accept names, paths, or user-facing text instead of options.
- Are flat; they have longer names instead of options of a broader directive.
Comparisons
Task runners are often tied to a language ecosystem or designed around build artifacts. CJTaskrunner instead provides a small, project-local catalog of imperative commands.
just
- Inspired by make
- Lists recipes from command line
- Really simple to use
- Rich expression language
- Built-in functions
- Support for modules and imports
- Uses the shell for execution
- Tasks can be shell scripts
- Supports arbitrary scripting language
make
- Target-based
- Makefiles are declarative at the dependency-graph level
- Designed for long build processes
- Focused on orchestrating compiled artifacts
- Doesn’t list tasks or document them easily
- Values idempotency and deterministic results
- Arbitrary tasks are secondary
- Has a set of “conventions” and idiomatic ways of authoring files
rake
- Target-based
- Uses Ruby syntax and language constructs
- Can be used outside Ruby projects
- Lists tasks, which inspired CJTaskrunner’s summary mode
- Can be used as an imperative task runner
Apache Ant
- Target-based
- Uses a verbose XML structure
- Describes tasks with quoted XML attributes
- Lots of syntax to learn
- Lots of boilerplate and typing
- Requires Java
Vite
- Task- and target-based
- Provides built-in features and plugins for web development
- Uses JavaScript configuration
- Requires Node.js
- The holy grail of web build tools
Conclusion
These tools are all excellent. CJTaskrunner does not replace these tools. It provides a directory and launch point for them, while remaining sufficient for simple workflows on its own.