mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
41 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2930d06f41 | ||
|
|
ae0b8ca2b0 | ||
|
|
2fe35bf898 | ||
|
|
73908f798a | ||
|
|
ffe2a7ffd6 | ||
|
|
ae82c74339 | ||
|
|
71e6201790 | ||
|
|
ea6843c1fe | ||
|
|
b0850c97e6 | ||
|
|
781a14a6d2 | ||
|
|
be0c741ebb | ||
|
|
36754522f7 | ||
|
|
391cc0dff8 | ||
|
|
1631c0a50f | ||
|
|
6385250264 | ||
|
|
bfe4772b79 | ||
|
|
c0ba81190b | ||
|
|
e53ddcb0cc | ||
|
|
769acafcab | ||
|
|
2a397aad6c | ||
|
|
4d3a4281bc | ||
|
|
58f5730dd5 | ||
|
|
52b20f1a82 | ||
|
|
579579ba80 | ||
|
|
34a086940f | ||
|
|
0b6bf865c1 | ||
|
|
043c4ec572 | ||
|
|
5ad312863f | ||
|
|
58b3cadb39 | ||
|
|
cce47f6900 | ||
|
|
e14561f773 | ||
|
|
cf0abe28f7 | ||
|
|
9b3546f437 | ||
|
|
e9dfe900f6 | ||
|
|
d0a83890d5 | ||
|
|
88b3230e2e | ||
|
|
f5be0fffc8 | ||
|
|
34bbaafbf3 | ||
|
|
8def197612 | ||
|
|
a62fb1f034 | ||
|
|
a5b6ce4173 |
16
.github/workflows/add-community-bundle.lock.yml
generated
vendored
16
.github/workflows/add-community-bundle.lock.yml
generated
vendored
@@ -33,10 +33,10 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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 }}
|
||||
|
||||
12
.github/workflows/add-community-extension.lock.yml
generated
vendored
12
.github/workflows/add-community-extension.lock.yml
generated
vendored
@@ -33,7 +33,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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 }}
|
||||
|
||||
12
.github/workflows/add-community-preset.lock.yml
generated
vendored
12
.github/workflows/add-community-preset.lock.yml
generated
vendored
@@ -33,7 +33,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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 }}
|
||||
|
||||
8
.github/workflows/bug-assess.lock.yml
generated
vendored
8
.github/workflows/bug-assess.lock.yml
generated
vendored
@@ -32,7 +32,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
|
||||
12
.github/workflows/bug-fix.lock.yml
generated
vendored
12
.github/workflows/bug-fix.lock.yml
generated
vendored
@@ -33,7 +33,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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 }}
|
||||
|
||||
8
.github/workflows/bug-test.lock.yml
generated
vendored
8
.github/workflows/bug-test.lock.yml
generated
vendored
@@ -32,7 +32,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -19,14 +19,14 @@ jobs:
|
||||
language: [ 'actions', 'python' ]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for git info
|
||||
|
||||
|
||||
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
|
||||
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
|
||||
with:
|
||||
globs: |
|
||||
'**/*.md'
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# shellcheck is preinstalled on ubuntu-latest runners.
|
||||
# Start at --severity=error to block real bugs without flagging style
|
||||
|
||||
6
.github/workflows/publish-pypi.yml
vendored
6
.github/workflows/publish-pypi.yml
vendored
@@ -27,12 +27,12 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- 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@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Publish to PyPI
|
||||
run: uv publish
|
||||
|
||||
2
.github/workflows/release-trigger.yml
vendored
2
.github/workflows/release-trigger.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
8
.github/workflows/security.yml
vendored
8
.github/workflows/security.yml
vendored
@@ -19,12 +19,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
|
||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -13,10 +13,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Run ruff check
|
||||
run: uvx ruff check src tests
|
||||
run: uvx ruff@0.15.0 check src tests
|
||||
|
||||
pytest:
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -34,10 +34,10 @@ jobs:
|
||||
python-version: ["3.13", "3.14"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
|
||||
11
AGENTS.md
11
AGENTS.md
@@ -64,13 +64,14 @@ class KilocodeIntegration(MarkdownIntegration):
|
||||
key = "kilocode"
|
||||
config = {
|
||||
"name": "Kilo Code",
|
||||
"folder": ".kilocode/",
|
||||
"commands_subdir": "workflows",
|
||||
"folder": ".kilo/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".kilocode/workflows",
|
||||
"dir": ".kilo/commands",
|
||||
"legacy_dir": ".kilocode/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
@@ -201,8 +202,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, .kilocode/workflows/)
|
||||
ls -R my-project/.kilocode/workflows/
|
||||
# config["folder"] + config["commands_subdir"] (for example, .kilo/commands/)
|
||||
ls -R my-project/.kilo/commands/
|
||||
|
||||
# Uninstall cleanly
|
||||
cd my-project && specify integration uninstall <key>
|
||||
|
||||
50
CHANGELOG.md
50
CHANGELOG.md
@@ -2,6 +2,56 @@
|
||||
|
||||
<!-- 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
|
||||
|
||||
- Update Agent Parity Governance preset to v0.4.0 (#3697)
|
||||
- fix(bundler): InstallResult.changed counts uninstalled as a change (#3692)
|
||||
- [preset] Update Cross-Platform Governance preset to v0.2.1 (#3695)
|
||||
- Update A11Y Governance preset to v0.4.1 (#3693)
|
||||
- fix(workflows): escape step-graph brackets in `workflow info` so the type shows (#3690)
|
||||
- fix(workflows): filter parser rejects trailing tokens (fullmatch, not match) (#3689)
|
||||
- Update iSAQB Architecture Governance preset to v0.2.1 (#3687)
|
||||
- fix(extensions): parse SKILL.md on the --- delimiter line during removal (#3634)
|
||||
- fix(cli): guard lazy .hostname ValueError in extension/preset add --from (#3651)
|
||||
- Update Architecture Governance preset to v0.5.1 (#3686)
|
||||
- fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config (#3659)
|
||||
- Update Security Governance preset to v0.6.1 (#3685)
|
||||
- fix(integrations): declare OmpIntegration multi_install_safe (#3650)
|
||||
- feat(git-extension): add configurable Conventional Commit support (#3390) (#3413)
|
||||
- fix(extensions): hyphenate command names in the Forge post-install listing (#3669)
|
||||
- fix(bundler): reject falsy non-mapping requires/provides in CatalogEntry.from_dict (#3667)
|
||||
- fix(bundler): reject falsy non-list bundles/contributed_components in records (#3666)
|
||||
- Update Intake Authoring Governance preset to v0.1.1 (#3678)
|
||||
- docs(extensions): clarify agent-context README and add config examples (#3389)
|
||||
- chore: release 0.14.0, begin 0.14.1.dev0 development (#3677)
|
||||
|
||||
## [0.14.0] - 2026-07-23
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -36,6 +36,7 @@ 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) |
|
||||
@@ -158,7 +159,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 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 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 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) |
|
||||
|
||||
@@ -7,28 +7,28 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| A11Y Governance | Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Architecture Governance | Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
| Cross-Platform Governance | Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence. | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| 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 | Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries. | 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 general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality 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) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Parallel Autonomous Run Governance | Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
|
||||
@@ -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` | |
|
||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | Installs commands into `.kilo/commands`; legacy `.kilocode/workflows` installs remain supported as a registration fallback |
|
||||
| [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,12 +269,16 @@ 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` | `.kilocode/workflows` |
|
||||
| `kilocode` | `.kilo/commands` |
|
||||
| `kiro-cli` | `.kiro/prompts` |
|
||||
| `lingma` | `.lingma/skills` |
|
||||
| `omp` | `.omp/commands` |
|
||||
| `pi` | `.pi/prompts` |
|
||||
| `qodercli` | `.qoder/commands` |
|
||||
| `qwen` | `.qwen/commands` |
|
||||
| `shai` | `.shai/commands` |
|
||||
|
||||
@@ -502,6 +502,32 @@ 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:
|
||||
|
||||
@@ -195,15 +195,13 @@ Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash command
|
||||
**Example for Kilo Code:**
|
||||
|
||||
```bash
|
||||
# Navigate to the agent's commands folder
|
||||
cd .kilocode/workflows/
|
||||
|
||||
# List files and identify duplicates
|
||||
ls -la
|
||||
# List current and legacy Kilo command folders
|
||||
ls -la .kilo/commands/
|
||||
ls -la .kilocode/workflows/
|
||||
|
||||
# Delete old versions (example filenames - yours may differ)
|
||||
rm speckit.specify-old.md
|
||||
rm speckit.plan-v1.md
|
||||
rm .kilocode/workflows/speckit.specify-old.md
|
||||
rm .kilocode/workflows/speckit.plan-v1.md
|
||||
```
|
||||
|
||||
Restart your IDE to refresh the command list.
|
||||
@@ -248,14 +246,12 @@ specify extension update
|
||||
This happens with IDE-based agents (Kilo Code, Cline, etc.).
|
||||
|
||||
```bash
|
||||
# Find the agent folder (example: .kilocode/workflows/)
|
||||
cd .kilocode/workflows/
|
||||
|
||||
# List all files
|
||||
ls -la
|
||||
# For Kilo Code, inspect both current and legacy command folders
|
||||
ls -la .kilo/commands/
|
||||
ls -la .kilocode/workflows/
|
||||
|
||||
# Delete old command files
|
||||
rm speckit.old-command-name.md
|
||||
rm .kilocode/workflows/speckit.old-command-name.md
|
||||
|
||||
# Restart your IDE
|
||||
```
|
||||
@@ -307,7 +303,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
|
||||
2. **For CLI-based agents**, verify files exist:
|
||||
|
||||
```bash
|
||||
ls -la .claude/commands/ # Claude Code
|
||||
ls -la .claude/skills/ # Claude Code
|
||||
ls -la .gemini/commands/ # Gemini
|
||||
ls -la .cursor/skills/ # Cursor
|
||||
ls -la .pi/prompts/ # Pi Coding Agent
|
||||
@@ -356,7 +352,7 @@ This warning appears when you run `specify init --here` (or `specify init .`) in
|
||||
|
||||
Only Spec Kit infrastructure files:
|
||||
|
||||
- Agent command files (`.claude/commands/`, `.github/prompts/`, etc.)
|
||||
- Agent command/skill files (`.claude/skills/`, `.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
|
||||
@@ -445,7 +441,7 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
|
||||
ls -la .github/prompts/
|
||||
|
||||
# For Claude
|
||||
ls -la .claude/commands/
|
||||
ls -la .claude/skills/
|
||||
|
||||
# For Pi
|
||||
ls -la .pi/prompts/
|
||||
|
||||
@@ -2,55 +2,55 @@
|
||||
|
||||
This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration.
|
||||
|
||||
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`).
|
||||
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`). For `.mdc` files, it also ensures the YAML frontmatter (the metadata block at the top of the file) contains `alwaysApply: true`. Otherwise, everything outside the managed section is untouched.
|
||||
|
||||
> NOTE: Spec Kit itself never touches your agent context file. This extension is the only thing that does, and it's opt-in: install it if you want the block kept in sync, skip it if you'd rather manage that file yourself.
|
||||
|
||||
## Why an extension?
|
||||
|
||||
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** extension lets users:
|
||||
|
||||
- **Choose whether to install it at all** — `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file.
|
||||
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — the bundled scripts honor the `context_markers` value.
|
||||
- **Choose whether to install it at all** - `specify init` does **not** install it. Add it explicitly when you want Spec Kit to manage the agent context file; when it is absent, the file is never modified, and when it is disabled, its automatic hooks do not run.
|
||||
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in this repo) - the bundled scripts honor the `context_markers` value.
|
||||
- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`.
|
||||
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator — `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
|
||||
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in [extension.yml](./extension.yml) (`after_specify`, `after_plan`).
|
||||
|
||||
## Installation
|
||||
|
||||
To install the extension, from the root of an initialized Spec Kit project, run:
|
||||
|
||||
```bash
|
||||
specify extension add agent-context
|
||||
```
|
||||
|
||||
## Disabling
|
||||
|
||||
```bash
|
||||
specify extension disable agent-context
|
||||
|
||||
# Re-enable it
|
||||
specify extension enable agent-context
|
||||
```
|
||||
|
||||
While this extension is disabled (or not installed), nothing in Spec Kit creates, updates, or removes the managed block - the `__CONTEXT_FILE__` placeholder in any template is left as-is, and the extension's own config is never read.
|
||||
|
||||
## Commands
|
||||
|
||||
The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Command | Description |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
|
||||
|
||||
> NOTE: The command ID above is canonical. Invoke it using the syntax for your integration: `/speckit.agent-context.update` for dot-command integrations; `/speckit-agent-context-update` for hyphen/skills integrations (including Forge and Cline); `$speckit-agent-context-update` for Codex or ZCode in skills mode; or `/skill:speckit-agent-context-update` for Kimi.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration flows through the extension's own config file at
|
||||
`.specify/extensions/agent-context/agent-context-config.yml`:
|
||||
|
||||
```yaml
|
||||
# Path to the coding agent context file managed by this extension
|
||||
context_file: CLAUDE.md
|
||||
|
||||
# Optional list of coding agent context files to manage together.
|
||||
# When non-empty, this takes precedence over context_file.
|
||||
context_files:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
|
||||
# Delimiters for the managed Spec Kit section
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
```
|
||||
|
||||
- `context_file` — the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted.
|
||||
- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
|
||||
All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in the repo).
|
||||
|
||||
## Requirements
|
||||
|
||||
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
|
||||
|
||||
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports _"PyYAML is required … not available in the current Python environment"_, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||
|
||||
```bash
|
||||
pip install pyyaml
|
||||
@@ -58,10 +58,6 @@ pip install pyyaml
|
||||
/path/to/speckit-python -m pip install pyyaml
|
||||
```
|
||||
|
||||
## Disable
|
||||
## Issues
|
||||
|
||||
```bash
|
||||
specify extension disable agent-context
|
||||
```
|
||||
|
||||
When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal — the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge — including the per-agent default mapping in `agent-context-defaults.json` — lives entirely within this extension, so disabling it is a complete opt-out.
|
||||
For any other issues, please create an issue in the [official GitHub repo](https://github.com/github/spec-kit/issues).
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
# Coding Agent Context Extension Configuration
|
||||
# These values are populated automatically by `specify init` and
|
||||
# `specify integration use` / `specify integration install`.
|
||||
|
||||
# Path (relative to the project root) to the default coding agent context file
|
||||
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
|
||||
# .github/copilot-instructions.md). Set automatically from the active
|
||||
# integration and regenerated during `specify init` or integration switches.
|
||||
# WHAT: The single agent context file relative to the project root (the directory containing .specify/). Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
# REQUIREMENT: OPTIONAL. Use this if you want to manually specify a single context file. If you leave this entry blank, it will use the default context file for the coding agent you picked when you set up Spec Kit. See `agent-context-defaults.json` for the defaults.
|
||||
# EXAMPLE: context_file: CLAUDE.md
|
||||
context_file: ""
|
||||
|
||||
# Optional list of project-relative coding agent context files managed by this
|
||||
# extension. When non-empty, this list takes precedence over `context_file`.
|
||||
# Use this for projects that intentionally keep multiple agent anchors in sync.
|
||||
# WHAT: List of agent context files relative to the project root (the directory containing .specify/). If you have both `context_file` and `context_files` filled, then this (`context_files`) takes precedence. Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
# REQUIREMENT: OPTIONAL. Use this if your project requires you to keep multiple agent context files in sync.
|
||||
# EXAMPLE:
|
||||
# context_files:
|
||||
# - AGENTS.md
|
||||
# - CLAUDE.md
|
||||
context_files: []
|
||||
|
||||
# Delimiters for the managed Spec Kit section.
|
||||
# Edit these to use custom markers.
|
||||
# WHAT: Markers (delimiters) for the managed Spec Kit section. This extension injects information only between these markers.
|
||||
# REQUIREMENT: OPTIONAL. Only change if you wish to have a custom marker name.
|
||||
# EXAMPLE:
|
||||
# context_markers:
|
||||
# start: "<!-- AGENT SPEC KIT CONTEXT START -->"
|
||||
# end: "<!-- AGENT SPEC KIT CONTEXT END -->"
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-24T00: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.8.17",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.17.zip",
|
||||
"version": "1.13.1",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.13.1.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": 10,
|
||||
"commands": 14,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -313,13 +313,14 @@
|
||||
"refactor",
|
||||
"workflow",
|
||||
"governance",
|
||||
"guardrails"
|
||||
"guardrails",
|
||||
"hygiene"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-05T07:26:00Z",
|
||||
"updated_at": "2026-06-08T00:00:00Z"
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
},
|
||||
"archive": {
|
||||
"name": "Archive Extension",
|
||||
@@ -489,6 +490,46 @@
|
||||
"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",
|
||||
@@ -4777,17 +4818,18 @@
|
||||
"verify-review-ship": {
|
||||
"name": "Verify Review Ship",
|
||||
"id": "verify-review-ship",
|
||||
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
|
||||
"description": "Adds verify and review quality gates plus transactional merge, cleanup, and delivery summary.",
|
||||
"author": "Carlos Eduardo Gevaerd Araujo",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.3.0.zip",
|
||||
"sha256": "a7326c899855f46ff28e9f03ede2f89c4db0fd2b8a64c85017b3ab639e004fd3",
|
||||
"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-only",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
@@ -4799,14 +4841,14 @@
|
||||
"quality",
|
||||
"review",
|
||||
"shipping",
|
||||
"workflow",
|
||||
"testing"
|
||||
"merge",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-10T00:00:00Z"
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
},
|
||||
"verify-tasks": {
|
||||
"name": "Verify Tasks Extension",
|
||||
|
||||
@@ -10,7 +10,7 @@ This extension provides Git operations as an optional, self-contained module. It
|
||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces
|
||||
- **Branch validation** to ensure branches follow naming conventions
|
||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages, or Conventional Commit messages generated by the agent)
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -66,6 +66,11 @@ branch_prefix: ""
|
||||
# Custom commit message for git init
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style for auto-commit hooks: "fixed" (default) uses the
|
||||
# messages below; "conventional" asks the agent to generate a Conventional
|
||||
# Commit message (e.g. "feat: add OAuth spec") from the diff instead.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit per command (all disabled by default)
|
||||
# Example: enable auto-commit after specify
|
||||
auto_commit:
|
||||
|
||||
@@ -14,23 +14,37 @@ This command is invoked as a hook after (or before) core commands. It:
|
||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||
3. Looks up the specific event key to see if auto-commit is enabled
|
||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||
5. Uses the per-command `message` if configured, otherwise a default message
|
||||
5. Determines the commit message based on `commit_style` (see below)
|
||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||
|
||||
## Commit Message Styles
|
||||
|
||||
Controlled by the `commit_style` key in `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
- **`fixed`** (default): use the per-command `message` if configured, otherwise a generic `[Spec Kit] Auto-commit <phase> <command>` message.
|
||||
- **`conventional`**: inspect the actual changes (`git diff` / `git status`) since the last commit and generate a single-line [Conventional Commit](https://www.conventionalcommits.org/) message (`type(scope): subject`, e.g. `feat: add OAuth specification` or `docs: update implementation plan`) that accurately summarizes the change. Write this message to a temporary file and pass the file's path to the script (see Execution below). The configured `message` values are ignored in this mode.
|
||||
|
||||
## Execution
|
||||
|
||||
Determine the event name from the hook that triggered this command, then run the script:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name> [--message-file <path>]`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name> [-MessageFile <path>]`
|
||||
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). Only pass a generated message when `commit_style: conventional` is configured — first check `.specify/extensions/git/git-config.yml` for the value of `commit_style`:
|
||||
|
||||
- If `conventional`: inspect the diff and generate a Conventional Commit message. **Do not interpolate the generated message directly into a shell command string** — its content is derived from repository changes and may contain characters (quotes, `$(...)`, backticks) that a shell would execute or that would break command quoting. Instead, write the message to a temporary file using your file-editing tool (not a shell `echo`/`printf`), then pass that file's path via `--message-file <path>` (Bash) or `-MessageFile <path>` (PowerShell).
|
||||
- If `fixed` or absent: run the script with just `<event_name>`; it uses the configured/static message.
|
||||
|
||||
## Configuration
|
||||
|
||||
In `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
# "fixed" (default) uses the messages below; "conventional" asks the agent
|
||||
# to generate a Conventional Commit message from the diff instead.
|
||||
commit_style: fixed
|
||||
|
||||
auto_commit:
|
||||
default: false # Global toggle — set true to enable for all commands
|
||||
after_specify:
|
||||
@@ -46,3 +60,4 @@ auto_commit:
|
||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||
- If no config file exists: skips (disabled by default)
|
||||
- If no changes to commit: skips with a message
|
||||
- If `commit_style: conventional` is set and no generated message was supplied: fails with a clear error instead of silently falling back to the fixed message format
|
||||
|
||||
@@ -17,6 +17,13 @@ branch_prefix: ""
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style used by auto-commit hooks (speckit.git.commit):
|
||||
# "fixed" - default; use the configured/static messages below.
|
||||
# "conventional" - ask the agent to inspect the diff and generate a
|
||||
# Conventional Commit message (e.g. "feat: add OAuth spec")
|
||||
# instead of using the messages configured below.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
|
||||
@@ -17,6 +17,13 @@ branch_prefix: ""
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style used by auto-commit hooks (speckit.git.commit):
|
||||
# "fixed" - default; use the configured/static messages below.
|
||||
# "conventional" - ask the agent to inspect the diff and generate a
|
||||
# Conventional Commit message (e.g. "feat: add OAuth spec")
|
||||
# instead of using the messages configured below.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
|
||||
@@ -3,16 +3,57 @@
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.sh <event_name>
|
||||
# Usage: auto-commit.sh <event_name> [generated_message]
|
||||
# auto-commit.sh <event_name> --message-file <path>
|
||||
# e.g.: auto-commit.sh after_specify
|
||||
# e.g.: auto-commit.sh after_specify --message-file /tmp/commit-msg.txt (commit_style: conventional)
|
||||
#
|
||||
# --message-file is the preferred way to supply an agent-generated commit
|
||||
# message: it reads the message from a file instead of a shell argument,
|
||||
# so message content (which may contain quotes, `$(...)`, backticks, etc.)
|
||||
# is never interpolated into a shell command line.
|
||||
|
||||
set -e
|
||||
|
||||
EVENT_NAME="${1:-}"
|
||||
if [ -z "$EVENT_NAME" ]; then
|
||||
echo "Usage: $0 <event_name>" >&2
|
||||
echo "Usage: $0 <event_name> [generated_message | --message-file <path>]" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift || true
|
||||
|
||||
# Optional second argument: an agent-generated commit message (used when
|
||||
# commit_style: conventional is configured). Prefer --message-file over
|
||||
# passing the message directly as a shell argument.
|
||||
GENERATED_MESSAGE=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--message-file)
|
||||
_message_file="${2:-}"
|
||||
if [ -z "$_message_file" ]; then
|
||||
echo "[specify] Error: --message-file requires a path argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$_message_file" ]; then
|
||||
echo "[specify] Error: message file '$_message_file' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
GENERATED_MESSAGE="$(cat "$_message_file")"
|
||||
# The message file is a transport-only artifact: its content is
|
||||
# now captured above, so remove it immediately. Otherwise, if it
|
||||
# was written inside the worktree, it would be picked up as an
|
||||
# untracked change by both the "any changes?" check below and by
|
||||
# `git add .`, polluting the commit or defeating the no-changes
|
||||
# short-circuit even when nothing else changed.
|
||||
rm -f "$_message_file"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
GENERATED_MESSAGE="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
@@ -46,8 +87,22 @@ fi
|
||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
_enabled=false
|
||||
_commit_msg=""
|
||||
_commit_style="fixed"
|
||||
|
||||
if [ -f "$_config_file" ]; then
|
||||
# Top-level scalar key: commit_style (fixed | conventional)
|
||||
_style_val=$(grep -m1 '^commit_style:' "$_config_file" 2>/dev/null | sed 's/^commit_style:[[:space:]]*//' | sed 's/[[:space:]]\{1,\}#.*$//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//' | tr '[:upper:]' '[:lower:]')
|
||||
if [ -n "$_style_val" ]; then
|
||||
case "$_style_val" in
|
||||
fixed|conventional)
|
||||
_commit_style="$_style_val"
|
||||
;;
|
||||
*)
|
||||
echo "[specify] Warning: unknown commit_style '$_style_val' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Parse the auto_commit section for this event.
|
||||
# Look for auto_commit.<event_name>.enabled and .message
|
||||
# Also check auto_commit.default as fallback.
|
||||
@@ -128,6 +183,17 @@ if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null &&
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# In conventional mode, the commit message must be supplied by the agent
|
||||
# (via the generated_message argument); never fall back to the fixed message.
|
||||
if [ "$_commit_style" = "conventional" ]; then
|
||||
if [ -n "$GENERATED_MESSAGE" ]; then
|
||||
_commit_msg="$GENERATED_MESSAGE"
|
||||
else
|
||||
echo "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass --message-file <path>, or a raw message as arg 2, or set commit_style: fixed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
# e.g., after_specify -> specify, before_plan -> plan
|
||||
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
|
||||
|
||||
@@ -3,14 +3,47 @@
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.ps1 <event_name>
|
||||
# Usage: auto-commit.ps1 <event_name> [generated_message]
|
||||
# auto-commit.ps1 <event_name> -MessageFile <path>
|
||||
# e.g.: auto-commit.ps1 after_specify
|
||||
# e.g.: auto-commit.ps1 after_specify -MessageFile C:\temp\commit-msg.txt (commit_style: conventional)
|
||||
#
|
||||
# -MessageFile is the preferred way to supply an agent-generated commit
|
||||
# message: it reads the message from a file instead of a shell argument,
|
||||
# so message content (which may contain quotes, $(...), backticks, etc.)
|
||||
# is never interpolated into a shell command line.
|
||||
param(
|
||||
[Parameter(Position = 0, Mandatory = $true)]
|
||||
[string]$EventName
|
||||
[string]$EventName,
|
||||
|
||||
# Optional agent-generated commit message (used when commit_style: conventional is configured).
|
||||
# Prefer -MessageFile over passing the message directly as a shell argument.
|
||||
[Parameter(Position = 1, Mandatory = $false)]
|
||||
[string]$GeneratedMessage = "",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$MessageFile = ""
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($MessageFile) {
|
||||
if (-not (Test-Path $MessageFile -PathType Leaf)) {
|
||||
Write-Warning "[specify] Error: message file '$MessageFile' not found"
|
||||
exit 1
|
||||
}
|
||||
$GeneratedMessage = (Get-Content -Path $MessageFile -Raw)
|
||||
if ($null -ne $GeneratedMessage) {
|
||||
$GeneratedMessage = $GeneratedMessage.TrimEnd("`r", "`n")
|
||||
}
|
||||
# The message file is a transport-only artifact: its content is now
|
||||
# captured above, so remove it immediately. Otherwise, if it was written
|
||||
# inside the worktree, it would be picked up as an untracked change by
|
||||
# both the "any changes?" check below and by `git add .`, polluting the
|
||||
# commit or defeating the no-changes short-circuit even when nothing
|
||||
# else changed.
|
||||
Remove-Item -Path $MessageFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Find-ProjectRoot {
|
||||
param([string]$StartDir)
|
||||
$current = Resolve-Path $StartDir
|
||||
@@ -55,8 +88,25 @@ if (-not $isRepo) {
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
$enabled = $false
|
||||
$commitMsg = ""
|
||||
$commitStyle = "fixed"
|
||||
|
||||
if (Test-Path $configFile) {
|
||||
# Top-level scalar key: commit_style (fixed | conventional)
|
||||
foreach ($line in Get-Content $configFile) {
|
||||
if ($line -match '^commit_style:\s*(.+)$') {
|
||||
$styleVal = (($matches[1] -replace '\s+#.*$', '').Trim()) -replace '^["'']' -replace '["'']$'
|
||||
if ($styleVal) {
|
||||
$styleVal = $styleVal.ToLower()
|
||||
if ($styleVal -eq 'fixed' -or $styleVal -eq 'conventional') {
|
||||
$commitStyle = $styleVal
|
||||
} else {
|
||||
Write-Warning "[specify] Warning: unknown commit_style '$styleVal' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'"
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Parse YAML to find auto_commit section
|
||||
$inAutoCommit = $false
|
||||
$inEvent = $false
|
||||
@@ -140,6 +190,17 @@ if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# In conventional mode, the commit message must be supplied by the agent
|
||||
# (via the GeneratedMessage argument); never fall back to the fixed message.
|
||||
if ($commitStyle -eq 'conventional') {
|
||||
if ($GeneratedMessage) {
|
||||
$commitMsg = $GeneratedMessage
|
||||
} else {
|
||||
Write-Warning "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass -MessageFile <path>, or a raw message as arg 2, or set commit_style: fixed)"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
|
||||
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-24T00:00:00Z",
|
||||
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
"name": "A11Y Governance",
|
||||
"id": "a11y-governance",
|
||||
"version": "0.4.0",
|
||||
"description": "Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence.",
|
||||
"version": "0.4.1",
|
||||
"description": "Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -33,18 +34,18 @@
|
||||
"didactic-comments"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
"id": "agent-parity-governance",
|
||||
"version": "0.3.0",
|
||||
"description": "Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift.",
|
||||
"version": "0.4.0",
|
||||
"description": "Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.3.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.0/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -63,7 +64,7 @@
|
||||
"multi-agent"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"aide-in-place": {
|
||||
"name": "AIDE In-Place Migration",
|
||||
@@ -96,13 +97,13 @@
|
||||
"architecture-governance": {
|
||||
"name": "Architecture Governance",
|
||||
"id": "architecture-governance",
|
||||
"version": "0.5.0",
|
||||
"description": "Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"version": "0.5.1",
|
||||
"description": "Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -129,7 +130,7 @@
|
||||
"assurance"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
@@ -243,13 +244,13 @@
|
||||
"cross-platform-governance": {
|
||||
"name": "Cross-Platform Governance",
|
||||
"id": "cross-platform-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit.",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -271,7 +272,7 @@
|
||||
"linux"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"explicit-task-dependencies": {
|
||||
"name": "Explicit Task Dependencies",
|
||||
@@ -367,42 +368,42 @@
|
||||
"intake-authoring-governance": {
|
||||
"name": "Intake Authoring Governance",
|
||||
"id": "intake-authoring-governance",
|
||||
"version": "0.1.0",
|
||||
"description": "Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries.",
|
||||
"version": "0.2.0",
|
||||
"description": "Governs traceable intake CRUD, bounded public HTTPS sources, and explicitly approved single or series authoring without granting execution authority.",
|
||||
"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.1.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.2.0.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.1.0/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.2.0/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 7,
|
||||
"commands": 2,
|
||||
"scripts": 2
|
||||
"templates": 10,
|
||||
"commands": 5,
|
||||
"scripts": 4
|
||||
},
|
||||
"tags": [
|
||||
"intake",
|
||||
"authoring",
|
||||
"governance",
|
||||
"traceability",
|
||||
"clarification"
|
||||
"provenance",
|
||||
"requirements"
|
||||
],
|
||||
"created_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z"
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
},
|
||||
"intake-review-governance": {
|
||||
"name": "Intake Review Governance",
|
||||
"id": "intake-review-governance",
|
||||
"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.",
|
||||
"version": "0.1.1",
|
||||
"description": "Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes.",
|
||||
"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.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.1.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.0/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
@@ -420,18 +421,18 @@
|
||||
"autonomous"
|
||||
],
|
||||
"created_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
},
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
"id": "isaqb-architecture-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt.",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -452,7 +453,7 @@
|
||||
"technical-debt"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"jira": {
|
||||
"name": "Jira Issue Tracking",
|
||||
@@ -635,13 +636,13 @@
|
||||
"security-governance": {
|
||||
"name": "Security Governance",
|
||||
"id": "security-governance",
|
||||
"version": "0.6.0",
|
||||
"description": "Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA to Spec Kit.",
|
||||
"version": "0.6.1",
|
||||
"description": "Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -680,7 +681,7 @@
|
||||
"regulatory"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"sicario-core": {
|
||||
"name": "SicarioSpec Core",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.14.2"
|
||||
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"
|
||||
|
||||
@@ -8,6 +8,7 @@ ALLOW_EXISTING=false
|
||||
SHORT_NAME=""
|
||||
BRANCH_NUMBER=""
|
||||
USE_TIMESTAMP=false
|
||||
NUMBER_EXPLICIT=false
|
||||
ARGS=()
|
||||
i=1
|
||||
while [ $i -le $# ]; do
|
||||
@@ -48,6 +49,9 @@ while [ $i -le $# ]; do
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER="$next_arg"
|
||||
if [ -n "$BRANCH_NUMBER" ]; then
|
||||
NUMBER_EXPLICIT=true
|
||||
fi
|
||||
;;
|
||||
--timestamp)
|
||||
USE_TIMESTAMP=true
|
||||
@@ -60,7 +64,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 Specify branch number manually (overrides auto-detection)"
|
||||
echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)"
|
||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
@@ -91,6 +95,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
fi
|
||||
|
||||
MAX_FEATURE_NUMBER=9223372036854775807
|
||||
MAX_BRANCH_LENGTH=244
|
||||
|
||||
is_feature_number_in_range() {
|
||||
local value="$1"
|
||||
@@ -128,12 +133,40 @@ 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).
|
||||
@@ -253,26 +286,41 @@ 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))")
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
|
||||
# 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
|
||||
|
||||
fi
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
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}"
|
||||
|
||||
ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
|
||||
>&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)"
|
||||
|
||||
@@ -14,6 +14,7 @@ param(
|
||||
[string[]]$FeatureDescription
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$maxBranchLength = 244
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
@@ -24,7 +25,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 Specify branch number manually (overrides auto-detection)"
|
||||
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
|
||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
Write-Host " -Help Show this help message"
|
||||
Write-Host ""
|
||||
@@ -67,11 +68,44 @@ 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"
|
||||
|
||||
@@ -176,26 +210,40 @@ if ($Timestamp) {
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $resolvedNumber)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
|
||||
# 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")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
$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"
|
||||
|
||||
$originalBranchName = "$featureNum-$branchSuffix"
|
||||
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
if ($branchName -ne $originalBranchName) {
|
||||
[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)")
|
||||
|
||||
@@ -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 Specify branch number manually (overrides auto-detection)
|
||||
--number N Prefer a feature number (auto-corrected if its specs prefix exists)
|
||||
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
|
||||
--help, -h Show this help message
|
||||
|
||||
@@ -204,6 +204,43 @@ 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)
|
||||
@@ -261,18 +298,48 @@ 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
|
||||
|
||||
branch_name = f"{feature_num}-{branch_suffix}"
|
||||
original_branch_name = f"{feature_num}-{branch_suffix}"
|
||||
branch_name = _fit_branch_name(feature_num, branch_suffix)
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names.
|
||||
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}"
|
||||
if branch_name != original_branch_name:
|
||||
print(
|
||||
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
|
||||
file=sys.stderr,
|
||||
|
||||
@@ -102,8 +102,17 @@ def resolve_github_release_asset_api_url(
|
||||
|
||||
from specify_cli._download_security import read_response_limited
|
||||
|
||||
parsed = urlparse(download_url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
# 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
|
||||
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
|
||||
|
||||
is_ghes = (
|
||||
|
||||
@@ -69,21 +69,14 @@ 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.
|
||||
|
||||
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.
|
||||
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.
|
||||
"""
|
||||
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)
|
||||
|
||||
@@ -13,6 +13,7 @@ import stat
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
@@ -53,6 +54,19 @@ 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]:
|
||||
@@ -182,10 +196,10 @@ def load_auth_config(
|
||||
provider=provider,
|
||||
auth=auth,
|
||||
token=token,
|
||||
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"),
|
||||
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")),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -40,9 +40,12 @@ def _read(project_root: Path) -> list[dict]:
|
||||
path = ensure_within(project_root, _config_path(project_root))
|
||||
if not path.exists():
|
||||
return []
|
||||
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
|
||||
# otherwise, so a non-mapping top level — a falsy ``[]``/``false``/``0``/``''``
|
||||
# or an explicit null (``load_yaml`` -> ``None``) — is caught by the isinstance
|
||||
# guard below and raised like a truthy one, staying consistent with the other
|
||||
# reader of this file (models/catalog._merge_config).
|
||||
data = load_yaml(path)
|
||||
if data is None:
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {path}: expected a mapping at the top "
|
||||
|
||||
@@ -39,17 +39,35 @@ def ensure_within(root: Path, candidate: Path) -> Path:
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> Any:
|
||||
"""Parse a YAML file, returning ``{}`` for an empty document."""
|
||||
"""Parse a YAML file, returning ``{}`` only for an *empty* document.
|
||||
|
||||
A non-empty document is returned exactly as parsed — including a
|
||||
non-mapping such as ``[]``, ``false``, ``0``, ``''``, or an explicit null
|
||||
(``null``/``~``) — so callers can validate the top-level shape (e.g. reject
|
||||
a non-mapping config) instead of having it silently coerced to an empty
|
||||
mapping.
|
||||
|
||||
``yaml.safe_load`` returns ``None`` for *both* an empty document and an
|
||||
explicit null scalar, so ``yaml.compose`` (which yields no node only for a
|
||||
truly empty document) is used to tell them apart: an empty document becomes
|
||||
``{}`` while an explicit ``null``/``~`` is returned as ``None`` for the
|
||||
caller to reject.
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"File not found: {path}")
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not read {path}: {exc}") from exc
|
||||
try:
|
||||
has_node = yaml.compose(text) is not None
|
||||
data = yaml.safe_load(text)
|
||||
except yaml.YAMLError as exc:
|
||||
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
|
||||
if data is None and not has_node:
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
|
||||
|
||||
@@ -152,14 +152,21 @@ class CatalogEntry:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each catalog entry must be a mapping.")
|
||||
entry_id = str(data.get("id", "")).strip()
|
||||
requires = data.get("requires") or {}
|
||||
if not isinstance(requires, dict):
|
||||
# `or {}` would coerce a FALSY non-mapping (0, '', False, []) to {} before
|
||||
# the isinstance guard, silently accepting a corrupt catalog entry; only
|
||||
# an absent/None value means "not present".
|
||||
requires = data.get("requires")
|
||||
if requires is None:
|
||||
requires = {}
|
||||
elif not isinstance(requires, dict):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id or '<unknown>'}': 'requires' must be a "
|
||||
"mapping when present."
|
||||
)
|
||||
provides_raw = data.get("provides") or {}
|
||||
if not isinstance(provides_raw, dict):
|
||||
provides_raw = data.get("provides")
|
||||
if provides_raw is None:
|
||||
provides_raw = {}
|
||||
elif not isinstance(provides_raw, dict):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id or '<unknown>'}': 'provides' must be a "
|
||||
"mapping when present."
|
||||
@@ -249,8 +256,18 @@ def load_source_stack(project_root: Path, user_config_dir: Path | None = None) -
|
||||
def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None:
|
||||
if not config_path.exists():
|
||||
return
|
||||
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
|
||||
# otherwise, so a non-mapping top level (a YAML list or scalar, including
|
||||
# the falsy ``[]``/``false``/``0``/``''``) is caught here and raised —
|
||||
# matching the sibling reader commands_impl/catalog_config._read. #3623
|
||||
# aligned the inner non-list ``catalogs`` value between the two readers.
|
||||
data = load_yaml(config_path)
|
||||
catalogs = data.get("catalogs") if isinstance(data, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {config_path}: expected a mapping at "
|
||||
f"the top level, got {type(data).__name__}."
|
||||
)
|
||||
catalogs = data.get("catalogs")
|
||||
if catalogs is None:
|
||||
return
|
||||
if not isinstance(catalogs, list):
|
||||
|
||||
@@ -55,8 +55,13 @@ class InstalledBundleRecord:
|
||||
def from_dict(cls, data: Any) -> "InstalledBundleRecord":
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each installed-bundle record must be a mapping.")
|
||||
components_raw = data.get("contributed_components") or []
|
||||
if not isinstance(components_raw, list):
|
||||
components_raw = data.get("contributed_components")
|
||||
if components_raw is None:
|
||||
components_raw = []
|
||||
elif not isinstance(components_raw, list):
|
||||
# `or []` would coerce a FALSY non-list (0, '', False, {}) to []
|
||||
# before this guard, silently accepting a corrupt record; only an
|
||||
# absent/None value means "no components".
|
||||
raise BundlerError(
|
||||
"Corrupt record: 'contributed_components' must be a list."
|
||||
)
|
||||
@@ -121,8 +126,13 @@ def load_records(project_root: Path) -> list[InstalledBundleRecord]:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(f"Corrupt records file: {path}")
|
||||
_check_schema_version(data.get("schema_version"), path=path, required=True)
|
||||
bundles = data.get("bundles") or []
|
||||
if not isinstance(bundles, list):
|
||||
bundles = data.get("bundles")
|
||||
if bundles is None:
|
||||
bundles = []
|
||||
elif not isinstance(bundles, list):
|
||||
# `or []` would coerce a FALSY non-list (0, '', False, {}) to [] before
|
||||
# this guard, silently treating a corrupt file as "no bundles"; only an
|
||||
# absent/None value means empty.
|
||||
raise BundlerError(
|
||||
f"Corrupt records file: {path} — 'bundles' must be a list."
|
||||
)
|
||||
|
||||
@@ -50,7 +50,10 @@ class InstallResult:
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(self.installed or self.refreshed)
|
||||
# `uninstalled` is a mutating outcome too: a `bundle update` whose new
|
||||
# manifest drops components (removing them via the refresh path) with no
|
||||
# new install/refresh must still report changed=True, not a no-op.
|
||||
return bool(self.installed or self.refreshed or self.uninstalled)
|
||||
|
||||
|
||||
def install_bundle(
|
||||
|
||||
@@ -1330,19 +1330,20 @@ class ExtensionManager:
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
try:
|
||||
import yaml as _yaml
|
||||
from ..agents import CommandRegistrar as _Registrar
|
||||
|
||||
raw = skill_md.read_text(encoding="utf-8")
|
||||
source = ""
|
||||
if raw.startswith("---"):
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm = _yaml.safe_load(parts[1]) or {}
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
# Parse on the ``---`` delimiter *line*, not any ``---``
|
||||
# substring: a description containing ``---`` would trip a
|
||||
# raw ``split("---", 2)`` and hide metadata.source, so this
|
||||
# extension's own skill would look unrelated and be left
|
||||
# orphaned. Mirrors the #3590 parse_frontmatter fix.
|
||||
fm, _ = _Registrar.parse_frontmatter(raw)
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
if source != f"extension:{extension_id}":
|
||||
continue
|
||||
except (OSError, UnicodeDecodeError, Exception):
|
||||
@@ -1386,19 +1387,20 @@ class ExtensionManager:
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
try:
|
||||
import yaml as _yaml
|
||||
from ..agents import CommandRegistrar as _Registrar
|
||||
|
||||
raw = skill_md.read_text(encoding="utf-8")
|
||||
source = ""
|
||||
if raw.startswith("---"):
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm = _yaml.safe_load(parts[1]) or {}
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
# Parse on the ``---`` delimiter *line*, not any ``---``
|
||||
# substring: a description containing ``---`` would trip
|
||||
# a raw ``split("---", 2)`` and hide metadata.source, so
|
||||
# this extension's own skill would look unrelated and be
|
||||
# left orphaned. Mirrors the #3590 parse_frontmatter fix.
|
||||
fm, _ = _Registrar.parse_frontmatter(raw)
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
# Only remove skills explicitly created by this extension
|
||||
if source != f"extension:{extension_id}":
|
||||
continue
|
||||
@@ -2177,7 +2179,13 @@ class ExtensionManager:
|
||||
return []
|
||||
return [item for item in value if isinstance(item, str)]
|
||||
|
||||
def unregister_agent_artifacts(self, agent_name: str) -> None:
|
||||
def unregister_agent_artifacts(
|
||||
self,
|
||||
agent_name: str,
|
||||
*,
|
||||
enabled_only: bool = False,
|
||||
commands_only: bool = False,
|
||||
) -> None:
|
||||
"""Remove extension files registered for a specific agent.
|
||||
|
||||
Extension command files are tracked per agent in ``registered_commands``.
|
||||
@@ -2185,6 +2193,14 @@ 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.
|
||||
"""
|
||||
@@ -2203,6 +2219,9 @@ 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", {})
|
||||
@@ -2225,7 +2244,7 @@ class ExtensionManager:
|
||||
registered_skills = self._valid_name_list(
|
||||
metadata.get("registered_skills", [])
|
||||
)
|
||||
if registered_skills:
|
||||
if registered_skills and not commands_only:
|
||||
# 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
|
||||
|
||||
@@ -428,10 +428,17 @@ def extension_add(
|
||||
|
||||
try:
|
||||
parsed = urlparse(from_url)
|
||||
# Read .hostname inside the try: parsing a malformed authority -- or
|
||||
# accessing .hostname on one, e.g. an invalid bracketed IPv6 host like
|
||||
# "https://[not-an-ip]/x.zip" -- can raise ValueError. Keeping both the
|
||||
# parse and the .hostname read inside the guard surfaces a clean
|
||||
# "Invalid URL" message instead of leaking a raw traceback past the
|
||||
# CLI. Reuse the value below.
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
console.print("[red]Error:[/red] URL must use HTTPS for security.")
|
||||
@@ -623,16 +630,22 @@ def extension_add(
|
||||
for warning in manifest.warnings:
|
||||
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
|
||||
|
||||
is_cline = load_init_options(project_root).get("ai") == "cline"
|
||||
selected_ai = load_init_options(project_root).get("ai")
|
||||
is_cline = selected_ai == "cline"
|
||||
is_forge = selected_ai == "forge"
|
||||
|
||||
if is_cline:
|
||||
from specify_cli.integrations.cline import format_cline_command_name
|
||||
if is_forge:
|
||||
from specify_cli.integrations.forge import format_forge_command_name
|
||||
|
||||
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
|
||||
for cmd in manifest.commands:
|
||||
cmd_name = cmd['name']
|
||||
if is_cline:
|
||||
cmd_name = format_cline_command_name(cmd_name)
|
||||
elif is_forge:
|
||||
cmd_name = format_forge_command_name(cmd_name)
|
||||
console.print(f" • {_escape_markup(str(cmd_name))} - {_escape_markup(str(cmd.get('description', '')))}")
|
||||
|
||||
# Report agent skills registration
|
||||
|
||||
@@ -443,6 +443,26 @@ 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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -35,6 +35,7 @@ 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,
|
||||
@@ -54,6 +55,66 @@ 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.
|
||||
|
||||
@@ -64,7 +125,12 @@ class _PresetRegistryUnreadableError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str]:
|
||||
def _installed_presets_affecting_agent(
|
||||
project_root,
|
||||
agent_key: str,
|
||||
*,
|
||||
include_skills: bool = True,
|
||||
) -> list[str]:
|
||||
"""Return IDs of installed presets with artifacts registered for *agent_key*.
|
||||
|
||||
Presets register command overrides for every detected agent and mirror
|
||||
@@ -117,17 +183,30 @@ def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str
|
||||
f"preset '{preset_id}' registered_commands is malformed"
|
||||
)
|
||||
registered_skills = meta.get("registered_skills", [])
|
||||
if not isinstance(registered_skills, (list, tuple)):
|
||||
raise _PresetRegistryUnreadableError(
|
||||
f"preset '{preset_id}' registered_skills is malformed"
|
||||
)
|
||||
if include_skills:
|
||||
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 = bool(registered_skills)
|
||||
has_skills = include_skills and 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"),
|
||||
@@ -487,6 +566,60 @@ 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``).
|
||||
@@ -646,6 +779,22 @@ 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.
|
||||
|
||||
@@ -7,13 +7,14 @@ class KilocodeIntegration(MarkdownIntegration):
|
||||
key = "kilocode"
|
||||
config = {
|
||||
"name": "Kilo Code",
|
||||
"folder": ".kilocode/",
|
||||
"commands_subdir": "workflows",
|
||||
"folder": ".kilo/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".kilocode/workflows",
|
||||
"dir": ".kilo/commands",
|
||||
"legacy_dir": ".kilocode/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
|
||||
@@ -20,6 +20,7 @@ class OmpIntegration(MarkdownIntegration):
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
|
||||
@@ -18,3 +18,4 @@ class PiIntegration(MarkdownIntegration):
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
@@ -2376,7 +2376,15 @@ def workflow_info(
|
||||
console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]")
|
||||
for step in definition.steps:
|
||||
stype = step.get("type", "command")
|
||||
console.print(f" → {step.get('id', '?')} [{stype}]")
|
||||
# Escape the literal bracket (\[) so Rich renders `[<type>]`
|
||||
# instead of parsing it as a style tag named after the step
|
||||
# type (which it silently swallows); escape id/type too, as
|
||||
# the sibling workflow_list does. Mirrors the `\[disabled]`
|
||||
# precedent above.
|
||||
console.print(
|
||||
f" → {_escape_markup(str(step.get('id', '?')))} "
|
||||
f"\\[{_escape_markup(str(stype))}]"
|
||||
)
|
||||
return
|
||||
|
||||
# Try catalog
|
||||
|
||||
@@ -1400,6 +1400,14 @@ 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
|
||||
|
||||
@@ -392,8 +392,14 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An
|
||||
)
|
||||
return _filter_from_json(value)
|
||||
|
||||
# Parse filter name and argument
|
||||
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
|
||||
# Parse filter name and argument. Use fullmatch (not match) so trailing
|
||||
# tokens after the closing paren — e.g. a comparison/boolean operator that
|
||||
# binds looser than the pipe, as in ``count | default(0) > 5`` — are not
|
||||
# silently discarded but fall through to the "unsupported form" ValueError
|
||||
# below, mirroring the strict trailing-token handling of the from_json
|
||||
# branch above. The greedy ``.+`` still handles literal ``)`` and ``|``
|
||||
# inside quoted args.
|
||||
filter_match = re.fullmatch(r"(\w+)\((.+)\)", filter_expr)
|
||||
if filter_match:
|
||||
fname = filter_match.group(1)
|
||||
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
|
||||
|
||||
@@ -68,10 +68,44 @@ def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str):
|
||||
load_source_stack(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", ["catalogs:\n", "catalogs: []\n"])
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
"- a\n- b\n", # truthy list
|
||||
"42\n", # truthy scalar
|
||||
"[]\n", # falsy list
|
||||
"false\n", # falsy bool
|
||||
"0\n", # falsy int
|
||||
"''\n", # falsy empty string
|
||||
"null\n", # explicit null scalar (safe_load -> None, but a real node)
|
||||
"~\n", # explicit null scalar (alt spelling)
|
||||
],
|
||||
)
|
||||
def test_toplevel_non_mapping_raises(tmp_path: Path, body: str):
|
||||
"""A top-level non-mapping bundle-catalogs.yml (list/scalar/null) must raise,
|
||||
matching the sibling reader (catalog_config._read) — not silently fall back
|
||||
to the built-in default stack. This includes FALSY non-mappings ([], false,
|
||||
0, '') and an explicit null (null/~); the shared load_yaml would coerce those
|
||||
to {} and hide them, so it distinguishes them from a truly empty document."""
|
||||
make_project(tmp_path)
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
|
||||
load_source_stack(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
"catalogs:\n", # present key, null value
|
||||
"catalogs: []\n", # present key, empty list
|
||||
"", # truly empty document
|
||||
"# only a comment\n", # comment-only == empty document
|
||||
],
|
||||
)
|
||||
def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str):
|
||||
"""An absent (``None``) or empty-list ``catalogs:`` is valid: it contributes
|
||||
no project sources and falls back to the built-in default stack."""
|
||||
"""An empty document, comment-only file, or absent/empty-list ``catalogs:``
|
||||
is valid: it contributes no project sources and falls back to the built-in
|
||||
default stack (must not be confused with an explicit top-level null)."""
|
||||
make_project(tmp_path)
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
|
||||
# Does not raise; still yields the built-in defaults.
|
||||
@@ -207,3 +241,17 @@ def test_catalog_entry_rejects_non_mapping_provides():
|
||||
data["provides"] = "extensions"
|
||||
with pytest.raises(BundlerError, match="'provides' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["requires", "provides"])
|
||||
@pytest.mark.parametrize("bad", [[], "", 0, False])
|
||||
def test_catalog_entry_rejects_falsy_non_mapping(field, bad):
|
||||
# `or {}` coerced a FALSY non-mapping ([], '', 0, False) to {} before the
|
||||
# isinstance guard, silently accepting a corrupt entry; only absent/None
|
||||
# means "not present". Mirrors the manifest requires/provides guard.
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data[field] = bad
|
||||
with pytest.raises(BundlerError, match=f"'{field}' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
@@ -1167,6 +1167,295 @@ class TestAutoCommitBash:
|
||||
assert "\u2713" not in result.stderr, "Must not use Unicode checkmark"
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestAutoCommitBashCommitStyle:
|
||||
"""Tests for the `commit_style: conventional` option (issue #3390)."""
|
||||
|
||||
def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
|
||||
"""Omitting commit_style preserves the fixed/static message behavior."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
|
||||
"""commit_style: fixed (explicit) still uses the configured static message,
|
||||
not just the absent-key default."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: fixed\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: this should be ignored"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
assert "this should be ignored" not in log.stdout
|
||||
|
||||
def test_conventional_message_file_used(self, tmp_path: Path):
|
||||
"""--message-file reads the generated message from a file instead of argv,
|
||||
avoiding shell interpolation of agent-controlled content."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
# Write the message file inside the worktree (as an agent invoking
|
||||
# this from a working directory tool naturally would) to exercise
|
||||
# the exclusion-from-staging behavior below.
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add $(dangerous) `injection` test" in log.stdout
|
||||
|
||||
def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
|
||||
"""--message-file written inside the worktree must never be staged or
|
||||
committed itself, and must be removed once its content is consumed."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: real change\n")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert not msg_file.exists()
|
||||
show = subprocess.run(
|
||||
["git", "show", "--stat", "--oneline", "HEAD"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "new-file.txt" in show.stdout
|
||||
assert "commit-msg.txt" not in show.stdout
|
||||
|
||||
def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
|
||||
"""If the message file is the only 'change' in the worktree (no real
|
||||
edits), auto-commit must still report no changes rather than
|
||||
committing the transport file by itself."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
# Baseline-commit the scaffolding (and config) so the tree is
|
||||
# genuinely clean before introducing the message file — otherwise
|
||||
# the untracked scaffold files would mask whether the message file
|
||||
# alone is enough to (incorrectly) trigger a commit.
|
||||
subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "baseline"],
|
||||
cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
|
||||
)
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: no real changes\n")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "No changes to commit" in result.stderr
|
||||
assert not msg_file.exists()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "baseline" in log.stdout
|
||||
|
||||
def test_message_file_missing_fails(self, tmp_path: Path):
|
||||
"""--message-file pointing at a nonexistent file fails clearly."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
missing = tmp_path / "does-not-exist.txt"
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(missing)
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "not found" in result.stderr.lower()
|
||||
|
||||
def test_conventional_uses_generated_message(self, tmp_path: Path):
|
||||
"""commit_style: conventional uses the generated_message argument as the commit message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_without_generated_message_fails(self, tmp_path: Path):
|
||||
"""commit_style: conventional fails clearly instead of falling back to the fixed message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode != 0
|
||||
assert "conventional" in result.stderr.lower()
|
||||
|
||||
# No commit should have been made, and the fixed message must not be used.
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_skips_cleanly_with_no_changes(self, tmp_path: Path):
|
||||
"""No pending changes short-circuits before the missing-message failure."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
subprocess.run(["git", "add", "."], cwd=project, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
|
||||
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
assert "No changes" in result.stderr
|
||||
|
||||
def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
|
||||
"""commit_style value with a trailing YAML inline comment is still recognized."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional # team standard\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
|
||||
"""An unrecognized commit_style value falls back to 'fixed' with a warning,
|
||||
instead of silently mis-parsing or crashing."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventonal\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
assert "unknown commit_style" in result.stderr.lower()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
def test_duplicate_commit_style_lines_use_first_match(self, tmp_path: Path):
|
||||
"""A config with multiple `commit_style:` lines (e.g. from a bad merge) uses only
|
||||
the first match instead of concatenating values into an unrecognized style."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"commit_style: fixed\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "unknown commit_style" not in result.stderr.lower()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestAutoCommitPowerShell:
|
||||
def test_disabled_by_default(self, tmp_path: Path):
|
||||
@@ -1227,6 +1516,271 @@ class TestAutoCommitPowerShell:
|
||||
assert "\u2713" not in result.stdout, "Must not use Unicode checkmark"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestAutoCommitPowerShellCommitStyle:
|
||||
"""Tests for the `commit_style: conventional` option (issue #3390)."""
|
||||
|
||||
def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
|
||||
"""Omitting commit_style preserves the fixed/static message behavior."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
|
||||
"""commit_style: fixed (explicit) still uses the configured static message,
|
||||
not just the absent-key default."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: fixed\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "feat: this should be ignored"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
assert "this should be ignored" not in log.stdout
|
||||
|
||||
def test_conventional_message_file_used(self, tmp_path: Path):
|
||||
"""-MessageFile reads the generated message from a file instead of argv,
|
||||
avoiding shell interpolation of agent-controlled content."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add $(dangerous) `injection` test" in log.stdout
|
||||
|
||||
def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
|
||||
"""-MessageFile written inside the worktree must never be staged or
|
||||
committed itself, and must be removed once its content is consumed."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: real change\n")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert not msg_file.exists()
|
||||
show = subprocess.run(
|
||||
["git", "show", "--stat", "--oneline", "HEAD"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "new-file.txt" in show.stdout
|
||||
assert "commit-msg.txt" not in show.stdout
|
||||
|
||||
def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
|
||||
"""If the message file is the only 'change' in the worktree (no real
|
||||
edits), auto-commit must still report no changes rather than
|
||||
committing the transport file by itself."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
# Baseline-commit the scaffolding (and config) so the tree is
|
||||
# genuinely clean before introducing the message file — otherwise
|
||||
# the untracked scaffold files would mask whether the message file
|
||||
# alone is enough to (incorrectly) trigger a commit.
|
||||
subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "baseline"],
|
||||
cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
|
||||
)
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: no real changes\n")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "No changes to commit" in (result.stdout + result.stderr)
|
||||
assert not msg_file.exists()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "baseline" in log.stdout
|
||||
|
||||
def test_message_file_missing_fails(self, tmp_path: Path):
|
||||
"""-MessageFile pointing at a nonexistent file fails clearly."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
missing = tmp_path / "does-not-exist.txt"
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(missing)
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "not found" in (result.stdout + result.stderr).lower()
|
||||
|
||||
def test_conventional_uses_generated_message(self, tmp_path: Path):
|
||||
"""commit_style: conventional uses the generated_message argument as the commit message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_without_generated_message_fails(self, tmp_path: Path):
|
||||
"""commit_style: conventional fails clearly instead of falling back to the fixed message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode != 0
|
||||
# Write-Warning output placement (stdout vs. stderr) is not deterministic
|
||||
# across pwsh versions/platforms, so check the combined stream like the
|
||||
# other pwsh tests above (e.g. test_not_a_repo_still_detected_with_autocrlf).
|
||||
combined = result.stdout + result.stderr
|
||||
assert "conventional" in combined.lower()
|
||||
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_skips_cleanly_with_no_changes(self, tmp_path: Path):
|
||||
"""No pending changes short-circuits before the missing-message failure."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
subprocess.run(["git", "add", "."], cwd=project, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
|
||||
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
combined = result.stdout + result.stderr
|
||||
assert "No changes" in combined
|
||||
|
||||
def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
|
||||
"""commit_style value with a trailing YAML inline comment is still recognized."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional # team standard\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
|
||||
"""An unrecognized commit_style value falls back to 'fixed' with a warning,
|
||||
instead of silently mis-parsing or crashing."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventonal\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
combined = (result.stdout or "") + (result.stderr or "")
|
||||
assert "unknown commit_style" in combined.lower()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
|
||||
# ── auto-commit.ps1 CRLF warning tests (issue #2253) ────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -491,3 +491,16 @@ def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
|
||||
assert ("extensions", "ext-b") not in {
|
||||
(c.kind, c.id) for c in rec.contributed_components
|
||||
}
|
||||
|
||||
|
||||
def test_install_result_changed_reports_uninstalled():
|
||||
# A `bundle update` that only DROPS components (new manifest reduces
|
||||
# provides) populates uninstalled with nothing installed/refreshed; that is
|
||||
# still a mutating change, so `changed` must be True — not a false no-op.
|
||||
from specify_cli.bundler.services.installer import InstallResult
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
|
||||
result = InstallResult(bundle_id="x")
|
||||
assert result.changed is False # empty == no change
|
||||
result.uninstalled.append(ComponentRef(kind="presets", id="p1"))
|
||||
assert result.changed is True
|
||||
|
||||
@@ -422,7 +422,12 @@ 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 / ".kilocode" / "workflows" / "speckit.my-extension.example.md"
|
||||
kilocode_cmd = (
|
||||
tmp_path
|
||||
/ ".kilo"
|
||||
/ "commands"
|
||||
/ "speckit.my-extension.example.md"
|
||||
)
|
||||
assert kilocode_cmd.exists()
|
||||
|
||||
content = kilocode_cmd.read_text(encoding="utf-8")
|
||||
|
||||
@@ -1,10 +1,76 @@
|
||||
"""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 = ".kilocode/"
|
||||
COMMANDS_SUBDIR = "workflows"
|
||||
REGISTRAR_DIR = ".kilocode/workflows"
|
||||
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()
|
||||
|
||||
@@ -11,6 +11,12 @@ class TestOmpIntegration(MarkdownIntegrationTests):
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".omp/commands"
|
||||
|
||||
def test_multi_install_safe(self):
|
||||
# Omp writes only to its isolated, static root .omp/commands, 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
|
||||
|
||||
def test_build_exec_args_uses_omp_json_mode(self):
|
||||
i = get_integration(self.KEY)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for PiIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
@@ -8,3 +10,9 @@ 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
|
||||
|
||||
@@ -69,6 +69,26 @@ 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)
|
||||
@@ -2493,6 +2513,206 @@ 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).
|
||||
|
||||
@@ -2936,6 +3156,7 @@ class TestIntegrationUpgrade:
|
||||
"""
|
||||
from specify_cli.integrations._migrate_commands import (
|
||||
_PresetRegistryUnreadableError,
|
||||
_installed_command_presets_affecting_agent,
|
||||
_installed_presets_affecting_agent,
|
||||
)
|
||||
|
||||
@@ -3001,6 +3222,9 @@ class TestIntegrationUpgrade:
|
||||
"p1",
|
||||
"p3",
|
||||
]
|
||||
assert _installed_command_presets_affecting_agent(project, "bob") == [
|
||||
"p1"
|
||||
]
|
||||
|
||||
|
||||
# ── Full lifecycle ───────────────────────────────────────────────────
|
||||
|
||||
@@ -92,6 +92,27 @@ 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({
|
||||
@@ -136,6 +157,26 @@ 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({
|
||||
|
||||
@@ -53,6 +53,60 @@ 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",
|
||||
@@ -355,7 +409,7 @@ def test_python_missing_template_warning_matches_bash(
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_existing_directory_error_matches_bash(
|
||||
def test_python_existing_prefix_auto_correct_matches_bash(
|
||||
repo_pair: tuple[Path, Path],
|
||||
) -> None:
|
||||
repo_a, repo_b = repo_pair
|
||||
@@ -377,8 +431,8 @@ def test_python_existing_directory_error_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 == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert json_stdout(py)["FEATURE_NUM"] == json_stdout(bash)["FEATURE_NUM"] == "002"
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
@@ -531,6 +585,11 @@ 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", ""]
|
||||
@@ -819,19 +878,92 @@ 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_existing_directory_failure_diagnostics(repo: Path) -> None:
|
||||
(repo / "specs" / "001-x").mkdir(parents=True)
|
||||
expected = (
|
||||
"Error: Feature directory '<REPO>/specs/001-x' already exists. "
|
||||
"Please use a different feature name or specify a different number "
|
||||
"with --number."
|
||||
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"
|
||||
)
|
||||
|
||||
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 == 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
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
|
||||
@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:
|
||||
(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,
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
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
|
||||
|
||||
@@ -163,6 +163,58 @@ def _create_unicode_extension_dir(temp_dir: Path, ext_id: str = "uni-ext") -> Pa
|
||||
return ext_dir
|
||||
|
||||
|
||||
def _create_dashed_description_extension_dir(
|
||||
temp_dir: Path, ext_id: str = "dash-ext"
|
||||
) -> Path:
|
||||
"""Create an extension whose command description contains a ``---`` run.
|
||||
|
||||
A ``---`` inside the description survives into the generated SKILL.md
|
||||
frontmatter and exercises the delimiter-line parsing used when reading
|
||||
metadata.source back during removal (regression guard for the
|
||||
split("---", 2) substring bug, mirroring #3590).
|
||||
"""
|
||||
ext_dir = temp_dir / ext_id
|
||||
ext_dir.mkdir()
|
||||
description = "Separate sections with --- markers"
|
||||
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"extension": {
|
||||
"id": ext_id,
|
||||
"name": "Dashed Extension",
|
||||
"version": "1.0.0",
|
||||
"description": description,
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"commands": [
|
||||
{
|
||||
"name": f"speckit.{ext_id}.hello",
|
||||
"file": "commands/hello.md",
|
||||
"description": description,
|
||||
},
|
||||
]
|
||||
},
|
||||
}
|
||||
|
||||
with open(ext_dir / "extension.yml", "w", encoding="utf-8") as f:
|
||||
yaml.safe_dump(manifest_data, f, allow_unicode=True)
|
||||
|
||||
commands_dir = ext_dir / "commands"
|
||||
commands_dir.mkdir()
|
||||
(commands_dir / "hello.md").write_text(
|
||||
"---\n"
|
||||
f'description: "{description}"\n'
|
||||
"---\n"
|
||||
"\n"
|
||||
"# Hello\n"
|
||||
"\n"
|
||||
"Body.\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
return ext_dir
|
||||
|
||||
|
||||
def _can_create_symlink(temp_dir: Path) -> bool:
|
||||
"""Return True when the current platform/user can create file symlinks."""
|
||||
target = temp_dir / "symlink-target.txt"
|
||||
@@ -1658,6 +1710,65 @@ class TestExtensionSkillUnregistration:
|
||||
assert not (skills_dir / "speckit-test-ext-hello").exists()
|
||||
assert not (skills_dir / "speckit-test-ext-world").exists()
|
||||
|
||||
def test_skills_removed_when_description_contains_dashes(
|
||||
self, skills_project, temp_dir
|
||||
):
|
||||
"""A ``---`` in the command description must not orphan the skill dir.
|
||||
|
||||
The removal safety check reads metadata.source back from the generated
|
||||
SKILL.md. A raw ``split("---", 2)`` stopped at the ``---`` embedded in
|
||||
the description, so metadata.source parsed empty, the skill looked
|
||||
unrelated, and its directory was left behind. Regression guard for the
|
||||
delimiter-line fix (mirrors #3590).
|
||||
"""
|
||||
project_dir, skills_dir = skills_project
|
||||
ext_dir = _create_dashed_description_extension_dir(temp_dir)
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_dir = skills_dir / "speckit-dash-ext-hello"
|
||||
skill_md = skill_dir / "SKILL.md"
|
||||
assert skill_md.exists()
|
||||
# The dashed description must have survived into the frontmatter.
|
||||
assert "--- markers" in skill_md.read_text(encoding="utf-8")
|
||||
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
|
||||
# The extension's own skill must be recognised and removed, not orphaned.
|
||||
assert not skill_dir.exists()
|
||||
|
||||
def test_skills_removed_with_dashes_via_fallback_scan(
|
||||
self, skills_project, temp_dir
|
||||
):
|
||||
"""Same ``---`` guard, but exercised through the fallback scan branch.
|
||||
|
||||
The fast path resolves the skills dir from init-options; the fallback
|
||||
branch scans every candidate agent dir when that resolution returns
|
||||
None, and it re-reads metadata.source with an independently duplicated
|
||||
parser. Deleting init-options.json after install forces removal down
|
||||
the fallback path so a substring-split regression there is caught too.
|
||||
"""
|
||||
project_dir, skills_dir = skills_project
|
||||
ext_dir = _create_dashed_description_extension_dir(temp_dir)
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_directory(
|
||||
ext_dir, "0.1.0", register_commands=False
|
||||
)
|
||||
|
||||
skill_dir = skills_dir / "speckit-dash-ext-hello"
|
||||
assert (skill_dir / "SKILL.md").exists()
|
||||
|
||||
# Drop init-options so _get_skills_dir() returns None and removal takes
|
||||
# the fallback directory-scan branch instead of the fast path.
|
||||
(project_dir / ".specify" / "init-options.json").unlink()
|
||||
|
||||
result = manager.remove(manifest.id, keep_config=False)
|
||||
assert result is True
|
||||
assert not skill_dir.exists()
|
||||
|
||||
def test_other_skills_preserved_on_remove(self, skills_project, extension_dir):
|
||||
"""Non-extension skills should not be affected by extension removal."""
|
||||
project_dir, skills_dir = skills_project
|
||||
|
||||
@@ -6607,6 +6607,80 @@ class TestExtensionAddCLI:
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Invalid URL" in plain
|
||||
|
||||
def test_add_from_bracketed_non_ip_url_exits_cleanly(self, tmp_path):
|
||||
"""A bracketed-but-invalid IPv6 host must produce a clean error, not a
|
||||
ValueError traceback. "https://[not-an-ip]/ext.zip" is a malformed
|
||||
authority that raises ValueError during URL validation; the try/except
|
||||
guard around parsing and the .hostname read must turn that into a clean
|
||||
"Invalid URL" message.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://[not-an-ip]/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Invalid URL" in plain
|
||||
|
||||
def test_add_from_url_lazy_hostname_valueerror_exits_cleanly(self, tmp_path, monkeypatch):
|
||||
"""Synthetic defensive coverage: monkeypatch urlparse() to return an
|
||||
object whose .hostname raises ValueError lazily. This does not reproduce
|
||||
any specific CPython behavior -- it just exercises the case where the
|
||||
ValueError surfaces on the .hostname read rather than at parse time, so a
|
||||
raw ValueError would leak if .hostname were read outside the try/except.
|
||||
"""
|
||||
import urllib.parse
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
real_urlparse = urllib.parse.urlparse
|
||||
|
||||
class _LazyHostnameRaiser:
|
||||
def __init__(self, parsed):
|
||||
self._parsed = parsed
|
||||
|
||||
@property
|
||||
def hostname(self):
|
||||
raise ValueError("simulated lazy IPv6 hostname failure")
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._parsed, name)
|
||||
|
||||
def _fake_urlparse(url, *args, **kwargs):
|
||||
return _LazyHostnameRaiser(real_urlparse(url, *args, **kwargs))
|
||||
|
||||
monkeypatch.setattr(urllib.parse, "urlparse", _fake_urlparse)
|
||||
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert "Invalid URL" in strip_ansi(result.output)
|
||||
|
||||
def test_add_status_escapes_extension_markup(self, tmp_path):
|
||||
"""User-controlled extension names must not be parsed as Rich markup."""
|
||||
from rich.markup import escape as escape_markup
|
||||
@@ -9010,3 +9084,34 @@ class TestConfigManagerCrossExtensionEnvLeak:
|
||||
# Must not raise; must fall back to the "no siblings" path.
|
||||
cfg = ConfigManager(tmp_path, "testext")._get_env_config()
|
||||
assert cfg == {"url": "v"}
|
||||
|
||||
|
||||
def test_forge_extension_install_listing_hyphenates_command_names(
|
||||
extension_dir, project_dir
|
||||
):
|
||||
"""The post-install 'Provided commands' listing must show hyphenated
|
||||
/speckit-<name> command names for a Forge project (Forge registers
|
||||
hyphenated names), mirroring the existing Cline handling."""
|
||||
import json
|
||||
import os
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.write_text(json.dumps({"ai": "forge", "script": "sh"}))
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project_dir)
|
||||
result = CliRunner().invoke(
|
||||
app, ["extension", "add", str(extension_dir), "--dev"]
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# Forge registers hyphenated command names, so the summary must match.
|
||||
assert "speckit-test-ext-hello" in result.output
|
||||
assert "speckit.test-ext.hello" not in result.output
|
||||
|
||||
@@ -292,6 +292,23 @@ 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
|
||||
|
||||
@@ -5605,6 +5605,58 @@ class TestBundledPresetLocator:
|
||||
assert "Invalid URL" in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_bracketed_non_ip_url_exits_cleanly(self, project_dir):
|
||||
"""A bracketed-but-invalid IPv6 host in --from must exit cleanly.
|
||||
|
||||
"https://[not-an-ip]/preset.zip" is a malformed authority that raises
|
||||
ValueError during URL validation; the try/except guard around parsing
|
||||
and the .hostname read must turn that into a clean "Invalid URL" message.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url") as open_url:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["preset", "add", "--from", "https://[not-an-ip]/preset.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
output = strip_ansi(result.output)
|
||||
assert "Invalid URL" in output
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_from_url_out_of_range_port_exits_cleanly(self, project_dir):
|
||||
"""An out-of-range port raises ValueError lazily on .port access.
|
||||
|
||||
The up-front guard reads ``_parsed.port`` (urllib validates the port
|
||||
range/syntax there) inside its try/except, so "https://example.com:99999/
|
||||
preset.zip" must produce a clean "Invalid URL" message rather than
|
||||
leaking a raw ValueError traceback past the CLI.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.authentication.http.open_url") as open_url:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["preset", "add", "--from", "https://example.com:99999/preset.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert result.exception is None or isinstance(result.exception, SystemExit)
|
||||
assert "Invalid URL" in strip_ansi(result.output)
|
||||
open_url.assert_not_called()
|
||||
|
||||
def test_preset_add_bracketed_host_download_url_exits_cleanly(self, project_dir):
|
||||
"""A catalog download_url with a bracketed non-IP host must render cleanly.
|
||||
|
||||
|
||||
@@ -288,6 +288,50 @@ 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):
|
||||
@@ -332,6 +376,50 @@ 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
|
||||
@@ -531,14 +619,15 @@ class TestAllowExistingBranch:
|
||||
assert feature_dir.is_dir()
|
||||
assert (feature_dir / "spec.md").exists()
|
||||
|
||||
def test_without_flag_still_errors(self, git_repo: Path):
|
||||
"""T009: Existing feature directories still fail without the flag."""
|
||||
def test_without_flag_auto_corrects_existing_prefix(self, git_repo: Path):
|
||||
"""T009: Existing prefix advances when exact reuse is not allowed."""
|
||||
(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, "should fail without --allow-existing-branch"
|
||||
assert "already exists" in result.stderr
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert (git_repo / "specs" / "008-no-flag").is_dir()
|
||||
assert "using 008 instead" 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."""
|
||||
@@ -598,6 +687,26 @@ 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,
|
||||
|
||||
@@ -9,7 +9,13 @@ import pytest
|
||||
from specify_cli import run_command
|
||||
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
@@ -686,6 +686,28 @@ class TestExpressions:
|
||||
):
|
||||
evaluate_expression("{{ inputs.tags | map }}", ctx)
|
||||
|
||||
def test_filter_call_with_trailing_tokens_fails_loudly(self):
|
||||
# A trailing operator/token after a filter's closing paren must not be
|
||||
# silently discarded (the parser used an unanchored regex). It must
|
||||
# fall through to the "unsupported form" ValueError, like the from_json
|
||||
# branch's strict trailing-token handling.
|
||||
import pytest
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
# A comparison after a filter (binds looser than the pipe) was dropped,
|
||||
# so `default('7') > '5'` silently returned '7'.
|
||||
with pytest.raises(ValueError, match="unsupported form"):
|
||||
evaluate_expression(
|
||||
"{{ inputs.missing | default('7') > '5' }}", StepContext(inputs={})
|
||||
)
|
||||
# Trailing garbage after a valid filter call.
|
||||
with pytest.raises(ValueError, match="unsupported form"):
|
||||
evaluate_expression(
|
||||
"{{ inputs.tags | join(',') extra }}",
|
||||
StepContext(inputs={"tags": ["a", "b"]}),
|
||||
)
|
||||
|
||||
def test_chained_filters_apply_left_to_right(self):
|
||||
# Filters chain: each filter's result feeds the next. `map` yields a
|
||||
# list and `join` is the only filter that renders a list to a string,
|
||||
@@ -3590,6 +3612,23 @@ 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
|
||||
|
||||
@@ -7860,6 +7899,37 @@ class TestWorkflowAddCaseInsensitiveSuffix:
|
||||
assert "installed" in result.output
|
||||
|
||||
|
||||
class TestWorkflowInfoStepGraph:
|
||||
"""`workflow info` must render each step as `→ <id> [<type>]` with LITERAL
|
||||
brackets. Rich parses an unescaped `[<type>]` as a style tag and silently
|
||||
swallows it, so the step type would vanish from the output."""
|
||||
|
||||
def test_step_type_rendered_in_literal_brackets(self, temp_dir, monkeypatch):
|
||||
import types
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
|
||||
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
|
||||
|
||||
fake = types.SimpleNamespace(
|
||||
name="My WF", id="my-wf", version="1.0.0", author="", description="",
|
||||
default_integration=None, inputs={},
|
||||
steps=[{"id": "step-one", "type": "gate"}],
|
||||
)
|
||||
monkeypatch.setattr(WorkflowEngine, "load_workflow", lambda self, wid: fake)
|
||||
monkeypatch.chdir(temp_dir)
|
||||
|
||||
result = CliRunner().invoke(app, ["workflow", "info", "my-wf"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "step-one" in result.output
|
||||
# The step type must survive as a literal bracketed token, not be eaten
|
||||
# by Rich as an unknown style tag.
|
||||
assert "[gate]" in result.output
|
||||
|
||||
|
||||
class TestWorkflowAddSymlinkGuard:
|
||||
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
|
||||
@@ -154,6 +154,20 @@ def test_read_rejects_non_mapping_top_level(tmp_path: Path):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n", "null\n", "~\n"])
|
||||
def test_read_rejects_falsy_non_mapping_top_level(tmp_path: Path, body: str):
|
||||
# A FALSY non-mapping top level ([], false, 0, '') OR an explicit null
|
||||
# (null/~) must raise like a truthy one. safe_load coerces these to
|
||||
# None/{}, so load_yaml distinguishes them from a truly empty document —
|
||||
# staying consistent with models/catalog._merge_config.
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
cc._config_path(project).write_text(body, encoding="utf-8")
|
||||
|
||||
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
|
||||
cc._read(project)
|
||||
|
||||
|
||||
def test_read_rejects_unknown_schema_version(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
|
||||
@@ -45,6 +45,27 @@ def test_load_missing_file_returns_empty(tmp_path: Path):
|
||||
assert load_records(tmp_path) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, False, "", {}])
|
||||
def test_load_records_rejects_falsy_non_list_bundles(tmp_path: Path, bad):
|
||||
# `data.get("bundles") or []` coerced a FALSY non-list (0, '', False, {})
|
||||
# to [] before the isinstance guard, silently treating a corrupt records
|
||||
# file as "no bundles". Only an absent/None value means empty.
|
||||
(tmp_path / ".specify").mkdir()
|
||||
records_path(tmp_path).write_text(
|
||||
json.dumps({"schema_version": "1.0", "bundles": bad}), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(BundlerError, match="'bundles' must be a list"):
|
||||
load_records(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("bad", [0, False, "", {}])
|
||||
def test_from_dict_rejects_falsy_non_list_contributed_components(bad):
|
||||
# Same falsy-coercion hole for a record's 'contributed_components'.
|
||||
data = {"bundle_id": "a", "version": "1.0.0", "contributed_components": bad}
|
||||
with pytest.raises(BundlerError, match="'contributed_components' must be a list"):
|
||||
InstalledBundleRecord.from_dict(data)
|
||||
|
||||
|
||||
def test_corrupt_priority_raises_actionable_error(tmp_path: Path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
rec = _record("a", [("presets", "p1")])
|
||||
|
||||
@@ -280,7 +280,8 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
|
||||
@@ -111,10 +111,22 @@ Run a shell command and capture output:
|
||||
```yaml
|
||||
- id: run-tests
|
||||
type: shell
|
||||
run: "cd {{ inputs.project_dir }} && npm test"
|
||||
run: "npm test" # runs from the project root; no interpolation needed
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user