Skip to main content
The Aspect CLI ships with built-in tasks (build, test, format, lint, etc.) and lets you define your own in AXL. This page covers the essentials: running tasks, understanding how the CLI discovers extensions, and writing a simple custom task. After installing, run aspect help to see what’s available:
Built-in tasks like build and test are loaded from the @aspect extension library. Custom tasks you define locally appear alongside them.

How tasks are registered

A task becomes an aspect <name> CLI command through one of three paths.

1. Auto-discovery in .aspect/

The CLI scans every .axl file inside an .aspect/ directory and registers any task(...) it finds at the module’s top level. This is the happy path for project-local tasks. The scan starts in your current working directory and walks up to the workspace root, so subdirectories can scope tasks to themselves:
From inside app1, the CLI loads tasks from app1/.aspect/, then walks up and loads tasks from the root .aspect/. Both mycmd and subcmd are visible. From the repo root, only mycmd is.
config.axl and version.axl are reserved filenames inside .aspect/. The task auto-discovery scan skips them — they’re loaded for their own purpose (CLI / task configuration and version pinning, respectively). Tasks defined directly in those files won’t appear as CLI commands; use a separately-named .axl file or the programmatic path below.

2. External AXL modules via MODULE.aspect

Tasks shipped by an external AXL module are pulled in by declaring the module in MODULE.aspect at the repo root. With auto_use_tasks = True, every task the module exports becomes a CLI command without a load() in your own files; otherwise you load() the symbols you want. See How to use external AXL modules for the full mechanics.

3. Programmatic registration in config.axl

config.axl itself isn’t scanned for top-level tasks, but it can register them programmatically — useful when the task value comes from a helper or an alias rather than a static task(...) literal. The canonical example is format.alias(), which produces a task value you register with ctx.tasks.add(...):
.aspect/config.axl
After this runs, aspect buildifier is a real CLI command alongside the built-ins. See aspect buildifier for the complete alias.

Config file precedence

config.axl files are evaluated in a fixed order. Later files override values set by earlier ones, and an explicit CLI flag always beats any config value:
  1. The project root’s .aspect/config.axl.
  2. Any nested .aspect/config.axl files walking down from the project root toward the current working directory.
  3. The user-global ~/.aspect/config.axl, if the file exists.
  4. Explicit CLI flags (--flag=value).

User-global ~/.aspect/config.axl

Use ~/.aspect/config.axl for per-machine, per-user preferences that apply across every Aspect project. Common cases: defaulting --config=debug, opting a workstation into an internal build cache, or setting a personal task:friendly-name. It loads after every project config, so your overrides win over repo defaults. To force a value regardless of any config, pass it as a CLI flag. The file is optional. If it doesn’t exist, discovery is unchanged. If a project’s root happens to be your home directory, the file is loaded once — it isn’t evaluated twice. ~/.aspect/config.axl evaluates in its own module scope rooted at ~/.aspect, independent of the project. Both relative (./helpers.axl) and module-rooted (helpers.axl) load() statements resolve inside ~/.aspect. Any load() that would escape that directory fails with a confinement error. Split helpers into sibling files under ~/.aspect/ and load them from config.axl:
~/.aspect/config.axl

Write your first extension

Custom tasks are Starlark functions registered with the task() built-in. Here’s a minimal example that builds targets and prints the output file paths: Follow these steps to create a custom mycmd task that wraps Bazel’s build functionality:
  1. Create a .aspect directory at the root of your project:
  2. Create a file named mycmd.axl within the .aspect directory:
  3. Add the following Starlark code to mycmd.axl:
  4. Verify the new task appears:
  5. Test the new AXL task by running:
This task builds all targets and prints the output file paths reported by Bazel’s Build Event Stream.

Task arguments

The args dict in task() maps argument names to arg specs. Argument names become kebab-case CLI flags (output_dir--output-dir). Values merge from defaults → project config.axl overrides → user-global ~/.aspect/config.axl overrides → CLI flags, with CLI winning. See Config file precedence for the full order. Use values=["a","b","c"] on args.string to restrict to a fixed set — the CLI validates the input and shows choices in --help. Any scalar arg accepts short="x" for a single-character alias (e.g., -v). Here’s a task that generates code from Bazel targets using typed arguments:
The task() built-in also accepts summary (one-liner in aspect help), description (extended --help text), and name (override the command name derived from the variable name). Tasks are named from their variable name converted to kebab-case: my_cmdmy-cmd.

Running processes

Use ctx.std.process.command(binary) to run any tool. .spawn() is non-blocking and returns a handle; .wait() blocks until the process exits.
Capture stdout with .stdout("piped"):
.current_dir(path) sets the working directory. .env(name, value) injects an environment variable. Methods chain fluently — each returns the same Command.

File and environment access

Filesystemctx.std.fs reads and writes files:
Environmentctx.std.env inspects the runtime environment: A common pattern is branching on whether the task is running in CI — ctx.std.env.var() works the same in both TaskContext (task implementations) and ConfigContext (config.axl):

Rich return values

Tasks return an int exit code or a TaskConclusion for a message alongside the exit code. The message appears in the terminal and in CI platform annotations.

Cleanup with defer

ctx.defer(callable, *args) registers a cleanup function that runs after the task returns, in LIFO order, even on failure or cancellation. Errors in deferred calls are logged but do not change the exit code. The pattern is borrowed from Go’s defer.
Use ctx.defer for any resource that must be cleaned up regardless of how the task exits: file handles, temp files, running subprocesses.

Querying the build graph

ctx.bazel.query() exposes Bazel’s query engine. Set an expression with .raw(), then .eval() to get back an iterable TargetSet:
Any Bazel query expression works — deps(), rdeps(), kind(), attr(), somepath(), set operations, and so on:
For Bazel server information, ctx.bazel.info() returns a dict of every key Bazel exposes (output_base, execution_root, workspace, release, etc.):

Task identity and phases

ctx.task describes the currently-running task. Use it to log identifiers that show up in CI annotations, or to break long-running work into named phases that surface in status displays:
Available fields: ctx.task.phase(name, description="", emoji="", display_name="") marks the start of a new phase. The currently-active phase closes automatically when the next one starts or when the task returns.