Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Introduction

CJTaskrunner logo CJTaskrunner wordmark
Independent task wrangler
license version

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 cj executable.
  • 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.

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 @cd persist 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

@export can 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 @set are 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 @export on 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.

See Flow Control Directives.

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

Official 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.

Runtime variable

A variable whose value can be read only by CJTaskrunner inside of a task.

  • Set in a task using the @set directive.
  • 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.

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 @task and @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.

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 status 0.
  • @fail - return status 1.
  • @stop - print an optional message and return status 1.

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:

  • @await is 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 @await directive.
  • @await without a block returns awaited task status directly.
  • @await with 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: value overrides an inherited value.
  • NAME?: value supplies 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 alpha sets the version prerelease to alpha.
  • @pre app alpha. sets the prerelease to alpha.0, or increments the trailing number when the current prerelease is already alpha.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 path changes the current working directory.
  • Relative @cd paths resolve from the current working directory.

@back

build-docs:
  @cd docs
  npm run build
  @back
  • @back undoes one @cd in the current scope.
  • @back does 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:

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:

  1. cjtasks
  2. production.cjtasks
  3. staging.cjtasks
  4. development.cjtasks
  5. local.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 expansion
  • NAME=value command environment 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:

  • $NAME reads 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 when NAME is absent.
  • \$NAME and \${NAME} escape interpolation.
  • Variable references are invalid in @desc text 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.

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:

  • $NAME expands 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} uses fallback when 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, @or and @if
  • “shell builtins” - are pretty rare in reality, but things like local, kill, eval, test are builtins for bash. However CJTaskrunner includes its own @echo, @cd, @mkdir and @cp directives.
  • ~ “home-directory expansion” - This is not supported in path names.

CJTaskrunner can do some of these things using directives, however.

  • Use @export to 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.