mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
489a3d51d1 |
@@ -8,7 +8,7 @@ body:
|
||||
value: |
|
||||
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
||||
|
||||
**Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Command Code, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
**Currently supported agents**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
@@ -70,7 +70,6 @@ body:
|
||||
- Cline
|
||||
- CodeBuddy
|
||||
- Codex CLI
|
||||
- Command Code
|
||||
- Cursor
|
||||
- Devin for Terminal
|
||||
- Factory Droid
|
||||
|
||||
@@ -16,8 +16,6 @@ body:
|
||||
- If you host a bundle catalog, test catalog installation with `specify bundle catalog add <catalog-url> --id <catalog-id> --policy install-allowed` and `specify bundle install <bundle-id>`
|
||||
- If your bundle depends on components from non-default catalogs, document those catalog URLs and test installation from a clean project
|
||||
|
||||
**After submitting:** a maintainer applies the `bundle-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one.
|
||||
|
||||
- type: input
|
||||
id: bundle-id
|
||||
attributes:
|
||||
|
||||
@@ -14,8 +14,6 @@ body:
|
||||
- Create a GitHub release with a version tag (e.g., v1.0.0)
|
||||
- Test installation: `specify extension add <extension-name> --from <your-release-url>`
|
||||
|
||||
**After submitting:** a maintainer applies the `extension-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one.
|
||||
|
||||
- type: input
|
||||
id: extension-id
|
||||
attributes:
|
||||
|
||||
@@ -64,7 +64,6 @@ body:
|
||||
- Cline
|
||||
- CodeBuddy
|
||||
- Codex CLI
|
||||
- Command Code
|
||||
- Cursor
|
||||
- Devin for Terminal
|
||||
- Factory Droid
|
||||
|
||||
@@ -14,8 +14,6 @@ body:
|
||||
- Create a GitHub release with a version tag (e.g., v1.0.0)
|
||||
- Test installation from the release archive: `specify preset add --from <download-url>`
|
||||
|
||||
**After submitting:** a maintainer applies the `preset-submission` label during issue triage, which starts the automated catalog validation. You don't need to apply any label or ask for one.
|
||||
|
||||
- type: input
|
||||
id: preset-id
|
||||
attributes:
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"entries": {
|
||||
"actions/checkout@v7.0.1": {
|
||||
"actions/checkout@v6.0.3": {
|
||||
"repo": "actions/checkout",
|
||||
"version": "v7.0.1",
|
||||
"sha": "3d3c42e5aac5ba805825da76410c181273ba90b1"
|
||||
"version": "v6.0.3",
|
||||
"sha": "df4cb1c069e1874edd31b4311f1884172cec0e10"
|
||||
},
|
||||
"actions/download-artifact@v8.0.1": {
|
||||
"repo": "actions/download-artifact",
|
||||
@@ -15,20 +15,10 @@
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"actions/setup-node@v7.0.0": {
|
||||
"actions/setup-node@v6.4.0": {
|
||||
"repo": "actions/setup-node",
|
||||
"version": "v7.0.0",
|
||||
"sha": "820762786026740c76f36085b0efc47a31fe5020"
|
||||
},
|
||||
"actions/setup-python@v7.0.0": {
|
||||
"repo": "actions/setup-python",
|
||||
"version": "v7.0.0",
|
||||
"sha": "5fda3b95a4ea91299a34e894583c3862153e4b97"
|
||||
},
|
||||
"astral-sh/setup-uv@v9.0.0": {
|
||||
"repo": "astral-sh/setup-uv",
|
||||
"version": "v9.0.0",
|
||||
"sha": "c771a70e6277c0a99b617c7a806ffedaca235ff9"
|
||||
"version": "v6.4.0",
|
||||
"sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"
|
||||
},
|
||||
"actions/upload-artifact@v7.0.1": {
|
||||
"repo": "actions/upload-artifact",
|
||||
|
||||
@@ -8,10 +8,6 @@ updates:
|
||||
- dependency-name: "github/gh-aw-actions/**"
|
||||
- dependency-name: "github/gh-aw-actions" # Managed by gh aw compile. Version-locked to the gh-aw compiler; do not bump.
|
||||
package-ecosystem: github-actions
|
||||
groups:
|
||||
codeql-action:
|
||||
patterns:
|
||||
- "github/codeql-action*"
|
||||
schedule:
|
||||
interval: weekly
|
||||
version: 2
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"c64e3dc29aca89e48108bb6d4eb877f6264b4cec9cd56dcd36827893802d2a64","body_hash":"cade22e5083254b735200f4ff7d686104e4ccab848ff7355141b9689354834db","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# 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":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"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":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# 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":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"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":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
|
||||
#
|
||||
# ___ _ _
|
||||
|
||||
+6
-6
@@ -1,5 +1,5 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"f9532e77722bfd32e8f626cbfbf6c5372ddcd9997963d965d53e8531b3e28a15","body_hash":"83b7e917f475d6ddf32f17e7da09dd4097a01dddbcbbf8eeec673912285de8b2","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# 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":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"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":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"687ea37b376b3b918331c13fce6cdbf5b9898bab8e514ca57b662b92b6d3cd2c","body_hash":"83b7e917f475d6ddf32f17e7da09dd4097a01dddbcbbf8eeec673912285de8b2","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# 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":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"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":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
|
||||
#
|
||||
# ___ _ _
|
||||
@@ -511,9 +511,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_253299f841a5ea45_EOF'
|
||||
{"add_comment":{"max":2},"add_labels":{"allowed":["extension-submission","validation-passed","validation-failed","needs-info"],"max":3},"create_pull_request":{"allowed_files":["extensions/catalog.community.json","docs/community/extensions.md"],"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_253299f841a5ea45_EOF
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_7b8091d12bfe1e7b_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_7b8091d12bfe1e7b_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -1705,7 +1705,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\":{\"allowed_files\":[\"extensions/catalog.community.json\",\"docs/community/extensions.md\"],\"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_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 }}
|
||||
|
||||
@@ -31,9 +31,6 @@ safe-outputs:
|
||||
labels: [extension-submission, automated]
|
||||
draft: true
|
||||
max: 1
|
||||
allowed-files:
|
||||
- extensions/catalog.community.json
|
||||
- docs/community/extensions.md
|
||||
protected-files:
|
||||
policy: blocked
|
||||
exclude:
|
||||
|
||||
+6
-6
@@ -1,5 +1,5 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"09fb89e95c57c7beeaa0c823fb35f38d5f9db898a419e59dd0a323e7a9209753","body_hash":"cb6c19088fa13da0a8320c174e8c14c4887d2c8a005a5cb2d2d2faa3f890de39","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# 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":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"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":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"b4ba1db5fdec754fa825cc3160879924118bc454a781eed70ef6c90beab83a95","body_hash":"cb6c19088fa13da0a8320c174e8c14c4887d2c8a005a5cb2d2d2faa3f890de39","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# 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":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"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":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
|
||||
#
|
||||
# ___ _ _
|
||||
@@ -511,9 +511,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_429ff69f52872d0b_EOF'
|
||||
{"add_comment":{"max":2},"add_labels":{"allowed":["preset-submission","validation-passed","validation-failed","needs-info"],"max":3},"create_pull_request":{"allowed_files":["presets/catalog.community.json","docs/community/presets.md"],"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_429ff69f52872d0b_EOF
|
||||
cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_78499ff7c917c441_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_78499ff7c917c441_EOF
|
||||
- name: Generate Safe Outputs Tools
|
||||
env:
|
||||
GH_AW_TOOLS_META_JSON: |
|
||||
@@ -1705,7 +1705,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\":{\"allowed_files\":[\"presets/catalog.community.json\",\"docs/community/presets.md\"],\"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_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 }}
|
||||
|
||||
@@ -31,9 +31,6 @@ safe-outputs:
|
||||
labels: [preset-submission, automated]
|
||||
draft: true
|
||||
max: 1
|
||||
allowed-files:
|
||||
- presets/catalog.community.json
|
||||
- docs/community/presets.md
|
||||
protected-files:
|
||||
policy: blocked
|
||||
exclude:
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"00c226f69fb7ec2b63755304328cee6ecddbcedbe4a9840310e5f430bd3949f0","body_hash":"44428ecd81ba0e5ed7bb16436052e6cc3479fe4ad02414812e574d17830a464e","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"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":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"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":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
|
||||
#
|
||||
# ___ _ _
|
||||
|
||||
Generated
+1
-1
@@ -1,5 +1,5 @@
|
||||
# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"aafdb01f262d603577971994522575829802b93d9042d62446313955485df558","body_hash":"4596de2b7de95c7c73c05caedc5c1e97724b39d09d21e9b0dbfc8b570312798a","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
|
||||
# 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":"3d3c42e5aac5ba805825da76410c181273ba90b1","version":"v7.0.1"},{"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":"820762786026740c76f36085b0efc47a31fe5020","version":"v7.0.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# 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":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"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":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
|
||||
# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
|
||||
#
|
||||
# ___ _ _
|
||||
|
||||
Generated
+7
-18
File diff suppressed because one or more lines are too long
@@ -60,22 +60,6 @@ permissions:
|
||||
checkout:
|
||||
fetch-depth: 0
|
||||
|
||||
network:
|
||||
allowed:
|
||||
- defaults
|
||||
- pypi.org
|
||||
- files.pythonhosted.org
|
||||
|
||||
steps:
|
||||
- name: Setup uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
- name: Install Python test dependencies
|
||||
run: uv pip install --system -e ".[test]"
|
||||
|
||||
safe-outputs:
|
||||
noop:
|
||||
report-as-issue: false
|
||||
|
||||
@@ -1,11 +1,11 @@
|
||||
name: "Catalog: Notify submission"
|
||||
name: "Catalog: Auto-assign submission"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled]
|
||||
|
||||
jobs:
|
||||
notify:
|
||||
assign:
|
||||
if: >
|
||||
(github.event.action == 'opened' && (
|
||||
contains(github.event.issue.labels.*.name, 'extension-submission') ||
|
||||
@@ -24,8 +24,24 @@ jobs:
|
||||
- uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9
|
||||
with:
|
||||
script: |
|
||||
const issue = context.payload.issue;
|
||||
const assigned = (issue.assignees || []).map(a => a.login);
|
||||
const marker = '<!-- catalog-assign-bot -->';
|
||||
|
||||
// Assign mnriem if not already assigned
|
||||
if (!assigned.includes('mnriem')) {
|
||||
try {
|
||||
await github.rest.issues.addAssignees({
|
||||
owner: context.repo.owner,
|
||||
repo: context.repo.repo,
|
||||
issue_number: context.issue.number,
|
||||
assignees: ['mnriem'],
|
||||
});
|
||||
} catch (e) {
|
||||
console.log(`Warning: could not assign mnriem: ${e.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
// Post team notification if not already posted
|
||||
const comments = await github.paginate(
|
||||
github.rest.issues.listComments,
|
||||
|
||||
@@ -22,11 +22,11 @@ jobs:
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@5595ccaf912efad79be6eef63a5619ff05969be3 # v4
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0
|
||||
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
|
||||
with:
|
||||
globs: |
|
||||
'**/*.md'
|
||||
|
||||
@@ -260,13 +260,13 @@ The base classes handle most work automatically. Override only when the agent de
|
||||
| Override | When to use | Example |
|
||||
|---|---|---|
|
||||
| `command_filename(template_name)` | Custom file naming or extension | Copilot → `speckit.{name}.agent.md` |
|
||||
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--commands` flag |
|
||||
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `speckit-<name>/SKILL.md` (default) or `.agent.md` + `.prompt.md` + `.vscode/settings.json` (`--commands`) |
|
||||
| `options()` | Integration-specific CLI flags via `--integration-options` | Codex → `--skills` flag, Copilot → `--skills` flag |
|
||||
| `setup()` | Custom install logic (companion files, settings merge) | Copilot → `.agent.md` + `.prompt.md` + `.vscode/settings.json` (default) or `speckit-<name>/SKILL.md` (skills mode) |
|
||||
| `teardown()` | Custom uninstall logic | Rarely needed; base handles manifest-tracked files |
|
||||
|
||||
**Example — Copilot (fully custom `setup`):**
|
||||
|
||||
Copilot extends `IntegrationBase` directly because it supports two layouts. It scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` by default using composition with an internal `_CopilotSkillsHelper`. Its `--commands` mode creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
|
||||
Copilot extends `IntegrationBase` directly because it creates `.agent.md` commands, companion `.prompt.md` files, and merges `.vscode/settings.json`. It also supports a `--skills` mode that scaffolds `speckit-<name>/SKILL.md` under `.github/skills/` using composition with an internal `_CopilotSkillsHelper`. See `src/specify_cli/integrations/copilot/__init__.py` for the full implementation.
|
||||
|
||||
### 7. Update Devcontainer files (Optional)
|
||||
|
||||
@@ -415,28 +415,36 @@ Some agents require custom processing beyond the standard template transformatio
|
||||
|
||||
### Copilot Integration
|
||||
|
||||
GitHub Copilot uses skills by default, scaffolded as
|
||||
`speckit-<name>/SKILL.md` under `.github/skills/`.
|
||||
GitHub Copilot has unique requirements:
|
||||
|
||||
**Commands mode (`--commands`):** Copilot also supports a commands-based layout
|
||||
via `--integration-options="--commands"`. When enabled:
|
||||
|
||||
- Commands use `.agent.md` extension under `.github/agents/`
|
||||
- Commands use `.agent.md` extension (not `.md`)
|
||||
- Each command gets a companion `.prompt.md` file in `.github/prompts/`
|
||||
- `.vscode/settings.json` is merged with prompt file recommendations
|
||||
- `build_command_invocation()` returns bare args for `--agent` dispatch
|
||||
- Installs `.vscode/settings.json` with prompt file recommendations
|
||||
- Context file lives at `.github/copilot-instructions.md`
|
||||
|
||||
In the default skills mode, no companion prompts or VS Code settings merge are
|
||||
created, and `build_command_invocation()` returns `/speckit-<stem>`.
|
||||
Implementation: Extends `IntegrationBase` with custom `setup()` method that:
|
||||
|
||||
1. Processes templates with `process_template()`
|
||||
2. Generates companion `.prompt.md` files
|
||||
3. Merges VS Code settings
|
||||
|
||||
**Skills mode (`--skills`):** Copilot also supports an alternative skills-based layout
|
||||
via `--integration-options="--skills"`. When enabled:
|
||||
|
||||
- Commands are scaffolded as `speckit-<name>/SKILL.md` under `.github/skills/`
|
||||
- No companion `.prompt.md` files are generated
|
||||
- No `.vscode/settings.json` merge
|
||||
- `post_process_skill_content()` injects a `mode: speckit.<stem>` frontmatter field
|
||||
- `build_command_invocation()` returns `/speckit-<stem>` instead of bare args
|
||||
|
||||
The two modes are mutually exclusive — a project uses one or the other:
|
||||
|
||||
```bash
|
||||
# Default skills mode: speckit-<name>/SKILL.md under .github/skills/
|
||||
# Default mode: .agent.md agents + .prompt.md companions + settings merge
|
||||
specify init my-project --integration copilot
|
||||
|
||||
# Commands mode: .agent.md agents + .prompt.md companions + settings merge
|
||||
specify init my-project --integration copilot --integration-options="--commands"
|
||||
# Skills mode: speckit-<name>/SKILL.md under .github/skills/
|
||||
specify init my-project --integration copilot --integration-options="--skills"
|
||||
```
|
||||
|
||||
### Forge Integration
|
||||
@@ -501,11 +509,6 @@ When an issue exists, include its number immediately after the prefix — this i
|
||||
|
||||
Disclosure is **continuous**, not a one-time event. A single AI-disclosure paragraph in the PR body does **not** cover the commits and replies you add during review rounds. Each of the following must independently attest to agent authorship.
|
||||
|
||||
### Opening pull requests
|
||||
|
||||
- Before opening a pull request, check whether the account that will file it already has three open pull requests in this repository.
|
||||
- If so, alert the user that additional submissions may receive lower review priority and ask for explicit permission to proceed. Do not assume consent.
|
||||
|
||||
### Commits
|
||||
|
||||
- **Every commit you author must carry an `Assisted-by:` trailer** identifying the agent and whether it acted autonomously or under direct human supervision, for example:
|
||||
|
||||
-152
@@ -2,158 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.16.4] - 2026-08-14
|
||||
|
||||
### Changed
|
||||
|
||||
- Add SpecAssay preset to community catalog (#4123)
|
||||
- Update Intake Authoring Governance preset to v0.3.1 (#4121)
|
||||
- Update Superspec extension to v1.0.2 (#4120)
|
||||
- fix(taskstoissues): widen task-ID regex to match IDs longer than 3 digits (#4101)
|
||||
- Add Architecture Governance extension to community catalog (#4122)
|
||||
- fix(workflows): validate non-string step types (#4111)
|
||||
- Harden community submission workflow output allowlists (#4103)
|
||||
- chore(deps): bump github/codeql-action (init + analyze) from 4.37.5 to 4.37.6 (#4114)
|
||||
- Add SpecAssay Check extension to community catalog (#4113)
|
||||
- fix(integrations): dispatch goose commands via `goose run` (#2416) (#3781)
|
||||
- fix(powershell): stop Out-Null swallowing the AVAILABLE_DOCS status lines (#3891)
|
||||
- fix: remove TOCTOU race in RunState.load (#3839)
|
||||
- fix: decode the zipped manifest as UTF-8 before parsing (#3958)
|
||||
- Update Agent Parity Governance preset to v0.4.2 (#4110)
|
||||
- fix: log progress tracker refresh errors instead of silently swallowing (#3975)
|
||||
- [extension] Add SpecJudge extension to community catalog (#4079)
|
||||
- fix(bundler): read the authoritative `default_integration` field, not only its legacy aliases (#3880)
|
||||
- fix(auth): treat exact host patterns literally (#4108)
|
||||
- feat: add Mistral Vibe integration with Claude parity (#4075)
|
||||
- [extension] Add spec-kit-atlas extension to community catalog (#4105)
|
||||
- chore: release 0.16.3, begin 0.16.4.dev0 development (#4107)
|
||||
|
||||
## [0.16.3] - 2026-08-13
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: narrow bare except Exception in VS Code settings merge (#3844)
|
||||
- feat(presets): list presets in resolution/precedence order (#4086) (#4104)
|
||||
- Fix: scaffold self-contained namespaced preset commands (#4076) (#4082)
|
||||
- Update Cross-Platform Governance preset to v0.2.2 (#4080)
|
||||
- fix(bundler): treat a blank active integration as indeterminate in FR-019 (#3886)
|
||||
- Integrate Junie with dot-to-hyphen behavior and command formatting (#4073)
|
||||
- Update A11Y Governance preset to v0.4.3 (#4074)
|
||||
- Fix Alquimia argument hints after folded descriptions (#4063)
|
||||
- fix: use bounded read for bundle download HTTP responses (#3764)
|
||||
- Update iSAQB Architecture Governance preset to v0.2.2 (#4056)
|
||||
- fix(claude): make argument-hint injection fold-aware for long descriptions (#4045)
|
||||
- Add SpecKit Grill Me extension to community catalog (#4052)
|
||||
- Update Architecture Governance preset to v0.5.2 (#4050)
|
||||
- Remove auto-assign from catalog submission workflow (#4054)
|
||||
- docs: clarify example spec guidance (#4048)
|
||||
- Clarify custom checklist ownership and lifecycle (#4028)
|
||||
- Update Archive Extension to v1.2.2 (#4053)
|
||||
- Update Security Governance preset to v0.6.2 (#4040)
|
||||
- docs: clarify maintainer applies submission label during triage (#4041)
|
||||
- chore: release 0.16.2, begin 0.16.3.dev0 development (#4038)
|
||||
|
||||
## [0.16.2] - 2026-08-10
|
||||
|
||||
### Changed
|
||||
|
||||
- Add Command Code integration to spec-kit (#4019)
|
||||
- fix(workflows): strip a resolved condition before the true/false check (#3883)
|
||||
- fix(workflows): guard a non-string overlay edit 'operation' (#3881)
|
||||
- fix: bound response read in integration catalog fetch (#3818)
|
||||
- Fix bug-test Python dependency provisioning (#4030)
|
||||
- fix(bundle): escape Rich markup in bundle CLI error and status output (#4023)
|
||||
- fix(presets): skip an unreadable restore source in `preset remove` (#4020)
|
||||
- Add Keel Discovery extension to community catalog (#4035)
|
||||
- fix: show error details in preset catalog config read failure (#3840)
|
||||
- Update Reconcile Extension to v1.1.0 (#4034)
|
||||
- Add Model Routing Governance preset to community catalog (#4033)
|
||||
- fix: use missing_ok=True in integration JSON removal (#3846)
|
||||
- fix: use missing_ok=True in extension cache clear (#3845)
|
||||
- fix(extensions): reject duplicate provides.templates/scripts names (#4016)
|
||||
- feat(presets): resolve constitution templates at command time (#3984)
|
||||
- [bug-fix] Fix preset-wrap-drops-argument-hint: inherit argument-hint from core template (#3996)
|
||||
- docs: document installing specify-cli from a custom package index (#4032)
|
||||
- feat(extensions): accept provides.templates and provides.scripts in manifest (#4012)
|
||||
- fix(presets): treat an unreadable core template as missing (#3961)
|
||||
- chore: release 0.16.1, begin 0.16.2.dev0 development (#4014)
|
||||
|
||||
## [0.16.1] - 2026-08-07
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(integrations): wrap a non-UTF-8 catalog response (#4011)
|
||||
- fix(events): skip an unreadable command template (#3956)
|
||||
- fix(bundle): wrap malformed YAML in a local .zip bundle manifest (#4013)
|
||||
- chore(deps): bump github/codeql-action/analyze from 4.37.3 to 4.37.5 (#4005)
|
||||
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#4006)
|
||||
- fix(agent-context): recurse for nested plans in Python mtime fallback (#3757)
|
||||
- fix: add utf-8 encoding to extension and preset registry file I/O (#3834)
|
||||
- fix(init): escape user-supplied values in `specify init` output (#3787)
|
||||
- fix: bound response read in integration catalog fetch (#3812)
|
||||
- fix: use missing_ok for temp file cleanup to avoid masking errors (#3803)
|
||||
- fix(workflows): handle an unreadable run state in `workflow status` (#3999)
|
||||
- feat(init): scaffold managed .specify/.gitignore (#4000)
|
||||
- fix(scripts): stop check-prerequisites text mode crashing on a legacy stdout code page (#3890)
|
||||
- fix(presets): return None for an unreadable layer in resolve_content (#3959)
|
||||
- fix(extensions): start fresh on a non-UTF-8 extension registry (#3998)
|
||||
- Fix init-force-preset-desync: reapply presets/extensions on init --here --force (#3995)
|
||||
- fix(skills): apply the line-anchored delimiter scan to hermes and kimi (#3739)
|
||||
- fix(archives): wrap the bare EOFError a truncated tar.gz raises (#3938)
|
||||
- test(integrations): guard multiline/control-char SKILL.md frontmatter escaping (#3392)
|
||||
- fix(scripts): stop setup-tasks text mode crashing on a legacy code page (#3892)
|
||||
- chore: release 0.16.0, begin 0.16.1.dev0 development (#3992)
|
||||
|
||||
## [0.16.0] - 2026-08-05
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: keep long frontmatter values on a single line (#3989)
|
||||
- fix: skip corrupted run state files in list_runs (#3817)
|
||||
- fix: skip corrupted run state files in list_runs (#3814)
|
||||
- Add July 2026 newsletter (#3987)
|
||||
- fix(presets): start fresh on a non-UTF-8 preset registry (#3955)
|
||||
- docs: clarify agent PR review prioritization (#3985)
|
||||
- fix(events): preserve a non-UTF-8 config.toml on hook install/teardown (#3963)
|
||||
- fix(extensions): treat an unreadable staged backup as a conflict (#3962)
|
||||
- fix(manifests): reject non-string requires.speckit_version (#3980)
|
||||
- fix(extensions): reject reinstall when a kept config cannot be read (#3960)
|
||||
- [extension] Update Charter extension to v0.5.1 (#3983)
|
||||
- fix(events): return None for an unparseable script command (#3957)
|
||||
- feat(events): context injection for opencode and JSON-envelope agent hooks (#3934)
|
||||
- Add TDD Extension to community catalog (#3982)
|
||||
- Update Archive Extension to v1.1.0 (#3981)
|
||||
- feat(copilot): default integration to skills (#3976)
|
||||
- fix(events): ignore non-UTF-8 event overrides (#3897)
|
||||
- fix: cap stdin read at 1 MiB to prevent DoS (#3857)
|
||||
- fix(workflows): reject mismatched run state IDs (#3899)
|
||||
- chore: release 0.15.2, begin 0.15.3.dev0 development (#3953)
|
||||
|
||||
## [0.15.2] - 2026-08-03
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(presets): restore core skills instead of deleting them on preset remove (#3929)
|
||||
- fix(manifests): reject non-string metadata instead of crashing on it (#3943)
|
||||
- fix: narrow bare except Exception in invoke separator resolution (#3856)
|
||||
- fix(workflows): keep the init step's documented ignore_agent_tools default on an explicit null (#3889)
|
||||
- fix(kimi): preserve non-UTF-8 user skills (#3895)
|
||||
- fix(presets): tolerate non-UTF-8 legacy commands (#3896)
|
||||
- feat: allow overriding default init integration via SPECKIT_INTEGRATION_DEFAULT (#3952)
|
||||
- Add adrkit extension to community catalog (#3947)
|
||||
- feat(extensions): scaffold config templates on extension add/enable (#2000)
|
||||
- fix(events): skip non-UTF-8 extension manifests (#3900)
|
||||
- fix(workflows): fail a gate whose on_reject is not abort/skip/retry (#3888)
|
||||
- fix(presets): validate required manifest mappings (#3898)
|
||||
- fix: eliminate TOCTOU race in zip packaging (#3855)
|
||||
- fix(workflows): fail a fan-in step whose output is not a mapping (#3887)
|
||||
- fix(workflows): refetch non-UTF-8 catalog caches (#3901)
|
||||
- fix(bundler): wrap local catalog decode failures (#3902)
|
||||
- Add `--extension` flag to `specify init` for opting into extensions at init time (#3914)
|
||||
- fix: bound response reads in extension catalog and download (#3775)
|
||||
- fix(workflows): reject a retry gate whose verdict enum forbids the reset value (#3912)
|
||||
- chore: release 0.15.1, begin 0.15.2.dev0 development (#3913)
|
||||
|
||||
## [0.15.1] - 2026-07-31
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -55,8 +55,6 @@ Here are a few things you can do that will increase the likelihood of your pull
|
||||
- Write a [good commit message](http://tbaggery.com/2008/04/19/a-note-about-git-commit-messages.html).
|
||||
- Test your changes with the Spec-Driven Development workflow to ensure compatibility.
|
||||
|
||||
Accounts with three open pull requests may continue submitting changes, but additional submissions may be placed behind contributions from other authors in the review queue. Coding agents should disclose this possibility and obtain the filer's confirmation before opening another pull request.
|
||||
|
||||
### Branch naming
|
||||
|
||||
We recommend naming branches as `<type>/<number>-<short-slug>`, where `<number>` is the issue or PR number (whichever comes first) and `<type>` is one of:
|
||||
|
||||
@@ -90,7 +90,7 @@ Bare `specify self upgrade` executes immediately, matching the no-prompt behavio
|
||||
|
||||
### 3. Establish project principles
|
||||
|
||||
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI and Command Code in skills mode use `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt.
|
||||
Launch your coding agent in the project directory. Most agents expose spec-kit as `/speckit.*` slash commands; Codex CLI in skills mode uses `$speckit-*` instead; GitHub Copilot CLI uses `/agents` to select the agent or address it directly in a prompt.
|
||||
|
||||
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
|
||||
|
||||
@@ -283,11 +283,9 @@ specify bundle validate --path ./my-bundle # structural + reference checks
|
||||
specify bundle build --path ./my-bundle # produce a versioned .zip artifact
|
||||
```
|
||||
|
||||
Four ready-to-read example bundle manifests live under
|
||||
Four ready-to-read example manifests live under
|
||||
[`examples/bundles/`](examples/bundles/) (product manager, business analyst,
|
||||
security researcher, developer). These are bundle packaging examples, not
|
||||
filled generated feature specs; for end-to-end community examples, see the
|
||||
[community walkthroughs](https://github.github.io/spec-kit/community/walkthroughs.html).
|
||||
security researcher, developer).
|
||||
|
||||
Key guarantees: `info` shows exactly what `install` adds (transparency);
|
||||
installs are idempotent and confined to the project root; `remove` never touches
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-08-14T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json",
|
||||
"bundles": {
|
||||
"sicario-spec": {
|
||||
@@ -30,28 +30,6 @@
|
||||
"threat-modeling"
|
||||
],
|
||||
"verified": false
|
||||
},
|
||||
"specassay": {
|
||||
"name": "SpecAssay",
|
||||
"id": "specassay",
|
||||
"version": "0.3.4",
|
||||
"role": "developer",
|
||||
"description": "Durable-ID promotion for stock Spec Kit: templates, Gate 2 refusal, and trace-manifest emission.",
|
||||
"author": "Rik Dryfoos",
|
||||
"license": "MIT",
|
||||
"download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.4/specassay-0.3.4.zip",
|
||||
"repository": "https://github.com/rdryfoos/specassay",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.14.0"
|
||||
},
|
||||
"provides": {
|
||||
"extensions": 1,
|
||||
"presets": 1,
|
||||
"steps": 0,
|
||||
"workflows": 0
|
||||
},
|
||||
"tags": ["traceability", "governance", "durable-ids", "gate", "sdd"],
|
||||
"verified": false
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,6 @@ Accepted community bundle entries are published in [`bundles/catalog.community.j
|
||||
| Bundle | Purpose | Role or team | Provides | Required catalogs | URL |
|
||||
|--------|---------|--------------|----------|-------------------|-----|
|
||||
| SicarioSpec Security & Governance Bundle | Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates. | `security-engineer` | 1 extension, 11 presets | Documented | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| SpecAssay | Durable-ID promotion for stock Spec Kit: templates, Gate 2 refusal, and trace-manifest emission. | `developer` | 1 extension, 1 preset | Documented | [specassay](https://github.com/rdryfoos/specassay) |
|
||||
|
||||
## What to Submit
|
||||
|
||||
|
||||
@@ -25,18 +25,15 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
|
||||
| Extension | Purpose | Category | Effect | URL |
|
||||
|-----------|---------|----------|--------|-----|
|
||||
| adrkit — decision memory for spec-driven development | Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact | `process` | Read+Write | [adrkit](https://github.com/mbeacom/adrkit) |
|
||||
| Agent Assign | Assign specialized Claude Code agents to spec-kit tasks for targeted execution | `process` | Read+Write | [spec-kit-agent-assign](https://github.com/xymelon/spec-kit-agent-assign) |
|
||||
| Agent Governance | Generate agent-platform repository governance files from Spec Kit metadata | `process` | Read+Write | [spec-kit-agent-governance](https://github.com/bigsmartben/spec-kit-agent-governance) |
|
||||
| AI-Driven Engineering (AIDE) | A structured 7-step workflow for building new projects from scratch with AI assistants — from vision through implementation | `process` | Read+Write | [aide](https://github.com/mnriem/spec-kit-extensions/tree/main/aide) |
|
||||
| Analytics | Measure what your AI builds, and how much time it saves you | `visibility` | Read+Write | [spec-kit-analytics](https://github.com/Fyloss/spec-kit-analytics) |
|
||||
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
|
||||
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
|
||||
| Architecture Governance | Keep specs, code & ADRs in sync: citation slots + a read-only, fail-closed validator | `docs` | Read+Write | [spec-kit-arch-governance](https://github.com/ashbrener/spec-kit-arch-governance) |
|
||||
| Architecture Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
|
||||
| Architecture Workflow | Generate or reverse project-level 4+1 architecture views with per-view and full-workflow commands | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
|
||||
| Archive Extension | Archive merged features into main project memory, resolving gaps and conflicts. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| spec-kit-atlas | Synthesize spec-kit specs into faithful, interactive architecture storybooks & doc portals. | `docs` | Read-only | [spec-kit-atlas](https://github.com/ashbrener/spec-kit-atlas) |
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
||||
| Blueprint Index — Living Architecture Map | A living architecture map for spec-driven projects, kept honest by a deterministic, low-friction, machine-first CI gate (JSON, self-healable) that blocks only when the map contradicts the specs or code. Brownfield or greenfield. | `process` | Read+Write | [spec-kit-blueprint](https://github.com/ogil109/spec-kit-blueprint) |
|
||||
@@ -74,7 +71,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
|
||||
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
|
||||
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
|
||||
| Keel Discovery | A Spec Kit extension that puts customer evidence upstream of /speckit.specify, and audits what you shipped against it afterwards | `process` | Read+Write | [spec-kit-keel](https://github.com/keeldiscovery/spec-kit-keel) |
|
||||
| Learning Extension | Generate educational guides from implementations and enhance clarifications with mentoring context | `docs` | Read+Write | [spec-kit-learn](https://github.com/imviancagrace/spec-kit-learn) |
|
||||
| Linear Integration | Mirror spec-kit feature directories into Linear (filesystem → Linear, reconcile-based, unidirectional). | `integration` | Read+Write | [spec-kit-linear-sync](https://github.com/ashbrener/spec-kit-linear-sync) |
|
||||
| Linear Weave | Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses | `integration` | Read+Write | [spec-kit-linear-weave](https://github.com/tonydwoodhouse/spec-kit-linear-weave) |
|
||||
@@ -147,10 +143,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
|
||||
| Spec-Kit BDD | ATDD/BDD extension: convert specs to Gherkin scenarios, scaffold step definitions, and verify acceptance test coverage | `process` | Read+Write | [spec-kit-bdd](https://github.com/RSginer/spec-kit-bdd) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| SpecAssay Check | Gate 2 refuses silent gaps and emits a trace-manifest (trace-manifest.json). | `visibility` | Read+Write | [specassay](https://github.com/rdryfoos/specassay) |
|
||||
| SpecJudge — right-size the model before you implement | Recommends the model that fits your tasks, citing the spec fragment behind every level. | `process` | Read-only | [SpecJudge](https://github.com/JoaquinRuiz/SpecJudge) |
|
||||
| SpecKit Companion | Live spec-driven progress — lifecycle capture, status, resume, and a turbo pipeline profile | `visibility` | Read+Write | [speckit-companion](https://github.com/alfredoperez/speckit-companion) |
|
||||
| SpecKit Grill Me | Exhaustively resolve specification ambiguities and decisions before planning | `process` | Read+Write | [speckit-grill-me](https://github.com/yoshi1220/speckit-grill-me) |
|
||||
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
|
||||
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks. | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
|
||||
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
|
||||
@@ -159,7 +152,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Superpowers Implementation Bridge | Thin orchestrator between Spec Kit (design) and Superpowers (implementation). Cross-agent. | `process` | Read+Write | [speckit-superpowers-bridge](https://github.com/lihan3238/speckit-superpowers-bridge) |
|
||||
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) |
|
||||
| TDD Extension | Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength. | `process` | Read+Write | [spec-kit-tdd](https://github.com/d0whc3r/spec-kit-tdd) |
|
||||
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
|
||||
| Test Coverage Drift Control | Generate incremental coverage drift reports and planned remediation tasks after implementation | `code` | Read+Write | [spec-kit-test-coverage-drift-control](https://github.com/benizzio/spec-kit-test-coverage-drift-control) |
|
||||
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
|
||||
|
||||
@@ -7,33 +7,31 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds WCAG 2.2 AA, accessible status output, bilingual CEFR-B2 delivery, inclusive-content and didactic-comment governance, and provider-neutral model routing. | 11 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance parity, fleet-completion evidence, secret-free runner metadata, audit-ready Spec Kit evidence, and agent-neutral model routing across declared AI-agent surfaces. | 7 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure architecture, STRIDE/CAPEC threat modeling, arc42/S-ADR guidance, Zero Trust, SAMM, BSI cloud assurance, audit evidence, and provider-neutral model routing. | 14 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Architecture Governance | Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell parity, read-only checks, path and native-override review, Unix man pages, bilingual PowerShell help, and provider-neutral model routing. | 9 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence. | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
||||
| Intake Authoring Governance | Governs traceable intake CRUD, language-aware requirements collections, bounded public HTTPS sources, and explicitly approved single or series authoring. | 13 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
|
||||
| Intake Authoring Governance | Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring. | 12 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
|
||||
| Intake Review Governance | Reviews single, series, campaign, and language-aware requirements collections before Spec Kit execution. | 8 templates, 3 commands, 4 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
|
||||
| Intake Sequencing Governance | Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection. | 11 templates, 6 commands, 8 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) |
|
||||
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance, architecture views, quality scenarios, ADRs, risks, technical-debt evidence, and provider-neutral model routing. | 14 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
|
||||
| Model Routing Governance | Maps provider-neutral Spec Kit roles to validated harness-local runner profiles without storing model availability, credentials, or machine-specific selections in Git. | 4 templates, 2 commands, 2 scripts | — | [spec-kit-preset-model-routing-governance](https://github.com/hindermath/spec-kit-preset-model-routing-governance) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Parallel Autonomous Run Governance | Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head security evidence, ASVS, supply-chain transparency, EU regulatory screening, and provider-neutral model routing. | 15 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| SpecAssay | Appends durable-ID, Carries, and SpecAssay vocabulary onto Spec Kit spec, tasks, and constitution templates. | 3 templates | — | [specassay](https://github.com/rdryfoos/specassay) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
| Test-First Governance | Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates. | 10 templates, 8 commands | — | [spec-kit-preset-test-first-governance](https://github.com/ka-zo/spec-kit-preset-test-first-governance) |
|
||||
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
> [!NOTE]
|
||||
> Community walkthroughs are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their content before following along and use at your own discretion.
|
||||
|
||||
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs. They are useful read-only examples of completed flows, but they are not official golden outputs for generated specs, plans, or tasks.
|
||||
See Spec-Driven Development in action across different scenarios with these community-contributed walkthroughs:
|
||||
|
||||
- **[Greenfield .NET CLI tool](https://github.com/mnriem/spec-kit-dotnet-cli-demo)** — Builds a Timezone Utility as a .NET single-binary CLI tool from a blank directory, covering the full spec-kit workflow: constitution, specify, plan, tasks, and multi-pass implement using GitHub Copilot agents.
|
||||
|
||||
|
||||
@@ -35,27 +35,6 @@ pipx install specify-cli==0.12.11
|
||||
pip install specify-cli==0.12.11
|
||||
```
|
||||
|
||||
## Install from a custom or private package index
|
||||
|
||||
Some environments (corporate networks, mirrors, proxies, or artifact feeds) require installing `specify-cli` from a package index other than the default public PyPI. Each Python tool exposes a way to point at a different index — configure it before running the install commands above. Substitute your own index URL for the placeholder shown here.
|
||||
|
||||
```bash
|
||||
# uv — via environment variable (applies to the whole command)
|
||||
UV_DEFAULT_INDEX=https://your-index.example.com/pypi/simple/ uv tool install specify-cli
|
||||
|
||||
# uv — via flag
|
||||
uv tool install --default-index https://your-index.example.com/pypi/simple/ specify-cli
|
||||
|
||||
# pipx — pass a pip argument through
|
||||
pipx install specify-cli --index-url https://your-index.example.com/pypi/simple/
|
||||
|
||||
# pip
|
||||
pip install specify-cli --index-url https://your-index.example.com/pypi/simple/
|
||||
```
|
||||
|
||||
> [!NOTE]
|
||||
> The same index configuration applies to pinned installs, upgrades (`--force`/`--upgrade`), and one-time usage — set the environment variable or flag on those commands too. If your index requires authentication, follow your tool's documentation and prefer credential environment variables, keyring, or netrc; do not embed secrets in command-line URLs because they can leak through shell history, process listings, or logs. Avoid committing secrets. For fully offline installs, see the [air-gapped installation guide](air-gapped.md).
|
||||
|
||||
## Verify
|
||||
|
||||
```bash
|
||||
|
||||
@@ -50,7 +50,7 @@ pipx install specify-cli
|
||||
pip install specify-cli
|
||||
```
|
||||
|
||||
To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade and how to [install from a custom or private package index](install/pypi.md#install-from-a-custom-or-private-package-index).
|
||||
To install a specific release, pin the version — for example `uv tool install specify-cli==0.12.11`. See the [PyPI installation guide](install/pypi.md) for details, including how to upgrade.
|
||||
|
||||
### One-time Usage
|
||||
|
||||
|
||||
+5
-5
@@ -5,7 +5,8 @@ This guide will help you get started with Spec-Driven Development using Spec Kit
|
||||
> [!NOTE]
|
||||
> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
|
||||
|
||||
Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical.
|
||||
> [!NOTE]
|
||||
> Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical.
|
||||
|
||||
## Recommended Process
|
||||
|
||||
@@ -82,7 +83,7 @@ Generates the design artifacts from the spec. This is where implementation detai
|
||||
|
||||
### Step 5: `/speckit.checklist` — validate the spec
|
||||
|
||||
Generates a custom quality checklist — "unit tests for your requirements" — to confirm the spec is complete, clear, and consistent before you break the work down. These custom checklists are reviewer-owned requirements-quality review artifacts: mark an item `[x]` only when the reviewer determines that requirement-quality criterion is satisfied. Checked custom items do not mean implementation work is complete.
|
||||
Generates a quality checklist — "unit tests for your requirements" — to confirm the spec is complete, clear, and consistent before you break the work down.
|
||||
|
||||
```text
|
||||
/speckit.checklist
|
||||
@@ -106,7 +107,7 @@ Reports conflicts, gaps, and ambiguities across `spec.md`, `plan.md`, and `tasks
|
||||
|
||||
### Step 8: `/speckit.implement` — build it
|
||||
|
||||
Executes the tasks in `tasks.md` in dependency order. Before implementation, it reads checklist checkbox state as a gate and asks before proceeding if any checklist items are unchecked; it does not change any checklist files or markers. The built-in `checklists/requirements.md` checklist is maintained by `/speckit.specify` and `/speckit.clarify`, while custom checklists remain reviewer-owned. Run it once to build everything, or scope it to one phase at a time for large features.
|
||||
Executes the tasks in `tasks.md` in dependency order. Run it once to build everything, or scope it to one phase at a time for large features.
|
||||
|
||||
```text
|
||||
/speckit.implement
|
||||
@@ -135,6 +136,5 @@ Checks the codebase against the spec, plan, and tasks. If it finds gaps, it appe
|
||||
|
||||
- See the [Agentic SDD](reference/agentic-sdd.md) reference for full detail on every command
|
||||
- Read the [complete methodology](https://github.com/github/spec-kit/blob/main/spec-driven.md) for in-depth guidance
|
||||
- Compare the [core templates](https://github.com/github/spec-kit/tree/main/templates) with
|
||||
[community walkthroughs](community/walkthroughs.md) to see how Spec-Driven Development is used in real projects
|
||||
- Check out [more examples](https://github.com/github/spec-kit/tree/main/templates) in the repository
|
||||
- Explore the [source code on GitHub](https://github.com/github/spec-kit)
|
||||
|
||||
@@ -23,8 +23,6 @@ Creates or updates the project **constitution** — the guiding principles that
|
||||
|
||||
Creates or updates the feature **specification** from a natural-language description. Focus on the **what** and **why** — the user-facing behavior and goals — not the tech stack, which belongs in `/speckit.plan`.
|
||||
|
||||
This workflow may also maintain `checklists/requirements.md`, the built-in spec-quality checklist that `/speckit.specify` creates and `/speckit.clarify` re-evaluates. That lifecycle is separate from custom checklists generated by `/speckit.checklist`.
|
||||
|
||||
```text
|
||||
/speckit.specify Build an application that helps me organize photos into albums grouped by date, re-orderable by drag-and-drop on the main page, with a tile preview inside each album.
|
||||
```
|
||||
@@ -39,8 +37,6 @@ Asks up to five targeted questions about underspecified areas of the current spe
|
||||
|
||||
Clarifying before planning keeps you from designing on top of ambiguity. If `/speckit.analyze` later surfaces requirement gaps, come back and run `/speckit.clarify` (or `/speckit.specify`) again.
|
||||
|
||||
When `checklists/requirements.md` exists, `/speckit.clarify` may update its evaluated state as part of tightening the spec. This exception applies only to the built-in requirements checklist, not to custom review checklists.
|
||||
|
||||
## `/speckit.plan`
|
||||
|
||||
Runs the planning process to generate design artifacts from the spec. This is where implementation detail belongs — provide your tech stack, architecture, and technical constraints as arguments.
|
||||
@@ -53,8 +49,6 @@ Runs the planning process to generate design artifacts from the spec. This is wh
|
||||
|
||||
Generates a quality checklist for the feature — think of it as **"unit tests for your requirements."** Rather than testing code, it checks whether the spec itself is complete, clear, unambiguous, and consistent (for example: "Are the drag-and-drop rules defined for every column?", "Is behavior specified for a deleted assigned user?").
|
||||
|
||||
Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts. An agent may help evaluate them when explicitly asked, but implementation must not silently self-approve them. In a custom checklist, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does not mean implementation work is complete.
|
||||
|
||||
Run it with no arguments for a broad pass, or pass a focus area to target one aspect:
|
||||
|
||||
```text
|
||||
@@ -65,7 +59,7 @@ Run it with no arguments for a broad pass, or pass a focus area to target one as
|
||||
/speckit.checklist Focus on the Kanban board interactions and comment permissions.
|
||||
```
|
||||
|
||||
Review the generated checklist. If it surfaces gaps, loop back to `/speckit.clarify` or `/speckit.specify` to tighten the spec before breaking the work down, then mark each custom checklist item `[x]` only after the requirements-quality criterion has been reviewed and satisfied.
|
||||
Review the generated checklist. If it surfaces gaps, loop back to `/speckit.clarify` or `/speckit.specify` to tighten the spec before breaking the work down.
|
||||
|
||||
## `/speckit.tasks`
|
||||
|
||||
@@ -89,8 +83,6 @@ Run it before implementing, while the artifacts can still be adjusted cheaply. I
|
||||
|
||||
Executes the tasks in `tasks.md`, running each phase in dependency order and respecting parallel markers.
|
||||
|
||||
Before executing tasks, it reads checklist checkbox state as a gate. Checklist markers are read-only for this command: `/speckit.implement` counts checked and unchecked items and asks before proceeding when any are unchecked, but it must not change checklist markers. For custom checklists, checked items mean reviewer approval of requirements quality, not completed implementation work.
|
||||
|
||||
For a small feature, run it once to build everything:
|
||||
|
||||
```text
|
||||
|
||||
@@ -25,7 +25,7 @@ Creates a new Spec Kit project with the necessary directory structure, templates
|
||||
|
||||
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
|
||||
|
||||
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly, or set `SPECKIT_INTEGRATION_DEFAULT` to change the fallback (see [Environment Variables](#environment-variables)).
|
||||
When `--integration` is omitted, interactive terminals prompt you to choose an integration. Non-interactive sessions, such as CI or piped runs, default to GitHub Copilot; pass `--integration <key>` to choose a different integration explicitly.
|
||||
|
||||
### Examples
|
||||
|
||||
@@ -50,15 +50,12 @@ specify init my-project --integration copilot --preset compliance
|
||||
|
||||
| Variable | Description |
|
||||
| ----------------- | ------------------------------------------------------------------------ |
|
||||
| `SPECKIT_INTEGRATION_DEFAULT` | Override the fallback integration used by `specify init` when `--integration` is omitted (interactive prompt default and non-interactive fallback). Set it to any registered integration key (e.g. `gemini`, `claude`). An unrecognized value is ignored with a warning and the built-in default (`copilot`) is used. An explicit `--integration <key>` always takes precedence. |
|
||||
| `SPECIFY_INIT_DIR` | Target a member project from outside its directory (e.g. a monorepo root) without `cd`, for non-interactive / CI use. Set it to the **project root** — the directory *containing* `.specify/` (relative paths resolve against the current directory). The path must exist and contain `.specify/`, otherwise the command errors and does **not** fall back to the current directory. Resolved once in the core root helper (`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell), so it is honored by the core feature scripts (`/speckit.plan`, `/speckit.tasks`, …) and the Git extension's feature-branch creation, which inherit it. The `specify` CLI applies the **same** validation rules to every project-scoped subcommand (`specify integration …`, `specify extension …`, `specify workflow …`, `specify preset …`, and the rest that operate on a `.specify/` project), so those can target a member project too. When unset, Bash/PowerShell helpers keep their existing upward search; the `specify` CLI keeps its project-scoped resolver cwd-only unless a command explicitly defines broader detection (for example, bundle commands). |
|
||||
| `SPECIFY_FEATURE_DIRECTORY` | Override the active feature directory *within* the resolved project (takes precedence over `.specify/feature.json`). Relative paths resolve under the project root. Combine with `SPECIFY_INIT_DIR` to pick both the project and the feature non-interactively. |
|
||||
| `SPECIFY_FEATURE` | Override feature detection for non-Git repositories. Set to the feature directory name (e.g., `001-photo-albums`) to work on a specific feature when not using Git branches. Must be set in the context of the agent prior to using `/speckit.plan` or follow-up commands. |
|
||||
|
||||
> **Two resolution axes.** `SPECIFY_INIT_DIR` selects the **project** (which directory contains `.specify/`); `SPECIFY_FEATURE_DIRECTORY` / `.specify/feature.json` select the **feature** within that project. They are independent — project first, then feature.
|
||||
|
||||
> **Version control.** `specify init` scaffolds a managed `.specify/.gitignore` that excludes machine-local state — `feature.json` (the current-feature pointer, rewritten on every feature switch) and per-machine extension `extensions/*/local-config.yml` overrides — while leaving everything else under `.specify/` (constitution, templates, scripts, extension config) shareable so teams stay aligned. Like the rest of `.specify/`'s shared scripts and templates, the file is tracked in the shared-infrastructure manifest: your edits are preserved on re-init and `specify init --here --force` restores the managed content. It is intentionally left in place by `specify integration uninstall`, which only removes the uninstalled agent's own files.
|
||||
|
||||
> **Symlinked project roots.** `SPECIFY_INIT_DIR` relocates *where* the project is, not *how* a command treats symlinks: each command keeps its existing cwd-path stance. Commands that traverse and write project files through broad input paths (`bundle`, `workflow run <file>`) refuse a symlinked `.specify/` to preserve write confinement. Other project-scoped commands keep their existing behavior when `SPECIFY_INIT_DIR` points at a project root, which may include following a symlinked `.specify/`.
|
||||
|
||||
## Check Installed Tools
|
||||
|
||||
@@ -14,14 +14,13 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Cline](https://github.com/cline/cline) | `cline` | IDE-based agent |
|
||||
| [CodeBuddy CLI](https://www.codebuddy.cn/docs/cli/installation) | `codebuddy` | |
|
||||
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
|
||||
| [Command Code](https://commandcode.ai/docs) | `command-code` | Skills-based integration; installs skills into `.commandcode/skills/` and invokes them as `$speckit-<command>` |
|
||||
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
|
||||
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-<command>` |
|
||||
| [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-<command>` |
|
||||
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
|
||||
| [Forge](https://forgecode.dev/) | `forge` | |
|
||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Skills-based by default; installs `speckit-<command>/SKILL.md` under `.github/skills/`. Pass `--integration-options="--commands"` to use the supported commands layout: `.agent.md` files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. |
|
||||
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | Defaults to legacy markdown mode: `.agent.md` command files under `.github/agents/`, companion `.prompt.md` files under `.github/prompts/`, and a `.vscode/settings.json` merge. Pass `--integration-options="--skills"` to scaffold skills as `speckit-<command>/SKILL.md` under `.github/skills/` instead. Legacy markdown mode is deprecated and will stop being the default in a future release. |
|
||||
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
|
||||
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
|
||||
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
|
||||
@@ -235,8 +234,7 @@ Some integrations accept additional options via `--integration-options`:
|
||||
| ----------- | ------------------- | -------------------------------------------------------------- |
|
||||
| `generic` | `--commands-dir` | Required. Directory for command files |
|
||||
| `kimi` | `--migrate-legacy` | Migrate legacy `.kimi/skills/` installs to `.kimi-code/skills/` (including dotted→hyphenated skill naming, e.g. `speckit.xxx` → `speckit-xxx`) |
|
||||
| `copilot` | `--commands` | Scaffold `.github/agents/*.agent.md` commands with `.github/prompts/*.prompt.md` companions and merge `.vscode/settings.json` instead of using the default skills layout. |
|
||||
| `copilot` | `--skills` | Force the default skills layout, overriding an existing commands layout during an explicit migration. |
|
||||
| `copilot` | `--skills` | Scaffold commands as agent skills (`speckit-<command>/SKILL.md` under `.github/skills/`, invoked as `/speckit-<command>`) instead of the default legacy markdown mode (`.github/agents/*.agent.md` plus `.github/prompts/*.prompt.md` and a `.vscode/settings.json` merge). Without this flag, install warns that legacy markdown mode is deprecated. |
|
||||
|
||||
Example:
|
||||
|
||||
@@ -280,7 +278,6 @@ The currently declared multi-install safe integrations are:
|
||||
| `cline` | `.clinerules/workflows` |
|
||||
| `codebuddy` | `.codebuddy/commands` |
|
||||
| `codex` | `.agents/skills` |
|
||||
| `command-code` | `.commandcode/skills` |
|
||||
| `cursor-agent` | `.cursor/skills` |
|
||||
| `droid` | `.factory/skills` |
|
||||
| `firebender` | `.firebender/commands` |
|
||||
|
||||
@@ -47,8 +47,6 @@ specify preset list
|
||||
|
||||
Lists installed presets with their versions, descriptions, template counts, and current status.
|
||||
|
||||
Presets are printed in **resolution/precedence order**: the highest-precedence preset (lowest priority number) is listed first, and ties on priority are broken alphabetically by preset id. This matches the order used when composing commands and resolving templates, so the top entry is the one that wins for overlapping files.
|
||||
|
||||
## Preset Info
|
||||
|
||||
```bash
|
||||
|
||||
@@ -623,19 +623,6 @@ pauses: the named stored input is reset to `""`. A later resume therefore
|
||||
prompts or pauses again until another verdict is supplied. Approve, abort, and
|
||||
skip outcomes leave the input unchanged.
|
||||
|
||||
Because of that reset, a verdict input used with `on_reject: retry` must accept
|
||||
`""`. If it declares an `enum`, include the empty string — otherwise the reset
|
||||
value violates the input's own `enum` and the run can no longer be resumed with
|
||||
any input. `specify workflow add` reports this as a validation error.
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
spec_verdict:
|
||||
type: string
|
||||
enum: ["", approve, reject]
|
||||
default: ""
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### What happens when a workflow hits a gate step?
|
||||
|
||||
@@ -40,25 +40,12 @@ requires:
|
||||
required: boolean # Optional, default: false
|
||||
|
||||
provides:
|
||||
commands: # At least one of commands/templates/scripts/hooks/events required
|
||||
commands: # Required, at least one command
|
||||
- name: string # Required, pattern: ^speckit\.[a-z0-9-]+\.[a-z0-9-]+$
|
||||
file: string # Required, relative path to command file
|
||||
description: string # Required
|
||||
aliases: [string] # Optional, same pattern as name; namespace must match extension.id and must not shadow core or installed extension commands
|
||||
|
||||
templates: # Optional, array of declared templates. Always resolve
|
||||
# as "replace" -- 'strategy' is not an authorable field here.
|
||||
- name: string # Required, pattern: ^[a-z0-9-]+$
|
||||
file: string # Required, relative path to template file
|
||||
description: string # Optional
|
||||
|
||||
scripts: # Optional, array of declared scripts. Always resolve
|
||||
# as "replace" -- 'strategy' is not an authorable field here.
|
||||
- name: string # Required, pattern: ^[a-z0-9-]+$
|
||||
file: string # Required, relative path to script file
|
||||
description: string # Optional
|
||||
runtimes: [string] # Optional, subset of: bash, powershell, python
|
||||
|
||||
config: # Optional, array of config files
|
||||
- name: string # Config file name
|
||||
template: string # Template file path
|
||||
@@ -124,29 +111,6 @@ defaults: # Optional, default configuration values
|
||||
- **Examples**: `speckit.jira.specstoissues`, `speckit.linear.sync`
|
||||
- **Invalid**: `jira.specstoissues`, `speckit.command`, `speckit.jira.CreateIssues`
|
||||
|
||||
#### `provides.templates[].name` / `provides.scripts[].name`
|
||||
|
||||
- **Type**: string
|
||||
- **Pattern**: `^[a-z0-9-]+$`
|
||||
- **Description**: Unlike commands, templates and scripts are not invoked by
|
||||
name, so they use the same plain slug pattern as `extension.id` rather than
|
||||
the namespaced command pattern.
|
||||
- **Examples**: `myext-template`, `myext-collect`
|
||||
|
||||
#### `provides.templates[].strategy` / `provides.scripts[].strategy`
|
||||
|
||||
- Not an authorable field. Extension-contributed templates and scripts are
|
||||
always resolved as `replace`; a manifest that includes a `strategy` key on
|
||||
one of these entries is rejected with a `ValidationError`. Composable
|
||||
strategies (`wrap`/`prepend`/`append`) are preset-only.
|
||||
|
||||
#### `provides.scripts[].runtimes`
|
||||
|
||||
- **Type**: array of strings
|
||||
- **Values**: `bash`, `powershell`, `python`
|
||||
- **Description**: Declares which runtimes the script supports. Purely
|
||||
informational metadata — it is not used to select or invoke the script.
|
||||
|
||||
#### `hooks`
|
||||
|
||||
- **Type**: object
|
||||
@@ -179,8 +143,6 @@ manifest.version # str: Version
|
||||
manifest.description # str: Description
|
||||
manifest.requires_speckit_version # str: Required spec-kit version
|
||||
manifest.commands # List[Dict]: Command definitions
|
||||
manifest.templates # List[Dict]: Declared template definitions
|
||||
manifest.scripts # List[Dict]: Declared script definitions
|
||||
manifest.hooks # Dict: Hook definitions
|
||||
```
|
||||
|
||||
|
||||
@@ -177,16 +177,9 @@ Compatibility requirements.
|
||||
|
||||
What the extension provides.
|
||||
|
||||
**Optional sub-fields:**
|
||||
**Optional sub-fields**:
|
||||
|
||||
- `commands`: Array of command objects
|
||||
- `templates`: Array of template objects
|
||||
- `scripts`: Array of script objects
|
||||
|
||||
`hooks` and `events` are separate top-level manifest fields (siblings of
|
||||
`provides`, not nested under it — see [`hooks`](#hooks) below). At least one
|
||||
of `provides.commands`, `provides.templates`, `provides.scripts`, `hooks`, or
|
||||
`events` is required.
|
||||
- `commands`: Array of command objects (at least one command or hook is required)
|
||||
|
||||
**Command object**:
|
||||
|
||||
@@ -195,21 +188,6 @@ of `provides.commands`, `provides.templates`, `provides.scripts`, `hooks`, or
|
||||
- `description`: Command description (optional)
|
||||
- `aliases`: Alternative command names (optional, array; each must match `speckit.{ext-id}.{command}`)
|
||||
|
||||
**Template object**:
|
||||
|
||||
- `name`: Template name (lowercase, alphanumeric, hyphens — e.g. `myext-template`)
|
||||
- `file`: Path to template file (relative to extension root)
|
||||
- `description`: Template description (optional)
|
||||
|
||||
**Script object**:
|
||||
|
||||
- `name`: Script name (lowercase, alphanumeric, hyphens — e.g. `myext-collect`)
|
||||
- `file`: Path to script file (relative to extension root)
|
||||
- `description`: Script description (optional)
|
||||
- `runtimes`: Runtimes the script supports (optional, array; subset of `bash`, `powershell`, `python` — informational only, not used to select or invoke the script)
|
||||
|
||||
Extension-provided templates and scripts always resolve as `replace`; a manifest that includes a `strategy` key on one of these entries is rejected with a `ValidationError`. Composable strategies (`wrap`/`prepend`/`append`) are preset-only.
|
||||
|
||||
### Optional Fields
|
||||
|
||||
#### `hooks`
|
||||
|
||||
@@ -151,7 +151,7 @@ To submit your extension to the community catalog, file a new issue using the **
|
||||
|
||||
### What Happens After You Submit
|
||||
|
||||
1. A maintainer reviews the issue during issue triage and applies the `extension-submission` label, which starts the automated catalog validation. On this public repository, contributors cannot apply that label themselves, so there is nothing to label or re-request — the issue simply waits in triage.
|
||||
1. Your issue is automatically labeled and assigned to a maintainer for review
|
||||
2. A maintainer verifies that the catalog entry is complete and correctly formatted
|
||||
3. Once approved, the maintainer adds your extension to `extensions/catalog.community.json` and the Community Extensions table in the README
|
||||
4. Your extension becomes discoverable via `specify extension search`
|
||||
|
||||
@@ -11,8 +11,8 @@ Usage: update_agent_context.py [plan_path]
|
||||
|
||||
When ``plan_path`` is omitted, the script derives it from
|
||||
``.specify/feature.json`` (written by /speckit-specify). Falls back to the most
|
||||
recently modified ``plan.md`` found anywhere under ``specs/`` — scoped layouts
|
||||
nest it as ``specs/<scope>/<feature>/plan.md`` — only when feature.json is
|
||||
recently modified ``plan.md`` anywhere under ``specs/`` (including nested scoped
|
||||
layouts such as ``specs/<scope>/<feature>/plan.md``) only when feature.json is
|
||||
absent or its plan does not exist yet.
|
||||
"""
|
||||
|
||||
@@ -173,31 +173,16 @@ def _resolve_plan_path(project_root: str) -> str:
|
||||
|
||||
if not plan_path:
|
||||
root = Path(project_root).resolve()
|
||||
specs = root / "specs"
|
||||
|
||||
def _resolved_rel(p: Path) -> Path | None:
|
||||
# Resolve symlinks before checking containment: relative_to() is
|
||||
# lexical and would otherwise accept a plan reached through a specs/
|
||||
# symlink that points outside the project, emitting an
|
||||
# in-project-looking path for an out-of-project file (or picking it
|
||||
# as "most recent").
|
||||
plans = sorted(
|
||||
(root / "specs").rglob("plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if plans:
|
||||
try:
|
||||
return p.resolve().relative_to(root)
|
||||
except (OSError, ValueError):
|
||||
return None
|
||||
|
||||
# Recurse (rather than the old one-level specs/*/plan.md glob) so scoped
|
||||
# layouts created via SPECIFY_FEATURE_DIRECTORY, e.g.
|
||||
# specs/<scope>/<feature>/plan.md, are still discovered when
|
||||
# feature.json is absent (#3024). Mirrors the bash and PowerShell twins.
|
||||
candidates = []
|
||||
for p in specs.rglob("plan.md"):
|
||||
rel = _resolved_rel(p)
|
||||
if rel is not None:
|
||||
candidates.append((p, rel))
|
||||
candidates.sort(key=lambda pr: pr[0].stat().st_mtime, reverse=True)
|
||||
if candidates:
|
||||
plan_path = candidates[0][1].as_posix()
|
||||
plan_path = plans[0].relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
plan_path = ""
|
||||
return plan_path
|
||||
|
||||
|
||||
|
||||
@@ -1,43 +1,8 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-08-14T00:00:00Z",
|
||||
"updated_at": "2026-07-29T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"adrkit": {
|
||||
"name": "adrkit — decision memory for spec-driven development",
|
||||
"id": "adrkit",
|
||||
"description": "Pulls the decisions governing this work into agent context, checks produced plans against them, and drafts an ADR from a plan artifact.",
|
||||
"author": "Mark Beacom (@mbeacom)",
|
||||
"version": "0.1.2",
|
||||
"download_url": "https://github.com/mbeacom/adrkit/releases/download/spec-kit-v0.1.2/adrkit.zip",
|
||||
"repository": "https://github.com/mbeacom/adrkit",
|
||||
"homepage": "https://adrkit.dev",
|
||||
"documentation": "https://github.com/mbeacom/adrkit/blob/main/packages/adapters/spec-kit/README.md",
|
||||
"changelog": "https://github.com/mbeacom/adrkit/blob/main/CHANGELOG.md",
|
||||
"license": "Apache-2.0",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.13.0,<0.16.0",
|
||||
"tools": [{ "name": "adr", "version": ">=0.3.0", "required": true }]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 3,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"adr",
|
||||
"governance",
|
||||
"decision-records",
|
||||
"architecture",
|
||||
"compliance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-03T00:00:00Z",
|
||||
"updated_at": "2026-08-03T00:00:00Z"
|
||||
},
|
||||
"aide": {
|
||||
"name": "AI-Driven Engineering (AIDE)",
|
||||
"id": "aide",
|
||||
@@ -286,44 +251,6 @@
|
||||
"created_at": "2026-05-14T00:00:00Z",
|
||||
"updated_at": "2026-06-30T00:00:00Z"
|
||||
},
|
||||
"arch-governance": {
|
||||
"name": "Architecture Governance",
|
||||
"id": "arch-governance",
|
||||
"description": "Keep specs, code & ADRs in sync: citation slots + a read-only, fail-closed validator.",
|
||||
"author": "Ash Brener",
|
||||
"version": "1.2.2",
|
||||
"download_url": "https://github.com/ashbrener/spec-kit-arch-governance/archive/refs/tags/v1.2.2.zip",
|
||||
"repository": "https://github.com/ashbrener/spec-kit-arch-governance",
|
||||
"homepage": "https://github.com/ashbrener/spec-kit-arch-governance",
|
||||
"documentation": "https://github.com/ashbrener/spec-kit-arch-governance/blob/main/README.md",
|
||||
"changelog": "https://github.com/ashbrener/spec-kit-arch-governance/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{ "name": "python", "version": ">=3.11", "required": true },
|
||||
{ "name": "uv", "required": true }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 6,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"adr",
|
||||
"citations",
|
||||
"spec-sync"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-14T00:00:00Z",
|
||||
"updated_at": "2026-08-14T00:00:00Z"
|
||||
},
|
||||
"architect-preview": {
|
||||
"name": "Architect Impact Previewer",
|
||||
"id": "architect-preview",
|
||||
@@ -400,8 +327,8 @@
|
||||
"id": "archive",
|
||||
"description": "Archive merged features into main project memory, resolving gaps and conflicts.",
|
||||
"author": "Stanislav Deviatov",
|
||||
"version": "1.2.2",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.2.2.zip",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/stn1slv/spec-kit-archive",
|
||||
"homepage": "https://github.com/stn1slv/spec-kit-archive",
|
||||
"documentation": "https://github.com/stn1slv/spec-kit-archive/blob/main/README.md",
|
||||
@@ -426,45 +353,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-08-11T00:00:00Z"
|
||||
},
|
||||
"atlas": {
|
||||
"name": "spec-kit-atlas",
|
||||
"id": "atlas",
|
||||
"description": "Synthesize spec-kit specs into faithful, interactive architecture storybooks & doc portals.",
|
||||
"author": "Ash Brener",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/ashbrener/spec-kit-atlas/archive/refs/tags/v0.1.0.zip",
|
||||
"repository": "https://github.com/ashbrener/spec-kit-atlas",
|
||||
"homepage": "https://github.com/ashbrener/spec-kit-atlas",
|
||||
"documentation": "https://github.com/ashbrener/spec-kit-atlas/blob/main/README.md",
|
||||
"changelog": "https://github.com/ashbrener/spec-kit-atlas/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "docs",
|
||||
"effect": "read-only",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0",
|
||||
"tools": [
|
||||
{ "name": "python", "version": ">=3.11", "required": true },
|
||||
{ "name": "uv", "required": true }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"documentation",
|
||||
"architecture",
|
||||
"storybook",
|
||||
"traceability",
|
||||
"atlas"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-13T00:00:00Z",
|
||||
"updated_at": "2026-08-13T00:00:00Z"
|
||||
"updated_at": "2026-03-14T00:00:00Z"
|
||||
},
|
||||
"azure-devops": {
|
||||
"name": "Azure DevOps Integration",
|
||||
@@ -887,8 +776,8 @@
|
||||
"id": "charter",
|
||||
"description": "Compose modular project constitutions from shared fragment registries. Centralize governance rules, select per-project fragments, track upstream changes, and keep multi-project setups consistent.",
|
||||
"author": "Fyloss",
|
||||
"version": "0.5.1",
|
||||
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.5.1.zip",
|
||||
"version": "0.3.1",
|
||||
"download_url": "https://github.com/Fyloss/spec-kit-charter/archive/refs/tags/v0.3.1.zip",
|
||||
"repository": "https://github.com/Fyloss/spec-kit-charter",
|
||||
"homepage": "https://github.com/Fyloss/spec-kit-charter",
|
||||
"documentation": "https://github.com/Fyloss/spec-kit-charter/tree/master/docs",
|
||||
@@ -897,8 +786,7 @@
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.11.9",
|
||||
"tools": [{ "name": "git", "required": false }]
|
||||
"speckit_version": ">=0.11.9"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
@@ -915,7 +803,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-06T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00:00:00Z"
|
||||
"updated_at": "2026-07-06T00:00:00Z"
|
||||
},
|
||||
"ci-guard": {
|
||||
"name": "CI Guard",
|
||||
@@ -1891,41 +1779,6 @@
|
||||
"created_at": "2026-06-24T00:00:00Z",
|
||||
"updated_at": "2026-07-07T00:00:00Z"
|
||||
},
|
||||
"grill": {
|
||||
"name": "SpecKit Grill Me",
|
||||
"id": "grill",
|
||||
"description": "Exhaustively resolve specification ambiguities and decisions before planning.",
|
||||
"author": "yoshi1220",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/yoshi1220/speckit-grill-me/releases/download/v1.0.0/speckit-grill-me-extension-v1.0.0.zip",
|
||||
"repository": "https://github.com/yoshi1220/speckit-grill-me",
|
||||
"homepage": "https://github.com/yoshi1220/speckit-grill-me/tree/main/spec-kit-extension",
|
||||
"documentation": "https://github.com/yoshi1220/speckit-grill-me/blob/main/spec-kit-extension/README.md",
|
||||
"changelog": "https://github.com/yoshi1220/speckit-grill-me/blob/main/spec-kit-extension/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.16.2",
|
||||
"tools": [{ "name": "bash", "required": true }]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"clarification",
|
||||
"requirements",
|
||||
"specification",
|
||||
"elicitation",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-11T00:00:00Z",
|
||||
"updated_at": "2026-08-11T00:00:00Z"
|
||||
},
|
||||
"harness": {
|
||||
"name": "Research Harness",
|
||||
"id": "harness",
|
||||
@@ -2218,40 +2071,6 @@
|
||||
"created_at": "2026-06-08T00:00:00Z",
|
||||
"updated_at": "2026-06-24T00:00:00Z"
|
||||
},
|
||||
"keel": {
|
||||
"name": "Keel Discovery",
|
||||
"id": "keel",
|
||||
"description": "A Spec Kit extension that puts customer evidence upstream of /speckit.specify, and audits what you shipped against it afterwards.",
|
||||
"author": "Keel Discovery",
|
||||
"version": "0.1.1",
|
||||
"download_url": "https://github.com/keeldiscovery/spec-kit-keel/archive/refs/tags/v0.1.1.zip",
|
||||
"repository": "https://github.com/keeldiscovery/spec-kit-keel",
|
||||
"homepage": "https://keeldiscovery.com",
|
||||
"documentation": "https://github.com/keeldiscovery/spec-kit-keel/blob/main/README.md",
|
||||
"changelog": "https://github.com/keeldiscovery/spec-kit-keel/blob/main/CHANGELOG.md",
|
||||
"license": "Apache-2.0",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.15.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 2
|
||||
},
|
||||
"tags": [
|
||||
"discovery",
|
||||
"evidence",
|
||||
"customer-research",
|
||||
"validation",
|
||||
"traceability"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-10T00:00:00Z",
|
||||
"updated_at": "2026-08-10T00:00:00Z"
|
||||
},
|
||||
"learn": {
|
||||
"name": "Learning Extension",
|
||||
"id": "learn",
|
||||
@@ -3551,8 +3370,8 @@
|
||||
"id": "reconcile",
|
||||
"description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.",
|
||||
"author": "Stanislav Deviatov",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.1.0.zip",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/stn1slv/spec-kit-reconcile",
|
||||
"homepage": "https://github.com/stn1slv/spec-kit-reconcile",
|
||||
"documentation": "https://github.com/stn1slv/spec-kit-reconcile/blob/main/README.md",
|
||||
@@ -3577,7 +3396,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-08-10T00:00:00Z"
|
||||
"updated_at": "2026-03-14T00:00:00Z"
|
||||
},
|
||||
"red-team": {
|
||||
"name": "Red Team",
|
||||
@@ -4224,78 +4043,6 @@
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"specassay-check": {
|
||||
"name": "SpecAssay Check",
|
||||
"id": "specassay-check",
|
||||
"description": "Gate 2 refuses silent gaps and emits a trace-manifest (trace-manifest.json).",
|
||||
"author": "Rik Dryfoos",
|
||||
"version": "0.3.3",
|
||||
"download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.3/specassay-check-0.3.3.zip",
|
||||
"repository": "https://github.com/rdryfoos/specassay",
|
||||
"homepage": "https://www.specassay.com",
|
||||
"documentation": "https://github.com/rdryfoos/specassay/blob/main/extensions/specassay-check/README.md",
|
||||
"changelog": "https://github.com/rdryfoos/specassay/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "visibility",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.14.0",
|
||||
"tools": [
|
||||
{ "name": "bash", "required": true },
|
||||
{ "name": "python3", "version": ">=3.8", "required": true }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"traceability",
|
||||
"gate",
|
||||
"ci",
|
||||
"governance",
|
||||
"sdd"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-13T00:00:00Z",
|
||||
"updated_at": "2026-08-13T00:00:00Z"
|
||||
},
|
||||
"specjudge": {
|
||||
"name": "SpecJudge — right-size the model before you implement",
|
||||
"id": "specjudge",
|
||||
"description": "Recommends the model that fits your tasks, citing the spec fragment behind every level.",
|
||||
"author": "Joaquín Ruiz",
|
||||
"version": "0.5.4",
|
||||
"download_url": "https://github.com/JoaquinRuiz/SpecJudge/releases/download/v0.5.4/spec-kit-specjudge.zip",
|
||||
"repository": "https://github.com/JoaquinRuiz/SpecJudge",
|
||||
"homepage": "https://github.com/JoaquinRuiz/SpecJudge",
|
||||
"documentation": "https://github.com/JoaquinRuiz/SpecJudge/blob/main/extensions/spec-kit/README.md",
|
||||
"changelog": "https://github.com/JoaquinRuiz/SpecJudge/blob/main/extensions/spec-kit/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-only",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.13.0",
|
||||
"tools": [{ "name": "specjudge", "version": ">=0.5.0", "required": true }]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"cost",
|
||||
"model-selection",
|
||||
"local-first",
|
||||
"ollama"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-12T00:00:00Z",
|
||||
"updated_at": "2026-08-12T00:00:00Z"
|
||||
},
|
||||
"speckit-superpowers-bridge": {
|
||||
"name": "Superpowers Implementation Bridge",
|
||||
"id": "speckit-superpowers-bridge",
|
||||
@@ -4604,8 +4351,8 @@
|
||||
"id": "superspec",
|
||||
"description": "Bridges spec-kit workflows with obra/superpowers capabilities for brainstorming, TDD, code review, and resumable execution.",
|
||||
"author": "WangX0111",
|
||||
"version": "1.0.2",
|
||||
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.2.zip",
|
||||
"version": "1.0.1",
|
||||
"download_url": "https://github.com/WangX0111/superspec/archive/refs/tags/v1.0.1.zip",
|
||||
"repository": "https://github.com/WangX0111/superspec",
|
||||
"homepage": "https://github.com/WangX0111/superspec",
|
||||
"documentation": "https://github.com/WangX0111/superspec/blob/main/README.md",
|
||||
@@ -4632,7 +4379,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-04-22T00:00:00Z",
|
||||
"updated_at": "2026-08-14T00:00:00Z"
|
||||
"updated_at": "2026-05-30T00:00:00Z"
|
||||
},
|
||||
"sync": {
|
||||
"name": "Spec Sync",
|
||||
@@ -4706,47 +4453,6 @@
|
||||
"created_at": "2026-06-22T00:00:00Z",
|
||||
"updated_at": "2026-06-22T00:00:00Z"
|
||||
},
|
||||
"tdd": {
|
||||
"name": "TDD Extension",
|
||||
"id": "tdd",
|
||||
"description": "Drives spec-kit implementation with tests: a language-agnostic red-green-refactor loop with a per-feature test list, recorded red and green evidence, and mutation-checked test strength.",
|
||||
"author": "d0whc3r",
|
||||
"version": "1.1.2",
|
||||
"download_url": "https://github.com/d0whc3r/spec-kit-tdd/releases/download/v1.1.2/tdd-1.1.2.zip",
|
||||
"repository": "https://github.com/d0whc3r/spec-kit-tdd",
|
||||
"homepage": "https://d0whc3r.github.io/spec-kit-tdd/",
|
||||
"documentation": "https://github.com/d0whc3r/spec-kit-tdd/wiki",
|
||||
"changelog": "https://github.com/d0whc3r/spec-kit-tdd/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.11.9"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
"acceptance-tests",
|
||||
"mutation-testing",
|
||||
"property-based-testing",
|
||||
"quality",
|
||||
"red-green-refactor",
|
||||
"spec-kit",
|
||||
"spec-kit-extension",
|
||||
"tdd",
|
||||
"test-driven-development",
|
||||
"test-first",
|
||||
"testing",
|
||||
"unit-tests"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-08-04T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00:00:00Z"
|
||||
},
|
||||
"team-assign": {
|
||||
"name": "Team Assign",
|
||||
"id": "team-assign",
|
||||
|
||||
@@ -84,15 +84,6 @@
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli", "skills"]
|
||||
},
|
||||
"command-code": {
|
||||
"id": "command-code",
|
||||
"name": "Command Code",
|
||||
"version": "1.0.0",
|
||||
"description": "Command Code CLI skills-based integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli", "skills"]
|
||||
},
|
||||
"devin": {
|
||||
"id": "devin",
|
||||
"name": "Devin for Terminal",
|
||||
|
||||
@@ -4,7 +4,7 @@ This edition covers Spec Kit activity in April 2026. Seventeen releases shipped
|
||||
|
||||
| **Spec Kit Core (Apr 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
|
||||
| --- | --- | --- |
|
||||
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. External coverage continued across developer blogs and industry press. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
|
||||
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. 12 substantive external articles published. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
|
||||
|
||||
***
|
||||
|
||||
@@ -94,7 +94,7 @@ On **April 15**, the **Thoughtworks Technology Radar Volume 34** placed GitHub S
|
||||
|
||||
### Developer Articles and Blog Posts
|
||||
|
||||
April produced a steady stream of external articles.
|
||||
April produced 12 substantive external articles (plus one excluded as AI-generated SEO spam).
|
||||
|
||||
**Matt Rickard** published *"The Spec Layer: Why Spec-Driven Development (SDD) Works"* on April 1. His thesis: specs reduce execution freedom for AI agents, functioning as constraint surfaces. He compared Spec Kit, Kiro, OpenSpec, Tessl, Intent, and Symphony, and advocated for **"smaller specs, harder checks, less guessing."** [\[blog.matt-rickard.com\]](https://blog.matt-rickard.com/p/the-spec-layer)
|
||||
|
||||
|
||||
@@ -1,152 +0,0 @@
|
||||
# Spec Kit - July 2026 Newsletter
|
||||
|
||||
This edition covers Spec Kit activity in July 2026 — a month of hardening and expanding the envelope. Twenty-eight releases shipped (v0.12.3 through v0.15.1), crossing three minor bumps and delivering three headline capabilities: the **`assess` "Idea Assessment Pipeline" extension**, which pushes spec-driven development *upstream* of the spec to answer "should we even build this?"; the new **`py` (Python) script type** and the broad shell→Python port that underpins it; and a **first-class agent-native runtime events layer** for integrations. Beneath the features, the month's dominant engineering theme was a sustained **security-hardening wave** — bounded HTTP reads, strict redirect validation, TOCTOU-race elimination, and defensive validation across the workflow engine. Externally, coverage broadened structurally: mainstream tech press (heise online) covered the v0.13 `assess` release in two languages, and a **companion-tooling ecosystem** bloomed around the project — spec↔code drift detectors, model-sizing advisors, and testing-gap tools all built *on top of* Spec Kit. A summary is in the table below, followed by details.
|
||||
|
||||
| **Spec Kit Core (Jul 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
|
||||
| --- | --- | --- |
|
||||
| Twenty-eight releases shipped (v0.12.3–v0.15.1), crossing v0.13, v0.14, and v0.15. Headline features: the `assess` **Idea Assessment Pipeline** extension (capture→evidence→refine→design→go/clarify/kill), the new **`py` script type** plus a shell→Python port of the core scripts, git extension, and agent-context updater, and an **agent-native runtime events layer** for integrations. Three agents joined (Grok Build, Factory Droid CLI, Alquimia AI), the label-driven **bug-fix/bug-test** automation completed the triage pipeline, and a heavy **security-hardening** wave landed. The repo grew from ~117,400 to **124,655 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 125 to **144 entries**; presets reached **29**, community workflows **2**, bundles **1**. **heise online** covered the v0.13 `assess` release in English and German. Coverage shifted toward comparisons, companion tooling, and "who verifies the spec?" critiques. **~258 contributors** now listed. | A **companion-tooling ecosystem** emerged — artgraph (deterministic spec↔code drift), SpecJudge (model right-sizing), GAUNTLEX (security-testing gap), and custom skills like `speckit-next` and `prefill`. Comparisons increasingly pit Spec Kit against Kiro; balanced reviews keep flagging documentation proliferation and cognitive load, precisely the gaps the `assess` upstream step and the drift/companion ecosystem are built to close. |
|
||||
|
||||
***
|
||||
|
||||
> **Hardening the Foundation, Expanding the Envelope.** If June was defined by external validation, July was defined by internal consolidation and reach. No single release carried the weight of `converge` or `bundle`, but the month moved the project in two directions at once. It reached *upstream* — the new `assess` pipeline lets a team evaluate an idea (capture evidence, refine, design, then go/clarify/kill) *before* a spec exists, extending SDD past the spec into the decision to build. And it reached *down to the metal* — a new `py` script type and a systematic port of the core scripts, git extension, and agent-context updater from shell to Python, alongside a security-hardening wave that bounded every HTTP read, validated every redirect hop, eliminated file-race conditions, and taught the workflow engine to fail loudly instead of crashing on malformed input. Meanwhile the ecosystem answered the project's most-cited critique — "who verifies the spec, and who reads all this documentation?" — not with complaints but with *code*: a wave of companion tools built directly on Spec Kit artifacts. None of this happens without the community — the contributors, extension and preset authors, bundle builders, agent-integration maintainers, and practitioners writing in more than 20 languages. Thank you.
|
||||
|
||||
## Spec Kit Project Updates
|
||||
|
||||
### Releases Overview
|
||||
|
||||
**v0.12.3–v0.12.18** (July 1–17) was the month's longest patch run and carried two features amid heavy hardening. The **`py` script type** landed (#3285), adding Python interpreter resolution alongside the existing `sh`/`ps` options, and the **label-driven bug-fix (#3258) and bug-test (#3239) agentic workflows** completed the `bug-assess → bug-test → bug-fix` triage pipeline. The systematic shell→Python port began here: the **`update-agent-context` script** (#3387), the **git extension scripts** (#3400), and a **`check-prerequisites` proof-of-concept** (#3302) were all ported. **PyPI was documented as a first-class second install route** (#3516). New agents arrived — **Grok Build** (#3535) as a skills-based integration — while **Roo Code was retired** as a shut-down product (#3212). The rest was a broad defensive-validation sweep across the workflow engine (case-insensitive gate reject, quote-aware interpolation, host-less catalog-URL rejection, and dozens of "fail loudly on malformed input" guards). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.13.0–v0.13.4** (July 17–22) delivered the month's headline feature: the **`assess` Idea Assessment Pipeline extension** (#3568), a pre-spec evaluation flow. The release also completed the Python port of the three core scripts — **`create-new-feature`, `setup-plan`, and `setup-tasks`** (#3386) — and added **Azure DevOps `az`-CLI token acquisition** hardening (#3527), **community bundle submission automation** (#3553), and the standalone **`WorkflowResolver`** refactor (#3557). **Factory Droid CLI** joined as an integration (#3587), **Bob was updated to a skills-based layout for Bob 2.0** (#3415), the **`pipeline` workflow** was added to the community catalog (#3338), and the **spec-of-specs feature-breakdown** approach was documented for handling complex features (#3648). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.14.0–v0.14.4** (July 23–29) crossed a minor with a **security-hardening focus**: **bounded HTTP reads and strict redirect enforcement** (#3140, #3671), **secured extension/preset archive downloads** (#3141), and the **removal of the `shell` parameter from `run_command`** (#3716). The **git extension gained configurable Conventional Commit support** (#3413), the wheel now **bundles `scripts/python`** so `--script py` works from a clean install (#3665), and **Alquimia AI** joined as the month's third new agent (#2734). Documentation added a **Simplified Chinese README translation** (#3740), and the constitution stopped **propagating guidance into templates** (#3790). A long run of bundler, preset, and integration validation fixes rounded out the cluster. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
**v0.15.0–v0.15.1** (July 30–31) closed the month with a **first-class agent-native runtime events layer for integrations** (#3704) — the release's headline — plus a continued security pass: **TOCTOU-race elimination in file-unlink calls** (#3811, #3815, #3819), **UTF-8 encoding on registry file opens** (#3810, #3816), and **hardening of the extension URL-download cache against symlink/junction races** (#3869). Workflows gained the ability to **bind a gate verdict to a workflow input via `verdict_input`** (#3725), an opt-in **`constitution-sync` preset** shipped (#3873), the **`yolo` workflow** was added to the community catalog (#3864), and installs gained **tar-archive support** (#3874). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Idea Assessment Pipeline: `assess`
|
||||
|
||||
July's headline feature was the **`assess` extension** (#3568), an *idea-assessment pipeline* that ships as an opt-in extension and extends spec-driven development one step further upstream than it has ever reached. Where the core lifecycle begins at `/speckit.specify` — assuming the decision to build has already been made — `assess` addresses the question that comes *before* the spec: **should this idea be built at all, and is it understood well enough to specify?**
|
||||
|
||||
The pipeline runs a staged flow — **capture → evidence → refine → design → decision** — that takes a raw idea, gathers supporting evidence, refines it into something concrete, sketches a design, and terminates in an explicit **go / clarify / kill** verdict. A `go` feeds a well-formed problem into the existing `/speckit.specify` step; a `clarify` routes back for more information; a `kill` stops work before a line of spec is written. Its input is just an idea — pasted text, a URL, a ticket, or a codebase pointer — so the pipeline works **equally well on an empty, freshly-initialized project or on an existing codebase** (#3732); a team can evaluate a green idea before any scaffolding exists, or assess a change against a repo that already has one.
|
||||
|
||||
The feature drew the month's most prominent mainstream-press coverage: **heise online** ran *"From Idea to Spec: The New Feature in Spec Kit 0.13"* in both English and German, framing `assess` as the notable addition of the 0.13 line alongside the Azure DevOps CLI support and the bundler/preset validation fixes. Coming from a major European technology outlet rather than a developer blog, it was a signal that Spec Kit's release cadence is now tracked as mainstream tooling news. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
|
||||
|
||||
### The Python Migration: the `py` Script Type
|
||||
|
||||
Spec Kit's second July theme was quieter but structurally important: the project began migrating its shell scripts to **Python**. The new **`py` script type** (#3285) joins `sh` (bash) and `ps` (PowerShell) as a third option at `specify init`, backed by Python-interpreter resolution that skips broken stubs (including the Windows Store `python3` alias, #3385). The `py` type is the project's answer to the perennial bash/PowerShell parity tax — every script fix previously had to be written twice and kept in sync, a recurring source of the Windows-parity bugs that filled prior months' changelogs.
|
||||
|
||||
Behind the new type, a systematic port landed piece by piece across the month: the **`update-agent-context`** updater (#3387), the **git extension scripts** (#3400), a **`check-prerequisites`** proof-of-concept (#3302), and finally the three core scripts — **`create-new-feature`, `setup-plan`, and `setup-tasks`** (#3386). The wheel was updated to bundle `scripts/python` so `--script py` works from a clean PyPI install (#3665), and the installation docs and init option table were updated to document the new type and the sh/ps migration plan (#3284, #3640). The end state is a single, cross-platform script implementation that removes an entire class of parity bugs. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### Agent-Native Runtime Events
|
||||
|
||||
The v0.15.0 headline was a **first-class agent-native runtime events layer for integrations** (#3704). It bridges Spec Kit to the host agent's own lifecycle via a set of canonical, snake_case event names — `session_start`, `pre_tool_use`, `post_tool_use`, `user_prompt_submit`, `stop`, and `session_end`. A lightweight, zero-dependency **Event Dispatcher** (`.specify/events.py`) is scaffolded during `specify init`, and per-integration **Event Adapters** translate each canonical event into the agent's *native* hook configuration — `.github/hooks/speckit.json` (bash/PowerShell variants) for **Copilot CLI**, `.claude/settings.json` for Claude Code, `.cursor/hooks.json` for Cursor, `.codex/config.toml` for Codex, a TypeScript plugin for opencode, and native settings merges for Gemini, Qwen, Devin, and Tabnine — so extension authors declare `events:` in `extension.yml` once and never learn agent-specific names. Resolution is a four-tier stack (CLI `--events false` → user `.specify/integration-events.yml` override → extension-declared events → built-in defaults), and multiple extensions declaring the same event all run. The change accompanied a broader integration-refinement run: agents that use an always-slash invocation (Droid, Forge, Cline) now render hyphenated `/speckit-<name>` commands correctly (#3688, #3642, #3622), native skill-invocation prefixes are preserved (#3663), and several agents (kiro-cli, Lingma, Pi, omp) were declared multi-install-safe. The through-line is that integrations are increasingly *native* to each agent rather than a lowest-common-denominator overlay. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Security-Hardening Wave
|
||||
|
||||
The dominant engineering theme across all twenty-eight releases was **security and robustness**. The work fell into three bands. **Bounded I/O:** every catalog, download, and bundle HTTP response is now read under a byte cap with strict redirect validation on every hop (#3140, #3671, #3763, #3141), closing a class of unbounded-read / DoS exposure. **Race elimination:** TOCTOU races in file-unlink and state-file handling were removed (#3811, #3815, #3819), the extension URL-download cache was hardened against symlink and junction races (#3869), and registry file opens were pinned to UTF-8 (#3810, #3816). **Injection and input hardening:** the `shell` parameter was removed from `run_command` (#3716), user-supplied catalog metadata is escaped in every discovery/list/`init` output path (#3772, #3773, #3774, #3806, #3826, #3863), and catalog URLs are re-validated *after* redirects to preserve HTTPS/host guarantees (#3523, #3524).
|
||||
|
||||
Running alongside this was a systematic **"fail loudly, don't crash"** campaign across the workflow engine and catalog loaders: dozens of PRs replaced raw `ValueError`/`OverflowError`/crash paths with clean validation errors on malformed input — non-string commands, prompts, integrations, and models; non-list branches and `wait_for` entries; `priority: .inf` and boolean priorities; non-mapping manifest blocks; and superscript-digit gate prompts. The entire month's hardening arrived as prevention rather than response. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Workflow Engine & Bundles Mature
|
||||
|
||||
Beyond hardening, the **workflow engine** kept gaining capability. Steps can now read their **workflow source directory** (#3469), the shell and prompt steps got **configurable, validated timeouts** (#3404, #3847, #3768), a **gate verdict can bind to a workflow input** via `verdict_input` (#3725), and the **`WorkflowResolver`** was extracted as a standalone component (#3557). Two community workflows reached the catalog — the guided **`pipeline`** (#3338, which chains into the core `/speckit.converge`) and **`yolo`** (#3864) — bringing the standalone-workflow count to two.
|
||||
|
||||
The **bundle subsystem** introduced in June matured through a long tail of correctness work — reproducible builds via canonical POSIX arcnames (#3658), literal UTF-8 manifest dumps (#3660), strict rejection of malformed `requires`/`provides`/`integration`/`catalogs` blocks, and a clean `BundlerError` on malformed download URLs (#3586). **Community bundle submission automation** landed (#3553) and the **SicarioSpec Security & Governance Bundle** became a cataloged community bundle (#3636), making bundles a live community-submittable artifact type in practice. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### Agent Integrations
|
||||
|
||||
The agent portfolio grew net **+3 to 37 integrations**. Three joined — **Grok Build** (#3535), **Factory Droid CLI** (#3587, closing the 300+-day #822), and **Alquimia AI** (#2734) — while **Roo Code** was retired as a shut-down product (#3212). **Bob** was migrated to a skills-based layout for Bob 2.0 (#3415), **Kilocode** now installs commands under `.kilo/commands` (#3672), and a broad correctness pass fixed hyphenated-command rendering and dispatch for the always-slash agents (Droid, Forge, Cline) and preserved native skill-invocation prefixes (#3663). The pattern continues from June — pruning dead products while making the surviving integrations more native to each agent. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
### The Extension & Preset Ecosystem
|
||||
|
||||
The community extension catalog grew from 125 to **144 entries** during July — nineteen net additions. Community presets grew from 23 to **29**, community workflows reached **2**, and the first community **bundle** (SicarioSpec) was cataloged.
|
||||
|
||||
Notable new extensions by category:
|
||||
|
||||
- **Verification, drift & evidence**: Test Coverage Drift Control, PatchWarden Evidence Pack, Quality Gates (Enforcement Layer), Verify Review Ship, Intent Reconciliation
|
||||
- **Requirements & intake**: EARS Requirements Syntax, the `assess` Idea Assessment Pipeline, Spec-Kit BDD, Charter
|
||||
- **External trackers & round-trip**: Linear Weave, Multi-Repo Branch Sync, ContextForge MCP
|
||||
- **Design & docs**: Spec Kit Figma, Figma Starter, Blueprint Index — Living Architecture Map, LLM Wiki, Dotdog
|
||||
- **Knowledge & orchestration**: OKF Knowledge Bundle Generator, Orchestration Task Context Management, Spec Kit Memory
|
||||
|
||||
The catalog also showed strong maintenance activity: **DocGuard — CDD Enforcement** advanced through several releases (to v0.33.0), **Verify Review Ship** and **Quality Gates (Enforcement Layer)** iterated rapidly, and **Architecture Guard**, **Golden Demo**, **Coding Standards Drift Control**, **Ripple**, and the **Ralph Loop** all shipped updates. The preset side was the month's busiest: a large **governance-preset** family expanded and iterated — the **Autonomous Run Governance** and **Parallel Autonomous Run Governance** presets, a full **Intake** governance suite (Authoring, Review, Sequencing), **Test-First Governance**, and coordinated version bumps across the A11Y, Agent-Parity, Cross-Platform, iSAQB-Architecture, Architecture, and Security governance presets. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
|
||||
|
||||
### Documentation & Docs Site
|
||||
|
||||
July's documentation work paired the new features with a landing-page refresh. The **spec-of-specs feature-breakdown** approach was documented for handling complex features (#3648), the **`py` (Python) script type** was documented in the installation guide and init option table (#3284, #3625, #3640), and the **`__SPECKIT_COMMAND`** token for portable cross-command references was documented (#3503). The landing page was reframed to weave the **harness/SDLC framing** and modernize the install and positioning story (#3565, #3567), ecosystem stats were refreshed (#3561), and **`extensions.yml` hook configuration** was documented (#3563). Upgrade guidance clarified that project-file upgrades flow through `integration upgrade` / `extension update` (#3326) and that Claude Code files live in `.claude/skills` (#3708). [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
|
||||
## Community & Content
|
||||
|
||||
### Press and Industry Coverage
|
||||
|
||||
July's coverage shifted from "what is SDD" explainers toward tool comparisons, companion tooling, and pointed "who verifies the spec?" critiques. No first-party Microsoft or GitHub post appeared in July; the nearest remained June's Microsoft Developer Blog piece.
|
||||
|
||||
**heise online** (Wolf Hosbach, July 21) was the month's most prominent mainstream-press coverage, publishing *"From Idea to Spec: The New Feature in Spec Kit 0.13"* in both English and German — news coverage of the `assess` Idea Assessment Pipeline, the Azure DevOps CLI support, and the 0.13 validation fixes. Mainstream European tech press now tracks Spec Kit's minor releases as tooling news. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
|
||||
|
||||
**Towards AI** (Rost Glukhov, July 12) compared **GitHub Spec Kit vs Kiro vs Claude Code** on SDD workflow rather than model capability, part of a July-long current of "which SDD tool?" comparisons that increasingly pit Spec Kit specifically against Kiro. [\[pub.towardsai.net\]](https://pub.towardsai.net/github-spec-kit-vs-kiro-vs-claude-code-sdd-workflows-a9e7fab3e545)
|
||||
|
||||
**ranjankumar.in** (Ranjan Kumar, July 13) argued that four SDD frameworks — BMAD, Spec Kit, Kiro, and Superpowers — converge on the same structural "invariants," engaging Spec Kit's actual internals (`workflows.md`, run-state `state.json`) rather than treating it as a black box. [\[ranjankumar.in\]](https://ranjankumar.in/spec-driven-development-invariants-not-frameworks)
|
||||
|
||||
Release-trackers continued their factual coverage of the 0.13–0.15 run, and **Level Up Coding** (JingJing "Chris" Bao) published a three-part practitioner series on Spec Kit's presets, extensions, and pipeline/workflow features as the path beyond linear slash-commands. [\[levelup.gitconnected.com\]](https://levelup.gitconnected.com/from-linear-commands-to-automated-pipelines-how-spec-kit-orchestrates-nonlinear-ai-development-55ca03c5617b)
|
||||
|
||||
### The Companion-Tooling Ecosystem
|
||||
|
||||
July's most telling signal was not an article but a pattern: independent developers responded to Spec Kit's most-cited critiques by **building tools on top of it**. The recurring complaint — documentation proliferation and "who verifies the generated spec?" — turned into code.
|
||||
|
||||
- **artgraph** (mori-shin, July 20) — a deterministic, hash-based spec↔code drift-detection CLI with an `artgraph integrate speckit` hook, built specifically to give Spec Kit's LLM-prompt-based verification a deterministic backstop. [\[zenn.dev\]](https://zenn.dev/mrmtsntr/articles/artgraph-spec-code-drift)
|
||||
- **SpecJudge** (Joaquín Ruiz, July 21) — a companion CLI that reads Spec Kit's constitution/spec/tasks artifacts to recommend a *right-sized* model for the project. [\[dev.to\]](https://dev.to/jokiruiz/specjudge-which-ai-model-is-right-sized-for-your-project-ask-your-specs-2edp)
|
||||
- **GAUNTLEX** (Sanjoy Ghosh, July 16) — named Spec Kit a leading SDD tool while arguing SDD leaves a security-testing gap, and shipped a tool to fill it. [\[hashnode.dev\]](https://sanjoy1234.hashnode.dev/the-testing-gap-nobody-s-talking-about-in-spec-driven-development)
|
||||
- **Custom skills** — [`speckit-next`](https://qiita.com/htcd/items/ec76f2b7194be3297b93) (htcd, July 31), a skill that recommends the next command because the names and order are hard to remember, and [`prefill`](https://velog.io/@k3nta/ai-adoption-journey-1-tools) (k3nta, July 1), a skill that patches `clarify`'s blind spots.
|
||||
|
||||
Together these are the clearest evidence yet that Spec Kit has become a *platform* — its artifacts are stable enough, and its gaps well-enough understood, that a third-party tooling layer is forming around it. [\[zenn.dev\]](https://zenn.dev/mrmtsntr/articles/artgraph-spec-code-drift)
|
||||
|
||||
### Developer Articles and Blog Posts
|
||||
|
||||
July's articles skewed heavily multilingual — strong hands-on series in Japanese, Chinese, and Korean — with a clear thread of honest, use-it-in-anger critique.
|
||||
|
||||
Notable articles:
|
||||
|
||||
- **ta_kawano** (note.com, July 28–31) published a consolidated four-part **Kiro vs Spec Kit** head-to-head, completing a 108-task / 301-test build with Spec Kit where Kiro ran out of credit, praising measurable Success Criteria and auto-listed edge cases while flagging ~15,000 lines of generated documentation — and reframing Spec Kit as a requirements-elicitation tool. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
|
||||
- **magebyte / 码哥字节** (SegmentFault, July 26) built a Go REST API through the five-step workflow, covered the three spec-persistence models and the extension/preset system, and claimed ~80% less AI "hallucination" rework. [\[segmentfault.com\]](https://segmentfault.com/a/1190000048082146)
|
||||
- **Nil Seri** (Medium, July 16) published a brownfield guide adding Spec Kit to an existing Spring Boot / Maven project with Jira and Confluence integration — "from Jira ticket to verified code." [\[medium.com\]](https://medium.com/@senoritadeveloper/using-spec-kit-in-an-existing-spring-boot-maven-project-from-jira-ticket-to-verified-code-4e6da99b5d19)
|
||||
- **kitroc7134** (Qiita, July 4) tested `/speckit.converge` with deliberate fault-injection on a FastAPI Todo API — detect drift → append convergence tasks → re-implement — validating June's convergence loop in the field. [\[qiita.com\]](https://qiita.com/kitroc7134/items/117d4839f259bc403626)
|
||||
- **yutakaosada** (Zenn, July 25) — a Microsoft-MVP .NETラボ talk that uses Spec Kit but candidly flags AI-credit consumption, over-production of docs, and single-source-of-truth collapse, comparing it with Copilot Plan mode. [\[zenn.dev\]](https://zenn.dev/yutakaosada/articles/70e01981647159)
|
||||
|
||||
Additional coverage appeared on TechWealthBuzz, Hashnode, TabNews-adjacent outlets, CSDN and 腾讯云 (Chinese), Naver/velog/Tistory (Korean), and Qiita/note (Japanese) — including several "is it too heavy?" and documentation-proliferation critiques, and a [Korean instructor's piece](https://blog.naver.com/gaussian88/224363268926) citing Spec Kit's star growth from ~90k in May to ~120k in July. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
|
||||
|
||||
### Community Growth by the Numbers
|
||||
|
||||
| Metric | Start of July | End of July | Change |
|
||||
| --- | --- | --- | --- |
|
||||
| GitHub stars | 117,423 | 124,655 | +7,232 (+6%) |
|
||||
| Forks | 10,382 | 11,125 | +743 |
|
||||
| Contributors | 245 | ~258 | +~13 |
|
||||
| Releases (total) | 177 | 205 | +28 (v0.12.3–v0.15.1) |
|
||||
| Community extensions | 125 | 144 | +19 |
|
||||
| Community presets | 23 | 29 | +6 |
|
||||
| Community workflows | 1 | 2 | +1 |
|
||||
| Community bundles | 1 | 1 | steady |
|
||||
| Agent integrations | 34 | 37 | +3 (net) |
|
||||
| Discussions (open) | 457 | ~467 | +~10 |
|
||||
|
||||
## SDD Ecosystem & Industry Trends
|
||||
|
||||
### From Tool to Platform
|
||||
|
||||
July's clearest ecosystem signal was structural: the conversation moved from "how do I use Spec Kit?" to "what do I build *around* it?" The companion tools — artgraph for deterministic drift, SpecJudge for model sizing, GAUNTLEX for the testing gap, and a growing set of custom agent skills — treat Spec Kit's artifacts (constitution, spec, tasks, run-state) as a stable substrate to build against. The public community catalog reinforces the point: the loudest theme across the 144 cataloged extensions is verification and quality (review, validate, drift, sync, verify, audit), and core SDD verbs are increasingly *re-expressed* by extensions rather than merely overridden — evidence of demand for composable, overridable core commands. [\[github.com\]](https://github.github.io/spec-kit/community/extensions.html)
|
||||
|
||||
### Competitive Landscape
|
||||
|
||||
The "which SDD tool?" comparison remained the dominant content genre, but July's framing narrowed: where June's surveys ran a seven-tool field, July's most substantive pieces increasingly went head-to-head **Spec Kit vs Kiro** (ta_kawano, [faruryo](https://qiita.com/faruryo/items/87a14728299e89f80ff4), Towards AI). The recurring verdict held — Spec Kit is the heaviest and most flexible option, strong on measurable Success Criteria, requirements elicitation, and greenfield decomposition, while its documentation proliferation and cognitive load are the consistent trade-off. The convergence-invariants analyses (ranjankumar.in) went further, arguing the frameworks are converging on the same structural primitives, which shifts the competitive question from "which tool" to "which ecosystem and governance model." On that axis, Spec Kit's widening catalog, agent-neutrality, and now a forming companion-tooling layer are its differentiators. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
|
||||
|
||||
## Roadmap
|
||||
|
||||
Areas under discussion or in progress for future development:
|
||||
|
||||
- **Upstream of the spec** — the `assess` Idea Assessment Pipeline extends SDD before the spec exists. Expect the capture→evidence→refine→design→decision flow to deepen, and the boundary between idea assessment and `/speckit.specify` to be a key area to refine as the pipeline sees real use. [\[heise.de\]](https://www.heise.de/en/news/From-Idea-to-Spec-The-New-Feature-in-Spec-Kit-0-13-11371866.html)
|
||||
- **The Python migration** — the `py` script type and the port of the core scripts, git extension, and agent-context updater establish Python as the path out of the bash/PowerShell parity tax. Completing the port and making `py` a well-trodden default (rather than sh/ps) is the payoff: an entire class of Windows-parity bugs disappears. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Agent-native runtime events** — the first-class events layer lets integrations wire Spec Kit into each agent's own runtime through canonical event names and per-agent adapters. The layer is **actively evolving** — early signals point to opencode context injection and JSON-envelope agent hooks. Expect more agents to gain event adapters and extension authors to lean on the declarative `events:` surface as the integration layer shifts from lowest-common-denominator overlay to genuinely native behavior. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Copilot skills as the default** — July shipped a warning ahead of the skills-default rollout, and the signals now point to the **default flip being in progress** — moving `specify init --integration copilot` to the skills-based layout and making the default init integration overridable via an environment variable, with the markdown-command layout becoming the legacy path. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **A Copilot-native surface** — the first-party [`github/spec-kit-copilot`](https://github.com/github/spec-kit-copilot) repo (a listed community friend) wraps the `specify` CLI as a **Copilot skills plugin** (nine skills across setup, init, extensions, presets, bundles, workflows, and self-upgrade) for the Copilot CLI and App, aligned to CLI v0.15.0. The emerging direction is a **visual, Copilot-driven surface** — early work explores canvas dashboards for the Spec-Driven Development flow, a Bug Fix Pipeline, and `assess` — turning the CLI's flows into an interactive layer. [\[github.com\]](https://github.com/github/spec-kit-copilot)
|
||||
- **The companion-tooling layer** — artgraph, SpecJudge, GAUNTLEX, and custom skills signal a third-party ecosystem forming on Spec Kit artifacts. The open question is whether the project absorbs these patterns (as it did drift → `converge`) or leaves them to the ecosystem; the verification/drift demand in the extension catalog suggests continued upstream pull. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Security and robustness as a standing discipline** — July's hardening wave (bounded reads, strict redirects, TOCTOU elimination, fail-loudly validation) shifted from feature to routine, and the signals point to it **continuing as an ongoing campaign** — a stdin read cap to close a DoS path and a broader atomicity push (atomic temp-file writes, subprocess timeouts, structured logging, and narrowed exception handlers). Sustaining the no-unbounded-read invariant as the surface (bundles, workflows, catalogs, events) grows is the ongoing work. [\[github.com\]](https://github.com/github/spec-kit/releases)
|
||||
- **Experience simplification** — documentation proliferation and cognitive load remain the single most-cited concern across July's balanced reviews (ta_kawano, yutakaosada, and multiple Japanese/Korean pieces). The `assess` upstream gate, the lean/TinySpec presets, `/speckit.converge`, and the forming companion-tooling layer all provide answers; surfacing them to new users is the persistent opportunity. [\[note.com\]](https://note.com/takawano/n/ncb552ee37331)
|
||||
@@ -1,14 +1,14 @@
|
||||
# Spec Kit - June 2026 Newsletter
|
||||
|
||||
This edition covers Spec Kit activity in June 2026 — a month of maturation and mainstream validation. Twenty-five releases shipped (v0.9.0 through v0.12.2), spanning four minor bumps and delivering two headline capabilities: the **`/speckit.converge` command**, which closes the loop between a spec and the code that implements it, and the new **`specify bundle` subsystem**, a role-based distribution layer that composes extensions, presets, workflows, and steps into a single installable unit. The workflow engine became programmable, the git extension went opt-in as the first real breaking change, and the ecosystem crossed **120+ community extensions**. Externally, June brought broad validation — Microsoft's own Developer Blog published a first-party spec-driven development post, an enterprise reported 2–4× velocity gains, and coverage spanned dozens of languages. A summary is in the table below, followed by details.
|
||||
This edition covers Spec Kit activity in June 2026 — a month of maturation and mainstream validation. Twenty-five releases shipped (v0.9.0 through v0.12.2), spanning four minor bumps and delivering two headline capabilities: the **`/speckit.converge` command**, which closes the loop between a spec and the code that implements it, and the new **`specify bundle` subsystem**, a role-based distribution layer that composes extensions, presets, workflows, and steps into a single installable unit. The workflow engine became programmable, the git extension went opt-in as the first real breaking change, and the ecosystem crossed **120+ community extensions**. Externally, June was the highest-volume press month on record — Microsoft's own Developer Blog published a first-party spec-driven development post, an enterprise reported 2–4× velocity gains, and 75 substantive articles appeared across 25+ languages. A summary is in the table below, followed by details.
|
||||
|
||||
| **Spec Kit Core (Jun 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
|
||||
| --- | --- | --- |
|
||||
| Twenty-five releases shipped (v0.9.0–v0.12.2) with key features: the `/speckit.converge` convergence loop, the `specify bundle` role-based packaging subsystem, a programmable workflow engine (step catalog, JSON output, `from_json`), the git extension becoming opt-in (`--no-git` removed), and six new agents (Cline, rovodev, Zed, Firebender, ZCode, omp). The repo grew from ~107k to **~116,500 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 105 to **124 entries**; presets reached **23**. Microsoft's Developer Blog published a first-party SDD post naming Spec Kit as the operationalizing toolkit. Press coverage spanned dozens of languages. **245 contributors** now listed. | An enterprise (SNCF Connect & Tech) reported **2–4× velocity** from SDD. Analysts and comparisons increasingly name Spec Kit "the category anchor" and agent-neutral default. Competitors differentiate on brownfield and drift; balanced reviews continue to flag review-overload and ceremony for small tasks. |
|
||||
| Twenty-five releases shipped (v0.9.0–v0.12.2) with key features: the `/speckit.converge` convergence loop, the `specify bundle` role-based packaging subsystem, a programmable workflow engine (step catalog, JSON output, `from_json`), the git extension becoming opt-in (`--no-git` removed), and six new agents (Cline, rovodev, Zed, Firebender, ZCode, omp). The repo grew from ~107k to **~116,500 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | The community extension catalog grew from 105 to **124 entries**; presets reached **23**. Microsoft's Developer Blog published a first-party SDD post naming Spec Kit as the operationalizing toolkit. June was the highest-volume press month yet — **75 substantive articles** across 25+ languages. **245 contributors** now listed. | An enterprise (SNCF Connect & Tech) reported **2–4× velocity** from SDD. Analysts and comparisons increasingly name Spec Kit "the category anchor" and agent-neutral default. Competitors differentiate on brownfield and drift; balanced reviews continue to flag review-overload and ceremony for small tasks. |
|
||||
|
||||
***
|
||||
|
||||
> **Spec-Driven Development, Institutionalized.** If May was defined by milestone 100s, June was defined by validation from outside the project. Microsoft's own Developer Blog published a first-party post presenting spec-driven development and positioning Spec Kit as the toolkit that operationalizes it. An enterprise — SNCF Connect & Tech — went on the record with **2–4× velocity gains** from adopting SDD. Coverage appeared in dozens of languages, and the recurring verdict across independent comparisons was that Spec Kit is "the category anchor" and the agent-neutral default. Meanwhile the core matured from v0.9 to v0.12: the workflow engine became genuinely programmable, the first real breaking change shipped, and the new convergence loop and bundle subsystem gave the project answers to its two most-cited gaps — drift and distribution. None of this happens without the community — the contributors, extension and preset authors, bundle builders, and practitioners writing in a dozen languages. Thank you.
|
||||
> **Spec-Driven Development, Institutionalized.** If May was defined by milestone 100s, June was defined by validation from outside the project. Microsoft's own Developer Blog published a first-party post presenting spec-driven development and positioning Spec Kit as the toolkit that operationalizes it. An enterprise — SNCF Connect & Tech — went on the record with **2–4× velocity gains** from adopting SDD. A record **75 substantive articles** appeared in more than 25 languages, and the recurring verdict across independent comparisons was that Spec Kit is "the category anchor" and the agent-neutral default. Meanwhile the core matured from v0.9 to v0.12: the workflow engine became genuinely programmable, the first real breaking change shipped, and the new convergence loop and bundle subsystem gave the project answers to its two most-cited gaps — drift and distribution. None of this happens without the community — the contributors, extension and preset authors, bundle builders, and practitioners writing in a dozen languages. Thank you.
|
||||
|
||||
## Spec Kit Project Updates
|
||||
|
||||
@@ -84,7 +84,7 @@ On **June 10**, the **Microsoft Developer Blog** published *"Spec-Driven Develop
|
||||
|
||||
### Press and Industry Coverage
|
||||
|
||||
June's press coverage spanned dozens of languages and platforms.
|
||||
June was the **highest-volume coverage month on record — 75 substantive articles** across more than 25 languages.
|
||||
|
||||
**Xebia / XPRT Magazine #21** (Hidde de Smet & Emanuele Bartolesi, June 17) published a 32-minute full six-command walkthrough covering both greenfield and brownfield, honest about markdown-review overhead and where spec quality becomes the bottleneck. [\[xebia.com\]](https://xebia.com/blog/building-software-with-spec-kit/)
|
||||
|
||||
@@ -102,7 +102,7 @@ June's press coverage spanned dozens of languages and platforms.
|
||||
|
||||
### Developer Articles and Blog Posts
|
||||
|
||||
June's coverage skewed heavily multilingual, with deep hands-on series in Chinese, Japanese, and Korean, and a strong current of "which tool should I choose?" comparisons.
|
||||
June's 75 articles skewed heavily multilingual, with deep hands-on series in Chinese, Japanese, and Korean, and a strong current of "which tool should I choose?" comparisons.
|
||||
|
||||
Notable English-language articles:
|
||||
|
||||
@@ -137,7 +137,7 @@ Coverage also appeared on TabNews (Portuguese), Habr and CSDN, note.com, Substac
|
||||
|
||||
### The Category Consolidates
|
||||
|
||||
Across June's broad article coverage, a consistent framing emerged: spec-driven development is now an established category, and Spec Kit is its reference implementation. SSOJet called it "the category anchor," Design News and multiple comparison pieces called it the agent-neutral default, and ToolTwist's CxO guide named it the "safe default for scaling teams." The Microsoft Developer Blog post and the SNCF enterprise interview extended that framing beyond the developer press into institutional and enterprise contexts. [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
|
||||
Across June's record article volume, a consistent framing emerged: spec-driven development is now an established category, and Spec Kit is its reference implementation. SSOJet called it "the category anchor," Design News and multiple comparison pieces called it the agent-neutral default, and ToolTwist's CxO guide named it the "safe default for scaling teams." The Microsoft Developer Blog post and the SNCF enterprise interview extended that framing beyond the developer press into institutional and enterprise contexts. [\[ssojet.com\]](https://ssojet.com/blog/best-spec-driven-development-tools)
|
||||
|
||||
### Competitive Landscape
|
||||
|
||||
|
||||
@@ -4,7 +4,7 @@ This edition covers Spec Kit activity in May 2026 — a month defined by three m
|
||||
|
||||
| **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 many more across multiple languages. **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. |
|
||||
| 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. |
|
||||
|
||||
***
|
||||
|
||||
@@ -76,7 +76,7 @@ May produced the broadest press coverage to date, with publications from the mai
|
||||
|
||||
### Developer Articles and Blog Posts
|
||||
|
||||
May produced a wave of independent coverage across many languages.
|
||||
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:
|
||||
|
||||
|
||||
@@ -59,19 +59,6 @@ Content resolution functions for composition:
|
||||
- **Bash**: `resolve_template_content()` in `scripts/bash/common.sh` (templates only; command/script composition is handled by the Python resolver)
|
||||
- **PowerShell**: `Resolve-TemplateContent` in `scripts/powershell/common.ps1` (templates only; command/script composition is handled by the Python resolver)
|
||||
|
||||
### Constitution lifecycle
|
||||
|
||||
Initialization resolves `constitution-template` through the full stack and seeds
|
||||
`.specify/memory/constitution.md` once. Existing files are preserved byte-for-byte. On subsequent
|
||||
`/constitution` runs, the command resolves the current composed template at runtime and uses the live
|
||||
constitution as the source of project-specific values and amendments.
|
||||
|
||||
Preset installation, removal, enablement, disablement, and priority changes do not materialize
|
||||
`constitution-template` by default. When the enabled preset registry contains `constitution-sync`,
|
||||
those operations may reconcile the live file, but only if its provenance hash proves it is still
|
||||
generated content. Missing files may be seeded when the preset is installed; authored or edited
|
||||
constitutions are never overwritten.
|
||||
|
||||
## Command Registration
|
||||
|
||||
When a preset is installed with `type: "command"` entries, the `PresetManager` registers them into all detected agent directories using the shared `CommandRegistrar` from `src/specify_cli/agents.py`.
|
||||
|
||||
@@ -300,12 +300,6 @@ git push origin add-your-preset
|
||||
|
||||
## Verification Process
|
||||
|
||||
> **How submissions get picked up:** the automated catalog-validation workflow only runs
|
||||
> once the `preset-submission` label is on the issue. On this public repository, contributors
|
||||
> cannot apply that label themselves — a maintainer applies it during issue triage. Until then
|
||||
> the issue simply waits in triage; there is no action required from you, and there is no need to
|
||||
> re-request the label in a comment.
|
||||
|
||||
After submission, maintainers will review:
|
||||
|
||||
1. **Manifest validation** — valid `preset.yml`, all files exist
|
||||
|
||||
@@ -15,16 +15,6 @@ If no preset is installed, core templates are used — exactly the same behavior
|
||||
|
||||
Template resolution happens **at runtime** — although preset files are copied into `.specify/presets/<id>/` during installation, Spec Kit walks the resolution stack on every template lookup rather than merging templates into a single location.
|
||||
|
||||
`constitution-template` follows the same runtime model. Project initialization seeds
|
||||
`.specify/memory/constitution.md` once so downstream commands always have a constitution to read.
|
||||
After that, installing, removing, enabling, disabling, or reprioritizing presets does not rewrite the
|
||||
live constitution. Each `/constitution` run resolves the current composed `constitution-template`,
|
||||
then applies existing project values and amendments to that scaffold.
|
||||
|
||||
Teams that intentionally want preset stack changes to refresh an unchanged generated constitution can
|
||||
install the bundled `constitution-sync` preset. It restores guarded install-time materialization in
|
||||
addition to its command-time propagation behavior; authored constitutions remain protected.
|
||||
|
||||
For detailed resolution and command registration flows, see [ARCHITECTURE.md](ARCHITECTURE.md).
|
||||
|
||||
## Command Overrides
|
||||
|
||||
+96
-106
@@ -1,52 +1,57 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-08-14T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z",
|
||||
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
"name": "A11Y Governance",
|
||||
"id": "a11y-governance",
|
||||
"version": "0.4.3",
|
||||
"description": "Adds WCAG 2.2 AA, accessible status output, bilingual CEFR-B2 delivery, inclusive-content and didactic-comment governance, and provider-neutral model routing.",
|
||||
"version": "0.4.2",
|
||||
"description": "Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.3.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.3/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 11,
|
||||
"templates": 10,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"a11y",
|
||||
"accessibility",
|
||||
"bilingual",
|
||||
"wcag",
|
||||
"wcag-2-2",
|
||||
"cefr-b2",
|
||||
"inclusion",
|
||||
"model-routing"
|
||||
"include-everyone",
|
||||
"didactic-comments"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-08-12T00:00:00Z"
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
"id": "agent-parity-governance",
|
||||
"version": "0.4.2",
|
||||
"description": "Adds shared-guidance parity, fleet-completion evidence, secret-free runner metadata, audit-ready Spec Kit evidence, and agent-neutral model routing across declared AI-agent surfaces.",
|
||||
"version": "0.4.1",
|
||||
"description": "Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 7,
|
||||
"templates": 6,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -59,7 +64,7 @@
|
||||
"multi-agent"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-08-13T00:00:00Z"
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
},
|
||||
"aide-in-place": {
|
||||
"name": "AIDE In-Place Migration",
|
||||
@@ -92,30 +97,40 @@
|
||||
"architecture-governance": {
|
||||
"name": "Architecture Governance",
|
||||
"id": "architecture-governance",
|
||||
"version": "0.5.2",
|
||||
"description": "Adds secure architecture, STRIDE/CAPEC threat modeling, arc42/S-ADR guidance, Zero Trust, SAMM, BSI cloud assurance, audit evidence, and provider-neutral model routing.",
|
||||
"version": "0.5.1",
|
||||
"description": "Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 14,
|
||||
"templates": 13,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"threat-modeling",
|
||||
"stride",
|
||||
"capec",
|
||||
"arc42",
|
||||
"adr",
|
||||
"zero-trust",
|
||||
"samm",
|
||||
"isaqb",
|
||||
"cloud",
|
||||
"model-routing"
|
||||
"sovereignty",
|
||||
"c3a",
|
||||
"c5",
|
||||
"assurance"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-08-11T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
@@ -228,19 +243,19 @@
|
||||
"cross-platform-governance": {
|
||||
"name": "Cross-Platform Governance",
|
||||
"id": "cross-platform-governance",
|
||||
"version": "0.2.2",
|
||||
"description": "Adds Bash/PowerShell parity, read-only checks, path and native-override review, Unix man pages, bilingual PowerShell help, and provider-neutral model routing.",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 9,
|
||||
"templates": 8,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -248,10 +263,15 @@
|
||||
"governance",
|
||||
"bash",
|
||||
"powershell",
|
||||
"model-routing"
|
||||
"man-page",
|
||||
"cmdlet",
|
||||
"verb-noun",
|
||||
"windows",
|
||||
"macos",
|
||||
"linux"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-08-12T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"explicit-task-dependencies": {
|
||||
"name": "Explicit Task Dependencies",
|
||||
@@ -347,19 +367,19 @@
|
||||
"intake-authoring-governance": {
|
||||
"name": "Intake Authoring Governance",
|
||||
"id": "intake-authoring-governance",
|
||||
"version": "0.3.1",
|
||||
"description": "Governs traceable intake CRUD, language-aware requirements collections, bounded public HTTPS sources, and explicitly approved single or series authoring.",
|
||||
"version": "0.3.0",
|
||||
"description": "Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.3.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.3.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.3.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.3.0/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 13,
|
||||
"templates": 12,
|
||||
"commands": 5,
|
||||
"scripts": 7
|
||||
},
|
||||
@@ -371,7 +391,7 @@
|
||||
"migration"
|
||||
],
|
||||
"created_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-08-14T00:00:00Z"
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
},
|
||||
"intake-review-governance": {
|
||||
"name": "Intake Review Governance",
|
||||
@@ -434,30 +454,34 @@
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
"id": "isaqb-architecture-governance",
|
||||
"version": "0.2.2",
|
||||
"description": "Adds iSAQB/CPSA-F and arc42 architecture governance, architecture views, quality scenarios, ADRs, risks, technical-debt evidence, and provider-neutral model routing.",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 14,
|
||||
"templates": 13,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"isaqb",
|
||||
"cpsa-f",
|
||||
"arc42",
|
||||
"model-routing"
|
||||
"adr",
|
||||
"quality-attributes",
|
||||
"architecture-views",
|
||||
"technical-debt"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-08-11T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"jira": {
|
||||
"name": "Jira Issue Tracking",
|
||||
@@ -517,35 +541,6 @@
|
||||
"created_at": "2026-05-08T00:00:00Z",
|
||||
"updated_at": "2026-05-08T00:00:00Z"
|
||||
},
|
||||
"model-routing-governance": {
|
||||
"name": "Model Routing Governance",
|
||||
"id": "model-routing-governance",
|
||||
"version": "0.1.4",
|
||||
"description": "Maps provider-neutral Spec Kit roles to validated harness-local runner profiles without storing model availability, credentials, or machine-specific selections in Git.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-model-routing-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-model-routing-governance/archive/refs/tags/v0.1.4.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-model-routing-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-model-routing-governance/blob/v0.1.4/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 4,
|
||||
"commands": 2,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"model-routing",
|
||||
"agents",
|
||||
"governance",
|
||||
"provider-neutral",
|
||||
"cross-platform"
|
||||
],
|
||||
"created_at": "2026-08-10T00:00:00Z",
|
||||
"updated_at": "2026-08-10T00:00:00Z"
|
||||
},
|
||||
"multi-repo-branching": {
|
||||
"name": "Multi-Repo Branching",
|
||||
"id": "multi-repo-branching",
|
||||
@@ -585,7 +580,7 @@
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.4/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 9,
|
||||
@@ -668,30 +663,52 @@
|
||||
"security-governance": {
|
||||
"name": "Security Governance",
|
||||
"id": "security-governance",
|
||||
"version": "0.6.2",
|
||||
"description": "Adds memory-safe-language and secure-coding governance, exact-head security evidence, ASVS, supply-chain transparency, EU regulatory screening, and provider-neutral model routing.",
|
||||
"version": "0.6.1",
|
||||
"description": "Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 15,
|
||||
"templates": 14,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"governance",
|
||||
"secure-coding",
|
||||
"msl",
|
||||
"ssdf",
|
||||
"asvs",
|
||||
"supply-chain",
|
||||
"model-routing"
|
||||
"sbom",
|
||||
"ai-sbom",
|
||||
"vex",
|
||||
"slsa",
|
||||
"cwe-top-25",
|
||||
"secure-coding",
|
||||
"rust",
|
||||
"go",
|
||||
"swift",
|
||||
"java",
|
||||
"kotlin",
|
||||
"python",
|
||||
"typescript",
|
||||
"g7",
|
||||
"bsi",
|
||||
"cra",
|
||||
"cyber-resilience-act",
|
||||
"nis2",
|
||||
"ai-act",
|
||||
"dora",
|
||||
"regulatory"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-08-10T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"sicario-core": {
|
||||
"name": "SicarioSpec Core",
|
||||
@@ -747,33 +764,6 @@
|
||||
"created_at": "2026-04-30T00:00:00Z",
|
||||
"updated_at": "2026-04-30T00:00:00Z"
|
||||
},
|
||||
"specassay": {
|
||||
"name": "SpecAssay",
|
||||
"id": "specassay",
|
||||
"version": "0.3.4",
|
||||
"description": "Appends durable-ID, Carries, and SpecAssay vocabulary onto Spec Kit spec, tasks, and constitution templates.",
|
||||
"author": "Rik Dryfoos",
|
||||
"repository": "https://github.com/rdryfoos/specassay",
|
||||
"download_url": "https://github.com/rdryfoos/specassay/releases/download/v0.3.4/specassay-preset-0.3.4.zip",
|
||||
"homepage": "https://github.com/rdryfoos/specassay",
|
||||
"documentation": "https://github.com/rdryfoos/specassay/blob/main/presets/specassay/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.14.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 3,
|
||||
"commands": 0
|
||||
},
|
||||
"tags": [
|
||||
"traceability",
|
||||
"durable-ids",
|
||||
"governance",
|
||||
"sdd"
|
||||
],
|
||||
"created_at": "2026-08-14T00:00:00Z",
|
||||
"updated_at": "2026-08-14T00:00:00Z"
|
||||
},
|
||||
"test-first-governance": {
|
||||
"name": "Test-First Governance",
|
||||
"id": "test-first-governance",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-08-04T00:00:00Z",
|
||||
"updated_at": "2026-04-24T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.json",
|
||||
"presets": {
|
||||
"lean": {
|
||||
@@ -30,7 +30,7 @@
|
||||
"name": "Constitution Template Sync",
|
||||
"id": "constitution-sync",
|
||||
"version": "1.0.0",
|
||||
"description": "Opt-in: restores guarded install-time constitution seeding and /constitution propagation for teams that treat materialized templates as reviewed artifacts.",
|
||||
"description": "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts.",
|
||||
"author": "github",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,15 +1,13 @@
|
||||
# Constitution Template Sync
|
||||
|
||||
An **opt-in** preset that restores materialized constitution workflows. It refreshes an unchanged
|
||||
generated `.specify/memory/constitution.md` when constitution-providing presets are installed,
|
||||
removed, enabled, disabled, or reprioritized. After `/constitution` updates the live file, it also
|
||||
aligns `plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and
|
||||
An **opt-in** preset that restores `/constitution`'s ability to propagate amended guidance into your
|
||||
project's own templates and command files. After you update the constitution, it aligns
|
||||
`plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and
|
||||
guidance docs so they reflect the current principles.
|
||||
|
||||
This propagation used to be built into `/constitution`; it was dropped when the command moved to the
|
||||
preset model. Installing this preset opts you back into materialization: preset stack changes refresh
|
||||
the generated constitution, and `/constitution` propagates its guidance into reviewed, committed
|
||||
artifacts instead of relying on runtime resolution alone.
|
||||
preset model. Installing this preset opts you back into it: you get the guidance materialized into
|
||||
reviewed, committed artifacts instead of relying on runtime resolution alone.
|
||||
|
||||
> **What you're opting into.** Propagation was removed deliberately — it duplicates the constitution
|
||||
> as the source of truth and can fight the composition stack (materialized edits get shadowed or
|
||||
@@ -30,12 +28,7 @@ versioned preset a core team maintains.
|
||||
|
||||
## What it does
|
||||
|
||||
Its presence enables core's guarded install-time constitution reconciliation. Installing the preset
|
||||
materializes the currently resolved `constitution-template`; later stack changes re-materialize it
|
||||
only while the live file still matches its recorded generated-content hash. Human edits disable
|
||||
automatic replacement.
|
||||
|
||||
It also ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the
|
||||
Ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the
|
||||
current core command (via `{CORE_TEMPLATE}`), so it stays forward-compatible with core changes, and
|
||||
appends a propagation pass that, after the constitution is written:
|
||||
|
||||
@@ -50,8 +43,6 @@ appends a propagation pass that, after the constitution is written:
|
||||
- It does **not** disable runtime resolution. `plan`, `tasks`, and `analyze` still read the live
|
||||
constitution every run; this preset adds materialized copies on top — it does not replace the
|
||||
source of truth.
|
||||
- It does **not** overwrite an authored or edited constitution. Install-time reconciliation only
|
||||
replaces content whose provenance proves it is an unchanged generated file.
|
||||
- It does **not** edit versioned, package-owned files — templates or command files provided or
|
||||
wrapped by another preset or extension. Those are recomposed from the resolution stack, so it
|
||||
only ever writes into your project's own `.specify/templates/` scaffolds and command files that
|
||||
|
||||
@@ -4,7 +4,7 @@ preset:
|
||||
id: "constitution-sync"
|
||||
name: "Constitution Template Sync"
|
||||
version: "1.0.0"
|
||||
description: "Opt-in: restores guarded install-time constitution seeding and /constitution propagation for teams that treat materialized templates as reviewed artifacts."
|
||||
description: "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts."
|
||||
author: "github"
|
||||
repository: "https://github.com/github/spec-kit"
|
||||
license: "MIT"
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.16.5.dev0"
|
||||
version = "0.15.1"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
# --require-tasks Require tasks.md to exist (for implementation phase)
|
||||
# --include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
# --paths-only Only output path variables (no validation)
|
||||
# --template NAME Include composed template content in JSON output
|
||||
# --help, -h Show help message
|
||||
#
|
||||
# OUTPUTS:
|
||||
@@ -27,10 +26,9 @@ JSON_MODE=false
|
||||
REQUIRE_TASKS=false
|
||||
INCLUDE_TASKS=false
|
||||
PATHS_ONLY=false
|
||||
TEMPLATE_NAME=""
|
||||
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
@@ -43,14 +41,6 @@ while [[ $# -gt 0 ]]; do
|
||||
--paths-only)
|
||||
PATHS_ONLY=true
|
||||
;;
|
||||
--template)
|
||||
shift
|
||||
if [[ $# -eq 0 ]]; then
|
||||
echo "ERROR: --template requires a template name" >&2
|
||||
exit 1
|
||||
fi
|
||||
TEMPLATE_NAME="$1"
|
||||
;;
|
||||
--help|-h)
|
||||
cat << 'EOF'
|
||||
Usage: check-prerequisites.sh [OPTIONS]
|
||||
@@ -62,7 +52,6 @@ OPTIONS:
|
||||
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
--paths-only Only output path variables (no prerequisite validation)
|
||||
--template NAME Include composed template content in JSON output
|
||||
--help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
@@ -79,11 +68,10 @@ EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown option '$1'. Use --help for usage information." >&2
|
||||
echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Source common functions
|
||||
@@ -168,16 +156,6 @@ if $INCLUDE_TASKS && [[ -f "$TASKS" ]]; then
|
||||
docs+=("tasks.md")
|
||||
fi
|
||||
|
||||
TEMPLATE_CONTENT=""
|
||||
if [[ -n "$TEMPLATE_NAME" ]]; then
|
||||
if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
|
||||
TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}"
|
||||
else
|
||||
echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Output results
|
||||
if $JSON_MODE; then
|
||||
# Build JSON array of documents
|
||||
@@ -187,18 +165,10 @@ if $JSON_MODE; then
|
||||
else
|
||||
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||
fi
|
||||
if [[ -n "$TEMPLATE_NAME" ]]; then
|
||||
jq -cn \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
--arg template_content "$TEMPLATE_CONTENT" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TEMPLATE_CONTENT:$template_content}'
|
||||
else
|
||||
jq -cn \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
|
||||
fi
|
||||
jq -cn \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
|
||||
else
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
@@ -206,12 +176,7 @@ if $JSON_MODE; then
|
||||
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||
json_docs="[${json_docs%,}]"
|
||||
fi
|
||||
if [[ -n "$TEMPLATE_NAME" ]]; then
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TEMPLATE_CONTENT":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "$TEMPLATE_CONTENT")"
|
||||
else
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
||||
fi
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
||||
fi
|
||||
else
|
||||
# Text output
|
||||
|
||||
+106
-320
@@ -398,101 +398,6 @@ json_escape() {
|
||||
check_file() { [[ -f "$1" ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
check_dir() { [[ -d "$1" && -n $(ls -A "$1" 2>/dev/null) ]] && echo " ✓ $2" || echo " ✗ $2"; }
|
||||
|
||||
_python3_command() {
|
||||
if command -v python3 >/dev/null 2>&1 &&
|
||||
python3 -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then
|
||||
printf '%s\n' "python3"
|
||||
elif command -v python >/dev/null 2>&1 &&
|
||||
python -c 'import sys; raise SystemExit(sys.version_info.major != 3)' >/dev/null 2>&1; then
|
||||
printf '%s\n' "python"
|
||||
elif command -v py >/dev/null 2>&1 &&
|
||||
py -3 -c 'import sys' >/dev/null 2>&1; then
|
||||
printf '%s\n' "py -3"
|
||||
else
|
||||
return 1
|
||||
fi
|
||||
}
|
||||
|
||||
_sorted_extension_ids() {
|
||||
local ext_dir="$1"
|
||||
local python_spec
|
||||
if python_spec=$(_python3_command); then
|
||||
local -a python_cmd
|
||||
read -r -a python_cmd <<< "$python_spec"
|
||||
local py_stderr sorted_ids
|
||||
py_stderr=$(mktemp)
|
||||
if sorted_ids=$(SPECKIT_EXTENSIONS="$ext_dir" "${python_cmd[@]}" -c "
|
||||
import json, os, re, sys
|
||||
from pathlib import Path
|
||||
|
||||
root = Path(os.environ['SPECKIT_EXTENSIONS'])
|
||||
registered = {}
|
||||
registry = root / '.registry'
|
||||
if os.path.lexists(registry):
|
||||
if not registry.is_file():
|
||||
print('registry_invalid: not a regular file', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
try:
|
||||
data = json.loads(registry.read_text(encoding='utf-8'))
|
||||
except Exception as exc:
|
||||
print('registry_invalid: ' + str(exc), file=sys.stderr)
|
||||
sys.exit(1)
|
||||
if not isinstance(data, dict):
|
||||
print('registry_invalid: root must be a mapping', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
raw_extensions = data.get('extensions', {})
|
||||
if not isinstance(raw_extensions, dict):
|
||||
print('registry_invalid: extensions must be a mapping', file=sys.stderr)
|
||||
sys.exit(1)
|
||||
registered = raw_extensions
|
||||
|
||||
def priority(value):
|
||||
if isinstance(value, bool):
|
||||
return 10
|
||||
try:
|
||||
parsed = int(value)
|
||||
return parsed if parsed >= 1 else 10
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 10
|
||||
|
||||
ranked = []
|
||||
for ext_id, meta in registered.items():
|
||||
if isinstance(ext_id, str) and re.fullmatch(r'[a-z0-9-]+', ext_id) and isinstance(meta, dict) and bool(meta.get('enabled', True)):
|
||||
ranked.append((priority(meta.get('priority')), ext_id))
|
||||
for path in root.iterdir():
|
||||
if path.is_dir() and re.fullmatch(r'[a-z0-9-]+', path.name) and path.name not in registered:
|
||||
ranked.append((10, path.name))
|
||||
for _, ext_id in sorted(ranked):
|
||||
print(ext_id)
|
||||
" 2>"$py_stderr"); then
|
||||
rm -f "$py_stderr"
|
||||
printf '%s\n' "$sorted_ids"
|
||||
return 0
|
||||
else
|
||||
echo "Error: invalid extension registry $ext_dir/.registry" >&2
|
||||
rm -f "$py_stderr"
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ -e "$ext_dir/.registry" ] || [ -L "$ext_dir/.registry" ]; then
|
||||
if [ ! -f "$ext_dir/.registry" ] || [ ! -r "$ext_dir/.registry" ]; then
|
||||
echo "Error: invalid extension registry $ext_dir/.registry" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "Error: Python 3 is required to honor the extension registry" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
local ext extension_id
|
||||
for ext in "$ext_dir"/*/; do
|
||||
[ -d "$ext" ] || continue
|
||||
extension_id=$(basename "$ext")
|
||||
case "$extension_id" in *[!a-z0-9-]*) continue ;; esac
|
||||
printf '%s\n' "$extension_id"
|
||||
done
|
||||
}
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||
@@ -503,8 +408,6 @@ resolve_template() {
|
||||
local repo_root="$2"
|
||||
local base="$repo_root/.specify/templates"
|
||||
|
||||
case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac
|
||||
|
||||
# Priority 1: Project overrides
|
||||
local override="$base/overrides/${template_name}.md"
|
||||
[ -f "$override" ] && echo "$override" && return 0
|
||||
@@ -513,32 +416,19 @@ resolve_template() {
|
||||
local presets_dir="$repo_root/.specify/presets"
|
||||
if [ -d "$presets_dir" ]; then
|
||||
local registry_file="$presets_dir/.registry"
|
||||
local python_spec=""
|
||||
local -a python_cmd=()
|
||||
if python_spec=$(_python3_command); then
|
||||
read -r -a python_cmd <<< "$python_spec"
|
||||
fi
|
||||
if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then
|
||||
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||
# Read preset IDs sorted by priority (lower number = higher precedence).
|
||||
# The python3 call is wrapped in an if-condition so that set -e does not
|
||||
# abort the function when python3 exits non-zero (e.g. invalid JSON).
|
||||
local sorted_presets=""
|
||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c "
|
||||
import json, re, sys, os
|
||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||
import json, sys, os
|
||||
try:
|
||||
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
|
||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||
data = json.load(f)
|
||||
presets = data.get('presets', {})
|
||||
def priority(meta):
|
||||
if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool):
|
||||
return 10
|
||||
try:
|
||||
value = int(meta.get('priority', 10))
|
||||
return value if value >= 1 else 10
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 10
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])):
|
||||
if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid):
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
|
||||
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
@@ -548,8 +438,6 @@ except Exception:
|
||||
while IFS= read -r preset_id; do
|
||||
local candidate="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
candidate="$presets_dir/$preset_id/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done <<< "$sorted_presets"
|
||||
fi
|
||||
# python3 succeeded but registry has no presets — nothing to search
|
||||
@@ -559,8 +447,6 @@ except Exception:
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
candidate="$preset/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
else
|
||||
@@ -569,8 +455,6 @@ except Exception:
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
candidate="$preset/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
fi
|
||||
fi
|
||||
@@ -578,17 +462,13 @@ except Exception:
|
||||
# Priority 3: Extension-provided templates
|
||||
local ext_dir="$repo_root/.specify/extensions"
|
||||
if [ -d "$ext_dir" ]; then
|
||||
local sorted_extensions=""
|
||||
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
|
||||
return 2
|
||||
fi
|
||||
while IFS= read -r extension_id; do
|
||||
[ -n "$extension_id" ] || continue
|
||||
local ext="$ext_dir/$extension_id"
|
||||
for ext in "$ext_dir"/*/; do
|
||||
[ -d "$ext" ] || continue
|
||||
# Skip hidden directories (e.g. .backup, .cache)
|
||||
case "$(basename "$ext")" in .*) continue;; esac
|
||||
local candidate="$ext/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done <<< "$sorted_extensions"
|
||||
done
|
||||
fi
|
||||
|
||||
# Priority 4: Core templates
|
||||
@@ -612,8 +492,6 @@ resolve_template_content() {
|
||||
local repo_root="$2"
|
||||
local base="$repo_root/.specify/templates"
|
||||
|
||||
case "$template_name" in ""|*[!a-z0-9-]*) return 1 ;; esac
|
||||
|
||||
# Collect all layers (highest priority first)
|
||||
local -a layer_paths=()
|
||||
local -a layer_strategies=()
|
||||
@@ -621,206 +499,133 @@ resolve_template_content() {
|
||||
# Priority 1: Project overrides (always "replace")
|
||||
local override="$base/overrides/${template_name}.md"
|
||||
if [ -f "$override" ]; then
|
||||
if ! cat "$override"; then
|
||||
echo "Error: failed to read template layer $override" >&2
|
||||
return 2
|
||||
fi
|
||||
return 0
|
||||
layer_paths+=("$override")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
|
||||
local effective_base_found=false
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
local presets_dir="$repo_root/.specify/presets"
|
||||
if [ -d "$presets_dir" ]; then
|
||||
local registry_file="$presets_dir/.registry"
|
||||
local sorted_presets=""
|
||||
local registry_parsed=false
|
||||
local python_spec=""
|
||||
local -a python_cmd=()
|
||||
if python_spec=$(_python3_command); then
|
||||
read -r -a python_cmd <<< "$python_spec"
|
||||
fi
|
||||
if [ -f "$registry_file" ] && [ "${#python_cmd[@]}" -gt 0 ]; then
|
||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c "
|
||||
import json, re, sys, os
|
||||
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" python3 -c "
|
||||
import json, sys, os
|
||||
try:
|
||||
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
|
||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||
data = json.load(f)
|
||||
presets = data.get('presets', {})
|
||||
def priority(meta):
|
||||
if not isinstance(meta, dict) or isinstance(meta.get('priority'), bool):
|
||||
return 10
|
||||
try:
|
||||
value = int(meta.get('priority', 10))
|
||||
return value if value >= 1 else 10
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 10
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: (priority(x[1]), x[0])):
|
||||
if isinstance(meta, dict) and bool(meta.get('enabled', True)) and re.fullmatch(r'[a-z0-9-]+', pid):
|
||||
for pid, meta in sorted(presets.items(), key=lambda x: x[1].get('priority', 10) if isinstance(x[1], dict) else 10):
|
||||
if isinstance(meta, dict) and meta.get('enabled', True) is not False:
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null); then
|
||||
registry_parsed=true
|
||||
fi
|
||||
fi
|
||||
if [ "$registry_parsed" = false ]; then
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local fallback_id
|
||||
fallback_id=$(basename "$preset")
|
||||
case "$fallback_id" in *[!a-z0-9-]*) continue ;; esac
|
||||
sorted_presets+="${sorted_presets:+$'\n'}$fallback_id"
|
||||
done
|
||||
fi
|
||||
|
||||
if [ -n "$sorted_presets" ]; then
|
||||
while IFS= read -r preset_id; do
|
||||
local strategy="replace"
|
||||
local manifest_file=""
|
||||
local manifest="$presets_dir/$preset_id/preset.yml"
|
||||
local manifest_declared=false
|
||||
if [ -f "$manifest" ]; then
|
||||
if [ "${#python_cmd[@]}" -eq 0 ]; then
|
||||
echo "Error: Python 3 and PyYAML are required to resolve preset template composition" >&2
|
||||
return 2
|
||||
fi
|
||||
local result
|
||||
local py_stderr
|
||||
local parse_status
|
||||
py_stderr=$(mktemp)
|
||||
if result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" "${python_cmd[@]}" -c "
|
||||
if [ -n "$sorted_presets" ]; then
|
||||
local yaml_warned=false
|
||||
while IFS= read -r preset_id; do
|
||||
# Read strategy and file path from preset manifest
|
||||
local strategy="replace"
|
||||
local manifest_file=""
|
||||
local manifest="$presets_dir/$preset_id/preset.yml"
|
||||
if [ -f "$manifest" ] && command -v python3 >/dev/null 2>&1; then
|
||||
# Requires PyYAML; falls back to replace/convention if unavailable
|
||||
local result
|
||||
local py_stderr
|
||||
py_stderr=$(mktemp)
|
||||
result=$(SPECKIT_MANIFEST="$manifest" SPECKIT_TMPL="$template_name" python3 -c "
|
||||
import sys, os
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print('yaml_missing', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print('replace\t')
|
||||
sys.exit(0)
|
||||
try:
|
||||
with open(os.environ['SPECKIT_MANIFEST'], encoding='utf-8') as f:
|
||||
with open(os.environ['SPECKIT_MANIFEST']) as f:
|
||||
data = yaml.safe_load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('manifest root must be a mapping')
|
||||
if 'provides' not in data:
|
||||
raise ValueError('manifest missing provides section')
|
||||
provides = data['provides']
|
||||
if not isinstance(provides, dict):
|
||||
raise ValueError('manifest provides must be a mapping')
|
||||
if 'templates' not in provides:
|
||||
raise ValueError('manifest provides missing templates')
|
||||
templates = provides['templates']
|
||||
if not isinstance(templates, list):
|
||||
raise ValueError('manifest templates must be a list')
|
||||
if not templates:
|
||||
raise ValueError('manifest must provide at least one template')
|
||||
valid_types = ('template', 'command', 'script')
|
||||
valid_strategies = ('replace', 'prepend', 'append', 'wrap')
|
||||
for t in templates:
|
||||
if not isinstance(t, dict):
|
||||
raise ValueError('manifest template entries must be mappings')
|
||||
if 'type' not in t or 'name' not in t or 'file' not in t:
|
||||
raise ValueError('manifest template entry missing type, name, or file')
|
||||
for field in ('type', 'name', 'file'):
|
||||
if not isinstance(t[field], str):
|
||||
raise ValueError('manifest template ' + field + ' must be a string')
|
||||
if t['type'] not in valid_types:
|
||||
raise ValueError('invalid manifest template type')
|
||||
strategy = t.get('strategy', 'replace')
|
||||
if not isinstance(strategy, str):
|
||||
raise ValueError('manifest template strategy must be a string')
|
||||
strategy = strategy.lower()
|
||||
if strategy not in valid_strategies:
|
||||
raise ValueError('invalid manifest template strategy')
|
||||
if t['type'] == 'script' and strategy not in ('replace', 'wrap'):
|
||||
raise ValueError('invalid manifest script strategy')
|
||||
for t in templates:
|
||||
for t in data.get('provides', {}).get('templates', []):
|
||||
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
|
||||
file_value = t.get('file', '')
|
||||
strategy = t.get('strategy', 'replace')
|
||||
print('found\t' + strategy + '\t' + file_value)
|
||||
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
||||
sys.exit(0)
|
||||
print('absent\treplace\t')
|
||||
except Exception as exc:
|
||||
print(f'manifest_invalid: {exc}', file=sys.stderr)
|
||||
sys.exit(3)
|
||||
" 2>"$py_stderr"); then
|
||||
parse_status=0
|
||||
else
|
||||
parse_status=$?
|
||||
fi
|
||||
if [ "$parse_status" -ne 0 ]; then
|
||||
if [ "$parse_status" -eq 2 ]; then
|
||||
echo "Error: PyYAML is required to resolve preset template composition" >&2
|
||||
else
|
||||
echo "Error: invalid preset manifest $manifest" >&2
|
||||
print('replace\t')
|
||||
except Exception:
|
||||
print('replace\t')
|
||||
" 2>"$py_stderr")
|
||||
local parse_status=$?
|
||||
if [ $parse_status -eq 0 ] && [ -n "$result" ]; then
|
||||
IFS=$'\t' read -r strategy manifest_file <<< "$result"
|
||||
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
|
||||
fi
|
||||
if [ "$yaml_warned" = false ] && grep -q 'yaml_missing' "$py_stderr" 2>/dev/null; then
|
||||
echo "Warning: PyYAML not available; composition strategies may be ignored" >&2
|
||||
yaml_warned=true
|
||||
fi
|
||||
rm -f "$py_stderr"
|
||||
fi
|
||||
rm -f "$py_stderr"
|
||||
return 2
|
||||
# Try manifest file path first, then convention path
|
||||
local candidate=""
|
||||
if [ -n "$manifest_file" ]; then
|
||||
# Reject absolute paths and parent traversal
|
||||
case "$manifest_file" in
|
||||
/*|*../*|../*) manifest_file="" ;;
|
||||
esac
|
||||
fi
|
||||
if [ -n "$manifest_file" ]; then
|
||||
local mf="$presets_dir/$preset_id/$manifest_file"
|
||||
[ -f "$mf" ] && candidate="$mf"
|
||||
fi
|
||||
if [ -z "$candidate" ]; then
|
||||
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||
[ -f "$cf" ] && candidate="$cf"
|
||||
fi
|
||||
if [ -n "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("$strategy")
|
||||
fi
|
||||
done <<< "$sorted_presets"
|
||||
fi
|
||||
else
|
||||
# python3 failed — fall back to unordered directory scan (replace only)
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
if [ -f "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
if [ -n "$result" ]; then
|
||||
local declaration
|
||||
IFS=$'\t' read -r declaration strategy manifest_file <<< "$result"
|
||||
[ "$declaration" = "found" ] && manifest_declared=true
|
||||
strategy=$(printf '%s' "$strategy" | tr '[:upper:]' '[:lower:]')
|
||||
fi
|
||||
rm -f "$py_stderr"
|
||||
fi
|
||||
|
||||
local candidate=""
|
||||
if [ -n "$manifest_file" ]; then
|
||||
case "$manifest_file" in
|
||||
/*|*../*|../*) manifest_file="" ;;
|
||||
esac
|
||||
fi
|
||||
if [ -n "$manifest_file" ]; then
|
||||
local mf="$presets_dir/$preset_id/$manifest_file"
|
||||
[ -f "$mf" ] && candidate="$mf"
|
||||
fi
|
||||
if [ -z "$candidate" ] && [ "$manifest_declared" = false ]; then
|
||||
local cf="$presets_dir/$preset_id/templates/${template_name}.md"
|
||||
[ -f "$cf" ] && candidate="$cf"
|
||||
if [ -z "$candidate" ]; then
|
||||
cf="$presets_dir/$preset_id/${template_name}.md"
|
||||
[ -f "$cf" ] && candidate="$cf"
|
||||
fi
|
||||
fi
|
||||
if [ -n "$candidate" ]; then
|
||||
done
|
||||
fi
|
||||
else
|
||||
# No python3 or registry — fall back to unordered directory scan (replace only)
|
||||
for preset in "$presets_dir"/*/; do
|
||||
[ -d "$preset" ] || continue
|
||||
local candidate="$preset/templates/${template_name}.md"
|
||||
if [ -f "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("$strategy")
|
||||
if [ "$strategy" = "replace" ]; then
|
||||
effective_base_found=true
|
||||
break
|
||||
fi
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
done <<< "$sorted_presets"
|
||||
done
|
||||
fi
|
||||
fi
|
||||
|
||||
# Priority 3: Extension-provided templates (always "replace")
|
||||
local ext_dir="$repo_root/.specify/extensions"
|
||||
if [ "$effective_base_found" = false ] && [ -d "$ext_dir" ]; then
|
||||
local sorted_extensions=""
|
||||
if ! sorted_extensions=$(_sorted_extension_ids "$ext_dir"); then
|
||||
return 2
|
||||
fi
|
||||
while IFS= read -r extension_id; do
|
||||
[ -n "$extension_id" ] || continue
|
||||
local ext="$ext_dir/$extension_id"
|
||||
if [ -d "$ext_dir" ]; then
|
||||
for ext in "$ext_dir"/*/; do
|
||||
[ -d "$ext" ] || continue
|
||||
case "$(basename "$ext")" in .*) continue;; esac
|
||||
local candidate="$ext/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
|
||||
if [ -f "$candidate" ]; then
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("replace")
|
||||
effective_base_found=true
|
||||
break
|
||||
fi
|
||||
done <<< "$sorted_extensions"
|
||||
done
|
||||
fi
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
local core="$base/${template_name}.md"
|
||||
if [ "$effective_base_found" = false ] && [ -f "$core" ]; then
|
||||
if [ -f "$core" ]; then
|
||||
layer_paths+=("$core")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
@@ -837,18 +642,12 @@ except Exception as exc:
|
||||
# If the top (highest-priority) layer is replace, it wins entirely —
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if [ "${layer_strategies[0]}" = "replace" ]; then
|
||||
if ! cat "${layer_paths[0]}"; then
|
||||
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
|
||||
return 2
|
||||
fi
|
||||
cat "${layer_paths[0]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$has_composition" = false ]; then
|
||||
if ! cat "${layer_paths[0]}"; then
|
||||
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
|
||||
return 2
|
||||
fi
|
||||
cat "${layer_paths[0]}"
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -864,16 +663,12 @@ except Exception as exc:
|
||||
done
|
||||
|
||||
if [ $base_idx -lt 0 ]; then
|
||||
echo "Error: template '$template_name' has composing layers but no replace base" >&2
|
||||
return 2
|
||||
return 1 # no base layer found
|
||||
fi
|
||||
|
||||
# Read the base content; compose layers above the base (higher priority)
|
||||
local content
|
||||
if ! content=$(cat "${layer_paths[$base_idx]}"; status=$?; printf x; exit "$status"); then
|
||||
echo "Error: failed to read template layer ${layer_paths[$base_idx]}" >&2
|
||||
return 2
|
||||
fi
|
||||
content=$(cat "${layer_paths[$base_idx]}"; printf x)
|
||||
content="${content%x}"
|
||||
|
||||
for (( i=base_idx-1; i>=0; i-- )); do
|
||||
@@ -881,26 +676,17 @@ except Exception as exc:
|
||||
local strat="${layer_strategies[$i]}"
|
||||
local layer_content
|
||||
# Preserve trailing newlines
|
||||
if ! layer_content=$(cat "$path"; status=$?; printf x; exit "$status"); then
|
||||
echo "Error: failed to read template layer $path" >&2
|
||||
return 2
|
||||
fi
|
||||
layer_content=$(cat "$path"; printf x)
|
||||
layer_content="${layer_content%x}"
|
||||
|
||||
case "$strat" in
|
||||
replace) content="$layer_content" ;;
|
||||
prepend)
|
||||
content=$(printf '%s\n\n%s' "$layer_content" "$content"; printf x)
|
||||
content="${content%x}"
|
||||
;;
|
||||
append)
|
||||
content=$(printf '%s\n\n%s' "$content" "$layer_content"; printf x)
|
||||
content="${content%x}"
|
||||
;;
|
||||
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
|
||||
append) content="$(printf '%s\n\n%s' "$content" "$layer_content")" ;;
|
||||
wrap)
|
||||
case "$layer_content" in
|
||||
*'{CORE_TEMPLATE}'*) ;;
|
||||
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;;
|
||||
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
|
||||
esac
|
||||
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
|
||||
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
|
||||
@@ -909,7 +695,7 @@ except Exception as exc:
|
||||
done
|
||||
content="$layer_content"
|
||||
;;
|
||||
*) echo "Error: unknown strategy '$strat'" >&2; return 2 ;;
|
||||
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
@@ -339,27 +339,12 @@ if [ "$DRY_RUN" != true ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
NEEDS_SPEC=false
|
||||
SPEC_TEMPLATE_FOUND=false
|
||||
SPEC_TEMPLATE_CONTENT=""
|
||||
if [ ! -f "$SPEC_FILE" ]; then
|
||||
NEEDS_SPEC=true
|
||||
if SPEC_TEMPLATE_CONTENT=$(resolve_template_content "spec-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
|
||||
SPEC_TEMPLATE_CONTENT="${SPEC_TEMPLATE_CONTENT%x}"
|
||||
SPEC_TEMPLATE_FOUND=true
|
||||
else
|
||||
resolve_status=$?
|
||||
if [ "$resolve_status" -ne 1 ]; then
|
||||
exit "$resolve_status"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
mkdir -p "$FEATURE_DIR"
|
||||
|
||||
if [ "$NEEDS_SPEC" = true ]; then
|
||||
if [ "$SPEC_TEMPLATE_FOUND" = true ]; then
|
||||
printf '%s' "$SPEC_TEMPLATE_CONTENT" > "$SPEC_FILE"
|
||||
if [ ! -f "$SPEC_FILE" ]; then
|
||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
||||
cp "$TEMPLATE" "$SPEC_FILE"
|
||||
else
|
||||
echo "Warning: Spec template not found; created empty spec file" >&2
|
||||
touch "$SPEC_FILE"
|
||||
|
||||
@@ -1,57 +0,0 @@
|
||||
#!/usr/bin/env bash
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(CDPATH="" cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
|
||||
JSON_MODE=false
|
||||
TEMPLATE_NAME=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--json) JSON_MODE=true ;;
|
||||
--help|-h)
|
||||
echo "Usage: $0 <template-name> [--json]"
|
||||
exit 0
|
||||
;;
|
||||
-*)
|
||||
echo "ERROR: Unknown option '$arg'" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
if [[ -n "$TEMPLATE_NAME" ]]; then
|
||||
echo "ERROR: Unexpected argument '$arg'" >&2
|
||||
exit 1
|
||||
fi
|
||||
TEMPLATE_NAME="$arg"
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [[ -z "$TEMPLATE_NAME" ]]; then
|
||||
echo "ERROR: Template name is required" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
REPO_ROOT=$(get_repo_root)
|
||||
if TEMPLATE_CONTENT=$(resolve_template_content "$TEMPLATE_NAME" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
|
||||
TEMPLATE_CONTENT="${TEMPLATE_CONTENT%x}"
|
||||
else
|
||||
echo "ERROR: Could not resolve required $TEMPLATE_NAME from the template override stack for $REPO_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if $JSON_MODE; then
|
||||
if has_jq; then
|
||||
jq -cn \
|
||||
--arg template_name "$TEMPLATE_NAME" \
|
||||
--arg template_content "$TEMPLATE_CONTENT" \
|
||||
'{TEMPLATE_NAME:$template_name,TEMPLATE_CONTENT:$template_content}'
|
||||
else
|
||||
printf '{"TEMPLATE_NAME":"%s","TEMPLATE_CONTENT":"%s"}\n' \
|
||||
"$(json_escape "$TEMPLATE_NAME")" "$(json_escape "$TEMPLATE_CONTENT")"
|
||||
fi
|
||||
else
|
||||
printf '%s' "$TEMPLATE_CONTENT"
|
||||
fi
|
||||
@@ -43,23 +43,21 @@ if [[ -f "$IMPL_PLAN" ]]; then
|
||||
echo "Plan already exists at $IMPL_PLAN, skipping template copy"
|
||||
fi
|
||||
else
|
||||
if resolve_template_content "plan-template" "$REPO_ROOT" > "$IMPL_PLAN"; then
|
||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
|
||||
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||
if $JSON_MODE; then
|
||||
echo "Copied plan template to $IMPL_PLAN" >&2
|
||||
else
|
||||
echo "Copied plan template to $IMPL_PLAN"
|
||||
fi
|
||||
else
|
||||
resolve_status=$?
|
||||
rm -f "$IMPL_PLAN"
|
||||
if [ "$resolve_status" -ne 1 ]; then
|
||||
exit "$resolve_status"
|
||||
fi
|
||||
if $JSON_MODE; then
|
||||
echo "Warning: Plan template not found" >&2
|
||||
else
|
||||
echo "Warning: Plan template not found"
|
||||
fi
|
||||
# Create a basic plan file if template doesn't exist
|
||||
touch "$IMPL_PLAN"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -51,9 +51,7 @@ fi
|
||||
|
||||
# Resolve tasks template through override stack
|
||||
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
|
||||
if TASKS_TEMPLATE_CONTENT=$(resolve_template_content "tasks-template" "$REPO_ROOT"; status=$?; printf x; exit "$status"); then
|
||||
TASKS_TEMPLATE_CONTENT="${TASKS_TEMPLATE_CONTENT%x}"
|
||||
else
|
||||
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
|
||||
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
|
||||
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
|
||||
exit 1
|
||||
@@ -71,8 +69,7 @@ if $JSON_MODE; then
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
--arg tasks_template "${TASKS_TEMPLATE:-}" \
|
||||
--arg tasks_template_content "$TASKS_TEMPLATE_CONTENT" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template,TASKS_TEMPLATE_CONTENT:$tasks_template_content}'
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
|
||||
else
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
@@ -80,8 +77,8 @@ if $JSON_MODE; then
|
||||
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
|
||||
json_docs="[${json_docs%,}]"
|
||||
fi
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s","TASKS_TEMPLATE_CONTENT":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")" "$(json_escape "$TASKS_TEMPLATE_CONTENT")"
|
||||
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
|
||||
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
|
||||
fi
|
||||
else
|
||||
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||
|
||||
@@ -12,7 +12,6 @@
|
||||
# -RequireTasks Require tasks.md to exist (for implementation phase)
|
||||
# -IncludeTasks Include tasks.md in AVAILABLE_DOCS list
|
||||
# -PathsOnly Only output path variables (no validation)
|
||||
# -Template NAME Include composed template content in JSON output
|
||||
# -Help, -h Show help message
|
||||
|
||||
[CmdletBinding()]
|
||||
@@ -21,7 +20,6 @@ param(
|
||||
[switch]$RequireTasks,
|
||||
[switch]$IncludeTasks,
|
||||
[switch]$PathsOnly,
|
||||
[string]$Template,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
@@ -39,7 +37,6 @@ OPTIONS:
|
||||
-RequireTasks Require tasks.md to exist (for implementation phase)
|
||||
-IncludeTasks Include tasks.md in AVAILABLE_DOCS list
|
||||
-PathsOnly Only output path variables (no prerequisite validation)
|
||||
-Template NAME Include composed template content in JSON output
|
||||
-Help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
@@ -132,43 +129,25 @@ if ($IncludeTasks -and (Test-Path $paths.TASKS)) {
|
||||
$docs += 'tasks.md'
|
||||
}
|
||||
|
||||
$templateContent = $null
|
||||
if ($Template) {
|
||||
$templateContent = Resolve-TemplateContent -TemplateName $Template -RepoRoot $paths.REPO_ROOT
|
||||
if ($null -eq $templateContent) {
|
||||
[Console]::Error.WriteLine("ERROR: Could not resolve required $Template from the template override stack for $($paths.REPO_ROOT)")
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
# JSON output
|
||||
$result = [ordered]@{
|
||||
[PSCustomObject]@{
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
AVAILABLE_DOCS = $docs
|
||||
}
|
||||
if ($Template) {
|
||||
$result.TEMPLATE_CONTENT = $templateContent
|
||||
}
|
||||
[PSCustomObject]$result | ConvertTo-Json -Compress
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
# Text output
|
||||
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
|
||||
Write-Output "AVAILABLE_DOCS:"
|
||||
|
||||
# Show status of each potential document.
|
||||
# These helpers report their line with Write-Output and ALSO return a
|
||||
# bool, both on the Success stream, so 'Out-Null' discarded the report
|
||||
# line along with the return value and left AVAILABLE_DOCS empty. Drop
|
||||
# only the boolean so the per-document lines reach stdout like the
|
||||
# bash and Python twins.
|
||||
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Where-Object { $_ -isnot [bool] }
|
||||
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Where-Object { $_ -isnot [bool] }
|
||||
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Where-Object { $_ -isnot [bool] }
|
||||
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Where-Object { $_ -isnot [bool] }
|
||||
# Show status of each potential document
|
||||
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
|
||||
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
|
||||
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
|
||||
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
|
||||
|
||||
if ($IncludeTasks) {
|
||||
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Where-Object { $_ -isnot [bool] }
|
||||
Test-FileExists -Path $paths.TASKS -Description 'tasks.md' | Out-Null
|
||||
}
|
||||
}
|
||||
|
||||
+82
-239
@@ -332,82 +332,6 @@ function Get-Python3Command {
|
||||
return $null
|
||||
}
|
||||
|
||||
function Get-NormalizedPriority {
|
||||
param($Value)
|
||||
|
||||
if ($Value -is [bool]) { return 10 }
|
||||
if ($Value -is [string]) {
|
||||
$integerText = $Value.Trim()
|
||||
if ($integerText -cnotmatch '^[+-]?[0-9]+(?:_[0-9]+)*$') { return 10 }
|
||||
$Value = $integerText.Replace('_', '')
|
||||
}
|
||||
try {
|
||||
$parsedPriority = [System.Numerics.BigInteger]$Value
|
||||
} catch {
|
||||
return 10
|
||||
}
|
||||
return $(if ($parsedPriority -ge 1) { $parsedPriority } else { 10 })
|
||||
}
|
||||
|
||||
function Get-SortedExtensionIds {
|
||||
param([Parameter(Mandatory=$true)][string]$ExtensionsDir)
|
||||
|
||||
$registeredNames = @()
|
||||
$ranked = @()
|
||||
$registryFile = Join-Path $ExtensionsDir '.registry'
|
||||
# Detect any filesystem entry at the registry path without following symlinks.
|
||||
# Test-Path follows links and reports $false for a dangling symlink, so a
|
||||
# broken .registry symlink would otherwise bypass this guard and let the
|
||||
# directory scan below enable every on-disk extension. Enumerating the parent
|
||||
# directory still observes a broken symlink as an entry.
|
||||
$registryEntry = Get-ChildItem -LiteralPath $ExtensionsDir -Force -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -eq '.registry' } |
|
||||
Select-Object -First 1
|
||||
if ($registryEntry) {
|
||||
if (-not (Test-Path -LiteralPath $registryFile -PathType Leaf)) {
|
||||
throw "Invalid extension registry ${registryFile}: not a regular file"
|
||||
}
|
||||
try {
|
||||
$data = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
} catch {
|
||||
throw "Invalid extension registry ${registryFile}: $($_.Exception.Message)"
|
||||
}
|
||||
if ($null -eq $data -or $data -isnot [PSCustomObject]) {
|
||||
throw "Invalid extension registry ${registryFile}: root must be a mapping"
|
||||
}
|
||||
$extensionsProperty = $data.PSObject.Properties['extensions']
|
||||
if ($extensionsProperty) {
|
||||
if ($extensionsProperty.Value -isnot [PSCustomObject]) {
|
||||
throw "Invalid extension registry ${registryFile}: 'extensions' must be a mapping"
|
||||
}
|
||||
$extensions = $extensionsProperty.Value
|
||||
} else {
|
||||
$extensions = [PSCustomObject]@{}
|
||||
}
|
||||
$registeredNames = @($extensions.PSObject.Properties | ForEach-Object { $_.Name })
|
||||
foreach ($entry in $extensions.PSObject.Properties) {
|
||||
if ($entry.Name -cnotmatch '^[a-z0-9-]+$' -or $entry.Value -isnot [PSCustomObject]) {
|
||||
continue
|
||||
}
|
||||
$enabledProperty = $entry.Value.PSObject.Properties['enabled']
|
||||
if ($enabledProperty -and -not [bool]$enabledProperty.Value) { continue }
|
||||
$priority = 10
|
||||
$priorityProperty = $entry.Value.PSObject.Properties['priority']
|
||||
if ($priorityProperty) {
|
||||
$priority = Get-NormalizedPriority -Value $priorityProperty.Value
|
||||
}
|
||||
$ranked += [PSCustomObject]@{ Priority = $priority; Id = $entry.Name }
|
||||
}
|
||||
}
|
||||
|
||||
foreach ($directory in Get-ChildItem -Path $ExtensionsDir -Directory -ErrorAction SilentlyContinue) {
|
||||
if ($directory.Name -cmatch '^[a-z0-9-]+$' -and $directory.Name -cnotin $registeredNames) {
|
||||
$ranked += [PSCustomObject]@{ Priority = 10; Id = $directory.Name }
|
||||
}
|
||||
}
|
||||
return $ranked | Sort-Object Priority, Id | ForEach-Object { $_.Id }
|
||||
}
|
||||
|
||||
# Resolve a template name to a file path using the priority stack:
|
||||
# 1. .specify/templates/overrides/
|
||||
# 2. .specify/presets/<preset-id>/templates/ (sorted by priority from .registry)
|
||||
@@ -419,8 +343,6 @@ function Resolve-Template {
|
||||
[Parameter(Mandatory=$true)][string]$RepoRoot
|
||||
)
|
||||
|
||||
if ($TemplateName -cnotmatch '^[a-z0-9-]+$') { return $null }
|
||||
|
||||
$base = Join-Path $RepoRoot '.specify/templates'
|
||||
|
||||
# Priority 1: Project overrides
|
||||
@@ -435,7 +357,7 @@ function Resolve-Template {
|
||||
$registryParsed = $false
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
|
||||
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
|
||||
throw 'Registry root must be an object'
|
||||
}
|
||||
@@ -450,20 +372,30 @@ function Resolve-Template {
|
||||
param($Entry)
|
||||
if ($Entry.Value -is [PSCustomObject]) {
|
||||
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
|
||||
if ($priorityProperty) {
|
||||
return Get-NormalizedPriority -Value $priorityProperty.Value
|
||||
}
|
||||
if ($priorityProperty) { return $priorityProperty.Value }
|
||||
}
|
||||
return 10
|
||||
}
|
||||
if ($presetEntries.Count -gt 1) {
|
||||
$allNumeric = $true
|
||||
$allStrings = $true
|
||||
foreach ($entry in $presetEntries) {
|
||||
$priority = & $priorityFor $entry
|
||||
if ($null -eq $priority -or $priority -isnot [ValueType]) {
|
||||
$allNumeric = $false
|
||||
}
|
||||
if ($null -eq $priority -or $priority -isnot [string]) {
|
||||
$allStrings = $false
|
||||
}
|
||||
}
|
||||
if (-not $allNumeric -and -not $allStrings) {
|
||||
throw 'Registry priorities are not mutually orderable'
|
||||
}
|
||||
}
|
||||
$sortedPresets = $presetEntries |
|
||||
Where-Object { $_.Value -is [PSCustomObject] } |
|
||||
Where-Object {
|
||||
$enabled = $_.Value.PSObject.Properties['enabled']
|
||||
-not $enabled -or [bool]$enabled.Value
|
||||
} |
|
||||
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
|
||||
Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } |
|
||||
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
|
||||
Sort-Object { & $priorityFor $_ } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
$registryParsed = $true
|
||||
@@ -476,16 +408,12 @@ function Resolve-Template {
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
$candidate = Join-Path $presetsDir "$presetId/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
} else {
|
||||
# Fallback: alphabetical directory order
|
||||
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
$candidate = Join-Path $preset.FullName "$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -493,11 +421,8 @@ function Resolve-Template {
|
||||
# Priority 3: Extension-provided templates
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
if (Test-Path $extDir) {
|
||||
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
|
||||
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
|
||||
if (-not (Test-Path $candidate)) {
|
||||
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
|
||||
}
|
||||
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
@@ -518,10 +443,6 @@ function Resolve-TemplateContent {
|
||||
[Parameter(Mandatory=$true)][string]$RepoRoot
|
||||
)
|
||||
|
||||
if ($TemplateName -cnotmatch '^[a-z0-9-]+$') {
|
||||
return $null
|
||||
}
|
||||
|
||||
$base = Join-Path $RepoRoot '.specify/templates'
|
||||
|
||||
# Collect all layers (highest priority first)
|
||||
@@ -531,77 +452,49 @@ function Resolve-TemplateContent {
|
||||
# Priority 1: Project overrides (always "replace")
|
||||
$override = Join-Path $base "overrides/$TemplateName.md"
|
||||
if (Test-Path $override) {
|
||||
return [System.IO.File]::ReadAllText(
|
||||
$override,
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
$layerPaths += $override
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
|
||||
$effectiveBaseFound = $false
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority from .registry)
|
||||
$presetsDir = Join-Path $RepoRoot '.specify/presets'
|
||||
if (Test-Path $presetsDir) {
|
||||
$registryFile = Join-Path $presetsDir '.registry'
|
||||
$sortedPresets = @()
|
||||
$registryParsed = $false
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = [System.IO.File]::ReadAllText($registryFile, [System.Text.Encoding]::UTF8) | ConvertFrom-Json
|
||||
if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
|
||||
throw 'Registry root must be an object'
|
||||
}
|
||||
$presetsProperty = $registryData.PSObject.Properties['presets']
|
||||
if ($presetsProperty) {
|
||||
$presets = $presetsProperty.Value
|
||||
if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
|
||||
throw 'Registry presets must be an object'
|
||||
}
|
||||
$presetEntries = @($presets.PSObject.Properties)
|
||||
$priorityFor = {
|
||||
param($Entry)
|
||||
if ($Entry.Value -is [PSCustomObject]) {
|
||||
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
|
||||
if ($priorityProperty) {
|
||||
return Get-NormalizedPriority -Value $priorityProperty.Value
|
||||
}
|
||||
}
|
||||
return 10
|
||||
}
|
||||
$sortedPresets = $presetEntries |
|
||||
Where-Object { $_.Value -is [PSCustomObject] } |
|
||||
Where-Object {
|
||||
$enabled = $_.Value.PSObject.Properties['enabled']
|
||||
-not $enabled -or [bool]$enabled.Value
|
||||
} |
|
||||
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
|
||||
Sort-Object @{ Expression = { & $priorityFor $_ } }, @{ Expression = { $_.Name } } |
|
||||
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
|
||||
$presets = $registryData.presets
|
||||
if ($presets) {
|
||||
$sortedPresets = $presets.PSObject.Properties |
|
||||
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
|
||||
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
$registryParsed = $true
|
||||
} catch {
|
||||
$registryParsed = $false
|
||||
$sortedPresets = @()
|
||||
}
|
||||
}
|
||||
|
||||
if (-not $registryParsed) {
|
||||
$sortedPresets = Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -cmatch '^[a-z0-9-]+$' } |
|
||||
Sort-Object Name |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
|
||||
$pyCmd = @(Get-Python3Command)
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
if ($sortedPresets.Count -gt 0) {
|
||||
$pyCmd = Get-Python3Command
|
||||
if (-not $pyCmd) {
|
||||
# Check if any preset has strategy fields that would be ignored
|
||||
foreach ($pid in $sortedPresets) {
|
||||
$mf = Join-Path $presetsDir "$pid/preset.yml"
|
||||
if ((Test-Path $mf) -and (Select-String -Path $mf -Pattern 'strategy:' -Quiet -ErrorAction SilentlyContinue)) {
|
||||
Write-Warning "No Python 3 found; preset composition strategies will be ignored"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
$yamlWarned = $false
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
# Read strategy and file path from preset manifest
|
||||
$strategy = 'replace'
|
||||
$manifestFilePath = ''
|
||||
$manifestDeclared = $false
|
||||
$manifest = Join-Path $presetsDir "$presetId/preset.yml"
|
||||
if ((Test-Path $manifest) -and -not $pyCmd) {
|
||||
throw "Python 3 and PyYAML are required to resolve preset template composition"
|
||||
}
|
||||
if (Test-Path $manifest) {
|
||||
if ((Test-Path $manifest) -and $pyCmd) {
|
||||
try {
|
||||
# Use Python to parse YAML manifest for strategy and file path
|
||||
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
|
||||
@@ -612,71 +505,32 @@ try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print('yaml_missing', file=sys.stderr)
|
||||
sys.exit(2)
|
||||
print('replace\t')
|
||||
sys.exit(0)
|
||||
try:
|
||||
with open(sys.argv[1], encoding='utf-8') as f:
|
||||
with open(sys.argv[1]) as f:
|
||||
data = yaml.safe_load(f)
|
||||
if not isinstance(data, dict):
|
||||
raise ValueError('manifest root must be a mapping')
|
||||
if 'provides' not in data:
|
||||
raise ValueError('manifest missing provides section')
|
||||
provides = data['provides']
|
||||
if not isinstance(provides, dict):
|
||||
raise ValueError('manifest provides must be a mapping')
|
||||
if 'templates' not in provides:
|
||||
raise ValueError('manifest provides missing templates')
|
||||
templates = provides['templates']
|
||||
if not isinstance(templates, list):
|
||||
raise ValueError('manifest templates must be a list')
|
||||
if not templates:
|
||||
raise ValueError('manifest must provide at least one template')
|
||||
valid_types = ('template', 'command', 'script')
|
||||
valid_strategies = ('replace', 'prepend', 'append', 'wrap')
|
||||
for t in templates:
|
||||
if not isinstance(t, dict):
|
||||
raise ValueError('manifest template entries must be mappings')
|
||||
if 'type' not in t or 'name' not in t or 'file' not in t:
|
||||
raise ValueError('manifest template entry missing type, name, or file')
|
||||
for field in ('type', 'name', 'file'):
|
||||
if not isinstance(t[field], str):
|
||||
raise ValueError('manifest template ' + field + ' must be a string')
|
||||
if t['type'] not in valid_types:
|
||||
raise ValueError('invalid manifest template type')
|
||||
strategy = t.get('strategy', 'replace')
|
||||
if not isinstance(strategy, str):
|
||||
raise ValueError('manifest template strategy must be a string')
|
||||
strategy = strategy.lower()
|
||||
if strategy not in valid_strategies:
|
||||
raise ValueError('invalid manifest template strategy')
|
||||
if t['type'] == 'script' and strategy not in ('replace', 'wrap'):
|
||||
raise ValueError('invalid manifest script strategy')
|
||||
for t in templates:
|
||||
for t in data.get('provides', {}).get('templates', []):
|
||||
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
|
||||
file_value = t.get('file', '')
|
||||
strategy = t.get('strategy', 'replace')
|
||||
print('found\t' + strategy + '\t' + file_value)
|
||||
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
||||
sys.exit(0)
|
||||
print('absent\treplace\t')
|
||||
except Exception as exc:
|
||||
print(f'manifest_invalid: {exc}', file=sys.stderr)
|
||||
sys.exit(3)
|
||||
print('replace\t')
|
||||
except Exception:
|
||||
print('replace\t')
|
||||
"@ $manifest $TemplateName 2>$pyStderrFile
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
if ($LASTEXITCODE -eq 2) {
|
||||
throw "PyYAML is required to resolve preset template composition"
|
||||
}
|
||||
throw "Invalid preset manifest $manifest"
|
||||
}
|
||||
if ($stratResult) {
|
||||
$parts = $stratResult.Trim() -split "`t", 3
|
||||
$manifestDeclared = $parts[0] -eq 'found'
|
||||
$strategy = $parts[1].ToLowerInvariant()
|
||||
if ($parts.Count -gt 2 -and $parts[2]) { $manifestFilePath = $parts[2] }
|
||||
$parts = $stratResult.Trim() -split "`t", 2
|
||||
$strategy = $parts[0].ToLowerInvariant()
|
||||
if ($parts.Count -gt 1 -and $parts[1]) { $manifestFilePath = $parts[1] }
|
||||
}
|
||||
if (-not $yamlWarned -and (Test-Path $pyStderrFile) -and (Get-Content $pyStderrFile -Raw -ErrorAction SilentlyContinue) -match 'yaml_missing') {
|
||||
Write-Warning "PyYAML not available; composition strategies may be ignored"
|
||||
$yamlWarned = $true
|
||||
}
|
||||
Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue
|
||||
} catch {
|
||||
$strategy = 'replace'
|
||||
if ($pyStderrFile) { Remove-Item $pyStderrFile -Force -ErrorAction SilentlyContinue }
|
||||
throw
|
||||
}
|
||||
}
|
||||
# Try manifest file path first, then convention path
|
||||
@@ -691,45 +545,42 @@ except Exception as exc:
|
||||
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
|
||||
if (Test-Path $mf) { $candidate = $mf }
|
||||
}
|
||||
if (-not $candidate -and -not $manifestDeclared) {
|
||||
if (-not $candidate) {
|
||||
$cf = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
|
||||
if (Test-Path $cf) { $candidate = $cf }
|
||||
if (-not $candidate) {
|
||||
$cf = Join-Path $presetsDir "$presetId/$TemplateName.md"
|
||||
if (Test-Path $cf) { $candidate = $cf }
|
||||
}
|
||||
}
|
||||
if ($candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += $strategy
|
||||
if ($strategy -eq 'replace') {
|
||||
$effectiveBaseFound = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
# Fallback: alphabetical directory order (no registry or parse failure)
|
||||
foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
|
||||
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 3: Extension-provided templates (always "replace")
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
if (-not $effectiveBaseFound -and (Test-Path $extDir)) {
|
||||
foreach ($extensionId in Get-SortedExtensionIds -ExtensionsDir $extDir) {
|
||||
$candidate = Join-Path $extDir "$extensionId/templates/$TemplateName.md"
|
||||
if (-not (Test-Path $candidate)) {
|
||||
$candidate = Join-Path $extDir "$extensionId/$TemplateName.md"
|
||||
}
|
||||
if (Test-Path $extDir) {
|
||||
foreach ($ext in Get-ChildItem -Path $extDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
|
||||
$candidate = Join-Path $ext.FullName "templates/$TemplateName.md"
|
||||
if (Test-Path $candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += 'replace'
|
||||
$effectiveBaseFound = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
$core = Join-Path $base "$TemplateName.md"
|
||||
if (-not $effectiveBaseFound -and (Test-Path $core)) {
|
||||
if (Test-Path $core) {
|
||||
$layerPaths += $core
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
@@ -739,7 +590,7 @@ except Exception as exc:
|
||||
# If the top (highest-priority) layer is replace, it wins entirely --
|
||||
# lower layers are irrelevant regardless of their strategies.
|
||||
if ($layerStrategies[0] -eq 'replace') {
|
||||
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
|
||||
return (Get-Content $layerPaths[0] -Raw)
|
||||
}
|
||||
|
||||
# Check if any layer uses a non-replace strategy
|
||||
@@ -749,7 +600,7 @@ except Exception as exc:
|
||||
}
|
||||
|
||||
if (-not $hasComposition) {
|
||||
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
|
||||
return (Get-Content $layerPaths[0] -Raw)
|
||||
}
|
||||
|
||||
# Find the effective base: scan from highest priority (index 0) downward
|
||||
@@ -761,22 +612,14 @@ except Exception as exc:
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($baseIdx -lt 0) {
|
||||
throw "Template '$TemplateName' has composing layers but no replace base"
|
||||
}
|
||||
if ($baseIdx -lt 0) { return $null }
|
||||
|
||||
$content = [System.IO.File]::ReadAllText(
|
||||
$layerPaths[$baseIdx],
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
$content = Get-Content $layerPaths[$baseIdx] -Raw
|
||||
|
||||
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
|
||||
$path = $layerPaths[$i]
|
||||
$strat = $layerStrategies[$i]
|
||||
$layerContent = [System.IO.File]::ReadAllText(
|
||||
$path,
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
$layerContent = Get-Content $path -Raw
|
||||
|
||||
switch ($strat) {
|
||||
'replace' { $content = $layerContent }
|
||||
|
||||
@@ -262,16 +262,13 @@ if (-not $DryRun) {
|
||||
exit 1
|
||||
}
|
||||
|
||||
$needsSpec = -not (Test-Path -PathType Leaf $specFile)
|
||||
$content = $null
|
||||
if ($needsSpec) {
|
||||
$content = Resolve-TemplateContent -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||
}
|
||||
|
||||
New-Item -ItemType Directory -Path $featureDir -Force | Out-Null
|
||||
|
||||
if ($needsSpec) {
|
||||
if ($null -ne $content) {
|
||||
if (-not (Test-Path -PathType Leaf $specFile)) {
|
||||
$template = Resolve-Template -TemplateName 'spec-template' -RepoRoot $repoRoot
|
||||
if ($template -and (Test-Path $template)) {
|
||||
# Read the template content and write it to the spec file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
|
||||
} else {
|
||||
|
||||
@@ -1,38 +0,0 @@
|
||||
#!/usr/bin/env pwsh
|
||||
|
||||
param(
|
||||
[Parameter(Position=0)]
|
||||
[string]$TemplateName,
|
||||
[switch]$Json,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($Help) {
|
||||
Write-Output "Usage: resolve-template.ps1 <template-name> [-Json]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if (-not $TemplateName) {
|
||||
[Console]::Error.WriteLine("ERROR: Template name is required")
|
||||
exit 1
|
||||
}
|
||||
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
$repoRoot = Get-RepoRoot
|
||||
$templateContent = Resolve-TemplateContent -TemplateName $TemplateName -RepoRoot $repoRoot
|
||||
if ($null -eq $templateContent) {
|
||||
[Console]::Error.WriteLine("ERROR: Could not resolve required $TemplateName from the template override stack for $repoRoot")
|
||||
exit 1
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
[PSCustomObject]@{
|
||||
TEMPLATE_NAME = $TemplateName
|
||||
TEMPLATE_CONTENT = $templateContent
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
[Console]::Out.Write($templateContent)
|
||||
}
|
||||
@@ -41,8 +41,10 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
|
||||
Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy"
|
||||
}
|
||||
} else {
|
||||
$content = Resolve-TemplateContent -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($null -ne $content) {
|
||||
$template = Resolve-Template -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($template -and (Test-Path $template)) {
|
||||
# Read the template content and write it to the implementation plan file with UTF-8 encoding without BOM
|
||||
$content = [System.IO.File]::ReadAllText($template)
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($paths.IMPL_PLAN, $content, $utf8NoBom)
|
||||
# Emit the copy status like the bash twin (setup-plan.sh); route to stderr
|
||||
|
||||
@@ -57,17 +57,12 @@ if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
|
||||
|
||||
# Resolve tasks template through override stack
|
||||
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
|
||||
$tasksTemplateContent = Resolve-TemplateContent -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($null -eq $tasksTemplateContent) {
|
||||
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
|
||||
[Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.")
|
||||
exit 1
|
||||
}
|
||||
if ($tasksTemplate -and (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
|
||||
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
|
||||
} else {
|
||||
$tasksTemplate = ''
|
||||
}
|
||||
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
@@ -75,7 +70,6 @@ if ($Json) {
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
AVAILABLE_DOCS = $docs
|
||||
TASKS_TEMPLATE = $tasksTemplate
|
||||
TASKS_TEMPLATE_CONTENT = $tasksTemplateContent
|
||||
} | ConvertTo-Json -Compress
|
||||
} else {
|
||||
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
|
||||
|
||||
@@ -9,22 +9,10 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import (
|
||||
FeaturePaths,
|
||||
TemplateResolutionError,
|
||||
format_speckit_command,
|
||||
get_feature_paths,
|
||||
resolve_template_content,
|
||||
)
|
||||
from common import FeaturePaths, format_speckit_command, get_feature_paths
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import (
|
||||
FeaturePaths,
|
||||
TemplateResolutionError,
|
||||
format_speckit_command,
|
||||
get_feature_paths,
|
||||
resolve_template_content,
|
||||
)
|
||||
from common import FeaturePaths, format_speckit_command, get_feature_paths
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
@@ -40,7 +28,6 @@ OPTIONS:
|
||||
--require-tasks Require tasks.md to exist (for implementation phase)
|
||||
--include-tasks Include tasks.md in AVAILABLE_DOCS list
|
||||
--paths-only Only output path variables (no prerequisite validation)
|
||||
--template NAME Include composed template content in JSON output
|
||||
--help, -h Show this help message
|
||||
|
||||
EXAMPLES:
|
||||
@@ -62,7 +49,6 @@ class Args:
|
||||
require_tasks: bool = False
|
||||
include_tasks: bool = False
|
||||
paths_only: bool = False
|
||||
template_name: str | None = None
|
||||
|
||||
|
||||
def _parse_args(argv: list[str]) -> Args:
|
||||
@@ -70,11 +56,8 @@ def _parse_args(argv: list[str]) -> Args:
|
||||
require_tasks = False
|
||||
include_tasks = False
|
||||
paths_only = False
|
||||
template_name = None
|
||||
|
||||
index = 0
|
||||
while index < len(argv):
|
||||
arg = argv[index]
|
||||
for arg in argv:
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg == "--require-tasks":
|
||||
@@ -83,15 +66,6 @@ def _parse_args(argv: list[str]) -> Args:
|
||||
include_tasks = True
|
||||
elif arg == "--paths-only":
|
||||
paths_only = True
|
||||
elif arg == "--template":
|
||||
index += 1
|
||||
if index >= len(argv):
|
||||
print(
|
||||
"ERROR: --template requires a template name",
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
template_name = argv[index]
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(HELP_TEXT)
|
||||
raise SystemExit(0)
|
||||
@@ -101,14 +75,12 @@ def _parse_args(argv: list[str]) -> Args:
|
||||
file=sys.stderr,
|
||||
)
|
||||
raise SystemExit(1)
|
||||
index += 1
|
||||
|
||||
return Args(
|
||||
json_mode=json_mode,
|
||||
require_tasks=require_tasks,
|
||||
include_tasks=include_tasks,
|
||||
paths_only=paths_only,
|
||||
template_name=template_name,
|
||||
)
|
||||
|
||||
|
||||
@@ -158,31 +130,14 @@ def _print_paths_only(paths: FeaturePaths, json_mode: bool) -> None:
|
||||
print(f"TASKS: {paths.tasks}")
|
||||
|
||||
|
||||
def _status_marker(ok: bool) -> str:
|
||||
"""Return the status glyph, downgraded to ASCII when stdout cannot encode it.
|
||||
|
||||
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
|
||||
console - a pipe or a file redirect, which is how agents and workflow steps
|
||||
invoke these scripts - and U+2713 is unencodable in cp1252, so printing it
|
||||
raised UnicodeEncodeError and aborted the report right after
|
||||
"AVAILABLE_DOCS:". "[OK]"/"[FAIL]" is the ASCII rendering these markers
|
||||
already have in-tree: see Test-FileExists in scripts/powershell/common.ps1
|
||||
and normalize_status_text in tests/parity_helpers.py.
|
||||
"""
|
||||
glyph = "✓" if ok else "✗"
|
||||
try:
|
||||
glyph.encode(getattr(sys.stdout, "encoding", None) or "utf-8")
|
||||
except (LookupError, UnicodeEncodeError):
|
||||
return "[OK]" if ok else "[FAIL]"
|
||||
return glyph
|
||||
|
||||
|
||||
def _check_file(path: Path, description: str) -> None:
|
||||
print(f" {_status_marker(path.is_file())} {description}")
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
print(f" {_status_marker(_dir_has_entries(path))} {description}")
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
|
||||
@@ -239,32 +194,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 1
|
||||
|
||||
docs = _available_docs(paths, args.include_tasks)
|
||||
template_content = None
|
||||
if args.template_name:
|
||||
try:
|
||||
template_content = resolve_template_content(
|
||||
args.template_name, paths.repo_root
|
||||
)
|
||||
except TemplateResolutionError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if template_content is None:
|
||||
print(
|
||||
f"ERROR: Could not resolve required {args.template_name} from "
|
||||
f"the template override stack for {paths.repo_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.json_mode:
|
||||
payload: dict[str, object] = {
|
||||
"FEATURE_DIR": str(paths.feature_dir),
|
||||
"AVAILABLE_DOCS": docs,
|
||||
}
|
||||
if args.template_name:
|
||||
payload["TEMPLATE_CONTENT"] = template_content
|
||||
sys.stdout.write(
|
||||
_json_line(payload)
|
||||
_json_line({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
|
||||
)
|
||||
else:
|
||||
_print_text_results(paths, args.include_tasks)
|
||||
|
||||
+19
-280
@@ -4,7 +4,6 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -183,30 +182,12 @@ def get_feature_paths(
|
||||
)
|
||||
|
||||
|
||||
_SAFE_COMPONENT_PATTERN = re.compile(r"[a-z0-9-]+")
|
||||
|
||||
|
||||
def _is_safe_component(value: object) -> bool:
|
||||
return (
|
||||
isinstance(value, str)
|
||||
and _SAFE_COMPONENT_PATTERN.fullmatch(value) is not None
|
||||
)
|
||||
|
||||
|
||||
def _normalize_priority(value: object) -> int:
|
||||
if isinstance(value, bool):
|
||||
return 10
|
||||
try:
|
||||
priority = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
return 10
|
||||
return priority if priority >= 1 else 10
|
||||
|
||||
|
||||
def _sorted_preset_ids(presets_dir: Path) -> list[str]:
|
||||
registry = presets_dir / ".registry"
|
||||
if registry.is_file():
|
||||
# Invalid JSON or registry shapes fall back to the directory scan below.
|
||||
# Mirrors bash: any failure while reading or sorting the registry
|
||||
# (invalid JSON, non-dict shapes, unorderable priority values) falls
|
||||
# back to the directory scan below.
|
||||
try:
|
||||
data = json.loads(registry.read_text(encoding="utf-8"))
|
||||
presets = data.get("presets", {})
|
||||
@@ -214,18 +195,11 @@ def _sorted_preset_ids(presets_dir: Path) -> list[str]:
|
||||
pid
|
||||
for pid, meta in sorted(
|
||||
presets.items(),
|
||||
key=lambda kv: (
|
||||
_normalize_priority(kv[1].get("priority"))
|
||||
if isinstance(kv[1], dict)
|
||||
else 10,
|
||||
kv[0],
|
||||
),
|
||||
)
|
||||
if (
|
||||
_is_safe_component(pid)
|
||||
and isinstance(meta, dict)
|
||||
and bool(meta.get("enabled", True))
|
||||
key=lambda kv: kv[1].get("priority", 10)
|
||||
if isinstance(kv[1], dict)
|
||||
else 10,
|
||||
)
|
||||
if isinstance(meta, dict) and meta.get("enabled", True) is not False
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
@@ -233,78 +207,12 @@ def _sorted_preset_ids(presets_dir: Path) -> list[str]:
|
||||
return sorted(
|
||||
p.name
|
||||
for p in presets_dir.iterdir()
|
||||
if p.is_dir() and _is_safe_component(p.name)
|
||||
if p.is_dir() and not p.name.startswith(".")
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def _sorted_extension_ids(extensions_dir: Path) -> list[str]:
|
||||
registry = extensions_dir / ".registry"
|
||||
registered_ids: set[str] = set()
|
||||
extensions: dict[object, object] = {}
|
||||
if os.path.lexists(registry):
|
||||
if not registry.is_file():
|
||||
raise TemplateResolutionError(
|
||||
f"Invalid extension registry {registry}: not a regular file"
|
||||
)
|
||||
try:
|
||||
data = json.loads(registry.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
||||
raise TemplateResolutionError(
|
||||
f"Failed to parse extension registry {registry}: {exc}"
|
||||
) from exc
|
||||
if not isinstance(data, dict):
|
||||
raise TemplateResolutionError(
|
||||
f"Invalid extension registry {registry}: root must be a mapping"
|
||||
)
|
||||
raw_extensions = data.get("extensions", {})
|
||||
if not isinstance(raw_extensions, dict):
|
||||
raise TemplateResolutionError(
|
||||
f"Invalid extension registry {registry}: "
|
||||
"'extensions' must be a mapping"
|
||||
)
|
||||
extensions = raw_extensions
|
||||
registered_ids = {
|
||||
ext_id for ext_id in extensions if isinstance(ext_id, str)
|
||||
}
|
||||
|
||||
ranked: list[tuple[int, str]] = []
|
||||
for ext_id, metadata in extensions.items():
|
||||
if (
|
||||
_is_safe_component(ext_id)
|
||||
and isinstance(metadata, dict)
|
||||
and bool(metadata.get("enabled", True))
|
||||
):
|
||||
ranked.append((_normalize_priority(metadata.get("priority")), ext_id))
|
||||
|
||||
try:
|
||||
ranked.extend(
|
||||
(10, path.name)
|
||||
for path in extensions_dir.iterdir()
|
||||
if (
|
||||
path.is_dir()
|
||||
and _is_safe_component(path.name)
|
||||
and path.name not in registered_ids
|
||||
)
|
||||
)
|
||||
except OSError:
|
||||
pass
|
||||
return [ext_id for _, ext_id in sorted(ranked)]
|
||||
|
||||
|
||||
def _conventional_template(
|
||||
base_dir: Path, template_name: str
|
||||
) -> Path | None:
|
||||
for candidate in (
|
||||
base_dir / "templates" / f"{template_name}.md",
|
||||
base_dir / f"{template_name}.md",
|
||||
):
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
return None
|
||||
|
||||
|
||||
def resolve_template(template_name: str, repo_root: Path) -> Path | None:
|
||||
"""Resolve a template name to a file path using the priority stack.
|
||||
|
||||
@@ -314,9 +222,6 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None:
|
||||
3. .specify/extensions/<ext-id>/templates/ (hidden directories skipped)
|
||||
4. .specify/templates/ (core)
|
||||
"""
|
||||
if not _is_safe_component(template_name):
|
||||
return None
|
||||
|
||||
base = repo_root / ".specify" / "templates"
|
||||
|
||||
override = base / "overrides" / f"{template_name}.md"
|
||||
@@ -326,18 +231,21 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None:
|
||||
presets_dir = repo_root / ".specify" / "presets"
|
||||
if presets_dir.is_dir():
|
||||
for preset_id in _sorted_preset_ids(presets_dir):
|
||||
candidate = _conventional_template(
|
||||
presets_dir / preset_id, template_name
|
||||
)
|
||||
if candidate is not None:
|
||||
candidate = presets_dir / preset_id / "templates" / f"{template_name}.md"
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
ext_dir = repo_root / ".specify" / "extensions"
|
||||
if ext_dir.is_dir():
|
||||
for extension_id in _sorted_extension_ids(ext_dir):
|
||||
ext = ext_dir / extension_id
|
||||
candidate = _conventional_template(ext, template_name)
|
||||
if candidate is not None:
|
||||
try:
|
||||
extensions = sorted(p for p in ext_dir.iterdir() if p.is_dir())
|
||||
except OSError:
|
||||
extensions = []
|
||||
for ext in extensions:
|
||||
if ext.name.startswith("."):
|
||||
continue
|
||||
candidate = ext / "templates" / f"{template_name}.md"
|
||||
if candidate.is_file():
|
||||
return candidate
|
||||
|
||||
core = base / f"{template_name}.md"
|
||||
@@ -346,175 +254,6 @@ def resolve_template(template_name: str, repo_root: Path) -> Path | None:
|
||||
return None
|
||||
|
||||
|
||||
class TemplateResolutionError(RuntimeError):
|
||||
"""Raised when template layers exist but cannot be composed safely."""
|
||||
|
||||
|
||||
# Mirror the canonical PresetManifest contract (see src/specify_cli/presets)
|
||||
# so runtime resolution rejects the same structurally malformed manifests.
|
||||
_VALID_TEMPLATE_TYPES = ("template", "command", "script")
|
||||
_VALID_TEMPLATE_STRATEGIES = ("replace", "prepend", "append", "wrap")
|
||||
_VALID_SCRIPT_STRATEGIES = ("replace", "wrap")
|
||||
|
||||
|
||||
def _validate_manifest_template_entry(entry: object) -> None:
|
||||
"""Validate a single manifest template entry against the canonical rules."""
|
||||
if not isinstance(entry, dict):
|
||||
raise ValueError("manifest template entries must be mappings")
|
||||
if "type" not in entry or "name" not in entry or "file" not in entry:
|
||||
raise ValueError("manifest template entry missing type, name, or file")
|
||||
for field in ("type", "name", "file"):
|
||||
if not isinstance(entry[field], str):
|
||||
raise ValueError(f"manifest template {field} must be a string")
|
||||
if entry["type"] not in _VALID_TEMPLATE_TYPES:
|
||||
raise ValueError(f"invalid manifest template type '{entry['type']}'")
|
||||
strategy = entry.get("strategy", "replace")
|
||||
if not isinstance(strategy, str):
|
||||
raise ValueError("manifest template strategy must be a string")
|
||||
strategy = strategy.lower()
|
||||
if strategy not in _VALID_TEMPLATE_STRATEGIES:
|
||||
raise ValueError(f"invalid manifest template strategy '{strategy}'")
|
||||
if entry["type"] == "script" and strategy not in _VALID_SCRIPT_STRATEGIES:
|
||||
raise ValueError(
|
||||
f"invalid manifest script strategy '{strategy}'"
|
||||
)
|
||||
|
||||
|
||||
def _preset_template_layer(
|
||||
preset_dir: Path, template_name: str
|
||||
) -> tuple[Path, str] | None:
|
||||
"""Return the preset template path and composition strategy."""
|
||||
manifest_path = preset_dir / "preset.yml"
|
||||
conventional = _conventional_template(preset_dir, template_name)
|
||||
|
||||
try:
|
||||
import yaml
|
||||
except ImportError as exc:
|
||||
if manifest_path.is_file():
|
||||
raise TemplateResolutionError(
|
||||
"PyYAML is required to resolve preset template composition"
|
||||
) from exc
|
||||
return (conventional, "replace") if conventional is not None else None
|
||||
|
||||
if manifest_path.is_file():
|
||||
try:
|
||||
manifest = yaml.safe_load(manifest_path.read_text(encoding="utf-8"))
|
||||
if not isinstance(manifest, dict):
|
||||
raise ValueError("manifest root must be a mapping")
|
||||
if "provides" not in manifest:
|
||||
raise ValueError("manifest missing provides section")
|
||||
provides = manifest["provides"]
|
||||
if not isinstance(provides, dict):
|
||||
raise ValueError("manifest provides must be a mapping")
|
||||
if "templates" not in provides:
|
||||
raise ValueError("manifest provides missing templates")
|
||||
templates = provides["templates"]
|
||||
if not isinstance(templates, list):
|
||||
raise ValueError("manifest templates must be a list")
|
||||
if not templates:
|
||||
raise ValueError("manifest must provide at least one template")
|
||||
for entry in templates:
|
||||
_validate_manifest_template_entry(entry)
|
||||
for entry in templates:
|
||||
if (
|
||||
entry.get("name") != template_name
|
||||
or entry.get("type", "template") != "template"
|
||||
):
|
||||
continue
|
||||
file_value = entry.get("file", "")
|
||||
strategy = entry.get("strategy", "replace")
|
||||
relative = Path(file_value)
|
||||
if (
|
||||
not relative
|
||||
or relative.is_absolute()
|
||||
or ".." in relative.parts
|
||||
):
|
||||
return None
|
||||
candidate = preset_dir / relative
|
||||
if not candidate.is_file():
|
||||
return None
|
||||
return candidate, strategy.lower()
|
||||
except (OSError, UnicodeError, ValueError, yaml.YAMLError) as exc:
|
||||
raise TemplateResolutionError(
|
||||
f"Failed to parse preset manifest {manifest_path}: {exc}"
|
||||
) from exc
|
||||
|
||||
return (conventional, "replace") if conventional is not None else None
|
||||
|
||||
|
||||
def resolve_template_content(template_name: str, repo_root: Path) -> str | None:
|
||||
"""Resolve and compose template content through the project layer stack."""
|
||||
if not _is_safe_component(template_name):
|
||||
return None
|
||||
|
||||
layers: list[tuple[Path, str]] = []
|
||||
|
||||
def compose_from_base() -> str:
|
||||
try:
|
||||
content = layers[-1][0].read_bytes().decode("utf-8")
|
||||
for path, strategy in reversed(layers[:-1]):
|
||||
layer_content = path.read_bytes().decode("utf-8")
|
||||
if strategy == "prepend":
|
||||
content = f"{layer_content}\n\n{content}"
|
||||
elif strategy == "append":
|
||||
content = f"{content}\n\n{layer_content}"
|
||||
elif strategy == "wrap":
|
||||
placeholder = "{CORE_TEMPLATE}"
|
||||
if placeholder not in layer_content:
|
||||
raise TemplateResolutionError(
|
||||
f"Wrap layer {path} is missing {placeholder}"
|
||||
)
|
||||
content = layer_content.replace(placeholder, content)
|
||||
else:
|
||||
raise TemplateResolutionError(
|
||||
f"Unknown template composition strategy '{strategy}' in {path}"
|
||||
)
|
||||
except (OSError, UnicodeError) as exc:
|
||||
raise TemplateResolutionError(
|
||||
f"Failed to read template layer for '{template_name}': {exc}"
|
||||
) from exc
|
||||
return content
|
||||
|
||||
override = (
|
||||
repo_root
|
||||
/ ".specify"
|
||||
/ "templates"
|
||||
/ "overrides"
|
||||
/ f"{template_name}.md"
|
||||
)
|
||||
if override.is_file():
|
||||
layers.append((override, "replace"))
|
||||
return compose_from_base()
|
||||
|
||||
presets_dir = repo_root / ".specify" / "presets"
|
||||
for preset_id in _sorted_preset_ids(presets_dir):
|
||||
layer = _preset_template_layer(presets_dir / preset_id, template_name)
|
||||
if layer is not None:
|
||||
layers.append(layer)
|
||||
if layer[1] == "replace":
|
||||
return compose_from_base()
|
||||
|
||||
extensions_dir = repo_root / ".specify" / "extensions"
|
||||
for extension_id in _sorted_extension_ids(extensions_dir):
|
||||
extension_dir = extensions_dir / extension_id
|
||||
candidate = _conventional_template(extension_dir, template_name)
|
||||
if candidate is not None:
|
||||
layers.append((candidate, "replace"))
|
||||
return compose_from_base()
|
||||
|
||||
core = repo_root / ".specify" / "templates" / f"{template_name}.md"
|
||||
if core.is_file():
|
||||
layers.append((core, "replace"))
|
||||
return compose_from_base()
|
||||
|
||||
if not layers:
|
||||
return None
|
||||
|
||||
raise TemplateResolutionError(
|
||||
f"Template '{template_name}' has composing layers but no replace base"
|
||||
)
|
||||
|
||||
|
||||
def get_invoke_separator(repo_root: Path) -> str:
|
||||
integration_json = repo_root / ".specify" / "integration.json"
|
||||
if not integration_json.is_file():
|
||||
|
||||
@@ -7,25 +7,16 @@ import datetime
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import (
|
||||
TemplateResolutionError,
|
||||
get_repo_root,
|
||||
persist_feature_json,
|
||||
resolve_template_content,
|
||||
)
|
||||
from common import get_repo_root, persist_feature_json, resolve_template
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import (
|
||||
TemplateResolutionError,
|
||||
get_repo_root,
|
||||
persist_feature_json,
|
||||
resolve_template_content,
|
||||
)
|
||||
from common import get_repo_root, persist_feature_json, resolve_template
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
@@ -383,22 +374,12 @@ def main(argv: list[str] | None = None) -> int:
|
||||
)
|
||||
return 1
|
||||
|
||||
template_content = None
|
||||
needs_spec = not spec_file.is_file()
|
||||
if needs_spec:
|
||||
try:
|
||||
template_content = resolve_template_content(
|
||||
"spec-template", repo_root
|
||||
)
|
||||
except TemplateResolutionError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
feature_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if needs_spec:
|
||||
if template_content is not None:
|
||||
spec_file.write_bytes(template_content.encode("utf-8"))
|
||||
if not spec_file.is_file():
|
||||
template = resolve_template("spec-template", repo_root)
|
||||
if template is not None and template.is_file():
|
||||
shutil.copy(template, spec_file)
|
||||
else:
|
||||
print(
|
||||
"Warning: Spec template not found; created empty spec file",
|
||||
|
||||
@@ -1,63 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Resolve composed template content from the project template stack."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import (
|
||||
TemplateResolutionError,
|
||||
get_repo_root,
|
||||
resolve_template_content,
|
||||
)
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import (
|
||||
TemplateResolutionError,
|
||||
get_repo_root,
|
||||
resolve_template_content,
|
||||
)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser()
|
||||
parser.add_argument("template_name")
|
||||
parser.add_argument("--json", action="store_true")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
repo_root = get_repo_root(Path(__file__))
|
||||
try:
|
||||
content = resolve_template_content(args.template_name, repo_root)
|
||||
except TemplateResolutionError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if content is None:
|
||||
print(
|
||||
f"ERROR: Could not resolve required {args.template_name} from the "
|
||||
f"template override stack for {repo_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if args.json:
|
||||
print(
|
||||
json.dumps(
|
||||
{
|
||||
"TEMPLATE_NAME": args.template_name,
|
||||
"TEMPLATE_CONTENT": content,
|
||||
},
|
||||
ensure_ascii=False,
|
||||
separators=(",", ":"),
|
||||
)
|
||||
)
|
||||
else:
|
||||
sys.stdout.write(content)
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -4,22 +4,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import (
|
||||
TemplateResolutionError,
|
||||
get_feature_paths,
|
||||
resolve_template_content,
|
||||
)
|
||||
from common import get_feature_paths, resolve_template
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import (
|
||||
TemplateResolutionError,
|
||||
get_feature_paths,
|
||||
resolve_template_content,
|
||||
)
|
||||
from common import get_feature_paths, resolve_template
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
@@ -62,13 +55,9 @@ def main(argv: list[str] | None = None) -> int:
|
||||
file=status_stream,
|
||||
)
|
||||
else:
|
||||
try:
|
||||
template_content = resolve_template_content("plan-template", paths.repo_root)
|
||||
except TemplateResolutionError as exc:
|
||||
print(f"Error: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if template_content is not None:
|
||||
paths.impl_plan.write_bytes(template_content.encode("utf-8"))
|
||||
template = resolve_template("plan-template", paths.repo_root)
|
||||
if template is not None and template.is_file():
|
||||
shutil.copy(template, paths.impl_plan)
|
||||
print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
|
||||
else:
|
||||
print("Warning: Plan template not found", file=status_stream)
|
||||
|
||||
@@ -10,21 +10,17 @@ from pathlib import Path
|
||||
try:
|
||||
from common import (
|
||||
FeaturePaths,
|
||||
TemplateResolutionError,
|
||||
format_speckit_command,
|
||||
get_feature_paths,
|
||||
resolve_template,
|
||||
resolve_template_content,
|
||||
)
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import (
|
||||
FeaturePaths,
|
||||
TemplateResolutionError,
|
||||
format_speckit_command,
|
||||
get_feature_paths,
|
||||
resolve_template,
|
||||
resolve_template_content,
|
||||
)
|
||||
|
||||
|
||||
@@ -59,31 +55,14 @@ def _available_docs(paths: FeaturePaths) -> list[str]:
|
||||
return docs
|
||||
|
||||
|
||||
def _status_marker(ok: bool) -> str:
|
||||
"""Return the status glyph, downgraded to ASCII when stdout cannot encode it.
|
||||
|
||||
On Windows sys.stdout falls back to the ANSI code page whenever it is not a
|
||||
console - a pipe or a file redirect, which is how agents and workflow steps
|
||||
invoke these scripts - and U+2713 is unencodable in cp1252, so printing it
|
||||
raised UnicodeEncodeError and aborted the report mid-listing.
|
||||
"[OK]"/"[FAIL]" is the ASCII rendering these markers already have in-tree:
|
||||
see Test-FileExists in scripts/powershell/common.ps1 and
|
||||
normalize_status_text in tests/parity_helpers.py.
|
||||
"""
|
||||
glyph = "✓" if ok else "✗"
|
||||
try:
|
||||
glyph.encode(getattr(sys.stdout, "encoding", None) or "utf-8")
|
||||
except (LookupError, UnicodeEncodeError):
|
||||
return "[OK]" if ok else "[FAIL]"
|
||||
return glyph
|
||||
|
||||
|
||||
def _check_file(path: Path, description: str) -> None:
|
||||
print(f" {_status_marker(path.is_file())} {description}")
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
print(f" {_status_marker(_dir_has_entries(path))} {description}")
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -124,14 +103,8 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
docs = _available_docs(paths)
|
||||
|
||||
try:
|
||||
tasks_template_content = resolve_template_content(
|
||||
"tasks-template", paths.repo_root
|
||||
)
|
||||
except TemplateResolutionError as exc:
|
||||
print(f"ERROR: {exc}", file=sys.stderr)
|
||||
return 1
|
||||
if tasks_template_content is None:
|
||||
tasks_template = resolve_template("tasks-template", paths.repo_root)
|
||||
if tasks_template is None or not tasks_template.is_file():
|
||||
print(
|
||||
"ERROR: Could not resolve required tasks-template from the template "
|
||||
f"override stack for {paths.repo_root}",
|
||||
@@ -148,21 +121,18 @@ def main(argv: list[str] | None = None) -> int:
|
||||
return 1
|
||||
|
||||
if json_mode:
|
||||
tasks_template = resolve_template("tasks-template", paths.repo_root)
|
||||
sys.stdout.write(
|
||||
_json_line(
|
||||
{
|
||||
"FEATURE_DIR": str(paths.feature_dir),
|
||||
"AVAILABLE_DOCS": docs,
|
||||
"TASKS_TEMPLATE": str(tasks_template) if tasks_template else "",
|
||||
"TASKS_TEMPLATE_CONTENT": tasks_template_content,
|
||||
"TASKS_TEMPLATE": str(tasks_template),
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
tasks_template = resolve_template("tasks-template", paths.repo_root)
|
||||
print(f"FEATURE_DIR: {paths.feature_dir}")
|
||||
print(f"TASKS_TEMPLATE: {tasks_template or 'not found'}")
|
||||
print(f"TASKS_TEMPLATE: {tasks_template}")
|
||||
print("AVAILABLE_DOCS:")
|
||||
_check_file(paths.research, "research.md")
|
||||
_check_file(paths.data_model, "data-model.md")
|
||||
|
||||
@@ -77,9 +77,7 @@ from ._version import (
|
||||
from ._agent_config import (
|
||||
AGENT_CONFIG as AGENT_CONFIG,
|
||||
DEFAULT_INIT_INTEGRATION as DEFAULT_INIT_INTEGRATION,
|
||||
DEFAULT_INIT_INTEGRATION_ENV_VAR as DEFAULT_INIT_INTEGRATION_ENV_VAR,
|
||||
SCRIPT_TYPE_CHOICES as SCRIPT_TYPE_CHOICES,
|
||||
resolve_default_init_integration as resolve_default_init_integration,
|
||||
)
|
||||
from ._init_options import (
|
||||
INIT_OPTIONS_FILE as INIT_OPTIONS_FILE,
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
"""Agent configuration constants derived from the integration registry."""
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import sys
|
||||
from typing import Any
|
||||
|
||||
|
||||
@@ -19,36 +17,6 @@ AGENT_CONFIG: dict[str, dict[str, Any]] = _build_agent_config()
|
||||
|
||||
DEFAULT_INIT_INTEGRATION = "copilot"
|
||||
|
||||
#: Environment variable used to override the fallback integration that
|
||||
#: ``specify init`` selects in non-interactive sessions. Follows the existing
|
||||
#: ``SPECKIT_INTEGRATION_*`` namespace (see ``SPECKIT_INTEGRATION_<KEY>_EXECUTABLE``
|
||||
#: and ``SPECKIT_INTEGRATION_CATALOG_URL``).
|
||||
DEFAULT_INIT_INTEGRATION_ENV_VAR = "SPECKIT_INTEGRATION_DEFAULT"
|
||||
|
||||
|
||||
def resolve_default_init_integration() -> str:
|
||||
"""Return the default init integration, honoring an env-var override.
|
||||
|
||||
Reads :data:`DEFAULT_INIT_INTEGRATION_ENV_VAR`
|
||||
(``SPECKIT_INTEGRATION_DEFAULT``). When it names a registered integration
|
||||
key, that key is returned; otherwise the hardcoded
|
||||
:data:`DEFAULT_INIT_INTEGRATION` (``"copilot"``) is used. An invalid value
|
||||
emits a warning to stderr rather than silently falling back, so operators
|
||||
can tell a typo from an intentional default.
|
||||
"""
|
||||
override = (os.environ.get(DEFAULT_INIT_INTEGRATION_ENV_VAR) or "").strip()
|
||||
if not override:
|
||||
return DEFAULT_INIT_INTEGRATION
|
||||
if override in AGENT_CONFIG:
|
||||
return override
|
||||
print(
|
||||
f"Warning: {DEFAULT_INIT_INTEGRATION_ENV_VAR}='{override}' is not a "
|
||||
f"recognized integration; falling back to '{DEFAULT_INIT_INTEGRATION}'. "
|
||||
f"Choose from: {', '.join(sorted(AGENT_CONFIG.keys()))}.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return DEFAULT_INIT_INTEGRATION
|
||||
|
||||
SCRIPT_TYPE_CHOICES: dict[str, str] = {
|
||||
"sh": "POSIX Shell (bash/zsh)",
|
||||
"ps": "PowerShell",
|
||||
|
||||
@@ -7,7 +7,6 @@ layer, not out of it, to avoid circular imports.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import sys
|
||||
from collections.abc import Callable
|
||||
|
||||
@@ -22,8 +21,6 @@ from rich.text import Text
|
||||
from rich.tree import Tree
|
||||
from typer.core import TyperGroup
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
BANNER = """
|
||||
███████╗██████╗ ███████╗ ██████╗██╗███████╗██╗ ██╗
|
||||
██╔════╝██╔══██╗██╔════╝██╔════╝██║██╔════╝╚██╗ ██╔╝
|
||||
@@ -88,7 +85,7 @@ class StepTracker:
|
||||
try:
|
||||
self._refresh_cb()
|
||||
except Exception:
|
||||
logger.debug("Progress tracker refresh failed", exc_info=True)
|
||||
pass
|
||||
|
||||
def render(self):
|
||||
tree = Tree(f"[cyan]{self.title}[/cyan]", guide_style="grey50")
|
||||
|
||||
@@ -10,7 +10,6 @@ import struct
|
||||
import tarfile
|
||||
import unicodedata
|
||||
import zipfile
|
||||
import zlib
|
||||
from collections.abc import Iterator
|
||||
from contextlib import ExitStack, contextmanager
|
||||
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||
@@ -70,19 +69,6 @@ _ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
|
||||
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
|
||||
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
|
||||
)
|
||||
#: Decompression failures a truncated or corrupt gzip stream raises from
|
||||
#: ``tarfile``. Most are wrapped in ``TarError``, but two escape raw, and
|
||||
#: neither derives from ``TarError`` or ``OSError``, so both bypass a
|
||||
#: ``(TarError, OSError)`` handler:
|
||||
#:
|
||||
#: * ``EOFError`` -- from the gzip layer when the stream ends before its
|
||||
#: end-of-stream marker, i.e. a truncated archive.
|
||||
#: * ``zlib.error`` -- from a corrupt deflate block. ``tarfile`` converts this
|
||||
#: to ``ReadError`` while reading a member *header*, but the forward seek it
|
||||
#: performs to skip member *data* sits outside that conversion, so a corrupt
|
||||
#: region past the first header escapes raw.
|
||||
_TAR_DECOMPRESSION_ERRORS = (tarfile.TarError, EOFError, zlib.error)
|
||||
|
||||
_ARCHIVE_CONTENT_TYPES: dict[str, ArchiveFormat] = {
|
||||
"application/gzip": "tar.gz",
|
||||
"application/x-gzip": "tar.gz",
|
||||
@@ -180,11 +166,7 @@ def detect_archive_format(
|
||||
try:
|
||||
with tarfile.open(fileobj=archive_file, mode="r:gz"):
|
||||
is_tar_gz = True
|
||||
except _TAR_DECOMPRESSION_ERRORS:
|
||||
# A truncated gzip stream raises a bare EOFError here rather
|
||||
# than a TarError, so catching only TarError let it escape
|
||||
# this probe as a raw exception instead of leaving
|
||||
# ``is_tar_gz`` False and reporting the format mismatch.
|
||||
except tarfile.TarError:
|
||||
pass
|
||||
archive_file.seek(0)
|
||||
except OSError as exc:
|
||||
@@ -1095,7 +1077,7 @@ def safe_extract_tar(
|
||||
mode="r:gz",
|
||||
fileobj=archive_file,
|
||||
)
|
||||
except (*_TAR_DECOMPRESSION_ERRORS, OSError) as exc:
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
_raise_from(error_type, f"Invalid tar.gz archive: {archive_path}", exc)
|
||||
|
||||
with archive:
|
||||
@@ -1167,7 +1149,7 @@ def safe_extract_tar(
|
||||
f"of {max_total_bytes} bytes",
|
||||
)
|
||||
validated.append((member, normalized_name, is_dir))
|
||||
except (*_TAR_DECOMPRESSION_ERRORS, OSError) as exc:
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
_raise_from(
|
||||
error_type,
|
||||
f"Invalid tar.gz archive: {archive_path}",
|
||||
|
||||
@@ -9,7 +9,7 @@ and ``specify init``'s next-steps output stay consistent.
|
||||
from __future__ import annotations
|
||||
|
||||
# Agents that render $speckit-<name> (chat invocation) when in skills mode.
|
||||
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode", "command-code"})
|
||||
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode"})
|
||||
|
||||
# Agents that always render /speckit-<name>, regardless of ai_skills.
|
||||
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"})
|
||||
|
||||
@@ -192,8 +192,8 @@ def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker
|
||||
|
||||
os.replace(temp_path, target_file)
|
||||
except Exception:
|
||||
if temp_path:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
if temp_path and temp_path.exists():
|
||||
temp_path.unlink()
|
||||
raise
|
||||
|
||||
try:
|
||||
@@ -213,7 +213,7 @@ def handle_vscode_settings(sub_item, dest_file, rel_path, verbose=False, tracker
|
||||
shutil.copy2(sub_item, dest_file)
|
||||
log("Copied (no existing settings.json):", "blue")
|
||||
|
||||
except (OSError, ValueError, KeyError) as e:
|
||||
except Exception as e:
|
||||
log(f"Warning: Could not merge settings: {e}", "yellow")
|
||||
if not dest_file.exists():
|
||||
shutil.copy2(sub_item, dest_file)
|
||||
|
||||
@@ -157,11 +157,7 @@ class CommandRegistrar:
|
||||
return ""
|
||||
|
||||
yaml_str = yaml.dump(
|
||||
fm,
|
||||
default_flow_style=False,
|
||||
sort_keys=False,
|
||||
allow_unicode=True,
|
||||
width=float("inf"),
|
||||
fm, default_flow_style=False, sort_keys=False, allow_unicode=True
|
||||
)
|
||||
return f"---\n{yaml_str}---\n"
|
||||
|
||||
@@ -683,7 +679,7 @@ class CommandRegistrar:
|
||||
_integ = get_integration(agent_name)
|
||||
if _integ is not None:
|
||||
_sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
|
||||
except (ImportError, ValueError, KeyError):
|
||||
except Exception:
|
||||
pass
|
||||
_prefix = get_invocation_prefix(agent_name, registrar_writes_skills)
|
||||
|
||||
|
||||
@@ -11,6 +11,7 @@ import json
|
||||
import os
|
||||
import stat
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
@@ -47,21 +48,10 @@ def _is_valid_host_pattern(pattern: str) -> bool:
|
||||
* ``*.example.com`` — leading ``*.`` wildcard; matches subdomains
|
||||
such as ``myorg.example.com`` but not ``example.com`` itself
|
||||
"""
|
||||
if any(char in pattern for char in "?[]"):
|
||||
return False
|
||||
if "*" not in pattern:
|
||||
return True # exact hostname — already validated as non-empty
|
||||
# Only *.suffix is allowed; no other wildcard positions
|
||||
return pattern.startswith("*.") and len(pattern) > 2 and "*" not in pattern[2:]
|
||||
|
||||
|
||||
def _host_matches_pattern(hostname: str, pattern: str) -> bool:
|
||||
"""Match a hostname against an exact host or leading ``*.`` wildcard."""
|
||||
hostname = hostname.lower()
|
||||
pattern = pattern.lower()
|
||||
if pattern.startswith("*.") and _is_valid_host_pattern(pattern):
|
||||
return hostname.endswith(pattern[1:])
|
||||
return hostname == pattern
|
||||
return pattern.startswith("*.") and "*" not in pattern[2:]
|
||||
|
||||
|
||||
def _norm(value: Any) -> Any:
|
||||
@@ -234,5 +224,8 @@ def find_entries_for_url(
|
||||
return [
|
||||
e
|
||||
for e in entries
|
||||
if any(_host_matches_pattern(hostname, pattern) for pattern in e.hosts)
|
||||
if any(
|
||||
pattern == hostname or fnmatch(hostname, pattern)
|
||||
for pattern in e.hosts
|
||||
)
|
||||
]
|
||||
|
||||
@@ -13,18 +13,13 @@ from __future__ import annotations
|
||||
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
from fnmatch import fnmatch
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .._download_security import is_safe_download_redirect
|
||||
from . import get_provider
|
||||
from .config import (
|
||||
AuthConfigEntry,
|
||||
_default_config_path,
|
||||
_host_matches_pattern,
|
||||
find_entries_for_url,
|
||||
load_auth_config,
|
||||
)
|
||||
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
|
||||
|
||||
|
||||
_config_override: list[AuthConfigEntry] | None = None
|
||||
@@ -59,7 +54,8 @@ def _load_config() -> list[AuthConfigEntry]:
|
||||
|
||||
def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
|
||||
"""Return True if *hostname* matches any pattern in *hosts*."""
|
||||
return any(_host_matches_pattern(hostname, pattern) for pattern in hosts)
|
||||
hostname = hostname.lower()
|
||||
return any(p == hostname or fnmatch(hostname, p) for p in hosts)
|
||||
|
||||
|
||||
RedirectValidator = Callable[[str, str], None]
|
||||
|
||||
@@ -82,21 +82,7 @@ def active_integration(project_root: Path) -> str | None:
|
||||
except BundlerError:
|
||||
return None
|
||||
if isinstance(data, dict):
|
||||
# ``default_integration`` first, matching the canonical reader in
|
||||
# ``integration_state`` (line 199):
|
||||
# ``state.get("default_integration") or state.get("integration")``.
|
||||
# ``write_integration_json`` writes both keys, so a marker produced by
|
||||
# the current CLI already resolved through the ``integration`` alias --
|
||||
# this is about which field is authoritative when they disagree, and
|
||||
# about resolving a marker that carries only ``default_integration``
|
||||
# (hand-edited, or written by anything that follows the canonical
|
||||
# reader's shape). ``integration``/``id``/``active`` stay as fallbacks.
|
||||
value = (
|
||||
data.get("default_integration")
|
||||
or data.get("integration")
|
||||
or data.get("id")
|
||||
or data.get("active")
|
||||
)
|
||||
value = data.get("integration") or data.get("id") or data.get("active")
|
||||
if isinstance(value, str) and value:
|
||||
return value
|
||||
return None
|
||||
|
||||
@@ -18,7 +18,7 @@ from urllib.request import url2pathname
|
||||
from ..._assets import _locate_core_pack, _repo_root
|
||||
from ..._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
|
||||
from .. import BundlerError
|
||||
from ..lib.yamlio import load_json, loads_json
|
||||
from ..lib.yamlio import loads_json
|
||||
from ..models.catalog import CatalogSource
|
||||
from ..models.manifest import ComponentRef
|
||||
|
||||
@@ -145,13 +145,13 @@ def make_catalog_fetcher(*, allow_network: bool = True):
|
||||
path = _file_url_to_path(parsed)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"Catalog file not found: {path}")
|
||||
return load_json(path)
|
||||
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
|
||||
|
||||
if scheme == "" or _is_windows_drive_path(url):
|
||||
path = Path(url)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"Catalog file not found: {path}")
|
||||
return load_json(path)
|
||||
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
|
||||
|
||||
if scheme in ("http", "https"):
|
||||
if not allow_network:
|
||||
|
||||
@@ -93,11 +93,9 @@ def build_bundle(
|
||||
# extraction, but collapse to two canonical modes (0755 when any
|
||||
# execute bit is set on the source, otherwise 0644) so identical
|
||||
# inputs yield a byte-for-byte identical artifact.
|
||||
with file_path.open("rb") as fh:
|
||||
st = os.fstat(fh.fileno())
|
||||
mode = 0o755 if st.st_mode & 0o111 else 0o644
|
||||
info.external_attr = mode << 16
|
||||
archive.writestr(info, fh.read())
|
||||
mode = 0o755 if file_path.stat().st_mode & 0o111 else 0o644
|
||||
info.external_attr = mode << 16
|
||||
archive.writestr(info, file_path.read_bytes())
|
||||
|
||||
return BuildResult(artifact_path=artifact_path, file_count=len(files))
|
||||
|
||||
|
||||
@@ -77,16 +77,6 @@ def resolve_install_plan(
|
||||
|
||||
# FR-019: integration-compatibility — a bundle that pins a different
|
||||
# integration than the project's active one halts (no silent change).
|
||||
#
|
||||
# A blank integration arrives as ``""``, not ``None`` — which is not a usable
|
||||
# integration id but satisfied NEITHER guard below (the first is a truthiness
|
||||
# test, the second an ``is None`` test), so a pinned bundle was silently
|
||||
# adopted: precisely the outcome this guard exists to prevent. Treat blank as
|
||||
# indeterminate, and strip first like the writer
|
||||
# (``integration_state.clean_integration_key``) so a padded value is not
|
||||
# reported as clashing with itself.
|
||||
if active_integration is not None:
|
||||
active_integration = active_integration.strip() or None
|
||||
effective_integration = active_integration
|
||||
if manifest.integration is not None:
|
||||
required = manifest.integration.id
|
||||
|
||||
@@ -14,8 +14,8 @@ from pathlib import Path
|
||||
import typer
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited
|
||||
from ..._console import console, err_console
|
||||
from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited
|
||||
from ...bundler import BundlerError
|
||||
from ...bundler.lib.project import (
|
||||
active_integration,
|
||||
@@ -45,12 +45,7 @@ def _fail(message: str) -> None:
|
||||
"""Print an actionable error to stderr and exit non-zero."""
|
||||
# Use the stderr console so the error never lands on stdout, which under
|
||||
# ``--json`` carries the machine-readable payload and must stay parseable.
|
||||
# Escape the message: every caller passes ``str(exc)`` from a BundlerError
|
||||
# that interpolates untrusted data (a CLI argument, a catalog url, a
|
||||
# bundle.yml field), so a '[...]' in it would be parsed as a Rich style tag
|
||||
# -- silently swallowing the text, or raising MarkupError on an unbalanced
|
||||
# closer and replacing the whole message with a traceback.
|
||||
err_console.print(f"[red]Error:[/red] {_escape_markup(message)}", style=None)
|
||||
err_console.print(f"[red]Error:[/red] {message}", style=None)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@@ -124,8 +119,6 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N
|
||||
preset=None,
|
||||
integration=integration,
|
||||
integration_options=None,
|
||||
extensions=None,
|
||||
trust_extension_urls=False,
|
||||
)
|
||||
except typer.Exit as exc:
|
||||
if exc.exit_code:
|
||||
@@ -136,13 +129,13 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N
|
||||
|
||||
def _resolve_init_integration(override: str | None, manifest) -> str:
|
||||
"""Precedence (FR-013): explicit override → bundle-declared → default."""
|
||||
from ..._agent_config import resolve_default_init_integration
|
||||
from ..._agent_config import DEFAULT_INIT_INTEGRATION
|
||||
|
||||
if override:
|
||||
return override
|
||||
if manifest is not None and manifest.integration is not None:
|
||||
return manifest.integration.id
|
||||
return resolve_default_init_integration()
|
||||
return DEFAULT_INIT_INTEGRATION
|
||||
|
||||
|
||||
# ===== Consume =====
|
||||
@@ -337,10 +330,9 @@ def bundle_list(
|
||||
console.print("\n[bold cyan]Installed bundles:[/bold cyan]\n")
|
||||
for record in records:
|
||||
console.print(
|
||||
f" [bold]{_escape_markup(str(record.bundle_id))}[/bold] "
|
||||
f"v{_escape_markup(str(record.version))} "
|
||||
f" [bold]{record.bundle_id}[/bold] v{record.version} "
|
||||
f"[dim]({len(record.contributed_components)} components, "
|
||||
f"installed {_escape_markup(str(record.installed_at))})[/dim]"
|
||||
f"installed {record.installed_at})[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@@ -400,13 +392,13 @@ def bundle_install(
|
||||
)
|
||||
console.print(
|
||||
f"[cyan]No Spec Kit project here; initializing with integration "
|
||||
f"'{_escape_markup(str(init_integration))}'…[/cyan]"
|
||||
f"'{init_integration}'…[/cyan]"
|
||||
)
|
||||
_run_init(init_integration, script_type=_default_script_type(), offline=offline)
|
||||
project_root = require_project_root()
|
||||
|
||||
for overlap in _bundle_overlaps(project_root, manifest, offline=offline):
|
||||
console.print(f"[yellow]![/yellow] {_escape_markup(str(overlap))}")
|
||||
console.print(f"[yellow]![/yellow] {overlap}")
|
||||
|
||||
# For an already-initialized project, the project's recorded active
|
||||
# integration is authoritative — an explicit --integration must not be
|
||||
@@ -421,7 +413,7 @@ def bundle_install(
|
||||
integration_explicit=bool(integration) and detected is None,
|
||||
)
|
||||
for warning in plan.warnings:
|
||||
console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}")
|
||||
console.print(f"[yellow]![/yellow] {warning}")
|
||||
|
||||
result = install_bundle(
|
||||
project_root,
|
||||
@@ -434,7 +426,7 @@ def bundle_install(
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Installed '{_escape_markup(str(result.bundle_id))}' "
|
||||
f"[green]✓[/green] Installed '{result.bundle_id}' "
|
||||
f"({len(result.installed)} added, {len(result.skipped)} already present)."
|
||||
)
|
||||
|
||||
@@ -486,10 +478,7 @@ def bundle_update(
|
||||
integration_explicit=bool(integration) and detected is None,
|
||||
)
|
||||
install_bundle(project_root, plan, installer, manifest=manifest, refresh=True)
|
||||
console.print(
|
||||
f"[green]✓[/green] Updated '{_escape_markup(str(target))}' "
|
||||
f"to v{_escape_markup(str(plan.version))}."
|
||||
)
|
||||
console.print(f"[green]✓[/green] Updated '{target}' to v{plan.version}.")
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
@@ -511,7 +500,7 @@ def bundle_remove(
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Removed '{_escape_markup(str(result.bundle_id))}' "
|
||||
f"[green]✓[/green] Removed '{result.bundle_id}' "
|
||||
f"({len(result.uninstalled)} uninstalled, {len(result.skipped)} kept for other bundles)."
|
||||
)
|
||||
|
||||
@@ -551,16 +540,13 @@ def bundle_validate(
|
||||
return
|
||||
|
||||
for warning in report.warnings:
|
||||
console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}")
|
||||
console.print(f"[yellow]![/yellow] {warning}")
|
||||
if not report.ok:
|
||||
console.print("[red]Manifest is invalid:[/red]")
|
||||
for error in report.errors:
|
||||
console.print(f" [red]-[/red] {_escape_markup(str(error))}")
|
||||
console.print(f" [red]-[/red] {error}")
|
||||
raise typer.Exit(code=1)
|
||||
console.print(
|
||||
f"[green]✓[/green] {_escape_markup(str(manifest.bundle.id))} "
|
||||
"is well-formed and valid."
|
||||
)
|
||||
console.print(f"[green]✓[/green] {manifest.bundle.id} is well-formed and valid.")
|
||||
|
||||
|
||||
@bundle_app.command("build")
|
||||
@@ -583,9 +569,8 @@ def bundle_build(
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Built {_escape_markup(result.artifact_path.name)} "
|
||||
f"({result.file_count} files) → "
|
||||
f"{_escape_markup(str(result.artifact_path))}"
|
||||
f"[green]✓[/green] Built {result.artifact_path.name} "
|
||||
f"({result.file_count} files) → {result.artifact_path}"
|
||||
)
|
||||
|
||||
|
||||
@@ -604,7 +589,7 @@ def bundle_init(
|
||||
init_integration = _resolve_init_integration(integration, None)
|
||||
console.print(
|
||||
f"[cyan]Initializing a Spec Kit project with integration "
|
||||
f"'{_escape_markup(str(init_integration))}'…[/cyan]"
|
||||
f"'{init_integration}'…[/cyan]"
|
||||
)
|
||||
_run_init(init_integration, script_type=_default_script_type(), offline=offline)
|
||||
project_root = require_project_root()
|
||||
@@ -612,10 +597,7 @@ def bundle_init(
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Spec Kit project ready at "
|
||||
f"{_escape_markup(str(project_root))}."
|
||||
)
|
||||
console.print(f"[green]✓[/green] Spec Kit project ready at {project_root}.")
|
||||
if bundle:
|
||||
bundle_install(bundle, integration=integration, offline=offline)
|
||||
|
||||
@@ -639,11 +621,10 @@ def catalog_list() -> None:
|
||||
only_builtin = all(s.scope == Scope.BUILTIN for s in sources)
|
||||
for source in sources:
|
||||
console.print(
|
||||
f" [bold]{_escape_markup(str(source.id))}[/bold] "
|
||||
f"priority={source.priority} "
|
||||
f" [bold]{source.id}[/bold] priority={source.priority} "
|
||||
f"policy={source.install_policy.value} scope={source.scope.value}"
|
||||
)
|
||||
console.print(f" [dim]{_escape_markup(str(source.url))}[/dim]")
|
||||
console.print(f" [dim]{source.url}[/dim]")
|
||||
if only_builtin:
|
||||
console.print("\n[dim]Using the built-in default stack.[/dim]")
|
||||
|
||||
@@ -668,7 +649,7 @@ def catalog_add(
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' "
|
||||
f"[green]✓[/green] Added catalog '{source.id}' "
|
||||
f"(priority {source.priority}, {source.install_policy.value})."
|
||||
)
|
||||
|
||||
@@ -687,10 +668,7 @@ def catalog_remove(
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Removed catalog source "
|
||||
f"'{_escape_markup(str(removed))}'."
|
||||
)
|
||||
console.print(f"[green]✓[/green] Removed catalog source '{removed}'.")
|
||||
|
||||
|
||||
# ZIP magic-byte signatures used to detect .zip payloads from REST API asset
|
||||
@@ -772,6 +750,8 @@ def _local_manifest_source(arg: str):
|
||||
return BundleManifest.from_file(manifest_path)
|
||||
|
||||
if candidate.suffix == ".zip":
|
||||
import io
|
||||
|
||||
import yaml as _yaml
|
||||
|
||||
from ..._download_security import open_zip_bounded, read_zip_member_limited
|
||||
@@ -789,29 +769,7 @@ def _local_manifest_source(arg: str):
|
||||
error_type=BundlerError,
|
||||
label="bundle manifest",
|
||||
)
|
||||
# The bounded-zip helpers above keep archive failures inside the
|
||||
# BundlerError contract, but the manifest bytes need the same
|
||||
# treatment as yamlio.load_yaml: decode as UTF-8 explicitly —
|
||||
# feeding PyYAML the byte stream would let its Reader auto-detect
|
||||
# a UTF-16 BOM and accept a manifest the directory and bundle.yml
|
||||
# sources reject.
|
||||
try:
|
||||
text = raw.decode("utf-8")
|
||||
except UnicodeError as exc:
|
||||
raise BundlerError(
|
||||
f"Could not read bundle.yml inside '{candidate}': {exc}"
|
||||
) from exc
|
||||
try:
|
||||
data = _yaml.safe_load(text)
|
||||
except _yaml.YAMLError as exc:
|
||||
# The sibling directory/bundle.yml branches reach YAML through
|
||||
# load_yaml(), which turns a parse failure into a BundlerError. This
|
||||
# branch parses inline, so without this it raises a raw YAMLError --
|
||||
# neither a ValueError nor an OSError -- which escapes
|
||||
# bundle_install()'s `except BundlerError` as a traceback.
|
||||
raise BundlerError(
|
||||
f"Invalid YAML in bundle.yml inside '{candidate}': {exc}"
|
||||
) from exc
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"):
|
||||
|
||||
@@ -24,19 +24,8 @@ def event_run(
|
||||
"""Resolve and run an event-driven command script with stdin payload."""
|
||||
from ..events import resolve_and_run_event_command
|
||||
|
||||
# Read payload from stdin if available (capped at 1 MiB to prevent DoS).
|
||||
MAX_STDIN_BYTES = 1 * 1024 * 1024
|
||||
if not sys.stdin.isatty():
|
||||
raw = sys.stdin.read(MAX_STDIN_BYTES)
|
||||
if not sys.stdin.eof:
|
||||
raise typer.Exit(
|
||||
code=1,
|
||||
message="stdin payload exceeds 1 MiB limit; "
|
||||
"truncate or pipe a smaller payload",
|
||||
)
|
||||
payload = raw
|
||||
else:
|
||||
payload = "{}"
|
||||
# Read payload from stdin if available
|
||||
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
|
||||
|
||||
# Run the event command
|
||||
project_root = Path.cwd() # The agent runs events from project root
|
||||
|
||||
@@ -3,22 +3,19 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import shlex
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
from rich.live import Live
|
||||
from rich.markup import escape as _escape_markup
|
||||
from rich.panel import Panel
|
||||
|
||||
from .._agent_config import (
|
||||
AGENT_CONFIG,
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
SCRIPT_TYPE_CHOICES,
|
||||
resolve_default_init_integration,
|
||||
)
|
||||
from .._assets import (
|
||||
_locate_bundled_preset,
|
||||
@@ -33,164 +30,6 @@ def _stdin_is_interactive() -> bool:
|
||||
return sys.stdin.isatty()
|
||||
|
||||
|
||||
def _ext_spec_is_url(ext_spec: str) -> bool:
|
||||
"""Return True when *ext_spec* is an http(s) URL rather than a name/path."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
return urlparse(ext_spec).scheme in ("http", "https")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _confirm_extension_url_trust(
|
||||
url_specs: list[str], *, trust_override: bool
|
||||
) -> dict[str, bool]:
|
||||
"""Resolve trust for each URL-based extension before the Live display.
|
||||
|
||||
URL installs pull an arbitrary external extension, so they get the same
|
||||
default-deny confirmation as ``extension add --from``. Returns a mapping of
|
||||
``url_spec -> approved``. With *trust_override* every URL is pre-approved.
|
||||
In a non-interactive session without the override, every URL is denied
|
||||
(the prompt cannot be answered), mirroring the default-deny posture.
|
||||
"""
|
||||
from rich.markup import escape as _escape_markup
|
||||
from rich.panel import Panel
|
||||
|
||||
approvals: dict[str, bool] = {}
|
||||
interactive = _stdin_is_interactive()
|
||||
for spec in url_specs:
|
||||
if trust_override:
|
||||
approvals[spec] = True
|
||||
continue
|
||||
if not interactive:
|
||||
approvals[spec] = False
|
||||
continue
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold]You are installing an extension from an external URL that is not\n"
|
||||
"listed in any of your configured extension catalogs.[/bold]\n\n"
|
||||
f"URL: {_escape_markup(spec)}\n\n"
|
||||
"Only install extensions from sources you trust.",
|
||||
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
approvals[spec] = typer.confirm(
|
||||
f"Install extension from {spec}?", default=False
|
||||
)
|
||||
return approvals
|
||||
|
||||
|
||||
def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_version: str) -> str:
|
||||
"""Install a single extension during ``specify init``.
|
||||
|
||||
Handles bundled extension names, local directory paths, and HTTPS URLs.
|
||||
Returns a short status message on success.
|
||||
Raises ``ValueError`` on failure so the caller can convert it to a
|
||||
tracker error without aborting the entire init.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .._assets import _locate_bundled_extension
|
||||
from ..extensions import ExtensionCatalog, ExtensionError, ExtensionManager
|
||||
from ..extensions._commands import (
|
||||
_resolve_catalog_extension,
|
||||
install_extension_from_url,
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_path)
|
||||
|
||||
# --- URL ---
|
||||
parsed = urlparse(ext_spec)
|
||||
if parsed.scheme in ("http", "https"):
|
||||
try:
|
||||
manifest = install_extension_from_url(
|
||||
manager, project_path, ext_spec, speckit_version
|
||||
)
|
||||
except ExtensionError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
return f"{manifest.name} v{manifest.version} installed"
|
||||
|
||||
# --- Local path ---
|
||||
if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")) or Path(ext_spec).is_absolute():
|
||||
source_path = Path(ext_spec).expanduser().resolve()
|
||||
if not source_path.exists():
|
||||
raise ValueError(f"Directory not found: {source_path}")
|
||||
if not (source_path / "extension.yml").exists():
|
||||
raise ValueError(f"No extension.yml found in {source_path}")
|
||||
manifest = manager.install_from_directory(source_path, speckit_version)
|
||||
return f"{manifest.name} v{manifest.version} installed"
|
||||
|
||||
# --- Bundled extension name or catalog ID ---
|
||||
bundled_path = _locate_bundled_extension(ext_spec)
|
||||
if bundled_path is not None:
|
||||
if manager.registry.is_installed(ext_spec):
|
||||
return "already installed"
|
||||
manifest = manager.install_from_directory(bundled_path, speckit_version)
|
||||
return f"{manifest.name} v{manifest.version} installed"
|
||||
|
||||
# Fall back to catalog
|
||||
catalog = ExtensionCatalog(project_path)
|
||||
ext_info, catalog_error = _resolve_catalog_extension(ext_spec, catalog, "add")
|
||||
if catalog_error:
|
||||
raise ValueError(f"Could not query extension catalog: {catalog_error}")
|
||||
if not ext_info:
|
||||
raise ValueError(f"Extension '{ext_spec}' not found in bundled extensions or catalog")
|
||||
|
||||
resolved_id = ext_info["id"]
|
||||
if resolved_id != ext_spec:
|
||||
bundled_path = _locate_bundled_extension(resolved_id)
|
||||
if bundled_path is not None:
|
||||
if manager.registry.is_installed(resolved_id):
|
||||
return "already installed"
|
||||
manifest = manager.install_from_directory(bundled_path, speckit_version)
|
||||
return f"{manifest.name} v{manifest.version} installed"
|
||||
|
||||
if ext_info.get("bundled") and not ext_info.get("download_url"):
|
||||
from ..extensions import REINSTALL_COMMAND
|
||||
|
||||
raise ValueError(
|
||||
f"Extension '{resolved_id}' is bundled with spec-kit but not found in the installed package. "
|
||||
f"Try reinstalling spec-kit: {REINSTALL_COMMAND}"
|
||||
)
|
||||
|
||||
if not ext_info.get("_install_allowed", True):
|
||||
catalog_name = ext_info.get("_catalog_name", "community")
|
||||
raise ValueError(
|
||||
f"Extension '{ext_spec}' is in the '{catalog_name}' catalog but installation is not allowed from that catalog"
|
||||
)
|
||||
|
||||
zip_path = catalog.download_extension(resolved_id)
|
||||
try:
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version)
|
||||
finally:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
return f"{manifest.name} v{manifest.version} installed"
|
||||
|
||||
|
||||
def _shell_quote_arg(value: str) -> str:
|
||||
"""Quote *value* as one argument for the shells of the host OS.
|
||||
|
||||
The Next Steps ``cd`` line is copy-pasted into whichever shell ran
|
||||
``specify init``, so it is quoted for the host the same way
|
||||
``_version._render_argv`` renders its copy-pasteable installer command:
|
||||
``list2cmdline`` on Windows, ``shlex.quote`` elsewhere. The Windows branch
|
||||
must emit double quotes -- ``cd 'my project'`` is a path-not-found in
|
||||
cmd.exe, while ``cd "my project"`` is accepted by cmd.exe, PowerShell and
|
||||
Git Bash alike. A value needing no quoting is returned unchanged.
|
||||
|
||||
Whitespace only. PowerShell also glob-expands ``[``/``]`` and expands
|
||||
``$``/backtick inside double quotes, so such a name still needs
|
||||
``Set-Location -LiteralPath`` there -- syntax invalid in cmd.exe and sh, so
|
||||
this shell-neutral line cannot cover it.
|
||||
"""
|
||||
return subprocess.list2cmdline([value]) if os.name == "nt" else shlex.quote(value)
|
||||
|
||||
|
||||
def ensure_constitution_from_template(
|
||||
project_path: Path, tracker: StepTracker | None = None
|
||||
) -> None:
|
||||
@@ -303,16 +142,6 @@ def register(app: typer.Typer) -> None:
|
||||
"--integration-options",
|
||||
help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")',
|
||||
),
|
||||
extensions: list[str] | None = typer.Option(
|
||||
None,
|
||||
"--extension",
|
||||
help="Install an extension during initialization (bundled name, local path, or HTTPS URL). Repeatable.",
|
||||
),
|
||||
trust_extension_urls: bool = typer.Option(
|
||||
False,
|
||||
"--trust-extension-urls",
|
||||
help="Pre-authorize installing extensions from external URLs without the interactive trust prompt (required for non-interactive URL installs).",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project.
|
||||
@@ -345,10 +174,6 @@ def register(app: typer.Typer) -> None:
|
||||
specify init --here --integration gemini
|
||||
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
|
||||
specify init my-project --integration claude --preset healthcare-compliance # With preset
|
||||
specify init my-project --integration copilot --extension git # With bundled extension
|
||||
specify init my-project --extension git --extension selftest # Multiple extensions
|
||||
specify init my-project --extension ./my-extensions/custom-ext # Local path extension
|
||||
specify init my-project --extension https://example.com/extensions/my-ext.zip --trust-extension-urls # URL extension (non-interactive)
|
||||
"""
|
||||
# Lazy imports to avoid circular dependency — __init__.py imports this module
|
||||
from .. import (
|
||||
@@ -373,10 +198,7 @@ def register(app: typer.Typer) -> None:
|
||||
if integration:
|
||||
resolved_integration = get_integration(integration)
|
||||
if not resolved_integration:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Unknown integration: "
|
||||
f"'{_escape_markup(str(integration))}'"
|
||||
)
|
||||
console.print(f"[red]Error:[/red] Unknown integration: '{integration}'")
|
||||
available = ", ".join(sorted(INTEGRATION_REGISTRY))
|
||||
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
||||
raise typer.Exit(1)
|
||||
@@ -453,27 +275,26 @@ def register(app: typer.Typer) -> None:
|
||||
project_path = Path(project_name).resolve()
|
||||
dir_existed_before = project_path.exists()
|
||||
if project_path.exists():
|
||||
safe_name = _escape_markup(str(project_name))
|
||||
if not project_path.is_dir():
|
||||
console.print(
|
||||
f"[red]Error:[/red] '{safe_name}' exists but is not a directory."
|
||||
f"[red]Error:[/red] '{project_name}' exists but is not a directory."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
existing_items = list(project_path.iterdir())
|
||||
if force:
|
||||
if existing_items:
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Directory '{safe_name}' is not empty ({len(existing_items)} items)"
|
||||
f"[yellow]Warning:[/yellow] Directory '{project_name}' is not empty ({len(existing_items)} items)"
|
||||
)
|
||||
console.print(
|
||||
"[yellow]Template files will be merged with existing content and may overwrite existing files[/yellow]"
|
||||
)
|
||||
console.print(
|
||||
f"[cyan]--force supplied: merging into existing directory '[cyan]{safe_name}[/cyan]'[/cyan]"
|
||||
f"[cyan]--force supplied: merging into existing directory '[cyan]{project_name}[/cyan]'[/cyan]"
|
||||
)
|
||||
else:
|
||||
error_panel = Panel(
|
||||
f"Directory already exists: '[cyan]{safe_name}[/cyan]'\n"
|
||||
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
|
||||
"Please choose a different project name or remove the existing directory.\n"
|
||||
"Use [bold]--force[/bold] to merge into the existing directory.",
|
||||
title="[red]Directory Conflict[/red]",
|
||||
@@ -487,23 +308,22 @@ def register(app: typer.Typer) -> None:
|
||||
if integration:
|
||||
if integration not in AGENT_CONFIG:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid integration '{_escape_markup(str(integration))}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
|
||||
f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
selected_ai = integration
|
||||
elif not _stdin_is_interactive():
|
||||
default_integration = resolve_default_init_integration()
|
||||
console.print(
|
||||
f"[dim]Non-interactive session detected: defaulting to '{default_integration}'. "
|
||||
f"[dim]Non-interactive session detected: defaulting to '{DEFAULT_INIT_INTEGRATION}'. "
|
||||
"Use --integration to choose a different agent.[/dim]"
|
||||
)
|
||||
selected_ai = default_integration
|
||||
selected_ai = DEFAULT_INIT_INTEGRATION
|
||||
else:
|
||||
ai_choices = {key: config["name"] for key, config in AGENT_CONFIG.items()}
|
||||
selected_ai = select_with_arrows(
|
||||
ai_choices,
|
||||
"Choose your coding agent integration:",
|
||||
resolve_default_init_integration(),
|
||||
DEFAULT_INIT_INTEGRATION,
|
||||
)
|
||||
|
||||
if not integration:
|
||||
@@ -526,14 +346,12 @@ def register(app: typer.Typer) -> None:
|
||||
setup_lines = [
|
||||
"[cyan]Specify Project Setup[/cyan]",
|
||||
"",
|
||||
f"{'Project':<15} [green]{_escape_markup(project_path.name)}[/green]",
|
||||
f"{'Working Path':<15} [dim]{_escape_markup(str(current_dir))}[/dim]",
|
||||
f"{'Project':<15} [green]{project_path.name}[/green]",
|
||||
f"{'Working Path':<15} [dim]{current_dir}[/dim]",
|
||||
]
|
||||
|
||||
if not here:
|
||||
setup_lines.append(
|
||||
f"{'Target Path':<15} [dim]{_escape_markup(str(project_path))}[/dim]"
|
||||
)
|
||||
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
|
||||
|
||||
console.print(
|
||||
Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))
|
||||
@@ -560,7 +378,7 @@ def register(app: typer.Typer) -> None:
|
||||
if script_type:
|
||||
if script_type not in SCRIPT_TYPE_CHOICES:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid script type '{_escape_markup(str(script_type))}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
|
||||
f"[red]Error:[/red] Invalid script type '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
selected_script = script_type
|
||||
@@ -595,29 +413,10 @@ def register(app: typer.Typer) -> None:
|
||||
("chmod", "Ensure scripts executable"),
|
||||
("constitution", "Constitution setup"),
|
||||
("workflow", "Install bundled workflow"),
|
||||
("final", "Finalize"),
|
||||
]:
|
||||
tracker.add(key, label)
|
||||
|
||||
if extensions:
|
||||
for i, ext_spec in enumerate(extensions):
|
||||
tracker.add(
|
||||
f"extension-{i}", f"Install extension: {_escape_markup(ext_spec)}"
|
||||
)
|
||||
|
||||
tracker.add("final", "Finalize")
|
||||
|
||||
# Resolve trust for URL-based extensions BEFORE entering the Live
|
||||
# display: the confirmation prompt cannot be shown/answered underneath
|
||||
# the Rich Live spinner. URL installs are default-deny unless the user
|
||||
# confirms interactively or passes --trust-extension-urls.
|
||||
extension_url_approvals: dict[str, bool] = {}
|
||||
if extensions:
|
||||
url_specs = [e for e in extensions if _ext_spec_is_url(e)]
|
||||
if url_specs:
|
||||
extension_url_approvals = _confirm_extension_url_trust(
|
||||
url_specs, trust_override=trust_extension_urls
|
||||
)
|
||||
|
||||
# Disable transient mode on Windows: PowerShell 5.1's legacy console
|
||||
# hangs when Rich tries to restore cursor state via VT escape sequences.
|
||||
_transient = sys.platform != "win32"
|
||||
@@ -661,30 +460,6 @@ def register(app: typer.Typer) -> None:
|
||||
)
|
||||
manifest.save()
|
||||
|
||||
if force:
|
||||
from ..integrations._helpers import (
|
||||
_register_extensions_for_agent,
|
||||
_register_presets_for_agent,
|
||||
)
|
||||
|
||||
_register_extensions_for_agent(
|
||||
project_path,
|
||||
resolved_integration.key,
|
||||
force=True,
|
||||
continuing=(
|
||||
"The project was re-initialized, but installed extensions"
|
||||
" may need re-registration."
|
||||
),
|
||||
)
|
||||
_register_presets_for_agent(
|
||||
project_path,
|
||||
resolved_integration.key,
|
||||
continuing=(
|
||||
"The project was re-initialized, but installed presets"
|
||||
" may need re-registration."
|
||||
),
|
||||
)
|
||||
|
||||
integration_settings = _with_integration_setting(
|
||||
{},
|
||||
resolved_integration.key,
|
||||
@@ -851,44 +626,6 @@ def register(app: typer.Typer) -> None:
|
||||
continuing="Continuing without the optional preset.",
|
||||
)
|
||||
|
||||
# Install extensions specified via --extension
|
||||
if extensions:
|
||||
from ..extensions._commands import _refresh_events_and_warn
|
||||
|
||||
speckit_ver = get_speckit_version()
|
||||
any_extension_installed = False
|
||||
for i, ext_spec in enumerate(extensions):
|
||||
tracker.start(f"extension-{i}")
|
||||
# Skip URL extensions the user did not confirm as trusted
|
||||
# (default-deny; resolved before the Live display).
|
||||
if _ext_spec_is_url(ext_spec) and not extension_url_approvals.get(
|
||||
ext_spec, False
|
||||
):
|
||||
tracker.error(
|
||||
f"extension-{i}",
|
||||
"skipped: untrusted URL not confirmed "
|
||||
"(use --trust-extension-urls)",
|
||||
)
|
||||
continue
|
||||
try:
|
||||
status_msg = _install_extension_during_init(
|
||||
project_path, ext_spec, speckit_ver
|
||||
)
|
||||
tracker.complete(f"extension-{i}", status_msg)
|
||||
any_extension_installed = True
|
||||
except Exception as ext_err:
|
||||
sanitized_ext = str(ext_err).replace("\n", " ").strip()
|
||||
tracker.error(
|
||||
f"extension-{i}",
|
||||
f"failed: {_escape_markup(sanitized_ext[:120])}",
|
||||
)
|
||||
|
||||
# Refresh native event configuration once after the batch so
|
||||
# that an extension declaring ``events:`` has its hooks
|
||||
# activated, mirroring the ``extension add`` path.
|
||||
if any_extension_installed:
|
||||
_refresh_events_and_warn(project_path)
|
||||
|
||||
# Seed the constitution AFTER preset installation so that a
|
||||
# preset-provided constitution-template (resolved via the
|
||||
# priority stack) wins over the core template.
|
||||
@@ -942,7 +679,7 @@ def register(app: typer.Typer) -> None:
|
||||
if agent_folder:
|
||||
security_notice = Panel(
|
||||
f"Some agents may store credentials, auth tokens, or other identifying and private artifacts in the agent folder within your project.\n"
|
||||
f"Consider adding [cyan]{_escape_markup(str(agent_folder))}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
||||
f"Consider adding [cyan]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
||||
title="[yellow]Agent Folder Security[/yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
@@ -953,7 +690,7 @@ def register(app: typer.Typer) -> None:
|
||||
steps_lines = []
|
||||
if not here:
|
||||
steps_lines.append(
|
||||
f"1. Go to the project folder: [cyan]cd {_escape_markup(_shell_quote_arg(str(project_name)))}[/cyan]"
|
||||
f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]"
|
||||
)
|
||||
step_num = 2
|
||||
else:
|
||||
|
||||
+35
-456
@@ -251,7 +251,7 @@ def _resolve_argv(template_path, project_root, ext_id):
|
||||
return [str(script_abs), *rest]
|
||||
|
||||
|
||||
def _run_inline(command_name, payload, project_root, timeout, envelope="plain", native_event=""):
|
||||
def _run_inline(command_name, payload, project_root, timeout):
|
||||
"""Resolve and run the event command with stdlib only (no specify_cli)."""
|
||||
template_path, ext_id = _find_command_template(command_name, project_root)
|
||||
if not template_path:
|
||||
@@ -269,7 +269,7 @@ def _run_inline(command_name, payload, project_root, timeout, envelope="plain",
|
||||
cwd=str(project_root),
|
||||
)
|
||||
if result.stdout:
|
||||
_emit(result.stdout, envelope, native_event)
|
||||
sys.stdout.write(result.stdout)
|
||||
if result.returncode != 0:
|
||||
if result.stderr:
|
||||
sys.stderr.write(result.stderr)
|
||||
@@ -283,53 +283,6 @@ def _run_inline(command_name, payload, project_root, timeout, envelope="plain",
|
||||
return 2
|
||||
|
||||
|
||||
def _emit(output, envelope, native_event=""):
|
||||
"""Write handler output to stdout in the agent's context-injection shape.
|
||||
|
||||
Not every agent injects a hook's plain-text stdout as model context:
|
||||
Gemini/Tabnine/Qwen/Devin are JSON-only protocols (plain text becomes
|
||||
user-facing noise, never context), Copilot discards non-JSON stdout, and
|
||||
Cursor parses stdout as JSON. The native hook command passes the envelope
|
||||
as the dispatcher's 5th argument (see events_context_envelope on the
|
||||
integration classes), and the native event name as the 6th argument so
|
||||
hookSpecificOutput can include hookEventName:
|
||||
|
||||
hookSpecificOutput → {"hookSpecificOutput": {"hookEventName": ..., "additionalContext": ...}}
|
||||
additionalContext → {"additionalContext": ...} (top-level, Copilot)
|
||||
additional_context → {"additional_context": ...} (top-level, Cursor)
|
||||
hook_specific_output → {"decision": "allow", "hook_specific_output":
|
||||
{"additional_context": ...}} (Vibe: any non-empty
|
||||
stdout must parse as a HookStructuredResponse or
|
||||
the hook is reported failed and output dropped)
|
||||
suppress → emit nothing (strict-JSON agents on events whose
|
||||
output can't be used)
|
||||
plain (default) → passthrough (Claude/Codex inject plain stdout)
|
||||
|
||||
Empty output emits nothing under any envelope (an empty additionalContext
|
||||
is useless noise).
|
||||
"""
|
||||
if not output:
|
||||
return
|
||||
if envelope == "suppress":
|
||||
return
|
||||
if envelope == "hookSpecificOutput":
|
||||
payload = {"additionalContext": output}
|
||||
if native_event:
|
||||
payload["hookEventName"] = native_event
|
||||
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\\n")
|
||||
return
|
||||
if envelope == "additionalContext":
|
||||
sys.stdout.write(json.dumps({"additionalContext": output}) + "\\n")
|
||||
return
|
||||
if envelope == "additional_context":
|
||||
sys.stdout.write(json.dumps({"additional_context": output}) + "\\n")
|
||||
return
|
||||
if envelope == "hook_specific_output":
|
||||
sys.stdout.write(json.dumps({"decision": "allow", "hook_specific_output": {"additional_context": output}}) + "\\n")
|
||||
return
|
||||
sys.stdout.write(output)
|
||||
|
||||
|
||||
def main():
|
||||
if len(sys.argv) < 3:
|
||||
sys.exit(0)
|
||||
@@ -344,17 +297,6 @@ def main():
|
||||
timeout = int(sys.argv[3])
|
||||
except (TypeError, ValueError):
|
||||
timeout = 120
|
||||
# Optional 5th arg: context-injection envelope for stdout (C13): plain
|
||||
# (default), hookSpecificOutput, additionalContext, additional_context,
|
||||
# hook_specific_output, or suppress. Unknown values fall back to plain
|
||||
# passthrough.
|
||||
envelope = sys.argv[4] if len(sys.argv) >= 5 else "plain"
|
||||
if envelope not in ("plain", "hookSpecificOutput", "additionalContext", "additional_context", "hook_specific_output", "suppress"):
|
||||
envelope = "plain"
|
||||
# Optional 6th arg: native event name for hookSpecificOutput's
|
||||
# hookEventName field (required by Qwen's hooks spec; included by
|
||||
# Gemini/Tabnine/Devin which derive from the same protocol).
|
||||
native_event = sys.argv[5] if len(sys.argv) >= 6 else ""
|
||||
payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
|
||||
project_root = Path(__file__).parent.parent.resolve()
|
||||
|
||||
@@ -365,14 +307,14 @@ def main():
|
||||
from specify_cli.events import resolve_and_run_event_command
|
||||
sys.exit(
|
||||
resolve_and_run_event_command(
|
||||
command_name, _event_name, payload, project_root, timeout=timeout, envelope=envelope, native_event=native_event
|
||||
command_name, _event_name, payload, project_root, timeout=timeout
|
||||
)
|
||||
)
|
||||
except (ImportError, TypeError):
|
||||
except ImportError:
|
||||
pass
|
||||
|
||||
# Fallback: self-contained stdlib resolver (one-time/temporary installs).
|
||||
sys.exit(_run_inline(command_name, payload, project_root, timeout, envelope, native_event))
|
||||
sys.exit(_run_inline(command_name, payload, project_root, timeout))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
@@ -420,21 +362,17 @@ function resolveDispatcher(directory: string): void {{
|
||||
) as string;
|
||||
}}
|
||||
|
||||
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): string {{
|
||||
if (!DISPATCHER) return '';
|
||||
function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): void {{
|
||||
if (!DISPATCHER) return;
|
||||
try {{
|
||||
// execFileSync with an argv array invokes the interpreter directly — no
|
||||
// shell — so command/event strings with metacharacters can't break out
|
||||
// of the dispatcher argument (C9). The dispatcher arg is seconds; the
|
||||
// execFileSync timeout is ms with a buffer so the outer cap fires after
|
||||
// the dispatcher's inner subprocess (S3). stdout is captured and
|
||||
// returned so context-injection hooks (experimental.chat.system.transform,
|
||||
// chat.message) can push it into their outputs; stderr stays inherited so
|
||||
// dispatcher errors remain visible (C11).
|
||||
return execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
|
||||
// the dispatcher's inner subprocess (S3).
|
||||
execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
|
||||
input: JSON.stringify({{ input, output }}),
|
||||
stdio: ['pipe', 'pipe', 'inherit'],
|
||||
encoding: 'utf-8',
|
||||
stdio: ['pipe', 'inherit', 'inherit'],
|
||||
timeout: (timeoutSec + {buffer}) * 1000,
|
||||
}});
|
||||
}} catch (e) {{
|
||||
@@ -444,12 +382,6 @@ function runEvent(command: string, event: string, input: any, output: any, timeo
|
||||
}}
|
||||
}}
|
||||
|
||||
// Cache session_start handler output per sessionID so non-idempotent
|
||||
// handlers (setup, telemetry, file-mutating scripts) run once per session
|
||||
// instead of on every LLM request (experimental.chat.system.transform
|
||||
// fires per LLM turn). Evicted on session.deleted.
|
||||
const sessionStartCache = new Map<string, string>();
|
||||
|
||||
{event_entries}
|
||||
|
||||
export default (async ({{ client, project, directory, $ }}) => {{
|
||||
@@ -556,15 +488,7 @@ def _resolve_event_command_argv(
|
||||
"""
|
||||
from .integrations.base import IntegrationBase
|
||||
|
||||
try:
|
||||
content = template_path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# An unreadable or undecodable template cannot declare a runnable
|
||||
# script. Degrade to "no argv" like every other failure in this
|
||||
# resolver (missing frontmatter, malformed YAML, absent scripts)
|
||||
# instead of leaking a raw traceback through
|
||||
# resolve_and_run_event_command.
|
||||
return None
|
||||
content = template_path.read_text(encoding="utf-8")
|
||||
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
@@ -600,13 +524,7 @@ def _resolve_event_command_argv(
|
||||
else:
|
||||
base = project_root / ".specify"
|
||||
|
||||
try:
|
||||
tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
|
||||
except ValueError:
|
||||
# Mirror the generated dispatcher's _resolve_argv: a scripts: value
|
||||
# shlex cannot tokenize (e.g. an unclosed quote) declares no runnable
|
||||
# script, so degrade to "no argv" instead of raising.
|
||||
return None
|
||||
tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
|
||||
if not tokens:
|
||||
return None
|
||||
script_abs = base / tokens[0]
|
||||
@@ -667,28 +585,12 @@ def resolve_and_run_event_command(
|
||||
project_root: Path,
|
||||
*,
|
||||
timeout: int = 120,
|
||||
envelope: str = "plain",
|
||||
native_event: str = "",
|
||||
) -> int:
|
||||
"""Core entry point to resolve and execute an event-driven command.
|
||||
|
||||
*timeout* is the per-handler timeout in seconds, passed through from the
|
||||
native hook config via the dispatcher (S4) so a handler configured above
|
||||
the previous fixed 120s cap can run for its full duration.
|
||||
|
||||
*envelope* selects how the handler's stdout is emitted for the agent's
|
||||
context-injection protocol (C13): ``plain`` passthrough (Claude/Codex
|
||||
inject plain stdout), ``hookSpecificOutput``/``additionalContext``/
|
||||
``additional_context`` JSON wrappers (Gemini/Tabnine/Qwen/Devin, Copilot,
|
||||
Cursor respectively), ``hook_specific_output`` (Vibe's
|
||||
HookStructuredResponse — any non-empty stdout that isn't valid JSON is
|
||||
reported as a hook failure and dropped), or ``suppress`` (strict-JSON
|
||||
agents on events whose output can't be used).
|
||||
|
||||
*native_event* is the agent's native hookEventName (e.g. ``"SessionStart"``),
|
||||
required inside ``hookSpecificOutput`` by Qwen's hooks spec (and included
|
||||
by the Claude Code hooks spec Gemini/Tabnine/Devin derive from). Only
|
||||
used when *envelope* is ``hookSpecificOutput``.
|
||||
"""
|
||||
template_path, ext_id = _find_command_template(command_name, project_root)
|
||||
if not template_path:
|
||||
@@ -708,7 +610,7 @@ def resolve_and_run_event_command(
|
||||
cwd=str(project_root),
|
||||
)
|
||||
if result.stdout:
|
||||
_emit_event_stdout(result.stdout, envelope, native_event)
|
||||
sys.stdout.write(result.stdout)
|
||||
if result.returncode != 0:
|
||||
if result.stderr:
|
||||
sys.stderr.write(result.stderr)
|
||||
@@ -722,42 +624,6 @@ def resolve_and_run_event_command(
|
||||
return 2
|
||||
|
||||
|
||||
def _emit_event_stdout(output: str, envelope: str, native_event: str = "") -> None:
|
||||
"""Write handler stdout in the agent's context-injection shape (C13).
|
||||
|
||||
Mirrors the ``_emit`` helper inside the generated dispatcher template;
|
||||
keep both in sync. Empty output emits nothing under any envelope.
|
||||
|
||||
*native_event* is the agent's native hookEventName, required inside
|
||||
``hookSpecificOutput`` by Qwen's hooks spec (and included by the
|
||||
Claude Code hooks spec Gemini/Tabnine/Devin derive from).
|
||||
"""
|
||||
if not output:
|
||||
return
|
||||
if envelope == "suppress":
|
||||
return
|
||||
if envelope == "hookSpecificOutput":
|
||||
payload = {"additionalContext": output}
|
||||
if native_event:
|
||||
payload["hookEventName"] = native_event
|
||||
sys.stdout.write(json.dumps({"hookSpecificOutput": payload}) + "\n")
|
||||
return
|
||||
if envelope == "additionalContext":
|
||||
sys.stdout.write(json.dumps({"additionalContext": output}) + "\n")
|
||||
return
|
||||
if envelope == "additional_context":
|
||||
sys.stdout.write(json.dumps({"additional_context": output}) + "\n")
|
||||
return
|
||||
if envelope == "hook_specific_output":
|
||||
# Vibe parses any non-empty hook stdout as a HookStructuredResponse;
|
||||
# plain text would be reported as a hook failure. Wrap it as an
|
||||
# explicit allow with additional_context (injected on post_tool,
|
||||
# harmlessly ignored on pre_tool/post_agent).
|
||||
sys.stdout.write(json.dumps({"decision": "allow", "hook_specific_output": {"additional_context": output}}) + "\n")
|
||||
return
|
||||
sys.stdout.write(output)
|
||||
|
||||
|
||||
# -- Sourcing events map (CLI/Orchestration domain) -------------------------
|
||||
|
||||
# Resolved events map: each canonical event name maps to an *ordered list* of
|
||||
@@ -871,10 +737,8 @@ def resolve_events(
|
||||
if override_file.exists():
|
||||
try:
|
||||
override = yaml.safe_load(override_file.read_text(encoding="utf-8")) or {}
|
||||
except (OSError, UnicodeError, yaml.YAMLError):
|
||||
logger.warning(
|
||||
"Could not read or parse %s; ignoring override", override_file
|
||||
)
|
||||
except yaml.YAMLError:
|
||||
logger.warning("Could not parse %s; ignoring override", override_file)
|
||||
override = {}
|
||||
integrations = override.get("integrations", {}) if isinstance(override, dict) else {}
|
||||
if isinstance(integrations, dict) and integration_key in integrations:
|
||||
@@ -1031,7 +895,7 @@ def collect_extension_events(project_root: Path) -> ResolvedEvents:
|
||||
continue
|
||||
try:
|
||||
data = yaml.safe_load(ext_yml.read_text(encoding="utf-8")) or {}
|
||||
except (UnicodeDecodeError, yaml.YAMLError):
|
||||
except yaml.YAMLError:
|
||||
continue
|
||||
if not isinstance(data, dict):
|
||||
continue
|
||||
@@ -1110,15 +974,6 @@ def _shell_quote(value: str, target_os: str) -> str:
|
||||
"""
|
||||
if target_os == "windows":
|
||||
return "'" + value.replace("'", "''") + "'"
|
||||
if target_os == "cmd":
|
||||
# cmd.exe (Vibe launches hooks via create_subprocess_shell, which is
|
||||
# %COMSPEC% on Windows): single quotes are not quoting there, so a
|
||||
# POSIX-quoted path with spaces would break apart. Double-quote only
|
||||
# when needed; embedded double quotes are doubled (MSVCRT argv
|
||||
# parsing treats "" inside a quoted string as a literal quote).
|
||||
if re.fullmatch(r"[A-Za-z0-9_.\-\\/:]+", value):
|
||||
return value
|
||||
return '"' + value.replace('"', '""') + '"'
|
||||
# "host" and "posix" both use POSIX quoting. On Windows the single-
|
||||
# command-string formats (Claude/Gemini/Qwen/Devin/Tabnine) are run via
|
||||
# Git Bash or the agent's POSIX-ish shell, so POSIX quoting is correct and
|
||||
@@ -1126,17 +981,6 @@ def _shell_quote(value: str, target_os: str) -> str:
|
||||
return shlex.quote(value)
|
||||
|
||||
|
||||
def _vibe_target_os() -> str:
|
||||
"""Quoting target for Vibe hook commands.
|
||||
|
||||
Vibe launches hooks with ``asyncio.create_subprocess_shell`` — the host's
|
||||
native shell: POSIX ``sh`` on Unix, ``cmd.exe`` (%COMSPEC%) on Windows,
|
||||
where POSIX single-quoting is not quoting at all and an interpreter or
|
||||
dispatcher path containing spaces would split.
|
||||
"""
|
||||
return "cmd" if os.name == "nt" else "host"
|
||||
|
||||
|
||||
def _dispatcher_command(
|
||||
integration: IntegrationBase,
|
||||
project_root: Path,
|
||||
@@ -1159,8 +1003,6 @@ def _dispatcher_command(
|
||||
both POSIX and Windows variants into one checked-in file (Copilot): ``host``
|
||||
uses the host-resolved interpreter (venv-aware), while ``posix``/``windows``
|
||||
emit portable interpreters so the config works on either OS (#S4).
|
||||
``cmd`` also uses the host-resolved interpreter but quotes for cmd.exe —
|
||||
for agents that launch hooks through the native Windows shell (Vibe).
|
||||
|
||||
Each component is shell-quoted for the target shell (R2) so an interpreter
|
||||
path with spaces or a command/event containing shell metacharacters is
|
||||
@@ -1174,22 +1016,9 @@ def _dispatcher_command(
|
||||
When *timeout_seconds* is given, the resolved timeout (in the
|
||||
integration's native unit) is appended as a 4th argument so the dispatcher
|
||||
and inner runner honor the per-handler timeout instead of a fixed 120s cap
|
||||
that would kill a handler configured for longer (S4). When omitted, a
|
||||
default of 60s is emitted so the positional argument order
|
||||
(command event timeout envelope native_event) stays aligned — otherwise
|
||||
the envelope would land in the timeout slot and the dispatcher would
|
||||
silently fall back to plain stdout.
|
||||
|
||||
When the integration declares a context-injection envelope for this
|
||||
canonical event (``events_context_envelope``, C13), the envelope token is
|
||||
appended as a 5th argument so the dispatcher wraps stdout in the JSON
|
||||
shape the agent's hook protocol requires. Plain-passthrough agents
|
||||
(Claude/Codex) declare no envelope and get no extra argument.
|
||||
that would kill a handler configured for longer (S4).
|
||||
"""
|
||||
if target_os in ("host", "cmd"):
|
||||
# "cmd" is host-resolved too (venv-aware): it is selected only when
|
||||
# generating on a Windows host for an agent that runs hooks through
|
||||
# cmd.exe (Vibe), and differs from "host" purely in quoting style.
|
||||
if target_os == "host":
|
||||
interpreter = _resolve_interpreter(project_root)
|
||||
else:
|
||||
interpreter = _resolve_interpreter_for_target(target_os)
|
||||
@@ -1207,44 +1036,16 @@ def _dispatcher_command(
|
||||
# operator. Prefix & for the explicit windows target only.
|
||||
prefix = "& " if target_os == "windows" else ""
|
||||
base = f"{prefix}{q_interp} {dispatcher} {q_command} {q_event}"
|
||||
# Always emit the timeout (4th positional arg) so the dispatcher's argv
|
||||
# parsing stays aligned when an envelope (5th) or native_event (6th)
|
||||
# follows. Without it the envelope would land in the timeout slot and
|
||||
# the dispatcher would fall back to plain stdout (R3).
|
||||
resolved_timeout = 60 if timeout_seconds is None else int(timeout_seconds)
|
||||
# R2: the dispatcher interprets this argument as seconds, so pass the
|
||||
# raw seconds — NOT _native_timeout(...) (which converts to ms for
|
||||
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
|
||||
# applied to the native hook timeout field (in the adapter formatters)
|
||||
# so the agent's outer cap fires after the inner subprocess timeout.
|
||||
base += f" {_shell_quote(str(resolved_timeout), target_os)}"
|
||||
envelope = _context_envelope_for(integration, event_name)
|
||||
if envelope:
|
||||
base += f" {_shell_quote(envelope, target_os)}"
|
||||
# hookSpecificOutput requires the native hookEventName inside the
|
||||
# envelope (Qwen's hooks spec marks it mandatory; the Claude Code
|
||||
# hooks spec that Gemini/Tabnine/Devin derive from includes it).
|
||||
# Append the native event name as a 6th dispatcher argument so the
|
||||
# dispatcher can populate hookEventName in the JSON output.
|
||||
if envelope == "hookSpecificOutput":
|
||||
native_event = getattr(integration, "CANONICAL_TO_NATIVE", {}).get(event_name, "")
|
||||
if native_event:
|
||||
base += f" {_shell_quote(native_event, target_os)}"
|
||||
if timeout_seconds is not None:
|
||||
# R2: the dispatcher interprets this argument as seconds, so pass the
|
||||
# raw seconds — NOT _native_timeout(...) (which converts to ms for
|
||||
# Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
|
||||
# applied to the native hook timeout field (in the adapter formatters)
|
||||
# so the agent's outer cap fires after the inner subprocess timeout.
|
||||
base += f" {_shell_quote(str(int(timeout_seconds)), target_os)}"
|
||||
return base
|
||||
|
||||
|
||||
def _context_envelope_for(integration: IntegrationBase, canonical_event: str) -> str | None:
|
||||
"""Resolve the context-injection envelope for an integration + event (C13).
|
||||
|
||||
The event key wins; ``"*"`` is the fallback. Returns ``None`` when the
|
||||
integration declares no envelope for the event (plain stdout passthrough).
|
||||
"""
|
||||
mapping = getattr(integration, "events_context_envelope", None) or {}
|
||||
if canonical_event in mapping:
|
||||
return mapping[canonical_event]
|
||||
return mapping.get("*")
|
||||
|
||||
|
||||
def install_integration_events(
|
||||
integration: IntegrationBase,
|
||||
project_root: Path,
|
||||
@@ -1392,61 +1193,11 @@ def install_integration_events(
|
||||
lines.append(f'timeout = {_native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER)}')
|
||||
lines.append('speckit_marker = true')
|
||||
lines.append('')
|
||||
# S5: only track when the merge wrote (skips on unreadable file).
|
||||
if _merge_toml_fragment(config_path, "\n".join(lines)):
|
||||
rel = str(config_path.relative_to(project_root))
|
||||
if rel not in manifest.files:
|
||||
manifest.record_existing(rel)
|
||||
created.append(config_path)
|
||||
|
||||
elif fmt == "toml-vibe":
|
||||
# Vibe hooks.toml custom merge. Flat [[hooks]] array; Vibe's
|
||||
# HookConfig schema is name/type/command/match/timeout, with type
|
||||
# limited to "pre_tool" | "post_tool" | "post_agent". Hook names must
|
||||
# be unique (Vibe silently drops duplicates by name), so a per-file
|
||||
# counter suffix disambiguates handlers whose commands share a final
|
||||
# segment (e.g. speckit.a.validate vs speckit.b.validate).
|
||||
lines: list[str] = []
|
||||
used_names: set[str] = set()
|
||||
for ev, handlers in filtered.items():
|
||||
native = canonical_to_native[ev]
|
||||
for cfg in handlers:
|
||||
command = cfg.get("command", "")
|
||||
dispatcher_cmd = _dispatcher_command(
|
||||
integration, project_root, command, ev,
|
||||
target_os=_vibe_target_os(),
|
||||
timeout_seconds=cfg.get("timeout", 60),
|
||||
)
|
||||
command_stem = command.split('.')[-1] if command else "unknown"
|
||||
command_stem = re.sub(r'[^A-Za-z0-9_-]+', '-', command_stem) or "unknown"
|
||||
base_name = f"speckit-{native}-{command_stem}"
|
||||
hook_name = base_name
|
||||
suffix = 2
|
||||
while hook_name in used_names:
|
||||
hook_name = f"{base_name}-{suffix}"
|
||||
suffix += 1
|
||||
used_names.add(hook_name)
|
||||
lines.append("[[hooks]]")
|
||||
lines.append(f'name = {_toml_quote(hook_name)}')
|
||||
lines.append(f'type = {_toml_quote(native)}')
|
||||
# Vibe's field is `match` (fnmatch glob, or `re:`-prefixed
|
||||
# regex, case-insensitive) and it is only valid on tool
|
||||
# hooks — HookConfig rejects `match` on post_agent. Canonical
|
||||
# matchers are Claude-style regexes ("Edit|Write"), so
|
||||
# non-wildcard matchers are emitted as `re:` patterns.
|
||||
matcher = cfg.get("matcher", "*")
|
||||
if matcher and matcher != "*" and native in ("pre_tool", "post_tool"):
|
||||
lines.append(f'match = {_toml_quote("re:" + matcher)}')
|
||||
lines.append(f'command = {_toml_quote(dispatcher_cmd)}')
|
||||
lines.append(f'timeout = {_native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER)}')
|
||||
lines.append('speckit_marker = true')
|
||||
lines.append('')
|
||||
# S5: only track when the merge wrote (skips on unreadable file).
|
||||
if _merge_vibe_toml_fragment(config_path, "\n".join(lines)):
|
||||
rel = str(config_path.relative_to(project_root))
|
||||
if rel not in manifest.files:
|
||||
manifest.record_existing(rel)
|
||||
created.append(config_path)
|
||||
_merge_toml_fragment(config_path, "\n".join(lines))
|
||||
rel = str(config_path.relative_to(project_root))
|
||||
if rel not in manifest.files:
|
||||
manifest.record_existing(rel)
|
||||
created.append(config_path)
|
||||
|
||||
elif fmt == "json-flat":
|
||||
# Cursor hooks.json custom merge. Flat command-string entries, one
|
||||
@@ -1570,8 +1321,6 @@ def _remove_native_event_hooks(
|
||||
_remove_copilot_entries(config_path)
|
||||
elif fmt == "toml":
|
||||
_remove_toml_entries(config_path)
|
||||
elif fmt == "toml-vibe":
|
||||
_remove_vibe_toml_entries(config_path)
|
||||
elif fmt in ("json-nested", "json-flat"):
|
||||
_remove_json_entries(config_path)
|
||||
elif fmt == "json-root-nested":
|
||||
@@ -1839,14 +1588,6 @@ def _build_opencode_plugin(
|
||||
an argv array (C9). Both the ``input`` and ``output`` callback arguments
|
||||
are forwarded to ``runEvent`` (C7) so pre_tool_use can inspect tool
|
||||
arguments and post_tool_use can inspect the result.
|
||||
|
||||
Context-injection natives get dedicated hook bodies: for
|
||||
``experimental.chat.system.transform`` the handlers' concatenated stdout
|
||||
is pushed into ``output.system`` (system-prompt injection, re-applied per
|
||||
LLM request so the context survives compaction); for ``chat.message`` it
|
||||
is pushed as a synthetic text part on the user message (C11). Other
|
||||
natives keep their side-effect behavior (tool.execute.* args mutation;
|
||||
session.* lifecycle events via the generic ``event`` hook).
|
||||
"""
|
||||
event_entries: list[str] = []
|
||||
plugin_returns: list[str] = []
|
||||
@@ -1861,16 +1602,11 @@ def _build_opencode_plugin(
|
||||
ev_lit = json.dumps(ev)
|
||||
native_lit = json.dumps(native)
|
||||
|
||||
is_injection = native in ("experimental.chat.system.transform", "chat.message")
|
||||
|
||||
# Build the body: one runEvent() call per handler wrapped in try/catch,
|
||||
# forwarding both input and output (C7). An optional tool-name matcher
|
||||
# guard applies to tool.execute.* hooks. All handlers execute before
|
||||
# any aggregate error is thrown. Injection hooks additionally collect
|
||||
# each handler's stdout and return the concatenation.
|
||||
# any aggregate error is thrown.
|
||||
body_lines: list[str] = [" const errors: string[] = [];"]
|
||||
if is_injection:
|
||||
body_lines.append(" const contexts: string[] = [];")
|
||||
for cfg in handlers:
|
||||
command = str(cfg.get("command", ""))
|
||||
command_lit = json.dumps(command)
|
||||
@@ -1892,10 +1628,6 @@ def _build_opencode_plugin(
|
||||
body_lines.append(
|
||||
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
|
||||
)
|
||||
elif is_injection:
|
||||
body_lines.append(
|
||||
f" try {{ const ctx = runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); if (ctx) contexts.push(ctx); }} catch (e) {{ errors.push((e as Error).message); }}"
|
||||
)
|
||||
else:
|
||||
body_lines.append(
|
||||
f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
|
||||
@@ -1914,65 +1646,14 @@ def _build_opencode_plugin(
|
||||
f" _{ev}(input, output);\n"
|
||||
f" }},"
|
||||
)
|
||||
elif native == "experimental.chat.system.transform":
|
||||
body_lines.append(' return contexts.join("\\n\\n");')
|
||||
event_entries.append(
|
||||
f"function _{ev}(input: any, output: any): string {{\n"
|
||||
+ "\n".join(body_lines) + "\n"
|
||||
" }"
|
||||
)
|
||||
# OpenCode fires experimental.chat.system.transform for non-session
|
||||
# operations (e.g. agent generation) with no sessionID. Guard so
|
||||
# canonical session_start handlers only run when a session is
|
||||
# present, preventing their output from being injected into
|
||||
# internal prompts. Cache the handler output per sessionID so
|
||||
# non-idempotent handlers (setup, telemetry, file-mutating
|
||||
# scripts) execute once per session instead of on every LLM
|
||||
# request; the cache is evicted on session.deleted.
|
||||
plugin_returns.append(
|
||||
f" {native_lit}: async (input: any, output: any) => {{\n"
|
||||
f" if (!input.sessionID) return;\n"
|
||||
f" let ctx = sessionStartCache.get(input.sessionID);\n"
|
||||
f" if (ctx === undefined) {{\n"
|
||||
f" ctx = _{ev}(input, output);\n"
|
||||
f" sessionStartCache.set(input.sessionID, ctx ?? \"\");\n"
|
||||
f" }}\n"
|
||||
f" if (ctx) output.system.push(ctx);\n"
|
||||
f" }},"
|
||||
)
|
||||
elif native == "chat.message":
|
||||
body_lines.append(' return contexts.join("\\n\\n");')
|
||||
event_entries.append(
|
||||
f"function _{ev}(input: any, output: any): string {{\n"
|
||||
+ "\n".join(body_lines) + "\n"
|
||||
" }"
|
||||
)
|
||||
# Part id must start with "prt" (opencode's Identifier brand): an
|
||||
# invalid id fails the user-part schema validation and crashes the
|
||||
# whole session (C12). Derive it from the last existing part so the
|
||||
# brand survives an opencode prefix change, falling back to "prt_"
|
||||
# when output.parts is empty.
|
||||
plugin_returns.append(
|
||||
f" {native_lit}: async (input: any, output: any) => {{\n"
|
||||
f" const ctx = _{ev}(input, output);\n"
|
||||
f" if (!ctx) return;\n"
|
||||
f" const base = output.parts[output.parts.length - 1]?.id ?? \"prt_\" + Date.now().toString(36) + Math.random().toString(36).slice(2, 10);\n"
|
||||
f" output.parts.push({{ id: base + \".speckit\" + Math.random().toString(36).slice(2, 8), sessionID: input.sessionID, messageID: output.message.id, type: \"text\", text: ctx, synthetic: true }});\n"
|
||||
f" }},"
|
||||
)
|
||||
else:
|
||||
event_entries.append(
|
||||
f"function _{ev}(input: any, output: any) {{\n"
|
||||
+ "\n".join(body_lines) + "\n"
|
||||
" }"
|
||||
)
|
||||
# Evict the sessionStartCache when the session is deleted so the
|
||||
# cache doesn't grow unbounded across sessions.
|
||||
eviction = ""
|
||||
if native == "session.deleted":
|
||||
eviction = "if (event.sessionID) sessionStartCache.delete(event.sessionID); "
|
||||
event_handlers.append(
|
||||
f" if (event.type === {native_lit}) {{ {eviction}_{ev}(event, event); }}"
|
||||
f" if (event.type === {native_lit}) {{ _{ev}(event, event); }}"
|
||||
)
|
||||
|
||||
if event_handlers:
|
||||
@@ -2034,27 +1715,11 @@ def _remove_opencode_entries(config_path: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
|
||||
"""Merge Specify-owned TOML entries into *dst*, regenerating the file.
|
||||
|
||||
An unreadable or undecodable pre-existing file aborts the merge instead
|
||||
of discarding the user's bytes, mirroring ``_load_user_json`` (#22).
|
||||
Returns False when skipped so callers avoid tracking the untouched file
|
||||
(S5).
|
||||
"""
|
||||
def _merge_toml_fragment(dst: Path, fragment: str) -> None:
|
||||
_ensure_safe_destination(dst)
|
||||
existing = ""
|
||||
if dst.exists():
|
||||
try:
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
logger.warning(
|
||||
"Could not read %s (it may be unreadable or not UTF-8); "
|
||||
"skipping event-config merge to preserve user content.",
|
||||
dst,
|
||||
)
|
||||
logger.debug("Read error detail: %s", exc)
|
||||
return False
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
existing = re.sub(
|
||||
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
|
||||
"",
|
||||
@@ -2063,43 +1728,6 @@ def _merge_toml_fragment(dst: Path, fragment: str) -> bool:
|
||||
)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def _merge_vibe_toml_fragment(dst: Path, fragment: str) -> bool:
|
||||
"""Merge Specify-owned Vibe TOML hook entries into *dst*, regenerating the file.
|
||||
|
||||
Vibe uses a flat [[hooks]] array with type/matcher/command fields.
|
||||
This removes any existing Specify-marked hooks and appends the new fragment.
|
||||
An unreadable or undecodable pre-existing file aborts the merge instead
|
||||
of discarding the user's bytes, mirroring ``_load_user_json`` (#22).
|
||||
Returns False when skipped so callers avoid tracking the untouched file
|
||||
(S5).
|
||||
"""
|
||||
_ensure_safe_destination(dst)
|
||||
existing = ""
|
||||
if dst.exists():
|
||||
try:
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
logger.warning(
|
||||
"Could not read %s (it may be unreadable or not UTF-8); "
|
||||
"skipping event-config merge to preserve user content.",
|
||||
dst,
|
||||
)
|
||||
logger.debug("Read error detail: %s", exc)
|
||||
return False
|
||||
# Remove existing Specify-marked [[hooks]] blocks
|
||||
# Match [[hooks]] ... speckit_marker = true (with any content in between)
|
||||
existing = re.sub(
|
||||
r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?speckit_marker = true\n*',
|
||||
"",
|
||||
existing,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
|
||||
return True
|
||||
|
||||
|
||||
def _remove_toml_entries(dst: Path) -> bool:
|
||||
@@ -2113,19 +1741,7 @@ def _remove_toml_entries(dst: Path) -> bool:
|
||||
# the config after install can't make teardown overwrite a file outside
|
||||
# the project (the merge/write path already validates; teardown must too).
|
||||
_ensure_safe_destination(dst)
|
||||
try:
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
# An unreadable or undecodable file is left untouched rather than
|
||||
# crashing teardown — it contains only user content as far as we can
|
||||
# tell, and the caller drops the manifest claim either way (S9).
|
||||
logger.warning(
|
||||
"Could not read %s (it may be unreadable or not UTF-8); "
|
||||
"skipping event-config cleanup to preserve user content.",
|
||||
dst,
|
||||
)
|
||||
logger.debug("Read error detail: %s", exc)
|
||||
return False
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
cleaned = re.sub(
|
||||
r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
|
||||
"",
|
||||
@@ -2145,43 +1761,6 @@ def _remove_toml_entries(dst: Path) -> bool:
|
||||
return False
|
||||
|
||||
|
||||
def _remove_vibe_toml_entries(dst: Path) -> bool:
|
||||
"""Remove Specify-marked Vibe TOML hook entries; delete the file if now empty.
|
||||
|
||||
Returns True if the file was deleted (no user content remained).
|
||||
"""
|
||||
if not dst.exists():
|
||||
return False
|
||||
_ensure_safe_destination(dst)
|
||||
try:
|
||||
existing = dst.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
logger.warning(
|
||||
"Could not read %s (it may be unreadable or not UTF-8); "
|
||||
"skipping event-config cleanup to preserve user content.",
|
||||
dst,
|
||||
)
|
||||
logger.debug("Read error detail: %s", exc)
|
||||
return False
|
||||
# Remove Specify-marked [[hooks]] blocks
|
||||
cleaned = re.sub(
|
||||
r'\[\[hooks\]\]\n(?:(?!\[\[hooks\]\]).)*?speckit_marker = true\n*',
|
||||
"",
|
||||
existing,
|
||||
flags=re.DOTALL,
|
||||
)
|
||||
# If only whitespace/comments remain, the file had no user content
|
||||
stripped = "\n".join(
|
||||
line for line in cleaned.splitlines()
|
||||
if line.strip() and not line.strip().startswith("#")
|
||||
)
|
||||
if not stripped:
|
||||
dst.unlink(missing_ok=True)
|
||||
return True
|
||||
dst.write_text(cleaned, encoding="utf-8")
|
||||
return False
|
||||
|
||||
|
||||
def _merge_copilot_json(dst: Path, new_hooks: dict[str, list]) -> bool:
|
||||
"""Merge Specify-owned hooks into Copilot's dedicated hooks JSON (#8).
|
||||
|
||||
|
||||
@@ -61,13 +61,6 @@ _FALLBACK_CORE_COMMAND_NAMES = frozenset(
|
||||
)
|
||||
EXTENSION_COMMAND_NAME_PATTERN = re.compile(r"^speckit\.([a-z0-9-]+)\.([a-z0-9-]+)$")
|
||||
|
||||
# Naming pattern for provides.templates / provides.scripts entries. Unlike
|
||||
# commands, these are not namespaced (they aren't invoked via a command
|
||||
# name), so they follow the same plain slug pattern as extension.id.
|
||||
VALID_EXTENSION_ARTIFACT_NAME_PATTERN = re.compile(r"^[a-z0-9-]+$")
|
||||
|
||||
VALID_SCRIPT_RUNTIMES = frozenset({"bash", "powershell", "python"})
|
||||
|
||||
VALID_EFFECTS = frozenset({"read-only", "read-write"})
|
||||
|
||||
DEFAULT_HOOK_PRIORITY = 10
|
||||
@@ -292,25 +285,9 @@ class ExtensionManifest:
|
||||
raise ValidationError(
|
||||
f"Invalid extension: expected a mapping, got {type(ext).__name__}"
|
||||
)
|
||||
# Check presence AND type: the format/version checks below feed these
|
||||
# values straight to ``re.match`` and ``packaging.Version``, both of
|
||||
# which raise a bare TypeError on a non-string. YAML makes that an easy
|
||||
# authoring slip -- unquoted ``version: 1.0`` parses as a float and
|
||||
# ``id: 2`` as an int -- and TypeError is not a ValidationError, so it
|
||||
# escapes every caller that already handles a malformed manifest (see
|
||||
# list_installed()'s "Corrupted extension" fallback, which catches
|
||||
# ValidationError only, making one bad extension exit ``specify
|
||||
# extension list`` with a raw traceback and hide the healthy ones).
|
||||
# Mirrors the sibling IntegrationDescriptor, which already type-checks
|
||||
# the same four fields.
|
||||
for field in ["id", "name", "version", "description"]:
|
||||
if field not in ext:
|
||||
raise ValidationError(f"Missing extension.{field}")
|
||||
if not isinstance(ext[field], str):
|
||||
raise ValidationError(
|
||||
f"Invalid extension.{field}: expected a string, "
|
||||
f"got {type(ext[field]).__name__}"
|
||||
)
|
||||
|
||||
# Validate extension ID format
|
||||
if not re.match(r"^[a-z0-9-]+$", ext["id"]):
|
||||
@@ -348,25 +325,6 @@ class ExtensionManifest:
|
||||
)
|
||||
if "speckit_version" not in requires:
|
||||
raise ValidationError("Missing requires.speckit_version")
|
||||
# Presence alone is not enough: check_compatibility() feeds this value to
|
||||
# ``SpecifierSet(required)``, guarded only by ``except InvalidSpecifier``,
|
||||
# which a non-string escapes two different ways. A float/int/bool/None
|
||||
# raises TypeError from the constructor, while a list or dict is an
|
||||
# *iterable*, so SpecifierSet accepts it and the failure surfaces much
|
||||
# later as ``AttributeError: 'str' object has no attribute 'filter'`` from
|
||||
# inside .contains(). Neither is a CompatibilityError, so both bypass the
|
||||
# CLI's "Compatibility Error" handler and exit 1 with a raw traceback
|
||||
# naming no field. An unquoted ``speckit_version: 1.0`` is an easy YAML
|
||||
# slip. Mirrors the sibling IntegrationDescriptor, which already requires
|
||||
# a non-empty string here.
|
||||
if (
|
||||
not isinstance(requires["speckit_version"], str)
|
||||
or not requires["speckit_version"].strip()
|
||||
):
|
||||
raise ValidationError(
|
||||
"Invalid requires.speckit_version: expected a non-empty string, "
|
||||
f"got {type(requires['speckit_version']).__name__}"
|
||||
)
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
@@ -375,17 +333,11 @@ class ExtensionManifest:
|
||||
f"Invalid provides: expected a mapping, got {type(provides).__name__}"
|
||||
)
|
||||
commands = provides.get("commands", [])
|
||||
templates = provides.get("templates", [])
|
||||
scripts = provides.get("scripts", [])
|
||||
hooks = self.data.get("hooks")
|
||||
events = self.data.get("events")
|
||||
|
||||
if "commands" in provides and not isinstance(commands, list):
|
||||
raise ValidationError("Invalid provides.commands: expected a list")
|
||||
if "templates" in provides and not isinstance(templates, list):
|
||||
raise ValidationError("Invalid provides.templates: expected a list")
|
||||
if "scripts" in provides and not isinstance(scripts, list):
|
||||
raise ValidationError("Invalid provides.scripts: expected a list")
|
||||
if "hooks" in self.data and not isinstance(hooks, dict):
|
||||
raise ValidationError("Invalid hooks: expected a mapping")
|
||||
if "events" in self.data:
|
||||
@@ -395,17 +347,9 @@ class ExtensionManifest:
|
||||
has_commands = bool(commands)
|
||||
has_hooks = bool(hooks)
|
||||
has_events = bool(events)
|
||||
has_templates = bool(templates)
|
||||
has_scripts = bool(scripts)
|
||||
|
||||
if not has_commands and not has_hooks and not has_events and not has_templates and not has_scripts:
|
||||
raise ValidationError(
|
||||
"Extension must provide at least one command, hook, or event "
|
||||
"(or a declared template/script)"
|
||||
)
|
||||
|
||||
self._validate_provided_artifacts(templates, section="templates", singular="template")
|
||||
self._validate_provided_artifacts(scripts, section="scripts", singular="script")
|
||||
if not has_commands and not has_hooks and not has_events:
|
||||
raise ValidationError("Extension must provide at least one command, hook, or event")
|
||||
|
||||
# Validate hook values (if present).
|
||||
# Each event is a single mapping or a list of mappings.
|
||||
@@ -447,16 +391,6 @@ class ExtensionManifest:
|
||||
)
|
||||
if "name" not in cmd or "file" not in cmd:
|
||||
raise ValidationError("Command missing 'name' or 'file'")
|
||||
# The pattern match below would raise a bare TypeError on a
|
||||
# non-string name (``name: 2``), escaping the ValidationError
|
||||
# contract. The 'file' field needs no check here:
|
||||
# relative_extension_path_violation() below already rejects a
|
||||
# non-string value.
|
||||
if not isinstance(cmd["name"], str):
|
||||
raise ValidationError(
|
||||
f"Invalid command name: expected a string, "
|
||||
f"got {type(cmd['name']).__name__}"
|
||||
)
|
||||
|
||||
# Validate the 'file' field at manifest-load time using the single
|
||||
# shared policy in relative_extension_path_violation(), so manifest
|
||||
@@ -566,80 +500,6 @@ class ExtensionManifest:
|
||||
f"The extension author should update the manifest."
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _validate_provided_artifacts(entries: List[Any], section: str, singular: str) -> None:
|
||||
"""Validate provides.templates / provides.scripts entries.
|
||||
|
||||
Mirrors the shape/path-safety checks PresetManifest applies to its
|
||||
non-command templates, minus 'type' (the section name already
|
||||
distinguishes template vs script) and 'strategy' (extension-provided
|
||||
artifacts are always 'replace' -- see the forced-replace resolver
|
||||
behavior for extension layers in presets/__init__.py). A present
|
||||
'strategy' key is rejected rather than silently ignored, so an author
|
||||
who copies a preset-style entry gets a clear error instead of a
|
||||
silently-dropped field. Duplicate names within a section are also
|
||||
rejected: the resolver returns the first matching entry by name
|
||||
(``PresetResolver._extension_manifest_declared_template``), so a
|
||||
later duplicate would be silently unreachable while still being
|
||||
exposed by ``ExtensionManifest.templates``/``.scripts``.
|
||||
"""
|
||||
seen_names: set[str] = set()
|
||||
for entry in entries:
|
||||
if not isinstance(entry, dict):
|
||||
raise ValidationError(
|
||||
f"Each entry in 'provides.{section}' must be a mapping"
|
||||
)
|
||||
if "name" not in entry or "file" not in entry:
|
||||
raise ValidationError(f"{singular.capitalize()} missing 'name' or 'file'")
|
||||
|
||||
name = entry["name"]
|
||||
if not isinstance(name, str):
|
||||
raise ValidationError(
|
||||
f"Invalid {singular} name: expected a string, got {type(name).__name__}"
|
||||
)
|
||||
if not VALID_EXTENSION_ARTIFACT_NAME_PATTERN.match(name):
|
||||
raise ValidationError(
|
||||
f"Invalid {singular} name '{name}': "
|
||||
"must be lowercase alphanumeric with hyphens only"
|
||||
)
|
||||
if name in seen_names:
|
||||
raise ValidationError(
|
||||
f"Duplicate {singular} name '{name}' in 'provides.{section}'"
|
||||
)
|
||||
seen_names.add(name)
|
||||
|
||||
file_value = entry["file"]
|
||||
reason = relative_extension_path_violation(file_value)
|
||||
if reason:
|
||||
label = repr(file_value) if isinstance(file_value, str) else f"for {singular} '{name}'"
|
||||
raise ValidationError(f"Invalid {singular} 'file' {label}: {reason}")
|
||||
|
||||
if "description" in entry and not isinstance(entry["description"], str):
|
||||
raise ValidationError(
|
||||
f"Invalid {singular} description for '{name}': expected a string"
|
||||
)
|
||||
|
||||
if "strategy" in entry:
|
||||
raise ValidationError(
|
||||
f"Invalid {singular} entry '{name}': 'strategy' is not authorable for "
|
||||
"extension-provided artifacts, which always use 'replace' semantics"
|
||||
)
|
||||
|
||||
if section == "scripts" and "runtimes" in entry:
|
||||
runtimes = entry["runtimes"]
|
||||
if not isinstance(runtimes, list) or not all(
|
||||
isinstance(r, str) for r in runtimes
|
||||
):
|
||||
raise ValidationError(
|
||||
f"Invalid runtimes for script '{name}': expected a list of strings"
|
||||
)
|
||||
invalid = sorted(set(runtimes) - VALID_SCRIPT_RUNTIMES)
|
||||
if invalid:
|
||||
raise ValidationError(
|
||||
f"Invalid runtimes {invalid} for script '{name}': "
|
||||
f"must be one of {sorted(VALID_SCRIPT_RUNTIMES)}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
|
||||
"""Try to auto-correct a non-conforming command name to the required pattern.
|
||||
@@ -702,24 +562,6 @@ class ExtensionManifest:
|
||||
"""Get list of provided commands."""
|
||||
return self.data.get("provides", {}).get("commands", [])
|
||||
|
||||
@property
|
||||
def config(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of provided config templates, normalized to dictionaries."""
|
||||
raw = self.data.get("provides", {}).get("config", [])
|
||||
if not isinstance(raw, list) or not all(isinstance(entry, dict) for entry in raw):
|
||||
return []
|
||||
return raw
|
||||
|
||||
@property
|
||||
def templates(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of declared templates (provides.templates)."""
|
||||
return self.data.get("provides", {}).get("templates", [])
|
||||
|
||||
@property
|
||||
def scripts(self) -> List[Dict[str, Any]]:
|
||||
"""Get list of declared scripts (provides.scripts)."""
|
||||
return self.data.get("provides", {}).get("scripts", [])
|
||||
|
||||
@property
|
||||
def hooks(self) -> Dict[str, Any]:
|
||||
"""Get hook definitions."""
|
||||
@@ -755,13 +597,6 @@ class ExtensionRegistry:
|
||||
if not self.registry_path.exists():
|
||||
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
|
||||
|
||||
# A non-regular file (e.g. a directory at the registry path) is not a
|
||||
# readable registry. Recover to empty so construction — used by the
|
||||
# install/enable/disable flows — does not crash. Resolution paths that
|
||||
# must fail closed consult is_corrupt() instead of relying on this.
|
||||
if not self.registry_path.is_file():
|
||||
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
|
||||
|
||||
try:
|
||||
with open(self.registry_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
@@ -772,47 +607,10 @@ class ExtensionRegistry:
|
||||
if not isinstance(data.get("extensions"), dict):
|
||||
data["extensions"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, UnicodeDecodeError, FileNotFoundError):
|
||||
# Corrupted or missing registry, start fresh. A registry whose
|
||||
# bytes cannot be decoded as UTF-8 is the same corruption class as
|
||||
# malformed JSON — only the exception type differs, and it is
|
||||
# raised by the text-mode read before JSON parsing begins. OSError
|
||||
# is deliberately not caught: the data may be intact on disk, and
|
||||
# starting fresh would let a later _save() wipe it.
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
# Corrupted or missing registry, start fresh
|
||||
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
|
||||
|
||||
def is_corrupt(self) -> bool:
|
||||
"""Report whether an existing registry file is present but unreadable.
|
||||
|
||||
``_load`` deliberately recovers from a corrupt registry by normalizing
|
||||
it to an empty mapping so install/enable/disable flows keep working.
|
||||
Resolution paths, however, must fail closed: a corrupt registry that
|
||||
normalizes to ``{}`` would otherwise cause every on-disk extension
|
||||
directory to be admitted as an unregistered, enabled extension. This
|
||||
probe lets those callers distinguish "no registry" (safe) from
|
||||
"registry exists but is invalid" (unsafe) without changing recovery
|
||||
behavior. An absent registry returns ``False``; a directory, broken
|
||||
or dangling symlink, non-regular file, unreadable file, non-mapping
|
||||
root, or non-mapping ``extensions`` value returns ``True``.
|
||||
"""
|
||||
# os.path.lexists (not Path.exists) so a dangling symlink is detected
|
||||
# rather than followed to a non-existent target and mistaken for an
|
||||
# absent registry — which would reopen the fail-open directory scan.
|
||||
if not os.path.lexists(self.registry_path):
|
||||
return False
|
||||
if not self.registry_path.is_file():
|
||||
return True
|
||||
try:
|
||||
with open(self.registry_path, "r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
except (json.JSONDecodeError, OSError, UnicodeDecodeError):
|
||||
return True
|
||||
if not isinstance(data, dict):
|
||||
return True
|
||||
if "extensions" in data and not isinstance(data["extensions"], dict):
|
||||
return True
|
||||
return False
|
||||
|
||||
def _save(self):
|
||||
"""Save registry to disk."""
|
||||
self.extensions_dir.mkdir(parents=True, exist_ok=True)
|
||||
@@ -2019,17 +1817,6 @@ class ExtensionManager:
|
||||
required = manifest.requires_speckit_version
|
||||
|
||||
# Parse version specifier (e.g., ">=0.1.0,<2.0.0")
|
||||
# Defense in depth: the manifest validator now rejects a non-string
|
||||
# requires.speckit_version, but this method is public and also reachable
|
||||
# with a hand-built manifest object. ``InvalidSpecifier`` alone does not
|
||||
# cover a non-string -- scalars raise TypeError from the constructor, and
|
||||
# a list/dict is iterable so it constructs here and only breaks inside
|
||||
# .contains(). Reject up front so this always reports a CompatibilityError.
|
||||
if not isinstance(required, str):
|
||||
raise CompatibilityError(
|
||||
"Invalid version specifier: expected a string, got "
|
||||
f"{type(required).__name__} ({required!r})"
|
||||
)
|
||||
try:
|
||||
SpecifierSet(required) # Just to validate
|
||||
except InvalidSpecifier:
|
||||
@@ -2272,19 +2059,8 @@ class ExtensionManager:
|
||||
_staged_modes = _loaded_modes
|
||||
for staged_name in sorted(staged_names):
|
||||
staged_file = rescue_staging_dir / staged_name
|
||||
# A staged backup that cannot be read or stat'ed must not
|
||||
# crash the retry with a raw OSError: like an uncomparable
|
||||
# live config below, treat it as a conflict so both copies
|
||||
# are preserved and the user resolves it while dest_dir is
|
||||
# still untouched. Every sibling read in this path (live
|
||||
# twin, packaged baseline, mode sidecar) already catches
|
||||
# OSError.
|
||||
try:
|
||||
staged_stat = staged_file.stat()
|
||||
staged_bytes = staged_file.read_bytes()
|
||||
except OSError:
|
||||
conflicting.add(staged_name)
|
||||
continue
|
||||
staged_stat = staged_file.stat()
|
||||
staged_bytes = staged_file.read_bytes()
|
||||
# Prefer the sidecar-recorded mode; fall back to the staged
|
||||
# file's own mode for backwards-compat with staging dirs
|
||||
# written before the sidecar was introduced.
|
||||
@@ -2376,24 +2152,10 @@ class ExtensionManager:
|
||||
"a regular file or remove it — then reinstall."
|
||||
)
|
||||
if cfg_file.is_file():
|
||||
# A kept config that cannot be read or stat'ed must not
|
||||
# crash the reinstall with a raw OSError — and must not
|
||||
# reach the rmtree below unrescued. Like the symlink
|
||||
# guard above, reject while dest_dir is untouched so the
|
||||
# preserved bytes are never lost.
|
||||
try:
|
||||
stranded_configs[cfg_file.name] = (
|
||||
cfg_file.read_bytes(),
|
||||
cfg_file.stat().st_mode,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ValidationError(
|
||||
"Preserved extension config for "
|
||||
f"'{manifest.id}' cannot be read "
|
||||
f"({cfg_file.name}) in {dest_dir}: {exc}. "
|
||||
"Resolve manually — fix its permissions or "
|
||||
"remove it — then reinstall."
|
||||
) from exc
|
||||
stranded_configs[cfg_file.name] = (
|
||||
cfg_file.read_bytes(),
|
||||
cfg_file.stat().st_mode,
|
||||
)
|
||||
|
||||
if stranded_configs and not staging_is_complete:
|
||||
# Write a durable backup outside dest_dir before any
|
||||
@@ -2727,149 +2489,6 @@ class ExtensionManager:
|
||||
extension_dir, speckit_version, priority=priority, force=force
|
||||
)
|
||||
|
||||
def _config_root_is_contained(self, specify_dir: Path) -> bool:
|
||||
"""Report whether `.specify` is a real directory inside the project.
|
||||
|
||||
Checked component by component so a symlink anywhere on the path is
|
||||
rejected before it becomes the containment root. A missing `.specify`
|
||||
is fine: scaffolding creates it under the project root.
|
||||
"""
|
||||
try:
|
||||
root = self.project_root.resolve()
|
||||
except OSError:
|
||||
return False
|
||||
current = self.project_root
|
||||
for part in specify_dir.relative_to(self.project_root).parts:
|
||||
current = current / part
|
||||
if current.is_symlink():
|
||||
return False
|
||||
if not current.exists():
|
||||
return True
|
||||
try:
|
||||
if current.resolve().relative_to(root) is None:
|
||||
return False
|
||||
except (OSError, ValueError):
|
||||
return False
|
||||
return current.is_dir()
|
||||
|
||||
@staticmethod
|
||||
def _target_follows_preserved_convention(target_name: str) -> bool:
|
||||
"""True when a scaffold target survives remove/backup/restore.
|
||||
|
||||
Those paths only handle top-level ``*-config.yml`` and
|
||||
``*-config.local.yml`` files, so anything nested or otherwise named is
|
||||
not preserved across an update.
|
||||
"""
|
||||
if "/" in target_name or "\\" in target_name:
|
||||
return False
|
||||
return target_name.endswith("-config.yml") or target_name.endswith(
|
||||
"-config.local.yml"
|
||||
)
|
||||
|
||||
def scaffold_config(self, extension_id: str) -> tuple[List[str], List[str], List[str]]:
|
||||
"""Deploy config templates from an installed extension to the project.
|
||||
|
||||
Reads the extension's manifest provides.config section and copies
|
||||
each config template to the project's .specify/ directory. Existing
|
||||
config files are never overwritten (user customizations are preserved).
|
||||
|
||||
Args:
|
||||
extension_id: ID of the installed extension
|
||||
|
||||
Returns:
|
||||
Tuple of (deployed, skipped_existing, failed) where each is a list
|
||||
of config file names.
|
||||
"""
|
||||
ext_dir = self.extensions_dir / extension_id
|
||||
manifest_path = ext_dir / "extension.yml"
|
||||
if not manifest_path.exists():
|
||||
return [], [], []
|
||||
|
||||
manifest = ExtensionManifest(manifest_path)
|
||||
deployed = []
|
||||
skipped_existing = []
|
||||
failed = []
|
||||
|
||||
provides = manifest.data.get("provides", {})
|
||||
raw_config = provides.get("config", [])
|
||||
config_is_malformed = (
|
||||
"config" in provides
|
||||
and (
|
||||
not isinstance(raw_config, list)
|
||||
or not all(isinstance(entry, dict) for entry in raw_config)
|
||||
)
|
||||
)
|
||||
if config_is_malformed:
|
||||
return deployed, skipped_existing, ["provides.config"]
|
||||
|
||||
ext_dir_resolved = ext_dir.resolve()
|
||||
# Config is deployed beneath the extension's own directory because that
|
||||
# is where it is read from: ConfigManager._get_project_config() loads
|
||||
# `.specify/extensions/<id>/<id>-config.yml`, and the bundled scripts
|
||||
# and READMEs use the same location. Writing to `.specify/<name>` put
|
||||
# the file somewhere nothing ever looks.
|
||||
config_dir = self.project_root / ".specify" / "extensions" / extension_id
|
||||
# Resolving that directory and trusting the result as the containment
|
||||
# root lets a symlinked component point outside the project: every
|
||||
# target would then satisfy relative_to and copy2 would write
|
||||
# externally. Refuse a symlinked component up front, matching the
|
||||
# project safe-write path in shared_infra.
|
||||
if not self._config_root_is_contained(config_dir):
|
||||
return deployed, skipped_existing, ["provides.config"]
|
||||
config_dir_resolved = config_dir.resolve()
|
||||
|
||||
for config_entry in manifest.config:
|
||||
template_name = config_entry.get("template", "")
|
||||
target_name = config_entry.get("name", template_name)
|
||||
failure_name = target_name if isinstance(target_name, str) and target_name else "provides.config"
|
||||
if not isinstance(template_name, str) or not template_name:
|
||||
failed.append(failure_name)
|
||||
continue
|
||||
if not isinstance(target_name, str) or not target_name:
|
||||
failed.append(failure_name)
|
||||
continue
|
||||
# Only scaffold what removal actually preserves. remove(keep_config)
|
||||
# keeps top-level files ending in -config.yml / -config.local.yml and
|
||||
# rmtree's every subdirectory; the backup path globs the same
|
||||
# top-level pattern. A nested or differently-named target would be
|
||||
# silently destroyed by `extension add --force` and replaced with the
|
||||
# template default, losing the user's customization.
|
||||
if not self._target_follows_preserved_convention(target_name):
|
||||
failed.append(failure_name)
|
||||
continue
|
||||
|
||||
template_candidate = ext_dir / template_name
|
||||
template_path = template_candidate.resolve()
|
||||
target_path = (config_dir / target_name).resolve()
|
||||
try:
|
||||
template_path.relative_to(ext_dir_resolved)
|
||||
target_path.relative_to(config_dir_resolved)
|
||||
except ValueError:
|
||||
failed.append(failure_name)
|
||||
continue
|
||||
|
||||
if template_candidate.is_symlink() or not template_path.is_file():
|
||||
failed.append(failure_name)
|
||||
continue
|
||||
|
||||
if target_path.exists():
|
||||
skipped_existing.append(target_name)
|
||||
continue
|
||||
|
||||
try:
|
||||
# mkdir belongs inside the handler: a nested target like
|
||||
# foo/config.yml must land in `failed` when `.specify/foo` is a
|
||||
# file or cannot be created, not raise out of scaffolding after
|
||||
# `extension add` has already installed the extension.
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.copy2(template_path, target_path)
|
||||
except OSError:
|
||||
failed.append(target_name)
|
||||
continue
|
||||
deployed.append(target_name)
|
||||
|
||||
return deployed, skipped_existing, failed
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
@@ -4459,10 +4078,11 @@ class ConfigManager:
|
||||
Returns an empty list if the registry is missing or corrupted
|
||||
(fresh project, ad-hoc test harness) so ``_get_env_config`` degrades
|
||||
to its pre-fix behaviour rather than crashing. ``UnicodeError`` is
|
||||
kept alongside ``OSError`` as belt-and-braces: ``_load()`` now starts
|
||||
fresh on non-UTF-8 registry bytes itself, but catching it here too
|
||||
keeps this call site degrading gracefully rather than breaking *every*
|
||||
config read if that handling ever regresses.
|
||||
caught alongside ``OSError`` because ``ExtensionRegistry._load()``
|
||||
opens the file in text mode and only handles ``JSONDecodeError`` /
|
||||
``FileNotFoundError``, so a registry file with non-UTF-8 bytes would
|
||||
otherwise surface a ``UnicodeDecodeError`` here and break *every*
|
||||
config read instead of degrading gracefully.
|
||||
|
||||
Used by ``_get_env_config`` to detect env vars whose remainder claims
|
||||
a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is
|
||||
@@ -4712,7 +4332,6 @@ class HookExecutor:
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
cline_mode = selected_ai == "cline"
|
||||
forge_mode = selected_ai == "forge"
|
||||
junie_mode = selected_ai == "junie"
|
||||
|
||||
skill_name = self._skill_name_from_command(command_id)
|
||||
if dollar_skill_mode and skill_name:
|
||||
@@ -4727,10 +4346,6 @@ class HookExecutor:
|
||||
from ..integrations.forge import format_forge_command_name
|
||||
|
||||
return f"/{format_forge_command_name(command_id)}"
|
||||
if junie_mode:
|
||||
from ..integrations.junie import format_junie_command_name
|
||||
|
||||
return f"/{format_junie_command_name(command_id)}"
|
||||
|
||||
use_slash = is_slash_skills_agent(selected_ai, ai_skills_enabled)
|
||||
|
||||
|
||||
@@ -95,141 +95,6 @@ def _refresh_events_and_warn(project_root: Path) -> None:
|
||||
console.print(f" {key}: {_escape_markup(detail)}")
|
||||
|
||||
|
||||
def install_extension_from_url(
|
||||
manager,
|
||||
project_root: Path,
|
||||
url: str,
|
||||
speckit_version: str,
|
||||
*,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
):
|
||||
"""Download an archive from *url* and install it, reusing the hardened path.
|
||||
|
||||
Shares the same download hardening as ``extension add --from``:
|
||||
HTTPS enforcement, the catalog's authenticated + redirect-guarded
|
||||
``_open_url`` fetch, a bounded (50 MiB) response read, archive-format
|
||||
detection (ZIP or tar.gz/tgz), and a TOCTOU-safe transient download file
|
||||
consumed directly by ``install_from_zip``.
|
||||
|
||||
Returns the installed manifest. Raises ``ExtensionError`` on any failure so
|
||||
callers can present a uniform message without a second downloader.
|
||||
"""
|
||||
import urllib.error
|
||||
|
||||
from . import ExtensionCatalog, ExtensionError
|
||||
|
||||
if not is_https_or_localhost_http(url):
|
||||
raise ExtensionError(
|
||||
"URL must use HTTPS (HTTP is only allowed for localhost)"
|
||||
)
|
||||
|
||||
download_dir = _validate_safe_cache_dir(project_root)
|
||||
archive_filename = f"extension-url-download-{uuid4().hex}.archive"
|
||||
# Only used for diagnostic messages: the real archive is a transient inode
|
||||
# (unlinked on POSIX, O_TEMPORARY on Windows) consumed via ``archive_file``
|
||||
# below, so this path is never opened again.
|
||||
archive_path = download_dir / archive_filename
|
||||
|
||||
try:
|
||||
dl_catalog = ExtensionCatalog(project_root)
|
||||
download_url = url
|
||||
extra_headers = None
|
||||
resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url)
|
||||
if resolved_url:
|
||||
download_url = resolved_url
|
||||
extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
with dl_catalog._open_url(
|
||||
download_url, timeout=60, extra_headers=extra_headers
|
||||
) as response:
|
||||
archive_data = read_response_limited(
|
||||
response,
|
||||
error_type=ExtensionError,
|
||||
label=f"extension {url}",
|
||||
)
|
||||
final_url = (
|
||||
response.geturl() if hasattr(response, "geturl") else download_url
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
except urllib.error.URLError as exc:
|
||||
raise ExtensionError(f"Failed to download from {url}: {exc}") from exc
|
||||
|
||||
download_fd = -1
|
||||
download_file = None
|
||||
try:
|
||||
try:
|
||||
download_fd = _safe_open_download_zip(
|
||||
project_root, download_dir, archive_filename
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ExtensionError(
|
||||
f"Could not safely create download file: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
download_file = os.fdopen(download_fd, "w+b")
|
||||
download_fd = -1
|
||||
download_file.write(archive_data)
|
||||
download_file.flush()
|
||||
download_file.seek(0)
|
||||
except OSError as exc:
|
||||
raise ExtensionError(
|
||||
f"Could not safely write download file: {exc}"
|
||||
) from exc
|
||||
|
||||
format_source = (
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else url
|
||||
)
|
||||
try:
|
||||
detect_archive_format(
|
||||
archive_path,
|
||||
archive_file=download_file,
|
||||
source_name=format_source,
|
||||
content_type=content_type,
|
||||
error_type=ExtensionError,
|
||||
)
|
||||
except ExtensionError as exc:
|
||||
raise ExtensionError(
|
||||
f"{url} did not return a ZIP archive or tar.gz/tgz archive "
|
||||
f"(got {len(archive_data)} bytes). This usually means the request "
|
||||
"was not authenticated and a login/HTML page was returned. "
|
||||
"Verify the URL and configured credentials."
|
||||
) from exc
|
||||
|
||||
# Consume the transient inode reserved above rather than reopening the
|
||||
# cache pathname during extraction.
|
||||
try:
|
||||
return manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority=priority,
|
||||
force=force,
|
||||
archive_file=download_file,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ExtensionError(
|
||||
f"Could not install extension from downloaded archive: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
if download_file is not None:
|
||||
try:
|
||||
download_file.close()
|
||||
except OSError:
|
||||
pass
|
||||
elif download_fd >= 0:
|
||||
try:
|
||||
os.close(download_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict:
|
||||
"""Load extension catalog CLI config with user-facing shape errors."""
|
||||
try:
|
||||
@@ -945,20 +810,134 @@ def extension_add(
|
||||
)
|
||||
|
||||
elif from_url:
|
||||
# Install from URL archive via the shared hardened downloader
|
||||
# (HTTPS enforcement, authenticated redirect-guarded fetch,
|
||||
# bounded read, archive-format detection, TOCTOU-safe transient
|
||||
# archive). Same path used by ``specify init --extension <url>``.
|
||||
console.print(f"Downloading from {safe_url}...")
|
||||
manifest = install_extension_from_url(
|
||||
manager,
|
||||
project_root,
|
||||
from_url,
|
||||
speckit_version,
|
||||
priority=priority,
|
||||
force=force,
|
||||
)
|
||||
# Install from an archive URL.
|
||||
import urllib.error
|
||||
|
||||
console.print(f"Downloading from {safe_url}...")
|
||||
|
||||
download_dir = _validate_safe_cache_dir(project_root)
|
||||
archive_filename = f"extension-url-download-{uuid4().hex}.archive"
|
||||
# Only used for diagnostic messages: the real archive is a
|
||||
# transient inode (unlinked on POSIX, O_TEMPORARY on Windows)
|
||||
# consumed via ``archive_file`` below, so this path is never
|
||||
# opened again.
|
||||
archive_path = download_dir / archive_filename
|
||||
|
||||
try:
|
||||
# Use the catalog's authenticated fetch so configured
|
||||
# credentials (incl. GitHub Enterprise Server) are applied
|
||||
# and GHES release-asset URLs resolve via /api/v3 — keeping
|
||||
# --from consistent with catalog-based installs.
|
||||
dl_catalog = ExtensionCatalog(project_root)
|
||||
download_url = from_url
|
||||
extra_headers = None
|
||||
resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url)
|
||||
if resolved_url:
|
||||
download_url = resolved_url
|
||||
extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
with dl_catalog._open_url(
|
||||
download_url, timeout=60, extra_headers=extra_headers
|
||||
) as response:
|
||||
archive_data = read_response_limited(
|
||||
response,
|
||||
error_type=ExtensionError,
|
||||
label=f"extension {from_url}",
|
||||
)
|
||||
final_url = (
|
||||
response.geturl()
|
||||
if hasattr(response, "geturl")
|
||||
else download_url
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
|
||||
download_fd = -1
|
||||
download_file = None
|
||||
try:
|
||||
try:
|
||||
download_fd = _safe_open_download_zip(
|
||||
project_root, download_dir, archive_filename
|
||||
)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
"[red]Error:[/red] Could not safely create download file: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
download_file = os.fdopen(download_fd, "w+b")
|
||||
download_fd = -1
|
||||
download_file.write(archive_data)
|
||||
download_file.flush()
|
||||
download_file.seek(0)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
"[red]Error:[/red] Could not safely write download file: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
format_source = (
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else from_url
|
||||
)
|
||||
try:
|
||||
detect_archive_format(
|
||||
archive_path,
|
||||
archive_file=download_file,
|
||||
source_name=format_source,
|
||||
content_type=content_type,
|
||||
error_type=ExtensionError,
|
||||
)
|
||||
except ExtensionError:
|
||||
console.print(
|
||||
f"[red]Error:[/red] {safe_url} did not return a ZIP archive "
|
||||
"or tar.gz/tgz archive "
|
||||
f"(got {len(archive_data)} bytes). This usually means "
|
||||
"the request was not authenticated and a login/HTML page was "
|
||||
"returned. Verify the URL and configured credentials."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Consume the transient inode reserved above rather
|
||||
# than reopening the cache pathname during extraction.
|
||||
try:
|
||||
manifest = manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority=priority,
|
||||
force=force,
|
||||
archive_file=download_file,
|
||||
)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
"[red]Error:[/red] Could not install extension from downloaded archive: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
if download_file is not None:
|
||||
try:
|
||||
download_file.close()
|
||||
except OSError:
|
||||
pass
|
||||
elif download_fd >= 0:
|
||||
try:
|
||||
os.close(download_fd)
|
||||
except OSError:
|
||||
pass
|
||||
except urllib.error.URLError as e:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to download from {safe_url}: "
|
||||
f"{_escape_markup(str(e))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
else:
|
||||
# Try bundled extensions first (shipped with spec-kit)
|
||||
bundled_path = _locate_bundled_extension(extension)
|
||||
@@ -1071,29 +1050,8 @@ def extension_add(
|
||||
if reg_skills:
|
||||
console.print(f"\n[green]✓[/green] {len(reg_skills)} agent skill(s) auto-registered")
|
||||
|
||||
# Scaffold config templates automatically
|
||||
deployed, skipped, failed = manager.scaffold_config(manifest.id)
|
||||
config_home = f".specify/extensions/{_escape_markup(str(manifest.id))}"
|
||||
if deployed:
|
||||
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
|
||||
for cfg in deployed:
|
||||
console.print(f" • {config_home}/{_escape_markup(str(cfg))}")
|
||||
if skipped:
|
||||
console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]")
|
||||
if failed:
|
||||
console.print(
|
||||
f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: "
|
||||
f"{_escape_markup(', '.join(failed))}. "
|
||||
"Verify the extension manifest and template files."
|
||||
)
|
||||
|
||||
# Only warn when configuration is actually unresolved. Scaffolding that
|
||||
# deployed or preserved every template has already answered this, and an
|
||||
# extension without provides.config has nothing to configure; the blanket
|
||||
# warning contradicted the output directly above it.
|
||||
if failed or not (deployed or skipped):
|
||||
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
|
||||
console.print(f" Check: {config_home}/")
|
||||
console.print("\n[yellow]⚠[/yellow] Configuration may be required")
|
||||
console.print(f" Check: .specify/extensions/{_escape_markup(str(manifest.id))}/")
|
||||
|
||||
except ValidationError as e:
|
||||
console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}")
|
||||
@@ -2573,30 +2531,6 @@ def extension_enable(
|
||||
# are re-emitted in installed integrations.
|
||||
_refresh_events_and_warn(project_root)
|
||||
|
||||
# Scaffold config templates on enable
|
||||
try:
|
||||
deployed, skipped, failed = manager.scaffold_config(extension_id)
|
||||
except Exception as exc:
|
||||
console.print(
|
||||
f"\n[yellow]Warning:[/yellow] Failed to scaffold config for extension "
|
||||
f"'{_escape_markup(str(display_name))}'."
|
||||
)
|
||||
console.print(f"[dim]Details: {_escape_markup(str(exc))}[/dim]")
|
||||
deployed, skipped, failed = [], [], []
|
||||
config_home = f".specify/extensions/{_escape_markup(str(extension_id))}"
|
||||
if deployed:
|
||||
console.print("\n[bold cyan]Config scaffolded:[/bold cyan]")
|
||||
for cfg in deployed:
|
||||
console.print(f" • {config_home}/{_escape_markup(str(cfg))}")
|
||||
if skipped:
|
||||
console.print(f"\n[dim]Config files already exist (preserved): {_escape_markup(', '.join(skipped))}[/dim]")
|
||||
if failed:
|
||||
console.print(
|
||||
f"\n[yellow]Warning:[/yellow] Config templates not scaffolded: "
|
||||
f"{_escape_markup(', '.join(failed))}. "
|
||||
"Verify the extension manifest and template files."
|
||||
)
|
||||
|
||||
|
||||
@extension_app.command("disable")
|
||||
def extension_disable(
|
||||
|
||||
@@ -70,8 +70,8 @@ def with_integration_setting(
|
||||
# ``script_type`` changes (``parsed_options`` and ``raw_options`` both
|
||||
# None), the previously-stored ``parsed_options`` are retained above, so
|
||||
# deriving the separator from the argument (None) would drop an
|
||||
# options-dependent separator (e.g. Copilot ``--commands`` -> ".") back to
|
||||
# the default "-".
|
||||
# options-dependent separator (e.g. Copilot ``--skills`` -> "-") back to
|
||||
# the default ".".
|
||||
current["invoke_separator"] = integration.effective_invoke_separator(
|
||||
current.get("parsed_options"), project_root
|
||||
)
|
||||
|
||||
@@ -56,7 +56,6 @@ def _register_builtins() -> None:
|
||||
from .cline import ClineIntegration
|
||||
from .codebuddy import CodebuddyIntegration
|
||||
from .codex import CodexIntegration
|
||||
from .command_code import CommandCodeIntegration
|
||||
from .copilot import CopilotIntegration
|
||||
from .cursor_agent import CursorAgentIntegration
|
||||
from .devin import DevinIntegration
|
||||
@@ -96,7 +95,6 @@ def _register_builtins() -> None:
|
||||
_register(ClineIntegration())
|
||||
_register(CodebuddyIntegration())
|
||||
_register(CodexIntegration())
|
||||
_register(CommandCodeIntegration())
|
||||
_register(CopilotIntegration())
|
||||
_register(CursorAgentIntegration())
|
||||
_register(DevinIntegration())
|
||||
|
||||
@@ -331,14 +331,6 @@ def integration_switch(
|
||||
|
||||
selected_script = _resolve_script_type(project_root, script)
|
||||
|
||||
# Resolve and validate target options before uninstalling the current
|
||||
# integration. Invalid options must not leave the project partially
|
||||
# switched with the previous integration already removed.
|
||||
target_raw_options, target_parsed_options = _resolve_integration_options(
|
||||
target_integration, current, target, integration_options
|
||||
)
|
||||
target_integration.is_skills_mode(target_parsed_options, project_root)
|
||||
|
||||
# Phase 1: Uninstall current integration (if any)
|
||||
if installed_key:
|
||||
current_integration = get_integration(installed_key)
|
||||
@@ -411,10 +403,7 @@ def integration_switch(
|
||||
fallback_key = installed_keys[0]
|
||||
fallback_integration = get_integration(fallback_key)
|
||||
if fallback_integration is not None:
|
||||
(
|
||||
fallback_raw_options,
|
||||
fallback_parsed_options,
|
||||
) = _resolve_integration_options(
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
fallback_integration, current, fallback_key, None
|
||||
)
|
||||
_set_default_integration_or_exit(
|
||||
@@ -423,8 +412,8 @@ def integration_switch(
|
||||
fallback_key,
|
||||
fallback_integration,
|
||||
installed_keys,
|
||||
raw_options=fallback_raw_options,
|
||||
parsed_options=fallback_parsed_options,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
else:
|
||||
_write_integration_json(
|
||||
@@ -434,6 +423,13 @@ def integration_switch(
|
||||
_remove_integration_json(project_root)
|
||||
current = _read_integration_json(project_root)
|
||||
|
||||
# Build parsed options from --integration-options so the integration
|
||||
# can determine its effective invoke separator before shared infra
|
||||
# is installed.
|
||||
raw_options, parsed_options = _resolve_integration_options(
|
||||
target_integration, current, target, integration_options
|
||||
)
|
||||
|
||||
# Refresh shared infrastructure to the current CLI version. Switching
|
||||
# integrations is exactly when stale vendored shared scripts (e.g.
|
||||
# update-agent-context.sh that pre-dates the target integration's
|
||||
@@ -449,11 +445,11 @@ def integration_switch(
|
||||
force=refresh_shared_infra,
|
||||
refresh_managed=True,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
target_integration, current, target, target_parsed_options,
|
||||
target_integration, current, target, parsed_options,
|
||||
project_root=project_root,
|
||||
),
|
||||
invoke_prefix=_invoke_prefix_for_integration(
|
||||
target_integration, target, target_parsed_options, project_root
|
||||
target_integration, target, parsed_options, project_root
|
||||
),
|
||||
refresh_hint=(
|
||||
"To overwrite customizations, re-run with "
|
||||
@@ -475,14 +471,14 @@ def integration_switch(
|
||||
target_integration.key,
|
||||
target_integration.config,
|
||||
project_root,
|
||||
target_parsed_options,
|
||||
parsed_options,
|
||||
)
|
||||
try:
|
||||
target_integration.setup(
|
||||
project_root, manifest,
|
||||
parsed_options=target_parsed_options,
|
||||
parsed_options=parsed_options,
|
||||
script_type=selected_script,
|
||||
raw_options=target_raw_options,
|
||||
raw_options=raw_options,
|
||||
events=events_map,
|
||||
)
|
||||
manifest.save()
|
||||
@@ -493,8 +489,8 @@ def integration_switch(
|
||||
target_integration,
|
||||
_dedupe_integration_keys([*installed_keys, target_integration.key]),
|
||||
script_type=selected_script,
|
||||
raw_options=target_raw_options,
|
||||
parsed_options=target_parsed_options,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
|
||||
@@ -65,16 +65,7 @@ class AlquimiaAIIntegration(SkillsIntegration):
|
||||
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
"""Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter.
|
||||
|
||||
A long ``description`` gets folded by the YAML dumper across
|
||||
indented continuation lines (plain or quoted), and an embedded
|
||||
paragraph break can add unindented blank lines inside a quoted
|
||||
scalar. Inserting the new line right after the *first* line of
|
||||
that scalar — instead of after the whole scalar — either produces
|
||||
invalid YAML or gets silently absorbed into the description
|
||||
string (#4044), so every continuation line (indented, or blank)
|
||||
is skipped first.
|
||||
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
|
||||
|
||||
Skips injection if ``argument-hint:`` already exists in the
|
||||
frontmatter to avoid duplicate keys.
|
||||
@@ -97,29 +88,15 @@ class AlquimiaAIIntegration(SkillsIntegration):
|
||||
in_fm = False
|
||||
dash_count = 0
|
||||
injected = False
|
||||
i = 0
|
||||
n = len(lines)
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
in_fm = dash_count == 1
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if in_fm and not injected and stripped.startswith("description:"):
|
||||
out.append(line)
|
||||
i += 1
|
||||
# Skip folded/quoted continuation lines before inserting
|
||||
# so the new key lands after the description scalar ends.
|
||||
# Blank lines count too: PyYAML emits unindented blank
|
||||
# lines for embedded "\n\n" inside a quoted scalar.
|
||||
while i < n and (
|
||||
lines[i][:1] in (" ", "\t") or lines[i].rstrip("\r\n") == ""
|
||||
):
|
||||
out.append(lines[i])
|
||||
i += 1
|
||||
# Preserve the exact line-ending style (\r\n vs \n)
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
@@ -132,7 +109,6 @@ class AlquimiaAIIntegration(SkillsIntegration):
|
||||
injected = True
|
||||
continue
|
||||
out.append(line)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -225,8 +225,8 @@ class IntegrationBase(ABC):
|
||||
on-disk layout to avoid silently migrating an existing project to a
|
||||
different mode. The default ignores it.
|
||||
|
||||
The default for command-first integrations is skills mode only when
|
||||
``--skills`` was requested.
|
||||
The default (command-first integrations, e.g. Copilot's default
|
||||
layout) is skills mode only when ``--skills`` was requested.
|
||||
``SkillsIntegration`` overrides this to return ``True`` by default;
|
||||
skills-first integrations that expose a legacy opt-out (e.g. Bob)
|
||||
override it to honor their own flag.
|
||||
@@ -957,20 +957,6 @@ class IntegrationBase(ABC):
|
||||
"""Return True if this integration supports agent-native events."""
|
||||
return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
|
||||
|
||||
# Context-injection envelope for hook stdout, keyed by canonical event
|
||||
# (with "*" as the fallback). Not every agent injects a hook's plain-text
|
||||
# stdout as model context: Gemini/Tabnine/Qwen/Devin are JSON-only
|
||||
# protocols (plain text becomes user-facing noise), Copilot discards
|
||||
# non-JSON stdout, and Cursor parses stdout as JSON. Values:
|
||||
# "hookSpecificOutput" → {"hookSpecificOutput": {"additionalContext": ...}}
|
||||
# "additionalContext" → {"additionalContext": ...} (top-level, Copilot)
|
||||
# "additional_context" → {"additional_context": ...} (top-level, Cursor)
|
||||
# "suppress" → emit nothing (strict-JSON agents on events whose
|
||||
# output can't be used)
|
||||
# Absent (no matching key and no "*") → plain stdout passthrough
|
||||
# (Claude/Codex inject plain stdout; opencode injects via its TS plugin).
|
||||
events_context_envelope: dict[str, str] = {}
|
||||
|
||||
# -- Convenience helpers for subclasses -------------------------------
|
||||
|
||||
def install(
|
||||
|
||||
@@ -207,7 +207,7 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
max_bytes=MAX_JSON_METADATA_BYTES,
|
||||
error_type=IntegrationCatalogError,
|
||||
label=f"catalog from {entry.url}",
|
||||
).decode("utf-8")
|
||||
)
|
||||
)
|
||||
|
||||
shape_error = _catalog_shape_error(catalog_data)
|
||||
@@ -237,15 +237,6 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
raise IntegrationCatalogError(
|
||||
f"Failed to fetch catalog from {entry.url}: {exc}"
|
||||
)
|
||||
except UnicodeDecodeError as exc:
|
||||
# A non-UTF-8 response body fails at .decode() before json.loads()
|
||||
# ever runs, so JSONDecodeError below does not cover it (the two are
|
||||
# sibling ValueError subclasses, not parent/child). Without this the
|
||||
# raw UnicodeDecodeError escapes _get_merged_integrations()'s
|
||||
# "warn and skip this catalog" handler and kills the whole command.
|
||||
raise IntegrationCatalogError(
|
||||
f"Catalog from {entry.url} is not valid UTF-8: {exc}"
|
||||
)
|
||||
except json.JSONDecodeError as exc:
|
||||
raise IntegrationCatalogError(
|
||||
f"Invalid JSON in catalog from {entry.url}: {exc}"
|
||||
|
||||
@@ -67,16 +67,7 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
"""Insert ``argument-hint`` after the ``description:`` scalar in YAML frontmatter.
|
||||
|
||||
A long ``description`` gets folded by the YAML dumper across
|
||||
indented continuation lines (plain or quoted), and an embedded
|
||||
paragraph break can add unindented blank lines inside a quoted
|
||||
scalar. Inserting the new line right after the *first* line of
|
||||
that scalar — instead of after the whole scalar — either produces
|
||||
invalid YAML or gets silently absorbed into the description
|
||||
string (#4044), so every continuation line (indented, or blank)
|
||||
is skipped first.
|
||||
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
|
||||
|
||||
Skips injection if ``argument-hint:`` already exists in the
|
||||
frontmatter to avoid duplicate keys.
|
||||
@@ -99,29 +90,15 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
in_fm = False
|
||||
dash_count = 0
|
||||
injected = False
|
||||
i = 0
|
||||
n = len(lines)
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
in_fm = dash_count == 1
|
||||
out.append(line)
|
||||
i += 1
|
||||
continue
|
||||
if in_fm and not injected and stripped.startswith("description:"):
|
||||
out.append(line)
|
||||
i += 1
|
||||
# Skip past folded/quoted continuation lines of the scalar
|
||||
# before inserting, so the new key lands after it ends.
|
||||
# Blank lines count too: PyYAML emits unindented blank
|
||||
# lines for embedded "\n\n" inside a quoted scalar.
|
||||
while i < n and (
|
||||
lines[i][:1] in (" ", "\t") or lines[i].rstrip("\r\n") == ""
|
||||
):
|
||||
out.append(lines[i])
|
||||
i += 1
|
||||
# Preserve the exact line-ending style (\r\n vs \n)
|
||||
if line.endswith("\r\n"):
|
||||
eol = "\r\n"
|
||||
@@ -134,7 +111,6 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
injected = True
|
||||
continue
|
||||
out.append(line)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
def _render_skill(self, template_name: str, frontmatter: dict[str, Any], body: str) -> str:
|
||||
|
||||
@@ -1,41 +0,0 @@
|
||||
"""Command Code integration — skills-based agent.
|
||||
|
||||
Command Code loads agent skills from ``.commandcode/skills/speckit-<name>/SKILL.md``
|
||||
(project) or ``~/.commandcode/skills/`` (personal). Skills are invoked in chat
|
||||
with ``$speckit-<name>``.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import IntegrationOption, SkillsIntegration
|
||||
|
||||
|
||||
class CommandCodeIntegration(SkillsIntegration):
|
||||
"""Integration for Command Code CLI."""
|
||||
|
||||
key = "command-code"
|
||||
config = {
|
||||
"name": "Command Code",
|
||||
"folder": ".commandcode/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://commandcode.ai/docs",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".commandcode/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=True,
|
||||
help="Install as agent skills (default for Command Code)",
|
||||
),
|
||||
]
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user