build, test, format, lint, etc.) and lets you define your own in AXL.
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.
The root help carries only --version and --help. The --task:* flags every task accepts live in a Task Options: block on the task’s own help — aspect build --help.
How tasks are registered
A task becomes anaspect <name> CLI command in one of these ways.
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. 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 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, which is 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, not 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. To create a mycmd task that builds targets and prints each output file path:
-
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:
my_cmd → my-cmd. The task() built-in also accepts summary (one-liner in aspect help), description (extended --help text), kind (overrides the derived name, and sets the task kind that status surfaces and CI task names key on), and group (nests the command — group = ["tools"] gives aspect tools my-cmd).
Always set summary
summary is strongly recommended on every task() and is slated to become required in a future release. A task without one renders in aspect --help as the placeholder "<kind> task defined in <file>". That’s the level at which readers choose a task, so an undocumented task is effectively invisible.
To make that gap visible today, aspect prints a deprecation warning on stderr for each first-party task defined without a summary, naming the kind and defining file:
@mod//…) don’t warn, since they’re the module author’s to fix, not yours. Silence the warning by adding a one-line summary to each task(...) call it names.
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: 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.
text is the short suffix on the closing bookend. For a refusal whose explanation is the whole story, pass message instead: it prints on its own line before the bookend, as an ERROR: line for a non-zero exit code, with no traceback.
return can’t reach the task, ctx.std.process.exit(code, message) ends the task the same way. So does raising an error whose type was declared with traceback = False, which exits with code 1 (see Errors and error handling). Keep a plain fail("...") for bugs, where the traceback is what you want.
Errors and error handling
An error in AXL is a value.error("message") builds one, and error.type(...) derives a new error type with its own fields. Every error has a message, a cause (the error that led to it, or None), and a stacktrace.
Declare error types at module top level. A type takes its name from the variable it is first assigned to, and that name appears in repr(e) and tracebacks. Fields take a type, or field(type, default = ...) for an optional field:
isinstance(e, DeployError) is true for a DeployTimeout. isinstance(e, error) is true for every error.
Raising. fail(e) raises the error value itself, keeping its type, fields, cause, and stacktrace:
traceback setting decides how an escaping error renders. With traceback = True (the default), Aspect CLI prints the traceback and TypeName: message. Use it for failures someone has to debug. With traceback = False, Aspect CLI prints only the message as an ERROR: line and exits with code 1, as ctx.std.process.exit(1, message) would. Use it for expected refusals, like a missing login. A derived type inherits its parent’s setting unless it sets its own.
Catching. catch turns a failure into a value in one of two forms. Both return an (err, value) pair: (None, value) on success, (err, None) on failure. Test if err: before using value.
- Name the types you expect.
fut.catch(DeployError)andcatch(fn, types = [DeployError])catch only those types and their subtypes. Any other failure still raises. A barecatch()catches every failure, including bugs raised with a plainfail. - Re-raise with
fail(err). A caught error keeps its type and fields. - Exits are not caught.
ctx.std.process.exitinside the caught code still ends the task. A raised error withtraceback = Falseis caught like any other error. - Failures that are not error values arrive as a plain
error, with the failure text asmessage. fut.catch()must be the last call beforeblock(), and you can call it only once.
Future[HttpResponse]. So catch().block() returns tuple[error | None, HttpResponse | None]. See Future.catch and error for the full API.
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. A deferred call that fails is reported as a WARNING: line and doesn’t 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. When the cleanup or follow-up depends on how the task ended, register a post-task hook instead; see Run code before and after a task.
Querying the build graph
ctx.bazel.query(expr) runs bazel query and returns an iterable of targets that supports len() and indexing:
deps(), rdeps(), kind(), attr(), somepath(), set operations, and so on. Pass flags = [...] for extra query flags:
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.
