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:
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 anaspect <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:
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
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:
- The project root’s
.aspect/config.axl. - Any nested
.aspect/config.axlfiles walking down from the project root toward the current working directory. - The user-global
~/.aspect/config.axl, if the file exists. - 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 thetask() 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:
-
Create a
.aspectdirectory at the root of your project: -
Create a file named
mycmd.axlwithin the.aspectdirectory: -
Add the following Starlark code to
mycmd.axl: -
Verify the new task appears:
-
Test the new AXL task by running:
Task arguments
Theargs 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:
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_cmd → my-cmd.
Running processes
Usectx.std.process.command(binary) to run any tool. .spawn() is non-blocking and returns a handle; .wait() blocks until the process exits.
.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
Filesystem —ctx.std.fs reads and writes files:
ctx.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 anint 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.
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:
deps(), rdeps(), kind(), attr(), somepath(), set operations, and so on:
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:
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.
