Adding a new CLI command
The chap command is built with cyclopts. Commands are organized as plain functions in chap_core/cli_endpoints/ and registered with the top-level app in chap_core/cli.py.
The console-script entry point is declared in pyproject.toml:
File layout
chap_core/
cli.py # Builds the cyclopts App and calls register_commands
cli_endpoints/
_common.py # Shared helpers (load_dataset, get_estimator, ...)
evaluate.py # eval, evaluate-hpo
forecast.py # forecast, multi-forecast
convert.py # convert-request
explain.py # explain-lime
preference_learn.py # preference-learn
utils.py # plot-backtest, export-metrics, ...
validate.py # validate
Each module exposes a register_commands(app) function that the top-level cli.py calls during startup.
Adding a command
-
Pick or create a module under
chap_core/cli_endpoints/. Group commands by topic — add to an existing module if it fits, otherwise create a new one. -
Write the command as a plain function. Use
typing.Annotatedwithcyclopts.Parameterto attach help text. The docstring becomes the command's help output. Cyclopts derives flag names from parameter names (output_file→--output-file).
from pathlib import Path
from typing import Annotated
from cyclopts import Parameter
def my_command(
input_file: Annotated[Path, Parameter(help="Path to the input NetCDF file")],
output_file: Annotated[Path, Parameter(help="Path to the output CSV file")],
verbose: bool = False,
) -> None:
"""One-line summary shown in `chap --help`.
Longer description shown in `chap my-command --help`.
"""
...
- Register the function in the module's
register_commands. The function name becomes the command name (snake_case → kebab-case). Passname=to override.
def register_commands(app):
app.command()(my_command) # -> chap my-command
app.command(name="eval")(eval_cmd) # -> chap eval
-
Wire the module into
chap_core/cli.pyif it is new — import it and callregister_commands(app)alongside the existing modules. -
Reuse helpers from
_common.py. Loading datasets, resolving CSV paths/GeoJSON, and building estimators should go through the existing helpers (load_dataset_from_csv,resolve_csv_path,get_estimator, etc.) so behavior stays consistent across commands.
Conventions
- Use
pathlib.Pathfor file/directory parameters, notstr. - Use Pydantic models grouped under one
Annotated[..., Parameter(...)]argument when a command takes a cluster of related options (seeBacktestParamsandRunConfigineval_cmd). Cyclopts exposes nested fields as--group.fieldflags automatically. - Call
chap_core.log_config.initialize_logging(debug, log_file)at the top of any command that produces user-visible output. - Keep heavy imports (plotting, model code) inside the function body, not at module top, so
chap --helpstays fast.
Testing
Tests call the command function directly with keyword arguments — there is no need to spawn a subprocess. See tests/test_cli.py for the pattern:
def test_my_command(tmp_path):
output_file = tmp_path / "out.csv"
my_command(input_file=fixture_path, output_file=output_file)
assert output_file.exists()
Run the suite with uv run pytest tests/test_cli.py.
Verifying
After registering, the command should appear in: