Skip to content

Adding a command ​

The CLI discovers its commands from a registry, so adding one is small and local.

The contract ​

python
# cli/the_loop/commands/hello.py
from __future__ import annotations

import argparse

from .base import Command, register


@register
class HelloCommand(Command):
    name = "hello"
    help = "Say hello (and show the shape of a command)"

    def add_arguments(self, parser: argparse.ArgumentParser) -> None:
        parser.add_argument("--name", default="world")

    def run(self, args: argparse.Namespace) -> int:
        print(f"hello, {args.name}")
        return 0

Then import it for its registration side effect:

python
# cli/the_loop/commands/__init__.py
from . import hello  # noqa: F401,E402

That is all. the-loop --help picks it up, and commands are listed sorted by name for stable help output.

MemberPurpose
nameThe subcommand. Must be non-empty and unique — a duplicate raises at import.
helpOne line, shown in the-loop --help.
add_arguments(parser)Register flags, or nested subparsers for a multi-action command.
run(args) -> intDo the work. Return a process exit code; 0 is success.

Conventions worth following ​

  • Exit codes. 0 success, 1 ran-but-negative, 2 could-not-run. Consistency is what makes the CLI scriptable — see exit codes.
  • Which config? Ask who reads the setting. The CLI reads exactly one file, the operator's CLI config — ingress, routing, hosting, logging, the spec directory, the critics, the graph hooks. A project's harness config is the agent's: how work is done there, read in every session and never by the CLI (decision-123). A command that needs something a repository knows takes it as a flag the agent passes (--spec-dir, --glob, --doc). The split is decision-032.
  • Never open .the-loop/harness-config.yaml from the CLI. There is no reader module any more, by design; a command that wants a repository's value has the agent hand it over.
  • Compute path defaults inside add_arguments, not at import. --config is resolved just before add_arguments runs, so a default computed at import time would ignore it.
  • Emit events. If the command makes decisions worth explaining later, call eventlog.configure_from_file("<name>") and append to the event log. Add each new type to the catalog — a unit test enforces that the emitted types and the-loop events --types agree.
  • Stay stdlib. PyYAML is the one runtime dependency (decision-038). Anything more needs justifying in the work item's design.md.

And write its page ​

A registered command with no page under docs/cli/commands/ fails the test suite, in both directions — an orphaned page for a command that no longer exists fails it too.

That is deliberate. Before this, three commands shipped and were never documented, because a single flat cli/README.md had nowhere to put them. The rule now is mechanical rather than remembered:

bash
uv run --project cli python -m pytest cli/tests/test_docs_parity.py

Add docs/cli/commands/<name>.md and list it in the commands table. The same test guards the CLI config options against .the-loop/cli-config.schema.json, so a new config key needs a documented option too.

Tests ​

bash
make test        # from the repository root

Integration tests carry a Gherkin docstring naming their scenario — see scenarios and the testing reference.

See also ​

  • Adding a hook — the other extension point: a hook of your repository's own, run at a boundary the-loop's process graph already declares.
  • Commands — what exists today.
  • cli — the capability doc, and the CLI's current behaviour.

Released under the MIT License.