mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
67 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46ce37f6c | ||
|
|
d8d62756fe | ||
|
|
9602ad2edf | ||
|
|
39f2ac3c63 | ||
|
|
7fc5b236c8 | ||
|
|
118062eac4 | ||
|
|
0117a7b977 | ||
|
|
2355fcb350 | ||
|
|
98c9e67ce2 | ||
|
|
6136706ef3 | ||
|
|
683bfd00c9 | ||
|
|
42c7230aa9 | ||
|
|
e6a3ccfb27 | ||
|
|
962f9f0765 | ||
|
|
c1028e5506 | ||
|
|
9e150cd3b2 | ||
|
|
99dc915ae3 | ||
|
|
103ad73775 | ||
|
|
59e63699b8 | ||
|
|
015d125667 | ||
|
|
3ca0eb169e | ||
|
|
c6cb25cb4a | ||
|
|
eb8108e7b3 | ||
|
|
403fcdc6fd | ||
|
|
2fb94e0f9c | ||
|
|
446ee329b1 | ||
|
|
c0fe0e43cd | ||
|
|
ae0b8ca2b0 | ||
|
|
2fe35bf898 | ||
|
|
73908f798a | ||
|
|
ffe2a7ffd6 | ||
|
|
ae82c74339 | ||
|
|
71e6201790 | ||
|
|
ea6843c1fe | ||
|
|
b0850c97e6 | ||
|
|
781a14a6d2 | ||
|
|
be0c741ebb | ||
|
|
36754522f7 | ||
|
|
391cc0dff8 | ||
|
|
1631c0a50f | ||
|
|
6385250264 | ||
|
|
bfe4772b79 | ||
|
|
c0ba81190b | ||
|
|
e53ddcb0cc | ||
|
|
769acafcab | ||
|
|
2a397aad6c | ||
|
|
4d3a4281bc | ||
|
|
58f5730dd5 | ||
|
|
52b20f1a82 | ||
|
|
579579ba80 | ||
|
|
34a086940f | ||
|
|
0b6bf865c1 | ||
|
|
043c4ec572 | ||
|
|
5ad312863f | ||
|
|
58b3cadb39 | ||
|
|
cce47f6900 | ||
|
|
e14561f773 | ||
|
|
cf0abe28f7 | ||
|
|
9b3546f437 | ||
|
|
e9dfe900f6 | ||
|
|
d0a83890d5 | ||
|
|
88b3230e2e | ||
|
|
f5be0fffc8 | ||
|
|
34bbaafbf3 | ||
|
|
8def197612 | ||
|
|
a62fb1f034 | ||
|
|
a5b6ce4173 |
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
@@ -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**: Alquimia AI, 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
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -62,6 +62,7 @@ body:
|
||||
label: AI Agent
|
||||
description: Which AI agent are you using?
|
||||
options:
|
||||
- Alquimia AI
|
||||
- Amp
|
||||
- Antigravity
|
||||
- Auggie CLI
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -56,6 +56,7 @@ body:
|
||||
description: Does this feature relate to a specific AI agent?
|
||||
options:
|
||||
- All agents
|
||||
- Alquimia AI
|
||||
- Amp
|
||||
- Antigravity
|
||||
- Auggie CLI
|
||||
|
||||
16
.github/workflows/add-community-bundle.lock.yml
generated
vendored
16
.github/workflows/add-community-bundle.lock.yml
generated
vendored
@@ -33,10 +33,10 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
|
||||
#
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1352,7 +1352,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
@@ -1419,7 +1419,7 @@ jobs:
|
||||
mkdir -p /tmp/gh-aw/threat-detection
|
||||
touch /tmp/gh-aw/threat-detection/detection.log
|
||||
- name: Setup Node.js
|
||||
uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
with:
|
||||
node-version: '24'
|
||||
package-manager-cache: false
|
||||
@@ -1678,7 +1678,7 @@ jobs:
|
||||
await main();
|
||||
- name: Checkout repository (trusted default branch for comment events)
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -1686,7 +1686,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Checkout repository
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
|
||||
uses: actions/checkout@df4cb1c069e1874edd31b4311f1884172cec0e10 # v6.0.3
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
12
.github/workflows/add-community-extension.lock.yml
generated
vendored
12
.github/workflows/add-community-extension.lock.yml
generated
vendored
@@ -33,7 +33,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1332,7 +1332,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
@@ -1658,7 +1658,7 @@ jobs:
|
||||
await main();
|
||||
- name: Checkout repository (trusted default branch for comment events)
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -1666,7 +1666,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Checkout repository
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
12
.github/workflows/add-community-preset.lock.yml
generated
vendored
12
.github/workflows/add-community-preset.lock.yml
generated
vendored
@@ -33,7 +33,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1332,7 +1332,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
@@ -1658,7 +1658,7 @@ jobs:
|
||||
await main();
|
||||
- name: Checkout repository (trusted default branch for comment events)
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -1666,7 +1666,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Checkout repository
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
8
.github/workflows/bug-assess.lock.yml
generated
vendored
8
.github/workflows/bug-assess.lock.yml
generated
vendored
@@ -32,7 +32,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -430,7 +430,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1277,7 +1277,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
|
||||
12
.github/workflows/bug-fix.lock.yml
generated
vendored
12
.github/workflows/bug-fix.lock.yml
generated
vendored
@@ -33,7 +33,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1338,7 +1338,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
@@ -1664,7 +1664,7 @@ jobs:
|
||||
await main();
|
||||
- name: Checkout repository (trusted default branch for comment events)
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -1672,7 +1672,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Checkout repository
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
8
.github/workflows/bug-test.lock.yml
generated
vendored
8
.github/workflows/bug-test.lock.yml
generated
vendored
@@ -32,7 +32,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -431,7 +431,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1299,7 +1299,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -19,14 +19,14 @@ jobs:
|
||||
language: [ 'actions', 'python' ]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for git info
|
||||
|
||||
|
||||
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
|
||||
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
|
||||
with:
|
||||
globs: |
|
||||
'**/*.md'
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
# shellcheck is preinstalled on ubuntu-latest runners.
|
||||
# Start at --severity=error to block real bugs without flagging style
|
||||
|
||||
6
.github/workflows/publish-pypi.yml
vendored
6
.github/workflows/publish-pypi.yml
vendored
@@ -27,12 +27,12 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
path: dist/
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Publish to PyPI
|
||||
run: uv publish
|
||||
|
||||
2
.github/workflows/release-trigger.yml
vendored
2
.github/workflows/release-trigger.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
8
.github/workflows/security.yml
vendored
8
.github/workflows/security.yml
vendored
@@ -19,12 +19,12 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
@@ -52,10 +52,10 @@ jobs:
|
||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
|
||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -13,10 +13,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Run ruff check
|
||||
run: uvx ruff check src tests
|
||||
run: uvx ruff@0.15.0 check src tests
|
||||
|
||||
pytest:
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -34,10 +34,10 @@ jobs:
|
||||
python-version: ["3.13", "3.14"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
|
||||
99
AGENTS.md
99
AGENTS.md
@@ -10,6 +10,20 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their
|
||||
|
||||
---
|
||||
|
||||
## Quickstart — Add a New Integration in 5 Steps
|
||||
|
||||
If you are new to the codebase and want to add support for a new AI agent, here is the shortest path from zero to a working integration:
|
||||
|
||||
1. **Choose a base class** — most agents only need `MarkdownIntegration`. See [Choose a base class](#1-choose-a-base-class).
|
||||
2. **Create a subpackage** — add `src/specify_cli/integrations/<package_dir>/__init__.py` with the required `key`, `config`, and `registrar_config` fields.
|
||||
3. **Register it** — add one import and one `_register()` call in `src/specify_cli/integrations/__init__.py` (both alphabetical).
|
||||
4. **Write a test file** — create `tests/integrations/test_integration_<key>.py` (hyphens in the key become underscores in the filename).
|
||||
5. **Run and verify** — use `specify init --integration <key>` to exercise the full install/uninstall cycle.
|
||||
|
||||
Each step is expanded under [Adding a New Integration](#adding-a-new-integration). Note that agent **context files** (`CLAUDE.md`, `AGENTS.md`, …) are **not** handled by the integration — that is owned by the opt-in `agent-context` extension; see [Context file behavior](#4-context-file-behavior).
|
||||
|
||||
---
|
||||
|
||||
## Integration Architecture
|
||||
|
||||
Each AI agent is a self-contained **integration subpackage** under `src/specify_cli/integrations/<key>/`. The subpackage exposes a single class that declares all metadata and inherits setup/teardown logic from a base class. Built-in integrations are then instantiated and added to the global `INTEGRATION_REGISTRY` by `src/specify_cli/integrations/__init__.py` via `_register_builtins()`.
|
||||
@@ -34,6 +48,30 @@ The registry is the **single source of truth for Python integration metadata**.
|
||||
|
||||
---
|
||||
|
||||
## IntegrationManifest — File Tracking
|
||||
|
||||
`manifest.py` provides the `IntegrationManifest` class, which records every file an integration installs. This record is what makes uninstall reliable and safe.
|
||||
|
||||
### How it works
|
||||
|
||||
`setup()` receives an `IntegrationManifest` and writes files through it rather than touching the filesystem directly:
|
||||
|
||||
```python
|
||||
# Produce a new file and record its hash for later verification.
|
||||
manifest.record_file("commands/speckit.plan.md", processed_content)
|
||||
|
||||
# Adopt a pre-existing file the integration is now responsible for.
|
||||
manifest.record_existing(".vscode/settings.json")
|
||||
```
|
||||
|
||||
The manifest is persisted at `.specify/integrations/<key>.manifest.json` (one per integration, keyed by `key`) and stores a SHA-256 hash per file. When the user runs `specify integration uninstall <key>`, `teardown()` delegates to `manifest.uninstall()`, which removes only files whose current hash still matches the recorded value — so files the user later edited by hand are skipped, not clobbered (use `specify integration uninstall <key> --force` to remove modified tracked files anyway).
|
||||
|
||||
### Why this matters
|
||||
|
||||
Without hash-tracked manifests, uninstall would either remove files it should not (destructive) or leave orphans behind (messy). If you write a custom `setup()`, route **every** file you create through `manifest.record_file(...)` (or `record_existing(...)` for files you adopt) so uninstall can reason about them.
|
||||
|
||||
---
|
||||
|
||||
## Adding a New Integration
|
||||
|
||||
### 1. Choose a base class
|
||||
@@ -64,13 +102,14 @@ class KilocodeIntegration(MarkdownIntegration):
|
||||
key = "kilocode"
|
||||
config = {
|
||||
"name": "Kilo Code",
|
||||
"folder": ".kilocode/",
|
||||
"commands_subdir": "workflows",
|
||||
"folder": ".kilo/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".kilocode/workflows",
|
||||
"dir": ".kilo/commands",
|
||||
"legacy_dir": ".kilocode/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
@@ -201,8 +240,8 @@ Only add custom setup logic when the agent needs non-standard behavior. Integrat
|
||||
specify init my-project --integration <key>
|
||||
|
||||
# Verify files were created in the commands directory configured by
|
||||
# config["folder"] + config["commands_subdir"] (for example, .kilocode/workflows/)
|
||||
ls -R my-project/.kilocode/workflows/
|
||||
# config["folder"] + config["commands_subdir"] (for example, .kilo/commands/)
|
||||
ls -R my-project/.kilo/commands/
|
||||
|
||||
# Uninstall cleanly
|
||||
cd my-project && specify integration uninstall <key>
|
||||
@@ -510,4 +549,54 @@ Disclosure is **continuous**, not a one-time event. A single AI-disclosure parag
|
||||
|
||||
---
|
||||
|
||||
## Error Handling and Debugging
|
||||
|
||||
### Common Errors and Fixes
|
||||
|
||||
| Symptom | Likely Cause | Fix |
|
||||
|---|---|---|
|
||||
| `Integration '<key>' not found` | Missing `_register()` call | Add `_register(<Name>Integration())` inside `_register_builtins()` |
|
||||
| `NameError: name '<Name>Integration' is not defined` at startup | Missing import | Add `from .<package_dir> import <Name>Integration` inside `_register_builtins()` |
|
||||
| CLI check fails for a `requires_cli: True` agent | `key` does not match the executable name | Set `key` to the exact name `shutil.which(key)` must resolve (e.g. `"cursor-agent"`, not `"cursor"`) |
|
||||
| Command files have the wrong argument syntax | Wrong `args` value in `registrar_config` | Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML/YAML agents, or the agent's custom placeholder |
|
||||
| `ModuleNotFoundError` on a brand-new subpackage under pytest only | Ambient interpreter with a stale editable `.pth` | Run inside this tree's own venv (see Common Pitfall 6) |
|
||||
| Uninstall leaves files behind, or skips files you expected removed | Files not recorded via the manifest, or their hash changed after install | Route every created file through `manifest.record_file(...)`; user-edited files are intentionally skipped unless `force=True` |
|
||||
| Context file (`CLAUDE.md`, etc.) not updated | Expecting the CLI to manage it | Context files are owned by the opt-in `agent-context` extension, not the integration — see [Context file behavior](#4-context-file-behavior) |
|
||||
|
||||
### Debugging Tips
|
||||
|
||||
**Inspect the manifest** to see what an installed integration tracks:
|
||||
|
||||
```bash
|
||||
cat .specify/integrations/<key>.manifest.json
|
||||
```
|
||||
|
||||
**Verify a CLI tool is detected** before debugging a `requires_cli` agent:
|
||||
|
||||
```bash
|
||||
which <key> # Should print the executable path if installed
|
||||
```
|
||||
|
||||
**Verify the installed output structure** after `specify init`:
|
||||
|
||||
```bash
|
||||
find my-project/<folder> -type f
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Contribution Checklist
|
||||
|
||||
Before opening or merging an integration PR, confirm the following:
|
||||
|
||||
- [ ] Added the integration subpackage under `src/specify_cli/integrations/<package_dir>/`.
|
||||
- [ ] Registered it (import **and** `_register()`) in `src/specify_cli/integrations/__init__.py`, both alphabetical.
|
||||
- [ ] Added or updated tests in `tests/integrations/test_integration_<key>.py`.
|
||||
- [ ] Verified the install/uninstall flow with `specify init --integration <key>`.
|
||||
- [ ] Did **not** add `context_file` handling to the CLI (that belongs to the `agent-context` extension).
|
||||
- [ ] Updated devcontainer files if the agent needs a VS Code extension or CLI install step.
|
||||
- [ ] Updated this guide or other relevant docs if the integration has special setup or limitations.
|
||||
|
||||
---
|
||||
|
||||
*This documentation should be updated whenever new integrations are added to maintain accuracy and completeness.*
|
||||
|
||||
81
CHANGELOG.md
81
CHANGELOG.md
@@ -2,6 +2,87 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.14.3] - 2026-07-28
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Intake Authoring Governance preset to v0.3.0 (#3788)
|
||||
- fix(copilot): honor preset command template overrides (#3592)
|
||||
- clarify: require real interrogatives, ban topic-label questions (#3745)
|
||||
- feat: Add Alquimia AI integration (#2734)
|
||||
- harden: secure extension and preset archive downloads (#3141)
|
||||
- fix: correct Optional type annotation for context_note parameter (#3765)
|
||||
- Update AGENTS.md (#2626)
|
||||
- fix(extensions): tolerate non-string catalog name in display-name lookup (#3747)
|
||||
- fix(presets): coerce non-string catalog tags before joining (#3743)
|
||||
- fix: register extensions for the active integration only (#3459)
|
||||
- fix(extensions): tolerate non-string tags in catalog search (#3746)
|
||||
- fix(extensions): hyphenate command names in 'extension info' listing (#3744)
|
||||
- fix(workflows): escape remaining untrusted fields in `workflow info` (#3731)
|
||||
- fix(extensions): guard non-numeric catalog downloads in search/info rendering (#3710)
|
||||
- fix(agent-context): apply default markers when config markers are blank (bash) (#3736)
|
||||
- fix: escape Rich markup in catalog list output (#3738)
|
||||
- fix(workflows): guard non-mapping 'workflow:' block in WorkflowDefinition (#3694)
|
||||
- fix(bundler): reject unsupported schema_version in _merge_config (align readers) (#3711)
|
||||
- Update Linear Weave extension to v1.0.1 (#3762)
|
||||
- Add Intake Sequencing Governance preset to community catalog (#3761)
|
||||
- Update Quality Gates (Enforcement Layer) extension to v0.3.3 (#3760)
|
||||
- Update Verify Review Ship extension to v0.4.1 (#3759)
|
||||
- fix(agent-context): discover nested plans in Python port mtime fallback (#3734)
|
||||
- fix(extensions): make shipped scripts executable after install (#3723)
|
||||
- docs(assess): clarify the pipeline works on an empty project (#3732)
|
||||
- chore: release 0.14.2, begin 0.14.3.dev0 development (#3730)
|
||||
|
||||
## [0.14.2] - 2026-07-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Intake Review Governance preset to v0.1.1 (#3729)
|
||||
- Update Verify Review Ship extension to v0.3.0 (#3728)
|
||||
- Update Architecture Guard extension to v1.13.1 (#3724)
|
||||
- docs(upgrade): Claude Code files live in .claude/skills, not .claude/commands (#3708)
|
||||
- fix(kilocode): install commands under .kilo/commands (#3672)
|
||||
- fix(auth): normalize whitespace in auth-config env-var/id references at store time (#3691)
|
||||
- fix(workflows): guard non-mapping 'inputs:' block in engine._resolve_inputs (#3696)
|
||||
- Update Intake Authoring Governance preset to v0.2.0 (#3721)
|
||||
- docs: clarify shell-step interpolation safety (#3719)
|
||||
- [extension] Add Blueprint Index — Living Architecture Map extension to community catalog (#3718)
|
||||
- fix(github-http): return None on malformed host in resolve_github_release_asset_api_url (#3715)
|
||||
- fix(integrations): declare PiIntegration multi_install_safe (#3652)
|
||||
- harden: remove shell parameter from run_command() (#3716)
|
||||
- chore(deps): bump github/codeql-action/init from 4.37.1 to 4.37.3 (#3699)
|
||||
- fix: auto-correct conflicting feature prefixes (#1829)
|
||||
- chore(deps): bump actions/checkout from 6.0.3 to 7.0.1 (#3703)
|
||||
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#3702)
|
||||
- chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#3701)
|
||||
- chore(deps): bump astral-sh/setup-uv from 8.3.2 to 9.0.0 (#3700)
|
||||
- chore: release 0.14.1, begin 0.14.2.dev0 development (#3698)
|
||||
|
||||
## [0.14.1] - 2026-07-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Agent Parity Governance preset to v0.4.0 (#3697)
|
||||
- fix(bundler): InstallResult.changed counts uninstalled as a change (#3692)
|
||||
- [preset] Update Cross-Platform Governance preset to v0.2.1 (#3695)
|
||||
- Update A11Y Governance preset to v0.4.1 (#3693)
|
||||
- fix(workflows): escape step-graph brackets in `workflow info` so the type shows (#3690)
|
||||
- fix(workflows): filter parser rejects trailing tokens (fullmatch, not match) (#3689)
|
||||
- Update iSAQB Architecture Governance preset to v0.2.1 (#3687)
|
||||
- fix(extensions): parse SKILL.md on the --- delimiter line during removal (#3634)
|
||||
- fix(cli): guard lazy .hostname ValueError in extension/preset add --from (#3651)
|
||||
- Update Architecture Governance preset to v0.5.1 (#3686)
|
||||
- fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config (#3659)
|
||||
- Update Security Governance preset to v0.6.1 (#3685)
|
||||
- fix(integrations): declare OmpIntegration multi_install_safe (#3650)
|
||||
- feat(git-extension): add configurable Conventional Commit support (#3390) (#3413)
|
||||
- fix(extensions): hyphenate command names in the Forge post-install listing (#3669)
|
||||
- fix(bundler): reject falsy non-mapping requires/provides in CatalogEntry.from_dict (#3667)
|
||||
- fix(bundler): reject falsy non-list bundles/contributed_components in records (#3666)
|
||||
- Update Intake Authoring Governance preset to v0.1.1 (#3678)
|
||||
- docs(extensions): clarify agent-context README and add config examples (#3389)
|
||||
- chore: release 0.14.0, begin 0.14.1.dev0 development (#3677)
|
||||
|
||||
## [0.14.0] - 2026-07-23
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -36,6 +36,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
||||
| Blueprint Index — Living Architecture Map | A living architecture map for spec-driven projects, kept honest by a deterministic, low-friction, machine-first CI gate (JSON, self-healable) that blocks only when the map contradicts the specs or code. Brownfield or greenfield. | `process` | Read+Write | [spec-kit-blueprint](https://github.com/ogil109/spec-kit-blueprint) |
|
||||
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
|
||||
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
|
||||
| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) |
|
||||
@@ -158,7 +159,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
|
||||
| Verify Review Ship | Post-convergence operational verification, technical review, learning governance, and transactional delivery. | `process` | Read+Write | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
|
||||
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
|
||||
|
||||
@@ -7,28 +7,29 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| A11Y Governance | Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Architecture Governance | Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
| Cross-Platform Governance | Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence. | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
||||
| Intake Authoring Governance | Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries. | 7 templates, 2 commands, 2 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
|
||||
| Intake Review Governance | Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
|
||||
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Intake Authoring Governance | Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring. | 12 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
|
||||
| Intake Review Governance | Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
|
||||
| Intake Sequencing Governance | Manages traceable intake-series order, typed dependencies, lifecycle, and safe next-candidate selection without executing downstream workflows. | 10 templates, 6 commands, 5 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) |
|
||||
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Parallel Autonomous Run Governance | Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
|
||||
@@ -6,6 +6,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
|
||||
| Agent | Key | Notes |
|
||||
| ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| [Alquimia AI](https://docs.alquimia.ai) | `alquimia` | Skills-based integration; installs skills into `.alquimia/skills` and invokes them as `/speckit-<command>` |
|
||||
| [Amp](https://ampcode.com/) | `amp` | |
|
||||
| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically |
|
||||
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | |
|
||||
@@ -25,7 +26,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
|
||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | Skills-based integration by default; installs skills as `speckit-<command>/SKILL.md` under `.bob/skills/` and invokes them as `/speckit-<command>`. Pass `--integration-options="--legacy-commands"` to scaffold the deprecated Bob 1.x layout (`.bob/commands/*.md`) instead; that flag will be removed in a future release. Existing legacy installs can migrate with `specify integration upgrade bob --integration-options="--skills"`, which converts them to the skills layout and removes the old command files. If preset overrides are installed, the migration is rejected with an actionable error (preset artifacts cannot yet be reconciled across a layout change) — remove the preset(s), migrate, then reinstall them. |
|
||||
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
|
||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | Installs commands into `.kilo/commands`; legacy `.kilocode/workflows` installs remain supported as a registration fallback |
|
||||
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
|
||||
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
|
||||
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
|
||||
@@ -94,6 +95,8 @@ Installs the specified integration into the current project. If another integrat
|
||||
|
||||
Installing an additional integration does not change the default integration. Use `specify integration use <key>` to change the default.
|
||||
|
||||
Installed extensions and presets are not registered for a non-default integration at install time — they follow the currently active (default) integration only. `specify integration use <key>` (or `switch <key>`) is what rescaffolds them for the newly active integration.
|
||||
|
||||
> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init <project> --integration <key>` instead.
|
||||
|
||||
**Version note:** Controlled multi-install support was introduced in Spec Kit 0.8.5. If `specify integration install <key>` says another integration is already installed and only suggests `switch` or `uninstall`, check your local CLI with `specify version` and upgrade it. Running a one-shot command such as `uvx --from git+https://github.com/github/spec-kit.git specify ...` uses a temporary copy for that command only; it does not update the persistent `specify` executable on your `PATH`.
|
||||
@@ -127,7 +130,7 @@ specify integration switch <key>
|
||||
| `--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 |
|
||||
|
||||
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`.
|
||||
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`. Like `use`, `switch` rescaffolds installed extensions and presets for the target integration once it becomes the default.
|
||||
|
||||
## Use an Installed Integration
|
||||
|
||||
@@ -141,6 +144,8 @@ specify integration use <key>
|
||||
|
||||
Sets the default integration without uninstalling any other installed integrations. This also refreshes managed shared templates so command references match the new default integration's invocation style. Modified or untracked shared templates are preserved unless `--force` is used.
|
||||
|
||||
`use` is also the activation point for installed extensions and presets: it re-registers every enabled extension's and preset's command overrides (and skills, for skills-mode agents) for the newly active integration, so artifacts installed while a different integration was active are rescaffolded here rather than at install time.
|
||||
|
||||
## Upgrade an Integration
|
||||
|
||||
```bash
|
||||
@@ -155,6 +160,10 @@ specify integration upgrade [<key>]
|
||||
|
||||
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.
|
||||
|
||||
Enabled extensions and presets are re-registered only when upgrading the currently active (default) integration. A non-default upgrade still refreshes that integration's core commands, but does not re-register its extension or preset layers — `use`/`switch` that integration afterward to rescaffold them.
|
||||
|
||||
If an upgrade would change an integration between command and skills layouts while preset artifacts are registered for it, the upgrade is rejected before changing files. Remove the affected presets, run the layout-changing upgrade, then reinstall them.
|
||||
|
||||
## Report Integration Status
|
||||
|
||||
```bash
|
||||
@@ -263,18 +272,23 @@ The currently declared multi-install safe integrations are:
|
||||
|
||||
| Key | Command directory |
|
||||
| --- | ----------------- |
|
||||
| `alquimia` | `.alquimia/skills` |
|
||||
| `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` |
|
||||
| `grok` | `.grok/skills` |
|
||||
| `junie` | `.junie/commands` |
|
||||
| `kilocode` | `.kilocode/workflows` |
|
||||
| `kilocode` | `.kilo/commands` |
|
||||
| `kiro-cli` | `.kiro/prompts` |
|
||||
| `lingma` | `.lingma/skills` |
|
||||
| `omp` | `.omp/commands` |
|
||||
| `pi` | `.pi/prompts` |
|
||||
| `qodercli` | `.qoder/commands` |
|
||||
| `qwen` | `.qwen/commands` |
|
||||
| `shai` | `.shai/commands` |
|
||||
@@ -299,3 +313,7 @@ CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be ins
|
||||
### When should I use `upgrade` vs `switch`?
|
||||
|
||||
Use `upgrade` when you've upgraded Spec Kit and want to refresh an installed integration's managed files. Use `switch` when you want to replace the current default with another integration; if the target is already installed, `switch` behaves like `use`.
|
||||
|
||||
### Do extensions and presets I install apply to every installed integration?
|
||||
|
||||
No. Extensions (`specify extension add`) and presets (`specify preset add`) register their command overrides for the currently active (default) integration only, even if other integrations are installed. A non-default integration does not receive those artifacts until it becomes the default: `specify integration use <key>` (or `switch <key>`) rescaffolds every enabled extension and preset for the newly active integration. `specify integration upgrade` follows the same rule — it only re-registers extensions and presets when upgrading the active integration.
|
||||
|
||||
@@ -139,7 +139,7 @@ catalogs:
|
||||
|
||||
Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers.
|
||||
|
||||
Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into detected agent directories instead of being re-resolved by agents. During preset install, Spec Kit registers command files for the preset being installed; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Agents do not re-resolve the stack each time they run a command.
|
||||
Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into the active integration's directory only, instead of being re-resolved by agents or written to every detected agent directory (#2948). During preset install, Spec Kit registers command files for the preset being installed against the currently active integration; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Install and rescaffold remain active-only, but removal may also update previously targeted inactive directories recorded by the removed preset to restore the surviving command or skill layer. A non-active installed integration does not otherwise receive these command files until it becomes the default — `specify integration use <key>` (or `switch <key>`) rescaffolds enabled presets for the newly active integration. Agents do not re-resolve the stack each time they run a command.
|
||||
|
||||
By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder.
|
||||
|
||||
|
||||
@@ -502,6 +502,32 @@ args: "{{ inputs.spec }}"
|
||||
message: "{{ status | default('pending') }}"
|
||||
```
|
||||
|
||||
### Interpolation and shell safety
|
||||
|
||||
Expressions are resolved by **plain string substitution** — the value of `{{ ... }}` is spliced into the surrounding text exactly as-is, with no quoting or escaping added. That is convenient for building `args` and `message` strings, but it has an important consequence for `shell` steps: a `run` field is handed to the system shell (`/bin/sh -c` on POSIX), so any interpolated value is interpreted as **shell syntax**, not just data.
|
||||
|
||||
If an interpolated value can contain characters like `;`, `|`, `&`, `$( )`, backticks, or quotes, it can change or extend the command that actually runs. This matters most when the value is not fully under the workflow author's control:
|
||||
|
||||
- **Workflow `inputs.*`** — supplied by whoever runs the workflow.
|
||||
- **A prior step's output**, e.g. `{{ steps.plan.output.stdout }}` — for a `prompt` step this is **text produced by the AI agent**, which can in turn be influenced by files, tickets, or web content the agent read. Treat agent output as untrusted when it flows into a `shell` step.
|
||||
|
||||
There is **no shell-escaping filter** in the expression language and **no sandbox** around a `shell` step, so none of the practices below can be treated as a guarantee that a hostile value is neutralised. The only reliable control is to constrain what an interpolated value *can* be, and to keep values you cannot constrain out of `run` fields entirely. Scrutinise every `run` field that interpolates a value you do not control, and at minimum:
|
||||
|
||||
- **Constrain the value at the source with `enum`/an allowlist.** When `inputs.*` feeds a `run` field, restrict it to a fixed set of known-safe values so a caller cannot supply arbitrary shell text at all. This is the strongest control the engine offers — prefer it over any downstream mitigation.
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
target:
|
||||
type: string
|
||||
enum: [staging, production] # caller cannot inject arbitrary text
|
||||
```
|
||||
|
||||
- **Keep unconstrained values out of `run`.** If a value cannot be constrained to an allowlist — most agent/`prompt` output — do not interpolate it into a `run` field. Branch on it with `if`/`switch` against fixed conditions, or act on it in a `command`/`prompt` step rather than a shell command built from it.
|
||||
- **Quoting is not a security boundary.** Surrounding a substitution with quotes (`'{{ inputs.x }}'`) helps the shell treat a *trusted* value as a single argument and avoids word-splitting on spaces, but a value that itself contains the matching quote character can still break out and inject shell syntax. Quote for correctness on constrained values; never rely on quoting to make an *unconstrained* substitution safe.
|
||||
- **Gates do not inspect the next step, and `message` is printed verbatim.** A `gate` step renders only its own `message`/`show_file` — it does not display, resolve, or sanitise the command that follows it, and approval never neutralises an injectable interpolation. Do **not** interpolate raw untrusted data into `message`: it is printed as-is with no control-character stripping, so agent or caller output could inject terminal/ANSI escapes that alter or hide the approval prompt. Keep `message` to trusted, constrained text, and surface untrusted material for review via `show_file` instead — its path and contents are control/ANSI-stripped before display.
|
||||
|
||||
A `shell` step is an arbitrary-command primitive by design; these practices reduce exposure and keep *which* command runs under the author's control, but they do not eliminate the risk of interpolating values you do not fully control.
|
||||
|
||||
## Shell Step Environment Variables
|
||||
|
||||
Shell steps automatically receive the following environment variables:
|
||||
|
||||
@@ -195,15 +195,13 @@ Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash command
|
||||
**Example for Kilo Code:**
|
||||
|
||||
```bash
|
||||
# Navigate to the agent's commands folder
|
||||
cd .kilocode/workflows/
|
||||
|
||||
# List files and identify duplicates
|
||||
ls -la
|
||||
# List current and legacy Kilo command folders
|
||||
ls -la .kilo/commands/
|
||||
ls -la .kilocode/workflows/
|
||||
|
||||
# Delete old versions (example filenames - yours may differ)
|
||||
rm speckit.specify-old.md
|
||||
rm speckit.plan-v1.md
|
||||
rm .kilocode/workflows/speckit.specify-old.md
|
||||
rm .kilocode/workflows/speckit.plan-v1.md
|
||||
```
|
||||
|
||||
Restart your IDE to refresh the command list.
|
||||
@@ -248,14 +246,12 @@ specify extension update
|
||||
This happens with IDE-based agents (Kilo Code, Cline, etc.).
|
||||
|
||||
```bash
|
||||
# Find the agent folder (example: .kilocode/workflows/)
|
||||
cd .kilocode/workflows/
|
||||
|
||||
# List all files
|
||||
ls -la
|
||||
# For Kilo Code, inspect both current and legacy command folders
|
||||
ls -la .kilo/commands/
|
||||
ls -la .kilocode/workflows/
|
||||
|
||||
# Delete old command files
|
||||
rm speckit.old-command-name.md
|
||||
rm .kilocode/workflows/speckit.old-command-name.md
|
||||
|
||||
# Restart your IDE
|
||||
```
|
||||
@@ -307,7 +303,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
|
||||
2. **For CLI-based agents**, verify files exist:
|
||||
|
||||
```bash
|
||||
ls -la .claude/commands/ # Claude Code
|
||||
ls -la .claude/skills/ # Claude Code
|
||||
ls -la .gemini/commands/ # Gemini
|
||||
ls -la .cursor/skills/ # Cursor
|
||||
ls -la .pi/prompts/ # Pi Coding Agent
|
||||
@@ -356,7 +352,7 @@ This warning appears when you run `specify init --here` (or `specify init .`) in
|
||||
|
||||
Only Spec Kit infrastructure files:
|
||||
|
||||
- Agent command files (`.claude/commands/`, `.github/prompts/`, etc.)
|
||||
- Agent command/skill files (`.claude/skills/`, `.github/prompts/`, etc.)
|
||||
- Scripts in `.specify/scripts/`
|
||||
- Templates in `.specify/templates/`
|
||||
- Missing memory files such as `.specify/memory/constitution.md` may be created from templates; an existing constitution is preserved
|
||||
@@ -445,7 +441,7 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
|
||||
ls -la .github/prompts/
|
||||
|
||||
# For Claude
|
||||
ls -la .claude/commands/
|
||||
ls -la .claude/skills/
|
||||
|
||||
# For Pi
|
||||
ls -la .pi/prompts/
|
||||
|
||||
@@ -2,55 +2,55 @@
|
||||
|
||||
This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration.
|
||||
|
||||
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`).
|
||||
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`). For `.mdc` files, it also ensures the YAML frontmatter (the metadata block at the top of the file) contains `alwaysApply: true`. Otherwise, everything outside the managed section is untouched.
|
||||
|
||||
> NOTE: Spec Kit itself never touches your agent context file. This extension is the only thing that does, and it's opt-in: install it if you want the block kept in sync, skip it if you'd rather manage that file yourself.
|
||||
|
||||
## Why an extension?
|
||||
|
||||
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** extension lets users:
|
||||
|
||||
- **Choose whether to install it at all** — `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file.
|
||||
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — the bundled scripts honor the `context_markers` value.
|
||||
- **Choose whether to install it at all** - `specify init` does **not** install it. Add it explicitly when you want Spec Kit to manage the agent context file; when it is absent, the file is never modified, and when it is disabled, its automatic hooks do not run.
|
||||
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in this repo) - the bundled scripts honor the `context_markers` value.
|
||||
- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`.
|
||||
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator — `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
|
||||
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in [extension.yml](./extension.yml) (`after_specify`, `after_plan`).
|
||||
|
||||
## Installation
|
||||
|
||||
To install the extension, from the root of an initialized Spec Kit project, run:
|
||||
|
||||
```bash
|
||||
specify extension add agent-context
|
||||
```
|
||||
|
||||
## Disabling
|
||||
|
||||
```bash
|
||||
specify extension disable agent-context
|
||||
|
||||
# Re-enable it
|
||||
specify extension enable agent-context
|
||||
```
|
||||
|
||||
While this extension is disabled (or not installed), nothing in Spec Kit creates, updates, or removes the managed block - the `__CONTEXT_FILE__` placeholder in any template is left as-is, and the extension's own config is never read.
|
||||
|
||||
## Commands
|
||||
|
||||
The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| Command | Description |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
|
||||
|
||||
> NOTE: The command ID above is canonical. Invoke it using the syntax for your integration: `/speckit.agent-context.update` for dot-command integrations; `/speckit-agent-context-update` for hyphen/skills integrations (including Forge and Cline); `$speckit-agent-context-update` for Codex or ZCode in skills mode; or `/skill:speckit-agent-context-update` for Kimi.
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration flows through the extension's own config file at
|
||||
`.specify/extensions/agent-context/agent-context-config.yml`:
|
||||
|
||||
```yaml
|
||||
# Path to the coding agent context file managed by this extension
|
||||
context_file: CLAUDE.md
|
||||
|
||||
# Optional list of coding agent context files to manage together.
|
||||
# When non-empty, this takes precedence over context_file.
|
||||
context_files:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
|
||||
# Delimiters for the managed Spec Kit section
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
```
|
||||
|
||||
- `context_file` — the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted.
|
||||
- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
|
||||
All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in the repo).
|
||||
|
||||
## Requirements
|
||||
|
||||
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
|
||||
|
||||
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports _"PyYAML is required … not available in the current Python environment"_, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||
|
||||
```bash
|
||||
pip install pyyaml
|
||||
@@ -58,10 +58,6 @@ pip install pyyaml
|
||||
/path/to/speckit-python -m pip install pyyaml
|
||||
```
|
||||
|
||||
## Disable
|
||||
## Issues
|
||||
|
||||
```bash
|
||||
specify extension disable agent-context
|
||||
```
|
||||
|
||||
When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal — the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge — including the per-agent default mapping in `agent-context-defaults.json` — lives entirely within this extension, so disabling it is a complete opt-out.
|
||||
For any other issues, please create an issue in the [official GitHub repo](https://github.com/github/spec-kit/issues).
|
||||
|
||||
@@ -1,20 +1,24 @@
|
||||
# Coding Agent Context Extension Configuration
|
||||
# These values are populated automatically by `specify init` and
|
||||
# `specify integration use` / `specify integration install`.
|
||||
|
||||
# Path (relative to the project root) to the default coding agent context file
|
||||
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
|
||||
# .github/copilot-instructions.md). Set automatically from the active
|
||||
# integration and regenerated during `specify init` or integration switches.
|
||||
# WHAT: The single agent context file relative to the project root (the directory containing .specify/). Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
# REQUIREMENT: OPTIONAL. Use this if you want to manually specify a single context file. If you leave this entry blank, it will use the default context file for the coding agent you picked when you set up Spec Kit. See `agent-context-defaults.json` for the defaults.
|
||||
# EXAMPLE: context_file: CLAUDE.md
|
||||
context_file: ""
|
||||
|
||||
# Optional list of project-relative coding agent context files managed by this
|
||||
# extension. When non-empty, this list takes precedence over `context_file`.
|
||||
# Use this for projects that intentionally keep multiple agent anchors in sync.
|
||||
# WHAT: List of agent context files relative to the project root (the directory containing .specify/). If you have both `context_file` and `context_files` filled, then this (`context_files`) takes precedence. Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
# REQUIREMENT: OPTIONAL. Use this if your project requires you to keep multiple agent context files in sync.
|
||||
# EXAMPLE:
|
||||
# context_files:
|
||||
# - AGENTS.md
|
||||
# - CLAUDE.md
|
||||
context_files: []
|
||||
|
||||
# Delimiters for the managed Spec Kit section.
|
||||
# Edit these to use custom markers.
|
||||
# WHAT: Markers (delimiters) for the managed Spec Kit section. This extension injects information only between these markers.
|
||||
# REQUIREMENT: OPTIONAL. Only change if you wish to have a custom marker name.
|
||||
# EXAMPLE:
|
||||
# context_markers:
|
||||
# start: "<!-- AGENT SPEC KIT CONTEXT START -->"
|
||||
# end: "<!-- AGENT SPEC KIT CONTEXT END -->"
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
"_comment": "Default coding agent context file per integration, owned by the agent-context extension. Used to self-seed agent-context-config.yml when it declares no context_file/context_files. Keyed by the Spec Kit integration key recorded in .specify/init-options.json. This mapping is independent of the Specify CLI by design.",
|
||||
"agents": {
|
||||
"agy": "AGENTS.md",
|
||||
"alquimia": "ALQUIMIA.md",
|
||||
"amp": "AGENTS.md",
|
||||
"auggie": ".augment/rules/specify-rules.md",
|
||||
"bob": "AGENTS.md",
|
||||
|
||||
@@ -176,13 +176,18 @@ _opts_lines=()
|
||||
while IFS= read -r _line || [[ -n "$_line" ]]; do
|
||||
_opts_lines+=("$_line")
|
||||
done < <(printf '%s\n' "$_raw_opts")
|
||||
if (( ${#_opts_lines[@]} < 3 )); then
|
||||
echo "agent-context: malformed config parser output; expected 3 lines (context_files, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
|
||||
if (( ${#_opts_lines[@]} < 1 )); then
|
||||
echo "agent-context: malformed config parser output; expected at least the context_files line, got ${#_opts_lines[@]}; skipping update." >&2
|
||||
exit 0
|
||||
fi
|
||||
# The marker lines may be absent: the $(...) capture above strips trailing
|
||||
# newlines, so blank markers (the config omitting context_markers and relying on
|
||||
# defaults) collapse the 3-line output to fewer lines. Default them to empty here
|
||||
# and let the DEFAULT_START/END substitution below fill them in, matching the
|
||||
# Python and PowerShell ports.
|
||||
CONTEXT_FILES_JSON="${_opts_lines[0]}"
|
||||
MARKER_START="${_opts_lines[1]}"
|
||||
MARKER_END="${_opts_lines[2]}"
|
||||
MARKER_START="${_opts_lines[1]:-}"
|
||||
MARKER_END="${_opts_lines[2]:-}"
|
||||
|
||||
if ! _context_files_raw="$("$_python" - "$CONTEXT_FILES_JSON" <<'PY'
|
||||
import json
|
||||
|
||||
@@ -11,8 +11,9 @@ Usage: update_agent_context.py [plan_path]
|
||||
|
||||
When ``plan_path`` is omitted, the script derives it from
|
||||
``.specify/feature.json`` (written by /speckit-specify). Falls back to the most
|
||||
recently modified ``specs/*/plan.md`` only when feature.json is absent or its
|
||||
plan does not exist yet.
|
||||
recently modified ``plan.md`` anywhere under ``specs/`` (including nested scoped
|
||||
layouts such as ``specs/<scope>/<feature>/plan.md``) only when feature.json is
|
||||
absent or its plan does not exist yet.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
@@ -173,7 +174,7 @@ def _resolve_plan_path(project_root: str) -> str:
|
||||
if not plan_path:
|
||||
root = Path(project_root).resolve()
|
||||
plans = sorted(
|
||||
(root / "specs").glob("*/plan.md"),
|
||||
(root / "specs").rglob("plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
|
||||
@@ -6,6 +6,8 @@ Discovery answers *"is this worth building?"* Delivery answers *"how do we build
|
||||
|
||||
## Overview
|
||||
|
||||
`assess` runs inside an initialized Spec Kit project (it writes assessments under `.specify/assessments/`), but that project can be **completely empty of source code** — a freshly initialized project with no code works just as well as an established codebase. The input is just an idea: pasted text, a URL, or a ticket need no existing code, while a codebase pointer lets you assess an idea for code that already exists. Neither starting point is more "correct" than the other.
|
||||
|
||||
Each idea lives in its own directory under `.specify/assessments/<slug>/`, with one Markdown artifact per stage:
|
||||
|
||||
```
|
||||
|
||||
@@ -21,6 +21,8 @@ The user input is the idea and (optionally) a slug. Treat it as one of:
|
||||
3. **A codebase pointer** — phrasing like "an idea for this repo" or a path. Read enough of the repository to record what the idea relates to.
|
||||
4. **A mix** of the above.
|
||||
|
||||
There is **no requirement for existing source code**: within an initialized Spec Kit project, intake works just as well when the project is empty of code as when it already has a codebase. Pasted text or a URL (options 1–2) need no existing codebase; a codebase pointer (option 3) targets existing code. Both are equally valid.
|
||||
|
||||
If the input is empty, ask the user for the idea (interactive), or stop with a note that there is nothing to intake (automated).
|
||||
|
||||
## Slug Resolution
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-27T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -290,8 +290,8 @@
|
||||
"id": "architecture-guard",
|
||||
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.8.17",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.17.zip",
|
||||
"version": "1.13.1",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.13.1.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/docs/architecture-overview.md",
|
||||
@@ -303,7 +303,7 @@
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 10,
|
||||
"commands": 14,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -313,13 +313,14 @@
|
||||
"refactor",
|
||||
"workflow",
|
||||
"governance",
|
||||
"guardrails"
|
||||
"guardrails",
|
||||
"hygiene"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-05T07:26:00Z",
|
||||
"updated_at": "2026-06-08T00:00:00Z"
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
},
|
||||
"archive": {
|
||||
"name": "Archive Extension",
|
||||
@@ -489,6 +490,46 @@
|
||||
"created_at": "2026-04-17T00:00:00Z",
|
||||
"updated_at": "2026-04-17T00:00:00Z"
|
||||
},
|
||||
"blueprint-index": {
|
||||
"name": "Blueprint Index — Living Architecture Map",
|
||||
"id": "blueprint-index",
|
||||
"description": "Living architecture map for brownfield and greenfield projects, with a deterministic CI gate that blocks contradictions between the map, specs, and code while warning on non-blocking drift.",
|
||||
"author": "ogil109",
|
||||
"version": "0.2.0",
|
||||
"download_url": "https://github.com/ogil109/spec-kit-blueprint/releases/download/v0.2.0/blueprint.zip",
|
||||
"repository": "https://github.com/ogil109/spec-kit-blueprint",
|
||||
"homepage": "https://github.com/ogil109/spec-kit-blueprint/tree/main",
|
||||
"documentation": "https://github.com/ogil109/spec-kit-blueprint/blob/main/README.md",
|
||||
"changelog": "https://github.com/ogil109/spec-kit-blueprint/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.10.0",
|
||||
"tools": [
|
||||
{ "name": "bash", "required": false },
|
||||
{ "name": "git", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"blueprint",
|
||||
"architecture",
|
||||
"coherence",
|
||||
"drift",
|
||||
"brownfield",
|
||||
"autonomous",
|
||||
"ci"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-24T00:00:00Z",
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
},
|
||||
"branch-convention": {
|
||||
"name": "Branch Convention",
|
||||
"id": "branch-convention",
|
||||
@@ -1578,8 +1619,8 @@
|
||||
"id": "gates",
|
||||
"description": "Deterministic quality enforcement for Spec Kit across agent hooks, git checks, and CI pipelines with one policy file and one verify entrypoint for identical results at every boundary.",
|
||||
"author": "schwichtgit",
|
||||
"version": "0.3.2",
|
||||
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.3.2/gates-0.3.2.zip",
|
||||
"version": "0.3.3",
|
||||
"download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.3.3/gates-0.3.3.zip",
|
||||
"repository": "https://github.com/schwichtgit/spec-gates",
|
||||
"homepage": "https://github.com/schwichtgit/spec-gates",
|
||||
"documentation": "https://github.com/schwichtgit/spec-gates/blob/main/docs/how-it-works.md",
|
||||
@@ -1623,7 +1664,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-09T00:00:00Z",
|
||||
"updated_at": "2026-07-15T00:00:00Z"
|
||||
"updated_at": "2026-07-27T00:00:00Z"
|
||||
},
|
||||
"github-issues": {
|
||||
"name": "GitHub Issues Integration 1",
|
||||
@@ -2034,11 +2075,11 @@
|
||||
"id": "linear-weave",
|
||||
"description": "Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses.",
|
||||
"author": "Tony Woodhouse",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/archive/refs/tags/v1.0.1.zip",
|
||||
"repository": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
|
||||
"homepage": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
|
||||
"documentation": "https://github.com/tonydwoodhouse/spec-kit-linear-weave#readme",
|
||||
"documentation": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/blob/main/README.md",
|
||||
"changelog": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "integration",
|
||||
@@ -2061,7 +2102,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
"updated_at": "2026-07-27T00:00:00Z"
|
||||
},
|
||||
"loop": {
|
||||
"name": "Loop Engineering",
|
||||
@@ -4777,36 +4818,40 @@
|
||||
"verify-review-ship": {
|
||||
"name": "Verify Review Ship",
|
||||
"id": "verify-review-ship",
|
||||
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
|
||||
"description": "Post-convergence operational verification, technical review, learning governance, and transactional delivery.",
|
||||
"author": "Carlos Eduardo Gevaerd Araujo",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
|
||||
"version": "0.4.1",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.4.1.zip",
|
||||
"sha256": "cfa89b405fcf4857745653e923dfab92f101fbdda15e1e8757ad9f2ea55ae5e2",
|
||||
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
|
||||
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
|
||||
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
|
||||
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-only",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
"speckit_version": ">=0.11.2"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"quality",
|
||||
"review",
|
||||
"shipping",
|
||||
"workflow",
|
||||
"testing"
|
||||
"merge",
|
||||
"cleanup",
|
||||
"learning",
|
||||
"governance",
|
||||
"agent-skills"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-10T00:00:00Z"
|
||||
"updated_at": "2026-07-27T00:00:00Z"
|
||||
},
|
||||
"verify-tasks": {
|
||||
"name": "Verify Tasks Extension",
|
||||
|
||||
@@ -10,7 +10,7 @@ This extension provides Git operations as an optional, self-contained module. It
|
||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces
|
||||
- **Branch validation** to ensure branches follow naming conventions
|
||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages, or Conventional Commit messages generated by the agent)
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -66,6 +66,11 @@ branch_prefix: ""
|
||||
# Custom commit message for git init
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style for auto-commit hooks: "fixed" (default) uses the
|
||||
# messages below; "conventional" asks the agent to generate a Conventional
|
||||
# Commit message (e.g. "feat: add OAuth spec") from the diff instead.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit per command (all disabled by default)
|
||||
# Example: enable auto-commit after specify
|
||||
auto_commit:
|
||||
|
||||
@@ -14,23 +14,37 @@ This command is invoked as a hook after (or before) core commands. It:
|
||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||
3. Looks up the specific event key to see if auto-commit is enabled
|
||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||
5. Uses the per-command `message` if configured, otherwise a default message
|
||||
5. Determines the commit message based on `commit_style` (see below)
|
||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||
|
||||
## Commit Message Styles
|
||||
|
||||
Controlled by the `commit_style` key in `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
- **`fixed`** (default): use the per-command `message` if configured, otherwise a generic `[Spec Kit] Auto-commit <phase> <command>` message.
|
||||
- **`conventional`**: inspect the actual changes (`git diff` / `git status`) since the last commit and generate a single-line [Conventional Commit](https://www.conventionalcommits.org/) message (`type(scope): subject`, e.g. `feat: add OAuth specification` or `docs: update implementation plan`) that accurately summarizes the change. Write this message to a temporary file and pass the file's path to the script (see Execution below). The configured `message` values are ignored in this mode.
|
||||
|
||||
## Execution
|
||||
|
||||
Determine the event name from the hook that triggered this command, then run the script:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name> [--message-file <path>]`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name> [-MessageFile <path>]`
|
||||
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). Only pass a generated message when `commit_style: conventional` is configured — first check `.specify/extensions/git/git-config.yml` for the value of `commit_style`:
|
||||
|
||||
- If `conventional`: inspect the diff and generate a Conventional Commit message. **Do not interpolate the generated message directly into a shell command string** — its content is derived from repository changes and may contain characters (quotes, `$(...)`, backticks) that a shell would execute or that would break command quoting. Instead, write the message to a temporary file using your file-editing tool (not a shell `echo`/`printf`), then pass that file's path via `--message-file <path>` (Bash) or `-MessageFile <path>` (PowerShell).
|
||||
- If `fixed` or absent: run the script with just `<event_name>`; it uses the configured/static message.
|
||||
|
||||
## Configuration
|
||||
|
||||
In `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
# "fixed" (default) uses the messages below; "conventional" asks the agent
|
||||
# to generate a Conventional Commit message from the diff instead.
|
||||
commit_style: fixed
|
||||
|
||||
auto_commit:
|
||||
default: false # Global toggle — set true to enable for all commands
|
||||
after_specify:
|
||||
@@ -46,3 +60,4 @@ auto_commit:
|
||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||
- If no config file exists: skips (disabled by default)
|
||||
- If no changes to commit: skips with a message
|
||||
- If `commit_style: conventional` is set and no generated message was supplied: fails with a clear error instead of silently falling back to the fixed message format
|
||||
|
||||
@@ -17,6 +17,13 @@ branch_prefix: ""
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style used by auto-commit hooks (speckit.git.commit):
|
||||
# "fixed" - default; use the configured/static messages below.
|
||||
# "conventional" - ask the agent to inspect the diff and generate a
|
||||
# Conventional Commit message (e.g. "feat: add OAuth spec")
|
||||
# instead of using the messages configured below.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
|
||||
@@ -17,6 +17,13 @@ branch_prefix: ""
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style used by auto-commit hooks (speckit.git.commit):
|
||||
# "fixed" - default; use the configured/static messages below.
|
||||
# "conventional" - ask the agent to inspect the diff and generate a
|
||||
# Conventional Commit message (e.g. "feat: add OAuth spec")
|
||||
# instead of using the messages configured below.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
|
||||
@@ -3,16 +3,57 @@
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.sh <event_name>
|
||||
# Usage: auto-commit.sh <event_name> [generated_message]
|
||||
# auto-commit.sh <event_name> --message-file <path>
|
||||
# e.g.: auto-commit.sh after_specify
|
||||
# e.g.: auto-commit.sh after_specify --message-file /tmp/commit-msg.txt (commit_style: conventional)
|
||||
#
|
||||
# --message-file is the preferred way to supply an agent-generated commit
|
||||
# message: it reads the message from a file instead of a shell argument,
|
||||
# so message content (which may contain quotes, `$(...)`, backticks, etc.)
|
||||
# is never interpolated into a shell command line.
|
||||
|
||||
set -e
|
||||
|
||||
EVENT_NAME="${1:-}"
|
||||
if [ -z "$EVENT_NAME" ]; then
|
||||
echo "Usage: $0 <event_name>" >&2
|
||||
echo "Usage: $0 <event_name> [generated_message | --message-file <path>]" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift || true
|
||||
|
||||
# Optional second argument: an agent-generated commit message (used when
|
||||
# commit_style: conventional is configured). Prefer --message-file over
|
||||
# passing the message directly as a shell argument.
|
||||
GENERATED_MESSAGE=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--message-file)
|
||||
_message_file="${2:-}"
|
||||
if [ -z "$_message_file" ]; then
|
||||
echo "[specify] Error: --message-file requires a path argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$_message_file" ]; then
|
||||
echo "[specify] Error: message file '$_message_file' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
GENERATED_MESSAGE="$(cat "$_message_file")"
|
||||
# The message file is a transport-only artifact: its content is
|
||||
# now captured above, so remove it immediately. Otherwise, if it
|
||||
# was written inside the worktree, it would be picked up as an
|
||||
# untracked change by both the "any changes?" check below and by
|
||||
# `git add .`, polluting the commit or defeating the no-changes
|
||||
# short-circuit even when nothing else changed.
|
||||
rm -f "$_message_file"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
GENERATED_MESSAGE="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
@@ -46,8 +87,22 @@ fi
|
||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
_enabled=false
|
||||
_commit_msg=""
|
||||
_commit_style="fixed"
|
||||
|
||||
if [ -f "$_config_file" ]; then
|
||||
# Top-level scalar key: commit_style (fixed | conventional)
|
||||
_style_val=$(grep -m1 '^commit_style:' "$_config_file" 2>/dev/null | sed 's/^commit_style:[[:space:]]*//' | sed 's/[[:space:]]\{1,\}#.*$//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//' | tr '[:upper:]' '[:lower:]')
|
||||
if [ -n "$_style_val" ]; then
|
||||
case "$_style_val" in
|
||||
fixed|conventional)
|
||||
_commit_style="$_style_val"
|
||||
;;
|
||||
*)
|
||||
echo "[specify] Warning: unknown commit_style '$_style_val' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Parse the auto_commit section for this event.
|
||||
# Look for auto_commit.<event_name>.enabled and .message
|
||||
# Also check auto_commit.default as fallback.
|
||||
@@ -128,6 +183,17 @@ if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null &&
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# In conventional mode, the commit message must be supplied by the agent
|
||||
# (via the generated_message argument); never fall back to the fixed message.
|
||||
if [ "$_commit_style" = "conventional" ]; then
|
||||
if [ -n "$GENERATED_MESSAGE" ]; then
|
||||
_commit_msg="$GENERATED_MESSAGE"
|
||||
else
|
||||
echo "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass --message-file <path>, or a raw message as arg 2, or set commit_style: fixed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
# e.g., after_specify -> specify, before_plan -> plan
|
||||
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
|
||||
|
||||
@@ -3,14 +3,47 @@
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.ps1 <event_name>
|
||||
# Usage: auto-commit.ps1 <event_name> [generated_message]
|
||||
# auto-commit.ps1 <event_name> -MessageFile <path>
|
||||
# e.g.: auto-commit.ps1 after_specify
|
||||
# e.g.: auto-commit.ps1 after_specify -MessageFile C:\temp\commit-msg.txt (commit_style: conventional)
|
||||
#
|
||||
# -MessageFile is the preferred way to supply an agent-generated commit
|
||||
# message: it reads the message from a file instead of a shell argument,
|
||||
# so message content (which may contain quotes, $(...), backticks, etc.)
|
||||
# is never interpolated into a shell command line.
|
||||
param(
|
||||
[Parameter(Position = 0, Mandatory = $true)]
|
||||
[string]$EventName
|
||||
[string]$EventName,
|
||||
|
||||
# Optional agent-generated commit message (used when commit_style: conventional is configured).
|
||||
# Prefer -MessageFile over passing the message directly as a shell argument.
|
||||
[Parameter(Position = 1, Mandatory = $false)]
|
||||
[string]$GeneratedMessage = "",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$MessageFile = ""
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($MessageFile) {
|
||||
if (-not (Test-Path $MessageFile -PathType Leaf)) {
|
||||
Write-Warning "[specify] Error: message file '$MessageFile' not found"
|
||||
exit 1
|
||||
}
|
||||
$GeneratedMessage = (Get-Content -Path $MessageFile -Raw)
|
||||
if ($null -ne $GeneratedMessage) {
|
||||
$GeneratedMessage = $GeneratedMessage.TrimEnd("`r", "`n")
|
||||
}
|
||||
# The message file is a transport-only artifact: its content is now
|
||||
# captured above, so remove it immediately. Otherwise, if it was written
|
||||
# inside the worktree, it would be picked up as an untracked change by
|
||||
# both the "any changes?" check below and by `git add .`, polluting the
|
||||
# commit or defeating the no-changes short-circuit even when nothing
|
||||
# else changed.
|
||||
Remove-Item -Path $MessageFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Find-ProjectRoot {
|
||||
param([string]$StartDir)
|
||||
$current = Resolve-Path $StartDir
|
||||
@@ -55,8 +88,25 @@ if (-not $isRepo) {
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
$enabled = $false
|
||||
$commitMsg = ""
|
||||
$commitStyle = "fixed"
|
||||
|
||||
if (Test-Path $configFile) {
|
||||
# Top-level scalar key: commit_style (fixed | conventional)
|
||||
foreach ($line in Get-Content $configFile) {
|
||||
if ($line -match '^commit_style:\s*(.+)$') {
|
||||
$styleVal = (($matches[1] -replace '\s+#.*$', '').Trim()) -replace '^["'']' -replace '["'']$'
|
||||
if ($styleVal) {
|
||||
$styleVal = $styleVal.ToLower()
|
||||
if ($styleVal -eq 'fixed' -or $styleVal -eq 'conventional') {
|
||||
$commitStyle = $styleVal
|
||||
} else {
|
||||
Write-Warning "[specify] Warning: unknown commit_style '$styleVal' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'"
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Parse YAML to find auto_commit section
|
||||
$inAutoCommit = $false
|
||||
$inEvent = $false
|
||||
@@ -140,6 +190,17 @@ if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# In conventional mode, the commit message must be supplied by the agent
|
||||
# (via the GeneratedMessage argument); never fall back to the fixed message.
|
||||
if ($commitStyle -eq 'conventional') {
|
||||
if ($GeneratedMessage) {
|
||||
$commitMsg = $GeneratedMessage
|
||||
} else {
|
||||
Write-Warning "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass -MessageFile <path>, or a raw message as arg 2, or set commit_style: fixed)"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
|
||||
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
|
||||
|
||||
@@ -1,8 +1,17 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-17T00:00:00Z",
|
||||
"updated_at": "2026-07-27T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
|
||||
"integrations": {
|
||||
"alquimia": {
|
||||
"id": "alquimia",
|
||||
"name": "Alquimia AI",
|
||||
"version": "1.0.0",
|
||||
"description": "Alquimia AI CLI integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["alquimia"]
|
||||
},
|
||||
"claude": {
|
||||
"id": "claude",
|
||||
"name": "Claude Code",
|
||||
|
||||
@@ -1,18 +1,19 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z",
|
||||
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
"name": "A11Y Governance",
|
||||
"id": "a11y-governance",
|
||||
"version": "0.4.0",
|
||||
"description": "Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence.",
|
||||
"version": "0.4.1",
|
||||
"description": "Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -33,18 +34,18 @@
|
||||
"didactic-comments"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
"id": "agent-parity-governance",
|
||||
"version": "0.3.0",
|
||||
"description": "Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift.",
|
||||
"version": "0.4.0",
|
||||
"description": "Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.3.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.0/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -63,7 +64,7 @@
|
||||
"multi-agent"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"aide-in-place": {
|
||||
"name": "AIDE In-Place Migration",
|
||||
@@ -96,13 +97,13 @@
|
||||
"architecture-governance": {
|
||||
"name": "Architecture Governance",
|
||||
"id": "architecture-governance",
|
||||
"version": "0.5.0",
|
||||
"description": "Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"version": "0.5.1",
|
||||
"description": "Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -129,7 +130,7 @@
|
||||
"assurance"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
@@ -243,13 +244,13 @@
|
||||
"cross-platform-governance": {
|
||||
"name": "Cross-Platform Governance",
|
||||
"id": "cross-platform-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit.",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -271,7 +272,7 @@
|
||||
"linux"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"explicit-task-dependencies": {
|
||||
"name": "Explicit Task Dependencies",
|
||||
@@ -367,42 +368,42 @@
|
||||
"intake-authoring-governance": {
|
||||
"name": "Intake Authoring Governance",
|
||||
"id": "intake-authoring-governance",
|
||||
"version": "0.1.0",
|
||||
"description": "Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries.",
|
||||
"version": "0.3.0",
|
||||
"description": "Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.3.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.1.0/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.3.0/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 7,
|
||||
"commands": 2,
|
||||
"scripts": 2
|
||||
"templates": 12,
|
||||
"commands": 5,
|
||||
"scripts": 7
|
||||
},
|
||||
"tags": [
|
||||
"intake",
|
||||
"authoring",
|
||||
"governance",
|
||||
"traceability",
|
||||
"clarification"
|
||||
"requirements",
|
||||
"migration"
|
||||
],
|
||||
"created_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z"
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
},
|
||||
"intake-review-governance": {
|
||||
"name": "Intake Review Governance",
|
||||
"id": "intake-review-governance",
|
||||
"version": "0.1.0",
|
||||
"description": "Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution.",
|
||||
"version": "0.1.1",
|
||||
"description": "Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.0/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
@@ -420,18 +421,47 @@
|
||||
"autonomous"
|
||||
],
|
||||
"created_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
},
|
||||
"intake-sequencing-governance": {
|
||||
"name": "Intake Sequencing Governance",
|
||||
"id": "intake-sequencing-governance",
|
||||
"version": "0.1.0",
|
||||
"description": "Manages traceable intake-series order, typed dependencies, lifecycle, and safe next-candidate selection without executing downstream workflows.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.1.0/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 10,
|
||||
"commands": 6,
|
||||
"scripts": 5
|
||||
},
|
||||
"tags": [
|
||||
"intake",
|
||||
"sequencing",
|
||||
"governance",
|
||||
"dag",
|
||||
"lifecycle"
|
||||
],
|
||||
"created_at": "2026-07-27T00:00:00Z",
|
||||
"updated_at": "2026-07-27T00:00:00Z"
|
||||
},
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
"id": "isaqb-architecture-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt.",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -452,7 +482,7 @@
|
||||
"technical-debt"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"jira": {
|
||||
"name": "Jira Issue Tracking",
|
||||
@@ -635,13 +665,13 @@
|
||||
"security-governance": {
|
||||
"name": "Security Governance",
|
||||
"id": "security-governance",
|
||||
"version": "0.6.0",
|
||||
"description": "Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA to Spec Kit.",
|
||||
"version": "0.6.1",
|
||||
"description": "Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -680,7 +710,7 @@
|
||||
"regulatory"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"sicario-core": {
|
||||
"name": "SicarioSpec Core",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.14.0"
|
||||
version = "0.14.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"
|
||||
|
||||
@@ -8,6 +8,7 @@ ALLOW_EXISTING=false
|
||||
SHORT_NAME=""
|
||||
BRANCH_NUMBER=""
|
||||
USE_TIMESTAMP=false
|
||||
NUMBER_EXPLICIT=false
|
||||
ARGS=()
|
||||
i=1
|
||||
while [ $i -le $# ]; do
|
||||
@@ -48,6 +49,9 @@ while [ $i -le $# ]; do
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER="$next_arg"
|
||||
if [ -n "$BRANCH_NUMBER" ]; then
|
||||
NUMBER_EXPLICIT=true
|
||||
fi
|
||||
;;
|
||||
--timestamp)
|
||||
USE_TIMESTAMP=true
|
||||
@@ -60,7 +64,7 @@ while [ $i -le $# ]; do
|
||||
echo " --dry-run Compute feature name and paths without creating directories or files"
|
||||
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
|
||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||
echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)"
|
||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
@@ -91,6 +95,7 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
fi
|
||||
|
||||
MAX_FEATURE_NUMBER=9223372036854775807
|
||||
MAX_BRANCH_LENGTH=244
|
||||
|
||||
is_feature_number_in_range() {
|
||||
local value="$1"
|
||||
@@ -128,12 +133,40 @@ get_highest_from_specs() {
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Return success when a spec directory owns the given numeric prefix.
|
||||
spec_prefix_exists() {
|
||||
local specs_dir="$1"
|
||||
local feature_num="$2"
|
||||
|
||||
for spec_path in "$specs_dir/${feature_num}-"*; do
|
||||
[ -d "$spec_path" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to clean and format a branch name
|
||||
clean_branch_name() {
|
||||
local name="$1"
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Fit a feature prefix and suffix within GitHub's branch-name limit.
|
||||
fit_branch_name() {
|
||||
local feature_num="$1"
|
||||
local branch_suffix="$2"
|
||||
local branch_name="${feature_num}-${branch_suffix}"
|
||||
|
||||
if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then
|
||||
local prefix_length=$(( ${#feature_num} + 1 ))
|
||||
local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length))
|
||||
local truncated_suffix
|
||||
truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//')
|
||||
branch_name="${feature_num}-${truncated_suffix}"
|
||||
fi
|
||||
|
||||
printf '%s' "$branch_name"
|
||||
}
|
||||
|
||||
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
|
||||
# so the persistence hints match the Python variant exactly (printf %q output
|
||||
# differs between bash versions and from shlex.quote for spaces/metachars).
|
||||
@@ -253,26 +286,41 @@ else
|
||||
|
||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
|
||||
# Treat an explicit number as a preference when its prefix is already used
|
||||
# by a feature directory. Auto-detected numbers are already conflict-free.
|
||||
if [ "$NUMBER_EXPLICIT" = true ]; then
|
||||
SPEC_CONFLICT=false
|
||||
REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME"
|
||||
if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then
|
||||
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true
|
||||
fi
|
||||
|
||||
if [ "$SPEC_CONFLICT" = true ]; then
|
||||
REQUESTED_NUM="$FEATURE_NUM"
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$HIGHEST
|
||||
while true; do
|
||||
if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then
|
||||
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER=$((BRANCH_NUMBER + 1))
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break
|
||||
done
|
||||
>&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead"
|
||||
fi
|
||||
fi
|
||||
|
||||
fi
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
MAX_BRANCH_LENGTH=244
|
||||
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
||||
# Calculate how much we need to trim from suffix
|
||||
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||
|
||||
# Truncate suffix at word boundary if possible
|
||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||
# Remove trailing hyphen if truncation created one
|
||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
|
||||
ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
|
||||
@@ -14,6 +14,7 @@ param(
|
||||
[string[]]$FeatureDescription
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$maxBranchLength = 244
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
@@ -24,7 +25,7 @@ if ($Help) {
|
||||
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
|
||||
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
|
||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
|
||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
Write-Host " -Help Show this help message"
|
||||
Write-Host ""
|
||||
@@ -67,11 +68,44 @@ function Get-HighestNumberFromSpecs {
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Test-SpecPrefixInUse {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[string]$FeatureNum
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like "$FeatureNum-*" } |
|
||||
Select-Object -First 1)
|
||||
}
|
||||
|
||||
function ConvertTo-CleanBranchName {
|
||||
param([string]$Name)
|
||||
|
||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||
}
|
||||
|
||||
function Get-FittedBranchName {
|
||||
param(
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
$fittedName = "$FeatureNum-$BranchSuffix"
|
||||
if ($fittedName.Length -gt $maxBranchLength) {
|
||||
$prefixLength = $FeatureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
$fittedName = "$FeatureNum-$truncatedSuffix"
|
||||
}
|
||||
|
||||
return $fittedName
|
||||
}
|
||||
# Load common functions (includes Get-RepoRoot and Resolve-Template)
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
@@ -176,26 +210,40 @@ if ($Timestamp) {
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $resolvedNumber)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
|
||||
# Treat an explicit number as a preference when its prefix is already used
|
||||
# by a feature directory. Auto-detected numbers are already conflict-free.
|
||||
$specConflict = $false
|
||||
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
|
||||
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
$requestedDir = Join-Path $specsDir $requestedBranchName
|
||||
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
|
||||
$specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
|
||||
}
|
||||
}
|
||||
|
||||
if ($specConflict) {
|
||||
$requestedNum = $featureNum
|
||||
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
|
||||
$resolvedNumber = $highestNumber
|
||||
do {
|
||||
if ($resolvedNumber -eq [long]::MaxValue) {
|
||||
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
|
||||
exit 1
|
||||
}
|
||||
$resolvedNumber++
|
||||
$featureNum = ('{0:000}' -f $resolvedNumber)
|
||||
} while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
|
||||
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
$maxBranchLength = 244
|
||||
if ($branchName.Length -gt $maxBranchLength) {
|
||||
# Calculate how much we need to trim from suffix
|
||||
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||
$prefixLength = $featureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
|
||||
# Truncate suffix
|
||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||
# Remove trailing hyphen if truncation created one
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
|
||||
$originalBranchName = $branchName
|
||||
$branchName = "$featureNum-$truncatedSuffix"
|
||||
|
||||
$originalBranchName = "$featureNum-$branchSuffix"
|
||||
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
if ($branchName -ne $originalBranchName) {
|
||||
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
|
||||
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
|
||||
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
|
||||
|
||||
@@ -76,7 +76,7 @@ Options:
|
||||
--dry-run Compute feature name and paths without creating directories or files
|
||||
--allow-existing-branch Reuse an existing feature directory if it already exists
|
||||
--short-name <name> Provide a custom short name (2-4 words) for the feature
|
||||
--number N Specify branch number manually (overrides auto-detection)
|
||||
--number N Prefer a feature number (auto-corrected if its specs prefix exists)
|
||||
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
|
||||
--help, -h Show this help message
|
||||
|
||||
@@ -204,6 +204,43 @@ def _get_highest_from_specs(specs_dir: Path) -> int:
|
||||
return highest
|
||||
|
||||
|
||||
def _fit_branch_name(feature_num: str, branch_suffix: str) -> str:
|
||||
"""Fit a feature prefix and suffix within GitHub's branch-name limit."""
|
||||
branch_name = f"{feature_num}-{branch_suffix}"
|
||||
if len(branch_name) <= _MAX_BRANCH_LENGTH:
|
||||
return branch_name
|
||||
|
||||
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
|
||||
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
|
||||
return f"{feature_num}-{truncated_suffix}"
|
||||
|
||||
|
||||
def _spec_prefix_exists(specs_dir: Path, feature_num: str) -> bool:
|
||||
"""Return whether a spec directory owns the given numeric prefix."""
|
||||
try:
|
||||
return any(
|
||||
entry.is_dir() and entry.name.startswith(f"{feature_num}-")
|
||||
for entry in specs_dir.iterdir()
|
||||
)
|
||||
except OSError:
|
||||
# Match Bash globbing and PowerShell's ErrorAction=SilentlyContinue.
|
||||
return False
|
||||
|
||||
|
||||
def _has_spec_prefix_conflict(
|
||||
specs_dir: Path,
|
||||
feature_num: str,
|
||||
requested_dir: Path,
|
||||
*,
|
||||
allow_existing: bool,
|
||||
) -> bool:
|
||||
"""Return whether another spec directory owns the requested prefix."""
|
||||
if allow_existing and requested_dir.is_dir():
|
||||
return False
|
||||
|
||||
return _spec_prefix_exists(specs_dir, feature_num)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv0 = sys.argv[0]
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
|
||||
@@ -261,18 +298,48 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 1
|
||||
feature_num = f"{number:03d}"
|
||||
|
||||
# Treat an explicit number as a preference when its prefix is already used
|
||||
# by a feature directory. Auto-detected numbers are already conflict-free.
|
||||
if branch_number:
|
||||
requested_branch_name = _fit_branch_name(feature_num, branch_suffix)
|
||||
requested_dir = specs_dir / requested_branch_name
|
||||
spec_conflict = _has_spec_prefix_conflict(
|
||||
specs_dir,
|
||||
feature_num,
|
||||
requested_dir,
|
||||
allow_existing=args.allow_existing,
|
||||
)
|
||||
if spec_conflict:
|
||||
requested_num = feature_num
|
||||
number = _get_highest_from_specs(specs_dir)
|
||||
while True:
|
||||
number += 1
|
||||
if number > _MAX_FEATURE_NUMBER:
|
||||
print(
|
||||
f"Error: feature number must be between 0 and "
|
||||
f"{_MAX_FEATURE_NUMBER}, got '{number}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
feature_num = f"{number:03d}"
|
||||
if not _spec_prefix_exists(specs_dir, feature_num):
|
||||
break
|
||||
print(
|
||||
f"[specify] Warning: --number {requested_num} conflicts with "
|
||||
f"an existing spec directory; using {feature_num} instead",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
|
||||
if max_suffix_length <= 0:
|
||||
print("Error: feature number is too long for a branch name", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
branch_name = f"{feature_num}-{branch_suffix}"
|
||||
original_branch_name = f"{feature_num}-{branch_suffix}"
|
||||
branch_name = _fit_branch_name(feature_num, branch_suffix)
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names.
|
||||
if len(branch_name) > _MAX_BRANCH_LENGTH:
|
||||
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
|
||||
original_branch_name = branch_name
|
||||
branch_name = f"{feature_num}-{truncated_suffix}"
|
||||
if branch_name != original_branch_name:
|
||||
print(
|
||||
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
|
||||
file=sys.stderr,
|
||||
|
||||
@@ -1,10 +1,19 @@
|
||||
"""Helpers for bounded HTTP downloads."""
|
||||
"""Helpers for bounded downloads and archive extraction."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import re
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||
from itertools import pairwise
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import NoReturn, TypeVar
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
|
||||
@@ -12,17 +21,52 @@ from urllib.parse import ParseResult, urlparse
|
||||
ErrorT = TypeVar("ErrorT", bound=Exception)
|
||||
|
||||
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
|
||||
MAX_ZIP_ENTRIES = 512
|
||||
MAX_ZIP_MEMBER_BYTES = 10 * 1024 * 1024
|
||||
MAX_ZIP_TOTAL_BYTES = 50 * 1024 * 1024
|
||||
MAX_ZIP_PATH_BYTES = 4096
|
||||
MAX_ZIP_COMPONENT_BYTES = 255
|
||||
# ``ZipFile`` reads this whole structure into memory. Four MiB leaves roughly
|
||||
# 8 KiB of filename/extra/comment metadata for each of the 512 allowed entries.
|
||||
MAX_ZIP_CENTRAL_DIRECTORY_BYTES = 4 * 1024 * 1024
|
||||
READ_CHUNK_SIZE = 64 * 1024
|
||||
|
||||
# Tighter ceiling for responses that are read fully into memory and parsed as
|
||||
# Tighter ceilings 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
|
||||
# downloads; JSON 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 the matching constant
|
||||
# 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.
|
||||
# * METADATA - fixed-shape single-object responses (an OAuth token, one
|
||||
# release's metadata): a few KiB in practice, 1 MiB is already generous.
|
||||
# * CATALOG - listings that grow with the number of published items. The
|
||||
# largest bundled catalog is ~130 KiB today, so 8 MiB leaves ~60x headroom
|
||||
# for growth while staying well under the download ceiling.
|
||||
MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
|
||||
MAX_JSON_CATALOG_BYTES = 8 * 1024 * 1024
|
||||
|
||||
_WINDOWS_INVALID_FILENAME_CHARS = frozenset('<>:"|?*')
|
||||
_WINDOWS_RESERVED_FILENAME = re.compile(
|
||||
r"^(?:con|prn|aux|nul|conin\$|conout\$|"
|
||||
r"com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])$",
|
||||
re.IGNORECASE,
|
||||
)
|
||||
_ZIP_EOCD = struct.Struct("<4s4H2LH")
|
||||
_ZIP_EOCD_SIGNATURE = b"PK\x05\x06"
|
||||
_ZIP64_LOCATOR_SIGNATURE = b"PK\x06\x07"
|
||||
_ZIP_CENTRAL_HEADER_SIZE = 46
|
||||
_ZIP_CENTRAL_SIGNATURE = b"PK\x01\x02"
|
||||
_ZIP_LOCAL_HEADER_SIZE = 30
|
||||
_ZIP_LOCAL_SIGNATURE = b"PK\x03\x04"
|
||||
_ZIP_EXTRA_HEADER = struct.Struct("<HH")
|
||||
_ZIP64_EXTRA_FIELD_ID = 0x0001
|
||||
_ZIP64_MIN_EXTRACT_VERSION = 45
|
||||
_ZIP_UINT16_MAX = (1 << 16) - 1
|
||||
_ZIP_UINT32_MAX = (1 << 32) - 1
|
||||
_ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
|
||||
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
|
||||
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
|
||||
)
|
||||
|
||||
|
||||
def _ip_address_without_scope(
|
||||
@@ -179,6 +223,41 @@ def _raise(error_type: type[ErrorT], message: str) -> NoReturn:
|
||||
raise error_type(message)
|
||||
|
||||
|
||||
def _raise_from(error_type: type[ErrorT], message: str, exc: Exception) -> NoReturn:
|
||||
raise error_type(message) from exc
|
||||
|
||||
|
||||
class _ReadLimitExceeded(Exception):
|
||||
"""Internal signal used to keep domain-specific errors at call sites."""
|
||||
|
||||
|
||||
def _validate_non_negative_int(value: int, name: str) -> None:
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
raise TypeError(f"{name} must be an integer")
|
||||
if value < 0:
|
||||
raise ValueError(f"{name} must be non-negative")
|
||||
|
||||
|
||||
def _validate_max_bytes(max_bytes: int) -> None:
|
||||
_validate_non_negative_int(max_bytes, "max_bytes")
|
||||
|
||||
|
||||
def _read_limited(response, max_bytes: int) -> bytes:
|
||||
"""Read a stream with bounded requests and without retaining fragments."""
|
||||
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 _ReadLimitExceeded
|
||||
output.write(chunk)
|
||||
return output.getvalue()
|
||||
|
||||
|
||||
def read_response_limited(
|
||||
response,
|
||||
*,
|
||||
@@ -199,20 +278,619 @@ def read_response_limited(
|
||||
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")
|
||||
_validate_max_bytes(max_bytes)
|
||||
try:
|
||||
return _read_limited(response, max_bytes)
|
||||
except _ReadLimitExceeded:
|
||||
_raise(error_type, f"{label!r} exceeds maximum size of {max_bytes} bytes")
|
||||
|
||||
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()
|
||||
|
||||
def build_safe_download_path(
|
||||
target_dir: Path,
|
||||
identifier: object,
|
||||
version: object,
|
||||
*,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
label: str = "archive",
|
||||
) -> Path:
|
||||
"""Build a portable single-component archive path inside *target_dir*."""
|
||||
if not isinstance(identifier, str) or not isinstance(version, str):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe {label} download filename derived from "
|
||||
f"{identifier!r} and {version!r}",
|
||||
)
|
||||
|
||||
filename = f"{identifier}-{version}.zip"
|
||||
try:
|
||||
filename_too_long = (
|
||||
len(filename.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
|
||||
)
|
||||
except UnicodeEncodeError:
|
||||
filename_too_long = True
|
||||
posix_path = PurePosixPath(filename)
|
||||
windows_path = PureWindowsPath(filename)
|
||||
if (
|
||||
filename_too_long
|
||||
or posix_path.name != filename
|
||||
or windows_path.name != filename
|
||||
or any(unicodedata.category(character) == "Cc" for character in filename)
|
||||
or any(
|
||||
character in _WINDOWS_INVALID_FILENAME_CHARS
|
||||
for character in filename
|
||||
)
|
||||
or filename.endswith((" ", "."))
|
||||
):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe {label} download filename derived from "
|
||||
f"{identifier!r} and {version!r}",
|
||||
)
|
||||
return Path(target_dir) / filename
|
||||
|
||||
|
||||
def read_zip_member_limited(
|
||||
zf: zipfile.ZipFile,
|
||||
name: str,
|
||||
*,
|
||||
max_bytes: int = MAX_ZIP_MEMBER_BYTES,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
label: str | None = None,
|
||||
) -> bytes:
|
||||
"""Read a single ZIP member into memory under a hard size cap.
|
||||
|
||||
Reading a member with ``zf.open(name).read()`` is unbounded: a crafted
|
||||
archive can declare a tiny ``file_size`` yet decompress to many gigabytes (a
|
||||
"zip bomb"), exhausting memory before the caller ever inspects the data.
|
||||
This rejects members whose *declared* size already exceeds *max_bytes* and,
|
||||
to defend against headers that lie, also reads in bounded chunks and stops
|
||||
one byte past the limit.
|
||||
|
||||
Use this for any inline manifest/metadata read that happens *before*
|
||||
:func:`safe_extract_zip` (which already enforces the same per-member bound
|
||||
during extraction); a raw ``zf.open(...).read()`` bypasses that protection.
|
||||
"""
|
||||
_validate_max_bytes(max_bytes)
|
||||
member_label = label or name
|
||||
try:
|
||||
info = zf.getinfo(name)
|
||||
except KeyError as exc:
|
||||
_raise_from(error_type, f"ZIP member not found: {name!r}", exc)
|
||||
if info.file_size > max_bytes:
|
||||
_raise(
|
||||
error_type,
|
||||
f"ZIP member {member_label!r} exceeds maximum size of {max_bytes} bytes",
|
||||
)
|
||||
|
||||
try:
|
||||
with zf.open(name, "r") as source:
|
||||
return _read_limited(source, max_bytes)
|
||||
except _ReadLimitExceeded:
|
||||
_raise(
|
||||
error_type,
|
||||
f"ZIP member {member_label!r} exceeds maximum size of {max_bytes} bytes",
|
||||
)
|
||||
except Exception as exc:
|
||||
_raise_from(
|
||||
error_type,
|
||||
f"Failed to read ZIP member {member_label!r}: {exc!r}",
|
||||
exc,
|
||||
)
|
||||
|
||||
|
||||
def normalize_zip_member_name(
|
||||
name: str,
|
||||
*,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
) -> str:
|
||||
"""Return a normalized, portable ZIP member name or raise if unsafe."""
|
||||
if "\x00" in name:
|
||||
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
|
||||
|
||||
normalized = name.replace("\\", "/")
|
||||
try:
|
||||
encoded_name = normalized.encode("utf-8")
|
||||
except UnicodeEncodeError:
|
||||
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
|
||||
if len(encoded_name) > MAX_ZIP_PATH_BYTES:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in ZIP archive: {name!r} "
|
||||
"(not portable across supported filesystems)",
|
||||
)
|
||||
path = PurePosixPath(normalized)
|
||||
raw_parts = normalized.split("/")
|
||||
# Strip a single trailing empty segment, i.e. the one-slash directory
|
||||
# marker that legitimate ZIPs use ("mydir/", "mydir/subdir/"). Anything
|
||||
# else that produces an empty segment - consecutive slashes ("a//b") or a
|
||||
# second trailing slash - is left in place and rejected below as malformed.
|
||||
if raw_parts and raw_parts[-1] == "":
|
||||
raw_parts = raw_parts[:-1]
|
||||
has_windows_drive = re.match(r"^[A-Za-z]:", normalized) is not None
|
||||
if (
|
||||
not raw_parts
|
||||
or path.is_absolute()
|
||||
or has_windows_drive
|
||||
or any(part in {"", ".", ".."} for part in raw_parts)
|
||||
):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in ZIP archive: {name!r} (potential path traversal)",
|
||||
)
|
||||
for part in raw_parts:
|
||||
reserved_stem = part.partition(".")[0].partition(":")[0].rstrip(" ")
|
||||
if (
|
||||
len(part.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
|
||||
or any(
|
||||
unicodedata.category(character) == "Cc"
|
||||
for character in part
|
||||
)
|
||||
or any(character in _WINDOWS_INVALID_FILENAME_CHARS for character in part)
|
||||
or part.startswith(" ")
|
||||
or part.endswith((" ", "."))
|
||||
or _WINDOWS_RESERVED_FILENAME.fullmatch(reserved_stem)
|
||||
):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in ZIP archive: {name!r} "
|
||||
"(not portable across supported filesystems)",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def portable_zip_path_key(name: str) -> tuple[str, ...]:
|
||||
"""Return a comparison key for filesystems with case/Unicode folding."""
|
||||
normalized_name = name.replace("\\", "/")
|
||||
return tuple(
|
||||
unicodedata.normalize("NFC", part.casefold())
|
||||
for part in normalized_name.removesuffix("/").split("/")
|
||||
)
|
||||
|
||||
|
||||
def _raise_zip64(error_type: type[ErrorT]) -> NoReturn:
|
||||
_raise(
|
||||
error_type,
|
||||
"ZIP64 archives are not supported by the bounded extractor",
|
||||
)
|
||||
|
||||
|
||||
def _preflight_zip_entry_features(
|
||||
extract_version: int,
|
||||
compression_method: int,
|
||||
*,
|
||||
error_type: type[ErrorT],
|
||||
) -> None:
|
||||
"""Enforce the formats whose output can be bounded by ``ZipExtFile``.
|
||||
|
||||
Python's BZIP2 and LZMA ``ZipExtFile`` paths do not pass the requested
|
||||
output length to the decompressor; only STORED and DEFLATED preserve this
|
||||
module's hard memory bound. APPNOTE assigns extract version 4.5 to ZIP64
|
||||
size extensions. Because this field declares the minimum extractor feature
|
||||
level, reject 4.5 and every newer level for the supported methods,
|
||||
independently of the usual size sentinels and extra field.
|
||||
"""
|
||||
if compression_method not in _BOUNDED_ZIP_COMPRESSION_METHODS:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsupported ZIP compression method {compression_method}; "
|
||||
"the bounded extractor supports only STORED and DEFLATED",
|
||||
)
|
||||
if extract_version >= _ZIP64_MIN_EXTRACT_VERSION:
|
||||
_raise(
|
||||
error_type,
|
||||
"ZIP64 or newer ZIP features requiring extractor version 4.5 or "
|
||||
"newer are not supported by the bounded extractor",
|
||||
)
|
||||
|
||||
|
||||
def _reject_zip64_extra_fields(
|
||||
extra: bytes,
|
||||
zip_path: Path,
|
||||
*,
|
||||
error_type: type[ErrorT],
|
||||
) -> None:
|
||||
"""Reject ZIP64 extra fields and malformed complete extra records."""
|
||||
offset = 0
|
||||
while offset + _ZIP_EXTRA_HEADER.size <= len(extra):
|
||||
field_id, field_size = _ZIP_EXTRA_HEADER.unpack_from(extra, offset)
|
||||
field_end = offset + _ZIP_EXTRA_HEADER.size + field_size
|
||||
if field_id == _ZIP64_EXTRA_FIELD_ID:
|
||||
_raise_zip64(error_type)
|
||||
if field_end > len(extra):
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
offset = field_end
|
||||
|
||||
|
||||
def _preflight_zip_local_header(
|
||||
archive_file,
|
||||
zip_path: Path,
|
||||
*,
|
||||
error_type: type[ErrorT],
|
||||
archive_prefix_size: int,
|
||||
central_directory_start: int,
|
||||
local_header_offset: int,
|
||||
) -> None:
|
||||
"""Reject local-entry ZIP64 indicators before ``ZipFile`` is constructed."""
|
||||
physical_offset = archive_prefix_size + local_header_offset
|
||||
if (
|
||||
physical_offset < archive_prefix_size
|
||||
or physical_offset + _ZIP_LOCAL_HEADER_SIZE > central_directory_start
|
||||
):
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
|
||||
archive_file.seek(physical_offset)
|
||||
header = archive_file.read(_ZIP_LOCAL_HEADER_SIZE)
|
||||
if (
|
||||
len(header) != _ZIP_LOCAL_HEADER_SIZE
|
||||
or header[:4] != _ZIP_LOCAL_SIGNATURE
|
||||
):
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
|
||||
extract_version = struct.unpack_from("<H", header, 4)[0]
|
||||
compression_method = struct.unpack_from("<H", header, 8)[0]
|
||||
_preflight_zip_entry_features(
|
||||
extract_version,
|
||||
compression_method,
|
||||
error_type=error_type,
|
||||
)
|
||||
|
||||
compressed_size, uncompressed_size = struct.unpack_from("<LL", header, 18)
|
||||
if (
|
||||
compressed_size == _ZIP_UINT32_MAX
|
||||
or uncompressed_size == _ZIP_UINT32_MAX
|
||||
):
|
||||
_raise_zip64(error_type)
|
||||
|
||||
filename_size, extra_size = struct.unpack_from("<HH", header, 26)
|
||||
extra_offset = physical_offset + _ZIP_LOCAL_HEADER_SIZE + filename_size
|
||||
if extra_offset + extra_size > central_directory_start:
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
|
||||
archive_file.seek(extra_offset)
|
||||
extra = archive_file.read(extra_size)
|
||||
if len(extra) != extra_size:
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
_reject_zip64_extra_fields(extra, zip_path, error_type=error_type)
|
||||
|
||||
|
||||
def _preflight_zip_central_directory(
|
||||
archive_file,
|
||||
zip_path: Path,
|
||||
*,
|
||||
error_type: type[ErrorT],
|
||||
max_entries: int,
|
||||
) -> None:
|
||||
"""Bound and count the central directory before ``ZipFile`` materializes it."""
|
||||
archive_file.seek(0, 2)
|
||||
file_size = archive_file.tell()
|
||||
tail_size = min(file_size, _ZIP_EOCD.size + _ZIP_MAX_COMMENT_BYTES)
|
||||
archive_file.seek(file_size - tail_size)
|
||||
tail = archive_file.read(tail_size)
|
||||
|
||||
# ZipFile selects the last EOCD signature in the search window. Inspect
|
||||
# exactly that record too: falling back to an earlier signature would let
|
||||
# the preflight validate one central directory while ZipFile materializes
|
||||
# another.
|
||||
eocd_index = tail.rfind(_ZIP_EOCD_SIGNATURE)
|
||||
if eocd_index < 0 or eocd_index + _ZIP_EOCD.size > len(tail):
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
eocd = _ZIP_EOCD.unpack_from(tail, eocd_index)
|
||||
comment_size = eocd[-1]
|
||||
if eocd_index + _ZIP_EOCD.size + comment_size != len(tail):
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
|
||||
eocd_offset = file_size - len(tail) + eocd_index
|
||||
if eocd_offset >= 20:
|
||||
archive_file.seek(eocd_offset - 20)
|
||||
if archive_file.read(4) == _ZIP64_LOCATOR_SIGNATURE:
|
||||
_raise_zip64(error_type)
|
||||
|
||||
(
|
||||
_signature,
|
||||
disk_number,
|
||||
central_directory_disk,
|
||||
entries_on_disk,
|
||||
declared_entries,
|
||||
central_directory_size,
|
||||
central_directory_offset,
|
||||
_comment_size,
|
||||
) = eocd
|
||||
if (
|
||||
disk_number != 0
|
||||
or central_directory_disk != 0
|
||||
or entries_on_disk != declared_entries
|
||||
):
|
||||
_raise(error_type, "Multi-disk ZIP archives are not supported")
|
||||
if (
|
||||
declared_entries == _ZIP_UINT16_MAX
|
||||
or central_directory_size == _ZIP_UINT32_MAX
|
||||
or central_directory_offset == _ZIP_UINT32_MAX
|
||||
):
|
||||
_raise_zip64(error_type)
|
||||
if declared_entries > max_entries:
|
||||
_raise(
|
||||
error_type,
|
||||
f"ZIP archive contains too many entries "
|
||||
f"({declared_entries} > {max_entries})",
|
||||
)
|
||||
if central_directory_size > MAX_ZIP_CENTRAL_DIRECTORY_BYTES:
|
||||
_raise(
|
||||
error_type,
|
||||
f"ZIP central directory exceeds maximum size of "
|
||||
f"{MAX_ZIP_CENTRAL_DIRECTORY_BYTES} bytes",
|
||||
)
|
||||
|
||||
central_directory_start = eocd_offset - central_directory_size
|
||||
if (
|
||||
central_directory_start < 0
|
||||
or central_directory_offset > central_directory_start
|
||||
):
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
archive_prefix_size = central_directory_start - central_directory_offset
|
||||
|
||||
consumed = 0
|
||||
actual_entries = 0
|
||||
local_header_offsets: list[int] = []
|
||||
while consumed < central_directory_size:
|
||||
archive_file.seek(central_directory_start + consumed)
|
||||
remaining = central_directory_size - consumed
|
||||
if remaining < _ZIP_CENTRAL_HEADER_SIZE:
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
header = archive_file.read(_ZIP_CENTRAL_HEADER_SIZE)
|
||||
if (
|
||||
len(header) != _ZIP_CENTRAL_HEADER_SIZE
|
||||
or header[:4] != _ZIP_CENTRAL_SIGNATURE
|
||||
):
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
|
||||
extract_version = struct.unpack_from("<H", header, 6)[0]
|
||||
compression_method = struct.unpack_from("<H", header, 10)[0]
|
||||
_preflight_zip_entry_features(
|
||||
extract_version,
|
||||
compression_method,
|
||||
error_type=error_type,
|
||||
)
|
||||
|
||||
compressed_size, uncompressed_size = struct.unpack_from("<LL", header, 20)
|
||||
disk_number_start = struct.unpack_from("<H", header, 34)[0]
|
||||
local_header_offset = struct.unpack_from("<L", header, 42)[0]
|
||||
if (
|
||||
compressed_size == _ZIP_UINT32_MAX
|
||||
or uncompressed_size == _ZIP_UINT32_MAX
|
||||
or local_header_offset == _ZIP_UINT32_MAX
|
||||
or disk_number_start == _ZIP_UINT16_MAX
|
||||
):
|
||||
_raise_zip64(error_type)
|
||||
if disk_number_start != 0:
|
||||
_raise(error_type, "Multi-disk ZIP archives are not supported")
|
||||
|
||||
filename_size, extra_size, comment_size = struct.unpack_from(
|
||||
"<HHH", header, 28
|
||||
)
|
||||
variable_size = filename_size + extra_size + comment_size
|
||||
record_size = _ZIP_CENTRAL_HEADER_SIZE + variable_size
|
||||
if record_size > remaining:
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
variable_data = archive_file.read(variable_size)
|
||||
if len(variable_data) != variable_size:
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
extra = variable_data[filename_size : filename_size + extra_size]
|
||||
_reject_zip64_extra_fields(extra, zip_path, error_type=error_type)
|
||||
local_header_offsets.append(local_header_offset)
|
||||
|
||||
consumed += record_size
|
||||
actual_entries += 1
|
||||
if actual_entries > max_entries:
|
||||
_raise(
|
||||
error_type,
|
||||
f"ZIP archive contains too many entries "
|
||||
f"({actual_entries} > {max_entries})",
|
||||
)
|
||||
|
||||
if actual_entries != declared_entries:
|
||||
_raise(error_type, f"Invalid ZIP archive: {zip_path}")
|
||||
|
||||
for local_header_offset in local_header_offsets:
|
||||
_preflight_zip_local_header(
|
||||
archive_file,
|
||||
zip_path,
|
||||
error_type=error_type,
|
||||
archive_prefix_size=archive_prefix_size,
|
||||
central_directory_start=central_directory_start,
|
||||
local_header_offset=local_header_offset,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def open_zip_bounded(
|
||||
zip_path: Path,
|
||||
*,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
max_entries: int = MAX_ZIP_ENTRIES,
|
||||
) -> Iterator[zipfile.ZipFile]:
|
||||
"""Open an untrusted ZIP after a bounded-memory header preflight."""
|
||||
_validate_non_negative_int(max_entries, "max_entries")
|
||||
zip_path = Path(zip_path)
|
||||
with ExitStack() as stack:
|
||||
try:
|
||||
archive_file = stack.enter_context(zip_path.open("rb"))
|
||||
except OSError as exc:
|
||||
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
|
||||
try:
|
||||
_preflight_zip_central_directory(
|
||||
archive_file,
|
||||
zip_path,
|
||||
error_type=error_type,
|
||||
max_entries=max_entries,
|
||||
)
|
||||
except OSError as exc:
|
||||
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
|
||||
try:
|
||||
archive_file.seek(0)
|
||||
zf = stack.enter_context(zipfile.ZipFile(archive_file, "r"))
|
||||
except Exception as exc:
|
||||
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
|
||||
yield zf
|
||||
|
||||
|
||||
def safe_extract_zip(
|
||||
zip_path: Path,
|
||||
target_dir: Path,
|
||||
*,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
max_entries: int = MAX_ZIP_ENTRIES,
|
||||
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
|
||||
max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
|
||||
) -> None:
|
||||
"""Extract a ZIP archive after path, symlink, and size validation."""
|
||||
_validate_non_negative_int(max_member_bytes, "max_member_bytes")
|
||||
_validate_non_negative_int(max_total_bytes, "max_total_bytes")
|
||||
try:
|
||||
target_root = target_dir.resolve()
|
||||
except OSError as exc:
|
||||
_raise_from(error_type, f"Invalid ZIP extraction target: {target_dir}", exc)
|
||||
|
||||
with open_zip_bounded(
|
||||
zip_path,
|
||||
error_type=error_type,
|
||||
max_entries=max_entries,
|
||||
) as zf:
|
||||
try:
|
||||
members = zf.infolist()
|
||||
except zipfile.BadZipFile as exc:
|
||||
_raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
|
||||
if len(members) > max_entries:
|
||||
_raise(
|
||||
error_type,
|
||||
f"ZIP archive contains too many entries ({len(members)} > {max_entries})",
|
||||
)
|
||||
|
||||
normalized_members: list[tuple[zipfile.ZipInfo, str, bool]] = []
|
||||
validated_paths: dict[tuple[str, ...], tuple[str, bool]] = {}
|
||||
total_size = 0
|
||||
for member in members:
|
||||
normalized_name = normalize_zip_member_name(
|
||||
member.filename,
|
||||
error_type=error_type,
|
||||
)
|
||||
is_dir = member.is_dir() or normalized_name.endswith("/")
|
||||
path_key = portable_zip_path_key(normalized_name)
|
||||
|
||||
existing = validated_paths.get(path_key)
|
||||
if existing is not None:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Conflicting path in ZIP archive: {member.filename} conflicts "
|
||||
f"with {existing[0]}",
|
||||
)
|
||||
validated_paths[path_key] = (member.filename, is_dir)
|
||||
|
||||
mode = member.external_attr >> 16
|
||||
if stat.S_ISLNK(mode):
|
||||
_raise(error_type, f"Unsafe symlink in ZIP archive: {member.filename}")
|
||||
|
||||
member_path = (target_dir / normalized_name).resolve()
|
||||
try:
|
||||
member_path.relative_to(target_root)
|
||||
except ValueError:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in ZIP archive: {member.filename} "
|
||||
"(potential path traversal)",
|
||||
)
|
||||
|
||||
if not is_dir:
|
||||
if member.file_size > max_member_bytes:
|
||||
_raise(
|
||||
error_type,
|
||||
f"ZIP member {member.filename} exceeds maximum size "
|
||||
f"of {max_member_bytes} bytes",
|
||||
)
|
||||
total_size += member.file_size
|
||||
if total_size > max_total_bytes:
|
||||
_raise(
|
||||
error_type,
|
||||
f"ZIP archive exceeds maximum uncompressed size "
|
||||
f"of {max_total_bytes} bytes",
|
||||
)
|
||||
|
||||
normalized_members.append((member, normalized_name, is_dir))
|
||||
|
||||
# Tuple sorting places every path immediately before its descendants.
|
||||
# One adjacent comparison per entry detects file/directory conflicts
|
||||
# without repeatedly rebuilding every path prefix.
|
||||
for (
|
||||
(path_key, (original, is_dir)),
|
||||
(next_key, (next_original, _next_is_dir)),
|
||||
) in pairwise(sorted(validated_paths.items())):
|
||||
if (
|
||||
not is_dir
|
||||
and len(next_key) > len(path_key)
|
||||
and next_key[: len(path_key)] == path_key
|
||||
):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Conflicting path in ZIP archive: {original} conflicts "
|
||||
f"with {next_original}",
|
||||
)
|
||||
|
||||
# The loop above bounds the *declared* total via member.file_size, but a
|
||||
# crafted archive can understate those headers. Mirror the per-member
|
||||
# guard below with a cumulative count of the bytes actually written so
|
||||
# the total-size bound holds even when the headers lie.
|
||||
total_written = 0
|
||||
for member, normalized_name, is_dir in normalized_members:
|
||||
member_path = target_dir / normalized_name
|
||||
if is_dir:
|
||||
try:
|
||||
member_path.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
_raise_from(
|
||||
error_type,
|
||||
f"Failed to create ZIP directory {member.filename}: {exc}",
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
|
||||
try:
|
||||
member_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
except OSError as exc:
|
||||
_raise_from(
|
||||
error_type,
|
||||
f"Failed to create parent directory for ZIP member {member.filename}: {exc}",
|
||||
exc,
|
||||
)
|
||||
written = 0
|
||||
# Raised outside the try below: if error_type subclasses OSError or
|
||||
# RuntimeError, raising inside would re-wrap the limit error as
|
||||
# "Failed to extract" and lose the size-bound message.
|
||||
limit_error: str | None = None
|
||||
try:
|
||||
with zf.open(member, "r") as source, member_path.open("wb") as dest:
|
||||
while True:
|
||||
chunk = source.read(READ_CHUNK_SIZE)
|
||||
if not chunk:
|
||||
break
|
||||
written += len(chunk)
|
||||
if written > max_member_bytes:
|
||||
limit_error = (
|
||||
f"ZIP member {member.filename} exceeds maximum size "
|
||||
f"of {max_member_bytes} bytes"
|
||||
)
|
||||
break
|
||||
total_written += len(chunk)
|
||||
if total_written > max_total_bytes:
|
||||
limit_error = (
|
||||
f"ZIP archive exceeds maximum uncompressed size "
|
||||
f"of {max_total_bytes} bytes"
|
||||
)
|
||||
break
|
||||
dest.write(chunk)
|
||||
except Exception as exc:
|
||||
_raise_from(
|
||||
error_type,
|
||||
f"Failed to extract ZIP member {member.filename}: {exc}",
|
||||
exc,
|
||||
)
|
||||
if limit_error is not None:
|
||||
_raise(error_type, limit_error)
|
||||
|
||||
@@ -102,8 +102,17 @@ def resolve_github_release_asset_api_url(
|
||||
|
||||
from specify_cli._download_security import read_response_limited
|
||||
|
||||
parsed = urlparse(download_url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
# Accessing ``.hostname`` (like ``.port`` below) raises ValueError on a
|
||||
# malformed authority, e.g. an invalid bracketed IPv6 host
|
||||
# ``https://[not-an-ip]/...``. The function's contract is to return None for
|
||||
# anything it can't resolve, not to raise, so guard the read. ``download_url``
|
||||
# is server-controlled here (a catalog ``download_url`` payload), so a
|
||||
# malformed value must not leak a raw traceback past the caller.
|
||||
try:
|
||||
parsed = urlparse(download_url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
except ValueError:
|
||||
return None
|
||||
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
|
||||
|
||||
is_ghes = (
|
||||
|
||||
@@ -3,12 +3,22 @@
|
||||
import json
|
||||
from collections.abc import Mapping
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from typing import Any, Union
|
||||
|
||||
|
||||
INIT_OPTIONS_FILE = ".specify/init-options.json"
|
||||
|
||||
|
||||
class _MissingInitOptionsFile:
|
||||
"""Sentinel: init-options.json does not exist at all (legacy layout)."""
|
||||
|
||||
def __repr__(self) -> str: # pragma: no cover - debug aid only
|
||||
return "MISSING_INIT_OPTIONS_FILE"
|
||||
|
||||
|
||||
MISSING_INIT_OPTIONS_FILE = _MissingInitOptionsFile()
|
||||
|
||||
|
||||
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
|
||||
"""Persist the CLI options used during ``specify init``."""
|
||||
dest = project_path / INIT_OPTIONS_FILE
|
||||
@@ -34,3 +44,40 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
|
||||
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
|
||||
"""Return True only when init options explicitly enable AI skills."""
|
||||
return isinstance(opts, Mapping) and opts.get("ai_skills") is True
|
||||
|
||||
|
||||
def resolve_active_agent_for_registration(
|
||||
project_path: Path,
|
||||
) -> Union[str, None, _MissingInitOptionsFile]:
|
||||
"""Resolve the active integration key for active-only registration (#2948).
|
||||
|
||||
``load_init_options`` collapses "no file", "unreadable/malformed file",
|
||||
and "valid file with no recorded active agent" into the same ``{}``
|
||||
result, which previously made corrupted-but-present init-options behave
|
||||
like a legacy pre-init-options project and fall back to registering
|
||||
every detected agent. This helper distinguishes those cases explicitly:
|
||||
|
||||
- Returns :data:`MISSING_INIT_OPTIONS_FILE` when init-options.json does
|
||||
not exist at all (pre-init-options layout or direct library use).
|
||||
Callers should fall back to detection-based registration for all
|
||||
agents, matching the original pre-#2948 behavior for such projects.
|
||||
- Returns ``None`` when init-options.json exists but could not provide a
|
||||
valid non-empty string active agent (malformed/unreadable JSON,
|
||||
non-object payload, or a non-string/empty ``ai`` value). Callers must
|
||||
fail closed (register nothing) rather than treat this like "no file"
|
||||
or pass a non-string key into agent-config lookups.
|
||||
- Returns the active agent key (a non-empty string) otherwise.
|
||||
"""
|
||||
path = project_path / INIT_OPTIONS_FILE
|
||||
# A dangling symlink's target doesn't exist, so Path.exists() (which
|
||||
# follows symlinks) returns False even though the path itself is
|
||||
# present as a broken/corrupted entry. Treat any symlink as "present"
|
||||
# so a dangling one fails closed via the invalid-file branch below
|
||||
# instead of being mistaken for "no file at all" (legacy fallback).
|
||||
if not path.is_symlink() and not path.exists():
|
||||
return MISSING_INIT_OPTIONS_FILE
|
||||
|
||||
active_agent = load_init_options(project_path).get("ai")
|
||||
if isinstance(active_agent, str) and active_agent:
|
||||
return active_agent
|
||||
return None
|
||||
|
||||
@@ -12,6 +12,7 @@ import yaml
|
||||
from pathlib import Path, PurePosixPath, PureWindowsPath
|
||||
from typing import Any
|
||||
from ._console import console
|
||||
from ._download_security import normalize_zip_member_name
|
||||
|
||||
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
|
||||
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
|
||||
@@ -27,19 +28,22 @@ def relative_extension_path_violation(value: Any) -> str | None:
|
||||
``None`` when it is an acceptable relative path within the extension
|
||||
directory.
|
||||
|
||||
Policy: the value must be a non-empty string with no leading/trailing
|
||||
whitespace, no absolute/anchored form, and no ``..`` traversal. The value is
|
||||
Policy: the value must be a non-empty, portable file path with no
|
||||
leading/trailing whitespace, absolute/anchored form, ``..`` traversal,
|
||||
platform-reserved component, or directory-only suffix. The value is
|
||||
evaluated under both POSIX and Windows path semantics because a native
|
||||
``Path`` is OS-dependent (a ``PurePosixPath`` on POSIX does not interpret
|
||||
Windows drive/UNC forms, and ``C:foo`` is anchored but not ``is_absolute()``
|
||||
yet resolves against the CWD on its drive). Rejecting any non-empty anchor
|
||||
covers POSIX-absolute (``/abs``), Windows drive-relative (``C:foo``), Windows
|
||||
absolute (``C:\\foo``), and UNC/rooted forms.
|
||||
Windows drive/UNC forms, and ``C:foo`` is anchored but not
|
||||
``is_absolute()`` yet resolves against the CWD on its drive). Rejecting any
|
||||
non-empty anchor covers POSIX-absolute (``/abs``), Windows drive-relative
|
||||
(``C:foo``), Windows absolute (``C:\\foo``), and UNC/rooted forms.
|
||||
"""
|
||||
if not isinstance(value, str) or not value:
|
||||
return "must be a non-empty string"
|
||||
if value.strip() != value:
|
||||
return "must not have leading or trailing whitespace"
|
||||
if "\\" in value:
|
||||
return "must use forward slashes as path separators"
|
||||
posix_path = PurePosixPath(value)
|
||||
win_path = PureWindowsPath(value)
|
||||
if (
|
||||
@@ -52,6 +56,15 @@ def relative_extension_path_violation(value: Any) -> str | None:
|
||||
"must be a relative path within the extension directory "
|
||||
"(no absolute paths, drive letters, or '..' segments)"
|
||||
)
|
||||
if value.endswith(("/", "\\")):
|
||||
return "must name a file or command, not a directory"
|
||||
try:
|
||||
normalize_zip_member_name(value)
|
||||
except ValueError:
|
||||
return (
|
||||
"must use portable path components "
|
||||
"(no reserved names or platform-invalid characters)"
|
||||
)
|
||||
return None
|
||||
|
||||
|
||||
@@ -69,21 +82,14 @@ def run_command(
|
||||
cmd: list[str],
|
||||
check_return: bool = True,
|
||||
capture: bool = False,
|
||||
shell: bool = False,
|
||||
) -> str | None:
|
||||
"""Run a command without invoking a shell and optionally capture output.
|
||||
|
||||
The ``shell`` parameter is kept in the signature so existing keyword
|
||||
callers (and the re-export from ``specify_cli``) don't raise ``TypeError``,
|
||||
but only the default ``shell=False`` is honoured. ``shell=True`` is
|
||||
rejected with ``ValueError`` rather than silently ignored, so the
|
||||
unsupported mode fails loudly instead of running with a different meaning.
|
||||
Commands are always executed with ``shell=False`` and must be passed as an
|
||||
argv ``list[str]``. There is deliberately no ``shell`` parameter: the
|
||||
argv-list contract makes shell interpolation impossible by construction, so
|
||||
the shell-injection surface cannot be re-enabled at a call site.
|
||||
"""
|
||||
if shell:
|
||||
raise ValueError(
|
||||
"run_command() does not support shell=True; pass argv as a list"
|
||||
)
|
||||
|
||||
try:
|
||||
if capture:
|
||||
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True)
|
||||
|
||||
@@ -10,7 +10,7 @@ import os
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
from typing import Any, Dict, List, Optional
|
||||
from typing import Any, Dict, Iterable, List, Optional
|
||||
|
||||
import yaml
|
||||
|
||||
@@ -270,7 +270,7 @@ class CommandRegistrar:
|
||||
return text
|
||||
|
||||
def render_markdown_command(
|
||||
self, frontmatter: dict, body: str, source_id: str, context_note: str = None
|
||||
self, frontmatter: dict, body: str, source_id: str, context_note: Optional[str] = None
|
||||
) -> str:
|
||||
"""Render command in Markdown format.
|
||||
|
||||
@@ -597,7 +597,7 @@ class CommandRegistrar:
|
||||
source_id: str,
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: str = None,
|
||||
context_note: Optional[str] = None,
|
||||
_resolved_dir: Path = None,
|
||||
link_outputs: bool = False,
|
||||
extension_id: Optional[str] = None,
|
||||
@@ -675,6 +675,23 @@ class CommandRegistrar:
|
||||
cmd_name = cmd_info["name"]
|
||||
aliases = cmd_info.get("aliases", [])
|
||||
cmd_file = cmd_info["file"]
|
||||
name_reason = relative_extension_path_violation(cmd_name)
|
||||
if name_reason:
|
||||
raise ValueError(
|
||||
f"Invalid command name {cmd_name!r}: {name_reason}"
|
||||
)
|
||||
if aliases is None:
|
||||
aliases = []
|
||||
if not isinstance(aliases, list):
|
||||
raise ValueError(
|
||||
f"Aliases for command {cmd_name!r} must be a list"
|
||||
)
|
||||
for alias in aliases:
|
||||
alias_reason = relative_extension_path_violation(alias)
|
||||
if alias_reason:
|
||||
raise ValueError(
|
||||
f"Invalid command alias {alias!r}: {alias_reason}"
|
||||
)
|
||||
|
||||
# Guard against path traversal using the single shared policy in
|
||||
# relative_extension_path_violation(), so the runtime guard stays
|
||||
@@ -957,10 +974,16 @@ class CommandRegistrar:
|
||||
project_root: Path to project root
|
||||
cmd_name: Command name (e.g. 'speckit.my-ext.example')
|
||||
"""
|
||||
name_reason = relative_extension_path_violation(cmd_name)
|
||||
if name_reason:
|
||||
raise ValueError(
|
||||
f"Invalid Copilot prompt name {cmd_name!r}: {name_reason}"
|
||||
)
|
||||
prompts_dir = project_root / ".github" / "prompts"
|
||||
prompts_dir.mkdir(parents=True, exist_ok=True)
|
||||
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
|
||||
CommandRegistrar._ensure_inside(prompt_file, prompts_dir)
|
||||
prompt_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
@@ -1016,10 +1039,11 @@ class CommandRegistrar:
|
||||
source_id: str,
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: str = None,
|
||||
context_note: Optional[str] = None,
|
||||
link_outputs: bool = False,
|
||||
create_missing_active_skills_dir: bool = False,
|
||||
extension_id: Optional[str] = None,
|
||||
only_agent: Optional[str] = None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all detected agents in the project.
|
||||
|
||||
@@ -1037,6 +1061,8 @@ class CommandRegistrar:
|
||||
skills directory) and is skipped when safe resolution or
|
||||
creation fails.
|
||||
extension_id: Extension id when rendering extension-owned commands.
|
||||
only_agent: If set, restrict registration to this single agent
|
||||
while keeping all detection and recovery safeguards (#2948).
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
@@ -1060,6 +1086,8 @@ class CommandRegistrar:
|
||||
)
|
||||
active_created_skills_dir: Optional[Path] = None
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
if only_agent is not None and agent_name != only_agent:
|
||||
continue
|
||||
active_skills_output = (
|
||||
agent_name == active_skills_agent
|
||||
and agent_config.get("extension") == "/SKILL.md"
|
||||
@@ -1165,6 +1193,8 @@ class CommandRegistrar:
|
||||
context_note: Optional[str] = None,
|
||||
link_outputs: bool = False,
|
||||
extension_id: Optional[str] = None,
|
||||
only_agent: Optional[str] = None,
|
||||
extra_agents: Optional[Iterable[str]] = None,
|
||||
) -> Dict[str, List[str]]:
|
||||
"""Register commands for all non-skill agents in the project.
|
||||
|
||||
@@ -1181,13 +1211,29 @@ class CommandRegistrar:
|
||||
link_outputs: If True, create dev-mode symlinks for rendered
|
||||
command files when supported by the OS.
|
||||
extension_id: Extension id when rendering extension-owned commands.
|
||||
only_agent: If set, restrict registration to this single agent
|
||||
(#2948). An agent name that matches no configured agent
|
||||
(e.g. an empty string) yields no registrations at all.
|
||||
extra_agents: Additional agent names to register for besides
|
||||
``only_agent``. Used by post-removal reconciliation to also
|
||||
restore surviving content into historical agent directories
|
||||
a just-removed preset actually wrote to, not only the
|
||||
currently active agent (#2948). Ignored when ``only_agent``
|
||||
is ``None`` (already unrestricted).
|
||||
|
||||
Returns:
|
||||
Dictionary mapping agent names to list of registered commands
|
||||
"""
|
||||
results = {}
|
||||
self._ensure_configs()
|
||||
extra_agents_set = frozenset(extra_agents) if extra_agents else frozenset()
|
||||
for agent_name, agent_config in self.AGENT_CONFIGS.items():
|
||||
if (
|
||||
only_agent is not None
|
||||
and agent_name != only_agent
|
||||
and agent_name not in extra_agents_set
|
||||
):
|
||||
continue
|
||||
if agent_config.get("extension") == "/SKILL.md":
|
||||
continue
|
||||
detect_dir_str = agent_config.get("detect_dir")
|
||||
|
||||
@@ -13,6 +13,7 @@ import stat
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
@@ -53,6 +54,19 @@ def _is_valid_host_pattern(pattern: str) -> bool:
|
||||
return pattern.startswith("*.") and "*" not in pattern[2:]
|
||||
|
||||
|
||||
def _norm(value: Any) -> Any:
|
||||
"""Strip surrounding whitespace from a whitespace-insignificant string
|
||||
config reference (env-var names, tenant/client ids) before it is stored.
|
||||
|
||||
These fields are validated on their ``.strip()``ed form, so an accidentally
|
||||
padded value passes validation but then silently breaks the verbatim
|
||||
``os.environ.get(...)`` / URL lookups downstream. Normalizing at store time
|
||||
mirrors how ``hosts`` is already handled (``h.strip().lower()``). Non-string
|
||||
values (e.g. ``None``) pass through unchanged.
|
||||
"""
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
def load_auth_config(
|
||||
path: Path | None = None,
|
||||
) -> list[AuthConfigEntry]:
|
||||
@@ -182,10 +196,10 @@ def load_auth_config(
|
||||
provider=provider,
|
||||
auth=auth,
|
||||
token=token,
|
||||
token_env=token_env,
|
||||
tenant_id=entry_raw.get("tenant_id"),
|
||||
client_id=entry_raw.get("client_id"),
|
||||
client_secret_env=entry_raw.get("client_secret_env"),
|
||||
token_env=_norm(token_env),
|
||||
tenant_id=_norm(entry_raw.get("tenant_id")),
|
||||
client_id=_norm(entry_raw.get("client_id")),
|
||||
client_secret_env=_norm(entry_raw.get("client_secret_env")),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -14,14 +14,13 @@ from .. import BundlerError
|
||||
from ..lib.yamlio import dump_yaml, ensure_within, load_yaml
|
||||
from ..models.catalog import (
|
||||
CONFIG_FILENAME,
|
||||
CONFIG_SCHEMA_VERSION,
|
||||
BUILTIN_DEFAULT_STACK,
|
||||
CatalogSource,
|
||||
InstallPolicy,
|
||||
Scope,
|
||||
)
|
||||
|
||||
CONFIG_SCHEMA_VERSION = "1.0"
|
||||
|
||||
_BUILTIN_IDS = {raw["id"] for raw in BUILTIN_DEFAULT_STACK}
|
||||
|
||||
# Windows absolute paths like ``C:\catalog.json`` parse with a single-letter
|
||||
@@ -40,9 +39,12 @@ def _read(project_root: Path) -> list[dict]:
|
||||
path = ensure_within(project_root, _config_path(project_root))
|
||||
if not path.exists():
|
||||
return []
|
||||
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
|
||||
# otherwise, so a non-mapping top level — a falsy ``[]``/``false``/``0``/``''``
|
||||
# or an explicit null (``load_yaml`` -> ``None``) — is caught by the isinstance
|
||||
# guard below and raised like a truthy one, staying consistent with the other
|
||||
# reader of this file (models/catalog._merge_config).
|
||||
data = load_yaml(path)
|
||||
if data is None:
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {path}: expected a mapping at the top "
|
||||
@@ -150,6 +152,8 @@ def add_source(
|
||||
# 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
|
||||
# Accessing ``port`` performs urllib's syntax/range validation.
|
||||
_ = parsed.port
|
||||
except ValueError as exc:
|
||||
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
|
||||
if not (parsed.scheme or parsed.path):
|
||||
|
||||
@@ -39,17 +39,35 @@ def ensure_within(root: Path, candidate: Path) -> Path:
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> Any:
|
||||
"""Parse a YAML file, returning ``{}`` for an empty document."""
|
||||
"""Parse a YAML file, returning ``{}`` only for an *empty* document.
|
||||
|
||||
A non-empty document is returned exactly as parsed — including a
|
||||
non-mapping such as ``[]``, ``false``, ``0``, ``''``, or an explicit null
|
||||
(``null``/``~``) — so callers can validate the top-level shape (e.g. reject
|
||||
a non-mapping config) instead of having it silently coerced to an empty
|
||||
mapping.
|
||||
|
||||
``yaml.safe_load`` returns ``None`` for *both* an empty document and an
|
||||
explicit null scalar, so ``yaml.compose`` (which yields no node only for a
|
||||
truly empty document) is used to tell them apart: an empty document becomes
|
||||
``{}`` while an explicit ``null``/``~`` is returned as ``None`` for the
|
||||
caller to reject.
|
||||
"""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"File not found: {path}")
|
||||
try:
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not read {path}: {exc}") from exc
|
||||
try:
|
||||
has_node = yaml.compose(text) is not None
|
||||
data = yaml.safe_load(text)
|
||||
except yaml.YAMLError as exc:
|
||||
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
|
||||
if data is None and not has_node:
|
||||
return {}
|
||||
return data
|
||||
|
||||
|
||||
def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
|
||||
|
||||
@@ -15,6 +15,11 @@ from .. import BundlerError
|
||||
from ..lib.yamlio import ensure_within, load_yaml
|
||||
|
||||
CONFIG_FILENAME = "bundle-catalogs.yml"
|
||||
# Supported bundle-catalogs.yml schema (major version). Both readers of the
|
||||
# file — this module's _merge_config and commands_impl/catalog_config._read —
|
||||
# reject an unsupported major version so a file written by a newer/incompatible
|
||||
# Spec Kit fails fast instead of being parsed under the wrong assumptions.
|
||||
CONFIG_SCHEMA_VERSION = "1.0"
|
||||
|
||||
|
||||
class InstallPolicy(str, Enum):
|
||||
@@ -139,6 +144,7 @@ class CatalogEntry:
|
||||
license: str
|
||||
download_url: str
|
||||
requires_speckit_version: str
|
||||
sha256: str | None = None
|
||||
provides: dict[str, int] = field(default_factory=dict)
|
||||
repository: str | None = None
|
||||
tags: tuple[str, ...] = ()
|
||||
@@ -152,14 +158,21 @@ class CatalogEntry:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each catalog entry must be a mapping.")
|
||||
entry_id = str(data.get("id", "")).strip()
|
||||
requires = data.get("requires") or {}
|
||||
if not isinstance(requires, dict):
|
||||
# `or {}` would coerce a FALSY non-mapping (0, '', False, []) to {} before
|
||||
# the isinstance guard, silently accepting a corrupt catalog entry; only
|
||||
# an absent/None value means "not present".
|
||||
requires = data.get("requires")
|
||||
if requires is None:
|
||||
requires = {}
|
||||
elif not isinstance(requires, dict):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id or '<unknown>'}': 'requires' must be a "
|
||||
"mapping when present."
|
||||
)
|
||||
provides_raw = data.get("provides") or {}
|
||||
if not isinstance(provides_raw, dict):
|
||||
provides_raw = data.get("provides")
|
||||
if provides_raw is None:
|
||||
provides_raw = {}
|
||||
elif not isinstance(provides_raw, dict):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id or '<unknown>'}': 'provides' must be a "
|
||||
"mapping when present."
|
||||
@@ -174,6 +187,11 @@ class CatalogEntry:
|
||||
license=str(data.get("license", "")).strip(),
|
||||
download_url=str(data.get("download_url", "")).strip(),
|
||||
requires_speckit_version=str(requires.get("speckit_version", "")).strip(),
|
||||
sha256=(
|
||||
None
|
||||
if data.get("sha256") is None
|
||||
else str(data["sha256"]).strip()
|
||||
),
|
||||
provides=dict(provides_raw),
|
||||
repository=(str(data["repository"]) if data.get("repository") else None),
|
||||
tags=_parse_tags(data.get("tags"), entry_id),
|
||||
@@ -186,6 +204,7 @@ class CatalogEntry:
|
||||
description=self.description, author=self.author, license=self.license,
|
||||
download_url=self.download_url,
|
||||
requires_speckit_version=self.requires_speckit_version,
|
||||
sha256=self.sha256,
|
||||
provides=self.provides, repository=self.repository, tags=self.tags,
|
||||
verified=self.verified, source_id=source.id,
|
||||
source_policy=source.install_policy,
|
||||
@@ -249,8 +268,35 @@ def load_source_stack(project_root: Path, user_config_dir: Path | None = None) -
|
||||
def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None:
|
||||
if not config_path.exists():
|
||||
return
|
||||
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
|
||||
# otherwise, so a non-mapping top level (a YAML list or scalar, including
|
||||
# the falsy ``[]``/``false``/``0``/``''``) is caught here and raised —
|
||||
# matching the sibling reader commands_impl/catalog_config._read. #3623
|
||||
# aligned the inner non-list ``catalogs`` value between the two readers.
|
||||
data = load_yaml(config_path)
|
||||
catalogs = data.get("catalogs") if isinstance(data, dict) else None
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {config_path}: expected a mapping at "
|
||||
f"the top level, got {type(data).__name__}."
|
||||
)
|
||||
# Reject an unsupported major schema version, matching the sibling reader
|
||||
# commands_impl/catalog_config._read. Without this, a file written by a
|
||||
# newer/incompatible Spec Kit was silently parsed under v1 assumptions on
|
||||
# the resolution path (bundle search/install), while the other reader
|
||||
# rejected it — the two readers disagreed. An absent schema_version stays
|
||||
# valid (backward compatible with configs that omit it).
|
||||
schema_version = data.get("schema_version")
|
||||
if schema_version is not None and (
|
||||
str(schema_version).strip().split(".")[0]
|
||||
!= CONFIG_SCHEMA_VERSION.split(".")[0]
|
||||
):
|
||||
raise BundlerError(
|
||||
f"Unsupported catalog config schema version "
|
||||
f"'{str(schema_version).strip()}' at {config_path}; this Spec Kit "
|
||||
f"understands version {CONFIG_SCHEMA_VERSION}. The file may have been "
|
||||
"written by a newer version or is corrupt."
|
||||
)
|
||||
catalogs = data.get("catalogs")
|
||||
if catalogs is None:
|
||||
return
|
||||
if not isinstance(catalogs, list):
|
||||
|
||||
@@ -55,8 +55,13 @@ class InstalledBundleRecord:
|
||||
def from_dict(cls, data: Any) -> "InstalledBundleRecord":
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each installed-bundle record must be a mapping.")
|
||||
components_raw = data.get("contributed_components") or []
|
||||
if not isinstance(components_raw, list):
|
||||
components_raw = data.get("contributed_components")
|
||||
if components_raw is None:
|
||||
components_raw = []
|
||||
elif not isinstance(components_raw, list):
|
||||
# `or []` would coerce a FALSY non-list (0, '', False, {}) to []
|
||||
# before this guard, silently accepting a corrupt record; only an
|
||||
# absent/None value means "no components".
|
||||
raise BundlerError(
|
||||
"Corrupt record: 'contributed_components' must be a list."
|
||||
)
|
||||
@@ -121,8 +126,13 @@ def load_records(project_root: Path) -> list[InstalledBundleRecord]:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(f"Corrupt records file: {path}")
|
||||
_check_schema_version(data.get("schema_version"), path=path, required=True)
|
||||
bundles = data.get("bundles") or []
|
||||
if not isinstance(bundles, list):
|
||||
bundles = data.get("bundles")
|
||||
if bundles is None:
|
||||
bundles = []
|
||||
elif not isinstance(bundles, list):
|
||||
# `or []` would coerce a FALSY non-list (0, '', False, {}) to [] before
|
||||
# this guard, silently treating a corrupt file as "no bundles"; only an
|
||||
# absent/None value means empty.
|
||||
raise BundlerError(
|
||||
f"Corrupt records file: {path} — 'bundles' must be a list."
|
||||
)
|
||||
|
||||
@@ -16,6 +16,7 @@ from urllib.parse import ParseResult, urlparse
|
||||
from urllib.request import url2pathname
|
||||
|
||||
from ..._assets import _locate_core_pack, _repo_root
|
||||
from ..._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
|
||||
from .. import BundlerError
|
||||
from ..lib.yamlio import loads_json
|
||||
from ..models.catalog import CatalogSource
|
||||
@@ -76,6 +77,8 @@ def _validate_remote_url(source_id: str, url: str) -> None:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
# Accessing ``port`` performs urllib's syntax/range validation.
|
||||
_ = parsed.port
|
||||
except ValueError:
|
||||
raise BundlerError(
|
||||
f"Catalog '{source_id}' URL is malformed: {url}"
|
||||
@@ -117,7 +120,15 @@ def make_catalog_fetcher(*, allow_network: bool = True):
|
||||
|
||||
def fetch(source: CatalogSource) -> dict:
|
||||
url = source.url
|
||||
parsed = urlparse(url)
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
# Keep malformed authorities and ports inside the BundlerError
|
||||
# contract even when a config file was edited by hand.
|
||||
_ = parsed.port
|
||||
except ValueError:
|
||||
raise BundlerError(
|
||||
f"Catalog {source.id!r} URL is malformed: {url!r}"
|
||||
) from None
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
if scheme == "builtin":
|
||||
@@ -180,7 +191,12 @@ def _http_get_json(source_id: str, url: str) -> dict:
|
||||
) as response:
|
||||
final_url = response.geturl()
|
||||
_validate_remote_url(source_id, final_url)
|
||||
raw = response.read().decode("utf-8")
|
||||
raw = read_response_limited(
|
||||
response,
|
||||
max_bytes=MAX_JSON_CATALOG_BYTES,
|
||||
error_type=BundlerError,
|
||||
label=f"bundle catalog '{source_id}'",
|
||||
).decode("utf-8")
|
||||
except BundlerError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
|
||||
@@ -50,7 +50,10 @@ class InstallResult:
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
return bool(self.installed or self.refreshed)
|
||||
# `uninstalled` is a mutating outcome too: a `bundle update` whose new
|
||||
# manifest drops components (removing them via the refresh path) with no
|
||||
# new install/refresh must still report changed=True, not a no-op.
|
||||
return bool(self.installed or self.refreshed or self.uninstalled)
|
||||
|
||||
|
||||
def install_bundle(
|
||||
|
||||
@@ -14,6 +14,7 @@ from pathlib import Path
|
||||
import typer
|
||||
|
||||
from ..._console import console, err_console
|
||||
from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited
|
||||
from ...bundler import BundlerError
|
||||
from ...bundler.lib.project import (
|
||||
active_integration,
|
||||
@@ -337,6 +338,10 @@ def bundle_install(
|
||||
local_manifest = _local_manifest_source(bundle_id)
|
||||
if local_manifest is not None:
|
||||
manifest = local_manifest
|
||||
_validate_manifest_structure(
|
||||
manifest,
|
||||
source=f"Local bundle source {bundle_id!r}",
|
||||
)
|
||||
else:
|
||||
stack = _build_stack(project_root or Path.cwd(), offline=offline)
|
||||
resolved = stack.resolve(bundle_id)
|
||||
@@ -350,6 +355,16 @@ def bundle_install(
|
||||
|
||||
if project_root is None:
|
||||
init_integration = _resolve_init_integration(integration, manifest)
|
||||
# Resolve all hard compatibility gates before ``specify init``.
|
||||
# Otherwise an incompatible but structurally valid bundle would
|
||||
# initialize a project and only then fail its version/integration
|
||||
# checks, leaving state behind after a failed install.
|
||||
resolve_install_plan(
|
||||
manifest,
|
||||
speckit_version=_speckit_version(),
|
||||
active_integration=init_integration,
|
||||
integration_explicit=True,
|
||||
)
|
||||
console.print(
|
||||
f"[cyan]No Spec Kit project here; initializing with integration "
|
||||
f"'{init_integration}'…[/cyan]"
|
||||
@@ -711,17 +726,24 @@ def _local_manifest_source(arg: str):
|
||||
|
||||
if candidate.suffix == ".zip":
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
import yaml as _yaml
|
||||
|
||||
with zipfile.ZipFile(candidate) as archive:
|
||||
from ..._download_security import open_zip_bounded, read_zip_member_limited
|
||||
|
||||
with open_zip_bounded(candidate, error_type=BundlerError) as archive:
|
||||
try:
|
||||
raw = archive.read("bundle.yml")
|
||||
archive.getinfo("bundle.yml")
|
||||
except KeyError as exc:
|
||||
raise BundlerError(
|
||||
f"Artifact '{candidate}' does not contain a bundle.yml."
|
||||
) from exc
|
||||
raw = read_zip_member_limited(
|
||||
archive,
|
||||
"bundle.yml",
|
||||
error_type=BundlerError,
|
||||
label="bundle manifest",
|
||||
)
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
@@ -805,7 +827,13 @@ def _download_manifest(resolved, *, offline: bool):
|
||||
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
|
||||
f"from {url}."
|
||||
)
|
||||
return _download_remote_manifest(resolved.entry.id, url)
|
||||
manifest = _download_remote_manifest(
|
||||
resolved.entry.id,
|
||||
url,
|
||||
expected_sha256=getattr(resolved.entry, "sha256", None),
|
||||
)
|
||||
_validate_catalog_manifest(resolved.entry, manifest)
|
||||
return manifest
|
||||
|
||||
|
||||
def _require_https(label: str, url: str) -> None:
|
||||
@@ -817,6 +845,8 @@ def _require_https(label: str, url: str) -> None:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
# Accessing ``port`` performs urllib's syntax/range validation.
|
||||
_ = parsed.port
|
||||
except ValueError:
|
||||
raise BundlerError(
|
||||
f"Refusing to download {label}: URL is malformed: {url}"
|
||||
@@ -830,7 +860,12 @@ def _require_https(label: str, url: str) -> None:
|
||||
raise BundlerError(f"Refusing to download {label} from URL with no host: {url}")
|
||||
|
||||
|
||||
def _download_remote_manifest(entry_id: str, url: str):
|
||||
def _download_remote_manifest(
|
||||
entry_id: str,
|
||||
url: str,
|
||||
*,
|
||||
expected_sha256: str | None = None,
|
||||
):
|
||||
"""Fetch a remote bundle artifact over HTTPS and extract its manifest."""
|
||||
import io
|
||||
import tempfile
|
||||
@@ -842,6 +877,7 @@ def _download_remote_manifest(entry_id: str, url: str):
|
||||
from ...authentication.http import github_provider_hosts, open_url
|
||||
from ..._github_http import resolve_github_release_asset_api_url
|
||||
from ...bundler.models.manifest import BundleManifest
|
||||
from ...shared_infra import verify_archive_sha256
|
||||
|
||||
def _validate_redirect(old_url: str, new_url: str) -> None:
|
||||
_require_https(f"bundle '{entry_id}'", new_url)
|
||||
@@ -879,7 +915,18 @@ def _download_remote_manifest(entry_id: str, url: str):
|
||||
extra_headers=extra_headers,
|
||||
) as resp:
|
||||
_require_https(f"bundle '{entry_id}'", resp.geturl())
|
||||
raw = resp.read()
|
||||
raw = read_response_limited(
|
||||
resp,
|
||||
max_bytes=MAX_DOWNLOAD_BYTES,
|
||||
error_type=BundlerError,
|
||||
label=f"bundle '{entry_id}' download",
|
||||
)
|
||||
verify_archive_sha256(
|
||||
raw,
|
||||
expected_sha256,
|
||||
entry_id,
|
||||
BundlerError,
|
||||
)
|
||||
except BundlerError:
|
||||
raise
|
||||
except Exception as exc: # noqa: BLE001
|
||||
@@ -940,6 +987,38 @@ def _download_remote_manifest(entry_id: str, url: str):
|
||||
) from exc
|
||||
|
||||
|
||||
def _validate_manifest_structure(manifest, *, source: str) -> None:
|
||||
"""Reject a malformed manifest before any project mutation can occur."""
|
||||
from ...bundler.services.validator import validate_manifest
|
||||
|
||||
report = validate_manifest(manifest)
|
||||
if report.ok:
|
||||
return
|
||||
raise BundlerError(
|
||||
f"{source} contains an invalid bundle manifest:\n - "
|
||||
+ "\n - ".join(report.errors)
|
||||
)
|
||||
|
||||
|
||||
def _validate_catalog_manifest(entry, manifest) -> None:
|
||||
"""Bind a downloaded manifest to the catalog identity that selected it."""
|
||||
if manifest.bundle.id != entry.id:
|
||||
raise BundlerError(
|
||||
f"Downloaded bundle id mismatch: catalog entry {entry.id!r} points to "
|
||||
f"a manifest for {manifest.bundle.id!r}."
|
||||
)
|
||||
if manifest.bundle.version != entry.version:
|
||||
raise BundlerError(
|
||||
f"Downloaded bundle version mismatch for {entry.id!r}: catalog declares "
|
||||
f"{entry.version!r}, but the manifest declares "
|
||||
f"{manifest.bundle.version!r}."
|
||||
)
|
||||
_validate_manifest_structure(
|
||||
manifest,
|
||||
source=f"Downloaded bundle {entry.id!r}",
|
||||
)
|
||||
|
||||
|
||||
def register(app: typer.Typer) -> None:
|
||||
"""Attach the bundle command group to the root Typer app."""
|
||||
app.add_typer(bundle_app, name="bundle")
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -8,12 +8,14 @@ which re-fetch from the parent package at call time so test monkeypatching of
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
@@ -23,6 +25,15 @@ from rich.table import Table
|
||||
|
||||
from .._console import console
|
||||
from .._assets import get_speckit_version
|
||||
from .._download_security import (
|
||||
is_https_or_localhost_http,
|
||||
normalize_zip_member_name,
|
||||
open_zip_bounded,
|
||||
portable_zip_path_key,
|
||||
read_response_limited,
|
||||
read_zip_member_limited,
|
||||
)
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
|
||||
extension_app = typer.Typer(
|
||||
name="extension",
|
||||
@@ -166,9 +177,17 @@ def _resolve_catalog_extension(
|
||||
if ext_info:
|
||||
return (ext_info, None)
|
||||
|
||||
# Try by display name - search using argument as query, then filter for exact match
|
||||
search_results = catalog.search(query=argument)
|
||||
name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()]
|
||||
# Try by display name - search using argument as query, then filter for exact match.
|
||||
# Coerce name defensively: catalog JSON is user-editable, so a hand-authored
|
||||
# non-string/missing name must not crash the match (the ambiguous-match display
|
||||
# below already str()-coerces name for the same reason).
|
||||
search_results = catalog.search()
|
||||
argument_lower = argument.lower()
|
||||
name_matches = [
|
||||
ext
|
||||
for ext in search_results
|
||||
if str(ext.get("name", "")).lower() == argument_lower
|
||||
]
|
||||
|
||||
if len(name_matches) == 1:
|
||||
return (name_matches[0], None)
|
||||
@@ -428,14 +447,24 @@ 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
|
||||
parsed.port
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if not hostname:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
if not is_https_or_localhost_http(from_url):
|
||||
console.print("[red]Error:[/red] URL must use HTTPS for security.")
|
||||
console.print("HTTP is only allowed for localhost URLs.")
|
||||
console.print("HTTP is only allowed for loopback URLs.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
safe_url = _escape_markup(from_url)
|
||||
@@ -518,7 +547,11 @@ def extension_add(
|
||||
with dl_catalog._open_url(
|
||||
download_url, timeout=60, extra_headers=extra_headers
|
||||
) as response:
|
||||
zip_data = response.read()
|
||||
zip_data = read_response_limited(
|
||||
response,
|
||||
error_type=ExtensionError,
|
||||
label=f"extension {from_url}",
|
||||
)
|
||||
|
||||
if not zipfile.is_zipfile(io.BytesIO(zip_data)):
|
||||
console.print(
|
||||
@@ -623,16 +656,22 @@ def extension_add(
|
||||
for warning in manifest.warnings:
|
||||
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
|
||||
|
||||
is_cline = load_init_options(project_root).get("ai") == "cline"
|
||||
selected_ai = load_init_options(project_root).get("ai")
|
||||
is_cline = selected_ai == "cline"
|
||||
is_forge = selected_ai == "forge"
|
||||
|
||||
if is_cline:
|
||||
from specify_cli.integrations.cline import format_cline_command_name
|
||||
if is_forge:
|
||||
from specify_cli.integrations.forge import format_forge_command_name
|
||||
|
||||
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
|
||||
for cmd in manifest.commands:
|
||||
cmd_name = cmd['name']
|
||||
if is_cline:
|
||||
cmd_name = format_cline_command_name(cmd_name)
|
||||
elif is_forge:
|
||||
cmd_name = format_forge_command_name(cmd_name)
|
||||
console.print(f" • {_escape_markup(str(cmd_name))} - {_escape_markup(str(cmd.get('description', '')))}")
|
||||
|
||||
# Report agent skills registration
|
||||
@@ -777,10 +816,24 @@ def extension_search(
|
||||
|
||||
# Stats
|
||||
stats = []
|
||||
if ext.get('downloads') is not None:
|
||||
stats.append(f"Downloads: {ext['downloads']:,}")
|
||||
if ext.get('stars') is not None:
|
||||
stats.append(f"Stars: {ext['stars']}")
|
||||
downloads = ext.get('downloads')
|
||||
if downloads is not None:
|
||||
# Catalog fields are untrusted; a non-numeric ``downloads``
|
||||
# (e.g. the JSON string "1500") would crash the ``:,`` format
|
||||
# with "Cannot specify ',' with 's'". Only group-format numbers,
|
||||
# and escape the fallback: the joined stats are rendered as Rich
|
||||
# markup, so a value like "[/red]foo" would raise MarkupError
|
||||
# (matching how every other catalog field here is escaped).
|
||||
stats.append(
|
||||
f"Downloads: {downloads:,}"
|
||||
if isinstance(downloads, (int, float))
|
||||
else f"Downloads: {_escape_markup(str(downloads))}"
|
||||
)
|
||||
stars = ext.get('stars')
|
||||
if stars is not None:
|
||||
# Same untrusted-value/Rich-markup hazard as `downloads` above,
|
||||
# in the same joined string.
|
||||
stats.append(f"Stars: {_escape_markup(str(stars))}")
|
||||
if stats:
|
||||
console.print(f" [dim]{' | '.join(stats)}[/dim]")
|
||||
|
||||
@@ -866,9 +919,30 @@ def extension_info(
|
||||
console.print()
|
||||
|
||||
if ext_manifest.commands:
|
||||
# Print each command the way the active agent registers it.
|
||||
# Cline and Forge hyphenate command names (e.g. Forge invokes
|
||||
# `/speckit-jira-sync`, not the manifest's dotted
|
||||
# `speckit.jira.sync`), so mirror the same formatting used by
|
||||
# `extension add`'s "Provided commands" listing — otherwise the
|
||||
# names shown here don't match what the user actually types.
|
||||
selected_ai = load_init_options(project_root).get("ai")
|
||||
if selected_ai == "cline":
|
||||
from specify_cli.integrations.cline import (
|
||||
format_cline_command_name as _format_command_name,
|
||||
)
|
||||
elif selected_ai == "forge":
|
||||
from specify_cli.integrations.forge import (
|
||||
format_forge_command_name as _format_command_name,
|
||||
)
|
||||
else:
|
||||
_format_command_name = None
|
||||
|
||||
console.print("[bold]Commands:[/bold]")
|
||||
for cmd in ext_manifest.commands:
|
||||
console.print(f" • {_escape_markup(str(cmd['name']))}: {_escape_markup(str(cmd.get('description', '')))}")
|
||||
cmd_name = cmd['name']
|
||||
if _format_command_name is not None:
|
||||
cmd_name = _format_command_name(cmd_name)
|
||||
console.print(f" • {_escape_markup(str(cmd_name))}: {_escape_markup(str(cmd.get('description', '')))}")
|
||||
console.print()
|
||||
|
||||
# Show catalog status
|
||||
@@ -958,10 +1032,24 @@ def _print_extension_info(ext_info: dict, manager):
|
||||
|
||||
# Statistics
|
||||
stats = []
|
||||
if ext_info.get('downloads') is not None:
|
||||
stats.append(f"Downloads: {ext_info['downloads']:,}")
|
||||
if ext_info.get('stars') is not None:
|
||||
stats.append(f"Stars: {ext_info['stars']}")
|
||||
downloads = ext_info.get('downloads')
|
||||
if downloads is not None:
|
||||
# Catalog fields are untrusted; a non-numeric ``downloads`` (e.g. the
|
||||
# JSON string "1500") would crash the ``:,`` format with "Cannot
|
||||
# specify ',' with 's'". Only group-format numbers, and escape the
|
||||
# fallback: the joined stats are rendered as Rich markup, so a value
|
||||
# like "[/red]foo" would raise MarkupError (matching how every other
|
||||
# catalog field here is escaped).
|
||||
stats.append(
|
||||
f"Downloads: {downloads:,}"
|
||||
if isinstance(downloads, (int, float))
|
||||
else f"Downloads: {_escape_markup(str(downloads))}"
|
||||
)
|
||||
stars = ext_info.get('stars')
|
||||
if stars is not None:
|
||||
# Same untrusted-value/Rich-markup hazard as `downloads` above, in the
|
||||
# same joined string.
|
||||
stats.append(f"Stars: {_escape_markup(str(stars))}")
|
||||
if stats:
|
||||
console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}")
|
||||
console.print()
|
||||
@@ -1009,6 +1097,7 @@ def extension_update(
|
||||
from . import (
|
||||
ExtensionManager,
|
||||
ExtensionCatalog,
|
||||
ExtensionManifest,
|
||||
ExtensionError,
|
||||
ValidationError,
|
||||
CommandRegistrar,
|
||||
@@ -1123,9 +1212,17 @@ def extension_update(
|
||||
console.print(f"📦 Updating {safe_ext_name}...")
|
||||
|
||||
# Backup paths
|
||||
backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update"
|
||||
backup_root = manager.extensions_dir / ".backup"
|
||||
backup_key = hashlib.sha256(
|
||||
extension_id.encode("utf-8")
|
||||
).hexdigest()[:16]
|
||||
backup_base = (
|
||||
backup_root
|
||||
/ f"update-{backup_key}-{uuid4().hex}"
|
||||
)
|
||||
backup_ext_dir = backup_base / "extension"
|
||||
backup_commands_dir = backup_base / "commands"
|
||||
backup_skills_dir = backup_base / "skills"
|
||||
backup_config_dir = backup_base / "config"
|
||||
|
||||
# Store backup state
|
||||
@@ -1133,14 +1230,125 @@ def extension_update(
|
||||
backup_installed = UNSET # Original installed list from extensions.yml
|
||||
backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured
|
||||
backed_up_command_files = {}
|
||||
backed_up_command_symlinks = {}
|
||||
backed_up_skill_dirs = {}
|
||||
new_command_dirs_absent_before_update = []
|
||||
new_command_paths_absent_before_update = []
|
||||
new_skill_names = []
|
||||
new_skill_paths_absent_before_update = []
|
||||
# Validation failures must not rewrite an untouched installation.
|
||||
installation_modified = False
|
||||
zip_cleanup_error = None
|
||||
backup_created_by_attempt = False
|
||||
|
||||
def backup_command_artifact(original_file, backup_file):
|
||||
"""Back up one command artifact once, preserving its full path."""
|
||||
nonlocal backup_created_by_attempt
|
||||
original_key = str(original_file)
|
||||
if original_key in backed_up_command_files:
|
||||
return
|
||||
if original_file.is_symlink():
|
||||
backed_up_command_symlinks[original_key] = os.readlink(
|
||||
original_file
|
||||
)
|
||||
else:
|
||||
if original_file.stat().st_nlink > 1:
|
||||
raise RuntimeError(
|
||||
"Cannot safely update hard-linked generated "
|
||||
f"artifact '{original_file}'"
|
||||
)
|
||||
backup_created_by_attempt = True
|
||||
backup_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(original_file, backup_file)
|
||||
backed_up_command_files[original_key] = str(backup_file)
|
||||
|
||||
def restore_command_artifact(original_path, backup_path):
|
||||
"""Restore one regular file or symlink without following it."""
|
||||
original_key = str(original_path)
|
||||
original_file = Path(original_path)
|
||||
backup_file = Path(backup_path)
|
||||
symlink_state = backed_up_command_symlinks.get(
|
||||
original_key
|
||||
)
|
||||
|
||||
if symlink_state is not None:
|
||||
if original_file.is_symlink() or original_file.is_file():
|
||||
original_file.unlink()
|
||||
elif original_file.exists():
|
||||
raise RuntimeError(
|
||||
"Command rollback found an unexpected directory "
|
||||
f"at '{original_file}'"
|
||||
)
|
||||
original_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
os.symlink(symlink_state, original_file)
|
||||
return
|
||||
|
||||
if not backup_file.is_file() or backup_file.is_symlink():
|
||||
raise RuntimeError(
|
||||
"Command rollback backup is missing for "
|
||||
f"'{original_file}'"
|
||||
)
|
||||
if original_file.is_symlink() or original_file.is_file():
|
||||
original_file.unlink()
|
||||
elif original_file.exists():
|
||||
raise RuntimeError(
|
||||
"Command rollback found an unexpected directory "
|
||||
f"at '{original_file}'"
|
||||
)
|
||||
original_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(backup_file, original_file)
|
||||
|
||||
def remember_absent_parent_dirs(artifact_path, root_dir):
|
||||
"""Remember absent parents a failed renderer may create."""
|
||||
boundary = root_dir.parent
|
||||
if root_dir.is_relative_to(project_root):
|
||||
boundary = project_root
|
||||
parent = artifact_path.parent
|
||||
while parent != boundary:
|
||||
if parent.exists() or parent.is_symlink():
|
||||
break
|
||||
new_command_dirs_absent_before_update.append(parent)
|
||||
parent = parent.parent
|
||||
|
||||
def backup_extension_skills(skill_names, *, skills_dir=None):
|
||||
"""Back up every owned skill directory that remove() may delete."""
|
||||
nonlocal backup_created_by_attempt
|
||||
for skill_dir in manager._find_extension_skill_dirs(
|
||||
skill_names,
|
||||
extension_id,
|
||||
skills_dir=skills_dir,
|
||||
create_skills_dir=False,
|
||||
):
|
||||
original_key = str(skill_dir)
|
||||
if original_key in backed_up_skill_dirs:
|
||||
continue
|
||||
backup_created_by_attempt = True
|
||||
backup_skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
backup_skill_dir = backup_skills_dir / str(
|
||||
len(backed_up_skill_dirs)
|
||||
)
|
||||
shutil.copytree(skill_dir, backup_skill_dir, symlinks=True)
|
||||
backed_up_skill_dirs[original_key] = str(backup_skill_dir)
|
||||
|
||||
try:
|
||||
if backup_root.is_symlink():
|
||||
raise RuntimeError(
|
||||
"Cannot safely create update backup under symlinked "
|
||||
f"directory '{backup_root}'"
|
||||
)
|
||||
if backup_base.exists() or backup_base.is_symlink():
|
||||
raise RuntimeError(
|
||||
"Cannot safely reuse an existing update backup "
|
||||
f"directory '{backup_base}'"
|
||||
)
|
||||
|
||||
# 1. Backup registry entry (always, even if extension dir doesn't exist)
|
||||
backup_registry_entry = manager.registry.get(extension_id)
|
||||
|
||||
# 2. Backup extension directory
|
||||
extension_dir = manager.extensions_dir / extension_id
|
||||
if extension_dir.exists():
|
||||
backup_created_by_attempt = True
|
||||
backup_base.mkdir(parents=True, exist_ok=True)
|
||||
if backup_ext_dir.exists():
|
||||
shutil.rmtree(backup_ext_dir)
|
||||
@@ -1164,30 +1372,91 @@ def extension_update(
|
||||
commands_dir = _AgentReg._resolve_agent_dir(
|
||||
agent_name, agent_config, project_root
|
||||
)
|
||||
dirs_to_backup = [commands_dir]
|
||||
legacy = agent_config.get("legacy_dir")
|
||||
if legacy:
|
||||
legacy_dir = project_root / legacy
|
||||
if (
|
||||
legacy_dir.exists()
|
||||
and legacy_dir != commands_dir
|
||||
):
|
||||
dirs_to_backup.append(legacy_dir)
|
||||
|
||||
for cmd_name in cmd_names:
|
||||
output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
|
||||
cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
|
||||
if cmd_file.exists():
|
||||
# Mirror the real on-disk layout under the backup dir.
|
||||
# Skills agents (extension == "/SKILL.md") name every
|
||||
# command file "SKILL.md", living in a per-command
|
||||
# subdir (e.g. speckit-plan/SKILL.md). Using cmd_file.name
|
||||
# alone would collide all of them onto one backup path and
|
||||
# break rollback; keep the relative path to stay unique.
|
||||
backup_cmd_path = backup_commands_dir / agent_name / cmd_file.relative_to(commands_dir)
|
||||
backup_cmd_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(cmd_file, backup_cmd_path)
|
||||
backed_up_command_files[str(cmd_file)] = str(backup_cmd_path)
|
||||
output_name = _AgentReg._compute_output_name(
|
||||
agent_name, cmd_name, agent_config
|
||||
)
|
||||
names_to_backup = [output_name]
|
||||
if (
|
||||
output_name != cmd_name
|
||||
and _AgentReg._is_safe_command_name(cmd_name)
|
||||
):
|
||||
names_to_backup.append(cmd_name)
|
||||
|
||||
for dir_index, target_dir in enumerate(
|
||||
dirs_to_backup
|
||||
):
|
||||
for name in names_to_backup:
|
||||
cmd_file = (
|
||||
target_dir
|
||||
/ f"{name}{agent_config['extension']}"
|
||||
)
|
||||
try:
|
||||
_AgentReg._ensure_inside(
|
||||
cmd_file, target_dir
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
if (
|
||||
cmd_file.exists()
|
||||
or cmd_file.is_symlink()
|
||||
):
|
||||
# Keep both the directory location and
|
||||
# relative path unique. unregister_commands()
|
||||
# removes legacy and canonical copies, and
|
||||
# skills agents place every SKILL.md in its
|
||||
# own command subdirectory.
|
||||
backup_cmd_path = (
|
||||
backup_commands_dir
|
||||
/ agent_name
|
||||
/ f"location-{dir_index}"
|
||||
/ cmd_file.relative_to(target_dir)
|
||||
)
|
||||
backup_command_artifact(
|
||||
cmd_file, backup_cmd_path
|
||||
)
|
||||
|
||||
# Also backup copilot prompt files
|
||||
if agent_name == "copilot":
|
||||
prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
|
||||
if prompt_file.exists():
|
||||
backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name
|
||||
backup_prompt_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(prompt_file, backup_prompt_path)
|
||||
backed_up_command_files[str(prompt_file)] = str(backup_prompt_path)
|
||||
prompts_dir = (
|
||||
project_root / ".github" / "prompts"
|
||||
)
|
||||
prompt_file = (
|
||||
prompts_dir / f"{cmd_name}.prompt.md"
|
||||
)
|
||||
try:
|
||||
_AgentReg._ensure_inside(
|
||||
prompt_file, prompts_dir
|
||||
)
|
||||
except ValueError:
|
||||
continue
|
||||
if prompt_file.exists() or prompt_file.is_symlink():
|
||||
backup_prompt_path = (
|
||||
backup_commands_dir
|
||||
/ "copilot-prompts"
|
||||
/ prompt_file.relative_to(prompts_dir)
|
||||
)
|
||||
backup_command_artifact(
|
||||
prompt_file, backup_prompt_path
|
||||
)
|
||||
|
||||
raw_registered_skills = (
|
||||
backup_registry_entry.get("registered_skills", [])
|
||||
if isinstance(backup_registry_entry, dict)
|
||||
else []
|
||||
)
|
||||
registered_skills = manager._valid_name_list(raw_registered_skills)
|
||||
backup_extension_skills(registered_skills)
|
||||
|
||||
# 4. Backup hooks and installed list from extensions.yml
|
||||
# get_project_config() always normalizes installed->[] and hooks->{},
|
||||
@@ -1211,24 +1480,107 @@ def extension_update(
|
||||
try:
|
||||
# 6. Validate extension ID from ZIP BEFORE modifying installation
|
||||
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
with open_zip_bounded(zip_path) as zf:
|
||||
import yaml
|
||||
manifest_data = None
|
||||
manifest_bytes = None
|
||||
namelist = zf.namelist()
|
||||
|
||||
# First try root-level extension.yml
|
||||
if "extension.yml" in namelist:
|
||||
with zf.open("extension.yml") as f:
|
||||
parsed_manifest = yaml.safe_load(f)
|
||||
manifest_data = parsed_manifest if parsed_manifest is not None else {}
|
||||
else:
|
||||
# Look for extension.yml in a single top-level subdirectory
|
||||
# (e.g., "repo-name-branch/extension.yml")
|
||||
manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1]
|
||||
if len(manifest_paths) == 1:
|
||||
with zf.open(manifest_paths[0]) as f:
|
||||
parsed_manifest = yaml.safe_load(f)
|
||||
manifest_data = parsed_manifest if parsed_manifest is not None else {}
|
||||
# Read the manifest under a hard size cap: this happens
|
||||
# before install_from_zip()'s safe_extract_zip(), so a
|
||||
# raw zf.open().read() here would bypass that bound and
|
||||
# let a zip-bomb extension.yml exhaust memory.
|
||||
# Normalize separators before choosing the manifest so
|
||||
# this pre-scan cannot approve one entry while extraction
|
||||
# later overwrites it with a backslash alias.
|
||||
manifest_candidates = []
|
||||
archive_entries = []
|
||||
for name in namelist:
|
||||
normalized_name = normalize_zip_member_name(name)
|
||||
parts = normalized_name.removesuffix("/").split(
|
||||
"/"
|
||||
)
|
||||
path_key = portable_zip_path_key(normalized_name)
|
||||
archive_entries.append(
|
||||
(normalized_name, parts)
|
||||
)
|
||||
if (
|
||||
len(parts) in {1, 2}
|
||||
and path_key[-1] == "extension.yml"
|
||||
):
|
||||
manifest_candidates.append(
|
||||
(name, normalized_name, path_key)
|
||||
)
|
||||
|
||||
seen_manifest_keys = {}
|
||||
for name, _normalized_name, path_key in manifest_candidates:
|
||||
previous = seen_manifest_keys.get(path_key)
|
||||
if previous is not None:
|
||||
raise ValueError(
|
||||
"Downloaded extension archive contains multiple "
|
||||
"extension.yml manifests"
|
||||
)
|
||||
seen_manifest_keys[path_key] = name
|
||||
|
||||
for _name, normalized_name, _path_key in manifest_candidates:
|
||||
if normalized_name.split("/")[-1] != "extension.yml":
|
||||
raise ValueError(
|
||||
"Downloaded extension archive manifest "
|
||||
"filenames must use canonical "
|
||||
"'extension.yml' casing"
|
||||
)
|
||||
|
||||
root_manifest = next(
|
||||
(
|
||||
name
|
||||
for name, _normalized_name, path_key
|
||||
in manifest_candidates
|
||||
if path_key == ("extension.yml",)
|
||||
),
|
||||
None,
|
||||
)
|
||||
nested_manifests = [
|
||||
(name, normalized_name)
|
||||
for name, normalized_name, path_key
|
||||
in manifest_candidates
|
||||
if len(path_key) == 2
|
||||
and path_key[-1] == "extension.yml"
|
||||
]
|
||||
manifest_path = root_manifest
|
||||
if manifest_path is None and len(nested_manifests) == 1:
|
||||
manifest_path, normalized_manifest_path = (
|
||||
nested_manifests[0]
|
||||
)
|
||||
manifest_root = normalized_manifest_path.split(
|
||||
"/", 1
|
||||
)[0]
|
||||
top_level_dirs = {
|
||||
parts[0]
|
||||
for normalized_name, parts in archive_entries
|
||||
if (
|
||||
len(parts) > 1
|
||||
or normalized_name.endswith("/")
|
||||
)
|
||||
}
|
||||
if top_level_dirs != {manifest_root}:
|
||||
raise ValueError(
|
||||
"Downloaded extension archive with a "
|
||||
"nested extension.yml must contain exactly "
|
||||
"one top-level directory"
|
||||
)
|
||||
|
||||
if manifest_path is not None:
|
||||
manifest_bytes = read_zip_member_limited(
|
||||
zf, manifest_path
|
||||
)
|
||||
parsed_manifest = yaml.safe_load(
|
||||
manifest_bytes
|
||||
)
|
||||
manifest_data = (
|
||||
parsed_manifest
|
||||
if parsed_manifest is not None
|
||||
else {}
|
||||
)
|
||||
|
||||
if manifest_data is None:
|
||||
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
|
||||
@@ -1242,13 +1594,205 @@ def extension_update(
|
||||
"Invalid extension manifest in downloaded archive: expected 'extension' mapping"
|
||||
)
|
||||
|
||||
zip_extension_id = extension_data.get("id")
|
||||
# Run the same manifest and compatibility validation as a
|
||||
# normal install while the existing extension is still
|
||||
# untouched. Reuse the exact bounded bytes selected above.
|
||||
if manifest_bytes is None:
|
||||
raise ValueError(
|
||||
"Downloaded extension archive is missing 'extension.yml'"
|
||||
)
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-update-manifest-"
|
||||
) as manifest_tmpdir:
|
||||
manifest_file = Path(manifest_tmpdir) / "extension.yml"
|
||||
manifest_file.write_bytes(manifest_bytes)
|
||||
preflight_manifest = ExtensionManifest(manifest_file)
|
||||
manager.check_compatibility(
|
||||
preflight_manifest, speckit_version
|
||||
)
|
||||
|
||||
zip_extension_id = preflight_manifest.id
|
||||
if zip_extension_id != extension_id:
|
||||
raise ValueError(
|
||||
f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'"
|
||||
)
|
||||
|
||||
expected_version = pkg_version.Version(update["available"])
|
||||
archive_version = pkg_version.Version(
|
||||
preflight_manifest.version
|
||||
)
|
||||
if archive_version != expected_version:
|
||||
raise ValueError(
|
||||
"Extension version mismatch: "
|
||||
f"expected '{update['available']}', "
|
||||
f"got '{preflight_manifest.version}'"
|
||||
)
|
||||
|
||||
# Match the remaining deterministic install validation
|
||||
# before crossing the destructive boundary. The helper
|
||||
# excludes this extension's current registry entry while
|
||||
# still detecting namespace, core, duplicate, and
|
||||
# cross-extension command conflicts.
|
||||
manager._validate_install_conflicts(preflight_manifest)
|
||||
|
||||
new_command_names = list(
|
||||
manager._collect_manifest_command_names(
|
||||
preflight_manifest
|
||||
)
|
||||
)
|
||||
new_skill_names = list(
|
||||
dict.fromkeys(
|
||||
manager._skill_name_for_command(command_name)
|
||||
for command_name in new_command_names
|
||||
)
|
||||
)
|
||||
|
||||
# Command rendering happens before hook registration and
|
||||
# registry.add(). Preserve every candidate output that
|
||||
# already exists, and remember paths that are absent now so
|
||||
# rollback can remove files created before registry state is
|
||||
# available. Include aliases and Copilot companion prompts.
|
||||
for (
|
||||
agent_name,
|
||||
commands_dir,
|
||||
) in manager._command_registration_targets().items():
|
||||
agent_config = registrar.AGENT_CONFIGS[agent_name]
|
||||
for command_name in new_command_names:
|
||||
output_name = _AgentReg._compute_output_name(
|
||||
agent_name, command_name, agent_config
|
||||
)
|
||||
command_file = (
|
||||
commands_dir
|
||||
/ f"{output_name}{agent_config['extension']}"
|
||||
)
|
||||
_AgentReg._ensure_inside(command_file, commands_dir)
|
||||
backup_command_path = (
|
||||
backup_commands_dir
|
||||
/ agent_name
|
||||
/ command_file.relative_to(commands_dir)
|
||||
)
|
||||
if command_file.exists() or command_file.is_symlink():
|
||||
backup_command_artifact(
|
||||
command_file, backup_command_path
|
||||
)
|
||||
else:
|
||||
new_command_paths_absent_before_update.append(
|
||||
command_file
|
||||
)
|
||||
remember_absent_parent_dirs(
|
||||
command_file, commands_dir
|
||||
)
|
||||
|
||||
if agent_name == "copilot":
|
||||
prompts_dir = (
|
||||
project_root / ".github" / "prompts"
|
||||
)
|
||||
prompt_file = (
|
||||
prompts_dir / f"{command_name}.prompt.md"
|
||||
)
|
||||
_AgentReg._ensure_inside(
|
||||
prompt_file, prompts_dir
|
||||
)
|
||||
if prompt_file.is_symlink():
|
||||
raise RuntimeError(
|
||||
"Cannot safely update symlinked Copilot "
|
||||
f"prompt artifact '{prompt_file}'"
|
||||
)
|
||||
backup_prompt_path = (
|
||||
backup_commands_dir
|
||||
/ "copilot-prompts"
|
||||
/ prompt_file.relative_to(prompts_dir)
|
||||
)
|
||||
if (
|
||||
prompt_file.exists()
|
||||
or prompt_file.is_symlink()
|
||||
):
|
||||
backup_command_artifact(
|
||||
prompt_file, backup_prompt_path
|
||||
)
|
||||
else:
|
||||
new_command_paths_absent_before_update.append(
|
||||
prompt_file
|
||||
)
|
||||
remember_absent_parent_dirs(
|
||||
prompt_file, prompts_dir
|
||||
)
|
||||
|
||||
new_command_paths_absent_before_update = list(
|
||||
dict.fromkeys(
|
||||
new_command_paths_absent_before_update
|
||||
)
|
||||
)
|
||||
new_command_dirs_absent_before_update = list(
|
||||
dict.fromkeys(
|
||||
new_command_dirs_absent_before_update
|
||||
)
|
||||
)
|
||||
|
||||
# A newly introduced command may reuse an existing
|
||||
# extension-owned skill directory that was not present in
|
||||
# the old registry. Back it up before cleanup can touch it.
|
||||
backup_extension_skills(new_skill_names)
|
||||
new_skills_dir = manager._get_skills_dir(create=False)
|
||||
if new_skills_dir is not None:
|
||||
# Unscoped removal deliberately ignores home-scoped
|
||||
# outputs because the flat registry cannot establish
|
||||
# project ownership. The active install can still
|
||||
# replace a marker-owned skill in its explicit root,
|
||||
# so back up that exact project/home target separately.
|
||||
backup_extension_skills(
|
||||
list(
|
||||
dict.fromkeys(
|
||||
registered_skills + new_skill_names
|
||||
)
|
||||
),
|
||||
skills_dir=new_skills_dir,
|
||||
)
|
||||
init_options = load_init_options(project_root)
|
||||
if (
|
||||
isinstance(init_options, dict)
|
||||
and is_ai_skills_enabled(init_options)
|
||||
and isinstance(init_options.get("ai"), str)
|
||||
and init_options["ai"]
|
||||
):
|
||||
# resolve_active_skills_dir() first creates the
|
||||
# configured project-local skills marker. Some
|
||||
# agents (notably Hermes) then redirect rendered
|
||||
# skills to a different global root, so snapshot
|
||||
# both locations for exact rollback.
|
||||
from .. import _get_skills_dir
|
||||
|
||||
configured_skills_dir = _get_skills_dir(
|
||||
project_root, init_options["ai"]
|
||||
)
|
||||
remember_absent_parent_dirs(
|
||||
configured_skills_dir / ".update-marker",
|
||||
configured_skills_dir,
|
||||
)
|
||||
new_skills_root = new_skills_dir.resolve()
|
||||
for skill_name in new_skill_names:
|
||||
skill_path = new_skills_dir / skill_name
|
||||
resolved_skill_path = skill_path.resolve(strict=False)
|
||||
resolved_skill_path.relative_to(new_skills_root)
|
||||
if not (
|
||||
skill_path.exists() or skill_path.is_symlink()
|
||||
):
|
||||
new_skill_paths_absent_before_update.append(
|
||||
skill_path
|
||||
)
|
||||
remember_absent_parent_dirs(
|
||||
skill_path / "SKILL.md",
|
||||
new_skills_dir,
|
||||
)
|
||||
|
||||
new_command_dirs_absent_before_update = list(
|
||||
dict.fromkeys(
|
||||
new_command_dirs_absent_before_update
|
||||
)
|
||||
)
|
||||
|
||||
# 7. Remove old extension (handles command file cleanup and registry removal)
|
||||
installation_modified = True
|
||||
manager.remove(extension_id, keep_config=True)
|
||||
|
||||
# 8. Install new version
|
||||
@@ -1298,15 +1842,42 @@ def extension_update(
|
||||
hook["enabled"] = False
|
||||
hook_executor.save_project_config(config)
|
||||
finally:
|
||||
# Clean up downloaded ZIP
|
||||
# ZIP cleanup is housekeeping: never replace an install
|
||||
# error or roll back an already committed update because a
|
||||
# scanner temporarily locks the download on Windows.
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
try:
|
||||
zip_path.unlink()
|
||||
except OSError as error:
|
||||
zip_cleanup_error = error
|
||||
|
||||
# 10. Clean up backup on success
|
||||
if backup_base.exists():
|
||||
shutil.rmtree(backup_base)
|
||||
# 10. Clean up backup on success. The update has committed at
|
||||
# this point, so a locked backup file must not trigger rollback
|
||||
# of an otherwise successful installation.
|
||||
cleanup_error = None
|
||||
if backup_created_by_attempt and backup_base.exists():
|
||||
try:
|
||||
shutil.rmtree(backup_base)
|
||||
except OSError as error:
|
||||
cleanup_error = error
|
||||
|
||||
console.print(f" [green]✓[/green] Updated to v{update['available']}")
|
||||
if cleanup_error is not None:
|
||||
console.print(
|
||||
" [yellow]Warning:[/yellow] Could not fully remove "
|
||||
"update backup: "
|
||||
f"{_escape_markup(str(cleanup_error))}"
|
||||
)
|
||||
console.print(
|
||||
" [dim]Backup may remain at: "
|
||||
f"{_escape_markup(str(backup_base))}[/dim]"
|
||||
)
|
||||
if zip_cleanup_error is not None:
|
||||
console.print(
|
||||
" [yellow]Warning:[/yellow] Could not remove "
|
||||
"downloaded update archive: "
|
||||
f"{_escape_markup(str(zip_cleanup_error))}"
|
||||
)
|
||||
updated_extensions.append(ext_name)
|
||||
|
||||
except KeyboardInterrupt:
|
||||
@@ -1314,6 +1885,24 @@ def extension_update(
|
||||
except Exception as e:
|
||||
console.print(f" [red]✗[/red] Failed: {_escape_markup(str(e))}")
|
||||
failed_updates.append((ext_name, str(e)))
|
||||
if zip_cleanup_error is not None:
|
||||
console.print(
|
||||
" [yellow]Warning:[/yellow] Could not remove "
|
||||
"downloaded update archive: "
|
||||
f"{_escape_markup(str(zip_cleanup_error))}"
|
||||
)
|
||||
|
||||
if not installation_modified:
|
||||
if backup_created_by_attempt and backup_base.exists():
|
||||
try:
|
||||
shutil.rmtree(backup_base)
|
||||
except OSError as cleanup_error:
|
||||
console.print(
|
||||
" [yellow]Warning:[/yellow] Could not remove "
|
||||
"untouched-update backup: "
|
||||
f"{_escape_markup(str(cleanup_error))}"
|
||||
)
|
||||
continue
|
||||
|
||||
# Rollback on failure
|
||||
console.print(f" [yellow]↩[/yellow] Rolling back {safe_ext_name}...")
|
||||
@@ -1330,13 +1919,28 @@ def extension_update(
|
||||
shutil.copytree(backup_ext_dir, extension_dir)
|
||||
|
||||
# Remove any NEW command files created by failed install
|
||||
# (files that weren't in the original backup)
|
||||
# (files that weren't in the original backup). Registration
|
||||
# writes before registry.add(), so start with the paths that
|
||||
# were absent at the destructive boundary instead of relying
|
||||
# only on a possibly missing new registry entry.
|
||||
for command_path in new_command_paths_absent_before_update:
|
||||
if command_path.is_symlink() or command_path.is_file():
|
||||
command_path.unlink()
|
||||
elif command_path.exists():
|
||||
raise RuntimeError(
|
||||
"Command rollback found an unexpected directory "
|
||||
f"at '{command_path}'"
|
||||
)
|
||||
new_registered_skills = []
|
||||
try:
|
||||
new_registry_entry = manager.registry.get(extension_id)
|
||||
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
|
||||
new_registered_commands = {}
|
||||
else:
|
||||
new_registered_commands = new_registry_entry.get("registered_commands", {})
|
||||
new_registered_skills = manager._valid_name_list(
|
||||
new_registry_entry.get("registered_skills", [])
|
||||
)
|
||||
for agent_name, cmd_names in new_registered_commands.items():
|
||||
if agent_name not in registrar.AGENT_CONFIGS:
|
||||
continue
|
||||
@@ -1360,13 +1964,78 @@ def extension_update(
|
||||
except KeyError:
|
||||
pass # No new registry entry exists, nothing to clean up
|
||||
|
||||
# Restore backed up command files
|
||||
# Restore command artifacts that existed before the update
|
||||
# before extension-skill cleanup inspects ownership. A
|
||||
# failed skills registrar may have overwritten a user's
|
||||
# pre-existing SKILL.md with extension metadata; restoring
|
||||
# it first prevents the conservative skill unregistrar from
|
||||
# misclassifying and deleting the user's whole directory.
|
||||
for original_path, backup_path in backed_up_command_files.items():
|
||||
backup_file = Path(backup_path)
|
||||
if backup_file.exists():
|
||||
original_file = Path(original_path)
|
||||
original_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(backup_file, original_file)
|
||||
restore_command_artifact(
|
||||
original_path, backup_path
|
||||
)
|
||||
|
||||
# Skill generation happens before hooks and registry.add(),
|
||||
# so a failed install may have created skills that are not
|
||||
# recorded in any registry entry yet. Derive names from the
|
||||
# preflighted manifest as well as any partial new entry.
|
||||
skills_to_remove = list(
|
||||
dict.fromkeys(new_skill_names + new_registered_skills)
|
||||
)
|
||||
# A write failure can leave a partial skill without valid
|
||||
# ownership metadata, which the normal conservative
|
||||
# unregistrar intentionally refuses to delete. Paths that
|
||||
# were absent at the destructive boundary are safe to
|
||||
# remove directly during rollback.
|
||||
for skill_path in new_skill_paths_absent_before_update:
|
||||
if skill_path.is_symlink() or skill_path.is_file():
|
||||
skill_path.unlink()
|
||||
elif skill_path.exists():
|
||||
shutil.rmtree(skill_path)
|
||||
manager._unregister_extension_skills(
|
||||
skills_to_remove, extension_id
|
||||
)
|
||||
|
||||
# Restore all original registered skill artifacts after
|
||||
# removing skills created by the failed installation.
|
||||
for original_path, backup_path in backed_up_skill_dirs.items():
|
||||
backup_skill_dir = Path(backup_path)
|
||||
if not backup_skill_dir.is_dir():
|
||||
raise RuntimeError(
|
||||
"Skill rollback backup is missing for "
|
||||
f"'{original_path}'"
|
||||
)
|
||||
original_skill_dir = Path(original_path)
|
||||
if (
|
||||
original_skill_dir.is_symlink()
|
||||
or original_skill_dir.is_file()
|
||||
):
|
||||
original_skill_dir.unlink()
|
||||
elif original_skill_dir.exists():
|
||||
shutil.rmtree(original_skill_dir)
|
||||
original_skill_dir.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copytree(
|
||||
backup_skill_dir,
|
||||
original_skill_dir,
|
||||
symlinks=True,
|
||||
)
|
||||
|
||||
# Remove empty artifact directories that did not exist at
|
||||
# the destructive boundary. Do this after skill cleanup and
|
||||
# restoration so newly created skills roots and their
|
||||
# project-local parents can also be removed exactly.
|
||||
for command_dir in sorted(
|
||||
new_command_dirs_absent_before_update,
|
||||
key=lambda path: len(path.parts),
|
||||
reverse=True,
|
||||
):
|
||||
if command_dir.is_dir() and not command_dir.is_symlink():
|
||||
try:
|
||||
command_dir.rmdir()
|
||||
except OSError:
|
||||
# Preserve any non-empty directory: other
|
||||
# content may belong to the user.
|
||||
pass
|
||||
|
||||
# Restore metadata in extensions.yml (hooks and installed list).
|
||||
# Only run if backup step 4 was reached (backup_hooks is not None);
|
||||
@@ -1421,10 +2090,26 @@ def extension_update(
|
||||
if backup_registry_entry:
|
||||
manager.registry.restore(extension_id, backup_registry_entry)
|
||||
|
||||
# Backup cleanup is post-rollback housekeeping. A locked
|
||||
# file (notably on Windows) must not turn successfully
|
||||
# restored state into a contradictory "Rollback failed".
|
||||
cleanup_error = None
|
||||
if backup_created_by_attempt and backup_base.exists():
|
||||
try:
|
||||
shutil.rmtree(backup_base)
|
||||
except OSError as error:
|
||||
cleanup_error = error
|
||||
console.print(" [green]✓[/green] Rollback successful")
|
||||
# Clean up backup directory only on successful rollback
|
||||
if backup_base.exists():
|
||||
shutil.rmtree(backup_base)
|
||||
if cleanup_error is not None:
|
||||
console.print(
|
||||
" [yellow]Warning:[/yellow] Could not fully "
|
||||
"remove rollback backup: "
|
||||
f"{_escape_markup(str(cleanup_error))}"
|
||||
)
|
||||
console.print(
|
||||
" [dim]Backup may remain at: "
|
||||
f"{_escape_markup(str(backup_base))}[/dim]"
|
||||
)
|
||||
except Exception as rollback_error:
|
||||
console.print(f" [red]✗[/red] Rollback failed: {_escape_markup(str(rollback_error))}")
|
||||
console.print(f" [dim]Backup preserved at: {_escape_markup(str(backup_base))}[/dim]")
|
||||
|
||||
@@ -48,6 +48,7 @@ def _register_builtins() -> None:
|
||||
"""
|
||||
# -- Imports (alphabetical) -------------------------------------------
|
||||
from .agy import AgyIntegration
|
||||
from .alquimia import AlquimiaAIIntegration
|
||||
from .amp import AmpIntegration
|
||||
from .auggie import AuggieIntegration
|
||||
from .bob import BobIntegration
|
||||
@@ -86,6 +87,7 @@ def _register_builtins() -> None:
|
||||
|
||||
# -- Registration (alphabetical) --------------------------------------
|
||||
_register(AgyIntegration())
|
||||
_register(AlquimiaAIIntegration())
|
||||
_register(AmpIntegration())
|
||||
_register(AuggieIntegration())
|
||||
_register(BobIntegration())
|
||||
|
||||
@@ -395,19 +395,14 @@ def _register_extensions_for_agent(
|
||||
"""Register all enabled extensions' commands/skills for ``agent_key``.
|
||||
|
||||
``use`` / ``switch`` re-register enabled extensions for the agent they
|
||||
activate; ``upgrade`` backfills them for the refreshed agent. Plain
|
||||
``install`` deliberately does not call this helper so adding a secondary
|
||||
integration has no extension side effects until it is selected or upgraded.
|
||||
See issue #2886.
|
||||
activate (rescaffold); ``upgrade`` does so only for the *active*
|
||||
integration. Plain ``install`` and upgrade of a non-active integration
|
||||
deliberately skip this helper so a secondary integration has no extension
|
||||
side effects until it is selected. See issues #2886 and #2948.
|
||||
|
||||
Known limitation: extension *skill* rendering is scoped to the active
|
||||
agent (init-options track a single ``ai`` / ``ai_skills`` pair). A
|
||||
skills-mode agent registered while it is *not* the active agent (e.g.
|
||||
Copilot ``--skills`` registered while non-active) therefore
|
||||
receives command files rather than skills here — matching ``extension
|
||||
add``'s multi-agent behavior. ``use`` / ``switch`` avoid this because they
|
||||
make the target the active agent first. Per-agent skills parity is tracked in
|
||||
#2948.
|
||||
Callers always pass the active agent (use/switch activate the target
|
||||
before registering), so extension *skill* rendering — which is scoped to
|
||||
the active ``ai`` / ``ai_skills`` init-options — matches ``agent_key``.
|
||||
|
||||
Best-effort: never aborts the surrounding integration operation. Callers
|
||||
invoke it *after* the use/upgrade/switch transaction has committed so a
|
||||
@@ -443,6 +438,91 @@ def _unregister_extensions_for_agent(
|
||||
)
|
||||
|
||||
|
||||
def _register_presets_for_agent(
|
||||
project_root: Path,
|
||||
agent_key: str,
|
||||
*,
|
||||
continuing: str,
|
||||
) -> None:
|
||||
"""Register all enabled presets' command overrides/skills for ``agent_key``.
|
||||
|
||||
Presets follow the same single-active rule as extensions (#2948):
|
||||
``use`` / ``switch`` re-register enabled presets for the agent they
|
||||
activate (rescaffold), so a preset installed while a different
|
||||
integration was active is not left targeting that inactive integration.
|
||||
|
||||
Best-effort: never aborts the surrounding integration operation.
|
||||
"""
|
||||
try:
|
||||
from ..presets import PresetManager
|
||||
|
||||
preset_mgr = PresetManager(project_root)
|
||||
preset_mgr.register_enabled_presets_for_agent(agent_key)
|
||||
except Exception as preset_err:
|
||||
from .. import _print_cli_warning
|
||||
|
||||
_print_cli_warning(
|
||||
"register preset artifacts for",
|
||||
"integration",
|
||||
agent_key,
|
||||
preset_err,
|
||||
continuing=continuing,
|
||||
)
|
||||
|
||||
|
||||
def _unregister_presets_for_agent(
|
||||
project_root: Path,
|
||||
agent_key: str,
|
||||
*,
|
||||
continuing: str,
|
||||
) -> None:
|
||||
"""Best-effort removal of ``agent_key``'s preset command/skill artifacts.
|
||||
|
||||
Mirrors ``_unregister_extensions_for_agent``: used by ``switch`` when
|
||||
uninstalling the previous integration so its preset command overrides
|
||||
and skill mirrors don't linger as orphans in the old agent's directory
|
||||
once a different (possibly not-yet-installed) integration becomes
|
||||
active (#2948).
|
||||
|
||||
Best-effort: never aborts the surrounding integration operation.
|
||||
"""
|
||||
try:
|
||||
from ..presets import PresetManager
|
||||
|
||||
preset_mgr = PresetManager(project_root)
|
||||
preset_mgr.unregister_agent_artifacts(agent_key)
|
||||
except Exception as preset_err:
|
||||
from .. import _print_cli_warning
|
||||
|
||||
_print_cli_warning(
|
||||
"clean up preset artifacts for",
|
||||
"integration",
|
||||
agent_key,
|
||||
preset_err,
|
||||
continuing=continuing,
|
||||
)
|
||||
|
||||
|
||||
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)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -29,13 +29,16 @@ from ._helpers import (
|
||||
_read_integration_json,
|
||||
_refresh_init_options_speckit_version,
|
||||
_register_extensions_for_agent,
|
||||
_register_presets_for_agent,
|
||||
_remove_integration_json,
|
||||
_resolve_integration_options,
|
||||
_resolve_integration_script_type,
|
||||
_resolve_script_type,
|
||||
_set_default_integration,
|
||||
_set_default_integration_or_exit,
|
||||
_unregister_enabled_extension_commands_for_agent,
|
||||
_unregister_extensions_for_agent,
|
||||
_unregister_presets_for_agent,
|
||||
_update_init_options_for_integration,
|
||||
_write_integration_json,
|
||||
)
|
||||
@@ -54,6 +57,66 @@ def _manifest_tracks_skill_layout(manifest) -> bool:
|
||||
return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)
|
||||
|
||||
|
||||
def _manifest_path_under(rel_path: str, root: str) -> bool:
|
||||
"""Return True when manifest key *rel_path* is inside project-relative *root*."""
|
||||
normalized_root = PurePath(root).as_posix().strip("/")
|
||||
normalized_rel = PurePath(rel_path).as_posix().strip("/")
|
||||
if not normalized_root:
|
||||
return False
|
||||
return normalized_rel == normalized_root or normalized_rel.startswith(
|
||||
f"{normalized_root}/"
|
||||
)
|
||||
|
||||
|
||||
def _legacy_command_root_changed(
|
||||
integration,
|
||||
project_root: Path,
|
||||
old_manifest,
|
||||
new_manifest,
|
||||
) -> bool:
|
||||
"""Return True when command artifacts moved from legacy_dir to canonical dir."""
|
||||
config = integration.registrar_config or {}
|
||||
canonical = config.get("dir")
|
||||
legacy = config.get("legacy_dir")
|
||||
if (
|
||||
not isinstance(canonical, str)
|
||||
or not canonical.strip()
|
||||
or not isinstance(legacy, str)
|
||||
or not legacy.strip()
|
||||
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
|
||||
):
|
||||
return False
|
||||
|
||||
canonical_dir = project_root / canonical
|
||||
legacy_dir = project_root / legacy
|
||||
if not canonical_dir.is_dir() or not legacy_dir.is_dir():
|
||||
return False
|
||||
|
||||
old_had_legacy = any(
|
||||
_manifest_path_under(rel, legacy) for rel in old_manifest.files
|
||||
)
|
||||
new_has_canonical = any(
|
||||
_manifest_path_under(rel, canonical) for rel in new_manifest.files
|
||||
)
|
||||
return old_had_legacy and new_has_canonical
|
||||
|
||||
|
||||
def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool:
|
||||
"""Return True when the old manifest tracks command files under legacy_dir."""
|
||||
config = integration.registrar_config or {}
|
||||
canonical = config.get("dir")
|
||||
legacy = config.get("legacy_dir")
|
||||
if (
|
||||
not isinstance(canonical, str)
|
||||
or not canonical.strip()
|
||||
or not isinstance(legacy, str)
|
||||
or not legacy.strip()
|
||||
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
|
||||
):
|
||||
return False
|
||||
return any(_manifest_path_under(rel, legacy) for rel in old_manifest.files)
|
||||
|
||||
|
||||
class _PresetRegistryUnreadableError(Exception):
|
||||
"""Raised when an existing preset registry cannot be read or parsed.
|
||||
|
||||
@@ -64,16 +127,21 @@ class _PresetRegistryUnreadableError(Exception):
|
||||
"""
|
||||
|
||||
|
||||
def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str]:
|
||||
def _installed_presets_affecting_agent(
|
||||
project_root,
|
||||
agent_key: str,
|
||||
*,
|
||||
include_skills: bool = True,
|
||||
) -> list[str]:
|
||||
"""Return IDs of installed presets with artifacts registered for *agent_key*.
|
||||
|
||||
Presets register command overrides for every detected agent and mirror
|
||||
skills for the active skills agent, tracking the result in each preset's
|
||||
``registered_commands`` / ``registered_skills`` metadata. There is no
|
||||
agent-scoped preset re-registration mechanism, so a command↔skills *layout
|
||||
change* cannot reconcile those artifacts (see ``integration_upgrade``).
|
||||
Callers use this to detect the unsafe case and reject the migration rather
|
||||
than silently orphaning preset files / leaving stale registry entries.
|
||||
Preset registration is active-agent-only (#2948): command overrides are
|
||||
written for the active non-skills agent and skills for the active skills
|
||||
agent, tracked per preset in ``registered_commands`` /
|
||||
``registered_skills``. Entries for *other* agents may still exist from
|
||||
when those agents were active. Callers use this to reject command-root or
|
||||
command↔skills layout migrations before mutation: preset rescaffolding is
|
||||
best-effort and cannot guarantee every tracked artifact has a replacement.
|
||||
|
||||
Fails **closed**: a genuinely absent registry (no presets ever installed)
|
||||
returns an empty list, but if the registry file exists and cannot be read
|
||||
@@ -112,22 +180,53 @@ def _installed_presets_affecting_agent(project_root, agent_key: str) -> list[str
|
||||
f"preset '{preset_id}' entry is malformed"
|
||||
)
|
||||
registered_commands = meta.get("registered_commands", {})
|
||||
if not isinstance(registered_commands, dict):
|
||||
if not isinstance(registered_commands, dict) or not all(
|
||||
isinstance(names, list) for names in registered_commands.values()
|
||||
):
|
||||
raise _PresetRegistryUnreadableError(
|
||||
f"preset '{preset_id}' registered_commands is malformed"
|
||||
)
|
||||
registered_skills = meta.get("registered_skills", [])
|
||||
if not isinstance(registered_skills, (list, tuple)):
|
||||
if isinstance(registered_skills, dict):
|
||||
# Per-agent provenance ({agent: [skill names]}): only entries for
|
||||
# *this* agent make the preset affect it. Values must be lists —
|
||||
# anything else (e.g. null) leaves ownership undecidable, so fail
|
||||
# closed rather than read it as "no artifacts".
|
||||
if not all(
|
||||
isinstance(names, list) for names in registered_skills.values()
|
||||
):
|
||||
raise _PresetRegistryUnreadableError(
|
||||
f"preset '{preset_id}' registered_skills is malformed"
|
||||
)
|
||||
has_skills = include_skills and bool(
|
||||
registered_skills.get(agent_key)
|
||||
)
|
||||
elif isinstance(registered_skills, (list, tuple)):
|
||||
# Legacy flat list: not agent-scoped, so any recorded skill may
|
||||
# belong to this agent — fail closed and count it as affecting.
|
||||
has_skills = include_skills and bool(registered_skills)
|
||||
else:
|
||||
raise _PresetRegistryUnreadableError(
|
||||
f"preset '{preset_id}' registered_skills is malformed"
|
||||
)
|
||||
has_commands = bool(registered_commands.get(agent_key))
|
||||
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"),
|
||||
@@ -218,6 +317,14 @@ def integration_switch(
|
||||
"need re-registration."
|
||||
),
|
||||
)
|
||||
_register_presets_for_agent(
|
||||
project_root,
|
||||
target,
|
||||
continuing=(
|
||||
"The integration switch succeeded, but installed presets may "
|
||||
"need re-registration."
|
||||
),
|
||||
)
|
||||
console.print(f"\n[green]✓[/green] Default integration set to [bold]{target}[/bold].")
|
||||
raise typer.Exit(0)
|
||||
|
||||
@@ -275,6 +382,19 @@ def integration_switch(
|
||||
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
|
||||
)
|
||||
|
||||
# Unregister preset commands/skills for the old agent for the same
|
||||
# reason: without this, a preset's command overrides (including
|
||||
# custom preset commands) and skill mirrors rendered for
|
||||
# installed_key would remain orphaned in its directory once a
|
||||
# different, possibly not-yet-installed integration becomes active
|
||||
# (#2948). Scoped strictly to installed_key; other agents' files,
|
||||
# tracking, and the preset packs themselves are untouched.
|
||||
_unregister_presets_for_agent(
|
||||
project_root,
|
||||
installed_key,
|
||||
continuing="Continuing with integration switch; old preset artifacts may need manual cleanup.",
|
||||
)
|
||||
|
||||
# Clear metadata so a failed Phase 2 doesn't leave stale references
|
||||
installed_keys = [installed for installed in installed_keys if installed != installed_key]
|
||||
_clear_init_options_for_integration(project_root, installed_key)
|
||||
@@ -396,6 +516,24 @@ def integration_switch(
|
||||
f"[yellow]Warning:[/yellow] Failed to restore default "
|
||||
f"integration '{fallback_key}': {restore_err}"
|
||||
)
|
||||
else:
|
||||
# Under active-only registration the fallback may never
|
||||
# have received any extension/preset artifacts (it was
|
||||
# installed while another integration was active), and
|
||||
# Phase 1 already unregistered the outgoing agent's
|
||||
# artifacts. Rescaffold so the restored default is
|
||||
# actually usable. Both helpers are best-effort and
|
||||
# cannot raise past this point.
|
||||
_register_extensions_for_agent(
|
||||
project_root,
|
||||
fallback_key,
|
||||
continuing="The switch was rolled back; installed extensions may need re-registration.",
|
||||
)
|
||||
_register_presets_for_agent(
|
||||
project_root,
|
||||
fallback_key,
|
||||
continuing="The switch was rolled back; installed presets may need re-registration.",
|
||||
)
|
||||
else:
|
||||
_write_integration_json(
|
||||
project_root, fallback_key, installed_keys, _integration_settings(current)
|
||||
@@ -416,6 +554,11 @@ def integration_switch(
|
||||
target,
|
||||
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
|
||||
)
|
||||
_register_presets_for_agent(
|
||||
project_root,
|
||||
target,
|
||||
continuing="The integration switch succeeded, but installed presets may need re-registration.",
|
||||
)
|
||||
|
||||
name = (target_integration.config or {}).get("name", target)
|
||||
console.print(f"\n[green]✓[/green] Switched to integration '{name}'")
|
||||
@@ -487,18 +630,65 @@ def integration_upgrade(
|
||||
integration, current, key, integration_options
|
||||
)
|
||||
|
||||
# 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``).
|
||||
# Extension artifacts are reconciled after the flip (see below), but preset
|
||||
# artifacts cannot be: there is no agent-scoped preset re-registration
|
||||
# anywhere in the CLI, so migrating would delete a preset's old-layout
|
||||
# files without recreating them in the new layout and leave the preset
|
||||
# registry claiming artifacts that no longer exist. Detect the intended
|
||||
# layout (``is_skills_mode`` reflects the resolved flags/disk state, so a
|
||||
# plain same-layout upgrade is unaffected) and bail out *before* any
|
||||
# mutation with an actionable error so the project is never left in a
|
||||
# half-migrated, inconsistent state.
|
||||
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 tracked outside the
|
||||
# integration manifest, and their agent-scoped rescaffold is best-effort,
|
||||
# not transactional with command-root cleanup. Refuse before setup writes
|
||||
# .kilo/commands rather than risking orphaned legacy files or missing
|
||||
# registry-tracked overrides in 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)
|
||||
|
||||
# Reject command↔skills layout changes while preset artifacts are tracked
|
||||
# for the integration (review #3415). Preset rescaffolding is best-effort:
|
||||
# an enabled preset can still have a missing/corrupt manifest or command
|
||||
# source, or fail during a write. Phase 2 would otherwise delete the
|
||||
# old-layout file before a replacement is known to exist. Refuse before
|
||||
# any mutation; same-layout upgrades still rescaffold the active agent.
|
||||
if _manifest_tracks_skill_layout(old_manifest) != integration.is_skills_mode(
|
||||
parsed_options, project_root
|
||||
):
|
||||
@@ -524,9 +714,9 @@ def integration_upgrade(
|
||||
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
|
||||
)
|
||||
console.print(
|
||||
"Preset artifacts cannot yet be reconciled across a command↔skills "
|
||||
"layout change, so the migration would orphan their files and leave "
|
||||
"the preset registry inconsistent."
|
||||
"Preset artifacts cannot be safely reconciled across a "
|
||||
"command↔skills layout change, so the migration is refused "
|
||||
"before changing files."
|
||||
)
|
||||
console.print(
|
||||
"Remove the preset(s), run the upgrade, then reinstall them:\n"
|
||||
@@ -646,66 +836,37 @@ def integration_upgrade(
|
||||
if stale_removed:
|
||||
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
|
||||
|
||||
# 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.
|
||||
# Done after the upgrade has fully settled (Phase 2 included) and outside
|
||||
# the try/except above so this best-effort step cannot affect upgrade
|
||||
# success.
|
||||
#
|
||||
# Layout-change reconciliation: a dual-mode agent (e.g. Bob) can flip
|
||||
# between the legacy commands layout and the skills layout across an
|
||||
# upgrade (``upgrade bob --integration-options "--skills"`` / reverse
|
||||
# ``--legacy-commands``). Phase 2 above only removes stale files tracked by
|
||||
# the *integration* manifest (core commands); extension artifacts are
|
||||
# tracked separately in the extension registry, so the old layout's
|
||||
# extension command/skill files would otherwise linger as orphans. When the
|
||||
# layout actually changed, first unregister the agent's extension artifacts
|
||||
# (removing old-layout files and clearing per-agent registry entries) so the
|
||||
# re-registration below recreates them in the new layout. ``upgrade``s that
|
||||
# don't change layout skip this to avoid needless remove/re-add churn.
|
||||
#
|
||||
# Only the *active* integration is reconciled this way (``installed_key ==
|
||||
# key``). ``ExtensionManager.unregister_agent_artifacts`` treats the
|
||||
# per-extension ``registered_skills`` list as belonging to the passed agent
|
||||
# and, when that agent's skills directory is absent, falls back to scanning
|
||||
# every agent's skills directory — so running it for a *secondary*
|
||||
# (non-active) agent could delete or untrack the *active* agent's extension
|
||||
# skills. The subsequent re-registration cannot repair that because
|
||||
# extension skill rendering is intentionally scoped to the active agent
|
||||
# (#2948). Extension skills only ever exist for the active agent, so
|
||||
# skipping the unregister for a secondary agent orphans nothing new: a
|
||||
# secondary agent only has extension *command* files, which the
|
||||
# re-registration below rewrites in place regardless of layout.
|
||||
#
|
||||
# Known limitation: preset command/skill artifacts are NOT reconciled on a
|
||||
# layout change. There is no agent-scoped preset re-registration mechanism
|
||||
# anywhere in the CLI — ``use`` / ``switch`` / ``upgrade`` never reconcile
|
||||
# presets for any agent (presets are only (un)registered at preset
|
||||
# install/remove time). Rather than silently orphan them, the guard near
|
||||
# the top of this function rejects a layout-changing upgrade while preset
|
||||
# overrides are installed, so control only reaches here (with a changed
|
||||
# layout) when no preset artifacts are at stake. Full preset reconciliation
|
||||
# would require a new cross-cutting PresetManager subsystem affecting every
|
||||
# dual-layout agent, which is out of scope for this Bob migration.
|
||||
if (
|
||||
installed_key == key
|
||||
and _manifest_tracks_skill_layout(old_manifest)
|
||||
!= _manifest_tracks_skill_layout(new_manifest)
|
||||
):
|
||||
_unregister_extensions_for_agent(
|
||||
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 layout changed, but old-layout extension "
|
||||
"artifacts may need manual cleanup."
|
||||
"The integration command directory changed, but legacy enabled "
|
||||
"extension artifacts may need manual cleanup."
|
||||
),
|
||||
)
|
||||
_register_extensions_for_agent(
|
||||
project_root,
|
||||
key,
|
||||
continuing="The integration was upgraded, but installed extensions may need re-registration.",
|
||||
)
|
||||
|
||||
# Re-register enabled extensions and presets only when upgrading the
|
||||
# active integration. Inactive integrations remain untouched until
|
||||
# `use` or `switch` activates and rescaffolds them (#2948). This runs
|
||||
# after the core upgrade transaction, so failures remain best-effort.
|
||||
if key == installed_key:
|
||||
_register_extensions_for_agent(
|
||||
project_root,
|
||||
key,
|
||||
continuing="The integration was upgraded, but installed extensions may need re-registration.",
|
||||
)
|
||||
_register_presets_for_agent(
|
||||
project_root,
|
||||
key,
|
||||
continuing="The integration was upgraded, but installed presets may need re-registration.",
|
||||
)
|
||||
|
||||
name = (integration.config or {}).get("name", key)
|
||||
console.print(f"\n[green]✓[/green] Integration '{name}' upgraded successfully")
|
||||
|
||||
@@ -18,6 +18,7 @@ from ._commands import integration_app, integration_catalog_app
|
||||
from ._helpers import (
|
||||
_read_integration_json,
|
||||
_register_extensions_for_agent,
|
||||
_register_presets_for_agent,
|
||||
_resolve_integration_options,
|
||||
_set_default_integration_or_exit,
|
||||
)
|
||||
@@ -248,6 +249,11 @@ def integration_use(
|
||||
key,
|
||||
continuing="The integration was selected, but installed extensions may need re-registration.",
|
||||
)
|
||||
_register_presets_for_agent(
|
||||
project_root,
|
||||
key,
|
||||
continuing="The integration was selected, but installed presets may need re-registration.",
|
||||
)
|
||||
console.print(f"[green]✓[/green] Default integration set to [bold]{key}[/bold].")
|
||||
|
||||
|
||||
@@ -489,13 +495,14 @@ def integration_catalog_list():
|
||||
display_name = str(raw_name).strip() if raw_name is not None else ""
|
||||
if not display_name:
|
||||
display_name = f"catalog-{i + 1}"
|
||||
safe_name = _rich_escape(display_name)
|
||||
if env_override or project_configs is None:
|
||||
console.print(f" - [bold]{display_name}[/bold] — {install_status}")
|
||||
console.print(f" - [bold]{safe_name}[/bold] — {install_status}")
|
||||
else:
|
||||
console.print(f" [{i}] [bold]{display_name}[/bold] — {install_status}")
|
||||
console.print(f" {cfg.get('url', '')}")
|
||||
console.print(f" [{i}] [bold]{safe_name}[/bold] — {install_status}")
|
||||
console.print(f" {_rich_escape(str(cfg.get('url', '')))}")
|
||||
if cfg.get("description"):
|
||||
console.print(f" [dim]{cfg['description']}[/dim]")
|
||||
console.print(f" [dim]{_rich_escape(str(cfg['description']))}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
165
src/specify_cli/integrations/alquimia/__init__.py
Normal file
165
src/specify_cli/integrations/alquimia/__init__.py
Normal file
@@ -0,0 +1,165 @@
|
||||
"""Alquimia AI integration."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any
|
||||
|
||||
from ..._utils import dump_frontmatter
|
||||
from ..base import SkillsIntegration
|
||||
|
||||
# Mapping of command template stem → argument-hint text shown inline
|
||||
# when a user invokes the slash command in Alquimia AI.
|
||||
ARGUMENT_HINTS: dict[str, str] = {
|
||||
"specify": "Describe the feature you want to specify",
|
||||
"plan": "Optional guidance for the planning phase",
|
||||
"tasks": "Optional task generation constraints",
|
||||
"implement": "Optional implementation guidance or task filter",
|
||||
"analyze": "Optional focus areas for analysis",
|
||||
"clarify": "Optional areas to clarify in the spec",
|
||||
"constitution": "Principles or values for the project constitution",
|
||||
"checklist": "Domain or focus area for the checklist",
|
||||
"taskstoissues": "Optional filter or label for GitHub issues",
|
||||
}
|
||||
|
||||
|
||||
class AlquimiaAIIntegration(SkillsIntegration):
|
||||
"""Integration for Alquimia AI skills."""
|
||||
|
||||
key = "alquimia"
|
||||
config = {
|
||||
"name": "Alquimia AI",
|
||||
"folder": ".alquimia/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://docs.alquimia.ai",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".alquimia/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
def _render_skill(
|
||||
self, template_name: str, frontmatter: dict[str, Any], body: str
|
||||
) -> str:
|
||||
"""Render a processed command template as an Alquimia skill."""
|
||||
skill_name = f"speckit-{template_name.replace('.', '-')}"
|
||||
description = frontmatter.get(
|
||||
"description",
|
||||
f"Spec-kit workflow command: {template_name}",
|
||||
)
|
||||
skill_frontmatter = self._build_skill_fm(
|
||||
skill_name, description, f"templates/commands/{template_name}.md"
|
||||
)
|
||||
frontmatter_text = dump_frontmatter(skill_frontmatter)
|
||||
return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
|
||||
|
||||
def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
return CommandRegistrar.build_skill_frontmatter(
|
||||
self.key, name, description, source
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
|
||||
|
||||
Skips injection if ``argument-hint:`` already exists in the
|
||||
frontmatter to avoid duplicate keys.
|
||||
"""
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Pre-scan: bail out if argument-hint 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("argument-hint:"):
|
||||
return content # already present
|
||||
|
||||
out: list[str] = []
|
||||
in_fm = False
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
in_fm = dash_count == 1
|
||||
out.append(line)
|
||||
continue
|
||||
if in_fm and not injected and stripped.startswith("description:"):
|
||||
out.append(line)
|
||||
# Preserve the exact line-ending style (\r\n vs \n)
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
elif line.endswith("\n"):
|
||||
eol = "\n"
|
||||
else:
|
||||
eol = ""
|
||||
escaped = hint.replace("\\", "\\\\").replace('"', '\\"')
|
||||
out.append(f'argument-hint: "{escaped}"{eol}')
|
||||
injected = True
|
||||
continue
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
@staticmethod
|
||||
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
|
||||
"""Insert ``key: value`` before the closing ``---`` if not already present."""
|
||||
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
|
||||
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:
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
elif line.endswith("\n"):
|
||||
eol = "\n"
|
||||
else:
|
||||
eol = ""
|
||||
out.append(f"{key}: {value}{eol}")
|
||||
injected = True
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject Alquimia-specific frontmatter flags, hints and hook notes."""
|
||||
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"
|
||||
)
|
||||
for line in updated.splitlines():
|
||||
if line.startswith("name:"):
|
||||
name = line.removeprefix("name:").strip().strip("\"'")
|
||||
hint = ARGUMENT_HINTS.get(name.removeprefix("speckit-"))
|
||||
if hint:
|
||||
updated = self.inject_argument_hint(updated, hint)
|
||||
break
|
||||
return updated
|
||||
@@ -379,6 +379,10 @@ class CopilotIntegration(IntegrationBase):
|
||||
if not templates:
|
||||
return []
|
||||
|
||||
from ...presets import PresetResolver
|
||||
|
||||
preset_resolver = PresetResolver(project_root_resolved)
|
||||
|
||||
dest = self.commands_dest(project_root)
|
||||
dest_resolved = dest.resolve()
|
||||
try:
|
||||
@@ -396,7 +400,11 @@ class CopilotIntegration(IntegrationBase):
|
||||
|
||||
# 1. Process and write command files as .agent.md
|
||||
for src_file in templates:
|
||||
raw = src_file.read_text(encoding="utf-8")
|
||||
resolved_template = preset_resolver.resolve(
|
||||
f"speckit.{src_file.stem}", template_type="command"
|
||||
)
|
||||
source_path = resolved_template or src_file
|
||||
raw = source_path.read_text(encoding="utf-8")
|
||||
processed = self.process_template(
|
||||
raw, self.key, script_type, arg_placeholder,
|
||||
project_root=project_root,
|
||||
|
||||
@@ -7,13 +7,14 @@ class KilocodeIntegration(MarkdownIntegration):
|
||||
key = "kilocode"
|
||||
config = {
|
||||
"name": "Kilo Code",
|
||||
"folder": ".kilocode/",
|
||||
"commands_subdir": "workflows",
|
||||
"folder": ".kilo/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".kilocode/workflows",
|
||||
"dir": ".kilo/commands",
|
||||
"legacy_dir": ".kilocode/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
|
||||
@@ -20,6 +20,7 @@ class OmpIntegration(MarkdownIntegration):
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
|
||||
@@ -18,3 +18,4 @@ class PiIntegration(MarkdownIntegration):
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -19,6 +19,7 @@ from .._console import console
|
||||
from .._download_security import (
|
||||
is_https_or_localhost_http,
|
||||
is_safe_download_redirect,
|
||||
read_response_limited,
|
||||
)
|
||||
|
||||
preset_app = typer.Typer(
|
||||
@@ -61,7 +62,7 @@ def preset_list():
|
||||
console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} — {status} — priority {pri}")
|
||||
console.print(f" {pack['description']}")
|
||||
if pack.get("tags"):
|
||||
tags_str = ", ".join(pack["tags"])
|
||||
tags_str = _escape_markup(", ".join(str(t) for t in pack["tags"]))
|
||||
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
|
||||
console.print()
|
||||
@@ -126,15 +127,15 @@ def preset_add(
|
||||
|
||||
if not is_https_or_localhost_http(from_url):
|
||||
console.print(
|
||||
"[red]Error:[/red] URL must use HTTPS with a hostname, "
|
||||
"or HTTP for localhost/loopback."
|
||||
"[red]Error:[/red] URL must use HTTPS with a hostname and be "
|
||||
"a valid URL with a host. HTTP is only allowed for localhost, "
|
||||
"127.0.0.1, and ::1."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
console.print(f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]...")
|
||||
import urllib.error
|
||||
import tempfile
|
||||
import shutil
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "preset.zip"
|
||||
@@ -162,16 +163,21 @@ def preset_add(
|
||||
console.print(
|
||||
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
|
||||
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
|
||||
"or HTTP for localhost/loopback."
|
||||
"or HTTP for localhost (127.0.0.1, ::1)."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
with zip_path.open("wb") as output:
|
||||
try:
|
||||
shutil.copyfileobj(response, output)
|
||||
except TypeError:
|
||||
output.write(response.read())
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download: {_escape_markup(str(e))}")
|
||||
zip_path.write_bytes(
|
||||
read_response_limited(
|
||||
response,
|
||||
error_type=PresetError,
|
||||
label=f"preset {from_url}",
|
||||
)
|
||||
)
|
||||
except (urllib.error.URLError, PresetError) as e:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to download: "
|
||||
f"{_escape_markup(str(e))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
@@ -288,7 +294,7 @@ def preset_search(
|
||||
console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
|
||||
console.print(f" {pack.get('description', '')}")
|
||||
if pack.get("tags"):
|
||||
tags_str = ", ".join(pack["tags"])
|
||||
tags_str = ", ".join(str(t) for t in pack["tags"])
|
||||
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||
console.print()
|
||||
|
||||
@@ -379,7 +385,7 @@ def preset_info(
|
||||
if local_pack.author:
|
||||
console.print(f" Author: {local_pack.author}")
|
||||
if local_pack.tags:
|
||||
console.print(f" Tags: {', '.join(local_pack.tags)}")
|
||||
console.print(f" Tags: {', '.join(str(t) for t in local_pack.tags)}")
|
||||
console.print(f" Templates: {len(local_pack.templates)}")
|
||||
for tmpl in local_pack.templates:
|
||||
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
|
||||
@@ -415,7 +421,7 @@ def preset_info(
|
||||
if pack_info.get("author"):
|
||||
console.print(f" Author: {pack_info['author']}")
|
||||
if pack_info.get("tags"):
|
||||
console.print(f" Tags: {', '.join(pack_info['tags'])}")
|
||||
console.print(f" Tags: {', '.join(str(t) for t in pack_info['tags'])}")
|
||||
if pack_info.get("repository"):
|
||||
console.print(f" Repository: {pack_info['repository']}")
|
||||
if pack_info.get("license"):
|
||||
@@ -580,10 +586,10 @@ def preset_catalog_list():
|
||||
if entry.install_allowed
|
||||
else "[yellow]discovery only[/yellow]"
|
||||
)
|
||||
console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
|
||||
console.print(f" [bold]{_escape_markup(str(entry.name))}[/bold] (priority {entry.priority})")
|
||||
if entry.description:
|
||||
console.print(f" {entry.description}")
|
||||
console.print(f" URL: {entry.url}")
|
||||
console.print(f" {_escape_markup(str(entry.description))}")
|
||||
console.print(f" URL: {_escape_markup(str(entry.url))}")
|
||||
console.print(f" Install: {install_str}")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
@@ -401,6 +401,12 @@ def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
|
||||
# a ceiling any legitimate workflow definition should ever approach.
|
||||
_MAX_WORKFLOW_YAML_BYTES = 5 * 1024 * 1024 # 5 MiB
|
||||
_DOWNLOAD_CHUNK_SIZE = 65536
|
||||
# Custom step packages contain executable Python, metadata, and optional helper
|
||||
# files downloaded one-by-one rather than as an archive. Mirror the archive
|
||||
# ceilings so a catalog cannot turn individually valid files into an unbounded
|
||||
# aggregate download.
|
||||
_MAX_STEP_PACKAGE_FILES = 512
|
||||
_MAX_STEP_PACKAGE_BYTES = 50 * 1024 * 1024 # 50 MiB
|
||||
|
||||
|
||||
def _read_response_within_limit(response, max_bytes: int | None = None) -> bytes:
|
||||
@@ -2354,14 +2360,25 @@ def workflow_info(
|
||||
raise typer.Exit(1)
|
||||
|
||||
if definition:
|
||||
console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})")
|
||||
console.print(f" Version: {definition.version}")
|
||||
# Escape every user-controlled field: workflow.yml values (name,
|
||||
# version, author, description, integration, input names/types) are not
|
||||
# trusted, and console.print has Rich markup enabled, so an unescaped
|
||||
# `[...]` in any of them is parsed as a style tag and silently swallowed
|
||||
# (same defect fixed for the step graph below; the sibling workflow_list
|
||||
# already escapes all of these).
|
||||
console.print(
|
||||
f"\n[bold cyan]{_escape_markup(str(definition.name))}[/bold cyan] "
|
||||
f"({_escape_markup(str(definition.id))})"
|
||||
)
|
||||
console.print(f" Version: {_escape_markup(str(definition.version))}")
|
||||
if definition.author:
|
||||
console.print(f" Author: {definition.author}")
|
||||
console.print(f" Author: {_escape_markup(str(definition.author))}")
|
||||
if definition.description:
|
||||
console.print(f" Description: {definition.description}")
|
||||
console.print(f" Description: {_escape_markup(str(definition.description))}")
|
||||
if definition.default_integration:
|
||||
console.print(f" Integration: {definition.default_integration}")
|
||||
console.print(
|
||||
f" Integration: {_escape_markup(str(definition.default_integration))}"
|
||||
)
|
||||
if installed:
|
||||
console.print(" [green]Installed[/green]")
|
||||
|
||||
@@ -2370,13 +2387,24 @@ def workflow_info(
|
||||
for name, inp in definition.inputs.items():
|
||||
if isinstance(inp, dict):
|
||||
req = "required" if inp.get("required") else "optional"
|
||||
console.print(f" {name} ({inp.get('type', 'string')}) — {req}")
|
||||
console.print(
|
||||
f" {_escape_markup(str(name))} "
|
||||
f"({_escape_markup(str(inp.get('type', 'string')))}) — {req}"
|
||||
)
|
||||
|
||||
if definition.steps:
|
||||
console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]")
|
||||
for step in definition.steps:
|
||||
stype = step.get("type", "command")
|
||||
console.print(f" → {step.get('id', '?')} [{stype}]")
|
||||
# Escape the literal bracket (\[) so Rich renders `[<type>]`
|
||||
# instead of parsing it as a style tag named after the step
|
||||
# type (which it silently swallows); escape id/type too, as
|
||||
# the sibling workflow_list does. Mirrors the `\[disabled]`
|
||||
# precedent above.
|
||||
console.print(
|
||||
f" → {_escape_markup(str(step.get('id', '?')))} "
|
||||
f"\\[{_escape_markup(str(stype))}]"
|
||||
)
|
||||
return
|
||||
|
||||
# Try catalog
|
||||
@@ -2387,15 +2415,23 @@ def workflow_info(
|
||||
info = None
|
||||
|
||||
if info:
|
||||
console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})")
|
||||
console.print(f" Version: {info.get('version', '?')}")
|
||||
# Catalog-derived fields are untrusted; escape them so bracketed content
|
||||
# is rendered literally rather than parsed (and swallowed) as Rich markup.
|
||||
console.print(
|
||||
f"\n[bold cyan]{_escape_markup(str(info.get('name', workflow_id)))}[/bold cyan] "
|
||||
f"({_escape_markup(str(workflow_id))})"
|
||||
)
|
||||
console.print(f" Version: {_escape_markup(str(info.get('version', '?')))}")
|
||||
if info.get("description"):
|
||||
console.print(f" Description: {info['description']}")
|
||||
console.print(f" Description: {_escape_markup(str(info['description']))}")
|
||||
if info.get("tags"):
|
||||
console.print(f" Tags: {', '.join(info['tags'])}")
|
||||
safe_tags = _escape_markup(", ".join(str(t) for t in info["tags"]))
|
||||
console.print(f" Tags: {safe_tags}")
|
||||
console.print(" [yellow]Not installed[/yellow]")
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found")
|
||||
console.print(
|
||||
f"[red]Error:[/red] Workflow '{_escape_markup(str(workflow_id))}' not found"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@@ -2416,10 +2452,10 @@ def workflow_catalog_list():
|
||||
console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n")
|
||||
for i, cfg in enumerate(configs):
|
||||
install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]"
|
||||
console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}")
|
||||
console.print(f" {cfg['url']}")
|
||||
console.print(f" [{i}] [bold]{_escape_markup(str(cfg['name']))}[/bold] — {install_status}")
|
||||
console.print(f" {_escape_markup(str(cfg['url']))}")
|
||||
if cfg.get("description"):
|
||||
console.print(f" [dim]{cfg['description']}[/dim]")
|
||||
console.print(f" [dim]{_escape_markup(str(cfg['description']))}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -2628,14 +2664,39 @@ def workflow_step_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
step_yml_url = info.get("step_yml_url") or info.get("url")
|
||||
if not step_yml_url:
|
||||
declared_step_yml_url = info.get("step_yml_url")
|
||||
if declared_step_yml_url is not None and not isinstance(
|
||||
declared_step_yml_url, str
|
||||
):
|
||||
console.print(
|
||||
f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
|
||||
"step.yml URL; expected a non-empty string"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
step_yml_url = declared_step_yml_url or info.get("url")
|
||||
if step_yml_url is None or (
|
||||
isinstance(step_yml_url, str) and not step_yml_url.strip()
|
||||
):
|
||||
console.print(f"[red]Error:[/red] Catalog entry for '{step_id}' has no URL")
|
||||
raise typer.Exit(1)
|
||||
if not isinstance(step_yml_url, str):
|
||||
console.print(
|
||||
f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
|
||||
"step.yml URL; expected a non-empty string"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Derive __init__.py URL: replace trailing step.yml with __init__.py
|
||||
# or use explicit init_url if provided.
|
||||
init_url = info.get("init_url")
|
||||
if init_url is not None and (
|
||||
not isinstance(init_url, str) or not init_url.strip()
|
||||
):
|
||||
console.print(
|
||||
f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
|
||||
"__init__.py URL; expected a non-empty string"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if not init_url:
|
||||
if step_yml_url.endswith("step.yml"):
|
||||
init_url = step_yml_url[: -len("step.yml")] + "__init__.py"
|
||||
@@ -2646,6 +2707,41 @@ def workflow_step_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Preflight the declared file count before creating a staging directory or
|
||||
# issuing any request. The two required files are always part of the package;
|
||||
# duplicate declarations for them in extra_files are ignored below and do
|
||||
# not count twice.
|
||||
extra_files = info.get("extra_files")
|
||||
if extra_files is not None and not isinstance(extra_files, dict):
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; "
|
||||
"additional package files will not be downloaded."
|
||||
)
|
||||
extra_files = {}
|
||||
|
||||
def _is_required_package_file(rel_path: object) -> bool:
|
||||
"""Match portable path/case aliases of the two required package files."""
|
||||
if not isinstance(rel_path, str):
|
||||
return False
|
||||
parts = PurePosixPath(rel_path.replace("\\", "/")).parts
|
||||
return len(parts) == 1 and parts[0].casefold() in {
|
||||
"step.yml",
|
||||
"__init__.py",
|
||||
}
|
||||
|
||||
declared_extra_count = sum(
|
||||
1
|
||||
for rel_path in (extra_files or {})
|
||||
if not _is_required_package_file(rel_path)
|
||||
)
|
||||
package_file_count = 2 + declared_extra_count
|
||||
if package_file_count > _MAX_STEP_PACKAGE_FILES:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Step package declares {package_file_count} files, "
|
||||
f"exceeding the {_MAX_STEP_PACKAGE_FILES}-file limit"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
|
||||
def _safe_fetch(url: str) -> bytes:
|
||||
@@ -2702,6 +2798,14 @@ def workflow_step_add(
|
||||
console.print(f"[red]Error:[/red] Failed to download step files: {exc}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
package_bytes = len(step_yml_content) + len(init_py_content)
|
||||
if package_bytes > _MAX_STEP_PACKAGE_BYTES:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Step package exceeds the "
|
||||
f"{_MAX_STEP_PACKAGE_BYTES}-byte total size limit"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Validate step.yml
|
||||
try:
|
||||
import yaml as _yaml
|
||||
@@ -2746,13 +2850,6 @@ def workflow_step_add(
|
||||
# relative-path → URL. step.yml and __init__.py are ignored here (already
|
||||
# written). Paths are validated to stay within the step package directory to
|
||||
# prevent path-traversal attacks.
|
||||
extra_files = info.get("extra_files")
|
||||
if extra_files is not None and not isinstance(extra_files, dict):
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; "
|
||||
"additional package files will not be downloaded."
|
||||
)
|
||||
extra_files = {}
|
||||
for rel_path, file_url in (extra_files or {}).items():
|
||||
if not isinstance(rel_path, str) or not rel_path.strip():
|
||||
console.print(
|
||||
@@ -2760,7 +2857,7 @@ def workflow_step_add(
|
||||
"empty or non-string path key"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if rel_path in ("step.yml", "__init__.py"):
|
||||
if _is_required_package_file(rel_path):
|
||||
continue # already written above
|
||||
# Reject dot-path segments ('', '.', '..') that would refer to the
|
||||
# package directory itself (IsADirectoryError) or escape it.
|
||||
@@ -2796,6 +2893,13 @@ def workflow_step_add(
|
||||
f"[red]Error:[/red] Failed to download extra file '{rel_path}': {exc}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
package_bytes += len(file_content)
|
||||
if package_bytes > _MAX_STEP_PACKAGE_BYTES:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Step package exceeds the "
|
||||
f"{_MAX_STEP_PACKAGE_BYTES}-byte total size limit"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
dest.parent.mkdir(parents=True, exist_ok=True)
|
||||
dest.write_bytes(file_content)
|
||||
@@ -3059,10 +3163,10 @@ def workflow_step_catalog_list():
|
||||
if cfg["install_allowed"]
|
||||
else "[yellow]discovery only[/yellow]"
|
||||
)
|
||||
console.print(f" [{i}] [bold]{cfg['name']}[/bold] — {install_status}")
|
||||
console.print(f" {cfg['url']}")
|
||||
console.print(f" [{i}] [bold]{_escape_markup(str(cfg['name']))}[/bold] — {install_status}")
|
||||
console.print(f" {_escape_markup(str(cfg['url']))}")
|
||||
if cfg.get("description"):
|
||||
console.print(f" [dim]{cfg['description']}[/dim]")
|
||||
console.print(f" [dim]{_escape_markup(str(cfg['description']))}[/dim]")
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -22,6 +22,8 @@ from typing import Any
|
||||
|
||||
import yaml
|
||||
|
||||
from .._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Errors
|
||||
@@ -308,7 +310,8 @@ class WorkflowCatalog:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
_ = parsed.port
|
||||
except (TypeError, ValueError):
|
||||
raise WorkflowValidationError(
|
||||
f"Catalog URL is malformed: {url}"
|
||||
) from None
|
||||
@@ -505,7 +508,8 @@ class WorkflowCatalog:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
_ = parsed.port
|
||||
except (TypeError, ValueError):
|
||||
raise WorkflowCatalogError(
|
||||
f"Refusing to fetch catalog from malformed URL: {url}"
|
||||
) from None
|
||||
@@ -538,7 +542,14 @@ class WorkflowCatalog:
|
||||
entry.url, timeout=30, redirect_validator=_validate_redirect
|
||||
) as resp:
|
||||
_validate_catalog_url(resp.geturl())
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
data = json.loads(
|
||||
read_response_limited(
|
||||
resp,
|
||||
max_bytes=MAX_JSON_CATALOG_BYTES,
|
||||
error_type=WorkflowCatalogError,
|
||||
label="workflow catalog",
|
||||
).decode("utf-8")
|
||||
)
|
||||
except Exception as exc:
|
||||
# Fall back to cache if available
|
||||
if cache_file.exists():
|
||||
@@ -982,7 +993,8 @@ class StepCatalog:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
_ = parsed.port
|
||||
except (TypeError, ValueError):
|
||||
raise StepValidationError(
|
||||
f"Catalog URL is malformed: {url}"
|
||||
) from None
|
||||
@@ -1178,7 +1190,8 @@ class StepCatalog:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
_ = parsed.port
|
||||
except (TypeError, ValueError):
|
||||
raise StepCatalogError(
|
||||
f"Refusing to fetch catalog from malformed URL: {url}"
|
||||
) from None
|
||||
@@ -1211,7 +1224,14 @@ class StepCatalog:
|
||||
entry.url, timeout=30, redirect_validator=_validate_redirect
|
||||
) as resp:
|
||||
_validate_url(resp.geturl())
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
data = json.loads(
|
||||
read_response_limited(
|
||||
resp,
|
||||
max_bytes=MAX_JSON_CATALOG_BYTES,
|
||||
error_type=StepCatalogError,
|
||||
label="step catalog",
|
||||
).decode("utf-8")
|
||||
)
|
||||
except Exception as exc:
|
||||
if cache_safe and cache_file.exists():
|
||||
try:
|
||||
|
||||
@@ -42,6 +42,17 @@ class WorkflowDefinition:
|
||||
self.source_path = source_path
|
||||
|
||||
workflow = data.get("workflow", {})
|
||||
# A present-but-non-mapping ``workflow:`` block (bare ``workflow:`` ->
|
||||
# None, or ``workflow: <str/list>``) would crash the following
|
||||
# ``workflow.get(...)`` calls with AttributeError, so construction fails
|
||||
# before any validation can run. Normalize the local to {} instead: the
|
||||
# header fields fall back to their defaults and ``validate_workflow``
|
||||
# (which reads those parsed attributes) reports the missing
|
||||
# ``workflow.id``/``workflow.name``. ``self.data`` is deliberately left
|
||||
# holding the raw value, since it is what gets written back out when a
|
||||
# definition is serialized. Mirrors the default_options guard below.
|
||||
if not isinstance(workflow, dict):
|
||||
workflow = {}
|
||||
self.id: str = workflow.get("id", "")
|
||||
self.name: str = workflow.get("name", "")
|
||||
self.version: str = workflow.get("version", "0.0.0")
|
||||
@@ -1400,6 +1411,14 @@ class WorkflowEngine:
|
||||
) -> dict[str, Any]:
|
||||
"""Resolve workflow inputs against definitions and provided values."""
|
||||
resolved: dict[str, Any] = {}
|
||||
# execute()/resume() accept UNVALIDATED definitions (load_workflow does
|
||||
# not validate). A non-mapping ``inputs:`` block (bare ``inputs:`` ->
|
||||
# None, or ``inputs: []``) is stored raw, so iterating ``.items()`` here
|
||||
# would crash the run with AttributeError. Treat a non-mapping inputs
|
||||
# block as "no inputs"; validate_workflow reports the malformed shape
|
||||
# via its own isinstance check.
|
||||
if not isinstance(definition.inputs, dict):
|
||||
return {}
|
||||
for name, input_def in definition.inputs.items():
|
||||
if not isinstance(input_def, dict):
|
||||
continue
|
||||
|
||||
@@ -392,8 +392,14 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An
|
||||
)
|
||||
return _filter_from_json(value)
|
||||
|
||||
# Parse filter name and argument
|
||||
filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
|
||||
# Parse filter name and argument. Use fullmatch (not match) so trailing
|
||||
# tokens after the closing paren — e.g. a comparison/boolean operator that
|
||||
# binds looser than the pipe, as in ``count | default(0) > 5`` — are not
|
||||
# silently discarded but fall through to the "unsupported form" ValueError
|
||||
# below, mirroring the strict trailing-token handling of the from_json
|
||||
# branch above. The greedy ``.+`` still handles literal ``)`` and ``|``
|
||||
# inside quoted args.
|
||||
filter_match = re.fullmatch(r"(\w+)\((.+)\)", filter_expr)
|
||||
if filter_match:
|
||||
fname = filter_match.group(1)
|
||||
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
|
||||
|
||||
@@ -139,6 +139,12 @@ Execution steps:
|
||||
|
||||
5. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- **Question writing quality (applies to every question, MC or short-answer):**
|
||||
- Lead with `**Question:**` followed by a full interrogative that ends with `?`. The question text before the `?` must make sense on its own.
|
||||
- NEVER use a topic label, section heading, or requirement id as the question itself. For example, `Acceptance device/runtime matrix (FR-023)` is INVALID — it is a label, not a question.
|
||||
- After the `?`, the only permitted suffix is an optional parenthesized requirement/question id. Exact format: `**Question:** <interrogative>?` or `**Question:** <interrogative>? (FR-023)`. Never put the id before the `?`, and never use the id (alone or with a topic label) as the whole prompt.
|
||||
- Immediately after the question line, add one plain-language "Why it matters" sentence (the stake for acceptance or shipping) before the recommendation/options.
|
||||
- Use everyday wording; introduce jargon only if defined in the same sentence. Self-check: a reader who does not know Spec Kit must be able to answer from the Question line alone. Terse is fine; cryptic labels are not.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
- Best practices for the project type
|
||||
|
||||
@@ -68,10 +68,44 @@ def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str):
|
||||
load_source_stack(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", ["catalogs:\n", "catalogs: []\n"])
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
"- a\n- b\n", # truthy list
|
||||
"42\n", # truthy scalar
|
||||
"[]\n", # falsy list
|
||||
"false\n", # falsy bool
|
||||
"0\n", # falsy int
|
||||
"''\n", # falsy empty string
|
||||
"null\n", # explicit null scalar (safe_load -> None, but a real node)
|
||||
"~\n", # explicit null scalar (alt spelling)
|
||||
],
|
||||
)
|
||||
def test_toplevel_non_mapping_raises(tmp_path: Path, body: str):
|
||||
"""A top-level non-mapping bundle-catalogs.yml (list/scalar/null) must raise,
|
||||
matching the sibling reader (catalog_config._read) — not silently fall back
|
||||
to the built-in default stack. This includes FALSY non-mappings ([], false,
|
||||
0, '') and an explicit null (null/~); the shared load_yaml would coerce those
|
||||
to {} and hide them, so it distinguishes them from a truly empty document."""
|
||||
make_project(tmp_path)
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="expected a mapping at the top level"):
|
||||
load_source_stack(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
[
|
||||
"catalogs:\n", # present key, null value
|
||||
"catalogs: []\n", # present key, empty list
|
||||
"", # truly empty document
|
||||
"# only a comment\n", # comment-only == empty document
|
||||
],
|
||||
)
|
||||
def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str):
|
||||
"""An absent (``None``) or empty-list ``catalogs:`` is valid: it contributes
|
||||
no project sources and falls back to the built-in default stack."""
|
||||
"""An empty document, comment-only file, or absent/empty-list ``catalogs:``
|
||||
is valid: it contributes no project sources and falls back to the built-in
|
||||
default stack (must not be confused with an explicit top-level null)."""
|
||||
make_project(tmp_path)
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
|
||||
# Does not raise; still yields the built-in defaults.
|
||||
@@ -79,6 +113,44 @@ def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str):
|
||||
assert len(sources) > 0
|
||||
|
||||
|
||||
def test_load_source_stack_rejects_unknown_schema_version(tmp_path: Path):
|
||||
"""A bundle-catalogs.yml with an unsupported MAJOR schema_version must raise
|
||||
on the resolution path (load_source_stack -> _merge_config), matching the
|
||||
sibling reader commands_impl/catalog_config._read. Without this a file
|
||||
written by a newer/incompatible Spec Kit was silently parsed under v1
|
||||
assumptions on the install/search path, while the other reader rejected it."""
|
||||
make_project(tmp_path)
|
||||
config = {
|
||||
"schema_version": "2.0",
|
||||
"catalogs": [{"id": "corp", "url": "https://corp/catalog.json",
|
||||
"priority": 1, "install_policy": "install-allowed"}],
|
||||
}
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
yaml.safe_dump(config), encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(BundlerError, match="Unsupported catalog config schema version"):
|
||||
load_source_stack(tmp_path)
|
||||
|
||||
|
||||
def test_load_source_stack_accepts_matching_or_absent_schema_version(tmp_path: Path):
|
||||
"""A matching major version (1.x) and an absent schema_version both stay
|
||||
valid — the guard rejects only a different major, so existing configs that
|
||||
omit the key are unaffected."""
|
||||
make_project(tmp_path)
|
||||
cfg = tmp_path / ".specify" / "bundle-catalogs.yml"
|
||||
cfg.write_text(yaml.safe_dump({
|
||||
"schema_version": "1.5", # same major as CONFIG_SCHEMA_VERSION (1.0)
|
||||
"catalogs": [{"id": "corp", "url": "https://corp/catalog.json",
|
||||
"priority": 1, "install_policy": "install-allowed"}],
|
||||
}), encoding="utf-8")
|
||||
assert "corp" in {s.id for s in load_source_stack(tmp_path)}
|
||||
cfg.write_text(yaml.safe_dump({ # no schema_version key
|
||||
"catalogs": [{"id": "corp2", "url": "https://corp2/catalog.json",
|
||||
"priority": 1, "install_policy": "install-allowed"}],
|
||||
}), encoding="utf-8")
|
||||
assert "corp2" in {s.id for s in load_source_stack(tmp_path)}
|
||||
|
||||
|
||||
def test_project_config_overrides_same_id(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
config = {
|
||||
@@ -175,6 +247,25 @@ def test_catalog_entry_rejects_non_boolean_verified():
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
def test_catalog_entry_preserves_sha256_through_provenance():
|
||||
digest = "a" * 64
|
||||
payload = catalog_payload(
|
||||
{"demo": catalog_entry_dict("demo", sha256=f"sha256:{digest}")}
|
||||
)
|
||||
|
||||
entry = load_catalog_payload(payload)["demo"]
|
||||
source = CatalogSource(
|
||||
id="team",
|
||||
url="https://example.com/catalog.json",
|
||||
priority=10,
|
||||
install_policy=InstallPolicy.INSTALL_ALLOWED,
|
||||
scope=Scope.PROJECT,
|
||||
)
|
||||
|
||||
assert entry.sha256 == f"sha256:{digest}"
|
||||
assert entry.with_provenance(source).sha256 == f"sha256:{digest}"
|
||||
|
||||
|
||||
def test_load_payload_rejects_id_key_mismatch():
|
||||
# The enclosing key is authoritative; an entry whose own id disagrees with
|
||||
# the key must be rejected so a catalog can't list a spoofed/unresolvable id.
|
||||
@@ -207,3 +298,17 @@ def test_catalog_entry_rejects_non_mapping_provides():
|
||||
data["provides"] = "extensions"
|
||||
with pytest.raises(BundlerError, match="'provides' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("field", ["requires", "provides"])
|
||||
@pytest.mark.parametrize("bad", [[], "", 0, False])
|
||||
def test_catalog_entry_rejects_falsy_non_mapping(field, bad):
|
||||
# `or {}` coerced a FALSY non-mapping ([], '', 0, False) to {} before the
|
||||
# isinstance guard, silently accepting a corrupt entry; only absent/None
|
||||
# means "not present". Mirrors the manifest requires/provides guard.
|
||||
from specify_cli.bundler.models.catalog import CatalogEntry
|
||||
|
||||
data = catalog_entry_dict("demo")
|
||||
data[field] = bad
|
||||
with pytest.raises(BundlerError, match=f"'{field}' must be a mapping"):
|
||||
CatalogEntry.from_dict(data)
|
||||
|
||||
@@ -1167,6 +1167,295 @@ class TestAutoCommitBash:
|
||||
assert "\u2713" not in result.stderr, "Must not use Unicode checkmark"
|
||||
|
||||
|
||||
@requires_bash
|
||||
class TestAutoCommitBashCommitStyle:
|
||||
"""Tests for the `commit_style: conventional` option (issue #3390)."""
|
||||
|
||||
def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
|
||||
"""Omitting commit_style preserves the fixed/static message behavior."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
|
||||
"""commit_style: fixed (explicit) still uses the configured static message,
|
||||
not just the absent-key default."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: fixed\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: this should be ignored"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
assert "this should be ignored" not in log.stdout
|
||||
|
||||
def test_conventional_message_file_used(self, tmp_path: Path):
|
||||
"""--message-file reads the generated message from a file instead of argv,
|
||||
avoiding shell interpolation of agent-controlled content."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
# Write the message file inside the worktree (as an agent invoking
|
||||
# this from a working directory tool naturally would) to exercise
|
||||
# the exclusion-from-staging behavior below.
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add $(dangerous) `injection` test" in log.stdout
|
||||
|
||||
def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
|
||||
"""--message-file written inside the worktree must never be staged or
|
||||
committed itself, and must be removed once its content is consumed."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: real change\n")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert not msg_file.exists()
|
||||
show = subprocess.run(
|
||||
["git", "show", "--stat", "--oneline", "HEAD"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "new-file.txt" in show.stdout
|
||||
assert "commit-msg.txt" not in show.stdout
|
||||
|
||||
def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
|
||||
"""If the message file is the only 'change' in the worktree (no real
|
||||
edits), auto-commit must still report no changes rather than
|
||||
committing the transport file by itself."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
# Baseline-commit the scaffolding (and config) so the tree is
|
||||
# genuinely clean before introducing the message file — otherwise
|
||||
# the untracked scaffold files would mask whether the message file
|
||||
# alone is enough to (incorrectly) trigger a commit.
|
||||
subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "baseline"],
|
||||
cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
|
||||
)
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: no real changes\n")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "No changes to commit" in result.stderr
|
||||
assert not msg_file.exists()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "baseline" in log.stdout
|
||||
|
||||
def test_message_file_missing_fails(self, tmp_path: Path):
|
||||
"""--message-file pointing at a nonexistent file fails clearly."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
missing = tmp_path / "does-not-exist.txt"
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "--message-file", str(missing)
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "not found" in result.stderr.lower()
|
||||
|
||||
def test_conventional_uses_generated_message(self, tmp_path: Path):
|
||||
"""commit_style: conventional uses the generated_message argument as the commit message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_without_generated_message_fails(self, tmp_path: Path):
|
||||
"""commit_style: conventional fails clearly instead of falling back to the fixed message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode != 0
|
||||
assert "conventional" in result.stderr.lower()
|
||||
|
||||
# No commit should have been made, and the fixed message must not be used.
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_skips_cleanly_with_no_changes(self, tmp_path: Path):
|
||||
"""No pending changes short-circuits before the missing-message failure."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
subprocess.run(["git", "add", "."], cwd=project, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
|
||||
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
assert "No changes" in result.stderr
|
||||
|
||||
def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
|
||||
"""commit_style value with a trailing YAML inline comment is still recognized."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional # team standard\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
|
||||
"""An unrecognized commit_style value falls back to 'fixed' with a warning,
|
||||
instead of silently mis-parsing or crashing."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventonal\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash("auto-commit.sh", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
assert "unknown commit_style" in result.stderr.lower()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
def test_duplicate_commit_style_lines_use_first_match(self, tmp_path: Path):
|
||||
"""A config with multiple `commit_style:` lines (e.g. from a bad merge) uses only
|
||||
the first match instead of concatenating values into an unrecognized style."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"commit_style: fixed\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_bash(
|
||||
"auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "unknown commit_style" not in result.stderr.lower()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestAutoCommitPowerShell:
|
||||
def test_disabled_by_default(self, tmp_path: Path):
|
||||
@@ -1227,6 +1516,271 @@ class TestAutoCommitPowerShell:
|
||||
assert "\u2713" not in result.stdout, "Must not use Unicode checkmark"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
|
||||
class TestAutoCommitPowerShellCommitStyle:
|
||||
"""Tests for the `commit_style: conventional` option (issue #3390)."""
|
||||
|
||||
def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
|
||||
"""Omitting commit_style preserves the fixed/static message behavior."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
|
||||
"""commit_style: fixed (explicit) still uses the configured static message,
|
||||
not just the absent-key default."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: fixed\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "feat: this should be ignored"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
assert "this should be ignored" not in log.stdout
|
||||
|
||||
def test_conventional_message_file_used(self, tmp_path: Path):
|
||||
"""-MessageFile reads the generated message from a file instead of argv,
|
||||
avoiding shell interpolation of agent-controlled content."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: add $(dangerous) `injection` test\n")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add $(dangerous) `injection` test" in log.stdout
|
||||
|
||||
def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
|
||||
"""-MessageFile written inside the worktree must never be staged or
|
||||
committed itself, and must be removed once its content is consumed."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: real change\n")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert not msg_file.exists()
|
||||
show = subprocess.run(
|
||||
["git", "show", "--stat", "--oneline", "HEAD"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "new-file.txt" in show.stdout
|
||||
assert "commit-msg.txt" not in show.stdout
|
||||
|
||||
def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
|
||||
"""If the message file is the only 'change' in the worktree (no real
|
||||
edits), auto-commit must still report no changes rather than
|
||||
committing the transport file by itself."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
# Baseline-commit the scaffolding (and config) so the tree is
|
||||
# genuinely clean before introducing the message file — otherwise
|
||||
# the untracked scaffold files would mask whether the message file
|
||||
# alone is enough to (incorrectly) trigger a commit.
|
||||
subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
|
||||
subprocess.run(
|
||||
["git", "commit", "-q", "-m", "baseline"],
|
||||
cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
|
||||
)
|
||||
msg_file = project / "commit-msg.txt"
|
||||
msg_file.write_text("feat: no real changes\n")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
|
||||
)
|
||||
assert result.returncode == 0
|
||||
assert "No changes to commit" in (result.stdout + result.stderr)
|
||||
assert not msg_file.exists()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "baseline" in log.stdout
|
||||
|
||||
def test_message_file_missing_fails(self, tmp_path: Path):
|
||||
"""-MessageFile pointing at a nonexistent file fails clearly."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
missing = tmp_path / "does-not-exist.txt"
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "-MessageFile", str(missing)
|
||||
)
|
||||
assert result.returncode != 0
|
||||
assert "not found" in (result.stdout + result.stderr).lower()
|
||||
|
||||
def test_conventional_uses_generated_message(self, tmp_path: Path):
|
||||
"""commit_style: conventional uses the generated_message argument as the commit message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_without_generated_message_fails(self, tmp_path: Path):
|
||||
"""commit_style: conventional fails clearly instead of falling back to the fixed message."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode != 0
|
||||
# Write-Warning output placement (stdout vs. stderr) is not deterministic
|
||||
# across pwsh versions/platforms, so check the combined stream like the
|
||||
# other pwsh tests above (e.g. test_not_a_repo_still_detected_with_autocrlf).
|
||||
combined = result.stdout + result.stderr
|
||||
assert "conventional" in combined.lower()
|
||||
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_conventional_skips_cleanly_with_no_changes(self, tmp_path: Path):
|
||||
"""No pending changes short-circuits before the missing-message failure."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
))
|
||||
subprocess.run(["git", "add", "."], cwd=project, check=True)
|
||||
subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
|
||||
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
combined = result.stdout + result.stderr
|
||||
assert "No changes" in combined
|
||||
|
||||
def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
|
||||
"""commit_style value with a trailing YAML inline comment is still recognized."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventional # team standard\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh(
|
||||
"auto-commit.ps1", project, "after_specify", "feat: add OAuth specification"
|
||||
)
|
||||
assert result.returncode == 0
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "feat: add OAuth specification" in log.stdout
|
||||
assert "[Spec Kit] Add specification" not in log.stdout
|
||||
|
||||
def test_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
|
||||
"""An unrecognized commit_style value falls back to 'fixed' with a warning,
|
||||
instead of silently mis-parsing or crashing."""
|
||||
project = _setup_project(tmp_path)
|
||||
_write_config(project, (
|
||||
"commit_style: conventonal\n"
|
||||
"auto_commit:\n"
|
||||
" default: false\n"
|
||||
" after_specify:\n"
|
||||
" enabled: true\n"
|
||||
' message: "[Spec Kit] Add specification"\n'
|
||||
))
|
||||
(project / "new-file.txt").write_text("content")
|
||||
result = _run_pwsh("auto-commit.ps1", project, "after_specify")
|
||||
assert result.returncode == 0
|
||||
combined = (result.stdout or "") + (result.stderr or "")
|
||||
assert "unknown commit_style" in combined.lower()
|
||||
log = subprocess.run(
|
||||
["git", "log", "--oneline", "-1"],
|
||||
cwd=project, capture_output=True, text=True,
|
||||
)
|
||||
assert "[Spec Kit] Add specification" in log.stdout
|
||||
|
||||
|
||||
# ── auto-commit.ps1 CRLF warning tests (issue #2253) ────────────────────────
|
||||
|
||||
|
||||
|
||||
@@ -222,6 +222,32 @@ def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
|
||||
assert "old" not in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_blank_markers_use_defaults_matching_bash(tmp_path: Path) -> None:
|
||||
# Regression: with blank markers (config relying on the built-in defaults),
|
||||
# the Bash port must fall back to DEFAULT_START/END, matching the Python and
|
||||
# PowerShell ports. Previously the Bash config-parser transport dropped the
|
||||
# trailing empty marker lines under $(...) command substitution, tripping the
|
||||
# "malformed config parser output" guard so the default-marker substitution
|
||||
# became unreachable and the context file was never updated.
|
||||
markers = {"start": "", "end": ""}
|
||||
repo_a, repo_b = twin_projects(
|
||||
tmp_path, context_file="AGENTS.md", context_markers=markers
|
||||
)
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"<!-- SPECKIT START -->" in content
|
||||
assert b"<!-- SPECKIT END -->" in content
|
||||
assert b"at specs/001-demo/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None:
|
||||
files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"]
|
||||
@@ -317,6 +343,27 @@ def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
|
||||
assert b"at specs/001-new/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_mtime_fallback_finds_nested_plan_matching_bash(tmp_path: Path) -> None:
|
||||
# Regression: the mtime fallback must discover plan.md in nested scoped
|
||||
# layouts (specs/<scope>/<feature>/plan.md), matching the Bash/PowerShell
|
||||
# ports and the documented recursive-discovery contract (see #3024). A
|
||||
# one-level scan (specs/*/plan.md) would miss this and omit the plan link.
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
for repo in (repo_a, repo_b):
|
||||
plan = repo / "specs" / "scope-a" / "002-nested" / "plan.md"
|
||||
plan.parent.mkdir(parents=True, exist_ok=True)
|
||||
plan.write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/scope-a/002-nested/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_prefers_feature_json_over_mtime_matching_bash(tmp_path: Path) -> None:
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
|
||||
@@ -491,3 +491,16 @@ def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
|
||||
assert ("extensions", "ext-b") not in {
|
||||
(c.kind, c.id) for c in rec.contributed_components
|
||||
}
|
||||
|
||||
|
||||
def test_install_result_changed_reports_uninstalled():
|
||||
# A `bundle update` that only DROPS components (new manifest reduces
|
||||
# provides) populates uninstalled with nothing installed/refreshed; that is
|
||||
# still a mutating change, so `changed` must be True — not a false no-op.
|
||||
from specify_cli.bundler.services.installer import InstallResult
|
||||
from specify_cli.bundler.models.manifest import ComponentRef
|
||||
|
||||
result = InstallResult(bundle_id="x")
|
||||
assert result.changed is False # empty == no change
|
||||
result.uninstalled.append(ComponentRef(kind="presets", id="p1"))
|
||||
assert result.changed is True
|
||||
|
||||
@@ -7,7 +7,9 @@ proving the real in-process primitive dispatch (T044) works without a network.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
@@ -171,3 +173,62 @@ def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path):
|
||||
)
|
||||
with pytest.raises(BundlerError, match="HTTPS"):
|
||||
_download_manifest(resolved, offline=True)
|
||||
|
||||
|
||||
def test_local_zip_uses_bounded_archive_open(tmp_path: Path):
|
||||
artifact = tmp_path / "too-many-entries.zip"
|
||||
with zipfile.ZipFile(artifact, "w") as archive:
|
||||
archive.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict()))
|
||||
for index in range(512):
|
||||
archive.writestr(f"assets/{index}.txt", "")
|
||||
|
||||
with pytest.raises(BundlerError, match="too many entries"):
|
||||
_local_manifest_source(str(artifact))
|
||||
|
||||
|
||||
def test_invalid_local_manifest_is_rejected_before_project_init(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
bundle_dir = tmp_path / "invalid-bundle"
|
||||
data = valid_manifest_dict()
|
||||
data["bundle"]["author"] = ""
|
||||
write_manifest(bundle_dir, data)
|
||||
empty_cwd = tmp_path / "empty"
|
||||
empty_cwd.mkdir()
|
||||
monkeypatch.chdir(empty_cwd)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("specify_cli.commands.bundle._run_init") as run_init:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["bundle", "install", str(bundle_dir), "--offline"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Missing required field: bundle.author" in result.output
|
||||
run_init.assert_not_called()
|
||||
|
||||
|
||||
def test_incompatible_local_manifest_is_rejected_before_project_init(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
):
|
||||
bundle_dir = tmp_path / "incompatible-bundle"
|
||||
data = valid_manifest_dict()
|
||||
data["requires"]["speckit_version"] = ">=999.0.0"
|
||||
write_manifest(bundle_dir, data)
|
||||
empty_cwd = tmp_path / "empty"
|
||||
empty_cwd.mkdir()
|
||||
monkeypatch.chdir(empty_cwd)
|
||||
|
||||
runner = CliRunner()
|
||||
with patch("specify_cli.commands.bundle._run_init") as run_init:
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["bundle", "install", str(bundle_dir), "--offline"],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "requires Spec Kit >=999.0.0" in result.output
|
||||
run_init.assert_not_called()
|
||||
|
||||
607
tests/integrations/test_integration_alquimia.py
Normal file
607
tests/integrations/test_integration_alquimia.py
Normal file
@@ -0,0 +1,607 @@
|
||||
"""Tests for AlquimiaAIIntegration."""
|
||||
|
||||
import json
|
||||
import os
|
||||
from unittest.mock import patch
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
|
||||
from specify_cli.integrations.base import IntegrationBase, SkillsIntegration
|
||||
from specify_cli.integrations.alquimia import ARGUMENT_HINTS
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
|
||||
class TestAlquimiaAIIntegration:
|
||||
def test_registered(self):
|
||||
assert "alquimia" in INTEGRATION_REGISTRY
|
||||
assert get_integration("alquimia") is not None
|
||||
|
||||
def test_is_base_integration(self):
|
||||
assert isinstance(get_integration("alquimia"), IntegrationBase)
|
||||
|
||||
def test_config_uses_skills(self):
|
||||
integration = get_integration("alquimia")
|
||||
assert integration.config["folder"] == ".alquimia/"
|
||||
assert integration.config["commands_subdir"] == "skills"
|
||||
|
||||
def test_registrar_config_uses_skill_layout(self):
|
||||
integration = get_integration("alquimia")
|
||||
assert integration.registrar_config["dir"] == ".alquimia/skills"
|
||||
assert integration.registrar_config["format"] == "markdown"
|
||||
assert integration.registrar_config["args"] == "$ARGUMENTS"
|
||||
assert integration.registrar_config["extension"] == "/SKILL.md"
|
||||
|
||||
def test_requires_cli_is_true(self):
|
||||
integration = get_integration("alquimia")
|
||||
assert integration.config["requires_cli"] is True
|
||||
assert integration.multi_install_safe is True
|
||||
|
||||
def test_build_exec_args_uses_headless_prompt_flag(self):
|
||||
"""Workflow dispatch relies on the inherited
|
||||
``SkillsIntegration.build_exec_args()`` — pin its argv shape so a
|
||||
future change to the base class or this integration's config is
|
||||
caught here rather than surfacing as a silent workflow failure."""
|
||||
integration = get_integration("alquimia")
|
||||
args = integration.build_exec_args(
|
||||
"hello", model="alquimia-default", output_json=True
|
||||
)
|
||||
assert args is not None
|
||||
assert args[0] == "alquimia" or args[0].endswith("/alquimia")
|
||||
assert "-p" in args
|
||||
assert "hello" in args
|
||||
assert "--model" in args
|
||||
assert "alquimia-default" in args
|
||||
assert "--output-format" in args
|
||||
assert "json" in args
|
||||
|
||||
def test_setup_creates_skill_files(self, tmp_path):
|
||||
integration = get_integration("alquimia")
|
||||
manifest = IntegrationManifest("alquimia", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest, script_type="sh")
|
||||
|
||||
skill_files = [path for path in created if path.name == "SKILL.md"]
|
||||
assert skill_files
|
||||
|
||||
skills_dir = tmp_path / ".alquimia" / "skills"
|
||||
assert skills_dir.is_dir()
|
||||
|
||||
plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
|
||||
assert plan_skill.exists()
|
||||
|
||||
content = plan_skill.read_text(encoding="utf-8")
|
||||
assert "{SCRIPT}" not in content
|
||||
assert "{ARGS}" not in content
|
||||
assert "__AGENT__" not in content
|
||||
assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__"
|
||||
assert "/speckit." not in content, (
|
||||
"skills agent must use /speckit-<name> not /speckit.<name>"
|
||||
)
|
||||
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed["name"] == "speckit-plan"
|
||||
assert parsed["user-invocable"] is True
|
||||
assert parsed["disable-model-invocation"] is False
|
||||
assert parsed["metadata"]["source"] == "templates/commands/plan.md"
|
||||
|
||||
def test_render_skill_unicode(self):
|
||||
"""Test rendering a skill preserves non-ASCII characters."""
|
||||
integration = get_integration("alquimia")
|
||||
rendered = integration._render_skill(
|
||||
"constitution",
|
||||
{"description": "Prüfe Konformität der Implementierung"},
|
||||
"Body",
|
||||
)
|
||||
assert "Prüfe Konformität" in rendered
|
||||
|
||||
def test_setup_does_not_write_context_section(self, tmp_path):
|
||||
"""The CLI no longer manages the agent context file — that is owned by
|
||||
the opt-in agent-context extension. Setup must not create or touch it."""
|
||||
integration = get_integration("alquimia")
|
||||
manifest = IntegrationManifest("alquimia", tmp_path)
|
||||
integration.setup(tmp_path, manifest, script_type="sh")
|
||||
|
||||
for path in tmp_path.rglob("*"):
|
||||
if path.is_file():
|
||||
text = path.read_text(encoding="utf-8", errors="ignore")
|
||||
assert "<!-- SPECKIT START -->" not in text
|
||||
|
||||
def test_teardown_does_not_touch_existing_context_file(self, tmp_path):
|
||||
"""A user-authored context file is left intact on teardown."""
|
||||
integration = get_integration("alquimia")
|
||||
ctx_path = tmp_path / "ALQUIMIA.md"
|
||||
original = "# ALQUIMIA.md\n\nUser content.\n"
|
||||
ctx_path.write_text(original, encoding="utf-8")
|
||||
|
||||
manifest = IntegrationManifest("alquimia", tmp_path)
|
||||
integration.setup(tmp_path, manifest, script_type="sh")
|
||||
integration.teardown(tmp_path, manifest)
|
||||
|
||||
assert ctx_path.read_text(encoding="utf-8") == original
|
||||
|
||||
def test_integration_flag_creates_skill_files_cli(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "alquimia-promote"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
"alquimia",
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (project / ".alquimia" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert not (project / ".alquimia" / "commands").exists()
|
||||
|
||||
init_options = json.loads(
|
||||
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert init_options["ai"] == "alquimia"
|
||||
assert init_options["ai_skills"] is True
|
||||
assert init_options["integration"] == "alquimia"
|
||||
|
||||
def test_integration_flag_creates_skill_files(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "alquimia-integration"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--integration",
|
||||
"alquimia",
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (
|
||||
project / ".alquimia" / "skills" / "speckit-specify" / "SKILL.md"
|
||||
).exists()
|
||||
assert (
|
||||
project / ".specify" / "integrations" / "alquimia.manifest.json"
|
||||
).exists()
|
||||
|
||||
def test_interactive_alquimia_selection_uses_integration_path(self, tmp_path):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "alquimia-interactive"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
with (
|
||||
patch(
|
||||
"specify_cli.commands.init._stdin_is_interactive", return_value=True
|
||||
),
|
||||
patch(
|
||||
"specify_cli.commands.init.select_with_arrows",
|
||||
return_value="alquimia",
|
||||
),
|
||||
):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
"--here",
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert (project / ".specify" / "integration.json").exists()
|
||||
assert (
|
||||
project / ".specify" / "integrations" / "alquimia.manifest.json"
|
||||
).exists()
|
||||
|
||||
skill_file = project / ".alquimia" / "skills" / "speckit-plan" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
skill_content = skill_file.read_text(encoding="utf-8")
|
||||
assert "user-invocable: true" in skill_content
|
||||
assert "disable-model-invocation: false" in skill_content
|
||||
|
||||
init_options = json.loads(
|
||||
(project / ".specify" / "init-options.json").read_text(encoding="utf-8")
|
||||
)
|
||||
assert init_options["ai"] == "alquimia"
|
||||
assert init_options["ai_skills"] is True
|
||||
assert init_options["integration"] == "alquimia"
|
||||
|
||||
def test_alquimia_init_remains_usable_when_converter_fails(self, tmp_path):
|
||||
"""Alquimia init should succeed even without install_skills."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "fail-proj"
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
str(target),
|
||||
"--integration",
|
||||
"alquimia",
|
||||
"--script",
|
||||
"sh",
|
||||
"--ignore-agent-tools",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert (
|
||||
target / ".alquimia" / "skills" / "speckit-specify" / "SKILL.md"
|
||||
).exists()
|
||||
|
||||
def test_alquimia_preset_creates_new_skill_without_commands_dir(self, tmp_path):
|
||||
from specify_cli import save_init_options
|
||||
from specify_cli.presets import PresetManager
|
||||
|
||||
project = tmp_path / "alquimia-preset-skill"
|
||||
project.mkdir()
|
||||
save_init_options(
|
||||
project, {"ai": "alquimia", "ai_skills": True, "script": "sh"}
|
||||
)
|
||||
|
||||
skills_dir = project / ".alquimia" / "skills"
|
||||
skills_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
preset_dir = tmp_path / "alquimia-skill-command"
|
||||
preset_dir.mkdir()
|
||||
(preset_dir / "commands").mkdir()
|
||||
(preset_dir / "commands" / "speckit.research.md").write_text(
|
||||
"---\n"
|
||||
"description: Research workflow\n"
|
||||
"---\n\n"
|
||||
"preset:alquimia-skill-command\n"
|
||||
)
|
||||
manifest_data = {
|
||||
"schema_version": "1.0",
|
||||
"preset": {
|
||||
"id": "alquimia-skill-command",
|
||||
"name": "Alquimia Skill Command",
|
||||
"version": "1.0.0",
|
||||
"description": "Test",
|
||||
},
|
||||
"requires": {"speckit_version": ">=0.1.0"},
|
||||
"provides": {
|
||||
"templates": [
|
||||
{
|
||||
"type": "command",
|
||||
"name": "speckit.research",
|
||||
"file": "commands/speckit.research.md",
|
||||
}
|
||||
]
|
||||
},
|
||||
}
|
||||
with open(preset_dir / "preset.yml", "w") as f:
|
||||
yaml.dump(manifest_data, f)
|
||||
|
||||
manager = PresetManager(project)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
|
||||
skill_file = skills_dir / "speckit-research" / "SKILL.md"
|
||||
assert skill_file.exists()
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "preset:alquimia-skill-command" in content
|
||||
assert "name: speckit-research" in content
|
||||
assert "user-invocable: true" in content
|
||||
assert "disable-model-invocation: false" in content
|
||||
|
||||
metadata = manager.registry.get("alquimia-skill-command")
|
||||
assert "speckit-research" in metadata.get("registered_skills", {}).get(
|
||||
"alquimia", []
|
||||
)
|
||||
|
||||
|
||||
class TestAlquimiaArgumentHints:
|
||||
"""Verify that argument-hint frontmatter is injected for Alquimia skills."""
|
||||
|
||||
def test_converge_has_no_argument_hint(self):
|
||||
"""Converge should not advertise unsupported feature-name arguments."""
|
||||
assert "converge" not in ARGUMENT_HINTS
|
||||
|
||||
def test_all_skills_have_hints(self, tmp_path):
|
||||
"""Every skill with a configured hint must contain an argument-hint line."""
|
||||
i = get_integration("alquimia")
|
||||
m = IntegrationManifest("alquimia", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
stem = f.parent.name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-") :]
|
||||
content = f.read_text(encoding="utf-8")
|
||||
if stem in ARGUMENT_HINTS:
|
||||
assert "argument-hint:" in content, (
|
||||
f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter"
|
||||
)
|
||||
else:
|
||||
assert "argument-hint:" not in content, (
|
||||
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
|
||||
)
|
||||
|
||||
def test_hints_match_expected_values(self, tmp_path):
|
||||
"""Each skill's argument-hint must match the expected text."""
|
||||
i = get_integration("alquimia")
|
||||
m = IntegrationManifest("alquimia", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
# Extract stem: speckit-plan -> plan
|
||||
stem = f.parent.name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-") :]
|
||||
expected_hint = ARGUMENT_HINTS.get(stem)
|
||||
content = f.read_text(encoding="utf-8")
|
||||
if expected_hint is None:
|
||||
assert "argument-hint:" not in content, (
|
||||
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
|
||||
)
|
||||
else:
|
||||
assert f'argument-hint: "{expected_hint}"' in content, (
|
||||
f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found"
|
||||
)
|
||||
|
||||
def test_hint_is_inside_frontmatter(self, tmp_path):
|
||||
"""argument-hint must appear between the --- delimiters, not in the body."""
|
||||
i = get_integration("alquimia")
|
||||
m = IntegrationManifest("alquimia", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md"
|
||||
frontmatter = parts[1]
|
||||
body = parts[2]
|
||||
stem = f.parent.name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-") :]
|
||||
if stem in ARGUMENT_HINTS:
|
||||
assert "argument-hint:" in frontmatter, (
|
||||
f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section"
|
||||
)
|
||||
assert "argument-hint:" not in body, (
|
||||
f"{f.parent.name}/SKILL.md: argument-hint leaked into body"
|
||||
)
|
||||
else:
|
||||
assert "argument-hint:" not in content, (
|
||||
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
|
||||
)
|
||||
|
||||
def test_hint_appears_after_description(self, tmp_path):
|
||||
"""argument-hint must immediately follow the description line."""
|
||||
i = get_integration("alquimia")
|
||||
m = IntegrationManifest("alquimia", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
lines = content.splitlines()
|
||||
stem = f.parent.name
|
||||
if stem.startswith("speckit-"):
|
||||
stem = stem[len("speckit-") :]
|
||||
if stem not in ARGUMENT_HINTS:
|
||||
assert "argument-hint:" not in content, (
|
||||
f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
|
||||
)
|
||||
continue
|
||||
found_description = False
|
||||
for idx, line in enumerate(lines):
|
||||
if line.startswith("description:"):
|
||||
found_description = True
|
||||
assert idx + 1 < len(lines), (
|
||||
f"{f.parent.name}/SKILL.md: description is last line"
|
||||
)
|
||||
assert lines[idx + 1].startswith("argument-hint:"), (
|
||||
f"{f.parent.name}/SKILL.md: argument-hint does not follow description"
|
||||
)
|
||||
break
|
||||
assert found_description, (
|
||||
f"{f.parent.name}/SKILL.md: no description: line found in output"
|
||||
)
|
||||
|
||||
def test_inject_argument_hint_only_in_frontmatter(self):
|
||||
"""inject_argument_hint must not modify description: lines in the body."""
|
||||
from specify_cli.integrations.alquimia import AlquimiaAIIntegration
|
||||
|
||||
content = (
|
||||
"---\ndescription: My command\n---\n\ndescription: this is body text\n"
|
||||
)
|
||||
result = AlquimiaAIIntegration.inject_argument_hint(content, "Test hint")
|
||||
lines = result.splitlines()
|
||||
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
|
||||
assert hint_count == 1, (
|
||||
f"Expected exactly 1 argument-hint line, found {hint_count}"
|
||||
)
|
||||
|
||||
def test_inject_argument_hint_skips_if_already_present(self):
|
||||
"""inject_argument_hint must not duplicate if argument-hint already exists."""
|
||||
from specify_cli.integrations.alquimia import AlquimiaAIIntegration
|
||||
|
||||
content = (
|
||||
"---\n"
|
||||
"description: My command\n"
|
||||
'argument-hint: "Existing hint"\n'
|
||||
"---\n"
|
||||
"\n"
|
||||
"Body text\n"
|
||||
)
|
||||
result = AlquimiaAIIntegration.inject_argument_hint(content, "New hint")
|
||||
assert result == content, "Content should be unchanged when hint already exists"
|
||||
lines = result.splitlines()
|
||||
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
|
||||
assert hint_count == 1
|
||||
|
||||
|
||||
class TestAlquimiaDisableModelInvocation:
|
||||
"""Verify disable-model-invocation is false for Alquimia skills."""
|
||||
|
||||
def test_setup_sets_disable_model_invocation_false(self, tmp_path):
|
||||
"""Generated SKILL.md files must have disable-model-invocation: false."""
|
||||
i = get_integration("alquimia")
|
||||
m = IntegrationManifest("alquimia", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) > 0
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
parts = content.split("---", 2)
|
||||
parsed = yaml.safe_load(parts[1])
|
||||
assert parsed["disable-model-invocation"] is False, (
|
||||
f"{f.parent.name}: expected disable-model-invocation: false"
|
||||
)
|
||||
|
||||
def test_disable_model_invocation_not_true(self, tmp_path):
|
||||
"""No Alquimia skill should have disable-model-invocation: true."""
|
||||
i = get_integration("alquimia")
|
||||
m = IntegrationManifest("alquimia", tmp_path)
|
||||
created = i.setup(tmp_path, m, script_type="sh")
|
||||
for f in created:
|
||||
if f.name != "SKILL.md":
|
||||
continue
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "disable-model-invocation: true" not in content, (
|
||||
f"{f.parent.name}: must not have disable-model-invocation: true"
|
||||
)
|
||||
|
||||
def test_non_alquimia_agents_lack_disable_model_invocation(self, tmp_path):
|
||||
"""Non-Alquimia skill agents should not get disable-model-invocation."""
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
|
||||
fm = CommandRegistrar.build_skill_frontmatter(
|
||||
"codex", "speckit-plan", "desc", "templates/commands/plan.md"
|
||||
)
|
||||
assert "disable-model-invocation" not in fm
|
||||
assert "user-invocable" not in fm
|
||||
|
||||
def test_skills_default_post_process_preserves_content_without_hooks(
|
||||
self, tmp_path
|
||||
):
|
||||
"""SkillsIntegration agents without an override preserve non-hook content."""
|
||||
# ``agy`` is a plain SkillsIntegration with no post-process override,
|
||||
# so it stands in for the base-class default behavior.
|
||||
agy = get_integration("agy")
|
||||
if agy is None:
|
||||
return # agy not registered in this build
|
||||
content = "---\nname: test\n---\nBody"
|
||||
assert agy.post_process_skill_content(content) == content
|
||||
|
||||
|
||||
class TestAlquimiaHookCommandNote:
|
||||
"""Verify dot-to-hyphen normalization note is injected in hook sections."""
|
||||
|
||||
def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
|
||||
"""Skills that have hook sections should get the normalization note."""
|
||||
i = get_integration("alquimia")
|
||||
m = IntegrationManifest("alquimia", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
specify_skill = tmp_path / ".alquimia/skills/speckit-specify/SKILL.md"
|
||||
assert specify_skill.exists()
|
||||
content = specify_skill.read_text(encoding="utf-8")
|
||||
# specify.md has hook sections
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should have dot-to-hyphen hook note"
|
||||
)
|
||||
|
||||
def test_hook_note_not_in_skills_without_hooks(self, tmp_path):
|
||||
"""Skills without hook sections should not get the note."""
|
||||
content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
assert "replace dots" not in result
|
||||
|
||||
def test_hook_note_idempotent(self, tmp_path):
|
||||
"""Injecting the note twice should not duplicate it."""
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
once = SkillsIntegration._inject_hook_command_note(content)
|
||||
twice = SkillsIntegration._inject_hook_command_note(once)
|
||||
assert once == twice, "Hook note injection should be idempotent"
|
||||
|
||||
def test_hook_note_fills_missing_repeated_instructions(self, tmp_path):
|
||||
"""Already-noted hook sections should not suppress later sections."""
|
||||
from specify_cli.integrations.base import _HOOK_COMMAND_NOTE
|
||||
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
f"{_HOOK_COMMAND_NOTE}"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
"\n"
|
||||
" - For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
assert result.count("replace dots (`.`) with hyphens") == 2
|
||||
|
||||
def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path):
|
||||
"""Unrelated text should not trip the hook-note idempotence guard."""
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
"This paragraph says replace dots in a different context.\n"
|
||||
"- For each executable hook, output the following based on its flag:\n"
|
||||
)
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
assert "This paragraph says replace dots in a different context." in result
|
||||
assert result.count("replace dots (`.`) with hyphens") == 1
|
||||
|
||||
def test_hook_note_preserves_indentation(self, tmp_path):
|
||||
"""The injected note should match the indentation of the target line."""
|
||||
content = (
|
||||
"---\nname: test\n---\n\n"
|
||||
" - For each executable hook, output the following\n"
|
||||
)
|
||||
result = SkillsIntegration._inject_hook_command_note(content)
|
||||
lines = result.splitlines()
|
||||
note_line = [line for line in lines if "replace dots" in line][0]
|
||||
assert note_line.startswith(" "), "Note should preserve indentation"
|
||||
|
||||
def test_post_process_injects_all_alquimia_flags(self):
|
||||
"""post_process_skill_content should inject all Alquimia-specific fields."""
|
||||
i = get_integration("alquimia")
|
||||
content = (
|
||||
"---\nname: test\ndescription: test\n---\n\n"
|
||||
"- For each executable hook, output the following\n"
|
||||
)
|
||||
result = i.post_process_skill_content(content)
|
||||
assert "user-invocable: true" in result
|
||||
assert "disable-model-invocation: false" in result
|
||||
assert "replace dots" in result
|
||||
@@ -633,6 +633,40 @@ class TestIntegrationListCatalog:
|
||||
assert "copilot" in result.output
|
||||
assert "installed" in result.output
|
||||
|
||||
def test_catalog_list_escapes_rich_markup(self, tmp_path, monkeypatch):
|
||||
"""User-editable catalog name/url/description must not be parsed as Rich markup."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
from specify_cli.integrations.catalog import IntegrationCatalog
|
||||
runner = CliRunner()
|
||||
project = self._init_project(tmp_path)
|
||||
|
||||
configs = [
|
||||
{
|
||||
"name": "Bracket [Catalog]",
|
||||
"url": "https://example.com/[cat].json",
|
||||
"description": "desc [with] brackets",
|
||||
"install_allowed": True,
|
||||
},
|
||||
]
|
||||
monkeypatch.setattr(
|
||||
IntegrationCatalog,
|
||||
"get_project_catalog_configs",
|
||||
lambda self: [dict(c) for c in configs],
|
||||
)
|
||||
|
||||
old = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = runner.invoke(app, ["integration", "catalog", "list"])
|
||||
finally:
|
||||
os.chdir(old)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Bracket [Catalog]" in result.output
|
||||
assert "https://example.com/[cat].json" in result.output
|
||||
assert "desc [with] brackets" in result.output
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI: integration upgrade
|
||||
|
||||
@@ -303,7 +303,7 @@ class TestClaudeIntegration:
|
||||
assert "disable-model-invocation: false" in content
|
||||
|
||||
metadata = manager.registry.get("claude-skill-command")
|
||||
assert "speckit-research" in metadata.get("registered_skills", [])
|
||||
assert "speckit-research" in metadata.get("registered_skills", {}).get("claude", [])
|
||||
|
||||
|
||||
class TestClaudeArgumentHints:
|
||||
|
||||
@@ -188,6 +188,43 @@ class TestCopilotIntegration:
|
||||
assert "Copy `.specify/templates/spec-template.md`" not in content
|
||||
assert "Load `.specify/templates/spec-template.md`" not in content
|
||||
|
||||
def test_setup_falls_back_to_bundled_command_template_without_preset_override(self, tmp_path):
|
||||
"""Copilot should keep using the bundled specify command template when no preset override exists."""
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
copilot.setup(tmp_path, m)
|
||||
|
||||
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
|
||||
content = specify_file.read_text(encoding="utf-8")
|
||||
assert "Create or update the feature specification" in content
|
||||
assert "preset override content" not in content
|
||||
|
||||
def test_setup_uses_preset_command_override_when_present(self, tmp_path):
|
||||
"""Copilot should prefer a preset-provided command template over the bundled one."""
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
preset_dir = tmp_path / ".specify" / "presets" / "demo"
|
||||
(preset_dir / "commands").mkdir(parents=True, exist_ok=True)
|
||||
(preset_dir / "commands" / "speckit.specify.md").write_text(
|
||||
"preset override content\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
(tmp_path / ".specify" / "presets" / ".registry").write_text(
|
||||
'{"schema_version": "1.0", "presets": {"demo": {"version": "1.0.0", "source": "local", "enabled": true, "priority": 10}}}',
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
copilot.setup(tmp_path, m)
|
||||
|
||||
specify_file = tmp_path / ".github" / "agents" / "speckit.specify.agent.md"
|
||||
content = specify_file.read_text(encoding="utf-8")
|
||||
assert "preset override content" in content
|
||||
assert "Create or update the feature specification" not in content
|
||||
|
||||
def test_plan_command_has_no_context_placeholder(self, tmp_path):
|
||||
"""The core plan command must not carry a context-file placeholder —
|
||||
agent context files are owned by the opt-in agent-context extension."""
|
||||
|
||||
@@ -422,7 +422,12 @@ class TestForgeCommandRegistrar:
|
||||
|
||||
# Kilo Code uses standard markdown format without name injection.
|
||||
# The format_name callback should not be invoked for non-Forge agents.
|
||||
kilocode_cmd = tmp_path / ".kilocode" / "workflows" / "speckit.my-extension.example.md"
|
||||
kilocode_cmd = (
|
||||
tmp_path
|
||||
/ ".kilo"
|
||||
/ "commands"
|
||||
/ "speckit.my-extension.example.md"
|
||||
)
|
||||
assert kilocode_cmd.exists()
|
||||
|
||||
content = kilocode_cmd.read_text(encoding="utf-8")
|
||||
|
||||
@@ -1,10 +1,76 @@
|
||||
"""Tests for KilocodeIntegration."""
|
||||
|
||||
from specify_cli.agents import CommandRegistrar
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
class TestKilocodeIntegration(MarkdownIntegrationTests):
|
||||
KEY = "kilocode"
|
||||
FOLDER = ".kilocode/"
|
||||
COMMANDS_SUBDIR = "workflows"
|
||||
REGISTRAR_DIR = ".kilocode/workflows"
|
||||
FOLDER = ".kilo/"
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".kilo/commands"
|
||||
|
||||
def test_registrar_config_has_legacy_dir(self):
|
||||
integration = get_integration(self.KEY)
|
||||
assert integration.registrar_config["legacy_dir"] == ".kilocode/workflows"
|
||||
|
||||
def test_legacy_dir_extension_registration(self, tmp_path):
|
||||
"""Extension commands still register into legacy Kilo projects."""
|
||||
legacy_dir = tmp_path / ".kilocode" / "workflows"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
(legacy_dir / "speckit.specify.md").write_text(
|
||||
"# existing", encoding="utf-8"
|
||||
)
|
||||
|
||||
src_dir = tmp_path / "_ext_src"
|
||||
src_dir.mkdir()
|
||||
(src_dir / "myext.md").write_text(
|
||||
"---\ndescription: test\n---\n# ext command",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
commands = [{"name": "speckit.myext", "file": "myext.md"}]
|
||||
results = registrar.register_commands(
|
||||
self.KEY,
|
||||
commands,
|
||||
"test-ext",
|
||||
src_dir,
|
||||
tmp_path,
|
||||
)
|
||||
|
||||
assert results == ["speckit.myext"]
|
||||
assert (legacy_dir / "speckit.myext.md").exists()
|
||||
assert not (tmp_path / ".kilo" / "commands").exists()
|
||||
|
||||
def test_legacy_dir_extension_unregister(self, tmp_path):
|
||||
"""Unregister removes commands from legacy Kilo projects."""
|
||||
legacy_dir = tmp_path / ".kilocode" / "workflows"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
cmd_file = legacy_dir / "speckit.myext.md"
|
||||
cmd_file.write_text("# ext command", encoding="utf-8")
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registrar.unregister_commands({"kilocode": ["speckit.myext"]}, tmp_path)
|
||||
|
||||
assert not cmd_file.exists()
|
||||
|
||||
def test_unregister_cleans_legacy_when_both_dirs_exist(self, tmp_path):
|
||||
"""Unregister removes stale legacy files after Kilo path migration."""
|
||||
canonical_dir = tmp_path / ".kilo" / "commands"
|
||||
canonical_dir.mkdir(parents=True)
|
||||
legacy_dir = tmp_path / ".kilocode" / "workflows"
|
||||
legacy_dir.mkdir(parents=True)
|
||||
|
||||
canonical_cmd = canonical_dir / "speckit.myext.md"
|
||||
canonical_cmd.write_text("# ext command", encoding="utf-8")
|
||||
legacy_cmd = legacy_dir / "speckit.myext.md"
|
||||
legacy_cmd.write_text("# stale ext command", encoding="utf-8")
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registrar.unregister_commands({"kilocode": ["speckit.myext"]}, tmp_path)
|
||||
|
||||
assert not canonical_cmd.exists()
|
||||
assert not legacy_cmd.exists()
|
||||
|
||||
@@ -11,6 +11,12 @@ class TestOmpIntegration(MarkdownIntegrationTests):
|
||||
COMMANDS_SUBDIR = "commands"
|
||||
REGISTRAR_DIR = ".omp/commands"
|
||||
|
||||
def test_multi_install_safe(self):
|
||||
# Omp writes only to its isolated, static root .omp/commands, disjoint
|
||||
# from every other integration, so it must be co-install safe (mirrors
|
||||
# qwen/shai/qodercli and the kiro-cli #3471 precedent).
|
||||
assert get_integration(self.KEY).multi_install_safe is True
|
||||
|
||||
def test_build_exec_args_uses_omp_json_mode(self):
|
||||
i = get_integration(self.KEY)
|
||||
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
"""Tests for PiIntegration."""
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
from .test_integration_base_markdown import MarkdownIntegrationTests
|
||||
|
||||
|
||||
@@ -8,3 +10,9 @@ class TestPiIntegration(MarkdownIntegrationTests):
|
||||
FOLDER = ".pi/"
|
||||
COMMANDS_SUBDIR = "prompts"
|
||||
REGISTRAR_DIR = ".pi/prompts"
|
||||
|
||||
def test_multi_install_safe(self):
|
||||
# Pi writes only to its isolated, static root .pi/prompts, disjoint from
|
||||
# every other integration, so it must be co-install safe (mirrors
|
||||
# qwen/shai/qodercli and the kiro-cli #3471 precedent).
|
||||
assert get_integration(self.KEY).multi_install_safe is True
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -11,6 +11,7 @@ from specify_cli.extensions import CommandRegistrar
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
|
||||
ISSUE_TEMPLATE_AGENT_KEYS = [
|
||||
"alquimia",
|
||||
"amp",
|
||||
"agy",
|
||||
"auggie",
|
||||
|
||||
@@ -92,6 +92,27 @@ class TestLoadAuthConfig:
|
||||
assert entries[0].auth == "bearer"
|
||||
assert entries[0].token_env == "GH_TOKEN"
|
||||
|
||||
def test_padded_token_env_is_normalized_and_resolves(self, tmp_path, monkeypatch):
|
||||
# token_env is validated on its stripped form but was stored raw, so a
|
||||
# padded env-var name passed validation yet broke the verbatim
|
||||
# os.environ.get() lookup — resolve_token silently returned None.
|
||||
monkeypatch.setenv("GH_TOKEN", "secret-tok")
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["github.com"],
|
||||
"provider": "github",
|
||||
"auth": "bearer",
|
||||
"token_env": " GH_TOKEN ",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert len(entries) == 1
|
||||
# Stored normalized (matching how hosts are normalized), ...
|
||||
assert entries[0].token_env == "GH_TOKEN"
|
||||
# ... so the env lookup finds the token instead of returning None.
|
||||
assert GitHubAuth().resolve_token(entries[0]) == "secret-tok"
|
||||
|
||||
def test_valid_ado_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
@@ -136,6 +157,26 @@ class TestLoadAuthConfig:
|
||||
assert entries[0].auth == "azure-ad"
|
||||
assert entries[0].tenant_id == "tid"
|
||||
|
||||
def test_padded_azure_ad_refs_are_normalized(self, tmp_path):
|
||||
# The normalization also covers tenant_id / client_id / client_secret_env
|
||||
# (used verbatim in the OAuth token URL/body and os.environ.get). Padded
|
||||
# values were validated on their stripped form but stored raw.
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
"providers": [{
|
||||
"hosts": ["dev.azure.com"],
|
||||
"provider": "azure-devops",
|
||||
"auth": "azure-ad",
|
||||
"tenant_id": " tid ",
|
||||
"client_id": " cid ",
|
||||
"client_secret_env": " SECRET ",
|
||||
}]
|
||||
}))
|
||||
entries = load_auth_config(cfg)
|
||||
assert entries[0].tenant_id == "tid"
|
||||
assert entries[0].client_id == "cid"
|
||||
assert entries[0].client_secret_env == "SECRET"
|
||||
|
||||
def test_azure_cli_config(self, tmp_path):
|
||||
cfg = tmp_path / "auth.json"
|
||||
cfg.write_text(json.dumps({
|
||||
|
||||
@@ -53,6 +53,60 @@ def repo_pair(tmp_path: Path) -> tuple[Path, Path]:
|
||||
return _setup_repo(tmp_path, "proj-a"), _setup_repo(tmp_path, "proj-b")
|
||||
|
||||
|
||||
def _run_all_variants_allow_existing(
|
||||
repo: Path, *, number: str, short_name: str
|
||||
):
|
||||
"""Run each create-new-feature variant with the allow-existing options."""
|
||||
common_args = (
|
||||
"--json",
|
||||
"--dry-run",
|
||||
"--number",
|
||||
number,
|
||||
"--allow-existing-branch",
|
||||
"--short-name",
|
||||
short_name,
|
||||
"x",
|
||||
)
|
||||
bash = run(bash_cmd(repo, SCRIPT, *common_args), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, *common_args), repo)
|
||||
ps = run(
|
||||
ps_cmd(
|
||||
repo,
|
||||
SCRIPT,
|
||||
"-Json",
|
||||
"-DryRun",
|
||||
"-Number",
|
||||
number,
|
||||
"-AllowExistingBranch",
|
||||
"-ShortName",
|
||||
short_name,
|
||||
"x",
|
||||
),
|
||||
repo,
|
||||
)
|
||||
return bash, ps, py
|
||||
|
||||
|
||||
def test_python_prefix_scan_tolerates_permission_error(
|
||||
tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""Python matches shell variants when a spec directory cannot be listed."""
|
||||
specs_dir = tmp_path / "specs"
|
||||
specs_dir.mkdir()
|
||||
|
||||
def deny_listing(_path: Path):
|
||||
raise PermissionError("denied")
|
||||
|
||||
monkeypatch.setattr(Path, "iterdir", deny_listing)
|
||||
|
||||
assert not create_new_feature._has_spec_prefix_conflict(
|
||||
specs_dir,
|
||||
"001",
|
||||
specs_dir / "001-x",
|
||||
allow_existing=False,
|
||||
)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.parametrize(
|
||||
"description",
|
||||
@@ -355,7 +409,7 @@ def test_python_missing_template_warning_matches_bash(
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_python_existing_directory_error_matches_bash(
|
||||
def test_python_existing_prefix_auto_correct_matches_bash(
|
||||
repo_pair: tuple[Path, Path],
|
||||
) -> None:
|
||||
repo_a, repo_b = repo_pair
|
||||
@@ -377,8 +431,8 @@ def test_python_existing_directory_error_matches_bash(
|
||||
bash = run(bash_cmd(repo_a, SCRIPT, "--json", "--number", "1", description), repo_a)
|
||||
py = run(py_cmd(repo_b, SCRIPT, "--json", "--number", "1", description), repo_b)
|
||||
|
||||
assert py.returncode == bash.returncode == 1
|
||||
assert py.stdout == bash.stdout == ""
|
||||
assert py.returncode == bash.returncode == 0
|
||||
assert json_stdout(py)["FEATURE_NUM"] == json_stdout(bash)["FEATURE_NUM"] == "002"
|
||||
assert normalize_repo_paths(py.stderr, repo_b) == normalize_repo_paths(
|
||||
bash.stderr, repo_a
|
||||
)
|
||||
@@ -531,6 +585,11 @@ def test_all_variants_reject_signed_number(repo: Path, number: str) -> None:
|
||||
def test_all_variants_treat_empty_number_as_omitted(
|
||||
repo: Path, timestamp: bool
|
||||
) -> None:
|
||||
if not timestamp:
|
||||
specs_dir = repo / "specs"
|
||||
(specs_dir / "20260318-sequential").mkdir(parents=True)
|
||||
(specs_dir / "20260319-143022-timestamp").mkdir()
|
||||
|
||||
bash_args = ["--json", "--dry-run", "--number", ""]
|
||||
ps_args = ["-Json", "-DryRun", "-Number", ""]
|
||||
py_args = ["--json", "--dry-run", "--number", ""]
|
||||
@@ -819,19 +878,92 @@ def test_all_variants_allow_existing_branch(repo: Path) -> None:
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_existing_directory_failure_diagnostics(repo: Path) -> None:
|
||||
(repo / "specs" / "001-x").mkdir(parents=True)
|
||||
expected = (
|
||||
"Error: Feature directory '<REPO>/specs/001-x' already exists. "
|
||||
"Please use a different feature name or specify a different number "
|
||||
"with --number."
|
||||
def test_all_variants_allow_existing_prefers_exact_dir_over_sibling(
|
||||
repo: Path,
|
||||
) -> None:
|
||||
"""Allow-existing preserves exact reuse even when a sibling shares its prefix."""
|
||||
(repo / "specs" / "004-pre-exist").mkdir(parents=True)
|
||||
(repo / "specs" / "004-other").mkdir()
|
||||
|
||||
bash, ps, py = _run_all_variants_allow_existing(
|
||||
repo, number="4", short_name="pre-exist"
|
||||
)
|
||||
|
||||
bash = run(bash_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
|
||||
ps = run(ps_cmd(repo, SCRIPT, "-Json", "-Number", "1", "x"), repo)
|
||||
py = run(py_cmd(repo, SCRIPT, "--json", "--number", "1", "x"), repo)
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
assert json_stdout(py)["BRANCH_NAME"] == "004-pre-exist"
|
||||
for result in (bash, ps, py):
|
||||
assert "conflicts with an existing spec directory" not in result.stderr
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 1
|
||||
assert bash.stdout == ps.stdout == py.stdout == ""
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_allow_existing_reuses_truncated_exact_dir(repo: Path) -> None:
|
||||
"""Allow-existing compares the canonical truncated feature directory name."""
|
||||
short_name = "a" * 300
|
||||
expected_branch = f"001-{'a' * 240}"
|
||||
(repo / "specs" / expected_branch).mkdir(parents=True)
|
||||
|
||||
bash, ps, py = _run_all_variants_allow_existing(
|
||||
repo, number="1", short_name=short_name
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
assert json_stdout(py)["BRANCH_NAME"] == expected_branch
|
||||
for result in (bash, ps, py):
|
||||
assert "conflicts with an existing spec directory" not in result.stderr
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_existing_prefix_auto_correct_diagnostics(repo: Path) -> None:
|
||||
(repo / "specs" / "001-x").mkdir(parents=True)
|
||||
expected = "conflicts with an existing spec directory; using 002 instead"
|
||||
|
||||
bash = run(
|
||||
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
|
||||
repo,
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", "1", "x"),
|
||||
repo,
|
||||
)
|
||||
py = run(
|
||||
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
|
||||
repo,
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
for result in (bash, ps, py):
|
||||
assert expected in _normalized_error_text(result.stderr, repo)
|
||||
|
||||
|
||||
@requires_bash
|
||||
@pytest.mark.skipif(not HAS_POWERSHELL, reason="no PowerShell available")
|
||||
def test_all_variants_corrected_prefix_skips_timestamp_collision(repo: Path) -> None:
|
||||
"""Auto-correction skips candidates owned by timestamp directories."""
|
||||
specs_dir = repo / "specs"
|
||||
(specs_dir / "001-existing").mkdir(parents=True)
|
||||
(specs_dir / "20260318-sequential").mkdir()
|
||||
(specs_dir / "20260319-143022-timestamp").mkdir()
|
||||
|
||||
bash = run(
|
||||
bash_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
|
||||
repo,
|
||||
)
|
||||
ps = run(
|
||||
ps_cmd(repo, SCRIPT, "-Json", "-DryRun", "-Number", "1", "x"),
|
||||
repo,
|
||||
)
|
||||
py = run(
|
||||
py_cmd(repo, SCRIPT, "--json", "--dry-run", "--number", "1", "x"),
|
||||
repo,
|
||||
)
|
||||
|
||||
assert bash.returncode == ps.returncode == py.returncode == 0
|
||||
assert json_stdout(bash) == json_stdout(ps) == json_stdout(py)
|
||||
assert json_stdout(py)["FEATURE_NUM"] == "20260320"
|
||||
for result in (bash, ps, py):
|
||||
assert "using 20260320 instead" in result.stderr
|
||||
|
||||
@@ -1,15 +1,24 @@
|
||||
"""Tests for bounded HTTP download helpers."""
|
||||
"""Tests for bounded download and ZIP extraction helpers."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import stat
|
||||
import struct
|
||||
import weakref
|
||||
import zipfile
|
||||
import zlib
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli._download_security import (
|
||||
MAX_ZIP_CENTRAL_DIRECTORY_BYTES,
|
||||
build_safe_download_path,
|
||||
is_https_or_localhost_http,
|
||||
is_loopback_url,
|
||||
read_response_limited,
|
||||
read_zip_member_limited,
|
||||
safe_extract_zip,
|
||||
)
|
||||
|
||||
|
||||
@@ -112,8 +121,6 @@ class _Response:
|
||||
def __init__(self, data: bytes, *, chunk: int | None = None):
|
||||
self.data = data
|
||||
self.pos = 0
|
||||
# When set, never return more than *chunk* bytes per call even if more is
|
||||
# requested - simulates short reads (e.g. chunked transfer encoding).
|
||||
self.chunk = chunk
|
||||
|
||||
def read(self, size: int = -1) -> bytes:
|
||||
@@ -160,6 +167,55 @@ class _OneByteResponse:
|
||||
)
|
||||
return chunk
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _tb):
|
||||
return False
|
||||
|
||||
|
||||
class _CustomZipError(ValueError):
|
||||
pass
|
||||
|
||||
|
||||
class _ExplodingResponse:
|
||||
def read(self, _size: int = -1) -> bytes:
|
||||
raise zlib.error("corrupt compressed data")
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _tb):
|
||||
return False
|
||||
|
||||
|
||||
class _FakeZipArchive:
|
||||
def __init__(
|
||||
self,
|
||||
response,
|
||||
*,
|
||||
filename: str = "extension.yml",
|
||||
file_size: int = 0,
|
||||
):
|
||||
self.response = response
|
||||
self.info = zipfile.ZipInfo(filename)
|
||||
self.info.file_size = file_size
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, _exc_type, _exc, _tb):
|
||||
return False
|
||||
|
||||
def getinfo(self, _name):
|
||||
return self.info
|
||||
|
||||
def infolist(self):
|
||||
return [self.info]
|
||||
|
||||
def open(self, _member, _mode="r"):
|
||||
return self.response
|
||||
|
||||
|
||||
def test_read_response_limited_rejects_oversized_download():
|
||||
with pytest.raises(ValueError, match="exceeds maximum size"):
|
||||
@@ -171,9 +227,6 @@ def test_read_response_limited_returns_full_body_within_limit():
|
||||
|
||||
|
||||
def test_read_response_limited_enforces_bound_under_short_reads():
|
||||
# A server that streams more than max_bytes total while every read() returns
|
||||
# fewer bytes than requested (chunked encoding) must still be rejected - a
|
||||
# single read(max_bytes + 1) could be fooled, the accumulating loop cannot.
|
||||
response = _Response(b"x" * 100, chunk=8)
|
||||
with pytest.raises(ValueError, match="exceeds maximum size"):
|
||||
read_response_limited(response, max_bytes=16)
|
||||
@@ -225,3 +278,794 @@ def test_read_response_limited_rejects_first_byte_at_zero_limit():
|
||||
max_bytes=0,
|
||||
error_type=_CustomLimitError,
|
||||
)
|
||||
|
||||
|
||||
def test_read_response_limited_escapes_control_characters_in_label():
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
read_response_limited(
|
||||
_Response(b"x"),
|
||||
max_bytes=0,
|
||||
label="bad\x1b[2J download",
|
||||
)
|
||||
|
||||
assert "\x1b" not in str(exc_info.value)
|
||||
assert "\\x1b" in str(exc_info.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"identifier",
|
||||
[
|
||||
"../outside",
|
||||
"..\\outside",
|
||||
"a" * 256,
|
||||
"delete\x7f",
|
||||
"csi\x9b[2J",
|
||||
"\ud800",
|
||||
],
|
||||
)
|
||||
def test_build_safe_download_path_rejects_nonportable_identifiers(
|
||||
tmp_path, identifier
|
||||
):
|
||||
with pytest.raises(ValueError, match="Unsafe archive download filename"):
|
||||
build_safe_download_path(
|
||||
tmp_path,
|
||||
identifier,
|
||||
"1.0.0",
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_name",
|
||||
[
|
||||
"../evil.txt",
|
||||
"nested/../../evil.txt",
|
||||
"nested\\..\\evil.txt",
|
||||
"C:\\Windows\\evil.txt",
|
||||
"C:drive-relative.txt",
|
||||
],
|
||||
)
|
||||
def test_safe_extract_zip_rejects_traversal(tmp_path, member_name):
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr(member_name, "nope")
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe path"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("member_name", [".", "./file.txt", "nested/./file.txt", "nested//file.txt"])
|
||||
def test_safe_extract_zip_rejects_dot_path_segments(tmp_path, member_name):
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr(member_name, "nope")
|
||||
|
||||
with pytest.raises(_CustomZipError, match="Unsafe path"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_symlinks(tmp_path):
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
info = zipfile.ZipInfo("link")
|
||||
info.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr(info, "target")
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe symlink"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_symlink_without_partial_extraction(tmp_path):
|
||||
zip_path = tmp_path / "mixed.zip"
|
||||
link = zipfile.ZipInfo("evil-link")
|
||||
link.external_attr = (stat.S_IFLNK | 0o777) << 16
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("safe/first.txt", "hello")
|
||||
zf.writestr(link, "target")
|
||||
zf.writestr("safe/second.txt", "world")
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
with pytest.raises(ValueError, match="Unsafe symlink"):
|
||||
safe_extract_zip(zip_path, out_dir)
|
||||
|
||||
assert not out_dir.exists() or not any(out_dir.rglob("*"))
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_oversized_member(tmp_path):
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("big.txt", "abcde")
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds maximum size"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out", max_member_bytes=4)
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_too_many_entries(tmp_path):
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("one.txt", "1")
|
||||
zf.writestr("two.txt", "2")
|
||||
|
||||
with pytest.raises(ValueError, match="too many entries"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out", max_entries=1)
|
||||
|
||||
|
||||
def _legacy_zip_eocd(
|
||||
*,
|
||||
entries: int,
|
||||
central_directory_size: int,
|
||||
central_directory_offset: int = 0,
|
||||
comment_size: int = 0,
|
||||
) -> bytes:
|
||||
return struct.pack(
|
||||
"<4s4H2LH",
|
||||
b"PK\x05\x06",
|
||||
0,
|
||||
0,
|
||||
entries,
|
||||
entries,
|
||||
central_directory_size,
|
||||
central_directory_offset,
|
||||
comment_size,
|
||||
)
|
||||
|
||||
|
||||
def test_safe_extract_zip_preflights_declared_entry_count(tmp_path, monkeypatch):
|
||||
zip_path = tmp_path / "too-many.zip"
|
||||
zip_path.write_bytes(
|
||||
_legacy_zip_eocd(entries=513, central_directory_size=0)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="too many entries"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_zip_preflights_actual_entry_count_when_eocd_lies(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
central_header = b"PK\x01\x02" + b"\x00" * 42
|
||||
central_directory = central_header * 513
|
||||
zip_path = tmp_path / "lying-count.zip"
|
||||
zip_path.write_bytes(
|
||||
central_directory
|
||||
+ _legacy_zip_eocd(
|
||||
entries=1,
|
||||
central_directory_size=len(central_directory),
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="too many entries"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_truncated_last_eocd_comment(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
trailing_eocd = _legacy_zip_eocd(
|
||||
entries=0,
|
||||
central_directory_size=0,
|
||||
comment_size=1,
|
||||
)
|
||||
zip_path = tmp_path / "ambiguous-eocd.zip"
|
||||
zip_path.write_bytes(
|
||||
_legacy_zip_eocd(
|
||||
entries=0,
|
||||
central_directory_size=0,
|
||||
comment_size=len(trailing_eocd),
|
||||
)
|
||||
+ trailing_eocd
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Invalid ZIP archive"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_zip64_before_zipfile_construction(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
zip64_eocd = struct.pack(
|
||||
"<4sQ2H2L4Q",
|
||||
b"PK\x06\x06",
|
||||
44,
|
||||
45,
|
||||
45,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
0,
|
||||
)
|
||||
zip64_locator = struct.pack(
|
||||
"<4sLQL",
|
||||
b"PK\x06\x07",
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
)
|
||||
zip_path = tmp_path / "zip64.zip"
|
||||
zip_path.write_bytes(
|
||||
zip64_eocd
|
||||
+ zip64_locator
|
||||
+ _legacy_zip_eocd(
|
||||
entries=0xFFFF,
|
||||
central_directory_size=0xFFFFFFFF,
|
||||
central_directory_offset=0xFFFFFFFF,
|
||||
)
|
||||
)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
assert zf.namelist() == []
|
||||
monkeypatch.setattr(
|
||||
zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="ZIP64"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"indicator",
|
||||
[
|
||||
"central-sizes",
|
||||
"central-offset",
|
||||
"central-disk",
|
||||
"local-sizes",
|
||||
],
|
||||
)
|
||||
def test_safe_extract_zip_rejects_entry_zip64_before_zipfile_construction(
|
||||
tmp_path, monkeypatch, indicator
|
||||
):
|
||||
contents = b"contents"
|
||||
if indicator == "central-offset":
|
||||
zip64_payload = struct.pack("<Q", 0)
|
||||
elif indicator == "central-disk":
|
||||
zip64_payload = struct.pack("<L", 0)
|
||||
else:
|
||||
zip64_payload = struct.pack("<QQ", len(contents), len(contents))
|
||||
|
||||
info = zipfile.ZipInfo("file.txt")
|
||||
info.extra = struct.pack("<HH", 0xCAFE, len(zip64_payload)) + zip64_payload
|
||||
zip_path = tmp_path / f"{indicator}.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr(info, contents)
|
||||
|
||||
archive = bytearray(zip_path.read_bytes())
|
||||
local_header = archive.index(b"PK\x03\x04")
|
||||
central_header = archive.index(b"PK\x01\x02")
|
||||
if indicator.startswith("central"):
|
||||
filename_size = struct.unpack_from("<H", archive, central_header + 28)[0]
|
||||
extra_offset = central_header + 46 + filename_size
|
||||
struct.pack_into("<H", archive, extra_offset, 0x0001)
|
||||
if indicator == "central-sizes":
|
||||
struct.pack_into("<LL", archive, central_header + 20, 0xFFFFFFFF, 0xFFFFFFFF)
|
||||
elif indicator == "central-offset":
|
||||
struct.pack_into("<L", archive, central_header + 42, 0xFFFFFFFF)
|
||||
else:
|
||||
struct.pack_into("<H", archive, central_header + 34, 0xFFFF)
|
||||
else:
|
||||
filename_size = struct.unpack_from("<H", archive, local_header + 26)[0]
|
||||
extra_offset = local_header + 30 + filename_size
|
||||
struct.pack_into("<H", archive, extra_offset, 0x0001)
|
||||
struct.pack_into("<LL", archive, local_header + 18, 0xFFFFFFFF, 0xFFFFFFFF)
|
||||
zip_path.write_bytes(archive)
|
||||
|
||||
# The stdlib accepts each hybrid ZIP64 entry. The bounded opener must reject
|
||||
# it during preflight, before handing the archive to ZipFile.
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
assert zf.read("file.txt") == contents
|
||||
monkeypatch.setattr(
|
||||
zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="ZIP64"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("header_kind", ["central", "local"])
|
||||
def test_safe_extract_zip_rejects_zip64_extra_without_sentinel_before_zipfile(
|
||||
tmp_path, monkeypatch, header_kind
|
||||
):
|
||||
info = zipfile.ZipInfo("file.txt")
|
||||
info.extra = struct.pack("<HH", 0xCAFE, 0)
|
||||
zip_path = tmp_path / f"{header_kind}-extra.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr(info, b"contents")
|
||||
|
||||
archive = bytearray(zip_path.read_bytes())
|
||||
if header_kind == "central":
|
||||
header_offset = archive.index(b"PK\x01\x02")
|
||||
filename_size = struct.unpack_from("<H", archive, header_offset + 28)[0]
|
||||
extra_offset = header_offset + 46 + filename_size
|
||||
else:
|
||||
header_offset = archive.index(b"PK\x03\x04")
|
||||
filename_size = struct.unpack_from("<H", archive, header_offset + 26)[0]
|
||||
extra_offset = header_offset + 30 + filename_size
|
||||
struct.pack_into("<H", archive, extra_offset, 0x0001)
|
||||
zip_path.write_bytes(archive)
|
||||
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
assert zf.read("file.txt") == b"contents"
|
||||
monkeypatch.setattr(
|
||||
zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="ZIP64"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_force_zip64_local_header_before_zipfile(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
zip_path = tmp_path / "forced-local-zip64.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
with zf.open("file.txt", "w", force_zip64=True) as target:
|
||||
target.write(b"contents")
|
||||
|
||||
# For a small streamed member, ZipFile leaves the central directory and
|
||||
# EOCD legacy-sized while placing ZIP64 sentinels and extra data locally.
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
assert zf.read("file.txt") == b"contents"
|
||||
monkeypatch.setattr(
|
||||
zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="ZIP64"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("extract_version", [45, 46])
|
||||
@pytest.mark.parametrize("visible_version", ["central", "local"])
|
||||
def test_safe_extract_zip_rejects_masked_zip64_data_descriptor_version_or_newer(
|
||||
tmp_path, monkeypatch, visible_version, extract_version
|
||||
):
|
||||
class UnseekableBuffer(io.BytesIO):
|
||||
def seek(self, *_args, **_kwargs):
|
||||
raise io.UnsupportedOperation
|
||||
|
||||
stream = UnseekableBuffer()
|
||||
with zipfile.ZipFile(stream, "w") as zf:
|
||||
with zf.open("file.txt", "w", force_zip64=True) as target:
|
||||
target.write(b"contents")
|
||||
|
||||
archive = bytearray(stream.getvalue())
|
||||
local_header = archive.index(b"PK\x03\x04")
|
||||
central_header = archive.index(b"PK\x01\x02")
|
||||
assert struct.unpack_from("<H", archive, local_header + 6)[0] & 0x0008
|
||||
assert struct.unpack_from("<H", archive, local_header + 4)[0] == 45
|
||||
assert struct.unpack_from("<H", archive, central_header + 6)[0] == 45
|
||||
|
||||
# Hide the local size sentinels and ZIP64 extra ID while retaining the
|
||||
# 64-bit data descriptor emitted by ZipFile. Leave a ZIP64-or-newer
|
||||
# extractor version visible in exactly one header to exercise both
|
||||
# preflight checks.
|
||||
struct.pack_into("<LL", archive, local_header + 18, 0, 0)
|
||||
filename_size = struct.unpack_from("<H", archive, local_header + 26)[0]
|
||||
local_extra = local_header + 30 + filename_size
|
||||
struct.pack_into("<H", archive, local_extra, 0xCAFE)
|
||||
struct.pack_into("<H", archive, local_header + 4, 20)
|
||||
struct.pack_into("<H", archive, central_header + 6, 20)
|
||||
if visible_version == "central":
|
||||
struct.pack_into("<H", archive, central_header + 6, extract_version)
|
||||
else:
|
||||
struct.pack_into("<H", archive, local_header + 4, extract_version)
|
||||
|
||||
zip_path = tmp_path / f"masked-{visible_version}-v{extract_version}.zip"
|
||||
zip_path.write_bytes(archive)
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
assert zf.read("file.txt") == b"contents"
|
||||
monkeypatch.setattr(
|
||||
zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="extractor version 4.5 or newer"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
@pytest.mark.parametrize("compression", [zipfile.ZIP_BZIP2, zipfile.ZIP_LZMA])
|
||||
def test_safe_extract_zip_rejects_unbounded_compression_before_zipfile(
|
||||
tmp_path, monkeypatch, compression
|
||||
):
|
||||
zip_path = tmp_path / f"unsupported-{compression}.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", compression=compression) as zf:
|
||||
zf.writestr("bomb.txt", b"A" * (1024 * 1024))
|
||||
|
||||
# Lie about the output size. For BZIP2/LZMA, ZipExtFile materializes the
|
||||
# whole decompressor result before slicing it to the requested length.
|
||||
archive = bytearray(zip_path.read_bytes())
|
||||
central_header = archive.index(b"PK\x01\x02")
|
||||
struct.pack_into("<L", archive, central_header + 24, 1)
|
||||
zip_path.write_bytes(archive)
|
||||
monkeypatch.setattr(
|
||||
zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="supports only STORED and DEFLATED"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"compression",
|
||||
[zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED],
|
||||
)
|
||||
def test_safe_extract_zip_accepts_bounded_compression_methods(
|
||||
tmp_path, compression
|
||||
):
|
||||
zip_path = tmp_path / f"supported-{compression}.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", compression=compression) as zf:
|
||||
zf.writestr("file.txt", b"contents")
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
safe_extract_zip(zip_path, out_dir)
|
||||
|
||||
assert (out_dir / "file.txt").read_bytes() == b"contents"
|
||||
|
||||
|
||||
def test_safe_extract_zip_accepts_archive_with_prepended_data(tmp_path):
|
||||
zip_path = tmp_path / "prefixed.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("file.txt", "contents")
|
||||
zip_path.write_bytes(b"launcher-prefix" + zip_path.read_bytes())
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
safe_extract_zip(zip_path, out_dir)
|
||||
|
||||
assert (out_dir / "file.txt").read_text(encoding="utf-8") == "contents"
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_central_entry_from_another_disk(tmp_path):
|
||||
zip_path = tmp_path / "multi-disk-entry.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("file.txt", "contents")
|
||||
|
||||
archive = bytearray(zip_path.read_bytes())
|
||||
central_header = archive.index(b"PK\x01\x02")
|
||||
struct.pack_into("<H", archive, central_header + 34, 1)
|
||||
zip_path.write_bytes(archive)
|
||||
|
||||
with pytest.raises(ValueError, match="Multi-disk"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_zip_caps_central_directory_before_zipfile(
|
||||
tmp_path, monkeypatch
|
||||
):
|
||||
zip_path = tmp_path / "large-directory.zip"
|
||||
zip_path.write_bytes(
|
||||
_legacy_zip_eocd(
|
||||
entries=1,
|
||||
central_directory_size=MAX_ZIP_CENTRAL_DIRECTORY_BYTES + 1,
|
||||
)
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
zipfile,
|
||||
"ZipFile",
|
||||
lambda *_args, **_kwargs: pytest.fail("ZipFile constructor was called"),
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="central directory exceeds"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_zip_rejects_total_uncompressed_size(tmp_path):
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("one.txt", "123")
|
||||
zf.writestr("two.txt", "456")
|
||||
|
||||
with pytest.raises(ValueError, match="maximum uncompressed size"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out", max_total_bytes=5)
|
||||
|
||||
|
||||
def test_safe_extract_zip_wraps_bad_zip_file(tmp_path):
|
||||
zip_path = tmp_path / "bad.zip"
|
||||
zip_path.write_bytes(b"not a zip archive")
|
||||
|
||||
with pytest.raises(_CustomZipError, match="Invalid ZIP archive"):
|
||||
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
|
||||
|
||||
|
||||
def test_safe_extract_zip_wraps_unsupported_zip_version(tmp_path):
|
||||
zip_path = tmp_path / "unsupported.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("file.txt", "contents")
|
||||
|
||||
archive = bytearray(zip_path.read_bytes())
|
||||
central_header = archive.index(b"PK\x01\x02")
|
||||
struct.pack_into("<H", archive, central_header + 6, 99)
|
||||
zip_path.write_bytes(archive)
|
||||
|
||||
with pytest.raises(
|
||||
_CustomZipError,
|
||||
match="extractor version 4.5 or newer",
|
||||
):
|
||||
safe_extract_zip(zip_path, tmp_path / "out", error_type=_CustomZipError)
|
||||
|
||||
|
||||
def test_read_zip_member_limited_returns_member_within_limit(tmp_path):
|
||||
zip_path = tmp_path / "ok.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("extension.yml", "extension:\n id: demo\n")
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
data = read_zip_member_limited(zf, "extension.yml")
|
||||
|
||||
assert data == b"extension:\n id: demo\n"
|
||||
|
||||
|
||||
def test_read_zip_member_limited_does_not_retain_short_read_fragments():
|
||||
response = _OneByteResponse(64)
|
||||
archive = _FakeZipArchive(response, file_size=64)
|
||||
|
||||
assert (
|
||||
read_zip_member_limited(archive, "extension.yml", max_bytes=64)
|
||||
== b"x" * 64
|
||||
)
|
||||
assert response.peak_live <= 2
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", [None, "1", 1.5, True])
|
||||
def test_read_zip_member_limited_rejects_non_integer_limits(value):
|
||||
archive = _FakeZipArchive(_OneByteResponse(0))
|
||||
|
||||
with pytest.raises(TypeError, match="integer"):
|
||||
read_zip_member_limited(archive, "extension.yml", max_bytes=value)
|
||||
|
||||
|
||||
def test_read_zip_member_limited_rejects_negative_limit_without_opening():
|
||||
archive = _FakeZipArchive(_OneByteResponse(0))
|
||||
|
||||
with pytest.raises(ValueError, match="non-negative"):
|
||||
read_zip_member_limited(archive, "extension.yml", max_bytes=-1)
|
||||
|
||||
|
||||
def test_read_zip_member_limited_rejects_oversized_member(tmp_path):
|
||||
zip_path = tmp_path / "bomb.zip"
|
||||
with zipfile.ZipFile(zip_path, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
zf.writestr("extension.yml", "a" * 5000)
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
with pytest.raises(ValueError, match="exceeds maximum size"):
|
||||
read_zip_member_limited(zf, "extension.yml", max_bytes=16)
|
||||
|
||||
|
||||
def test_read_zip_member_limited_rejects_when_declared_size_is_too_small():
|
||||
archive = _FakeZipArchive(_OneByteResponse(5), file_size=1)
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds maximum size"):
|
||||
read_zip_member_limited(
|
||||
archive,
|
||||
"extension.yml",
|
||||
max_bytes=4,
|
||||
)
|
||||
|
||||
|
||||
def test_read_zip_member_limited_escapes_control_characters_in_errors():
|
||||
member_name = "bad\x1b[2J/extension.yml"
|
||||
archive = _FakeZipArchive(
|
||||
_OneByteResponse(0),
|
||||
filename=member_name,
|
||||
file_size=5,
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
read_zip_member_limited(
|
||||
archive,
|
||||
member_name,
|
||||
max_bytes=4,
|
||||
)
|
||||
|
||||
assert "\x1b" not in str(exc_info.value)
|
||||
assert "\\x1b" in str(exc_info.value)
|
||||
|
||||
|
||||
def test_read_zip_member_limited_wraps_missing_member(tmp_path):
|
||||
zip_path = tmp_path / "ok.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("other.txt", "x")
|
||||
|
||||
with zipfile.ZipFile(zip_path, "r") as zf:
|
||||
with pytest.raises(_CustomZipError, match="ZIP member not found"):
|
||||
read_zip_member_limited(zf, "extension.yml", error_type=_CustomZipError)
|
||||
|
||||
|
||||
def test_read_zip_member_limited_wraps_decompression_errors():
|
||||
archive = _FakeZipArchive(_ExplodingResponse(), file_size=1)
|
||||
|
||||
with pytest.raises(_CustomZipError, match="Failed to read ZIP member"):
|
||||
read_zip_member_limited(
|
||||
archive,
|
||||
"extension.yml",
|
||||
error_type=_CustomZipError,
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"members",
|
||||
[
|
||||
[("nested\\file.txt", "first"), ("nested/file.txt", "second")],
|
||||
[("node", "file"), ("node/child.txt", "child")],
|
||||
[("node/child.txt", "child"), ("node", "file")],
|
||||
[("Readme.txt", "first"), ("README.TXT", "second")],
|
||||
[("caf\u00e9.txt", "first"), ("cafe\u0301.txt", "second")],
|
||||
],
|
||||
)
|
||||
def test_safe_extract_zip_rejects_conflicting_paths_before_writing(
|
||||
tmp_path, members
|
||||
):
|
||||
zip_path = tmp_path / "conflict.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
for name, contents in members:
|
||||
zf.writestr(name, contents)
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
with pytest.raises(ValueError, match="Conflicting path"):
|
||||
safe_extract_zip(zip_path, out_dir)
|
||||
|
||||
assert not out_dir.exists() or not any(out_dir.rglob("*"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_name",
|
||||
[
|
||||
"file::$DATA",
|
||||
"file.",
|
||||
"file ",
|
||||
" leading.txt",
|
||||
"NUL.txt",
|
||||
"COM\u00b9.log",
|
||||
"COM1 .txt",
|
||||
"CONOUT$.log",
|
||||
"nested/name?.txt",
|
||||
"nested/control\u0001.txt",
|
||||
"nested/delete\u007f.txt",
|
||||
"nested/csi\u009b[2J.txt",
|
||||
],
|
||||
)
|
||||
def test_safe_extract_zip_rejects_nonportable_member_names(tmp_path, member_name):
|
||||
zip_path = tmp_path / "nonportable.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr(member_name, "contents")
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
with pytest.raises(ValueError, match="Unsafe path"):
|
||||
safe_extract_zip(zip_path, out_dir)
|
||||
|
||||
assert not out_dir.exists() or not any(out_dir.rglob("*"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_name",
|
||||
[
|
||||
"a" * 256,
|
||||
"a/" * 2048 + "file.txt",
|
||||
],
|
||||
)
|
||||
def test_safe_extract_zip_rejects_excessively_long_paths(tmp_path, member_name):
|
||||
zip_path = tmp_path / "nonportable.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr(member_name, "contents")
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
with pytest.raises(ValueError, match="Unsafe path"):
|
||||
safe_extract_zip(zip_path, out_dir)
|
||||
|
||||
assert not out_dir.exists() or not any(out_dir.rglob("*"))
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("control_character", "escaped_character"),
|
||||
[
|
||||
("\x1b", "\\x1b"),
|
||||
("\x7f", "\\x7f"),
|
||||
("\x9b", "\\x9b"),
|
||||
],
|
||||
)
|
||||
def test_safe_extract_zip_escapes_unicode_control_characters_in_errors(
|
||||
tmp_path,
|
||||
control_character,
|
||||
escaped_character,
|
||||
):
|
||||
zip_path = tmp_path / "terminal-control.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr(f"bad{control_character}[2J.txt", "contents")
|
||||
|
||||
with pytest.raises(ValueError) as exc_info:
|
||||
safe_extract_zip(zip_path, tmp_path / "out")
|
||||
|
||||
assert control_character not in str(exc_info.value)
|
||||
assert escaped_character in str(exc_info.value)
|
||||
|
||||
|
||||
def test_safe_extract_zip_accepts_single_decomposed_unicode_name(tmp_path):
|
||||
zip_path = tmp_path / "unicode.zip"
|
||||
out_dir = tmp_path / "out"
|
||||
decomposed_name = "cafe\u0301.txt"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr(decomposed_name, "contents")
|
||||
|
||||
safe_extract_zip(zip_path, out_dir)
|
||||
|
||||
assert (out_dir / decomposed_name).read_text(encoding="utf-8") == "contents"
|
||||
|
||||
|
||||
def test_safe_extract_zip_wraps_decompression_errors(tmp_path, monkeypatch):
|
||||
zip_path = tmp_path / "corrupt.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("extension.yml", "x")
|
||||
|
||||
archive = _FakeZipArchive(_ExplodingResponse(), file_size=1)
|
||||
monkeypatch.setattr(zipfile, "ZipFile", lambda *_args, **_kwargs: archive)
|
||||
|
||||
with pytest.raises(_CustomZipError, match="Failed to extract ZIP member"):
|
||||
safe_extract_zip(
|
||||
zip_path,
|
||||
tmp_path / "out",
|
||||
error_type=_CustomZipError,
|
||||
)
|
||||
|
||||
|
||||
def test_safe_extract_zip_enforces_actual_member_size(tmp_path, monkeypatch):
|
||||
zip_path = tmp_path / "lying-size.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("extension.yml", "x")
|
||||
|
||||
archive = _FakeZipArchive(_OneByteResponse(5), file_size=1)
|
||||
monkeypatch.setattr(zipfile, "ZipFile", lambda *_args, **_kwargs: archive)
|
||||
|
||||
with pytest.raises(ValueError, match="exceeds maximum size"):
|
||||
safe_extract_zip(
|
||||
zip_path,
|
||||
tmp_path / "out",
|
||||
max_member_bytes=4,
|
||||
)
|
||||
|
||||
|
||||
def test_safe_extract_zip_extracts_safe_archive(tmp_path):
|
||||
zip_path = tmp_path / "ok.zip"
|
||||
out_dir = tmp_path / "out"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("nested/file.txt", "hello")
|
||||
|
||||
safe_extract_zip(zip_path, out_dir)
|
||||
|
||||
assert (out_dir / "nested" / "file.txt").read_text(encoding="utf-8") == "hello"
|
||||
|
||||
|
||||
def test_safe_extract_zip_treats_normalized_trailing_backslash_as_directory(tmp_path):
|
||||
zip_path = tmp_path / "ok.zip"
|
||||
out_dir = tmp_path / "out"
|
||||
with zipfile.ZipFile(zip_path, "w") as zf:
|
||||
zf.writestr("nested\\", "")
|
||||
zf.writestr("nested/file.txt", "hello")
|
||||
|
||||
safe_extract_zip(zip_path, out_dir)
|
||||
|
||||
assert (out_dir / "nested").is_dir()
|
||||
assert (out_dir / "nested" / "file.txt").read_text(encoding="utf-8") == "hello"
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -292,6 +292,23 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_returns_none_on_malformed_host(self):
|
||||
"""A malformed authority (e.g. an invalid bracketed IPv6 host) returns
|
||||
None, not a ValueError (contract: resolve or return None, never raise)."""
|
||||
called = []
|
||||
|
||||
def open_never(url, timeout=None, extra_headers=None):
|
||||
called.append(url)
|
||||
raise AssertionError("open_url_fn must not be called")
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://[not-an-ip]/o/r/releases/download/v1/ext.zip",
|
||||
open_never,
|
||||
github_hosts=("ghes.example",),
|
||||
)
|
||||
assert result is None
|
||||
assert called == []
|
||||
|
||||
def test_passthrough_for_unlisted_ghes_api_asset_url(self):
|
||||
"""A direct GHES /api/v3 asset URL passes through even when the host is
|
||||
not allowlisted: passthrough issues no API request, and the download
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,7 +13,13 @@ TRAVERSAL_PAYLOADS = [
|
||||
"../pwned",
|
||||
"../../etc/passwd",
|
||||
"subdir/../../escape",
|
||||
"link/../victim",
|
||||
"/absolute/evil",
|
||||
"NUL",
|
||||
"name:stream",
|
||||
"x?y",
|
||||
"trailing.",
|
||||
"group\\run",
|
||||
]
|
||||
|
||||
|
||||
@@ -89,7 +95,8 @@ class TestAliasTraversal:
|
||||
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
|
||||
def test_gemini_rejects_traversal_in_alias(self, tmp_path, bad_alias):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".gemini" / "commands").mkdir(parents=True)
|
||||
commands_dir = project / ".gemini" / "commands"
|
||||
commands_dir.mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
@@ -102,12 +109,15 @@ class TestAliasTraversal:
|
||||
)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
|
||||
assert list(commands_dir.rglob("*")) == []
|
||||
|
||||
@pytest.mark.parametrize("bad_alias", TRAVERSAL_PAYLOADS)
|
||||
def test_copilot_rejects_traversal_in_alias(self, tmp_path, bad_alias):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
(project / ".github" / "agents").mkdir(parents=True)
|
||||
(project / ".github" / "prompts").mkdir(parents=True)
|
||||
agents_dir = project / ".github" / "agents"
|
||||
prompts_dir = project / ".github" / "prompts"
|
||||
agents_dir.mkdir(parents=True)
|
||||
prompts_dir.mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
with pytest.raises(ValueError, match="escapes|outside|Invalid"):
|
||||
@@ -120,6 +130,8 @@ class TestAliasTraversal:
|
||||
)
|
||||
|
||||
_assert_no_stray_files(tmp_path, Path(bad_alias).name.replace("/", ""))
|
||||
assert list(agents_dir.rglob("*")) == []
|
||||
assert list(prompts_dir.rglob("*")) == []
|
||||
|
||||
|
||||
class TestCopilotPromptTraversal:
|
||||
@@ -290,6 +302,13 @@ class TestRelativeExtensionPathPolicy:
|
||||
"\\\\server\\share\\x.md",
|
||||
"../escape.md",
|
||||
"commands/../../escape.md",
|
||||
"NUL",
|
||||
"commands/CON.md",
|
||||
"commands\\run.md",
|
||||
"name:stream",
|
||||
"x?y",
|
||||
"trailing.",
|
||||
"directory/",
|
||||
],
|
||||
)
|
||||
def test_unsafe_values_report_violation(self, value):
|
||||
@@ -380,3 +399,27 @@ class TestReadSkipWarning:
|
||||
/ "speckit-myext-hi"
|
||||
/ "SKILL.md"
|
||||
).exists()
|
||||
|
||||
def test_copilot_nested_alias_creates_companion_prompt(self, tmp_path):
|
||||
project, ext_dir = _project_and_source(tmp_path)
|
||||
agents_dir = project / ".github" / "agents"
|
||||
agents_dir.mkdir(parents=True)
|
||||
|
||||
registrar = CommandRegistrar()
|
||||
registered = registrar.register_commands(
|
||||
"copilot",
|
||||
[_cmd("speckit.myext.hello", ["group/run"])],
|
||||
"myext",
|
||||
ext_dir,
|
||||
project,
|
||||
)
|
||||
|
||||
assert registered == ["speckit.myext.hello", "group/run"]
|
||||
assert (agents_dir / "group" / "run.agent.md").is_file()
|
||||
assert (
|
||||
project
|
||||
/ ".github"
|
||||
/ "prompts"
|
||||
/ "group"
|
||||
/ "run.prompt.md"
|
||||
).is_file()
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user