Compare commits

..
1 Commits
Author SHA1 Message Date
github-actions[bot] 7de4e943f5 chore: bump version to 0.13.3 2026-07-22 12:13:32 +00:00
137 changed files with 721 additions and 5569 deletions
+1 -2
View File
@@ -65,8 +65,7 @@
},
"chat.tools.terminal.autoApprove": {
".specify/scripts/bash/": true,
".specify/scripts/powershell/": true,
".specify/scripts/python/": true
".specify/scripts/powershell/": true
}
}
}
-11
View File
@@ -97,17 +97,6 @@ echo -e "\n🤖 Installing CodeBuddy CLI..."
run_command "npm install -g @tencent-ai/codebuddy-code@latest"
echo "✅ Done"
echo -e "\n🤖 Installing Factory Droid CLI..."
run_command "npm install -g droid@latest"
if ! command -v droid >/dev/null 2>&1; then
echo -e "\033[0;31m[ERROR] Droid CLI installation did not create 'droid' in PATH.\033[0m" >&2
exit 1
fi
run_command "droid --version > /dev/null"
echo "✅ Done"
# Installing UV (Python package manager)
echo -e "\n🐍 Installing UV - Python Package Manager..."
run_command "pipx install uv"
+1 -1
View File
@@ -8,7 +8,7 @@ body:
value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
- type: input
id: agent-name
-1
View File
@@ -71,7 +71,6 @@ body:
- Codex CLI
- Cursor
- Devin for Terminal
- Factory Droid
- Firebender
- Forge
- Gemini CLI
@@ -65,7 +65,6 @@ body:
- Codex CLI
- Cursor
- Devin for Terminal
- Factory Droid
- Firebender
- Forge
- Gemini CLI
+8 -8
View File
@@ -33,10 +33,10 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
# - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
#
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
fetch-depth: 0
@@ -1352,7 +1352,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1419,7 +1419,7 @@ jobs:
mkdir -p /tmp/gh-aw/threat-detection
touch /tmp/gh-aw/threat-detection/detection.log
- name: Setup Node.js
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
with:
node-version: '24'
package-manager-cache: false
@@ -1678,7 +1678,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1686,7 +1686,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+6 -6
View File
@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -1332,7 +1332,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1658,7 +1658,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1666,7 +1666,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+6 -6
View File
@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -1332,7 +1332,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1658,7 +1658,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1666,7 +1666,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+4 -4
View File
@@ -32,7 +32,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -161,7 +161,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: |
@@ -430,7 +430,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -1277,7 +1277,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# --- Threat Detection ---
+6 -6
View File
@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -1338,7 +1338,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1664,7 +1664,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1672,7 +1672,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+4 -4
View File
@@ -32,7 +32,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -161,7 +161,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
sparse-checkout: |
@@ -431,7 +431,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
fetch-depth: 0
@@ -1299,7 +1299,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
persist-credentials: false
# --- Threat Detection ---
+3 -3
View File
@@ -19,14 +19,14 @@ jobs:
language: [ 'actions', 'python' ]
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Initialize CodeQL
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4
with:
category: "/language:${{ matrix.language }}"
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0 # Fetch all history for git info
+3 -3
View File
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 1
@@ -37,7 +37,7 @@ jobs:
fi
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
with:
globs: |
'**/*.md'
@@ -47,7 +47,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
# shellcheck is preinstalled on ubuntu-latest runners.
# Start at --severity=error to block real bugs without flagging style
+3 -3
View File
@@ -27,12 +27,12 @@ jobs:
fi
- name: Checkout release tag
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
ref: refs/tags/${{ inputs.tag }}
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -74,7 +74,7 @@ jobs:
path: dist/
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Publish to PyPI
run: uv publish
+1 -1
View File
@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}
+1 -1
View File
@@ -12,7 +12,7 @@ jobs:
contents: write
steps:
- name: Checkout repository
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
+4 -4
View File
@@ -19,12 +19,12 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
with:
fetch-depth: 0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -52,10 +52,10 @@ jobs:
python-version: ["3.11", "3.12", "3.13", "3.14"]
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
+5 -5
View File
@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -24,7 +24,7 @@ jobs:
python-version: "3.14"
- name: Run ruff check
run: uvx ruff@0.15.0 check src tests
run: uvx ruff 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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
- name: Install uv
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
+7 -47
View File
@@ -64,14 +64,13 @@ class KilocodeIntegration(MarkdownIntegration):
key = "kilocode"
config = {
"name": "Kilo Code",
"folder": ".kilo/",
"commands_subdir": "commands",
"folder": ".kilocode/",
"commands_subdir": "workflows",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".kilo/commands",
"legacy_dir": ".kilocode/workflows",
"dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
@@ -188,7 +187,7 @@ context_markers:
end: "<!-- SPECKIT END -->"
```
- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh`, `.ps1`, and `extensions/agent-context/scripts/python/update_agent_context.py`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension.
- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension.
- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly.
Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run.
@@ -202,8 +201,8 @@ Only add custom setup logic when the agent needs non-standard behavior. Integrat
specify init my-project --integration <key>
# Verify files were created in the commands directory configured by
# config["folder"] + config["commands_subdir"] (for example, .kilo/commands/)
ls -R my-project/.kilo/commands/
# config["folder"] + config["commands_subdir"] (for example, .kilocode/workflows/)
ls -R my-project/.kilocode/workflows/
# Uninstall cleanly
cd my-project && specify integration uninstall <key>
@@ -269,25 +268,6 @@ echo "✅ Done"
## Command File Formats
### Script References (`scripts:` frontmatter)
Core command templates (`templates/commands/*.md`) that invoke a helper script declare it in a `scripts:` frontmatter block with one line per supported script type. The `{SCRIPT}` placeholder in the command body is replaced at install time with the entry matching the project's selected script type (`--script sh|ps|py`):
```yaml
scripts:
sh: scripts/bash/setup-plan.sh --json
ps: scripts/powershell/setup-plan.ps1 -Json
py: scripts/python/setup_plan.py --json
```
| Key | Script type | Location |
| ---- | ---------------------- | -------------------------- |
| `sh` | POSIX shell (bash/zsh) | `scripts/bash/*.sh` |
| `ps` | PowerShell | `scripts/powershell/*.ps1` |
| `py` | Python | `scripts/python/*.py` |
All three entries must be present and behaviorally equivalent — agents parse the same stdout contract (`FEATURE_DIR:…`, `AVAILABLE_DOCS:…`, `--json` shapes) regardless of which one runs. (The bundled `agent-context` and `git` extension command templates also invoke helpers but do not yet use `scripts:` frontmatter — see [Script Types and Migration](#script-types-and-migration).)
### Markdown Format
**Standard format:**
@@ -348,29 +328,9 @@ Different agents use different argument placeholders. The placeholder used in co
- **TOML-based**: `{{args}}` (e.g., Gemini)
- **YAML-based**: `{{args}}` (e.g., Goose)
- **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`)
- **Script placeholders**: `{SCRIPT}` (replaced with the resolved command from the template's `scripts:` frontmatter, per the project's `--script sh|ps|py` selection)
- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)
- **Agent placeholders**: `__AGENT__` (replaced with agent name)
## Script Types and Migration
Spec Kit ships every core workflow script in three interchangeable variants — POSIX shell (`sh`), PowerShell (`ps`), and Python (`py`) — selected per project with `specify init --script sh|ps|py`. Each core command template that invokes a helper script carries all three in its `scripts:` frontmatter (templates that don't call a script, e.g. `constitution`/`specify`, have no `scripts:` block); see [Script References](#script-references-scripts-frontmatter).
### Why Python is recommended
- **No extra runtime.** The `specify` CLI is already Python, so the interpreter is guaranteed present — `py` adds no new dependency.
- **Path toward a single source of truth.** The shell variants require paired `.sh` + `.ps1` maintenance and diverge on JSON handling (`jq` vs manual parsing). The Python variant avoids `jq` and is intended to eventually replace that dual-maintenance — but that consolidation has not happened yet: all three variants are still maintained in parallel (see the parity rule below).
- **Parity-tested.** The Python ports are covered by tests — output-parity tests against the shell scripts where the contract is stdout-based, and direct unit tests elsewhere — so the stdout contract agents rely on stays stable.
### Defaults and availability
- `py` is available today for the core command templates (via their `scripts:` frontmatter). The bundled extensions (`agent-context`, `git`) ship Python script variants on disk, but their command templates still hard-code the Bash/PowerShell invocations, so `--script py` does not yet route those extension commands to Python — wiring `py` into the extension command templates is tracked separately.
- Selection is per project: interactive `specify init` prompts for the script type, while non-interactive runs default to a shell variant by OS (`sh` on Linux/macOS, `ps` on Windows). `py` is chosen at the prompt or via `--script py`.
- `sh` and `ps` remain fully supported. Nothing is removed, and `py` is not yet the default.
### Parity rule for contributors
All three script types are first-class: any change to a workflow script must update `sh`, `ps`, and `py` together and keep their tests (parity and unit) green. Making `py` the default and eventually retiring `sh`/`ps` is future work gated on adoption, tracked under the script-unification epic ([#3277](https://github.com/github/spec-kit/issues/3277)) — not something to act on from this doc.
## Special Processing Requirements
Some agents require custom processing beyond the standard template transformations:
-100
View File
@@ -2,106 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.14.2] - 2026-07-24
### Changed
- Update Intake Review Governance preset to v0.1.1 (#3729)
- Update Verify Review Ship extension to v0.3.0 (#3728)
- Update Architecture Guard extension to v1.13.1 (#3724)
- docs(upgrade): Claude Code files live in .claude/skills, not .claude/commands (#3708)
- fix(kilocode): install commands under .kilo/commands (#3672)
- fix(auth): normalize whitespace in auth-config env-var/id references at store time (#3691)
- fix(workflows): guard non-mapping 'inputs:' block in engine._resolve_inputs (#3696)
- Update Intake Authoring Governance preset to v0.2.0 (#3721)
- docs: clarify shell-step interpolation safety (#3719)
- [extension] Add Blueprint Index — Living Architecture Map extension to community catalog (#3718)
- fix(github-http): return None on malformed host in resolve_github_release_asset_api_url (#3715)
- fix(integrations): declare PiIntegration multi_install_safe (#3652)
- harden: remove shell parameter from run_command() (#3716)
- chore(deps): bump github/codeql-action/init from 4.37.1 to 4.37.3 (#3699)
- fix: auto-correct conflicting feature prefixes (#1829)
- chore(deps): bump actions/checkout from 6.0.3 to 7.0.1 (#3703)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#3702)
- chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#3701)
- chore(deps): bump astral-sh/setup-uv from 8.3.2 to 9.0.0 (#3700)
- chore: release 0.14.1, begin 0.14.2.dev0 development (#3698)
## [0.14.1] - 2026-07-23
### Changed
- 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
- docs: add spec-kit-copilot to community friends (#3675)
- fix(integrations): recompute invoke_separator from retained parsed_options (#3664)
- fix(workflows): preserve intra-overlay order for multiple insert_after edits (#3662)
- fix(bundler): reject falsy non-mapping requires/provides in manifest from_dict (#3661)
- fix(bundler): dump_yaml writes literal UTF-8 (allow_unicode=True) (#3660)
- fix(integrations): declare kiro-cli multi-install safe (#3477)
- fix(git-extension): trim trailing whitespace before stripping commit-message quotes (#3673)
- fix(bundler): order bundle members by canonical POSIX arcname (reproducible builds) (#3658)
- fix(integrations): Cline overrides post_process_command_content (correct hook name) (#3657)
- docs(workflows): gate step docstring lists the 'retry' on_reject behaviour (#3656)
- fix: harden bounded reads and redirect validation (#3671)
- fix(packaging): bundle scripts/python into the wheel core_pack (#3665) (#3670)
- fix: bundle scripts/python in wheel so --script py works (#3665) (#3668)
- docs(workflows): init step docstring lists the 'py' script type (#3655)
- fix(integrations): declare LingmaIntegration multi_install_safe (#3654)
- fix: guard constitution command against feature execution (#3646)
- Fix duplicate step numbering in specify command (#3647)
- docs(scripts): document the 'py' script type and sh/ps migration plan (#3284) (#3653)
- harden: bound HTTP reads and enforce strict redirects (#3140)
- chore: release 0.13.4, begin 0.13.5.dev0 development (#3649)
## [0.13.4] - 2026-07-22
### Changed
- docs(concepts): document the spec-of-specs feature breakdown approach (#3648)
- fix(scripts): git-ext PowerShell emits the '# To persist' SPECIFY_FEATURE hint (parity) (#3632)
- fix(integrations): validate cached catalog shape before returning it (#3627)
- fix(bundler): reject non-list 'catalogs' in bundle-catalogs.yml with a clean error (#3623)
- fix(bundler): guard lazy .hostname ValueError in catalog add_source (#3644)
- Add Intake Authoring Governance preset to community catalog (#3643)
- feat: add Factory Droid CLI integration (#822) (#3587)
- docs(installation): document the 'py' (Python) script type (#3640)
- fix(init): show hyphenated /speckit-<name> in Next Steps for Forge projects (#3642)
- fix(extensions): render hyphenated hook invocations for Forge projects (#3641)
- fix(workflows): workflow add detects local YAML files case-insensitively (#3633)
- fix(workflows): list-literal expression ignores trailing/empty commas (#3631)
- fix(workflows): StepRegistry.add tolerates a corrupted non-dict existing entry (#3630)
- fix(bundler): reject non-mapping 'integration' in a bundle manifest (#3629)
- fix(workflows): command/prompt steps fail cleanly on a non-string integration (#3626)
- docs(core): document the 'py' (Python) --script type in the init option table (#3625)
- fix(workflows): gate prompt uses isdecimal() so a superscript digit doesn't crash (#3624)
- fix(integrations): Cline dispatches hyphenated /speckit-<cmd> invocations (#3622)
- docs(upgrade): document integration upgrade / extension update as the project-files upgrade path (#3326)
- chore: release 0.13.3, begin 0.13.4.dev0 development (#3645)
## [0.13.3] - 2026-07-22
### Changed
+1 -2
View File
@@ -36,7 +36,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
| Blueprint Index — Living Architecture Map | A living architecture map for spec-driven projects, kept honest by a deterministic, low-friction, machine-first CI gate (JSON, self-healable) that blocks only when the map contradicts the specs or code. Brownfield or greenfield. | `process` | Read+Write | [spec-kit-blueprint](https://github.com/ogil109/spec-kit-blueprint) |
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) |
@@ -159,7 +158,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
| Verify Review Ship | Adds verify and review quality gates plus transactional merge, cleanup, and delivery summary | `process` | Read+Write | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
| Verify Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
+1 -3
View File
@@ -1,7 +1,7 @@
# Community Friends
> [!NOTE]
> Community projects listed here are independently created and maintained by their respective authors. Unless explicitly marked as a **first-party GitHub project**, they are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion.
> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion.
Community projects that extend, visualize, or build on Spec Kit:
@@ -16,5 +16,3 @@ Community projects that extend, visualize, or build on Spec Kit:
- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace.
- **[spectatui](https://github.com/tinesoft/spectatui)** — A terminal UI (TUI) dashboard for Spec Kit that lets you track features, manage specifications, integrations, presets, workflows, and extensions, and monitor AI agent workflows. Attach to existing AI sessions or launch new ones from your terminal. Keyboard and mouse support. Light/dark theme support. Customizable and performance-oriented. Requires the `specify` CLI in your PATH.
- **[spec-kit-copilot](https://github.com/github/spec-kit-copilot)** — _First-party GitHub project._ A GitHub Copilot **skills plugin** that exposes the Spec Kit `specify` CLI to the Copilot agent in both the Copilot CLI and the GitHub Copilot app. It provides a focused skill per `specify` command group — setup, init, check, extensions, presets, bundles, workflows, workflow steps, and self-upgrade — so you can navigate and drive the entire Spec Kit ecosystem through natural language, letting Copilot decide when and how to run the right `specify` commands on your behalf.
+7 -8
View File
@@ -7,28 +7,27 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| 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) |
| 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) |
| 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, 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) |
| 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) |
| 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 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) |
| 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) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
| Intake Authoring Governance | Governs traceable intake CRUD, bounded public HTTPS sources, and explicitly approved single or series authoring without granting execution authority. | 10 templates, 5 commands, 4 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
| Intake Review Governance | Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
| 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) |
| 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) |
| 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 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) |
| 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) |
| 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) |
+4 -8
View File
@@ -63,14 +63,10 @@ independently specified sub-features. Each sub-feature gets its own
`spec.md`, `plan.md`, and `tasks.md`, and runs through its own
specify/plan/tasks/implement cycle.
This is the "spec of specs" approach: a first pass breaks a massive feature into
smaller, self-contained specs that can each be implemented without overwhelming the
model. It adds the most overhead, so reserve it for features that are too large to
handle any other way.
See [Spec of Specs](spec-of-specs.md) for the full procedure — how to run the
roadmap pass, structure the roadmap artifact, link sub-specs back to it, and a worked
example.
This is the "spec of specs" approach: the first iteration breaks a massive
feature into smaller, self-contained specs that can each be implemented without
overwhelming the model. It adds the most overhead, so reserve it for features
that are too large to handle any other way.
## Which Approach to Choose
-171
View File
@@ -1,171 +0,0 @@
# Spec of Specs
When a feature is too large to run through a single
`/speckit.specify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement`
cycle without the model losing track mid-implementation, you can break it into a
**roadmap** of smaller, independently-specified sub-features. This is the "spec of
specs" approach: one up-front pass decomposes a massive feature into self-contained
specs, and each of those runs through its own specify/plan/tasks/implement cycle.
> **When to reach for this.** Decomposition adds the most overhead of any strategy
> in [Handling Complex Features](complex-features.md). Use it **only when the lighter
> options there are insufficient** — first try limiting how many tasks run per
> `/speckit.implement` invocation, then sub-agent delegation, then a combination.
> Reach for a spec of specs only when even a single phase is too large to handle in
> one run.
The rest of this page describes *how* to do it with the tools you already have. No
new commands or extensions are required.
## The roadmap pass
Before writing any sub-spec, do a single decomposition pass to produce a roadmap.
Treat this as a lightweight planning conversation with your agent, not a full spec:
1. **State the whole feature.** Describe the large feature (the "epic") in a
sentence or two so the agent has the full picture up front.
2. **Identify independent slices.** Ask the agent to propose a small set of
sub-features that each deliver a coherent piece of the epic and can be specified
on their own. Aim for slices that are independently testable — implementing just
one should leave you with something demonstrable.
3. **Draw the boundaries.** For each slice, write one line of intent and an explicit
scope boundary (what is in, what is deferred to a sibling slice). Sharp
boundaries are what keep each sub-spec small enough to fit in context.
4. **Order by dependency.** Note which slices depend on others and sequence them so
prerequisites come first. Slices with no dependency on each other can be built in
any order. To build independent slices in parallel, use separate worktrees so each
run has isolated active-feature state.
5. **Record the result as a roadmap.** Capture the slices in a durable roadmap file
(below) so every later sub-spec can point back to it.
The roadmap is deliberately shallow: it names and orders the sub-features but does
**not** design them. The design happens when each slice runs through its own
`/speckit.specify`.
## The roadmap artifact
The roadmap is an ordinary Markdown file you author and keep under version control —
there is no special tooling behind it. Put it where the sub-specs can find it:
- For a feature-scoped epic: `specs/<epic-slug>/roadmap.md`.
- For a larger, cross-cutting epic: a top-level `ROADMAP.md`.
Each roadmap entry carries a stable id (used later for linking), a name, its intent,
its scope boundary, its dependencies, a status, and — once the sub-spec exists — a
link to it. A minimal template:
```markdown
# Roadmap: <epic name>
<One or two sentences: what the epic is and why it is being decomposed.>
**Status legend**: planned · in-progress · done
| ID | Sub-feature | Intent | Scope boundary | Depends on | Status | Sub-spec |
|----|-------------|--------|----------------|-----------|--------|----------|
| R1 | <name> | <one line> | <in / deferred> | — | planned | — |
| R2 | <name> | <one line> | <in / deferred> | R1 | planned | — |
| R3 | <name> | <one line> | <in / deferred> | R1 | planned | — |
```
Keep the `ID` column immutable once a sub-spec references it — it is the anchor for
traceability. Fill in the `Sub-spec` column with the path to each sub-feature's spec
directory as you create it, and update `Status` as work progresses.
## Specifying each sub-feature
With the roadmap in hand, work through the entries one at a time using the normal
Spec Kit flow — nothing new to learn:
1. Pick the next roadmap entry whose dependencies are already `done` (or have none).
2. Run `/speckit.specify` for just that slice, describing only its intent and scope
from the roadmap entry. Because the slice is bounded, its spec, plan, and tasks
stay well within the context window.
3. Run `/speckit.plan`, `/speckit.tasks`, and `/speckit.implement` for that slice as
usual.
4. Mark the roadmap entry `done` and move to the next one.
Each slice is a complete, independent Spec Kit feature with its own
`spec.md`/`plan.md`/`tasks.md`. The roadmap is what ties them together.
## Linking sub-specs to the roadmap
To keep scope and intent from drifting across separate runs, every sub-spec
references its roadmap entry, and the roadmap links back — a simple, greppable,
bidirectional convention:
- **Sub-spec → roadmap.** In the sub-feature's `spec.md`, name the parent roadmap
and entry id in the `Input` / summary line, for example:
```markdown
**Input**: Parent roadmap: `specs/<epic>/roadmap.md` → entry **R3**. <feature description>
```
- **Roadmap → sub-spec.** In the roadmap table, set the entry's `Sub-spec` column to
the sub-feature's directory, e.g. `specs/<epic>-part-3/`.
Because both directions are plain text, you can trace any sub-spec back to its place
in the epic (and find its siblings) with a quick search — no tooling, no metadata
schema.
## Keeping the roadmap and sub-specs in sync
The roadmap is a living document. As you learn more, keep it and the sub-specs
aligned:
- **Roadmap first, then reconcile.** When scope shifts, update the roadmap entry
first, then update any sub-specs it affects. The roadmap is the source of truth for
how the epic is divided.
- **Respect dependencies and ordering.** If a slice depends on another, build the
prerequisite first and cross-reference the dependent sub-spec so the relationship
is visible from both sides.
- **Recurse when a slice is still too big.** If a sub-feature turns out to be too
large to specify in one cycle, give it its own roadmap and decompose it further —
the same approach applies one level down. Recursion adds overhead, so only go as
deep as the context problem actually requires.
## Worked example
Suppose the epic is **"Add a self-service billing portal"** — far too large for a
single cycle. The roadmap pass breaks it into three independently-specifiable
slices.
`specs/billing-portal/roadmap.md`:
```markdown
# Roadmap: Self-service billing portal
Let customers view invoices, manage payment methods, and change plans without
contacting support. Too large for one cycle, so it is split into independent slices.
**Status legend**: planned · in-progress · done
| ID | Sub-feature | Intent | Scope boundary | Depends on | Status | Sub-spec |
|----|--------------------|------------------------------------------|---------------------------------------------|-----------|---------|----------|
| R1 | Invoice history | Customers view and download past invoices | Read-only; no payment actions | — | done | specs/billing-invoices/ |
| R2 | Payment methods | Add, remove, and set a default card | No plan changes; assumes invoices exist | R1 | in-progress | specs/billing-payment-methods/ |
| R3 | Plan changes | Upgrade/downgrade the subscription plan | Uses R2's default payment method | R1, R2 | planned | — |
```
Each slice is then specified on its own. For example, the **R2** sub-feature's
`spec.md` opens with a back-reference:
```markdown
# Feature Specification: Billing — payment methods
**Input**: Parent roadmap: `specs/billing-portal/roadmap.md` → entry **R2**.
Let customers add, remove, and set a default payment method in the billing portal.
```
From here a reader can trace **R2** back to the roadmap, see that it depends on
**R1** (invoice history, already `done`), and see that **R3** (plan changes) is
waiting on it. Building R1, then R2, then R3 keeps every run small while the roadmap
preserves the shape of the whole epic.
## For automation (optional)
If you would rather automate roadmap capture and consistency checks than maintain
the file by hand, the community-maintained
[Spec Roadmap extension](https://github.com/srobroek/speckit-roadmap) explores that
direction. It is a third-party extension and is not required — the manual convention
above is enough on its own.
+2 -4
View File
@@ -77,9 +77,9 @@ specify init <project_name> --integration pi
specify init <project_name> --integration omp
```
### Specify Script Type (Shell, PowerShell, or Python)
### Specify Script Type (Shell vs PowerShell)
Automation scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants.
All automation scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants.
Auto behavior:
@@ -92,7 +92,6 @@ Force a specific script type:
```bash
specify init <project_name> --script sh
specify init <project_name> --script ps
specify init <project_name> --script py
```
### Ignore Agent Tools Check
@@ -132,7 +131,6 @@ Scripts are installed into a variant subdirectory matching the chosen script typ
- `.specify/scripts/bash/` — contains `.sh` scripts (default on Linux/macOS)
- `.specify/scripts/powershell/` — contains `.ps1` scripts (default on Windows)
- `.specify/scripts/python/` — contains `.py` scripts (chosen with `--script py`; also installs the platform shell fallback)
## Troubleshooting
+2 -2
View File
@@ -2,7 +2,7 @@
This guide shows how to iterate on the `specify` CLI locally without publishing a release or committing to `main` first.
> Scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
> Scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants. The CLI auto-selects based on OS unless you pass `--script sh|ps`.
## 1. Clone and Switch Branches
@@ -189,7 +189,7 @@ rm -rf .venv dist build *.egg-info
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
| Git commands unavailable | Install the git extension with `specify extension add git` |
| Wrong script type downloaded | Pass `--script sh`, `--script ps`, or `--script py` explicitly |
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
## 14. Next Steps
+1 -1
View File
@@ -3,7 +3,7 @@
This guide will help you get started with Spec-Driven Development using Spec Kit. Throughout, we illustrate each step with a running example: **Taskify**, a small team productivity platform.
> [!NOTE]
> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
> Automation scripts are provided as both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on your OS unless you pass `--script sh|ps`.
> [!NOTE]
> Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical.
+1 -1
View File
@@ -12,7 +12,7 @@ specify init [<project_name>]
| ------------------------ | ------------------------------------------------------------------------ |
| `--integration <key>` | AI coding agent integration to use (e.g. `copilot`, `claude`, `gemini`). See the [Integrations reference](integrations.md) for all available keys |
| `--integration-options` | Options for the integration (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--here` | Initialize in the current directory instead of creating a new one |
| `--force` | Force merge/overwrite when initializing in an existing directory |
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |
+25 -29
View File
@@ -15,7 +15,6 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-<command>` |
| [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-<command>` |
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
@@ -25,7 +24,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | Skills-based integration by default; installs skills as `speckit-<command>/SKILL.md` under `.bob/skills/` and invokes them as `/speckit-<command>`. Pass `--integration-options="--legacy-commands"` to scaffold the deprecated Bob 1.x layout (`.bob/commands/*.md`) instead; that flag will be removed in a future release. Existing legacy installs can migrate with `specify integration upgrade bob --integration-options="--skills"`, which converts them to the skills layout and removes the old command files. If preset overrides are installed, the migration is rejected with an actionable error (preset artifacts cannot yet be reconciled across a layout change) — remove the preset(s), migrate, then reinstall them. |
| [Junie](https://junie.jetbrains.com/) | `junie` | |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | Installs commands into `.kilo/commands`; legacy `.kilocode/workflows` installs remain supported as a registration fallback |
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
@@ -86,7 +85,7 @@ specify integration install <key>
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--force` | Opt in to installing alongside integrations that are not declared multi-install safe |
| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
@@ -122,7 +121,7 @@ specify integration switch <key>
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default |
| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) |
| `--integration-options` | Options for the target integration when it is not already installed |
@@ -150,7 +149,7 @@ specify integration upgrade [<key>]
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--force` | Overwrite files even if they have been modified |
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--integration-options` | Options for the integration |
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
@@ -257,34 +256,31 @@ Spec Kit tracks one default integration in `.specify/integration.json` with `def
An integration is multi-install safe when it uses a static, unique agent root and command directory, stable command invocation settings, and a separate install manifest whose managed files do not overlap another safe integration. Registry tests enforce those path and manifest invariants. Shared Spec Kit templates remain aligned to the single default integration.
The Command directory column below lists the directory each integration installs its commands or skills into. Context-file targeting is a separate concern from integration multi-install safety: `multi_install_safe` is an integration declaration about command/skill paths, whereas the optional agent-context extension manages a per-agent context file (for example `AGENTS.md` or `CLAUDE.md`) and can even synchronize several anchors at once via its `context_files` setting. Multiple agents mapping to the same context file is expected there and does not affect whether an integration is multi-install safe; see the agent-context extension for details.
The Isolation column below lists paths Spec Kit manages for that integration (skills/commands roots and any integration-owned rule files). It is not a full inventory of every file an agent may read.
**Agent-context defaults are separate.** The optional agent-context extension maps each integration to a default context file in `extensions/agent-context/agent-context-defaults.json`. Those defaults are independent of multi-install safety: several agents may share a root file such as `AGENTS.md` when the extension is enabled. Multi-install safety does not require a unique context file per safe integration.
The currently declared multi-install safe integrations are:
| Key | Command directory |
| --- | ----------------- |
| `auggie` | `.augment/commands` |
| `claude` | `.claude/skills` |
| `cline` | `.clinerules/workflows` |
| `codebuddy` | `.codebuddy/commands` |
| `codex` | `.agents/skills` |
| `cursor-agent` | `.cursor/skills` |
| `droid` | `.factory/skills` |
| `firebender` | `.firebender/commands` |
| `gemini` | `.gemini/commands` |
| Key | Isolation |
| --- | --------- |
| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` |
| `claude` | `.claude/skills`, `CLAUDE.md` |
| `cline` | `.clinerules/workflows`, `.clinerules/specify-rules.md` |
| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
| `codex` | `.agents/skills`, `AGENTS.md` |
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` |
| `gemini` | `.gemini/commands`, `GEMINI.md` |
| `grok` | `.grok/skills` |
| `junie` | `.junie/commands` |
| `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` |
| `tabnine` | `.tabnine/agent/commands` |
| `trae` | `.trae/skills` |
| `zcode` | `.zcode/skills` |
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
| `qodercli` | `.qoder/commands`, `QODER.md` |
| `qwen` | `.qwen/commands`, `QWEN.md` |
| `shai` | `.shai/commands`, `SHAI.md` |
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
| `zcode` | `.zcode/skills`, `ZCODE.md` |
Integrations that share a command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
-26
View File
@@ -502,32 +502,6 @@ args: "{{ inputs.spec }}"
message: "{{ status | default('pending') }}"
```
### Interpolation and shell safety
Expressions are resolved by **plain string substitution** — the value of `{{ ... }}` is spliced into the surrounding text exactly as-is, with no quoting or escaping added. That is convenient for building `args` and `message` strings, but it has an important consequence for `shell` steps: a `run` field is handed to the system shell (`/bin/sh -c` on POSIX), so any interpolated value is interpreted as **shell syntax**, not just data.
If an interpolated value can contain characters like `;`, `|`, `&`, `$( )`, backticks, or quotes, it can change or extend the command that actually runs. This matters most when the value is not fully under the workflow author's control:
- **Workflow `inputs.*`** — supplied by whoever runs the workflow.
- **A prior step's output**, e.g. `{{ steps.plan.output.stdout }}` — for a `prompt` step this is **text produced by the AI agent**, which can in turn be influenced by files, tickets, or web content the agent read. Treat agent output as untrusted when it flows into a `shell` step.
There is **no shell-escaping filter** in the expression language and **no sandbox** around a `shell` step, so none of the practices below can be treated as a guarantee that a hostile value is neutralised. The only reliable control is to constrain what an interpolated value *can* be, and to keep values you cannot constrain out of `run` fields entirely. Scrutinise every `run` field that interpolates a value you do not control, and at minimum:
- **Constrain the value at the source with `enum`/an allowlist.** When `inputs.*` feeds a `run` field, restrict it to a fixed set of known-safe values so a caller cannot supply arbitrary shell text at all. This is the strongest control the engine offers — prefer it over any downstream mitigation.
```yaml
inputs:
target:
type: string
enum: [staging, production] # caller cannot inject arbitrary text
```
- **Keep unconstrained values out of `run`.** If a value cannot be constrained to an allowlist — most agent/`prompt` output — do not interpolate it into a `run` field. Branch on it with `if`/`switch` against fixed conditions, or act on it in a `command`/`prompt` step rather than a shell command built from it.
- **Quoting is not a security boundary.** Surrounding a substitution with quotes (`'{{ inputs.x }}'`) helps the shell treat a *trusted* value as a single argument and avoids word-splitting on spaces, but a value that itself contains the matching quote character can still break out and inject shell syntax. Quote for correctness on constrained values; never rely on quoting to make an *unconstrained* substitution safe.
- **Gates do not inspect the next step, and `message` is printed verbatim.** A `gate` step renders only its own `message`/`show_file` — it does not display, resolve, or sanitise the command that follows it, and approval never neutralises an injectable interpolation. Do **not** interpolate raw untrusted data into `message`: it is printed as-is with no control-character stripping, so agent or caller output could inject terminal/ANSI escapes that alter or hide the approval prompt. Keep `message` to trusted, constrained text, and surface untrusted material for review via `show_file` instead — its path and contents are control/ANSI-stripped before display.
A `shell` step is an arbitrary-command primitive by design; these practices reduce exposure and keep *which* command runs under the author's control, but they do not eliminate the risk of interpolating values you do not fully control.
## Shell Step Environment Variables
Shell steps automatically receive the following environment variables:
-2
View File
@@ -55,8 +55,6 @@
href: concepts/spec-persistence.md
- name: Handling Complex Features
href: concepts/complex-features.md
- name: Spec of Specs
href: concepts/spec-of-specs.md
# Development workflows
- name: Development
+98 -100
View File
@@ -12,7 +12,7 @@
| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z[suffix]` | Upgrade to a specific release tag instead of the latest stable. Suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms. |
| **CLI Tool — manual fallback** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | When `specify self upgrade` isn't available (older installs) or when you want explicit control. |
| **CLI Tool — manual fallback (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Same as above, for pipx installs. |
| **Project Files** | Run `specify integration upgrade <key>`, then `specify extension update` | Refresh installed integration files and extensions in your project |
| **Project Files** | `specify init --here --force --integration <your-agent>` | Update slash commands, templates, and scripts in your project |
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
---
@@ -89,94 +89,91 @@ specify self check
## Part 2: Updating Project Files
When Spec Kit releases new features (like new slash commands, updated templates, or extension changes), you need to refresh the Spec Kit files that were installed into your project.
When Spec Kit releases new features (like new slash commands or updated templates), you need to refresh your project's Spec Kit files.
### What gets updated?
For existing Spec Kit projects, use the manifest-aware upgrade path first:
Running `specify init --here --force` will update:
- ✅ **Integration command/skill files** (`.claude/skills/`, `.github/prompts/`, `.agents/skills/`, etc.)
- ✅ **Managed shared scripts and templates** (`.specify/scripts/`, `.specify/templates/`) when they are unchanged from the previous managed copy
- ✅ **Installed extensions** when you run `specify extension update`
The integration upgrade command uses the install manifest to detect local edits. If a managed integration file was modified after install, the command stops and asks you to inspect the change or rerun with `--force`.
- ✅ **Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)
- ✅ **Script files** (`.specify/scripts/`) — **only with `--force`**; without it, only missing files are added
- ✅ **Template files** (`.specify/templates/`) — **only with `--force`**; without it, only missing files are added
- ✅ **Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**
### What stays safe?
These files are **never touched** by the manifest-aware integration/extension upgrade path:
These files are **never touched** by the upgrade—the template packages don't even contain them:
- ✅ **Your specifications** (`specs/001-my-feature/spec.md`, etc.) - **CONFIRMED SAFE**
- ✅ **Your implementation plans** (`specs/001-my-feature/plan.md`, `tasks.md`, etc.) - **CONFIRMED SAFE**
- ✅ **Your constitution** (`.specify/memory/constitution.md`) when using `specify integration upgrade`
- ✅ **Your source code** - **CONFIRMED SAFE**
- ✅ **Your git history** - **CONFIRMED SAFE**
The `specs/` directory is completely excluded from template packages and will never be modified during upgrades.
### 1. Check installed integrations
### Update command
Run this inside your project directory:
```bash
specify integration status
```
This reports the default integration, all installed integrations, and any modified or missing managed files. You can also inspect `.specify/integration.json`; installed integrations are listed under `installed_integrations`.
### 2. Upgrade each installed integration
Run this inside your project directory:
```bash
specify integration upgrade <key>
```
Replace `<key>` with an installed integration key such as `copilot`, `claude`, or `codex`. In projects with multiple installed integrations, run the command once per installed key.
**Example:**
```bash
specify integration upgrade claude
specify integration upgrade codex
```
See the [integration reference](reference/integrations.md#upgrade-an-integration) for options such as `--script`, `--integration-options`, and `--force`.
### 3. Update installed extensions
Run:
```bash
specify extension update
```
With no extension argument, this updates all installed extensions. Use `specify extension update <extension-id-or-name>` to update only one extension. See the [extensions reference](reference/extensions.md#update-extensions) for details.
### Fallback: re-run init
If a project predates manifests, has missing integration metadata, or needs a broader recovery, you can still re-run init:
```bash
specify init --here --force --integration <your-agent>
```
Use this as an escape hatch rather than the default project-file upgrade path. It refreshes the selected integration and shared project scaffolding, but it does not use the same per-integration manifest checks before overwriting files.
Replace `<your-agent>` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md)
**Example:**
```bash
specify init --here --force --integration copilot
```
### Understanding the `--force` flag
Without `--force`, the CLI warns you and asks for confirmation:
```text
Warning: Current directory is not empty (25 items)
Template files will be merged with existing content and may overwrite existing files
Proceed? [y/N]
```
With `--force`, it skips the confirmation and proceeds immediately. It also **overwrites shared infrastructure files** (`.specify/scripts/` and `.specify/templates/`) with the latest versions from the installed Spec Kit release.
Without `--force`, shared infrastructure files that already exist are skipped — the CLI will print a warning listing the skipped files so you know which ones were not updated.
**Important: Your `specs/` directory is always safe.** The `--force` flag only affects template files (commands, scripts, templates, memory). Your feature specifications, plans, and tasks in `specs/` are never included in upgrade packages and cannot be overwritten.
---
## ⚠️ Important Warnings
### 1. Constitution file and memory customizations
### 1. Constitution file will be overwritten
`specify integration upgrade <key>` does not update `.specify/memory/constitution.md`.
**Known issue:** `specify init --here --force` currently overwrites `.specify/memory/constitution.md` with the default template, erasing any customizations you made.
The fallback `specify init --here --force --integration <your-agent>` path also preserves an existing `.specify/memory/constitution.md`; if the file is missing, init creates it from the current constitution template. You do not need a constitution backup/restore step for the manifest-aware upgrade path.
**Workaround:**
As with any broad fallback refresh, commit or back up local customizations before using `init --here --force` so you can review the resulting diff.
```bash
# 1. Back up your constitution before upgrading
cp .specify/memory/constitution.md .specify/memory/constitution-backup.md
### 2. Custom integration, script, or template modifications
# 2. Run the upgrade
specify init --here --force --integration copilot
`specify integration upgrade <key>` blocks when manifest-tracked integration files were modified locally, unless you pass `--force`.
# 3. Restore your customized constitution
mv .specify/memory/constitution-backup.md .specify/memory/constitution.md
```
Shared scripts and templates are refreshed when they still match the previously recorded managed copy. Local customizations are preserved unless you explicitly use a force/refresh option that overwrites them. If you customized files in `.specify/scripts/` or `.specify/templates/`, commit or back them up first:
Or use git to restore it:
```bash
# After upgrade, restore from git history
git restore .specify/memory/constitution.md
```
### 2. Custom script or template modifications
If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first:
```bash
# Back up custom templates and scripts
@@ -195,13 +192,15 @@ Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash command
**Example for Kilo Code:**
```bash
# List current and legacy Kilo command folders
ls -la .kilo/commands/
ls -la .kilocode/workflows/
# Navigate to the agent's commands folder
cd .kilocode/workflows/
# List files and identify duplicates
ls -la
# Delete old versions (example filenames - yours may differ)
rm .kilocode/workflows/speckit.specify-old.md
rm .kilocode/workflows/speckit.plan-v1.md
rm speckit.specify-old.md
rm speckit.plan-v1.md
```
Restart your IDE to refresh the command list.
@@ -216,29 +215,29 @@ Restart your IDE to refresh the command list.
# Upgrade CLI (auto-detects uv tool vs pipx install)
specify self upgrade
# Inspect installed integrations
specify integration status
# Update project files to get new commands
specify integration upgrade <key>
specify extension update
specify init --here --force --integration copilot
# Restore your constitution if customized
git restore .specify/memory/constitution.md
```
### Scenario 2: "I customized templates and constitution"
```bash
# 1. Commit or back up customizations
git status
# 1. Back up customizations
cp .specify/memory/constitution.md /tmp/constitution-backup.md
cp -r .specify/templates /tmp/templates-backup
# 2. Upgrade CLI
specify self upgrade
# 3. Use the manifest-aware project update first
specify integration upgrade <key>
specify extension update
# 3. Update project
specify init --here --force --integration copilot
# 4. If the upgrade reports modified managed files, inspect the diff before using --force
# 4. Restore customizations
mv /tmp/constitution-backup.md .specify/memory/constitution.md
# Manually merge template changes if needed
```
### Scenario 3: "I see duplicate slash commands in my IDE"
@@ -246,12 +245,14 @@ specify extension update
This happens with IDE-based agents (Kilo Code, Cline, etc.).
```bash
# For Kilo Code, inspect both current and legacy command folders
ls -la .kilo/commands/
ls -la .kilocode/workflows/
# Find the agent folder (example: .kilocode/workflows/)
cd .kilocode/workflows/
# List all files
ls -la
# Delete old command files
rm .kilocode/workflows/speckit.old-command-name.md
rm speckit.old-command-name.md
# Restart your IDE
```
@@ -261,14 +262,14 @@ rm .kilocode/workflows/speckit.old-command-name.md
The git extension is now opt-in, so upgrades do not install it unless you add it explicitly.
```bash
# Upgrade CLI
specify self upgrade
# Manually back up files you customized
cp .specify/memory/constitution.md .specify/memory/constitution.backup.md
# Refresh integration files and installed extensions
specify integration upgrade <key>
specify extension update
# Run upgrade
specify init --here --force --integration copilot
# The git extension is not added unless you run `specify extension add git`
# Restore customizations
mv .specify/memory/constitution.backup.md .specify/memory/constitution.md
```
If you later decide you want the git extension's commands and hooks, install it explicitly:
@@ -303,7 +304,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
2. **For CLI-based agents**, verify files exist:
```bash
ls -la .claude/skills/ # Claude Code
ls -la .claude/commands/ # Claude Code
ls -la .gemini/commands/ # Gemini
ls -la .cursor/skills/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
@@ -314,21 +315,19 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
- Codex requires `CODEX_HOME` environment variable
- Some agents need workspace restart or cache clearing
### "Will init overwrite my constitution customizations?"
### "I lost my constitution customizations"
Current `specify init --here --force` preserves an existing `.specify/memory/constitution.md`; it creates the file from the template only when it is missing.
If you previously lost constitution changes through an older workflow or manual replacement, restore from git or backup:
**Fix:** Restore from git or backup:
```bash
# If you committed the customized constitution
# If you committed before upgrading
git restore .specify/memory/constitution.md
# If you backed up manually
cp /tmp/constitution-backup.md .specify/memory/constitution.md
```
**Prevention:** Use `specify integration upgrade <key>` for routine project-file updates. If you need the fallback `specify init --here --force` path, commit first so you can review the full diff afterward.
**Prevention:** Always commit or back up `constitution.md` before upgrading.
### "Warning: Current directory is not empty"
@@ -352,10 +351,10 @@ This warning appears when you run `specify init --here` (or `specify init .`) in
Only Spec Kit infrastructure files:
- Agent command/skill files (`.claude/skills/`, `.github/prompts/`, etc.)
- Agent command files (`.claude/commands/`, `.github/prompts/`, etc.)
- Scripts in `.specify/scripts/`
- Templates in `.specify/templates/`
- Missing memory files such as `.specify/memory/constitution.md` may be created from templates; an existing constitution is preserved
- Memory files in `.specify/memory/` (including constitution)
**What stays untouched:**
@@ -366,7 +365,7 @@ Only Spec Kit infrastructure files:
**How to respond:**
- **Type `y` and press Enter** - Proceed with the merge when using the fallback init path
- **Type `y` and press Enter** - Proceed with the merge (recommended if upgrading)
- **Type `n` and press Enter** - Cancel the operation
- **Use `--force` flag** - Skip this confirmation entirely:
@@ -376,11 +375,11 @@ Only Spec Kit infrastructure files:
**When you see this warning:**
- ✅ **Expected** when using the fallback init path in an existing Spec Kit project
- ✅ **Expected** when upgrading an existing Spec Kit project
- ✅ **Expected** when adding Spec Kit to an existing codebase
- ⚠️ **Unexpected** if you thought you were creating a new project in an empty directory
**Prevention tip:** Before using the fallback init path, commit your current work so any refreshed files are easy to review or restore.
**Prevention tip:** Before upgrading, commit or back up your `.specify/memory/constitution.md` if you customized it.
### "CLI upgrade doesn't seem to work"
@@ -419,15 +418,14 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
### "Do I need to run specify every time I open my project?"
**Short answer:** No, you only run `specify init` once per project, or later as a fallback recovery path.
**Short answer:** No, you only run `specify init` once per project (or when upgrading).
**Explanation:**
The `specify` CLI tool is used for:
- **Initial setup:** `specify init` to bootstrap Spec Kit in your project
- **Routine project-file upgrades:** `specify integration upgrade <key>` and `specify extension update`
- **Fallback recovery:** `specify init --here --force` when integration metadata is missing or the manifest-aware path cannot be used
- **Upgrades:** `specify init --here --force` to update templates and commands
- **Diagnostics:** `specify check` to verify tool installation
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, `.omp/commands/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.
@@ -441,7 +439,7 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
ls -la .github/prompts/
# For Claude
ls -la .claude/skills/
ls -la .claude/commands/
# For Pi
ls -la .pi/prompts/
+37 -33
View File
@@ -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 -->`). 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.
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`).
## 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; 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.
- **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.
- **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](./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.
- **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).
## Commands
| Command | Description |
| ------------------------------ | --------------------------------------------------------------------------------- |
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
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).
> 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.
| Command | Description |
|---------|-------------|
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
## Configuration
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).
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.
## 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,6 +58,10 @@ pip install pyyaml
/path/to/speckit-python -m pip install pyyaml
```
## Issues
## Disable
For any other issues, please create an issue in the [official GitHub repo](https://github.com/github/spec-kit/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.
@@ -1,24 +1,20 @@
# Coding Agent Context Extension Configuration
# These values are populated automatically by `specify init` and
# `specify integration use` / `specify integration install`.
# 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
# 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.
context_file: ""
# 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
# 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.
context_files: []
# 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 -->"
# Delimiters for the managed Spec Kit section.
# Edit these to use custom markers.
context_markers:
start: "<!-- SPECKIT START -->"
end: "<!-- SPECKIT END -->"
+13 -55
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-24T00:00:00Z",
"updated_at": "2026-07-21T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -290,8 +290,8 @@
"id": "architecture-guard",
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
"author": "DyanGalih",
"version": "1.13.1",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.13.1.zip",
"version": "1.8.17",
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.17.zip",
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/docs/architecture-overview.md",
@@ -303,7 +303,7 @@
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 14,
"commands": 10,
"hooks": 3
},
"tags": [
@@ -313,14 +313,13 @@
"refactor",
"workflow",
"governance",
"guardrails",
"hygiene"
"guardrails"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-05T07:26:00Z",
"updated_at": "2026-07-24T00:00:00Z"
"updated_at": "2026-06-08T00:00:00Z"
},
"archive": {
"name": "Archive Extension",
@@ -490,46 +489,6 @@
"created_at": "2026-04-17T00:00:00Z",
"updated_at": "2026-04-17T00:00:00Z"
},
"blueprint-index": {
"name": "Blueprint Index — Living Architecture Map",
"id": "blueprint-index",
"description": "Living architecture map for brownfield and greenfield projects, with a deterministic CI gate that blocks contradictions between the map, specs, and code while warning on non-blocking drift.",
"author": "ogil109",
"version": "0.2.0",
"download_url": "https://github.com/ogil109/spec-kit-blueprint/releases/download/v0.2.0/blueprint.zip",
"repository": "https://github.com/ogil109/spec-kit-blueprint",
"homepage": "https://github.com/ogil109/spec-kit-blueprint/tree/main",
"documentation": "https://github.com/ogil109/spec-kit-blueprint/blob/main/README.md",
"changelog": "https://github.com/ogil109/spec-kit-blueprint/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.10.0",
"tools": [
{ "name": "bash", "required": false },
{ "name": "git", "required": false }
]
},
"provides": {
"commands": 4,
"hooks": 0
},
"tags": [
"blueprint",
"architecture",
"coherence",
"drift",
"brownfield",
"autonomous",
"ci"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-24T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
},
"branch-convention": {
"name": "Branch Convention",
"id": "branch-convention",
@@ -4818,18 +4777,17 @@
"verify-review-ship": {
"name": "Verify Review Ship",
"id": "verify-review-ship",
"description": "Adds verify and review quality gates plus transactional merge, cleanup, and delivery summary.",
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
"author": "Carlos Eduardo Gevaerd Araujo",
"version": "0.3.0",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.3.0.zip",
"sha256": "a7326c899855f46ff28e9f03ede2f89c4db0fd2b8a64c85017b3ab639e004fd3",
"version": "0.1.0",
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"effect": "read-only",
"requires": {
"speckit_version": ">=0.1.0"
},
@@ -4841,14 +4799,14 @@
"quality",
"review",
"shipping",
"merge",
"workflow"
"workflow",
"testing"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
"updated_at": "2026-07-10T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",
+1 -6
View File
@@ -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, or Conventional Commit messages generated by the agent)
- **Auto-commit** after core commands (configurable per-command with custom messages)
## Commands
@@ -66,11 +66,6 @@ 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:
+4 -19
View File
@@ -14,37 +14,23 @@ 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. Determines the commit message based on `commit_style` (see below)
5. Uses the per-command `message` if configured, otherwise a default message
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> [--message-file <path>]`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name> [-MessageFile <path>]`
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
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.
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
## 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:
@@ -60,4 +46,3 @@ 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
-7
View File
@@ -17,13 +17,6 @@ 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.
-7
View File
@@ -17,13 +17,6 @@ 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 -74
View File
@@ -3,57 +3,16 @@
# 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> [generated_message]
# auto-commit.sh <event_name> --message-file <path>
# Usage: auto-commit.sh <event_name>
# 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> [generated_message | --message-file <path>]" >&2
echo "Usage: $0 <event_name>" >&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)"
@@ -87,22 +46,8 @@ 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.
@@ -149,12 +94,7 @@ if [ -f "$_config_file" ]; then
[ "$_val" = "false" ] && _enabled=false
fi
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
# Trim trailing whitespace before stripping the closing quote:
# a value like `message: "Done" ` (trailing spaces after the
# quote) would otherwise leave the quote dangling (`Done" `),
# since the closing-quote strip is anchored to end-of-string.
# The PowerShell twin .Trim()s first; match it for parity.
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
fi
fi
fi
@@ -183,17 +123,6 @@ 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,47 +3,14 @@
# 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> [generated_message]
# auto-commit.ps1 <event_name> -MessageFile <path>
# Usage: auto-commit.ps1 <event_name>
# 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,
# 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 = ""
[string]$EventName
)
$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
@@ -88,25 +55,8 @@ 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
@@ -190,17 +140,6 @@ 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' }
@@ -565,12 +565,6 @@ if (-not $DryRun) {
$env:SPECIFY_FEATURE = $branchName
}
# Build the PowerShell-idiomatic persist hint, mirroring the core
# create-new-feature.ps1 twin (and the bash/python twins of this script), which
# all emit "# To persist in your shell: ...".
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
if ($Json) {
$obj = [PSCustomObject]@{
BRANCH_NAME = $branchName
@@ -587,6 +581,6 @@ if ($Json) {
Write-Output "BRANCH_NAME: $branchName"
Write-Output "FEATURE_NUM: $featureNum"
if (-not $DryRun) {
Write-Output "# To persist in your shell: $featureAssignment"
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
}
}
+1 -9
View File
@@ -33,15 +33,7 @@ def _value_after_colon(line: str) -> str:
def _strip_quotes(value: str) -> str:
"""Strip surrounding whitespace, then one leading quote and all trailing quotes.
Trimming first matters when the YAML value has trailing whitespace after a
closing quote (``message: "Done" ``): stripping quotes anchored to the end
of string would leave the closing quote dangling (``Done" ``) because the
quote is no longer at the end. The PowerShell twin ``.Trim()``s before
stripping, so trim here too to keep all three script variants in parity.
"""
value = value.strip()
"""Strip one leading quote and all trailing quotes, mirroring the bash sed."""
value = re.sub(r"^[\"']", "", value)
return re.sub(r"[\"']*$", "", value)
+1 -10
View File
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-17T00:00:00Z",
"updated_at": "2026-07-15T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -48,15 +48,6 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"droid": {
"id": "droid",
"name": "Factory Droid",
"version": "1.0.0",
"description": "Factory Droid CLI skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills", "factory"]
},
"amp": {
"id": "amp",
"name": "Amp",
+36 -66
View File
@@ -1,19 +1,18 @@
{
"schema_version": "1.0",
"updated_at": "2026-07-24T00:00:00Z",
"updated_at": "2026-07-22T00: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.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.",
"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.",
"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.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -34,18 +33,18 @@
"didactic-comments"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-06-14T00:00:00Z"
},
"agent-parity-governance": {
"name": "Agent Parity Governance",
"id": "agent-parity-governance",
"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.",
"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.",
"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.4.0.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.3.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/v0.4.0/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -64,7 +63,7 @@
"multi-agent"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-06-14T00:00:00Z"
},
"aide-in-place": {
"name": "AIDE In-Place Migration",
@@ -97,13 +96,13 @@
"architecture-governance": {
"name": "Architecture Governance",
"id": "architecture-governance",
"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.",
"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.",
"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.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -130,7 +129,7 @@
"assurance"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-06-14T00:00:00Z"
},
"autonomous-run-governance": {
"name": "Autonomous Run Governance",
@@ -244,13 +243,13 @@
"cross-platform-governance": {
"name": "Cross-Platform Governance",
"id": "cross-platform-governance",
"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.",
"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.",
"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.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -272,7 +271,7 @@
"linux"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-06-14T00:00:00Z"
},
"explicit-task-dependencies": {
"name": "Explicit Task Dependencies",
@@ -365,45 +364,16 @@
"created_at": "2026-05-05T08:00:00Z",
"updated_at": "2026-06-22T00:00:00Z"
},
"intake-authoring-governance": {
"name": "Intake Authoring Governance",
"id": "intake-authoring-governance",
"version": "0.2.0",
"description": "Governs traceable intake CRUD, bounded public HTTPS sources, and explicitly approved single or series authoring without granting execution authority.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.2.0/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
},
"provides": {
"templates": 10,
"commands": 5,
"scripts": 4
},
"tags": [
"intake",
"authoring",
"governance",
"provenance",
"requirements"
],
"created_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.1",
"description": "Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes.",
"version": "0.1.0",
"description": "Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.0/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
@@ -421,18 +391,18 @@
"autonomous"
],
"created_at": "2026-07-21T00:00:00Z",
"updated_at": "2026-07-24T00:00:00Z"
"updated_at": "2026-07-21T00:00:00Z"
},
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
"id": "isaqb-architecture-governance",
"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.",
"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.",
"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.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -453,7 +423,7 @@
"technical-debt"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-06-14T00:00:00Z"
},
"jira": {
"name": "Jira Issue Tracking",
@@ -636,13 +606,13 @@
"security-governance": {
"name": "Security Governance",
"id": "security-governance",
"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.",
"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.",
"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.1.zip",
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.1/README.md",
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -681,7 +651,7 @@
"regulatory"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-07-23T00:00:00Z"
"updated_at": "2026-06-14T00:00:00Z"
},
"sicario-core": {
"name": "SicarioSpec Core",
@@ -8,24 +8,6 @@ description: Create or update the project constitution.
$ARGUMENTS
```
## Scope Guard
This command's own work is limited to creating or updating the project constitution and
propagating constitution-driven changes to dependent Spec Kit artifacts.
- Classify every part of the user input as constitution content or a separate non-governance
intent. Feature implementation, code generation, refactoring, build, and deployment requests
are examples of non-governance intents.
- You **MUST NOT** execute any non-governance intent. Defer each one to `Next Actions`.
- You **MUST NOT** create, modify, or delete application source files or other artifacts
unrelated to the constitution workflow.
- If an instruction could be either constitution content or a non-governance intent, ask for
clarification before making changes.
- After updating the constitution, list each deferred intent in a `Next Actions` section with an
appropriate follow-up Spec Kit command, such as `__SPECKIT_COMMAND_SPECIFY__`, but do not
invoke it.
- Omit `Next Actions` when there are no non-governance intents.
## Outline
1. Create or update the project constitution and store it in `.specify/memory/constitution.md`.
+1 -2
View File
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.14.2"
version = "0.13.3"
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"
@@ -39,7 +39,6 @@ packages = ["src/specify_cli"]
"templates/commands" = "specify_cli/core_pack/commands"
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
"scripts/python" = "specify_cli/core_pack/scripts/python"
# Bundled extensions (installable via `specify extension add <name>`)
"extensions/git" = "specify_cli/core_pack/extensions/git"
"extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context"
+17 -65
View File
@@ -8,7 +8,6 @@ ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
NUMBER_EXPLICIT=false
ARGS=()
i=1
while [ $i -le $# ]; do
@@ -49,9 +48,6 @@ while [ $i -le $# ]; do
exit 1
fi
BRANCH_NUMBER="$next_arg"
if [ -n "$BRANCH_NUMBER" ]; then
NUMBER_EXPLICIT=true
fi
;;
--timestamp)
USE_TIMESTAMP=true
@@ -64,7 +60,7 @@ while [ $i -le $# ]; do
echo " --dry-run Compute feature name and paths without creating directories or files"
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)"
echo " --number N Specify branch number manually (overrides auto-detection)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
@@ -95,7 +91,6 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
fi
MAX_FEATURE_NUMBER=9223372036854775807
MAX_BRANCH_LENGTH=244
is_feature_number_in_range() {
local value="$1"
@@ -133,40 +128,12 @@ get_highest_from_specs() {
echo "$highest"
}
# Return success when a spec directory owns the given numeric prefix.
spec_prefix_exists() {
local specs_dir="$1"
local feature_num="$2"
for spec_path in "$specs_dir/${feature_num}-"*; do
[ -d "$spec_path" ] && return 0
done
return 1
}
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
# Fit a feature prefix and suffix within GitHub's branch-name limit.
fit_branch_name() {
local feature_num="$1"
local branch_suffix="$2"
local branch_name="${feature_num}-${branch_suffix}"
if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then
local prefix_length=$(( ${#feature_num} + 1 ))
local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length))
local truncated_suffix
truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//')
branch_name="${feature_num}-${truncated_suffix}"
fi
printf '%s' "$branch_name"
}
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
# so the persistence hints match the Python variant exactly (printf %q output
# differs between bash versions and from shlex.quote for spaces/metachars).
@@ -286,41 +253,26 @@ else
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
if [ "$NUMBER_EXPLICIT" = true ]; then
SPEC_CONFLICT=false
REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME"
if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true
fi
if [ "$SPEC_CONFLICT" = true ]; then
REQUESTED_NUM="$FEATURE_NUM"
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
BRANCH_NUMBER=$HIGHEST
while true; do
if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
exit 1
fi
BRANCH_NUMBER=$((BRANCH_NUMBER + 1))
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break
done
>&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead"
fi
fi
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
fi
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
MAX_BRANCH_LENGTH=244
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
# Calculate how much we need to trim from suffix
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
# Truncate suffix at word boundary if possible
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
# Remove trailing hyphen if truncation created one
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
+17 -65
View File
@@ -14,7 +14,6 @@ param(
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
$maxBranchLength = 244
# Show help if requested
if ($Help) {
@@ -25,7 +24,7 @@ if ($Help) {
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
Write-Host ""
@@ -68,44 +67,11 @@ function Get-HighestNumberFromSpecs {
return $highest
}
function Test-SpecPrefixInUse {
param(
[string]$SpecsDir,
[string]$FeatureNum
)
if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
return $false
}
return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
Where-Object { $_.Name -like "$FeatureNum-*" } |
Select-Object -First 1)
}
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
function Get-FittedBranchName {
param(
[string]$FeatureNum,
[string]$BranchSuffix
)
$fittedName = "$FeatureNum-$BranchSuffix"
if ($fittedName.Length -gt $maxBranchLength) {
$prefixLength = $FeatureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$fittedName = "$FeatureNum-$truncatedSuffix"
}
return $fittedName
}
# Load common functions (includes Get-RepoRoot and Resolve-Template)
. "$PSScriptRoot/common.ps1"
@@ -210,40 +176,26 @@ if ($Timestamp) {
}
$featureNum = ('{0:000}' -f $resolvedNumber)
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
$specConflict = $false
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
$requestedDir = Join-Path $specsDir $requestedBranchName
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
$specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
}
}
if ($specConflict) {
$requestedNum = $featureNum
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
$resolvedNumber = $highestNumber
do {
if ($resolvedNumber -eq [long]::MaxValue) {
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
exit 1
}
$resolvedNumber++
$featureNum = ('{0:000}' -f $resolvedNumber)
} while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
}
$branchName = "$featureNum-$branchSuffix"
}
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
$originalBranchName = "$featureNum-$branchSuffix"
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
if ($branchName -ne $originalBranchName) {
$maxBranchLength = 244
if ($branchName.Length -gt $maxBranchLength) {
# Calculate how much we need to trim from suffix
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
$prefixLength = $featureNum.Length + 1
$maxSuffixLength = $maxBranchLength - $prefixLength
# Truncate suffix
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
# Remove trailing hyphen if truncation created one
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
$originalBranchName = $branchName
$branchName = "$featureNum-$truncatedSuffix"
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
+6 -73
View File
@@ -76,7 +76,7 @@ Options:
--dry-run Compute feature name and paths without creating directories or files
--allow-existing-branch Reuse an existing feature directory if it already exists
--short-name <name> Provide a custom short name (2-4 words) for the feature
--number N Prefer a feature number (auto-corrected if its specs prefix exists)
--number N Specify branch number manually (overrides auto-detection)
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
--help, -h Show this help message
@@ -204,43 +204,6 @@ def _get_highest_from_specs(specs_dir: Path) -> int:
return highest
def _fit_branch_name(feature_num: str, branch_suffix: str) -> str:
"""Fit a feature prefix and suffix within GitHub's branch-name limit."""
branch_name = f"{feature_num}-{branch_suffix}"
if len(branch_name) <= _MAX_BRANCH_LENGTH:
return branch_name
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
return f"{feature_num}-{truncated_suffix}"
def _spec_prefix_exists(specs_dir: Path, feature_num: str) -> bool:
"""Return whether a spec directory owns the given numeric prefix."""
try:
return any(
entry.is_dir() and entry.name.startswith(f"{feature_num}-")
for entry in specs_dir.iterdir()
)
except OSError:
# Match Bash globbing and PowerShell's ErrorAction=SilentlyContinue.
return False
def _has_spec_prefix_conflict(
specs_dir: Path,
feature_num: str,
requested_dir: Path,
*,
allow_existing: bool,
) -> bool:
"""Return whether another spec directory owns the requested prefix."""
if allow_existing and requested_dir.is_dir():
return False
return _spec_prefix_exists(specs_dir, feature_num)
def main(argv: list[str] | None = None) -> int:
argv0 = sys.argv[0]
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
@@ -298,48 +261,18 @@ def main(argv: list[str] | None = None) -> int:
return 1
feature_num = f"{number:03d}"
# Treat an explicit number as a preference when its prefix is already used
# by a feature directory. Auto-detected numbers are already conflict-free.
if branch_number:
requested_branch_name = _fit_branch_name(feature_num, branch_suffix)
requested_dir = specs_dir / requested_branch_name
spec_conflict = _has_spec_prefix_conflict(
specs_dir,
feature_num,
requested_dir,
allow_existing=args.allow_existing,
)
if spec_conflict:
requested_num = feature_num
number = _get_highest_from_specs(specs_dir)
while True:
number += 1
if number > _MAX_FEATURE_NUMBER:
print(
f"Error: feature number must be between 0 and "
f"{_MAX_FEATURE_NUMBER}, got '{number}'",
file=sys.stderr,
)
return 1
feature_num = f"{number:03d}"
if not _spec_prefix_exists(specs_dir, feature_num):
break
print(
f"[specify] Warning: --number {requested_num} conflicts with "
f"an existing spec directory; using {feature_num} instead",
file=sys.stderr,
)
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
if max_suffix_length <= 0:
print("Error: feature number is too long for a branch name", file=sys.stderr)
return 1
original_branch_name = f"{feature_num}-{branch_suffix}"
branch_name = _fit_branch_name(feature_num, branch_suffix)
branch_name = f"{feature_num}-{branch_suffix}"
# GitHub enforces a 244-byte limit on branch names.
if branch_name != original_branch_name:
if len(branch_name) > _MAX_BRANCH_LENGTH:
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
original_branch_name = branch_name
branch_name = f"{feature_num}-{truncated_suffix}"
print(
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
file=sys.stderr,
-218
View File
@@ -1,218 +0,0 @@
"""Helpers for bounded HTTP downloads."""
from __future__ import annotations
import io
import socket
from ipaddress import IPv4Address, IPv6Address, ip_address
from typing import NoReturn, TypeVar
from urllib.parse import ParseResult, urlparse
ErrorT = TypeVar("ErrorT", bound=Exception)
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
READ_CHUNK_SIZE = 64 * 1024
# Tighter ceiling for responses that are read fully into memory and parsed as
# JSON. The 50 MiB MAX_DOWNLOAD_BYTES default is sized for archive/payload
# downloads; JSON metadata responses are far smaller, so capping them close to
# their real size shrinks the memory-DoS surface and keeps the "too large"
# error reachable (rather than only triggering on tens of MiB). Pass it
# explicitly at each JSON call site so the intended bound is pinned there.
# METADATA covers fixed-shape single-object responses (an OAuth token, one
# release's metadata): a few KiB in practice, 1 MiB is already generous.
MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
def _ip_address_without_scope(
hostname: str,
) -> IPv4Address | IPv6Address | None:
"""Parse a canonical IP literal, validating an optional IPv6 zone ID."""
if "%" in hostname:
# Accept only the RFC 6874 ``%25<zone>`` spelling. Other escapes can
# alter the IPv6 address when urllib unquotes the authority.
address_text, separator, zone = hostname.partition("%25")
if (
not separator
or ":" not in address_text
or "%" in address_text
or "%" in zone
):
return None
if not zone or any(
not (character.isascii() and (character.isalnum() or character in "._~-"))
for character in zone
):
return None
else:
address_text = hostname
try:
address = ip_address(address_text)
except ValueError:
return None
if "%" in hostname and not isinstance(address, IPv6Address):
return None
return address
def _is_ip_loopback(address: IPv4Address | IPv6Address | None) -> bool:
if address is None:
return False
mapped = getattr(address, "ipv4_mapped", None)
return address.is_loopback or bool(mapped and mapped.is_loopback)
def _is_ip_local_redirect_target(
address: IPv4Address | IPv6Address | None,
) -> bool:
"""Treat loopback and unspecified listener aliases as local targets."""
if address is None:
return False
mapped = getattr(address, "ipv4_mapped", None)
return _is_ip_loopback(address) or address.is_unspecified or bool(
mapped and mapped.is_unspecified
)
def _parse_url(url: str) -> ParseResult | None:
"""Parse *url*, rejecting missing hosts and malformed ports."""
try:
parsed = urlparse(url)
hostname = parsed.hostname
# Accessing ``port`` performs urllib's range and syntax validation.
parsed.port
except (TypeError, ValueError):
return None
if not hostname:
return None
if "%" in hostname:
# urllib unquotes reg-name/IPv4 authorities before connecting. Reject
# them so encoded dots, characters, ports, or brackets cannot make the
# validated hostname differ from the effective target. The only safe
# percent form retained is a validated bracketed IPv6 zone ID.
if _ip_address_without_scope(hostname) is None:
return None
elif ":" not in hostname:
try:
hostname.encode("idna")
except UnicodeError:
return None
return parsed
def _is_definite_loopback_host(hostname: str) -> bool:
"""Recognize only unambiguous hosts that may safely authorize HTTP."""
if not hostname.isascii():
return False
if hostname == "localhost":
return True
return _is_ip_loopback(_ip_address_without_scope(hostname))
def _is_potential_local_target_host(hostname: str) -> bool:
"""Conservatively classify aliases that could reach a local listener."""
if ":" in hostname:
return _is_ip_local_redirect_target(_ip_address_without_scope(hostname))
try:
host = hostname.encode("idna").decode("ascii").lower().removesuffix(".")
except UnicodeError:
return False
if host == "localhost" or host.endswith(".localhost"):
return True
address = _ip_address_without_scope(host)
if address is None:
# Historical IPv4 spellings are resolver-dependent. They are never
# trusted to authorize HTTP, but treating them as potentially local
# prevents them from bypassing a remote-to-loopback redirect check.
try:
address = ip_address(socket.inet_aton(host))
except OSError:
return False
return _is_ip_local_redirect_target(address)
def is_loopback_url(url: str) -> bool:
"""Return whether *url* has an unambiguous loopback host."""
parsed = _parse_url(url)
return parsed is not None and _is_definite_loopback_host(parsed.hostname)
def _is_potential_local_target_url(url: str) -> bool:
parsed = _parse_url(url)
return parsed is not None and _is_potential_local_target_host(parsed.hostname)
def is_https_or_localhost_http(url: str) -> bool:
"""Return True if *url* is HTTPS, or HTTP limited to loopback hosts.
Shared scheme-safety predicate used by the auth HTTP redirect handler and
direct URL validations in CLI download flows.
A hostname is always required: a URL without one (e.g. ``https:///x``)
has no real target and is rejected regardless of scheme.
The HTTP exception is deliberately limited to unambiguous ``localhost``
and canonical IPv4/IPv6 loopback literals. Ambiguous numeric, Unicode, and
unspecified-address aliases are classified defensively for redirects but
never authorize HTTP. No DNS lookup is performed; DNS and hosts-file
aliases require connection-level rebinding protection outside this helper.
"""
parsed = _parse_url(url)
if parsed is None:
return False
return parsed.scheme == "https" or (
parsed.scheme == "http" and _is_definite_loopback_host(parsed.hostname)
)
def is_safe_download_redirect(old_url: str, new_url: str) -> bool:
"""Return whether a redirect preserves the shared download URL policy."""
if not is_https_or_localhost_http(new_url):
return False
return not _is_potential_local_target_url(new_url) or is_loopback_url(old_url)
def _raise(error_type: type[ErrorT], message: str) -> NoReturn:
raise error_type(message)
def read_response_limited(
response,
*,
max_bytes: int = MAX_DOWNLOAD_BYTES,
error_type: type[ErrorT] = ValueError,
label: str = "download",
) -> bytes:
"""Read at most *max_bytes* from a response object.
``response.read(n)`` is only guaranteed to return *up to* ``n`` bytes and may
return fewer even when more data is pending (e.g. chunked transfer encoding),
so a single ``read(max_bytes + 1)`` cannot enforce the bound on its own. Read
in a loop until EOF or until one byte past the limit has been accumulated.
*max_bytes* is keyword-only. It defaults to the module-wide
``MAX_DOWNLOAD_BYTES`` (50 MiB) ceiling for archive/payload downloads;
callers with a tighter budget (e.g. small JSON responses) should pass an
explicit value so the intended bound is pinned at the call site rather than
tracking changes to the shared default.
"""
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int):
raise TypeError("max_bytes must be an integer")
if max_bytes < 0:
raise ValueError("max_bytes must be non-negative")
output = io.BytesIO()
total = 0
limit = max_bytes + 1
while total < limit:
chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
if not chunk:
break
total += len(chunk)
if total > max_bytes:
_raise(error_type, f"{label} exceeds maximum size of {max_bytes} bytes")
output.write(chunk)
return output.getvalue()
+6 -20
View File
@@ -100,19 +100,8 @@ def resolve_github_release_asset_api_url(
import json
import urllib.error
from specify_cli._download_security import read_response_limited
# Accessing ``.hostname`` (like ``.port`` below) raises ValueError on a
# malformed authority, e.g. an invalid bracketed IPv6 host
# ``https://[not-an-ip]/...``. The function's contract is to return None for
# anything it can't resolve, not to raise, so guard the read. ``download_url``
# is server-controlled here (a catalog ``download_url`` payload), so a
# malformed value must not leak a raw traceback past the caller.
try:
parsed = urlparse(download_url)
hostname = (parsed.hostname or "").lower()
except ValueError:
return None
parsed = urlparse(download_url)
hostname = (parsed.hostname or "").lower()
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
is_ghes = (
@@ -169,13 +158,10 @@ def resolve_github_release_asset_api_url(
if redirect_validator is not None:
open_kwargs["redirect_validator"] = redirect_validator
with open_url_fn(release_url, **open_kwargs) as response:
release_data = json.loads(
read_response_limited(
response,
max_bytes=max_metadata_bytes,
label=f"GitHub release metadata {release_url}",
)
)
raw_release_data = response.read(max_metadata_bytes + 1)
if len(raw_release_data) > max_metadata_bytes:
raise ValueError("GitHub release metadata exceeds size limit")
release_data = json.loads(raw_release_data)
except (
urllib.error.URLError,
json.JSONDecodeError,
+11 -4
View File
@@ -69,14 +69,21 @@ def run_command(
cmd: list[str],
check_return: bool = True,
capture: bool = False,
shell: bool = False,
) -> str | None:
"""Run a command without invoking a shell and optionally capture output.
Commands are always executed with ``shell=False`` and must be passed as an
argv ``list[str]``. There is deliberately no ``shell`` parameter: the
argv-list contract makes shell interpolation impossible by construction, so
the shell-injection surface cannot be re-enabled at a call site.
The ``shell`` parameter is kept in the signature so existing keyword
callers (and the re-export from ``specify_cli``) don't raise ``TypeError``,
but only the default ``shell=False`` is honoured. ``shell=True`` is
rejected with ``ValueError`` rather than silently ignored, so the
unsupported mode fails loudly instead of running with a different meaning.
"""
if shell:
raise ValueError(
"run_command() does not support shell=True; pass argv as a list"
)
try:
if capture:
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True)
+3 -10
View File
@@ -4,8 +4,8 @@ Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
release tag. The ``self_app`` Typer sub-command group is co-located here so
all version-related logic lives in one place.
Dependencies: stdlib + packaging + ._console + ._download_security only
(keeping this layer thin and circular-import-safe).
Dependencies: stdlib + packaging + ._console only (no other internal imports
at module level, keeping this layer thin and circular-import-safe).
"""
from __future__ import annotations
@@ -28,7 +28,6 @@ from pathlib import Path
import typer
from packaging.version import InvalidVersion, Version
from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from ._console import console
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
@@ -120,13 +119,7 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
timeout=5,
extra_headers={"Accept": "application/vnd.github+json"},
) as resp:
payload = json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_METADATA_BYTES,
label="GitHub latest release",
).decode("utf-8")
)
payload = json.loads(resp.read().decode("utf-8"))
tag = payload.get("tag_name")
if not isinstance(tag, str) or not tag:
raise ValueError("GitHub API response missing valid tag_name")
+7 -49
View File
@@ -8,7 +8,6 @@ import os
import subprocess
from typing import TYPE_CHECKING
from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from .base import AuthProvider
if TYPE_CHECKING:
@@ -18,20 +17,6 @@ if TYPE_CHECKING:
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
class _TokenResponseTooLarge(Exception):
"""Raised when an Azure AD token response exceeds the bounded read limit."""
def _extract_token(payload: object, key: str) -> str | None:
"""Return a normalized token from a JSON object, or None for other shapes."""
if not isinstance(payload, dict):
return None
token = payload.get(key)
if not isinstance(token, str):
return None
return token.strip() or None
class AzureDevOpsAuth(AuthProvider):
"""Azure DevOps authentication provider.
@@ -89,7 +74,8 @@ class AzureDevOpsAuth(AuthProvider):
if result.returncode != 0:
return None
payload = _json.loads(result.stdout)
return _extract_token(payload, "accessToken")
token = payload.get("accessToken", "").strip()
return token or None
except (
OSError,
subprocess.TimeoutExpired,
@@ -133,37 +119,9 @@ class AzureDevOpsAuth(AuthProvider):
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
from specify_cli.authentication.http import _StripAuthOnRedirect
def reject_token_redirect(_old_url: str, new_url: str) -> None:
# A 307/308 redirect preserves this POST body, including the
# client_secret. Refuse every redirect so credentials cannot
# leave the fixed Microsoft token endpoint.
raise urllib.error.URLError(
f"Azure AD token request must not be redirected to {new_url}"
)
opener = urllib.request.build_opener(
_StripAuthOnRedirect((), reject_token_redirect)
)
with opener.open(req, timeout=30) as resp: # noqa: S310
payload = _json.loads(
read_response_limited(
resp,
max_bytes=MAX_JSON_METADATA_BYTES,
error_type=_TokenResponseTooLarge,
label="Azure DevOps token response",
).decode("utf-8")
)
return _extract_token(payload, "access_token")
except (
urllib.error.URLError,
OSError,
_json.JSONDecodeError,
UnicodeDecodeError,
_TokenResponseTooLarge,
):
# Network failure, malformed JSON, or an oversized response — fall
# through to the next strategy. Unrelated programming errors (other
# ValueErrors, KeyErrors) intentionally propagate so they surface.
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
payload = _json.loads(resp.read().decode("utf-8"))
token = payload.get("access_token", "").strip()
return token or None
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
return None
+4 -18
View File
@@ -13,7 +13,6 @@ import stat
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path
from typing import Any
from urllib.parse import urlparse
@@ -54,19 +53,6 @@ def _is_valid_host_pattern(pattern: str) -> bool:
return pattern.startswith("*.") and "*" not in pattern[2:]
def _norm(value: Any) -> Any:
"""Strip surrounding whitespace from a whitespace-insignificant string
config reference (env-var names, tenant/client ids) before it is stored.
These fields are validated on their ``.strip()``ed form, so an accidentally
padded value passes validation but then silently breaks the verbatim
``os.environ.get(...)`` / URL lookups downstream. Normalizing at store time
mirrors how ``hosts`` is already handled (``h.strip().lower()``). Non-string
values (e.g. ``None``) pass through unchanged.
"""
return value.strip() if isinstance(value, str) else value
def load_auth_config(
path: Path | None = None,
) -> list[AuthConfigEntry]:
@@ -196,10 +182,10 @@ def load_auth_config(
provider=provider,
auth=auth,
token=token,
token_env=_norm(token_env),
tenant_id=_norm(entry_raw.get("tenant_id")),
client_id=_norm(entry_raw.get("client_id")),
client_secret_env=_norm(entry_raw.get("client_secret_env")),
token_env=token_env,
tenant_id=entry_raw.get("tenant_id"),
client_id=entry_raw.get("client_id"),
client_secret_env=entry_raw.get("client_secret_env"),
)
)
+5 -30
View File
@@ -17,7 +17,6 @@ from fnmatch import fnmatch
from typing import Callable
from urllib.parse import urlparse
from .._download_security import is_safe_download_redirect
from . import get_provider
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
@@ -61,23 +60,8 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
RedirectValidator = Callable[[str, str], None]
def _validate_strict_redirect(old_url: str, new_url: str) -> None:
if not is_safe_download_redirect(old_url, new_url):
raise urllib.error.URLError(
f"unsafe redirect to {new_url}: target must use HTTPS with a hostname, "
"must not enter a local target from a remote host, and may use HTTP only "
"within loopback (for example localhost, 127.0.0.1, ::1)"
)
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
"""Redirect handler that guards every redirect it is installed for.
1. Run any caller-provided redirect validator.
2. Reject redirects that are not HTTPS with a hostname. HTTP loopback is
allowed only when the previous hop is also loopback.
3. Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades.
"""
"""Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
def __init__(
self,
@@ -91,8 +75,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
def redirect_request(self, req, fp, code, msg, headers, newurl):
try:
new_parsed = urlparse(newurl)
# Force urllib's syntax and range validation before following.
new_parsed.port
except ValueError as exc:
# Malformed redirect target (e.g. unterminated IPv6 bracket).
# Surface as URLError so callers' download error handling applies.
@@ -100,7 +82,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
if self._redirect_validator is not None:
self._redirect_validator(req.full_url, newurl)
_validate_strict_redirect(req.full_url, newurl)
original_auth = (
req.get_header("Authorization")
@@ -174,12 +155,6 @@ def open_url(
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
*redirect_validator*, when provided, is called with ``(old_url, new_url)``
before following each redirect and may raise to reject the redirect.
Every attempt uses an isolated opener so a process-wide opener installed
with ``urllib.request.install_opener`` cannot replace the redirect guard.
Redirect scheme safety: every attempt goes through
``_StripAuthOnRedirect``, which rejects redirects to non-HTTPS URLs except
HTTP between loopback URLs, and rejects remote-to-local redirects.
"""
entries = find_entries_for_url(url, _load_config())
@@ -213,7 +188,7 @@ def open_url(
# No entry worked (or none matched) — unauthenticated fallback
req = _make_req({})
# No auth is attached on this path, so the handler's host list is empty:
# here it runs redirect validation only, not auth stripping.
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
return opener.open(req, timeout=timeout)
if redirect_validator is not None:
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
return opener.open(req, timeout=timeout)
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310
@@ -40,12 +40,9 @@ 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 "
@@ -146,13 +143,6 @@ def add_source(
raise BundlerError("A catalog url is required.")
try:
parsed = urlparse(url)
# Read .hostname inside the try: a bracketed-but-invalid IPv6 authority
# (e.g. "https://[not-an-ip]/c.json") parses cleanly under urlparse() on
# Python < 3.14 but raises ValueError lazily on the first .hostname access
# (the raise moved eager into urlparse() only in 3.14). Reading it here
# keeps that ValueError inside the guard instead of leaking a raw
# traceback past the CLI's `except BundlerError`. Reuse the value below.
hostname = parsed.hostname
except ValueError as exc:
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
if not (parsed.scheme or parsed.path):
@@ -171,13 +161,13 @@ def add_source(
# netloc — netloc is truthy for host-less URLs like "https://:8080"
# or "https://user@". Validating here keeps junk out of
# bundle-catalogs.yml instead of failing later at fetch time.
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme.lower() != "https" and not is_localhost:
raise BundlerError(
f"Catalog url must use HTTPS (got {parsed.scheme}://). "
"HTTP is only allowed for localhost."
)
if not hostname:
if not parsed.hostname:
raise BundlerError(f"Catalog url must be a valid URL with a host: {url}")
url = _canonicalize_url(url)
+6 -30
View File
@@ -39,35 +39,17 @@ def ensure_within(root: Path, candidate: Path) -> Path:
def load_yaml(path: Path) -> Any:
"""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.
"""
"""Parse a YAML file, returning ``{}`` for an empty document."""
path = Path(path)
if not path.exists():
raise BundlerError(f"File not found: {path}")
try:
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)
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
if data is None and not has_node:
return {}
return data
except OSError as exc:
raise BundlerError(f"Could not read {path}: {exc}") from exc
def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
@@ -78,13 +60,7 @@ def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
yaml.safe_dump(
data,
handle,
sort_keys=False,
default_flow_style=False,
allow_unicode=True,
)
yaml.safe_dump(data, handle, sort_keys=False, default_flow_style=False)
except OSError as exc:
raise BundlerError(f"Could not write {path}: {exc}") from exc
return path
+6 -37
View File
@@ -152,21 +152,14 @@ class CatalogEntry:
if not isinstance(data, dict):
raise BundlerError("Each catalog entry must be a mapping.")
entry_id = str(data.get("id", "")).strip()
# `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):
requires = data.get("requires") or {}
if 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")
if provides_raw is None:
provides_raw = {}
elif not isinstance(provides_raw, dict):
provides_raw = data.get("provides") or {}
if not isinstance(provides_raw, dict):
raise BundlerError(
f"Catalog entry '{entry_id or '<unknown>'}': 'provides' must be a "
"mapping when present."
@@ -256,34 +249,10 @@ 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)
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:
catalogs = data.get("catalogs") if isinstance(data, dict) else None
if not catalogs:
return
if not isinstance(catalogs, list):
# Treat only an absent/``None`` ``catalogs`` as "nothing to merge"; any
# other non-list value (``catalogs: 5``, ``false``, ``0``, ``''``,
# ``{}``) is a malformed config and must raise, not be silently skipped
# by a falsy check. Otherwise a truthy scalar would raise a raw
# ``TypeError: 'int' object is not iterable`` from the loop below, while
# falsy non-lists would be swallowed. Report the same actionable
# BundlerError the sibling reader of this file raises
# (commands_impl/catalog_config.py) so both readers of
# bundle-catalogs.yml agree. An empty list stays valid (loop is a no-op).
raise BundlerError(
f"Malformed catalog config at {config_path}: 'catalogs' must be a "
f"list, got {type(catalogs).__name__}."
)
for raw in catalogs:
src = CatalogSource.from_dict(raw, scope)
by_id[src.id] = src
+4 -13
View File
@@ -111,10 +111,8 @@ class BundleManifest:
license=str(bundle_raw.get("license", "")).strip(),
)
requires_raw = data.get("requires")
if requires_raw is None:
requires_raw = {}
elif not isinstance(requires_raw, dict):
requires_raw = data.get("requires") or {}
if not isinstance(requires_raw, dict):
raise BundlerError("'requires' must be a mapping when present.")
requires = Requires(
speckit_version=str(requires_raw.get("speckit_version", "")).strip(),
@@ -124,18 +122,11 @@ class BundleManifest:
integration = None
integration_raw = data.get("integration")
# Mirror the requires/provides guards above: a present-but-non-mapping
# 'integration' (e.g. a bare string "copilot") was silently dropped,
# leaving the bundle wrongly integration-agnostic. Reject it instead.
if integration_raw is not None and not isinstance(integration_raw, dict):
raise BundlerError("'integration' must be a mapping when present.")
if isinstance(integration_raw, dict) and integration_raw.get("id"):
integration = IntegrationRef(id=str(integration_raw["id"]).strip())
provides = data.get("provides")
if provides is None:
provides = {}
elif not isinstance(provides, dict):
provides = data.get("provides") or {}
if not isinstance(provides, dict):
raise BundlerError("'provides' must be a mapping when present.")
tags_raw = data.get("tags")
+4 -14
View File
@@ -55,13 +55,8 @@ 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")
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".
components_raw = data.get("contributed_components") or []
if not isinstance(components_raw, list):
raise BundlerError(
"Corrupt record: 'contributed_components' must be a list."
)
@@ -126,13 +121,8 @@ 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")
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.
bundles = data.get("bundles") or []
if not isinstance(bundles, list):
raise BundlerError(
f"Corrupt records file: {path} — 'bundles' must be a list."
)
@@ -50,10 +50,7 @@ class InstallResult:
@property
def changed(self) -> bool:
# `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)
return bool(self.installed or self.refreshed)
def install_bundle(
+1 -7
View File
@@ -142,10 +142,4 @@ def _collect_files(
# Skip symlinked files to avoid escaping the bundle directory.
continue
collected.append(path)
# Order by the canonical POSIX arcname (the same key build_bundle uses to
# NAME each member), not by pathlib.Path comparison. Path ordering is
# platform-dependent (Windows folds case and uses backslash separators),
# which would lay out zip members differently across build hosts and break
# the byte-for-byte reproducible-build guarantee even though the member
# names are identical.
return sorted(collected, key=lambda p: p.relative_to(bundle_dir).as_posix())
return sorted(collected)
-2
View File
@@ -700,7 +700,6 @@ def register(app: typer.Typer) -> None:
zed_skill_mode = selected_ai == "zed" and _is_skills_integration
grok_skill_mode = selected_ai == "grok" and _is_skills_integration
cline_skill_mode = selected_ai == "cline"
forge_skill_mode = selected_ai == "forge"
bob_skill_mode = selected_ai == "bob" and _is_skills_integration
native_skill_mode = (
codex_skill_mode
@@ -777,7 +776,6 @@ def register(app: typer.Typer) -> None:
if (
_is_slash_skills_agent(selected_ai, _ai_skills_enabled)
or cline_skill_mode
or forge_skill_mode
):
return f"/speckit-{name}"
return f"/speckit.{name}"
+24 -48
View File
@@ -1330,20 +1330,19 @@ class ExtensionManager:
if not skill_md.is_file():
continue
try:
from ..agents import CommandRegistrar as _Registrar
import yaml as _yaml
raw = skill_md.read_text(encoding="utf-8")
# 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 ""
)
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 ""
)
if source != f"extension:{extension_id}":
continue
except (OSError, UnicodeDecodeError, Exception):
@@ -1387,20 +1386,19 @@ class ExtensionManager:
if not skill_md.is_file():
continue
try:
from ..agents import CommandRegistrar as _Registrar
import yaml as _yaml
raw = skill_md.read_text(encoding="utf-8")
# 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 ""
)
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 ""
)
# Only remove skills explicitly created by this extension
if source != f"extension:{extension_id}":
continue
@@ -2179,13 +2177,7 @@ class ExtensionManager:
return []
return [item for item in value if isinstance(item, str)]
def unregister_agent_artifacts(
self,
agent_name: str,
*,
enabled_only: bool = False,
commands_only: bool = False,
) -> None:
def unregister_agent_artifacts(self, agent_name: str) -> None:
"""Remove extension files registered for a specific agent.
Extension command files are tracked per agent in ``registered_commands``.
@@ -2193,14 +2185,6 @@ class ExtensionManager:
from that agent's skills directory (resolved via its integration config)
and the registry field is cleared.
Set ``enabled_only=True`` when a caller is about to re-register enabled
extensions and must preserve disabled extensions' existing artifacts and
registry entries.
Set ``commands_only=True`` for command-directory reconciliation where
skill artifacts are outside the target agent's lifecycle and must not
be touched.
Skips cleanup when *agent_name* is not a supported agent to avoid
losing registry entries while leaving orphaned files on disk.
"""
@@ -2219,9 +2203,6 @@ class ExtensionManager:
agent_skills_dir = resolve_skills_dir(self.project_root, agent_name)
for ext_id, metadata in self.registry.list().items():
if enabled_only and not metadata.get("enabled", True):
continue
updates: Dict[str, Any] = {}
registered_commands = metadata.get("registered_commands", {})
@@ -2244,7 +2225,7 @@ class ExtensionManager:
registered_skills = self._valid_name_list(
metadata.get("registered_skills", [])
)
if registered_skills and not commands_only:
if registered_skills:
# Only pass the resolved skills_dir when it actually exists.
# Otherwise let _unregister_extension_skills fall back to
# scanning all known agent skills directories, which is useful
@@ -3627,7 +3608,6 @@ class HookExecutor:
dollar_skill_mode = is_dollar_skills_agent(selected_ai, ai_skills_enabled)
kimi_skill_mode = selected_ai == "kimi"
cline_mode = selected_ai == "cline"
forge_mode = selected_ai == "forge"
skill_name = self._skill_name_from_command(command_id)
if dollar_skill_mode and skill_name:
@@ -3638,10 +3618,6 @@ class HookExecutor:
from ..integrations.cline import format_cline_command_name
return f"/{format_cline_command_name(command_id)}"
if forge_mode:
from ..integrations.forge import format_forge_command_name
return f"/{format_forge_command_name(command_id)}"
use_slash = is_slash_skills_agent(selected_ai, ai_skills_enabled)
+2 -15
View File
@@ -428,17 +428,10 @@ 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 = hostname in ("localhost", "127.0.0.1", "::1")
is_localhost = parsed.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.")
@@ -630,22 +623,16 @@ def extension_add(
for warning in manifest.warnings:
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
selected_ai = load_init_options(project_root).get("ai")
is_cline = selected_ai == "cline"
is_forge = selected_ai == "forge"
is_cline = load_init_options(project_root).get("ai") == "cline"
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
+1 -8
View File
@@ -64,15 +64,8 @@ def with_integration_setting(
elif raw_options is not None:
current.pop("parsed_options", None)
# Recompute the separator from the options actually STORED on ``current``
# after the update, not the raw ``parsed_options`` argument. When only
# ``script_type`` changes (``parsed_options`` and ``raw_options`` both
# None), the previously-stored ``parsed_options`` are retained above, so
# deriving the separator from the argument (None) would drop an
# options-dependent separator (e.g. Copilot ``--skills`` -> "-") back to
# the default ".".
current["invoke_separator"] = integration.effective_invoke_separator(
current.get("parsed_options"), project_root
parsed_options, project_root
)
settings[key] = current
return settings
-2
View File
@@ -58,7 +58,6 @@ def _register_builtins() -> None:
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .devin import DevinIntegration
from .droid import DroidIntegration
from .firebender import FirebenderIntegration
from .forge import ForgeIntegration
from .gemini import GeminiIntegration
@@ -96,7 +95,6 @@ def _register_builtins() -> None:
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(DevinIntegration())
_register(DroidIntegration())
_register(FirebenderIntegration())
_register(ForgeIntegration())
_register(GeminiIntegration())
-20
View File
@@ -443,26 +443,6 @@ def _unregister_extensions_for_agent(
)
def _unregister_enabled_extension_commands_for_agent(
project_root: Path,
agent_key: str,
*,
continuing: str,
) -> None:
"""Best-effort removal of enabled extension command artifacts for ``agent_key``."""
_best_effort_extension_op(
project_root,
agent_key,
lambda mgr, key: mgr.unregister_agent_artifacts(
key,
enabled_only=True,
commands_only=True,
),
phase="clean up enabled extension command artifacts for",
continuing=continuing,
)
# ---------------------------------------------------------------------------
# CLI formatting helpers (re-exported from _commands.py)
# ---------------------------------------------------------------------------
@@ -35,7 +35,6 @@ from ._helpers import (
_resolve_script_type,
_set_default_integration,
_set_default_integration_or_exit,
_unregister_enabled_extension_commands_for_agent,
_unregister_extensions_for_agent,
_update_init_options_for_integration,
_write_integration_json,
@@ -55,66 +54,6 @@ def _manifest_tracks_skill_layout(manifest) -> bool:
return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)
def _manifest_path_under(rel_path: str, root: str) -> bool:
"""Return True when manifest key *rel_path* is inside project-relative *root*."""
normalized_root = PurePath(root).as_posix().strip("/")
normalized_rel = PurePath(rel_path).as_posix().strip("/")
if not normalized_root:
return False
return normalized_rel == normalized_root or normalized_rel.startswith(
f"{normalized_root}/"
)
def _legacy_command_root_changed(
integration,
project_root: Path,
old_manifest,
new_manifest,
) -> bool:
"""Return True when command artifacts moved from legacy_dir to canonical dir."""
config = integration.registrar_config or {}
canonical = config.get("dir")
legacy = config.get("legacy_dir")
if (
not isinstance(canonical, str)
or not canonical.strip()
or not isinstance(legacy, str)
or not legacy.strip()
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
):
return False
canonical_dir = project_root / canonical
legacy_dir = project_root / legacy
if not canonical_dir.is_dir() or not legacy_dir.is_dir():
return False
old_had_legacy = any(
_manifest_path_under(rel, legacy) for rel in old_manifest.files
)
new_has_canonical = any(
_manifest_path_under(rel, canonical) for rel in new_manifest.files
)
return old_had_legacy and new_has_canonical
def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool:
"""Return True when the old manifest tracks command files under legacy_dir."""
config = integration.registrar_config or {}
canonical = config.get("dir")
legacy = config.get("legacy_dir")
if (
not isinstance(canonical, str)
or not canonical.strip()
or not isinstance(legacy, str)
or not legacy.strip()
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
):
return False
return any(_manifest_path_under(rel, legacy) for rel in old_manifest.files)
class _PresetRegistryUnreadableError(Exception):
"""Raised when an existing preset registry cannot be read or parsed.
@@ -125,12 +64,7 @@ class _PresetRegistryUnreadableError(Exception):
"""
def _installed_presets_affecting_agent(
project_root,
agent_key: str,
*,
include_skills: bool = True,
) -> list[str]:
def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str]:
"""Return IDs of installed presets with artifacts registered for *agent_key*.
Presets register command overrides for every detected agent and mirror
@@ -183,30 +117,17 @@ def _installed_presets_affecting_agent(
f"preset '{preset_id}' registered_commands is malformed"
)
registered_skills = meta.get("registered_skills", [])
if include_skills:
if not isinstance(registered_skills, (list, tuple)):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_skills is malformed"
)
if not isinstance(registered_skills, (list, tuple)):
raise _PresetRegistryUnreadableError(
f"preset '{preset_id}' registered_skills is malformed"
)
has_commands = bool(registered_commands.get(agent_key))
has_skills = include_skills and bool(registered_skills)
has_skills = bool(registered_skills)
if has_commands or has_skills:
affected.append(preset_id)
return affected
def _installed_command_presets_affecting_agent(
project_root,
agent_key: str,
) -> list[str]:
"""Return installed presets with command artifacts registered for *agent_key*."""
return _installed_presets_affecting_agent(
project_root,
agent_key,
include_skills=False,
)
@integration_app.command("switch")
def integration_switch(
target: str = typer.Argument(help="Integration key to switch to"),
@@ -566,60 +487,6 @@ def integration_upgrade(
integration, current, key, integration_options
)
legacy_command_root_upgrade_pending = _legacy_command_root_upgrade_pending(
integration,
old_manifest,
)
# Guard: Kilo's legacy command root moves from .kilocode/workflows to
# .kilo/commands. Preset command artifacts are registered only during
# preset install/remove, with no agent-scoped re-registration hook to
# recreate them at the new command root while preserving priority and
# composition semantics. Refuse before setup writes .kilo/commands rather
# than leaving legacy preset files orphaned or registry-tracked overrides
# missing from the canonical directory.
if key == "kilocode" and legacy_command_root_upgrade_pending:
config = integration.registrar_config or {}
legacy = config.get("legacy_dir", "legacy command directory")
canonical = config.get("dir", "canonical command directory")
try:
affected_presets = _installed_command_presets_affecting_agent(
project_root,
key,
)
except _PresetRegistryUnreadableError as exc:
console.print(
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan]: "
"the preset registry could not be read to verify installed presets."
)
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
console.print(
"A command directory migration cannot reconcile preset command "
"artifacts while the preset registry state is unknown. Fix or "
"restore [cyan].specify/presets/.registry[/cyan] and retry."
)
raise typer.Exit(1)
if affected_presets:
preset_list = ", ".join(sorted(affected_presets))
console.print(
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan] while "
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
)
console.print(
"Preset command artifacts cannot yet be reconciled across this "
"command directory migration, so the upgrade is refused before "
"changing files."
)
console.print(
"Remove the preset(s), run the upgrade, then reinstall them:\n"
f" [cyan]specify preset remove <id>[/cyan]\n"
f" [cyan]specify integration upgrade {key} --script {selected_script} --force[/cyan]\n"
f" [cyan]specify preset add <id>[/cyan]"
)
raise typer.Exit(1)
# Guard: reject a command↔skills layout change while preset overrides are
# installed for this agent (review #3415). A dual-mode agent (e.g. Bob)
# can flip layout across an upgrade (``--skills`` / ``--legacy-commands``).
@@ -779,22 +646,6 @@ def integration_upgrade(
if stale_removed:
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
legacy_command_root_changed = _legacy_command_root_changed(
integration,
project_root,
old_manifest,
new_manifest,
)
if legacy_command_root_changed:
_unregister_enabled_extension_commands_for_agent(
project_root,
key,
continuing=(
"The integration command directory changed, but legacy enabled "
"extension artifacts may need manual cleanup."
),
)
# Re-register enabled extensions for the upgraded agent so its extension
# commands are (re)created — including agents installed before this
# back-fill existed. Mirrors switch for command registration; see #2886.
+14 -34
View File
@@ -40,25 +40,6 @@ class IntegrationDescriptorError(Exception):
"""Raised when an integration.yml descriptor is invalid."""
def _catalog_shape_error(payload: Any) -> Optional[str]:
"""Return a human-readable reason if *payload* is not a valid integration
catalog document, else ``None``.
Shared by the fresh-fetch and cache-read paths so both enforce the same
format contract: a JSON object carrying ``schema_version`` and a mapping
``integrations``. Keeping a single validator prevents the two paths from
drifting (e.g. a cache that skips the ``schema_version`` check and lets an
older/poisoned payload bypass validation).
"""
if not isinstance(payload, dict):
return "expected a JSON object"
if "schema_version" not in payload or "integrations" not in payload:
return "missing required 'schema_version' or 'integrations' key"
if not isinstance(payload.get("integrations"), dict):
return "'integrations' must be a JSON object"
return None
# ---------------------------------------------------------------------------
# IntegrationCatalogEntry
# ---------------------------------------------------------------------------
@@ -172,18 +153,7 @@ class IntegrationCatalog(CatalogStackBase):
cached_at = cached_at.replace(tzinfo=timezone.utc)
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
if age < self.CACHE_DURATION:
cached = json.loads(cache_file.read_text(encoding="utf-8"))
# A poisoned/older-format cache must clear the SAME shape
# contract as a fresh fetch (via the shared validator) —
# otherwise a payload like [], {"integrations": []}, or one
# missing "schema_version" is returned and later crashes on
# .items()/.get() or silently bypasses the format contract.
# The ValueError is caught just below, which drops the
# corrupt cache and refetches from source.
shape_error = _catalog_shape_error(cached)
if shape_error is not None:
raise ValueError(f"cached catalog has invalid shape: {shape_error}")
return cached
return json.loads(cache_file.read_text(encoding="utf-8"))
except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError):
# Cache is invalid or stale metadata; delete and refetch from source.
try:
@@ -202,10 +172,20 @@ class IntegrationCatalog(CatalogStackBase):
self._validate_catalog_url(final_url)
catalog_data = json.loads(resp.read())
shape_error = _catalog_shape_error(catalog_data)
if shape_error is not None:
if not isinstance(catalog_data, dict):
raise IntegrationCatalogError(
f"Invalid catalog format from {entry.url}: {shape_error}"
f"Invalid catalog format from {entry.url}: expected a JSON object"
)
if (
"schema_version" not in catalog_data
or "integrations" not in catalog_data
):
raise IntegrationCatalogError(
f"Invalid catalog format from {entry.url}"
)
if not isinstance(catalog_data.get("integrations"), dict):
raise IntegrationCatalogError(
f"Invalid catalog format from {entry.url}: 'integrations' must be a JSON object"
)
try:
+3 -22
View File
@@ -77,19 +77,6 @@ class ClineIntegration(MarkdownIntegration):
"""Cline uses hyphenated filenames (e.g. speckit-git-commit.md)."""
return format_cline_command_name(template_name) + ".md"
def build_command_invocation(self, command_name: str, args: str = "") -> str:
"""Cline installs hyphenated slash-commands (``/speckit-<name>``), so the
dispatch invocation must match. The inherited MarkdownIntegration default
builds the dotted ``/speckit.<name>``, which references a command Cline
never registered. Reuse the same hyphenation as command_filename /
the injected frontmatter name (see ``format_cline_command_name``),
mirroring the forge integration.
"""
invocation = "/" + format_cline_command_name(command_name)
if args:
invocation = f"{invocation} {args}"
return invocation
def process_template(self, *args, **kwargs):
"""Ensure shared templates render Cline command references with hyphens."""
kwargs.setdefault("invoke_separator", self.invoke_separator)
@@ -138,14 +125,8 @@ class ClineIntegration(MarkdownIntegration):
content,
)
def post_process_command_content(self, content: str) -> str:
"""Apply Cline-specific transformations to command content.
Overrides the ``IntegrationBase`` hook of the same name so that
``CommandRegistrar.register_commands()`` (which dispatches to
``post_process_command_content``) applies these transforms to
extension/preset command files too, not just core commands.
"""
def post_process_content(self, content: str) -> str:
"""Apply Cline-specific transformations to command content."""
updated = self._inject_hook_command_note(content)
updated = self._rewrite_handoff_references(updated)
return updated
@@ -175,7 +156,7 @@ class ClineIntegration(MarkdownIntegration):
content_bytes = path.read_bytes()
content = content_bytes.decode("utf-8")
updated = self.post_process_command_content(content)
updated = self.post_process_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
@@ -1,135 +0,0 @@
"""Factory Droid CLI integration — skills-based agent.
Droid discovers project skills from
``.factory/skills/speckit-<name>/SKILL.md``. Spec Kit installs into that
native tree so the generated skills are visible to Droid without extra
configuration.
See: https://docs.factory.ai/cli/configuration/skills
"""
from __future__ import annotations
from ..base import SkillsIntegration
class DroidIntegration(SkillsIntegration):
"""Integration for Factory Droid CLI."""
key = "droid"
config = {
"name": "Factory Droid",
"folder": ".factory/",
"commands_subdir": "skills",
"install_url": "https://docs.factory.ai/cli/getting-started/overview",
"requires_cli": True,
}
registrar_config = {
"dir": ".factory/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
multi_install_safe = True
@staticmethod
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
"""Insert ``key: value`` before the closing ``---`` if not already present.
Mirrors the helper used by ``ClaudeIntegration`` / ``VibeIntegration``
so per-agent frontmatter injection stays consistent across skills-based
integrations. Pre-scans for the key to keep injection idempotent.
"""
lines = content.splitlines(keepends=True)
# Pre-scan: bail out if already present in frontmatter
dash_count = 0
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2:
break
continue
if dash_count == 1 and stripped.startswith(f"{key}:"):
return content
# Inject before the closing --- of frontmatter. Always emit a
# newline after the injected key so the key and the closing ---
# stay on separate lines even when the closing delimiter is the
# last line of the file with no trailing newline.
out: list[str] = []
dash_count = 0
injected = False
for line in lines:
stripped = line.rstrip("\n\r")
if stripped == "---":
dash_count += 1
if dash_count == 2 and not injected:
out.append(f"{key}: {value}\n")
injected = True
out.append(line)
return "".join(out)
def post_process_skill_content(self, content: str) -> str:
"""Inject Droid-specific skill frontmatter flags.
Applies the shared hook-command normalization note (skills agents use
hyphenated ``/speckit-<name>`` invocations, not dotted ``/speckit.<name>``)
and the Droid-specific ``user-invocable`` / ``disable-model-invocation``
frontmatter flags so skills are both user- and Droid-invocable.
"""
updated = super().post_process_skill_content(content)
updated = self._inject_frontmatter_flag(updated, "user-invocable")
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
return updated
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build CLI arguments for non-interactive ``droid`` execution.
Uses ``droid exec "<prompt>"`` for headless dispatch. Spec Kit does
not auto-apply any permission-bypass flag: operators who want to
skip interactive confirmation can pass it through
``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS`` (e.g.
``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS="--skip-permissions-unsafe"``).
Output format and model selection mirror the documented CLI flags:
``--output-format json`` (when ``output_json`` is set) and
``--model <id>``. Operator-supplied extra args via
``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS`` are appended after the
canonical Spec Kit flags so the canonical flags are guaranteed to
be present in argv. Note that with duplicate-flag CLI parsing the
later (operator-supplied) value may take precedence over the
canonical one, so operators can still override ``--model`` or
``--output-format``.
"""
if not self.config or not self.config.get("requires_cli"):
return None
args = [
self._resolve_executable(),
"exec",
prompt,
]
# Operator-injected extra args are appended after Spec Kit's
# canonical --model / --output-format flags so the canonical
# flags are guaranteed to be present in argv regardless of
# whatever the operator passes via SPECKIT_INTEGRATION_DROID_EXTRA_ARGS.
# This is a deliberate inversion of the cursor-agent / opencode /
# codex ordering (which all apply extra args first, then append
# canonical flags so the canonical values win under duplicate-flag
# parsing). For Droid the canonical flag values are written into
# argv first, then the operator-supplied values follow; with
# duplicate-flag parsing the later (operator) value may therefore
# take precedence.
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--output-format", "json"])
self._apply_extra_args_env_var(args)
return args
@@ -7,14 +7,13 @@ class KilocodeIntegration(MarkdownIntegration):
key = "kilocode"
config = {
"name": "Kilo Code",
"folder": ".kilo/",
"commands_subdir": "commands",
"folder": ".kilocode/",
"commands_subdir": "workflows",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
"dir": ".kilo/commands",
"legacy_dir": ".kilocode/workflows",
"dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
@@ -13,13 +13,6 @@ _KIRO_ARG_FALLBACK = "(the user will provide the argument in this conversation)"
class KiroCliIntegration(MarkdownIntegration):
key = "kiro-cli"
# Kiro CLI keeps everything under a static, isolated agent root
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
# integration writes to, so it is safe to install alongside others
# (issue #3471). IntegrationBase defaults this to False; declaring it
# True here is the actual behavior change this integration opts into.
# The registry's multi-install-safe contract tests enforce that
# isolation for every integration setting this flag.
multi_install_safe = True
config = {
"name": "Kiro CLI",
@@ -34,3 +27,10 @@ class KiroCliIntegration(MarkdownIntegration):
"args": _KIRO_ARG_FALLBACK,
"extension": ".md",
}
# Kiro CLI keeps everything under a static, isolated agent root
# (``.kiro/`` with commands in ``.kiro/prompts``) that no other
# integration writes to, so it is safe to install alongside others
# (issue #3471). The registry's multi-install-safe contract tests
# enforce that isolation for every integration setting this flag.
multi_install_safe = True
@@ -27,7 +27,6 @@ class LingmaIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
multi_install_safe = True
@classmethod
def options(cls) -> list[IntegrationOption]:
@@ -20,7 +20,6 @@ class OmpIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
multi_install_safe = True
def build_exec_args(
self,
@@ -18,4 +18,3 @@ class PiIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
multi_install_safe = True
+19 -10
View File
@@ -16,10 +16,6 @@ import yaml
from rich.markup import escape as _escape_markup
from .._console import console
from .._download_security import (
is_https_or_localhost_http,
is_safe_download_redirect,
)
preset_app = typer.Typer(
name="preset",
@@ -106,25 +102,38 @@ def preset_add(
elif from_url:
# Validate URL scheme before downloading
from ipaddress import ip_address
from urllib.parse import urlparse as _urlparse
try:
_parsed = _urlparse(from_url)
_parsed.port
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
def _is_allowed_download_url(parsed_url):
host = parsed_url.hostname
if not host:
return False
is_loopback = host == "localhost"
if not is_loopback:
try:
is_loopback = ip_address(host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
pass
return parsed_url.scheme == "https" or (parsed_url.scheme == "http" and is_loopback)
def _validate_download_redirect(old_url, new_url):
if not is_safe_download_redirect(old_url, new_url):
if not _is_allowed_download_url(_urlparse(new_url)):
import urllib.error
raise urllib.error.URLError(
"redirect target must use HTTPS without entering a local "
"target, or stay within loopback over HTTP"
"redirect target must use HTTPS with a hostname, "
"or HTTP for localhost/loopback"
)
if not is_https_or_localhost_http(from_url):
if not _is_allowed_download_url(_parsed):
console.print(
"[red]Error:[/red] URL must use HTTPS with a hostname, "
"or HTTP for localhost/loopback."
@@ -158,7 +167,7 @@ def preset_add(
redirect_validator=_validate_download_redirect,
) as response:
final_url = response.geturl() if hasattr(response, "geturl") else from_url
if not is_https_or_localhost_http(final_url):
if not _is_allowed_download_url(_urlparse(final_url)):
console.print(
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
+77 -26
View File
@@ -20,10 +20,6 @@ import yaml
from rich.markup import escape as _escape_markup
from .._console import console, err_console
from .._download_security import (
is_https_or_localhost_http,
is_safe_download_redirect,
)
from .._project import _resolve_init_dir_override
workflow_app = typer.Typer(
@@ -387,12 +383,27 @@ _RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"}
def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
"""Reject insecure redirects before they are followed."""
import urllib.error
from ipaddress import ip_address
from urllib.parse import urlparse
if is_safe_download_redirect(old_url, new_url):
def _is_loopback_http(url: str) -> bool:
parsed = urlparse(url)
if parsed.scheme != "http":
return False
host = parsed.hostname or ""
if host == "localhost":
return True
try:
return ip_address(host).is_loopback
except ValueError:
return False
if urlparse(new_url).scheme == "https":
return
if _is_loopback_http(old_url) and _is_loopback_http(new_url):
return
raise urllib.error.URLError(
"redirect target must use HTTPS without entering a local target; "
"loopback HTTP may only redirect from another loopback URL"
"redirect target must use HTTPS; loopback HTTP may only redirect from loopback HTTP"
)
@@ -1544,7 +1555,7 @@ def workflow_add(
# precedence over --from so a URL that would be ignored is never fetched.
if dev:
dev_path = Path(source).expanduser()
if dev_path.is_file() and dev_path.suffix.lower() in (".yml", ".yaml"):
if dev_path.is_file() and dev_path.suffix in (".yml", ".yaml"):
_validate_and_install_local(dev_path, str(dev_path))
return
if dev_path.is_dir():
@@ -1568,15 +1579,24 @@ def workflow_add(
else (source if source.startswith(("http://", "https://")) else None)
)
if download_url is not None:
from ipaddress import ip_address
from urllib.parse import urlparse
from specify_cli.authentication.http import open_url as _open_url
try:
urlparse(download_url).port
parsed_src = urlparse(download_url)
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(download_url)}")
raise typer.Exit(1)
if not is_https_or_localhost_http(download_url):
src_host = parsed_src.hostname or ""
src_loopback = src_host == "localhost"
if not src_loopback:
try:
src_loopback = ip_address(src_host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a DNS name); keep default non-loopback.
pass
if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback):
console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.")
raise typer.Exit(1)
@@ -1627,7 +1647,16 @@ def workflow_add(
redirect_validator=_reject_insecure_download_redirect,
) as resp:
final_url = resp.geturl()
if not is_https_or_localhost_http(final_url):
final_parsed = urlparse(final_url)
final_host = final_parsed.hostname or ""
final_lb = final_host == "localhost"
if not final_lb:
try:
final_lb = ip_address(final_host).is_loopback
except ValueError:
# Redirect host is not an IP literal; keep loopback as determined above.
pass
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
console.print(
f"[red]Error:[/red] URL redirected to non-HTTPS: {_escape_markup(final_url)}"
)
@@ -1685,7 +1714,7 @@ def workflow_add(
# Try as a local file/directory
source_path = Path(source)
if source_path.exists():
if source_path.is_file() and source_path.suffix.lower() in (".yml", ".yaml"):
if source_path.is_file() and source_path.suffix in (".yml", ".yaml"):
_validate_and_install_local(source_path, str(source_path))
return
elif source_path.is_dir():
@@ -1759,17 +1788,27 @@ def _install_workflow_from_catalog(
raise typer.Exit(1)
# Validate URL scheme (HTTPS required, HTTP allowed for localhost only)
from ipaddress import ip_address
from urllib.parse import urlparse
try:
parsed_url = urlparse(workflow_url)
parsed_url.port
url_host = parsed_url.hostname or ""
except ValueError:
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' has a malformed install URL."
)
raise typer.Exit(1)
if not is_https_or_localhost_http(workflow_url):
is_loopback = False
if url_host == "localhost":
is_loopback = True
else:
try:
is_loopback = ip_address(url_host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
pass
if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback):
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' has an invalid install URL. "
"Only HTTPS URLs are allowed, except HTTP for localhost/loopback."
@@ -1823,7 +1862,16 @@ def _install_workflow_from_catalog(
) as response:
# Validate final URL after redirects
final_url = response.geturl()
if not is_https_or_localhost_http(final_url):
final_parsed = urlparse(final_url)
final_host = final_parsed.hostname or ""
final_loopback = final_host == "localhost"
if not final_loopback:
try:
final_loopback = ip_address(final_host).is_loopback
except ValueError:
# Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
pass
if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback):
_safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before)
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}"
@@ -2376,15 +2424,7 @@ def workflow_info(
console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]")
for step in definition.steps:
stype = step.get("type", "command")
# 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))}]"
)
console.print(f" → {step.get('id', '?')} [{stype}]")
return
# Try catalog
@@ -2654,17 +2694,28 @@ def workflow_step_add(
)
raise typer.Exit(1)
from urllib.parse import urlparse
from specify_cli.authentication.http import open_url as _open_url
def _safe_fetch(url: str) -> bytes:
if not is_https_or_localhost_http(url):
parsed = urlparse(url)
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
raise ValueError(f"Refusing to fetch from non-HTTPS URL: {url}")
if not parsed.hostname:
raise ValueError(f"Refusing to fetch from URL with no hostname: {url}")
with _open_url(
url, timeout=30, redirect_validator=_reject_insecure_download_redirect
) as resp:
final_url = resp.geturl()
if not is_https_or_localhost_http(final_url):
final_parsed = urlparse(final_url)
final_is_localhost = final_parsed.hostname in ("localhost", "127.0.0.1", "::1")
if final_parsed.scheme != "https" and not (
final_parsed.scheme == "http" and final_is_localhost
):
raise ValueError(f"Redirect to non-HTTPS URL: {final_url}")
if not final_parsed.hostname:
raise ValueError(f"Redirect to URL with no hostname: {final_url}")
return _read_response_within_limit(resp)
_validate_step_id_or_exit(step_id)
+1 -5
View File
@@ -894,11 +894,7 @@ class StepRegistry:
import copy
from datetime import datetime, timezone
raw_existing = self.data["steps"].get(step_id)
# Corrupted-but-parseable registries may hold non-dict entries; treat
# them as absent rather than crashing on existing.get() (mirrors
# WorkflowRegistry.add).
existing = raw_existing if isinstance(raw_existing, dict) else {}
existing = self.data["steps"].get(step_id, {})
metadata_to_store = copy.deepcopy(metadata)
metadata_to_store["installed_at"] = existing.get(
"installed_at", datetime.now(timezone.utc).isoformat()
-8
View File
@@ -1400,14 +1400,6 @@ class WorkflowEngine:
) -> dict[str, Any]:
"""Resolve workflow inputs against definitions and provided values."""
resolved: dict[str, Any] = {}
# execute()/resume() accept UNVALIDATED definitions (load_workflow does
# not validate). A non-mapping ``inputs:`` block (bare ``inputs:`` ->
# None, or ``inputs: []``) is stored raw, so iterating ``.items()`` here
# would crash the run with AttributeError. Treat a non-mapping inputs
# block as "no inputs"; validate_workflow reports the malformed shape
# via its own isinstance check.
if not isinstance(definition.inputs, dict):
return {}
for name, input_def in definition.inputs.items():
if not isinstance(input_def, dict):
continue
+2 -12
View File
@@ -392,14 +392,8 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An
)
return _filter_from_json(value)
# 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)
# Parse filter name and argument
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
if filter_match:
fname = filter_match.group(1)
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
@@ -541,10 +535,6 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
items = [
_evaluate_simple_expression(i.strip(), namespace)
for i in _split_top_level_commas(inner)
# Drop empty segments from trailing/leading/double commas ([1, 2,] ->
# [1, 2], not [1, 2, None]). An intentional empty-string element
# ('') strips to "''" (truthy), so ['', 'a'] is preserved.
if i.strip()
]
return items
+3 -15
View File
@@ -292,21 +292,9 @@ def _traverse_and_apply(
cases[case_key] = _traverse_and_apply(case_steps, edits_by_anchor, sources)
result.append(step)
# Insert after: higher-priority overlays land closer to the anchor
# (reversed merge order), but a single overlay's own inserts must keep
# their declared order — mirroring the forward insert_before loop above.
# Reversing the whole flat list would also flip an overlay's own edits,
# so group contiguous same-layer edits and reverse the GROUP order only.
after_groups: list[list[tuple[OverlayLayer, OverlayEdit]]] = []
for layer, edit in edits:
if edit.operation != "insert_after":
continue
if after_groups and after_groups[-1][0][0] is layer:
after_groups[-1].append((layer, edit))
else:
after_groups.append([(layer, edit)])
for group in reversed(after_groups):
for layer, edit in group:
# Insert after (highest priority closest to anchor — reversed merge order).
for layer, edit in reversed(edits):
if edit.operation == "insert_after":
new_step = copy.deepcopy(edit.step)
_record_sources_recursively(new_step, layer.source, sources)
result.append(new_step)
@@ -189,11 +189,7 @@ class CommandStep(StepBase):
not possible (integration not found, CLI not installed, or
dispatch not supported).
"""
if not integration_key or not isinstance(integration_key, str):
# A non-string integration (a list/dict/expression that resolved to
# one) would raise TypeError: unhashable type from get_integration's
# dict lookup below and abort the whole run. Treat it as "not
# dispatchable" so execute() falls through to its FAILED StepResult.
if not integration_key:
return None
try:
@@ -26,7 +26,7 @@ class GateStep(StepBase):
later with ``specify workflow resume``.
The user's choice is stored in ``output.choice``. ``on_reject``
controls abort / skip / retry behaviour.
controls abort / skip behaviour.
"""
type_key = "gate"
@@ -168,11 +168,7 @@ class GateStep(StepBase):
except (EOFError, KeyboardInterrupt):
print()
return options[-1] # default to last (usually reject)
# isdecimal() (not isdigit()): int() accepts exactly the decimal-digit
# set, whereas isdigit() also returns True for superscripts/subscripts
# (e.g. "²") that int() then rejects with ValueError — crashing
# this interactive loop.
if raw.isdecimal() and 1 <= int(raw) <= len(options):
if raw.isdigit() and 1 <= int(raw) <= len(options):
return options[int(raw) - 1]
# Also accept the option name directly
if raw.lower() in [o.lower() for o in options]:
@@ -59,7 +59,7 @@ class InitStep(StepBase):
Extra options for the integration (e.g. ``"--skills"`` or
``"--commands-dir .myagent/cmds"``).
``script``
Script type, ``sh``, ``ps``, or ``py``.
Script type, ``sh`` or ``ps``.
``force``
Merge/overwrite without confirmation when the directory is not
empty.
@@ -138,10 +138,7 @@ class PromptStep(StepBase):
context: StepContext,
) -> dict[str, Any] | None:
"""Dispatch *prompt* directly through the integration CLI."""
if not integration_key or not isinstance(integration_key, str) or not prompt:
# A non-string integration would raise TypeError: unhashable type
# from get_integration's dict lookup and abort the run; treat it as
# not dispatchable so execute() falls through to its FAILED result.
if not integration_key or not prompt:
return None
try:
-20
View File
@@ -14,25 +14,6 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
## Scope Guard
This command's own work is limited to updating the project constitution and propagating
constitution-driven changes to the dependent artifacts identified in this command.
- Classify every part of the user input as either constitution content or a separate,
non-governance intent.
- If the input includes feature implementation, code generation, refactoring, building, or
deployment requests, you **MUST NOT** execute them. Extract them as deferred intents instead.
- You **MUST NOT** create, modify, or delete application source files, feature routes,
components, tests, deployment files, or other artifacts unrelated to the constitution
workflow and its required propagation.
- If it is unclear whether an instruction is constitution content, ask for clarification before
making changes.
- After completing the constitution update, include a `Next Actions` section for each deferred
intent. List the original intent and suggest the appropriate follow-up Spec Kit command, such
as `__SPECKIT_COMMAND_SPECIFY__`, without invoking it.
- If there are no non-governance intents, omit the `Next Actions` section.
## Pre-Execution Checks
**Check for extension hooks (before constitution update)**:
@@ -123,7 +104,6 @@ Follow this execution flow:
- New version and bump rationale.
- Any files flagged for manual follow-up.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
- A `Next Actions` section for any deferred non-governance intents.
Formatting & Style Requirements:
+2 -2
View File
@@ -139,9 +139,9 @@ Given that feature description, do this:
7. Identify Key Entities (if data involved)
8. Return: SUCCESS (spec ready for planning)
7. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
8. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
-84
View File
@@ -43,76 +43,6 @@ def test_builtin_default_stack_when_no_config(tmp_path: Path):
assert all(s.scope is Scope.BUILTIN for s in sources)
def test_non_list_catalogs_raises_actionable_error(tmp_path: Path):
"""A scalar ``catalogs:`` value raises a clean BundlerError, not a raw
'int object is not iterable' TypeError — matching what the sibling reader
(bundle catalog list) already reports for the same file."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
"catalogs: 5\n", encoding="utf-8"
)
with pytest.raises(BundlerError, match="must be a list"):
load_source_stack(tmp_path)
@pytest.mark.parametrize("value", ["false", "0", "''", "{}"])
def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str):
"""A *falsy* non-list ``catalogs:`` value (false/0/''/{}) must also raise —
only an absent/``None`` value means "nothing to merge". A plain falsy check
would silently swallow these, diverging from the sibling reader."""
make_project(tmp_path)
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
f"catalogs: {value}\n", encoding="utf-8"
)
with pytest.raises(BundlerError, match="must be a list"):
load_source_stack(tmp_path)
@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 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.
sources = load_source_stack(tmp_path)
assert len(sources) > 0
def test_project_config_overrides_same_id(tmp_path: Path):
make_project(tmp_path)
config = {
@@ -241,17 +171,3 @@ 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)
-41
View File
@@ -124,44 +124,3 @@ def test_string_mcp_rejected_not_split_per_character():
data["requires"]["mcp"] = "github"
with pytest.raises(BundlerError, match="'requires.mcp' must be a list of strings"):
BundleManifest.from_dict(data)
def test_string_integration_rejected_not_silently_dropped():
# A present-but-non-mapping 'integration' (a bare string) was silently
# dropped, leaving the bundle wrongly integration-agnostic. Reject it like
# the sibling requires/provides mapping fields.
data = valid_manifest_dict()
data["integration"] = "copilot"
with pytest.raises(BundlerError, match="'integration' must be a mapping when present"):
BundleManifest.from_dict(data)
@pytest.mark.parametrize("bad", [[], "", 0, False, "extensions"])
def test_non_mapping_provides_rejected_including_falsy(bad):
# `data.get("provides") or {}` coerced a FALSY non-mapping ([], '', 0, False)
# to {} before the type check, so a malformed manifest passed validation as
# a bundle that provides nothing. Only an absent/None value means "empty".
data = valid_manifest_dict()
data["provides"] = bad
with pytest.raises(BundlerError, match="'provides' must be a mapping when present"):
BundleManifest.from_dict(data)
@pytest.mark.parametrize("bad", [[], "", 0, False, "speckit>=0.1"])
def test_non_mapping_requires_rejected_including_falsy(bad):
# Same falsy-coercion hole for `requires`.
data = valid_manifest_dict()
data["requires"] = bad
with pytest.raises(BundlerError, match="'requires' must be a mapping when present"):
BundleManifest.from_dict(data)
def test_absent_provides_and_requires_do_not_raise_mapping_error():
# Absent (None) optional mappings default to empty and must NOT trigger the
# "must be a mapping when present" guard — that is reserved for present
# non-mappings. (Structural completeness, e.g. requires.speckit_version, is
# a separate concern checked by structural_errors().)
data = valid_manifest_dict()
data.pop("provides", None)
data.pop("requires", None)
BundleManifest.from_dict(data) # does not raise BundlerError
@@ -1,40 +0,0 @@
"""Contract tests for the script variants bundled into the wheel's core_pack.
``specify init --script <type>`` installs from ``specify_cli/core_pack/scripts/``
when the CLI runs from a wheel. Any script variant that lives in the repository
must therefore be force-included at build time, otherwise the generated
commands reference scripts the released package never ships (#3665).
"""
from __future__ import annotations
import tomllib
from pathlib import Path
REPO_ROOT = Path(__file__).parents[2]
def _force_include() -> dict[str, str]:
with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file:
pyproject = tomllib.load(pyproject_file)
return pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"]
def test_every_script_variant_is_bundled_into_core_pack():
force_include = _force_include()
variants = sorted(
path.name for path in (REPO_ROOT / "scripts").iterdir() if path.is_dir()
)
assert variants, "expected at least one script variant under scripts/"
for variant in variants:
assert force_include.get(f"scripts/{variant}") == (
f"specify_cli/core_pack/scripts/{variant}"
), f"scripts/{variant} is missing from the wheel force-include list"
def test_python_script_variant_is_bundled():
# Explicit regression guard for #3665: `--script py` shipped skills that
# invoked python3 .specify/scripts/python/*.py while the wheel bundled
# only the bash and PowerShell variants.
assert _force_include()["scripts/python"] == "specify_cli/core_pack/scripts/python"
-570
View File
@@ -698,22 +698,6 @@ class TestCreateFeaturePowerShell:
assert rt.returncode == 0, rt.stderr
assert "HAS_GIT" not in rt.stdout
def test_persist_hint_matches_twins(self, tmp_path: Path):
"""The non-JSON SPECIFY_FEATURE hint must use the '# To persist in your
shell: $env:SPECIFY_FEATURE = '<name>' form — matching the core
create-new-feature.ps1 twin and the bash/python twins of this script —
not the old 'environment variable set to:' wording (the env var is only
set in this child process, so the actionable output is the persist hint)."""
project = _setup_project(tmp_path)
result = _run_pwsh(
"create-new-feature-branch.ps1", project,
"-ShortName", "persist", "Persist hint feature",
)
assert result.returncode == 0, result.stderr
assert "# To persist in your shell:" in result.stdout
assert "$env:SPECIFY_FEATURE = '001-persist'" in result.stdout
assert "environment variable set to:" not in result.stdout
def test_help_documents_branch_prefix(self, tmp_path: Path):
"""-Help documents both template config knobs."""
project = _setup_project(tmp_path)
@@ -1167,295 +1151,6 @@ 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):
@@ -1516,271 +1211,6 @@ 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) ────────────────────────
@@ -515,32 +515,6 @@ class TestAutoCommitParity:
assert p.stderr.strip() == b.stderr.strip()
assert self._last_message(bash_proj) == self._last_message(py_proj) == "spec done"
def test_custom_message_with_trailing_whitespace_after_quote(self, tmp_path: Path):
"""Trailing whitespace after a closing quote must not leave the quote
dangling in the commit message. A raw close-quote strip anchored to
end-of-string skips the quote when spaces follow it (``spec done" ``);
trimming first (matching the PowerShell twin) yields a clean message and
keeps bash/python in parity."""
bash_proj, py_proj = _twin_projects(tmp_path)
config = (
"auto_commit:\n"
" default: false\n"
" after_specify:\n"
" enabled: true\n"
' message: "spec done" \n' # trailing spaces after the closing quote
)
for proj in (bash_proj, py_proj):
_write_config(proj, config)
self._dirty(proj)
b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
p = _run_py("auto-commit", py_proj, "after_specify")
_assert_parity(b, p)
assert (
self._last_message(bash_proj)
== self._last_message(py_proj)
== "spec done"
)
def test_default_true_applies_to_unlisted_event(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
+2 -33
View File
@@ -1,46 +1,15 @@
"""HTTP test helpers shared by CLI tests."""
"""HTTP test helpers shared by version-related CLI tests."""
import io
import json
import urllib.request
from unittest.mock import MagicMock
import pytest
def mock_urlopen_response(payload: dict) -> MagicMock:
"""Build a urlopen context-manager mock whose read returns JSON."""
body = json.dumps(payload).encode("utf-8")
resp = MagicMock()
resp.read.side_effect = io.BytesIO(body).read
resp.read.return_value = body
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = False
return cm
@pytest.fixture(autouse=True)
def route_opener_open_through_urlopen(monkeypatch):
"""Route build_opener().open through urllib.request.urlopen.
``open_url(...)`` fetches via ``build_opener(...).open()``, which bypasses
``urllib.request.urlopen`` — and with it the urlopen patches these test
modules are built on.
Delegating ``open()`` to urlopen at call time keeps those patches
effective; the redirect handler's own behavior is covered by
``TestRedirectStripping`` in test_authentication.py.
Import this fixture into a test module to activate it there.
"""
class _UrlopenDelegatingOpener:
def open(self, req, data=None, timeout=None):
if data is None:
return urllib.request.urlopen(req, timeout=timeout)
return urllib.request.urlopen(req, data=data, timeout=timeout)
monkeypatch.setattr(
urllib.request,
"build_opener",
lambda *handlers: _UrlopenDelegatingOpener(),
)
@@ -491,16 +491,3 @@ 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

Some files were not shown because too many files have changed in this diff Show More