Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
7fc9974014 chore: bump version to 0.14.1 2026-07-23 20:17:39 +00:00
48 changed files with 206 additions and 1313 deletions

View File

@@ -33,10 +33,10 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
#
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0
@@ -1352,7 +1352,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1419,7 +1419,7 @@ jobs:
mkdir -p /tmp/gh-aw/threat-detection
touch /tmp/gh-aw/threat-detection/detection.log
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
package-manager-cache: false
@@ -1678,7 +1678,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1686,7 +1686,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -1332,7 +1332,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1658,7 +1658,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1666,7 +1666,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -1332,7 +1332,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1658,7 +1658,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1666,7 +1666,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -32,7 +32,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -161,7 +161,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: |
@@ -430,7 +430,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -1277,7 +1277,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# --- Threat Detection ---

12
.github/workflows/bug-fix.lock.yml generated vendored
View File

@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -1338,7 +1338,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1664,7 +1664,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1672,7 +1672,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}

View File

@@ -32,7 +32,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -161,7 +161,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: |
@@ -431,7 +431,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -1299,7 +1299,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# --- Threat Detection ---

View File

@@ -19,14 +19,14 @@ jobs:
language: [ 'actions', 'python' ]
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4
with:
category: "/language:${{ matrix.language }}"

View File

@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # Fetch all history for git info

View File

@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
@@ -37,7 +37,7 @@ jobs:
fi
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
with:
globs: |
'**/*.md'
@@ -47,7 +47,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# shellcheck is preinstalled on ubuntu-latest runners.
# Start at --severity=error to block real bugs without flagging style

View File

@@ -27,12 +27,12 @@ jobs:
fi
- name: Checkout release tag
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: refs/tags/${{ inputs.tag }}
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -74,7 +74,7 @@ jobs:
path: dist/
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Publish to PyPI
run: uv publish

View File

@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}

View File

@@ -12,7 +12,7 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}

View File

@@ -19,12 +19,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -52,10 +52,10 @@ jobs:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6

View File

@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -34,10 +34,10 @@ jobs:
python-version: ["3.13", "3.14"]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6

View File

@@ -64,14 +64,13 @@ class KilocodeIntegration(MarkdownIntegration):
key = "kilocode"
config = {
"name": "Kilo Code",
"folder": ".kilo/",
"commands_subdir": "commands",
"folder": ".kilocode/",
"commands_subdir": "workflows",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".kilo/commands",
"legacy_dir": ".kilocode/workflows",
"dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
@@ -202,8 +201,8 @@ Only add custom setup logic when the agent needs non-standard behavior. Integrat
specify init my-project --integration <key>
# Verify files were created in the commands directory configured by
# config["folder"] + config["commands_subdir"] (for example, .kilo/commands/)
ls -R my-project/.kilo/commands/
# config["folder"] + config["commands_subdir"] (for example, .kilocode/workflows/)
ls -R my-project/.kilocode/workflows/
# Uninstall cleanly
cd my-project && specify integration uninstall <key>

View File

@@ -2,31 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.14.2] - 2026-07-24
### Changed
- Update Intake Review Governance preset to v0.1.1 (#3729)
- Update Verify Review Ship extension to v0.3.0 (#3728)
- Update Architecture Guard extension to v1.13.1 (#3724)
- docs(upgrade): Claude Code files live in .claude/skills, not .claude/commands (#3708)
- fix(kilocode): install commands under .kilo/commands (#3672)
- fix(auth): normalize whitespace in auth-config env-var/id references at store time (#3691)
- fix(workflows): guard non-mapping 'inputs:' block in engine._resolve_inputs (#3696)
- Update Intake Authoring Governance preset to v0.2.0 (#3721)
- docs: clarify shell-step interpolation safety (#3719)
- [extension] Add Blueprint Index — Living Architecture Map extension to community catalog (#3718)
- fix(github-http): return None on malformed host in resolve_github_release_asset_api_url (#3715)
- fix(integrations): declare PiIntegration multi_install_safe (#3652)
- harden: remove shell parameter from run_command() (#3716)
- chore(deps): bump github/codeql-action/init from 4.37.1 to 4.37.3 (#3699)
- fix: auto-correct conflicting feature prefixes (#1829)
- chore(deps): bump actions/checkout from 6.0.3 to 7.0.1 (#3703)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#3702)
- chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#3701)
- chore(deps): bump astral-sh/setup-uv from 8.3.2 to 9.0.0 (#3700)
- chore: release 0.14.1, begin 0.14.2.dev0 development (#3698)
## [0.14.1] - 2026-07-23
### Changed

View File

@@ -36,7 +36,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
| Blueprint Index — Living Architecture Map | A living architecture map for spec-driven projects, kept honest by a deterministic, low-friction, machine-first CI gate (JSON, self-healable) that blocks only when the map contradicts the specs or code. Brownfield or greenfield. | `process` | Read+Write | [spec-kit-blueprint](https://github.com/ogil109/spec-kit-blueprint) |
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) |
@@ -159,7 +158,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
| Verify Review Ship | Adds verify and review quality gates plus transactional merge, cleanup, and delivery summary | `process` | Read+Write | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
| Verify Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |

View File

@@ -19,8 +19,8 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| Intake Authoring Governance | Governs traceable intake CRUD, bounded public HTTPS sources, and explicitly approved single or series authoring without granting execution authority. | 10 templates, 5 commands, 4 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
| Intake Review Governance | Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| Intake Authoring Governance | Creates traceable Spec Kit intakes from ordered text sources and now truthfully adopts legacy intakes without inventing predecessor receipts. | 7 templates, 2 commands, 2 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
| Intake Review Governance | Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |

View File

@@ -25,7 +25,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | Skills-based integration by default; installs skills as `speckit-<command>/SKILL.md` under `.bob/skills/` and invokes them as `/speckit-<command>`. Pass `--integration-options="--legacy-commands"` to scaffold the deprecated Bob 1.x layout (`.bob/commands/*.md`) instead; that flag will be removed in a future release. Existing legacy installs can migrate with `specify integration upgrade bob --integration-options="--skills"`, which converts them to the skills layout and removes the old command files. If preset overrides are installed, the migration is rejected with an actionable error (preset artifacts cannot yet be reconciled across a layout change) — remove the preset(s), migrate, then reinstall them. |
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | Installs commands into `.kilo/commands`; legacy `.kilocode/workflows` installs remain supported as a registration fallback |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
@@ -269,16 +269,13 @@ The currently declared multi-install safe integrations are:
| `codebuddy` | `.codebuddy/commands` |
| `codex` | `.agents/skills` |
| `cursor-agent` | `.cursor/skills` |
| `droid` | `.factory/skills` |
| `firebender` | `.firebender/commands` |
| `gemini` | `.gemini/commands` |
| `grok` | `.grok/skills` |
| `junie` | `.junie/commands` |
| `kilocode` | `.kilo/commands` |
| `kilocode` | `.kilocode/workflows` |
| `kiro-cli` | `.kiro/prompts` |
| `lingma` | `.lingma/skills` |
| `omp` | `.omp/commands` |
| `pi` | `.pi/prompts` |
| `qodercli` | `.qoder/commands` |
| `qwen` | `.qwen/commands` |
| `shai` | `.shai/commands` |

View File

@@ -502,32 +502,6 @@ args: "{{ inputs.spec }}"
message: "{{ status | default('pending') }}"
```
### Interpolation and shell safety
Expressions are resolved by **plain string substitution** — the value of `{{ ... }}` is spliced into the surrounding text exactly as-is, with no quoting or escaping added. That is convenient for building `args` and `message` strings, but it has an important consequence for `shell` steps: a `run` field is handed to the system shell (`/bin/sh -c` on POSIX), so any interpolated value is interpreted as **shell syntax**, not just data.
If an interpolated value can contain characters like `;`, `|`, `&`, `$( )`, backticks, or quotes, it can change or extend the command that actually runs. This matters most when the value is not fully under the workflow author's control:
- **Workflow `inputs.*`** — supplied by whoever runs the workflow.
- **A prior step's output**, e.g. `{{ steps.plan.output.stdout }}` — for a `prompt` step this is **text produced by the AI agent**, which can in turn be influenced by files, tickets, or web content the agent read. Treat agent output as untrusted when it flows into a `shell` step.
There is **no shell-escaping filter** in the expression language and **no sandbox** around a `shell` step, so none of the practices below can be treated as a guarantee that a hostile value is neutralised. The only reliable control is to constrain what an interpolated value *can* be, and to keep values you cannot constrain out of `run` fields entirely. Scrutinise every `run` field that interpolates a value you do not control, and at minimum:
- **Constrain the value at the source with `enum`/an allowlist.** When `inputs.*` feeds a `run` field, restrict it to a fixed set of known-safe values so a caller cannot supply arbitrary shell text at all. This is the strongest control the engine offers — prefer it over any downstream mitigation.
```yaml
inputs:
target:
type: string
enum: [staging, production] # caller cannot inject arbitrary text
```
- **Keep unconstrained values out of `run`.** If a value cannot be constrained to an allowlist — most agent/`prompt` output — do not interpolate it into a `run` field. Branch on it with `if`/`switch` against fixed conditions, or act on it in a `command`/`prompt` step rather than a shell command built from it.
- **Quoting is not a security boundary.** Surrounding a substitution with quotes (`'{{ inputs.x }}'`) helps the shell treat a *trusted* value as a single argument and avoids word-splitting on spaces, but a value that itself contains the matching quote character can still break out and inject shell syntax. Quote for correctness on constrained values; never rely on quoting to make an *unconstrained* substitution safe.
- **Gates do not inspect the next step, and `message` is printed verbatim.** A `gate` step renders only its own `message`/`show_file` — it does not display, resolve, or sanitise the command that follows it, and approval never neutralises an injectable interpolation. Do **not** interpolate raw untrusted data into `message`: it is printed as-is with no control-character stripping, so agent or caller output could inject terminal/ANSI escapes that alter or hide the approval prompt. Keep `message` to trusted, constrained text, and surface untrusted material for review via `show_file` instead — its path and contents are control/ANSI-stripped before display.
A `shell` step is an arbitrary-command primitive by design; these practices reduce exposure and keep *which* command runs under the author's control, but they do not eliminate the risk of interpolating values you do not fully control.
## Shell Step Environment Variables
Shell steps automatically receive the following environment variables:

View File

@@ -195,13 +195,15 @@ Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash command
**Example for Kilo Code:**
```bash
# List current and legacy Kilo command folders
ls -la .kilo/commands/
ls -la .kilocode/workflows/
# Navigate to the agent's commands folder
cd .kilocode/workflows/
# List files and identify duplicates
ls -la
# Delete old versions (example filenames - yours may differ)
rm .kilocode/workflows/speckit.specify-old.md
rm .kilocode/workflows/speckit.plan-v1.md
rm speckit.specify-old.md
rm speckit.plan-v1.md
```
Restart your IDE to refresh the command list.
@@ -246,12 +248,14 @@ specify extension update
This happens with IDE-based agents (Kilo Code, Cline, etc.).
```bash
# For Kilo Code, inspect both current and legacy command folders
ls -la .kilo/commands/
ls -la .kilocode/workflows/
# Find the agent folder (example: .kilocode/workflows/)
cd .kilocode/workflows/
# List all files
ls -la
# Delete old command files
rm .kilocode/workflows/speckit.old-command-name.md
rm speckit.old-command-name.md
# Restart your IDE
```
@@ -303,7 +307,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
2. **For CLI-based agents**, verify files exist:
```bash
ls -la .claude/skills/ # Claude Code
ls -la .claude/commands/ # Claude Code
ls -la .gemini/commands/ # Gemini
ls -la .cursor/skills/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
@@ -352,7 +356,7 @@ This warning appears when you run `specify init --here` (or `specify init .`) in
Only Spec Kit infrastructure files:
- Agent command/skill files (`.claude/skills/`, `.github/prompts/`, etc.)
- Agent command files (`.claude/commands/`, `.github/prompts/`, etc.)
- Scripts in `.specify/scripts/`
- Templates in `.specify/templates/`
- Missing memory files such as `.specify/memory/constitution.md` may be created from templates; an existing constitution is preserved
@@ -441,7 +445,7 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
ls -la .github/prompts/
# For Claude
ls -la .claude/skills/
ls -la .claude/commands/
# For Pi
ls -la .pi/prompts/

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-24T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -290,8 +290,8 @@
"id": "architecture-guard",
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
"author": "DyanGalih",
"version": "1.13.1",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.13.1.zip",
"version": "1.8.17",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.17.zip",
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/docs/architecture-overview.md",
@@ -303,7 +303,7 @@
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 14,
"commands": 10,
"hooks": 3
},
"tags": [
@@ -313,14 +313,13 @@
"refactor",
"workflow",
"governance",
"guardrails",
"hygiene"
"guardrails"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-05T07:26:00Z",
"updated_at": "2026-07-24T00:00:00Z"
"updated_at": "2026-06-08T00:00:00Z"
},
"archive": {
"name": "Archive Extension",
@@ -490,46 +489,6 @@
"created_at": "2026-04-17T00:00:00Z",
"updated_at": "2026-04-17T00:00:00Z"
},
"blueprint-index": {
"name": "Blueprint Index — Living Architecture Map",
"id": "blueprint-index",
"description": "Living architecture map for brownfield and greenfield projects, with a deterministic CI gate that blocks contradictions between the map, specs, and code while warning on non-blocking drift.",
"author": "ogil109",
"version": "0.2.0",
"download_url": "https://github.com/ogil109/spec-kit-blueprint/releases/download/v0.2.0/blueprint.zip",
"repository": "https://github.com/ogil109/spec-kit-blueprint",
"homepage": "https://github.com/ogil109/spec-kit-blueprint/tree/main",
"documentation": "https://github.com/ogil109/spec-kit-blueprint/blob/main/README.md",
"changelog": "https://github.com/ogil109/spec-kit-blueprint/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.10.0",
"tools": [
{ "name": "bash", "required": false },
{ "name": "git", "required": false }
]
},
"provides": {
"commands": 4,
"hooks": 0
},
"tags": [
"blueprint",
"architecture",
"coherence",
"drift",
"brownfield",
"autonomous",
"ci"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-24T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
},
"branch-convention": {
"name": "Branch Convention",
"id": "branch-convention",
@@ -4818,18 +4777,17 @@
"verify-review-ship": {
"name": "Verify Review Ship",
"id": "verify-review-ship",
"description": "Adds verify and review quality gates plus transactional merge, cleanup, and delivery summary.",
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
"author": "Carlos Eduardo Gevaerd Araujo",
"version": "0.3.0",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.3.0.zip",
"sha256": "a7326c899855f46ff28e9f03ede2f89c4db0fd2b8a64c85017b3ab639e004fd3",
"version": "0.1.0",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"effect": "read-only",
"requires": {
"speckit_version": ">=0.1.0"
},
@@ -4841,14 +4799,14 @@
"quality",
"review",
"shipping",
"merge",
"workflow"
"workflow",
"testing"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
"updated_at": "2026-07-10T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-24T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
@@ -368,42 +368,42 @@
"intake-authoring-governance": {
"name": "Intake Authoring Governance",
"id": "intake-authoring-governance",
"version": "0.2.0",
"description": "Governs traceable intake CRUD, bounded public HTTPS sources, and explicitly approved single or series authoring without granting execution authority.",
"version": "0.1.1",
"description": "Creates traceable Spec Kit intakes from ordered text sources and now truthfully adopts legacy intakes without inventing predecessor receipts.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.2.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.1.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.2.0/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.1.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 10,
"commands": 5,
"scripts": 4
"templates": 7,
"commands": 2,
"scripts": 2
},
"tags": [
"intake",
"authoring",
"governance",
"provenance",
"requirements"
"traceability",
"legacy-adoption"
],
"created_at": "2026-07-22T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
"updated_at": "2026-07-23T00:00:00Z"
},
"intake-review-governance": {
"name": "Intake Review Governance",
"id": "intake-review-governance",
"version": "0.1.1",
"description": "Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes.",
"version": "0.1.0",
"description": "Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.0/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
@@ -421,7 +421,7 @@
"autonomous"
],
"created_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
"updated_at": "2026-07-21T00:00:00Z"
},
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.14.2"
version = "0.14.1"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"

View File

@@ -8,7 +8,6 @@ ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
NUMBER_EXPLICIT=false
ARGS=()
i=1
while [ $i -le $# ]; do
@@ -49,9 +48,6 @@ while [ $i -le $# ]; do
exit 1
fi
BRANCH_NUMBER="$next_arg"
if [ -n "$BRANCH_NUMBER" ]; then
NUMBER_EXPLICIT=true
fi
;;
--timestamp)
USE_TIMESTAMP=true
@@ -64,7 +60,7 @@ while [ $i -le $# ]; do
echo " --dry-run Compute feature name and paths without creating directories or files"
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
@@ -95,7 +91,6 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
fi
MAX_FEATURE_NUMBER=9223372036854775807
MAX_BRANCH_LENGTH=244
is_feature_number_in_range() {
local value="$1"
@@ -133,40 +128,12 @@ get_highest_from_specs() {
echo "$highest"
}
# Return success when a spec directory owns the given numeric prefix.
spec_prefix_exists() {
local specs_dir="$1"
local feature_num="$2"
for spec_path in "$specs_dir/${feature_num}-"*; do
[ -d "$spec_path" ] && return 0
done
return 1
}
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# Fit a feature prefix and suffix within GitHub's branch-name limit.
fit_branch_name() {
local feature_num="$1"
local branch_suffix="$2"
local branch_name="${feature_num}-${branch_suffix}"
if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then
local prefix_length=$(( ${#feature_num} + 1 ))
local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length))
local truncated_suffix
truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//')
branch_name="${feature_num}-${truncated_suffix}"
fi
printf '%s' "$branch_name"
}
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
# so the persistence hints match the Python variant exactly (printf %q output
# differs between bash versions and from shlex.quote for spaces/metachars).
@@ -286,41 +253,26 @@ else
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
if [ "$NUMBER_EXPLICIT" = true ]; then
SPEC_CONFLICT=false
REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME"
if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true
fi
if [ "$SPEC_CONFLICT" = true ]; then
REQUESTED_NUM="$FEATURE_NUM"
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$HIGHEST
while true; do
if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
exit 1
fi
BRANCH_NUMBER=$((BRANCH_NUMBER + 1))
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break
done
>&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead"
fi
fi
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
MAX_BRANCH_LENGTH=244
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
# Calculate how much we need to trim from suffix
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
# Truncate suffix at word boundary if possible
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
# Remove trailing hyphen if truncation created one
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"

View File

@@ -14,7 +14,6 @@ param(
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
$maxBranchLength = 244
# Show help if requested
if ($Help) {
@@ -25,7 +24,7 @@ if ($Help) {
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
Write-Host ""
@@ -68,44 +67,11 @@ function Get-HighestNumberFromSpecs {
return $highest
}
function Test-SpecPrefixInUse {
param(
[string]$SpecsDir,
[string]$FeatureNum
)
if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
return $false
}
return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "$FeatureNum-*" } |
Select-Object -First 1)
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
function Get-FittedBranchName {
param(
[string]$FeatureNum,
[string]$BranchSuffix
)
$fittedName = "$FeatureNum-$BranchSuffix"
if ($fittedName.Length -gt $maxBranchLength) {
$prefixLength = $FeatureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$fittedName = "$FeatureNum-$truncatedSuffix"
}
return $fittedName
}
# Load common functions (includes Get-RepoRoot and Resolve-Template)
. "$PSScriptRoot/common.ps1"
@@ -210,40 +176,26 @@ if ($Timestamp) {
}
$featureNum = ('{0:000}' -f $resolvedNumber)
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
$specConflict = $false
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
$requestedDir = Join-Path $specsDir $requestedBranchName
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
$specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
}
}
if ($specConflict) {
$requestedNum = $featureNum
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
$resolvedNumber = $highestNumber
do {
if ($resolvedNumber -eq [long]::MaxValue) {
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
exit 1
}
$resolvedNumber++
$featureNum = ('{0:000}' -f $resolvedNumber)
} while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
}
$branchName = "$featureNum-$branchSuffix"
}
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
$originalBranchName = "$featureNum-$branchSuffix"
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
if ($branchName -ne $originalBranchName) {
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
# Calculate how much we need to trim from suffix
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
# Truncate suffix
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
# Remove trailing hyphen if truncation created one
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")

View File

@@ -76,7 +76,7 @@ Options:
--dry-run Compute feature name and paths without creating directories or files
--allow-existing-branch Reuse an existing feature directory if it already exists
--short-name <name> Provide a custom short name (2-4 words) for the feature
--number N Prefer a feature number (auto-corrected if its specs prefix exists)
--number N Specify branch number manually (overrides auto-detection)
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
--help, -h Show this help message
@@ -204,43 +204,6 @@ def _get_highest_from_specs(specs_dir: Path) -> int:
return highest
def _fit_branch_name(feature_num: str, branch_suffix: str) -> str:
"""Fit a feature prefix and suffix within GitHub's branch-name limit."""
branch_name = f"{feature_num}-{branch_suffix}"
if len(branch_name) <= _MAX_BRANCH_LENGTH:
return branch_name
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
return f"{feature_num}-{truncated_suffix}"
def _spec_prefix_exists(specs_dir: Path, feature_num: str) -> bool:
"""Return whether a spec directory owns the given numeric prefix."""
try:
return any(
entry.is_dir() and entry.name.startswith(f"{feature_num}-")
for entry in specs_dir.iterdir()
)
except OSError:
# Match Bash globbing and PowerShell's ErrorAction=SilentlyContinue.
return False
def _has_spec_prefix_conflict(
specs_dir: Path,
feature_num: str,
requested_dir: Path,
*,
allow_existing: bool,
) -> bool:
"""Return whether another spec directory owns the requested prefix."""
if allow_existing and requested_dir.is_dir():
return False
return _spec_prefix_exists(specs_dir, feature_num)
def main(argv: list[str] | None = None) -> int:
argv0 = sys.argv[0]
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
@@ -298,48 +261,18 @@ def main(argv: list[str] | None = None) -> int:
return 1
feature_num = f"{number:03d}"
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
if branch_number:
requested_branch_name = _fit_branch_name(feature_num, branch_suffix)
requested_dir = specs_dir / requested_branch_name
spec_conflict = _has_spec_prefix_conflict(
specs_dir,
feature_num,
requested_dir,
allow_existing=args.allow_existing,
)
if spec_conflict:
requested_num = feature_num
number = _get_highest_from_specs(specs_dir)
while True:
number += 1
if number > _MAX_FEATURE_NUMBER:
print(
f"Error: feature number must be between 0 and "
f"{_MAX_FEATURE_NUMBER}, got '{number}'",
file=sys.stderr,
)
return 1
feature_num = f"{number:03d}"
if not _spec_prefix_exists(specs_dir, feature_num):
break
print(
f"[specify] Warning: --number {requested_num} conflicts with "
f"an existing spec directory; using {feature_num} instead",
file=sys.stderr,
)
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
if max_suffix_length <= 0:
print("Error: feature number is too long for a branch name", file=sys.stderr)
return 1
original_branch_name = f"{feature_num}-{branch_suffix}"
branch_name = _fit_branch_name(feature_num, branch_suffix)
branch_name = f"{feature_num}-{branch_suffix}"
# GitHub enforces a 244-byte limit on branch names.
if branch_name != original_branch_name:
if len(branch_name) > _MAX_BRANCH_LENGTH:
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
original_branch_name = branch_name
branch_name = f"{feature_num}-{truncated_suffix}"
print(
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
file=sys.stderr,

View File

@@ -102,17 +102,8 @@ def resolve_github_release_asset_api_url(
from specify_cli._download_security import read_response_limited
# Accessing ``.hostname`` (like ``.port`` below) raises ValueError on a
# malformed authority, e.g. an invalid bracketed IPv6 host
# ``https://[not-an-ip]/...``. The function's contract is to return None for
# anything it can't resolve, not to raise, so guard the read. ``download_url``
# is server-controlled here (a catalog ``download_url`` payload), so a
# malformed value must not leak a raw traceback past the caller.
try:
parsed = urlparse(download_url)
hostname = (parsed.hostname or "").lower()
except ValueError:
return None
parsed = urlparse(download_url)
hostname = (parsed.hostname or "").lower()
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
is_ghes = (

View File

@@ -69,14 +69,21 @@ def run_command(
cmd: list[str],
check_return: bool = True,
capture: bool = False,
shell: bool = False,
) -> str | None:
"""Run a command without invoking a shell and optionally capture output.
Commands are always executed with ``shell=False`` and must be passed as an
argv ``list[str]``. There is deliberately no ``shell`` parameter: the
argv-list contract makes shell interpolation impossible by construction, so
the shell-injection surface cannot be re-enabled at a call site.
The ``shell`` parameter is kept in the signature so existing keyword
callers (and the re-export from ``specify_cli``) don't raise ``TypeError``,
but only the default ``shell=False`` is honoured. ``shell=True`` is
rejected with ``ValueError`` rather than silently ignored, so the
unsupported mode fails loudly instead of running with a different meaning.
"""
if shell:
raise ValueError(
"run_command() does not support shell=True; pass argv as a list"
)
try:
if capture:
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True)

View File

@@ -13,7 +13,6 @@ import stat
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
@@ -54,19 +53,6 @@ def _is_valid_host_pattern(pattern: str) -> bool:
return pattern.startswith("*.") and "*" not in pattern[2:]
def _norm(value: Any) -> Any:
"""Strip surrounding whitespace from a whitespace-insignificant string
config reference (env-var names, tenant/client ids) before it is stored.
These fields are validated on their ``.strip()``ed form, so an accidentally
padded value passes validation but then silently breaks the verbatim
``os.environ.get(...)`` / URL lookups downstream. Normalizing at store time
mirrors how ``hosts`` is already handled (``h.strip().lower()``). Non-string
values (e.g. ``None``) pass through unchanged.
"""
return value.strip() if isinstance(value, str) else value
def load_auth_config(
path: Path | None = None,
) -> list[AuthConfigEntry]:
@@ -196,10 +182,10 @@ def load_auth_config(
provider=provider,
auth=auth,
token=token,
token_env=_norm(token_env),
tenant_id=_norm(entry_raw.get("tenant_id")),
client_id=_norm(entry_raw.get("client_id")),
client_secret_env=_norm(entry_raw.get("client_secret_env")),
token_env=token_env,
tenant_id=entry_raw.get("tenant_id"),
client_id=entry_raw.get("client_id"),
client_secret_env=entry_raw.get("client_secret_env"),
)
)

View File

@@ -2179,13 +2179,7 @@ class ExtensionManager:
return []
return [item for item in value if isinstance(item, str)]
def unregister_agent_artifacts(
self,
agent_name: str,
*,
enabled_only: bool = False,
commands_only: bool = False,
) -> None:
def unregister_agent_artifacts(self, agent_name: str) -> None:
"""Remove extension files registered for a specific agent.
Extension command files are tracked per agent in ``registered_commands``.
@@ -2193,14 +2187,6 @@ class ExtensionManager:
from that agent's skills directory (resolved via its integration config)
and the registry field is cleared.
Set ``enabled_only=True`` when a caller is about to re-register enabled
extensions and must preserve disabled extensions' existing artifacts and
registry entries.
Set ``commands_only=True`` for command-directory reconciliation where
skill artifacts are outside the target agent's lifecycle and must not
be touched.
Skips cleanup when *agent_name* is not a supported agent to avoid
losing registry entries while leaving orphaned files on disk.
"""
@@ -2219,9 +2205,6 @@ class ExtensionManager:
agent_skills_dir = resolve_skills_dir(self.project_root, agent_name)
for ext_id, metadata in self.registry.list().items():
if enabled_only and not metadata.get("enabled", True):
continue
updates: Dict[str, Any] = {}
registered_commands = metadata.get("registered_commands", {})
@@ -2244,7 +2227,7 @@ class ExtensionManager:
registered_skills = self._valid_name_list(
metadata.get("registered_skills", [])
)
if registered_skills and not commands_only:
if registered_skills:
# Only pass the resolved skills_dir when it actually exists.
# Otherwise let _unregister_extension_skills fall back to
# scanning all known agent skills directories, which is useful

View File

@@ -443,26 +443,6 @@ def _unregister_extensions_for_agent(
)
def _unregister_enabled_extension_commands_for_agent(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""Best-effort removal of enabled extension command artifacts for ``agent_key``."""
_best_effort_extension_op(
project_root,
agent_key,
lambda mgr, key: mgr.unregister_agent_artifacts(
key,
enabled_only=True,
commands_only=True,
),
phase="clean up enabled extension command artifacts for",
continuing=continuing,
)
# ---------------------------------------------------------------------------
# CLI formatting helpers (re-exported from _commands.py)
# ---------------------------------------------------------------------------

View File

@@ -35,7 +35,6 @@ from ._helpers import (
_resolve_script_type,
_set_default_integration,
_set_default_integration_or_exit,
_unregister_enabled_extension_commands_for_agent,
_unregister_extensions_for_agent,
_update_init_options_for_integration,
_write_integration_json,
@@ -55,66 +54,6 @@ def _manifest_tracks_skill_layout(manifest) -> bool:
return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)
def _manifest_path_under(rel_path: str, root: str) -> bool:
"""Return True when manifest key *rel_path* is inside project-relative *root*."""
normalized_root = PurePath(root).as_posix().strip("/")
normalized_rel = PurePath(rel_path).as_posix().strip("/")
if not normalized_root:
return False
return normalized_rel == normalized_root or normalized_rel.startswith(
f"{normalized_root}/"
)
def _legacy_command_root_changed(
integration,
project_root: Path,
old_manifest,
new_manifest,
) -> bool:
"""Return True when command artifacts moved from legacy_dir to canonical dir."""
config = integration.registrar_config or {}
canonical = config.get("dir")
legacy = config.get("legacy_dir")
if (
not isinstance(canonical, str)
or not canonical.strip()
or not isinstance(legacy, str)
or not legacy.strip()
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
):
return False
canonical_dir = project_root / canonical
legacy_dir = project_root / legacy
if not canonical_dir.is_dir() or not legacy_dir.is_dir():
return False
old_had_legacy = any(
_manifest_path_under(rel, legacy) for rel in old_manifest.files
)
new_has_canonical = any(
_manifest_path_under(rel, canonical) for rel in new_manifest.files
)
return old_had_legacy and new_has_canonical
def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool:
"""Return True when the old manifest tracks command files under legacy_dir."""
config = integration.registrar_config or {}
canonical = config.get("dir")
legacy = config.get("legacy_dir")
if (
not isinstance(canonical, str)
or not canonical.strip()
or not isinstance(legacy, str)
or not legacy.strip()
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
):
return False
return any(_manifest_path_under(rel, legacy) for rel in old_manifest.files)
class _PresetRegistryUnreadableError(Exception):
"""Raised when an existing preset registry cannot be read or parsed.
@@ -125,12 +64,7 @@ class _PresetRegistryUnreadableError(Exception):
"""
def _installed_presets_affecting_agent(
project_root,
agent_key: str,
*,
include_skills: bool = True,
) -> list[str]:
def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str]:
"""Return IDs of installed presets with artifacts registered for *agent_key*.
Presets register command overrides for every detected agent and mirror
@@ -183,30 +117,17 @@ def _installed_presets_affecting_agent(
f"preset '{preset_id}' registered_commands is malformed"
)
registered_skills = meta.get("registered_skills", [])
if include_skills:
if not isinstance(registered_skills, (list, tuple)):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_skills is malformed"
)
if not isinstance(registered_skills, (list, tuple)):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_skills is malformed"
)
has_commands = bool(registered_commands.get(agent_key))
has_skills = include_skills and bool(registered_skills)
has_skills = bool(registered_skills)
if has_commands or has_skills:
affected.append(preset_id)
return affected
def _installed_command_presets_affecting_agent(
project_root,
agent_key: str,
) -> list[str]:
"""Return installed presets with command artifacts registered for *agent_key*."""
return _installed_presets_affecting_agent(
project_root,
agent_key,
include_skills=False,
)
@integration_app.command("switch")
def integration_switch(
target: str = typer.Argument(help="Integration key to switch to"),
@@ -566,60 +487,6 @@ def integration_upgrade(
integration, current, key, integration_options
)
legacy_command_root_upgrade_pending = _legacy_command_root_upgrade_pending(
integration,
old_manifest,
)
# Guard: Kilo's legacy command root moves from .kilocode/workflows to
# .kilo/commands. Preset command artifacts are registered only during
# preset install/remove, with no agent-scoped re-registration hook to
# recreate them at the new command root while preserving priority and
# composition semantics. Refuse before setup writes .kilo/commands rather
# than leaving legacy preset files orphaned or registry-tracked overrides
# missing from the canonical directory.
if key == "kilocode" and legacy_command_root_upgrade_pending:
config = integration.registrar_config or {}
legacy = config.get("legacy_dir", "legacy command directory")
canonical = config.get("dir", "canonical command directory")
try:
affected_presets = _installed_command_presets_affecting_agent(
project_root,
key,
)
except _PresetRegistryUnreadableError as exc:
console.print(
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan]: "
"the preset registry could not be read to verify installed presets."
)
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
console.print(
"A command directory migration cannot reconcile preset command "
"artifacts while the preset registry state is unknown. Fix or "
"restore [cyan].specify/presets/.registry[/cyan] and retry."
)
raise typer.Exit(1)
if affected_presets:
preset_list = ", ".join(sorted(affected_presets))
console.print(
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan] while "
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
)
console.print(
"Preset command artifacts cannot yet be reconciled across this "
"command directory migration, so the upgrade is refused before "
"changing files."
)
console.print(
"Remove the preset(s), run the upgrade, then reinstall them:\n"
f" [cyan]specify preset remove <id>[/cyan]\n"
f" [cyan]specify integration upgrade {key} --script {selected_script} --force[/cyan]\n"
f" [cyan]specify preset add <id>[/cyan]"
)
raise typer.Exit(1)
# Guard: reject a command↔skills layout change while preset overrides are
# installed for this agent (review #3415). A dual-mode agent (e.g. Bob)
# can flip layout across an upgrade (``--skills`` / ``--legacy-commands``).
@@ -779,22 +646,6 @@ def integration_upgrade(
if stale_removed:
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
legacy_command_root_changed = _legacy_command_root_changed(
integration,
project_root,
old_manifest,
new_manifest,
)
if legacy_command_root_changed:
_unregister_enabled_extension_commands_for_agent(
project_root,
key,
continuing=(
"The integration command directory changed, but legacy enabled "
"extension artifacts may need manual cleanup."
),
)
# Re-register enabled extensions for the upgraded agent so its extension
# commands are (re)created — including agents installed before this
# back-fill existed. Mirrors switch for command registration; see #2886.

View File

@@ -7,14 +7,13 @@ class KilocodeIntegration(MarkdownIntegration):
key = "kilocode"
config = {
"name": "Kilo Code",
"folder": ".kilo/",
"commands_subdir": "commands",
"folder": ".kilocode/",
"commands_subdir": "workflows",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".kilo/commands",
"legacy_dir": ".kilocode/workflows",
"dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",

View File

@@ -18,4 +18,3 @@ class PiIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
multi_install_safe = True

View File

@@ -1400,14 +1400,6 @@ class WorkflowEngine:
) -> dict[str, Any]:
"""Resolve workflow inputs against definitions and provided values."""
resolved: dict[str, Any] = {}
# execute()/resume() accept UNVALIDATED definitions (load_workflow does
# not validate). A non-mapping ``inputs:`` block (bare ``inputs:`` ->
# None, or ``inputs: []``) is stored raw, so iterating ``.items()`` here
# would crash the run with AttributeError. Treat a non-mapping inputs
# block as "no inputs"; validate_workflow reports the malformed shape
# via its own isinstance check.
if not isinstance(definition.inputs, dict):
return {}
for name, input_def in definition.inputs.items():
if not isinstance(input_def, dict):
continue

View File

@@ -422,12 +422,7 @@ class TestForgeCommandRegistrar:
# Kilo Code uses standard markdown format without name injection.
# The format_name callback should not be invoked for non-Forge agents.
kilocode_cmd = (
tmp_path
/ ".kilo"
/ "commands"
/ "speckit.my-extension.example.md"
)
kilocode_cmd = tmp_path / ".kilocode" / "workflows" / "speckit.my-extension.example.md"
assert kilocode_cmd.exists()
content = kilocode_cmd.read_text(encoding="utf-8")

View File

@@ -1,76 +1,10 @@
"""Tests for KilocodeIntegration."""
from specify_cli.agents import CommandRegistrar
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestKilocodeIntegration(MarkdownIntegrationTests):
KEY = "kilocode"
FOLDER = ".kilo/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".kilo/commands"
def test_registrar_config_has_legacy_dir(self):
integration = get_integration(self.KEY)
assert integration.registrar_config["legacy_dir"] == ".kilocode/workflows"
def test_legacy_dir_extension_registration(self, tmp_path):
"""Extension commands still register into legacy Kilo projects."""
legacy_dir = tmp_path / ".kilocode" / "workflows"
legacy_dir.mkdir(parents=True)
(legacy_dir / "speckit.specify.md").write_text(
"# existing", encoding="utf-8"
)
src_dir = tmp_path / "_ext_src"
src_dir.mkdir()
(src_dir / "myext.md").write_text(
"---\ndescription: test\n---\n# ext command",
encoding="utf-8",
)
registrar = CommandRegistrar()
commands = [{"name": "speckit.myext", "file": "myext.md"}]
results = registrar.register_commands(
self.KEY,
commands,
"test-ext",
src_dir,
tmp_path,
)
assert results == ["speckit.myext"]
assert (legacy_dir / "speckit.myext.md").exists()
assert not (tmp_path / ".kilo" / "commands").exists()
def test_legacy_dir_extension_unregister(self, tmp_path):
"""Unregister removes commands from legacy Kilo projects."""
legacy_dir = tmp_path / ".kilocode" / "workflows"
legacy_dir.mkdir(parents=True)
cmd_file = legacy_dir / "speckit.myext.md"
cmd_file.write_text("# ext command", encoding="utf-8")
registrar = CommandRegistrar()
registrar.unregister_commands({"kilocode": ["speckit.myext"]}, tmp_path)
assert not cmd_file.exists()
def test_unregister_cleans_legacy_when_both_dirs_exist(self, tmp_path):
"""Unregister removes stale legacy files after Kilo path migration."""
canonical_dir = tmp_path / ".kilo" / "commands"
canonical_dir.mkdir(parents=True)
legacy_dir = tmp_path / ".kilocode" / "workflows"
legacy_dir.mkdir(parents=True)
canonical_cmd = canonical_dir / "speckit.myext.md"
canonical_cmd.write_text("# ext command", encoding="utf-8")
legacy_cmd = legacy_dir / "speckit.myext.md"
legacy_cmd.write_text("# stale ext command", encoding="utf-8")
registrar = CommandRegistrar()
registrar.unregister_commands({"kilocode": ["speckit.myext"]}, tmp_path)
assert not canonical_cmd.exists()
assert not legacy_cmd.exists()
FOLDER = ".kilocode/"
COMMANDS_SUBDIR = "workflows"
REGISTRAR_DIR = ".kilocode/workflows"

View File

@@ -1,7 +1,5 @@
"""Tests for PiIntegration."""
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
@@ -10,9 +8,3 @@ class TestPiIntegration(MarkdownIntegrationTests):
FOLDER = ".pi/"
COMMANDS_SUBDIR = "prompts"
REGISTRAR_DIR = ".pi/prompts"
def test_multi_install_safe(self):
# Pi writes only to its isolated, static root .pi/prompts, disjoint from
# every other integration, so it must be co-install safe (mirrors
# qwen/shai/qodercli and the kiro-cli #3471 precedent).
assert get_integration(self.KEY).multi_install_safe is True

View File

@@ -69,26 +69,6 @@ def _write_invalid_manifest(project, key):
return manifest
def _move_kilocode_install_to_legacy_layout(project):
"""Simulate a pre-.kilo Kilo install tracked under .kilocode/workflows."""
canonical = project / ".kilo" / "commands"
legacy = project / ".kilocode" / "workflows"
assert canonical.is_dir(), "init should have created .kilo/commands/"
legacy.parent.mkdir(parents=True, exist_ok=True)
canonical.rename(legacy)
assert legacy.is_dir()
assert not canonical.exists()
manifest_path = project / ".specify" / "integrations" / "kilocode.manifest.json"
manifest_data = json.loads(manifest_path.read_text(encoding="utf-8"))
manifest_data["files"] = {
path.replace(".kilo/commands/", ".kilocode/workflows/"): info
for path, info in manifest_data.get("files", {}).items()
}
manifest_path.write_text(json.dumps(manifest_data), encoding="utf-8")
return canonical, legacy
def _copy_project_template(tmp_path, template):
project = tmp_path / "proj"
shutil.copytree(template, project)
@@ -2513,206 +2493,6 @@ class TestIntegrationUpgrade:
f"found: {[f.name for f in core_remaining]}"
)
def test_upgrade_migrates_kilocode_legacy_dir(self, tmp_path):
"""Upgrade moves Kilo commands from .kilocode/workflows/ to .kilo/commands/."""
project = _init_project(tmp_path, "kilocode")
canonical, legacy = _move_kilocode_install_to_legacy_layout(project)
old_commands = sorted(legacy.glob("speckit.*.md"))
assert old_commands, "Legacy dir should have speckit command files"
result = _run_in_project(project, [
"integration", "upgrade", "kilocode",
"--script", "sh",
"--force",
])
assert result.exit_code == 0, f"upgrade failed: {result.output}"
assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade"
new_commands = sorted(canonical.glob("speckit.*.md"))
assert new_commands, "Commands should exist in .kilo/commands/"
core_remaining = [
f for f in legacy.glob("speckit.*.md")
if "agent-context" not in f.name
]
assert core_remaining == [], (
"Legacy .kilocode/workflows/ should have no core speckit files "
f"after upgrade, found: {[f.name for f in core_remaining]}"
)
def test_upgrade_kilocode_legacy_dir_rejects_installed_preset_overrides(
self, tmp_path
):
"""Kilo legacy command-root migration must fail closed with presets."""
project = _init_project(tmp_path, "kilocode")
canonical, legacy = _move_kilocode_install_to_legacy_layout(project)
preset_file = legacy / "speckit.plan.md"
preset_file.write_text("# preset plan override\n", encoding="utf-8")
presets_dir = project / ".specify" / "presets"
presets_dir.mkdir(parents=True, exist_ok=True)
(presets_dir / ".registry").write_text(
json.dumps({
"presets": {
"my-preset": {
"version": "1.0.0",
"enabled": True,
"registered_commands": {"kilocode": ["speckit.plan"]},
"registered_skills": [],
}
}
}),
encoding="utf-8",
)
result = _run_in_project(project, [
"integration", "upgrade", "kilocode",
"--script", "sh",
"--force",
])
assert result.exit_code != 0, (
"Kilo legacy command-root migration with presets must be rejected"
)
assert "preset" in result.output.lower()
assert "my-preset" in result.output
assert ".kilocode/workflows" in strip_ansi(result.output)
assert ".kilo/commands" in strip_ansi(result.output)
assert not canonical.exists(), (
"canonical Kilo commands must not be scaffolded after rejection"
)
assert preset_file.read_text(encoding="utf-8") == "# preset plan override\n"
def test_upgrade_reconciles_kilocode_legacy_extension_artifacts(self, tmp_path):
"""Kilo upgrade moves enabled extension commands to the canonical dir."""
project = _init_project(tmp_path, "kilocode")
canonical, legacy = _move_kilocode_install_to_legacy_layout(project)
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
assert sorted(legacy.glob("speckit.git.*.md")), (
"legacy Kilo should render the git extension under .kilocode/workflows"
)
assert not canonical.exists()
result = _run_in_project(project, [
"integration", "upgrade", "kilocode",
"--script", "sh",
"--force",
])
assert result.exit_code == 0, f"upgrade failed: {result.output}"
assert sorted(canonical.glob("speckit.git.*.md")), (
"enabled git extension commands should be recreated in .kilo/commands"
)
assert not sorted(legacy.glob("speckit.git.*.md")), (
"legacy git extension commands should be removed after Kilo upgrade"
)
registry_path = project / ".specify" / "extensions" / ".registry"
registered = json.loads(registry_path.read_text(encoding="utf-8"))[
"extensions"
]["git"]["registered_commands"]
assert "kilocode" in registered
def test_upgrade_preserves_disabled_kilocode_legacy_extension_and_user_file(
self, tmp_path
):
"""Legacy reconciliation must not clean disabled or user-owned files."""
project = _init_project(tmp_path, "kilocode")
canonical, legacy = _move_kilocode_install_to_legacy_layout(project)
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, ["extension", "disable", "git"])
assert result.exit_code == 0, f"extension disable failed: {result.output}"
disabled_extension_files = sorted(legacy.glob("speckit.git.*.md"))
assert disabled_extension_files, "disabled extension artifact should remain pre-upgrade"
user_file = legacy / "speckit.user-owned.md"
user_file.write_text("# user-owned legacy command", encoding="utf-8")
result = _run_in_project(project, [
"integration", "upgrade", "kilocode",
"--script", "sh",
"--force",
])
assert result.exit_code == 0, f"upgrade failed: {result.output}"
assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade"
assert user_file.read_text(encoding="utf-8") == "# user-owned legacy command"
for disabled_file in disabled_extension_files:
assert disabled_file.exists(), (
"disabled extension artifacts should be preserved during "
"legacy command-root reconciliation"
)
assert not sorted(canonical.glob("speckit.git.*.md")), (
"disabled extensions must not be re-registered in the canonical dir"
)
def test_upgrade_secondary_kilocode_legacy_dir_preserves_active_agent_skills(
self, tmp_path
):
"""Kilo command-root cleanup must not touch active agent extension skills."""
project = _init_project(tmp_path, "copilot", integration_options="--skills")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
skill = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
assert skill.exists(), "precondition: active copilot has the git extension skill"
registry_path = project / ".specify" / "extensions" / ".registry"
def _git_skills():
data = json.loads(registry_path.read_text(encoding="utf-8"))
return data["extensions"]["git"].get("registered_skills", [])
assert _git_skills(), "precondition: git skills registered for active copilot"
result = _run_in_project(project, [
"integration", "install", "kilocode",
"--script", "sh",
"--force",
])
assert result.exit_code == 0, result.output
canonical, legacy = _move_kilocode_install_to_legacy_layout(project)
legacy_git_command = legacy / "speckit.git.feature.md"
legacy_git_command.write_text("# legacy Kilo git command\n", encoding="utf-8")
registry = json.loads(registry_path.read_text(encoding="utf-8"))
registry["extensions"]["git"].setdefault("registered_commands", {})[
"kilocode"
] = ["speckit.git.feature"]
registry_path.write_text(json.dumps(registry), encoding="utf-8")
assert legacy_git_command.exists(), (
"precondition: secondary Kilo has a legacy extension command file"
)
result = _run_in_project(project, [
"integration", "upgrade", "kilocode",
"--script", "sh",
"--force",
])
assert result.exit_code == 0, result.output
assert canonical.is_dir(), ".kilo/commands/ should exist after upgrade"
assert sorted(canonical.glob("speckit.git.*.md")), (
"secondary Kilo should regain enabled extension commands"
)
assert not legacy_git_command.exists(), (
"secondary Kilo legacy extension commands should still be cleaned up"
)
assert skill.exists(), (
"secondary Kilo legacy cleanup must not delete the active agent's "
"extension skill"
)
assert _git_skills(), (
"secondary Kilo legacy cleanup must not untrack the active agent's "
"extension skills in the registry"
)
def test_upgrade_bob_skills_migration_preserves_manifest(self, tmp_path):
"""Regression (review #3415, 4724160183, comment 1).
@@ -3156,7 +2936,6 @@ class TestIntegrationUpgrade:
"""
from specify_cli.integrations._migrate_commands import (
_PresetRegistryUnreadableError,
_installed_command_presets_affecting_agent,
_installed_presets_affecting_agent,
)
@@ -3222,9 +3001,6 @@ class TestIntegrationUpgrade:
"p1",
"p3",
]
assert _installed_command_presets_affecting_agent(project, "bob") == [
"p1"
]
# ── Full lifecycle ───────────────────────────────────────────────────

View File

@@ -92,27 +92,6 @@ class TestLoadAuthConfig:
assert entries[0].auth == "bearer"
assert entries[0].token_env == "GH_TOKEN"
def test_padded_token_env_is_normalized_and_resolves(self, tmp_path, monkeypatch):
# token_env is validated on its stripped form but was stored raw, so a
# padded env-var name passed validation yet broke the verbatim
# os.environ.get() lookup — resolve_token silently returned None.
monkeypatch.setenv("GH_TOKEN", "secret-tok")
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["github.com"],
"provider": "github",
"auth": "bearer",
"token_env": " GH_TOKEN ",
}]
}))
entries = load_auth_config(cfg)
assert len(entries) == 1
# Stored normalized (matching how hosts are normalized), ...
assert entries[0].token_env == "GH_TOKEN"
# ... so the env lookup finds the token instead of returning None.
assert GitHubAuth().resolve_token(entries[0]) == "secret-tok"
def test_valid_ado_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
@@ -157,26 +136,6 @@ class TestLoadAuthConfig:
assert entries[0].auth == "azure-ad"
assert entries[0].tenant_id == "tid"
def test_padded_azure_ad_refs_are_normalized(self, tmp_path):
# The normalization also covers tenant_id / client_id / client_secret_env
# (used verbatim in the OAuth token URL/body and os.environ.get). Padded
# values were validated on their stripped form but stored raw.
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({
"providers": [{
"hosts": ["dev.azure.com"],
"provider": "azure-devops",
"auth": "azure-ad",
"tenant_id": " tid ",
"client_id": " cid ",
"client_secret_env": " SECRET ",
}]
}))
entries = load_auth_config(cfg)
assert entries[0].tenant_id == "tid"
assert entries[0].client_id == "cid"
assert entries[0].client_secret_env == "SECRET"
def test_azure_cli_config(self, tmp_path):
cfg = tmp_path / "auth.json"
cfg.write_text(json.dumps({

View File

@@ -53,60 +53,6 @@ def repo_pair(tmp_path: Path) -> tuple[Path, Path]:
return _setup_repo(tmp_path, "proj-a"), _setup_repo(tmp_path, "proj-b")
def _run_all_variants_allow_existing(
repo: Path, *, number: str, short_name: str
):
"""Run each create-new-feature variant with the allow-existing options."""
common_args = (
"--json",
"--dry-run",
"--number",
number,
"--allow-existing-branch",
"--short-name",
short_name,
"x",
)
bash = run(bash_cmd(repo, SCRIPT, *common_args), repo)
py = run(py_cmd(repo, SCRIPT, *common_args), repo)
ps = run(
ps_cmd(
repo,
SCRIPT,
"-Json",
"-DryRun",
"-Number",
number,
"-AllowExistingBranch",
"-ShortName",
short_name,
"x",
),
repo,
)
return bash, ps, py
def test_python_prefix_scan_tolerates_permission_error(
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
) -> None:
"""Python matches shell variants when a spec directory cannot be listed."""
specs_dir = tmp_path / "specs"
specs_dir.mkdir()
def deny_listing(_path: Path):
raise PermissionError("denied")
monkeypatch.setattr(Path, "iterdir", deny_listing)
assert not create_new_feature._has_spec_prefix_conflict(
specs_dir,
"001",
specs_dir / "001-x",
allow_existing=False,
)
@requires_bash
@pytest.mark.parametrize(
"description",
@@ -409,7 +355,7 @@ def test_python_missing_template_warning_matches_bash(
@requires_bash
def test_python_existing_prefix_auto_correct_matches_bash(
def test_python_existing_directory_error_matches_bash(
repo_pair: tuple[Path, Path],
) -> None:
repo_a, repo_b = repo_pair
@@ -431,8 +377,8 @@ def test_python_existing_prefix_auto_correct_matches_bash(
bash = run(bash_cmd(repo_a, SCRIPT, "--json", "--number", "1", description), repo_a)
py = run(py_cmd(repo_b, SCRIPT, "--json", "--number", "1", description), repo_b)
assert py.returncode == bash.returncode == 0
assert json_stdout(py)["FEATURE_NUM"] == json_stdout(bash)["FEATURE_NUM"] == "002"
assert py.returncode == bash.returncode == 1
assert py.stdout == bash.stdout == ""
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
bash.stderr, repo_a
)
@@ -585,11 +531,6 @@ def test_all_variants_reject_signed_number(repo: Path, number: str) -> None:
def test_all_variants_treat_empty_number_as_omitted(
repo: Path, timestamp: bool
) -> None:
if not timestamp:
specs_dir = repo / "specs"
(specs_dir / "20260318-sequential").mkdir(parents=True)
(specs_dir / "20260319-143022-timestamp").mkdir()
bash_args = ["--json", "--dry-run", "--number", ""]
ps_args = ["-Json", "-DryRun", "-Number", ""]
py_args = ["--json", "--dry-run", "--number", ""]
@@ -878,92 +819,19 @@ def test_all_variants_allow_existing_branch(repo: Path) -> None:
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_allow_existing_prefers_exact_dir_over_sibling(
repo: Path,
) -> None:
"""Allow-existing preserves exact reuse even when a sibling shares its prefix."""
(repo / "specs" / "004-pre-exist").mkdir(parents=True)
(repo / "specs" / "004-other").mkdir()
bash, ps, py = _run_all_variants_allow_existing(
repo, number="4", short_name="pre-exist"
)
assert bash.returncode == ps.returncode == py.returncode == 0
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
assert json_stdout(py)["BRANCH_NAME"] == "004-pre-exist"
for result in (bash, ps, py):
assert "conflicts with an existing spec directory" not in result.stderr
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_allow_existing_reuses_truncated_exact_dir(repo: Path) -> None:
"""Allow-existing compares the canonical truncated feature directory name."""
short_name = "a" * 300
expected_branch = f"001-{'a' * 240}"
(repo / "specs" / expected_branch).mkdir(parents=True)
bash, ps, py = _run_all_variants_allow_existing(
repo, number="1", short_name=short_name
)
assert bash.returncode == ps.returncode == py.returncode == 0
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
assert json_stdout(py)["BRANCH_NAME"] == expected_branch
for result in (bash, ps, py):
assert "conflicts with an existing spec directory" not in result.stderr
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_existing_prefix_auto_correct_diagnostics(repo: Path) -> None:
def test_all_variants_existing_directory_failure_diagnostics(repo: Path) -> None:
(repo / "specs" / "001-x").mkdir(parents=True)
expected = "conflicts with an existing spec directory; using 002 instead"
bash = run(
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
repo,
)
ps = run(
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", "1", "x"),
repo,
)
py = run(
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
repo,
expected = (
"Error: Feature directory '<REPO>/specs/001-x' already exists. "
"Please use a different feature name or specify a different number "
"with --number."
)
assert bash.returncode == ps.returncode == py.returncode == 0
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
bash = run(bash_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-Number", "1", "x"), repo)
py = run(py_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
assert bash.returncode == ps.returncode == py.returncode == 1
assert bash.stdout == ps.stdout == py.stdout == ""
for result in (bash, ps, py):
assert expected in _normalized_error_text(result.stderr, repo)
@requires_bash
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
def test_all_variants_corrected_prefix_skips_timestamp_collision(repo: Path) -> None:
"""Auto-correction skips candidates owned by timestamp directories."""
specs_dir = repo / "specs"
(specs_dir / "001-existing").mkdir(parents=True)
(specs_dir / "20260318-sequential").mkdir()
(specs_dir / "20260319-143022-timestamp").mkdir()
bash = run(
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
repo,
)
ps = run(
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", "1", "x"),
repo,
)
py = run(
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
repo,
)
assert bash.returncode == ps.returncode == py.returncode == 0
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
assert json_stdout(py)["FEATURE_NUM"] == "20260320"
for result in (bash, ps, py):
assert "using 20260320 instead" in result.stderr

View File

@@ -292,23 +292,6 @@ class TestResolveGitHubReleaseAssetApiUrl:
assert result is None
assert called == []
def test_returns_none_on_malformed_host(self):
"""A malformed authority (e.g. an invalid bracketed IPv6 host) returns
None, not a ValueError (contract: resolve or return None, never raise)."""
called = []
def open_never(url, timeout=None, extra_headers=None):
called.append(url)
raise AssertionError("open_url_fn must not be called")
result = resolve_github_release_asset_api_url(
"https://[not-an-ip]/o/r/releases/download/v1/ext.zip",
open_never,
github_hosts=("ghes.example",),
)
assert result is None
assert called == []
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
"""A direct GHES /api/v3 asset URL passes through even when the host is
not allowlisted: passthrough issues no API request, and the download

View File

@@ -288,50 +288,6 @@ class TestSequentialBranch:
assert data["FEATURE_NUM"] == "000"
assert data["BRANCH_NAME"] == "000-zero"
def test_explicit_conflicting_number_uses_next_spec_prefix(self, git_repo: Path):
"""An explicit number is advanced when its spec prefix already exists."""
(git_repo / "specs" / "001-existing").mkdir(parents=True)
(git_repo / "specs" / "1000-latest").mkdir()
result = run_script(
git_repo,
"--json",
"--dry-run",
"--number",
"1",
"--short-name",
"test",
"Test feature",
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["FEATURE_NUM"] == "1001"
assert data["BRANCH_NAME"] == "1001-test"
assert "--number 001 conflicts with an existing spec directory" in result.stderr
assert "using 1001 instead" in result.stderr
def test_explicit_number_ignores_matching_file(self, git_repo: Path):
"""A matching file does not count as a conflicting spec directory."""
specs_dir = git_repo / "specs"
specs_dir.mkdir()
(specs_dir / "001-placeholder").write_text("not a directory", encoding="utf-8")
result = run_script(
git_repo,
"--json",
"--dry-run",
"--number",
"1",
"--short-name",
"test",
"Test feature",
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout)["FEATURE_NUM"] == "001"
assert "conflicts with an existing spec directory" not in result.stderr
class TestSequentialBranchPowerShell:
def test_powershell_scanner_uses_long_tryparse_for_large_prefixes(self):
@@ -376,50 +332,6 @@ class TestSequentialBranchPowerShell:
assert data["FEATURE_NUM"] == "000"
assert data["BRANCH_NAME"] == "000-zero"
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_explicit_conflicting_number_uses_next_spec_prefix(
self, ps_git_repo: Path
):
"""PowerShell advances an explicit number when its spec prefix exists."""
script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1"
(ps_git_repo / "specs" / "001-existing").mkdir(parents=True)
(ps_git_repo / "specs" / "1000-latest").mkdir()
result = subprocess.run(
[
"pwsh", "-NoProfile", "-File", str(script), "-Json", "-DryRun",
"-Number", "1", "-ShortName", "test", "Test feature",
],
cwd=ps_git_repo, capture_output=True, text=True,
)
assert result.returncode == 0, result.stderr
data = json.loads(result.stdout)
assert data["FEATURE_NUM"] == "1001"
assert data["BRANCH_NAME"] == "1001-test"
assert "-Number 001 conflicts with an existing spec directory" in result.stderr
assert "using 1001 instead" in result.stderr
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_explicit_number_ignores_matching_file(self, ps_git_repo: Path):
"""PowerShell ignores files that resemble numbered spec directories."""
script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1"
specs_dir = ps_git_repo / "specs"
specs_dir.mkdir()
(specs_dir / "001-placeholder").write_text("not a directory", encoding="utf-8")
result = subprocess.run(
[
"pwsh", "-NoProfile", "-File", str(script), "-Json", "-DryRun",
"-Number", "1", "-ShortName", "test", "Test feature",
],
cwd=ps_git_repo, capture_output=True, text=True,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout)["FEATURE_NUM"] == "001"
assert "conflicts with an existing spec directory" not in result.stderr
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_missing_spec_template_warns_matching_bash(self, ps_git_repo: Path):
"""When no spec template can be resolved, create-new-feature.ps1 must warn on
@@ -619,15 +531,14 @@ class TestAllowExistingBranch:
assert feature_dir.is_dir()
assert (feature_dir / "spec.md").exists()
def test_without_flag_auto_corrects_existing_prefix(self, git_repo: Path):
"""T009: Existing prefix advances when exact reuse is not allowed."""
def test_without_flag_still_errors(self, git_repo: Path):
"""T009: Existing feature directories still fail without the flag."""
(git_repo / "specs" / "007-no-flag").mkdir(parents=True)
result = run_script(
git_repo, "--short-name", "no-flag", "--number", "7", "No flag feature",
)
assert result.returncode == 0, result.stderr
assert (git_repo / "specs" / "008-no-flag").is_dir()
assert "using 008 instead" in result.stderr
assert result.returncode != 0, "should fail without --allow-existing-branch"
assert "already exists" in result.stderr
def test_allow_existing_no_overwrite_spec(self, git_repo: Path):
"""T010: Pre-create spec.md with content, verify it is preserved."""
@@ -687,26 +598,6 @@ class TestAllowExistingBranchPowerShell:
assert "Feature directory '$featureDir' already exists" in contents
assert "-not $AllowExistingBranch" in contents
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
def test_powershell_reuses_exact_feature_dir(self, ps_git_repo: Path):
"""PowerShell exact-directory reuse bypasses prefix auto-correction."""
script = ps_git_repo / "scripts" / "powershell" / "create-new-feature.ps1"
feature_dir = ps_git_repo / "specs" / "004-pre-exist"
feature_dir.mkdir(parents=True)
result = subprocess.run(
[
"pwsh", "-NoProfile", "-File", str(script), "-Json",
"-AllowExistingBranch", "-Number", "4", "-ShortName",
"pre-exist", "Pre-existing feature",
],
cwd=ps_git_repo, capture_output=True, text=True,
)
assert result.returncode == 0, result.stderr
assert json.loads(result.stdout)["BRANCH_NAME"] == "004-pre-exist"
assert (feature_dir / "spec.md").is_file()
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not installed")
@pytest.mark.skipif(
os.name != "nt" or shutil.which("powershell.exe") is None,

View File

@@ -9,13 +9,7 @@ import pytest
from specify_cli import run_command
def test_run_command_has_no_shell_parameter():
"""The shell-injection surface is removed at the API level.
``run_command`` must never accept a ``shell`` parameter: the argv-list
contract makes shell interpolation impossible by construction, and there is
no runtime mode to re-enable it. Passing ``shell=`` is a hard ``TypeError``.
"""
assert "shell" not in inspect.signature(run_command).parameters
with pytest.raises(TypeError):
run_command(["echo", "blocked"], shell=True) # type: ignore[call-arg] # noqa: S604
def test_run_command_rejects_shell_execution_compatibly():
assert inspect.signature(run_command).parameters["shell"].default is False
with pytest.raises(ValueError, match="does not support shell=True"):
run_command(["echo", "blocked"], shell=True) # noqa: S604

View File

@@ -3612,23 +3612,6 @@ class TestWorkflowDefinition:
assert definition.id == "test-workflow"
assert len(definition.inputs) == 2
@pytest.mark.parametrize(
"block",
[
"workflow:\n id: w\n name: W\nsteps: []\ninputs: []\n", # list
"workflow:\n id: w\n name: W\nsteps: []\ninputs:\n", # null
],
)
def test_resolve_inputs_tolerates_non_mapping_inputs(self, block):
# execute()/resume() run UNVALIDATED definitions; a non-mapping `inputs:`
# block (list/null) is stored raw and would crash _resolve_inputs at
# `.items()`. It must be treated as "no inputs" instead.
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
definition = WorkflowDefinition.from_string(block)
resolved = WorkflowEngine()._resolve_inputs(definition, {}) # must not raise
assert resolved == {}
def test_from_string_invalid(self):
from specify_cli.workflows.engine import WorkflowDefinition

View File

@@ -280,8 +280,7 @@ Workflow `shell` steps execute their `run` field through `/bin/sh` (POSIX) or th
Catalog-listed workflows are reviewed at submission time (see [Verification Process](#verification-process)), but you should still treat every install as code-execution from an untrusted source until you have read the `workflow.yml`:
- **Before installing a workflow**, fetch the raw YAML and audit every `shell` step's `run` field directly. `specify workflow info <name>` only shows metadata (name, version, inputs, step IDs/types) — not the shell content that would actually execute.
- **Constrain interpolated values, don't just quote them** in `run` blocks: expressions are spliced in as raw text with no automatic escaping, and there is no shell-escaping filter, so quoting is not a security boundary. Restrict `{{ inputs.something }}` substitutions to a fixed set with `enum`/an allowlist so a malicious input can't inject shell syntax; treat quoting only as correctness handling for already-constrained values.
- **Treat prior-step output as untrusted too** — `{{ steps.*.output.* }}` from a `prompt` step is AI-generated text that upstream content can influence. Don't interpolate agent output into a `run` field at all when you can't constrain it; branch on it with `if`/`switch` or act on it in a non-shell step instead.
- **Prefer explicit commands over interpolation** in `run` blocks: `{{ inputs.something }}` substitutions should be quoted and constrained via `enum` so a malicious input can't inject shell syntax.
- **Limit privilege**: shell steps inherit the user's environment. Workflows that need elevated access (sudo, secrets, GitHub tokens) should call them out explicitly in the README so reviewers can spot the requirement.
- **Authors**: if your workflow has shell steps that look risky out of context (deletions, network calls, credential reads), document the rationale in your README. Maintainers will reject submissions whose shell steps can't be justified at review time.

View File

@@ -111,22 +111,10 @@ Run a shell command and capture output:
```yaml
- id: run-tests
type: shell
run: "npm test" # runs from the project root; no interpolation needed
run: "cd {{ inputs.project_dir }} && npm test"
timeout: 1800 # Optional: max seconds before the command is killed (default 300)
```
> ⚠️ **Constrain interpolated values in `run` fields.** A `run` field is executed
> by the system shell, and `{{ ... }}` expressions are substituted as raw text
> with no automatic quoting or escaping. An `inputs.*` value or prior-step output
> (including AI-generated `prompt` output) is parsed as shell syntax and can
> change the command that runs. The only reliable control is to restrict such a
> value at the source with an `enum`/allowlist, or to keep values you cannot
> constrain out of `run` entirely. Quoting a substitution helps a trusted value
> survive word-splitting but is **not** a security boundary — there is no
> shell-escaping filter, and a value containing the matching quote can still
> break out. See
> [Interpolation and shell safety](../docs/reference/workflows.md#interpolation-and-shell-safety).
`timeout` is the maximum time in seconds the command may run before it is
killed and the step fails; it must be a positive number and defaults to
`300` (five minutes) when omitted. Raise it for long-running gates such as