Extending the otto CLI¶
Beyond instructions (otto run ...) and suites (otto test ...), otto lets a
project register entirely new top-level commands — a single leaf command
or a whole command group — that show up in otto --help and tab completion
next to the built-ins (run, test, monitor, host, …). First-party and
third-party commands travel the exact same path:
register_cli_command() and the
cli_command() decorator built on top of it. otto’s own
eight subcommand groups register through this same function — see
otto/cli/builtin_commands.py for the reference call sites this page mirrors.
Registering a top-level command¶
Decorate an async function with @cli_command() in a module listed in your
settings file’s init field (see Repository Setup). The ergonomics
deliberately match @instruction() (see otto run): an OttoContext-annotated
parameter is injected and hidden from the CLI, and an options= dataclass
expands into individual flags. Unlike @instruction(), @cli_command() takes
keyword arguments only (options=, name=, help=, lab_free=, output_dir=,
gate=) and does not forward extra positional or keyword arguments to Typer.
from typing import Annotated
import typer
from otto import options
from otto.cli.registry import cli_command
from otto.context import OttoContext
from otto.result import Result
from otto.utils import Status
@options
class PingOptions:
count: Annotated[int, typer.Option(help="Number of hosts to sample.")] = 1
@cli_command(options=PingOptions)
async def ping(ctx: OttoContext, opts: PingOptions) -> Result:
"""Ping the first `count` hosts in the lab and report status."""
hosts = list(ctx.all_hosts())[: opts.count]
for host in hosts:
await host.run("true")
return Result(Status.Success, msg=f"pinged {len(hosts)} host(s)")
otto --lab my_lab ping --count 3
otto ping --help
The command name defaults to the function name with underscores turned into
dashes (send_report → send-report); pass name= to @cli_command() to
override it. The one-line help shown in otto --help comes from help= if
given, otherwise the docstring’s first line — read without importing the
module (see Where registration happens below).
Registering a command group¶
For more than one related subcommand, build a typer.Typer app and register
it directly with register_cli_command() — no
decorator needed, since a live typer.Typer is the loader:
import typer
from otto.cli.registry import register_cli_command
mytool_app = typer.Typer(help="Project-specific device utilities.")
@mytool_app.command()
def flash(image: str) -> None:
"""Flash IMAGE onto the currently selected device."""
...
@mytool_app.command()
def erase() -> None:
"""Erase the currently selected device."""
...
register_cli_command("mytool", mytool_app, help="Project-specific device utilities.")
otto mytool flash --image build/out.bin
otto mytool erase
otto mytool --help
A register_cli_command() loader can be one of three things:
a live
typer.Typerapp — treated as a group when it has more than one command, a callback, or sub-groups, sootto mytool --helplists subcommands. A single-command, callback-free, subgroup-free app instead flattens into a bare leaf under the registered name — exactly the rule Typer itself applies to a name-lessadd_typer, sootto monitor --helpshows monitor’s own--file/--hostsflags directly rather than hiding them behind a spurious nestedmonitorsubcommand;a plain or
asyncfunction — a leaf command, wrapped in a throwawayTyperthe same way@cli_command()’s target is;a
"pkg.mod:attr"string — resolved lazily, only when the command is actually dispatched or explicitly tab-completed. This is what every built-in uses (e.g.register_cli_command("run", "otto.cli.run:run_app", ...)) so thatotto --helpnever importsotto.cli.run,otto.cli.test, or any other subcommand module it isn’t showing the details of.
Where registration happens¶
Registration must run before the root Typer group is consulted, which
means it belongs in a module listed in your .otto/settings.toml init
field (or a package pulled in transitively from one). otto.bootstrap.bootstrap()
is otto’s composition root: it discovers your repos, then imports each
repo’s init modules and test files, and runs before argv parsing for every
real invocation (see otto.cli.main.entry()). Bootstrap is idempotent —
repeated calls return the same cached result.
Bootstrap contains failures per module: if one init file raises on
import, that one file’s exception is wrapped in a
BootstrapError and collected rather than crashing the
process. otto --help still renders — with a framed warning line printed to
stderr for each failed module — so a single broken plugin file degrades help
output instead of bricking the CLI entirely:
warning: repo /path/to/repo: failed to load my_broken_module: ImportError(...)
Real command dispatch is not so forgiving: command_preamble()
re-checks bootstrap’s result and fails loud —
Cannot run commands while a repo fails to load (see warnings above).
— with exit code 1, before any subcommand body runs (the per-module warning:
lines above are printed once, at startup, by entry(); the
dispatch preamble prints only this framed summary). The rule of thumb: a
broken init module never blocks discovery (--help, tab completion), but
it always blocks execution.
Metadata¶
register_cli_command() and @cli_command() share the same keyword-only
metadata, mirrored on CommandSpec:
Keyword |
Default |
Effect |
|---|---|---|
|
|
Skips lab bootstrap and CLI session setup (no banner, no logging init) for this command. Use for commands that never touch lab state. |
|
|
Creates a per-invocation artifact directory under |
|
|
Runs the reservation gate before dispatch (ignored entirely when |
All three are read lazily by the shared leaf-invoke preamble
(command_preamble()), which runs once per real
invocation, after argv parsing and never on a --help path — a
subcommand’s --help exits during Click’s parse step, before
Command.invoke is reached, so it can never create a spurious output
directory or trip the reservation gate.
The built-ins span the whole matrix — read them as worked examples
(otto/cli/builtin_commands.py):
schema(otto schema ...) setslab_free=True, output_dir=False, gate=False— JSON-Schema export never touches a lab, so nothing about lab or reservation state applies.covandreservationsetoutput_dir=False, gate=Falsebut stay lab-aware (lab_freedefaults toFalse) — they read lab state but write no per-invocation artifacts and gate nothing.monitorsetsgate=Falseat the spec level, then gates itself, per-branch, inside the command body: a historical--filereplay reads a local file and never touches live hardware, so it’s gate-exempt by design, while live collection still calls the gate explicitly. This is the precedent to follow whenever a uniformgate=True/gate=Falsewould be either too strict or too permissive for some of a command’s branches — declaregate=Falseon the spec and callgate()explicitly wherever the branch actually needs it.run,test,host,dockerall keep the defaults (or override justgatefordocker, which isgate=Falsebecause docker was never reservation-gated — the flag preserves that pre-existing behavior) — everything else takes the full lab-aware, output-dir-creating, gate-checked path.
Collisions¶
Two commands registering the same name is a hard failure at registration time, naming both modules:
ValueError: CLI command 'mytool' is already registered by 'acme.cli'; second
registration from 'acme.other'. CLI command names cannot be overwritten; pick a
unique name.
Unlike the backend registries covered in Extending otto with custom connection and transfer backends (term,
transfer, host classes, …), which accept overwrite=True for a deliberate
replacement, register_cli_command() has no overwrite parameter at
all — there is deliberately no escape hatch for CLI commands. A user-facing
top-level command name is part of your CLI’s surface area; silently letting a
second registration replace it would make otto --help and tab completion
depend on unpredictable init-module import order. If you need to intentionally
replace a built-in’s behavior, give your command a different top-level name.
Completion¶
Every registered command name appears automatically in otto --help and in
shell tab completion — there is nothing extra to wire up. Two paths feed this:
Slow path (a real invocation): bootstrap runs, so the live
CLI_COMMANDSregistry has every command from every loadedinitmodule, first- and third-party alike.Fast path (shell completion,
otto <TAB>): bootstrap is skipped entirely for latency — completion never executes arbitrary user code. A cache file records each third-party command’s name, help text, andlab_freeflag from the most recent slow-path run (built bycollect_cli_commands()inotto/configmodule/completion_cache.py) — plus, for a group, its subcommand tree (names, helps, option schemas), sootto <your-group> <TAB>completes children without importing your code. Built-in commands aren’t cached — they re-register on every real invocation, so caching them would be redundant. On the fast path, otto serves stubs assembled purely from that cached data; a name only the live registry knows about (never seen by a completing shell before) is simply invisible until the next slow-path run refreshes the cache.One cost note for lazy
"pkg.mod:attr"group loaders: serializing the subcommand tree imports that module during the slow-path cache refresh (never during completion itself). If the import fails, the cache degrades to the group’s name and help — and the real dispatch error stays loud.
Return values¶
Inside the command body, return whatever your logic produces. If it’s a
Result (or CommandResult/Results), otto derives the process exit code
from it using the same polymorphic, ssh-like rules otto host <name> <verb>
uses — see Exit codes in the host guide for the
full table. A plain (non-Result) return value is printed as-is and the
process exits 0.
See also¶
otto run — instructions (
otto run ...), the closest sibling to a@cli_command()leafExtending otto with custom connection and transfer backends — the term/transfer backend registries, which share
Registry’s engine but allowoverwrite=Truewhere CLI commands deliberately don’tRepository Setup — the
initfield that makes registration modules loadUsing otto as a library — using otto without the CLI at all