On this page
A strict, zero-dependency CLI framework for Python with mandatory help text, type-safe flags, groups, schema export, and PEP 561 type annotations.
#python.strictcli
#python.strictcli
A strict CLI framework for Python with mandatory help text, type-safe flags, groups, and schema export.
#RelativeToRoot
Opaque marker: a filesystem path relative to a declared infrastructure root.
Produced as RelativeToRoot(env_var, *parts) and accepted by a flag's default= and by App(config_path=...). env_var names the root (declared via App(infra_root={env_var: default})); parts are joined onto the resolved root path. Config-path markers resolve eagerly at construction; flag-default markers resolve when defaults are applied at parse time. A marker referencing an undeclared root is a registration-time hard error.
#_serialize_marker
def _serialize_marker(ref: RelativeToRoot) -> dictSerialize a RelativeToRoot marker to a machine-stable JSON shape.
Emits only the declared env var and path parts -- never the resolved, machine-specific path. The shape is identical across the Python and Go implementations so the schema round-trips and cross-language byte-compares.
#_resolve_infra_root_path
def _resolve_infra_root_path(ref: RelativeToRoot, roots: dict[str, str]) -> strResolve a RelativeToRoot marker against a roots map (env var -> path).
Raises ValueError if the marker references an undeclared root.
#_validate_connection_binding
def _validate_connection_binding(f: 'Flag', connection_env_names) -> NoneEnforce the connection-URL binding rules at registration time (mechanical enforcement, not review). A URL-class flag must bind to a declared connection env; the binding drives env resolution by reusing the per-flag env channel (connection_env is folded into env).
#_Source
Where a flag value came from.
#_SourcedEntry
A value paired with its provenance source.
#_SourcedStore
Map of flag-name to _SourcedEntry with source-filtered presence queries.
Replaces the plain cli_set: dict[str, object] in the validation pipeline, adding provenance tracking for each value.
#set
def set(self, name: str, value: object, source: str) -> None#get
def get(self, name: str) -> tuple[object, bool]Return (value, True) or (None, False).
#has
def has(self, name: str) -> bool#get_value
def get_value(self, name: str) -> objectReturn the value or raise KeyError.
#set_value
def set_value(self, name: str, value: object) -> NoneUpdate the value of an existing entry, keeping its source.
#is_present_for_mutex
def is_present_for_mutex(self, name: str) -> boolPresent for mutex: only cli, env, config. NOT default or implied.
#is_present_for_deps
def is_present_for_deps(self, name: str) -> boolPresent for deps (CoRequired, Requires): everything except default.
#source_map
def source_map(self) -> dict[str, str]Return a dict mapping flag names to source labels.
#from_dict
def from_dict(cls, d: dict[str, object], source: str) -> '_SourcedStore'Build a store from a plain dict, marking all entries with source.
#_InfraAccess
A Context's view of infrastructure env vars: resolved root values (captured at construction), declared handshake env vars (read live), and declared connection env vars (read live, but suppressed under --hermetic).
#Context
Structured output context for command handlers.
Always injected as the first positional argument to every handler. Provides info/warn/debug/error methods that route to the correct stream, plus source/infra_value provenance accessors. To return structured data, a handler returns strictcli.outcome(data=...).
#dry_run
def dry_run(self) -> boolTrue when the framework-owned --dry-run flag was passed.
#approve_consequential
def approve_consequential(self) -> boolTrue when the framework-owned --approve-consequential flag was passed.
#quiet
def quiet(self) -> boolTrue when the framework-owned --quiet flag was passed.
#verbose
def verbose(self) -> boolTrue when the framework-owned --verbose flag was passed.
#effects
def effects(self) -> '_Effects'The effects handle for this run (see the effects-regime contract).
#info
def info(self, msg: str) -> NoneWrite an informational message to stdout (hidden under --quiet).
#warn
def warn(self, msg: str) -> NoneWrite a warning message to stderr (never suppressed).
#debug
def debug(self, msg: str) -> NoneWrite a debug message to stdout (shown only under --verbose).
--quiet dominates --verbose: passing both hides debug output.
#error
def error(self, msg: str) -> NoneWrite an error message to stderr (never suppressed).
#source
def source(self, name: str) -> strReturn the provenance source label for a flag.
Returns one of: "cli", "env", "config", "default", "implied", "infra". ("infra" indicates the value came from a RelativeToRoot default resolved through a declared infrastructure root.) Raises KeyError if the flag name is not found.
#infra_value
def infra_value(self, env_var: str) -> tuple[str | None, bool]Return the value of a declared infrastructure env var.
For a declared location root (infra_root), returns the value resolved eagerly at construction (env var if set, else the declared default) and True -- the resolved value is always available.
For a declared handshake var (handshake_env), reads the environment LIVE at call time (handshakes are set by the invoking process and carry no construction-time value), returning (value, is_set).
For a declared connection env (connection_env), reads the environment LIVE at call time and returns (value, is_set) -- EXCEPT under --hermetic, where it resolves as absent (None, False) so connection-dependent behavior skips visibly instead of connecting.
Raises KeyError if env_var is not a declared root, handshake, or connection var.
#connection_env_value
def connection_env_value(self, env_var: str) -> tuple[str | None, bool]Return the value of a declared connection env (connection_env), read LIVE at call time -- EXCEPT under --hermetic, where it resolves as absent (None, False). Raises KeyError if env_var is not a declared connection env. This is the check-side and handler-side accessor for the connection-URL kind; see also infra_value, which resolves all three kinds.
#EffectFailed
A failed effect operation.
A failed operation is an error, not a value: a run whose child exits nonzero and an http whose status is outside 200-299 raise this, as does invalid UTF-8 on a captured stream. check=False opts a single call out.
#_DryRunTruncated
Raised when handler code extracts from or branches on an Unsettled value.
Derives from BaseException deliberately: a handler's except Exception must not be able to swallow the truncation and let the preview continue with a value the framework refuses to invent.
#Grant
A per-command, per-effect-kind authorization with a mandatory reason.
A grant is not permission to do something otherwise forbidden; it is a labelled reason that surfaces in the preview so a reviewer reading a dry run sees why a dangerous step is there.
#Completed
The result of a subprocess that ran to completion.
stdout/stderr are the child's output decoded as UTF-8 strictly, with a single trailing newline removed if present -- the form that can be forwarded straight into a later effect's argv.
#Response
The result of an HTTP request. Header names are lower-cased.
#Spawned
A handle for a started-but-not-awaited child process.
#wait
def wait(self, *, check: bool=True) -> CompletedWait for the child and return its Completed result.
check mirrors run's opt-out: with the default True a nonzero exit raises :class:EffectFailed.
#Unsettled
A value standing in for a result that cannot exist because nothing ran.
Produced by every mutating effect recorded in dry mode and by every post-mutation observe. FORWARDING one into a later ctx.effects call is legal and renders its brand inline; EXTRACTING from it or BRANCHING on it truncates the preview with a precise error.
#_EffectRecord
One entry in the structured effect log (see the conformance surface).
#to_dict
def to_dict(self) -> dict#render
def render(self) -> strRender this record as a would-do log line (without the indent).
#_EffectLog
The ordered effect records produced by one dispatch.
TWO counters, deliberately. Would-do numbering is the numbering of the RENDERED lines: it feeds the log's <N>. prefix, the «step N output» brand and the truncation error's "ends at step N". CACHE_WRITEs are never rendered, so they must never consume one of those numbers -- otherwise a coverage-instrumented run would silently start its preview at 2.. They get their own sequence instead, so every record still carries a seq.
#append
def append(self, rec: _EffectRecord) -> None#next_seq
def next_seq(self) -> intThe next would-do number. Pure: callers may ask without appending.
#next_cache_seq
def next_cache_seq(self) -> intThe next CACHE_WRITE number, on its own counter.
#render
def render(self) -> strRender the would-do log. CACHE_WRITEs are never written to it.
#to_list
def to_list(self) -> list[dict]#_msg_dry_run_truncated
def _msg_dry_run_truncated(step: int, cmd: str, brand: str) -> strThe truncation error. Carries its own error: prefix (it goes to stderr directly, not through the parse-error formatter).
#_msg_dry_run_aborted
def _msg_dry_run_aborted(step: int, cmd: str) -> strThe aborted-preview marker. Same shape and prefix as the truncation error above: both say the preview ended before the handler finished, and they differ only in why and in what the reader may conclude.
#_msg_confirm_prompt
def _msg_confirm_prompt(cmd_path: str) -> strThe confirm prompt. A prompt, not an error, but parity is still checked.
#_strip_confirm_line
def _strip_confirm_line(answer: str) -> strStrip the confirm answer's line terminator: one \n, then one \r.
Exactly one of each, never more. The carriage return matters because a human at a Windows console types the same y as everyone else and their terminal terminates the line CRLF; a stdin stream that does not translate newlines hands us "y\r\n", and declining there would refuse an answer that was plainly given. Stripping only the terminator (rather than whitespace) keeps " y" a decline, which §8.2 requires.
#_msg_call_consequential_unconsented
def _msg_call_consequential_unconsented(cmd_path: str) -> strThe programmatic-path refusal (contract §8.5).
Requiring confirmation is a property of the COMMAND, so every channel has to honour it -- but a programmatic caller has no terminal to prompt. The refusal makes the caller state, in the call, that it is proceeding without a human, instead of the framework deciding that silently on its behalf.
#_consequential_grant_warning
def _consequential_grant_warning(cmd_path: str, grant: str, kind: str) -> strThe consequential-grant-agreement warning (contract §8.1, §11).
A grant exists so a reviewer reading a preview sees WHY a dangerous step is there (§6.1) -- the same judgement consequential makes. When the grant's kind is one that leaves this process (proc_mutate runs another program, net_mutate changes remote state), the two declarations should almost always agree. They can legitimately disagree, so this is a warning: making it an error would push consumers to declare consequential reflexively to clear a gate, which is exactly the reflex the declaration exists to end.
#_observe_allowlist_breadth_warning
def _observe_allowlist_breadth_warning(binary: str) -> strThe observe-allowlist-breadth warning (contract §6.2).
A one-token prefix is a near-blanket exemption for that binary: EVERY invocation of it becomes an observe, which means it really executes under --dry-run, is never written to the would-do log, and is legal inside a read_only command. That may be exactly what the app wants -- the allowlist is a declared, source-visible choice and it authorizes real execution in dry mode -- so this is a warning, not an error.
#_msg_effect_option_not_accepted
def _msg_effect_option_not_accepted(name: str, method: str, opt: str) -> strAn option the receiving method does not accept (contract §12.8).
Python reaches this through each method's **_options catch-all rather than through CPython's native unexpected keyword argument TypeError, so the rendered text is byte-identical to Go's and TypeScript's. <opt> is the canonical snake_case option name, which is what makes that identity hold.
#_reject_unaccepted_options
def _reject_unaccepted_options(name: str, method: str, options: dict) -> NoneRaise on the first unaccepted option, in the caller's declaration order.
A TypeError, matching every other call-time argument guard on the handle (and what CPython itself raises for an unexpected keyword).
#_Effects
The effects handle reached as ctx.effects.
Exactly eight methods, and the set is CLOSED: there is no escape hatch that mints an unlisted effect, and CACHE_WRITE has no public method at all.
#_reject_carrier_params
def _reject_carrier_params(self, method: str, params: dict) -> NoneHard-error when a carrier reaches a parameter that cannot take one.
#_operand
def _operand(self, value: object, method: str, param: str) -> tupleResolve a carrier-accepting parameter.
Returns (runtime_value, rendered). runtime_value is None when the value is unsettled (nothing ran, so there is nothing to use); rendered is what the log line shows.
#_content_operand
def _content_operand(self, value: object) -> tupleResolve write's content. Returns (bytes_or_None, rendered).
The rendered form is the encoded byte count for a settled value, and the forwarded carrier's brand when the content is unsettled (there is no byte count to report -- nothing produced the bytes).
#_authorize
def _authorize(self, method: str, kind: str, grant: str | None) -> Grant | NoneRead-only enforcement plus grant validation, at call time.
#_is_observe
def _is_observe(self, argv: list) -> boolElement-wise argv-prefix matching by string equality. Nothing else.
#run
def run(self, argv: Sequence[str | Completed | Response], *, cwd=None, env=None, check=True, stream=False, resource=None, skip_if_current=None, grant=None, **_options) -> CompletedRun a subprocess to completion (PROC_MUTATE, or an observe).
#spawn
def spawn(self, argv: Sequence[str | Completed | Response], *, cwd=None, env=None, resource=None, skip_if_current=None, grant=None, **_options) -> SpawnedStart a subprocess without waiting (PROC_SPAWN).
Spawning is itself an effect: a dry run RECORDS the spawn instead of performing it, which is why no cross-process mode token exists.
#write
def write(self, path: str | os.PathLike[str] | Completed | Response, content: str | bytes | Completed | Response, *, resource=None, skip_if_current=None, grant=None, **_options) -> NoneWrite bytes to a path (FILE_WRITE).
#mkdir
def mkdir(self, path: str | os.PathLike[str] | Completed | Response, *, resource=None, skip_if_current=None, grant=None, **_options) -> NoneCreate a directory, parents included; an existing one is not an error.
#remove
def remove(self, path: str | os.PathLike[str] | Completed | Response, *, resource=None, skip_if_current=None, grant=None, **_options) -> NoneRemove a file, symlink or directory tree; a missing path is not an error.
#rename
def rename(self, src: str | os.PathLike[str] | Completed | Response, dst: str | os.PathLike[str] | Completed | Response, *, resource=None, skip_if_current=None, grant=None, **_options) -> NoneMove/rename a path (FILE_WRITE).
#chmod
def chmod(self, path: str | os.PathLike[str] | Completed | Response, mode, *, resource=None, skip_if_current=None, grant=None, **_options) -> NoneChange a path's mode (FILE_WRITE).
#http
def http(self, method, url: str | os.PathLike[str] | Completed | Response, *, body=None, headers=None, check=True, resource=None, skip_if_current=None, grant=None, **_options) -> ResponsePerform a network request (NET_MUTATE).
#_merged_env
def _merged_env(self, env)env merges OVER the inherited environment, never replacing it.
#_decode_effect_output
def _decode_effect_output(data: bytes, cmd_path: str, method: str) -> strDecode captured output as UTF-8 strictly, dropping one trailing newline.
#_remove_path
def _remove_path(path: str) -> NoneRemove a file, a symlink or a directory tree. A missing path is fine.
#_validate_grants
def _validate_grants(cmd_name: str, grants) -> tupleValidate a command's grant declarations at registration time.
#_BypassImports
What a module's imports say about the names it calls.
receivers maps a bound module name to the effect module it denotes (import os as o -> {"o": "os"}); calls maps a bound member name to the (module, member) pair it came from (from os import system as sh -> {"sh": ("os", "system")}). Both are used to normalize a call before the ban lists see it, so the lists stay written in terms of real module and member names rather than whatever the consumer spelled.
#_bypass_import_bindings
def _bypass_import_bindings(tree) -> _BypassImportsNames bound to effect modules and to their members, in one module.
Relative imports are skipped: from .os import system is the consumer's own module, not the stdlib one, and the analyser cannot resolve it.
#_call_target_name
def _call_target_name(node) -> tupleReturn (dotted_target, receiver) for a call's callee.
#_reaches_effects_handle
def _reaches_effects_handle(node, aliases=frozenset()) -> boolTrue when a callee's receiver chain goes through .effects.
aliases are local names bound to the handle (e = ctx.effects), which is an ordinary way to write a handler and must not read as a bypass.
#_bypass_effects_aliases
def _bypass_effects_aliases(tree) -> frozensetNames bound to the effects handle anywhere in the module.
e = ctx.effects then e.write(...) is the same call as ctx.effects.write(...); without this the lint would report the handle itself as a bypass.
#_function_opts_into_effects
def _function_opts_into_effects(fn) -> boolTrue when a function body reaches for an .effects handle at all.
One of the two root conditions: a function that uses the effects handle must route ALL of its effects through it, or the preview it promises is a lie.
#_decorator_leaf
def _decorator_leaf(node) -> str | NoneThe last dotted component of a decorator expression, call or not.
#_bypass_handler_names
def _bypass_handler_names(tree) -> setFunction names passed as handler= anywhere in the module.
The second way a handler is registered: Passthrough(handler=_pt), app.command(..., handler=deploy). Name-based, because that is all a single-module AST can honestly resolve.
#_is_registered_handler
def _is_registered_handler(fn, handler_names: set) -> boolTrue when this function is a registered command handler.
#_bypass_direct_call_names
def _bypass_direct_call_names(fn) -> setBare name(...) callees inside a function's subtree.
#_bypass_reachable_functions
def _bypass_reachable_functions(tree) -> setThe ids of every function REACHABLE FROM A REGISTERED COMMAND HANDLER.
§11's scope is reachability, not "a function whose own body mentions .effects" -- a handler that never touches the handle, and a bypass one helper-call away, are both trivial escapes from the narrower reading, and this lint is the sole stated mitigation for the accepted no-sandbox ceiling.
Roots are registered handlers (a .command / .passthrough decorator, or a name passed as handler=) plus, as before, any function that reaches for .effects itself. From each root the closure follows DIRECT calls to MODULE-LEVEL functions, transitively, within this one module -- the most a single-file AST can resolve without a symbol table.
#_bypass_resolve_call
def _bypass_resolve_call(leaf: str, receiver, imports: _BypassImports) -> tupleThe (leaf, receiver) a call really goes through, per its imports.
A bare name imported from an effect module answers with that module and the member's REAL name (from os import system as sh -> ("system", "os")), and an aliased module receiver answers with the module it denotes (import os as o -> os). Anything else is returned untouched, so an unresolvable receiver stays unresolvable rather than being guessed at.
#_bypass_call_is_banned
def _bypass_call_is_banned(node, target: str, receiver, imports: _BypassImports=_BYPASS_NO_IMPORTS) -> boolTrue when one call is a direct effect the handle should have carried.
Two leaves are deliberately narrower than the rest: builtin open is a finding only in a writing mode, and system only through os -- platform.system() observes this process and starts nothing, and the effects handle has no method that could carry it.
Builtin open is answered BEFORE import resolution, because it is the one leaf whose meaning comes from being unqualified. Everything after it is resolved through the module's imports (see :func:_bypass_resolve_call), so the lists below are written in terms of real module and member names.
#_bypass_walk
def _bypass_walk(node, stack: list, reachable: set, findings: list, rel: str, aliases: frozenset, imports: _BypassImports) -> NoneWalk one subtree, carrying the enclosing-function stack.
A banned call is reported once, at the INNERMOST enclosing function, when any enclosing function is reachable -- so a bypass inside a nested closure is one finding, not one per enclosing scope.
#_open_is_write_mode
def _open_is_write_mode(node) -> boolTrue when a bare open(...) call requests a writing mode.
#_scan_effects_bypasses
def _scan_effects_bypasses(root: Path) -> list[tuple]Find direct effect calls REACHABLE FROM a registered command handler.
Returns (relative_path, lineno, function_name, target) tuples, in file then line order. See :func:_bypass_reachable_functions for the scope rule.
#Outcome
A structured result returned by a command handler.
Built exclusively via the :func:outcome factory. Carries an exit code and optional structured data. When data is not None the framework JSON-prints it to stdout; test()/call() capture it as data.
#outcome
def outcome(exit_code: int=0, data: object=None) -> OutcomeBuild an :class:Outcome for a command handler to return.
Args:
exit_code: process exit code (default 0).data: optional structured data; when not None it is JSON-printed to
stdout and captured by test()/call().
#_interpret_handler_return
def _interpret_handler_return(result: object) -> tuple[int, object]Map a command handler's return value to (exit_code, data).
data is _MISSING when there is no structured payload to emit. The only permitted returns are int (exit code), None (exit 0), or an :class:Outcome built via :func:outcome. Anything else is a hard error.
#_config_path
def _config_path(app_name: str, *, override: str | None=None, config_format: str='json') -> strCompute the config file path for an app.
If override is provided, expand ~ and return it directly. Otherwise compute from XDG_CONFIG_HOME + app_name.
#_ConfigLoadResult
Result of loading a config file.
#_compute_json_position
def _compute_json_position(text: str, offset: int) -> tuple[int, int]Convert a byte offset to 1-based (line, column).
#_load_config
def _load_config(app_name: str, *, config_path_override: str | None=None, config_format: str='json', is_runtime_flag: bool=False) -> _ConfigLoadResultLoad the config file for an app.
Missing file with is_runtime_flag=True is a hard error (user explicitly passed --config). Missing file otherwise is soft (returns empty dict). Malformed file is always a hard error with position information.
#_toml_format_scalar
def _toml_format_scalar(value: object) -> strFormat a scalar value as a TOML literal.
#_load_toml_doc
def _load_toml_doc(path: str) -> 'tomlkit.TOMLDocument'Load a TOML file as a comment/order-preserving tomlkit document.
Returns an empty document if the file does not exist yet.
#_toml_value_item
def _toml_value_item(value: object) -> objectBuild a tomlkit item for value using canonical scalar formatting.
Scalars and lists are rendered through _toml_format_scalar (so floats keep their canonical spelling) and re-parsed into properly formatted tomlkit items. Dicts become section tables with keys sorted for deterministic output.
#_toml_set_nested
def _toml_set_nested(doc: 'tomlkit.TOMLDocument', dotted_key: str, value: object) -> NoneSet a dot-separated key on a tomlkit document, preserving comments/order.
Only the target key is (re)written; intermediate tables are created as needed. Comments and ordering of all other keys are untouched.
#_toml_del_nested
def _toml_del_nested(doc: 'tomlkit.TOMLDocument', dotted_key: str) -> boolDelete a dot-separated key from a tomlkit document.
Returns True if the key was found and removed. Prunes now-empty intermediate tables. Comments/order of untouched keys are preserved.
#_coerce_config_scalar
def _coerce_config_scalar(value: object, flag_type: type) -> objectCoerce a single JSON config value to the given type.
Returns the coerced value, or raises ValueError if coercion fails.
#_coerce_config_value
def _coerce_config_value(value: object, flag: 'Flag') -> objectCoerce a JSON config value to the flag's type.
Returns the coerced value, or raises ValueError if coercion fails. Handles scalar, array (repeatable), and object (dict) values.
#_resolve_flag_show_source
def _resolve_flag_show_source(f: 'Flag', config_data: dict) -> tuple[object, str]Resolve the effective value and source for a flag in config show context.
Precedence: env > config > default. "cli" is structurally impossible in config show because the app's own flags were never passed on the command line.
#_format_config_value
def _format_config_value(value: object) -> strFormat a config value for display, matching Go's formatConfigValue.
#_nested_get
def _nested_get(data: dict, dotted_key: str) -> tuple[bool, object]Look up a dot-separated key in a nested dict.
Returns (found, value). If any intermediate segment is missing or not a dict, returns (False, None).
#_nested_set
def _nested_set(data: dict, dotted_key: str, value: object) -> NoneSet a dot-separated key in a nested dict, creating intermediate dicts.
#_nested_delete
def _nested_delete(data: dict, dotted_key: str) -> boolDelete a dot-separated key from a nested dict.
Returns True if the key was found and deleted, False otherwise. Cleans up empty intermediate dicts.
#_collect_nested_keys
def _collect_nested_keys(data: dict, prefix: str='') -> list[str]Collect all leaf keys from a nested dict as dot-separated paths.
Non-dict values are leaves. Dict values are recursed into.
#_check_config_field_type
def _check_config_field_type(cf: 'ConfigField', value: object) -> str | NoneValidate that a config file value matches the config field's declared type.
Returns an error message, or None if the type matches.
#_config_set_field
def _config_set_field(effects: '_Effects', key: str, value: str | None, cf: 'ConfigField', existing: dict, path: str, config_format: str, kw: dict) -> intHandle 'config set' for a config field (not a flag).
Returns an exit code (0 = success, 1 = error).
#_write_config_set
def _write_config_set(effects: '_Effects', data: dict, path: str, config_format: str, key: str, value: object) -> NoneSet key = value in config and persist THROUGH ctx.effects.
For TOML, edits are comment/order-preserving: the existing file is loaded into a tomlkit document, only the changed key is written, and the document is dumped back. JSON is serialized from the in-memory data dict.
The write is a FILE_WRITE on the effects handle, not a bare open: config set is classified mutating, so under --dry-run the write must be RECORDED, never performed. A framework command that printed "DRY RUN -- no changes were made." while rewriting the user's config file would be the loudest possible counterexample to its own regime.
#_write_config_unset
def _write_config_unset(effects: '_Effects', data: dict, path: str, config_format: str, key: str) -> boolRemove key from config and persist. Returns False if key was absent.
For TOML, the removal is comment/order-preserving (only the target key is dropped). JSON is serialized from the in-memory data dict. The write goes through ctx.effects for the reason spelled out above.
#_ensure_config_dir
def _ensure_config_dir(effects: '_Effects', path: str) -> NoneRecord/perform the config file's parent directory creation.
The existence probe is an ordinary filesystem READ (never an effect), and branching on it is branching on a real value, so the preview walks straight through it in both modes -- the §5.2 idiom. Probing keeps the preview honest: a mkdir line appears only when a directory would really be created.
#_generate_config_template_toml
def _generate_config_template_toml(flags: list['Flag'], config_fields: dict[str, 'ConfigField']) -> strGenerate a TOML config template with comments.
#_generate_config_template_json
def _generate_config_template_json(flags: list['Flag'], config_fields: dict[str, 'ConfigField']) -> strGenerate a JSON config template.
#_split_escaped
def _split_escaped(value: str, sep: str) -> list[str]Split value on sep, treating backslash as escape character.
Escaped sep becomes literal sep. Escaped backslash becomes literal backslash. Trailing backslash with nothing to escape becomes literal backslash.
#_values_equal_for_conflict
def _values_equal_for_conflict(cli_val: object, config_val: object, flag: 'Flag') -> boolCompare a CLI/env value and a config value for conflict-mode equality.
Equality semantics (pinned):
- scalars: exact equality.
- plain repeatable lists: order-sensitive exact equality.
- Unique flags: order-insensitive multiset equality.
When the two values are equal, config+CLI/env co-presence is NOT a conflict (they agree), so error mode does not fire.
#_check_flag_configfield_default
def _check_flag_configfield_default(flag_name: str, flag_default: object, cf: 'ConfigField') -> NoneRaise ValueError when a colliding flag and config field have conflicting explicit defaults.
A ConfigField whose name equals a flag's param name is a validation-only declaration -- it annotates the flag. Their defaults must agree. The matrix: both absent OK; equal OK; both present unequal = error; one absent OK (the flag's default wins for rendering). A flag default of None means "no default" (absent); a ConfigField default of _MISSING means absent.
#_find_duplicate
def _find_duplicate(values: list) -> object | NoneReturn the first duplicate value in the list, or None if all unique.
#_format_float_canonical
def _format_float_canonical(value: float) -> strFormat a float in strictcli canonical form (SCF).
Rules (must match the Go implementation for cross-language parity):
- Shortest decimal string that round-trips to the identical IEEE-754 double
(Python's repr already yields shortest round-trip digits).
- Integer-valued floats in fixed notation always carry a trailing
.0. -0.0is preserved as-0.0.- Fixed notation for
|x|in[1e-6, 1e21); scientific outside. Zero
(0.0 / -0.0) is always rendered fixed.
- Scientific spelling: lowercase
e, explicit sign, no zero-padding on
the exponent (e.g. 1e+21, 1e-7, 1.5e+300).
- The
.0rule applies only in the fixed branch, never scientific.
#_format_dict_for_display
def _format_dict_for_display(value: dict) -> strRender a dict flag value as canonical key=value pairs.
Keys are sorted for deterministic output, matching Go's formatDictForDisplay. Values are rendered via _format_value_for_error.
#_format_default_for_help
def _format_default_for_help(value: object) -> strFormat a default value for help text.
Floats use the canonical form (SCF); dict values render as sorted key=value pairs (matching Go). Every other type is rendered as str.
#_format_value_for_error
def _format_value_for_error(value: object) -> strFormat a value for inclusion in error messages (without quotes).
Floats use the canonical form (SCF). Bools are lowercase. Dict values render as sorted key=value pairs (matching Go). Strings are returned as-is.
#_config_typename
def _config_typename(value: object) -> strReturn a type name for config values, matching Go's typeName.
#_parse_checks_toml
def _parse_checks_toml(data: bytes) -> tuple[str, dict[str, _CheckDef]]Parse and validate checks TOML data, returning (app_name, check_defs).
Raises ValueError on any schema violation or invalid TOML.
#_load_checks_toml
def _load_checks_toml(path: str | Path) -> tuple[str, dict[str, _CheckDef]]Read and parse a checks.toml file, returning (app_name, check_defs).
Raises ValueError on any file error, schema violation, or invalid TOML.
#_HelpRequested
Raised when --help or -h is encountered.
#_VersionRequested
Raised when --version or -v is encountered.
#_DumpSchemaRequested
Raised when --dump-schema is encountered.
#_McpRequested
Raised when --mcp is encountered.
#_ParseError
Raised for user-facing parse errors.
#InvokeError
Raised by app.call() for invocation errors (unknown command, missing flags, etc.).
#_strict_bool
def _strict_bool(s: str) -> boolParse a boolean string strictly.
Accepts: 1, true, yes (case-insensitive) -> True Accepts: 0, false, no (case-insensitive) -> False Everything else raises ValueError.
#_strict_int
def _strict_int(s: str) -> intParse an integer string strictly -- no leading/trailing whitespace allowed.
Python's int() silently strips whitespace; Go's strconv.Atoi does not. This matches Go's stricter behavior. Additionally, the result is range-checked to fit in a signed 64-bit integer, matching Go's int/int64.
All errors raise ValueError with the same message format as Go's parseIntStrict: "expected integer, got '
#_strict_float
def _strict_float(s: str) -> floatParse a float string strictly -- no leading/trailing whitespace allowed.
Rejects nan, inf, and -inf (case-insensitive) since these are valid Python floats but not useful CLI values.
#_float_parse_error
def _float_parse_error(flag_name: str, raw: str, exc: ValueError, *, env: str | None=None) -> '_ParseError'Build a _ParseError for a failed float parse.
If the ValueError is a NaN/Inf rejection, use its message directly. Otherwise, produce the generic "expected float, got ..." message.
#_coerce_arg_value
def _coerce_arg_value(a: 'Arg', raw: str) -> objectCoerce a raw positional arg string to the declared type.
Uses the same strict parsing functions as flags: _strict_int, _strict_float, _strict_bool. Error messages follow the same pattern as flag type errors, with "argument '
#_resolve_at_prefix
def _resolve_at_prefix(flag_name: str, raw: str, stdin_consumed_by: str | None) -> tuple[str, str | None]Resolve @-prefix for string flag values.
Returns (resolved_value, updated_stdin_consumed_by).
#_parse_dict_value
def _parse_dict_value(flag_name: str, raw: str, value_type: type) -> tuple[str, object] | dict[str, object]Parse a dict flag value from CLI.
Two formats:
- key=value: splits on first '=', coerces value to value_type
- JSON string starting with '{': parsed as JSON dict
For key=value format, returns a (key, coerced_value) tuple. For JSON format, returns a dict of {key: coerced_value}.
#_coerce_dict_json_value
def _coerce_dict_json_value(flag_name: str, key: str, value: object, value_type: type) -> objectCoerce a JSON-parsed value to the dict's value type.
#_store_dict_flag
def _store_dict_flag(f: 'Flag', raw: str, cli_set: dict) -> NoneParse and store a dict flag value from a raw CLI string.
Handles both key=value and JSON formats. For JSON, may add multiple entries at once. For key=value, adds one entry.
#_raise_flag_name_reserved_by_framework
def _raise_flag_name_reserved_by_framework(name: str)Message template: a flag name collides with the reserved quartet.
#_raise_flag_name_yes_banned
def _raise_flag_name_yes_banned()Message template: a flag named yes is banned outright.
yes owns no framework flag any more, but a private --yes would restate --approve-consequential in a spelling that IS muscle memory -- which is exactly what the rename removed.
#_raise_flag_name_consent_reserved
def _raise_flag_name_consent_reserved()Message template: a flag name collides with the consent parameter.
#_raise_arg_name_consent_reserved
def _raise_arg_name_consent_reserved()Message template: an arg name collides with the consent parameter.
#_parse_compound_type
def _parse_compound_type(raw_type: type, context: str) -> tuple[str, type | None, type | None]Parse a type annotation into (kind, item_type, value_type).
Returns:
- ("scalar", None, None) for str/bool/int/float
- ("list", item_type, None) for list[T]
- ("dict", None, value_type) for dict[str, T]
Raises ValueError for invalid compound types.
#_validate_element_type
def _validate_element_type(flag_name: str, expected_type: type, value: object, context: str) -> NoneValidate that a value matches the expected scalar type.
#Flag
Represents a --flag declaration.
#Arg
Represents a positional argument.
#FlagSet
A reusable bundle of flags.
#MutexGroup
A group of mutually exclusive flags.
#CoRequired
Flags that must all appear together or none.
#Requires
Flag that depends on another flag being present.
#Implies
When a trigger flag is provided, automatically set a target flag to a value.
#Passthrough
Marks a command as passthrough -- all tokens after the command name are forwarded to the handler as a raw list, bypassing flag/arg parsing.
#Forwarding
Declares that a handler deliberately accepts and forwards **kwargs.
Guard v2 refuses a var-keyword handler unless the command declares forwarding. The reason is mandatory, non-empty, and emitted in the schema so a consumer's audit gate can review every forwarding site.
#DeprecatedCommand
A declaration-only deprecated command: prints message to stderr and exits 1.
#_raise_command_read_only_consequential
def _raise_command_read_only_consequential(name: str)A read_only command cannot be consequential (contract §8.1).
Classification answers "should a dry run record rather than execute?"; consequential answers "are these effects worth interrupting someone for?". A command that changes nothing has no effects to weigh, so the two declarations cannot both hold.
#_raise_command_read_only_dry_run_unsupported
def _raise_command_read_only_dry_run_unsupported(name: str)A read_only command cannot declare dry_run_supported=False.
Mirrors the read_only + consequential prohibition: a command that changes nothing records nothing, so a preview of it can never be dishonest and there is no reason to refuse one.
#_validate_dry_run_declaration
def _validate_dry_run_declaration(name: str, effect: str, dry_run_supported: bool, dry_run_unsupported_reason: str | None) -> NoneThe three registration-time guards on the dry-run declaration.
Shared by :class:Command.__post_init__ and :func:_build_and_validate_command so both registration surfaces reject the same shapes with the same messages.
#Command
A leaf command with a handler.
#Group
A container for nested commands and subgroups (arbitrary depth).
#group
def group(self, name: str, *, help: str, tags: set[str] | None=None, hidden: bool=False) -> GroupCreate and register a child subgroup.
#deprecate
def deprecate(self, name: str, *, message: str, effect: str | None=None) -> NoneRegister a deprecated subcommand in this group.
Deprecated entries are classification-EXEMPT: they have no handler and execute nothing, so passing effect= is a registration-time error.
#command
def command(self, name: str, *, help: str, effect: str | None=None, consequential: bool=False, dry_run_supported: bool=True, dry_run_unsupported_reason: str | None=None, args: list[Arg] | None=None, flag_sets: list[FlagSet] | None=None, mutex: list[MutexGroup] | None=None, dependencies: list[CoRequired | Requires | Implies] | None=None, passthrough: Passthrough | None=None, grants: list[Grant] | None=None, forwarding: Forwarding | None=None, tags: set[str] | None=None, hidden: bool=False, interactive: bool=False, config_fields: list[str] | None=None) -> Callable[[F], F]Decorator to register a command within this group.
#ConfigField
Declares a typed config file field.
Fields with no default are required — the config system will error if they are missing from the config file. Fields with a default are optional.
#Result
Returned by app.test().
#Tool
A tool descriptor for exposing CLI commands to tool-using LLM agents.
#_CheckProblem
A single minted finding: text plus severity ("error" or "warn").
Module-private -- problems are minted only via reporter methods.
#_CheckOutcome
The ceiling-typed result of a check implementation.
Module-private with a construction guard: a valid outcome is obtained ONLY through reporter methods (passed/skipped/found) or the runner's internal skip mint, both of which pass _MINT_TOKEN. Direct construction raises.
#status
def status(self) -> strDerived verdict label ("pass"/"fail"/"warn"/"skip").
#_ordered_problems
def _ordered_problems(self) -> tuple[_CheckProblem, ...]Problems grouped by severity: all error problems, then all warns.
#_mint_skip
def _mint_skip(message: str) -> _CheckOutcomeRunner-internal mint for cascade/scope skip outcomes.
#_derive_status
def _derive_status(outcome: _CheckOutcome) -> strMap a minted outcome to its verdict label.
passed => pass; skipped => skip; found with an error problem => fail; found with only warns => warn.
#_ReporterCore
Shared problem accumulator and minting surface for both reporters.
Holds warn()/passed()/skipped()/found(). Error-minting lives ONLY on ErrorReporter, so WarnReporter structurally lacks it (accessing .error on a WarnReporter is an AttributeError at runtime and a type error under mypy).
#note
def note(self, text: str) -> NoneRecord an informational note. Non-empty text required.
Notes are allowed on EVERY outcome, including a pass -- they never trigger the problems-present errors that passed()/skipped() enforce. Notes are verdict-inert: they surface only under --verbose and in JSON.
#warn
def warn(self, text: str) -> NoneMint a warn-severity problem. Non-empty text required.
#passed
def passed(self, message: str) -> _CheckOutcomeFinalize a terminal PASS. Errors if any problems were reported.
#skipped
def skipped(self, reason: str) -> _CheckOutcomeFinalize a terminal SKIP. Errors if any problems were reported.
#found
def found(self, message: str) -> _CheckOutcomeFinalize an outcome carrying the accumulated problems.
Errors when nothing was reported -- nothing found means pass, so say so explicitly with passed().
#WarnReporter
Reporter handed to warn-severity check impls.
Can mint warn-severity problems and terminal outcomes but structurally LACKS error-minting: there is no error method, so a warn check cannot produce an error-severity problem and can never cascade.
#ErrorReporter
Reporter handed to error-severity check impls.
Everything WarnReporter has PLUS error (mints an error-severity problem).
#error
def error(self, text: str) -> NoneMint an error-severity problem. Non-empty text required.
#SkipCheck
Directive a scope adapter returns to skip a check with a reason.
The adapter can no longer mint arbitrary outcomes -- it either returns a replacement context (context projection) or this skip directive.
#CheckRunResult
A named check outcome returned by App.run_checks().
The verdict is derived from the minted outcome; the runner's exit/cascade logic and the formatters all consume these same accessors (one source of truth).
#status
def status(self) -> strDerived label: "pass", "fail", "warn", or "skip".
#message
def message(self) -> strThe outcome's human-readable message.
#problems
def problems(self) -> tuple[_CheckProblem, ...]The minted problems (error and warn severity) from this check run.
#notes
def notes(self) -> tuple[str, ...]Informational notes recorded during the check run (verdict-inert).
#gated
def gated(self) -> boolWhether the outcome carries an error-severity problem (derived FAIL).
#warned
def warned(self) -> boolWhether the outcome carries only warn-severity problems (derived WARN).
#CheckContext
Minimal interface that tool-specific check contexts must satisfy.
#ConnectionEnvReader
OPTIONAL capability a check context may expose: the value of a declared connection env (connection_env), read live -- EXCEPT under --hermetic, where it resolves as absent (None, False) so a check can skip visibly instead of connecting. The check command wraps the tool-supplied check context in a value that satisfies this protocol, backed by the app's declared connection envs and the invocation's hermetic state. Checks that need a connection URL call ctx.connection_env_value("DATABASE_URL").
is_hermetic() reports whether the invocation ran under --hermetic. It exists so a check can DISTINGUISH the two cases that connection_env_value's present=False otherwise conflates: "--hermetic suppressed the connection env" vs "the env var is simply unset". A check that layers config fallbacks below the env must honor hermetic even when the env is unset -- otherwise it falls through to a config URL and connects, violating the hermetic guarantee::
dsn, present = ctx.connection_env_value("DATABASE_URL") if not present: if ctx.is_hermetic(): return reporter.skipped("hermetic: connection suppressed") # env unset but not hermetic -- config fallback is allowed here
#connection_env_value
def connection_env_value(self, env_var: str) -> 'tuple[str | None, bool]'#is_hermetic
def is_hermetic(self) -> bool#_CheckContextWithConn
Wraps a tool-supplied check context, delegating attribute access while adding connection-env access (hermetic-suppressed) so check functions can read declared connection envs without the tool implementing anything beyond project_root.
#connection_env_value
def connection_env_value(self, env_var: str) -> 'tuple[str | None, bool]'#is_hermetic
def is_hermetic(self) -> boolReport whether the invocation ran under --hermetic. Mirrors the hermetic flag captured when the wrapper was built.
#_CheckDef
Internal definition of a single check loaded from TOML.
#CheckSpec
A fully-formed, ceiling-typed check produced by a check provider.
Opaque by construction: build one only via :func:error_check_spec or :func:warn_check_spec, which bind the reporter form to the declared severity so the impl cannot mint a problem its severity forbids. Providers return lists of these (see :meth:App.register_check_provider).
#error_check_spec
def error_check_spec(*, name: str, tags: list[str], fast: bool, pure: bool, needs_network: bool, depends_on: list[str], impl: Callable, severity: str='error', scope: str='') -> CheckSpecBuild an error-severity check spec for a provider.
impl receives (ctx, reporter) where reporter is an :class:ErrorReporter (can mint both error- and warn-severity problems). severity must be "error" -- a mismatch is a hard error at materialization (the provider analog of the TOML/register severity check).
#warn_check_spec
def warn_check_spec(*, name: str, tags: list[str], fast: bool, pure: bool, needs_network: bool, depends_on: list[str], impl: Callable, severity: str='warn', scope: str='') -> CheckSpecBuild a warn-severity check spec for a provider.
impl receives (ctx, reporter) where reporter is a :class:WarnReporter, which structurally lacks error-minting: a warn check cannot cascade. severity must be "warn".
#App
The root CLI application.
#_validate_flag_infra_marker
def _validate_flag_infra_marker(self, f: Flag) -> NonePanic if a flag's default is a RelativeToRoot marker referencing an undeclared root. Called at registration for construction-time errors.
#_infra_access
def _infra_access(self, hermetic: bool=False) -> '_InfraAccess | None'Snapshot infra data for a Context: resolved roots + declared handshake env var names + declared connection env var names. Connection envs are suppressed when hermetic is True. Returns None when nothing is declared.
#_record_coverage
def _record_coverage(self, cmd_path: str) -> NoneAppend a coverage record for the resolved command path.
Each test() or call() invocation appends one JSONL line to the process's shard file (named "
#_collect_all_command_paths
def _collect_all_command_paths(self) -> set[str]Enumerate all non-deprecated leaf command paths as dotted strings.
#_collect_all_commands
def _collect_all_commands(self) -> list[tuple[str, 'Command']]Enumerate (dotted path, Command) pairs in registration order.
#_test_coverage_provider
def _test_coverage_provider(self) -> list[CheckSpec]Built-in check provider for cli-test-coverage.
Registered automatically when test_coverage=True. The verdict is derived from committed state: the covered set is the union of the committed manifest (.strictcli/test-coverage.json) and any per-process shard files merged from .strictcli/coverage/. Every live registered command path (minus the injected check command) must be present in that union to pass; otherwise the check fails naming each uncovered command.
Because the verdict reads the committed manifest, it is deterministic on every machine -- a machine that never ran the suite (no local shards) still gets a stable verdict from the committed manifest alone. Both the coverage dir and the manifest path are anchored to the App's construction-time cwd, so the check evaluated from a foreign cwd reads the app's own repo state.
The manifest is rewritten as the monotonic union of its prior contents and the freshly merged shards, but ONLY when that content actually changes -- a pure check must not dirty a byte-identical file. Accepted staleness: deleting a test leaves its command covered in the manifest until the manifest is deliberately regenerated (e.g. by removing it and re-running the suite), because the union never removes a command.
#_effects_bypass_provider
def _effects_bypass_provider(self) -> list[CheckSpec]Built-in check provider for the three effects-regime lints.
Registered whenever the check system turns on, so a consumer that adopts checks at all gets all three without a TOML declaration:
effects-bypass(error) fails on any direct process,
filesystem-mutation or network call REACHABLE FROM A REGISTERED COMMAND HANDLER. Its remediation is always "route it through ctx.effects", so a leaf the handle could not carry must never be a finding: the handle's closed method set has no in-process-observe method, which is why platform.system() is exempt while os.system(...) is not (see :data:_BYPASS_PROCESS_OS_ONLY);
observe-allowlist-breadth(warn) surfaces short
proc_observe_allowlist prefixes, which authorize real execution under --dry-run;
consequential-grant-agreement(warn) surfaces commands that
declare a process- or network-mutating grant but do not declare themselves consequential.
#config_file_path
def config_file_path(self) -> strReturn the resolved config file path for this app.
#dump_schema_dict
def dump_schema_dict(self) -> dictReturn the app's full schema as a dict, excluding project_id.
This is the public, CWD-free accessor for the schema. Unlike the --dump-schema flag (which writes .strictcli/schema.json and derives project_id from pyproject.toml in the current working directory), this method reads only the in-memory App and performs no filesystem or CWD access. The returned dict is byte-identical to the written schema file with the project_id field removed.
#config_field
def config_field(self, name: str, type: type, help: str, default: object=_MISSING) -> ConfigFieldDeclare a typed config file field.
Args:
name: Field name. Dots allowed for TOML sections (e.g. "serve.port").
Names starting with underscore are reserved for framework fields.
type: Field type — str, bool, int, or float.help: Help text describing the field.default: Default value. If omitted, the field is required.
Returns:
- The registered ConfigField.
Raises:
ValueError: If the name is invalid, duplicated, reserved, or
the default doesn't match the declared type.
#_register_framework_field
def _register_framework_field(self, name: str, type: type, help: str) -> ConfigFieldRegister a framework-owned config field (e.g. _schema_version).
Framework fields must start with underscore. They are declared by the framework, not the user, and cannot conflict with user fields.
#error_check
def error_check(self, name: str) -> Callable[[F], F]Decorator registering an error-severity check implementation.
The decorated function takes (ctx, reporter) where reporter is an :class:ErrorReporter (annotate it as such for mypy binding). It must return a :class:_CheckOutcome obtained from that reporter. The check must be declared severity = "error" in checks.toml.
#warn_check
def warn_check(self, name: str) -> Callable[[F], F]Decorator registering a warn-severity check implementation.
The decorated function takes (ctx, reporter) where reporter is a :class:WarnReporter (which structurally lacks error, so a warn check cannot cascade). The check must be declared severity = "warn".
#_make_check_decorator
def _make_check_decorator(self, name: str, form: str) -> Callable[[F], F]Build the shared registration decorator for error/warn checks.
Enforces the double-entry contract (declared vs registered) and cross-checks the registration FORM against the TOML-declared severity so that @app.error_check on a severity="warn" definition is a hard error.
#_validate_check_registrations
def _validate_check_registrations(self) -> str | NoneValidate that all declared checks have registered implementations.
Returns an error message if any are missing, or None if all OK.
#tag_contract
def tag_contract(self, tag: str, *, requires_flag: str) -> NoneDeclare that any command with the given tag must have the named flag.
#_validate_tag_contracts
def _validate_tag_contracts(self) -> str | NoneCheck that all tag contracts are satisfied.
Returns an error message if any command violates a contract, or None.
#_resolve_config_data
def _resolve_config_data(self, runtime_path_override: str | None=None, hermetic: bool=False, is_runtime_flag: bool=False) -> _ConfigLoadResultSingle entry point for all config loading.
is_runtime_flag indicates the path came from --config (hard error on missing).
#_validate_config_fields
def _validate_config_fields(self, cmd: Command, config_data: dict) -> str | NoneValidate config file contents against the command's bound config fields.
Checks:
- Each bound required config field exists in config with the correct type.
- Each key in config matches a registered flag, config field, or framework
field. Unknown keys are hard errors.
Returns an error message string, or None if all OK.
#set_check_context
def set_check_context(self, factory: Callable) -> NoneSet the factory function that creates CheckContext for check runs.
The factory is called with no arguments and must return a CheckContext.
#_wrap_check_context
def _wrap_check_context(self, base)Augment a tool-supplied check context with connection-env access (hermetic-suppressed). When no connection envs are declared, the base context is returned unchanged so the common case is unaffected.
#set_scope_adapter
def set_scope_adapter(self, adapter: Callable) -> NoneSet the scope adapter callback for scoped checks.
The adapter is called as adapter(context, scope_string) and must return one of:
- a replacement context object -- used as the check's context (context
projection), or
- a :class:
SkipCheckdirective -- skips the check with the given
reason (no cascade, no exit-code change).
The adapter can no longer mint arbitrary outcomes: it either projects the context or skips. (This is the Python-only scope hook; Go has no scope adapter -- see the note in the Go check.go.)
#register_check_provider
def register_check_provider(self, provider: Callable[[], list[CheckSpec]]) -> NoneRegister a provider that supplies check specs at materialization time.
Three check-system hooks (do not confuse them):
- Check provider (this method) -- REGISTRY POPULATION. A provider
returns a list of fully-formed check specs (metadata + a ceiling-typed impl). Providers are the TOML-less way to add checks: they run lazily at the first registry read (materialization) and their specs go through the same single add-path as TOML-declared checks, so a name colliding with a TOML check or another provider's check is the usual hard error. Registering a provider ENABLES the check system (a TOML-less app with a provider gets a working check command).
- Check-context factory (:meth:
set_check_context) -- PROJECT
CONSTRUCTION. Called once per run to build the CheckContext handed to every check impl. Answers "what project are we checking?".
- Scope adapter (:meth:
set_scope_adapter, Python-only) -- PER-CHECK
CONTEXT PROJECTION. Called per scoped check to project the context or skip the check.
A provider decides WHICH checks exist; the context factory decides WHAT project they see; the scope adapter decides HOW an individual check sees that project.
A provider that returns an empty list is honest-empty (no checks for this context) and a valid no-op. A provider that raises is a hard error in every mode.
Reentrancy: a provider must not trigger check execution during materialization (e.g. by calling :meth:run_checks or the check command). Doing so re-enters materialization while it is in progress -- behavior is undefined (unbounded recursion). A provider's job is to return specs, nothing else.
#reset_check_provider_cache
def reset_check_provider_cache(self) -> NoneDrop provider-sourced definitions and clear the materialization memo.
The next registry read re-runs all providers. Intended for tests and long-lived singletons. Does NOT unregister the providers themselves.
#_materialize_check_providers
def _materialize_check_providers(self) -> NoneRun providers and insert their specs, memoized on the cwd.
Single chokepoint called at the start of every registry read (the check command handler and :meth:run_checks). A repeat call in the same cwd is a cheap no-op; a cwd change re-runs the providers (dropping the previous provider-sourced defs first).
#run_checks
def run_checks(self, context: CheckContext, *, tag_expr: str | None=None, name_glob: str | None=None, run_all: bool=False, ignore_warnings: bool=False, pure_only: bool=False) -> tuple[list[CheckRunResult], list[str], int]Run checks programmatically with filtering and dependency resolution.
Returns (results, impure_listed, exit_code):
- results: the executed checks as a list of CheckRunResult.
- impure_listed: the ordered names of checks NOT executed because of the
purity partition (empty unless pure_only is set). Listed checks contribute nothing to the exit code -- a consumer renders them as e.g. "would run: <name> (impure)".
- exit_code: 0 if all executed checks pass (or all warn with
ignore_warnings), else 1.
With pure_only set, only checks that are declared pure AND do not need network access execute; every other selected check (including a pure check that depends on a listed one) is listed instead. The default (pure_only False) is byte-identical to the previous behavior.
#_enable_checks
def _enable_checks(self) -> NoneTurn on the check system exactly once.
Flips _checks_enabled, initializes the check registry if it is not already present, and registers the auto-generated check command a single time. Idempotent: calling it again is a no-op. Callers (currently the TOML-loading branches) route through this so that future check sources share the same enablement path.
#_add_check_def
def _add_check_def(self, cdef: _CheckDef) -> NoneSingle internal insertion point for check definitions.
Rejects duplicate names as a hard error and inserts the definition into the registry. TOML loading routes through here; this is also the future insertion point for provider-sourced definitions.
#_register_check_command
def _register_check_command(self) -> NoneRegister the auto-generated 'check' command when checks.toml exists.
#command
def command(self, name: str, *, help: str, effect: str | None=None, consequential: bool=False, dry_run_supported: bool=True, dry_run_unsupported_reason: str | None=None, args: list[Arg] | None=None, flag_sets: list[FlagSet] | None=None, mutex: list[MutexGroup] | None=None, dependencies: list[CoRequired | Requires | Implies] | None=None, passthrough: Passthrough | None=None, grants: list[Grant] | None=None, forwarding: Forwarding | None=None, tags: set[str] | None=None, hidden: bool=False, interactive: bool=False, config_fields: list[str] | None=None) -> Callable[[F], F]Decorator to register a top-level command.
#group
def group(self, name: str, *, help: str, tags: set[str] | None=None, hidden: bool=False) -> GroupCreate and register a command group.
#deprecate
def deprecate(self, name: str, *, message: str, effect: str | None=None) -> NoneRegister a deprecated top-level command.
Deprecated entries are classification-EXEMPT: they have no handler and execute nothing, so passing effect= is a registration-time error.
#_collect_all_flags
def _collect_all_flags(self) -> list[Flag]Collect all flags (global + all commands in all groups), for config show.
#_colliding_config_fields
def _colliding_config_fields(self) -> dict[str, ConfigField]Return {flag_param_name: ConfigField} for config fields whose name equals a flag's param name.
Such config fields are validation-only: they annotate the colliding flag rather than rendering as a separate config key. Callers use this to render the key once (on the flag line, with the config field's help as a trailing annotation).
#_confirm_consequential
def _confirm_consequential(self, cmd: 'Command', cmd_path: str) -> NoneThe framework-owned confirm protocol.
Fires before dispatching a command that DECLARES ITSELF consequential, on the real CLI path, when neither --dry-run nor --approve-consequential was passed. A plain mutating command never prompts: classification answers "should a dry run record rather than execute?", which is a different question from "are these effects worth interrupting someone for?". Never fires on the programmatic paths (test/call/_invoke/MCP), which have no TTY contract and would hang.
A consequential PASSTHROUGH is not exempt: the framework knows LESS about what is about to happen, not more.
#_arm_effects
def _arm_effects(self, cmd: 'Command', cmd_path: str, *, dry_run: bool) -> '_Effects'Arm the effects handle for one dispatch (the runtime seal).
Called at EVERY ctx-construction site that dispatches a handler, so there is no path on which ctx.effects is missing or a carrier escapes unpoisoned. The log itself is reset by :meth:_begin_dispatch, which runs earlier so pre-handler CACHE_WRITEs (coverage shards) land in the same dispatch's log.
#_begin_dispatch
def _begin_dispatch(self) -> NoneStart a new dispatch: reset the structured effect log.
#_render_dry_log
def _render_dry_log(self, cmd_path: str, *, aborted: bool) -> NoneWrite the would-do log for a dry run. No-op outside dry mode.
Called on every exit path out of a dispatch, so a handler that leaves through sys.exit or an exception still shows the preview it was asked for. The log always goes to stdout and is never suppressed by --quiet: it is dry mode's primary output.
aborted marks a dispatch that did not finish. The log is still written -- the recorded effects are owed either way -- and the marker that follows it on stderr says the reader cannot assume the list is the whole preview. The truncation path (which ends the preview for its own pinned reason) renders itself and never comes through here.
#_record_cache_write
def _record_cache_write(self, path: str) -> NoneRecord a framework-blessed CACHE_WRITE.
The closed list of sites is exactly three: the schema dump, the test-coverage shards, and the test-coverage manifest. CACHE_WRITEs have no public method, never appear in the would-do log, never trip read-only enforcement, and EXECUTE even in dry mode -- which is why they always carry recorded: false.
#effect_log
def effect_log(self) -> list[dict]Return the structured effect records of the most recent dispatch.
Test-only surface, beside test() and _last_sources.
#_build_framework_command
def _build_framework_command(self, name: str, *, help: str, effect: str, handler: Callable, args: list[Arg] | None=None, mutex: list[MutexGroup] | None=None, extra_flags: list[Flag] | None=None, interactive: bool=False) -> CommandBuild one of strictcli's own auto-registered commands.
Framework-internal commands (check and the five config subcommands) go through the same single validated registration path as every consumer command -- there is no direct-Command-construction bypass left. Their handlers absorb the app's app-defined global flag values through **kwargs, which is legal only because they declare forwarding, and the private _framework_internal marker (unreachable from any public factory) makes the framework verify that the handler is actually defined in this module.
#_register_config_group
def _register_config_group(self) -> NoneRegister the auto-generated 'config' command group.
#_pre_scan_reserved_flags
def _pre_scan_reserved_flags(self, argv: list[str]) -> dictPre-scan for the framework-owned reserved flags.
Handles --dump-schema, --mcp, --config, --hermetic and the effects-regime quartet --dry-run/--approve-consequential/--quiet/--verbose.
Two regions, two rulesets (contract §7.2, amended):
- The pre-command region (before the first non-flag token, before
--) recognizes every reserved flag. Known global flags and their values are skipped so that a global-flag value matching a command name does not terminate the region early.
- The command region recognizes ONLY the quartet, anywhere, exactly
like --help/-h. --hermetic/--config/--dump-schema/--mcp stay pre-command-only. See _scan_command_region_quartet.
Returns a dict with keys: dump_schema, serve_mcp, hermetic, config_path, dry_run, approve_consequential, quiet, verbose, err, cleaned_argv.
#_scan_command_region_quartet
def _scan_command_region_quartet(self, argv: list[str], start: int, result: dict, exclude_indices: set[int]) -> NoneRecognize the reserved quartet in the command region of argv.
Contract §7.2 (amended 2026-08-04): the quartet's four tokens are recognized ANYWHERE in argv, exactly like --help/-h, because their applicability is per-command -- requiring them before the command name was backwards. Only the quartet is recognized here; --hermetic, --config, --dump-schema and --mcp remain pre-command-only.
The scan stops for good at two boundaries:
- a bare
--, after which every token is positional data; - a passthrough command's name, after which every token belongs to
the child process and is forwarded byte-for-byte. Eating a child's own --verbose would silently change what the child does.
Routing tokens are walked through the group/command tree so a quartet token may sit anywhere among them. Nothing here raises: routing errors are the real parse's job.
Both boundaries are visible in the dry_run_supported=False refusal, which reads the flag this scan resolved. app cmd -- --dry-run and app passthrough --dry-run are NOT refused, because in neither case did the operator ask this app for a dry run: after -- the token is the command's own data, and after a passthrough's name it is the child process's flag. app --dry-run passthrough IS refused -- there the token is unambiguously addressed to this app.
#_parse
def _parse(self, argv: list[str]) -> tuple[Command, dict[str, object] | list[str], dict[str, str]]Parse argv (without program name) into a resolved Command and kwargs.
For normal commands, returns (Command, kwargs_dict, sources). For passthrough commands, returns (Command, raw_args_list, {}). Callers disambiguate by checking cmd.passthrough.
After parsing, self._last_global_values holds the parsed global flag values (used by passthrough command handlers).
#_resolve_command
def _resolve_command(self, path_segments: list[str]) -> tuple[Command, list[str], list[str]]Traverse groups/commands tree to resolve a command from path segments.
Takes the remaining argv tokens after global flag parsing (group names, command name, and command arguments). Consumes group and command tokens from the front, returning the resolved Command, the unconsumed tokens (command arguments), and the list of group names traversed.
Raises _HelpRequested for group-level help and _ParseError for deprecated or unknown commands.
#_parse_global_flags
def _parse_global_flags(self, argv: list[str], *, hermetic: bool=False) -> tuple[dict[str, object], dict[str, str], list[str]]Parse global flags from argv, returning (global_values, global_sources, remaining_tokens).
Scans tokens from left to right. Global flags are consumed; the first non-global-flag token (the command name) and everything after it are returned as remaining tokens. A bare -- stops global flag parsing and is included in the remaining tokens.
When hermetic is True, env var and config resolution are skipped entirely.
#_find_command_prefix
def _find_command_prefix(self, cmd: Command) -> strFind the group prefix for a command (for help formatting).
Traverses the group tree recursively to find the full path.
#run
def run(self) -> NoneRun the CLI application, reading from sys.argv.
#test
def test(self, argv: list[str]) -> ResultRun the CLI with given argv, capturing output and exit code.
#_invoke
def _invoke(self, command_path: str, kwargs: dict[str, object], *, approve_consequential: bool=False) -> objectInvoke a command programmatically with pre-typed kwargs.
This is the internal pipeline for programmatic invocation. It bypasses CLI parsing, env var resolution, config file loading, and stdin handling. The caller provides fully-typed values directly.
Args:
command_path: dot-separated path to the command
(e.g. "deploy" or "config.set").
kwargs: handler keyword arguments. Flag names use underscores
(e.g. dry_run). Positional args use their declared name. For passthrough commands, pass a single key "_args" with a list of raw string arguments.
approve_consequential: the caller's explicit consent. A command
that declares itself consequential is refused without it.
Returns:
- The handler's return value (structured data, int, or None).
Raises:
_ParseError: if validation fails (missing required flags,
mutex violations, dependency errors, etc.), or if the command is consequential and no consent was supplied.
_HelpRequested: if the command path resolves to a group
with no subcommand.
#call
def call(self, command_path: str, *, approve_consequential: bool=False, **kwargs: object) -> objectInvoke a command programmatically and return its result.
Unlike _invoke(), this is the public API. It converts internal _ParseError exceptions to InvokeError so callers don't need to depend on private types.
Args:
command_path: dot-separated path to the command
(e.g. "deploy" or "config.set").
approve_consequential: the caller's explicit consent, the
programmatic counterpart of --approve-consequential. Keyword-only, and never a handler kwarg: the name is framework-reserved, so no command can declare a parameter that collides with it. A command that declares itself consequential is refused without it. Read-only and plain mutating commands ignore it.
**kwargs: handler keyword arguments. Flag names use underscores
(e.g. dry_run). Positional args use their declared name. For passthrough commands, pass _args=[...] for raw arguments.
Returns:
- The handler's return value (structured data, int, or None).
Raises:
InvokeError: if validation fails (unknown command, missing
required flags, mutex violations, dependency errors, etc.), or if the command is consequential and no consent was given.
#_call_with_kwargs
def _call_with_kwargs(self, command_path: str, kwargs: dict[str, object], *, approve_consequential: bool) -> objectcall() with the handler kwargs as a dict instead of a splat.
The MCP server routes through here rather than call(**arguments): an approve_consequential key inside a tools/call arguments object is a parameter of the command's own namespace -- no command can declare that reserved name, so it must surface as the usual unknown-parameter error, exactly as it does in the siblings whose kwargs are a map. Splatting it would silently promote it to consent.
#acall
async def acall(self, command_path: str, *, approve_consequential: bool=False, **kwargs: object) -> objectAsync version of call(). Runs the handler in a thread.
Args:
command_path: dot-separated path to the command.approve_consequential: the caller's explicit consent (same as
call()).
**kwargs: handler keyword arguments (same as call()).
Returns:
- The handler's return value (structured data, int, or None).
Raises:
InvokeError: if validation fails, or if the command is
consequential and no consent was given.
#json_schema
def json_schema(self, command_path: str) -> dictProduce a JSON Schema parameters object for a command's flags and args.
Args:
command_path: dot-separated path to the command (e.g. "deploy"
or "config.show").
Returns:
- A JSON Schema object with "type": "object", "properties",
- "required", and "additionalProperties": false.
Raises:
InvokeError: if the command path is invalid or resolves to a group.
#as_tools
def as_tools(self) -> list[Tool]Export non-hidden, non-interactive leaf commands as Tool descriptors.
Returns a list of Tool objects, one per eligible command plus a router tool. Each tool's execute function wraps acall().
#_collect_tools_from_group
def _collect_tools_from_group(self, group: Group, path: list[str], tools: list[Tool], command_paths: list[str]) -> NoneRecursively collect non-hidden, non-interactive commands from a group.
#_make_tool
def _make_tool(self, command_path: str, cmd: Command) -> ToolBuild a Tool for a single command.
#_make_router_tool
def _make_router_tool(self, command_paths: list[str]) -> ToolBuild the router tool that dispatches to per-command tools.
#serve_mcp
def serve_mcp(self, *, input: io.TextIOBase | None=None, output: io.TextIOBase | None=None) -> NoneRun a JSON-RPC 2.0 MCP server on stdin/stdout.
Reads one JSON object per line from input (default: sys.stdin), writes one JSON object per line to output (default: sys.stdout). Handles initialize, tools/list, tools/call, and notifications.
The server runs until input is exhausted (EOF).
#_build_json_schema
def _build_json_schema(cmd: Command) -> dictBuild a JSON Schema parameters object for a command's flags and args.
#_tokens_contain_help
def _tokens_contain_help(tokens: list[str]) -> boolCheck if --help or -h appears in tokens before any -- separator.
#_validate_choices
def _validate_choices(name: str, val: object, repeatable: bool, choices: list | None, *, is_arg: bool=False) -> NoneValidate a resolved flag or arg value against its choices list.
Raises _ParseError on an invalid value. is_arg selects the message prefix ("argument 'name':" instead of "--name:"); the two f-strings are kept as full literals so conformance/check_error_parity.py can extract them. A None value is exempt from validation: None only arises when the flag or arg was not passed (an unset mutex flag, or default=None on an arg) -- a CLI-supplied value is never None.
#_validate_and_build_kwargs
def _validate_and_build_kwargs(cmd: Command, store: _SourcedStore, positionals: list[str], global_flag_names: set[str], infra_roots: dict[str, str] | None=None) -> tuple[Command, dict[str, object], dict[str, object], dict[str, str]]Validate parsed values and build the kwargs dict for the command handler.
This is the second half of command parsing: mutex enforcement, implies resolution, dependency checks, defaults, choices validation, custom validation, positional arg resolution, and kwargs building. It operates on sourced values in the store and doesn't care how they were produced.
Returns (cmd, kwargs, global_cli_set, sources) where sources maps flag param names to source labels (cli/env/config/default/implied).
#_parse_command
def _parse_command(cmd: Command, tokens: list[str], global_flags: list[Flag] | None=None, config_data: dict | None=None, stdin_consumed_by: list[str | None] | None=None, conflict_mode: str='cli-wins', hermetic: bool=False, infra_roots: dict[str, str] | None=None) -> tuple[Command, dict[str, object], dict[str, object], dict[str, str]]Parse tokens against a resolved command's flags and args.
Returns (cmd, kwargs, global_cli_set, sources) where global_cli_set contains any global flag values parsed from tokens appearing after the command name.
stdin_consumed_by is a mutable single-element list tracking which flag has already consumed stdin via @-. Updated in-place.
When hermetic is True, env var and config resolution are skipped entirely.
#_flag_param_name
def _flag_param_name(flag_name: str) -> strConvert a flag name like '--dry-run' to a Python parameter name 'dry_run'.
If the result is a Python keyword (e.g. 'global', 'class'), appends '_' per PEP 8 convention (e.g. 'global_', 'class_').
#_build_and_validate_command
def _build_and_validate_command(name: str, *, help: str, effect: str | None, consequential: bool=False, dry_run_supported: bool=True, dry_run_unsupported_reason: str | None=None, handler: Callable, args: list[Arg] | None, flag_sets: list[FlagSet] | None, mutex: list[MutexGroup] | None, dependencies: list[CoRequired | Requires | Implies] | None=None, env_prefix: str | None, global_flags: list[Flag] | None=None, passthrough: Passthrough | None=None, grants: list[Grant] | None=None, forwarding: Forwarding | None=None, framework_internal: bool=False, extra_flags: list[Flag] | None=None, tags: set[str] | None=None, inherited_tags: frozenset[str] | None=None, hidden: bool=False, interactive: bool=False, config_fields: list[str] | None=None, config_fields_ref: dict[str, ConfigField] | None=None, infra_root_names: frozenset[str] | None=None, connection_env_names: frozenset[str] | None=None) -> CommandBuild a Command from a decorated handler, validate everything.
This is the single registration path: every command in every app -- including strictcli's own framework-internal check and config commands -- is built here, so classification, signature validation and flag validation are unbypassable.
#flag
def flag(name: str, *, short: str | None=None, type: type=str, default: object=_MISSING, help: str, env: str | None=None, env_separator: str | None=None, prefixed: bool=True, negatable: object=_MISSING, choices: list | None=None, validate: Callable | None=None, repeatable: bool=False, unique: object=_MISSING, conflict_mode: object=_MISSING, connection_url: bool=False, connection_env: str | None=None) -> Callable[[F], F]Module-level decorator to attach a Flag to a command handler.
#arg
def arg(name: str, *, help: str, required: bool=True, default: object=_MISSING, variadic: bool=False, type: type=str, choices: list | None=None) -> Callable[[F], F]Module-level decorator to attach an Arg to a command handler.
#_format_version
def _format_version(app: App) -> strFormat version string: '{name} {version}'.
#_format_app_help
def _format_app_help(app: App) -> strFormat app-level help shown when the user runs 'myapp --help'.
#_format_group_help
def _format_group_help(app: App, group: Group, path: list[str] | None=None) -> strFormat group-level help shown when the user runs 'myapp group --help'.
path is the list of group names leading to this group (e.g. ['dns', 'zone']). When None, the path is computed by searching the app's group tree.
#_find_group_path
def _find_group_path(app: App, target: Group) -> list[str]Find the full path (list of group names) from app root to the target group.
#_build_flag_spec
def _build_flag_spec(f: Flag) -> strBuild the left-column spec string for a flag (e.g. '--target, -t
#_build_flag_meta
def _build_flag_meta(f: Flag) -> strBuild the bracketed metadata suffix for a flag.
#_format_dry_run_section
def _format_dry_run_section(cmd: Command) -> list[str]The Dry run: section of command help, or nothing.
Rendered only for a command that declares dry_run_supported=False: the baseline (dry run works) needs no announcement, and a section on every command would be noise. Byte-identical across implementations.
#_format_command_help
def _format_command_help(app: App, cmd: Command, prefix: str='') -> strFormat command-level help shown when the user runs 'myapp cmd --help'.
#_tagdsl_tokenize
def _tagdsl_tokenize(expr: str) -> list[tuple[str, str, int]]Tokenize a tag expression into (type, value, position) tuples.
#_tagdsl_parse
def _tagdsl_parse(tokens: list[tuple[str, str, int]]) -> tupleParse tag expression tokens into an AST using recursive descent.
Precedence (tightest first): NOT, AND, XOR, OR, DIFF.
#_tagdsl_evaluate
def _tagdsl_evaluate(ast: tuple, tags: set[str]) -> boolEvaluate a tag DSL AST against a set of tags.
#_match_tag_expr
def _match_tag_expr(expr: str, tags: set[str]) -> boolEvaluate a tag expression against a set of tags. Returns bool.
#_filter_checks
def _filter_checks(check_defs: dict[str, _CheckDef], tag_expr: str | None, name_glob: str | None, run_all: bool) -> set[str]Filter checks by tag expression and/or name glob.
Returns the set of selected check names.
#_resolve_check_order
def _resolve_check_order(check_defs: dict[str, _CheckDef], selected: set[str]) -> list[str]Resolve execution order via topological sort, pulling in dependencies.
If a selected check depends on an unselected check, the dependency is pulled into the execution set. Raises ValueError on cycles.
#_find_cycle
def _find_cycle(check_defs: dict[str, _CheckDef], nodes: set[str]) -> strFind and format a cycle among the given nodes for error reporting.
#_check_is_pure
def _check_is_pure(cdef: _CheckDef) -> boolWhether a check is executable under the purity partition: declared pure AND not requiring network access. Everything else is "impure".
#_run_checks
def _run_checks(check_defs: dict, check_names: list[str], context: CheckContext, ignore_warnings: bool, scope_adapter: object | None=None, pure_only: bool=False) -> tuple[list[tuple[str, _CheckOutcome, int]], list[str], int]Execute checks in order, skipping dependents of gated (FAIL) checks.
Returns (results_list, impure_listed, exit_code). Each results_list entry is (name, outcome, duration_ms) where duration_ms is the wall-clock time in integer milliseconds spent inside the impl (0 for non-executed checks). impure_listed holds the ordered names of checks left unexecuted by the purity partition (empty unless pure_only=True); listed checks contribute nothing to the exit code. exit_code is 0 if all executed checks pass (or all warn with ignore_warnings=True), 1 otherwise.
Purity partition (pure_only): only pure, non-network checks execute; every other check is listed. A check also joins the listing if any dependency was listed (its precondition cannot be verified). The failed-dependency cascade takes precedence over the listing.
#_check_list_mode
def _check_list_mode(check_defs: dict[str, _CheckDef], json_mode: bool) -> NonePrint check listing in human or JSON format.
#_check_dry_run_mode
def _check_dry_run_mode(check_defs: dict[str, _CheckDef], order: list[str]) -> NonePrint execution plan without running checks.
#format_check_results
def format_check_results(results: list[CheckRunResult], verbose: bool=False) -> strFormat check results as a human-readable aligned string.
Shows the derived status label, name, and message, with minted problems listed under the check row grouped by severity (error problems first, then warn problems), each tagged with its severity. Problems appear for fail/warn/skip outcomes or when verbose is True.
#format_check_results_json
def format_check_results_json(results: list[CheckRunResult]) -> strFormat check results as a JSON string.
Each entry carries the derived status plus the minted problems (each with its severity and text). Problems serialize as [] when empty.
#_serialize_flag
def _serialize_flag(f: Flag) -> dictSerialize a Flag to a JSON-serializable dict.
Identity fields (name, type, help) are always included. Other fields are omitted when they match the schema defaults.
#_serialize_arg
def _serialize_arg(a: Arg) -> dictSerialize an Arg to a JSON-serializable dict.
Identity fields (name, help) are always included. Other fields are omitted when they match the schema defaults.
#_serialize_command
def _serialize_command(cmd: Command) -> dictSerialize a Command to a JSON-serializable dict.
Identity fields (name, help) are always included. Other fields are omitted when they match the schema defaults.
#_serialize_group
def _serialize_group(group: Group) -> dictSerialize a Group to a JSON-serializable dict (recursive).
Identity fields (name, help) are always included. Other fields are omitted when they match the schema defaults.
#_build_schema_defaults
def _build_schema_defaults() -> dictReturn the defaults object documenting what 'missing' means in the schema.
#_read_project_id
def _read_project_id() -> strRead project name from pyproject.toml in the current working directory.
#_collect_config_field_bindings
def _collect_config_field_bindings(commands: dict[str, Command], bindings: dict[str, list[str]], path: list[str]) -> NoneWalk commands and record which commands bind each config field.
#_collect_config_field_bindings_from_group
def _collect_config_field_bindings_from_group(group: Group, bindings: dict[str, list[str]], path: list[str]) -> NoneRecursively walk groups to collect config field bindings.
#_dump_schema_core
def _dump_schema_core(app: App) -> dictBuild the full schema dict, excluding project_id.
This is the CWD-free, filesystem-free core of schema production. It reads only the in-memory App (name, version, help, flags, commands, groups, etc.). project_id is added later by the file-writer path, since it is the only field that requires reading pyproject.toml from the CWD.
Fields whose values match the schema defaults are omitted. The top-level defaults key documents what each missing field means.
#_dump_schema
def _dump_schema(app: App) -> dictProduce the full schema dict including project_id (reads the CWD).
Delegates the bulk of the work to :func:_dump_schema_core and inserts project_id immediately after defaults so the on-disk layout is stable and byte-identical to the core dict once project_id is removed.
#_check_schema_project_id
def _check_schema_project_id(file_path: str, new_project_id: str) -> NoneVerify that an existing schema file belongs to the same project.
Raises RuntimeError on mismatch. Silently passes on: missing file, unreadable file, JSON without project_id field, or matching project_id.
#_write_schema
def _write_schema(app: App) -> strWrite the schema to .strictcli/schema.json and return the path.
#_mcp_collect_commands
def _mcp_collect_commands(app: App) -> dict[str, tuple[Command, str]]Collect non-hidden, non-interactive leaf commands as {dotted_path: (cmd, help)}.
Returns a dict mapping dotted command paths to (Command, help_text) tuples.
#_mcp_jsonrpc_error
def _mcp_jsonrpc_error(req_id: object, code: int, message: str) -> dictBuild a JSON-RPC 2.0 error response.
#_mcp_handle_initialize
def _mcp_handle_initialize(app: App, req_id: object) -> dictHandle the MCP 'initialize' request.
#_mcp_handle_tools_list
def _mcp_handle_tools_list(app: App, commands: dict[str, tuple[Command, str]], req_id: object) -> dictHandle the MCP 'tools/list' request.
#_mcp_handle_tools_call
def _mcp_handle_tools_call(app: App, req_id: object, params: dict) -> dictHandle the MCP 'tools/call' request.
#_run_mcp_server
def _run_mcp_server(app: App, *, input: io.TextIOBase | None=None, output: io.TextIOBase | None=None) -> NoneRun the MCP JSON-RPC 2.0 server loop.
Reads one JSON object per line from input, writes responses to output. Notifications (no 'id' field) get no response.
#PROC_MUTATE
PROC_MUTATE = 'proc_mutate'#PROC_SPAWN
PROC_SPAWN = 'proc_spawn'#FILE_WRITE
FILE_WRITE = 'file_write'#NET_MUTATE
NET_MUTATE = 'net_mutate'