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 what the setting describes, not which command is asking. If it describes the operator's machine — ingress, routing, hosting, logging — it is the CLI config, and no repository may supply it. If it describes how work is done in a project, it is that project's harness config, and a daemon command reads it too when it acts on that project. The split is decision-032; the direction rule is decision-044.
- Read the harness config through
the_loop.harness_config. It is the only module that opens the file, it handles the pre-renameconfig.yamlfallback, and itsREADStuple is where a new key gets declared. A test fails the build if a command reads the file itself or reads a key nobody declared. - 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.