11 KiB
name, description
| name | description |
|---|---|
| cyclone-cli | Cyclone CLI subcommand conventions (cli.py — Click group + subcommands parse-837/parse-835/validate-npi/validate-tax-id/backup, --yes + click.confirm for destructive ops, exit codes 0/1/2, CliRunner smoke tests in backend/tests/test_cli_*.py). Use when: adding a CLI subcommand, changing an exit code, adding a smoke test, or wiring a security-sensitive command (backup, key rotation, anything touching secrets.py). |
cyclone-cli
The operator-facing CLI is a Click group at cli.py:45 (@click.group() for main), mounted as the cyclone console script in pyproject.toml:54 (cyclone = "cyclone.cli:main"). The CLI has grown well past the original seven subcommands; the full inventory is:
- Parser / validator commands:
parse-837,parse-835,validate-npi,validate-tax-id,validate-837(SP40, Edifabric fail-closed pre-upload gate). - DB plumbing:
seed(deterministic sample claims),backfill-rendering-npi,backfill-999-rejections(SP33). - Submission flow:
submit-batch(SP37, canonical parse → DB-write → SFTP-upload),resubmit-rejected-claims(SP33),recover-ingest(re-parse a local path),pull-inbound(scheduler trigger). - Rebill + reissue:
rebill-from-835(SP41),reissue-claims(SP24). - Ack orphans + resubmissions:
ack-orphans status / reconcile,resubmissions status. - User management (
usersgroup):create,list,disable,reset-password,set-role. - Backup group (
backup):init-passphrase,create,list,verify,restore,prune,status.
serve lives separately in __main__.py:19 (dispatches uvicorn cyclone.api:app).
When to use
- Adding a subcommand. You need a new operator command (e.g.
cyclone rotate-key) and want to match the existing Click decorator + smoke-test rhythm. - Changing an exit code. You're tweaking which
sys.exit(N)a subcommand raises and need the 0/1/2 contract used byparse_837,parse_835,validate_npi_cmd,validate_tax_id_cmd, and thebackupgroup. - Adding a smoke test. You need
backend/tests/test_cli_<name>.pyusingclick.testing.CliRunner(NOTsubprocess.run) and want the canonical fixture + monkeypatch layout (Keychain stub, fresh SQLite,CYCLONE_BACKUP_DIR). - Wiring a security-sensitive command. Anything touching
cyclone/secrets.py(Keychain writes), DB key rotation, or destructive restores needs the two-step confirm dance used bybackup restore/backup prune(cli.py:462,509).
Conventions
- Click decorator pattern, not argparse. Each subcommand is a
top-level function decorated with
@main.command("<name>")and one@click.option/@click.argumentper parameter. Group dispatch is implicit — noset_defaults(func=...)and nocmd_<name>(args) -> intsignature. Top-level commands atcli.py:77,151,241,261,303; thebackupsub-group nests a second@main.group()(cli.py:303) with its own@backup.command("<name>")children. - Long-form flags for safety. Prefer
--rotate-key,--backup-dir,--from-stdinover positional args for anything that mutates state or takes a secret.init-passphrase(cli.py:308-311) demonstrates the canonical "flag OR stdin" pattern:--passphrasefor automation,--from-stdinfor interactivegetpass()prompting. - Exit codes: 0 / 1 / 2.
0= success.1= user / input error (invalid NPI/EIN atcli.py:258,277,285; Keychain write failure atcli.py:341,351; tampered-backup verify atcli.py:459).2= operator / parse error (CycloneParseErroratcli.py:106,178; passphrase empty/mismatch/short atcli.py:331,337). Document the codes in the docstring (seevalidate_npi_cmdatcli.py:244-250). Useclick.UsageError(...)for usage mistakes; reservesys.exit(2)for "the file failed to parse" semantics. - Smoke test with
click.testing.CliRunner. Every new subcommand getsbackend/tests/test_cli_<name>.pythat importsfrom cyclone.cli import mainand invokes viaCliRunner().invoke(main, [...], catch_exceptions=False). The test must stub Keychain (monkeypatch.setattr(secrets_mod, "get_secret"/"set_secret", ...)) and pin a temp SQLite DB viaCYCLONE_DB_URL+db._reset_for_tests()— seebackend/tests/test_cli_backup.py:13-69for the canonical_cli_envfixture. CliRunner captures output and exit codes in-process; do NOT shell out tosubprocess.run. - Destructive ops need
--yes+click.confirm(abort=True).backup restore(cli.py:462-506) andbackup prune(cli.py:509-537) both gate the destructive action behind a--yesis_flag and an interactiveclick.confirm(..., abort=True)prompt. CliRunner auto-aborts confirm prompts, so smoke tests assertexit_code != 0when--yesis omitted (test_cli_backup.py:122-137). A--dry-runflag is NOT yet implemented anywhere; if needed, mirror the--yespattern.
Patterns
A Click subcommand — @main.command("<name>") + options
From cli.py:241-258 (smallest standalone subcommand):
@main.command("validate-npi")
@click.argument("npi")
@click.option("--log-level", default="WARNING", show_default=True,
type=click.Choice(["DEBUG", "INFO", "WARNING", "ERROR"]))
def validate_npi_cmd(npi: str, log_level: str) -> None:
"""Validate a 10-digit NPI's Luhn checksum locally (SP20). Exit 0 valid, 1 invalid. PHI — don't log the value."""
setup_logging(level=log_level)
from cyclone.npi import is_valid_npi
if is_valid_npi(npi):
click.echo(f"OK: {len(npi)}-digit NPI passes Luhn checksum")
return
click.echo(f"INVALID: {npi!r} fails NPI Luhn checksum", err=True)
sys.exit(1)
A smoke test — CliRunner + Keychain stub + temp SQLite
From test_cli_backup.py:13-69. Stable hex salt keeps multiple CliRunner invocations consistent within one test. The Batch seed at cli_backup.py:27-35 is omitted — only the Keychain + DB plumbing is the convention:
@pytest.fixture
def _cli_env(tmp_path, monkeypatch):
from cyclone import db, secrets as secrets_mod
from cyclone import backup_service as svc_mod
monkeypatch.setenv("CYCLONE_DB_URL", f"sqlite:///{tmp_path}/test.db")
db._reset_for_tests()
db.init_db()
# ... seed any DB rows the subcommand needs (see cli_backup.py:27-35) ...
store = {svc_mod.KEYCHAIN_BACKUP_PASSPHRASE_ACCOUNT: "cli-test-passphrase",
svc_mod.KEYCHAIN_BACKUP_SALT_ACCOUNT:
"0123456789abcdef0123456789abcdef"}
monkeypatch.setattr(secrets_mod, "get_secret", lambda n: store.get(n))
monkeypatch.setattr(secrets_mod, "set_secret",
lambda n, v: store.__setitem__(n, v) or True)
monkeypatch.setenv("CYCLONE_BACKUP_DIR", str(tmp_path / "backups"))
yield tmp_path / "backups"
db._reset_for_tests()
def test_backup_create_list_verify_status(_cli_env):
from cyclone.cli import main
runner = CliRunner()
r = runner.invoke(main, ["backup", "create"], catch_exceptions=False)
assert r.exit_code == 0, r.output
assert "created backup id=" in r.output
A destructive subcommand — --yes + click.confirm(abort=True)
From cli.py:462-506 (backup restore). Two-step: announce, prompt unless --yes, then execute. Restore uses an explicit init/confirm round-trip so the operator can back out between phases. Matching smoke test asserts the guard fires:
@backup.command("restore")
@click.argument("backup_id", type=int)
@click.option("--yes", is_flag=True, help="Skip the interactive confirm prompt")
@click.option("--actor", default="operator-cli", show_default=True)
def backup_restore(backup_id: int, yes: bool, actor: str) -> None:
"""Restore the live DB from a backup (two-step, requires --yes)."""
# ... db.init_db() + service config omitted ...
click.echo(f"Initiating restore from backup {backup_id}...")
init = svc.restore_initiate(backup_id)
# ... echo init summary (filename, fp, table_count, ttl) ...
if not yes:
click.confirm(
"Replace the live DB with this backup? "
"This will dispose the engine and rebuild it.",
abort=True,
)
click.echo("Confirming restore...")
result = svc.restore_confirm(backup_id, init.restore_token, actor=actor)
def test_backup_restore_requires_yes_flag(_cli_env):
runner = CliRunner()
runner.invoke(main, ["backup", "create"], catch_exceptions=False)
r = runner.invoke(main, ["backup", "restore", "1"], catch_exceptions=False)
# CliRunner auto-aborts confirm prompts → exit_code != 0.
assert r.exit_code != 0
Anti-patterns
- Don't
sys.exit(2)for usage errors. Reserve code 2 for parse/operator errors (CycloneParseError, Keychain not initialized, passphrase policy violation). For "you passed the wrong flag" useraise click.UsageError(...)— Click formats it as a clean help message and exits 2 on its own. - Don't print errors to stdout. Use
click.echo(msg, err=True)for all error output.print(..., file=sys.stderr)and barelogging.error(...)bypass Click's stdout/stderr split and leak into testresult.output— breakingassert "FAIL" in r.outputstyle assertions. - Don't add a subcommand without a smoke test. Every new
@main.command(...)ships a siblingbackend/tests/test_cli_<name>.pyexercising the happy path AND at least one error path (missing input, invalid arg, tampered ciphertext — seetest_cli_backup.py:102-119). - Don't reuse the
parsecommand name. Existing subcommands are type-specific (parse-837,parse-835); a genericparsewould shadow them or force an--typeflag — neither is the codebase pattern.
Related skills
cyclone-store—backupsubcommands and the parse subcommands both round-trip throughCycloneStoreand the DB session; load when the increment changes write paths or the<entity>_writtenevent contract.cyclone-api-router—cyclone serve(via__main__.py:19) launches the FastAPI app; the CLI parse subcommands share the sameCycloneParseErrorexception and Pydantic result models as the matching HTTP endpoints.cyclone-edi—parse-837/parse-835are the CLI smoke entry points for the parser/validator surface; load when adding a parser or R-code rule.cyclone-tests— every CLI subcommand gets a pytest smoke case underbackend/tests/test_cli_<name>.py; the_cli_envfixture pattern (fresh SQLite + Keychain stub) is documented there.cyclone-spec— load when the SP-N spec introduces a new operator command or reserves a new exit-code category.