strictcli 0.30.0__tar.gz → 0.31.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 (94) hide show
  1. strictcli-0.30.0/.github/workflows/ci-pypi.yml → strictcli-0.31.0/.github/workflows/ci.yml +5 -1
  2. {strictcli-0.30.0 → strictcli-0.31.0}/.github/workflows/publish.yml +74 -39
  3. strictcli-0.31.0/.gitignore +20 -0
  4. strictcli-0.31.0/.rlsbl/bases/.github/workflows/ci.yml +33 -0
  5. strictcli-0.31.0/.rlsbl/bases/.github/workflows/publish.yml +187 -0
  6. {strictcli-0.30.0 → strictcli-0.31.0/.rlsbl/bases}/.gitignore +3 -2
  7. strictcli-0.31.0/.rlsbl/bases/.rlsbl/lint/python.toml +25 -0
  8. {strictcli-0.30.0 → strictcli-0.31.0}/.rlsbl/config.json +1 -8
  9. strictcli-0.31.0/.rlsbl/lint/python.toml +25 -0
  10. strictcli-0.31.0/.rlsbl/managed-files.json +9 -0
  11. strictcli-0.31.0/.rlsbl/version +1 -0
  12. {strictcli-0.30.0 → strictcli-0.31.0}/CLAUDE.md +1 -1
  13. {strictcli-0.30.0 → strictcli-0.31.0}/PKG-INFO +1 -1
  14. {strictcli-0.30.0 → strictcli-0.31.0}/pyproject.toml +1 -1
  15. {strictcli-0.30.0 → strictcli-0.31.0}/strictcli/__init__.py +12 -10
  16. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_mcp.py +40 -2
  17. {strictcli-0.30.0 → strictcli-0.31.0}/uv.lock +1 -1
  18. strictcli-0.30.0/.github/workflows/ci.yml +0 -22
  19. strictcli-0.30.0/.npmignore +0 -34
  20. strictcli-0.30.0/.rlsbl/managed-files.json +0 -11
  21. strictcli-0.30.0/index.js +0 -5
  22. strictcli-0.30.0/package-lock.json +0 -14
  23. strictcli-0.30.0/package.json +0 -25
  24. strictcli-0.30.0/postinstall.js +0 -17
  25. {strictcli-0.30.0 → strictcli-0.31.0}/.claude/settings.json +0 -0
  26. {strictcli-0.30.0 → strictcli-0.31.0}/.strictcli/schema.json +0 -0
  27. {strictcli-0.30.0 → strictcli-0.31.0}/LICENSE +0 -0
  28. {strictcli-0.30.0 → strictcli-0.31.0}/README.md +0 -0
  29. {strictcli-0.30.0 → strictcli-0.31.0}/strictcli/py.typed +0 -0
  30. {strictcli-0.30.0 → strictcli-0.31.0}/tests/conftest.py +0 -0
  31. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_arg_default.py +0 -0
  32. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_arg_default_validation.py +0 -0
  33. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_at_prefix.py +0 -0
  34. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_auto_version.py +0 -0
  35. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_call.py +0 -0
  36. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_check_command.py +0 -0
  37. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_check_discovery.py +0 -0
  38. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_check_provider.py +0 -0
  39. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_check_public_api.py +0 -0
  40. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_check_runner.py +0 -0
  41. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_check_schema.py +0 -0
  42. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_check_types.py +0 -0
  43. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_choices.py +0 -0
  44. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_choices_none.py +0 -0
  45. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_command_help_suggestion.py +0 -0
  46. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_command_tags.py +0 -0
  47. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_compound_types.py +0 -0
  48. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_config.py +0 -0
  49. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_config_fields.py +0 -0
  50. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_config_file_path.py +0 -0
  51. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_config_set_bugs.py +0 -0
  52. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_context.py +0 -0
  53. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_coverage.py +0 -0
  54. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_deep_nesting.py +0 -0
  55. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_dependencies.py +0 -0
  56. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_deprecated.py +0 -0
  57. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_dump_schema.py +0 -0
  58. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_e2e.py +0 -0
  59. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_env.py +0 -0
  60. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_exit_codes.py +0 -0
  61. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_flag_sets.py +0 -0
  62. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_float_format.py +0 -0
  63. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_float_type.py +0 -0
  64. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_float_vectors.py +0 -0
  65. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_global_flag_conflict_position.py +0 -0
  66. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_global_flags.py +0 -0
  67. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_help.py +0 -0
  68. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_hermetic.py +0 -0
  69. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_infra_env.py +0 -0
  70. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_int_type.py +0 -0
  71. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_invoke.py +0 -0
  72. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_keyword_flags.py +0 -0
  73. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_mutex.py +0 -0
  74. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_nesting.py +0 -0
  75. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_parser.py +0 -0
  76. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_passthrough.py +0 -0
  77. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_provenance.py +0 -0
  78. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_provenance_phase2.py +0 -0
  79. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_registration.py +0 -0
  80. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_repeatable.py +0 -0
  81. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_reserved_global_flags.py +0 -0
  82. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_tagdsl.py +0 -0
  83. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_toml_loading.py +0 -0
  84. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_tool_export.py +0 -0
  85. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_typed_args.py +0 -0
  86. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_unique.py +0 -0
  87. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_utilities.py +0 -0
  88. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_validate.py +0 -0
  89. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_variadic.py +0 -0
  90. {strictcli-0.30.0 → strictcli-0.31.0}/tests/test_visibility.py +0 -0
  91. {strictcli-0.30.0 → strictcli-0.31.0}/todo/.defer/deferred.md +0 -0
  92. {strictcli-0.30.0 → strictcli-0.31.0}/todo/.done/keyword-collision-in-flag-param-name.md +0 -0
  93. {strictcli-0.30.0 → strictcli-0.31.0}/todo/.done/original-idea.md +0 -0
  94. {strictcli-0.30.0 → strictcli-0.31.0}/todo/.done/public-check-runner-api.md +0 -0
@@ -26,4 +26,8 @@ jobs:
26
26
  - run: uv python install ${{ matrix.python-version }}
27
27
  - run: uv sync --locked
28
28
  - run: uv run python -c "import strictcli"
29
- - run: uv run pytest
29
+ - name: Install gitleaks
30
+ run: |
31
+ GITLEAKS_VERSION=8.24.3
32
+ curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar xz -C /usr/local/bin gitleaks
33
+ - run: uv run pytest --rootdir .
@@ -3,8 +3,18 @@ on:
3
3
  release:
4
4
  types: [published]
5
5
  workflow_dispatch:
6
+ inputs:
7
+ tag:
8
+ description: Release tag to publish (e.g. v1.2.3). Overrides the ref for
9
+ retry dispatch.
10
+ required: false
11
+ type: string
12
+
13
+ # One publish run per tag: a workflow_dispatch retry at the same tag
14
+ # queues behind the in-flight run instead of racing it. A publish is never
15
+ # cancelled mid-flight.
6
16
  concurrency:
7
- group: ${{ github.workflow_ref }}-${{ github.ref }}
17
+ group: publish-${{ inputs.tag || github.ref_name }}
8
18
  cancel-in-progress: false
9
19
  permissions:
10
20
  contents: read
@@ -20,16 +30,57 @@ jobs:
20
30
  GATE_TIMEOUT_MINUTES: '20'
21
31
  GATE_GRACE_MINUTES: '5'
22
32
  GATE_POLL_SECONDS: '15'
33
+ GATE_MARKER_ATTEMPTS: '5'
34
+ GATE_MARKER_RETRY_SECONDS: '5'
23
35
  CI_CHECK_REGEX: ^(test)( \(.*\))?$
24
36
  steps:
25
37
  - name: Wait for CI to succeed on the release commit
26
38
  run: |
27
39
  set -euo pipefail
28
40
 
29
- # Ref-based commit resolution: GITHUB_SHA is the tag's commit for both
30
- # release-triggered runs and workflow_dispatch retries at the tag ref.
31
- # Never read the release event payload -- dispatch retries have none.
32
- sha="$GITHUB_SHA"
41
+ # Commit resolution -- explicit marker-first order (never reads the release
42
+ # event payload; dispatch retries have none, so this path is uniform for
43
+ # release events and dispatch retries alike):
44
+ # (1) Marker: rlsbl writes the exact commit CI ran on into the GitHub
45
+ # Release body as a machine-parseable line of the form
46
+ # <!-- rlsbl-ci-sha: <40-hex> -->
47
+ # Prefer it when present -- it pins the precise commit and is immune to
48
+ # ref races. A just-created release can lag on a GitHub API read replica
49
+ # (the marker is written at release creation but may not be visible on
50
+ # the first read), so RETRY the read GATE_MARKER_ATTEMPTS times,
51
+ # GATE_MARKER_RETRY_SECONDS apart, before concluding it is absent. The
52
+ # tag is inputs.tag (TAG_INPUT) when dispatched with an explicit
53
+ # override, else GITHUB_REF_NAME (matches the router resolver).
54
+ # (2) Fallback: older releases predate the marker, so fall back to
55
+ # $GITHUB_SHA, the tag's commit for both release and dispatch-at-tag.
56
+ extract_ci_sha() {
57
+ # Emit the first rlsbl-ci-sha marker SHA found on stdin, if any.
58
+ sed -n 's/.*<!-- rlsbl-ci-sha: \([0-9a-f]\{40\}\) -->.*/\1/p' | head -n1
59
+ }
60
+ tag="${TAG_INPUT:-$GITHUB_REF_NAME}"
61
+ marker_attempts="${GATE_MARKER_ATTEMPTS:-5}"
62
+ marker_retry_seconds="${GATE_MARKER_RETRY_SECONDS:-5}"
63
+ sha=""
64
+ attempt=1
65
+ while [ "$attempt" -le "$marker_attempts" ]; do
66
+ if body="$(gh release view "$tag" --json body --jq .body 2>/dev/null)"; then
67
+ sha="$(printf '%s\n' "$body" | extract_ci_sha)"
68
+ fi
69
+ if [ -n "$sha" ]; then
70
+ break
71
+ fi
72
+ if [ "$attempt" -lt "$marker_attempts" ]; then
73
+ echo "Publish gate: rlsbl-ci-sha marker not yet visible in the '$tag' release body (attempt $attempt/$marker_attempts); retrying in ${marker_retry_seconds}s..."
74
+ sleep "$marker_retry_seconds"
75
+ fi
76
+ attempt=$(( attempt + 1 ))
77
+ done
78
+ if [ -n "$sha" ]; then
79
+ echo "Publish gate: resolved release commit from rlsbl-ci-sha marker in the '$tag' release body."
80
+ else
81
+ sha="$GITHUB_SHA"
82
+ echo "Publish gate: no rlsbl-ci-sha marker after $marker_attempts attempt(s) on the '$tag' release body; falling back to \$GITHUB_SHA."
83
+ fi
33
84
  echo "Publish gate: waiting for CI on $GITHUB_REF_NAME (commit $sha)"
34
85
  echo "Check-run name filter: $CI_CHECK_REGEX"
35
86
  # Timeout, grace window, and poll interval come from the job env above;
@@ -49,11 +100,19 @@ jobs:
49
100
  # Match this project's CI check runs by name; exclude check runs that
50
101
  # belong to THIS workflow run (the gate itself and the queued publish
51
102
  # jobs would otherwise deadlock the poll loop).
103
+ # A retried CI run creates a BRAND-NEW check-run with the same name as the
104
+ # old one, so a stale failure would block the gate forever. Project id and
105
+ # started_at, then reduce to the latest check-run per name (max started_at,
106
+ # numeric id as tiebreak for retries within the same second) BEFORE the
107
+ # pending / not-success logic runs. This way the newest run decides: still
108
+ # running -> wait; genuinely red -> hard fail.
52
109
  runs="$(jq -s --arg re "$CI_CHECK_REGEX" --arg run_id "$GITHUB_RUN_ID" '
53
110
  [ .[].check_runs[]
54
111
  | select(.name | test($re))
55
112
  | select((.details_url // "") | contains("/actions/runs/" + $run_id + "/") | not)
56
- | {name, status, conclusion} ]' <<< "$resp")"
113
+ | {name, status, conclusion, id, started_at} ]
114
+ | group_by(.name)
115
+ | map(sort_by(.started_at, .id) | last)' <<< "$resp")"
57
116
  total="$(jq 'length' <<< "$runs")"
58
117
 
59
118
  if [ "$total" -eq 0 ]; then
@@ -112,41 +171,17 @@ jobs:
112
171
  runs-on: ubuntu-latest
113
172
  steps:
114
173
  - uses: actions/checkout@v6
174
+ with:
175
+ ref: ${{ inputs.tag || github.event.release.tag_name }}
115
176
  - uses: astral-sh/setup-uv@v7
177
+ - name: Install gitleaks
178
+ run: |
179
+ GITLEAKS_VERSION=8.24.3
180
+ curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar xz -C /usr/local/bin gitleaks
116
181
  - run: uv build --out-dir dist
182
+ - name: Scan artifacts for secrets
183
+ run: |
184
+ gitleaks dir dist/
117
185
  - uses: pypa/gh-action-pypi-publish@release/v1
118
186
  with:
119
187
  skip-existing: true
120
- npm:
121
- needs: gate
122
- runs-on: ubuntu-latest
123
- steps:
124
- - uses: actions/checkout@v6
125
- - uses: actions/setup-node@v6
126
- with:
127
- node-version: 24
128
- registry-url: https://registry.npmjs.org
129
- - name: Check if already published
130
- id: check-npm
131
- run: |
132
- PKG_NAME=$(node -p "require('./package.json').name")
133
- PKG_VERSION=$(node -p "require('./package.json').version")
134
- if npm view "${PKG_NAME}@${PKG_VERSION}" version 2>/dev/null; then
135
- echo "skip=true" >> "$GITHUB_OUTPUT"
136
- echo "Already published: ${PKG_NAME}@${PKG_VERSION}"
137
- fi
138
- - name: Determine dist-tag
139
- id: dist-tag
140
- run: |
141
- PKG_VERSION=$(node -p "require('./package.json').version")
142
- if echo "$PKG_VERSION" | grep -q '-'; then
143
- PREID=$(echo "$PKG_VERSION" | sed 's/[^-]*-//' | sed 's/\.[^.]*$//')
144
- echo "tag=--tag $PREID" >> "$GITHUB_OUTPUT"
145
- else
146
- echo "tag=" >> "$GITHUB_OUTPUT"
147
- fi
148
- - run: npm publish --provenance --access public ${{
149
- steps.dist-tag.outputs.tag }}
150
- if: steps.check-npm.outputs.skip != 'true'
151
- env:
152
- NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }}
@@ -0,0 +1,20 @@
1
+ node_modules/
2
+ __pycache__/
3
+ *.pyc
4
+ *.log
5
+ .DS_Store
6
+ coverage/
7
+ dist/
8
+ *.egg-info/
9
+ .rlsbl-notes-*.tmp
10
+ .rlsbl/lock
11
+ .credentials.json
12
+ .*-cache.json
13
+ .env
14
+ .env.local
15
+ *.local-only
16
+
17
+ .rlsbl-monorepo/lock
18
+
19
+ build/
20
+ target/
@@ -0,0 +1,33 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ branches: [main]
6
+ pull_request:
7
+ branches: [main]
8
+ workflow_dispatch:
9
+
10
+ # Per-SHA group: re-runs of the same commit dedupe, but a new commit never
11
+ # cancels an earlier commit's in-flight run (release CI conclusions stay intact).
12
+ concurrency:
13
+ group: ${{ github.workflow_ref }}-${{ github.sha }}
14
+ cancel-in-progress: true
15
+
16
+ jobs:
17
+ test:
18
+ runs-on: ubuntu-latest
19
+ strategy:
20
+ matrix:
21
+ # requires-python: >= 3.11
22
+ python-version: ["3.12", "3.13", "3.14"]
23
+ steps:
24
+ - uses: actions/checkout@v6
25
+ - uses: astral-sh/setup-uv@v7
26
+ - run: uv python install ${{ matrix.python-version }}
27
+ - run: uv sync --locked
28
+ - run: uv run python -c "import strictcli"
29
+ - name: Install gitleaks
30
+ run: |
31
+ GITLEAKS_VERSION=8.24.3
32
+ curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar xz -C /usr/local/bin gitleaks
33
+ - run: uv run pytest --rootdir .
@@ -0,0 +1,187 @@
1
+ name: Publish
2
+ on:
3
+ release:
4
+ types: [published]
5
+ workflow_dispatch:
6
+ inputs:
7
+ tag:
8
+ description: Release tag to publish (e.g. v1.2.3). Overrides the ref for
9
+ retry dispatch.
10
+ required: false
11
+ type: string
12
+
13
+ # One publish run per tag: a workflow_dispatch retry at the same tag
14
+ # queues behind the in-flight run instead of racing it. A publish is never
15
+ # cancelled mid-flight.
16
+ concurrency:
17
+ group: publish-${{ inputs.tag || github.ref_name }}
18
+ cancel-in-progress: false
19
+ permissions:
20
+ contents: read
21
+ id-token: write
22
+ jobs:
23
+ gate:
24
+ name: Gate on CI
25
+ runs-on: ubuntu-latest
26
+ permissions:
27
+ checks: read
28
+ env:
29
+ GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
30
+ GATE_TIMEOUT_MINUTES: '20'
31
+ GATE_GRACE_MINUTES: '5'
32
+ GATE_POLL_SECONDS: '15'
33
+ GATE_MARKER_ATTEMPTS: '5'
34
+ GATE_MARKER_RETRY_SECONDS: '5'
35
+ CI_CHECK_REGEX: ^(test)( \(.*\))?$
36
+ steps:
37
+ - name: Wait for CI to succeed on the release commit
38
+ run: |
39
+ set -euo pipefail
40
+
41
+ # Commit resolution -- explicit marker-first order (never reads the release
42
+ # event payload; dispatch retries have none, so this path is uniform for
43
+ # release events and dispatch retries alike):
44
+ # (1) Marker: rlsbl writes the exact commit CI ran on into the GitHub
45
+ # Release body as a machine-parseable line of the form
46
+ # <!-- rlsbl-ci-sha: <40-hex> -->
47
+ # Prefer it when present -- it pins the precise commit and is immune to
48
+ # ref races. A just-created release can lag on a GitHub API read replica
49
+ # (the marker is written at release creation but may not be visible on
50
+ # the first read), so RETRY the read GATE_MARKER_ATTEMPTS times,
51
+ # GATE_MARKER_RETRY_SECONDS apart, before concluding it is absent. The
52
+ # tag is inputs.tag (TAG_INPUT) when dispatched with an explicit
53
+ # override, else GITHUB_REF_NAME (matches the router resolver).
54
+ # (2) Fallback: older releases predate the marker, so fall back to
55
+ # $GITHUB_SHA, the tag's commit for both release and dispatch-at-tag.
56
+ extract_ci_sha() {
57
+ # Emit the first rlsbl-ci-sha marker SHA found on stdin, if any.
58
+ sed -n 's/.*<!-- rlsbl-ci-sha: \([0-9a-f]\{40\}\) -->.*/\1/p' | head -n1
59
+ }
60
+ tag="${TAG_INPUT:-$GITHUB_REF_NAME}"
61
+ marker_attempts="${GATE_MARKER_ATTEMPTS:-5}"
62
+ marker_retry_seconds="${GATE_MARKER_RETRY_SECONDS:-5}"
63
+ sha=""
64
+ attempt=1
65
+ while [ "$attempt" -le "$marker_attempts" ]; do
66
+ if body="$(gh release view "$tag" --json body --jq .body 2>/dev/null)"; then
67
+ sha="$(printf '%s\n' "$body" | extract_ci_sha)"
68
+ fi
69
+ if [ -n "$sha" ]; then
70
+ break
71
+ fi
72
+ if [ "$attempt" -lt "$marker_attempts" ]; then
73
+ echo "Publish gate: rlsbl-ci-sha marker not yet visible in the '$tag' release body (attempt $attempt/$marker_attempts); retrying in ${marker_retry_seconds}s..."
74
+ sleep "$marker_retry_seconds"
75
+ fi
76
+ attempt=$(( attempt + 1 ))
77
+ done
78
+ if [ -n "$sha" ]; then
79
+ echo "Publish gate: resolved release commit from rlsbl-ci-sha marker in the '$tag' release body."
80
+ else
81
+ sha="$GITHUB_SHA"
82
+ echo "Publish gate: no rlsbl-ci-sha marker after $marker_attempts attempt(s) on the '$tag' release body; falling back to \$GITHUB_SHA."
83
+ fi
84
+ echo "Publish gate: waiting for CI on $GITHUB_REF_NAME (commit $sha)"
85
+ echo "Check-run name filter: $CI_CHECK_REGEX"
86
+ # Timeout, grace window, and poll interval come from the job env above;
87
+ # edit them there if this repository's CI needs different limits.
88
+
89
+ now() { date +%s; }
90
+ start="$(now)"
91
+ deadline=$(( start + GATE_TIMEOUT_MINUTES * 60 ))
92
+ grace_deadline=$(( start + GATE_GRACE_MINUTES * 60 ))
93
+
94
+ while :; do
95
+ if ! resp="$(gh api --paginate "repos/$GITHUB_REPOSITORY/commits/$sha/check-runs?per_page=100")"; then
96
+ echo "Checks API request failed; retrying in ${GATE_POLL_SECONDS}s..."
97
+ sleep "$GATE_POLL_SECONDS"
98
+ continue
99
+ fi
100
+ # Match this project's CI check runs by name; exclude check runs that
101
+ # belong to THIS workflow run (the gate itself and the queued publish
102
+ # jobs would otherwise deadlock the poll loop).
103
+ # A retried CI run creates a BRAND-NEW check-run with the same name as the
104
+ # old one, so a stale failure would block the gate forever. Project id and
105
+ # started_at, then reduce to the latest check-run per name (max started_at,
106
+ # numeric id as tiebreak for retries within the same second) BEFORE the
107
+ # pending / not-success logic runs. This way the newest run decides: still
108
+ # running -> wait; genuinely red -> hard fail.
109
+ runs="$(jq -s --arg re "$CI_CHECK_REGEX" --arg run_id "$GITHUB_RUN_ID" '
110
+ [ .[].check_runs[]
111
+ | select(.name | test($re))
112
+ | select((.details_url // "") | contains("/actions/runs/" + $run_id + "/") | not)
113
+ | {name, status, conclusion, id, started_at} ]
114
+ | group_by(.name)
115
+ | map(sort_by(.started_at, .id) | last)' <<< "$resp")"
116
+ total="$(jq 'length' <<< "$runs")"
117
+
118
+ if [ "$total" -eq 0 ]; then
119
+ if [ "$(now)" -ge "$grace_deadline" ]; then
120
+ 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."
122
+ echo "If CI jobs were renamed, update CI_CHECK_REGEX in this workflow's gate job to match the new names."
123
+ exit 1
124
+ fi
125
+ echo "No matching CI check runs yet; retrying in ${GATE_POLL_SECONDS}s..."
126
+ sleep "$GATE_POLL_SECONDS"
127
+ continue
128
+ fi
129
+
130
+ pending="$(jq '[ .[] | select(.status != "completed") ] | length' <<< "$runs")"
131
+ if [ "$pending" -gt 0 ]; then
132
+ if [ "$(now)" -ge "$deadline" ]; then
133
+ echo "::error::Publish gate: timed out after $GATE_TIMEOUT_MINUTES minutes waiting for CI to complete on $sha."
134
+ jq -r '.[] | " \(.name): status=\(.status) conclusion=\(.conclusion // "none")"' <<< "$runs"
135
+ exit 1
136
+ fi
137
+ echo "$pending of $total matching CI check runs still running; retrying in ${GATE_POLL_SECONDS}s..."
138
+ sleep "$GATE_POLL_SECONDS"
139
+ continue
140
+ fi
141
+
142
+ not_success="$(jq '[ .[] | select(.conclusion != "success") ]' <<< "$runs")"
143
+ if [ "$(jq 'length' <<< "$not_success")" -gt 0 ]; then
144
+ echo "::error::Publish gate: CI did not pass on $sha -- refusing to publish."
145
+ jq -r '.[] | " \(.name): \(.conclusion)"' <<< "$not_success"
146
+ while IFS= read -r conclusion; do
147
+ case "$conclusion" in
148
+ 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"
150
+ ;;
151
+ 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."
153
+ ;;
154
+ 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."
156
+ ;;
157
+ *)
158
+ echo "CI check concluded '$conclusion' (not success). The gate only proceeds when every matching check concluded success."
159
+ ;;
160
+ esac
161
+ done <<< "$(jq -r '.[].conclusion' <<< "$not_success" | sort -u)"
162
+ exit 1
163
+ fi
164
+
165
+ echo "Publish gate: all $total matching CI check runs succeeded."
166
+ jq -r '.[] | " \(.name): \(.conclusion)"' <<< "$runs"
167
+ exit 0
168
+ done
169
+ pypi:
170
+ needs: gate
171
+ runs-on: ubuntu-latest
172
+ steps:
173
+ - uses: actions/checkout@v6
174
+ with:
175
+ ref: ${{ inputs.tag || github.event.release.tag_name }}
176
+ - uses: astral-sh/setup-uv@v7
177
+ - name: Install gitleaks
178
+ run: |
179
+ GITLEAKS_VERSION=8.24.3
180
+ curl -sSfL "https://github.com/gitleaks/gitleaks/releases/download/v${GITLEAKS_VERSION}/gitleaks_${GITLEAKS_VERSION}_linux_x64.tar.gz" | tar xz -C /usr/local/bin gitleaks
181
+ - run: uv build --out-dir dist
182
+ - name: Scan artifacts for secrets
183
+ run: |
184
+ gitleaks dir dist/
185
+ - uses: pypa/gh-action-pypi-publish@release/v1
186
+ with:
187
+ skip-existing: true
@@ -4,14 +4,15 @@ __pycache__/
4
4
  *.log
5
5
  .DS_Store
6
6
  coverage/
7
+ build/
7
8
  dist/
9
+ target/
8
10
  *.egg-info/
9
11
  .rlsbl-notes-*.tmp
10
12
  .rlsbl/lock
13
+ .rlsbl-monorepo/lock
11
14
  .credentials.json
12
15
  .*-cache.json
13
16
  .env
14
17
  .env.local
15
18
  *.local-only
16
-
17
- .rlsbl-monorepo/lock
@@ -0,0 +1,25 @@
1
+ [forbidden-imports]
2
+ modules = [
3
+ "argparse",
4
+ "click",
5
+ "typer",
6
+ "flask",
7
+ "fastapi",
8
+ "django",
9
+ "uvicorn",
10
+ "granian",
11
+ "starlette",
12
+ "tornado",
13
+ "bottle",
14
+ ]
15
+
16
+ [stdout]
17
+ enabled = true
18
+ ignore = []
19
+
20
+ [entry-point]
21
+ enabled = true
22
+ ignore = []
23
+
24
+ [files]
25
+ exclude = []
@@ -1,7 +1,6 @@
1
1
  {
2
2
  "targets": [
3
- "pypi",
4
- "npm"
3
+ "pypi"
5
4
  ],
6
5
  "publish_mode": "ci",
7
6
  "push_timeout": 300,
@@ -10,12 +9,6 @@
10
9
  "type": "pypi",
11
10
  "target": "pypi",
12
11
  "local": false
13
- },
14
- "npm": {
15
- "type": "npm",
16
- "target": "npm",
17
- "local": false,
18
- "provenance": true
19
12
  }
20
13
  },
21
14
  "coverage_unit": "commit"
@@ -0,0 +1,25 @@
1
+ [forbidden-imports]
2
+ modules = [
3
+ "argparse",
4
+ "click",
5
+ "typer",
6
+ "flask",
7
+ "fastapi",
8
+ "django",
9
+ "uvicorn",
10
+ "granian",
11
+ "starlette",
12
+ "tornado",
13
+ "bottle",
14
+ ]
15
+
16
+ [stdout]
17
+ enabled = true
18
+ ignore = []
19
+
20
+ [entry-point]
21
+ enabled = true
22
+ ignore = []
23
+
24
+ [files]
25
+ exclude = []
@@ -0,0 +1,9 @@
1
+ {
2
+ "version": 1,
3
+ "files": {
4
+ ".github/workflows/ci.yml": "bfcdec1fcd8ff944f7ba7eb114ab091d0c5158f499ed4632945c2c4ca2d868df",
5
+ ".github/workflows/publish.yml": "9bc0b409f8c5c2a3c5795a3e7c66095b9479c1a03fb41250ff0461e1221b54f2",
6
+ ".gitignore": "a102f466628d2d28aea6b2157c1b406b1911d788b596b5b84f4952f121539d42",
7
+ ".rlsbl/lint/python.toml": "9cfbcef2e010d5fad243437bf2fd8df54a1fae70ee0762f9d11da70fe207501c"
8
+ }
9
+ }
@@ -0,0 +1 @@
1
+ 0.108.0
@@ -16,5 +16,5 @@ This project uses [rlsbl](https://github.com/smm-h/rlsbl) for release orchestrat
16
16
  - No tokens or secrets in command-line arguments (use env vars or config files)
17
17
  - All file writes to shared state should be atomic (write to tmp, then rename)
18
18
  - External calls (APIs, CLI tools) must have timeouts and graceful fallbacks
19
- - Use `npm link` (npm) or `uv pip install -e .` (Python) for local development
19
+ - Use `uv pip install -e .` for local development
20
20
  - CI runs smoke tests on every push; manual testing for UI/UX changes
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: strictcli
3
- Version: 0.30.0
3
+ Version: 0.31.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
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "strictcli"
7
- version = "0.30.0"
7
+ version = "0.31.0"
8
8
  description = "A strict 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.30.0"
5
+ __version__ = "0.31.0"
6
6
 
7
7
  __all__ = [
8
8
  "App", "Flag", "Arg", "FlagSet", "MutexGroup", "CoRequired", "Requires",
@@ -2843,6 +2843,7 @@ class App:
2843
2843
  if self._coverage_shard_path is None:
2844
2844
  return
2845
2845
  path = self._coverage_shard_path.format(n=self._coverage_shard_counter)
2846
+ os.makedirs(os.path.dirname(path), exist_ok=True)
2846
2847
  with open(path, "a", encoding="utf-8") as f:
2847
2848
  f.write(json.dumps({"command": cmd_path}) + "\n")
2848
2849
 
@@ -7716,26 +7717,27 @@ def _mcp_handle_tools_list(
7716
7717
 
7717
7718
  def _mcp_handle_tools_call(
7718
7719
  app: App,
7719
- commands: dict[str, tuple[Command, str]],
7720
7720
  req_id: object,
7721
7721
  params: dict,
7722
7722
  ) -> dict:
7723
7723
  """Handle the MCP 'tools/call' request."""
7724
- tool_name = params.get("name")
7725
- if not isinstance(tool_name, str):
7724
+ if "name" not in params:
7726
7725
  return _mcp_jsonrpc_error(
7727
- req_id, -32602, "missing or invalid 'name' in params",
7726
+ req_id, -32602, "missing required parameter: name",
7728
7727
  )
7729
-
7730
- if tool_name not in commands:
7728
+ tool_name = params["name"]
7729
+ if not isinstance(tool_name, str):
7731
7730
  return _mcp_jsonrpc_error(
7732
- req_id, -32602, f"unknown tool: {tool_name}",
7731
+ req_id, -32602, "parameter 'name' must be a string",
7733
7732
  )
7734
7733
 
7734
+ # Unknown tools are NOT a -32602 protocol error: like Go, the name is
7735
+ # passed to app.call(), whose invocation error surfaces as tool-result
7736
+ # error content (isError) below.
7735
7737
  arguments = params.get("arguments", {})
7736
7738
  if not isinstance(arguments, dict):
7737
7739
  return _mcp_jsonrpc_error(
7738
- req_id, -32602, "'arguments' must be an object",
7740
+ req_id, -32602, "parameter 'arguments' must be an object",
7739
7741
  )
7740
7742
 
7741
7743
  try:
@@ -7793,7 +7795,7 @@ def _run_mcp_server(
7793
7795
  app, commands, req_id,
7794
7796
  ),
7795
7797
  "tools/call": lambda req_id, params: _mcp_handle_tools_call(
7796
- app, commands, req_id, params,
7798
+ app, req_id, params,
7797
7799
  ),
7798
7800
  }
7799
7801
 
@@ -299,6 +299,11 @@ class TestMcpToolsCall:
299
299
  assert parsed == {"migrated": True, "dry_run": True}
300
300
 
301
301
  def test_call_unknown_tool(self):
302
+ """Unknown tools surface as tool-result errors (isError), not -32602.
303
+
304
+ Matches Go: the name is passed to Call, whose invocation error
305
+ becomes error content in the result.
306
+ """
302
307
  app = _build_app()
303
308
 
304
309
  @app.command("cmd", help="a command")
@@ -309,8 +314,12 @@ class TestMcpToolsCall:
309
314
  "jsonrpc": "2.0", "id": 15, "method": "tools/call",
310
315
  "params": {"name": "nonexistent", "arguments": {}},
311
316
  })
312
- assert resp["error"]["code"] == -32602
313
- assert "unknown tool" in resp["error"]["message"]
317
+ assert "error" not in resp
318
+ assert resp["result"]["isError"] is True
319
+ content = resp["result"]["content"]
320
+ assert len(content) == 1
321
+ assert content[0]["type"] == "text"
322
+ assert content[0]["text"] == "unknown command 'nonexistent'"
314
323
 
315
324
  def test_call_missing_required_flag(self):
316
325
  app = _build_app()
@@ -342,6 +351,35 @@ class TestMcpToolsCall:
342
351
  "params": {"arguments": {}},
343
352
  })
344
353
  assert resp["error"]["code"] == -32602
354
+ assert resp["error"]["message"] == "missing required parameter: name"
355
+
356
+ def test_call_non_string_name(self):
357
+ app = _build_app()
358
+
359
+ @app.command("cmd", help="a command")
360
+ def cmd(ctx):
361
+ pass
362
+
363
+ resp = _send_one(app, {
364
+ "jsonrpc": "2.0", "id": 19, "method": "tools/call",
365
+ "params": {"name": 42, "arguments": {}},
366
+ })
367
+ assert resp["error"]["code"] == -32602
368
+ assert resp["error"]["message"] == "parameter 'name' must be a string"
369
+
370
+ def test_call_non_object_arguments(self):
371
+ app = _build_app()
372
+
373
+ @app.command("cmd", help="a command")
374
+ def cmd(ctx):
375
+ pass
376
+
377
+ resp = _send_one(app, {
378
+ "jsonrpc": "2.0", "id": 20, "method": "tools/call",
379
+ "params": {"name": "cmd", "arguments": ["not", "an", "object"]},
380
+ })
381
+ assert resp["error"]["code"] == -32602
382
+ assert resp["error"]["message"] == "parameter 'arguments' must be an object"
345
383
 
346
384
  def test_call_no_arguments_key(self):
347
385
  """When 'arguments' is omitted, defaults to empty dict."""
@@ -232,7 +232,7 @@ wheels = [
232
232
 
233
233
  [[package]]
234
234
  name = "strictcli"
235
- version = "0.30.0"
235
+ version = "0.31.0"
236
236
  source = { editable = "." }
237
237
  dependencies = [
238
238
  { name = "tomlkit" },
@@ -1,22 +0,0 @@
1
- name: CI
2
-
3
- on:
4
- push:
5
- branches: [main]
6
- pull_request:
7
- branches: [main]
8
- workflow_dispatch:
9
-
10
- jobs:
11
- test:
12
- runs-on: ubuntu-latest
13
- strategy:
14
- matrix:
15
- # requires-python: >= 3.11
16
- python-version: ["3.12", "3.13", "3.14"]
17
- steps:
18
- - uses: actions/checkout@v6
19
- - uses: astral-sh/setup-uv@v7
20
- - run: uv python install ${{ matrix.python-version }}
21
- - run: uv sync
22
- - run: uv run python -c "import strictcli"
@@ -1,34 +0,0 @@
1
- # Development and CI
2
- tests/
3
- test/
4
- __tests__/
5
- .github/
6
- scripts/
7
- docs/
8
- todo/
9
- e2e/
10
-
11
- # rlsbl metadata
12
- .rlsbl/
13
- .rlsbl-monorepo/
14
-
15
- # Python artifacts (for multi-target projects)
16
- __pycache__/
17
- *.pyc
18
- *.py
19
- *.toml
20
- .venv/
21
-
22
- # Build/cache
23
- coverage/
24
- .nyc_output/
25
-
26
- # Editor/OS
27
- .DS_Store
28
- *.log
29
- .env
30
- .env.local
31
-
32
- # Selfdoc
33
- .selfdoc/
34
- selfdoc.json
@@ -1,11 +0,0 @@
1
- {
2
- "version": 1,
3
- "files": {
4
- ".github/workflows/ci-pypi.yml": "6bde3f6dd33979636f6a8f51721f85519c4d2b6106cb9c9fa78e540df26471c4",
5
- ".npmignore": "7c0eea9785c0318aeb39bf8b670ce8384f4a5b812fde69ce23d7d018057325a3",
6
- ".github/workflows/publish.yml": "20db711b8a7745c3ab41814b827e946d60e937e82ee3984e86f39567717199b1",
7
- ".gitignore": "c0582f9b1c640f9f9a8acbbe02cd19fff50e66cc595f25e129b7cc9b6cd357db",
8
- ".rlsbl/lint/python.toml": "9cfbcef2e010d5fad243437bf2fd8df54a1fae70ee0762f9d11da70fe207501c",
9
- ".rlsbl/lint/npm.toml": "baa36014e65923d8cc5c8b83c77fa651da79351627570476402da66840027645"
10
- }
11
- }
strictcli-0.30.0/index.js DELETED
@@ -1,5 +0,0 @@
1
- throw new Error(
2
- "strictcli is a Python library. Install it with: uv add strictcli (or pip install strictcli). " +
3
- "This npm package is a wrapper that installs the Python package. " +
4
- "Import it in Python: import strictcli"
5
- );
@@ -1,14 +0,0 @@
1
- {
2
- "name": "strictcli",
3
- "version": "0.30.0",
4
- "lockfileVersion": 3,
5
- "requires": true,
6
- "packages": {
7
- "": {
8
- "name": "strictcli",
9
- "version": "0.30.0",
10
- "hasInstallScript": true,
11
- "license": "MIT"
12
- }
13
- }
14
- }
@@ -1,25 +0,0 @@
1
- {
2
- "name": "strictcli",
3
- "version": "0.30.0",
4
- "description": "A strict, zero-dependency CLI framework for Python (npm wrapper)",
5
- "main": "index.js",
6
- "scripts": {
7
- "postinstall": "node postinstall.js"
8
- },
9
- "keywords": [
10
- "strictcli",
11
- "cli",
12
- "argparse",
13
- "command-line",
14
- "framework",
15
- "python",
16
- "rlsbl"
17
- ],
18
- "author": "S. M. Hosseini <m.hosseini@veliu.com>",
19
- "license": "MIT",
20
- "repository": {
21
- "type": "git",
22
- "url": "https://github.com/smm-h/strictcli.git"
23
- },
24
- "homepage": "https://github.com/smm-h/strictcli"
25
- }
@@ -1,17 +0,0 @@
1
- const { execSync } = require("child_process");
2
-
3
- const version = require("./package.json").version;
4
- const pkg = `strictcli==${version}`;
5
-
6
- try {
7
- execSync(`uv pip install ${pkg}`, { stdio: "inherit" });
8
- } catch {
9
- try {
10
- execSync(`pip install ${pkg}`, { stdio: "inherit" });
11
- } catch {
12
- console.error(
13
- `Failed to install Python package ${pkg}. ` +
14
- "Ensure uv or pip is available, then run: uv add strictcli"
15
- );
16
- }
17
- }
File without changes
File without changes
File without changes
File without changes
File without changes