strictcli 0.9.1__tar.gz → 0.11.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 (126) hide show
  1. {strictcli-0.9.1/.rlsbl/bases → strictcli-0.11.0}/.github/workflows/ci.yml +1 -0
  2. {strictcli-0.9.1 → strictcli-0.11.0}/.github/workflows/publish.yml +2 -0
  3. {strictcli-0.9.1 → strictcli-0.11.0/.rlsbl/bases}/.github/workflows/ci.yml +1 -0
  4. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/.github/workflows/publish.yml +3 -1
  5. strictcli-0.11.0/.rlsbl/changes/.validated +1 -0
  6. strictcli-0.11.0/.rlsbl/changes/0.10.0.jsonl +6 -0
  7. strictcli-0.11.0/.rlsbl/changes/0.10.0.md +6 -0
  8. strictcli-0.11.0/.rlsbl/changes/0.11.0.jsonl +7 -0
  9. strictcli-0.11.0/.rlsbl/changes/0.11.0.md +5 -0
  10. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/config.json +2 -1
  11. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/hashes.json +2 -2
  12. strictcli-0.11.0/.rlsbl/hooks/pre-checks.sh +10 -0
  13. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/hooks/pre-release.sh +5 -0
  14. strictcli-0.11.0/.rlsbl/releases/v0.11.0.toml +3 -0
  15. strictcli-0.11.0/.rlsbl/releases/v0.9.0.toml +3 -0
  16. strictcli-0.11.0/.rlsbl/version +1 -0
  17. {strictcli-0.9.1 → strictcli-0.11.0}/CHANGELOG.md +13 -0
  18. {strictcli-0.9.1 → strictcli-0.11.0}/PKG-INFO +1 -1
  19. {strictcli-0.9.1 → strictcli-0.11.0}/package-lock.json +2 -2
  20. {strictcli-0.9.1 → strictcli-0.11.0}/package.json +1 -1
  21. {strictcli-0.9.1 → strictcli-0.11.0}/pyproject.toml +1 -1
  22. {strictcli-0.9.1 → strictcli-0.11.0}/strictcli/__init__.py +218 -41
  23. strictcli-0.11.0/tests/test_at_prefix.py +342 -0
  24. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_check_discovery.py +67 -0
  25. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_dump_schema.py +232 -13
  26. {strictcli-0.9.1 → strictcli-0.11.0}/uv.lock +1 -1
  27. strictcli-0.9.1/.rlsbl/changes/.validated +0 -1
  28. strictcli-0.9.1/.rlsbl/hooks/pre-checks.sh +0 -5
  29. strictcli-0.9.1/.rlsbl/version +0 -1
  30. {strictcli-0.9.1 → strictcli-0.11.0}/.claude/settings.json +0 -0
  31. {strictcli-0.9.1 → strictcli-0.11.0}/.gitignore +0 -0
  32. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/.claude/settings.json +0 -0
  33. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/.gitignore +0 -0
  34. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/.rlsbl/hooks/post-release.sh +0 -0
  35. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/.rlsbl/hooks/pre-checks.sh +0 -0
  36. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/.rlsbl/hooks/pre-release.sh +0 -0
  37. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/.rlsbl/lint/go.toml +0 -0
  38. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/.rlsbl/lint/npm.toml +0 -0
  39. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/.rlsbl/lint/python.toml +0 -0
  40. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/CHANGELOG.md +0 -0
  41. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/CLAUDE.md +0 -0
  42. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/bases/LICENSE +0 -0
  43. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.4.0.jsonl +0 -0
  44. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.4.0.md +0 -0
  45. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.4.1.jsonl +0 -0
  46. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.4.1.md +0 -0
  47. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.5.0.jsonl +0 -0
  48. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.5.0.md +0 -0
  49. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.6.0.jsonl +0 -0
  50. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.6.0.md +0 -0
  51. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.6.1.jsonl +0 -0
  52. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.6.1.md +0 -0
  53. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.7.0.jsonl +0 -0
  54. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.7.0.md +0 -0
  55. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.7.1.jsonl +0 -0
  56. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.7.1.md +0 -0
  57. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.0.jsonl +0 -0
  58. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.0.md +0 -0
  59. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.1.jsonl +0 -0
  60. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.1.md +0 -0
  61. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.2.jsonl +0 -0
  62. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.2.md +0 -0
  63. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.3.jsonl +0 -0
  64. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.3.md +0 -0
  65. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.4.jsonl +0 -0
  66. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.4.md +0 -0
  67. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.5.jsonl +0 -0
  68. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.5.md +0 -0
  69. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.6.jsonl +0 -0
  70. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.6.md +0 -0
  71. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.7.jsonl +0 -0
  72. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.8.7.md +0 -0
  73. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.9.0.jsonl +0 -0
  74. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.9.0.md +0 -0
  75. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.9.1.jsonl +0 -0
  76. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/0.9.1.md +0 -0
  77. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/changes/unreleased.jsonl +0 -0
  78. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/hooks/post-release.sh +0 -0
  79. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/lint/go.toml +0 -0
  80. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/lint/npm.toml +0 -0
  81. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/lint/python.toml +0 -0
  82. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/releases/unreleased.toml +0 -0
  83. /strictcli-0.9.1/.rlsbl/releases/v0.9.0.toml → /strictcli-0.11.0/.rlsbl/releases/v0.10.0.toml +0 -0
  84. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/releases/v0.8.5.toml +0 -0
  85. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/releases/v0.8.6.toml +0 -0
  86. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/releases/v0.8.7.toml +0 -0
  87. {strictcli-0.9.1 → strictcli-0.11.0}/.rlsbl/releases/v0.9.1.toml +0 -0
  88. {strictcli-0.9.1 → strictcli-0.11.0}/.strictcli/schema.json +0 -0
  89. {strictcli-0.9.1 → strictcli-0.11.0}/CLAUDE.md +0 -0
  90. {strictcli-0.9.1 → strictcli-0.11.0}/LICENSE +0 -0
  91. {strictcli-0.9.1 → strictcli-0.11.0}/README.md +0 -0
  92. {strictcli-0.9.1 → strictcli-0.11.0}/index.js +0 -0
  93. {strictcli-0.9.1 → strictcli-0.11.0}/postinstall.js +0 -0
  94. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_arg_default.py +0 -0
  95. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_auto_version.py +0 -0
  96. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_check_command.py +0 -0
  97. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_check_runner.py +0 -0
  98. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_check_schema.py +0 -0
  99. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_check_types.py +0 -0
  100. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_choices.py +0 -0
  101. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_command_help_suggestion.py +0 -0
  102. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_config.py +0 -0
  103. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_config_file_path.py +0 -0
  104. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_deep_nesting.py +0 -0
  105. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_dependencies.py +0 -0
  106. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_deprecated.py +0 -0
  107. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_e2e.py +0 -0
  108. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_env.py +0 -0
  109. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_exit_codes.py +0 -0
  110. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_float_type.py +0 -0
  111. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_global_flags.py +0 -0
  112. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_help.py +0 -0
  113. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_int_type.py +0 -0
  114. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_mutex.py +0 -0
  115. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_nesting.py +0 -0
  116. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_parser.py +0 -0
  117. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_passthrough.py +0 -0
  118. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_registration.py +0 -0
  119. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_repeatable.py +0 -0
  120. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_tagdsl.py +0 -0
  121. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_tags.py +0 -0
  122. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_toml_loading.py +0 -0
  123. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_validate.py +0 -0
  124. {strictcli-0.9.1 → strictcli-0.11.0}/tests/test_variadic.py +0 -0
  125. {strictcli-0.9.1 → strictcli-0.11.0}/todo/.defer/deferred.md +0 -0
  126. {strictcli-0.9.1 → strictcli-0.11.0}/todo/.done/original-idea.md +0 -0
@@ -5,6 +5,7 @@ on:
5
5
  branches: [main]
6
6
  pull_request:
7
7
  branches: [main]
8
+ workflow_dispatch:
8
9
 
9
10
  jobs:
10
11
  test:
@@ -3,6 +3,8 @@ name: Publish
3
3
  on:
4
4
  release:
5
5
  types: [published]
6
+ workflow_dispatch:
7
+
6
8
 
7
9
  permissions:
8
10
  contents: read
@@ -5,6 +5,7 @@ on:
5
5
  branches: [main]
6
6
  pull_request:
7
7
  branches: [main]
8
+ workflow_dispatch:
8
9
 
9
10
  jobs:
10
11
  test:
@@ -3,6 +3,8 @@ name: Publish
3
3
  on:
4
4
  release:
5
5
  types: [published]
6
+ workflow_dispatch:
7
+
6
8
 
7
9
  permissions:
8
10
  contents: read
@@ -24,7 +26,7 @@ jobs:
24
26
  - uses: actions/setup-node@v6
25
27
  with:
26
28
  node-version: 24
27
- registry-url: {{registryUrl}}
29
+ registry-url: https://registry.npmjs.org
28
30
  - run: npm publish --provenance --access public
29
31
  env:
30
32
  NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1 @@
1
+ 228f7f6e4e51965e7f3cc19ea1d112e915b585c8
@@ -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,7 @@
1
+ {"commits":["feae73fae6f1e08f1355bc7c1b9beae842397517"],"user_facing":false}
2
+ {"commits":["087bd6cb358bf6ebf99e2635e93a2fdc59ac5517"],"user_facing":false}
3
+ {"commits":["f7e07e1d5ce69895df1a95da9c51ab686300e0b3"],"user_facing":false}
4
+ {"commits":["f96d157204a50c577f78b99667be81472e23bfe7"],"user_facing":false}
5
+ {"commits":["c7ee8c1401de38d9e086c38284388497c54cca0a"],"user_facing":true,"description":"**New feature.** `checks_path` parameter on `App` for explicit checks.toml location, replacing CWD-based discovery.","type":"feature"}
6
+ {"commits":["dd7a103769db5bccfaabb2636fce0ef5082e2159","df35fbde862721ef8b01a483abf6b33e5387afa8"],"user_facing":false}
7
+ {"commits":["f94f50bad6401756ca75ee82b31fbfc7e85edc83"],"user_facing":false}
@@ -0,0 +1,5 @@
1
+ ## 0.11.0
2
+
3
+ ### Features
4
+
5
+ - **New feature.** `checks_path` parameter on `App` for explicit checks.toml location, replacing CWD-based discovery.
@@ -3,5 +3,6 @@
3
3
  "pypi",
4
4
  "npm"
5
5
  ],
6
- "private": false
6
+ "private": false,
7
+ "push_timeout": 300
7
8
  }
@@ -1,6 +1,6 @@
1
1
  {
2
- ".github/workflows/ci.yml": "d1da13d13914be16b2ecafe67afc62d611dc8dcc4ebc9db2befd84c2d6296ab2",
3
- ".github/workflows/publish.yml": "5f4ac1bf0bdd4c70041e3d2502efaae28d29eca7575e8ff12fdaad0d3af5b17a",
2
+ ".github/workflows/ci.yml": "b4e075171b131d25f9cce55a3e3a30732e95a2a794af00f354ee3200626d6a04",
3
+ ".github/workflows/publish.yml": "8befe0164321eb68e9465c634aa15d6ccb30d46fa669f7d30bf3ed064ad258ba",
4
4
  "CHANGELOG.md": "7a6ec46141007c18090a0ff325e2dd68cd51588c4e0b5af7b580365e5eaaca85",
5
5
  ".gitignore": "c89d76c6c43e7f0897b3472a15b7c0103e0be5b3a04c2b44e585fa48bdcda2bb",
6
6
  "LICENSE": "e958892abc1dd5d991fca6d528e695d335f25773d373d2e0be5f4a9507d8f8d5",
@@ -0,0 +1,10 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+ # This hook runs BEFORE built-in pre-release checks (tests, lint).
4
+ # Use it for setup tasks: starting services, setting env vars, etc.
5
+ # Built-in checks run after this hook. Custom validation goes in pre-release.sh.
6
+
7
+ # Ensure strictcli and conformance tool are installed (editable)
8
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
9
+ pip install -e "$REPO_ROOT/python" --break-system-packages --quiet 2>/dev/null || true
10
+ pip install -e "$REPO_ROOT/conformance" --break-system-packages --quiet 2>/dev/null || true
@@ -6,3 +6,8 @@ set -euo pipefail
6
6
  # - Check for uncommitted documentation
7
7
  # - Verify external service connectivity
8
8
  # - Run integration tests not covered by the test suite
9
+
10
+ # Run conformance checks before release
11
+ REPO_ROOT="$(git rev-parse --show-toplevel)"
12
+ cd "$REPO_ROOT/conformance"
13
+ conformance check --tag pre-release
@@ -0,0 +1,3 @@
1
+ bump = "minor"
2
+ include = ["pypi", "npm"]
3
+ exclude = []
@@ -0,0 +1,3 @@
1
+ bump = "minor"
2
+ include = ["pypi", "npm"]
3
+ exclude = []
@@ -0,0 +1 @@
1
+ 0.45.0
@@ -2,6 +2,19 @@
2
2
 
3
3
  # Changelog
4
4
 
5
+ ## 0.11.0
6
+
7
+ ### Features
8
+
9
+ - **New feature.** `checks_path` parameter on `App` for explicit checks.toml location, replacing CWD-based discovery.
10
+
11
+ ## 0.10.0
12
+
13
+ ### Features
14
+
15
+ - **New feature.** `--dump-schema` omits fields matching defaults and includes a top-level `defaults` object documenting what missing fields mean.
16
+ - **New feature.** `@`-prefix for string flag values: `@path` reads from file, `@-` reads from stdin, `@@` escapes. 1 MB size limit, trailing whitespace stripped.
17
+
5
18
  ## 0.9.1
6
19
 
7
20
  ### Features
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.9.1
3
+ Version: 0.11.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.11.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.11.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.11.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.11.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.11.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")
@@ -623,6 +667,7 @@ class App:
623
667
  config: bool = False
624
668
  config_path: str | None = None
625
669
  config_format: str = "json"
670
+ checks_path: str | Path | None = None
626
671
  flags: list[Flag] = field(default_factory=list)
627
672
  _commands: dict[str, Command] = field(default_factory=dict)
628
673
  _groups: dict[str, Group] = field(default_factory=dict)
@@ -660,7 +705,12 @@ class App:
660
705
  self._register_config_group()
661
706
  # Discover checks TOML
662
707
  self._check_context_factory: Callable | None = None
663
- checks_toml_path = Path.cwd() / ".strictcli" / "checks.toml"
708
+ if self.checks_path is not None:
709
+ checks_toml_path = Path(self.checks_path).resolve()
710
+ if not checks_toml_path.is_file():
711
+ raise ValueError(f"checks_path does not exist: {self.checks_path}")
712
+ else:
713
+ checks_toml_path = Path.cwd() / ".strictcli" / "checks.toml"
664
714
  if checks_toml_path.is_file():
665
715
  self._check_defs: dict[str, _CheckDef] = _load_checks_toml(checks_toml_path)
666
716
  self._checks_enabled = True
@@ -1024,6 +1074,7 @@ class App:
1024
1074
  raise _DumpSchemaRequested()
1025
1075
 
1026
1076
  # Step 1.5: parse global flags before command routing
1077
+ self._stdin_consumed_by: str | None = None
1027
1078
  global_values, remaining = self._parse_global_flags(argv)
1028
1079
  self._last_global_values = global_values
1029
1080
 
@@ -1092,9 +1143,13 @@ class App:
1092
1143
  return cmd, rest
1093
1144
 
1094
1145
  # Step 3: parse remaining tokens for the resolved command
1146
+ # Pass stdin_consumed_by as a mutable single-element list so
1147
+ # _parse_command can update the shared state.
1148
+ stdin_state: list[str | None] = [self._stdin_consumed_by]
1095
1149
  try:
1096
1150
  cmd, kwargs, post_global = _parse_command(
1097
1151
  cmd, rest, self._global_flags, config_data=self._config_data,
1152
+ stdin_consumed_by=stdin_state,
1098
1153
  )
1099
1154
  except _ParseError as e:
1100
1155
  prefix_parts = [self.name] + path + [cmd.name]
@@ -1181,7 +1236,10 @@ class App:
1181
1236
  except ValueError as e:
1182
1237
  raise _float_parse_error(f.name, value_part, e)
1183
1238
  else:
1184
- _store_value(f, value_part)
1239
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1240
+ f.name, value_part, self._stdin_consumed_by,
1241
+ )
1242
+ _store_value(f, resolved)
1185
1243
  i += 1
1186
1244
  continue
1187
1245
  elif flag_part in negation_lookup:
@@ -1222,7 +1280,10 @@ class App:
1222
1280
  except ValueError as e:
1223
1281
  raise _float_parse_error(f.name, raw, e)
1224
1282
  else:
1225
- _store_value(f, raw)
1283
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1284
+ f.name, raw, self._stdin_consumed_by,
1285
+ )
1286
+ _store_value(f, resolved)
1226
1287
  i += 2
1227
1288
  else:
1228
1289
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1250,7 +1311,10 @@ class App:
1250
1311
  except ValueError as e:
1251
1312
  raise _float_parse_error(f.name, raw, e)
1252
1313
  else:
1253
- _store_value(f, raw)
1314
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1315
+ f.name, raw, self._stdin_consumed_by,
1316
+ )
1317
+ _store_value(f, resolved)
1254
1318
  i += 2
1255
1319
  else:
1256
1320
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1297,7 +1361,10 @@ class App:
1297
1361
  raise _float_parse_error(f.name, env_val, e, env=f.env)
1298
1362
  cli_set[f.name] = [coerced] if f.repeatable else coerced
1299
1363
  else:
1300
- cli_set[f.name] = [env_val] if f.repeatable else env_val
1364
+ resolved, self._stdin_consumed_by = _resolve_at_prefix(
1365
+ f.name, env_val, self._stdin_consumed_by,
1366
+ )
1367
+ cli_set[f.name] = [resolved] if f.repeatable else resolved
1301
1368
 
1302
1369
  # Resolve config values for global flags not set by CLI or env
1303
1370
  if self._config_data:
@@ -1471,12 +1538,18 @@ def _parse_command(
1471
1538
  tokens: list[str],
1472
1539
  global_flags: list[Flag] | None = None,
1473
1540
  config_data: dict | None = None,
1541
+ stdin_consumed_by: list[str | None] | None = None,
1474
1542
  ) -> tuple[Command, dict[str, object], dict[str, object]]:
1475
1543
  """Parse tokens against a resolved command's flags and args.
1476
1544
 
1477
1545
  Returns (cmd, kwargs, global_cli_set) where global_cli_set contains
1478
1546
  any global flag values parsed from tokens appearing after the command name.
1547
+
1548
+ stdin_consumed_by is a mutable single-element list tracking which flag
1549
+ has already consumed stdin via @-. Updated in-place.
1479
1550
  """
1551
+ if stdin_consumed_by is None:
1552
+ stdin_consumed_by = [None]
1480
1553
 
1481
1554
  # Build flag lookup dicts
1482
1555
  long_lookup: dict[str, Flag] = {} # --flag-name -> Flag
@@ -1554,7 +1627,10 @@ def _parse_command(
1554
1627
  except ValueError as e:
1555
1628
  raise _float_parse_error(f.name, value_part, e)
1556
1629
  else:
1557
- _store_value(f, value_part)
1630
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1631
+ f.name, value_part, stdin_consumed_by[0],
1632
+ )
1633
+ _store_value(f, resolved)
1558
1634
  elif flag_part in negation_lookup:
1559
1635
  raise _ParseError(
1560
1636
  f"flag '{flag_part}' is a boolean negation and does not take a value"
@@ -1593,7 +1669,10 @@ def _parse_command(
1593
1669
  except ValueError as e:
1594
1670
  raise _float_parse_error(f.name, raw, e)
1595
1671
  else:
1596
- _store_value(f, raw)
1672
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1673
+ f.name, raw, stdin_consumed_by[0],
1674
+ )
1675
+ _store_value(f, resolved)
1597
1676
  i += 2
1598
1677
  else:
1599
1678
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1623,7 +1702,10 @@ def _parse_command(
1623
1702
  except ValueError as e:
1624
1703
  raise _float_parse_error(f.name, raw, e)
1625
1704
  else:
1626
- _store_value(f, raw)
1705
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1706
+ f.name, raw, stdin_consumed_by[0],
1707
+ )
1708
+ _store_value(f, resolved)
1627
1709
  i += 2
1628
1710
  else:
1629
1711
  raise _ParseError(f"flag '{tok}' requires a value")
@@ -1668,7 +1750,10 @@ def _parse_command(
1668
1750
  raise _float_parse_error(f.name, env_val, e, env=f.env)
1669
1751
  cli_set[f.name] = [coerced] if f.repeatable else coerced
1670
1752
  else:
1671
- cli_set[f.name] = [env_val] if f.repeatable else env_val
1753
+ resolved, stdin_consumed_by[0] = _resolve_at_prefix(
1754
+ f.name, env_val, stdin_consumed_by[0],
1755
+ )
1756
+ cli_set[f.name] = [resolved] if f.repeatable else resolved
1672
1757
 
1673
1758
  # Step 4.2: resolve config values for flags not set by CLI or env
1674
1759
  if config_data:
@@ -2870,66 +2955,158 @@ _TYPE_NAMES = {str: "str", bool: "bool", int: "int", float: "float"}
2870
2955
 
2871
2956
 
2872
2957
  def _serialize_flag(f: Flag) -> dict:
2873
- """Serialize a Flag to a JSON-serializable dict."""
2874
- return {
2958
+ """Serialize a Flag to a JSON-serializable dict.
2959
+
2960
+ Identity fields (name, type, help) are always included.
2961
+ Other fields are omitted when they match the schema defaults.
2962
+ """
2963
+ d: dict = {
2875
2964
  "name": f.name,
2876
2965
  "type": _TYPE_NAMES[f.type],
2877
2966
  "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
2967
  }
2968
+ if f.short is not None:
2969
+ d["short"] = f.short
2970
+ if f.default is not None:
2971
+ d["default"] = f.default
2972
+ if f.env is not None:
2973
+ d["env"] = f.env
2974
+ if f.choices is not None:
2975
+ d["choices"] = f.choices
2976
+ if f.repeatable:
2977
+ d["repeatable"] = f.repeatable
2978
+ negatable = f.negatable if f.type is bool else None
2979
+ if negatable is not None:
2980
+ d["negatable"] = negatable
2981
+ # hidden is currently always False, so always omitted
2982
+ return d
2886
2983
 
2887
2984
 
2888
2985
  def _serialize_arg(a: Arg) -> dict:
2889
- """Serialize an Arg to a JSON-serializable dict."""
2890
- return {
2986
+ """Serialize an Arg to a JSON-serializable dict.
2987
+
2988
+ Identity fields (name, help) are always included.
2989
+ Other fields are omitted when they match the schema defaults.
2990
+ """
2991
+ d: dict = {
2891
2992
  "name": a.name,
2892
2993
  "help": a.help,
2893
- "required": a.required,
2894
- "variadic": a.variadic,
2895
2994
  }
2995
+ if not a.required:
2996
+ d["required"] = a.required
2997
+ if a.variadic:
2998
+ d["variadic"] = a.variadic
2999
+ return d
2896
3000
 
2897
3001
 
2898
3002
  def _serialize_command(cmd: Command) -> dict:
2899
- """Serialize a Command to a JSON-serializable dict."""
2900
- return {
3003
+ """Serialize a Command to a JSON-serializable dict.
3004
+
3005
+ Identity fields (name, help) are always included.
3006
+ Other fields are omitted when they match the schema defaults.
3007
+ """
3008
+ d: dict = {
2901
3009
  "name": cmd.name,
2902
3010
  "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
3011
  }
3012
+ if cmd.passthrough is not None:
3013
+ d["passthrough"] = True
3014
+ flags = [_serialize_flag(f) for f in cmd.flags]
3015
+ if flags:
3016
+ d["flags"] = flags
3017
+ args = [_serialize_arg(a) for a in cmd.args]
3018
+ if args:
3019
+ d["args"] = args
3020
+ return d
2907
3021
 
2908
3022
 
2909
3023
  def _serialize_group(group: Group) -> dict:
2910
- """Serialize a Group to a JSON-serializable dict (recursive)."""
2911
- return {
3024
+ """Serialize a Group to a JSON-serializable dict (recursive).
3025
+
3026
+ Identity fields (name, help) are always included.
3027
+ Other fields are omitted when they match the schema defaults.
3028
+ """
3029
+ d: dict = {
2912
3030
  "name": group.name,
2913
3031
  "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()},
3032
+ }
3033
+ commands = {name: _serialize_command(cmd) for name, cmd in group.commands.items()}
3034
+ if commands:
3035
+ d["commands"] = commands
3036
+ groups = {name: _serialize_group(g) for name, g in group._groups.items()}
3037
+ if groups:
3038
+ d["groups"] = groups
3039
+ deprecated = {name: dep.message for name, dep in group.deprecated.items()}
3040
+ if deprecated:
3041
+ d["deprecated"] = deprecated
3042
+ return d
3043
+
3044
+
3045
+ def _build_schema_defaults() -> dict:
3046
+ """Return the defaults object documenting what 'missing' means in the schema."""
3047
+ return {
3048
+ "app": {
3049
+ "env_prefix": None,
3050
+ "config": False,
3051
+ "global_flags": [],
3052
+ "commands": {},
3053
+ "groups": {},
3054
+ "deprecated": {},
3055
+ },
3056
+ "flag": {
3057
+ "short": None,
3058
+ "default": None,
3059
+ "env": None,
3060
+ "choices": None,
3061
+ "repeatable": False,
3062
+ "negatable": None,
3063
+ "hidden": False,
3064
+ },
3065
+ "arg": {
3066
+ "required": True,
3067
+ "variadic": False,
3068
+ },
3069
+ "command": {
3070
+ "passthrough": False,
3071
+ "flags": [],
3072
+ "args": [],
3073
+ },
3074
+ "group": {
3075
+ "commands": {},
3076
+ "groups": {},
3077
+ "deprecated": {},
3078
+ },
2917
3079
  }
2918
3080
 
2919
3081
 
2920
3082
  def _dump_schema(app: App) -> dict:
2921
- """Produce a JSON-serializable dict representing the app's command tree."""
2922
- schema = {
3083
+ """Produce a JSON-serializable dict representing the app's command tree.
3084
+
3085
+ Fields whose values match the schema defaults are omitted.
3086
+ The top-level ``defaults`` key documents what each missing field means.
3087
+ """
3088
+ schema: dict = {
3089
+ "defaults": _build_schema_defaults(),
2923
3090
  "name": app.name,
2924
3091
  "version": app.version,
2925
3092
  "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
3093
  }
3094
+ if app.env_prefix is not None:
3095
+ schema["env_prefix"] = app.env_prefix
3096
+ if app.config:
3097
+ schema["config"] = app.config
3098
+ global_flags = [_serialize_flag(f) for f in app._global_flags]
3099
+ if global_flags:
3100
+ schema["global_flags"] = global_flags
3101
+ commands = {name: _serialize_command(cmd) for name, cmd in app._commands.items()}
3102
+ if commands:
3103
+ schema["commands"] = commands
3104
+ groups = {name: _serialize_group(grp) for name, grp in app._groups.items()}
3105
+ if groups:
3106
+ schema["groups"] = groups
3107
+ deprecated = {name: dep.message for name, dep in app._deprecated.items()}
3108
+ if deprecated:
3109
+ schema["deprecated"] = deprecated
2933
3110
  if app._checks_enabled:
2934
3111
  schema["checks"] = {
2935
3112
  name: {