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.
Files changed (101) hide show
  1. {strictcli-0.35.4/.rlsbl/bases → strictcli-0.37.0}/.github/workflows/publish.yml +12 -4
  2. {strictcli-0.35.4 → strictcli-0.37.0/.rlsbl/bases}/.github/workflows/publish.yml +12 -4
  3. {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/managed-files.json +1 -1
  4. strictcli-0.37.0/.rlsbl/version +1 -0
  5. {strictcli-0.35.4 → strictcli-0.37.0}/PKG-INFO +140 -48
  6. {strictcli-0.35.4 → strictcli-0.37.0}/README.md +139 -47
  7. {strictcli-0.35.4 → strictcli-0.37.0}/pyproject.toml +1 -1
  8. {strictcli-0.35.4 → strictcli-0.37.0}/strictcli/__init__.py +317 -31
  9. strictcli-0.37.0/tests/test_confirm.py +349 -0
  10. strictcli-0.37.0/tests/test_dry_run_unsupported.py +263 -0
  11. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_effects.py +1 -1
  12. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_effects_bypass_check.py +132 -0
  13. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_reserved_quartet.py +57 -13
  14. {strictcli-0.35.4 → strictcli-0.37.0}/uv.lock +1 -1
  15. strictcli-0.35.4/.rlsbl/version +0 -1
  16. strictcli-0.35.4/tests/test_confirm.py +0 -191
  17. {strictcli-0.35.4 → strictcli-0.37.0}/.claude/settings.json +0 -0
  18. {strictcli-0.35.4 → strictcli-0.37.0}/.github/workflows/ci.yml +0 -0
  19. {strictcli-0.35.4 → strictcli-0.37.0}/.gitignore +0 -0
  20. {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  21. {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/bases/.gitignore +0 -0
  22. {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  23. {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/config.json +0 -0
  24. {strictcli-0.35.4 → strictcli-0.37.0}/.rlsbl/lint/python.toml +0 -0
  25. {strictcli-0.35.4 → strictcli-0.37.0}/.strictcli/schema.json +0 -0
  26. {strictcli-0.35.4 → strictcli-0.37.0}/CLAUDE.md +0 -0
  27. {strictcli-0.35.4 → strictcli-0.37.0}/LICENSE +0 -0
  28. {strictcli-0.35.4 → strictcli-0.37.0}/scripts/add_effect_classification.py +0 -0
  29. {strictcli-0.35.4 → strictcli-0.37.0}/scripts/add_forwarding_declaration.py +0 -0
  30. {strictcli-0.35.4 → strictcli-0.37.0}/strictcli/py.typed +0 -0
  31. {strictcli-0.35.4 → strictcli-0.37.0}/tests/conftest.py +0 -0
  32. {strictcli-0.35.4 → strictcli-0.37.0}/tests/flagship_app.py +0 -0
  33. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_arg_default.py +0 -0
  34. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_arg_default_validation.py +0 -0
  35. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_at_prefix.py +0 -0
  36. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_auto_version.py +0 -0
  37. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_call.py +0 -0
  38. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_command.py +0 -0
  39. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_discovery.py +0 -0
  40. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_provider.py +0 -0
  41. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_public_api.py +0 -0
  42. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_runner.py +0 -0
  43. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_schema.py +0 -0
  44. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_check_types.py +0 -0
  45. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_choices.py +0 -0
  46. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_choices_none.py +0 -0
  47. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_classification.py +0 -0
  48. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_command_help_suggestion.py +0 -0
  49. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_command_tags.py +0 -0
  50. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_compound_types.py +0 -0
  51. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_config.py +0 -0
  52. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_config_fields.py +0 -0
  53. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_config_file_path.py +0 -0
  54. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_config_set_bugs.py +0 -0
  55. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_connection_env.py +0 -0
  56. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_context.py +0 -0
  57. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_coverage.py +0 -0
  58. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_deep_nesting.py +0 -0
  59. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_dependencies.py +0 -0
  60. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_deprecated.py +0 -0
  61. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_dump_schema.py +0 -0
  62. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_e2e.py +0 -0
  63. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_env.py +0 -0
  64. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_exit_codes.py +0 -0
  65. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_flag_sets.py +0 -0
  66. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_flagship_preview.py +0 -0
  67. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_float_format.py +0 -0
  68. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_float_type.py +0 -0
  69. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_float_vectors.py +0 -0
  70. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_global_flag_conflict_position.py +0 -0
  71. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_global_flags.py +0 -0
  72. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_guard_v2.py +0 -0
  73. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_help.py +0 -0
  74. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_hermetic.py +0 -0
  75. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_infra_env.py +0 -0
  76. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_int_type.py +0 -0
  77. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_invoke.py +0 -0
  78. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_keyword_flags.py +0 -0
  79. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_mcp.py +0 -0
  80. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_mutex.py +0 -0
  81. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_nesting.py +0 -0
  82. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_parser.py +0 -0
  83. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_passthrough.py +0 -0
  84. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_provenance.py +0 -0
  85. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_provenance_phase2.py +0 -0
  86. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_registration.py +0 -0
  87. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_repeatable.py +0 -0
  88. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_reserved_global_flags.py +0 -0
  89. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_tagdsl.py +0 -0
  90. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_toml_loading.py +0 -0
  91. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_tool_export.py +0 -0
  92. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_typed_args.py +0 -0
  93. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_unique.py +0 -0
  94. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_utilities.py +0 -0
  95. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_validate.py +0 -0
  96. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_variadic.py +0 -0
  97. {strictcli-0.35.4 → strictcli-0.37.0}/tests/test_visibility.py +0 -0
  98. {strictcli-0.35.4 → strictcli-0.37.0}/todo/.defer/deferred.md +0 -0
  99. {strictcli-0.35.4 → strictcli-0.37.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  100. {strictcli-0.35.4 → strictcli-0.37.0}/todo/.done/original-idea.md +0 -0
  101. {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, so the release commit must produce CI check runs."
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. Fix the failure, re-run the CI workflow to green on this exact commit (gh run rerun <run-id>), then re-dispatch this publish workflow at the tag ref: gh workflow run <publish workflow> --ref $GITHUB_REF_NAME"
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. Re-run the cancelled CI workflow (gh run rerun <run-id>), then re-dispatch this publish workflow at the tag ref."
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's own CI must actually run on the release commit. Check paths filters and job conditions, re-run CI on this commit, then re-dispatch this publish workflow at the tag ref."
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, so the release commit must produce CI check runs."
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. Fix the failure, re-run the CI workflow to green on this exact commit (gh run rerun <run-id>), then re-dispatch this publish workflow at the tag ref: gh workflow run <publish workflow> --ref $GITHUB_REF_NAME"
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. Re-run the cancelled CI workflow (gh run rerun <run-id>), then re-dispatch this publish workflow at the tag ref."
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's own CI must actually run on the release commit. Check paths filters and job conditions, re-run CI on this commit, then re-dispatch this publish workflow at the tag ref."
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": "9bc0b409f8c5c2a3c5795a3e7c66095b9479c1a03fb41250ff0461e1221b54f2",
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.35.4
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+. Zero external dependencies.
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
- print(msg.upper() if loud else msg)
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> Who to greet
69
- --loud, --no-loud Shout it [default: false]
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
- print("migrating")
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("verbose", type=bool, help="Verbose output")
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 default to `False`, support `--flag` / `--no-flag` negation (disable with `negatable=False`). Float parsing rejects NaN and Inf.
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("verbose", short="v", type=bool, help="Verbose output")
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="verbose", type=bool, help="Verbose output"),
179
- strictcli.Flag(name="quiet", type=bool, help="Quiet output"),
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(verbose, quiet): ...
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="verbose", depends_on="output")` -- one-way dependency
191
- - `Implies(flag="verbose", implies="log_output", value=True)` -- auto-set a bool flag when another is provided; explicit contradictions are parse errors
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="verbose", depends_on="output"),
197
- strictcli.Implies(flag="verbose", implies="log_output", value=True),
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="verbose", type=bool, help="Verbose output"),
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, verbose):
218
- subprocess.run(args)
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.check("lint")
291
- def lint(context):
292
- return strictcli.CheckResult(status="pass", message="All good")
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. Auto-registers a `check` command with tag DSL filtering (`--tag "release & !slow"`), JSON output, and dependency resolution.
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
- | `CheckResult` | Check execution result |
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.check(name)` | Register a check handler |
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.check(name)` | Register a check handler (decorator) |
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
- - **Zero dependencies.** Standard library only.
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