mirror of
https://github.com/github/spec-kit.git
synced 2026-08-15 14:02:04 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b85aaeda4a | ||
|
|
16f45774a5 | ||
|
|
229022943c | ||
|
|
e79fa25f3f | ||
|
|
750ce47cb8 | ||
|
|
197dde6253 | ||
|
|
f2583e675c | ||
|
|
996b24c561 | ||
|
|
b77ca572ca | ||
|
|
c1bceb625c | ||
|
|
7dd706880e | ||
|
|
bd595cf838 | ||
|
|
85d3ed289d | ||
|
|
5ec937e07a | ||
|
|
6461f09483 | ||
|
|
fb427e47ef | ||
|
|
bd04776dcc | ||
|
|
c5258e680b | ||
|
|
9d15554c08 | ||
|
|
c3bbcc40a0 | ||
|
|
39b56626c9 | ||
|
|
6aa9431b24 | ||
|
|
1b3695bfb3 | ||
|
|
3451a21277 | ||
|
|
fd8cc6da22 | ||
|
|
2df78f33fb | ||
|
|
36da77f864 | ||
|
|
1a44a6aa08 | ||
|
|
f0ee1fc419 | ||
|
|
44c3dc7d3e | ||
|
|
2100f59f7e | ||
|
|
a2c7ff29db | ||
|
|
ea1f5b8a2d | ||
|
|
29efcbe2c4 | ||
|
|
11e3176fd1 | ||
|
|
16cfab7724 | ||
|
|
1a60d1b6b8 | ||
|
|
d751231468 | ||
|
|
684b3d8e05 | ||
|
|
247abbf5e4 | ||
|
|
6da6956c3e | ||
|
|
2a28f62e25 | ||
|
|
5d9ac6a3f5 | ||
|
|
920ed7546d | ||
|
|
8865e57344 | ||
|
|
d2befe4021 | ||
|
|
81d5cdbbf2 | ||
|
|
adb2413ab6 | ||
|
|
36a33555bc | ||
|
|
f71cfafa71 | ||
|
|
4a465431b8 | ||
|
|
204d94fdb1 | ||
|
|
40037b1aca | ||
|
|
3dff6f1d50 | ||
|
|
fe3732e268 | ||
|
|
3d4f71c90e | ||
|
|
f31b2b45eb | ||
|
|
f8a448f0a9 | ||
|
|
e9710ae45e | ||
|
|
71125fc346 | ||
|
|
f01cac6300 | ||
|
|
6fa8c9aaef |
@@ -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, 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, 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
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
@@ -70,6 +70,7 @@ body:
|
||||
- Cline
|
||||
- CodeBuddy
|
||||
- Codex CLI
|
||||
- Command Code
|
||||
- Cursor
|
||||
- Devin for Terminal
|
||||
- Factory Droid
|
||||
|
||||
@@ -16,6 +16,8 @@ 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,6 +14,8 @@ 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,6 +64,7 @@ body:
|
||||
- Cline
|
||||
- CodeBuddy
|
||||
- Codex CLI
|
||||
- Command Code
|
||||
- Cursor
|
||||
- Devin for Terminal
|
||||
- Factory Droid
|
||||
|
||||
@@ -14,6 +14,8 @@ 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@v6.0.3": {
|
||||
"actions/checkout@v7.0.1": {
|
||||
"repo": "actions/checkout",
|
||||
"version": "v6.0.3",
|
||||
"sha": "df4cb1c069e1874edd31b4311f1884172cec0e10"
|
||||
"version": "v7.0.1",
|
||||
"sha": "3d3c42e5aac5ba805825da76410c181273ba90b1"
|
||||
},
|
||||
"actions/download-artifact@v8.0.1": {
|
||||
"repo": "actions/download-artifact",
|
||||
@@ -15,10 +15,20 @@
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"actions/setup-node@v6.4.0": {
|
||||
"actions/setup-node@v7.0.0": {
|
||||
"repo": "actions/setup-node",
|
||||
"version": "v6.4.0",
|
||||
"sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"
|
||||
"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"
|
||||
},
|
||||
"actions/upload-artifact@v7.0.1": {
|
||||
"repo": "actions/upload-artifact",
|
||||
|
||||
+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":"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"}]}
|
||||
# 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"}]}
|
||||
# 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
|
||||
#
|
||||
# ___ _ _
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# 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"}]}
|
||||
# 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"}]}
|
||||
# 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
|
||||
#
|
||||
# ___ _ _
|
||||
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
# 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"}]}
|
||||
# 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"}]}
|
||||
# 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":"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":"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"}]}
|
||||
# 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"}]}
|
||||
# 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":"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"}]}
|
||||
# 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"}]}
|
||||
# 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
+18
-7
File diff suppressed because one or more lines are too long
@@ -60,6 +60,22 @@ 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: Auto-assign submission"
|
||||
name: "Catalog: Notify submission"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [opened, labeled]
|
||||
|
||||
jobs:
|
||||
assign:
|
||||
notify:
|
||||
if: >
|
||||
(github.event.action == 'opened' && (
|
||||
contains(github.event.issue.labels.*.name, 'extension-submission') ||
|
||||
@@ -24,24 +24,8 @@ 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@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
uses: github/codeql-action/init@d1ba80a13dd99fba24a470575428917156a28b43 # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
uses: github/codeql-action/analyze@d1ba80a13dd99fba24a470575428917156a28b43 # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
|
||||
uses: DavidAnson/markdownlint-cli2-action@21c1be1b93ad9ed58fa840aacc3f279cde2a72ff # v24.2.0
|
||||
with:
|
||||
globs: |
|
||||
'**/*.md'
|
||||
|
||||
@@ -2,6 +2,82 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [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
|
||||
|
||||
@@ -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 in skills mode uses `$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 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.
|
||||
|
||||
Use the **`/speckit.constitution`** command to create your project's governing principles and development guidelines that will guide all subsequent development.
|
||||
|
||||
@@ -283,9 +283,11 @@ 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 manifests live under
|
||||
Four ready-to-read example bundle manifests live under
|
||||
[`examples/bundles/`](examples/bundles/) (product manager, business analyst,
|
||||
security researcher, developer).
|
||||
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).
|
||||
|
||||
Key guarantees: `info` shows exactly what `install` adds (transparency);
|
||||
installs are idempotent and confined to the project root; `remove` never touches
|
||||
|
||||
@@ -34,7 +34,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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 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. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
@@ -72,6 +72,7 @@ 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) |
|
||||
@@ -145,6 +146,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
| 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) |
|
||||
|
||||
@@ -7,29 +7,30 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| 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) |
|
||||
| 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 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 software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Architecture Governance | Adds secure 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) |
|
||||
| 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 and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence. | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell parity, 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) |
|
||||
| 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 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 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) |
|
||||
| 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) |
|
||||
| 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 and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| Security Governance | Adds memory-safe-language 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) |
|
||||
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
|
||||
@@ -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:
|
||||
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.
|
||||
|
||||
- **[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,6 +35,27 @@ 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.
|
||||
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).
|
||||
|
||||
### One-time Usage
|
||||
|
||||
|
||||
+5
-5
@@ -5,8 +5,7 @@ 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.
|
||||
|
||||
> [!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.
|
||||
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
|
||||
|
||||
@@ -83,7 +82,7 @@ Generates the design artifacts from the spec. This is where implementation detai
|
||||
|
||||
### Step 5: `/speckit.checklist` — validate the spec
|
||||
|
||||
Generates a quality checklist — "unit tests for your requirements" — to confirm the spec is complete, clear, and consistent before you break the work down.
|
||||
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.
|
||||
|
||||
```text
|
||||
/speckit.checklist
|
||||
@@ -107,7 +106,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. 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. 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.
|
||||
|
||||
```text
|
||||
/speckit.implement
|
||||
@@ -136,5 +135,6 @@ 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
|
||||
- Check out [more examples](https://github.com/github/spec-kit/tree/main/templates) in the repository
|
||||
- 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
|
||||
- Explore the [source code on GitHub](https://github.com/github/spec-kit)
|
||||
|
||||
@@ -23,6 +23,8 @@ 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.
|
||||
```
|
||||
@@ -37,6 +39,8 @@ 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.
|
||||
@@ -49,6 +53,8 @@ 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
|
||||
@@ -59,7 +65,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.
|
||||
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.
|
||||
|
||||
## `/speckit.tasks`
|
||||
|
||||
@@ -83,6 +89,8 @@ 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
|
||||
|
||||
@@ -57,6 +57,8 @@ specify init my-project --integration copilot --preset compliance
|
||||
|
||||
> **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,6 +14,7 @@ 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>` |
|
||||
@@ -279,6 +280,7 @@ 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,6 +47,8 @@ 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
|
||||
|
||||
@@ -40,12 +40,25 @@ requires:
|
||||
required: boolean # Optional, default: false
|
||||
|
||||
provides:
|
||||
commands: # Required, at least one command
|
||||
commands: # At least one of commands/templates/scripts/hooks/events required
|
||||
- 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
|
||||
@@ -111,6 +124,29 @@ 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
|
||||
@@ -143,6 +179,8 @@ 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,9 +177,16 @@ Compatibility requirements.
|
||||
|
||||
What the extension provides.
|
||||
|
||||
**Optional sub-fields**:
|
||||
**Optional sub-fields:**
|
||||
|
||||
- `commands`: Array of command objects (at least one command or hook is required)
|
||||
- `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.
|
||||
|
||||
**Command object**:
|
||||
|
||||
@@ -188,6 +195,21 @@ What the extension provides.
|
||||
- `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. Your issue is automatically labeled and assigned to a maintainer for review
|
||||
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.
|
||||
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`` anywhere under ``specs/`` (including nested scoped
|
||||
layouts such as ``specs/<scope>/<feature>/plan.md``) only when feature.json is
|
||||
recently modified ``plan.md`` found anywhere under ``specs/`` — scoped layouts
|
||||
nest it as ``specs/<scope>/<feature>/plan.md`` — only when feature.json is
|
||||
absent or its plan does not exist yet.
|
||||
"""
|
||||
|
||||
@@ -173,16 +173,31 @@ def _resolve_plan_path(project_root: str) -> str:
|
||||
|
||||
if not plan_path:
|
||||
root = Path(project_root).resolve()
|
||||
plans = sorted(
|
||||
(root / "specs").rglob("plan.md"),
|
||||
key=lambda p: p.stat().st_mtime,
|
||||
reverse=True,
|
||||
)
|
||||
if plans:
|
||||
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").
|
||||
try:
|
||||
plan_path = plans[0].relative_to(root).as_posix()
|
||||
except ValueError:
|
||||
plan_path = ""
|
||||
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()
|
||||
return plan_path
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-08-04T00:00:00Z",
|
||||
"updated_at": "2026-08-11T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"adrkit": {
|
||||
@@ -362,8 +362,8 @@
|
||||
"id": "archive",
|
||||
"description": "Archive merged features into main project memory, resolving gaps and conflicts.",
|
||||
"author": "Stanislav Deviatov",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.1.0.zip",
|
||||
"version": "1.2.2",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-archive/archive/refs/tags/v1.2.2.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",
|
||||
@@ -388,7 +388,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00:00:00Z"
|
||||
"updated_at": "2026-08-11T00:00:00Z"
|
||||
},
|
||||
"azure-devops": {
|
||||
"name": "Azure DevOps Integration",
|
||||
@@ -1815,6 +1815,41 @@
|
||||
"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",
|
||||
@@ -2107,6 +2142,40 @@
|
||||
"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",
|
||||
@@ -3406,8 +3475,8 @@
|
||||
"id": "reconcile",
|
||||
"description": "Reconcile implementation drift by surgically updating the feature's own spec, plan, and tasks.",
|
||||
"author": "Stanislav Deviatov",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.0.0.zip",
|
||||
"version": "1.1.0",
|
||||
"download_url": "https://github.com/stn1slv/spec-kit-reconcile/archive/refs/tags/v1.1.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",
|
||||
@@ -3432,7 +3501,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-03-14T00:00:00Z",
|
||||
"updated_at": "2026-03-14T00:00:00Z"
|
||||
"updated_at": "2026-08-10T00:00:00Z"
|
||||
},
|
||||
"red-team": {
|
||||
"name": "Red Team",
|
||||
|
||||
@@ -84,6 +84,15 @@
|
||||
"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",
|
||||
|
||||
@@ -59,6 +59,19 @@ 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,6 +300,12 @@ 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,6 +15,16 @@ 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
|
||||
|
||||
@@ -1,40 +1,35 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-28T00:00:00Z",
|
||||
|
||||
"updated_at": "2026-08-12T00: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.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.",
|
||||
"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.",
|
||||
"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.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.3.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.3/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 10,
|
||||
"templates": 11,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"a11y",
|
||||
"accessibility",
|
||||
"bilingual",
|
||||
"wcag",
|
||||
"wcag-2-2",
|
||||
"cefr-b2",
|
||||
"inclusion",
|
||||
"include-everyone",
|
||||
"didactic-comments"
|
||||
"model-routing"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
"updated_at": "2026-08-12T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
@@ -97,40 +92,30 @@
|
||||
"architecture-governance": {
|
||||
"name": "Architecture Governance",
|
||||
"id": "architecture-governance",
|
||||
"version": "0.5.1",
|
||||
"description": "Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"version": "0.5.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.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 13,
|
||||
"templates": 14,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"threat-modeling",
|
||||
"stride",
|
||||
"capec",
|
||||
"arc42",
|
||||
"adr",
|
||||
"zero-trust",
|
||||
"samm",
|
||||
"isaqb",
|
||||
"cloud",
|
||||
"sovereignty",
|
||||
"c3a",
|
||||
"c5",
|
||||
"assurance"
|
||||
"model-routing"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-08-11T00:00:00Z"
|
||||
},
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
@@ -243,19 +228,19 @@
|
||||
"cross-platform-governance": {
|
||||
"name": "Cross-Platform Governance",
|
||||
"id": "cross-platform-governance",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence.",
|
||||
"version": "0.2.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.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 8,
|
||||
"templates": 9,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -263,15 +248,10 @@
|
||||
"governance",
|
||||
"bash",
|
||||
"powershell",
|
||||
"man-page",
|
||||
"cmdlet",
|
||||
"verb-noun",
|
||||
"windows",
|
||||
"macos",
|
||||
"linux"
|
||||
"model-routing"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-08-12T00:00:00Z"
|
||||
},
|
||||
"explicit-task-dependencies": {
|
||||
"name": "Explicit Task Dependencies",
|
||||
@@ -454,34 +434,30 @@
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
"id": "isaqb-architecture-governance",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt.",
|
||||
"version": "0.2.2",
|
||||
"description": "Adds iSAQB/CPSA-F and arc42 architecture governance, architecture views, quality scenarios, ADRs, risks, technical-debt evidence, and provider-neutral model routing.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 13,
|
||||
"templates": 14,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"architecture",
|
||||
"governance",
|
||||
"isaqb",
|
||||
"cpsa-f",
|
||||
"arc42",
|
||||
"adr",
|
||||
"quality-attributes",
|
||||
"architecture-views",
|
||||
"technical-debt"
|
||||
"model-routing"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-08-11T00:00:00Z"
|
||||
},
|
||||
"jira": {
|
||||
"name": "Jira Issue Tracking",
|
||||
@@ -541,6 +517,35 @@
|
||||
"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",
|
||||
@@ -580,7 +585,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,
|
||||
@@ -663,52 +668,30 @@
|
||||
"security-governance": {
|
||||
"name": "Security Governance",
|
||||
"id": "security-governance",
|
||||
"version": "0.6.1",
|
||||
"description": "Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening.",
|
||||
"version": "0.6.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.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 14,
|
||||
"templates": 15,
|
||||
"commands": 3
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"governance",
|
||||
"msl",
|
||||
"ssdf",
|
||||
"asvs",
|
||||
"supply-chain",
|
||||
"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"
|
||||
"supply-chain",
|
||||
"model-routing"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-08-10T00:00:00Z"
|
||||
},
|
||||
"sicario-core": {
|
||||
"name": "SicarioSpec Core",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-24T00:00:00Z",
|
||||
"updated_at": "2026-08-04T00: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 /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts.",
|
||||
"description": "Opt-in: restores guarded install-time constitution seeding and /constitution propagation for teams that treat materialized templates as reviewed artifacts.",
|
||||
"author": "github",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"license": "MIT",
|
||||
|
||||
@@ -1,13 +1,15 @@
|
||||
# Constitution Template Sync
|
||||
|
||||
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
|
||||
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
|
||||
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 it: you get the guidance materialized into
|
||||
reviewed, committed artifacts instead of relying on runtime resolution alone.
|
||||
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.
|
||||
|
||||
> **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
|
||||
@@ -28,7 +30,12 @@ versioned preset a core team maintains.
|
||||
|
||||
## What it does
|
||||
|
||||
Ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the
|
||||
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
|
||||
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:
|
||||
|
||||
@@ -43,6 +50,8 @@ 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 /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts."
|
||||
description: "Opt-in: restores guarded install-time constitution seeding and /constitution propagation 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.0"
|
||||
version = "0.16.3"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# --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:
|
||||
@@ -26,9 +27,10 @@ JSON_MODE=false
|
||||
REQUIRE_TASKS=false
|
||||
INCLUDE_TASKS=false
|
||||
PATHS_ONLY=false
|
||||
TEMPLATE_NAME=""
|
||||
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
while [[ $# -gt 0 ]]; do
|
||||
case "$1" in
|
||||
--json)
|
||||
JSON_MODE=true
|
||||
;;
|
||||
@@ -41,6 +43,14 @@ for arg in "$@"; 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]
|
||||
@@ -52,6 +62,7 @@ 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:
|
||||
@@ -68,10 +79,11 @@ EOF
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "ERROR: Unknown option '$arg'. Use --help for usage information." >&2
|
||||
echo "ERROR: Unknown option '$1'. Use --help for usage information." >&2
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
shift
|
||||
done
|
||||
|
||||
# Source common functions
|
||||
@@ -156,6 +168,16 @@ 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
|
||||
@@ -165,10 +187,18 @@ if $JSON_MODE; then
|
||||
else
|
||||
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
|
||||
fi
|
||||
jq -cn \
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs}'
|
||||
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
|
||||
else
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
@@ -176,7 +206,12 @@ 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}\n' "$(json_escape "$FEATURE_DIR")" "$json_docs"
|
||||
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
|
||||
fi
|
||||
else
|
||||
# Text output
|
||||
|
||||
+322
-108
@@ -398,6 +398,101 @@ 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)
|
||||
@@ -408,6 +503,8 @@ 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
|
||||
@@ -416,19 +513,32 @@ resolve_template() {
|
||||
local presets_dir="$repo_root/.specify/presets"
|
||||
if [ -d "$presets_dir" ]; then
|
||||
local registry_file="$presets_dir/.registry"
|
||||
if [ -f "$registry_file" ] && command -v python3 >/dev/null 2>&1; then
|
||||
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
|
||||
# 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" python3 -c "
|
||||
import json, sys, os
|
||||
if sorted_presets=$(SPECKIT_REGISTRY="$registry_file" "${python_cmd[@]}" -c "
|
||||
import json, re, sys, os
|
||||
try:
|
||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
presets = data.get('presets', {})
|
||||
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:
|
||||
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):
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
@@ -438,6 +548,8 @@ 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
|
||||
@@ -447,6 +559,8 @@ 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
|
||||
@@ -455,6 +569,8 @@ 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
|
||||
@@ -462,13 +578,17 @@ except Exception:
|
||||
# Priority 3: Extension-provided templates
|
||||
local ext_dir="$repo_root/.specify/extensions"
|
||||
if [ -d "$ext_dir" ]; then
|
||||
for ext in "$ext_dir"/*/; do
|
||||
[ -d "$ext" ] || continue
|
||||
# Skip hidden directories (e.g. .backup, .cache)
|
||||
case "$(basename "$ext")" in .*) continue;; esac
|
||||
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"
|
||||
local candidate="$ext/templates/${template_name}.md"
|
||||
[ -f "$candidate" ] || candidate="$ext/${template_name}.md"
|
||||
[ -f "$candidate" ] && echo "$candidate" && return 0
|
||||
done
|
||||
done <<< "$sorted_extensions"
|
||||
fi
|
||||
|
||||
# Priority 4: Core templates
|
||||
@@ -492,6 +612,8 @@ 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=()
|
||||
@@ -499,133 +621,206 @@ resolve_template_content() {
|
||||
# Priority 1: Project overrides (always "replace")
|
||||
local override="$base/overrides/${template_name}.md"
|
||||
if [ -f "$override" ]; then
|
||||
layer_paths+=("$override")
|
||||
layer_strategies+=("replace")
|
||||
if ! cat "$override"; then
|
||||
echo "Error: failed to read template layer $override" >&2
|
||||
return 2
|
||||
fi
|
||||
return 0
|
||||
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=""
|
||||
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
|
||||
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
|
||||
try:
|
||||
with open(os.environ['SPECKIT_REGISTRY']) as f:
|
||||
with open(os.environ['SPECKIT_REGISTRY'], encoding='utf-8') as f:
|
||||
data = json.load(f)
|
||||
presets = data.get('presets', {})
|
||||
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:
|
||||
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):
|
||||
print(pid)
|
||||
except Exception:
|
||||
sys.exit(1)
|
||||
" 2>/dev/null); then
|
||||
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 "
|
||||
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 "
|
||||
import sys, os
|
||||
try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print('yaml_missing', file=sys.stderr)
|
||||
print('replace\t')
|
||||
sys.exit(0)
|
||||
sys.exit(2)
|
||||
try:
|
||||
with open(os.environ['SPECKIT_MANIFEST']) as f:
|
||||
with open(os.environ['SPECKIT_MANIFEST'], encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
for t in data.get('provides', {}).get('templates', []):
|
||||
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:
|
||||
if t.get('name') == os.environ['SPECKIT_TMPL'] and t.get('type', 'template') == 'template':
|
||||
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
||||
file_value = t.get('file', '')
|
||||
strategy = t.get('strategy', 'replace')
|
||||
print('found\t' + strategy + '\t' + file_value)
|
||||
sys.exit(0)
|
||||
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
|
||||
# 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")
|
||||
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
|
||||
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+=("replace")
|
||||
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
|
||||
fi
|
||||
rm -f "$py_stderr"
|
||||
return 2
|
||||
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
|
||||
done
|
||||
|
||||
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
|
||||
layer_paths+=("$candidate")
|
||||
layer_strategies+=("$strategy")
|
||||
if [ "$strategy" = "replace" ]; then
|
||||
effective_base_found=true
|
||||
break
|
||||
fi
|
||||
fi
|
||||
done <<< "$sorted_presets"
|
||||
fi
|
||||
fi
|
||||
|
||||
# Priority 3: Extension-provided templates (always "replace")
|
||||
local ext_dir="$repo_root/.specify/extensions"
|
||||
if [ -d "$ext_dir" ]; then
|
||||
for ext in "$ext_dir"/*/; do
|
||||
[ -d "$ext" ] || continue
|
||||
case "$(basename "$ext")" in .*) continue;; esac
|
||||
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"
|
||||
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
|
||||
done <<< "$sorted_extensions"
|
||||
fi
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
local core="$base/${template_name}.md"
|
||||
if [ -f "$core" ]; then
|
||||
if [ "$effective_base_found" = false ] && [ -f "$core" ]; then
|
||||
layer_paths+=("$core")
|
||||
layer_strategies+=("replace")
|
||||
fi
|
||||
@@ -642,12 +837,18 @@ except Exception:
|
||||
# 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
|
||||
cat "${layer_paths[0]}"
|
||||
if ! cat "${layer_paths[0]}"; then
|
||||
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
|
||||
return 2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
if [ "$has_composition" = false ]; then
|
||||
cat "${layer_paths[0]}"
|
||||
if ! cat "${layer_paths[0]}"; then
|
||||
echo "Error: failed to read template layer ${layer_paths[0]}" >&2
|
||||
return 2
|
||||
fi
|
||||
return 0
|
||||
fi
|
||||
|
||||
@@ -663,12 +864,16 @@ except Exception:
|
||||
done
|
||||
|
||||
if [ $base_idx -lt 0 ]; then
|
||||
return 1 # no base layer found
|
||||
echo "Error: template '$template_name' has composing layers but no replace base" >&2
|
||||
return 2
|
||||
fi
|
||||
|
||||
# Read the base content; compose layers above the base (higher priority)
|
||||
local content
|
||||
content=$(cat "${layer_paths[$base_idx]}"; printf x)
|
||||
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="${content%x}"
|
||||
|
||||
for (( i=base_idx-1; i>=0; i-- )); do
|
||||
@@ -676,17 +881,26 @@ except Exception:
|
||||
local strat="${layer_strategies[$i]}"
|
||||
local layer_content
|
||||
# Preserve trailing newlines
|
||||
layer_content=$(cat "$path"; printf x)
|
||||
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="${layer_content%x}"
|
||||
|
||||
case "$strat" in
|
||||
replace) content="$layer_content" ;;
|
||||
prepend) content="$(printf '%s\n\n%s' "$layer_content" "$content")" ;;
|
||||
append) content="$(printf '%s\n\n%s' "$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}"
|
||||
;;
|
||||
wrap)
|
||||
case "$layer_content" in
|
||||
*'{CORE_TEMPLATE}'*) ;;
|
||||
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 1 ;;
|
||||
*) echo "Error: wrap strategy missing {CORE_TEMPLATE} placeholder" >&2; return 2 ;;
|
||||
esac
|
||||
while [[ "$layer_content" == *'{CORE_TEMPLATE}'* ]]; do
|
||||
local before="${layer_content%%\{CORE_TEMPLATE\}*}"
|
||||
@@ -695,7 +909,7 @@ except Exception:
|
||||
done
|
||||
content="$layer_content"
|
||||
;;
|
||||
*) echo "Error: unknown strategy '$strat'" >&2; return 1 ;;
|
||||
*) echo "Error: unknown strategy '$strat'" >&2; return 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
|
||||
@@ -339,12 +339,27 @@ 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 [ ! -f "$SPEC_FILE" ]; then
|
||||
TEMPLATE=$(resolve_template "spec-template" "$REPO_ROOT") || true
|
||||
if [ -n "$TEMPLATE" ] && [ -f "$TEMPLATE" ]; then
|
||||
cp "$TEMPLATE" "$SPEC_FILE"
|
||||
if [ "$NEEDS_SPEC" = true ]; then
|
||||
if [ "$SPEC_TEMPLATE_FOUND" = true ]; then
|
||||
printf '%s' "$SPEC_TEMPLATE_CONTENT" > "$SPEC_FILE"
|
||||
else
|
||||
echo "Warning: Spec template not found; created empty spec file" >&2
|
||||
touch "$SPEC_FILE"
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
#!/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,21 +43,23 @@ if [[ -f "$IMPL_PLAN" ]]; then
|
||||
echo "Plan already exists at $IMPL_PLAN, skipping template copy"
|
||||
fi
|
||||
else
|
||||
TEMPLATE=$(resolve_template "plan-template" "$REPO_ROOT") || true
|
||||
if [[ -n "$TEMPLATE" ]] && [[ -f "$TEMPLATE" ]]; then
|
||||
cp "$TEMPLATE" "$IMPL_PLAN"
|
||||
if resolve_template_content "plan-template" "$REPO_ROOT" > "$IMPL_PLAN"; then
|
||||
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,7 +51,9 @@ fi
|
||||
|
||||
# Resolve tasks template through override stack
|
||||
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
|
||||
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
|
||||
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
|
||||
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
|
||||
@@ -69,7 +71,8 @@ if $JSON_MODE; then
|
||||
--arg feature_dir "$FEATURE_DIR" \
|
||||
--argjson docs "$json_docs" \
|
||||
--arg tasks_template "${TASKS_TEMPLATE:-}" \
|
||||
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,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}'
|
||||
else
|
||||
if [[ ${#docs[@]} -eq 0 ]]; then
|
||||
json_docs="[]"
|
||||
@@ -77,8 +80,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"}\n' \
|
||||
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
|
||||
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")"
|
||||
fi
|
||||
else
|
||||
echo "FEATURE_DIR: $FEATURE_DIR"
|
||||
|
||||
@@ -12,6 +12,7 @@
|
||||
# -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()]
|
||||
@@ -20,6 +21,7 @@ param(
|
||||
[switch]$RequireTasks,
|
||||
[switch]$IncludeTasks,
|
||||
[switch]$PathsOnly,
|
||||
[string]$Template,
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
@@ -37,6 +39,7 @@ 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:
|
||||
@@ -129,13 +132,26 @@ 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
|
||||
[PSCustomObject]@{
|
||||
$result = [ordered]@{
|
||||
FEATURE_DIR = $paths.FEATURE_DIR
|
||||
AVAILABLE_DOCS = $docs
|
||||
} | ConvertTo-Json -Compress
|
||||
}
|
||||
if ($Template) {
|
||||
$result.TEMPLATE_CONTENT = $templateContent
|
||||
}
|
||||
[PSCustomObject]$result | ConvertTo-Json -Compress
|
||||
} else {
|
||||
# Text output
|
||||
Write-Output "FEATURE_DIR:$($paths.FEATURE_DIR)"
|
||||
|
||||
+239
-82
@@ -332,6 +332,82 @@ 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)
|
||||
@@ -343,6 +419,8 @@ 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
|
||||
@@ -357,7 +435,7 @@ function Resolve-Template {
|
||||
$registryParsed = $false
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
|
||||
$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'
|
||||
}
|
||||
@@ -372,30 +450,20 @@ function Resolve-Template {
|
||||
param($Entry)
|
||||
if ($Entry.Value -is [PSCustomObject]) {
|
||||
$priorityProperty = $Entry.Value.PSObject.Properties['priority']
|
||||
if ($priorityProperty) { return $priorityProperty.Value }
|
||||
if ($priorityProperty) {
|
||||
return Get-NormalizedPriority -Value $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 { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
|
||||
Sort-Object { & $priorityFor $_ } |
|
||||
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 } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
$registryParsed = $true
|
||||
@@ -408,12 +476,16 @@ 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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -421,8 +493,11 @@ function Resolve-Template {
|
||||
# Priority 3: Extension-provided templates
|
||||
$extDir = Join-Path $RepoRoot '.specify/extensions'
|
||||
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"
|
||||
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 $candidate) { return $candidate }
|
||||
}
|
||||
}
|
||||
@@ -443,6 +518,10 @@ 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)
|
||||
@@ -452,49 +531,77 @@ function Resolve-TemplateContent {
|
||||
# Priority 1: Project overrides (always "replace")
|
||||
$override = Join-Path $base "overrides/$TemplateName.md"
|
||||
if (Test-Path $override) {
|
||||
$layerPaths += $override
|
||||
$layerStrategies += 'replace'
|
||||
return [System.IO.File]::ReadAllText(
|
||||
$override,
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
}
|
||||
|
||||
$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 = 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 } } |
|
||||
$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 } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
$registryParsed = $true
|
||||
} catch {
|
||||
$sortedPresets = @()
|
||||
$registryParsed = $false
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
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) {
|
||||
# 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 $pyCmd) {
|
||||
if ((Test-Path $manifest) -and -not $pyCmd) {
|
||||
throw "Python 3 and PyYAML are required to resolve preset template composition"
|
||||
}
|
||||
if (Test-Path $manifest) {
|
||||
try {
|
||||
# Use Python to parse YAML manifest for strategy and file path
|
||||
$pyArgs = if ($pyCmd.Count -gt 1) { $pyCmd[1..($pyCmd.Count-1)] } else { @() }
|
||||
@@ -505,32 +612,71 @@ try:
|
||||
import yaml
|
||||
except ImportError:
|
||||
print('yaml_missing', file=sys.stderr)
|
||||
print('replace\t')
|
||||
sys.exit(0)
|
||||
sys.exit(2)
|
||||
try:
|
||||
with open(sys.argv[1]) as f:
|
||||
with open(sys.argv[1], encoding='utf-8') as f:
|
||||
data = yaml.safe_load(f)
|
||||
for t in data.get('provides', {}).get('templates', []):
|
||||
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:
|
||||
if t.get('name') == sys.argv[2] and t.get('type', 'template') == 'template':
|
||||
print(t.get('strategy', 'replace') + '\t' + t.get('file', ''))
|
||||
file_value = t.get('file', '')
|
||||
strategy = t.get('strategy', 'replace')
|
||||
print('found\t' + strategy + '\t' + file_value)
|
||||
sys.exit(0)
|
||||
print('replace\t')
|
||||
except Exception:
|
||||
print('replace\t')
|
||||
print('absent\treplace\t')
|
||||
except Exception as exc:
|
||||
print(f'manifest_invalid: {exc}', file=sys.stderr)
|
||||
sys.exit(3)
|
||||
"@ $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", 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
|
||||
$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] }
|
||||
}
|
||||
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
|
||||
@@ -545,42 +691,45 @@ except Exception:
|
||||
$mf = Join-Path $presetsDir "$presetId/$manifestFilePath"
|
||||
if (Test-Path $mf) { $candidate = $mf }
|
||||
}
|
||||
if (-not $candidate) {
|
||||
if (-not $candidate -and -not $manifestDeclared) {
|
||||
$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 (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 (-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 $candidate) {
|
||||
$layerPaths += $candidate
|
||||
$layerStrategies += 'replace'
|
||||
$effectiveBaseFound = $true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
# Priority 4: Core templates (always "replace")
|
||||
$core = Join-Path $base "$TemplateName.md"
|
||||
if (Test-Path $core) {
|
||||
if (-not $effectiveBaseFound -and (Test-Path $core)) {
|
||||
$layerPaths += $core
|
||||
$layerStrategies += 'replace'
|
||||
}
|
||||
@@ -590,7 +739,7 @@ except Exception:
|
||||
# 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 (Get-Content $layerPaths[0] -Raw)
|
||||
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
|
||||
}
|
||||
|
||||
# Check if any layer uses a non-replace strategy
|
||||
@@ -600,7 +749,7 @@ except Exception:
|
||||
}
|
||||
|
||||
if (-not $hasComposition) {
|
||||
return (Get-Content $layerPaths[0] -Raw)
|
||||
return [System.IO.File]::ReadAllText($layerPaths[0], [System.Text.Encoding]::UTF8)
|
||||
}
|
||||
|
||||
# Find the effective base: scan from highest priority (index 0) downward
|
||||
@@ -612,14 +761,22 @@ except Exception:
|
||||
break
|
||||
}
|
||||
}
|
||||
if ($baseIdx -lt 0) { return $null }
|
||||
if ($baseIdx -lt 0) {
|
||||
throw "Template '$TemplateName' has composing layers but no replace base"
|
||||
}
|
||||
|
||||
$content = Get-Content $layerPaths[$baseIdx] -Raw
|
||||
$content = [System.IO.File]::ReadAllText(
|
||||
$layerPaths[$baseIdx],
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
|
||||
for ($i = $baseIdx - 1; $i -ge 0; $i--) {
|
||||
$path = $layerPaths[$i]
|
||||
$strat = $layerStrategies[$i]
|
||||
$layerContent = Get-Content $path -Raw
|
||||
$layerContent = [System.IO.File]::ReadAllText(
|
||||
$path,
|
||||
[System.Text.Encoding]::UTF8
|
||||
)
|
||||
|
||||
switch ($strat) {
|
||||
'replace' { $content = $layerContent }
|
||||
|
||||
@@ -262,13 +262,16 @@ 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 (-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)
|
||||
if ($needsSpec) {
|
||||
if ($null -ne $content) {
|
||||
$utf8NoBom = New-Object System.Text.UTF8Encoding($false)
|
||||
[System.IO.File]::WriteAllText($specFile, $content, $utf8NoBom)
|
||||
} else {
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
#!/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,10 +41,8 @@ if (Test-Path $paths.IMPL_PLAN -PathType Leaf) {
|
||||
Write-Output "Plan already exists at $($paths.IMPL_PLAN), skipping template copy"
|
||||
}
|
||||
} else {
|
||||
$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)
|
||||
$content = Resolve-TemplateContent -TemplateName 'plan-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($null -ne $content) {
|
||||
$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,12 +57,17 @@ if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
|
||||
|
||||
# Resolve tasks template through override stack
|
||||
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
|
||||
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
|
||||
$tasksTemplateContent = Resolve-TemplateContent -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
|
||||
if ($null -eq $tasksTemplateContent) {
|
||||
[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
|
||||
}
|
||||
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
|
||||
if ($tasksTemplate -and (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
|
||||
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
|
||||
} else {
|
||||
$tasksTemplate = ''
|
||||
}
|
||||
|
||||
# Output results
|
||||
if ($Json) {
|
||||
@@ -70,6 +75,7 @@ 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,10 +9,22 @@ from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import FeaturePaths, format_speckit_command, get_feature_paths
|
||||
from common import (
|
||||
FeaturePaths,
|
||||
TemplateResolutionError,
|
||||
format_speckit_command,
|
||||
get_feature_paths,
|
||||
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, format_speckit_command, get_feature_paths
|
||||
from common import (
|
||||
FeaturePaths,
|
||||
TemplateResolutionError,
|
||||
format_speckit_command,
|
||||
get_feature_paths,
|
||||
resolve_template_content,
|
||||
)
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
@@ -28,6 +40,7 @@ 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:
|
||||
@@ -49,6 +62,7 @@ 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:
|
||||
@@ -56,8 +70,11 @@ def _parse_args(argv: list[str]) -> Args:
|
||||
require_tasks = False
|
||||
include_tasks = False
|
||||
paths_only = False
|
||||
template_name = None
|
||||
|
||||
for arg in argv:
|
||||
index = 0
|
||||
while index < len(argv):
|
||||
arg = argv[index]
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg == "--require-tasks":
|
||||
@@ -66,6 +83,15 @@ 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)
|
||||
@@ -75,12 +101,14 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -130,14 +158,31 @@ 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:
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
print(f" {_status_marker(path.is_file())} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
print(f" {_status_marker(_dir_has_entries(path))} {description}")
|
||||
|
||||
|
||||
def _print_text_results(paths: FeaturePaths, include_tasks: bool) -> None:
|
||||
@@ -194,9 +239,32 @@ 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({"FEATURE_DIR": str(paths.feature_dir), "AVAILABLE_DOCS": docs})
|
||||
_json_line(payload)
|
||||
)
|
||||
else:
|
||||
_print_text_results(paths, args.include_tasks)
|
||||
|
||||
+280
-19
@@ -4,6 +4,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
@@ -182,12 +183,30 @@ 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():
|
||||
# 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.
|
||||
# Invalid JSON or registry shapes fall back to the directory scan below.
|
||||
try:
|
||||
data = json.loads(registry.read_text(encoding="utf-8"))
|
||||
presets = data.get("presets", {})
|
||||
@@ -195,11 +214,18 @@ def _sorted_preset_ids(presets_dir: Path) -> list[str]:
|
||||
pid
|
||||
for pid, meta in sorted(
|
||||
presets.items(),
|
||||
key=lambda kv: kv[1].get("priority", 10)
|
||||
if isinstance(kv[1], dict)
|
||||
else 10,
|
||||
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))
|
||||
)
|
||||
if isinstance(meta, dict) and meta.get("enabled", True) is not False
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
@@ -207,12 +233,78 @@ def _sorted_preset_ids(presets_dir: Path) -> list[str]:
|
||||
return sorted(
|
||||
p.name
|
||||
for p in presets_dir.iterdir()
|
||||
if p.is_dir() and not p.name.startswith(".")
|
||||
if p.is_dir() and _is_safe_component(p.name)
|
||||
)
|
||||
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.
|
||||
|
||||
@@ -222,6 +314,9 @@ 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"
|
||||
@@ -231,21 +326,18 @@ 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 = presets_dir / preset_id / "templates" / f"{template_name}.md"
|
||||
if candidate.is_file():
|
||||
candidate = _conventional_template(
|
||||
presets_dir / preset_id, template_name
|
||||
)
|
||||
if candidate is not None:
|
||||
return candidate
|
||||
|
||||
ext_dir = repo_root / ".specify" / "extensions"
|
||||
if ext_dir.is_dir():
|
||||
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():
|
||||
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:
|
||||
return candidate
|
||||
|
||||
core = base / f"{template_name}.md"
|
||||
@@ -254,6 +346,175 @@ 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,16 +7,25 @@ import datetime
|
||||
import json
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
import sys
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import get_repo_root, persist_feature_json, resolve_template
|
||||
from common import (
|
||||
TemplateResolutionError,
|
||||
get_repo_root,
|
||||
persist_feature_json,
|
||||
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 get_repo_root, persist_feature_json, resolve_template
|
||||
from common import (
|
||||
TemplateResolutionError,
|
||||
get_repo_root,
|
||||
persist_feature_json,
|
||||
resolve_template_content,
|
||||
)
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
@@ -374,12 +383,22 @@ 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 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)
|
||||
if needs_spec:
|
||||
if template_content is not None:
|
||||
spec_file.write_bytes(template_content.encode("utf-8"))
|
||||
else:
|
||||
print(
|
||||
"Warning: Spec template not found; created empty spec file",
|
||||
|
||||
@@ -0,0 +1,63 @@
|
||||
#!/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,15 +4,22 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import get_feature_paths, resolve_template
|
||||
from common import (
|
||||
TemplateResolutionError,
|
||||
get_feature_paths,
|
||||
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 get_feature_paths, resolve_template
|
||||
from common import (
|
||||
TemplateResolutionError,
|
||||
get_feature_paths,
|
||||
resolve_template_content,
|
||||
)
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
@@ -55,9 +62,13 @@ def main(argv: list[str] | None = None) -> int:
|
||||
file=status_stream,
|
||||
)
|
||||
else:
|
||||
template = resolve_template("plan-template", paths.repo_root)
|
||||
if template is not None and template.is_file():
|
||||
shutil.copy(template, paths.impl_plan)
|
||||
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"))
|
||||
print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
|
||||
else:
|
||||
print("Warning: Plan template not found", file=status_stream)
|
||||
|
||||
@@ -10,17 +10,21 @@ 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,
|
||||
)
|
||||
|
||||
|
||||
@@ -55,14 +59,31 @@ 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:
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
print(f" {_status_marker(path.is_file())} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
print(f" {_status_marker(_dir_has_entries(path))} {description}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
@@ -103,8 +124,14 @@ def main(argv: list[str] | None = None) -> int:
|
||||
|
||||
docs = _available_docs(paths)
|
||||
|
||||
tasks_template = resolve_template("tasks-template", paths.repo_root)
|
||||
if tasks_template is None or not tasks_template.is_file():
|
||||
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:
|
||||
print(
|
||||
"ERROR: Could not resolve required tasks-template from the template "
|
||||
f"override stack for {paths.repo_root}",
|
||||
@@ -121,18 +148,21 @@ 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),
|
||||
"TASKS_TEMPLATE": str(tasks_template) if tasks_template else "",
|
||||
"TASKS_TEMPLATE_CONTENT": tasks_template_content,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
tasks_template = resolve_template("tasks-template", paths.repo_root)
|
||||
print(f"FEATURE_DIR: {paths.feature_dir}")
|
||||
print(f"TASKS_TEMPLATE: {tasks_template}")
|
||||
print(f"TASKS_TEMPLATE: {tasks_template or 'not found'}")
|
||||
print("AVAILABLE_DOCS:")
|
||||
_check_file(paths.research, "research.md")
|
||||
_check_file(paths.data_model, "data-model.md")
|
||||
|
||||
@@ -10,6 +10,7 @@ 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
|
||||
@@ -69,6 +70,19 @@ _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",
|
||||
@@ -166,7 +180,11 @@ def detect_archive_format(
|
||||
try:
|
||||
with tarfile.open(fileobj=archive_file, mode="r:gz"):
|
||||
is_tar_gz = True
|
||||
except tarfile.TarError:
|
||||
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.
|
||||
pass
|
||||
archive_file.seek(0)
|
||||
except OSError as exc:
|
||||
@@ -1077,7 +1095,7 @@ def safe_extract_tar(
|
||||
mode="r:gz",
|
||||
fileobj=archive_file,
|
||||
)
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
except (*_TAR_DECOMPRESSION_ERRORS, OSError) as exc:
|
||||
_raise_from(error_type, f"Invalid tar.gz archive: {archive_path}", exc)
|
||||
|
||||
with archive:
|
||||
@@ -1149,7 +1167,7 @@ def safe_extract_tar(
|
||||
f"of {max_total_bytes} bytes",
|
||||
)
|
||||
validated.append((member, normalized_name, is_dir))
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
except (*_TAR_DECOMPRESSION_ERRORS, 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"})
|
||||
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode", "command-code"})
|
||||
|
||||
# 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 and temp_path.exists():
|
||||
temp_path.unlink()
|
||||
if temp_path:
|
||||
temp_path.unlink(missing_ok=True)
|
||||
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 Exception as e:
|
||||
except (OSError, ValueError, KeyError) as e:
|
||||
log(f"Warning: Could not merge settings: {e}", "yellow")
|
||||
if not dest_file.exists():
|
||||
shutil.copy2(sub_item, dest_file)
|
||||
|
||||
@@ -77,6 +77,16 @@ 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 ..._console import console, err_console
|
||||
from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited
|
||||
from ..._console import console, err_console
|
||||
from ...bundler import BundlerError
|
||||
from ...bundler.lib.project import (
|
||||
active_integration,
|
||||
@@ -45,7 +45,12 @@ 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.
|
||||
err_console.print(f"[red]Error:[/red] {message}", style=None)
|
||||
# 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)
|
||||
raise typer.Exit(code=1)
|
||||
|
||||
|
||||
@@ -332,9 +337,10 @@ def bundle_list(
|
||||
console.print("\n[bold cyan]Installed bundles:[/bold cyan]\n")
|
||||
for record in records:
|
||||
console.print(
|
||||
f" [bold]{record.bundle_id}[/bold] v{record.version} "
|
||||
f" [bold]{_escape_markup(str(record.bundle_id))}[/bold] "
|
||||
f"v{_escape_markup(str(record.version))} "
|
||||
f"[dim]({len(record.contributed_components)} components, "
|
||||
f"installed {record.installed_at})[/dim]"
|
||||
f"installed {_escape_markup(str(record.installed_at))})[/dim]"
|
||||
)
|
||||
|
||||
|
||||
@@ -394,13 +400,13 @@ def bundle_install(
|
||||
)
|
||||
console.print(
|
||||
f"[cyan]No Spec Kit project here; initializing with integration "
|
||||
f"'{init_integration}'…[/cyan]"
|
||||
f"'{_escape_markup(str(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] {overlap}")
|
||||
console.print(f"[yellow]![/yellow] {_escape_markup(str(overlap))}")
|
||||
|
||||
# For an already-initialized project, the project's recorded active
|
||||
# integration is authoritative — an explicit --integration must not be
|
||||
@@ -415,7 +421,7 @@ def bundle_install(
|
||||
integration_explicit=bool(integration) and detected is None,
|
||||
)
|
||||
for warning in plan.warnings:
|
||||
console.print(f"[yellow]![/yellow] {warning}")
|
||||
console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}")
|
||||
|
||||
result = install_bundle(
|
||||
project_root,
|
||||
@@ -428,7 +434,7 @@ def bundle_install(
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Installed '{result.bundle_id}' "
|
||||
f"[green]✓[/green] Installed '{_escape_markup(str(result.bundle_id))}' "
|
||||
f"({len(result.installed)} added, {len(result.skipped)} already present)."
|
||||
)
|
||||
|
||||
@@ -480,7 +486,10 @@ 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 '{target}' to v{plan.version}.")
|
||||
console.print(
|
||||
f"[green]✓[/green] Updated '{_escape_markup(str(target))}' "
|
||||
f"to v{_escape_markup(str(plan.version))}."
|
||||
)
|
||||
except BundlerError as exc:
|
||||
_fail(str(exc))
|
||||
return
|
||||
@@ -502,7 +511,7 @@ def bundle_remove(
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Removed '{result.bundle_id}' "
|
||||
f"[green]✓[/green] Removed '{_escape_markup(str(result.bundle_id))}' "
|
||||
f"({len(result.uninstalled)} uninstalled, {len(result.skipped)} kept for other bundles)."
|
||||
)
|
||||
|
||||
@@ -542,13 +551,16 @@ def bundle_validate(
|
||||
return
|
||||
|
||||
for warning in report.warnings:
|
||||
console.print(f"[yellow]![/yellow] {warning}")
|
||||
console.print(f"[yellow]![/yellow] {_escape_markup(str(warning))}")
|
||||
if not report.ok:
|
||||
console.print("[red]Manifest is invalid:[/red]")
|
||||
for error in report.errors:
|
||||
console.print(f" [red]-[/red] {error}")
|
||||
console.print(f" [red]-[/red] {_escape_markup(str(error))}")
|
||||
raise typer.Exit(code=1)
|
||||
console.print(f"[green]✓[/green] {manifest.bundle.id} is well-formed and valid.")
|
||||
console.print(
|
||||
f"[green]✓[/green] {_escape_markup(str(manifest.bundle.id))} "
|
||||
"is well-formed and valid."
|
||||
)
|
||||
|
||||
|
||||
@bundle_app.command("build")
|
||||
@@ -571,8 +583,9 @@ def bundle_build(
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Built {result.artifact_path.name} "
|
||||
f"({result.file_count} files) → {result.artifact_path}"
|
||||
f"[green]✓[/green] Built {_escape_markup(result.artifact_path.name)} "
|
||||
f"({result.file_count} files) → "
|
||||
f"{_escape_markup(str(result.artifact_path))}"
|
||||
)
|
||||
|
||||
|
||||
@@ -591,7 +604,7 @@ def bundle_init(
|
||||
init_integration = _resolve_init_integration(integration, None)
|
||||
console.print(
|
||||
f"[cyan]Initializing a Spec Kit project with integration "
|
||||
f"'{init_integration}'…[/cyan]"
|
||||
f"'{_escape_markup(str(init_integration))}'…[/cyan]"
|
||||
)
|
||||
_run_init(init_integration, script_type=_default_script_type(), offline=offline)
|
||||
project_root = require_project_root()
|
||||
@@ -599,7 +612,10 @@ def bundle_init(
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
console.print(f"[green]✓[/green] Spec Kit project ready at {project_root}.")
|
||||
console.print(
|
||||
f"[green]✓[/green] Spec Kit project ready at "
|
||||
f"{_escape_markup(str(project_root))}."
|
||||
)
|
||||
if bundle:
|
||||
bundle_install(bundle, integration=integration, offline=offline)
|
||||
|
||||
@@ -623,10 +639,11 @@ def catalog_list() -> None:
|
||||
only_builtin = all(s.scope == Scope.BUILTIN for s in sources)
|
||||
for source in sources:
|
||||
console.print(
|
||||
f" [bold]{source.id}[/bold] priority={source.priority} "
|
||||
f" [bold]{_escape_markup(str(source.id))}[/bold] "
|
||||
f"priority={source.priority} "
|
||||
f"policy={source.install_policy.value} scope={source.scope.value}"
|
||||
)
|
||||
console.print(f" [dim]{source.url}[/dim]")
|
||||
console.print(f" [dim]{_escape_markup(str(source.url))}[/dim]")
|
||||
if only_builtin:
|
||||
console.print("\n[dim]Using the built-in default stack.[/dim]")
|
||||
|
||||
@@ -651,7 +668,7 @@ def catalog_add(
|
||||
return
|
||||
|
||||
console.print(
|
||||
f"[green]✓[/green] Added catalog '{source.id}' "
|
||||
f"[green]✓[/green] Added catalog '{_escape_markup(str(source.id))}' "
|
||||
f"(priority {source.priority}, {source.install_policy.value})."
|
||||
)
|
||||
|
||||
@@ -670,7 +687,10 @@ def catalog_remove(
|
||||
_fail(str(exc))
|
||||
return
|
||||
|
||||
console.print(f"[green]✓[/green] Removed catalog source '{removed}'.")
|
||||
console.print(
|
||||
f"[green]✓[/green] Removed catalog source "
|
||||
f"'{_escape_markup(str(removed))}'."
|
||||
)
|
||||
|
||||
|
||||
# ZIP magic-byte signatures used to detect .zip payloads from REST API asset
|
||||
@@ -771,7 +791,17 @@ def _local_manifest_source(arg: str):
|
||||
error_type=BundlerError,
|
||||
label="bundle manifest",
|
||||
)
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
try:
|
||||
data = _yaml.safe_load(io.BytesIO(raw))
|
||||
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
|
||||
return BundleManifest.from_dict(data)
|
||||
|
||||
if candidate.name == "bundle.yml" or candidate.suffix in (".yml", ".yaml"):
|
||||
|
||||
@@ -3,13 +3,16 @@
|
||||
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 (
|
||||
@@ -169,6 +172,25 @@ def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_ve
|
||||
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:
|
||||
@@ -351,7 +373,10 @@ 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: '{integration}'")
|
||||
console.print(
|
||||
f"[red]Error:[/red] Unknown integration: "
|
||||
f"'{_escape_markup(str(integration))}'"
|
||||
)
|
||||
available = ", ".join(sorted(INTEGRATION_REGISTRY))
|
||||
console.print(f"[yellow]Available integrations:[/yellow] {available}")
|
||||
raise typer.Exit(1)
|
||||
@@ -428,26 +453,27 @@ 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] '{project_name}' exists but is not a directory."
|
||||
f"[red]Error:[/red] '{safe_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 '{project_name}' is not empty ({len(existing_items)} items)"
|
||||
f"[yellow]Warning:[/yellow] Directory '{safe_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]{project_name}[/cyan]'[/cyan]"
|
||||
f"[cyan]--force supplied: merging into existing directory '[cyan]{safe_name}[/cyan]'[/cyan]"
|
||||
)
|
||||
else:
|
||||
error_panel = Panel(
|
||||
f"Directory already exists: '[cyan]{project_name}[/cyan]'\n"
|
||||
f"Directory already exists: '[cyan]{safe_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]",
|
||||
@@ -461,7 +487,7 @@ def register(app: typer.Typer) -> None:
|
||||
if integration:
|
||||
if integration not in AGENT_CONFIG:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid integration '{integration}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
|
||||
f"[red]Error:[/red] Invalid integration '{_escape_markup(str(integration))}'. Choose from: {', '.join(AGENT_CONFIG.keys())}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
selected_ai = integration
|
||||
@@ -500,12 +526,14 @@ def register(app: typer.Typer) -> None:
|
||||
setup_lines = [
|
||||
"[cyan]Specify Project Setup[/cyan]",
|
||||
"",
|
||||
f"{'Project':<15} [green]{project_path.name}[/green]",
|
||||
f"{'Working Path':<15} [dim]{current_dir}[/dim]",
|
||||
f"{'Project':<15} [green]{_escape_markup(project_path.name)}[/green]",
|
||||
f"{'Working Path':<15} [dim]{_escape_markup(str(current_dir))}[/dim]",
|
||||
]
|
||||
|
||||
if not here:
|
||||
setup_lines.append(f"{'Target Path':<15} [dim]{project_path}[/dim]")
|
||||
setup_lines.append(
|
||||
f"{'Target Path':<15} [dim]{_escape_markup(str(project_path))}[/dim]"
|
||||
)
|
||||
|
||||
console.print(
|
||||
Panel("\n".join(setup_lines), border_style="cyan", padding=(1, 2))
|
||||
@@ -532,7 +560,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 '{script_type}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
|
||||
f"[red]Error:[/red] Invalid script type '{_escape_markup(str(script_type))}'. Choose from: {', '.join(SCRIPT_TYPE_CHOICES.keys())}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
selected_script = script_type
|
||||
@@ -571,8 +599,6 @@ def register(app: typer.Typer) -> None:
|
||||
tracker.add(key, label)
|
||||
|
||||
if extensions:
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
for i, ext_spec in enumerate(extensions):
|
||||
tracker.add(
|
||||
f"extension-{i}", f"Install extension: {_escape_markup(ext_spec)}"
|
||||
@@ -635,6 +661,30 @@ 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,
|
||||
@@ -803,8 +853,6 @@ def register(app: typer.Typer) -> None:
|
||||
|
||||
# Install extensions specified via --extension
|
||||
if extensions:
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from ..extensions._commands import _refresh_events_and_warn
|
||||
|
||||
speckit_ver = get_speckit_version()
|
||||
@@ -894,7 +942,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]{agent_folder}[/cyan] (or parts of it) to [cyan].gitignore[/cyan] to prevent accidental credential leakage.",
|
||||
f"Consider adding [cyan]{_escape_markup(str(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),
|
||||
@@ -905,7 +953,7 @@ def register(app: typer.Typer) -> None:
|
||||
steps_lines = []
|
||||
if not here:
|
||||
steps_lines.append(
|
||||
f"1. Go to the project folder: [cyan]cd {project_name}[/cyan]"
|
||||
f"1. Go to the project folder: [cyan]cd {_escape_markup(_shell_quote_arg(str(project_name)))}[/cyan]"
|
||||
)
|
||||
step_num = 2
|
||||
else:
|
||||
|
||||
@@ -548,7 +548,15 @@ def _resolve_event_command_argv(
|
||||
"""
|
||||
from .integrations.base import IntegrationBase
|
||||
|
||||
content = template_path.read_text(encoding="utf-8")
|
||||
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
|
||||
m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
|
||||
if not m:
|
||||
return None
|
||||
|
||||
@@ -61,6 +61,13 @@ _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
|
||||
@@ -368,11 +375,17 @@ 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:
|
||||
@@ -382,9 +395,17 @@ 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:
|
||||
raise ValidationError("Extension must provide at least one command, hook, or event")
|
||||
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")
|
||||
|
||||
# Validate hook values (if present).
|
||||
# Each event is a single mapping or a list of mappings.
|
||||
@@ -545,6 +566,80 @@ 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.
|
||||
@@ -615,6 +710,16 @@ class ExtensionManifest:
|
||||
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."""
|
||||
@@ -650,6 +755,13 @@ 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)
|
||||
@@ -660,10 +772,47 @@ class ExtensionRegistry:
|
||||
if not isinstance(data.get("extensions"), dict):
|
||||
data["extensions"] = {}
|
||||
return data
|
||||
except (json.JSONDecodeError, FileNotFoundError):
|
||||
# Corrupted or missing registry, start fresh
|
||||
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.
|
||||
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)
|
||||
@@ -4310,11 +4459,10 @@ 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
|
||||
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.
|
||||
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.
|
||||
|
||||
Used by ``_get_env_config`` to detect env vars whose remainder claims
|
||||
a longer, sibling-owned prefix (e.g. ``SPECKIT_GIT_HOOKS_URL`` is
|
||||
@@ -4564,6 +4712,7 @@ 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:
|
||||
@@ -4578,6 +4727,10 @@ 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)
|
||||
|
||||
|
||||
@@ -56,6 +56,7 @@ 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
|
||||
@@ -95,6 +96,7 @@ def _register_builtins() -> None:
|
||||
_register(ClineIntegration())
|
||||
_register(CodebuddyIntegration())
|
||||
_register(CodexIntegration())
|
||||
_register(CommandCodeIntegration())
|
||||
_register(CopilotIntegration())
|
||||
_register(CursorAgentIntegration())
|
||||
_register(DevinIntegration())
|
||||
|
||||
@@ -65,7 +65,16 @@ class AlquimiaAIIntegration(SkillsIntegration):
|
||||
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
|
||||
"""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.
|
||||
|
||||
Skips injection if ``argument-hint:`` already exists in the
|
||||
frontmatter to avoid duplicate keys.
|
||||
@@ -88,15 +97,29 @@ class AlquimiaAIIntegration(SkillsIntegration):
|
||||
in_fm = False
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
i = 0
|
||||
n = len(lines)
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
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"
|
||||
@@ -109,6 +132,7 @@ class AlquimiaAIIntegration(SkillsIntegration):
|
||||
injected = True
|
||||
continue
|
||||
out.append(line)
|
||||
i += 1
|
||||
return "".join(out)
|
||||
|
||||
@staticmethod
|
||||
|
||||
@@ -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,6 +237,15 @@ 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,7 +67,16 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
|
||||
@staticmethod
|
||||
def inject_argument_hint(content: str, hint: str) -> str:
|
||||
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
|
||||
"""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.
|
||||
|
||||
Skips injection if ``argument-hint:`` already exists in the
|
||||
frontmatter to avoid duplicate keys.
|
||||
@@ -90,15 +99,29 @@ class ClaudeIntegration(SkillsIntegration):
|
||||
in_fm = False
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
i = 0
|
||||
n = len(lines)
|
||||
while i < n:
|
||||
line = lines[i]
|
||||
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"
|
||||
@@ -111,6 +134,7 @@ 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:
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
"""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)",
|
||||
),
|
||||
]
|
||||
@@ -121,13 +121,27 @@ class HermesIntegration(SkillsIntegration):
|
||||
command_name = src_file.stem # e.g. "plan"
|
||||
skill_name = f"speckit-{command_name.replace('.', '-')}"
|
||||
|
||||
# Parse frontmatter for description
|
||||
# Parse frontmatter for description. Locate the closing ``---`` on
|
||||
# its own line rather than with ``raw.split("---", 2)`` — a bare
|
||||
# substring split stops at the first ``---`` *anywhere*, including
|
||||
# one inside a value such as ``description: Separate sections
|
||||
# with ---``, which truncates the frontmatter and drops later keys.
|
||||
# The block between the delimiters is parsed unstripped so trailing
|
||||
# newlines in literal (``|``) block scalars survive.
|
||||
frontmatter: dict[str, Any] = {}
|
||||
if raw.startswith("---"):
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm_lines = raw.splitlines(keepends=True)
|
||||
fm_close = next(
|
||||
(
|
||||
i
|
||||
for i in range(1, len(fm_lines))
|
||||
if fm_lines[i].rstrip() == "---"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if fm_close is not None:
|
||||
try:
|
||||
fm = yaml.safe_load(parts[1])
|
||||
fm = yaml.safe_load("".join(fm_lines[1:fm_close]))
|
||||
if isinstance(fm, dict):
|
||||
frontmatter = fm
|
||||
except yaml.YAMLError:
|
||||
@@ -143,10 +157,26 @@ class HermesIntegration(SkillsIntegration):
|
||||
project_root=project_root,
|
||||
)
|
||||
# Strip the processed frontmatter — we rebuild it for skills.
|
||||
# Scan for the closing ``---`` on its own line rather than
|
||||
# ``split("---", 2)`` so a ``---`` embedded in a value does not
|
||||
# truncate the frontmatter and spill it into the body.
|
||||
if processed_body.startswith("---"):
|
||||
parts = processed_body.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
processed_body = parts[2]
|
||||
body_lines = processed_body.splitlines(keepends=True)
|
||||
close_idx = next(
|
||||
(
|
||||
i
|
||||
for i in range(1, len(body_lines))
|
||||
if body_lines[i].rstrip() == "---"
|
||||
),
|
||||
None,
|
||||
)
|
||||
if close_idx is not None:
|
||||
# Keep whatever trails the ``---`` marker on the closing
|
||||
# line so the body stays byte-for-byte identical to
|
||||
# ``split("---", 2)[2]`` for well-formed templates.
|
||||
processed_body = body_lines[close_idx][3:] + "".join(
|
||||
body_lines[close_idx + 1 :]
|
||||
)
|
||||
|
||||
# Select description
|
||||
description = frontmatter.get("description", "")
|
||||
|
||||
@@ -1,6 +1,51 @@
|
||||
"""Junie integration (JetBrains)."""
|
||||
|
||||
from ..base import MarkdownIntegration
|
||||
from ..manifest import IntegrationManifest
|
||||
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
# Note injected into hook sections so Junie maps dot-notation command
|
||||
# names (from extensions.yml) to the hyphenated slash commands it uses.
|
||||
_HOOK_COMMAND_NOTE = (
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
|
||||
|
||||
def format_junie_command_name(cmd_name: str) -> str:
|
||||
"""Convert command name to Junie-compatible hyphenated format.
|
||||
|
||||
Junie does not allow dots inside of slash-commands.
|
||||
This function converts dot-notation command names to hyphenated format.
|
||||
|
||||
The function is idempotent: already-formatted names are returned unchanged.
|
||||
|
||||
Examples:
|
||||
>>> format_junie_command_name("plan")
|
||||
'speckit-plan'
|
||||
>>> format_junie_command_name("speckit.plan")
|
||||
'speckit-plan'
|
||||
>>> format_junie_command_name("speckit.git.commit")
|
||||
'speckit-git-commit'
|
||||
|
||||
Args:
|
||||
cmd_name: Command name in dot notation (speckit.foo.bar),
|
||||
hyphenated format (speckit-foo-bar), or plain name (foo)
|
||||
|
||||
Returns:
|
||||
Hyphenated command name with 'speckit-' prefix
|
||||
"""
|
||||
cmd_name = cmd_name.replace(".", "-")
|
||||
|
||||
if not cmd_name.startswith("speckit-"):
|
||||
cmd_name = f"speckit-{cmd_name}"
|
||||
|
||||
return cmd_name
|
||||
|
||||
|
||||
class JunieIntegration(MarkdownIntegration):
|
||||
@@ -17,5 +62,117 @@ class JunieIntegration(MarkdownIntegration):
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
"inject_name": True,
|
||||
"format_name": format_junie_command_name,
|
||||
"invoke_separator": "-",
|
||||
}
|
||||
multi_install_safe = True
|
||||
invoke_separator = "-"
|
||||
|
||||
def command_filename(self, template_name: str) -> str:
|
||||
return format_junie_command_name(template_name) + ".md"
|
||||
|
||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||
"""Junie installs hyphenated slash-commands (``/speckit-<name>``), so the
|
||||
dispatch invocation must match. The inherited MarkdownIntegration default
|
||||
builds the dotted ``/speckit.<name>``, which references a command Junie
|
||||
never registered. Reuse the same hyphenation as command_filename /
|
||||
the injected frontmatter name (see ``format_junie_command_name``),
|
||||
mirroring the forge integration.
|
||||
"""
|
||||
invocation = "/" + format_junie_command_name(command_name)
|
||||
if args:
|
||||
invocation = f"{invocation} {args}"
|
||||
return invocation
|
||||
|
||||
def process_template(self, *args, **kwargs):
|
||||
"""Ensure shared templates render Junie command references with hyphens."""
|
||||
kwargs.setdefault("invoke_separator", self.invoke_separator)
|
||||
return super().process_template(*args, **kwargs)
|
||||
|
||||
@staticmethod
|
||||
def _inject_hook_command_note(content: str) -> str:
|
||||
"""Insert a dot-to-hyphen note before each hook output instruction.
|
||||
|
||||
Targets the line ``- For each executable hook, output the following``
|
||||
and inserts the note on the line before it, matching its indentation.
|
||||
Skips if the note is already present.
|
||||
"""
|
||||
if "replace dots" in content:
|
||||
return content
|
||||
|
||||
def repl(m: re.Match[str]) -> str:
|
||||
indent = m.group(1)
|
||||
instruction = m.group(2)
|
||||
# ``eol`` is empty when the regex matched via ``$`` because the
|
||||
# instruction was the final line of a file with no trailing
|
||||
# newline. Default to ``\n`` so the note never collapses onto
|
||||
# the same line as the instruction.
|
||||
eol = m.group(3) or "\n"
|
||||
return (
|
||||
indent
|
||||
+ _HOOK_COMMAND_NOTE.rstrip("\n")
|
||||
+ eol
|
||||
+ indent
|
||||
+ instruction
|
||||
+ eol
|
||||
)
|
||||
|
||||
return re.sub(
|
||||
r"(?m)^(\s*)(- For each executable hook, output the following[^\r\n]*)(\r\n|\n|$)",
|
||||
repl,
|
||||
content,
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _rewrite_handoff_references(content: str) -> str:
|
||||
"""Replace dot-notation agent references in handoffs with hyphens."""
|
||||
return re.sub(
|
||||
r"(?m)^(\s*agent:\s*)(speckit\.[A-Za-z0-9-_]+(?:\.[A-Za-z0-9-_]+)*)",
|
||||
lambda m: f"{m.group(1)}{format_junie_command_name(m.group(2))}",
|
||||
content,
|
||||
)
|
||||
def post_process_command_content(self, content: str) -> str:
|
||||
"""Apply Junie-specific transformations to command content.
|
||||
|
||||
Overrides the ``IntegrationBase`` hook of the same name so that
|
||||
``CommandRegistrar.register_commands()`` (which dispatches to
|
||||
``post_process_command_content``) applies these transforms to
|
||||
extension/preset command files too, not just core commands.
|
||||
"""
|
||||
updated = self._inject_hook_command_note(content)
|
||||
updated = self._rewrite_handoff_references(updated)
|
||||
return updated
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
"""Install Junie commands and apply post-processing transformations."""
|
||||
created = super().setup(project_root, manifest, parsed_options, **opts)
|
||||
|
||||
# Post-process generated command files
|
||||
dest_dir = self.commands_dest(project_root).resolve()
|
||||
|
||||
for path in created:
|
||||
# Only touch .md files under the commands directory
|
||||
try:
|
||||
path.resolve().relative_to(dest_dir)
|
||||
except ValueError:
|
||||
continue
|
||||
if path.suffix != ".md":
|
||||
continue
|
||||
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_bytes.decode("utf-8")
|
||||
|
||||
updated = self.post_process_command_content(content)
|
||||
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
self.record_file_in_manifest(path, project_root, manifest)
|
||||
|
||||
return created
|
||||
|
||||
@@ -323,14 +323,24 @@ def _is_speckit_generated_skill(skill_dir: Path) -> bool:
|
||||
if not content.startswith("---"):
|
||||
return False
|
||||
|
||||
parts = content.split("---", 2)
|
||||
if len(parts) < 3:
|
||||
# Locate the closing ``---`` on its own line rather than with
|
||||
# ``content.split("---", 2)`` — a bare substring split stops at the first
|
||||
# ``---`` *anywhere*, including one inside a value such as
|
||||
# ``description: Separate sections with ---``, which truncates the parsed
|
||||
# frontmatter and can drop the metadata block this check relies on (so a
|
||||
# Speckit-generated skill would not be recognized on teardown).
|
||||
lines = content.splitlines(keepends=True)
|
||||
close_idx = next(
|
||||
(i for i in range(1, len(lines)) if lines[i].rstrip() == "---"),
|
||||
None,
|
||||
)
|
||||
if close_idx is None:
|
||||
return False
|
||||
|
||||
try:
|
||||
import yaml
|
||||
|
||||
frontmatter = yaml.safe_load(parts[1])
|
||||
frontmatter = yaml.safe_load("".join(lines[1:close_idx]))
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
@@ -451,8 +451,7 @@ class IntegrationManifest:
|
||||
_ensure_safe_manifest_destination(self.project_root, path)
|
||||
os.replace(temp_path, path)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
temp_path.unlink(missing_ok=True)
|
||||
return path
|
||||
|
||||
@classmethod
|
||||
|
||||
+279
-126
@@ -56,6 +56,7 @@ from ..shared_infra import (
|
||||
|
||||
|
||||
_CONSTITUTION_PROVENANCE_FILE = ".constitution-template.json"
|
||||
_CONSTITUTION_SYNC_PRESET_ID = "constitution-sync"
|
||||
|
||||
|
||||
def _content_sha256(content: bytes) -> str:
|
||||
@@ -178,7 +179,7 @@ def _substitute_core_template(
|
||||
by the core template body and core_frontmatter holds the core template's parsed
|
||||
frontmatter (so callers can inherit scripts/agent_scripts from it). Both are
|
||||
unchanged / empty when the placeholder is absent or the core template file does
|
||||
not exist.
|
||||
not exist or cannot be read.
|
||||
"""
|
||||
if "{CORE_TEMPLATE}" not in body:
|
||||
return body, {}
|
||||
@@ -208,7 +209,23 @@ def _substitute_core_template(
|
||||
if core_file is None:
|
||||
return body, {}
|
||||
|
||||
core_frontmatter, core_body = registrar.parse_frontmatter(core_file.read_text(encoding="utf-8"))
|
||||
# Treat an unreadable/undecodable core template like a missing one so a
|
||||
# single corrupted project override cannot crash command registration —
|
||||
# the wrap-strategy callers already skip an unreadable preset source with
|
||||
# a warning (CommandRegistrar.register_pack).
|
||||
try:
|
||||
core_content = core_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"Ignoring core template for command '{cmd_name}': could not read "
|
||||
f"'{core_file.name}' ({exc.__class__.__name__}: {exc}).",
|
||||
stacklevel=2,
|
||||
)
|
||||
return body, {}
|
||||
|
||||
core_frontmatter, core_body = registrar.parse_frontmatter(core_content)
|
||||
return body.replace("{CORE_TEMPLATE}", core_body), core_frontmatter
|
||||
|
||||
|
||||
@@ -806,25 +823,6 @@ class PresetManager:
|
||||
|
||||
return True
|
||||
|
||||
def _extension_installed_for_command(self, command_name: str) -> bool:
|
||||
"""Whether *command_name* may be materialized in this project.
|
||||
|
||||
Extension command overrides follow ``speckit.<ext-id>.<cmd-name>``;
|
||||
they must be skipped everywhere preset artifacts are written —
|
||||
registration *and* reconciliation — when the extension isn't
|
||||
installed, or reconciliation would materialize files that
|
||||
registration refused to track. Core commands (single-dot names,
|
||||
e.g. ``speckit.specify``) always pass.
|
||||
"""
|
||||
parts = command_name.split(".")
|
||||
if len(parts) >= 3 and parts[0] == "speckit":
|
||||
ext_id = parts[1]
|
||||
if not (
|
||||
self.project_root / ".specify" / "extensions" / ext_id
|
||||
).is_dir():
|
||||
return False
|
||||
return True
|
||||
|
||||
def _register_commands(
|
||||
self,
|
||||
manifest: PresetManifest,
|
||||
@@ -853,21 +851,20 @@ class PresetManager:
|
||||
if not command_templates:
|
||||
return {}
|
||||
|
||||
# Filter out extension command overrides if the extension isn't installed.
|
||||
filtered = [
|
||||
cmd
|
||||
for cmd in command_templates
|
||||
if self._extension_installed_for_command(cmd["name"])
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
return {}
|
||||
|
||||
# A preset command template always ships its own body, so it is
|
||||
# self-contained and scaffolds regardless of whether any similarly
|
||||
# named extension is installed. Namespaced names (speckit.<ns>.<cmd>)
|
||||
# are treated exactly like short names (speckit.<cmd>) — they are NOT
|
||||
# filtered out just because ``.specify/extensions/<ns>/`` is absent.
|
||||
# The only command that cannot be materialized is a composition
|
||||
# (prepend/append/wrap) with no base layer to compose onto; that case
|
||||
# is handled per-command below (warn + skip), not by dropping names up
|
||||
# front.
|
||||
# Handle composition strategies: resolve composed content for non-replace commands
|
||||
resolver = PresetResolver(self.project_root)
|
||||
composed_dir = None
|
||||
commands_to_register = []
|
||||
for cmd in filtered:
|
||||
for cmd in command_templates:
|
||||
strategy = cmd.get("strategy", "replace")
|
||||
if strategy != "replace":
|
||||
# Only pre-compose if this preset is the top composing layer.
|
||||
@@ -890,13 +887,23 @@ class PresetManager:
|
||||
"file": f".composed/{cmd['name']}.md",
|
||||
})
|
||||
else:
|
||||
raise PresetValidationError(
|
||||
f"Command '{cmd['name']}' uses '{strategy}' strategy "
|
||||
f"but no base command layer exists to compose onto. "
|
||||
f"Ensure a lower-priority preset, extension, or core "
|
||||
f"command provides this command before using "
|
||||
f"composition strategies."
|
||||
# No base layer to compose onto (e.g. the command it
|
||||
# would wrap comes from an extension that isn't
|
||||
# installed). Warn and skip this single command rather
|
||||
# than aborting the whole install — mirrors the
|
||||
# "composed is None" branch in
|
||||
# _reconcile_composed_commands so command-mode and
|
||||
# reconciliation behave identically.
|
||||
import warnings
|
||||
warnings.warn(
|
||||
f"Command '{cmd['name']}' uses '{strategy}' "
|
||||
f"strategy but no base command layer exists to "
|
||||
f"compose onto; skipping. Provide a lower-priority "
|
||||
f"preset, extension, or core command for it before "
|
||||
f"using composition strategies.",
|
||||
stacklevel=2,
|
||||
)
|
||||
continue
|
||||
else:
|
||||
# Not the top layer — register raw file; reconciliation
|
||||
# will overwrite with the correct composed/winning content.
|
||||
@@ -1664,21 +1671,13 @@ class PresetManager:
|
||||
if not command_names:
|
||||
return set()
|
||||
|
||||
# Never materialize extension-scoped commands whose extension isn't
|
||||
# installed. Registration (_register_commands / _register_skills)
|
||||
# already refuses them, so a reconciliation pass writing them would
|
||||
# create files no registry entry tracks. Filtering here — the single
|
||||
# chokepoint every install/remove/rescaffold reconciliation funnels
|
||||
# through — keeps all callers consistent without each one re-applying
|
||||
# the filter when seeding names from manifest templates.
|
||||
command_names = [
|
||||
name
|
||||
for name in command_names
|
||||
if self._extension_installed_for_command(name)
|
||||
]
|
||||
if not command_names:
|
||||
return set()
|
||||
|
||||
# Every preset-owned command name flows through unchanged. Names are
|
||||
# NOT filtered by the ``speckit.<ns>.<cmd>`` shape: a self-contained
|
||||
# preset command scaffolds whether or not a like-named extension is
|
||||
# installed (parity with _register_commands), and a name whose base
|
||||
# layer has disappeared must still reach the loop below so its now
|
||||
# uncomposable stale file gets unregistered. The loop already skips
|
||||
# names that resolve to no layers at all (``if not layers: continue``).
|
||||
try:
|
||||
from ..agents import CommandRegistrar
|
||||
except ImportError:
|
||||
@@ -2119,14 +2118,11 @@ class PresetManager:
|
||||
if not command_names:
|
||||
return set()
|
||||
|
||||
command_names = [
|
||||
name
|
||||
for name in command_names
|
||||
if self._extension_installed_for_command(name)
|
||||
]
|
||||
if not command_names:
|
||||
return set()
|
||||
|
||||
# Preset-owned command names are not filtered by the
|
||||
# ``speckit.<ns>.<cmd>`` shape here either: a self-contained preset
|
||||
# command renders its skill whether or not a like-named extension is
|
||||
# installed. The per-name loop below skips anything that doesn't
|
||||
# resolve to a managed skill directory.
|
||||
resolver = PresetResolver(self.project_root)
|
||||
active_skills_dir = self._get_skills_dir()
|
||||
|
||||
@@ -2656,21 +2652,17 @@ class PresetManager:
|
||||
if not command_templates:
|
||||
return {}
|
||||
|
||||
# Filter out extension command overrides if the extension isn't installed,
|
||||
# matching the same logic used by _register_commands().
|
||||
filtered = [
|
||||
cmd
|
||||
for cmd in command_templates
|
||||
if self._extension_installed_for_command(cmd["name"])
|
||||
]
|
||||
|
||||
if not filtered:
|
||||
return {}
|
||||
|
||||
# Preset command templates are self-contained and render as skills
|
||||
# regardless of whether a like-named extension is installed — the same
|
||||
# rule _register_commands() uses. No ``speckit.<ns>.<cmd>`` name-shape
|
||||
# filtering; the per-command loop below skips anything without a target
|
||||
# skill directory.
|
||||
skills_dir = target_dir if target_dir is not None else self._get_skills_dir()
|
||||
if not skills_dir:
|
||||
return {}
|
||||
|
||||
resolver = PresetResolver(self.project_root)
|
||||
|
||||
from .. import SKILL_DESCRIPTIONS, load_init_options
|
||||
from ..agents import CommandRegistrar
|
||||
from ..integrations import get_integration
|
||||
@@ -2700,7 +2692,7 @@ class PresetManager:
|
||||
|
||||
written: List[str] = []
|
||||
|
||||
for cmd_tmpl in filtered:
|
||||
for cmd_tmpl in command_templates:
|
||||
cmd_name = cmd_tmpl["name"]
|
||||
cmd_file_rel = cmd_tmpl["file"]
|
||||
source_file = preset_dir / cmd_file_rel
|
||||
@@ -2740,10 +2732,33 @@ class PresetManager:
|
||||
content = source_file.read_text(encoding="utf-8")
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
|
||||
# A composition-strategy command (wrap/prepend/append) needs a
|
||||
# base layer to compose onto. When _register_commands produced no
|
||||
# composed file for it and the stack still has no base
|
||||
# (resolve_content is None) — e.g. the command it wraps comes from
|
||||
# an extension that isn't installed — rendering the raw preset
|
||||
# fragment as a skill would emit broken output: a literal
|
||||
# {CORE_TEMPLATE} for wrap, or only the preset's own fragment for
|
||||
# prepend/append. Skip it here too so command mode and skills mode
|
||||
# agree (mirrors _register_commands, which skips the same command).
|
||||
# _register_commands already warned for this command in the same
|
||||
# pass, so the skip is silent here to avoid a duplicate warning.
|
||||
effective_strategy = (
|
||||
cmd_tmpl.get("strategy")
|
||||
or frontmatter.get("strategy")
|
||||
or "replace"
|
||||
)
|
||||
if (
|
||||
effective_strategy != "replace"
|
||||
and not composed_file.exists()
|
||||
and resolver.resolve_content(cmd_name, "command") is None
|
||||
):
|
||||
continue
|
||||
|
||||
if frontmatter.get("strategy") == "wrap":
|
||||
body, core_frontmatter = _substitute_core_template(body, cmd_name, self.project_root, registrar)
|
||||
frontmatter = dict(frontmatter)
|
||||
for key in ("scripts", "agent_scripts"):
|
||||
for key in ("scripts", "agent_scripts", "argument-hint"):
|
||||
if key not in frontmatter and key in core_frontmatter:
|
||||
frontmatter[key] = core_frontmatter[key]
|
||||
|
||||
@@ -3251,6 +3266,30 @@ class PresetManager:
|
||||
if source in owned_sources:
|
||||
shutil.rmtree(skill_subdir)
|
||||
|
||||
@staticmethod
|
||||
def _warn_unrestored_skill(
|
||||
skill_name: str, source_file: Path, exc: BaseException
|
||||
) -> None:
|
||||
"""Warn that a skill kept preset content because its restore source is unreadable.
|
||||
|
||||
Skipping the restore is the safe recovery — the alternative branch
|
||||
deletes the skill outright — but it is still a partial removal: the
|
||||
preset directory and registry entry go away while this ``SKILL.md``
|
||||
keeps the removed preset's content, and reconciliation never revisits
|
||||
it because the name is left out of ``mutated_names``. Name the skill
|
||||
and the source so the condition is actionable instead of silent.
|
||||
"""
|
||||
import warnings
|
||||
|
||||
warnings.warn(
|
||||
f"Skill '{skill_name}' still contains the removed preset's content: "
|
||||
f"its restore source '{source_file}' could not be read "
|
||||
f"({exc.__class__.__name__}: {exc}). The skill was left in place "
|
||||
f"rather than deleted. Fix or remove that file and re-run "
|
||||
f"'specify preset add'/'specify preset remove' to refresh it.",
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
def _unregister_skills_in_dir(
|
||||
self,
|
||||
skill_names: List[str],
|
||||
@@ -3377,8 +3416,20 @@ class PresetManager:
|
||||
core_file = None
|
||||
|
||||
if core_file:
|
||||
# Restore from core template
|
||||
content = core_file.read_text(encoding="utf-8")
|
||||
# Restore from core template. An unreadable/undecodable
|
||||
# source cannot produce restored content, so leave the
|
||||
# existing skill untouched rather than leaking a raw
|
||||
# OSError/UnicodeDecodeError out of `preset remove` — and
|
||||
# rather than falling through to the rmtree below, which
|
||||
# would delete a skill precisely when its replacement
|
||||
# cannot be generated. Matches the `continue` guards above
|
||||
# (unsafe name, missing subdir, foreign owner), which also
|
||||
# skip without recording the name as mutated.
|
||||
try:
|
||||
content = core_file.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
self._warn_unrestored_skill(skill_name, core_file, exc)
|
||||
continue
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
if isinstance(selected_ai, str):
|
||||
body = registrar.resolve_skill_placeholders(
|
||||
@@ -3419,7 +3470,16 @@ class PresetManager:
|
||||
continue
|
||||
|
||||
if extension_restore:
|
||||
content = extension_restore["source_file"].read_text(encoding="utf-8")
|
||||
# Same boundary as the core-template branch above: an
|
||||
# unreadable extension source leaves the skill in place
|
||||
# instead of crashing or being deleted.
|
||||
try:
|
||||
content = extension_restore["source_file"].read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError) as exc:
|
||||
self._warn_unrestored_skill(
|
||||
skill_name, extension_restore["source_file"], exc
|
||||
)
|
||||
continue
|
||||
frontmatter, body = registrar.parse_frontmatter(content)
|
||||
# Mirror the register-time rewrite (#2101): resolve
|
||||
# extension-relative subdir references (agents/,
|
||||
@@ -3586,13 +3646,10 @@ class PresetManager:
|
||||
stacklevel=2,
|
||||
)
|
||||
|
||||
# Seed/re-seed memory/constitution.md from a preset-provided
|
||||
# constitution-template. The constitution is the only template that is
|
||||
# materialized to a live file rather than resolved on demand, so a
|
||||
# preset that ships one (e.g. strategy: replace with a ratified
|
||||
# constitution) must be propagated here. Guard against clobbering an
|
||||
# already-authored constitution by only replacing a file whose recorded
|
||||
# hash (or exact legacy core-template content) proves it was generated.
|
||||
# Materialize constitution-template changes only for projects that opt
|
||||
# into the constitution-sync preset. The core /constitution command
|
||||
# resolves this template on demand; constitution-sync preserves the
|
||||
# previous install-time behavior for teams that want reviewed snapshots.
|
||||
self._seed_constitution_from_preset(manifest, dest_dir)
|
||||
|
||||
return manifest
|
||||
@@ -3600,14 +3657,13 @@ class PresetManager:
|
||||
def _seed_constitution_from_preset(
|
||||
self, manifest: PresetManifest, preset_dir: Path
|
||||
) -> None:
|
||||
"""Seed memory/constitution.md from a preset constitution-template.
|
||||
"""Seed memory/constitution.md when constitution-sync opts into snapshots.
|
||||
|
||||
Only runs when the preset declares a ``type: template`` entry named
|
||||
``constitution-template`` or provides one at a convention path, and the
|
||||
live memory file is either missing or is an unchanged generated file.
|
||||
Authored constitutions are never overwritten.
|
||||
Installing constitution-sync itself materializes the currently resolved
|
||||
stack. Later preset installs only reconcile when they provide a
|
||||
``constitution-template``. Authored constitutions are never overwritten.
|
||||
"""
|
||||
provides_constitution = any(
|
||||
provides_constitution = manifest.id == _CONSTITUTION_SYNC_PRESET_ID or any(
|
||||
t.get("type") == "template" and t.get("name") == "constitution-template"
|
||||
for t in manifest.templates
|
||||
) or any(
|
||||
@@ -3628,7 +3684,7 @@ class PresetManager:
|
||||
def reconcile_constitution(
|
||||
self, failure_context: str, *, create_if_missing: bool = False
|
||||
) -> None:
|
||||
"""Reconcile generated constitution content without failing a persisted change."""
|
||||
"""Reconcile an opted-in generated constitution without failing a change."""
|
||||
try:
|
||||
self._reconcile_constitution(create_if_missing=create_if_missing)
|
||||
except (OSError, UnicodeDecodeError, PresetValidationError, ValueError) as exc:
|
||||
@@ -3640,7 +3696,11 @@ class PresetManager:
|
||||
)
|
||||
|
||||
def _reconcile_constitution(self, *, create_if_missing: bool = False) -> None:
|
||||
"""Materialize the winning constitution layer when the live file is generated."""
|
||||
"""Materialize the winning layer when constitution-sync is enabled."""
|
||||
sync_metadata = self.registry.get(_CONSTITUTION_SYNC_PRESET_ID)
|
||||
if sync_metadata is None or not sync_metadata.get("enabled", True):
|
||||
return
|
||||
|
||||
memory_constitution = (
|
||||
self.project_root / ".specify" / "memory" / "constitution.md"
|
||||
)
|
||||
@@ -4946,6 +5006,18 @@ class PresetResolver:
|
||||
self._manifest_cache[key] = None
|
||||
return self._manifest_cache[key]
|
||||
|
||||
@staticmethod
|
||||
def _is_safe_registry_id(value: object) -> bool:
|
||||
return isinstance(value, str) and re.fullmatch(r"[a-z0-9-]+", value) is not None
|
||||
|
||||
def _get_all_presets_by_priority(self) -> List[tuple[str, dict]]:
|
||||
registry = PresetRegistry(self.presets_dir)
|
||||
return [
|
||||
(pack_id, metadata)
|
||||
for pack_id, metadata in registry.list_by_priority()
|
||||
if self._is_safe_registry_id(pack_id)
|
||||
]
|
||||
|
||||
def _manifest_declared_template(
|
||||
self, pack_dir: Path, template_name: str, template_type: str
|
||||
) -> tuple[dict | None, Path | None]:
|
||||
@@ -4979,6 +5051,64 @@ class PresetResolver:
|
||||
return tmpl, None
|
||||
return None, None
|
||||
|
||||
def _extension_manifest_declared_template(
|
||||
self, ext_dir: Path, template_name: str, template_type: str
|
||||
) -> tuple[dict | None, Path | None]:
|
||||
"""Resolve an extension's manifest-declared command/template/script entry and usable file.
|
||||
|
||||
Mirrors ``_manifest_declared_template`` (for presets): returns ``(entry, candidate)``
|
||||
where ``entry`` is the matching ``provides.<type>`` mapping, or ``None`` if the
|
||||
extension has no (valid) manifest or doesn't declare this ``(name, type)``.
|
||||
``candidate`` is the declared ``file:`` resolved under ``ext_dir`` IFF it is a
|
||||
regular file that stays within ``ext_dir`` (guards against path traversal via a
|
||||
malformed manifest, mirroring ``resolve_extension_command_via_manifest``);
|
||||
``None`` otherwise.
|
||||
|
||||
The manifest is authoritative: when ``entry`` is not ``None`` but ``candidate`` is
|
||||
``None``, callers must NOT fall back to convention-based lookup — that would mask
|
||||
a typo or pick up an undeclared file. Shared by ``resolve()`` and
|
||||
``collect_all_layers()`` so their manifest-first resolution cannot silently
|
||||
diverge (the divergence flagged in review on #4012).
|
||||
"""
|
||||
if template_type not in ("command", "template", "script"):
|
||||
return None, None
|
||||
ext_manifest_path = ext_dir / "extension.yml"
|
||||
if not ext_manifest_path.exists():
|
||||
return None, None
|
||||
from ..extensions import ExtensionManifest, ValidationError as ExtValidationError
|
||||
|
||||
try:
|
||||
ext_manifest = ExtensionManifest(ext_manifest_path)
|
||||
except (ExtValidationError, yaml.YAMLError, OSError, TypeError, AttributeError):
|
||||
return None, None
|
||||
if template_type == "command":
|
||||
entries = ext_manifest.commands
|
||||
elif template_type == "template":
|
||||
entries = ext_manifest.templates
|
||||
else:
|
||||
entries = ext_manifest.scripts
|
||||
for entry in entries:
|
||||
if entry.get("name") != template_name:
|
||||
continue
|
||||
file_rel = entry.get("file")
|
||||
if not file_rel:
|
||||
return entry, None
|
||||
rel_path = Path(file_rel)
|
||||
if rel_path.is_absolute():
|
||||
return entry, None
|
||||
candidate = ext_dir / rel_path
|
||||
try:
|
||||
# Resolve only for the containment check, not for the
|
||||
# returned path -- resolving the returned path would follow
|
||||
# symlinks in ext_dir's ancestors (e.g. a symlinked tmp dir
|
||||
# on macOS) and diverge from the unresolved paths convention
|
||||
# lookup returns for the same directory.
|
||||
candidate.resolve().relative_to(ext_dir.resolve()) # raises ValueError if outside
|
||||
except (OSError, ValueError):
|
||||
return entry, None
|
||||
return entry, (candidate if candidate.is_file() else None)
|
||||
return None, None
|
||||
|
||||
def _get_all_extensions_by_priority(self) -> list[tuple[int, str, dict | None]]:
|
||||
"""Build unified list of registered and unregistered extensions sorted by priority.
|
||||
|
||||
@@ -4993,6 +5123,16 @@ class PresetResolver:
|
||||
return []
|
||||
|
||||
registry = ExtensionRegistry(self.extensions_dir)
|
||||
# Fail closed on a corrupt registry. ExtensionRegistry._load() recovers
|
||||
# by normalizing an unreadable registry to an empty mapping, which would
|
||||
# otherwise cause the directory scan below to admit every on-disk
|
||||
# directory as an unregistered, enabled extension — a fail-open path
|
||||
# that could supply constitution content from an invalid registry state.
|
||||
if registry.is_corrupt():
|
||||
raise PresetValidationError(
|
||||
f"Invalid extension registry {registry.registry_path}: "
|
||||
"refusing to enumerate extensions"
|
||||
)
|
||||
# Use keys() to track ALL extensions (including corrupted entries) without deep copy
|
||||
# This prevents corrupted entries from being picked up as "unregistered" dirs
|
||||
registered_extension_ids = registry.keys()
|
||||
@@ -5004,6 +5144,8 @@ class PresetResolver:
|
||||
|
||||
# Only include enabled extensions in the result
|
||||
for ext_id, metadata in all_registered:
|
||||
if not self._is_safe_registry_id(ext_id):
|
||||
continue
|
||||
# Skip disabled extensions
|
||||
if not metadata.get("enabled", True):
|
||||
continue
|
||||
@@ -5012,7 +5154,7 @@ class PresetResolver:
|
||||
|
||||
# Add unregistered directories with implicit priority=10
|
||||
for ext_dir in self.extensions_dir.iterdir():
|
||||
if not ext_dir.is_dir() or ext_dir.name.startswith("."):
|
||||
if not ext_dir.is_dir() or not self._is_safe_registry_id(ext_dir.name):
|
||||
continue
|
||||
if ext_dir.name not in registered_extension_ids:
|
||||
all_extensions.append((10, ext_dir.name, None))
|
||||
@@ -5078,8 +5220,7 @@ class PresetResolver:
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority — lower number wins)
|
||||
if not skip_presets and self.presets_dir.exists():
|
||||
registry = PresetRegistry(self.presets_dir)
|
||||
for pack_id, _metadata in registry.list_by_priority():
|
||||
for pack_id, _metadata in self._get_all_presets_by_priority():
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
# The preset manifest is authoritative: if it declares this
|
||||
# template with an explicit ``file:``, resolve to that path —
|
||||
@@ -5115,6 +5256,16 @@ class PresetResolver:
|
||||
ext_dir = self.extensions_dir / ext_id
|
||||
if not ext_dir.is_dir():
|
||||
continue
|
||||
# The extension manifest is authoritative, same as preset manifests
|
||||
# above: check it before convention-based lookup so a declared entry
|
||||
# at a non-conventional path wins over a stale conventional file.
|
||||
entry, manifest_candidate = self._extension_manifest_declared_template(
|
||||
ext_dir, template_name, template_type
|
||||
)
|
||||
if manifest_candidate is not None:
|
||||
return manifest_candidate
|
||||
if entry is not None:
|
||||
continue
|
||||
for subdir in subdirs:
|
||||
if subdir:
|
||||
candidate = ext_dir / subdir / f"{template_name}{ext}"
|
||||
@@ -5272,13 +5423,11 @@ class PresetResolver:
|
||||
return {"path": resolved_str, "source": "project override"}
|
||||
|
||||
if str(self.presets_dir) in resolved_str and self.presets_dir.exists():
|
||||
registry = PresetRegistry(self.presets_dir)
|
||||
for pack_id, _metadata in registry.list_by_priority():
|
||||
for pack_id, metadata in self._get_all_presets_by_priority():
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
try:
|
||||
resolved.relative_to(pack_dir)
|
||||
meta = registry.get(pack_id)
|
||||
version = meta.get("version", "?") if meta else "?"
|
||||
version = metadata.get("version", "?")
|
||||
return {
|
||||
"path": resolved_str,
|
||||
"source": f"{pack_id} v{version}",
|
||||
@@ -5364,8 +5513,7 @@ class PresetResolver:
|
||||
|
||||
# Priority 2: Installed presets (sorted by priority — lower number = higher precedence)
|
||||
if self.presets_dir.exists():
|
||||
registry = PresetRegistry(self.presets_dir)
|
||||
for pack_id, metadata in registry.list_by_priority():
|
||||
for pack_id, metadata in self._get_all_presets_by_priority():
|
||||
pack_dir = self.presets_dir / pack_id
|
||||
# Read strategy and manifest file path from preset manifest
|
||||
strategy = "replace"
|
||||
@@ -5424,27 +5572,15 @@ class PresetResolver:
|
||||
ext_dir = self.extensions_dir / ext_id
|
||||
if not ext_dir.is_dir():
|
||||
continue
|
||||
# Try convention-based lookup first
|
||||
candidate = _find_in_subdirs(ext_dir)
|
||||
# If not found and this is a command, check extension manifest
|
||||
if candidate is None and template_type == "command":
|
||||
ext_manifest_path = ext_dir / "extension.yml"
|
||||
if ext_manifest_path.exists():
|
||||
try:
|
||||
from ..extensions import ExtensionManifest, ValidationError as ExtValidationError
|
||||
ext_manifest = ExtensionManifest(ext_manifest_path)
|
||||
for cmd in ext_manifest.commands:
|
||||
if cmd.get("name") == template_name:
|
||||
cmd_file = cmd.get("file")
|
||||
if cmd_file:
|
||||
c = ext_dir / cmd_file
|
||||
if c.exists():
|
||||
candidate = c
|
||||
break
|
||||
except (ExtValidationError, yaml.YAMLError):
|
||||
# Invalid extension manifest — fall back to
|
||||
# convention-based lookup (already attempted above).
|
||||
pass
|
||||
# The extension manifest is authoritative, same as preset manifests
|
||||
# above: check it before convention-based lookup so a declared entry
|
||||
# at a non-conventional path wins over a stale conventional file, and
|
||||
# a declared-but-missing file isn't silently masked by convention.
|
||||
entry, candidate = self._extension_manifest_declared_template(
|
||||
ext_dir, template_name, template_type
|
||||
)
|
||||
if entry is None:
|
||||
candidate = _find_in_subdirs(ext_dir)
|
||||
if candidate:
|
||||
if ext_meta:
|
||||
version = ext_meta.get("version", "?")
|
||||
@@ -5576,7 +5712,7 @@ class PresetResolver:
|
||||
if not layers:
|
||||
return None
|
||||
|
||||
def _read_layer_content(layer: Dict[str, Any]) -> str:
|
||||
def _read_layer_content(layer: Dict[str, Any]) -> Optional[str]:
|
||||
"""Read a layer's raw text, rewriting extension-relative subdir
|
||||
references (agents/, knowledge-base/, etc.) to their installed
|
||||
location when the layer is extension-provided (#2101).
|
||||
@@ -5586,8 +5722,18 @@ class PresetResolver:
|
||||
rewrite when it wins outright above or serves as the
|
||||
composition base below — never as a mid-stack composing
|
||||
(append/prepend/wrap) layer.
|
||||
|
||||
Returns None when the layer cannot be read or decoded:
|
||||
collect_all_layers deliberately keeps a non-UTF-8 legacy layer
|
||||
(with its "replace" default) so unrelated commands still
|
||||
resolve, so the same tolerance must apply here — the documented
|
||||
contract is "Composed content string, or None if not found",
|
||||
not a raw UnicodeDecodeError at composition time.
|
||||
"""
|
||||
text = layer["path"].read_text(encoding="utf-8")
|
||||
try:
|
||||
text = layer["path"].read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
return None
|
||||
extension_id = layer.get("extension_id")
|
||||
extension_dir = layer.get("extension_dir")
|
||||
if extension_id and extension_dir:
|
||||
@@ -5625,6 +5771,8 @@ class PresetResolver:
|
||||
# Convert to reversed_layers index
|
||||
base_reversed_idx = len(layers) - 1 - base_layer_idx
|
||||
content = _read_layer_content(layers[base_layer_idx])
|
||||
if content is None:
|
||||
return None
|
||||
# Compose only the layers above the base (higher priority = lower index in layers,
|
||||
# higher index in reversed_layers). Process bottom-up from base+1.
|
||||
start_idx = base_reversed_idx + 1
|
||||
@@ -5668,7 +5816,12 @@ class PresetResolver:
|
||||
|
||||
# Apply composition layers from bottom to top
|
||||
for layer in reversed_layers[start_idx:]:
|
||||
layer_content = layer["path"].read_text(encoding="utf-8")
|
||||
try:
|
||||
layer_content = layer["path"].read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeDecodeError):
|
||||
# Same tolerance as _read_layer_content: an unreadable layer
|
||||
# means the composed result cannot be produced.
|
||||
return None
|
||||
strategy = layer["strategy"]
|
||||
|
||||
if is_command:
|
||||
@@ -5725,7 +5878,7 @@ class PresetResolver:
|
||||
# Inherit scripts/agent_scripts from base frontmatter if missing
|
||||
if base_frontmatter_text and base_frontmatter_text != top_frontmatter_text:
|
||||
base_fm = _parse_fm_yaml(base_frontmatter_text)
|
||||
for key in ("scripts", "agent_scripts"):
|
||||
for key in ("scripts", "agent_scripts", "argument-hint"):
|
||||
if key not in top_fm and key in base_fm:
|
||||
top_fm[key] = base_fm[key]
|
||||
|
||||
|
||||
@@ -9,6 +9,7 @@ keeps working.
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import re
|
||||
from pathlib import Path
|
||||
|
||||
import typer
|
||||
@@ -58,7 +59,15 @@ def preset_list():
|
||||
console.print(" [cyan]specify preset add <pack-name>[/cyan]")
|
||||
return
|
||||
|
||||
console.print("\n[bold cyan]Installed Presets:[/bold cyan]\n")
|
||||
# Sort by actual resolution precedence: lower priority number wins, ties
|
||||
# broken by preset id (matching PresetRegistry.list_by_priority()). This
|
||||
# keeps the printed order aligned with how presets are composed/resolved.
|
||||
installed = sorted(
|
||||
installed,
|
||||
key=lambda pack: (pack.get("priority", 10), str(pack.get("id", ""))),
|
||||
)
|
||||
|
||||
console.print("\n[bold cyan]Installed Presets[/bold cyan] [dim](in resolution order — highest precedence first)[/dim]\n")
|
||||
for pack in installed:
|
||||
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
|
||||
pri = pack.get('priority', 10)
|
||||
@@ -74,6 +83,8 @@ def preset_list():
|
||||
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
|
||||
console.print()
|
||||
|
||||
console.print("[dim]Lower priority number = higher precedence. Ties are broken by preset id (alphabetical).[/dim]")
|
||||
|
||||
|
||||
@preset_app.command("add")
|
||||
def preset_add(
|
||||
@@ -352,9 +363,26 @@ def preset_resolve(
|
||||
from .. import _require_specify_project
|
||||
from . import PresetResolver
|
||||
|
||||
is_command = "." in template_name
|
||||
valid_name = (
|
||||
re.fullmatch(r"[a-z0-9-]+(?:\.[a-z0-9-]+)+", template_name)
|
||||
if is_command
|
||||
else re.fullmatch(r"[a-z0-9-]+", template_name)
|
||||
)
|
||||
if valid_name is None:
|
||||
typer.echo(
|
||||
f"Error: invalid template name '{template_name}'; "
|
||||
"use lowercase letters, digits, and hyphens, with non-empty "
|
||||
"dot-separated segments for commands",
|
||||
err=True,
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
project_root = _require_specify_project()
|
||||
resolver = PresetResolver(project_root)
|
||||
layers = resolver.collect_all_layers(template_name)
|
||||
template_type = "command" if is_command else "template"
|
||||
|
||||
layers = resolver.collect_all_layers(template_name, template_type)
|
||||
safe_template_name = _escape_markup(str(template_name))
|
||||
|
||||
if layers:
|
||||
@@ -377,7 +405,7 @@ def preset_resolve(
|
||||
if has_composition:
|
||||
# Verify composition is actually possible
|
||||
try:
|
||||
composed = resolver.resolve_content(template_name)
|
||||
composed = resolver.resolve_content(template_name, template_type)
|
||||
except Exception as exc:
|
||||
composed = None
|
||||
console.print(
|
||||
@@ -416,7 +444,7 @@ def preset_resolve(
|
||||
)
|
||||
else:
|
||||
# No layers found — fall back to resolve_with_source for non-composition cases
|
||||
result = resolver.resolve_with_source(template_name)
|
||||
result = resolver.resolve_with_source(template_name, template_type)
|
||||
if result:
|
||||
console.print(
|
||||
f" [bold]{safe_template_name}[/bold]: "
|
||||
@@ -800,8 +828,8 @@ def preset_catalog_remove(
|
||||
|
||||
try:
|
||||
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
except Exception:
|
||||
console.print("[red]Error:[/red] Failed to read preset catalog config.")
|
||||
except Exception as e:
|
||||
console.print(f"[red]Error:[/red] Failed to read preset catalog config: {e}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
catalogs = config.get("catalogs", [])
|
||||
|
||||
@@ -16,6 +16,22 @@ from .integrations.manifest import IntegrationManifest
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Managed ``.specify/.gitignore``. Keeps machine-local Spec Kit state out of
|
||||
# version control while leaving shareable project files (specs, constitution,
|
||||
# templates, scripts, extension config) tracked. Patterns are relative to the
|
||||
# ``.specify/`` directory the file lives in.
|
||||
SPECIFY_GITIGNORE_CONTENT = """\
|
||||
# Machine-local Spec Kit state — not meant to be shared.
|
||||
# Managed by the Specify CLI; safe to edit (your changes are preserved on refresh).
|
||||
|
||||
# Local pointer to the current feature directory. Rewritten every time you
|
||||
# switch features, so it is per-checkout state rather than something to share.
|
||||
feature.json
|
||||
|
||||
# Per-machine extension config overrides.
|
||||
extensions/*/local-config.yml
|
||||
"""
|
||||
|
||||
# Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal
|
||||
# characters. Callers lowercase the declared value before matching (see
|
||||
# ``expected_hex = raw.lower()`` below), so an uppercase digest is accepted and
|
||||
@@ -262,8 +278,7 @@ def _write_shared_bytes(
|
||||
_ensure_safe_shared_destination(project_path, dest)
|
||||
os.replace(temp_path, dest)
|
||||
finally:
|
||||
if temp_path.exists():
|
||||
temp_path.unlink()
|
||||
temp_path.unlink(missing_ok=True)
|
||||
|
||||
|
||||
_BASH_FORMAT_COMMAND_RE = re.compile(
|
||||
@@ -608,6 +623,36 @@ def install_shared_infra(
|
||||
)
|
||||
planned_templates.append((dst, rel, content))
|
||||
|
||||
# Managed ``.specify/.gitignore`` — keeps machine-local state (the
|
||||
# ``feature.json`` pointer and per-machine ``local-config.yml`` overrides)
|
||||
# out of git while leaving everything else shareable. Routed through the
|
||||
# same overwrite/skip/preserve policy as templates so ``--force`` refreshes
|
||||
# it and user edits are preserved. Like every other shared-infra file it is
|
||||
# tracked in ``speckit.manifest.json`` (not the per-integration manifest) and
|
||||
# is therefore intentionally left in place by ``integration uninstall``.
|
||||
specify_dir = project_path / ".specify"
|
||||
if _ensure_or_bucket_dir(specify_dir):
|
||||
gitignore_dst = specify_dir / ".gitignore"
|
||||
gitignore_rel = gitignore_dst.relative_to(project_path).as_posix()
|
||||
seen_rels.add(gitignore_rel)
|
||||
if _safe_dest_or_bucket(gitignore_dst, gitignore_rel):
|
||||
write, bucket = _decide_overwrite(gitignore_rel, gitignore_dst)
|
||||
if write:
|
||||
planned_templates.append(
|
||||
(gitignore_dst, gitignore_rel, SPECIFY_GITIGNORE_CONTENT)
|
||||
)
|
||||
elif bucket == "preserved":
|
||||
preserved_user_files.append(gitignore_rel)
|
||||
else:
|
||||
skipped_files.append(gitignore_rel)
|
||||
if gitignore_dst.is_file() and gitignore_rel not in prior_hashes:
|
||||
try:
|
||||
manifest.record_existing(gitignore_rel, recovered=True)
|
||||
except (OSError, ValueError) as exc:
|
||||
console.print(
|
||||
f"[yellow]⚠[/yellow] could not record {gitignore_rel} in manifest: {exc}"
|
||||
)
|
||||
|
||||
for dst_path, rel, content, mode in planned_copies:
|
||||
if not _ensure_or_bucket_dir(dst_path.parent):
|
||||
continue
|
||||
|
||||
@@ -1594,6 +1594,12 @@ def workflow_status(
|
||||
except ValueError as exc:
|
||||
err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
|
||||
raise typer.Exit(1)
|
||||
except OSError as exc:
|
||||
# An unreadable state.json (bad permissions, a directory in its
|
||||
# place, I/O error) must fail as cleanly as the malformed-JSON
|
||||
# case above -- `workflow resume` already handles OSError here.
|
||||
err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if json_output:
|
||||
# Build on the shared run/resume payload so the common fields
|
||||
|
||||
@@ -671,8 +671,20 @@ def evaluate_condition(condition: str, context: Any) -> bool:
|
||||
result = evaluate_expression(condition, context)
|
||||
# Treat plain "false"/"true" strings as booleans so that
|
||||
# condition: "false" (without {{ }}) behaves as expected.
|
||||
#
|
||||
# Strip before matching: the string a condition resolves to is most often
|
||||
# captured command output, and a ``shell`` step stores ``proc.stdout``
|
||||
# verbatim, so ``run: echo false`` resolves to ``"false\n"``. Without the
|
||||
# strip that trailing newline matches neither branch and falls through to
|
||||
# ``bool("false\n")`` -> True, silently taking an ``if`` step's ``then``
|
||||
# branch (and keeping a ``while``/``do-while`` looping) on a step that
|
||||
# printed "false". A workflow cannot strip it itself -- the registered
|
||||
# filters are default/join/map/contains/from_json, there is no ``trim``.
|
||||
# ``InitStep._resolve_bool`` and the catalog readers already strip before
|
||||
# matching boolean text. ``bool(result)`` below still sees the raw string,
|
||||
# so no non-boolean text changes truthiness.
|
||||
if isinstance(result, str):
|
||||
lower = result.lower()
|
||||
lower = result.strip().lower()
|
||||
if lower == "false":
|
||||
return False
|
||||
if lower == "true":
|
||||
|
||||
@@ -87,7 +87,12 @@ def _parse_edit(edit_raw: dict[str, Any], idx: int) -> tuple[OverlayEdit | None,
|
||||
else:
|
||||
return None, f"Edit at index {idx} has no operation; expected one of {sorted(VALID_OPERATIONS)}."
|
||||
|
||||
if operation not in VALID_OPERATIONS:
|
||||
# ``operation`` comes straight from hand-edited YAML, so it may be an
|
||||
# unhashable mapping/sequence (``operation: {insert_after: a}`` when the
|
||||
# shorthand form is nested by mistake). Membership-testing an unhashable
|
||||
# value against the frozenset raises TypeError, which would escape this
|
||||
# never-raising validator; check the type first, like 'anchor' below.
|
||||
if not isinstance(operation, str) or operation not in VALID_OPERATIONS:
|
||||
return None, f"Edit at index {idx} has invalid operation {operation!r}."
|
||||
|
||||
if not isinstance(anchor, str) or not anchor:
|
||||
|
||||
@@ -4,7 +4,9 @@
|
||||
**Created**: [DATE]
|
||||
**Feature**: [Link to spec.md or relevant documentation]
|
||||
|
||||
**Note**: This checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
|
||||
**Note**: This custom checklist is generated by the `__SPECKIT_COMMAND_CHECKLIST__` command based on feature context and requirements.
|
||||
**Review Ownership**: This checklist is a reviewer-owned requirements-quality review artifact. Mark an item `[x]` only when the reviewer determines the requirements-quality criterion is satisfied.
|
||||
**Marker Semantics**: `[x]` means the criterion has been reviewed and satisfied for requirements quality. It does not mean implementation work is complete.
|
||||
|
||||
<!--
|
||||
============================================================================
|
||||
@@ -34,7 +36,10 @@
|
||||
|
||||
## Notes
|
||||
|
||||
- Check items off as completed: `[x]`
|
||||
- Mark items `[x]` only after review confirms the requirement-quality criterion is satisfied
|
||||
- Leave items unchecked when they still require clarification, correction, or reviewer evaluation
|
||||
- `__SPECKIT_COMMAND_IMPLEMENT__` reads checklist checkbox state as a gate and must not modify markers
|
||||
- `checklists/requirements.md` has a separate built-in lifecycle maintained by `__SPECKIT_COMMAND_SPECIFY__` and `__SPECKIT_COMMAND_CLARIFY__`
|
||||
- Add comments or findings inline
|
||||
- Link to relevant resources or documentation
|
||||
- Items are numbered sequentially for easy reference
|
||||
|
||||
@@ -1,9 +1,9 @@
|
||||
---
|
||||
description: Generate a custom checklist for the current feature based on user requirements.
|
||||
scripts:
|
||||
sh: scripts/bash/check-prerequisites.sh --json
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json
|
||||
py: scripts/python/check_prerequisites.py --json
|
||||
sh: scripts/bash/check-prerequisites.sh --json --template checklist-template
|
||||
ps: scripts/powershell/check-prerequisites.ps1 -Json -Template checklist-template
|
||||
py: scripts/python/check_prerequisites.py --json --template checklist-template
|
||||
---
|
||||
|
||||
## Checklist Purpose: "Unit Tests for English"
|
||||
@@ -27,6 +27,15 @@ scripts:
|
||||
|
||||
**Metaphor**: If your spec is code written in English, the checklist is its unit test suite. You're testing whether the requirements are well-written, complete, unambiguous, and ready for implementation - NOT whether the implementation works.
|
||||
|
||||
**Ownership and checkbox lifecycle**:
|
||||
|
||||
- Custom checklists generated by this command are reviewer-owned requirements-quality review artifacts.
|
||||
- `[x]` means the reviewer determined the requirements-quality criterion is satisfied.
|
||||
- `[x]` does NOT mean implementation work is complete.
|
||||
- This command generates or appends checklist items; it MUST NOT mark generated items `[x]`.
|
||||
- An agent may assist with evaluating items only when explicitly asked by the reviewer.
|
||||
- `checklists/requirements.md` is a separate built-in spec-quality checklist maintained by `__SPECKIT_COMMAND_SPECIFY__` and `__SPECKIT_COMMAND_CLARIFY__`; do not treat that exception as applying to custom checklists generated here.
|
||||
|
||||
## User Input
|
||||
|
||||
```text
|
||||
@@ -72,7 +81,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Execution Steps
|
||||
|
||||
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR and AVAILABLE_DOCS list.
|
||||
1. **Setup**: Run `{SCRIPT}` from repo root and parse JSON for FEATURE_DIR, AVAILABLE_DOCS list, and TEMPLATE_CONTENT.
|
||||
- All file paths must be absolute.
|
||||
- For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
@@ -127,7 +136,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Use progressive disclosure: add follow-on retrieval only if gaps detected
|
||||
- If source docs are large, generate interim summary items instead of embedding raw text
|
||||
|
||||
6. **Generate checklist** - Create "Unit Tests for Requirements":
|
||||
6. **Generate checklist** - Use TEMPLATE_CONTENT as the structural template and create "Unit Tests for Requirements":
|
||||
- Create `FEATURE_DIR/checklists/` directory if it doesn't exist
|
||||
- Generate unique checklist filename:
|
||||
- Use short, descriptive name based on domain (e.g., `ux.md`, `api.md`, `security.md`)
|
||||
@@ -136,6 +145,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- If file does NOT exist: Create new file and number items starting from CHK001
|
||||
- If file exists: Append new items to existing file, continuing from the last CHK ID (e.g., if last item is CHK015, start new items at CHK016)
|
||||
- Never delete or replace existing checklist content - always preserve and append
|
||||
- Leave every newly generated item unchecked (`[ ]`); checkbox state belongs to the reviewer
|
||||
|
||||
**CORE PRINCIPLE - Test the Requirements, Not the Implementation**:
|
||||
Every checklist item MUST evaluate the REQUIREMENTS THEMSELVES for:
|
||||
@@ -245,7 +255,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- ✅ "Are [edge cases/scenarios] addressed in requirements?"
|
||||
- ✅ "Does the spec define [missing aspect]?"
|
||||
|
||||
7. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001.
|
||||
7. **Structure Reference**: Generate the checklist following the canonical template in `templates/checklist-template.md` for title, meta section, category headings, ownership note, notes section, and ID formatting. If template is unavailable, use: H1 title, purpose/created meta lines, an ownership note explaining that `[x]` means reviewer approval of requirements quality, `##` category sections containing `- [ ] CHK### <requirement item>` lines with globally incrementing IDs starting at CHK001, and notes that `__SPECKIT_COMMAND_IMPLEMENT__` reads checklist state but does not modify markers.
|
||||
|
||||
8. **Report**: Output full path to checklist file, item count, and summarize whether the run created a new file or appended to an existing one. Summarize:
|
||||
- Focus areas selected
|
||||
|
||||
@@ -4,6 +4,10 @@ handoffs:
|
||||
- label: Build Specification
|
||||
agent: speckit.specify
|
||||
prompt: Implement the feature specification based on the updated constitution. I want to build...
|
||||
scripts:
|
||||
sh: scripts/bash/resolve-template.sh constitution-template --json
|
||||
ps: scripts/powershell/resolve-template.ps1 constitution-template -Json
|
||||
py: scripts/python/resolve_template.py constitution-template --json
|
||||
---
|
||||
|
||||
## User Input
|
||||
@@ -70,13 +74,22 @@ and commands read the constitution at runtime and are not modified here.
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values and (b) fill the template precisely.
|
||||
|
||||
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. The active
|
||||
constitution scaffold is resolved at command time from `constitution-template` through the Spec Kit
|
||||
preset/template resolution stack.
|
||||
|
||||
Follow this execution flow:
|
||||
|
||||
1. Load the existing constitution at `.specify/memory/constitution.md`.
|
||||
1. Run `{SCRIPT}` from the repository root and parse `TEMPLATE_CONTENT` as the active template.
|
||||
- The shared resolver applies project overrides, composing preset layers, and extension layers
|
||||
before the core template fallback. It MUST succeed before continuing.
|
||||
- If it fails, stop and report the resolution error; do not continue with only one contributing
|
||||
template layer.
|
||||
- If `.specify/memory/constitution.md` exists, load it as the source of current project-specific
|
||||
values and amendments. Preserve information that is still applicable when applying the newly
|
||||
resolved scaffold.
|
||||
- If it does not exist, use the resolved template as the initial document.
|
||||
- Do not write back to any versioned template layer.
|
||||
- Identify every placeholder token of the form `[ALL_CAPS_IDENTIFIER]`.
|
||||
**IMPORTANT**: The user might require less or more principles than the ones used in the template. If a number is specified, respect that - follow the general template. You will update the doc accordingly.
|
||||
|
||||
@@ -90,7 +103,7 @@ Follow this execution flow:
|
||||
- PATCH: Clarifications, wording, typo fixes, non-semantic refinements.
|
||||
- If version bump type ambiguous, propose reasoning before finalizing.
|
||||
|
||||
3. Draft the updated constitution content:
|
||||
3. Draft the updated constitution content using the resolved template as the required structure:
|
||||
- Replace every placeholder with concrete text (no bracketed tokens left except intentionally retained template slots that the project has chosen not to define yet—explicitly justify any left).
|
||||
- Preserve heading hierarchy and comments can be removed once replaced unless they still add clarifying guidance.
|
||||
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing non‑negotiable rules, explicit rationale if not obvious.
|
||||
@@ -128,7 +141,7 @@ If the user supplies partial updates (e.g., only one principle revision), still
|
||||
|
||||
If critical info missing (e.g., ratification date truly unknown), insert `TODO(<FIELD_NAME>): explanation` and include in the Sync Impact Report under deferred items.
|
||||
|
||||
Do not create a new template; always operate on the existing `.specify/memory/constitution.md` file.
|
||||
Write only `.specify/memory/constitution.md`; do not create or modify template source files.
|
||||
|
||||
## Post-Execution Checks
|
||||
|
||||
|
||||
@@ -54,33 +54,36 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
1. Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Check checklists status** (if FEATURE_DIR/checklists/ exists):
|
||||
- Treat checklist markers as a read-only gate: scan checkbox state, report status, and ask before proceeding when needed; do NOT modify checklist files or markers
|
||||
- `checklists/requirements.md` is the built-in spec-quality checklist maintained by `__SPECKIT_COMMAND_SPECIFY__` and `__SPECKIT_COMMAND_CLARIFY__`; custom checklists generated by `__SPECKIT_COMMAND_CHECKLIST__` are reviewer-owned requirements-quality review artifacts
|
||||
- For custom checklists, `[x]` means the reviewer determined the requirements-quality criterion is satisfied; it does NOT mean implementation work is complete
|
||||
- Scan all checklist files in the checklists/ directory
|
||||
- For each checklist, count:
|
||||
- Total items: All lines matching `- [ ]` or `- [X]` or `- [x]`
|
||||
- Completed items: Lines matching `- [X]` or `- [x]`
|
||||
- Incomplete items: Lines matching `- [ ]`
|
||||
- Checked items: Lines matching `- [X]` or `- [x]`
|
||||
- Unchecked items: Lines matching `- [ ]`
|
||||
- Create a status table:
|
||||
|
||||
```text
|
||||
| Checklist | Total | Completed | Incomplete | Status |
|
||||
|-----------|-------|-----------|------------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
| Checklist | Total | Checked | Unchecked | Status |
|
||||
|-----------|-------|---------|-----------|--------|
|
||||
| ux.md | 12 | 12 | 0 | ✓ PASS |
|
||||
| test.md | 8 | 5 | 3 | ✗ FAIL |
|
||||
| security.md | 6 | 6 | 0 | ✓ PASS |
|
||||
```
|
||||
|
||||
- Calculate overall status:
|
||||
- **PASS**: All checklists have 0 incomplete items
|
||||
- **FAIL**: One or more checklists have incomplete items
|
||||
- **PASS**: All checklists have 0 unchecked items
|
||||
- **FAIL**: One or more checklists have unchecked items
|
||||
|
||||
- **If any checklist is incomplete**:
|
||||
- Display the table with incomplete item counts
|
||||
- **STOP** and ask: "Some checklists are incomplete. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- **If any checklist has unchecked items**:
|
||||
- Display the table with unchecked item counts
|
||||
- **STOP** and ask: "Some checklists have unchecked items. Do you want to proceed with implementation anyway? (yes/no)"
|
||||
- Wait for user response before continuing
|
||||
- If user says "no" or "wait" or "stop", halt execution
|
||||
- If user says "yes" or "proceed" or "continue", proceed to step 3
|
||||
|
||||
- **If all checklists are complete**:
|
||||
- **If all checklists are checked**:
|
||||
- Display the table showing all checklists passed
|
||||
- Automatically proceed to step 3
|
||||
|
||||
|
||||
@@ -60,7 +60,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
|
||||
## Outline
|
||||
|
||||
1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE_CONTENT, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
|
||||
|
||||
2. **Load design documents**: Read from FEATURE_DIR:
|
||||
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
|
||||
@@ -79,7 +79,7 @@ You **MUST** consider the user input before proceeding (if not empty).
|
||||
- Create parallel execution examples per user story
|
||||
- Validate task completeness (each user story has all needed tasks, independently testable)
|
||||
|
||||
4. **Generate tasks.md**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. Fill with:
|
||||
4. **Generate tasks.md**: Use TASKS_TEMPLATE_CONTENT (from the JSON output above) as the structure. For compatibility with older setup scripts that omit TASKS_TEMPLATE_CONTENT, read TASKS_TEMPLATE instead. Fill with:
|
||||
- Correct feature name from plan.md
|
||||
- Phase 1: Setup tasks (project initialization)
|
||||
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)
|
||||
|
||||
@@ -217,6 +217,31 @@ def test_catalog_remove_builtin_is_refused(project: Path):
|
||||
assert "built-in" in result.output
|
||||
|
||||
|
||||
# Every ``bundle`` error path funnels through ``_fail(str(exc))``, and the
|
||||
# BundlerError messages interpolate untrusted data -- including the command's
|
||||
# own argument. An unbalanced closer used to raise MarkupError instead of the
|
||||
# error, leaving the user with a traceback and no message at all.
|
||||
@pytest.mark.parametrize(
|
||||
"argv, expected",
|
||||
[
|
||||
(
|
||||
["bundle", "catalog", "add", "ssh://ex[/red]ample.com/c.json"],
|
||||
"ssh://ex[/red]ample.com/c.json",
|
||||
),
|
||||
(["bundle", "catalog", "remove", "no[/red]such"], "no[/red]such"),
|
||||
(["bundle", "update", "no[/red]such"], "no[/red]such"),
|
||||
(["bundle", "remove", "no[/red]such"], "no[/red]such"),
|
||||
],
|
||||
)
|
||||
def test_error_paths_escape_rich_markup(project: Path, argv: list, expected: str):
|
||||
result = runner.invoke(app, argv)
|
||||
|
||||
assert result.exit_code == 1
|
||||
# A MarkupError would surface here as an exception rather than a clean exit.
|
||||
assert isinstance(result.exception, SystemExit)
|
||||
assert expected in strip_ansi(result.output)
|
||||
|
||||
|
||||
def test_validate_reports_invalid_manifest(project: Path):
|
||||
data = valid_manifest_dict()
|
||||
del data["bundle"]["license"]
|
||||
@@ -237,6 +262,33 @@ def test_validate_accepts_valid_manifest(project: Path):
|
||||
assert "valid" in result.output
|
||||
|
||||
|
||||
def test_validate_escapes_manifest_markup_in_errors(project: Path):
|
||||
data = valid_manifest_dict()
|
||||
# An invalid constraint is echoed back inside the validation error.
|
||||
data["requires"] = {"speckit_version": ">=1.0[/bold]"}
|
||||
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, ["bundle", "validate", "--offline"])
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert isinstance(result.exception, SystemExit)
|
||||
assert ">=1.0[/bold]" in strip_ansi(result.output)
|
||||
|
||||
|
||||
def test_validate_escapes_manifest_markup_in_warnings(project: Path):
|
||||
data = valid_manifest_dict()
|
||||
# Step ids are not charset-validated, and the unresolved-reference warning
|
||||
# echoes them -- so an otherwise *valid* manifest crashed just as readily as
|
||||
# an invalid one, on the success path.
|
||||
data["provides"]["steps"] = [{"id": "step[/bold]a"}]
|
||||
(project / "bundle.yml").write_text(yaml.safe_dump(data), encoding="utf-8")
|
||||
|
||||
result = runner.invoke(app, ["bundle", "validate", "--offline"])
|
||||
|
||||
assert result.exit_code == 0, repr(result.exception)
|
||||
assert "step[/bold]a" in strip_ansi(result.output)
|
||||
|
||||
|
||||
def test_validate_rejects_broken_reference(project: Path):
|
||||
# Synthetic component ids resolve to nothing in any catalog → hard failure.
|
||||
(project / "bundle.yml").write_text(
|
||||
@@ -267,6 +319,65 @@ def test_build_produces_artifact(project: Path):
|
||||
assert len(artifacts) == 1
|
||||
|
||||
|
||||
def test_build_escapes_markup_in_output_path(project: Path):
|
||||
"""The build success line echoes a caller-supplied ``--output`` path.
|
||||
|
||||
Brackets are legal in a directory name on both POSIX and Windows, so the
|
||||
artifact is built and *then* misreported: ``[bold]`` is consumed as a style
|
||||
tag, and the success line names a path that does not exist on disk.
|
||||
|
||||
A closing tag (``[/red]``) would raise MarkupError outright, but ``/`` is a
|
||||
path separator on Windows, so this uses the silent-swallow form to keep the
|
||||
fixture portable.
|
||||
"""
|
||||
(project / "bundle.yml").write_text(
|
||||
yaml.safe_dump(valid_manifest_dict()), encoding="utf-8"
|
||||
)
|
||||
(project / "README.md").write_text("# Demo", encoding="utf-8")
|
||||
out_dir = project / "dist[bold]out"
|
||||
|
||||
result = runner.invoke(app, ["bundle", "build", "--output", str(out_dir)])
|
||||
|
||||
assert result.exit_code == 0, repr(result.exception)
|
||||
assert list(out_dir.glob("*.zip")), "the artifact should still be built"
|
||||
assert "dist[bold]out" in strip_ansi(result.output), (
|
||||
"the reported path must match the directory actually written"
|
||||
)
|
||||
|
||||
|
||||
def test_list_escapes_markup_in_records(project: Path):
|
||||
"""``bundle list`` renders record fields that are never charset-validated.
|
||||
|
||||
``InstalledBundleRecord.from_dict`` accepts any non-empty string for
|
||||
``bundle_id``/``version`` and any string for ``installed_at``, so a records
|
||||
file that *loads cleanly* could still crash the command that displays it.
|
||||
"""
|
||||
(project / ".specify" / "bundle-records.json").write_text(
|
||||
json.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"bundles": [
|
||||
{
|
||||
"bundle_id": "demo[/red]id",
|
||||
"version": "1.0.0[/bold]",
|
||||
"installed_at": "2026-01-01T00:00:00Z[/dim]",
|
||||
"contributed_components": [],
|
||||
}
|
||||
],
|
||||
}
|
||||
),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = runner.invoke(app, ["bundle", "list"])
|
||||
|
||||
assert result.exit_code == 0, repr(result.exception)
|
||||
output = strip_ansi(result.output)
|
||||
assert "demo[/red]id" in output
|
||||
assert "1.0.0[/bold]" in output
|
||||
assert "2026-01-01T00:00:00Z[/dim]" in output
|
||||
|
||||
|
||||
def _mock_manifest_download(monkeypatch, source_path: Path) -> None:
|
||||
"""Mock the HTTPS manifest fetch to return a locally-authored manifest.
|
||||
|
||||
@@ -899,3 +1010,33 @@ def test_bundle_info_resolves_ghes_browser_release_url(project: Path):
|
||||
|
||||
payload = json.loads(result.output)
|
||||
assert payload["id"] == "demo-bundle"
|
||||
|
||||
|
||||
def test_bundle_download_rejects_oversized_response(project: Path, monkeypatch):
|
||||
"""Bundle download rejects responses exceeding MAX_DOWNLOAD_BYTES."""
|
||||
# Monkeypatch to a small limit so the test is fast and low-memory.
|
||||
monkeypatch.setattr(
|
||||
"specify_cli.commands.bundle.MAX_DOWNLOAD_BYTES", 100
|
||||
)
|
||||
|
||||
api_asset_url = "https://api.github.com/repos/org/repo/releases/assets/99"
|
||||
|
||||
def fake_open_url(url, timeout=None, extra_headers=None, redirect_validator=None):
|
||||
# Return a response that exceeds 100 bytes.
|
||||
return FakeBundleResponse(b"x" * 200, url=api_asset_url)
|
||||
|
||||
catalog = project / "catalog.json"
|
||||
write_catalog_file(
|
||||
catalog,
|
||||
{"demo-bundle": catalog_entry_dict("demo-bundle", download_url=api_asset_url)},
|
||||
)
|
||||
_make_catalog_config(catalog, project)
|
||||
|
||||
with patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url):
|
||||
result = runner.invoke(app, ["bundle", "info", "demo-bundle", "--json"])
|
||||
|
||||
# Must fail with a size-limit error, not an unhandled traceback.
|
||||
assert result.exit_code == 1
|
||||
# Rich may wrap the message across lines; normalise whitespace before checking.
|
||||
output_flat = " ".join(result.output.split())
|
||||
assert "exceeds maximum size of 100 bytes" in output_flat
|
||||
|
||||
@@ -222,32 +222,6 @@ def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
|
||||
assert "old" not in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_blank_markers_use_defaults_matching_bash(tmp_path: Path) -> None:
|
||||
# Regression: with blank markers (config relying on the built-in defaults),
|
||||
# the Bash port must fall back to DEFAULT_START/END, matching the Python and
|
||||
# PowerShell ports. Previously the Bash config-parser transport dropped the
|
||||
# trailing empty marker lines under $(...) command substitution, tripping the
|
||||
# "malformed config parser output" guard so the default-marker substitution
|
||||
# became unreachable and the context file was never updated.
|
||||
markers = {"start": "", "end": ""}
|
||||
repo_a, repo_b = twin_projects(
|
||||
tmp_path, context_file="AGENTS.md", context_markers=markers
|
||||
)
|
||||
add_plan(repo_a)
|
||||
add_plan(repo_b)
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"<!-- SPECKIT START -->" in content
|
||||
assert b"<!-- SPECKIT END -->" in content
|
||||
assert b"at specs/001-demo/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None:
|
||||
files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"]
|
||||
@@ -344,14 +318,19 @@ def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_mtime_fallback_finds_nested_plan_matching_bash(tmp_path: Path) -> None:
|
||||
# Regression: the mtime fallback must discover plan.md in nested scoped
|
||||
# layouts (specs/<scope>/<feature>/plan.md), matching the Bash/PowerShell
|
||||
# ports and the documented recursive-discovery contract (see #3024). A
|
||||
# one-level scan (specs/*/plan.md) would miss this and omit the plan link.
|
||||
def test_python_mtime_fallback_finds_nested_plan_matching_bash(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""The mtime fallback must recurse into scoped layouts.
|
||||
|
||||
A plan created under specs/<scope>/<feature>/plan.md (as produced via
|
||||
SPECIFY_FEATURE_DIRECTORY) is more than one level below specs/. The old
|
||||
Python port used a one-level specs/*/plan.md glob and missed it, while the
|
||||
bash/PowerShell twins recurse (#3024). This locks in the parity.
|
||||
"""
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
for repo in (repo_a, repo_b):
|
||||
plan = repo / "specs" / "scope-a" / "002-nested" / "plan.md"
|
||||
plan = repo / "specs" / "backend" / "001-nested" / "plan.md"
|
||||
plan.parent.mkdir(parents=True, exist_ok=True)
|
||||
plan.write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
@@ -361,7 +340,39 @@ def test_python_mtime_fallback_finds_nested_plan_matching_bash(tmp_path: Path) -
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/scope-a/002-nested/plan.md" in content
|
||||
assert b"at specs/backend/001-nested/plan.md" in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
def test_python_mtime_fallback_skips_plan_reached_through_escaping_symlink(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""A plan reached via a specs/ symlink out of the project is not selected.
|
||||
|
||||
``relative_to()`` is lexical, so ``specs/linked/001-x/plan.md`` looks
|
||||
in-project even when ``specs/linked`` points outside the tree. Resolving
|
||||
before the containment check rejects it, so the fallback finds nothing and
|
||||
the ``at <plan>`` line is omitted rather than naming an out-of-project file
|
||||
with an in-project-looking path. Mirrors the bash twin's ``_resolved_rel``.
|
||||
"""
|
||||
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
|
||||
for repo in (repo_a, repo_b):
|
||||
outside = repo.parent / f"outside-{repo.name}" / "001-x"
|
||||
outside.mkdir(parents=True, exist_ok=True)
|
||||
(outside / "plan.md").write_text("# plan\n", encoding="utf-8")
|
||||
specs = repo / "specs"
|
||||
specs.mkdir(parents=True, exist_ok=True)
|
||||
(specs / "linked").symlink_to(outside.parent, target_is_directory=True)
|
||||
# Sanity: the plan really is reachable through the symlink.
|
||||
assert (specs / "linked" / "001-x" / "plan.md").is_file()
|
||||
|
||||
bash = run_bash(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert_parity(bash, py, repo_a, repo_b)
|
||||
content = (repo_b / "AGENTS.md").read_bytes()
|
||||
assert content == (repo_a / "AGENTS.md").read_bytes()
|
||||
assert b"\nat " not in content
|
||||
|
||||
|
||||
@requires_posix_bash
|
||||
@@ -508,6 +519,31 @@ def test_python_fresh_context_file_matches_powershell(tmp_path: Path) -> None:
|
||||
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_mtime_fallback_finds_nested_plan_matches_powershell(
|
||||
tmp_path: Path,
|
||||
) -> None:
|
||||
"""Python's mtime fallback must recurse like the PowerShell twin.
|
||||
|
||||
With no feature.json, discovery falls back to scanning under specs/. A plan
|
||||
at specs/<scope>/<feature>/plan.md sits more than one level deep; the old
|
||||
Python one-level glob missed it while PowerShell already recurses (#3024).
|
||||
"""
|
||||
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
|
||||
repo_b = make_project(tmp_path / "proj-py", context_file="AGENTS.md")
|
||||
for repo in (repo_a, repo_b):
|
||||
plan = repo / "specs" / "backend" / "001-nested" / "plan.md"
|
||||
plan.parent.mkdir(parents=True, exist_ok=True)
|
||||
plan.write_text("# plan\n", encoding="utf-8")
|
||||
|
||||
ps = run_powershell(repo_a)
|
||||
py = run_python(repo_b)
|
||||
|
||||
assert ps.returncode == py.returncode == 0, ps.stderr + py.stderr
|
||||
assert (repo_a / "AGENTS.md").read_bytes() == (repo_b / "AGENTS.md").read_bytes()
|
||||
assert b"at specs/backend/001-nested/plan.md" in (repo_b / "AGENTS.md").read_bytes()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not POWERSHELL, reason="no PowerShell available")
|
||||
def test_python_upsert_matches_powershell(tmp_path: Path) -> None:
|
||||
repo_a = make_project(tmp_path / "proj-ps", context_file="AGENTS.md")
|
||||
|
||||
@@ -186,6 +186,50 @@ def test_local_zip_uses_bounded_archive_open(tmp_path: Path):
|
||||
_local_manifest_source(str(artifact))
|
||||
|
||||
|
||||
def test_local_zip_wraps_malformed_manifest_yaml(tmp_path: Path):
|
||||
"""A malformed bundle.yml inside a .zip must raise BundlerError.
|
||||
|
||||
The zip branch parses YAML inline rather than through load_yaml(), so the
|
||||
raw yaml.YAMLError used to escape. It is neither a ValueError nor an
|
||||
OSError, so nothing upstream caught it.
|
||||
"""
|
||||
artifact = tmp_path / "bad-manifest.zip"
|
||||
with zipfile.ZipFile(artifact, "w") as archive:
|
||||
archive.writestr("bundle.yml", "bundle: [unclosed\n id: demo\n")
|
||||
|
||||
with pytest.raises(BundlerError, match="Invalid YAML"):
|
||||
_local_manifest_source(str(artifact))
|
||||
|
||||
|
||||
def test_malformed_manifest_yaml_fails_alike_for_every_local_source(tmp_path: Path):
|
||||
"""`bundle install` reports malformed YAML the same way for all 3 sources.
|
||||
|
||||
Directory and bundle.yml sources already exited 1 with an "Invalid YAML"
|
||||
message; the .zip source dumped a yaml.parser.ParserError traceback.
|
||||
"""
|
||||
bad_yaml = "bundle: [unclosed\n id: demo\n"
|
||||
|
||||
directory = tmp_path / "dir-src"
|
||||
directory.mkdir()
|
||||
(directory / "bundle.yml").write_text(bad_yaml, encoding="utf-8")
|
||||
|
||||
manifest_file = tmp_path / "standalone.yml"
|
||||
manifest_file.write_text(bad_yaml, encoding="utf-8")
|
||||
|
||||
artifact = tmp_path / "artifact.zip"
|
||||
with zipfile.ZipFile(artifact, "w") as archive:
|
||||
archive.writestr("bundle.yml", bad_yaml)
|
||||
|
||||
runner = CliRunner()
|
||||
for source in (directory, manifest_file, artifact):
|
||||
result = runner.invoke(app, ["bundle", "install", str(source)])
|
||||
assert result.exit_code == 1, f"{source.name}: {result.output}"
|
||||
assert result.exception is None or isinstance(
|
||||
result.exception, SystemExit
|
||||
), f"{source.name} leaked {type(result.exception).__name__}"
|
||||
assert "Invalid YAML" in result.output, f"{source.name}: {result.output}"
|
||||
|
||||
|
||||
def test_invalid_local_manifest_is_rejected_before_project_init(
|
||||
tmp_path: Path,
|
||||
monkeypatch,
|
||||
|
||||
@@ -1067,6 +1067,99 @@ class TestInitIntegrationFlag:
|
||||
assert "not updated" in result.output
|
||||
|
||||
|
||||
def test_init_here_force_reapplies_installed_presets(self, tmp_path, monkeypatch):
|
||||
"""Regression for #3990: init --here --force must call _register_presets_for_agent
|
||||
after setup() so preset-composed files are not silently reverted to core."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "force-preset-reapply"
|
||||
project.mkdir()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
|
||||
# First init to create a valid project structure.
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here", "--force",
|
||||
"--integration", "claude",
|
||||
"--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
# Second init --here --force: verify _register_presets_for_agent is called.
|
||||
# Patch at the source module since init.py does a lazy import of these functions.
|
||||
mock_presets = MagicMock()
|
||||
mock_extensions = MagicMock()
|
||||
with (
|
||||
patch(
|
||||
"specify_cli.integrations._helpers._register_presets_for_agent",
|
||||
mock_presets,
|
||||
),
|
||||
patch(
|
||||
"specify_cli.integrations._helpers._register_extensions_for_agent",
|
||||
mock_extensions,
|
||||
),
|
||||
):
|
||||
result2 = runner.invoke(app, [
|
||||
"init", "--here", "--force",
|
||||
"--integration", "claude",
|
||||
"--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result2.exit_code == 0, result2.output
|
||||
assert mock_presets.called, (
|
||||
"_register_presets_for_agent was not called during init --here --force"
|
||||
)
|
||||
assert mock_extensions.called, (
|
||||
"_register_extensions_for_agent was not called during init --here --force"
|
||||
)
|
||||
|
||||
def test_init_here_without_force_does_not_reapply_presets(self, tmp_path):
|
||||
"""Without --force (fresh project), _register_presets_for_agent should NOT be called."""
|
||||
from unittest.mock import MagicMock, patch
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "no-force-preset"
|
||||
project.mkdir()
|
||||
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
mock_presets = MagicMock()
|
||||
with patch(
|
||||
"specify_cli.integrations._helpers._register_presets_for_agent",
|
||||
mock_presets,
|
||||
):
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here",
|
||||
"--integration", "claude",
|
||||
"--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
], catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
# On a fresh project without --force the reapply guard should not fire.
|
||||
assert not mock_presets.called, (
|
||||
"_register_presets_for_agent should not be called on a fresh init without --force"
|
||||
)
|
||||
|
||||
|
||||
class TestForceExistingDirectory:
|
||||
"""Tests for --force merging into an existing named directory."""
|
||||
|
||||
|
||||
@@ -1263,6 +1263,53 @@ class TestCommandRunner:
|
||||
)
|
||||
|
||||
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
|
||||
|
||||
assert argv is None
|
||||
|
||||
def test_unreadable_template_returns_none(self, tmp_path):
|
||||
"""A command template that cannot be read must resolve to no argv.
|
||||
|
||||
Every other failure inside ``_resolve_event_command_argv`` — missing
|
||||
frontmatter, malformed YAML, absent scripts — degrades to ``None`` so
|
||||
the dispatcher treats the command as declaring no runnable script.
|
||||
The initial ``read_text`` was the one step outside that boundary: a
|
||||
non-UTF-8 template raised a raw ``UnicodeDecodeError`` through
|
||||
``resolve_and_run_event_command`` and out of ``specify event run``.
|
||||
"""
|
||||
from specify_cli.events import _resolve_event_command_argv
|
||||
|
||||
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
(cmd_dir / "boot.md").write_bytes(
|
||||
b"---\ndescription: \"B\xff\xfeoot\"\n---\nBody\n"
|
||||
)
|
||||
|
||||
argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
|
||||
assert argv is None
|
||||
|
||||
def test_permission_denied_template_returns_none(self, tmp_path, monkeypatch):
|
||||
"""The same boundary must cover ``OSError`` (e.g. permission denied).
|
||||
|
||||
Mocked rather than chmod-based so the case also holds under
|
||||
privileged CI where permission bits are not enforced.
|
||||
"""
|
||||
from specify_cli.events import _resolve_event_command_argv
|
||||
|
||||
cmd_dir = tmp_path / ".specify" / "templates" / "commands"
|
||||
cmd_dir.mkdir(parents=True)
|
||||
template = cmd_dir / "boot.md"
|
||||
template.write_text("---\ndescription: Boot\n---\nBody\n")
|
||||
|
||||
original_read_text = Path.read_text
|
||||
|
||||
def failing_read_text(self_path, *args, **kwargs):
|
||||
if self_path == template:
|
||||
raise PermissionError(13, "Permission denied")
|
||||
return original_read_text(self_path, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "read_text", failing_read_text)
|
||||
|
||||
argv = _resolve_event_command_argv(template, tmp_path, None)
|
||||
assert argv is None
|
||||
|
||||
def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path):
|
||||
|
||||
@@ -471,6 +471,101 @@ class TestAlquimiaArgumentHints:
|
||||
hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
|
||||
assert hint_count == 1
|
||||
|
||||
def test_inject_argument_hint_survives_folded_description(self):
|
||||
"""A long description folded across lines must not corrupt the YAML (#4044).
|
||||
|
||||
A description long enough for the YAML dumper to fold it into a
|
||||
multi-line plain scalar previously had ``argument-hint:`` spliced
|
||||
into the *middle* of that scalar, producing invalid YAML.
|
||||
"""
|
||||
from specify_cli.integrations.alquimia import AlquimiaAIIntegration
|
||||
|
||||
frontmatter = {
|
||||
"name": "speckit-specify",
|
||||
"description": (
|
||||
"Create or update the feature specification from a natural "
|
||||
"language feature description. Also accepts an issue URL "
|
||||
"resolved via gh CLI (demo customization)."
|
||||
),
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(
|
||||
frontmatter, sort_keys=False, allow_unicode=True
|
||||
).strip()
|
||||
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
|
||||
assert "\n " in content, "fixture description must actually fold across lines"
|
||||
|
||||
result = AlquimiaAIIntegration.inject_argument_hint(
|
||||
content, "Describe the feature"
|
||||
)
|
||||
|
||||
parsed = yaml.safe_load(result.split("---")[1])
|
||||
assert parsed["argument-hint"] == "Describe the feature"
|
||||
assert parsed["description"] == frontmatter["description"]
|
||||
|
||||
def test_inject_argument_hint_survives_quoted_folded_description(self):
|
||||
"""A folded description forced into quotes must not absorb the hint (#4044)."""
|
||||
from specify_cli.integrations.alquimia import AlquimiaAIIntegration
|
||||
|
||||
frontmatter = {
|
||||
"name": "speckit-specify",
|
||||
"description": (
|
||||
"Create or update the feature specification from a natural "
|
||||
"language feature description. Also accepts a GitHub "
|
||||
"issue/PR URL or #N reference resolved via gh CLI (demo)."
|
||||
),
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(
|
||||
frontmatter, sort_keys=False, allow_unicode=True
|
||||
).strip()
|
||||
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
|
||||
assert "\n " in content, "fixture description must actually fold across lines"
|
||||
|
||||
result = AlquimiaAIIntegration.inject_argument_hint(
|
||||
content, "Describe the feature"
|
||||
)
|
||||
|
||||
parsed = yaml.safe_load(result.split("---")[1])
|
||||
assert parsed["argument-hint"] == "Describe the feature"
|
||||
assert parsed["description"] == frontmatter["description"]
|
||||
|
||||
def test_inject_argument_hint_survives_multi_paragraph_description(self):
|
||||
"""A description with an embedded blank line must not absorb the hint.
|
||||
|
||||
PyYAML serializes an embedded ``\\n\\n`` inside a quoted scalar as
|
||||
unindented blank lines, not indented ones, so a fix that only skips
|
||||
indented continuation lines still fails on this case.
|
||||
"""
|
||||
from specify_cli.integrations.alquimia import AlquimiaAIIntegration
|
||||
|
||||
frontmatter = {
|
||||
"name": "speckit-specify",
|
||||
"description": (
|
||||
"First paragraph of a fairly long description that will "
|
||||
"need to wrap across multiple lines when dumped by PyYAML."
|
||||
"\n\n"
|
||||
"Second paragraph continues the description after a blank "
|
||||
"line separator to force embedded newlines in the scalar."
|
||||
),
|
||||
"compatibility": "Requires spec-kit project structure with .specify/ directory",
|
||||
}
|
||||
frontmatter_text = yaml.safe_dump(
|
||||
frontmatter, sort_keys=False, allow_unicode=True
|
||||
).strip()
|
||||
content = f"---\n{frontmatter_text}\n---\n\nBody text\n"
|
||||
assert "\n\n" in frontmatter_text, (
|
||||
"fixture must produce a blank continuation line"
|
||||
)
|
||||
|
||||
result = AlquimiaAIIntegration.inject_argument_hint(
|
||||
content, "Describe the feature"
|
||||
)
|
||||
|
||||
parsed = yaml.safe_load(result.split("---")[1])
|
||||
assert parsed["argument-hint"] == "Describe the feature"
|
||||
assert parsed["description"] == frontmatter["description"]
|
||||
|
||||
|
||||
class TestAlquimiaDisableModelInvocation:
|
||||
"""Verify disable-model-invocation is false for Alquimia skills."""
|
||||
|
||||
@@ -238,14 +238,15 @@ class MarkdownIntegrationTests:
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
files.append(".specify/.gitignore")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
|
||||
"setup-plan.sh", "setup-tasks.sh"]:
|
||||
"resolve-template.sh", "setup-plan.sh", "setup-tasks.sh"]:
|
||||
files.append(f".specify/scripts/bash/{name}")
|
||||
else:
|
||||
for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1",
|
||||
"setup-plan.ps1", "setup-tasks.ps1"]:
|
||||
"resolve-template.ps1", "setup-plan.ps1", "setup-tasks.ps1"]:
|
||||
files.append(f".specify/scripts/powershell/{name}")
|
||||
|
||||
for name in ["checklist-template.md",
|
||||
|
||||
@@ -141,6 +141,91 @@ class SkillsIntegrationTests:
|
||||
assert isinstance(fm["description"], str)
|
||||
assert len(fm["description"]) > 0, f"{f} has empty description"
|
||||
|
||||
def test_skill_frontmatter_preserves_multiline_description(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""A multiline (block-scalar) description must round-trip exactly.
|
||||
|
||||
The hand-built SKILL.md frontmatter used to only escape backslash and
|
||||
quote, so a block-scalar description was emitted with raw newlines inside
|
||||
a double-quoted scalar and reparsed with those newlines collapsed to
|
||||
spaces. The description must survive byte-for-byte."""
|
||||
from pathlib import Path
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
# Hermes writes to ~/.hermes/skills/ — isolate Path.home() to prevent
|
||||
# overwriting a developer's real global skill directory.
|
||||
if self.KEY == "hermes":
|
||||
home = tmp_path / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
"description: |\n"
|
||||
" first line\n"
|
||||
" second line\n"
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/x.sh\n"
|
||||
"---\n"
|
||||
"Body\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) == 1
|
||||
|
||||
content = skill_files[0].read_text(encoding="utf-8")
|
||||
fm = yaml.safe_load(content.split("---", 2)[1])
|
||||
assert "\n" in fm["description"]
|
||||
assert fm["description"] == "first line\nsecond line\n"
|
||||
|
||||
def test_skill_frontmatter_preserves_control_characters(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""A description carrying a C0/DEL control char must round-trip exactly.
|
||||
|
||||
A control character can reach ``description`` via a YAML escape in the
|
||||
source template (``"a\\x08b"`` parses to a real U+0008). The old
|
||||
hand-built frontmatter only escaped backslash and quote, so the raw
|
||||
control char landed inside the emitted double-quoted scalar and made the
|
||||
SKILL.md unparseable / lossy. ``yaml_quote`` must escape it so the
|
||||
value survives byte-for-byte."""
|
||||
from pathlib import Path
|
||||
|
||||
i = get_integration(self.KEY)
|
||||
# Hermes writes to ~/.hermes/skills/ — isolate Path.home() to prevent
|
||||
# overwriting a developer's real global skill directory.
|
||||
if self.KEY == "hermes":
|
||||
home = tmp_path / "home"
|
||||
home.mkdir(exist_ok=True)
|
||||
monkeypatch.setattr(Path, "home", lambda: home)
|
||||
|
||||
template = tmp_path / "sample.md"
|
||||
template.write_text(
|
||||
"---\n"
|
||||
'description: "a\\x08b\\ttab"\n'
|
||||
"scripts:\n"
|
||||
" sh: scripts/bash/x.sh\n"
|
||||
"---\n"
|
||||
"Body\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
monkeypatch.setattr(i, "list_command_templates", lambda: [template])
|
||||
|
||||
m = IntegrationManifest(self.KEY, tmp_path)
|
||||
created = i.setup(tmp_path, m)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) == 1
|
||||
|
||||
content = skill_files[0].read_text(encoding="utf-8")
|
||||
fm = yaml.safe_load(content.split("---", 2)[1])
|
||||
assert fm["description"] == "a\x08b\ttab"
|
||||
|
||||
def test_templates_are_processed(self, tmp_path):
|
||||
"""Skill body must have placeholders replaced, not raw templates."""
|
||||
i = get_integration(self.KEY)
|
||||
@@ -399,6 +484,7 @@ class SkillsIntegrationTests:
|
||||
".specify/integration.json",
|
||||
f".specify/integrations/{self.KEY}.manifest.json",
|
||||
".specify/integrations/speckit.manifest.json",
|
||||
".specify/.gitignore",
|
||||
".specify/memory/.constitution-template.json",
|
||||
".specify/memory/constitution.md",
|
||||
]
|
||||
@@ -408,6 +494,7 @@ class SkillsIntegrationTests:
|
||||
".specify/scripts/bash/check-prerequisites.sh",
|
||||
".specify/scripts/bash/common.sh",
|
||||
".specify/scripts/bash/create-new-feature.sh",
|
||||
".specify/scripts/bash/resolve-template.sh",
|
||||
".specify/scripts/bash/setup-plan.sh",
|
||||
".specify/scripts/bash/setup-tasks.sh",
|
||||
]
|
||||
@@ -416,6 +503,7 @@ class SkillsIntegrationTests:
|
||||
".specify/scripts/powershell/check-prerequisites.ps1",
|
||||
".specify/scripts/powershell/common.ps1",
|
||||
".specify/scripts/powershell/create-new-feature.ps1",
|
||||
".specify/scripts/powershell/resolve-template.ps1",
|
||||
".specify/scripts/powershell/setup-plan.ps1",
|
||||
".specify/scripts/powershell/setup-tasks.ps1",
|
||||
]
|
||||
|
||||
@@ -488,12 +488,14 @@ class TomlIntegrationTests:
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
files.append(".specify/.gitignore")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in [
|
||||
"check-prerequisites.sh",
|
||||
"common.sh",
|
||||
"create-new-feature.sh",
|
||||
"resolve-template.sh",
|
||||
"setup-plan.sh",
|
||||
"setup-tasks.sh",
|
||||
]:
|
||||
@@ -503,6 +505,7 @@ class TomlIntegrationTests:
|
||||
"check-prerequisites.ps1",
|
||||
"common.ps1",
|
||||
"create-new-feature.ps1",
|
||||
"resolve-template.ps1",
|
||||
"setup-plan.ps1",
|
||||
"setup-tasks.ps1",
|
||||
]:
|
||||
|
||||
@@ -402,12 +402,14 @@ class YamlIntegrationTests:
|
||||
files.append(".specify/init-options.json")
|
||||
files.append(f".specify/integrations/{self.KEY}.manifest.json")
|
||||
files.append(".specify/integrations/speckit.manifest.json")
|
||||
files.append(".specify/.gitignore")
|
||||
|
||||
if script_variant == "sh":
|
||||
for name in [
|
||||
"check-prerequisites.sh",
|
||||
"common.sh",
|
||||
"create-new-feature.sh",
|
||||
"resolve-template.sh",
|
||||
"setup-plan.sh",
|
||||
"setup-tasks.sh",
|
||||
]:
|
||||
@@ -417,6 +419,7 @@ class YamlIntegrationTests:
|
||||
"check-prerequisites.ps1",
|
||||
"common.ps1",
|
||||
"create-new-feature.ps1",
|
||||
"resolve-template.ps1",
|
||||
"setup-plan.ps1",
|
||||
"setup-tasks.ps1",
|
||||
]:
|
||||
|
||||
@@ -223,33 +223,6 @@ class TestActiveCatalogs:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _OversizedResponse:
|
||||
"""Response stub that supports bounded streaming reads for oversized-catalog tests."""
|
||||
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
self._pos = 0
|
||||
|
||||
def read(self, n=-1):
|
||||
if n < 0:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos : self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class TestCatalogFetch:
|
||||
"""Tests that use a local HTTP server stub via monkeypatch."""
|
||||
|
||||
@@ -260,15 +233,15 @@ class TestCatalogFetch:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
self._pos = 0
|
||||
self._offset = 0
|
||||
|
||||
def read(self, n=-1):
|
||||
if n < 0:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
def read(self, size=-1):
|
||||
if size == -1:
|
||||
chunk = self._data[self._offset:]
|
||||
self._offset = len(self._data)
|
||||
else:
|
||||
chunk = self._data[self._offset:self._offset + size]
|
||||
self._offset += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
@@ -357,6 +330,187 @@ class TestCatalogFetch:
|
||||
results = cat.search()
|
||||
assert "acme-coder" in [r["id"] for r in results]
|
||||
|
||||
def test_fetch_rejects_oversized_catalog_response(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""Regression: _fetch_single_catalog must use read_response_limited
|
||||
with MAX_JSON_METADATA_BYTES, not unbounded resp.read()."""
|
||||
from specify_cli.integrations.catalog import (
|
||||
IntegrationCatalog,
|
||||
IntegrationCatalogError,
|
||||
)
|
||||
import specify_cli.integrations.catalog as catalog_module
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
# Set limit very small so any response is oversized
|
||||
monkeypatch.setattr(catalog_module, "MAX_JSON_METADATA_BYTES", 32)
|
||||
|
||||
class _OversizedResponse:
|
||||
def __init__(self):
|
||||
self._data = b"x" * 64
|
||||
self._offset = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
if size == -1:
|
||||
chunk = self._data[self._offset:]
|
||||
self._offset = len(self._data)
|
||||
else:
|
||||
chunk = self._data[self._offset:self._offset + size]
|
||||
self._offset += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return "https://example.com/catalog.json"
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
def fake_urlopen(req, timeout=10):
|
||||
return _OversizedResponse()
|
||||
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
from specify_cli.integrations.catalog import IntegrationCatalogEntry
|
||||
|
||||
entry = IntegrationCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrationCatalogError, match="exceeds maximum size"):
|
||||
cat._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
def _patch_urlopen_bytes(self, monkeypatch, bodies):
|
||||
"""Patch urlopen to serve raw *bodies* keyed by URL substring.
|
||||
|
||||
Mirrors ``_patch_urlopen`` but passes the bytes through verbatim: these
|
||||
tests need a body that is not valid UTF-8, which ``json.dumps`` cannot
|
||||
produce.
|
||||
"""
|
||||
|
||||
class _RawResponse:
|
||||
def __init__(self, data, url):
|
||||
self._data = data
|
||||
self._url = url
|
||||
self._offset = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
if size == -1:
|
||||
chunk = self._data[self._offset:]
|
||||
self._offset = len(self._data)
|
||||
else:
|
||||
chunk = self._data[self._offset:self._offset + size]
|
||||
self._offset += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
def fake_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
for marker, body in bodies.items():
|
||||
if marker in url:
|
||||
return _RawResponse(body, url)
|
||||
raise AssertionError(f"unexpected URL requested: {url}")
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", fake_urlopen)
|
||||
|
||||
def test_fetch_wraps_non_utf8_catalog_response(self, tmp_path, monkeypatch):
|
||||
"""Regression: a non-UTF-8 response body must raise IntegrationCatalogError.
|
||||
|
||||
``.decode("utf-8")`` runs before ``json.loads``, so the resulting
|
||||
UnicodeDecodeError is not a JSONDecodeError and slipped past both
|
||||
handlers as a raw traceback.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir(exist_ok=True)
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
self._patch_urlopen_bytes(
|
||||
monkeypatch,
|
||||
{"catalog.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}'},
|
||||
)
|
||||
|
||||
entry = IntegrationCatalogEntry(
|
||||
url="https://example.com/catalog.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
|
||||
with pytest.raises(IntegrationCatalogError, match="not valid UTF-8"):
|
||||
cat._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
def test_search_skips_non_utf8_catalog(self, tmp_path, monkeypatch, capsys):
|
||||
"""A single non-UTF-8 catalog must not take down the whole search.
|
||||
|
||||
``_get_merged_integrations`` is built to warn and continue on a bad
|
||||
catalog; an unwrapped UnicodeDecodeError defeated that entirely.
|
||||
"""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir(exist_ok=True)
|
||||
(specify / "integration-catalogs.yml").write_text(
|
||||
"catalogs:\n"
|
||||
" - name: broken\n"
|
||||
" url: https://example.com/broken.json\n"
|
||||
" priority: 1\n"
|
||||
" - name: healthy\n"
|
||||
" url: https://example.com/healthy.json\n"
|
||||
" priority: 2\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
healthy = json.dumps(
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"integrations": {
|
||||
"acme-coder": {
|
||||
"name": "Acme Coder",
|
||||
"version": "1.0.0",
|
||||
"description": "Acme integration",
|
||||
}
|
||||
},
|
||||
}
|
||||
).encode("utf-8")
|
||||
|
||||
self._patch_urlopen_bytes(
|
||||
monkeypatch,
|
||||
{
|
||||
"broken.json": b'{"schema_version": "1.0", "name": "\xff\xfe"}',
|
||||
"healthy.json": healthy,
|
||||
},
|
||||
)
|
||||
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
results = cat.search()
|
||||
|
||||
assert "acme-coder" in [r["id"] for r in results]
|
||||
assert "broken" in capsys.readouterr().err
|
||||
|
||||
def test_search_by_tag(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
@@ -432,90 +586,6 @@ class TestCatalogFetch:
|
||||
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
|
||||
cat.search()
|
||||
|
||||
def test_oversized_catalog_response_rejected(self, tmp_path, monkeypatch):
|
||||
"""Response exceeding MAX_JSON_METADATA_BYTES is caught as IntegrationCatalogError.
|
||||
|
||||
The per-entry error is logged as a warning and skipped (not fatal).
|
||||
When ALL catalogs are oversized, search() raises the aggregate error.
|
||||
"""
|
||||
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
# Build a valid catalog dict whose JSON encoding exceeds the limit.
|
||||
oversized = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {},
|
||||
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
|
||||
}
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
def _oversized_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
return _OversizedResponse(oversized, url)
|
||||
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _oversized_urlopen)
|
||||
|
||||
# Both default + community catalogs are oversized → all fail → aggregate error.
|
||||
# The per-entry IntegrationCatalogError (with "exceeds maximum size") is
|
||||
# logged as a warning; the aggregate raise has a different message.
|
||||
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
|
||||
cat.search()
|
||||
|
||||
def test_oversized_catalog_does_not_block_healthy_one(self, tmp_path, monkeypatch):
|
||||
"""When one catalog is oversized, the healthy catalog still returns results."""
|
||||
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir()
|
||||
|
||||
healthy_catalog = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {
|
||||
"good-agent": {
|
||||
"id": "good-agent",
|
||||
"name": "Good Agent",
|
||||
"version": "1.0.0",
|
||||
"description": "A healthy integration",
|
||||
"author": "test-org",
|
||||
},
|
||||
},
|
||||
}
|
||||
oversized_catalog = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {},
|
||||
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
|
||||
}
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text(yaml.dump({"catalogs": [
|
||||
{"url": "https://healthy.example.com/catalog.json", "name": "healthy", "priority": 1, "install_allowed": True},
|
||||
{"url": "https://oversized.example.com/catalog.json", "name": "oversized", "priority": 2, "install_allowed": True},
|
||||
]}))
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
def _multi_catalog_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
if "oversized" in url:
|
||||
return _OversizedResponse(oversized_catalog, url)
|
||||
return _OversizedResponse(healthy_catalog, url)
|
||||
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _multi_catalog_urlopen)
|
||||
|
||||
# The oversized catalog is skipped; the healthy catalog's integrations are returned.
|
||||
results = cat.search()
|
||||
ids = [r["id"] for r in results]
|
||||
assert "good-agent" in ids
|
||||
|
||||
def test_clear_cache(self, tmp_path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
@@ -713,19 +783,23 @@ class TestIntegrationListCatalog:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
self._pos = 0
|
||||
def read(self, n=-1):
|
||||
if n < 0:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
self._offset = 0
|
||||
|
||||
def read(self, size=-1):
|
||||
if size == -1:
|
||||
chunk = self._data[self._offset:]
|
||||
self._offset = len(self._data)
|
||||
else:
|
||||
chunk = self._data[self._offset:self._offset + size]
|
||||
self._offset += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user