strictcli 0.35.4__tar.gz → 0.37.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {strictcli-0.35.4/.rlsbl/bases → strictcli-0.37.0}/.github/workflows/publish.yml +12 -4
- {strictcli-0.35.4 → strictcli-0.37.0/.rlsbl/bases}/.github/workflows/publish.yml +12 -4
- {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/managed-files.json +1 -1
- strictcli-0.37.0/.rlsbl/version +1 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/PKG-INFO +140 -48
- {strictcli-0.35.4 → strictcli-0.37.0}/README.md +139 -47
- {strictcli-0.35.4 → strictcli-0.37.0}/pyproject.toml +1 -1
- {strictcli-0.35.4 → strictcli-0.37.0}/strictcli/__init__.py +317 -31
- strictcli-0.37.0/tests/test_confirm.py +349 -0
- strictcli-0.37.0/tests/test_dry_run_unsupported.py +263 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_effects.py +1 -1
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_effects_bypass_check.py +132 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_reserved_quartet.py +57 -13
- {strictcli-0.35.4 → strictcli-0.37.0}/uv.lock +1 -1
- strictcli-0.35.4/.rlsbl/version +0 -1
- strictcli-0.35.4/tests/test_confirm.py +0 -191
- {strictcli-0.35.4 → strictcli-0.37.0}/.claude/settings.json +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/.github/workflows/ci.yml +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/.gitignore +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/bases/.gitignore +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/config.json +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/lint/python.toml +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/.strictcli/schema.json +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/CLAUDE.md +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/LICENSE +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/scripts/add_effect_classification.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/scripts/add_forwarding_declaration.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/strictcli/py.typed +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/conftest.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/flagship_app.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_arg_default.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_arg_default_validation.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_at_prefix.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_auto_version.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_call.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_command.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_discovery.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_provider.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_public_api.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_runner.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_schema.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_types.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_choices.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_choices_none.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_classification.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_command_help_suggestion.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_command_tags.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_compound_types.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_config.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_config_fields.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_config_file_path.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_config_set_bugs.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_connection_env.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_context.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_coverage.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_deep_nesting.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_dependencies.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_deprecated.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_dump_schema.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_e2e.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_env.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_exit_codes.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_flag_sets.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_flagship_preview.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_float_format.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_float_type.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_float_vectors.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_global_flag_conflict_position.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_global_flags.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_guard_v2.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_help.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_hermetic.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_infra_env.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_int_type.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_invoke.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_keyword_flags.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_mcp.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_mutex.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_nesting.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_parser.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_passthrough.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_provenance.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_provenance_phase2.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_registration.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_repeatable.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_reserved_global_flags.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_tagdsl.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_toml_loading.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_tool_export.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_typed_args.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_unique.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_utilities.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_validate.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_variadic.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_visibility.py +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/todo/.defer/deferred.md +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/todo/.done/original-idea.md +0 -0
- {strictcli-0.35.4 → strictcli-0.37.0}/todo/.done/public-check-runner-api.md +0 -0
|
@@ -25,8 +25,10 @@ jobs:
|
|
|
25
25
|
runs-on: ubuntu-latest
|
|
26
26
|
permissions:
|
|
27
27
|
checks: read
|
|
28
|
+
contents: read
|
|
28
29
|
env:
|
|
29
30
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
31
|
+
GH_REPO: ${{ github.repository }}
|
|
30
32
|
GATE_TIMEOUT_MINUTES: '20'
|
|
31
33
|
GATE_GRACE_MINUTES: '5'
|
|
32
34
|
GATE_POLL_SECONDS: '15'
|
|
@@ -118,7 +120,8 @@ jobs:
|
|
|
118
120
|
if [ "$total" -eq 0 ]; then
|
|
119
121
|
if [ "$(now)" -ge "$grace_deadline" ]; then
|
|
120
122
|
echo "::error::Publish gate: no CI check runs matching $CI_CHECK_REGEX appeared on $sha within $GATE_GRACE_MINUTES minutes."
|
|
121
|
-
echo "A scaffolded repository always has a CI workflow,
|
|
123
|
+
echo "A scaffolded repository always has a CI workflow, and rlsbl verifies CI on this exact commit BEFORE tagging it, so check runs must exist here."
|
|
124
|
+
echo "Their absence means the check runs were deleted, the commit resolution is wrong, or this tag was created outside rlsbl."
|
|
122
125
|
echo "If CI jobs were renamed, update CI_CHECK_REGEX in this workflow's gate job to match the new names."
|
|
123
126
|
exit 1
|
|
124
127
|
fi
|
|
@@ -146,13 +149,18 @@ jobs:
|
|
|
146
149
|
while IFS= read -r conclusion; do
|
|
147
150
|
case "$conclusion" in
|
|
148
151
|
failure|timed_out)
|
|
149
|
-
echo "CI concluded '$conclusion' on the release commit.
|
|
152
|
+
echo "CI concluded '$conclusion' on the release commit."
|
|
153
|
+
echo "rlsbl tags and releases a commit only AFTER its CI has gone green, so reaching this branch means one of: CI was re-run on an already-released commit and regressed, a required check was added after the release, or this tag/Release was created outside rlsbl."
|
|
154
|
+
echo "Do NOT re-dispatch this publish workflow expecting a different answer -- a failure baked into the code at this commit fails identically every time, and there is no way to make this tag green."
|
|
155
|
+
echo "Remedy: fix forward on the release branch and cut a NEW release with 'rlsbl release run' (its own CI gate must go green before it is tagged), then mark this one with 'rlsbl release deprecate <version>'."
|
|
150
156
|
;;
|
|
151
157
|
cancelled)
|
|
152
|
-
echo "A CI check run was CANCELLED. A cancelled run proves nothing about the commit, so the gate treats it as a hard failure instead of waiting for a conclusion that will never come.
|
|
158
|
+
echo "A CI check run was CANCELLED. A cancelled run proves nothing about the commit, so the gate treats it as a hard failure instead of waiting for a conclusion that will never come."
|
|
159
|
+
echo "Remedy: re-run the cancelled CI workflow on this exact commit (gh run rerun <run-id>). If it concludes success, re-dispatch this publish workflow at the tag ref: gh workflow run <publish workflow> --ref $GITHUB_REF_NAME"
|
|
153
160
|
;;
|
|
154
161
|
skipped)
|
|
155
|
-
echo "A CI check run matching the filter was SKIPPED. The gate cannot treat a skipped check as passing: this project
|
|
162
|
+
echo "A CI check run matching the filter was SKIPPED. The gate cannot treat a skipped check as passing: this project must actually run its own CI on the release commit."
|
|
163
|
+
echo "Remedy: check paths filters and job conditions so CI runs for this project, re-run CI on this commit, then re-dispatch this publish workflow at the tag ref."
|
|
156
164
|
;;
|
|
157
165
|
*)
|
|
158
166
|
echo "CI check concluded '$conclusion' (not success). The gate only proceeds when every matching check concluded success."
|
|
@@ -25,8 +25,10 @@ jobs:
|
|
|
25
25
|
runs-on: ubuntu-latest
|
|
26
26
|
permissions:
|
|
27
27
|
checks: read
|
|
28
|
+
contents: read
|
|
28
29
|
env:
|
|
29
30
|
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
|
31
|
+
GH_REPO: ${{ github.repository }}
|
|
30
32
|
GATE_TIMEOUT_MINUTES: '20'
|
|
31
33
|
GATE_GRACE_MINUTES: '5'
|
|
32
34
|
GATE_POLL_SECONDS: '15'
|
|
@@ -118,7 +120,8 @@ jobs:
|
|
|
118
120
|
if [ "$total" -eq 0 ]; then
|
|
119
121
|
if [ "$(now)" -ge "$grace_deadline" ]; then
|
|
120
122
|
echo "::error::Publish gate: no CI check runs matching $CI_CHECK_REGEX appeared on $sha within $GATE_GRACE_MINUTES minutes."
|
|
121
|
-
echo "A scaffolded repository always has a CI workflow,
|
|
123
|
+
echo "A scaffolded repository always has a CI workflow, and rlsbl verifies CI on this exact commit BEFORE tagging it, so check runs must exist here."
|
|
124
|
+
echo "Their absence means the check runs were deleted, the commit resolution is wrong, or this tag was created outside rlsbl."
|
|
122
125
|
echo "If CI jobs were renamed, update CI_CHECK_REGEX in this workflow's gate job to match the new names."
|
|
123
126
|
exit 1
|
|
124
127
|
fi
|
|
@@ -146,13 +149,18 @@ jobs:
|
|
|
146
149
|
while IFS= read -r conclusion; do
|
|
147
150
|
case "$conclusion" in
|
|
148
151
|
failure|timed_out)
|
|
149
|
-
echo "CI concluded '$conclusion' on the release commit.
|
|
152
|
+
echo "CI concluded '$conclusion' on the release commit."
|
|
153
|
+
echo "rlsbl tags and releases a commit only AFTER its CI has gone green, so reaching this branch means one of: CI was re-run on an already-released commit and regressed, a required check was added after the release, or this tag/Release was created outside rlsbl."
|
|
154
|
+
echo "Do NOT re-dispatch this publish workflow expecting a different answer -- a failure baked into the code at this commit fails identically every time, and there is no way to make this tag green."
|
|
155
|
+
echo "Remedy: fix forward on the release branch and cut a NEW release with 'rlsbl release run' (its own CI gate must go green before it is tagged), then mark this one with 'rlsbl release deprecate <version>'."
|
|
150
156
|
;;
|
|
151
157
|
cancelled)
|
|
152
|
-
echo "A CI check run was CANCELLED. A cancelled run proves nothing about the commit, so the gate treats it as a hard failure instead of waiting for a conclusion that will never come.
|
|
158
|
+
echo "A CI check run was CANCELLED. A cancelled run proves nothing about the commit, so the gate treats it as a hard failure instead of waiting for a conclusion that will never come."
|
|
159
|
+
echo "Remedy: re-run the cancelled CI workflow on this exact commit (gh run rerun <run-id>). If it concludes success, re-dispatch this publish workflow at the tag ref: gh workflow run <publish workflow> --ref $GITHUB_REF_NAME"
|
|
153
160
|
;;
|
|
154
161
|
skipped)
|
|
155
|
-
echo "A CI check run matching the filter was SKIPPED. The gate cannot treat a skipped check as passing: this project
|
|
162
|
+
echo "A CI check run matching the filter was SKIPPED. The gate cannot treat a skipped check as passing: this project must actually run its own CI on the release commit."
|
|
163
|
+
echo "Remedy: check paths filters and job conditions so CI runs for this project, re-run CI on this commit, then re-dispatch this publish workflow at the tag ref."
|
|
156
164
|
;;
|
|
157
165
|
*)
|
|
158
166
|
echo "CI check concluded '$conclusion' (not success). The gate only proceeds when every matching check concluded success."
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"version": 1,
|
|
3
3
|
"files": {
|
|
4
4
|
".github/workflows/ci.yml": "bfcdec1fcd8ff944f7ba7eb114ab091d0c5158f499ed4632945c2c4ca2d868df",
|
|
5
|
-
".github/workflows/publish.yml": "
|
|
5
|
+
".github/workflows/publish.yml": "bfa60b4f497b67e71b31336e0373a3730aca9b16e6b35d1f7a3e003ee406c01a",
|
|
6
6
|
".gitignore": "a102f466628d2d28aea6b2157c1b406b1911d788b596b5b84f4952f121539d42",
|
|
7
7
|
".rlsbl/lint/python.toml": "9cfbcef2e010d5fad243437bf2fd8df54a1fae70ee0762f9d11da70fe207501c"
|
|
8
8
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
0.110.2
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: strictcli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.37.0
|
|
4
4
|
Summary: A strict CLI framework for Python
|
|
5
5
|
Project-URL: Homepage, https://github.com/smm-h/strictcli
|
|
6
6
|
Project-URL: Repository, https://github.com/smm-h/strictcli
|
|
@@ -35,25 +35,30 @@ Or with uv:
|
|
|
35
35
|
uv add strictcli
|
|
36
36
|
```
|
|
37
37
|
|
|
38
|
-
Requires Python 3.11+.
|
|
38
|
+
Requires Python 3.11+. One runtime dependency: [tomlkit](https://pypi.org/project/tomlkit/), for comment-preserving TOML config support.
|
|
39
39
|
|
|
40
40
|
## Quickstart
|
|
41
41
|
|
|
42
|
-
```python
|
|
42
|
+
```python validate
|
|
43
43
|
import strictcli
|
|
44
44
|
|
|
45
45
|
app = strictcli.App("greet", version="1.0.0", help="A greeting app")
|
|
46
46
|
|
|
47
|
-
@app.command("hello", help="Say hello")
|
|
47
|
+
@app.command("hello", help="Say hello", effect="read_only")
|
|
48
48
|
@strictcli.flag("name", type=str, help="Who to greet")
|
|
49
|
-
@strictcli.flag("loud", type=bool, help="Shout it")
|
|
50
|
-
def hello(name, loud):
|
|
49
|
+
@strictcli.flag("loud", type=bool, default=False, help="Shout it")
|
|
50
|
+
def hello(ctx, name, loud):
|
|
51
51
|
msg = f"Hello, {name}!"
|
|
52
|
-
|
|
52
|
+
ctx.info(msg.upper() if loud else msg)
|
|
53
53
|
|
|
54
54
|
app.run()
|
|
55
55
|
```
|
|
56
56
|
|
|
57
|
+
Every handler is **ctx-first**: the framework injects a `Context` as the first
|
|
58
|
+
positional argument, and flag and arg values follow as keyword arguments. Every
|
|
59
|
+
command declares its `effect` -- `"read_only"` or `"mutating"` -- and the
|
|
60
|
+
declaration is mandatory.
|
|
61
|
+
|
|
57
62
|
```
|
|
58
63
|
$ python greet.py hello --name World
|
|
59
64
|
Hello, World!
|
|
@@ -65,8 +70,8 @@ $ python greet.py hello --help
|
|
|
65
70
|
greet hello -- Say hello
|
|
66
71
|
|
|
67
72
|
Flags:
|
|
68
|
-
--name <str>
|
|
69
|
-
--loud, --no-loud
|
|
73
|
+
--name <str> Who to greet [required]
|
|
74
|
+
--loud, --no-loud Shout it [default: false]
|
|
70
75
|
```
|
|
71
76
|
|
|
72
77
|
## Features
|
|
@@ -79,9 +84,9 @@ Top-level commands with `@app.command`, nested groups with `app.group`. Groups n
|
|
|
79
84
|
db = app.group("db", help="Database operations")
|
|
80
85
|
schema = db.group("schema", help="Schema management")
|
|
81
86
|
|
|
82
|
-
@schema.command("migrate", help="Run migrations")
|
|
83
|
-
def migrate():
|
|
84
|
-
|
|
87
|
+
@schema.command("migrate", help="Run migrations", effect="mutating")
|
|
88
|
+
def migrate(ctx):
|
|
89
|
+
ctx.info("migrating")
|
|
85
90
|
```
|
|
86
91
|
|
|
87
92
|
Invoked as `myapp db schema migrate`.
|
|
@@ -93,11 +98,11 @@ Invoked as `myapp db schema migrate`.
|
|
|
93
98
|
```python
|
|
94
99
|
@strictcli.flag("port", type=int, help="Port number")
|
|
95
100
|
@strictcli.flag("threshold", type=float, help="Score threshold")
|
|
96
|
-
@strictcli.flag("
|
|
101
|
+
@strictcli.flag("cache", type=bool, default=True, help="Reuse the build cache")
|
|
97
102
|
@strictcli.flag("output", type=str, help="Output path", default="out.txt")
|
|
98
103
|
```
|
|
99
104
|
|
|
100
|
-
Bool flags
|
|
105
|
+
Bool flags support `--flag` / `--no-flag` negation (disable with `negatable=False`) and have **no implicit default**: without `default=` they are required and the user must pass `--flag` or `--no-flag` explicitly. Float parsing rejects NaN and Inf.
|
|
101
106
|
|
|
102
107
|
### Compound types
|
|
103
108
|
|
|
@@ -116,16 +121,16 @@ Two equivalent declaration forms. Arguments can be required, optional (with `req
|
|
|
116
121
|
|
|
117
122
|
```python
|
|
118
123
|
# Decorator form
|
|
119
|
-
@app.command("show", help="Show a file")
|
|
124
|
+
@app.command("show", help="Show a file", effect="read_only")
|
|
120
125
|
@strictcli.arg("path", help="File to show")
|
|
121
|
-
def show(path): ...
|
|
126
|
+
def show(ctx, path): ...
|
|
122
127
|
|
|
123
128
|
# Inline form
|
|
124
|
-
@app.command("copy", help="Copy files", args=[
|
|
129
|
+
@app.command("copy", help="Copy files", effect="mutating", args=[
|
|
125
130
|
strictcli.Arg(name="src", help="Source"),
|
|
126
131
|
strictcli.Arg(name="dst", help="Destination"),
|
|
127
132
|
])
|
|
128
|
-
def copy(src, dst): ...
|
|
133
|
+
def copy(ctx, src, dst): ...
|
|
129
134
|
```
|
|
130
135
|
|
|
131
136
|
### Short flag aliases
|
|
@@ -133,7 +138,7 @@ def copy(src, dst): ...
|
|
|
133
138
|
Single-character shortcuts for any flag.
|
|
134
139
|
|
|
135
140
|
```python
|
|
136
|
-
@strictcli.flag("
|
|
141
|
+
@strictcli.flag("recursive", short="r", type=bool, default=False, help="Recurse into subdirectories")
|
|
137
142
|
@strictcli.flag("output", short="o", type=str, help="Output path", default=".")
|
|
138
143
|
```
|
|
139
144
|
|
|
@@ -160,12 +165,12 @@ auth_flags = strictcli.FlagSet(
|
|
|
160
165
|
name="auth",
|
|
161
166
|
flags=[
|
|
162
167
|
strictcli.Flag(name="token", type=str, help="Auth token", default=""),
|
|
163
|
-
strictcli.Flag(name="insecure", type=bool, help="Skip TLS verification"),
|
|
168
|
+
strictcli.Flag(name="insecure", type=bool, default=False, help="Skip TLS verification"),
|
|
164
169
|
],
|
|
165
170
|
)
|
|
166
171
|
|
|
167
|
-
@app.command("deploy", help="Deploy", flag_sets=[auth_flags])
|
|
168
|
-
def deploy(token, insecure): ...
|
|
172
|
+
@app.command("deploy", help="Deploy", effect="mutating", flag_sets=[auth_flags])
|
|
173
|
+
def deploy(ctx, token, insecure): ...
|
|
169
174
|
```
|
|
170
175
|
|
|
171
176
|
### Mutually exclusive flag groups
|
|
@@ -173,13 +178,13 @@ def deploy(token, insecure): ...
|
|
|
173
178
|
Exactly one flag from the group must be provided.
|
|
174
179
|
|
|
175
180
|
```python
|
|
176
|
-
@app.command("log", help="Show logs", mutex=[
|
|
181
|
+
@app.command("log", help="Show logs", effect="read_only", mutex=[
|
|
177
182
|
strictcli.MutexGroup(flags=[
|
|
178
|
-
strictcli.Flag(name="
|
|
179
|
-
strictcli.Flag(name="
|
|
183
|
+
strictcli.Flag(name="since", type=str, help="Show logs since a timestamp"),
|
|
184
|
+
strictcli.Flag(name="tail", type=int, help="Show the last N lines"),
|
|
180
185
|
]),
|
|
181
186
|
])
|
|
182
|
-
def log(
|
|
187
|
+
def log(ctx, since, tail): ...
|
|
183
188
|
```
|
|
184
189
|
|
|
185
190
|
### Flag dependencies
|
|
@@ -187,37 +192,53 @@ def log(verbose, quiet): ...
|
|
|
187
192
|
Three relationship types, all passed via `dependencies=[...]`:
|
|
188
193
|
|
|
189
194
|
- `CoRequired(flags=["output", "format"])` -- all must appear together, or none
|
|
190
|
-
- `Requires(flag="
|
|
191
|
-
- `Implies(flag="
|
|
195
|
+
- `Requires(flag="trace", depends_on="output")` -- one-way dependency
|
|
196
|
+
- `Implies(flag="trace", implies="log-output", value=True)` -- auto-set a bool flag when another is provided; explicit contradictions are parse errors
|
|
192
197
|
|
|
193
198
|
```python
|
|
194
|
-
@app.command("export", help="Export data", dependencies=[
|
|
199
|
+
@app.command("export", help="Export data", effect="mutating", dependencies=[
|
|
195
200
|
strictcli.CoRequired(flags=["output", "format"]),
|
|
196
|
-
strictcli.Requires(flag="
|
|
197
|
-
strictcli.Implies(flag="
|
|
201
|
+
strictcli.Requires(flag="trace", depends_on="output"),
|
|
202
|
+
strictcli.Implies(flag="trace", implies="log-output", value=True),
|
|
198
203
|
])
|
|
204
|
+
@strictcli.flag("output", type=str, default=None, help="Output path")
|
|
205
|
+
@strictcli.flag("format", type=str, default=None, help="Output format")
|
|
206
|
+
@strictcli.flag("trace", type=bool, default=False, help="Emit a trace")
|
|
207
|
+
@strictcli.flag("log-output", type=bool, default=False, help="Log the output path")
|
|
208
|
+
def export(ctx, output, format, trace, log_output): ...
|
|
199
209
|
```
|
|
200
210
|
|
|
211
|
+
Dependencies can only reference flags you declared, so the reserved quartet
|
|
212
|
+
(`dry-run`, `approve-consequential`, `quiet`, `verbose`) can never appear in one.
|
|
213
|
+
|
|
201
214
|
### Global flags
|
|
202
215
|
|
|
203
216
|
App-level flags available to all commands, parsed before and after the command token.
|
|
204
217
|
|
|
205
218
|
```python
|
|
206
219
|
app = strictcli.App("myapp", version="1.0.0", help="My app", flags=[
|
|
207
|
-
strictcli.Flag(name="
|
|
220
|
+
strictcli.Flag(name="color", type=bool, default=True, help="Colorize output"),
|
|
208
221
|
])
|
|
209
222
|
```
|
|
210
223
|
|
|
224
|
+
Global flag names cannot collide with the framework's reserved names (`help`,
|
|
225
|
+
`h`, `version`, `v`, `dump-schema`, `mcp`, `config`, `hermetic`) or with the
|
|
226
|
+
reserved quartet.
|
|
227
|
+
|
|
211
228
|
### Passthrough commands
|
|
212
229
|
|
|
213
230
|
Bypass all parsing -- handler gets raw args plus global flag values.
|
|
214
231
|
|
|
215
232
|
```python
|
|
216
|
-
@app.command("run", help="Run a script", passthrough=True)
|
|
217
|
-
def run(args,
|
|
218
|
-
|
|
233
|
+
@app.command("run", help="Run a script", effect="mutating", passthrough=True)
|
|
234
|
+
def run(ctx, args, color):
|
|
235
|
+
ctx.effects.run(args)
|
|
219
236
|
```
|
|
220
237
|
|
|
238
|
+
The reserved quartet is not scanned after a passthrough command's name: its args
|
|
239
|
+
are forwarded to the child byte-for-byte, so `myapp run deploy --dry-run` passes
|
|
240
|
+
`--dry-run` to the child.
|
|
241
|
+
|
|
221
242
|
### Repeatable flags
|
|
222
243
|
|
|
223
244
|
Flags that accumulate values across multiple occurrences. Requires explicit `unique=True` or `unique=False`.
|
|
@@ -258,8 +279,8 @@ Deprecated commands appear in help output under a `Deprecated:` section.
|
|
|
258
279
|
Commands and groups can be hidden from help output while remaining functional.
|
|
259
280
|
|
|
260
281
|
```python
|
|
261
|
-
@app.command("internal-debug", help="Debug internals", hidden=True)
|
|
262
|
-
def internal_debug(): ...
|
|
282
|
+
@app.command("internal-debug", help="Debug internals", effect="read_only", hidden=True)
|
|
283
|
+
def internal_debug(ctx): ...
|
|
263
284
|
```
|
|
264
285
|
|
|
265
286
|
### JSON config file support
|
|
@@ -276,9 +297,63 @@ Precedence: CLI > env > config > default. Config fields can be declared with typ
|
|
|
276
297
|
app.config_field("serve.port", type=int, help="Server port", default=8080)
|
|
277
298
|
```
|
|
278
299
|
|
|
300
|
+
### The effects regime
|
|
301
|
+
|
|
302
|
+
Every command declares `effect="read_only"` or `effect="mutating"` -- there is no
|
|
303
|
+
default and no inference. A read-only command changes nothing and calling a
|
|
304
|
+
mutating member of `ctx.effects` from one is a hard error at call time. A
|
|
305
|
+
mutating command participates in `--dry-run`, where the eight recorded
|
|
306
|
+
operations (`run`, `spawn`, `write`, `mkdir`, `remove`, `rename`, `chmod`,
|
|
307
|
+
`http`) are recorded rather than performed and rendered as a would-do log.
|
|
308
|
+
|
|
309
|
+
Four flag names are owned by the framework and cannot be declared at any level
|
|
310
|
+
(app flags, command flags, flag sets, mutex groups). They arrive on the context,
|
|
311
|
+
never as handler kwargs:
|
|
312
|
+
|
|
313
|
+
| Flag | Context property |
|
|
314
|
+
|------|-----------------|
|
|
315
|
+
| `--dry-run` | `ctx.dry_run` |
|
|
316
|
+
| `--approve-consequential` | `ctx.approve_consequential` |
|
|
317
|
+
| `--quiet` | `ctx.quiet` |
|
|
318
|
+
| `--verbose` | `ctx.verbose` |
|
|
319
|
+
|
|
320
|
+
A flag named `yes` is banned outright -- the confirmation skip is
|
|
321
|
+
`--approve-consequential`.
|
|
322
|
+
|
|
323
|
+
A command whose preview would lie declares the refusal instead of rendering one:
|
|
324
|
+
|
|
325
|
+
```python
|
|
326
|
+
@app.command("migrate", help="Run migrations", effect="mutating",
|
|
327
|
+
dry_run_supported=False,
|
|
328
|
+
dry_run_unsupported_reason="each migration reads the schema the previous one wrote")
|
|
329
|
+
def migrate(ctx): ...
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
`--dry-run` is then refused at parse time with the reason, which also appears in
|
|
333
|
+
the command's help under a `Dry run:` section and in the schema.
|
|
334
|
+
|
|
335
|
+
### Consequential commands
|
|
336
|
+
|
|
337
|
+
`consequential=True` is the only thing that makes the framework prompt -- a plain
|
|
338
|
+
mutating command never does. Classification answers "should a dry run record
|
|
339
|
+
this?"; `consequential` answers "are these effects worth interrupting someone
|
|
340
|
+
for?"
|
|
341
|
+
|
|
342
|
+
```python
|
|
343
|
+
@app.command("destroy", help="Destroy the cluster", effect="mutating", consequential=True)
|
|
344
|
+
def destroy(ctx): ...
|
|
345
|
+
```
|
|
346
|
+
|
|
347
|
+
Before dispatch the framework prints `about to run consequential command
|
|
348
|
+
'destroy'. Proceed? [y/N] ` to stderr and reads one line from stdin; only `y` or
|
|
349
|
+
`Y` proceeds. `--approve-consequential` answers in advance, and `--dry-run`
|
|
350
|
+
skips the prompt because nothing is being performed. A non-interactive stdin
|
|
351
|
+
without either flag is a hard error rather than a hang. Declaring
|
|
352
|
+
`consequential=True` on a read-only command raises `ValueError`.
|
|
353
|
+
|
|
279
354
|
### Schema dump
|
|
280
355
|
|
|
281
|
-
`--dump-schema` is auto-injected on every app. Writes `.strictcli/schema.json` describing the full CLI structure (commands, flags, args, groups, checks).
|
|
356
|
+
`--dump-schema` is auto-injected on every app. Writes `.strictcli/schema.json` describing the full CLI structure (commands, flags, args, groups, checks). Every command entry carries its `effect`; `consequential`, `dry_run_supported` and `dry_run_unsupported_reason` are emitted only when declared.
|
|
282
357
|
|
|
283
358
|
### Check system
|
|
284
359
|
|
|
@@ -287,12 +362,24 @@ First-class check/validation framework with double-entry security. Enabled via `
|
|
|
287
362
|
```python
|
|
288
363
|
app = strictcli.App("myapp", version="1.0.0", help="My app", checks_path="checks.toml")
|
|
289
364
|
|
|
290
|
-
@app.
|
|
291
|
-
def lint(
|
|
292
|
-
|
|
365
|
+
@app.error_check("lint")
|
|
366
|
+
def lint(ctx, reporter: strictcli.ErrorReporter):
|
|
367
|
+
if problems := find_problems():
|
|
368
|
+
for p in problems:
|
|
369
|
+
reporter.error(p)
|
|
370
|
+
return reporter.found("lint problems found")
|
|
371
|
+
return reporter.passed("All good")
|
|
293
372
|
```
|
|
294
373
|
|
|
295
|
-
Checks are declared in TOML and registered in code -- both must agree.
|
|
374
|
+
Checks are declared in TOML and registered in code -- both must agree. The
|
|
375
|
+
registration form must match the declared severity: `@app.error_check` for
|
|
376
|
+
`severity = "error"` (its reporter has `error` and `warn`), `@app.warn_check`
|
|
377
|
+
for `severity = "warn"` (its reporter structurally lacks `error`, so a warn
|
|
378
|
+
check cannot cascade). An outcome is minted only through a reporter method --
|
|
379
|
+
`passed(message)`, `skipped(reason)`, or `found(message)` after accumulating
|
|
380
|
+
problems; `reporter.note(text)` records verdict-inert informational notes.
|
|
381
|
+
Auto-registers a `check` command with tag DSL filtering
|
|
382
|
+
(`--tag "release & !slow"`), JSON output, and dependency resolution.
|
|
296
383
|
|
|
297
384
|
### Auto-version
|
|
298
385
|
|
|
@@ -329,6 +416,9 @@ assert "HELLO, WORLD!" in result.stdout
|
|
|
329
416
|
assert result.stderr == ""
|
|
330
417
|
```
|
|
331
418
|
|
|
419
|
+
The confirm protocol never fires on `test()` or `call()` -- the programmatic
|
|
420
|
+
paths have no TTY contract, so a consequential command is dispatched directly.
|
|
421
|
+
|
|
332
422
|
## API reference
|
|
333
423
|
|
|
334
424
|
### Core types
|
|
@@ -345,24 +435,25 @@ assert result.stderr == ""
|
|
|
345
435
|
| `Implies` | Auto-set a bool flag from another |
|
|
346
436
|
| `Result` | Return type of `app.test()` |
|
|
347
437
|
| `Tool` | LLM tool descriptor |
|
|
348
|
-
| `
|
|
438
|
+
| `CheckRunResult` | Check execution result with wall-clock timing |
|
|
349
439
|
| `CheckContext` | Protocol for check context |
|
|
440
|
+
| `ErrorReporter` / `WarnReporter` | Problem accumulators passed to check handlers |
|
|
350
441
|
| `ConfigField` | Typed config file field |
|
|
351
442
|
|
|
352
443
|
### Decorators
|
|
353
444
|
|
|
354
445
|
| Decorator | Description |
|
|
355
446
|
|-----------|-------------|
|
|
356
|
-
| `@app.command(name, help=...)` | Register a command |
|
|
447
|
+
| `@app.command(name, help=..., effect=...)` | Register a command (`effect` is mandatory) |
|
|
357
448
|
| `@strictcli.flag(name, type=, help=...)` | Declare a flag |
|
|
358
449
|
| `@strictcli.arg(name, help=...)` | Declare a positional argument |
|
|
359
|
-
| `@app.
|
|
450
|
+
| `@app.error_check(name)` / `@app.warn_check(name)` | Register a check handler |
|
|
360
451
|
|
|
361
452
|
### App methods
|
|
362
453
|
|
|
363
454
|
| Method | Description |
|
|
364
455
|
|--------|-------------|
|
|
365
|
-
| `app.command(name, help=...)` | Register a command (decorator) |
|
|
456
|
+
| `app.command(name, help=..., effect=...)` | Register a command (decorator; `effect` is mandatory) |
|
|
366
457
|
| `app.group(name, help=...)` | Create a command group |
|
|
367
458
|
| `app.deprecate(name, message=...)` | Register a deprecated command |
|
|
368
459
|
| `app.run()` | Parse `sys.argv` and execute |
|
|
@@ -370,7 +461,7 @@ assert result.stderr == ""
|
|
|
370
461
|
| `app.as_tools()` | Export commands as `Tool` descriptors |
|
|
371
462
|
| `app.serve_mcp()` | Run MCP server on stdin/stdout |
|
|
372
463
|
| `app.config_field(name, type=, help=...)` | Declare a typed config field |
|
|
373
|
-
| `app.
|
|
464
|
+
| `app.error_check(name)` / `app.warn_check(name)` | Register a check handler (decorator) |
|
|
374
465
|
| `app.set_check_context(factory)` | Set the check context factory |
|
|
375
466
|
|
|
376
467
|
## Design principles
|
|
@@ -378,8 +469,9 @@ assert result.stderr == ""
|
|
|
378
469
|
- **Help is mandatory.** Every command, flag, and argument must have help text. Missing help raises `ValueError` at registration time.
|
|
379
470
|
- **Four types only.** `str`, `bool`, `int`, `float` -- plus compound `list[T]` and `dict[str, T]`. No magic type coercion.
|
|
380
471
|
- **Handler signatures are validated.** Parameter names must match declared flags and args exactly. Extra or missing parameters raise `ValueError`.
|
|
472
|
+
- **Effect classification is mandatory.** Every command declares `read_only` or `mutating`. There is no default and no inference.
|
|
381
473
|
- **Registration-time errors.** Misconfigurations fail loud and early, not at parse time.
|
|
382
|
-
- **
|
|
474
|
+
- **Minimal dependencies.** The standard library plus [tomlkit](https://pypi.org/project/tomlkit/) for TOML config support.
|
|
383
475
|
|
|
384
476
|
## See also
|
|
385
477
|
|