# API reference

This page is generated from the package's docstrings with [mkdocstrings](https://mkdocstrings.com/). Update the source code, not this page, when the API changes.

The CLI's user-facing interface is the `wt` command itself — see [Usage](https://wagtail.github.io/wagtail-cli/usage/index.md) for the command reference. This page covers the Python modules behind it.

## CLI

The root Typer application, the `api` command group, and the `cli()` entry point handling delegation:

### `appify(fn)`

Map WgtlError to a stderr message + exit code; forward RFC 7807 body.

Source code in `src/wagtail_cli/cli/main.py`

```python
def appify(fn: Callable[..., Any]) -> Callable[..., Any]:
    """Map WgtlError to a stderr message + exit code; forward RFC 7807 body."""

    @functools.wraps(fn)
    def wrapper(*args: Any, **kwargs: Any) -> Any:
        try:
            return fn(*args, **kwargs)
        except WgtlError as e:
            _emit_error(_context_from_call(args, kwargs), e)
            raise typer.Exit(code=e.exit_code) from e

    return wrapper
```

### `cli()`

Console entry point: route known groups/globals to Typer, delegate the rest.

Source code in `src/wagtail_cli/cli/main.py`

```python
def cli() -> None:
    """Console entry point: route known groups/globals to Typer, delegate the rest."""
    argv = sys.argv[1:]
    if argv and argv[0] == "--version":
        _print_enhanced_version()
        return
    if argv and argv[0] in ("--help", "-h"):
        _print_enhanced_help()
        return
    first = argv[0] if argv else None
    if not first or first in _KNOWN_GROUPS or first.startswith("-"):
        app()
        return
    target = resolve_delegate(argv)
    if target is None:
        typer.echo(
            "Cannot run a Django command here: no ./manage.py in the current "
            "directory and DJANGO_SETTINGS_MODULE is not set.",
            err=True,
        )
        raise SystemExit(1)
    if target[0] == sys.executable and not is_django_available():
        typer.echo(
            "Note: Django is not importable in the environment running wt, so "
            "the command may fail. If the project uses a virtualenv, activate "
            "it first, or run wt inside the project environment with "
            "`uv run --with wagtail-cli wt ...`.",
            err=True,
        )
    # `cli()` is the console entry point and runs outside Typer's runner, so
    # we exit here via SystemExit, not typer.Exit (which is only meaningful
    # inside a Typer command context).
    raise SystemExit(subprocess.call(target))  # noqa: S603  # argv is user-authored CLI args forwarded verbatim to the Django runner
```

### `emit(ctx, data)`

Render data (or a dry-run request preview) to stdout.

Source code in `src/wagtail_cli/cli/main.py`

```python
def emit(ctx: typer.Context, data: Any) -> None:
    """Render data (or a dry-run request preview) to stdout."""
    fmt = resolve_output_format(ctx)
    if isinstance(data, DryRunRequest):
        if fmt == "json":
            typer.echo(json.dumps(asdict(data), separators=(",", ":"), default=str))
            return
        lines = [f"{data.method} {data.url}"]
        if data.params:
            lines.append(f"Params: {data.params}")
        if data.body is not None:
            lines.append(json.dumps(data.body, indent=2, default=str))
        if data.file:
            lines.append(f"File: {data.file}")
        typer.echo("\n".join(lines))
        return
    typer.echo(output.render(data, fmt, select=get_cli_context(ctx).select))
```

### `get_cli_context(ctx)`

Return the root CLI context from a nested command context.

Source code in `src/wagtail_cli/cli/main.py`

```python
def get_cli_context(ctx: typer.Context) -> CliContext:
    """Return the root CLI context from a nested command context."""
    cc = ctx.find_object(CliContext)
    if cc is None:  # pragma: no cover - every command runs below the root app
        raise RuntimeError("CliContext not found in the Click context chain")
    return cc
```

### `get_client(ctx)`

Build a configured WgtlClient from global options + config cascade.

Source code in `src/wagtail_cli/cli/main.py`

```python
def get_client(ctx: typer.Context) -> WgtlClient:
    """Build a configured WgtlClient from global options + config cascade."""
    cc = get_cli_context(ctx)
    cfg = load_config(cli_url=cc.url, cli_token=cc.token)
    if not cfg.is_configured:
        raise UsageError(
            "Not configured. Run `wt api init` or set WAGTAIL_CLI_BASE_URL / "
            "WAGTAIL_CLI_TOKEN."
        )
    return WgtlClient(
        cfg.base_url,
        cfg.token,
        dry_run=cc.dry_run,
        verbose=cc.verbose,
    )
```

### `main(version=typer.Option(False, '--version', callback=_version_callback, is_eager=True, help='Show version and exit.'), url=typer.Option(None, '--url', help='API base URL (overrides config/env).'), token=typer.Option(None, '--token', help='API token (overrides config/env).'), json=typer.Option(False, '--json', help='Force JSON output.'), human=typer.Option(False, '--human', help='Force human-readable output.'), verbose=typer.Option(False, '-v', '--verbose', help='Print HTTP request/response details to stderr.'), dry_run=typer.Option(False, '--dry-run', help='Print the HTTP request that would be sent without sending it.'), select=typer.Option(None, '--select', help='Return only these response fields (comma-separated or repeatable; supports dot paths).'), ctx=typer.Context)`

CLI client for the Wagtail v3 API.

Source code in `src/wagtail_cli/cli/main.py`

```python
@app.callback()
def main(
    version: bool = typer.Option(
        False,
        "--version",
        callback=_version_callback,
        is_eager=True,
        help="Show version and exit.",
    ),
    url: str | None = typer.Option(
        None, "--url", help="API base URL (overrides config/env)."
    ),
    token: str | None = typer.Option(
        None, "--token", help="API token (overrides config/env)."
    ),
    json: bool = typer.Option(False, "--json", help="Force JSON output."),
    human: bool = typer.Option(False, "--human", help="Force human-readable output."),
    verbose: bool = typer.Option(
        False,
        "-v",
        "--verbose",
        help="Print HTTP request/response details to stderr.",
    ),
    dry_run: bool = typer.Option(
        False,
        "--dry-run",
        help="Print the HTTP request that would be sent without sending it.",
    ),
    select: list[str] | None = typer.Option(  # noqa: B008
        None,
        "--select",
        help=(
            "Return only these response fields (comma-separated or repeatable; "
            "supports dot paths)."
        ),
    ),
    ctx: typer.Context = typer.Context,
) -> None:
    """CLI client for the Wagtail v3 API."""
    fmt: str | None = None
    if json and human:
        # The root callback is not appify-wrapped, so emit a clean usage error
        # rather than raising (which would surface as an uncaught traceback).
        typer.echo("Error (2): Cannot combine --json and --human", err=True)
        raise typer.Exit(code=2)
    if json:
        fmt = "json"
    elif human:
        fmt = "human"
    ctx.obj = CliContext(
        url=url,
        token=token,
        fmt=fmt,
        verbose=verbose,
        dry_run=dry_run,
        select=tuple(select or ()),
    )
```

### `resolve_delegate(args)`

Resolve the command to run for a delegated invocation.

Returns the argv to run, or None when there is nothing to delegate to. The project's own interpreter is preferred so that an isolated install of wt (uv tool, pipx) runs manage.py with the environment holding Django.

Source code in `src/wagtail_cli/cli/main.py`

```python
def resolve_delegate(args: list[str]) -> list[str] | None:
    """Resolve the command to run for a delegated invocation.

    Returns the argv to run, or None when there is nothing to delegate to.
    The project's own interpreter is preferred so that an isolated install of
    wt (uv tool, pipx) runs manage.py with the environment holding Django.
    """
    manage_py = Path.cwd() / "manage.py"
    if manage_py.is_file():
        return [find_project_python() or sys.executable, str(manage_py), *args]
    if os.environ.get("DJANGO_SETTINGS_MODULE"):
        python = find_project_python()
        if python is not None:
            # Equivalent to django-admin, but uses the resolved interpreter
            # rather than whatever django-admin happens to be on PATH.
            return [python, "-m", "django", *args]
        return ["django-admin", *args]
    return None
```

### `resolve_output_format(ctx, local_format=None)`

Resolve a local format before falling back to global CLI options.

Source code in `src/wagtail_cli/cli/main.py`

```python
def resolve_output_format(
    ctx: typer.Context,
    local_format: str | None = None,
) -> str | None:
    """Resolve a local format before falling back to global CLI options."""
    return local_format if local_format is not None else get_cli_context(ctx).fmt
```

### `start(name=typer.Argument(..., help='Name of the application or project.'), directory=typer.Argument(None, help='Optional destination directory, created if needed.'), template=typer.Option(DEFAULT_PROJECT_TEMPLATE, '--template', help='The path or URL to load the template from.'), extension=typer.Option(['html', 'rst'], '--extension', '-e', help='File extension(s) to render (repeatable).'), name_files=typer.Option(['Dockerfile'], '--name', '-n', help='File name(s) to render (repeatable).'), exclude=typer.Option([], '--exclude', '-x', help='Directory name(s) to exclude (repeatable).'), verbosity=typer.Option(1, '-v', '--verbosity', min=0, max=3, help='Verbosity level 0-3.'), settings=typer.Option(None, '--settings', help='Python path to settings module.'), pythonpath=typer.Option(None, '--pythonpath', help='Directory to add to PYTHONPATH.'), traceback=typer.Option(False, '--traceback', help='Show full traceback on CommandError.'), no_color=typer.Option(False, '--no-color', help="Don't colorize output."), force_color=typer.Option(False, '--force-color', help='Force colorized output.'), version=typer.Option(False, '--version', help='Show Django startproject version and exit.'))`

Create a Django project directory structure (replicates `wagtail start`).

Source code in `src/wagtail_cli/cli/main.py`

```python
@app.command()
def start(
    name: str = typer.Argument(..., help="Name of the application or project."),
    directory: str | None = typer.Argument(
        None, help="Optional destination directory, created if needed."
    ),
    template: str = typer.Option(
        DEFAULT_PROJECT_TEMPLATE,
        "--template",
        help="The path or URL to load the template from.",
    ),
    extension: list[str] = typer.Option(  # noqa: B008
        ["html", "rst"],
        "--extension",
        "-e",
        help="File extension(s) to render (repeatable).",
    ),
    name_files: list[str] = typer.Option(  # noqa: B008
        ["Dockerfile"], "--name", "-n", help="File name(s) to render (repeatable)."
    ),
    exclude: list[str] = typer.Option(  # noqa: B008
        [], "--exclude", "-x", help="Directory name(s) to exclude (repeatable)."
    ),
    verbosity: int = typer.Option(
        1, "-v", "--verbosity", min=0, max=3, help="Verbosity level 0-3."
    ),
    settings: str | None = typer.Option(
        None, "--settings", help="Python path to settings module."
    ),
    pythonpath: str | None = typer.Option(
        None, "--pythonpath", help="Directory to add to PYTHONPATH."
    ),
    traceback: bool = typer.Option(
        False, "--traceback", help="Show full traceback on CommandError."
    ),
    no_color: bool = typer.Option(False, "--no-color", help="Don't colorize output."),
    force_color: bool = typer.Option(
        False, "--force-color", help="Force colorized output."
    ),
    version: bool = typer.Option(
        False, "--version", help="Show Django startproject version and exit."
    ),
) -> None:
    """Create a Django project directory structure (replicates `wagtail start`)."""
    try:
        __import__(name)
    except ImportError:
        pass
    else:
        typer.echo(
            f"'{name}' conflicts with the name of an existing Python module "
            "and cannot be used as a project name. Please try another name.",
            err=True,
        )
        raise typer.Exit(code=1)

    template_display = template
    if template == DEFAULT_PROJECT_TEMPLATE:
        template_display = "the default custom base page template"
    typer.echo(f"Creating a Wagtail project called {name} using {template_display}")

    if not shutil.which("django-admin"):
        typer.echo(
            "django-admin not found on PATH. Install Django to create a project "
            "(e.g. `pip install Django`).",
            err=True,
        )
        raise typer.Exit(code=1)

    argv = build_startproject_args(
        name,
        directory,
        template,
        extension,
        name_files,
        exclude,
        verbosity,
        settings,
        pythonpath,
        traceback,
        no_color,
        force_color,
        version,
    )
    raise SystemExit(subprocess.run(argv).returncode)  # noqa: S603  # argv is user-authored CLI args forwarded verbatim to django-admin
```

## Configuration

The configuration cascade resolving flags, environment variables, and dotfiles:

## Errors

The error hierarchy and exit-code mapping:

### `WgtlError`

Bases: `Exception`

Base CLI error. `problem` is the verbatim RFC 7807 body (or raw text).

Source code in `src/wagtail_cli/errors.py`

```python
class WgtlError(Exception):
    """Base CLI error. `problem` is the verbatim RFC 7807 body (or raw text)."""

    exit_code: int = 1

    def __init__(
        self,
        message: str,
        *,
        status_code: int | None = None,
        problem: dict | list | str | None = None,
    ) -> None:
        super().__init__(message)
        self.status_code = status_code
        self.problem = problem
```

## Output

JSON and human-readable rendering:

### `project(data, selectors)`

Project response data onto dot-separated fields.

Collection responses keep their pagination count while selectors apply to each item. This is intentionally a local projection: the v3 API does not support `fields=` projections, but agents often only need an id, title, URL, or status from a large response.

Source code in `src/wagtail_cli/output.py`

```python
def project(data: Any, selectors: list[str] | tuple[str, ...]) -> Any:
    """Project response data onto dot-separated fields.

    Collection responses keep their pagination count while selectors apply to
    each item. This is intentionally a local projection: the v3 API does not
    support ``fields=`` projections, but agents often only need an id, title,
    URL, or status from a large response.
    """
    selectors = tuple(
        part.strip() for value in selectors for part in value.split(",") if part.strip()
    )
    if not selectors:
        return data
    if isinstance(data, dict) and isinstance(data.get("items"), list):
        projected: dict[str, Any] = {
            key: data[key] for key in ("count", "next", "previous") if key in data
        }
        projected["items"] = [_project_item(item, selectors) for item in data["items"]]
        return projected
    if isinstance(data, list):
        return [_project_item(item, selectors) for item in data]
    return _project_item(data, selectors)
```

## Field parsing

`--field` value parsing, `@file` references, and page-ref resolution:

## Docs viewer

Resolving, fetching, and rendering docs.wagtail.org content for `wt docs`:

Resolve, fetch, and render docs.wagtail.org content.

Pure logic shared by the `wt docs` commands: URL resolution, Wagtail version detection, API reference parsing, and search result formatting.

### `Operation`

One `### METHOD /api/v3/.../` section of the v3 API reference.

Source code in `src/wagtail_cli/docs.py`

```python
@dataclass
class Operation:
    """One `### METHOD /api/v3/.../` section of the v3 API reference."""

    method: str
    path: str
    heading: str
    body: str

    @property
    def normalized_path(self) -> str:
        return normalize_operation_path(self.path)
```

### `detect_wagtail_version()`

Return the docs version of the locally installed Wagtail, if any.

Falls back to querying the surrounding project's interpreter, so an isolated install of wt (uv tool, pipx) still detects the project's Wagtail version.

Source code in `src/wagtail_cli/docs.py`

```python
def detect_wagtail_version() -> str | None:
    """Return the docs version of the locally installed Wagtail, if any.

    Falls back to querying the surrounding project's interpreter, so an
    isolated install of wt (uv tool, pipx) still detects the project's
    Wagtail version.
    """
    raw = None
    try:
        raw = importlib.metadata.version("wagtail")
    except importlib.metadata.PackageNotFoundError:
        python = find_project_python()
        if python is not None:
            raw = package_version(python, "wagtail")
    if raw is None:
        return None
    return normalize_wagtail_version(raw)
```

### `extract_index_section(markdown)`

Return the content under the `## Index` heading of a docs page.

Source code in `src/wagtail_cli/docs.py`

```python
def extract_index_section(markdown: str) -> str:
    """Return the content under the `## Index` heading of a docs page."""
    lines = markdown.splitlines()
    start = None
    for i, line in enumerate(lines):
        if line.strip() == "## Index":
            start = i + 1
            break
    if start is None:
        raise ValueError("No '## Index' section found in the docs index page.")
    end = len(lines)
    for j in range(start, len(lines)):
        if lines[j].startswith("## "):
            end = j
            break
    return "\n".join(lines[start:end]).strip()
```

### `extract_outline(markdown)`

Render a docs page's Markdown headings as an indented outline.

Scans lines for ATX headings (same line-based detection as `parse_operations`), skipping fenced code blocks, and indents each heading by its level.

Source code in `src/wagtail_cli/docs.py`

```python
def extract_outline(markdown: str) -> str:
    """Render a docs page's Markdown headings as an indented outline.

    Scans lines for ATX headings (same line-based detection as
    `parse_operations`), skipping fenced code blocks, and indents each
    heading by its level.
    """
    lines = []
    in_fence = False
    for line in markdown.splitlines():
        if match := _FENCE_RE.match(line):
            in_fence = not in_fence
            continue
        if in_fence or not (match := _HEADING_RE.match(line)):
            continue
        level = len(match.group(1))
        lines.append(f"{'  ' * (level - 1)}{match.group(2)}")
    return "\n".join(lines)
```

### `find_operations(operations, query)`

Match a query like `GET /api/v3/documents/` against parsed operations.

Returns (exact_matches, similar_matches). The query's leading HTTP method (case-insensitive, optional) narrows the match; the API version prefix is optional and `/api/v3/`, `/api/v3-preview/`, and `/cms-api/v3/` all normalize to the same resource suffix.

Source code in `src/wagtail_cli/docs.py`

```python
def find_operations(
    operations: list[Operation],
    query: str,
) -> tuple[list[Operation], list[Operation]]:
    """Match a query like `GET /api/v3/documents/` against parsed operations.

    Returns (exact_matches, similar_matches). The query's leading HTTP method
    (case-insensitive, optional) narrows the match; the API version prefix is
    optional and `/api/v3/`, `/api/v3-preview/`, and `/cms-api/v3/` all
    normalize to the same resource suffix.
    """
    tokens = query.split()
    method = None
    if tokens and tokens[0].upper() in _HTTP_METHODS:
        method = tokens.pop(0).upper()
    elif len(tokens) > 1 and tokens[-1].upper() in _HTTP_METHODS:
        method = tokens.pop().upper()
    query_path = normalize_operation_path(" ".join(tokens))

    exact = []
    similar = []
    for op in operations:
        method_matches = method is None or method == op.method
        if not method_matches:
            continue
        if query_path == op.normalized_path:
            exact.append(op)
        elif query_path and _path_segments_match(query_path, op.normalized_path):
            similar.append(op)
    return exact, similar
```

### `format_search_results(payload)`

Render a search API response as a concise numbered list.

Source code in `src/wagtail_cli/docs.py`

```python
def format_search_results(payload: Mapping) -> str:
    """Render a search API response as a concise numbered list."""
    results = payload.get("results", [])
    count = payload.get("count", len(results))
    query = payload.get("query", "")
    if not results:
        return f"No results for {query!r}."
    lines = [f"{count} result{'s' if count != 1 else ''} for {query!r}:", ""]
    for i, result in enumerate(results, start=1):
        lines.append(f"{i}. {result.get('title', '(untitled)')}")
        lines.append(f"   {result.get('path', '')}")
        snippet = _first_snippet(result.get("blocks", []))
        if snippet:
            lines.append(f"   {snippet}")
    return "\n".join(lines)
```

### `normalize_operation_path(path)`

Reduce an API path to its resource suffix, without version prefixes.

Source code in `src/wagtail_cli/docs.py`

```python
def normalize_operation_path(path: str) -> str:
    """Reduce an API path to its resource suffix, without version prefixes."""
    normalized = path.strip().strip("/")
    while True:
        stripped = _API_PREFIX_RE.sub("", normalized)
        if stripped == normalized:
            break
        normalized = stripped
    return normalized.strip("/")
```

### `normalize_wagtail_version(raw)`

Reduce a package version (8.0.1, 8.0b1) to its docs version (8.0).

Source code in `src/wagtail_cli/docs.py`

```python
def normalize_wagtail_version(raw: str) -> str | None:
    """Reduce a package version (8.0.1, 8.0b1) to its docs version (8.0)."""
    match = _WAGTAIL_VERSION_RE.match(raw)
    return match.group(1) if match else None
```

### `parse_operations(markdown)`

Split the API reference Markdown into per-operation sections.

Source code in `src/wagtail_cli/docs.py`

```python
def parse_operations(markdown: str) -> list[Operation]:
    """Split the API reference Markdown into per-operation sections."""
    lines = markdown.splitlines()
    headings = [
        (i, match.group(1), match.group(2))
        for i, line in enumerate(lines)
        if (match := _OPERATION_HEADING_RE.match(line))
    ]
    operations = []
    for n, (line_no, method, path) in enumerate(headings):
        body_end = headings[n + 1][0] if n + 1 < len(headings) else len(lines)
        body = "\n".join(lines[line_no + 1 : body_end]).strip()
        operations.append(
            Operation(method=method, path=path, heading=f"{method} {path}", body=body)
        )
    return operations
```

### `resolve_docs_url(cli_url=None, environ=None)`

Resolve the docs site root: CLI flag > WAGTAIL_CLI_DOCS_URL > default.

Source code in `src/wagtail_cli/docs.py`

```python
def resolve_docs_url(
    cli_url: str | None = None,
    environ: Mapping[str, str] | None = None,
) -> str:
    """Resolve the docs site root: CLI flag > WAGTAIL_CLI_DOCS_URL > default."""
    if cli_url:
        return cli_url.rstrip("/")
    if environ is None:
        import os

        environ = os.environ
    env_url = environ.get(DOCS_URL_ENV_VAR)
    if env_url:
        return env_url.rstrip("/")
    return DEFAULT_DOCS_URL
```

### `resolve_page_url(docs_url, language, version, path)`

Resolve a user-provided docs path or URL to a Markdown page URL.

Accepts full URLs (any host, e.g. PR preview builds), paths starting with a language or version segment, and bare page paths which get the resolved version inserted. A missing .html extension is appended, and the .html.md Markdown variant is fetched.

Source code in `src/wagtail_cli/docs.py`

```python
def resolve_page_url(docs_url: str, language: str, version: str, path: str) -> str:
    """Resolve a user-provided docs path or URL to a Markdown page URL.

    Accepts full URLs (any host, e.g. PR preview builds), paths starting with
    a language or version segment, and bare page paths which get the resolved
    version inserted. A missing .html extension is appended, and the
    .html.md Markdown variant is fetched.
    """
    docs_url = docs_url.rstrip("/")

    if path.startswith(("http://", "https://")):
        url, _, _fragment = path.partition("#")
        parsed = urlsplit(url)
        if parsed.path in ("", "/"):
            # Scheme + host only (e.g. a PR build root): treat as a docs base.
            root = f"{parsed.scheme}://{parsed.netloc}"
            return resolve_page_url(root, language, version, "")
        return _to_markdown_url(url)

    path, _, _fragment = path.partition("#")
    segments = [segment for segment in path.strip().strip("/").split("/") if segment]
    if not segments:
        segments = ["index.html"]

    if (
        segments[0] == language
        and len(segments) > 1
        and _VERSION_SEGMENT_RE.match(segments[1])
    ):
        version, page_segments = segments[1], segments[2:]
    elif _VERSION_SEGMENT_RE.match(segments[0]):
        version, page_segments = segments[0], segments[1:]
    else:
        page_segments = segments

    if not page_segments:
        page_segments = ["index.html"]
    page = "/".join(page_segments)
    if page.endswith(".md"):
        return f"{docs_url}/{language}/{version}/{page}"
    if not page.endswith(".html"):
        page += ".html"
    return f"{docs_url}/{language}/{version}/{page}.md"
```

### `resolve_version(explicit=None, installed=_unset)`

Resolve the docs version to use: --version > local Wagtail > stable.

Source code in `src/wagtail_cli/docs.py`

```python
def resolve_version(
    explicit: str | None = None,
    installed: str | None | object = _unset,
) -> str:
    """Resolve the docs version to use: --version > local Wagtail > stable."""
    if explicit:
        return explicit
    if installed is _unset:
        installed = detect_wagtail_version()
    return installed if isinstance(installed, str) else "stable"
```

### `search_payload_to_json(payload)`

Serialize a search API response for --json output.

Source code in `src/wagtail_cli/docs.py`

```python
def search_payload_to_json(payload: Mapping) -> str:
    """Serialize a search API response for --json output."""
    return json.dumps(payload, indent=2)
```

## Transport

The HTTP client shared by all resources, with auth, error mapping, `--dry-run` and `-v` support:
