strictcli 0.9.1__tar.gz → 0.10.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 (122) hide show
  1. strictcli-0.10.0/.rlsbl/changes/.validated +1 -0
  2. strictcli-0.10.0/.rlsbl/changes/0.10.0.jsonl +6 -0
  3. strictcli-0.10.0/.rlsbl/changes/0.10.0.md +6 -0
  4. strictcli-0.10.0/.rlsbl/releases/v0.9.0.toml +3 -0
  5. strictcli-0.10.0/.rlsbl/version +1 -0
  6. {strictcli-0.9.1 → strictcli-0.10.0}/CHANGELOG.md +7 -0
  7. {strictcli-0.9.1 → strictcli-0.10.0}/PKG-INFO +1 -1
  8. {strictcli-0.9.1 → strictcli-0.10.0}/package-lock.json +2 -2
  9. {strictcli-0.9.1 → strictcli-0.10.0}/package.json +1 -1
  10. {strictcli-0.9.1 → strictcli-0.10.0}/pyproject.toml +1 -1
  11. {strictcli-0.9.1 → strictcli-0.10.0}/strictcli/__init__.py +211 -40
  12. strictcli-0.10.0/tests/test_at_prefix.py +342 -0
  13. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_dump_schema.py +232 -13
  14. {strictcli-0.9.1 → strictcli-0.10.0}/uv.lock +1 -1
  15. strictcli-0.9.1/.rlsbl/changes/.validated +0 -1
  16. strictcli-0.9.1/.rlsbl/version +0 -1
  17. {strictcli-0.9.1 → strictcli-0.10.0}/.claude/settings.json +0 -0
  18. {strictcli-0.9.1 → strictcli-0.10.0}/.github/workflows/ci.yml +0 -0
  19. {strictcli-0.9.1 → strictcli-0.10.0}/.github/workflows/publish.yml +0 -0
  20. {strictcli-0.9.1 → strictcli-0.10.0}/.gitignore +0 -0
  21. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/.claude/settings.json +0 -0
  22. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/.github/workflows/ci.yml +0 -0
  23. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/.github/workflows/publish.yml +0 -0
  24. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/.gitignore +0 -0
  25. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/hooks/post-release.sh +0 -0
  26. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +0 -0
  27. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +0 -0
  28. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/lint/go.toml +0 -0
  29. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/lint/npm.toml +0 -0
  30. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  31. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/CHANGELOG.md +0 -0
  32. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/CLAUDE.md +0 -0
  33. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/bases/LICENSE +0 -0
  34. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.4.0.jsonl +0 -0
  35. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.4.0.md +0 -0
  36. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.4.1.jsonl +0 -0
  37. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.4.1.md +0 -0
  38. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.5.0.jsonl +0 -0
  39. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.5.0.md +0 -0
  40. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.6.0.jsonl +0 -0
  41. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.6.0.md +0 -0
  42. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.6.1.jsonl +0 -0
  43. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.6.1.md +0 -0
  44. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.7.0.jsonl +0 -0
  45. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.7.0.md +0 -0
  46. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.7.1.jsonl +0 -0
  47. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.7.1.md +0 -0
  48. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.0.jsonl +0 -0
  49. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.0.md +0 -0
  50. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.1.jsonl +0 -0
  51. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.1.md +0 -0
  52. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.2.jsonl +0 -0
  53. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.2.md +0 -0
  54. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.3.jsonl +0 -0
  55. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.3.md +0 -0
  56. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.4.jsonl +0 -0
  57. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.4.md +0 -0
  58. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.5.jsonl +0 -0
  59. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.5.md +0 -0
  60. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.6.jsonl +0 -0
  61. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.6.md +0 -0
  62. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.7.jsonl +0 -0
  63. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.8.7.md +0 -0
  64. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.9.0.jsonl +0 -0
  65. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.9.0.md +0 -0
  66. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.9.1.jsonl +0 -0
  67. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/0.9.1.md +0 -0
  68. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/changes/unreleased.jsonl +0 -0
  69. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/config.json +0 -0
  70. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/hashes.json +0 -0
  71. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/hooks/post-release.sh +0 -0
  72. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/hooks/pre-checks.sh +0 -0
  73. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/hooks/pre-release.sh +0 -0
  74. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/lint/go.toml +0 -0
  75. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/lint/npm.toml +0 -0
  76. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/lint/python.toml +0 -0
  77. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/releases/unreleased.toml +0 -0
  78. /strictcli-0.9.1/.rlsbl/releases/v0.9.0.toml → /strictcli-0.10.0/.rlsbl/releases/v0.10.0.toml +0 -0
  79. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/releases/v0.8.5.toml +0 -0
  80. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/releases/v0.8.6.toml +0 -0
  81. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/releases/v0.8.7.toml +0 -0
  82. {strictcli-0.9.1 → strictcli-0.10.0}/.rlsbl/releases/v0.9.1.toml +0 -0
  83. {strictcli-0.9.1 → strictcli-0.10.0}/.strictcli/schema.json +0 -0
  84. {strictcli-0.9.1 → strictcli-0.10.0}/CLAUDE.md +0 -0
  85. {strictcli-0.9.1 → strictcli-0.10.0}/LICENSE +0 -0
  86. {strictcli-0.9.1 → strictcli-0.10.0}/README.md +0 -0
  87. {strictcli-0.9.1 → strictcli-0.10.0}/index.js +0 -0
  88. {strictcli-0.9.1 → strictcli-0.10.0}/postinstall.js +0 -0
  89. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_arg_default.py +0 -0
  90. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_auto_version.py +0 -0
  91. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_check_command.py +0 -0
  92. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_check_discovery.py +0 -0
  93. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_check_runner.py +0 -0
  94. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_check_schema.py +0 -0
  95. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_check_types.py +0 -0
  96. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_choices.py +0 -0
  97. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_command_help_suggestion.py +0 -0
  98. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_config.py +0 -0
  99. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_config_file_path.py +0 -0
  100. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_deep_nesting.py +0 -0
  101. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_dependencies.py +0 -0
  102. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_deprecated.py +0 -0
  103. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_e2e.py +0 -0
  104. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_env.py +0 -0
  105. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_exit_codes.py +0 -0
  106. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_float_type.py +0 -0
  107. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_global_flags.py +0 -0
  108. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_help.py +0 -0
  109. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_int_type.py +0 -0
  110. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_mutex.py +0 -0
  111. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_nesting.py +0 -0
  112. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_parser.py +0 -0
  113. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_passthrough.py +0 -0
  114. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_registration.py +0 -0
  115. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_repeatable.py +0 -0
  116. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_tagdsl.py +0 -0
  117. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_tags.py +0 -0
  118. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_toml_loading.py +0 -0
  119. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_validate.py +0 -0
  120. {strictcli-0.9.1 → strictcli-0.10.0}/tests/test_variadic.py +0 -0
  121. {strictcli-0.9.1 → strictcli-0.10.0}/todo/.defer/deferred.md +0 -0
  122. {strictcli-0.9.1 → strictcli-0.10.0}/todo/.done/original-idea.md +0 -0
@@ -0,0 +1 @@
1
+ 241a0b1102d02359dc80c640d8c8a17697f05a9b
@@ -0,0 +1,6 @@
1
+ {"commits":["faa8f348f03793084361365f15bd9f8be06046ff"],"user_facing":true,"description":"**New feature.** `--dump-schema` omits fields matching defaults and includes a top-level `defaults` object documenting what missing fields mean.","type":"feature"}
2
+ {"commits":["ef852d540829f5fb248c380b649e8c15e56b849b"],"user_facing":true,"description":"**New feature.** `@`-prefix for string flag values: `@path` reads from file, `@-` reads from stdin, `@@` escapes. 1 MB size limit, trailing whitespace stripped.","type":"feature"}
3
+ {"commits":["4294effc300d83eeba90c3cb90c6429fc443c929","e7e5bc79004fe8c0883fdc5c34a1f36eb8d97e75","6bf56e841883195ea6045386b21f93a4d1c8ee2d"],"user_facing":false}
4
+ {"commits":["05d896e80e8df10ee737f50ae735e99695bbc213","1e354f1681539c39d71e4df7d57090d6b32d3f59","5f8b3d49ab264aa26dbe8c6c1d1f1bebb955d73b"],"user_facing":false}
5
+ {"commits":["33dfbe42738b308dc01df908c8286b48eb97e12f"],"user_facing":false}
6
+ {"commits":["f2b61b9ca7eb86207a0631d01cb672c39dcb89a0"],"user_facing":false}
@@ -0,0 +1,6 @@
1
+ ## 0.10.0
2
+
3
+ ### Features
4
+
5
+ - **New feature.** `--dump-schema` omits fields matching defaults and includes a top-level `defaults` object documenting what missing fields mean.
6
+ - **New feature.** `@`-prefix for string flag values: `@path` reads from file, `@-` reads from stdin, `@@` escapes. 1 MB size limit, trailing whitespace stripped.
@@ -0,0 +1,3 @@
1
+ bump = "minor"
2
+ include = ["pypi", "npm"]
3
+ exclude = []
@@ -0,0 +1 @@
1
+ 0.42.0
@@ -2,6 +2,13 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## 0.10.0
6
+
7
+ ### Features
8
+
9
+ - **New feature.** `--dump-schema` omits fields matching defaults and includes a top-level `defaults` object documenting what missing fields mean.
10
+ - **New feature.** `@`-prefix for string flag values: `@path` reads from file, `@-` reads from stdin, `@@` escapes. 1 MB size limit, trailing whitespace stripped.
11
+
5
12
  ## 0.9.1
6
13
 
7
14
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.9.1
3
+ Version: 0.10.0
4
4
  Summary: A strict, zero-dependency 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
@@ -1,12 +1,12 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "lockfileVersion": 3,
5
5
  "requires": true,
6
6
  "packages": {
7
7
  "": {
8
8
  "name": "strictcli",
9
- "version": "0.9.1",
9
+ "version": "0.10.0",
10
10
  "hasInstallScript": true,
11
11
  "license": "MIT"
12
12
  }
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "strictcli",
3
- "version": "0.9.1",
3
+ "version": "0.10.0",
4
4
  "description": "A strict, zero-dependency CLI framework for Python (npm wrapper)",
5
5
  "main": "index.js",
6
6
  "scripts": {
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.9.1"
7
+ version = "0.10.0"
8
8
  description = "A strict, zero-dependency CLI framework for Python"
9
9
  readme = "README.md"
10
10
  license = "MIT"
@@ -2,7 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
- __version__ = "0.9.1"
5
+ __version__ = "0.10.0"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "Tag", "MutexGroup", "CoRequired", "Requires",
@@ -316,6 +316,50 @@ def _float_parse_error(
316
316
  return _ParseError(f"--{flag_name}: expected float, got {raw!r}{suffix}")
317
317
 
318
318
 
319
+ _AT_PREFIX_MAX_SIZE = 1024 * 1024 # 1 MB
320
+
321
+
322
+ def _resolve_at_prefix(
323
+ flag_name: str, raw: str, stdin_consumed_by: str | None,
324
+ ) -> tuple[str, str | None]:
325
+ """Resolve @-prefix for string flag values.
326
+
327
+ Returns (resolved_value, updated_stdin_consumed_by).
328
+ """
329
+ if not raw.startswith("@"):
330
+ return raw, stdin_consumed_by
331
+ if raw.startswith("@@"):
332
+ return raw[1:], stdin_consumed_by
333
+ if raw == "@-":
334
+ if stdin_consumed_by is not None:
335
+ raise _ParseError(
336
+ f"--{flag_name}: stdin (@-) can only be used once per invocation"
337
+ )
338
+ try:
339
+ data = sys.stdin.read(_AT_PREFIX_MAX_SIZE + 1)
340
+ if len(data) > _AT_PREFIX_MAX_SIZE:
341
+ raise _ParseError(f"--{flag_name}: file exceeds 1 MB limit")
342
+ return data.rstrip(), flag_name
343
+ except _ParseError:
344
+ raise
345
+ except Exception:
346
+ raise _ParseError(f"--{flag_name}: cannot read stdin")
347
+ # @path -- read file
348
+ path = raw[1:]
349
+ if not os.path.exists(path):
350
+ raise _ParseError(f"--{flag_name}: file not found: {path}")
351
+ try:
352
+ with open(path, "r") as f:
353
+ data = f.read(_AT_PREFIX_MAX_SIZE + 1)
354
+ if len(data) > _AT_PREFIX_MAX_SIZE:
355
+ raise _ParseError(f"--{flag_name}: file exceeds 1 MB limit")
356
+ return data.rstrip(), stdin_consumed_by
357
+ except _ParseError:
358
+ raise
359
+ except Exception:
360
+ raise _ParseError(f"--{flag_name}: cannot read file: {path}")
361
+
362
+
319
363
  def _require_non_empty_str(value: str, field_name: str, class_name: str) -> None:
320
364
  if not isinstance(value, str) or not value.strip():
321
365
  raise ValueError(f"{class_name}.{field_name} must be a non-empty string")
@@ -1024,6 +1068,7 @@ class App:
1024
1068
  raise _DumpSchemaRequested()
1025
1069
 
1026
1070
  # Step 1.5: parse global flags before command routing
1071
+ self._stdin_consumed_by: str | None = None
1027
1072
  global_values, remaining = self._parse_global_flags(argv)
1028
1073
  self._last_global_values = global_values
1029
1074
 
@@ -1092,9 +1137,13 @@ class App:
1092
1137
  return cmd, rest
1093
1138
 
1094
1139
  # Step 3: parse remaining tokens for the resolved command
1140
+ # Pass stdin_consumed_by as a mutable single-element list so
1141
+ # _parse_command can update the shared state.
1142
+ stdin_state: list[str | None] = [self._stdin_consumed_by]
1095
1143
  try:
1096
1144
  cmd, kwargs, post_global = _parse_command(
1097
1145
  cmd, rest, self._global_flags, config_data=self._config_data,
1146
+ stdin_consumed_by=stdin_state,
1098
1147
  )
1099
1148
  except _ParseError as e:
1100
1149
  prefix_parts = [self.name] + path + [cmd.name]
@@ -1181,7 +1230,10 @@ class App:
1181
1230
  except ValueError as e:
1182
1231
  raise _float_parse_error(f.name, value_part, e)
1183
1232
  else:
1184
- _store_value(f, value_part)
1233
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1234
+ f.name, value_part, self._stdin_consumed_by,
1235
+ )
1236
+ _store_value(f, resolved)
1185
1237
  i += 1
1186
1238
  continue
1187
1239
  elif flag_part in negation_lookup:
@@ -1222,7 +1274,10 @@ class App:
1222
1274
  except ValueError as e:
1223
1275
  raise _float_parse_error(f.name, raw, e)
1224
1276
  else:
1225
- _store_value(f, raw)
1277
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1278
+ f.name, raw, self._stdin_consumed_by,
1279
+ )
1280
+ _store_value(f, resolved)
1226
1281
  i += 2
1227
1282
  else:
1228
1283
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1250,7 +1305,10 @@ class App:
1250
1305
  except ValueError as e:
1251
1306
  raise _float_parse_error(f.name, raw, e)
1252
1307
  else:
1253
- _store_value(f, raw)
1308
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1309
+ f.name, raw, self._stdin_consumed_by,
1310
+ )
1311
+ _store_value(f, resolved)
1254
1312
  i += 2
1255
1313
  else:
1256
1314
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1297,7 +1355,10 @@ class App:
1297
1355
  raise _float_parse_error(f.name, env_val, e, env=f.env)
1298
1356
  cli_set[f.name] = [coerced] if f.repeatable else coerced
1299
1357
  else:
1300
- cli_set[f.name] = [env_val] if f.repeatable else env_val
1358
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1359
+ f.name, env_val, self._stdin_consumed_by,
1360
+ )
1361
+ cli_set[f.name] = [resolved] if f.repeatable else resolved
1301
1362
 
1302
1363
  # Resolve config values for global flags not set by CLI or env
1303
1364
  if self._config_data:
@@ -1471,12 +1532,18 @@ def _parse_command(
1471
1532
  tokens: list[str],
1472
1533
  global_flags: list[Flag] | None = None,
1473
1534
  config_data: dict | None = None,
1535
+ stdin_consumed_by: list[str | None] | None = None,
1474
1536
  ) -> tuple[Command, dict[str, object], dict[str, object]]:
1475
1537
  """Parse tokens against a resolved command's flags and args.
1476
1538
 
1477
1539
  Returns (cmd, kwargs, global_cli_set) where global_cli_set contains
1478
1540
  any global flag values parsed from tokens appearing after the command name.
1541
+
1542
+ stdin_consumed_by is a mutable single-element list tracking which flag
1543
+ has already consumed stdin via @-. Updated in-place.
1479
1544
  """
1545
+ if stdin_consumed_by is None:
1546
+ stdin_consumed_by = [None]
1480
1547
 
1481
1548
  # Build flag lookup dicts
1482
1549
  long_lookup: dict[str, Flag] = {} # --flag-name -> Flag
@@ -1554,7 +1621,10 @@ def _parse_command(
1554
1621
  except ValueError as e:
1555
1622
  raise _float_parse_error(f.name, value_part, e)
1556
1623
  else:
1557
- _store_value(f, value_part)
1624
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1625
+ f.name, value_part, stdin_consumed_by[0],
1626
+ )
1627
+ _store_value(f, resolved)
1558
1628
  elif flag_part in negation_lookup:
1559
1629
  raise _ParseError(
1560
1630
  f"flag '{flag_part}' is a boolean negation and does not take a value"
@@ -1593,7 +1663,10 @@ def _parse_command(
1593
1663
  except ValueError as e:
1594
1664
  raise _float_parse_error(f.name, raw, e)
1595
1665
  else:
1596
- _store_value(f, raw)
1666
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1667
+ f.name, raw, stdin_consumed_by[0],
1668
+ )
1669
+ _store_value(f, resolved)
1597
1670
  i += 2
1598
1671
  else:
1599
1672
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1623,7 +1696,10 @@ def _parse_command(
1623
1696
  except ValueError as e:
1624
1697
  raise _float_parse_error(f.name, raw, e)
1625
1698
  else:
1626
- _store_value(f, raw)
1699
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1700
+ f.name, raw, stdin_consumed_by[0],
1701
+ )
1702
+ _store_value(f, resolved)
1627
1703
  i += 2
1628
1704
  else:
1629
1705
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1668,7 +1744,10 @@ def _parse_command(
1668
1744
  raise _float_parse_error(f.name, env_val, e, env=f.env)
1669
1745
  cli_set[f.name] = [coerced] if f.repeatable else coerced
1670
1746
  else:
1671
- cli_set[f.name] = [env_val] if f.repeatable else env_val
1747
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1748
+ f.name, env_val, stdin_consumed_by[0],
1749
+ )
1750
+ cli_set[f.name] = [resolved] if f.repeatable else resolved
1672
1751
 
1673
1752
  # Step 4.2: resolve config values for flags not set by CLI or env
1674
1753
  if config_data:
@@ -2870,66 +2949,158 @@ _TYPE_NAMES = {str: "str", bool: "bool", int: "int", float: "float"}
2870
2949
 
2871
2950
 
2872
2951
  def _serialize_flag(f: Flag) -> dict:
2873
- """Serialize a Flag to a JSON-serializable dict."""
2874
- return {
2952
+ """Serialize a Flag to a JSON-serializable dict.
2953
+
2954
+ Identity fields (name, type, help) are always included.
2955
+ Other fields are omitted when they match the schema defaults.
2956
+ """
2957
+ d: dict = {
2875
2958
  "name": f.name,
2876
2959
  "type": _TYPE_NAMES[f.type],
2877
2960
  "help": f.help,
2878
- "short": f.short,
2879
- "default": f.default,
2880
- "env": f.env,
2881
- "choices": f.choices,
2882
- "repeatable": f.repeatable,
2883
- "negatable": f.negatable if f.type is bool else None,
2884
- "hidden": False,
2885
2961
  }
2962
+ if f.short is not None:
2963
+ d["short"] = f.short
2964
+ if f.default is not None:
2965
+ d["default"] = f.default
2966
+ if f.env is not None:
2967
+ d["env"] = f.env
2968
+ if f.choices is not None:
2969
+ d["choices"] = f.choices
2970
+ if f.repeatable:
2971
+ d["repeatable"] = f.repeatable
2972
+ negatable = f.negatable if f.type is bool else None
2973
+ if negatable is not None:
2974
+ d["negatable"] = negatable
2975
+ # hidden is currently always False, so always omitted
2976
+ return d
2886
2977
 
2887
2978
 
2888
2979
  def _serialize_arg(a: Arg) -> dict:
2889
- """Serialize an Arg to a JSON-serializable dict."""
2890
- return {
2980
+ """Serialize an Arg to a JSON-serializable dict.
2981
+
2982
+ Identity fields (name, help) are always included.
2983
+ Other fields are omitted when they match the schema defaults.
2984
+ """
2985
+ d: dict = {
2891
2986
  "name": a.name,
2892
2987
  "help": a.help,
2893
- "required": a.required,
2894
- "variadic": a.variadic,
2895
2988
  }
2989
+ if not a.required:
2990
+ d["required"] = a.required
2991
+ if a.variadic:
2992
+ d["variadic"] = a.variadic
2993
+ return d
2896
2994
 
2897
2995
 
2898
2996
  def _serialize_command(cmd: Command) -> dict:
2899
- """Serialize a Command to a JSON-serializable dict."""
2900
- return {
2997
+ """Serialize a Command to a JSON-serializable dict.
2998
+
2999
+ Identity fields (name, help) are always included.
3000
+ Other fields are omitted when they match the schema defaults.
3001
+ """
3002
+ d: dict = {
2901
3003
  "name": cmd.name,
2902
3004
  "help": cmd.help,
2903
- "flags": [_serialize_flag(f) for f in cmd.flags],
2904
- "args": [_serialize_arg(a) for a in cmd.args],
2905
- "passthrough": cmd.passthrough is not None,
2906
3005
  }
3006
+ if cmd.passthrough is not None:
3007
+ d["passthrough"] = True
3008
+ flags = [_serialize_flag(f) for f in cmd.flags]
3009
+ if flags:
3010
+ d["flags"] = flags
3011
+ args = [_serialize_arg(a) for a in cmd.args]
3012
+ if args:
3013
+ d["args"] = args
3014
+ return d
2907
3015
 
2908
3016
 
2909
3017
  def _serialize_group(group: Group) -> dict:
2910
- """Serialize a Group to a JSON-serializable dict (recursive)."""
2911
- return {
3018
+ """Serialize a Group to a JSON-serializable dict (recursive).
3019
+
3020
+ Identity fields (name, help) are always included.
3021
+ Other fields are omitted when they match the schema defaults.
3022
+ """
3023
+ d: dict = {
2912
3024
  "name": group.name,
2913
3025
  "help": group.help,
2914
- "commands": {name: _serialize_command(cmd) for name, cmd in group.commands.items()},
2915
- "groups": {name: _serialize_group(g) for name, g in group._groups.items()},
2916
- "deprecated": {name: dep.message for name, dep in group.deprecated.items()},
3026
+ }
3027
+ commands = {name: _serialize_command(cmd) for name, cmd in group.commands.items()}
3028
+ if commands:
3029
+ d["commands"] = commands
3030
+ groups = {name: _serialize_group(g) for name, g in group._groups.items()}
3031
+ if groups:
3032
+ d["groups"] = groups
3033
+ deprecated = {name: dep.message for name, dep in group.deprecated.items()}
3034
+ if deprecated:
3035
+ d["deprecated"] = deprecated
3036
+ return d
3037
+
3038
+
3039
+ def _build_schema_defaults() -> dict:
3040
+ """Return the defaults object documenting what 'missing' means in the schema."""
3041
+ return {
3042
+ "app": {
3043
+ "env_prefix": None,
3044
+ "config": False,
3045
+ "global_flags": [],
3046
+ "commands": {},
3047
+ "groups": {},
3048
+ "deprecated": {},
3049
+ },
3050
+ "flag": {
3051
+ "short": None,
3052
+ "default": None,
3053
+ "env": None,
3054
+ "choices": None,
3055
+ "repeatable": False,
3056
+ "negatable": None,
3057
+ "hidden": False,
3058
+ },
3059
+ "arg": {
3060
+ "required": True,
3061
+ "variadic": False,
3062
+ },
3063
+ "command": {
3064
+ "passthrough": False,
3065
+ "flags": [],
3066
+ "args": [],
3067
+ },
3068
+ "group": {
3069
+ "commands": {},
3070
+ "groups": {},
3071
+ "deprecated": {},
3072
+ },
2917
3073
  }
2918
3074
 
2919
3075
 
2920
3076
  def _dump_schema(app: App) -> dict:
2921
- """Produce a JSON-serializable dict representing the app's command tree."""
2922
- schema = {
3077
+ """Produce a JSON-serializable dict representing the app's command tree.
3078
+
3079
+ Fields whose values match the schema defaults are omitted.
3080
+ The top-level ``defaults`` key documents what each missing field means.
3081
+ """
3082
+ schema: dict = {
3083
+ "defaults": _build_schema_defaults(),
2923
3084
  "name": app.name,
2924
3085
  "version": app.version,
2925
3086
  "help": app.help,
2926
- "env_prefix": app.env_prefix,
2927
- "config": app.config,
2928
- "global_flags": [_serialize_flag(f) for f in app._global_flags],
2929
- "commands": {name: _serialize_command(cmd) for name, cmd in app._commands.items()},
2930
- "groups": {name: _serialize_group(grp) for name, grp in app._groups.items()},
2931
- "deprecated": {name: dep.message for name, dep in app._deprecated.items()},
2932
3087
  }
3088
+ if app.env_prefix is not None:
3089
+ schema["env_prefix"] = app.env_prefix
3090
+ if app.config:
3091
+ schema["config"] = app.config
3092
+ global_flags = [_serialize_flag(f) for f in app._global_flags]
3093
+ if global_flags:
3094
+ schema["global_flags"] = global_flags
3095
+ commands = {name: _serialize_command(cmd) for name, cmd in app._commands.items()}
3096
+ if commands:
3097
+ schema["commands"] = commands
3098
+ groups = {name: _serialize_group(grp) for name, grp in app._groups.items()}
3099
+ if groups:
3100
+ schema["groups"] = groups
3101
+ deprecated = {name: dep.message for name, dep in app._deprecated.items()}
3102
+ if deprecated:
3103
+ schema["deprecated"] = deprecated
2933
3104
  if app._checks_enabled:
2934
3105
  schema["checks"] = {
2935
3106
  name: {