Adding a command
The CLI discovers its commands from a registry, so adding one is small and local.
The contract
# 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 0Then import it for its registration side effect:
# cli/the_loop/commands/__init__.py
from . import hello # noqa: F401,E402That is all. the-loop --help picks it up, and commands are listed sorted by name for stable help output.
| Member | Purpose |
|---|---|
name | The subcommand. Must be non-empty and unique — a duplicate raises at import. |
help | One line, shown in the-loop --help. |
add_arguments(parser) | Register flags, or nested subparsers for a multi-action command. |
run(args) -> int | Do the work. Return a process exit code; 0 is success. |
Conventions worth following
- Exit codes.
0success,1ran-but-negative,2could-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.yamlfrom 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.--configis resolved just beforeadd_argumentsruns, 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 andthe-loop events --typesagree. - 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:
uv run --project cli python -m pytest cli/tests/test_docs_parity.pyAdd 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
make test # from the repository rootIntegration 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.