strictcli 0.40.0__tar.gz → 0.41.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 (116) hide show
  1. {strictcli-0.40.0 → strictcli-0.41.0}/PKG-INFO +215 -50
  2. strictcli-0.41.0/README.md +641 -0
  3. {strictcli-0.40.0 → strictcli-0.41.0}/pyproject.toml +1 -1
  4. strictcli-0.41.0/scripts/migrate_presence.py +202 -0
  5. {strictcli-0.40.0 → strictcli-0.41.0}/strictcli/__init__.py +10456 -4764
  6. {strictcli-0.40.0 → strictcli-0.41.0}/tests/flagship_app.py +1 -1
  7. strictcli-0.41.0/tests/test_arg_decorator_order.py +120 -0
  8. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_arg_default.py +34 -15
  9. strictcli-0.41.0/tests/test_arg_default_validation.py +60 -0
  10. strictcli-0.41.0/tests/test_argv_value_order.py +209 -0
  11. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_at_prefix.py +7 -7
  12. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_call.py +27 -16
  13. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_schema.py +55 -0
  14. strictcli-0.41.0/tests/test_choice_records.py +425 -0
  15. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_choices.py +14 -10
  16. strictcli-0.41.0/tests/test_choices_none.py +174 -0
  17. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_classification.py +1 -1
  18. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_command_help_suggestion.py +2 -2
  19. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_command_tags.py +1 -1
  20. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_compound_types.py +159 -103
  21. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_config.py +77 -60
  22. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_config_fields.py +28 -12
  23. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_config_set_bugs.py +23 -23
  24. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_confirm.py +7 -7
  25. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_connection_env.py +64 -4
  26. strictcli-0.41.0/tests/test_constraints.py +1969 -0
  27. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_context.py +6 -6
  28. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_deep_nesting.py +1 -1
  29. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_dump_schema.py +715 -136
  30. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_e2e.py +6 -6
  31. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_effects_bypass_check.py +12 -10
  32. strictcli-0.41.0/tests/test_flat_pre_typed.py +891 -0
  33. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_float_format.py +3 -3
  34. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_float_type.py +9 -5
  35. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_global_flags.py +2 -2
  36. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_guard_v2.py +2 -2
  37. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_help.py +3 -3
  38. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_hermetic.py +2 -2
  39. strictcli-0.41.0/tests/test_infra_env.py +912 -0
  40. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_int_type.py +7 -3
  41. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_invoke.py +76 -53
  42. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_machine_mode.py +50 -15
  43. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_mcp.py +68 -6
  44. strictcli-0.41.0/tests/test_member_spelling.py +688 -0
  45. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_nesting.py +2 -2
  46. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_parser.py +85 -5
  47. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_passthrough.py +20 -11
  48. strictcli-0.41.0/tests/test_presence.py +1035 -0
  49. strictcli-0.41.0/tests/test_provenance.py +216 -0
  50. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_provenance_phase2.py +2 -2
  51. strictcli-0.41.0/tests/test_record_pre_typed.py +331 -0
  52. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_registration.py +3 -3
  53. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_repeatable.py +53 -41
  54. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_reserved_global_flags.py +3 -3
  55. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_reserved_quartet.py +43 -8
  56. strictcli-0.41.0/tests/test_selectors.py +2166 -0
  57. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_tool_export.py +179 -31
  58. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_trace_store.py +2 -1
  59. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_typed_args.py +57 -57
  60. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_unique.py +9 -4
  61. strictcli-0.41.0/tests/test_update.py +1328 -0
  62. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_validate.py +29 -5
  63. strictcli-0.41.0/tests/test_value_sweep_order.py +302 -0
  64. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_variadic.py +21 -16
  65. {strictcli-0.40.0 → strictcli-0.41.0}/uv.lock +1 -1
  66. strictcli-0.40.0/README.md +0 -476
  67. strictcli-0.40.0/tests/test_arg_default_validation.py +0 -79
  68. strictcli-0.40.0/tests/test_choices_none.py +0 -163
  69. strictcli-0.40.0/tests/test_dependencies.py +0 -668
  70. strictcli-0.40.0/tests/test_infra_env.py +0 -258
  71. strictcli-0.40.0/tests/test_mutex.py +0 -623
  72. strictcli-0.40.0/tests/test_provenance.py +0 -212
  73. {strictcli-0.40.0 → strictcli-0.41.0}/.claude/settings.json +0 -0
  74. {strictcli-0.40.0 → strictcli-0.41.0}/.github/workflows/ci.yml +0 -0
  75. {strictcli-0.40.0 → strictcli-0.41.0}/.github/workflows/publish.yml +0 -0
  76. {strictcli-0.40.0 → strictcli-0.41.0}/.gitignore +0 -0
  77. {strictcli-0.40.0 → strictcli-0.41.0}/.rlsbl/config.json +0 -0
  78. {strictcli-0.40.0 → strictcli-0.41.0}/.rlsbl/lint/python.toml +0 -0
  79. {strictcli-0.40.0 → strictcli-0.41.0}/.rlsbl/managed-files.json +0 -0
  80. {strictcli-0.40.0 → strictcli-0.41.0}/CLAUDE.md +0 -0
  81. {strictcli-0.40.0 → strictcli-0.41.0}/LICENSE +0 -0
  82. {strictcli-0.40.0 → strictcli-0.41.0}/scripts/add_effect_classification.py +0 -0
  83. {strictcli-0.40.0 → strictcli-0.41.0}/scripts/add_forwarding_declaration.py +0 -0
  84. {strictcli-0.40.0 → strictcli-0.41.0}/strictcli/py.typed +0 -0
  85. {strictcli-0.40.0 → strictcli-0.41.0}/tests/conftest.py +0 -0
  86. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_auto_version.py +0 -0
  87. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_command.py +0 -0
  88. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_discovery.py +0 -0
  89. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_provider.py +0 -0
  90. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_public_api.py +0 -0
  91. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_runner.py +0 -0
  92. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_check_types.py +0 -0
  93. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_claimed_rendering.py +0 -0
  94. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_config_file_path.py +0 -0
  95. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_coverage.py +0 -0
  96. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_deprecated.py +0 -0
  97. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_dry_run_unsupported.py +0 -0
  98. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_effects.py +0 -0
  99. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_env.py +0 -0
  100. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_exit_codes.py +0 -0
  101. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_flag_sets.py +0 -0
  102. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_flagship_preview.py +0 -0
  103. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_float_vectors.py +0 -0
  104. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_global_flag_conflict_position.py +0 -0
  105. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_keyword_flags.py +0 -0
  106. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_owns_stdout.py +0 -0
  107. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_payload_schema.py +0 -0
  108. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_tagdsl.py +0 -0
  109. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_toml_loading.py +0 -0
  110. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_ulid_vectors.py +0 -0
  111. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_utilities.py +0 -0
  112. {strictcli-0.40.0 → strictcli-0.41.0}/tests/test_visibility.py +0 -0
  113. {strictcli-0.40.0 → strictcli-0.41.0}/todo/.defer/deferred.md +0 -0
  114. {strictcli-0.40.0 → strictcli-0.41.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  115. {strictcli-0.40.0 → strictcli-0.41.0}/todo/.done/original-idea.md +0 -0
  116. {strictcli-0.40.0 → strictcli-0.41.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: strictcli
3
- Version: 0.40.0
3
+ Version: 0.41.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
@@ -23,6 +23,16 @@ A strict CLI framework for Python.
23
23
 
24
24
  strictcli makes you declare everything -- every command, flag, argument, and environment variable must have help text or the framework errors at registration time. Four types only: `str`, `bool`, `int`, `float`. No magic type inference, no implicit defaults.
25
25
 
26
+ There are Go and TypeScript implementations too, and this one is not a port of
27
+ either. The surface here is Python's own -- decorators, keyword arguments, a
28
+ `Flag` dataclass, `ValueError` at registration -- and some of the enforcement
29
+ exists only in Python, because only Python can see it (a handler parameter
30
+ bound to an optional flag must default to `None`, since anything else
31
+ re-introduces the sentinel the declaration removed). What the three
32
+ implementations hold identical is behavior: the same semantics, the same help
33
+ bytes, the same schema, and the same error sentence with Python's spellings
34
+ inside it.
35
+
26
36
  ## Installation
27
37
 
28
38
  ```
@@ -45,7 +55,7 @@ import strictcli
45
55
  app = strictcli.App("greet", version="1.0.0", help="A greeting app")
46
56
 
47
57
  @app.command("hello", help="Say hello", effect="read_only")
48
- @strictcli.flag("name", type=str, help="Who to greet")
58
+ @strictcli.flag("name", type=str, help="Who to greet", presence="required")
49
59
  @strictcli.flag("loud", type=bool, default=False, help="Shout it")
50
60
  def hello(ctx, name, loud):
51
61
  msg = f"Hello, {name}!"
@@ -91,44 +101,68 @@ def migrate(ctx):
91
101
 
92
102
  Invoked as `myapp db schema migrate`.
93
103
 
104
+ ### The presence declaration
105
+
106
+ Every flag and every positional argument declares **exactly one** of three facts about itself. Declaring none of them does not register; declaring two does not register.
107
+
108
+ | Fact | Spelling | Delivered when nothing supplies a value |
109
+ |------|----------|------------------------------------------|
110
+ | required | `presence="required"` | nothing -- the parse fails with `flag '--x' is required` |
111
+ | optional | `presence="optional"` | `None` |
112
+ | default | `default=<value>` | the declared value |
113
+
114
+ ```python
115
+ @strictcli.flag("target", type=str, help="Deploy target", presence="required")
116
+ @strictcli.flag("note", type=str, help="A note", presence="optional")
117
+ @strictcli.flag("retries", type=int, help="Retry count", default=3)
118
+ ```
119
+
120
+ Nothing about presence is inferred. A bool with no declaration is not "false by default", a `list[T]` with no declaration is not "empty by default" -- an empty collection is declared with `default=[]` or `default={}` -- and `default=None` is not a spelling of optionality: it is refused at registration with a redirect to `presence="optional"`, which is what delivers `None`.
121
+
122
+ Requiredness is satisfied by any source that supplies a value: a command-line token, an environment variable, a config file, or an `Implies` injection. An optional flag makes real tri-state bools possible (`--x` true, `--no-x` false, absent absent), and it makes `""` a value again instead of an absence sentinel.
123
+
124
+ `ctx.provided(name)` answers whether the **invocation** caused a value: true for `cli`, `env`, `config` and `implied`, false for `default` and `infra`. `ctx.source(name)` still answers the narrower question of which origin it was.
125
+
126
+ A handler parameter bound to an optional flag or argument must default to `None` if it defaults to anything, so the sentinel the declaration removed cannot come back one line later. A choice flag declares `required` or a default and never `optional` -- an absent selection is a choice nobody named, so it is named as a choice of its own.
127
+
94
128
  ### Four flag types
95
129
 
96
130
  `str`, `bool`, `int`, and `float`. No magic coercion -- parse errors are clear and immediate.
97
131
 
98
132
  ```python
99
- @strictcli.flag("port", type=int, help="Port number")
100
- @strictcli.flag("threshold", type=float, help="Score threshold")
133
+ @strictcli.flag("port", type=int, help="Port number", presence="required")
134
+ @strictcli.flag("threshold", type=float, help="Score threshold", presence="optional")
101
135
  @strictcli.flag("cache", type=bool, default=True, help="Reuse the build cache")
102
136
  @strictcli.flag("output", type=str, help="Output path", default="out.txt")
103
137
  ```
104
138
 
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.
139
+ Bool flags support `--flag` / `--no-flag` negation (disable with `negatable=False`). A bool declared `presence="required"` must be passed as `--flag` or `--no-flag`; one declared `presence="optional"` is real tri-state. Float parsing rejects NaN and Inf.
106
140
 
107
141
  ### Compound types
108
142
 
109
143
  `list[T]` and `dict[str, T]` for collecting multiple values.
110
144
 
111
145
  ```python
112
- @strictcli.flag("tags", type=list[str], help="Tags to apply", unique=True)
113
- @strictcli.flag("env", type=dict[str, str], help="Environment variables")
146
+ @strictcli.flag("tags", type=list[str], help="Tags to apply", unique=True, default=[])
147
+ @strictcli.flag("env", type=dict[str, str], help="Environment variables", default={})
114
148
  ```
115
149
 
116
150
  List flags accept `--tags a --tags b`. Dict flags accept `--env KEY=VALUE` pairs or JSON objects.
117
151
 
118
152
  ### Positional arguments
119
153
 
120
- Two equivalent declaration forms. Arguments can be required, optional (with `required=False`), or variadic.
154
+ Two equivalent declaration forms. Every argument declares its presence exactly as a flag does: `presence="required"`, `presence="optional"`, or a `default=`. A variadic argument always delivers a list, so it declares `required` (at least one value) or `optional` (possibly none) and never a default.
121
155
 
122
156
  ```python
123
157
  # Decorator form
124
158
  @app.command("show", help="Show a file", effect="read_only")
125
- @strictcli.arg("path", help="File to show")
159
+ @strictcli.arg("path", help="File to show", presence="required")
126
160
  def show(ctx, path): ...
127
161
 
128
162
  # Inline form
129
163
  @app.command("copy", help="Copy files", effect="mutating", args=[
130
- strictcli.Arg(name="src", help="Source"),
131
- strictcli.Arg(name="dst", help="Destination"),
164
+ strictcli.Arg(name="src", help="Source", presence="required"),
165
+ strictcli.Arg(name="dst", help="Destination", presence="required"),
132
166
  ])
133
167
  def copy(ctx, src, dst): ...
134
168
  ```
@@ -164,8 +198,8 @@ Reusable bundles of flags shared across commands.
164
198
  auth_flags = strictcli.FlagSet(
165
199
  name="auth",
166
200
  flags=[
167
- strictcli.Flag(name="token", type=str, help="Auth token", default=""),
168
- strictcli.Flag(name="insecure", type=bool, default=False, help="Skip TLS verification"),
201
+ strictcli.Flag(name="token", type=str, help="Auth token", presence="required"),
202
+ strictcli.Flag(name="insecure", type=bool, presence="optional", help="Skip TLS verification"),
169
203
  ],
170
204
  )
171
205
 
@@ -173,43 +207,153 @@ auth_flags = strictcli.FlagSet(
173
207
  def deploy(ctx, token, insecure): ...
174
208
  ```
175
209
 
176
- ### Mutually exclusive flag groups
210
+ ### Choice flags: a choice is a declaration scope
177
211
 
178
- Exactly one flag from the group must be provided.
212
+ A **choice flag** elects exactly one of its declared choices, and each choice
213
+ owns the flags that exist only while it is elected. A flag supplied outside its
214
+ elected scope is a distinct parse error naming both sides -- never "unknown
215
+ flag" -- and the elected value reaches the handler as one tagged record that
216
+ `match` consumes exhaustively.
179
217
 
180
218
  ```python
181
- @app.command("log", help="Show logs", effect="read_only", mutex=[
182
- strictcli.MutexGroup(flags=[
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"),
185
- ]),
186
- ])
187
- def log(ctx, since, tail): ...
219
+ @strictcli.choice("email", help="deliver the notification as an email message")
220
+ class Email:
221
+ subject: str = strictcli.sub_flag(help="subject line", presence="required")
222
+ recipient: str = strictcli.sub_flag(help="destination address", presence="required")
223
+
224
+
225
+ @strictcli.choice("sms", help="deliver the notification as a text message")
226
+ class Sms:
227
+ phone_number: str = strictcli.sub_flag(help="destination number", presence="required")
228
+
229
+
230
+ @app.command("send", help="Send one notification", effect="mutating")
231
+ @strictcli.choice_flag("via", help="Delivery channel", short="v", presence="required",
232
+ elect_by="selector-token", choices=[Email, Sms])
233
+ def send(ctx, via: Email | Sms):
234
+ match via:
235
+ case Email(subject=subject, recipient=recipient): ...
236
+ case Sms(phone_number=number): ...
237
+ case _: assert_never(via)
188
238
  ```
189
239
 
190
- ### Flag dependencies
240
+ `notify send --via email --subject hi` parses; `notify send --via sms --subject hi`
241
+ says *`--subject` is only valid under `--via email`*. Order is irrelevant --
242
+ nothing is interpreted until every token is collected -- and a choice flag may be
243
+ declared inside a choice's scope to any depth (`strictcli.sub_choice_flag`).
244
+
245
+ `elect_by` is mandatory and has no default. `elect_by="member-flags"` spells each
246
+ choice as its own flag instead (`--profile work` / `--all-profiles`), with no
247
+ selector token ever typed; a member's payload is declared
248
+ `value: str = strictcli.member_value(help=...)`.
249
+
250
+ Scoped flags are never top-level handler arguments, at any depth, so every
251
+ declared top-level key is still always present. Inside the record,
252
+ `strictcli.provided(via, "subject")` answers whether the invocation caused a
253
+ field's value.
254
+
255
+ ### Constraints
256
+
257
+ Four relationship types, all passed via `constraints=[...]`, and every one of
258
+ them declares a **mandatory name** -- the name is what a violation prints and
259
+ what `--help` shows, and it is what lets one constraint be a member of another.
260
+
261
+ - `AtLeastOne(name, members)` -- at least one member is engaged. Members may
262
+ co-occur; it has no upper bound and is never exclusivity
263
+ - `AllOrNone(name, members)` -- either every member is engaged or none is.
264
+ Nothing engaged is vacuously satisfied
265
+ - `Requires(name, flag=..., depends_on=...)` -- one-way dependency
266
+ - `Implies(name, flag=..., implies=..., value=...)` -- auto-set a bool flag when
267
+ another is provided; explicit contradictions are parse errors
268
+
269
+ A **member** is a `Member(name, when=...)` record naming a command flag, a
270
+ positional arg, or another named at-least-one or all-or-none (nesting is a
271
+ cycle-checked DAG). A bare string is refused. `when` is the closed election
272
+ vocabulary -- `"present"` (the default; the value was provided by the
273
+ invocation, never by a declared default), `"true"` (bool only), `"non_empty"`
274
+ (strings and collections) -- and a **bool member must declare it explicitly**,
275
+ so `--no-all` can never engage a constraint while selecting nothing.
191
276
 
192
- Three relationship types, all passed via `dependencies=[...]`:
277
+ ```python
278
+ @app.command("rewrite", help="Rewrite author identity", effect="mutating",
279
+ constraints=[
280
+ strictcli.AllOrNone("author-name", [
281
+ strictcli.Member("old-name"), strictcli.Member("new-name"),
282
+ ]),
283
+ strictcli.AllOrNone("author-email", [
284
+ strictcli.Member("old-email"), strictcli.Member("new-email"),
285
+ ]),
286
+ strictcli.AtLeastOne("author-change", [
287
+ strictcli.Member("author-name"),
288
+ strictcli.Member("author-email"),
289
+ ]),
290
+ ])
291
+ @strictcli.flag("old-name", type=str, presence="optional", help="Current name")
292
+ @strictcli.flag("new-name", type=str, presence="optional", help="New name")
293
+ @strictcli.flag("old-email", type=str, presence="optional", help="Current email")
294
+ @strictcli.flag("new-email", type=str, presence="optional", help="New email")
295
+ def rewrite(ctx, old_name, new_name, old_email, new_email): ...
296
+ ```
193
297
 
194
- - `CoRequired(flags=["output", "format"])` -- all must appear together, or none
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
298
+ ```
299
+ Constraints:
300
+ author-name all or none of --old-name, --new-name
301
+ author-email all or none of --old-email, --new-email
302
+ author-change at least one of (--old-name with --new-name), (--old-email with --new-email)
303
+ ```
304
+
305
+ Children are evaluated before parents, so an operator who typed one half of a
306
+ pair is told the pair is incomplete rather than that the whole selection is
307
+ missing. No member of a co-occurrence constraint may declare
308
+ `presence="required"` -- a member the invocation must always supply leaves the
309
+ constraint nothing to decide.
310
+
311
+ Constraints can only reference flags and args you declared, and they operate at
312
+ root scope only, so the reserved quartet (`dry-run`, `approve-consequential`,
313
+ `quiet`, `verbose`) can never appear in one and a scoped flag is a registration
314
+ error.
315
+
316
+ ### Update commands
317
+
318
+ A command that changes some properties of one resource and leaves the rest alone
319
+ declares what it updates with `update_of=`. `UpdateOf` is a frozen, keyword-only
320
+ record whose first field is the resource name:
197
321
 
198
322
  ```python
199
- @app.command("export", help="Export data", effect="mutating", dependencies=[
200
- strictcli.CoRequired(flags=["output", "format"]),
201
- strictcli.Requires(flag="trace", depends_on="output"),
202
- strictcli.Implies(flag="trace", implies="log-output", value=True),
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): ...
323
+ @app.command("update-record", help="Change one DNS record in place", effect="mutating",
324
+ update_of=strictcli.UpdateOf("dns-record", write_mode="sparse",
325
+ identity=["zone", "record-id"],
326
+ properties=["content", "ttl", "proxied"]))
327
+ @strictcli.flag("zone", type=str, presence="required", help="Zone the record belongs to")
328
+ @strictcli.flag("record-id", type=str, presence="required", help="Identifier of the record")
329
+ @strictcli.flag("content", type=str, presence="optional", help="Record content")
330
+ @strictcli.flag("ttl", type=int, presence="optional", nullable=True, help="Time to live in seconds")
331
+ @strictcli.flag("proxied", type=bool, presence="optional", help="Whether the record is proxied")
332
+ def update_record(ctx, zone, record_id, content, ttl, proxied): ...
209
333
  ```
210
334
 
211
- Dependencies can only reference flags you declared, so the reserved quartet
212
- (`dry-run`, `approve-consequential`, `quiet`, `verbose`) can never appear in one.
335
+ `write_mode` is `"sparse"` or `"full_replace"` and carries no default. A property
336
+ declares `presence="optional"` and nothing else -- absence *is* untouched -- and
337
+ the framework refuses an invocation that supplies none of them:
338
+
339
+ ```
340
+ error: update "dns-record": at least one property is required: --content, --ttl, --proxied
341
+ ```
342
+
343
+ Because absence must never resolve to a value nobody stated, **no flag or arg on
344
+ a `mutating` command may declare a value default** (`default=[]` and `default={}`
345
+ stay legal, as does every default on a `read_only` command). Inside an update
346
+ `--no-proxied` writes `False`; a `nullable` property mints `--unset-<prop>`,
347
+ answered by `ctx.unset(name)`. Every run that reports what it does renders the
348
+ write set -- one unnumbered line in the would-do log, and a `writes` member on
349
+ the machine envelope:
350
+
351
+ ```
352
+ $ mytool --dry-run update-record --zone z1 --record-id r7 --content hi --unset-ttl
353
+ DRY RUN — no changes were made. Would do:
354
+ writes: content; clears: ttl (other properties unchanged)
355
+ 1. net: PATCH https://api.example.com/zones/z1/dns_records/r7
356
+ ```
213
357
 
214
358
  ### Global flags
215
359
 
@@ -244,23 +388,33 @@ are forwarded to the child byte-for-byte, so `myapp run deploy --dry-run` passes
244
388
  Flags that accumulate values across multiple occurrences. Requires explicit `unique=True` or `unique=False`.
245
389
 
246
390
  ```python
247
- @strictcli.flag("tag", type=str, help="Add a tag", repeatable=True, unique=True)
391
+ @strictcli.flag("tag", type=str, help="Add a tag", repeatable=True, unique=True, default=[])
248
392
  ```
249
393
 
250
394
  ### Choices
251
395
 
252
- Restrict flag values to an allowed set.
396
+ Restrict flag values to an allowed set. Every entry is a record, and its help is
397
+ optional:
253
398
 
254
399
  ```python
255
- @strictcli.flag("format", type=str, help="Output format", choices=["json", "csv", "xml"])
400
+ @strictcli.flag("format", type=str, help="Output format", presence="required",
401
+ choices=[strictcli.Choice("json", help="one JSON document"),
402
+ strictcli.Choice("csv"),
403
+ strictcli.Choice("xml")])
256
404
  ```
257
405
 
406
+ Help renders on one line (`[choices: json, csv, xml]`) until an entry carries
407
+ help, at which point the whole flag renders as an indented block.
408
+
409
+ The boundary against a choice flag is structural, not a matter of taste: **need a
410
+ scope or member spelling -> choice flag; a plain constrained value -> choices.**
411
+
258
412
  ### Custom validation
259
413
 
260
414
  Per-flag validation functions.
261
415
 
262
416
  ```python
263
- @strictcli.flag("port", type=int, help="Port number", validate=lambda v: 1 <= v <= 65535)
417
+ @strictcli.flag("port", type=int, help="Port number", validate=lambda v: 1 <= v <= 65535, presence="required")
264
418
  ```
265
419
 
266
420
  ### Deprecated commands
@@ -285,7 +439,7 @@ def internal_debug(ctx): ...
285
439
 
286
440
  ### JSON config file support
287
441
 
288
- Reads `~/.config/{name}/config.json` (or TOML). Auto-registers `config show/set/path/edit` subcommands.
442
+ Reads `~/.config/{name}/config.json` (or TOML). Auto-registers `config show/set/path/edit` subcommands, where `config set <key> --value <v>` writes under a required selector over a value, a clear (`--clear`) and a reset to the declared default (`--default`).
289
443
 
290
444
  ```python
291
445
  app = strictcli.App("myapp", version="1.0.0", help="My app", config=True)
@@ -307,7 +461,8 @@ operations (`run`, `spawn`, `write`, `mkdir`, `remove`, `rename`, `chmod`,
307
461
  `http`) are recorded rather than performed and rendered as a would-do log.
308
462
 
309
463
  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,
464
+ (app flags, command flags, flag sets, and flags declared inside a choice's
465
+ scope at any depth). They arrive on the context,
311
466
  never as handler kwargs:
312
467
 
313
468
  | Flag | Context property |
@@ -353,7 +508,9 @@ without either flag is a hard error rather than a hang. Declaring
353
508
 
354
509
  ### Schema dump
355
510
 
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.
511
+ `--dump-schema` is auto-injected on every app. Writes `.strictcli/schema.json` at `schema_version: 2` 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.
512
+
513
+ Every flag and arg entry carries a `value_schema`: a real JSON Schema fragment from a closed subset of `type`, `items`, `additionalProperties` and `enum`, using JSON Schema's own type names. Arity is part of the value's shape, so a repeatable scalar flag and a `list[T]` flag publish the identical array fragment. A choice flag carries no fragment -- its value is a variant the subset cannot express -- and publishes its nested `choices` and scopes instead, each scoped entry a full flag entry, with `elect_by` marking the spelling. A value flag's `choices=` splits in two: the values as an `enum` inside the fragment, and the value-plus-help records beside it under `choices`. Keys are emitted in a declared order at every depth and the document is written in one canonical encoding, so a schema file written by this implementation and one written by the Go or TypeScript implementation for the same declaration are byte-identical.
357
514
 
358
515
  ### Check system
359
516
 
@@ -441,10 +598,12 @@ paths have no TTY contract, so a consequential command is dispatched directly.
441
598
  | `Flag` | Flag declaration |
442
599
  | `Arg` | Positional argument |
443
600
  | `FlagSet` | Reusable flag bundle |
444
- | `MutexGroup` | Mutually exclusive flags |
445
- | `CoRequired` | Flags that must appear together |
601
+ | `Choice` | One entry of a `choices=` value flag: a value with optional help |
602
+ | `AtLeastOne` | At least one member must be engaged |
603
+ | `AllOrNone` | Every member is engaged, or none is |
446
604
  | `Requires` | One flag depends on another |
447
605
  | `Implies` | Auto-set a bool flag from another |
606
+ | `Member` | One operand of a co-occurrence constraint |
448
607
  | `Result` | Return type of `app.test()` |
449
608
  | `Tool` | LLM tool descriptor |
450
609
  | `CheckRunResult` | Check execution result with wall-clock timing |
@@ -457,8 +616,14 @@ paths have no TTY contract, so a consequential command is dispatched directly.
457
616
  | Decorator | Description |
458
617
  |-----------|-------------|
459
618
  | `@app.command(name, help=..., effect=...)` | Register a command (`effect` is mandatory) |
460
- | `@strictcli.flag(name, type=, help=...)` | Declare a flag |
461
- | `@strictcli.arg(name, help=...)` | Declare a positional argument |
619
+ | `@strictcli.flag(name, type=, help=..., presence=/default=)` | Declare a flag (presence is mandatory) |
620
+ | `@strictcli.arg(name, help=..., presence=/default=)` | Declare a positional argument (presence is mandatory) |
621
+ | `@strictcli.choice_flag(name, help=..., choices=, elect_by=, presence=/default=)` | Declare a choice flag (`elect_by` is mandatory) |
622
+ | `@strictcli.choice(name, help=...)` | Declare one choice of a choice flag, and its scope |
623
+ | `strictcli.sub_flag(help=..., presence=/default=, ...)` | Declare one flag of a choice's scope, inside the choice class body (the field name is the flag name) |
624
+ | `strictcli.sub_choice_flag(help=..., choices=, elect_by=, ...)` | Declare a nested choice flag inside a choice's scope |
625
+ | `strictcli.member_value(help=...)` | Declare a member-spelled choice's own payload, delivered under the reserved name `value` |
626
+ | `strictcli.provided(record, name)` | Whether the invocation caused a scoped field's value, asked of the delivered record |
462
627
  | `@app.error_check(name)` / `@app.warn_check(name)` | Register a check handler |
463
628
 
464
629
  ### App methods