> ## Documentation Index
> Fetch the complete documentation index at: https://site.aspect.build/llms.txt
> Use this file to discover all available pages before exploring further.

# How to run and define tasks

> Beginner's guide to running built-in Aspect CLI tasks and writing custom Bazel task extensions in AXL, covering discovery and a simple example.

The Aspect CLI ships with built-in tasks (`build`, `test`, `format`, `lint`, etc.) and lets you define your own in [AXL](/docs/cli/overview#aspect-extension-language). This page covers the essentials: running tasks, understanding how the CLI discovers extensions, and writing a simple custom task.

After [installing](../install), run `aspect help` to see what's available:

```shell theme={null}
% 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:name <NAME>                   A short name uniquely 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'). Defaults to '<kind>-<suffix>' if not set.
      --task:friendly-name <FRIENDLY_NAME> A human-readable label for this task invocation, shown on status surfaces. Defaults to --task:name.
      --task:id <UUID>                     A UUID uniquely identifying this task invocation. Auto-generated if not set.
      --task:timing-summary <LEVEL>        Verbosity of the timing summary trailing the task completion line: 'none' (no timing summary), 'total' (total only), 'short' (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:

```plaintext theme={null}
.
├── .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.

<Note>
  `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](#3-programmatic-registration-in-configaxl) and [version pinning](/docs/cli/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](#3-programmatic-registration-in-configaxl) below.
</Note>

### 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](/docs/cli/guides/module) 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(...)`:

```python title=".aspect/config.axl" theme={null}
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](/docs/cli/tasks/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:

   ```shell theme={null}
   mkdir .aspect
   ```

2. Create a file named `mycmd.axl` within the `.aspect` directory:

   ```shell theme={null}
   touch .aspect/mycmd.axl
   ```

3. Add the following Starlark code to `mycmd.axl`:

   ```python theme={null}
   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:

   ```shell theme={null}
   % 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:

   ```shell theme={null}
   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).

| Constructor                       | CLI form                             | Type in `ctx.args` |
| --------------------------------- | ------------------------------------ | ------------------ |
| `args.string(default="")`         | `--flag=value`                       | `str`              |
| `args.boolean(default=False)`     | `--flag` / `--flag=false`            | `bool`             |
| `args.int(default=0)`             | `--flag=42`                          | `int`              |
| `args.string_list(default=[])`    | `--flag=a --flag=b`                  | `list[str]`        |
| `args.positional(default=[])`     | trailing positional words            | `list[str]`        |
| `args.custom(type, default=None)` | config.axl only — not exposed on CLI | `type`             |

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:

```python theme={null}
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),
    },
)
```

```shell theme={null}
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_cmd` → `my-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.

```python theme={null}
result = (ctx.std.process.command("gofmt")
    .args(["-w", "."])
    .spawn()
    .wait())

if not result.success:
    return result.code
```

Capture stdout with `.stdout("piped")`:

```python theme={null}
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

**Filesystem** — `ctx.std.fs` reads and writes files:

```python theme={null}
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")
```

**Environment** — `ctx.std.env` inspects the runtime environment:

| Method                          | Returns                                     |
| ------------------------------- | ------------------------------------------- |
| `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):

```python theme={null}
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.

```python theme={null}
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`.

```python theme={null}
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`:

```python theme={null}
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:

```python theme={null}
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.):

```python theme={null}
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:

```python theme={null}
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:

| Field                    | Description                                                                                                                             |
| ------------------------ | --------------------------------------------------------------------------------------------------------------------------------------- |
| `ctx.task.kind`          | Task kind — the command being run (e.g. `"build"`, `"test"`, `"my-cmd"`)                                                                |
| `ctx.task.friendly_kind` | Human-readable label for the kind, from `task(friendly_kind=...)` (defaults to a Title-Cased kind, e.g. `"Build"`)                      |
| `ctx.task.name`          | Unique name for this invocation, from `--task:name` (auto-generated when unset: `<kind>-<ci-job>` on CI, else `<kind>-<random-suffix>`) |
| `ctx.task.friendly_name` | Human-readable label for the invocation, from `--task:friendly-name` (defaults to the name)                                             |
| `ctx.task.id`            | UUID v4 unique per invocation                                                                                                           |
| `ctx.task.elapsed_ms`    | Wall 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.
