Skip to main content

Documentation Index

Fetch the complete documentation index at: https://docs.aspect.build/llms.txt

Use this file to discover all available pages before exploring further.

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:
% aspect help
Aspect's programmable task runner built on top of Bazel
{ Correct, Fast, Usable } -- Choose three

Usage: aspect [OPTIONS] [TASK|GROUP|COMMAND]

Tasks:
  build     build task defined in @aspect//build.axl
  delivery  Build and deliver binary targets. Targets are built with stamping and delivered exactly once per commit unless forced; change detection skips targets whose outputs haven't changed since the last delivery.
  format    format task defined in @aspect//format.axl
  gazelle   gazelle task defined in @aspect//gazelle.axl
  lint      lint task defined in @aspect//lint.axl
  run       Build a target with bazel and run the resulting binary.
  test      test task defined in @aspect//test.axl

Task Groups:
  auth    auth task group
  axl     axl task group
  github  github task group

Commands:
  version  Print version
  help     Print this message or the help of the given subcommand(s)

Options:
  -v, --version         Print version
      --task-key <KEY>  A short key identifying this task invocation. Allowed characters: A-Za-z0-9, _, -. Useful when the same task runs multiple times in one pipeline (e.g. 'backend', 'frontend'). Auto-generated if not set.
      --task-id <UUID>  A UUID uniquely identifying this task invocation. Auto-generated if not set.
      --timing <LEVEL>  Verbosity of the phase-timing breakdown trailing the task completion line: 'total' (no breakdown total only), 'summary' (inline phases), or 'detailed' (multi-line with descriptions; default). Tasks that don't opt into phases see only the total regardless of this setting. [default: detailed]
  -h, --help            Print help
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:
.
├── .aspect/
│   ├── config.axl
│   ├── version.axl
│   └── mycmd.axl      # 'mycmd' task, available everywhere
├── app1/
│   └── .aspect/
│       └── subcmd.axl # 'subcmd' task, scoped to app1
├── MODULE.aspect
└── MODULE.bazel
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
load("@aspect//format.axl", "format")

buildifier = format.alias(
    defaults = {"formatter_target": "@buildifier_prebuilt//buildifier", "run_in_cwd": True},
    summary = "Format Starlark files using buildifier.",
)

def config(ctx: ConfigContext):
    ctx.tasks.add(buildifier)
After this runs, aspect buildifier is a real CLI command alongside the built-ins. See aspect buildifier for the complete alias.

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:
    mkdir .aspect
    
  2. Create a file named mycmd.axl within the .aspect directory:
    touch .aspect/mycmd.axl
    
  3. Add the following Starlark code to mycmd.axl:
    def impl(ctx: TaskContext) -> int:
        events = bazel.build_events.iterator()
        build = ctx.bazel.build(
            build_events = [events],
            *ctx.args.target_pattern,
        )
    
        for event in events:
            if event.kind == "named_set_of_files":
                for f in event.payload.files:
                    ctx.std.io.stdout.write("Built {}\n".format(f.name))
    
        return build.wait().code
    
    mycmd = task(
        implementation = impl,
        args = {
            "target_pattern": args.positional(default = ["..."]),
        },
    )
    
  4. Verify the new task appears:
    % aspect help
    Usage: aspect <TASK>
    
    Tasks:
      mycmd      mycmd task defined in .aspect/mycmd.axl
      ...
      help       Print this message or the help of the given subcommand(s)
    
  5. Test the new AXL task by running:
    aspect mycmd //...
    
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 → config.axl overrides → CLI flags (CLI wins).
ConstructorCLI formType in ctx.args
args.string(default="")--flag=valuestr
args.boolean(default=False)--flag / --flag=falsebool
args.int(default=0)--flag=42int
args.string_list(default=[])--flag=a --flag=blist[str]
args.positional(default=[])trailing positional wordslist[str]
args.custom(type, default=None)config.axl only — not exposed on CLItype
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:
def _impl(ctx: TaskContext) -> int:
    for target in ctx.args.targets:
        result = (ctx.std.process.command("codegen")
            .args(["--lang", ctx.args.lang, "--", target])
            .spawn()
            .wait())
        if not result.success:
            return result.code
    return 0

gen = task(
    implementation = _impl,
    summary = "Run the code generator on Bazel targets.",
    args = {
        "lang": args.string(
            default = "go",
            values = ["go", "typescript", "python"],
            description = "Output language.",
        ),
        "verbose": args.boolean(default = False, short = "v"),
        "targets": args.positional(default = ["//..."], maximum = 512),
    },
)
aspect gen --lang=typescript //services/...
aspect gen -v //...
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.
result = (ctx.std.process.command("gofmt")
    .args(["-w", "."])
    .spawn()
    .wait())

if not result.success:
    return result.code
Capture stdout with .stdout("piped"):
result = (ctx.std.process.command("git")
    .args(["log", "--oneline", "-5"])
    .stdout("piped")
    .spawn()
    .wait_with_output())

ctx.std.io.stdout.write(result.stdout)
return result.status.code
.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:
root = ctx.std.env.aspect_root_dir()

if ctx.std.fs.exists(root + "/codegen.json"):
    config = ctx.std.fs.read_to_string(root + "/codegen.json")

ctx.std.fs.write("/tmp/output.txt", "done\n")
Environmentctx.std.env inspects the runtime environment:
MethodReturns
ctx.std.env.var("NAME")Env var value, empty string if unset
ctx.std.env.current_dir()Current working directory
ctx.std.env.aspect_root_dir()Workspace root (where MODULE.bazel lives)
ctx.std.env.git_root_dir()Git repository root
ctx.std.env.temp_dir()System temp directory
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):
is_ci = bool(ctx.std.env.var("CI"))
if is_ci:
    # CI-only behaviour

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.
def _impl(ctx: TaskContext) -> int | TaskConclusion:
    status = ctx.bazel.build("//...").wait()
    if status.code != 0:
        return TaskConclusion(
            exit_code = status.code,
            text = "Build failed. Run 'aspect build //...' locally to reproduce.",
        )
    return 0

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.
def _impl(ctx: TaskContext) -> int:
    log = ctx.std.fs.create("/tmp/build.log")
    ctx.defer(log.flush)  # flushed even if impl exits early

    build = ctx.bazel.build("//...", stdout=log)
    return build.wait().code
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:
def _impl(ctx: TaskContext) -> int:
    targets = (ctx.bazel.query()
        .raw("kind('container_push rule', //services/...)")
        .eval())

    for target in targets:
        ctx.std.io.stdout.write(target.name + "\n")
    return 0
Any Bazel query expression works — deps(), rdeps(), kind(), attr(), somepath(), set operations, and so on:
results = ctx.bazel.query().raw("deps(//myapp:main) intersect kind('go_library rule', //...)").eval()
For Bazel server information, ctx.bazel.info() returns a dict of every key Bazel exposes (output_base, execution_root, workspace, release, etc.):
info = ctx.bazel.info()
log_dir = info["output_base"] + "/external"

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:
def _impl(ctx: TaskContext) -> int:
    ctx.std.io.stdout.write("Run {}\n".format(ctx.task.id))

    ctx.task.phase("build", description = "Building deliverables")
    build_status = ctx.bazel.build("//...").wait()
    if build_status.code != 0:
        return build_status.code

    ctx.task.phase("test", description = "Running tests")
    return ctx.bazel.test("//...").wait().code
Available fields:
FieldDescription
ctx.task.nameTask name (e.g. "build", "my-cmd")
ctx.task.keyHuman-readable invocation key (e.g. "fluffy-parakeet")
ctx.task.idUUID v4 unique per invocation
ctx.task.elapsed_msWall time in milliseconds since the task spawned
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.