Repository navigation
py-strictcli@v0.44.0
Framework-owned exits and output, --json interface_version 3 with a stdout guard, check values with failing-checks, declared runtime requirements, help and version commands, and stricter flag and naming rules.
Context
The three implementations move together toward one behavior contract. The framework now owns a command's whole exit path: an early exit raised from anywhere in the call stack, the answer written through ctx.out, the --json document's new output member (interface_version 3), a runtime guard that fails a machine-mode run when anything writes to stdout outside the framework, signal handling, and a --lint-framework-use scan that finds the bypasses statically. Checks gain per-check values, a failing-checks command, and named hook selections; commands gain declared runtime requirements; every app answers help and version commands, and help --json replaces --dump-schema. Several changes are breaking, which in 0.x is a minor: one naming rule refused at declaration, a non-repeatable flag given twice is refused instead of silently keeping the last value, a repeatable global given before and after the command collects every occurrence, check --ignore-warnings is removed in favor of failing-checks, warnings and errors carry prefixes in human mode, and a spawned child still running when its handler ends is killed.
Breaking
- [strictcli] ctx.out and the output member.
ctx.out(text)writes a command's answer to stdout, never hidden by--quiet; under--jsonthe same bytes go into the document's newoutputmember, andinterface_versionis now3. Consumers validating the document's key set must acceptoutput. - [strictcli] Prefixed warnings and errors. In human mode
ctx.errorprintserror: <message>andctx.warnprintswarning: <message>on stderr;--jsondiagnostics stay unprefixed. A handler that spelled its ownerror:prefix now prints it twice. - [strictcli] Runtime stdout guard under --json. While a handler runs in machine mode, stdout is redirected: any byte written outside the framework (a
print,os.write(1, ...), an unmanaged child) fails the run with exit 1 and a diagnostic naming the bytes, andos._exitends the run through the framework. Owns-stdout commands must write their document throughctx.document(). - [strictcli] A child started through
ctx.effects.spawnthat is still running when its handler ends is now killed (SIGTERM, then SIGKILL after one second) and fails the run: exit 1 unless the status is already nonzero, pluserror: child process <pid> was still running when the handler ended and was killed: <argv>. The exit step no longer waits for such a child. Wait on every child you spawn, or end it with the newSpawned.kill();Spawned.send_signal(signum)sends any signal. - [strictcli]
check --ignore-warningsandrun_checks(ignore_warnings=)are removed:checkstays the full report and exits nonzero on warnings, and the newfailing-checkscommand (same selection flags) is the run where only error-level failures fail. Every[checks.<name>]in checks.toml must now declaredescription(one non-empty line) andsubject(the options subject file its option belongs in: lowercase letters, digits, and hyphens, nevermanifest); a checks.toml lacking either is refused at construction withchecks.toml: check "<name>": missing required field "description"(or"subject"), so add both to every declared check.--dump-schemapublishes them on each check entry. - [strictcli] Names follow one rule, refused where they are declared: command, group, deprecated-command, and long flag names (at every level, globals and choice scopes included), choice, constraint, tag, check, hook, grant, and update-resource names must be lowercase kebab-case of at least two characters (
[a-z][a-z0-9]*(-[a-z0-9]+)*), and ashort=must be one ASCII letter (uppercase allowed).helpandversionare framework command names, refused for any command, group, or deprecated command at any depth. Rename a violating name, for example@strictcli.flag("m", short="m", ...)to@strictcli.flag("message", short="m", ...); positional argument names and the App name are unaffected. - [strictcli]
--dump-schemais replaced by<app> help --json, which prints the help document on stdout (the--jsondocument goes to stderr) and writes no file: produce a committed schema with<app> help --json > .strictcli/schema.json.App(schema_path=...)is removed,project_idnow names the project holding the module that constructed the App (the installed distribution that records it, or in a checkout the nearestpyproject.toml's[project] name) rather than thepyproject.tomlin the working directory, and passing--dump-schemais refused naminghelp --json.--helpand--versionstay text only: with--jsonthey are refused naming the command that prints the machine form. The root and group help pages now end withUse '<app> help <command>' for more information. - [strictcli] Breaking: a flag that is not repeatable is refused when it is given more than once.
--commits a --commits bused to keep onlybsilently; it now exits 1 with--commits: given more than once, as '--commits a' and '--commits b'; it takes one value. Every spelling counts ----commits=a, a short alias, and for a bool--cachegiven twice or beside--no-cache-- and so do flags inside a choice's scope, member flags, a global given before and after the command, a repeated--unset-<prop>, and the reserved--config. Repeatable, list, and dict flags still collect every occurrence, and environment variables and config files are sources, not occurrences, so their precedence is unchanged. A script or test that passes a flag twice to override an earlier value must pass it once. - [strictcli] Breaking: a repeatable global flag given both before and after the command collects every occurrence.
app --label a run --label bused to deliver onlyb, silently droppinga; it now delivers[a, b], in command-line order, exactly as if every occurrence had been typed on one side -- souniquerefuses a value repeated across the command name. This applies to repeatable, list, and dict globals; a global that is not repeatable is still refused when given on both sides. Environment variables and config files are sources, not occurrences: they are never joined to command-line values, and an occurrence on either side still replaces them. A script that repeated a global after the command to override the value before it must pass it once.
Features
- [strictcli] Early exit.
strictcli.exit_now(code, message)ends a command from anywhere in its call stack through the framework:finallyblocks run, the message is printed aserror: <message>or recorded in the--jsondiagnostics, and a dry run still renders its would-do log.app.call()raisesstrictcli.ExitErrorwith the code and the message, and MCP answersexit code <code>: <message>. - [strictcli] Payload renderers. A command with a
payload_schema=may declarepayload_renderer=; human mode prints the rendering of the supplied payload,--jsonemits only the payload. - [strictcli] Document writer.
ctx.document()gives anowns_stdout=Truecommand a binary writer to the real stdout, unchanged in both modes. - [strictcli] Signals.
app.run()catches SIGINT and SIGTERM while the handler runs and setsctx.canceled; when the handler returns, the command exits 130 or 143 witherror: canceled by signal <SIGNAL>. A second signal gets the default action. - [strictcli] --lint-framework-use. Every app reserves
--lint-framework-use, which scans the program's own package for process exits, direct stdout and stderr writes, argv access, and environment reads, and exits 1 listing each finding as<path>:<line>: <rule>: <message>. - [strictcli]
ExitErrorcarriespayload: what the command supplied throughctx.payloadbefore its early exit,Nonewhen it supplied nothing. - [strictcli] Check values,
failing-checks, and named hooks.app.set_check_value_resolver(fn)lets an app assign each checkstrictcli.CheckValue(value=..., source=...):offdoes not run the check (shown as anOFFrow with its source),warnreports its failures as warnings,errorruns it as registered; a value above the registered severity, outside the three, or without a source is refused. The newfailing-checkscommand runs the same selection ascheckand prints only error-level failures, exiting nonzero exactly when one exists.[hooks.<name>]tables in checks.toml (tag = "<expression>") name a selection thatcheck --hook <name>andfailing-checks --hook <name>run, andcheck --listshows each check's value and where it came from. - [strictcli] The
helpandversioncommands. Every app answers<app> help <groups> <command>with the page--helpprints,<app> help <command> --<flag>with that one flag's help (a scoped flag with the selector and choice that own it),<app> help --depth N [<group>]with the command tree N levels deep, and<app> versionwith the version line; under--json,helpprints the help document (pruned to the address, which it records) andversionprints{"name", "version"}. - [strictcli] Declared runtime requirements.
strictcli.Requirement(name=, help=, install=, load=)declares something a command needs at run time once,requires=[...]references it from each command that needs it, andctx.need(requirement)returns the loaded value. The framework loads a command's requirements before its handler on every door (run(),test(),call(), and MCP) and in dry mode; a missing one ends the command with exit 1 andcommand '<path>' needs <name> (<help>), which is not available: <reason>; install it: <install>.--helpshows aRequirements:section and the help document arequireslist.
Fixes
- [strictcli] Child stdout under --json. A child run through
ctx.effects.run(..., stream=True)orctx.effects.spawnno longer writes beside the--jsondocument: its stdout is captured into theoutputmember. - [strictcli] The
config set,config init,config edit, andcheckcommands report their own errors aserror: <message>and, under--json, aserrordiagnostics instead of bare stderr lines; acheck --tagexpression that does not parse is reported instead of crashing. - [strictcli] The check commands'
payload_schemain--dump-schemalistsitemsbeforetype, as Go writes it, so a checks-enabled app's schema dump is byte-identical across the Python, Go, and TypeScript implementations. - [strictcli]
--lint-framework-usenow refuses a source file that does not parse with--lint-framework-use: source file '<path>' does not parse: <detail>, the sentence Go and TypeScript print, instead of the parser's bare message, which named neither the scan nor the file.