mirror of
https://github.com/github/spec-kit.git
synced 2026-07-04 04:45:43 +08:00
Compare commits
19 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b40ec4160f | ||
|
|
c5865ef444 | ||
|
|
a042c785f5 | ||
|
|
ac0c17c28f | ||
|
|
5d6d199aaa | ||
|
|
089feca75f | ||
|
|
3617cd9c02 | ||
|
|
50da3a0f77 | ||
|
|
cd8a39f50e | ||
|
|
e53cb2c143 | ||
|
|
cc3d828227 | ||
|
|
b4e5a1c3be | ||
|
|
11bd31935f | ||
|
|
a130b7e8d1 | ||
|
|
5372dcbdea | ||
|
|
b48b22379e | ||
|
|
3f096ffcfc | ||
|
|
f50839a928 | ||
|
|
ae96f97035 |
52
.github/workflows/add-community-extension.lock.yml
generated
vendored
52
.github/workflows/add-community-extension.lock.yml
generated
vendored
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"1583d46477aa35f00e2c7ab97f06bacf4f6e21bdaa9d58b0ef704a588e588a7e","compiler_version":"v0.74.8","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"2ace61d3a4e86e81ce7ff110e118981b4d88a06aa351ecdc2c3b64e44b10690f","compiler_version":"v0.74.8","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"efa55847f72aadb03490d955263ff911bf758700","version":"v0.74.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.49"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.49"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.49"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
@@ -38,7 +38,7 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
# - github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.49
|
||||
@@ -52,8 +52,6 @@ name: "Add Community Extension from Issue Submission"
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- labeled
|
||||
# skip-bots: # Skip-bots processed as bot check in pre-activation job
|
||||
# - github-actions # Skip-bots processed as bot check in pre-activation job
|
||||
@@ -92,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -206,23 +204,23 @@ jobs:
|
||||
run: |
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
|
||||
{
|
||||
cat << 'GH_AW_PROMPT_2b92c540a0b471a7_EOF'
|
||||
cat << 'GH_AW_PROMPT_767e1d181d9dae54_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_2b92c540a0b471a7_EOF
|
||||
GH_AW_PROMPT_767e1d181d9dae54_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2b92c540a0b471a7_EOF'
|
||||
cat << 'GH_AW_PROMPT_767e1d181d9dae54_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment(max:2), create_pull_request, add_labels(max:3), missing_tool, missing_data, noop
|
||||
GH_AW_PROMPT_2b92c540a0b471a7_EOF
|
||||
GH_AW_PROMPT_767e1d181d9dae54_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md"
|
||||
cat << 'GH_AW_PROMPT_2b92c540a0b471a7_EOF'
|
||||
cat << 'GH_AW_PROMPT_767e1d181d9dae54_EOF'
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_2b92c540a0b471a7_EOF
|
||||
GH_AW_PROMPT_767e1d181d9dae54_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2b92c540a0b471a7_EOF'
|
||||
cat << 'GH_AW_PROMPT_767e1d181d9dae54_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -254,12 +252,12 @@ jobs:
|
||||
- **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it without proper authentication. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: ["refs/pulls/open/*"]` for all open PR refs, or `fetch: ["main", "feature/my-branch"]` for specific branches).
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_2b92c540a0b471a7_EOF
|
||||
GH_AW_PROMPT_767e1d181d9dae54_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_2b92c540a0b471a7_EOF'
|
||||
cat << 'GH_AW_PROMPT_767e1d181d9dae54_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/add-community-extension.md}}
|
||||
GH_AW_PROMPT_2b92c540a0b471a7_EOF
|
||||
GH_AW_PROMPT_767e1d181d9dae54_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -370,7 +368,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -466,9 +464,9 @@ jobs:
|
||||
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
|
||||
mkdir -p /tmp/gh-aw/safeoutputs
|
||||
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_66c58b0f685caa27_EOF'
|
||||
{"add_comment":{"max":2},"add_labels":{"allowed":["extension-submission","validation-passed","validation-failed","needs-info"],"max":3},"create_pull_request":{"draft":true,"labels":["extension-submission","automated"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","CONTRIBUTING.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","title_prefix":"[extension] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_66c58b0f685caa27_EOF
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_95f097d550e5bb4b_EOF'
|
||||
{"add_comment":{"max":2},"add_labels":{"allowed":["extension-submission","validation-passed","validation-failed","needs-info"],"max":3},"create_pull_request":{"draft":true,"labels":["extension-submission","automated"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","CONTRIBUTING.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","title_prefix":"[extension] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_95f097d550e5bb4b_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -724,7 +722,7 @@ jobs:
|
||||
|
||||
mkdir -p /home/runner/.copilot
|
||||
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
|
||||
cat << GH_AW_MCP_CONFIG_881a93100a972629_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
cat << GH_AW_MCP_CONFIG_9f16469ceb45c7f6_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
@@ -765,7 +763,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_881a93100a972629_EOF
|
||||
GH_AW_MCP_CONFIG_9f16469ceb45c7f6_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -1047,7 +1045,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1081,7 +1079,7 @@ jobs:
|
||||
GH_AW_WORKFLOW_NAME: "Add Community Extension from Issue Submission"
|
||||
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
|
||||
GH_AW_NOOP_REPORT_AS_ISSUE: "true"
|
||||
GH_AW_NOOP_REPORT_AS_ISSUE: "false"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -1188,7 +1186,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1384,7 +1382,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1456,7 +1454,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1558,7 +1556,7 @@ jobs:
|
||||
GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_API_URL: ${{ github.api_url }}
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":2},\"add_labels\":{\"allowed\":[\"extension-submission\",\"validation-passed\",\"validation-failed\",\"needs-info\"],\"max\":3},\"create_pull_request\":{\"draft\":true,\"labels\":[\"extension-submission\",\"automated\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"CONTRIBUTING.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"title_prefix\":\"[extension] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":2},\"add_labels\":{\"allowed\":[\"extension-submission\",\"validation-passed\",\"validation-failed\",\"needs-info\"],\"max\":3},\"create_pull_request\":{\"draft\":true,\"labels\":[\"extension-submission\",\"automated\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"CONTRIBUTING.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"title_prefix\":\"[extension] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
|
||||
GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
15
.github/workflows/add-community-extension.md
vendored
15
.github/workflows/add-community-extension.md
vendored
@@ -4,7 +4,7 @@ emoji: "🧩"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
types: [labeled]
|
||||
skip-bots: [github-actions, copilot, dependabot]
|
||||
|
||||
tools:
|
||||
@@ -22,6 +22,8 @@ checkout:
|
||||
fetch-depth: 0
|
||||
|
||||
safe-outputs:
|
||||
noop:
|
||||
report-as-issue: false
|
||||
create-pull-request:
|
||||
title-prefix: "[extension] "
|
||||
labels: [extension-submission, automated]
|
||||
@@ -47,14 +49,9 @@ or update entries in the community extension catalog.
|
||||
|
||||
## Triggering Conditions
|
||||
|
||||
This workflow triggers on issue events. **Only process the issue if ALL of these
|
||||
conditions are met:**
|
||||
|
||||
1. The issue has the `extension-submission` label
|
||||
2. The issue title starts with `[Extension]:`
|
||||
|
||||
If the issue does not meet these conditions, add a brief comment explaining that
|
||||
this workflow only processes extension submission issues, then stop.
|
||||
This workflow only triggers when the `extension-submission` label is added to an
|
||||
issue. Before processing, verify that the issue title starts with `[Extension]:`.
|
||||
If it does not, stop without commenting.
|
||||
|
||||
## Step 1 — Read and Parse the Issue
|
||||
|
||||
|
||||
52
.github/workflows/add-community-preset.lock.yml
generated
vendored
52
.github/workflows/add-community-preset.lock.yml
generated
vendored
@@ -1,4 +1,4 @@
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"6111bd4a1cd2c363f1f05f185164e08883d6df79da732a8c07b9aa602ed7dfe6","compiler_version":"v0.74.8","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-metadata: {"schema_version":"v3","frontmatter_hash":"f209d3fbcde6b25fd5099c7b1ea0d3dace8967b23d8049a92566c213ed9ccc5e","compiler_version":"v0.74.8","strict":true,"agent_id":"copilot"}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"de0fac2e4500dabe0009e67214ff5f5447ce83dd","version":"v6.0.2"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"efa55847f72aadb03490d955263ff911bf758700","version":"v0.74.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.25.49"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.25.49"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.25.49"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.9","digest":"sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.9@sha256:64828b42a4482f58fab16509d7f8f495a6d97c972a98a68aff20543531ac0388"},{"image":"ghcr.io/github/github-mcp-server:v1.0.4"},{"image":"node:lts-alpine","digest":"sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f","pinned_image":"node:lts-alpine@sha256:d1b3b4da11eefd5941e7f0b9cf17783fc99d9c6fc34884a665f40a06dbdfc94f"}]}
|
||||
# ___ _ _
|
||||
# / _ \ | | (_)
|
||||
@@ -38,7 +38,7 @@
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0 (source v9)
|
||||
# - actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0
|
||||
# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
# - github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
# - github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
#
|
||||
# Container images used:
|
||||
# - ghcr.io/github/gh-aw-firewall/agent:0.25.49
|
||||
@@ -52,8 +52,6 @@ name: "Add Community Preset from Issue Submission"
|
||||
on:
|
||||
issues:
|
||||
types:
|
||||
- opened
|
||||
- edited
|
||||
- labeled
|
||||
# skip-bots: # Skip-bots processed as bot check in pre-activation job
|
||||
# - github-actions # Skip-bots processed as bot check in pre-activation job
|
||||
@@ -92,7 +90,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -206,23 +204,23 @@ jobs:
|
||||
run: |
|
||||
bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
|
||||
{
|
||||
cat << 'GH_AW_PROMPT_fc7609016a7d28af_EOF'
|
||||
cat << 'GH_AW_PROMPT_c25ce620b285c8e3_EOF'
|
||||
<system>
|
||||
GH_AW_PROMPT_fc7609016a7d28af_EOF
|
||||
GH_AW_PROMPT_c25ce620b285c8e3_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_fc7609016a7d28af_EOF'
|
||||
cat << 'GH_AW_PROMPT_c25ce620b285c8e3_EOF'
|
||||
<safe-output-tools>
|
||||
Tools: add_comment(max:2), create_pull_request, add_labels(max:3), missing_tool, missing_data, noop
|
||||
GH_AW_PROMPT_fc7609016a7d28af_EOF
|
||||
GH_AW_PROMPT_c25ce620b285c8e3_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md"
|
||||
cat << 'GH_AW_PROMPT_fc7609016a7d28af_EOF'
|
||||
cat << 'GH_AW_PROMPT_c25ce620b285c8e3_EOF'
|
||||
</safe-output-tools>
|
||||
GH_AW_PROMPT_fc7609016a7d28af_EOF
|
||||
GH_AW_PROMPT_c25ce620b285c8e3_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_fc7609016a7d28af_EOF'
|
||||
cat << 'GH_AW_PROMPT_c25ce620b285c8e3_EOF'
|
||||
<github-context>
|
||||
The following GitHub context information is available for this workflow:
|
||||
{{#if github.actor}}
|
||||
@@ -254,12 +252,12 @@ jobs:
|
||||
- **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it without proper authentication. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: ["refs/pulls/open/*"]` for all open PR refs, or `fetch: ["main", "feature/my-branch"]` for specific branches).
|
||||
</github-context>
|
||||
|
||||
GH_AW_PROMPT_fc7609016a7d28af_EOF
|
||||
GH_AW_PROMPT_c25ce620b285c8e3_EOF
|
||||
cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
|
||||
cat << 'GH_AW_PROMPT_fc7609016a7d28af_EOF'
|
||||
cat << 'GH_AW_PROMPT_c25ce620b285c8e3_EOF'
|
||||
</system>
|
||||
{{#runtime-import .github/workflows/add-community-preset.md}}
|
||||
GH_AW_PROMPT_fc7609016a7d28af_EOF
|
||||
GH_AW_PROMPT_c25ce620b285c8e3_EOF
|
||||
} > "$GH_AW_PROMPT"
|
||||
- name: Interpolate variables and render templates
|
||||
uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
@@ -370,7 +368,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -466,9 +464,9 @@ jobs:
|
||||
mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
|
||||
mkdir -p /tmp/gh-aw/safeoutputs
|
||||
mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_9e8dea0461236832_EOF'
|
||||
{"add_comment":{"max":2},"add_labels":{"allowed":["preset-submission","validation-passed","validation-failed","needs-info"],"max":3},"create_pull_request":{"draft":true,"labels":["preset-submission","automated"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","CONTRIBUTING.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","title_prefix":"[preset] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"true"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_9e8dea0461236832_EOF
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_50dbf4670371d6f7_EOF'
|
||||
{"add_comment":{"max":2},"add_labels":{"allowed":["preset-submission","validation-passed","validation-failed","needs-info"],"max":3},"create_pull_request":{"draft":true,"labels":["preset-submission","automated"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","CONTRIBUTING.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","title_prefix":"[preset] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"report_incomplete":{}}
|
||||
GH_AW_SAFE_OUTPUTS_CONFIG_50dbf4670371d6f7_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -724,7 +722,7 @@ jobs:
|
||||
|
||||
mkdir -p /home/runner/.copilot
|
||||
GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
|
||||
cat << GH_AW_MCP_CONFIG_c8953ff00c8ee9ee_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
cat << GH_AW_MCP_CONFIG_04e1e53849e8d680_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
|
||||
{
|
||||
"mcpServers": {
|
||||
"github": {
|
||||
@@ -765,7 +763,7 @@ jobs:
|
||||
"payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
|
||||
}
|
||||
}
|
||||
GH_AW_MCP_CONFIG_c8953ff00c8ee9ee_EOF
|
||||
GH_AW_MCP_CONFIG_04e1e53849e8d680_EOF
|
||||
- name: Mount MCP servers as CLIs
|
||||
id: mount-mcp-clis
|
||||
continue-on-error: true
|
||||
@@ -1047,7 +1045,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1081,7 +1079,7 @@ jobs:
|
||||
GH_AW_WORKFLOW_NAME: "Add Community Preset from Issue Submission"
|
||||
GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
|
||||
GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
|
||||
GH_AW_NOOP_REPORT_AS_ISSUE: "true"
|
||||
GH_AW_NOOP_REPORT_AS_ISSUE: "false"
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
script: |
|
||||
@@ -1188,7 +1186,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1384,7 +1382,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1456,7 +1454,7 @@ jobs:
|
||||
steps:
|
||||
- name: Setup Scripts
|
||||
id: setup
|
||||
uses: github/gh-aw-actions/setup@318d7f4901f78b85e25b91709cf0109ac9b425f6 # v0.74.9
|
||||
uses: github/gh-aw-actions/setup@b11be78086764c43fa463398aed7ffdcf40549c1 # v0.77.0
|
||||
with:
|
||||
destination: ${{ runner.temp }}/gh-aw/actions
|
||||
job-name: ${{ github.job }}
|
||||
@@ -1558,7 +1556,7 @@ jobs:
|
||||
GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
|
||||
GITHUB_SERVER_URL: ${{ github.server_url }}
|
||||
GITHUB_API_URL: ${{ github.api_url }}
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":2},\"add_labels\":{\"allowed\":[\"preset-submission\",\"validation-passed\",\"validation-failed\",\"needs-info\"],\"max\":3},\"create_pull_request\":{\"draft\":true,\"labels\":[\"preset-submission\",\"automated\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"CONTRIBUTING.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"title_prefix\":\"[preset] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"true\"},\"report_incomplete\":{}}"
|
||||
GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":2},\"add_labels\":{\"allowed\":[\"preset-submission\",\"validation-passed\",\"validation-failed\",\"needs-info\"],\"max\":3},\"create_pull_request\":{\"draft\":true,\"labels\":[\"preset-submission\",\"automated\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"CONTRIBUTING.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"title_prefix\":\"[preset] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"report_incomplete\":{}}"
|
||||
GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }}
|
||||
with:
|
||||
github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
15
.github/workflows/add-community-preset.md
vendored
15
.github/workflows/add-community-preset.md
vendored
@@ -4,7 +4,7 @@ emoji: "🎨"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, edited, labeled]
|
||||
types: [labeled]
|
||||
skip-bots: [github-actions, copilot, dependabot]
|
||||
|
||||
tools:
|
||||
@@ -22,6 +22,8 @@ checkout:
|
||||
fetch-depth: 0
|
||||
|
||||
safe-outputs:
|
||||
noop:
|
||||
report-as-issue: false
|
||||
create-pull-request:
|
||||
title-prefix: "[preset] "
|
||||
labels: [preset-submission, automated]
|
||||
@@ -47,14 +49,9 @@ or update entries in the community preset catalog.
|
||||
|
||||
## Triggering Conditions
|
||||
|
||||
This workflow triggers on issue events. **Only process the issue if ALL of these
|
||||
conditions are met:**
|
||||
|
||||
1. The issue has the `preset-submission` label
|
||||
2. The issue title starts with `[Preset]:`
|
||||
|
||||
If the issue does not meet these conditions, add a brief comment explaining that
|
||||
this workflow only processes preset submission issues, then stop.
|
||||
This workflow only triggers when the `preset-submission` label is added to an
|
||||
issue. Before processing, verify that the issue title starts with `[Preset]:`.
|
||||
If it does not, stop without commenting.
|
||||
|
||||
## Step 1 — Read and Parse the Issue
|
||||
|
||||
|
||||
4
.github/workflows/codeql.yml
vendored
4
.github/workflows/codeql.yml
vendored
@@ -22,11 +22,11 @@ jobs:
|
||||
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
uses: github/codeql-action/init@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@9e0d7b8d25671d64c341c19c0152d693099fb5ba # v4
|
||||
uses: github/codeql-action/analyze@7211b7c8077ea37d8641b6271f6a365a22a5fbfa # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
fetch-depth: 0 # Fetch all history for git info
|
||||
|
||||
- name: Setup .NET
|
||||
uses: actions/setup-dotnet@c2fa09f4bde5ebb9d1777cf28262a3eb3db3ced7 # v5.2.0
|
||||
uses: actions/setup-dotnet@9a946fdbd5fb07b82b2f5a4466058b876ab72bb2 # v5.3.0
|
||||
with:
|
||||
dotnet-version: '8.x'
|
||||
|
||||
|
||||
21
AGENTS.md
21
AGENTS.md
@@ -177,7 +177,24 @@ def _register_builtins() -> None:
|
||||
|
||||
Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate.
|
||||
|
||||
Only add custom setup logic when the agent needs non-standard behavior. Most integrations do not need wrapper scripts or separate context-update dispatch code.
|
||||
The managed section is owned by the bundled `agent-context` extension (`extensions/agent-context/`). 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
|
||||
|
||||
# Delimiters for the managed Spec Kit section
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
```
|
||||
|
||||
- `context_file` is written automatically from the integration's class attribute when `specify init` or `specify integration use` is run.
|
||||
- `context_markers.{start,end}` defaults to `IntegrationBase.CONTEXT_MARKER_START` / `CONTEXT_MARKER_END`. Users who want custom markers edit `agent-context-config.yml` directly — both the Python layer (`upsert_context_section()` / `remove_context_section()`) and the bundled scripts (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`) read from this single source of truth.
|
||||
|
||||
Users can opt out entirely with `specify extension disable agent-context`; while disabled, Spec Kit skips context-file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
|
||||
|
||||
Only add custom setup logic when the agent needs non-standard behavior. Integrations no longer require per-agent thin wrapper scripts or shared context-update dispatcher scripts — the `agent-context` extension is fully generic.
|
||||
|
||||
### 5. Test it
|
||||
|
||||
@@ -409,7 +426,7 @@ When an issue exists, include its number immediately after the prefix — this i
|
||||
## Common Pitfalls
|
||||
|
||||
1. **Using shorthand keys for CLI-based integrations**: For CLI-based integrations (`requires_cli: True`), the `key` must match the executable name (e.g., `"cursor-agent"` not `"cursor"`). `shutil.which(key)` is used for CLI tool checks — mismatches require special-case mappings. IDE-based integrations (`requires_cli: False`) are not subject to this constraint.
|
||||
2. **Forgetting update scripts**: Both bash and PowerShell thin wrappers and the shared context-update scripts must be updated.
|
||||
2. **Forgetting context configuration**: The bundled `agent-context` extension reads from `.specify/extensions/agent-context/agent-context-config.yml`. New integrations only need to set `context_file` on the class — markers and dispatcher scripts are managed centrally.
|
||||
3. **Incorrect `requires_cli` value**: Set to `True` only for agents that have a CLI tool; set to `False` for IDE-based agents.
|
||||
4. **Wrong argument format**: Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML agents.
|
||||
5. **Skipping registration**: The import and `_register()` call in `_register_builtins()` must both be added.
|
||||
|
||||
28
CHANGELOG.md
28
CHANGELOG.md
@@ -2,6 +2,34 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.9.0] - 2026-06-01
|
||||
|
||||
### Changed
|
||||
|
||||
- chore: recompile workflow lock files (#2774)
|
||||
- Add Multi-Sites Spec Kit extension to community catalog (#2791)
|
||||
- Update Product Spec Extension to v0.8.3 (#2790)
|
||||
- Publish May 2026 Newsletter (#2787)
|
||||
- fix: move URL install confirmation prompt before spinner (#2783) (#2784)
|
||||
- Update Reqnroll BDD extension to v1.1.0 (#2775)
|
||||
- Extract agent context updates into bundled agent-context extension (#2546)
|
||||
- chore(deps): bump actions/setup-dotnet from 5.2.0 to 5.3.0 (#2755)
|
||||
- chore: release 0.8.18, begin 0.8.19.dev0 development (#2766)
|
||||
|
||||
## [0.8.18] - 2026-05-29
|
||||
|
||||
### Changed
|
||||
|
||||
- Add support for SPECKIT_WORKFLOW_RUN_ID override (#2742)
|
||||
- feat: support SPECKIT_INTEGRATION_<KEY>_EXECUTABLE env var (#2743)
|
||||
- chore(deps): bump github/gh-aw-actions from 0.74.8 to 0.77.0 (#2754)
|
||||
- chore(deps): bump github/codeql-action from 4.35.5 to 4.36.0 (#2753)
|
||||
- fix: disable no-op issue reporting for catalog submission workflows (#2748)
|
||||
- Add confirmation prompt for URL-based extension installs (#2745)
|
||||
- fix: restrict community submission workflows to labeled event only (#2741)
|
||||
- feat(integrations): support SPECIFY_<KEY>_EXTRA_ARGS env var for agent subprocess flags (#2596)
|
||||
- chore: release 0.8.17, begin 0.8.18.dev0 development (#2737)
|
||||
|
||||
## [0.8.17] - 2026-05-28
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -70,6 +70,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
|
||||
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
|
||||
| Multi-Model Review | Cross-model Spec Kit handoffs for spec authoring, implementation routing, and review. | `process` | Read+Write | [multi-model-review](https://github.com/formin/multi-model-review) |
|
||||
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
|
||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||
|
||||
57
extensions/agent-context/README.md
Normal file
57
extensions/agent-context/README.md
Normal file
@@ -0,0 +1,57 @@
|
||||
# Coding Agent Context Extension
|
||||
|
||||
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 -->`).
|
||||
|
||||
## Why an extension?
|
||||
|
||||
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Extracting this behavior into a dedicated extension lets users:
|
||||
|
||||
- **Opt out** entirely with `specify extension disable agent-context` — Spec Kit will then never create or modify the agent context file.
|
||||
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — both the Python layer and the bundled scripts honor the same `context_markers` value.
|
||||
- **Refresh on demand** with `/speckit.agent-context.update`, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration flows through the extension's own config file at
|
||||
`.specify/extensions/agent-context/agent-context-config.yml`:
|
||||
|
||||
```yaml
|
||||
# Path to the coding agent context file managed by this extension
|
||||
context_file: 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, written by `specify init` and `specify integration install`.
|
||||
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
|
||||
|
||||
## Requirements
|
||||
|
||||
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
|
||||
|
||||
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||
|
||||
```bash
|
||||
pip install pyyaml
|
||||
# or target the specific interpreter Spec Kit uses:
|
||||
/path/to/speckit-python -m pip install pyyaml
|
||||
```
|
||||
|
||||
## Disable
|
||||
|
||||
```bash
|
||||
specify extension disable agent-context
|
||||
```
|
||||
|
||||
When disabled, Spec Kit skips context file creation, updates, and removal (the gates are inside `upsert_context_section()` and `remove_context_section()`).
|
||||
15
extensions/agent-context/agent-context-config.yml
Normal file
15
extensions/agent-context/agent-context-config.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
# 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 coding agent context file
|
||||
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
|
||||
# .github/copilot-instructions.md). Set automatically from the active
|
||||
# integration and regenerated during `specify init` or integration switches.
|
||||
context_file: ""
|
||||
|
||||
# Delimiters for the managed Spec Kit section.
|
||||
# Edit these to use custom markers.
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
@@ -0,0 +1,26 @@
|
||||
---
|
||||
description: "Refresh the managed Spec Kit section in the coding agent context file"
|
||||
---
|
||||
|
||||
# Update Coding Agent Context
|
||||
|
||||
Refresh the managed Spec Kit section inside the active coding agent's context/instruction file (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`).
|
||||
|
||||
## Behavior
|
||||
|
||||
The script reads the agent-context extension config at
|
||||
`.specify/extensions/agent-context/agent-context-config.yml` to discover:
|
||||
|
||||
- `context_file` — the path of the coding agent context file to manage.
|
||||
- `context_markers.start` / `.end` — the delimiters surrounding the managed section. Defaults to `<!-- SPECKIT START -->` and `<!-- SPECKIT END -->` when the field is missing.
|
||||
|
||||
It then creates, replaces, or appends the managed block so that the section points at the most recent plan path when one can be discovered (`specs/<feature>/plan.md`).
|
||||
|
||||
If `context_file` is empty or the file cannot be located, the command reports nothing to do and exits successfully.
|
||||
|
||||
## Execution
|
||||
|
||||
- **Bash**: `.specify/extensions/agent-context/scripts/bash/update-agent-context.sh [plan_path]`
|
||||
- **PowerShell**: `.specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1 [plan_path]`
|
||||
|
||||
When `plan_path` is omitted, the script auto-detects the most recently modified `specs/*/plan.md`.
|
||||
34
extensions/agent-context/extension.yml
Normal file
34
extensions/agent-context/extension.yml
Normal file
@@ -0,0 +1,34 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
extension:
|
||||
id: agent-context
|
||||
name: "Coding Agent Context"
|
||||
version: "1.0.0"
|
||||
description: "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers"
|
||||
author: spec-kit-core
|
||||
repository: https://github.com/github/spec-kit
|
||||
license: MIT
|
||||
|
||||
requires:
|
||||
speckit_version: ">=0.2.0"
|
||||
|
||||
provides:
|
||||
commands:
|
||||
- name: speckit.agent-context.update
|
||||
file: commands/speckit.agent-context.update.md
|
||||
description: "Refresh the managed Spec Kit section in the coding agent context file"
|
||||
|
||||
hooks:
|
||||
after_specify:
|
||||
command: speckit.agent-context.update
|
||||
optional: true
|
||||
description: "Refresh agent context after specification"
|
||||
after_plan:
|
||||
command: speckit.agent-context.update
|
||||
optional: true
|
||||
description: "Refresh agent context after planning"
|
||||
|
||||
tags:
|
||||
- "agent"
|
||||
- "context"
|
||||
- "core"
|
||||
200
extensions/agent-context/scripts/bash/update-agent-context.sh
Executable file
200
extensions/agent-context/scripts/bash/update-agent-context.sh
Executable file
@@ -0,0 +1,200 @@
|
||||
#!/usr/bin/env bash
|
||||
# update-agent-context.sh
|
||||
#
|
||||
# Refresh the managed Spec Kit section in the coding agent's context file
|
||||
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
|
||||
#
|
||||
# Reads `context_file` and `context_markers.{start,end}` from the
|
||||
# agent-context extension config:
|
||||
# .specify/extensions/agent-context/agent-context-config.yml
|
||||
#
|
||||
# Usage: update-agent-context.sh [plan_path]
|
||||
#
|
||||
# When `plan_path` is omitted, the script picks the most recently modified
|
||||
# `specs/*/plan.md` if any exist, otherwise emits the section without a
|
||||
# concrete plan path.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PROJECT_ROOT="$(pwd)"
|
||||
EXT_CONFIG="$PROJECT_ROOT/.specify/extensions/agent-context/agent-context-config.yml"
|
||||
DEFAULT_START="<!-- SPECKIT START -->"
|
||||
DEFAULT_END="<!-- SPECKIT END -->"
|
||||
|
||||
if [[ ! -f "$EXT_CONFIG" ]]; then
|
||||
echo "agent-context: $EXT_CONFIG not found; nothing to do." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Locate a suitable Python interpreter (python3, then python).
|
||||
_python=""
|
||||
if command -v python3 >/dev/null 2>&1; then
|
||||
_python="python3"
|
||||
elif command -v python >/dev/null 2>&1 && python --version 2>&1 | grep -q "^Python 3"; then
|
||||
_python="python"
|
||||
fi
|
||||
|
||||
if [[ -z "$_python" ]]; then
|
||||
echo "agent-context: Python 3 not found on PATH; skipping update." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Parse extension config once; emit three newline-separated fields:
|
||||
# context_file, context_markers.start, context_markers.end
|
||||
if ! _raw_opts="$("$_python" - "$EXT_CONFIG" <<'PY'
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print(
|
||||
"agent-context: PyYAML is required to parse extension config but is not available "
|
||||
"in the current Python environment.\n"
|
||||
" To resolve: pip install pyyaml (or install it into the environment used by python3).\n"
|
||||
" Context file will not be updated until PyYAML is importable.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
try:
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
def get_str(obj, *keys):
|
||||
node = obj
|
||||
for k in keys:
|
||||
if isinstance(node, dict) and k in node:
|
||||
node = node[k]
|
||||
else:
|
||||
return ""
|
||||
return node if isinstance(node, str) else ""
|
||||
print(get_str(data, "context_file"))
|
||||
print(get_str(data, "context_markers", "start"))
|
||||
print(get_str(data, "context_markers", "end"))
|
||||
PY
|
||||
)"; then
|
||||
echo "agent-context: skipping update (see above for details)." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
_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_file, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
|
||||
exit 0
|
||||
fi
|
||||
CONTEXT_FILE="${_opts_lines[0]}"
|
||||
MARKER_START="${_opts_lines[1]}"
|
||||
MARKER_END="${_opts_lines[2]}"
|
||||
|
||||
if [[ -z "$CONTEXT_FILE" ]]; then
|
||||
echo "agent-context: context_file not set in extension config; nothing to do." >&2
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# Reject absolute paths, backslash separators, and '..' path segments in context_file
|
||||
if [[ "$CONTEXT_FILE" == /* ]] || [[ "$CONTEXT_FILE" =~ ^[A-Za-z]: ]]; then
|
||||
echo "agent-context: context_file must be a project-relative path; got '$CONTEXT_FILE'." >&2
|
||||
exit 1
|
||||
fi
|
||||
if [[ "$CONTEXT_FILE" == *\\* ]]; then
|
||||
echo "agent-context: context_file must not contain backslash separators; got '$CONTEXT_FILE'." >&2
|
||||
exit 1
|
||||
fi
|
||||
IFS='/' read -ra _cf_parts <<< "$CONTEXT_FILE"
|
||||
for _seg in "${_cf_parts[@]}"; do
|
||||
if [[ "$_seg" == ".." ]]; then
|
||||
echo "agent-context: context_file must not contain '..' path segments; got '$CONTEXT_FILE'." >&2
|
||||
exit 1
|
||||
fi
|
||||
done
|
||||
unset _cf_parts _seg
|
||||
|
||||
[[ -z "$MARKER_START" ]] && MARKER_START="$DEFAULT_START"
|
||||
[[ -z "$MARKER_END" ]] && MARKER_END="$DEFAULT_END"
|
||||
|
||||
PLAN_PATH="${1:-}"
|
||||
if [[ -z "$PLAN_PATH" ]]; then
|
||||
# Pick the most recently modified plan.md one level deep (specs/<feature>/plan.md).
|
||||
# Use find + sort by modification time to avoid ls/head fragility with
|
||||
# spaces in paths or SIGPIPE from pipefail.
|
||||
_plan_abs="$("$_python" - "$PROJECT_ROOT" <<'PY'
|
||||
import sys, os
|
||||
from pathlib import Path
|
||||
specs = Path(sys.argv[1]) / "specs"
|
||||
plans = sorted(
|
||||
specs.glob("*/plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
print(plans[0] if plans else "")
|
||||
PY
|
||||
)"
|
||||
if [[ -n "$_plan_abs" ]]; then
|
||||
PLAN_PATH="${_plan_abs#"$PROJECT_ROOT/"}"
|
||||
fi
|
||||
fi
|
||||
|
||||
CTX_PATH="$PROJECT_ROOT/$CONTEXT_FILE"
|
||||
mkdir -p "$(dirname "$CTX_PATH")"
|
||||
|
||||
# Build the managed section
|
||||
TMP_SECTION="$(mktemp)"
|
||||
trap 'rm -f "$TMP_SECTION"' EXIT
|
||||
{
|
||||
echo "$MARKER_START"
|
||||
echo "For additional context about technologies to be used, project structure,"
|
||||
echo "shell commands, and other important information, read the current plan"
|
||||
if [[ -n "$PLAN_PATH" ]]; then
|
||||
echo "at $PLAN_PATH"
|
||||
fi
|
||||
echo "$MARKER_END"
|
||||
} > "$TMP_SECTION"
|
||||
|
||||
"$_python" - "$CTX_PATH" "$MARKER_START" "$MARKER_END" "$TMP_SECTION" <<'PY'
|
||||
import sys, os
|
||||
ctx_path, start, end, section_path = sys.argv[1:5]
|
||||
with open(section_path, "r", encoding="utf-8") as fh:
|
||||
section = fh.read().rstrip("\n") + "\n"
|
||||
|
||||
if os.path.exists(ctx_path):
|
||||
with open(ctx_path, "r", encoding="utf-8-sig") as fh:
|
||||
content = fh.read()
|
||||
s = content.find(start)
|
||||
e = content.find(end, s if s != -1 else 0)
|
||||
if s != -1 and e != -1 and e > s:
|
||||
end_of_marker = e + len(end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
end_of_marker += 1
|
||||
new_content = content[:s] + section + content[end_of_marker:]
|
||||
elif s != -1:
|
||||
new_content = content[:s] + section
|
||||
elif e != -1:
|
||||
end_of_marker = e + len(end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
end_of_marker += 1
|
||||
new_content = section + content[end_of_marker:]
|
||||
else:
|
||||
if content and not content.endswith("\n"):
|
||||
content += "\n"
|
||||
new_content = (content + "\n" + section) if content else section
|
||||
else:
|
||||
new_content = section
|
||||
|
||||
new_content = new_content.replace("\r\n", "\n").replace("\r", "\n")
|
||||
with open(ctx_path, "wb") as fh:
|
||||
fh.write(new_content.encode("utf-8"))
|
||||
PY
|
||||
|
||||
echo "agent-context: updated $CONTEXT_FILE"
|
||||
@@ -0,0 +1,237 @@
|
||||
#!/usr/bin/env pwsh
|
||||
# update-agent-context.ps1
|
||||
#
|
||||
# Refresh the managed Spec Kit section in the coding agent's context file
|
||||
# (e.g. CLAUDE.md, .github/copilot-instructions.md, AGENTS.md).
|
||||
#
|
||||
# Reads `context_file` and `context_markers.{start,end}` from the
|
||||
# agent-context extension config:
|
||||
# .specify/extensions/agent-context/agent-context-config.yml
|
||||
#
|
||||
# Usage: update-agent-context.ps1 [plan_path]
|
||||
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Position = 0)]
|
||||
[string]$PlanPath
|
||||
)
|
||||
|
||||
function Get-ConfigValue {
|
||||
param(
|
||||
[AllowNull()][object]$Object,
|
||||
[Parameter(Mandatory = $true)][string]$Key
|
||||
)
|
||||
|
||||
if ($null -eq $Object) {
|
||||
return $null
|
||||
}
|
||||
if ($Object -is [System.Collections.IDictionary]) {
|
||||
return $Object[$Key]
|
||||
}
|
||||
$prop = $Object.PSObject.Properties[$Key]
|
||||
if ($prop) {
|
||||
return $prop.Value
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
function Test-ConfigObject {
|
||||
param(
|
||||
[AllowNull()][object]$Object
|
||||
)
|
||||
|
||||
if ($null -eq $Object) {
|
||||
return $false
|
||||
}
|
||||
if ($Object -is [System.Collections.IDictionary]) {
|
||||
return $true
|
||||
}
|
||||
if ($Object -is [System.Management.Automation.PSCustomObject]) {
|
||||
return $true
|
||||
}
|
||||
return $false
|
||||
}
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$DefaultStart = '<!-- SPECKIT START -->'
|
||||
$DefaultEnd = '<!-- SPECKIT END -->'
|
||||
$ProjectRoot = (Get-Location).Path
|
||||
$ExtConfig = Join-Path $ProjectRoot '.specify/extensions/agent-context/agent-context-config.yml'
|
||||
|
||||
if (-not (Test-Path -LiteralPath $ExtConfig)) {
|
||||
Write-Warning "agent-context: $ExtConfig not found; nothing to do."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$Options = $null
|
||||
if (Get-Command ConvertFrom-Yaml -ErrorAction SilentlyContinue) {
|
||||
try {
|
||||
$Options = Get-Content -LiteralPath $ExtConfig -Raw | ConvertFrom-Yaml -ErrorAction Stop
|
||||
} catch {
|
||||
# fall through to Python fallback
|
||||
}
|
||||
}
|
||||
|
||||
if ($null -eq $Options) {
|
||||
# ConvertFrom-Yaml unavailable or failed; fall back to Python+PyYAML.
|
||||
$pythonCmd = $null
|
||||
foreach ($candidate in @('python3', 'python')) {
|
||||
if (Get-Command $candidate -ErrorAction SilentlyContinue) {
|
||||
# Verify it is Python 3
|
||||
$verOut = & $candidate --version 2>&1
|
||||
if ($verOut -match 'Python 3') {
|
||||
$pythonCmd = $candidate
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ($pythonCmd) {
|
||||
try {
|
||||
$jsonOut = & $pythonCmd -c @'
|
||||
import json
|
||||
import sys
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print(
|
||||
"agent-context: PyYAML is required to parse extension config; cannot update context.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
try:
|
||||
with open(sys.argv[1], "r", encoding="utf-8") as fh:
|
||||
data = yaml.safe_load(fh)
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"agent-context: unable to parse {sys.argv[1]} ({exc}); cannot update context.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
sys.exit(2)
|
||||
|
||||
if not isinstance(data, dict):
|
||||
data = {}
|
||||
|
||||
print(json.dumps(data))
|
||||
'@ $ExtConfig
|
||||
if ($LASTEXITCODE -eq 0 -and $jsonOut) {
|
||||
$Options = $jsonOut | ConvertFrom-Json -ErrorAction Stop
|
||||
}
|
||||
} catch {
|
||||
$Options = $null
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $Options) {
|
||||
Write-Warning "agent-context: unable to parse $ExtConfig; skipping update."
|
||||
exit 0
|
||||
}
|
||||
}
|
||||
|
||||
if (-not (Test-ConfigObject -Object $Options)) {
|
||||
Write-Warning "agent-context: $ExtConfig must contain a YAML mapping; skipping update."
|
||||
exit 0
|
||||
}
|
||||
|
||||
$ContextFile = Get-ConfigValue -Object $Options -Key 'context_file'
|
||||
if (-not $ContextFile) {
|
||||
Write-Warning 'agent-context: context_file not set in extension config; nothing to do.'
|
||||
exit 0
|
||||
}
|
||||
|
||||
# Reject absolute paths and '..' path segments in context_file
|
||||
if ([System.IO.Path]::IsPathRooted($ContextFile)) {
|
||||
Write-Warning "agent-context: context_file must be a project-relative path; got '$ContextFile'."
|
||||
exit 1
|
||||
}
|
||||
$cfSegments = $ContextFile -split '[/\\]'
|
||||
if ($cfSegments -contains '..') {
|
||||
Write-Warning "agent-context: context_file must not contain '..' path segments; got '$ContextFile'."
|
||||
exit 1
|
||||
}
|
||||
|
||||
$MarkerStart = $DefaultStart
|
||||
$MarkerEnd = $DefaultEnd
|
||||
$cm = Get-ConfigValue -Object $Options -Key 'context_markers'
|
||||
if ($cm) {
|
||||
$cmStart = Get-ConfigValue -Object $cm -Key 'start'
|
||||
if ($cmStart -is [string] -and $cmStart) {
|
||||
$MarkerStart = $cmStart
|
||||
}
|
||||
$cmEnd = Get-ConfigValue -Object $cm -Key 'end'
|
||||
if ($cmEnd -is [string] -and $cmEnd) {
|
||||
$MarkerEnd = $cmEnd
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $PlanPath) {
|
||||
# Discover plan.md exactly one level deep (specs/<feature>/plan.md),
|
||||
# matching the bash glob specs/*/plan.md. Wrap in try/catch so access errors under
|
||||
# $ErrorActionPreference = 'Stop' don't abort the script.
|
||||
try {
|
||||
$specsDir = Join-Path $ProjectRoot 'specs'
|
||||
$candidate = Get-ChildItem -Path $specsDir -Directory -ErrorAction SilentlyContinue |
|
||||
ForEach-Object { Get-Item -LiteralPath (Join-Path $_.FullName 'plan.md') -ErrorAction SilentlyContinue } |
|
||||
Where-Object { $_ } |
|
||||
Sort-Object LastWriteTime -Descending |
|
||||
Select-Object -First 1
|
||||
if ($candidate) {
|
||||
$PlanPath = [System.IO.Path]::GetRelativePath($ProjectRoot, $candidate.FullName).Replace('\','/')
|
||||
}
|
||||
} catch {
|
||||
# Non-fatal: continue without a plan path.
|
||||
}
|
||||
}
|
||||
|
||||
$CtxPath = Join-Path $ProjectRoot $ContextFile
|
||||
$CtxDir = Split-Path -Parent $CtxPath
|
||||
if ($CtxDir -and -not (Test-Path -LiteralPath $CtxDir)) {
|
||||
New-Item -ItemType Directory -Path $CtxDir -Force | Out-Null
|
||||
}
|
||||
|
||||
$lines = @($MarkerStart,
|
||||
'For additional context about technologies to be used, project structure,',
|
||||
'shell commands, and other important information, read the current plan')
|
||||
if ($PlanPath) {
|
||||
$lines += "at $PlanPath"
|
||||
}
|
||||
$lines += $MarkerEnd
|
||||
$Section = ($lines -join "`n") + "`n"
|
||||
|
||||
if (Test-Path -LiteralPath $CtxPath) {
|
||||
$rawBytes = [System.IO.File]::ReadAllBytes($CtxPath)
|
||||
# Strip UTF-8 BOM if present
|
||||
if ($rawBytes.Length -ge 3 -and $rawBytes[0] -eq 0xEF -and $rawBytes[1] -eq 0xBB -and $rawBytes[2] -eq 0xBF) {
|
||||
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes, 3, $rawBytes.Length - 3)
|
||||
} else {
|
||||
$content = [System.Text.Encoding]::UTF8.GetString($rawBytes)
|
||||
}
|
||||
|
||||
$s = $content.IndexOf($MarkerStart)
|
||||
$e = if ($s -ge 0) { $content.IndexOf($MarkerEnd, $s) } else { $content.IndexOf($MarkerEnd) }
|
||||
|
||||
if ($s -ge 0 -and $e -ge 0 -and $e -gt $s) {
|
||||
$endOfMarker = $e + $MarkerEnd.Length
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
|
||||
$newContent = $content.Substring(0, $s) + $Section + $content.Substring($endOfMarker)
|
||||
} elseif ($s -ge 0) {
|
||||
$newContent = $content.Substring(0, $s) + $Section
|
||||
} elseif ($e -ge 0) {
|
||||
$endOfMarker = $e + $MarkerEnd.Length
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`r") { $endOfMarker++ }
|
||||
if ($endOfMarker -lt $content.Length -and $content[$endOfMarker] -eq "`n") { $endOfMarker++ }
|
||||
$newContent = $Section + $content.Substring($endOfMarker)
|
||||
} else {
|
||||
if ($content -and -not $content.EndsWith("`n")) { $content += "`n" }
|
||||
if ($content) { $newContent = $content + "`n" + $Section } else { $newContent = $Section }
|
||||
}
|
||||
} else {
|
||||
$newContent = $Section
|
||||
}
|
||||
|
||||
$newContent = $newContent.Replace("`r`n", "`n").Replace("`r", "`n")
|
||||
[System.IO.File]::WriteAllText($CtxPath, $newContent, (New-Object System.Text.UTF8Encoding($false)))
|
||||
|
||||
Write-Host "agent-context: updated $ContextFile"
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-05-28T00:00:00Z",
|
||||
"updated_at": "2026-06-01T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -1724,6 +1724,37 @@
|
||||
"created_at": "2026-05-04T02:51:52Z",
|
||||
"updated_at": "2026-05-04T02:51:52Z"
|
||||
},
|
||||
"multi-sites": {
|
||||
"name": "Multi-Sites Spec Kit",
|
||||
"id": "multi-sites",
|
||||
"description": "Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support",
|
||||
"author": "teeyo",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/teeyo/spec-kit-multi-sites/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/teeyo/spec-kit-multi-sites",
|
||||
"homepage": "https://github.com/teeyo/spec-kit-multi-sites",
|
||||
"documentation": "https://github.com/teeyo/spec-kit-multi-sites/blob/main/README.md",
|
||||
"changelog": "https://github.com/teeyo/spec-kit-multi-sites/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"multi-site",
|
||||
"drupal",
|
||||
"workflow",
|
||||
"process"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-06-01T00:00:00Z",
|
||||
"updated_at": "2026-06-01T00:00:00Z"
|
||||
},
|
||||
"onboard": {
|
||||
"name": "Onboard",
|
||||
"id": "onboard",
|
||||
@@ -1950,12 +1981,12 @@
|
||||
"name": "Product Spec Extension",
|
||||
"id": "product",
|
||||
"description": "Generates PRFAQ, Lean PRD, stakeholder summaries, and technical designs from engineering specs.",
|
||||
"author": "spec-kit-product contributors",
|
||||
"version": "0.1.3",
|
||||
"download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v0.1.3/product-0.1.3.zip",
|
||||
"author": "d0whc3r",
|
||||
"version": "0.8.3",
|
||||
"download_url": "https://github.com/d0whc3r/spec-kit-product/releases/download/v0.8.3/product-0.8.3.zip",
|
||||
"repository": "https://github.com/d0whc3r/spec-kit-product",
|
||||
"homepage": "https://github.com/d0whc3r/spec-kit-product",
|
||||
"documentation": "https://github.com/d0whc3r/spec-kit-product/blob/main/README.md",
|
||||
"homepage": "https://d0whc3r.github.io/spec-kit-product/",
|
||||
"documentation": "https://github.com/d0whc3r/spec-kit-product/wiki",
|
||||
"changelog": "https://github.com/d0whc3r/spec-kit-product/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
@@ -1963,20 +1994,30 @@
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 6
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"product",
|
||||
"spec",
|
||||
"prd",
|
||||
"design",
|
||||
"documentation"
|
||||
"documentation",
|
||||
"jtbd",
|
||||
"lean-prd",
|
||||
"planning",
|
||||
"prd",
|
||||
"prfaq",
|
||||
"product",
|
||||
"product-management",
|
||||
"requirements",
|
||||
"spec",
|
||||
"spec-kit",
|
||||
"spec-kit-extension",
|
||||
"stakeholder",
|
||||
"technical-design"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-26T00:00:00Z",
|
||||
"updated_at": "2026-05-26T00:00:00Z"
|
||||
"updated_at": "2026-06-01T00:00:00Z"
|
||||
},
|
||||
"product-forge": {
|
||||
"name": "Product Forge",
|
||||
@@ -2218,8 +2259,8 @@
|
||||
"id": "reqnroll-bdd",
|
||||
"description": "Adds Reqnroll BDD planning, Gherkin generation, traceability, safe task injection, handoff, and verification to Spec Kit.",
|
||||
"author": "LoogaCY Studio",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd/archive/refs/tags/v1.1.0.zip",
|
||||
"repository": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
|
||||
"homepage": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd",
|
||||
"documentation": "https://github.com/LoogacyStudio/spec-kit-reqnroll-bdd#readme",
|
||||
@@ -2249,7 +2290,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-13T00:00:00Z",
|
||||
"updated_at": "2026-05-13T00:00:00Z"
|
||||
"updated_at": "2026-05-30T00:00:00Z"
|
||||
},
|
||||
"retro": {
|
||||
"name": "Retro Extension",
|
||||
|
||||
@@ -3,6 +3,20 @@
|
||||
"updated_at": "2026-04-10T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.json",
|
||||
"extensions": {
|
||||
"agent-context": {
|
||||
"name": "Coding Agent Context",
|
||||
"id": "agent-context",
|
||||
"version": "1.0.0",
|
||||
"description": "Manages coding agent context/instruction files (e.g., CLAUDE.md, copilot-instructions.md) with project-specific plan references and configurable markers",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"bundled": true,
|
||||
"tags": [
|
||||
"agent",
|
||||
"context",
|
||||
"core"
|
||||
]
|
||||
},
|
||||
"git": {
|
||||
"name": "Git Branching Workflow",
|
||||
"id": "git",
|
||||
|
||||
138
newsletters/2026-May.md
Normal file
138
newsletters/2026-May.md
Normal file
@@ -0,0 +1,138 @@
|
||||
# Spec Kit - May 2026 Newsletter
|
||||
|
||||
This edition covers Spec Kit activity in May 2026 — a month defined by three milestone 100s: **100,000+ stars**, **100+ community extensions**, and recognition as a **top-100 GitHub project**. Fourteen releases shipped (v0.8.4 through v0.8.17), delivering multi-agent install support, constitution governance enforcement, and continued architecture cleanup. The Open Source Friday livestream, a wave of multilingual coverage, and analyst recognition from The Futurum Group marked the project's transition from fast-moving experiment to established ecosystem. A summary is in the table below, followed by details.
|
||||
|
||||
| **Spec Kit Core (May 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
|
||||
| --- | --- | --- |
|
||||
| Fourteen releases shipped with key features: multi-install for concurrent agent integrations, constitution governance in implement, authentication provider registry, Hermes and Lingma agents, and a `__init__.py` decomposition series. The repo grew from ~92k to **106,951 stars**, crossing **100K** on May 21. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog crossed **100 entries** (now 105). Open Source Friday livestream drove a press wave: Visual Studio Magazine, DevOps.com, MarkTechPost, HackerNoon, and 25+ more articles — now tracked across multiple languages following an expanded discovery methodology. **217 contributors** now listed. | MarkTechPost called Spec Kit "the most community-adopted open-source option" for SDD. The Futurum Group's Mitch Ashley framed specs as "the unit of governance across agents and contributors." Truong Phung published a 61-min production playbook referencing Spec Kit. Competitors grew but differentiate on orchestration; Spec Kit leads in portability and community. |
|
||||
|
||||
***
|
||||
|
||||
> **A Month of 100s.** May 2026 was defined by three milestones that all share the same number. The community extension catalog crossed **100 entries** during the week of May 21, making Spec Kit a genuine platform with more capabilities in its ecosystem than in its core. The repository crossed **100,000 GitHub stars** on the same week. And with 107K stars at month's end, Spec Kit now ranks among the **top 100 most-starred projects on all of GitHub**. None of this would have happened without the community — the contributors, extension authors, preset builders, article writers, and practitioners who turned a spec-driven development experiment into an ecosystem. Thank you.
|
||||
|
||||
## Spec Kit Project Updates
|
||||
|
||||
### Releases Overview
|
||||
|
||||
**v0.8.4–v0.8.7** (May 1–7) opened the month with four patch releases delivering the most-requested feature of the year: **multi-install support for concurrent AI agent integrations** (#2389), enabling multiple agents in a single project. This closed five long-standing issues dating back 228 days. The releases also added **constitution governance in `/speckit.implement`** (#2460), ensuring the implement phase now loads `constitution.md` to enforce governance during code generation. An **authentication provider registry** (#2393) added config-driven multi-platform auth. The **Lingma agent** joined the integration roster. Security hardening included pinning all remaining GitHub Actions to immutable SHAs (#2441) and URL scheme validation to prevent SSRF-style bugs (#2449). Seven new community extensions and six new governance-themed presets landed. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.8.8–v0.8.10** (May 8–14) shipped three releases focused on stability. **Version feature reporting** (#2548) improved upgrade visibility. Bug fixes addressed the Kiro CLI `$ARGUMENTS` placeholder (#1926, open 52 days), markdownlint-safe template metadata (#1343, open 147 days), and preset skill description precedence. The `__init__.py` decomposition series began with PRs 1–2/8, extracting `_console.py`, `_assets.py`, and `_utils.py`. Seven new extensions joined (Architecture Workflow, Agent Governance, BrownKit, Schedule, Reqnroll BDD, MDE, Changelog) along with two new presets (MDE, game-narrative-writing). The docs site received a major overhaul: the landing page was revamped with a four-pillar card layout, the install section was streamlined, and the community extensions table moved to the docs site. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.8.11–v0.8.13** (May 15–21) delivered three releases as the repo **crossed 100K stars**. **Agentic catalog submissions** (#2655) added AI-assisted workflows for community catalog contributions. A **high-assurance spec workflow** was documented (#2518). The while/do-while loop stale output bug (#2592) was caught and fixed same-day. **Integration auto mode** (#2421) now follows the project's initialized AI instead of hardcoding Copilot. The PowerShell UTF-8 BOM issue (#2280) was resolved. Four new extensions joined (Team Assign, Interactive HTML Preview, Time Machine, Superpowers Implementation Bridge), bringing the catalog to **103 entries** — crossing the 100 mark. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.8.14–v0.8.17** (May 22–28) closed the month with four releases. The **Hermes Agent** joined as a new integration target (#2651). Workflows gained a **`{{ context.run_id }}` template variable** (#2664). A new `SPECKIT_INTEGRATION_<KEY>_EXTRA_ARGS` environment variable (#2596) lets users pass extra flags to agent subprocesses. **Extension installs from URLs now prompt for confirmation** (#2745), a security improvement for URL-based installs. The spec quality checklist is now **re-validated after clarify updates the spec** (#2715). Token Budget, Product Spec, and Workflow Preset extensions joined the catalog, bringing it to **105 entries**. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### Architecture & Refactoring
|
||||
|
||||
The most significant internal effort in May was the **`__init__.py` decomposition series**, progressing through PRs 1–4 of 8. This systematic extraction moved `_console.py`, `_assets.py`, `_utils.py`, `_version.py`, and the `commands/` package out of the monolithic init module, improving maintainability and contributor onboarding. The **ExtensionCatalog was migrated to the shared catalog stack base** (#2437), reducing duplicated catalog handling across extension, preset, and integration catalogs. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### Bug Fixes and Security
|
||||
|
||||
Fourteen releases produced a strong cadence of fixes. Long-standing issues resolved include the Kiro CLI `$ARGUMENTS` placeholder (52 days), markdownlint template metadata line breaks (147 days), and the `--ai` flag for adding agent commands (136 days). The PowerShell UTF-8 BOM issue was fixed, preset skill rendering now correctly resolves `__SPECKIT_COMMAND_*__` refs (#2717), and a Windows gate-step crash was addressed (#2635).
|
||||
|
||||
Security improvements included **URL-based extension install confirmation** (#2745), **pinning GitHub Actions to immutable SHAs** (#2441), **URL scheme validation** (#2449), and restricting community submission workflows to labeled events only (#2741). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Extension & Preset Ecosystem
|
||||
|
||||
The community extension catalog grew from 92 to **105 entries** during May, crossing the **100 mark** on May 21. Thirteen new extensions were added over the month. Community presets grew from 18 to **21 entries**, with three new presets added.
|
||||
|
||||
Notable new extensions by category:
|
||||
|
||||
- **Architecture & governance**: Architecture Workflow (bigsmartben), Agent Governance (bigben), Architecture Guard (DyanGalih), BrownKit (Maksim Shautsou)
|
||||
- **Cost & token management**: Cost Tracker (Quratulain-bilal), Token Analyzer (Chris Roberts), Token Budget (Tine Kondo)
|
||||
- **Agent orchestration**: Agent Orchestrator (pragya247), Multi-Model Review (formin)
|
||||
- **Project management**: Team Assign (tarunkumarbhati), Changelog (Quratulain-bilal)
|
||||
- **Cloud & enterprise**: Spec2Cloud for Azure (Azure Samples), .NET Framework to Modern .NET Migration (RogerBestMsft)
|
||||
- **API & lifecycle**: API Evolve (Quratulain-bilal), Product Spec (spec-kit-product contributors)
|
||||
- **Quality**: Schedule with CP-SAT solver (Julio César Franco Ardila), Reqnroll BDD (LoogaCY Studio), MDE (AI-MDE)
|
||||
- **Spec exploration**: Interactive HTML Preview (bigsmartben), Time Machine (te3yo)
|
||||
- **Cross-tool bridges**: Superpowers Implementation Bridge (lihan3238)
|
||||
|
||||
New governance-themed presets dominated: a11y-governance, architecture-governance, security-governance, cross-platform-governance, agent-parity-governance, and Spec2Cloud preset. Creative presets included game-narrative-writing and MDE.
|
||||
|
||||
The extension ecosystem also showed maturation through active maintenance. **Architecture Guard** progressed through four releases (v1.6.7 → v1.8.9), adding documentation quality improvements and governance features. **Memory MD** shipped multiple updates (v0.6.9 → v0.8.0), adding a `speckit.memory-md.log-finding` command. **Security Review** reached v1.4.5 with a new `speckit.security-review.log-finding` command. **Superpowers Implementation Bridge** evolved rapidly (v0.5.0 → v0.7.0). **Squad Bridge** updated to v1.3.0, **Fiction Book Writing** to v1.8.1, **Security Governance** to v0.4.0, and **MemoryLint** to v1.4.0. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
|
||||
|
||||
### Documentation & Docs Site
|
||||
|
||||
The docs site received its most significant update since launch. The **landing page was revamped** with a four-pillar card layout (#2531). The **install section was streamlined** (#2561). The **community extensions table** was moved from the README to the docs site (#2560), reducing README length while improving discoverability. **Community sections in the README** were consolidated (#2736). The **uv installation guide** was added with inline callouts (#2465). Landing page stats and branch naming conventions were updated (#2727). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
## Community & Content
|
||||
|
||||
### The Open Source Friday Livestream
|
||||
|
||||
On **May 8**, the **GitHub Open Source Friday livestream** featured Spec Kit, hosted by Andrea Griffiths with lead maintainer Manfred Riem. The livestream demonstrated a full SDD workflow building a time-zone-aware command-line utility with GitHub Copilot in VS Code. Riem described AI agents as "a very capable intern and a very quick intern but it's still an intern nonetheless." He emphasized that "the spec is always the source of truth" and highlighted the community ecosystem, noting the project was "nearing the 100 mark" for extensions. The livestream drove significant press attention in the following days. [\[youtube.com\]](https://www.youtube.com/watch?v=2IArMAhkJcE)
|
||||
|
||||
### Press and Industry Coverage
|
||||
|
||||
May produced the broadest press coverage to date, with publications from the mainstream developer media covering Spec Kit for the first time.
|
||||
|
||||
**Visual Studio Magazine** (David Ramel, May 12) published *"GitHub Spec Kit Takes Off as Antidote to Piecemeal 'Vibe Coding'"*, reporting on the Open Source Friday livestream and the growing ecosystem. The article noted Spec Kit's story is "no longer just that GitHub open sourced a spec-driven development toolkit last fall" but that "the toolkit is becoming a fast-moving ecosystem for teams trying to make AI-assisted development more structured, repeatable and traceable." [\[visualstudiomagazine.com\]](https://visualstudiomagazine.com/articles/2026/05/12/github-spec-kit-takes-off-as-antidote-to-piecemeal-vibe-coding.aspx)
|
||||
|
||||
**DevOps.com** (Tom Smith, May 11) published *"GitHub's Spec Kit Puts the Spec Back in Software Development"*, featuring analyst commentary from The Futurum Group (see The Analyst View below). [\[devops.com\]](https://devops.com/githubs-spec-kit-puts-the-spec-back-in-software-development/)
|
||||
|
||||
**MarkTechPost** (Asif Razzaq, May 8) published two articles: a comprehensive step-by-step tutorial calling Spec Kit an open-source toolkit with "90k+ stars" and "one of the faster-growing developer tooling repositories," and a 9-tool SDD comparison calling Spec Kit **"the most community-adopted open-source option"** and "the default starting point for teams new to SDD." [\[marktechpost.com\]](https://www.marktechpost.com/2026/05/08/meet-github-spec-kit-an-open-source-toolkit-for-spec-driven-development-with-ai-coding-agents/)
|
||||
|
||||
**HackerNoon** (Andrey Kucherenko, May 6) published *"The Spec-First Development Showdown"*, a hands-on comparison of Spec Kit, OpenSpec, BMAD, and Gangsta Agents. [\[hackernoon.com\]](https://hackernoon.com/the-spec-first-development-showdown-spec-kit-openspec-bmad-and-gangsta-agents-compared)
|
||||
|
||||
### Developer Articles and Blog Posts
|
||||
|
||||
May produced a wave of independent coverage — well beyond any previous month. Starting this month, article discovery was expanded beyond English-centric search engines to include language-appropriate engines for 25+ languages, so the broader coverage partly reflects wider discovery rather than a sudden spike.
|
||||
|
||||
Notable non-English coverage:
|
||||
|
||||
- **Japanese**: テックオーシャン published a detailed experience report on *"Claude Code × Spec Kit"* on note.com, praising task decomposition accuracy while noting spec sync requires manual workarounds. [\[note.com\]](https://note.com/techocean_corp/n/nd2bd63106c16)
|
||||
- **Portuguese**: Jady Sobjak de Mello Godoi published *"GitHub Spec Kit: Revolucionando o Desenvolvimento com SDD"* on DEV Community. [\[dev.to\]](https://dev.to/jadysmgodoi/github-speckit-revolucionando-o-desenvolvimento-com-sdd-l66)
|
||||
- **Italian**: Cosmonet published a comprehensive guide, *"GitHub Spec Kit: la guida completa allo Spec-Driven Development."* [\[cosmonet.info\]](https://www.cosmonet.info/github-spec-kit-guida-spec-driven-development/)
|
||||
- **French**: InnoSpira covered Spec Kit's rapid growth past 100K stars. [\[innospira.fr\]](https://www.innospira.fr/index.php/2026/05/12/github-spec-kit-place-au-developpement-pilote-par-la-spec/)
|
||||
- **Spanish**: Q2B Studio published an overview for Spanish-speaking developers. [\[q2bstudio.com\]](https://www.q2bstudio.com/nuestro-blog/1727819/github-spec-kit-desarrollo-especificaciones-ia)
|
||||
|
||||
Notable English-language articles:
|
||||
|
||||
- **Truong Phung** (DEV Community, May 29) published a comprehensive production playbook for AI-assisted development, referencing Spec Kit (see The Production Playbook Pattern below). [\[dev.to\]](https://dev.to/truongpx396/building-production-grade-fullstack-products-with-ai-coding-agents-a-practical-playbook-2idd)
|
||||
- **Mehul Gupta** (Medium, May 17) called Spec Kit "an operating system for AI-assisted software engineering." [\[medium.com\]](https://medium.com/data-science-in-your-pocket/what-is-github-spec-kit-bye-bye-vibe-coding-37efbaa32880)
|
||||
- **Kento IKEDA** (DEV Community / AWS Builders, May 2) examined the emerging three-layer pattern for AI agent instructions (AGENTS.md, SKILL.md, DESIGN.md), referencing Spec Kit's approach. [\[dev.to\]](https://dev.to/aws-builders/agentsmd-skillmd-designmd-how-ai-instructions-split-into-three-layers-d0g)
|
||||
- **PyShine** (May 13) published a detailed guide covering the 6-step workflow, 30+ integrations, and 60+ extensions. [\[pyshine.com\]](https://pyshine.com/GitHub-Spec-Kit-Spec-Driven-Development/)
|
||||
- **DeployHQ** (Alex M, May 13) examined the "deployment gap" — Spec Kit ends at code, Workspaces ends at PR — and showed how to wire DeployHQ into the post-merge step. [\[deployhq.com\]](https://www.deployhq.com/blog/spec-kit-copilot-workspaces-deployment)
|
||||
- **spec-coding.dev** (May 11) examined five practical SDD patterns shared by OpenSpec, Superpowers, and Spec Kit. [\[spec-coding.dev\]](https://spec-coding.dev/blog/spec-driven-development-tools-openspec-spec-kit-superpowers)
|
||||
- **kiadev.net** (Ignaty Kashnitsky, May 9) published two articles: a detailed technical protocol and a 9-tool comparison recommending Spec Kit as a "portable, community-driven starting point." [\[kiadev.net\]](https://www.kiadev.net/news/2026-05-09-github-spec-kit-sdd-toolkit)
|
||||
|
||||
Coverage also appeared on WinBuzzer, Let's Data Science, Openflows, AI in Plain English (Medium), Artiverse, KnightLi Blog (multilingual EN/CN/JP/ES), and fundesk.io.
|
||||
|
||||
### Community Growth by the Numbers
|
||||
|
||||
| Metric | Start of May | End of May | Change |
|
||||
| --- | --- | --- | --- |
|
||||
| GitHub stars | 92,038 | 106,951 | +14,913 (+16%) |
|
||||
| Forks | ~8,000 | 9,464 | +~1,500 |
|
||||
| Contributors | — | 217 | — |
|
||||
| Releases (total) | 135 | 152 | +17 (incl. 3 late-April) |
|
||||
| Community extensions | 92 | 105 | +13 |
|
||||
| Community presets | 18 | 21 | +3 |
|
||||
| Discussions (open) | ~400 | 422 | +~22 |
|
||||
|
||||
## SDD Ecosystem & Industry Trends
|
||||
|
||||
### The Analyst View
|
||||
|
||||
The Futurum Group's **Mitch Ashley** provided the most significant analyst framing of SDD to date on DevOps.com: "GitHub's Spec Kit signals AI-assisted coding is shifting from prompts to durable, versioned specifications. Vendors are competing to own the artifact that governs intent across Copilot, Claude Code, and Gemini CLI." He warned that "verification at each checkpoint cannot be deferred to the agent producing it" — echoing the project's own emphasis on human oversight at phase boundaries. [\[devops.com\]](https://devops.com/githubs-spec-kit-puts-the-spec-back-in-software-development/)
|
||||
|
||||
### The Production Playbook Pattern
|
||||
|
||||
**Truong Phung's** 61-minute production playbook represented a new level of depth in community content. Rather than reviewing Spec Kit as a tool, Phung treated SDD as a given and built a comprehensive guide around the **Spec → Plan → Code → Verify loop**, with Spec Kit and Superpowers as the reference implementations. His seven opening truths — "the bottleneck moved from typing to thinking," "context engineering > prompt engineering," and "the PR is the unit of work, not the ticket" — capture the emerging practitioner consensus around structured AI development. [\[dev.to\]](https://dev.to/truongpx396/building-production-grade-fullstack-products-with-ai-coding-agents-a-practical-playbook-2idd)
|
||||
|
||||
### Competitive Landscape
|
||||
|
||||
The **MarkTechPost comparison** of nine SDD tools called Spec Kit "the most community-adopted open-source option," while positioning competitors along distinct axes: **Kiro** (integrated IDE with EARS-based specs and agent hooks), **BMAD-METHOD** (~48K stars, 12+ specialized agents), **GSD** (~64K stars, lean meta-prompting), **Augment Code** (context engine for 400K+ files, not a spec authoring tool), **OpenSpec** (~52K stars, change accountability and audit trails), and **Tessl** (spec registry with 10K+ library specs). [\[marktechpost.com\]](https://www.marktechpost.com/2026/05/08/9-best-ai-tools-for-spec-driven-development-in-2026-kiro-bmad-gsd-and-more-compare/)
|
||||
|
||||
With 107K stars at month's end, Spec Kit is the **only spec-driven development tool in the top 100 most-starred repositories on GitHub** — none of the competitors above are close to the 100K threshold. The broader top-100 list includes AI-adjacent projects like agentic skills frameworks (obra/superpowers at 212K, anthropics/skills at 143K), agent harness tools, and LLM inference engines, but Spec Kit is the only one built around a spec-first development workflow. [\[github.com\]](https://github.com/search?q=stars%3A%3E100000&type=repositories&s=stars&o=desc)
|
||||
|
||||
## Roadmap
|
||||
|
||||
Areas under discussion or in progress for future development:
|
||||
|
||||
- **CLI architecture cleanup** — the `__init__.py` decomposition (4/8 complete) continues toward a modular command structure. This internal cleanup improves contributor onboarding and test isolation. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Spec lifecycle management** — spec drift and context rot remain the most cited concern across articles (DevOps.com, DeployHQ, テックオーシャン). The clarify re-validation (#2715) and reconcile extensions are incremental steps; a more comprehensive solution is expected. [\[devops.com\]](https://devops.com/githubs-spec-kit-puts-the-spec-back-in-software-development/)
|
||||
- **Multi-agent workflows** — multi-install support (#2389) was the most-requested feature. The next frontier is orchestrating multiple agents across phases, a pattern the community's MAQA, Fleet, and Conduct extensions already explore. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Catalog maturity** — catalog discovery CLI (v0.8.3), agentic submissions (v0.8.13), and GITHUB_TOKEN auth (v0.8.2) are building toward a package-manager experience. As the catalog grows past 100 entries, curation and quality signals become critical. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Experience simplification** — the deployment gap (DeployHQ), ceremony overhead for small tasks (テックオーシャン, spec-coding.dev), and verbose output (Thoughtworks Radar) continue as open concerns. The lean preset, TinySpec extension, and workflow engine provide answers; discoverability of these options remains an opportunity. [\[deployhq.com\]](https://www.deployhq.com/blog/spec-kit-copilot-workspaces-deployment)
|
||||
- **Toward a stable release** — fourteen releases in one month reflects pre-1.0 momentum. The git extension default-off notice (#2432, gated at v0.10.0) and the `--no-git` deprecation (removal at v0.10.0) signal a path toward API stabilization. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.8.17"
|
||||
version = "0.9.0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
requires-python = ">=3.11"
|
||||
dependencies = [
|
||||
@@ -40,6 +40,7 @@ packages = ["src/specify_cli"]
|
||||
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
||||
# Bundled extensions (installable via `specify extension add <name>`)
|
||||
"extensions/git" = "specify_cli/core_pack/extensions/git"
|
||||
"extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context"
|
||||
# Bundled workflows (auto-installed during `specify init`)
|
||||
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
|
||||
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
|
||||
|
||||
@@ -304,6 +304,72 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
|
||||
return {}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Agent-context extension config helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_AGENT_CTX_EXT_CONFIG = (
|
||||
Path(".specify") / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
)
|
||||
|
||||
|
||||
def _load_agent_context_config(project_root: Path) -> dict[str, Any]:
|
||||
"""Load the agent-context extension config, returning defaults on failure."""
|
||||
from .integrations.base import IntegrationBase
|
||||
|
||||
defaults: dict[str, Any] = {
|
||||
"context_file": "",
|
||||
"context_markers": {
|
||||
"start": IntegrationBase.CONTEXT_MARKER_START,
|
||||
"end": IntegrationBase.CONTEXT_MARKER_END,
|
||||
},
|
||||
}
|
||||
path = project_root / _AGENT_CTX_EXT_CONFIG
|
||||
if not path.exists():
|
||||
return defaults
|
||||
try:
|
||||
raw = yaml.safe_load(path.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, yaml.YAMLError):
|
||||
return defaults
|
||||
if not isinstance(raw, dict):
|
||||
return defaults
|
||||
return raw
|
||||
|
||||
|
||||
def _save_agent_context_config(
|
||||
project_root: Path, config: dict[str, Any]
|
||||
) -> None:
|
||||
"""Persist *config* to the agent-context extension config file."""
|
||||
path = project_root / _AGENT_CTX_EXT_CONFIG
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False), encoding="utf-8")
|
||||
|
||||
|
||||
def _update_agent_context_config_file(
|
||||
project_root: Path,
|
||||
context_file: str | None,
|
||||
*,
|
||||
preserve_markers: bool = True,
|
||||
) -> None:
|
||||
"""Update the agent-context extension config with *context_file*.
|
||||
|
||||
When *preserve_markers* is True (default), any existing
|
||||
``context_markers`` values are kept unchanged so user customisations
|
||||
survive integration changes and reinit. When False, the default
|
||||
markers are written unconditionally.
|
||||
"""
|
||||
from .integrations.base import IntegrationBase
|
||||
|
||||
cfg = _load_agent_context_config(project_root)
|
||||
cfg["context_file"] = context_file or ""
|
||||
if not preserve_markers or not isinstance(cfg.get("context_markers"), dict):
|
||||
cfg["context_markers"] = {
|
||||
"start": IntegrationBase.CONTEXT_MARKER_START,
|
||||
"end": IntegrationBase.CONTEXT_MARKER_END,
|
||||
}
|
||||
_save_agent_context_config(project_root, cfg)
|
||||
|
||||
|
||||
def _get_skills_dir(project_path: Path, selected_ai: str) -> Path:
|
||||
"""Resolve the agent-specific skills directory.
|
||||
|
||||
@@ -649,13 +715,31 @@ def _refresh_init_options_speckit_version(project_root: Path) -> None:
|
||||
|
||||
|
||||
def _clear_init_options_for_integration(project_root: Path, integration_key: str) -> None:
|
||||
"""Clear active integration keys from init-options.json when they match."""
|
||||
"""Clear active integration keys from init-options.json when they match.
|
||||
|
||||
Also clears ``context_file`` from the agent-context extension config so
|
||||
no stale path is left behind when the integration is uninstalled.
|
||||
"""
|
||||
opts = load_init_options(project_root)
|
||||
has_legacy_context_keys = ("context_file" in opts) or ("context_markers" in opts)
|
||||
# Remove legacy fields that older versions may have written.
|
||||
opts.pop("context_file", None)
|
||||
opts.pop("context_markers", None)
|
||||
|
||||
if opts.get("integration") == integration_key or opts.get("ai") == integration_key:
|
||||
opts.pop("integration", None)
|
||||
opts.pop("ai", None)
|
||||
opts.pop("ai_skills", None)
|
||||
opts.pop("context_file", None)
|
||||
save_init_options(project_root, opts)
|
||||
# Clear context_file in the extension config if it already exists.
|
||||
# Avoid creating the config (and parent dirs) in projects where the
|
||||
# agent-context extension was never installed.
|
||||
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
|
||||
if ext_cfg_path.exists():
|
||||
_update_agent_context_config_file(
|
||||
project_root, "", preserve_markers=True
|
||||
)
|
||||
elif has_legacy_context_keys:
|
||||
save_init_options(project_root, opts)
|
||||
|
||||
|
||||
@@ -1100,12 +1184,23 @@ def _update_init_options_for_integration(
|
||||
integration: Any,
|
||||
script_type: str | None = None,
|
||||
) -> None:
|
||||
"""Update ``init-options.json`` to reflect *integration* as the active one."""
|
||||
"""Update init-options.json and the agent-context extension config to
|
||||
reflect *integration* as the active one.
|
||||
|
||||
``context_file`` and ``context_markers`` are stored in the agent-context
|
||||
extension config (``.specify/extensions/agent-context/agent-context-config.yml``),
|
||||
not in ``init-options.json``. Existing user-customised markers are
|
||||
always preserved when the config already exists; invalid marker values
|
||||
are silently ignored at runtime by ``_resolve_context_markers()`` which
|
||||
falls back to the class-level defaults.
|
||||
"""
|
||||
from .integrations.base import SkillsIntegration
|
||||
opts = load_init_options(project_root)
|
||||
opts["integration"] = integration.key
|
||||
opts["ai"] = integration.key
|
||||
opts["context_file"] = integration.context_file
|
||||
# Remove legacy fields if they were written by an older version.
|
||||
opts.pop("context_file", None)
|
||||
opts.pop("context_markers", None)
|
||||
opts["speckit_version"] = get_speckit_version()
|
||||
if script_type:
|
||||
opts["script"] = script_type
|
||||
@@ -1113,6 +1208,25 @@ def _update_init_options_for_integration(
|
||||
opts["ai_skills"] = True
|
||||
else:
|
||||
opts.pop("ai_skills", None)
|
||||
|
||||
# Update the agent-context extension config BEFORE init-options.json
|
||||
# so a failure here doesn't leave init-options partially updated.
|
||||
ext_cfg_path = project_root / _AGENT_CTX_EXT_CONFIG
|
||||
if ext_cfg_path.exists():
|
||||
_update_agent_context_config_file(
|
||||
project_root,
|
||||
integration.context_file,
|
||||
preserve_markers=True,
|
||||
)
|
||||
elif integration.context_file:
|
||||
# Extension config doesn't exist yet (extension not installed).
|
||||
# Write defaults so scripts have something to read.
|
||||
_update_agent_context_config_file(
|
||||
project_root,
|
||||
integration.context_file,
|
||||
preserve_markers=False,
|
||||
)
|
||||
|
||||
save_init_options(project_root, opts)
|
||||
|
||||
|
||||
@@ -2968,6 +3082,43 @@ def extension_add(
|
||||
manager = ExtensionManager(project_root)
|
||||
speckit_version = get_speckit_version()
|
||||
|
||||
# Prompt for URL-based installs BEFORE the spinner so the user can
|
||||
# actually see and respond to the confirmation (the Rich status
|
||||
# spinner overwrites the typer.confirm prompt line, making it appear
|
||||
# as though the command is hung).
|
||||
# Guard with ``not dev`` so that --dev + --from does not show a
|
||||
# confusing confirmation for a URL that will be ignored.
|
||||
if from_url and not dev:
|
||||
from urllib.parse import urlparse
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
parsed = urlparse(from_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
console.print("[red]Error:[/red] URL must use HTTPS for security.")
|
||||
console.print("HTTP is only allowed for localhost URLs.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
safe_url = _escape_markup(from_url)
|
||||
|
||||
# Warn about untrusted sources — default-deny confirmation
|
||||
console.print()
|
||||
console.print(Panel(
|
||||
f"[bold]You are installing an extension from an external URL that is not\n"
|
||||
f"listed in any of your configured extension catalogs.[/bold]\n\n"
|
||||
f"URL: {safe_url}\n\n"
|
||||
f"Only install extensions from sources you trust.",
|
||||
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
))
|
||||
console.print()
|
||||
confirm = typer.confirm("Continue with installation?", default=False)
|
||||
if not confirm:
|
||||
console.print("Cancelled")
|
||||
raise typer.Exit(0)
|
||||
|
||||
try:
|
||||
with console.status(f"[cyan]Installing extension: {extension}[/cyan]"):
|
||||
if dev:
|
||||
@@ -2990,23 +3141,9 @@ def extension_add(
|
||||
|
||||
elif from_url:
|
||||
# Install from URL (ZIP file)
|
||||
import urllib.request
|
||||
import urllib.error
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# Validate URL
|
||||
parsed = urlparse(from_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
console.print("[red]Error:[/red] URL must use HTTPS for security.")
|
||||
console.print("HTTP is only allowed for localhost URLs.")
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Warn about untrusted sources
|
||||
console.print("[yellow]Warning:[/yellow] Installing from external URL.")
|
||||
console.print("Only install extensions from sources you trust.\n")
|
||||
console.print(f"Downloading from {from_url}...")
|
||||
console.print(f"Downloading from {safe_url}...")
|
||||
|
||||
# Download ZIP to temp location
|
||||
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
@@ -3023,7 +3160,7 @@ def extension_add(
|
||||
# Install from downloaded ZIP
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority)
|
||||
except urllib.error.URLError as e:
|
||||
console.print(f"[red]Error:[/red] Failed to download from {from_url}: {e}")
|
||||
console.print(f"[red]Error:[/red] Failed to download from {safe_url}: {e}")
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
# Clean up downloaded ZIP
|
||||
|
||||
@@ -374,8 +374,15 @@ class CommandRegistrar:
|
||||
|
||||
body = body.replace("{ARGS}", "$ARGUMENTS").replace("__AGENT__", agent_name)
|
||||
|
||||
# Resolve __CONTEXT_FILE__ from init-options
|
||||
context_file = init_opts.get("context_file") or ""
|
||||
# Resolve __CONTEXT_FILE__ from the agent-context extension config.
|
||||
# Fall back to init-options.json for projects that haven't migrated.
|
||||
# Local import: _load_agent_context_config lives in __init__.py which
|
||||
# imports agents.py, so a top-level import would be circular.
|
||||
from . import _load_agent_context_config
|
||||
ac_cfg = _load_agent_context_config(project_root)
|
||||
context_file = ac_cfg.get("context_file") or ""
|
||||
if not context_file:
|
||||
context_file = init_opts.get("context_file") or ""
|
||||
body = body.replace("__CONTEXT_FILE__", context_file)
|
||||
|
||||
return CommandRegistrar.rewrite_project_relative_paths(body)
|
||||
|
||||
@@ -153,6 +153,7 @@ def register(app: typer.Typer) -> None:
|
||||
_install_shared_infra_or_exit,
|
||||
_parse_integration_options,
|
||||
_print_cli_warning,
|
||||
_update_agent_context_config_file,
|
||||
_write_integration_json,
|
||||
ensure_executable_scripts,
|
||||
save_init_options,
|
||||
@@ -394,6 +395,7 @@ def register(app: typer.Typer) -> None:
|
||||
("constitution", "Constitution setup"),
|
||||
("git", "Install git extension"),
|
||||
("workflow", "Install bundled workflow"),
|
||||
("agent-context", "Install agent-context extension"),
|
||||
("final", "Finalize"),
|
||||
]:
|
||||
tracker.add(key, label)
|
||||
@@ -535,13 +537,10 @@ def register(app: typer.Typer) -> None:
|
||||
sanitized_wf = str(wf_err).replace('\n', ' ').strip()
|
||||
tracker.error("workflow", f"install failed: {sanitized_wf[:120]}")
|
||||
|
||||
ensure_executable_scripts(project_path, tracker=tracker)
|
||||
|
||||
init_opts = {
|
||||
"ai": selected_ai,
|
||||
"integration": resolved_integration.key,
|
||||
"branch_numbering": branch_numbering or "sequential",
|
||||
"context_file": resolved_integration.context_file,
|
||||
"here": here,
|
||||
"script": selected_script,
|
||||
"speckit_version": get_speckit_version(),
|
||||
@@ -551,6 +550,47 @@ def register(app: typer.Typer) -> None:
|
||||
init_opts["ai_skills"] = True
|
||||
save_init_options(project_path, init_opts)
|
||||
|
||||
# --- agent-context extension (bundled, auto-installed) ---
|
||||
# Installed after init-options.json is written so that skill
|
||||
# registration can read ai_skills + integration key.
|
||||
try:
|
||||
from ..extensions import ExtensionManager as _ExtMgr
|
||||
bundled_ac = _locate_bundled_extension("agent-context")
|
||||
if bundled_ac:
|
||||
ac_mgr = _ExtMgr(project_path)
|
||||
if ac_mgr.registry.is_installed("agent-context"):
|
||||
tracker.complete("agent-context", "already installed")
|
||||
else:
|
||||
ac_mgr.install_from_directory(
|
||||
bundled_ac, get_speckit_version()
|
||||
)
|
||||
tracker.complete("agent-context", "extension installed")
|
||||
else:
|
||||
from ..extensions import REINSTALL_COMMAND as _ac_reinstall
|
||||
tracker.error(
|
||||
"agent-context",
|
||||
f"bundled extension not found — installation may be "
|
||||
f"incomplete. Run: {_ac_reinstall}",
|
||||
)
|
||||
except Exception as ac_err:
|
||||
sanitized_ac = str(ac_err).replace('\n', ' ').strip()
|
||||
tracker.error(
|
||||
"agent-context",
|
||||
f"extension install failed: {sanitized_ac[:120]}",
|
||||
)
|
||||
|
||||
# Write context_file to the agent-context extension config
|
||||
# AFTER the extension install (which copies the template config
|
||||
# with an empty context_file).
|
||||
if resolved_integration.context_file:
|
||||
_update_agent_context_config_file(
|
||||
project_path,
|
||||
resolved_integration.context_file,
|
||||
preserve_markers=True,
|
||||
)
|
||||
|
||||
ensure_executable_scripts(project_path, tracker=tracker)
|
||||
|
||||
if preset:
|
||||
try:
|
||||
from ..presets import PresetManager, PresetCatalog, PresetError
|
||||
|
||||
@@ -90,7 +90,7 @@ class AgyIntegration(SkillsIntegration):
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
# agy does not support --model or JSON output; both params are ignored
|
||||
return ["agy", "--print", prompt]
|
||||
return [self._resolve_executable(), "--print", prompt]
|
||||
|
||||
def setup(
|
||||
self,
|
||||
|
||||
@@ -13,7 +13,10 @@ Provides:
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
from abc import ABC
|
||||
from dataclasses import dataclass
|
||||
@@ -144,6 +147,65 @@ class IntegrationBase(ABC):
|
||||
"""
|
||||
return None
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
"""Return the executable for this integration's CLI tool.
|
||||
|
||||
Checks ``SPECKIT_INTEGRATION_<KEY>_EXECUTABLE`` first, allowing
|
||||
operators to override the binary path without modifying the
|
||||
integration configuration — useful when the tool is installed in
|
||||
a non-standard location or a specific version must be pinned.
|
||||
Hyphens in the integration key are replaced with underscores and
|
||||
the key is uppercased so that, for example, ``kiro-cli`` maps to
|
||||
``SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE``.
|
||||
|
||||
Falls back to ``self.key`` when the env var is unset or
|
||||
whitespace-only so existing behaviour is unchanged.
|
||||
|
||||
See issue #2596.
|
||||
"""
|
||||
env_name = (
|
||||
f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXECUTABLE"
|
||||
)
|
||||
override = os.environ.get(env_name, "").strip()
|
||||
return override if override else self.key
|
||||
|
||||
def _apply_extra_args_env_var(self, args: list[str]) -> None:
|
||||
"""Append `SPECKIT_INTEGRATION_<KEY>_EXTRA_ARGS` env-var value to *args*.
|
||||
|
||||
Operators can inject extra CLI flags into the spawned agent
|
||||
subprocess by setting an env var named for the integration key,
|
||||
e.g. `SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS="--dangerously-skip-permissions"`.
|
||||
The `INTEGRATION` segment scopes the variable to this subsystem
|
||||
so it does not collide with other Spec Kit env-var namespaces.
|
||||
Hyphens in the integration key are replaced with underscores
|
||||
and the key is uppercased
|
||||
(e.g. `kiro-cli` → `SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS`).
|
||||
|
||||
Useful in CI / non-interactive contexts where the spawned agent
|
||||
needs flags that change its prompt-handling behaviour.
|
||||
Default behaviour (env var unset or whitespace-only) is a no-op
|
||||
— *args* is unchanged. Multi-token values are parsed via
|
||||
`shlex.split`.
|
||||
|
||||
See issue #2595.
|
||||
"""
|
||||
env_name = (
|
||||
f"SPECKIT_INTEGRATION_{self.key.upper().replace('-', '_')}_EXTRA_ARGS"
|
||||
)
|
||||
extra = os.environ.get(env_name, "").strip()
|
||||
if not extra:
|
||||
return
|
||||
try:
|
||||
tokens = shlex.split(extra)
|
||||
except ValueError as exc:
|
||||
raise ValueError(
|
||||
f"{env_name} is not parseable as a POSIX-quoted command line "
|
||||
f"(value: {extra!r}). shlex reported: {exc}. "
|
||||
f"Use single or double quotes to group multi-word values, e.g. "
|
||||
f'{env_name}=\'--flag "value with spaces"\'.'
|
||||
) from exc
|
||||
args.extend(tokens)
|
||||
|
||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||
"""Build the native slash-command invocation for a Spec Kit command.
|
||||
|
||||
@@ -488,6 +550,91 @@ class IntegrationBase(ABC):
|
||||
lines.append(f"at {plan_path}")
|
||||
return "\n".join(lines)
|
||||
|
||||
@staticmethod
|
||||
def _agent_context_extension_enabled(project_root: Path) -> bool:
|
||||
"""Return whether the bundled ``agent-context`` extension is enabled.
|
||||
|
||||
The extension is the single source of truth for managing coding
|
||||
agent context/instruction files (e.g. ``CLAUDE.md``,
|
||||
``.github/copilot-instructions.md``).
|
||||
|
||||
Returns ``True`` (enabled) when:
|
||||
- the extension registry does not exist (legacy project, backwards
|
||||
compatibility), or
|
||||
- the registry has no ``agent-context`` entry (older project layout
|
||||
predating the extension), or
|
||||
- the entry is present and not explicitly disabled.
|
||||
|
||||
Returns ``False`` only when an entry exists with ``enabled: false``.
|
||||
"""
|
||||
registry_path = (
|
||||
project_root / ".specify" / "extensions" / ".registry"
|
||||
)
|
||||
if not registry_path.exists():
|
||||
return True
|
||||
try:
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError, UnicodeError):
|
||||
return True
|
||||
if not isinstance(data, dict):
|
||||
return True
|
||||
extensions = data.get("extensions")
|
||||
if not isinstance(extensions, dict):
|
||||
return True
|
||||
entry = extensions.get("agent-context")
|
||||
if not isinstance(entry, dict):
|
||||
return True
|
||||
return entry.get("enabled", True) is not False
|
||||
|
||||
def _resolve_context_markers(self, project_root: Path) -> tuple[str, str]:
|
||||
"""Return the (start, end) context markers to use for *project_root*.
|
||||
|
||||
Reads ``context_markers.start`` / ``context_markers.end`` from the
|
||||
agent-context extension config
|
||||
(``.specify/extensions/agent-context/agent-context-config.yml``)
|
||||
when present. Falls back to the class-level constants
|
||||
``CONTEXT_MARKER_START`` / ``CONTEXT_MARKER_END`` when the file is
|
||||
missing, the section is absent, or the values are not non-empty
|
||||
strings.
|
||||
"""
|
||||
from .._console import console # local import to avoid cycles
|
||||
|
||||
start = self.CONTEXT_MARKER_START
|
||||
end = self.CONTEXT_MARKER_END
|
||||
config_path = (
|
||||
project_root
|
||||
/ ".specify"
|
||||
/ "extensions"
|
||||
/ "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
try:
|
||||
raw = config_path.read_text(encoding="utf-8")
|
||||
cfg = yaml.safe_load(raw)
|
||||
except (OSError, UnicodeError, ValueError, yaml.YAMLError):
|
||||
return start, end
|
||||
markers = cfg.get("context_markers") if isinstance(cfg, dict) else None
|
||||
if isinstance(markers, dict):
|
||||
cm_start = markers.get("start")
|
||||
cm_end = markers.get("end")
|
||||
s_valid = isinstance(cm_start, str) and cm_start
|
||||
e_valid = isinstance(cm_end, str) and cm_end
|
||||
if not s_valid and cm_start is not None:
|
||||
console.print(
|
||||
f"[yellow]agent-context: ignoring invalid context_markers.start "
|
||||
f"({cm_start!r}), using default[/yellow]"
|
||||
)
|
||||
if not e_valid and cm_end is not None:
|
||||
console.print(
|
||||
f"[yellow]agent-context: ignoring invalid context_markers.end "
|
||||
f"({cm_end!r}), using default[/yellow]"
|
||||
)
|
||||
if s_valid:
|
||||
start = cm_start # type: ignore[assignment]
|
||||
if e_valid:
|
||||
end = cm_end # type: ignore[assignment]
|
||||
return start, end
|
||||
|
||||
def upsert_context_section(
|
||||
self,
|
||||
project_root: Path,
|
||||
@@ -496,34 +643,54 @@ class IntegrationBase(ABC):
|
||||
"""Create or update the managed section in the agent context file.
|
||||
|
||||
If the context file does not exist it is created with just the
|
||||
managed section. If it exists, the content between
|
||||
``<!-- SPECKIT START -->`` and ``<!-- SPECKIT END -->`` markers
|
||||
is replaced (or appended when no markers are found).
|
||||
managed section. If it exists, the content between the configured
|
||||
start/end markers (default ``<!-- SPECKIT START -->`` /
|
||||
``<!-- SPECKIT END -->``) is replaced, or appended when no markers
|
||||
are found. Markers are read from the agent-context extension config
|
||||
(``.specify/extensions/agent-context/agent-context-config.yml``)
|
||||
when present, falling back to the class-level constants.
|
||||
|
||||
Returns the path to the context file, or ``None`` when
|
||||
``context_file`` is not set.
|
||||
``context_file`` is not set or the ``agent-context`` extension is
|
||||
disabled.
|
||||
"""
|
||||
if not self.context_file:
|
||||
return None
|
||||
|
||||
if not self._agent_context_extension_enabled(project_root):
|
||||
return None
|
||||
|
||||
from .._console import console # local import to avoid cycles
|
||||
|
||||
console.print(
|
||||
"[yellow]Deprecation:[/yellow] Inline agent-context updates during "
|
||||
"integration setup will be disabled in v0.12.0. Context file "
|
||||
"management has moved to the bundled [bold]agent-context[/bold] "
|
||||
"extension. Run [cyan]specify extension disable agent-context[/cyan] "
|
||||
"to opt out early.",
|
||||
highlight=False,
|
||||
)
|
||||
|
||||
marker_start, marker_end = self._resolve_context_markers(project_root)
|
||||
|
||||
ctx_path = project_root / self.context_file
|
||||
section = (
|
||||
f"{self.CONTEXT_MARKER_START}\n"
|
||||
f"{marker_start}\n"
|
||||
f"{self._build_context_section(plan_path)}\n"
|
||||
f"{self.CONTEXT_MARKER_END}\n"
|
||||
f"{marker_end}\n"
|
||||
)
|
||||
|
||||
if ctx_path.exists():
|
||||
content = ctx_path.read_text(encoding="utf-8-sig")
|
||||
start_idx = content.find(self.CONTEXT_MARKER_START)
|
||||
start_idx = content.find(marker_start)
|
||||
end_idx = content.find(
|
||||
self.CONTEXT_MARKER_END,
|
||||
marker_end,
|
||||
start_idx if start_idx != -1 else 0,
|
||||
)
|
||||
|
||||
if start_idx != -1 and end_idx != -1 and end_idx > start_idx:
|
||||
# Replace existing section (include the end marker + newline)
|
||||
end_of_marker = end_idx + len(self.CONTEXT_MARKER_END)
|
||||
end_of_marker = end_idx + len(marker_end)
|
||||
# Consume trailing line ending (CRLF or LF)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
@@ -535,7 +702,7 @@ class IntegrationBase(ABC):
|
||||
new_content = content[:start_idx] + section
|
||||
elif end_idx != -1:
|
||||
# Corrupted: end marker without start — replace BOF through end marker
|
||||
end_of_marker = end_idx + len(self.CONTEXT_MARKER_END)
|
||||
end_of_marker = end_idx + len(marker_end)
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\r":
|
||||
end_of_marker += 1
|
||||
if end_of_marker < len(content) and content[end_of_marker] == "\n":
|
||||
@@ -569,20 +736,27 @@ class IntegrationBase(ABC):
|
||||
"""Remove the managed section from the agent context file.
|
||||
|
||||
Returns ``True`` if the section was found and removed. If the
|
||||
file becomes empty (or whitespace-only) after removal it is
|
||||
deleted.
|
||||
file becomes empty (or whitespace-only) after removal it is deleted.
|
||||
Markers are read from the agent-context extension config
|
||||
(``.specify/extensions/agent-context/agent-context-config.yml``)
|
||||
when present, falling back to the class-level constants.
|
||||
"""
|
||||
if not self.context_file:
|
||||
return False
|
||||
|
||||
if not self._agent_context_extension_enabled(project_root):
|
||||
return False
|
||||
|
||||
ctx_path = project_root / self.context_file
|
||||
if not ctx_path.exists():
|
||||
return False
|
||||
|
||||
marker_start, marker_end = self._resolve_context_markers(project_root)
|
||||
|
||||
content = ctx_path.read_text(encoding="utf-8-sig")
|
||||
start_idx = content.find(self.CONTEXT_MARKER_START)
|
||||
start_idx = content.find(marker_start)
|
||||
end_idx = content.find(
|
||||
self.CONTEXT_MARKER_END,
|
||||
marker_end,
|
||||
start_idx if start_idx != -1 else 0,
|
||||
)
|
||||
|
||||
@@ -593,7 +767,7 @@ class IntegrationBase(ABC):
|
||||
return False
|
||||
|
||||
removal_start = start_idx
|
||||
removal_end = end_idx + len(self.CONTEXT_MARKER_END)
|
||||
removal_end = end_idx + len(marker_end)
|
||||
|
||||
# Consume trailing line ending (CRLF or LF)
|
||||
if removal_end < len(content) and content[removal_end] == "\r":
|
||||
@@ -856,7 +1030,8 @@ class MarkdownIntegration(IntegrationBase):
|
||||
) -> list[str] | None:
|
||||
if not self.config or not self.config.get("requires_cli"):
|
||||
return None
|
||||
args = [self.key, "-p", prompt]
|
||||
args = [self._resolve_executable(), "-p", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
@@ -943,7 +1118,8 @@ class TomlIntegration(IntegrationBase):
|
||||
) -> list[str] | None:
|
||||
if not self.config or not self.config.get("requires_cli"):
|
||||
return None
|
||||
args = [self.key, "-p", prompt]
|
||||
args = [self._resolve_executable(), "-p", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["-m", model])
|
||||
if output_json:
|
||||
@@ -1361,7 +1537,8 @@ class SkillsIntegration(IntegrationBase):
|
||||
) -> list[str] | None:
|
||||
if not self.config or not self.config.get("requires_cli"):
|
||||
return None
|
||||
args = [self.key, "-p", prompt]
|
||||
args = [self._resolve_executable(), "-p", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
|
||||
@@ -37,7 +37,10 @@ class CodexIntegration(SkillsIntegration):
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
# Codex uses ``codex exec "prompt"`` for non-interactive mode.
|
||||
args: list[str] = ["codex", "exec", prompt]
|
||||
# Resolve argv[0] via the shared executable resolver so operators can
|
||||
# override the binary with SPECKIT_INTEGRATION_CODEX_EXECUTABLE.
|
||||
args: list[str] = [self._resolve_executable(), "exec", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
|
||||
@@ -134,6 +134,18 @@ class CopilotIntegration(IntegrationBase):
|
||||
),
|
||||
]
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
"""Return the Copilot CLI executable, respecting the env-var override.
|
||||
|
||||
Checks ``SPECKIT_INTEGRATION_COPILOT_EXECUTABLE`` first. Falls
|
||||
back to the platform-specific default from ``_copilot_executable()``
|
||||
(``copilot.cmd`` on Windows, ``copilot`` elsewhere) so that
|
||||
existing behaviour is preserved when the env var is unset.
|
||||
"""
|
||||
env_name = "SPECKIT_INTEGRATION_COPILOT_EXECUTABLE"
|
||||
override = os.environ.get(env_name, "").strip()
|
||||
return override if override else _copilot_executable()
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -148,7 +160,8 @@ class CopilotIntegration(IntegrationBase):
|
||||
# Controlled by SPECKIT_COPILOT_ALLOW_ALL_TOOLS env var
|
||||
# (default: enabled). The deprecated SPECKIT_ALLOW_ALL_TOOLS
|
||||
# is also honoured as a fallback.
|
||||
args = [_copilot_executable(), "-p", prompt]
|
||||
args = [self._resolve_executable(), "-p", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if _allow_all():
|
||||
args.append("--yolo")
|
||||
if model:
|
||||
@@ -216,7 +229,12 @@ class CopilotIntegration(IntegrationBase):
|
||||
agent_name = f"speckit.{stem}"
|
||||
prompt = args or ""
|
||||
|
||||
cli_args = [_copilot_executable(), "-p", prompt]
|
||||
cli_args = [self._resolve_executable(), "-p", prompt]
|
||||
# Honour SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS for real workflow
|
||||
# runs. `dispatch_command` builds cli_args inline rather than
|
||||
# going through `build_exec_args`, so the hook must be invoked
|
||||
# here too — otherwise the env var is silently ignored.
|
||||
self._apply_extra_args_env_var(cli_args)
|
||||
if not skills_mode:
|
||||
cli_args.extend(["--agent", agent_name])
|
||||
if _allow_all():
|
||||
|
||||
@@ -48,7 +48,8 @@ class DevinIntegration(SkillsIntegration):
|
||||
stdout instead of structured JSON. ``requires_cli=True`` is
|
||||
kept on the integration for tool detection.
|
||||
"""
|
||||
args = [self.key, "-p", prompt]
|
||||
args = [self._resolve_executable(), "-p", prompt]
|
||||
self._apply_extra_args_env_var(args)
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
return args
|
||||
|
||||
@@ -257,7 +257,7 @@ class HermesIntegration(SkillsIntegration):
|
||||
mapping slash-command invocations to the appropriate skill-based
|
||||
dispatch.
|
||||
"""
|
||||
args = [self.key, "chat", "-Q"]
|
||||
args = [self._resolve_executable(), "chat", "-Q"]
|
||||
|
||||
if model:
|
||||
args.extend(["-m", model])
|
||||
|
||||
@@ -28,7 +28,12 @@ class OpencodeIntegration(MarkdownIntegration):
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
args = [self.key, "run"]
|
||||
args = [self._resolve_executable(), "run"]
|
||||
# Apply operator-injected extra args before the prompt-derived
|
||||
# --command and the canonical --format/-m flags so Spec Kit's
|
||||
# later appends remain authoritative under repeated-flag CLI
|
||||
# semantics.
|
||||
self._apply_extra_args_env_var(args)
|
||||
|
||||
message = prompt
|
||||
if prompt.startswith("/"):
|
||||
|
||||
@@ -11,6 +11,7 @@ The engine is the orchestrator that:
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
@@ -425,7 +426,7 @@ class WorkflowEngine:
|
||||
inputs:
|
||||
User-provided input values.
|
||||
run_id:
|
||||
Optional run ID (auto-generated if not provided).
|
||||
Optional run ID (uses SPECKIT_WORKFLOW_RUN_ID when set, otherwise auto-generated).
|
||||
|
||||
Returns
|
||||
-------
|
||||
@@ -433,8 +434,14 @@ class WorkflowEngine:
|
||||
"""
|
||||
from . import STEP_REGISTRY
|
||||
|
||||
effective_run_id = run_id
|
||||
if effective_run_id is None:
|
||||
env_run_id = os.environ.get("SPECKIT_WORKFLOW_RUN_ID", "").strip()
|
||||
if env_run_id:
|
||||
effective_run_id = env_run_id
|
||||
|
||||
state = RunState(
|
||||
run_id=run_id,
|
||||
run_id=effective_run_id,
|
||||
workflow_id=definition.id,
|
||||
project_root=self.project_root,
|
||||
)
|
||||
|
||||
@@ -74,7 +74,9 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
2. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. **Clarify intent (dynamic)**: Derive up to THREE initial contextual clarifying questions (no pre-baked catalog). They MUST:
|
||||
- Be generated from the user's phrasing + extracted signals from spec/plan/tasks
|
||||
- Only ask about information that materially changes checklist content
|
||||
- Be skipped individually if already unambiguous in `$ARGUMENTS`
|
||||
@@ -106,13 +108,13 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
Output the questions (label Q1/Q2/Q3). After answers: if ≥2 scenario classes (Alternate / Exception / Recovery / Non-Functional domain) remain unclear, you MAY ask up to TWO more targeted follow‑ups (Q4/Q5) with a one-line justification each (e.g., "Unresolved recovery path risk"). Do not exceed five total questions. Skip escalation if user explicitly declines more.
|
||||
|
||||
3. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
4. **Understand user request**: Combine `$ARGUMENTS` + clarifying answers:
|
||||
- Derive checklist theme (e.g., security, review, deploy, ux)
|
||||
- Consolidate explicit must-have items mentioned by user
|
||||
- Map focus selections to category scaffolding
|
||||
- Infer any missing context from spec/plan/tasks (do NOT hallucinate)
|
||||
|
||||
4. **Load feature context**: Read from FEATURE_DIR:
|
||||
5. **Load feature context**: Read from FEATURE_DIR:
|
||||
- spec.md: Feature requirements and scope
|
||||
- plan.md (if exists): Technical details, dependencies
|
||||
- tasks.md (if exists): Implementation tasks
|
||||
@@ -123,7 +125,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
5. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
6. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
@@ -241,9 +243,9 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
6. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
7. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
|
||||
7. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
8. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
- Focus areas selected
|
||||
- Depth level
|
||||
- Actor/timing
|
||||
|
||||
@@ -66,7 +66,9 @@ Execution steps:
|
||||
- If JSON parsing fails, abort and instruct user to re-run `__SPECKIT_COMMAND_SPECIFY__` or verify feature branch environment.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
2. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
3. Load the current spec file. Perform a structured ambiguity & coverage scan using this taxonomy. For each category, mark status: Clear / Partial / Missing. Produce an internal coverage map used for prioritization (do not output raw map unless no questions will be asked).
|
||||
|
||||
Functional Scope & Behavior:
|
||||
- Core user goals & success criteria
|
||||
@@ -122,7 +124,7 @@ Execution steps:
|
||||
- Clarification would not materially change implementation or validation strategy
|
||||
- Information is better deferred to planning phase (note internally)
|
||||
|
||||
3. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
4. Generate (internally) a prioritized queue of candidate clarification questions (maximum 5). Do NOT output them all at once. Apply these constraints:
|
||||
- Maximum of 5 total questions across the whole session.
|
||||
- Each question must be answerable with EITHER:
|
||||
- A short multiple‑choice selection (2–5 distinct, mutually exclusive options), OR
|
||||
@@ -133,7 +135,7 @@ Execution steps:
|
||||
- Favor clarifications that reduce downstream rework risk or prevent misaligned acceptance tests.
|
||||
- If more than 5 categories remain unresolved, select the top 5 by (Impact * Uncertainty) heuristic.
|
||||
|
||||
4. Sequential questioning loop (interactive):
|
||||
5. Sequential questioning loop (interactive):
|
||||
- Present EXACTLY ONE question at a time.
|
||||
- For multiple‑choice questions:
|
||||
- **Analyze all options** and determine the **most suitable option** based on:
|
||||
@@ -169,7 +171,7 @@ Execution steps:
|
||||
- Never reveal future queued questions in advance.
|
||||
- If no valid questions exist at start, immediately report no critical ambiguities.
|
||||
|
||||
5. Integration after EACH accepted answer (incremental update approach):
|
||||
6. Integration after EACH accepted answer (incremental update approach):
|
||||
- Maintain in-memory representation of the spec (loaded once at start) plus the raw file contents.
|
||||
- For the first integrated answer in this session:
|
||||
- Ensure a `## Clarifications` section exists (create it just after the highest-level contextual/overview section per the spec template if missing).
|
||||
@@ -187,7 +189,7 @@ Execution steps:
|
||||
- Preserve formatting: do not reorder unrelated sections; keep heading hierarchy intact.
|
||||
- Keep each inserted clarification minimal and testable (avoid narrative drift).
|
||||
|
||||
6. Validation (performed after EACH write plus final pass):
|
||||
7. Validation (performed after EACH write plus final pass):
|
||||
- Clarifications session contains exactly one bullet per accepted answer (no duplicates).
|
||||
- Total asked (accepted) questions ≤ 5.
|
||||
- Updated sections contain no lingering vague placeholders the new answer was meant to resolve.
|
||||
@@ -195,9 +197,9 @@ Execution steps:
|
||||
- Markdown structure valid; only allowed new headings: `## Clarifications`, `### Session YYYY-MM-DD`.
|
||||
- Terminology consistency: same canonical term used across all updated sections.
|
||||
|
||||
7. Write the updated spec back to `FEATURE_SPEC`.
|
||||
8. Write the updated spec back to `FEATURE_SPEC`.
|
||||
|
||||
8. **Re-validate Spec Quality Checklist** (if it exists):
|
||||
9. **Re-validate Spec Quality Checklist** (if it exists):
|
||||
- Check if `FEATURE_DIR/checklists/requirements.md` exists.
|
||||
- If it does NOT exist, skip this step silently.
|
||||
- If it exists:
|
||||
|
||||
@@ -109,7 +109,9 @@ Given that feature description, do this:
|
||||
|
||||
4. Load `templates/spec-template.md` to understand required sections.
|
||||
|
||||
5. Follow this execution flow:
|
||||
5. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
|
||||
6. Follow this execution flow:
|
||||
1. Parse user description from arguments
|
||||
If empty: ERROR "No feature description provided"
|
||||
2. Extract key concepts from description
|
||||
|
||||
@@ -63,6 +63,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
- **Optional**: data-model.md (entities), contracts/ (interface contracts), research.md (decisions), quickstart.md (test scenarios)
|
||||
- **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints
|
||||
- Note: Not all projects have all documents. Generate tasks based on what's available.
|
||||
|
||||
3. **Execute task generation workflow**:
|
||||
|
||||
@@ -51,6 +51,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
## Outline
|
||||
|
||||
1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. **IF EXISTS**: Load `/memory/constitution.md` for project principles and governance constraints.
|
||||
1. From the executed script, extract the path to **tasks**.
|
||||
1. Get the Git remote by running:
|
||||
|
||||
|
||||
455
tests/extensions/test_extension_agent_context.py
Normal file
455
tests/extensions/test_extension_agent_context.py
Normal file
@@ -0,0 +1,455 @@
|
||||
"""Tests for the bundled ``agent-context`` extension and related plumbing."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli import (
|
||||
_load_agent_context_config,
|
||||
_save_agent_context_config,
|
||||
load_init_options,
|
||||
save_init_options,
|
||||
)
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
from specify_cli.integrations.claude import ClaudeIntegration
|
||||
|
||||
|
||||
PROJECT_ROOT = Path(__file__).resolve().parent.parent.parent
|
||||
EXT_DIR = PROJECT_ROOT / "extensions" / "agent-context"
|
||||
|
||||
|
||||
def _write_ext_config(project_root: Path, **overrides: object) -> None:
|
||||
"""Write a minimal agent-context extension config."""
|
||||
cfg: dict = {
|
||||
"context_file": overrides.get("context_file", ""),
|
||||
"context_markers": overrides.get(
|
||||
"context_markers",
|
||||
{
|
||||
"start": IntegrationBase.CONTEXT_MARKER_START,
|
||||
"end": IntegrationBase.CONTEXT_MARKER_END,
|
||||
},
|
||||
),
|
||||
}
|
||||
_save_agent_context_config(project_root, cfg)
|
||||
|
||||
|
||||
# ── Bundled extension layout ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtensionLayout:
|
||||
"""The bundled agent-context extension ships a complete package."""
|
||||
|
||||
def test_extension_yml_exists(self):
|
||||
assert (EXT_DIR / "extension.yml").is_file()
|
||||
|
||||
def test_extension_yml_has_required_fields(self):
|
||||
manifest = yaml.safe_load((EXT_DIR / "extension.yml").read_text())
|
||||
assert manifest["extension"]["id"] == "agent-context"
|
||||
assert manifest["extension"]["name"] == "Coding Agent Context"
|
||||
assert manifest["extension"]["author"] == "spec-kit-core"
|
||||
# Provides at least the manual update command
|
||||
commands = {c["name"] for c in manifest["provides"]["commands"]}
|
||||
assert "speckit.agent-context.update" in commands
|
||||
|
||||
def test_readme_exists(self):
|
||||
readme = EXT_DIR / "README.md"
|
||||
assert readme.is_file()
|
||||
text = readme.read_text(encoding="utf-8")
|
||||
assert "Coding Agent Context Extension" in text
|
||||
|
||||
def test_config_template_exists(self):
|
||||
cfg = EXT_DIR / "agent-context-config.yml"
|
||||
assert cfg.is_file()
|
||||
parsed = yaml.safe_load(cfg.read_text(encoding="utf-8"))
|
||||
assert "context_file" in parsed
|
||||
assert "context_markers" in parsed
|
||||
|
||||
def test_command_file_exists(self):
|
||||
cmd = EXT_DIR / "commands" / "speckit.agent-context.update.md"
|
||||
assert cmd.is_file()
|
||||
assert "agent-context-config.yml" in cmd.read_text(encoding="utf-8")
|
||||
|
||||
def test_bundled_scripts_exist(self):
|
||||
assert (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").is_file()
|
||||
assert (EXT_DIR / "scripts" / "powershell" / "update-agent-context.ps1").is_file()
|
||||
|
||||
def test_bash_script_reads_extension_config(self):
|
||||
text = (EXT_DIR / "scripts" / "bash" / "update-agent-context.sh").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
# The script must consult the extension config, not init-options.json
|
||||
assert "agent-context-config.yml" in text
|
||||
assert "context_file" in text
|
||||
assert "context_markers" in text
|
||||
|
||||
|
||||
# ── Catalog registration ─────────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestCatalogEntry:
|
||||
def test_catalog_lists_agent_context_as_bundled(self):
|
||||
catalog = json.loads(
|
||||
(PROJECT_ROOT / "extensions" / "catalog.json").read_text(encoding="utf-8")
|
||||
)
|
||||
entry = catalog["extensions"]["agent-context"]
|
||||
assert entry["bundled"] is True
|
||||
assert entry["id"] == "agent-context"
|
||||
assert entry["author"] == "spec-kit-core"
|
||||
|
||||
|
||||
# ── Marker resolution from extension config ──────────────────────────────────
|
||||
|
||||
|
||||
class _CtxIntegration(ClaudeIntegration):
|
||||
"""Use Claude as a concrete integration with a context_file."""
|
||||
|
||||
|
||||
class TestContextMarkerResolution:
|
||||
def test_defaults_when_ext_config_missing(self, tmp_path):
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_defaults_when_markers_field_missing(self, tmp_path):
|
||||
"""Config file exists with context_file but no context_markers key."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text("context_file: CLAUDE.md\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_custom_markers_respected(self, tmp_path):
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_markers={"start": "<!-- BEGIN -->", "end": "<!-- END -->"},
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == "<!-- BEGIN -->"
|
||||
assert end == "<!-- END -->"
|
||||
|
||||
def test_partial_override_falls_back_for_missing_side(self, tmp_path):
|
||||
_write_ext_config(tmp_path, context_markers={"start": "<!-- ONLY START -->"})
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == "<!-- ONLY START -->"
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_invalid_markers_fall_back(self, tmp_path):
|
||||
_write_ext_config(tmp_path, context_markers={"start": 42, "end": ""})
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
|
||||
# ── upsert_context_section / remove_context_section honor markers ───────────
|
||||
|
||||
|
||||
class TestUpsertWithCustomMarkers:
|
||||
def _setup(self, tmp_path: Path, markers: dict | None = None) -> _CtxIntegration:
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
**({"context_markers": markers} if markers is not None else {}),
|
||||
)
|
||||
return _CtxIntegration()
|
||||
|
||||
def test_upsert_uses_default_markers(self, tmp_path):
|
||||
i = self._setup(tmp_path)
|
||||
result = i.upsert_context_section(tmp_path)
|
||||
assert result is not None
|
||||
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert IntegrationBase.CONTEXT_MARKER_START in text
|
||||
assert IntegrationBase.CONTEXT_MARKER_END in text
|
||||
|
||||
def test_upsert_uses_custom_markers(self, tmp_path):
|
||||
i = self._setup(
|
||||
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
|
||||
)
|
||||
i.upsert_context_section(tmp_path)
|
||||
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert "<!-- BEGIN -->" in text
|
||||
assert "<!-- END -->" in text
|
||||
# Defaults must not appear
|
||||
assert IntegrationBase.CONTEXT_MARKER_START not in text
|
||||
assert IntegrationBase.CONTEXT_MARKER_END not in text
|
||||
|
||||
def test_upsert_replaces_existing_custom_section(self, tmp_path):
|
||||
i = self._setup(
|
||||
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
|
||||
)
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
ctx.write_text(
|
||||
"# header\n\n<!-- BEGIN -->\nold body\n<!-- END -->\n\nfooter\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
i.upsert_context_section(tmp_path, plan_path="specs/001-foo/plan.md")
|
||||
text = ctx.read_text(encoding="utf-8")
|
||||
assert "old body" not in text
|
||||
assert "specs/001-foo/plan.md" in text
|
||||
assert text.startswith("# header\n")
|
||||
assert "footer" in text
|
||||
|
||||
def test_remove_uses_custom_markers(self, tmp_path):
|
||||
i = self._setup(
|
||||
tmp_path, {"start": "<!-- BEGIN -->", "end": "<!-- END -->"}
|
||||
)
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
ctx.write_text(
|
||||
"preamble\n\n<!-- BEGIN -->\nbody\n<!-- END -->\nepilogue\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
removed = i.remove_context_section(tmp_path)
|
||||
assert removed is True
|
||||
remaining = ctx.read_text(encoding="utf-8")
|
||||
assert "<!-- BEGIN -->" not in remaining
|
||||
assert "<!-- END -->" not in remaining
|
||||
assert "body" not in remaining
|
||||
assert "preamble" in remaining
|
||||
assert "epilogue" in remaining
|
||||
|
||||
def test_remove_with_default_markers_unchanged_when_custom_in_file(self, tmp_path):
|
||||
# Extension config absent → default markers used. File contains only
|
||||
# custom markers — nothing should be removed.
|
||||
i = _CtxIntegration()
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
original = "x\n<!-- BEGIN -->\nbody\n<!-- END -->\n"
|
||||
ctx.write_text(original, encoding="utf-8")
|
||||
assert i.remove_context_section(tmp_path) is False
|
||||
assert ctx.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
# ── Extension disabled gates setup/teardown ──────────────────────────────────
|
||||
|
||||
|
||||
def _write_registry(project_root: Path, *, enabled: bool) -> None:
|
||||
registry = project_root / ".specify" / "extensions" / ".registry"
|
||||
registry.parent.mkdir(parents=True, exist_ok=True)
|
||||
registry.write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"extensions": {
|
||||
"agent-context": {
|
||||
"version": "1.0.0",
|
||||
"enabled": enabled,
|
||||
}
|
||||
},
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
class TestExtensionEnabledGate:
|
||||
def test_enabled_helper_default_when_no_registry(self, tmp_path):
|
||||
assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True
|
||||
|
||||
def test_enabled_helper_when_entry_present(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=True)
|
||||
assert IntegrationBase._agent_context_extension_enabled(tmp_path) is True
|
||||
|
||||
def test_disabled_helper_when_entry_disabled(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
assert IntegrationBase._agent_context_extension_enabled(tmp_path) is False
|
||||
|
||||
def test_upsert_skipped_when_disabled(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
i = _CtxIntegration()
|
||||
result = i.upsert_context_section(tmp_path)
|
||||
assert result is None
|
||||
assert not (tmp_path / "CLAUDE.md").exists()
|
||||
|
||||
def test_remove_skipped_when_disabled(self, tmp_path):
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
i = _CtxIntegration()
|
||||
ctx = tmp_path / "CLAUDE.md"
|
||||
original = (
|
||||
f"head\n{IntegrationBase.CONTEXT_MARKER_START}\nbody\n"
|
||||
f"{IntegrationBase.CONTEXT_MARKER_END}\ntail\n"
|
||||
)
|
||||
ctx.write_text(original, encoding="utf-8")
|
||||
assert i.remove_context_section(tmp_path) is False
|
||||
# File must be unchanged when extension is disabled
|
||||
assert ctx.read_text(encoding="utf-8") == original
|
||||
|
||||
|
||||
# ── Extension config writers ─────────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestExtensionConfigWriters:
|
||||
def test_clear_init_options_clears_ext_config_context_file(self, tmp_path):
|
||||
from specify_cli import _clear_init_options_for_integration
|
||||
|
||||
save_init_options(
|
||||
tmp_path,
|
||||
{"integration": "claude", "ai": "claude"},
|
||||
)
|
||||
_write_ext_config(tmp_path, context_file="CLAUDE.md")
|
||||
_clear_init_options_for_integration(tmp_path, "claude")
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg.get("context_file") == ""
|
||||
|
||||
def test_clear_init_options_creates_ext_config_when_missing(self, tmp_path):
|
||||
from specify_cli import _clear_init_options_for_integration
|
||||
|
||||
save_init_options(
|
||||
tmp_path,
|
||||
{"integration": "claude", "ai": "claude"},
|
||||
)
|
||||
_clear_init_options_for_integration(tmp_path, "claude")
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg.get("context_file") == ""
|
||||
|
||||
def test_clear_init_options_removes_legacy_context_keys_even_when_not_active(
|
||||
self, tmp_path
|
||||
):
|
||||
from specify_cli import _clear_init_options_for_integration
|
||||
|
||||
save_init_options(
|
||||
tmp_path,
|
||||
{
|
||||
"integration": "copilot",
|
||||
"ai": "copilot",
|
||||
"context_file": "CLAUDE.md",
|
||||
"context_markers": {"start": "<!-- X -->", "end": "<!-- Y -->"},
|
||||
},
|
||||
)
|
||||
_clear_init_options_for_integration(tmp_path, "claude")
|
||||
opts = load_init_options(tmp_path)
|
||||
assert opts["integration"] == "copilot"
|
||||
assert opts["ai"] == "copilot"
|
||||
assert "context_file" not in opts
|
||||
assert "context_markers" not in opts
|
||||
|
||||
def test_update_init_options_writes_context_file_to_ext_config(self, tmp_path):
|
||||
from specify_cli import _update_init_options_for_integration
|
||||
|
||||
# Pre-create the extension config so _update_init_options_for_integration
|
||||
# updates it (rather than skipping it when ext config doesn't exist yet).
|
||||
_write_ext_config(tmp_path, context_file="")
|
||||
i = _CtxIntegration()
|
||||
_update_init_options_for_integration(tmp_path, i, script_type="sh")
|
||||
# init-options.json must NOT have context_file or context_markers
|
||||
opts = load_init_options(tmp_path)
|
||||
assert "context_file" not in opts
|
||||
assert "context_markers" not in opts
|
||||
# Extension config must have them
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_file"] == i.context_file
|
||||
assert "context_markers" in cfg
|
||||
|
||||
def test_update_init_options_preserves_custom_markers(self, tmp_path):
|
||||
from specify_cli import _update_init_options_for_integration
|
||||
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="",
|
||||
context_markers={"start": "<!-- B -->", "end": "<!-- E -->"},
|
||||
)
|
||||
i = _CtxIntegration()
|
||||
_update_init_options_for_integration(tmp_path, i)
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_markers"] == {"start": "<!-- B -->", "end": "<!-- E -->"}
|
||||
|
||||
def test_reinit_preserves_custom_markers(self, tmp_path):
|
||||
"""specify init (reinit) must not overwrite user-customised markers."""
|
||||
from specify_cli import _update_agent_context_config_file
|
||||
|
||||
# Simulate existing project with custom markers
|
||||
_write_ext_config(
|
||||
tmp_path,
|
||||
context_file="CLAUDE.md",
|
||||
context_markers={"start": "<!-- CUSTOM -->", "end": "<!-- /CUSTOM -->"},
|
||||
)
|
||||
# Re-running init updates context_file but must preserve markers
|
||||
_update_agent_context_config_file(
|
||||
tmp_path, "CLAUDE.md", preserve_markers=True
|
||||
)
|
||||
cfg = _load_agent_context_config(tmp_path)
|
||||
assert cfg["context_markers"] == {
|
||||
"start": "<!-- CUSTOM -->",
|
||||
"end": "<!-- /CUSTOM -->",
|
||||
}
|
||||
|
||||
|
||||
# ── Deprecation warning on upsert ────────────────────────────────────────────
|
||||
|
||||
|
||||
class TestDeprecationWarning:
|
||||
def test_upsert_emits_deprecation_warning(self, tmp_path, capsys):
|
||||
"""upsert_context_section must emit a deprecation notice on stdout."""
|
||||
from tests.conftest import strip_ansi
|
||||
|
||||
i = _CtxIntegration()
|
||||
_write_ext_config(tmp_path, context_file="CLAUDE.md")
|
||||
i.upsert_context_section(tmp_path)
|
||||
captured = capsys.readouterr()
|
||||
plain = strip_ansi(captured.out)
|
||||
assert "Deprecation" in plain
|
||||
assert "v0.12.0" in plain
|
||||
assert "agent-context" in plain
|
||||
|
||||
def test_upsert_no_warning_when_disabled(self, tmp_path, capsys):
|
||||
"""No deprecation warning when agent-context extension is disabled."""
|
||||
_write_registry(tmp_path, enabled=False)
|
||||
i = _CtxIntegration()
|
||||
i.upsert_context_section(tmp_path)
|
||||
captured = capsys.readouterr()
|
||||
assert "Deprecation" not in captured.out
|
||||
|
||||
|
||||
# ── Corrupt / invalid extension config ───────────────────────────────────────
|
||||
|
||||
|
||||
class TestCorruptExtensionConfig:
|
||||
def test_marker_resolution_with_corrupt_yaml(self, tmp_path):
|
||||
"""Corrupt YAML in agent-context-config.yml falls back to defaults."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text(": invalid: yaml: {{{\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
|
||||
def test_upsert_with_corrupt_config_uses_defaults(self, tmp_path):
|
||||
"""upsert_context_section still works when config YAML is corrupt."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text("not valid yaml: {{{\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
result = i.upsert_context_section(tmp_path)
|
||||
assert result is not None
|
||||
text = (tmp_path / "CLAUDE.md").read_text(encoding="utf-8")
|
||||
assert IntegrationBase.CONTEXT_MARKER_START in text
|
||||
assert IntegrationBase.CONTEXT_MARKER_END in text
|
||||
|
||||
def test_marker_resolution_with_non_dict_yaml(self, tmp_path):
|
||||
"""Config file containing a scalar (not a dict) falls back to defaults."""
|
||||
cfg_path = (
|
||||
tmp_path / ".specify" / "extensions" / "agent-context"
|
||||
/ "agent-context-config.yml"
|
||||
)
|
||||
cfg_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
cfg_path.write_text("just a string\n", encoding="utf-8")
|
||||
i = _CtxIntegration()
|
||||
start, end = i._resolve_context_markers(tmp_path)
|
||||
assert start == IntegrationBase.CONTEXT_MARKER_START
|
||||
assert end == IntegrationBase.CONTEXT_MARKER_END
|
||||
@@ -87,7 +87,14 @@ class TestInitIntegrationFlag:
|
||||
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
|
||||
assert opts["integration"] == "copilot"
|
||||
assert opts["context_file"] == ".github/copilot-instructions.md"
|
||||
# context_file lives in the agent-context extension config, not init-options.json
|
||||
assert "context_file" not in opts
|
||||
|
||||
import yaml as _yaml
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
assert ext_cfg_path.exists(), "agent-context extension config must be created on init"
|
||||
ext_cfg = _yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8"))
|
||||
assert ext_cfg["context_file"] == ".github/copilot-instructions.md"
|
||||
|
||||
assert (project / ".specify" / "integrations" / "copilot.manifest.json").exists()
|
||||
|
||||
|
||||
638
tests/integrations/test_extra_args.py
Normal file
638
tests/integrations/test_extra_args.py
Normal file
@@ -0,0 +1,638 @@
|
||||
"""Tests for the per-integration `SPECKIT_INTEGRATION_<KEY>_EXTRA_ARGS` and
|
||||
`SPECKIT_INTEGRATION_<KEY>_EXECUTABLE` env-var hooks.
|
||||
|
||||
The hooks are implemented in `IntegrationBase._apply_extra_args_env_var` and
|
||||
`IntegrationBase._resolve_executable` and wired into every concrete
|
||||
`build_exec_args` — `MarkdownIntegration`, `TomlIntegration`,
|
||||
`SkillsIntegration`, plus override integrations.
|
||||
These tests cover both the shared mechanisms (via `SkillsIntegration` stubs
|
||||
near the top of the file) and override integrations end-to-end (further down).
|
||||
See issues #2595 and #2596."""
|
||||
|
||||
import os
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations.base import (
|
||||
MarkdownIntegration,
|
||||
SkillsIntegration,
|
||||
TomlIntegration,
|
||||
)
|
||||
|
||||
|
||||
class _ClaudeStub(SkillsIntegration):
|
||||
"""Minimal Claude-like SkillsIntegration for testing."""
|
||||
|
||||
key = "claude"
|
||||
config = {
|
||||
"name": "Claude (test stub)",
|
||||
"folder": ".claude/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".claude/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
context_file = "CLAUDE.md"
|
||||
|
||||
|
||||
class _KiroCliStub(SkillsIntegration):
|
||||
"""SkillsIntegration with a hyphenated key to exercise key
|
||||
normalization (`kiro-cli` → `KIRO_CLI`)."""
|
||||
|
||||
key = "kiro-cli"
|
||||
config = {
|
||||
"name": "Kiro CLI (test stub)",
|
||||
"folder": ".kiro/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".kiro/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "KIRO.md"
|
||||
|
||||
|
||||
class _NoCliStub(SkillsIntegration):
|
||||
"""SkillsIntegration with requires_cli=False — build_exec_args
|
||||
must return None and the env-var hook must not fire."""
|
||||
|
||||
key = "no-cli"
|
||||
config = {
|
||||
"name": "No-CLI agent (test stub)",
|
||||
"folder": ".no-cli/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".no-cli/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "NOCLI.md"
|
||||
|
||||
|
||||
class _MarkdownAgentStub(MarkdownIntegration):
|
||||
"""Bare MarkdownIntegration subclass — does NOT override
|
||||
`build_exec_args`. Locks the base implementation in
|
||||
`MarkdownIntegration.build_exec_args` for the common case
|
||||
(most concrete integrations: Amp, Auggie, Generic, …)."""
|
||||
|
||||
key = "md-agent"
|
||||
config = {
|
||||
"name": "Markdown agent (test stub)",
|
||||
"folder": ".md-agent/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".md-agent/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
context_file = "MDAGENT.md"
|
||||
|
||||
|
||||
class _TomlAgentStub(TomlIntegration):
|
||||
"""Bare TomlIntegration subclass — does NOT override
|
||||
`build_exec_args`. Locks the base implementation in
|
||||
`TomlIntegration.build_exec_args` (Gemini, Tabnine)."""
|
||||
|
||||
key = "toml-agent"
|
||||
config = {
|
||||
"name": "TOML agent (test stub)",
|
||||
"folder": ".toml-agent/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".toml-agent/commands",
|
||||
"format": "toml",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".toml",
|
||||
}
|
||||
context_file = "TOMLAGENT.md"
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def _clean_extra_args_env(monkeypatch):
|
||||
"""Strip any leaked SPECKIT_INTEGRATION_*_EXTRA_ARGS and
|
||||
SPECKIT_INTEGRATION_*_EXECUTABLE vars from the test env so a
|
||||
developer's shell setting doesn't pollute results."""
|
||||
for key in list(os.environ):
|
||||
if key.startswith("SPECKIT_INTEGRATION_") and (
|
||||
key.endswith("_EXTRA_ARGS") or key.endswith("_EXECUTABLE")
|
||||
):
|
||||
monkeypatch.delenv(key, raising=False)
|
||||
|
||||
|
||||
def test_env_var_unset_byte_identical_argv():
|
||||
"""Default behaviour: env var unset → no extra args inserted.
|
||||
|
||||
Locks the backward-compatibility guarantee that existing
|
||||
operators see no change.
|
||||
"""
|
||||
args = _ClaudeStub().build_exec_args("hello prompt")
|
||||
assert args == ["claude", "-p", "hello prompt", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_env_var_set_flag_inserted_before_model_and_output_format(
|
||||
monkeypatch,
|
||||
):
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", "--dangerously-skip-permissions"
|
||||
)
|
||||
args = _ClaudeStub().build_exec_args("hello prompt", model="sonnet")
|
||||
assert args == [
|
||||
"claude",
|
||||
"-p",
|
||||
"hello prompt",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_env_var_multi_token_parsed_via_shlex(monkeypatch):
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS",
|
||||
"--dangerously-skip-permissions --max-turns 3",
|
||||
)
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args == [
|
||||
"claude",
|
||||
"-p",
|
||||
"p",
|
||||
"--dangerously-skip-permissions",
|
||||
"--max-turns",
|
||||
"3",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_malformed_quoting_raises_actionable_value_error(monkeypatch):
|
||||
"""An unmatched quote in the env-var value must surface a clear
|
||||
error naming the offending env var and showing the invalid value,
|
||||
rather than crashing workflow dispatch with a bare shlex traceback."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS",
|
||||
'--flag "unterminated',
|
||||
)
|
||||
with pytest.raises(ValueError) as excinfo:
|
||||
_ClaudeStub().build_exec_args("p")
|
||||
msg = str(excinfo.value)
|
||||
assert "SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS" in msg
|
||||
assert "--flag \"unterminated" in msg
|
||||
|
||||
|
||||
def test_env_var_empty_or_whitespace_is_noop(monkeypatch):
|
||||
"""An env var set to '' or ' ' is treated as unset."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", " ")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args == ["claude", "-p", "p", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_other_integration_env_var_ignored(monkeypatch):
|
||||
"""`SPECKIT_INTEGRATION_GEMINI_EXTRA_ARGS` set must NOT leak into
|
||||
Claude's argv (per-integration scoping)."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_GEMINI_EXTRA_ARGS", "--gemini-only-flag")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args == ["claude", "-p", "p", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_key_normalization_hyphen_to_underscore_uppercase(monkeypatch):
|
||||
"""`kiro-cli` key looks up `SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS`
|
||||
(hyphens replaced with underscores, then uppercased)."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_KIRO_CLI_EXTRA_ARGS", "--some-kiro-flag"
|
||||
)
|
||||
args = _KiroCliStub().build_exec_args("p")
|
||||
assert args == [
|
||||
"kiro-cli",
|
||||
"-p",
|
||||
"p",
|
||||
"--some-kiro-flag",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_requires_cli_false_returns_none(monkeypatch):
|
||||
"""`requires_cli: False` short-circuits to None — the env-var
|
||||
hook is never reached and no argv is built."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_NO_CLI_EXTRA_ARGS", "--should-not-appear")
|
||||
assert _NoCliStub().build_exec_args("p") is None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Base-class coverage
|
||||
#
|
||||
# Most integrations inherit `build_exec_args` from `MarkdownIntegration`
|
||||
# or `TomlIntegration` without overriding it. The tests above use
|
||||
# `SkillsIntegration` stubs (which share the same hook mechanism) — these
|
||||
# tests exercise the two other base implementations directly so all three
|
||||
# concrete bases are covered.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_markdown_integration_base_honours_extra_args(monkeypatch):
|
||||
"""A bare `MarkdownIntegration` subclass — which does not override
|
||||
`build_exec_args` — must honour the env var via the base
|
||||
implementation. Covers the most common integration pattern."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_MD_AGENT_EXTRA_ARGS", "--debug --max-tokens 100"
|
||||
)
|
||||
args = _MarkdownAgentStub().build_exec_args("p")
|
||||
assert args == [
|
||||
"md-agent",
|
||||
"-p",
|
||||
"p",
|
||||
"--debug",
|
||||
"--max-tokens",
|
||||
"100",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
def test_toml_integration_base_honours_extra_args(monkeypatch):
|
||||
"""A bare `TomlIntegration` subclass — which does not override
|
||||
`build_exec_args` — must honour the env var via the base
|
||||
implementation. Covers Gemini/Tabnine-style integrations."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_TOML_AGENT_EXTRA_ARGS", "--yolo"
|
||||
)
|
||||
args = _TomlAgentStub().build_exec_args("p", model="gemini-pro")
|
||||
# TomlIntegration uses `-m` for model (vs Markdown's `--model`).
|
||||
assert args == [
|
||||
"toml-agent",
|
||||
"-p",
|
||||
"p",
|
||||
"--yolo",
|
||||
"-m",
|
||||
"gemini-pro",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Override-integration coverage
|
||||
#
|
||||
# CodexIntegration, DevinIntegration, OpencodeIntegration and
|
||||
# CopilotIntegration each override `build_exec_args` rather than using the
|
||||
# base implementations. The env-var hook must be wired into every override
|
||||
# so the documented behaviour ("works for every requires_cli integration")
|
||||
# is honoured. These tests lock that contract per integration.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_codex_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXTRA_ARGS", "--sandbox read-only")
|
||||
args = CodexIntegration().build_exec_args("p", model="gpt-5")
|
||||
assert args == [
|
||||
"codex",
|
||||
"exec",
|
||||
"p",
|
||||
"--sandbox",
|
||||
"read-only",
|
||||
"--model",
|
||||
"gpt-5",
|
||||
"--json",
|
||||
]
|
||||
|
||||
|
||||
def test_devin_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_DEVIN_EXTRA_ARGS", "--no-confirm")
|
||||
args = DevinIntegration().build_exec_args("p")
|
||||
assert args == ["devin", "-p", "p", "--no-confirm"]
|
||||
|
||||
|
||||
def test_opencode_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.opencode import OpencodeIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS", "--quiet")
|
||||
args = OpencodeIntegration().build_exec_args("p")
|
||||
assert args == [
|
||||
"opencode",
|
||||
"run",
|
||||
"--quiet",
|
||||
"--format",
|
||||
"json",
|
||||
"p",
|
||||
]
|
||||
|
||||
|
||||
def test_opencode_extra_args_cannot_clobber_prompt_derived_command(
|
||||
monkeypatch,
|
||||
):
|
||||
"""Operator-injected extra args must appear BEFORE the prompt-derived
|
||||
``--command <X>`` so that Spec Kit's command selection wins under
|
||||
repeated-flag CLI semantics (last value typically takes precedence).
|
||||
|
||||
Locks against the regression where an operator setting
|
||||
``SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS="--command malicious"`` could redirect
|
||||
a slash-prefixed prompt to a different command.
|
||||
"""
|
||||
from specify_cli.integrations.opencode import OpencodeIntegration
|
||||
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_OPENCODE_EXTRA_ARGS", "--command operator-override"
|
||||
)
|
||||
args = OpencodeIntegration().build_exec_args("/speckit body text")
|
||||
# Prompt-derived "--command speckit" appears AFTER the
|
||||
# operator-injected one, so a CLI that resolves repeated flags
|
||||
# last-wins will honour Spec Kit's choice.
|
||||
assert args == [
|
||||
"opencode",
|
||||
"run",
|
||||
"--command",
|
||||
"operator-override",
|
||||
"--command",
|
||||
"speckit",
|
||||
"--format",
|
||||
"json",
|
||||
"body text",
|
||||
]
|
||||
|
||||
|
||||
def test_copilot_integration_honours_extra_args(monkeypatch):
|
||||
from specify_cli.integrations.copilot import (
|
||||
CopilotIntegration,
|
||||
_copilot_executable,
|
||||
)
|
||||
|
||||
# Disable --yolo so the argv shape stays deterministic.
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'"
|
||||
)
|
||||
args = CopilotIntegration().build_exec_args("p")
|
||||
# `_copilot_executable()` returns "copilot.cmd" on Windows and
|
||||
# "copilot" elsewhere; the test must mirror that to stay portable.
|
||||
assert args == [
|
||||
_copilot_executable(),
|
||||
"-p",
|
||||
"p",
|
||||
"--allow-tool",
|
||||
"shell(echo)",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# `dispatch_command` end-to-end coverage
|
||||
#
|
||||
# Workflow execution calls `impl.dispatch_command(...)`, not
|
||||
# `build_exec_args` directly. `IntegrationBase.dispatch_command` delegates
|
||||
# to `build_exec_args` (so the override fixes above flow through), but
|
||||
# `CopilotIntegration` overrides `dispatch_command` and constructs
|
||||
# `cli_args` inline — the hook must be invoked there too or the env var
|
||||
# is silently ignored at workflow runtime. These tests monkeypatch
|
||||
# `subprocess.run` and assert the env-var args reach the executed argv.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _RunCapture:
|
||||
"""Test double that captures argv passed to subprocess.run."""
|
||||
|
||||
def __init__(self):
|
||||
self.captured_args: list[str] | None = None
|
||||
|
||||
def __call__(self, args, **kwargs):
|
||||
self.captured_args = list(args)
|
||||
|
||||
class _Result:
|
||||
returncode = 0
|
||||
stdout = ""
|
||||
stderr = ""
|
||||
|
||||
return _Result()
|
||||
|
||||
|
||||
def test_copilot_dispatch_command_includes_extra_args(monkeypatch):
|
||||
"""Locks the bypass fix: `CopilotIntegration.dispatch_command`
|
||||
must honour `SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS`, not just `build_exec_args`.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
|
||||
capture = _RunCapture()
|
||||
monkeypatch.setattr(subprocess, "run", capture)
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_COPILOT_EXTRA_ARGS", "--allow-tool 'shell(echo)'"
|
||||
)
|
||||
|
||||
CopilotIntegration().dispatch_command(
|
||||
"speckit.plan", args="body", stream=False
|
||||
)
|
||||
|
||||
assert capture.captured_args is not None
|
||||
# Hook inserted between `-p prompt` and the canonical Copilot flags.
|
||||
p_idx = capture.captured_args.index("-p")
|
||||
agent_idx = capture.captured_args.index("--agent")
|
||||
extra_idx = capture.captured_args.index("--allow-tool")
|
||||
assert p_idx < extra_idx < agent_idx
|
||||
assert "shell(echo)" in capture.captured_args
|
||||
|
||||
|
||||
def test_codex_dispatch_command_includes_extra_args(monkeypatch):
|
||||
"""Lock the inherited `IntegrationBase.dispatch_command` path:
|
||||
Codex (and by transitivity Devin, Opencode) flow through
|
||||
`build_exec_args`, so the env var must reach argv at workflow
|
||||
runtime.
|
||||
"""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
capture = _RunCapture()
|
||||
monkeypatch.setattr(subprocess, "run", capture)
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXTRA_ARGS", "--sandbox read-only")
|
||||
|
||||
CodexIntegration().dispatch_command(
|
||||
"speckit.plan", args="body", stream=False
|
||||
)
|
||||
|
||||
assert capture.captured_args is not None
|
||||
assert "--sandbox" in capture.captured_args
|
||||
assert "read-only" in capture.captured_args
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# SPECKIT_INTEGRATION_<KEY>_EXECUTABLE tests
|
||||
#
|
||||
# The `_resolve_executable()` method on `IntegrationBase` checks
|
||||
# `SPECKIT_INTEGRATION_<KEY>_EXECUTABLE` and, when set, substitutes that
|
||||
# value for `self.key` as the first token in argv. The tests below lock
|
||||
# the behaviour across shared and override integration paths:
|
||||
# - the shared SkillsIntegration/MarkdownIntegration/TomlIntegration bases,
|
||||
# - representative override integrations,
|
||||
# - the hyphen→underscore key normalisation, and
|
||||
# - whitespace/unset no-op guarantee.
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_executable_env_var_unset_uses_key():
|
||||
"""Default: no override → executable is the integration key."""
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args[0] == "claude"
|
||||
|
||||
|
||||
def test_executable_env_var_replaces_first_argv_token(monkeypatch):
|
||||
"""Setting the env var substitutes the executable name in argv."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude/bin/claude")
|
||||
args = _ClaudeStub().build_exec_args("hello")
|
||||
assert args[0] == "/opt/claude/bin/claude"
|
||||
assert args[1:] == ["-p", "hello", "--output-format", "json"]
|
||||
|
||||
|
||||
def test_executable_env_var_whitespace_only_falls_back_to_key(monkeypatch):
|
||||
"""Whitespace-only value is treated as unset → falls back to self.key."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", " ")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args[0] == "claude"
|
||||
|
||||
|
||||
def test_executable_env_var_key_normalization_hyphen_to_underscore(monkeypatch):
|
||||
"""`kiro-cli` key maps to `SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE`."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_KIRO_CLI_EXECUTABLE", "/usr/local/bin/kiro-cli")
|
||||
args = _KiroCliStub().build_exec_args("p")
|
||||
assert args[0] == "/usr/local/bin/kiro-cli"
|
||||
|
||||
|
||||
def test_executable_env_var_other_integration_ignored(monkeypatch):
|
||||
"""`SPECKIT_INTEGRATION_GEMINI_EXECUTABLE` must NOT affect Claude."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_GEMINI_EXECUTABLE", "/custom/gemini")
|
||||
args = _ClaudeStub().build_exec_args("p")
|
||||
assert args[0] == "claude"
|
||||
|
||||
|
||||
def test_executable_env_var_markdown_integration(monkeypatch):
|
||||
"""MarkdownIntegration base honours the executable env var."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_MD_AGENT_EXECUTABLE", "/custom/md-agent")
|
||||
args = _MarkdownAgentStub().build_exec_args("p")
|
||||
assert args[0] == "/custom/md-agent"
|
||||
|
||||
|
||||
def test_executable_env_var_toml_integration(monkeypatch):
|
||||
"""TomlIntegration base honours the executable env var."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_TOML_AGENT_EXECUTABLE", "/custom/toml-agent")
|
||||
args = _TomlAgentStub().build_exec_args("p")
|
||||
assert args[0] == "/custom/toml-agent"
|
||||
|
||||
|
||||
def test_executable_env_var_requires_cli_false_returns_none(monkeypatch):
|
||||
"""`requires_cli: False` still returns None even when executable is set."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_NO_CLI_EXECUTABLE", "/custom/no-cli")
|
||||
assert _NoCliStub().build_exec_args("p") is None
|
||||
|
||||
|
||||
def test_executable_env_var_codex_integration(monkeypatch):
|
||||
"""CodexIntegration honours the executable env var."""
|
||||
from specify_cli.integrations.codex import CodexIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CODEX_EXECUTABLE", "/opt/codex")
|
||||
args = CodexIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/codex"
|
||||
assert args[1] == "exec"
|
||||
|
||||
|
||||
def test_executable_env_var_devin_integration(monkeypatch):
|
||||
"""DevinIntegration honours the executable env var."""
|
||||
from specify_cli.integrations.devin import DevinIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_DEVIN_EXECUTABLE", "/opt/devin")
|
||||
args = DevinIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/devin"
|
||||
|
||||
|
||||
def test_executable_env_var_opencode_integration(monkeypatch):
|
||||
"""OpencodeIntegration honours the executable env var."""
|
||||
from specify_cli.integrations.opencode import OpencodeIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_OPENCODE_EXECUTABLE", "/opt/opencode")
|
||||
args = OpencodeIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/opencode"
|
||||
assert args[1] == "run"
|
||||
|
||||
|
||||
def test_executable_env_var_copilot_integration(monkeypatch):
|
||||
"""CopilotIntegration honours the executable env var, overriding the
|
||||
platform-specific default from `_copilot_executable()`."""
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_COPILOT_EXECUTABLE", "/opt/copilot")
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
args = CopilotIntegration().build_exec_args("p")
|
||||
assert args[0] == "/opt/copilot"
|
||||
|
||||
|
||||
def test_executable_env_var_copilot_unset_uses_platform_default(monkeypatch):
|
||||
"""When `SPECKIT_INTEGRATION_COPILOT_EXECUTABLE` is unset, Copilot
|
||||
falls back to the platform-specific default from `_copilot_executable()`."""
|
||||
from specify_cli.integrations.copilot import CopilotIntegration, _copilot_executable
|
||||
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
args = CopilotIntegration().build_exec_args("p")
|
||||
assert args[0] == _copilot_executable()
|
||||
|
||||
|
||||
def test_executable_env_var_copilot_dispatch_command(monkeypatch):
|
||||
"""CopilotIntegration.dispatch_command honours the executable env var."""
|
||||
import subprocess
|
||||
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
|
||||
capture = _RunCapture()
|
||||
monkeypatch.setattr(subprocess, "run", capture)
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_COPILOT_EXECUTABLE", "/opt/copilot")
|
||||
monkeypatch.setenv("SPECKIT_COPILOT_ALLOW_ALL_TOOLS", "0")
|
||||
|
||||
CopilotIntegration().dispatch_command("speckit.plan", args="body", stream=False)
|
||||
|
||||
assert capture.captured_args is not None
|
||||
assert capture.captured_args[0] == "/opt/copilot"
|
||||
|
||||
|
||||
def test_executable_and_extra_args_both_honoured(monkeypatch):
|
||||
"""Both the executable override and extra args env vars can be set
|
||||
simultaneously — they are independent hooks."""
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_CLAUDE_EXECUTABLE", "/opt/claude")
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_CLAUDE_EXTRA_ARGS", "--dangerously-skip-permissions"
|
||||
)
|
||||
args = _ClaudeStub().build_exec_args("hello", model="sonnet")
|
||||
assert args == [
|
||||
"/opt/claude",
|
||||
"-p",
|
||||
"hello",
|
||||
"--dangerously-skip-permissions",
|
||||
"--model",
|
||||
"sonnet",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
@@ -226,8 +226,8 @@ class MarkdownIntegrationTests:
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -243,15 +243,17 @@ class MarkdownIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze", "checklist", "clarify", "constitution",
|
||||
"implement", "plan", "specify", "tasks", "taskstoissues",
|
||||
]
|
||||
@@ -291,6 +293,16 @@ class MarkdownIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -357,8 +357,8 @@ class SkillsIntegrationTests:
|
||||
assert skills_dir.is_dir(), f"Skills directory {skills_dir} not created"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -374,10 +374,11 @@ class SkillsIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- IntegrationOption ------------------------------------------------
|
||||
@@ -402,9 +403,11 @@ class SkillsIntegrationTests:
|
||||
skills_prefix = i.config["folder"].rstrip("/") + "/" + i.config.get("commands_subdir", "skills")
|
||||
|
||||
files = []
|
||||
# Skill files
|
||||
# Skill files (core commands)
|
||||
for cmd in self._SKILL_COMMANDS:
|
||||
files.append(f"{skills_prefix}/speckit-{cmd}/SKILL.md")
|
||||
# Extension-installed skill (agent-context)
|
||||
files.append(f"{skills_prefix}/speckit-agent-context-update/SKILL.md")
|
||||
# Integration metadata
|
||||
files += [
|
||||
".specify/init-options.json",
|
||||
@@ -443,6 +446,15 @@ class SkillsIntegrationTests:
|
||||
".specify/workflows/speckit/workflow.yml",
|
||||
".specify/workflows/workflow-registry.json",
|
||||
]
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -457,8 +457,8 @@ class TomlIntegrationTests:
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -474,15 +474,17 @@ class TomlIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
@@ -543,6 +545,16 @@ class TomlIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -336,8 +336,8 @@ class YamlIntegrationTests:
|
||||
assert len(commands) > 0, f"No command files in {cmd_dir}"
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the active integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the active integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -353,15 +353,17 @@ class YamlIntegrationTests:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
i = get_integration(self.KEY)
|
||||
assert opts.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {opts.get('context_file')!r}"
|
||||
assert ext_cfg.get("context_file") == i.context_file, (
|
||||
f"Expected context_file={i.context_file!r}, got {ext_cfg.get('context_file')!r}"
|
||||
)
|
||||
|
||||
# -- Complete file inventory ------------------------------------------
|
||||
|
||||
COMMAND_STEMS = [
|
||||
"agent-context.update",
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
@@ -422,6 +424,16 @@ class YamlIntegrationTests:
|
||||
files.append(".specify/workflows/speckit/workflow.yml")
|
||||
files.append(".specify/workflows/workflow-registry.json")
|
||||
|
||||
# Bundled agent-context extension
|
||||
files.append(".specify/extensions.yml")
|
||||
files.append(".specify/extensions/.registry")
|
||||
files.append(".specify/extensions/agent-context/README.md")
|
||||
files.append(".specify/extensions/agent-context/agent-context-config.yml")
|
||||
files.append(".specify/extensions/agent-context/commands/speckit.agent-context.update.md")
|
||||
files.append(".specify/extensions/agent-context/extension.yml")
|
||||
files.append(".specify/extensions/agent-context/scripts/bash/update-agent-context.sh")
|
||||
files.append(".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1")
|
||||
|
||||
# Agent context file (if set)
|
||||
if i.context_file:
|
||||
files.append(i.context_file)
|
||||
|
||||
@@ -178,6 +178,7 @@ class TestCopilotIntegration:
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
".github/agents/speckit.checklist.agent.md",
|
||||
".github/agents/speckit.clarify.agent.md",
|
||||
@@ -187,6 +188,7 @@ class TestCopilotIntegration:
|
||||
".github/agents/speckit.specify.agent.md",
|
||||
".github/agents/speckit.tasks.agent.md",
|
||||
".github/agents/speckit.taskstoissues.agent.md",
|
||||
".github/prompts/speckit.agent-context.update.prompt.md",
|
||||
".github/prompts/speckit.analyze.prompt.md",
|
||||
".github/prompts/speckit.checklist.prompt.md",
|
||||
".github/prompts/speckit.clarify.prompt.md",
|
||||
@@ -198,6 +200,14 @@ class TestCopilotIntegration:
|
||||
".github/prompts/speckit.taskstoissues.prompt.md",
|
||||
".vscode/settings.json",
|
||||
".github/copilot-instructions.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/integration.json",
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
@@ -238,6 +248,7 @@ class TestCopilotIntegration:
|
||||
assert result.exit_code == 0
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
expected = sorted([
|
||||
".github/agents/speckit.agent-context.update.agent.md",
|
||||
".github/agents/speckit.analyze.agent.md",
|
||||
".github/agents/speckit.checklist.agent.md",
|
||||
".github/agents/speckit.clarify.agent.md",
|
||||
@@ -247,6 +258,7 @@ class TestCopilotIntegration:
|
||||
".github/agents/speckit.specify.agent.md",
|
||||
".github/agents/speckit.tasks.agent.md",
|
||||
".github/agents/speckit.taskstoissues.agent.md",
|
||||
".github/prompts/speckit.agent-context.update.prompt.md",
|
||||
".github/prompts/speckit.analyze.prompt.md",
|
||||
".github/prompts/speckit.checklist.prompt.md",
|
||||
".github/prompts/speckit.clarify.prompt.md",
|
||||
@@ -258,6 +270,14 @@ class TestCopilotIntegration:
|
||||
".github/prompts/speckit.taskstoissues.prompt.md",
|
||||
".vscode/settings.json",
|
||||
".github/copilot-instructions.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/integration.json",
|
||||
".specify/init-options.json",
|
||||
".specify/integrations/copilot.manifest.json",
|
||||
@@ -624,10 +644,20 @@ class TestCopilotSkillsMode:
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
actual = sorted(p.relative_to(project).as_posix() for p in project.rglob("*") if p.is_file())
|
||||
expected = sorted([
|
||||
# Skill files
|
||||
# Skill files (core + extension-installed agent-context command)
|
||||
*[f".github/skills/speckit-{cmd}/SKILL.md" for cmd in self._SKILL_COMMANDS],
|
||||
".github/skills/speckit-agent-context-update/SKILL.md",
|
||||
# Context file
|
||||
".github/copilot-instructions.md",
|
||||
# Bundled agent-context extension
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
# Integration metadata
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
|
||||
@@ -195,6 +195,39 @@ class TestGenericIntegration:
|
||||
content = implement_file.read_text(encoding="utf-8")
|
||||
assert ".specify/memory/constitution.md" in content
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"command_stem",
|
||||
[
|
||||
"analyze",
|
||||
"checklist",
|
||||
"clarify",
|
||||
"implement",
|
||||
"plan",
|
||||
"specify",
|
||||
"tasks",
|
||||
"taskstoissues",
|
||||
],
|
||||
)
|
||||
def test_command_loads_constitution_context(self, tmp_path, command_stem):
|
||||
"""Every command except constitution must reference constitution.md."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
cmd_file = tmp_path / ".custom" / "cmds" / f"speckit.{command_stem}.md"
|
||||
assert cmd_file.exists(), f"Command file missing: {cmd_file.name}"
|
||||
content = cmd_file.read_text(encoding="utf-8")
|
||||
assert "constitution.md" in content, (
|
||||
f"speckit.{command_stem}.md must reference constitution.md"
|
||||
)
|
||||
|
||||
def test_constitution_command_exists(self, tmp_path):
|
||||
"""The constitution command itself must exist but is not required to load itself."""
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ".custom/cmds"})
|
||||
cmd_file = tmp_path / ".custom" / "cmds" / "speckit.constitution.md"
|
||||
assert cmd_file.exists()
|
||||
|
||||
# -- CLI --------------------------------------------------------------
|
||||
|
||||
def test_cli_generic_without_commands_dir_fails(self, tmp_path):
|
||||
@@ -211,8 +244,8 @@ class TestGenericIntegration:
|
||||
assert result.exit_code != 0
|
||||
|
||||
def test_init_options_includes_context_file(self, tmp_path):
|
||||
"""init-options.json must include context_file for the generic integration."""
|
||||
import json
|
||||
"""agent-context extension config must include context_file for the generic integration."""
|
||||
import yaml
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
@@ -229,8 +262,9 @@ class TestGenericIntegration:
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
assert result.exit_code == 0
|
||||
opts = json.loads((project / ".specify" / "init-options.json").read_text())
|
||||
assert opts.get("context_file") == "AGENTS.md"
|
||||
ext_cfg_path = project / ".specify" / "extensions" / "agent-context" / "agent-context-config.yml"
|
||||
ext_cfg = yaml.safe_load(ext_cfg_path.read_text(encoding="utf-8")) if ext_cfg_path.exists() else {}
|
||||
assert ext_cfg.get("context_file") == "AGENTS.md"
|
||||
|
||||
def test_complete_file_inventory_sh(self, tmp_path):
|
||||
"""Every file produced by specify init --integration generic --ai-commands-dir ... --script sh."""
|
||||
@@ -265,6 +299,14 @@ class TestGenericIntegration:
|
||||
".myagent/commands/speckit.specify.md",
|
||||
".myagent/commands/speckit.tasks.md",
|
||||
".myagent/commands/speckit.taskstoissues.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
@@ -321,6 +363,14 @@ class TestGenericIntegration:
|
||||
".myagent/commands/speckit.specify.md",
|
||||
".myagent/commands/speckit.tasks.md",
|
||||
".myagent/commands/speckit.taskstoissues.md",
|
||||
".specify/extensions.yml",
|
||||
".specify/extensions/.registry",
|
||||
".specify/extensions/agent-context/README.md",
|
||||
".specify/extensions/agent-context/agent-context-config.yml",
|
||||
".specify/extensions/agent-context/commands/speckit.agent-context.update.md",
|
||||
".specify/extensions/agent-context/extension.yml",
|
||||
".specify/extensions/agent-context/scripts/bash/update-agent-context.sh",
|
||||
".specify/extensions/agent-context/scripts/powershell/update-agent-context.ps1",
|
||||
".specify/init-options.json",
|
||||
".specify/integration.json",
|
||||
".specify/integrations/generic.manifest.json",
|
||||
|
||||
@@ -241,10 +241,15 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
# Ensure no .hermes/skills/speckit-*/SKILL.md in project dir
|
||||
hermes_skill_files = [f for f in actual if f.startswith(".hermes/skills/speckit-")]
|
||||
# Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
hermes_skill_files = [
|
||||
f for f in actual
|
||||
if f.startswith(".hermes/skills/speckit-")
|
||||
and "agent-context" not in f
|
||||
]
|
||||
assert hermes_skill_files == [], (
|
||||
f"Expected no local SKILL.md files, found: {hermes_skill_files}"
|
||||
f"Expected no local core SKILL.md files, found: {hermes_skill_files}"
|
||||
)
|
||||
# Ensure the marker exists (empty dir won't appear in file listing)
|
||||
assert (project / ".hermes" / "skills").is_dir()
|
||||
@@ -274,9 +279,15 @@ class TestHermesIntegration(SkillsIntegrationTests):
|
||||
p.relative_to(project).as_posix()
|
||||
for p in project.rglob("*") if p.is_file()
|
||||
)
|
||||
hermes_skill_files = [f for f in actual if f.startswith(".hermes/skills/speckit-")]
|
||||
# Ensure no core .hermes/skills/speckit-*/SKILL.md in project dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
hermes_skill_files = [
|
||||
f for f in actual
|
||||
if f.startswith(".hermes/skills/speckit-")
|
||||
and "agent-context" not in f
|
||||
]
|
||||
assert hermes_skill_files == [], (
|
||||
f"Expected no local SKILL.md files, found: {hermes_skill_files}"
|
||||
f"Expected no local core SKILL.md files, found: {hermes_skill_files}"
|
||||
)
|
||||
assert (project / ".hermes" / "skills").is_dir()
|
||||
|
||||
@@ -342,6 +353,10 @@ class TestHermesAutoPromote:
|
||||
assert (home / ".hermes" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
# Local marker should exist
|
||||
assert (target / ".hermes" / "skills").is_dir()
|
||||
# No SKILL.md files in project-local dir
|
||||
local_skills = list((target / ".hermes" / "skills").iterdir())
|
||||
# No core SKILL.md files in project-local dir
|
||||
# (extension-installed skills like agent-context-update may appear)
|
||||
local_skills = [
|
||||
d for d in (target / ".hermes" / "skills").iterdir()
|
||||
if "agent-context" not in d.name
|
||||
]
|
||||
assert local_skills == [], f"Local skills dir should be empty, got: {local_skills}"
|
||||
|
||||
@@ -255,7 +255,7 @@ class TestIntegrationInstall:
|
||||
assert updated["speckit_version"] == "0.8.11"
|
||||
assert updated["integration"] == "claude"
|
||||
assert updated["ai"] == "claude"
|
||||
assert updated["context_file"] == "CLAUDE.md"
|
||||
assert "context_file" not in updated
|
||||
|
||||
def test_install_additional_preserves_shared_manifest(self, tmp_path):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
@@ -1250,7 +1250,7 @@ class TestIntegrationUpgrade:
|
||||
assert updated["speckit_version"] == "0.8.11"
|
||||
assert updated["integration"] == "gemini"
|
||||
assert updated["ai"] == "gemini"
|
||||
assert updated["context_file"] == "GEMINI.md"
|
||||
assert "context_file" not in updated
|
||||
|
||||
def test_upgrade_does_not_persist_state_when_shared_infra_refresh_fails(self, tmp_path, monkeypatch):
|
||||
project = _init_project(tmp_path, "claude")
|
||||
@@ -1376,11 +1376,16 @@ class TestIntegrationUpgrade:
|
||||
new_commands = sorted(canonical.glob("speckit.*.md"))
|
||||
assert len(new_commands) > 0, "Commands should exist in .opencode/commands/"
|
||||
|
||||
# Stale files removed from legacy dir
|
||||
remaining = list(legacy.glob("speckit.*.md"))
|
||||
assert len(remaining) == 0, (
|
||||
f"Legacy .opencode/command/ should have no speckit files after upgrade, "
|
||||
f"found: {[f.name for f in remaining]}"
|
||||
# Stale files removed from legacy dir (extension-installed commands
|
||||
# like agent-context.update may still appear — only check the original
|
||||
# core command stems that should have been migrated).
|
||||
core_remaining = [
|
||||
f for f in legacy.glob("speckit.*.md")
|
||||
if "agent-context" not in f.name
|
||||
]
|
||||
assert len(core_remaining) == 0, (
|
||||
f"Legacy .opencode/command/ should have no core speckit files after upgrade, "
|
||||
f"found: {[f.name for f in core_remaining]}"
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -3807,6 +3807,67 @@ class TestExtensionAddCLI:
|
||||
assert "bundled with spec-kit" in result.output
|
||||
assert "reinstall" in result.output.lower()
|
||||
|
||||
def test_add_from_url_prompts_before_spinner(self, tmp_path):
|
||||
"""Confirm prompt for --from <url> must fire before the console.status spinner.
|
||||
|
||||
Regression test for #2783: typer.confirm() inside console.status()
|
||||
was overwritten by the Rich spinner, making the command appear hung.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch, MagicMock
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
call_order: list[str] = []
|
||||
|
||||
original_status = MagicMock()
|
||||
|
||||
def record_status(*args, **kwargs):
|
||||
call_order.append("spinner")
|
||||
return original_status
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("specify_cli.console.status", side_effect=record_status), \
|
||||
patch("typer.confirm", side_effect=lambda *a, **kw: (call_order.append("confirm"), False)[-1]):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert "confirm" in call_order, "confirm prompt was never called"
|
||||
# The confirm must fire BEFORE the spinner is entered
|
||||
if "spinner" in call_order:
|
||||
assert call_order.index("confirm") < call_order.index("spinner"), \
|
||||
f"confirm must precede spinner, got: {call_order}"
|
||||
assert result.exit_code == 0 # user declined → clean exit
|
||||
|
||||
def test_add_from_url_cancel_exits_cleanly(self, tmp_path):
|
||||
"""Declining the --from <url> confirmation should exit with code 0."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = tmp_path / "test-project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=False):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
["extension", "add", "my-ext", "--from", "https://example.com/ext.zip"],
|
||||
catch_exceptions=True,
|
||||
)
|
||||
|
||||
assert result.exit_code == 0
|
||||
assert "Cancelled" in result.output
|
||||
|
||||
|
||||
class TestDownloadExtensionBundled:
|
||||
"""Tests for download_extension handling of bundled extensions."""
|
||||
|
||||
@@ -2332,6 +2332,48 @@ steps:
|
||||
assert state.status == RunStatus.COMPLETED
|
||||
assert state.step_results["only-step"]["output"]["stdout"].strip() == "hello"
|
||||
|
||||
def test_run_id_uses_speckit_workflow_run_id_env_override(self, project_dir, monkeypatch):
|
||||
"""When no run_id argument is provided, SPECKIT_WORKFLOW_RUN_ID overrides the auto-generated run ID."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
|
||||
monkeypatch.setenv("SPECKIT_WORKFLOW_RUN_ID", "env-run-123")
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "env-run-id"
|
||||
name: "Env Run Id"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: stamp
|
||||
type: shell
|
||||
run: "echo {{ context.run_id }}"
|
||||
""")
|
||||
state = WorkflowEngine(project_dir).execute(definition)
|
||||
|
||||
assert state.run_id == "env-run-123"
|
||||
assert state.step_results["stamp"]["output"]["stdout"].strip() == "env-run-123"
|
||||
|
||||
def test_run_id_arg_takes_precedence_over_env_override(self, project_dir, monkeypatch):
|
||||
"""Explicit run_id keeps existing precedence over SPECKIT_WORKFLOW_RUN_ID."""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, WorkflowEngine
|
||||
|
||||
monkeypatch.setenv("SPECKIT_WORKFLOW_RUN_ID", "env-run-123")
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "explicit-run-id"
|
||||
name: "Explicit Run Id"
|
||||
version: "1.0.0"
|
||||
steps:
|
||||
- id: stamp
|
||||
type: shell
|
||||
run: "echo {{ context.run_id }}"
|
||||
""")
|
||||
state = WorkflowEngine(project_dir).execute(definition, run_id="explicit-456")
|
||||
|
||||
assert state.run_id == "explicit-456"
|
||||
assert state.step_results["stamp"]["output"]["stdout"].strip() == "explicit-456"
|
||||
|
||||
|
||||
# ===== State Persistence Tests =====
|
||||
|
||||
|
||||
Reference in New Issue
Block a user