mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a30db484b |
@@ -65,8 +65,7 @@
|
||||
},
|
||||
"chat.tools.terminal.autoApprove": {
|
||||
".specify/scripts/bash/": true,
|
||||
".specify/scripts/powershell/": true,
|
||||
".specify/scripts/python/": true
|
||||
".specify/scripts/powershell/": true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -97,17 +97,6 @@ echo -e "\n🤖 Installing CodeBuddy CLI..."
|
||||
run_command "npm install -g @tencent-ai/codebuddy-code@latest"
|
||||
echo "✅ Done"
|
||||
|
||||
echo -e "\n🤖 Installing Factory Droid CLI..."
|
||||
run_command "npm install -g droid@latest"
|
||||
|
||||
if ! command -v droid >/dev/null 2>&1; then
|
||||
echo -e "\033[0;31m[ERROR] Droid CLI installation did not create 'droid' in PATH.\033[0m" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
run_command "droid --version > /dev/null"
|
||||
echo "✅ Done"
|
||||
|
||||
# Installing UV (Python package manager)
|
||||
echo -e "\n🐍 Installing UV - Python Package Manager..."
|
||||
run_command "pipx install uv"
|
||||
|
||||
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
2
.github/ISSUE_TEMPLATE/agent_request.yml
vendored
@@ -8,7 +8,7 @@ body:
|
||||
value: |
|
||||
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
|
||||
|
||||
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -71,7 +71,6 @@ body:
|
||||
- Codex CLI
|
||||
- Cursor
|
||||
- Devin for Terminal
|
||||
- Factory Droid
|
||||
- Firebender
|
||||
- Forge
|
||||
- Gemini CLI
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
1
.github/ISSUE_TEMPLATE/feature_request.yml
vendored
@@ -65,7 +65,6 @@ body:
|
||||
- Codex CLI
|
||||
- Cursor
|
||||
- Devin for Terminal
|
||||
- Factory Droid
|
||||
- Firebender
|
||||
- Forge
|
||||
- Gemini CLI
|
||||
|
||||
20
.github/aw/actions-lock.json
vendored
20
.github/aw/actions-lock.json
vendored
@@ -1,30 +1,10 @@
|
||||
{
|
||||
"entries": {
|
||||
"actions/checkout@v6.0.3": {
|
||||
"repo": "actions/checkout",
|
||||
"version": "v6.0.3",
|
||||
"sha": "df4cb1c069e1874edd31b4311f1884172cec0e10"
|
||||
},
|
||||
"actions/download-artifact@v8.0.1": {
|
||||
"repo": "actions/download-artifact",
|
||||
"version": "v8.0.1",
|
||||
"sha": "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
|
||||
},
|
||||
"actions/github-script@v9.0.0": {
|
||||
"repo": "actions/github-script",
|
||||
"version": "v9.0.0",
|
||||
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
|
||||
},
|
||||
"actions/setup-node@v6.4.0": {
|
||||
"repo": "actions/setup-node",
|
||||
"version": "v6.4.0",
|
||||
"sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"
|
||||
},
|
||||
"actions/upload-artifact@v7.0.1": {
|
||||
"repo": "actions/upload-artifact",
|
||||
"version": "v7.0.1",
|
||||
"sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
|
||||
},
|
||||
"github/gh-aw-actions/setup@v0.79.8": {
|
||||
"repo": "github/gh-aw-actions/setup",
|
||||
"version": "v0.79.8",
|
||||
|
||||
115
.github/scripts/check_security_requirements.py
vendored
115
.github/scripts/check_security_requirements.py
vendored
@@ -1,115 +0,0 @@
|
||||
"""Check that committed security audit requirements are up to date."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import subprocess
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parents[2]
|
||||
COMMITTED_REQUIREMENTS = REPO_ROOT / ".github" / "security-audit-requirements.txt"
|
||||
DEPENDENCY_INPUTS = ("pyproject.toml", ".github/security-audit-requirements.txt")
|
||||
|
||||
|
||||
def _dependency_diff_refs() -> tuple[str, str]:
|
||||
base_ref = os.environ.get("DEPENDENCY_DIFF_BASE", "").strip()
|
||||
head_ref = os.environ.get("DEPENDENCY_DIFF_HEAD", "").strip() or "HEAD"
|
||||
if base_ref and not set(base_ref) <= {"0"}:
|
||||
return base_ref, head_ref
|
||||
# Fallback when no usable base is supplied (push with an all-zero
|
||||
# ``github.event.before``, manual dispatch, etc.). ``HEAD^`` fails on a
|
||||
# shallow checkout or a single-commit repo; that ``git diff`` error is
|
||||
# caught by the caller and deliberately treated as "inputs changed" so the
|
||||
# audit runs anyway — failing safe (audit) rather than skipping silently.
|
||||
return "HEAD^", "HEAD"
|
||||
|
||||
|
||||
def _dependency_inputs_changed() -> bool:
|
||||
base_ref, head_ref = _dependency_diff_refs()
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
"git",
|
||||
"diff",
|
||||
"--name-only",
|
||||
base_ref,
|
||||
head_ref,
|
||||
"--",
|
||||
*DEPENDENCY_INPUTS,
|
||||
],
|
||||
check=True,
|
||||
cwd=REPO_ROOT,
|
||||
stderr=subprocess.PIPE,
|
||||
stdout=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
except subprocess.CalledProcessError as exc:
|
||||
print(
|
||||
"Could not determine changed dependency inputs; checking requirements.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
if exc.stderr:
|
||||
print(exc.stderr.strip(), file=sys.stderr)
|
||||
return True
|
||||
|
||||
changed_inputs = [line for line in result.stdout.splitlines() if line]
|
||||
if not changed_inputs:
|
||||
print("Dependency audit inputs unchanged; sync check skipped.")
|
||||
return False
|
||||
|
||||
print(f"Dependency audit inputs changed: {', '.join(changed_inputs)}")
|
||||
return True
|
||||
|
||||
|
||||
def main() -> int:
|
||||
if not _dependency_inputs_changed():
|
||||
return 0
|
||||
|
||||
generated_requirements_env = os.environ.get("GENERATED_REQUIREMENTS", "").strip()
|
||||
if not generated_requirements_env:
|
||||
print(
|
||||
"GENERATED_REQUIREMENTS must be set to the temporary output file path.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
generated_requirements = Path(generated_requirements_env)
|
||||
generated_requirements.parent.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
subprocess.run(
|
||||
[
|
||||
"uv",
|
||||
"pip",
|
||||
"compile",
|
||||
"pyproject.toml",
|
||||
"--extra",
|
||||
"test",
|
||||
"--universal",
|
||||
"--upgrade",
|
||||
"--generate-hashes",
|
||||
"--quiet",
|
||||
"--no-header",
|
||||
"--output-file",
|
||||
str(generated_requirements),
|
||||
],
|
||||
check=True,
|
||||
cwd=REPO_ROOT,
|
||||
)
|
||||
|
||||
committed = COMMITTED_REQUIREMENTS.read_text(encoding="utf-8")
|
||||
generated = generated_requirements.read_text(encoding="utf-8")
|
||||
if committed == generated:
|
||||
return 0
|
||||
|
||||
print(
|
||||
"Regenerate .github/security-audit-requirements.txt with the documented "
|
||||
"uv pip compile command.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
253
.github/security-audit-requirements.txt
vendored
253
.github/security-audit-requirements.txt
vendored
@@ -1,253 +0,0 @@
|
||||
annotated-doc==0.0.4 \
|
||||
--hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
|
||||
--hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4
|
||||
# via typer
|
||||
click==8.4.2 \
|
||||
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
|
||||
--hash=sha256:e6f9f66136c816745b9d65817da91d61d957fb16e02e4dcd0552553c5a197b76
|
||||
# via specify-cli (pyproject.toml)
|
||||
colorama==0.4.6 ; sys_platform == 'win32' \
|
||||
--hash=sha256:08695f5cb7ed6e0531a20572697297273c47b8cae5a63ffc6d6ed5c201be6e44 \
|
||||
--hash=sha256:4f1d9991f5acc0ca119f9d443620b77f9d6b33703e51011c16baf57afb285fc6
|
||||
# via
|
||||
# click
|
||||
# pytest
|
||||
# typer
|
||||
coverage==7.15.2 \
|
||||
--hash=sha256:075560438765b7a2ef43bf7aa7758661b53d889df47f062a31bda6c1ade553a2 \
|
||||
--hash=sha256:0901cfe6c13bcd2302da4f83e884555d2a22bda6e4c476f09ef204ba20ca536e \
|
||||
--hash=sha256:094dd37f3ef7b2da8b068b583d1f4c40f91c65197e16c52a71962d5d537fc5db \
|
||||
--hash=sha256:09f5c6ec5901f667bd97dd140b5b9a2586b10efec66f46fb1e6d8135f8b95bdf \
|
||||
--hash=sha256:0e55510bc98ae943cece9e667a6c0fe94c6a92913720dea34243657a17993d0c \
|
||||
--hash=sha256:1121caa19159a38b5463eaae4b1e1fde81e525b15ecc5e000cd5b1a108f743a8 \
|
||||
--hash=sha256:1268ac8fb9ddcd783d3948dbabaf80a5d53bfdaa0575e873e2139a692f797443 \
|
||||
--hash=sha256:1473b3ba8e7ee0f076117b1a72c23f579a2b9e2bb742f48a8d86ea27ca93f91a \
|
||||
--hash=sha256:17c432b5f73ad52ef46fb06019f6fa7c66ce381961cf0f7dfd1d3a4bd3a98145 \
|
||||
--hash=sha256:1adac78e5abc7c5438f7a209c9ca69d06542f0bf481d728b6989ea80b813fdf9 \
|
||||
--hash=sha256:1cd7a5beb7af3e864a13b1f0fb26efd3695da43ef0daf71e586adfffaf34d5b2 \
|
||||
--hash=sha256:1d16e3a7104ea84f03e614611b3edbf6fb6892554b3ab0fe7fbb3f2b2ef04376 \
|
||||
--hash=sha256:25fd15dd40a0a2c51a500d664ca29053c09c3259d998407bf982b6e114696138 \
|
||||
--hash=sha256:2617f8799d268fabdeef42a7e89ac3a23e1deee9025427db2df970f99a89a578 \
|
||||
--hash=sha256:26c3b04a6377fd7c09800921fa934e3a17c0020439cd59df73e73ae1d4b6a78c \
|
||||
--hash=sha256:29c052f7c83ccfcc5c577eaae025d2e4a9bb80daf03c0ac31c996e83b000ce88 \
|
||||
--hash=sha256:2f1ec6f304b156669cfde653b4e9a953f5de87e247ea02ac599bce0ab2744036 \
|
||||
--hash=sha256:2fbeeeecea279727f8ac16c8e1133ddfeee793e985c86ae343d6a5ce744eef8c \
|
||||
--hash=sha256:2ff08701be2d1556fc78b326c80a3e8042da09352ecb3819105f8e386c8a3071 \
|
||||
--hash=sha256:38c9518b7103826c403a461544e3c2e77151e8676d06eaed85911a97e962584a \
|
||||
--hash=sha256:3df60dc267f0a2ca23cb7a9ab1109c62b9335ffbf519fcfe167157c28c09b81d \
|
||||
--hash=sha256:3ed010aa1b69cda8e827aabfca9866216c980e2dca82ab9a78c5f83689964c8b \
|
||||
--hash=sha256:40f633c5c5fc783732f6312280122e859538fa24461235597c13d803ea9a108a \
|
||||
--hash=sha256:42ec3d989421b174a2ab607c1539f24127ad362757b7f1c0c0d7a2993f7eb37b \
|
||||
--hash=sha256:434e68d531858205895eb0d74b73d20b84260de426387d53c422a5acda2cf050 \
|
||||
--hash=sha256:44826758cfe73fcd0e6af5deb4ba6d5417cc1d13df3acb35c93484a11160f846 \
|
||||
--hash=sha256:4510fb9cdf6bb02dfa6af0be4a534b8102d086e22e4a33f8836df663da3d660d \
|
||||
--hash=sha256:48ccc6395958eda89093ecdc35644c86f23a8b23a7f4d44958812b721aad67c1 \
|
||||
--hash=sha256:4d3361879d736f469f45723c11ea1a5bbdaf1f6928f0e632c940378b5aa9b660 \
|
||||
--hash=sha256:582edc45c2040543fef83341be23c43024a3ab3ae0c2d8bc498a06282905ad40 \
|
||||
--hash=sha256:63022c4c8dec1d0342f05c3ede99842fe3d007689acc45e86f123a1746e4a026 \
|
||||
--hash=sha256:67d7602480a47bdf5b675635403625553ebaa70d5a62a657c035149fd401cea0 \
|
||||
--hash=sha256:68af907f595ab01a78f794932ff3bdf929c316d3000810d38dbc247129e26f8b \
|
||||
--hash=sha256:6aa28cfb6488e5453b5b762d65f73aa586380f6693a04d58078ce228a29b06c0 \
|
||||
--hash=sha256:6c0be82b4d4aa5b2704e08518e2252f3e3d110164bcca826816801052e48a7aa \
|
||||
--hash=sha256:6f6966fc30e6f06ca8f98fb0ce51eda6b111b3ee8d066a8b1ec9e77fa06ab55d \
|
||||
--hash=sha256:6fc448c377d6eeb00a47c673494bd9bae29280ca53987e1869e67ebedfe20658 \
|
||||
--hash=sha256:728a33676d4c3f0db977990a4bd421dcaa3be3e53b5b6273036fff6666008e89 \
|
||||
--hash=sha256:7466cc7ab6dc0db871d264bf99e8779f0917ee63d40730af0552f71535a6e072 \
|
||||
--hash=sha256:77f091ea3a9cc611cd29f433565476bc1936c084ac8eee00ea0e7e70c27e4199 \
|
||||
--hash=sha256:77f0ef5011df53a4bd1b35211ab122287f8d9b8d7aa1c4553e5c2deb24b1d446 \
|
||||
--hash=sha256:7c63387e21ab21f512c69c9756a8c7dadd322c7275edb064064433c9a09c3743 \
|
||||
--hash=sha256:7d29ca7bd67af6e12e74632d65f026eabc1364da5c254494cd914446a28a3ef7 \
|
||||
--hash=sha256:7dc2950a2992cd676d35c20ae63522836deeb034f08874699d14068710af3dc1 \
|
||||
--hash=sha256:7e8f27131dc7cd53de2c137dd207b3720919320b3c20d499dc30aa9ee6173287 \
|
||||
--hash=sha256:81f382c5a94b434ec1f6da607edb904c76d7212e618cd4d1bc9f97bed4120ef5 \
|
||||
--hash=sha256:835ec4e20b45f0a7f63ed78f94065aca00de033403df8377bfe8b9c6abc0a7be \
|
||||
--hash=sha256:8bb9f4b4279187560796a4cdaca3b0a93dd97e48ee667df005f4ed9a97403688 \
|
||||
--hash=sha256:8c726b232659cbd2ae57ade46509eb068c9bd7a06df9fcbff6fe484870006934 \
|
||||
--hash=sha256:913b6c56e110da40e035bbd168353bf7aaa2544a5eaccea5d98a4629aac156c7 \
|
||||
--hash=sha256:97a5c5457a9fb1d6c4e06cfb5dc835871fbfb6a6a51addc9e925bdeff5ef7440 \
|
||||
--hash=sha256:9854ca62c152874b2060772503535be2e8f53f70b8aaa7686b094888d872f984 \
|
||||
--hash=sha256:9911f31aad8906abe337c271343485cf20df5e70df5d2f57f9f136e7b55f26bc \
|
||||
--hash=sha256:9b5bd92ff1ec22e535eab0de75fa6db021992791f461a2aceb7822c625a1187d \
|
||||
--hash=sha256:9deddf09eecb717b7f980414b43d90a5b22ff3967d2949ab29cb0aa83d9e9098 \
|
||||
--hash=sha256:9e36686f7a442185db2400b3df171aac520869faf9deb59df687d28659eda2a6 \
|
||||
--hash=sha256:9f4432898c4bf2fba0435bbe35dd4437d7264565e5a88a21f5b49d8662a6b629 \
|
||||
--hash=sha256:a0f47002c6eeb7c280228467a4cb0cc15ca2103a8421b986b2d3ec04a0f9bd8b \
|
||||
--hash=sha256:a164b50081fc7357331c4024ef4d17b78ba325f8380d05f5a69599a7e05257ee \
|
||||
--hash=sha256:a29ec5305a7335aacee2d799e3422e91e1c8a12474986e2b3b07e315c91be82f \
|
||||
--hash=sha256:a300c6934e0989c327b9e8a1e110329da4641149f872bbe9f70168be66da76c1 \
|
||||
--hash=sha256:a4c46b247b5d4b78f613bd89fea926d32b25c6cc61a50bd1e99ba310348f3dad \
|
||||
--hash=sha256:a638db90c61cd219aeee65e83a24fdaa57269a741ae0cf773309208ac862cee3 \
|
||||
--hash=sha256:a63b9e190711134d581c4d703df5df09851b1acf99792c7aacbbe9f41f0283c9 \
|
||||
--hash=sha256:aaccad4129d735a8a4d526f26929894c9a4e8ef7034566f210b176749d6906e3 \
|
||||
--hash=sha256:ae901f7e55ba405c84ee1cab3d3e962e4e871e4a2bcb9c90911adbd69b42ac5a \
|
||||
--hash=sha256:afa29e2eff3d5729267e2cb2fd4ce9d61c952932fb2694e34ccb5d9540c6a296 \
|
||||
--hash=sha256:affd532502d34c0472d0cdb181325c89f1d2c44992fef0c17e88e7b1576259a1 \
|
||||
--hash=sha256:b171bdd71cb7ff792bf32e376173b0ace7e7963e7e57c58dfc42063a6a7174cd \
|
||||
--hash=sha256:b868acc62aa5de3be7a9d05c2333bf8359ca987e43f9cb30ff8fbda6a024ab73 \
|
||||
--hash=sha256:b9a6367e4aff723e8ee8190836836124284e8fcd4265e307c844010cfa074f3f \
|
||||
--hash=sha256:bbc808daf4f5cd567af8075ecc72d21c6dfef9a254709a621a84c217c935ebc0 \
|
||||
--hash=sha256:bbf44513ceb1589e31948e20eafbde9deaface90e1a1afa5f5f77b4423d17ce6 \
|
||||
--hash=sha256:bcc0aae933921d03096f53b0b03eeb702129fd406dee59f08d2efacc68681fa5 \
|
||||
--hash=sha256:bfd341ccf78128e72c094bc70cc25b3ef309c33c7c2c66ba3ed4309549e02de1 \
|
||||
--hash=sha256:c6a98d698f9e2c8008d0370ec7fc452ebfcc530002ae2d0061170d768b992589 \
|
||||
--hash=sha256:cb0fddaa6884be6aae36ced9544b5e90f7d5f03845a2853bf47a14953a4e8688 \
|
||||
--hash=sha256:cee0f89f4767a6057c8fbf168f8135f18be651300496086bd873e3189fed0487 \
|
||||
--hash=sha256:d17d7512151fedfcc64c1821a8977fc9be0dbf495754669afcab7b57abc98ae9 \
|
||||
--hash=sha256:d46e62cb35d91e6e2589fda6d28074426b0e276422b5d2ebef2c6b11dc60dbfd \
|
||||
--hash=sha256:d50dd325e18ec25bfcc10cd7f99b04df1ab9ec76b0918c260e60817ad0643dee \
|
||||
--hash=sha256:db9c8438057e5b0f6a22a0af99c0c1d26b57fbbdbd1be5861ddb8f897fcc3a2d \
|
||||
--hash=sha256:dee88b1ed88587abd8c0269a1fc1f4cc77f7750d1dfde2869e2a123af420e67d \
|
||||
--hash=sha256:dfd3db045e95960ae3683059571e597fda7cc610106a8916f77c5839048c1deb \
|
||||
--hash=sha256:e26ff680768b8095e8874aabe0e9d3a47a2a9f176a8340d05f8604c56457c23a \
|
||||
--hash=sha256:e370c12133095ff18432de8c044962be85a5a96d90c6fcbce8e17e76236d2328 \
|
||||
--hash=sha256:e38def96ad59853824c97953fdcd2c320a84ba3ce99b417db78af8bb6c3db635 \
|
||||
--hash=sha256:e8f91bce78e32343af184c3b7fa28fcf5a9e2641f4b6623d392038f804939188 \
|
||||
--hash=sha256:eb6bcae8d1a9d305351ecb108232441d11c5cfe9de840a04388ba5d2db8d735c \
|
||||
--hash=sha256:f653e5d7248c1191ec988a85c72edeab46c3ff44f90639a4ed4874ec0be90243 \
|
||||
--hash=sha256:fe41909c9515c3bfdb5f02c4d1f857dba322d9a9a1178069b91eea77889df63a
|
||||
# via pytest-cov
|
||||
iniconfig==2.3.0 \
|
||||
--hash=sha256:c76315c77db068650d49c5b56314774a7804df16fee4402c1f19d6d15d8c4730 \
|
||||
--hash=sha256:f631c04d2c48c52b84d0d0549c99ff3859c98df65b3101406327ecc7d53fbf12
|
||||
# via pytest
|
||||
json5==0.15.0 \
|
||||
--hash=sha256:56636a30c0e8a4665fe2179c0212f32eae3796dea89ea6f649b9436ecdb39618 \
|
||||
--hash=sha256:7424d1f1eb1d56da6e3d70643f53619862b4ce81440bdb8ecfd6f875e5ba4a71
|
||||
# via specify-cli (pyproject.toml)
|
||||
markdown-it-py==4.2.0 \
|
||||
--hash=sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49 \
|
||||
--hash=sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a
|
||||
# via rich
|
||||
mdurl==0.1.2 \
|
||||
--hash=sha256:84008a41e51615a49fc9966191ff91509e3c40b939176e643fd50a5c2196b8f8 \
|
||||
--hash=sha256:bb413d29f5eea38f31dd4754dd7377d4465116fb207585f97bf925588687c1ba
|
||||
# via markdown-it-py
|
||||
packaging==26.2 \
|
||||
--hash=sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e \
|
||||
--hash=sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661
|
||||
# via
|
||||
# specify-cli (pyproject.toml)
|
||||
# pytest
|
||||
pathspec==1.1.1 \
|
||||
--hash=sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a \
|
||||
--hash=sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189
|
||||
# via specify-cli (pyproject.toml)
|
||||
platformdirs==4.11.0 \
|
||||
--hash=sha256:0555d18370482847566ffabcaa53ad7c6c1c29f195989ae1ed634a05f76ea1e0 \
|
||||
--hash=sha256:360ccded2b7fce0af0ff80cc8f5942a1c5d99b0e856033acb030bfc634709e74
|
||||
# via specify-cli (pyproject.toml)
|
||||
pluggy==1.6.0 \
|
||||
--hash=sha256:7dcc130b76258d33b90f61b658791dede3486c3e6bfb003ee5c9bfb396dd22f3 \
|
||||
--hash=sha256:e920276dd6813095e9377c0bc5566d94c932c33b27a3e3945d8389c374dd4746
|
||||
# via
|
||||
# pytest
|
||||
# pytest-cov
|
||||
pygments==2.20.0 \
|
||||
--hash=sha256:6757cd03768053ff99f3039c1a36d6c0aa0b263438fcab17520b30a303a82b5f \
|
||||
--hash=sha256:81a9e26dd42fd28a23a2d169d86d7ac03b46e2f8b59ed4698fb4785f946d0176
|
||||
# via
|
||||
# pytest
|
||||
# rich
|
||||
pytest==9.1.1 \
|
||||
--hash=sha256:1088fbde8f2b49d95a549a195707afa7a76a3ce9bcadc26b6d71f0ffda5fe313 \
|
||||
--hash=sha256:37a86b45efb9a47a61a36449063e8e18d0cab3161329fc099eb21783169c4f0c
|
||||
# via
|
||||
# specify-cli (pyproject.toml)
|
||||
# pytest-cov
|
||||
pytest-cov==7.1.0 \
|
||||
--hash=sha256:30674f2b5f6351aa09702a9c8c364f6a01c27aae0c1366ae8016160d1efc56b2 \
|
||||
--hash=sha256:a0461110b7865f9a271aa1b51e516c9a95de9d696734a2f71e3e78f46e1d4678
|
||||
# via specify-cli (pyproject.toml)
|
||||
pyyaml==6.0.3 \
|
||||
--hash=sha256:00c4bdeba853cc34e7dd471f16b4114f4162dc03e6b7afcc2128711f0eca823c \
|
||||
--hash=sha256:0150219816b6a1fa26fb4699fb7daa9caf09eb1999f3b70fb6e786805e80375a \
|
||||
--hash=sha256:02893d100e99e03eda1c8fd5c441d8c60103fd175728e23e431db1b589cf5ab3 \
|
||||
--hash=sha256:02ea2dfa234451bbb8772601d7b8e426c2bfa197136796224e50e35a78777956 \
|
||||
--hash=sha256:0f29edc409a6392443abf94b9cf89ce99889a1dd5376d94316ae5145dfedd5d6 \
|
||||
--hash=sha256:10892704fc220243f5305762e276552a0395f7beb4dbf9b14ec8fd43b57f126c \
|
||||
--hash=sha256:16249ee61e95f858e83976573de0f5b2893b3677ba71c9dd36b9cf8be9ac6d65 \
|
||||
--hash=sha256:1d37d57ad971609cf3c53ba6a7e365e40660e3be0e5175fa9f2365a379d6095a \
|
||||
--hash=sha256:1ebe39cb5fc479422b83de611d14e2c0d3bb2a18bbcb01f229ab3cfbd8fee7a0 \
|
||||
--hash=sha256:214ed4befebe12df36bcc8bc2b64b396ca31be9304b8f59e25c11cf94a4c033b \
|
||||
--hash=sha256:2283a07e2c21a2aa78d9c4442724ec1eb15f5e42a723b99cb3d822d48f5f7ad1 \
|
||||
--hash=sha256:22ba7cfcad58ef3ecddc7ed1db3409af68d023b7f940da23c6c2a1890976eda6 \
|
||||
--hash=sha256:27c0abcb4a5dac13684a37f76e701e054692a9b2d3064b70f5e4eb54810553d7 \
|
||||
--hash=sha256:28c8d926f98f432f88adc23edf2e6d4921ac26fb084b028c733d01868d19007e \
|
||||
--hash=sha256:2e71d11abed7344e42a8849600193d15b6def118602c4c176f748e4583246007 \
|
||||
--hash=sha256:34d5fcd24b8445fadc33f9cf348c1047101756fd760b4dacb5c3e99755703310 \
|
||||
--hash=sha256:37503bfbfc9d2c40b344d06b2199cf0e96e97957ab1c1b546fd4f87e53e5d3e4 \
|
||||
--hash=sha256:3c5677e12444c15717b902a5798264fa7909e41153cdf9ef7ad571b704a63dd9 \
|
||||
--hash=sha256:3ff07ec89bae51176c0549bc4c63aa6202991da2d9a6129d7aef7f1407d3f295 \
|
||||
--hash=sha256:41715c910c881bc081f1e8872880d3c650acf13dfa8214bad49ed4cede7c34ea \
|
||||
--hash=sha256:418cf3f2111bc80e0933b2cd8cd04f286338bb88bdc7bc8e6dd775ebde60b5e0 \
|
||||
--hash=sha256:44edc647873928551a01e7a563d7452ccdebee747728c1080d881d68af7b997e \
|
||||
--hash=sha256:4a2e8cebe2ff6ab7d1050ecd59c25d4c8bd7e6f400f5f82b96557ac0abafd0ac \
|
||||
--hash=sha256:4ad1906908f2f5ae4e5a8ddfce73c320c2a1429ec52eafd27138b7f1cbe341c9 \
|
||||
--hash=sha256:501a031947e3a9025ed4405a168e6ef5ae3126c59f90ce0cd6f2bfc477be31b7 \
|
||||
--hash=sha256:5190d403f121660ce8d1d2c1bb2ef1bd05b5f68533fc5c2ea899bd15f4399b35 \
|
||||
--hash=sha256:5498cd1645aa724a7c71c8f378eb29ebe23da2fc0d7a08071d89469bf1d2defb \
|
||||
--hash=sha256:5cf4e27da7e3fbed4d6c3d8e797387aaad68102272f8f9752883bc32d61cb87b \
|
||||
--hash=sha256:5e0b74767e5f8c593e8c9b5912019159ed0533c70051e9cce3e8b6aa699fcd69 \
|
||||
--hash=sha256:5ed875a24292240029e4483f9d4a4b8a1ae08843b9c54f43fcc11e404532a8a5 \
|
||||
--hash=sha256:5fcd34e47f6e0b794d17de1b4ff496c00986e1c83f7ab2fb8fcfe9616ff7477b \
|
||||
--hash=sha256:5fdec68f91a0c6739b380c83b951e2c72ac0197ace422360e6d5a959d8d97b2c \
|
||||
--hash=sha256:6344df0d5755a2c9a276d4473ae6b90647e216ab4757f8426893b5dd2ac3f369 \
|
||||
--hash=sha256:64386e5e707d03a7e172c0701abfb7e10f0fb753ee1d773128192742712a98fd \
|
||||
--hash=sha256:652cb6edd41e718550aad172851962662ff2681490a8a711af6a4d288dd96824 \
|
||||
--hash=sha256:66291b10affd76d76f54fad28e22e51719ef9ba22b29e1d7d03d6777a9174198 \
|
||||
--hash=sha256:66e1674c3ef6f541c35191caae2d429b967b99e02040f5ba928632d9a7f0f065 \
|
||||
--hash=sha256:6adc77889b628398debc7b65c073bcb99c4a0237b248cacaf3fe8a557563ef6c \
|
||||
--hash=sha256:79005a0d97d5ddabfeeea4cf676af11e647e41d81c9a7722a193022accdb6b7c \
|
||||
--hash=sha256:7c6610def4f163542a622a73fb39f534f8c101d690126992300bf3207eab9764 \
|
||||
--hash=sha256:7f047e29dcae44602496db43be01ad42fc6f1cc0d8cd6c83d342306c32270196 \
|
||||
--hash=sha256:8098f252adfa6c80ab48096053f512f2321f0b998f98150cea9bd23d83e1467b \
|
||||
--hash=sha256:850774a7879607d3a6f50d36d04f00ee69e7fc816450e5f7e58d7f17f1ae5c00 \
|
||||
--hash=sha256:8d1fab6bb153a416f9aeb4b8763bc0f22a5586065f86f7664fc23339fc1c1fac \
|
||||
--hash=sha256:8da9669d359f02c0b91ccc01cac4a67f16afec0dac22c2ad09f46bee0697eba8 \
|
||||
--hash=sha256:8dc52c23056b9ddd46818a57b78404882310fb473d63f17b07d5c40421e47f8e \
|
||||
--hash=sha256:9149cad251584d5fb4981be1ecde53a1ca46c891a79788c0df828d2f166bda28 \
|
||||
--hash=sha256:93dda82c9c22deb0a405ea4dc5f2d0cda384168e466364dec6255b293923b2f3 \
|
||||
--hash=sha256:96b533f0e99f6579b3d4d4995707cf36df9100d67e0c8303a0c55b27b5f99bc5 \
|
||||
--hash=sha256:9c57bb8c96f6d1808c030b1687b9b5fb476abaa47f0db9c0101f5e9f394e97f4 \
|
||||
--hash=sha256:9c7708761fccb9397fe64bbc0395abcae8c4bf7b0eac081e12b809bf47700d0b \
|
||||
--hash=sha256:9f3bfb4965eb874431221a3ff3fdcddc7e74e3b07799e0e84ca4a0f867d449bf \
|
||||
--hash=sha256:a33284e20b78bd4a18c8c2282d549d10bc8408a2a7ff57653c0cf0b9be0afce5 \
|
||||
--hash=sha256:a80cb027f6b349846a3bf6d73b5e95e782175e52f22108cfa17876aaeff93702 \
|
||||
--hash=sha256:b30236e45cf30d2b8e7b3e85881719e98507abed1011bf463a8fa23e9c3e98a8 \
|
||||
--hash=sha256:b3bc83488de33889877a0f2543ade9f70c67d66d9ebb4ac959502e12de895788 \
|
||||
--hash=sha256:b865addae83924361678b652338317d1bd7e79b1f4596f96b96c77a5a34b34da \
|
||||
--hash=sha256:b8bb0864c5a28024fac8a632c443c87c5aa6f215c0b126c449ae1a150412f31d \
|
||||
--hash=sha256:ba1cc08a7ccde2d2ec775841541641e4548226580ab850948cbfda66a1befcdc \
|
||||
--hash=sha256:bdb2c67c6c1390b63c6ff89f210c8fd09d9a1217a465701eac7316313c915e4c \
|
||||
--hash=sha256:c1ff362665ae507275af2853520967820d9124984e0f7466736aea23d8611fba \
|
||||
--hash=sha256:c2514fceb77bc5e7a2f7adfaa1feb2fb311607c9cb518dbc378688ec73d8292f \
|
||||
--hash=sha256:c3355370a2c156cffb25e876646f149d5d68f5e0a3ce86a5084dd0b64a994917 \
|
||||
--hash=sha256:c458b6d084f9b935061bc36216e8a69a7e293a2f1e68bf956dcd9e6cbcd143f5 \
|
||||
--hash=sha256:d0eae10f8159e8fdad514efdc92d74fd8d682c933a6dd088030f3834bc8e6b26 \
|
||||
--hash=sha256:d76623373421df22fb4cf8817020cbb7ef15c725b9d5e45f17e189bfc384190f \
|
||||
--hash=sha256:ebc55a14a21cb14062aa4162f906cd962b28e2e9ea38f9b4391244cd8de4ae0b \
|
||||
--hash=sha256:eda16858a3cab07b80edaf74336ece1f986ba330fdb8ee0d6c0d68fe82bc96be \
|
||||
--hash=sha256:ee2922902c45ae8ccada2c5b501ab86c36525b883eff4255313a253a3160861c \
|
||||
--hash=sha256:efd7b85f94a6f21e4932043973a7ba2613b059c4a000551892ac9f1d11f5baf3 \
|
||||
--hash=sha256:f7057c9a337546edc7973c0d3ba84ddcdf0daa14533c2065749c9075001090e6 \
|
||||
--hash=sha256:fa160448684b4e94d80416c0fa4aac48967a969efe22931448d853ada8baf926 \
|
||||
--hash=sha256:fc09d0aa354569bc501d4e787133afc08552722d3ab34836a80547331bb5d4a0
|
||||
# via specify-cli (pyproject.toml)
|
||||
readchar==4.2.2 \
|
||||
--hash=sha256:92daf7e42c52b0787e6c75d01ecfb9a94f4ceff3764958b570c1dddedd47b200 \
|
||||
--hash=sha256:e3b270fe16fc90c50ac79107700330a133dd4c63d22939f5b03b4f24564d5dd8
|
||||
# via specify-cli (pyproject.toml)
|
||||
rich==15.0.0 \
|
||||
--hash=sha256:33bd4ef74232fb73fe9279a257718407f169c09b78a87ad3d296f548e27de0bb \
|
||||
--hash=sha256:edd07a4824c6b40189fb7ac9bc4c52536e9780fbbfbddf6f1e2502c31b068c36
|
||||
# via
|
||||
# specify-cli (pyproject.toml)
|
||||
# typer
|
||||
shellingham==1.5.4 \
|
||||
--hash=sha256:7ecfff8f2fd72616f7481040475a65b2bf8af90a56c89140852d1120324e8686 \
|
||||
--hash=sha256:8dbca0739d487e5bd35ab3ca4b36e11c4078f3a234bfce294b0a0291363404de
|
||||
# via typer
|
||||
typer==0.27.0 \
|
||||
--hash=sha256:629bd12ea5d13a17148125d9a264f949eb171fb3f120f9b04d85873cab054fa5 \
|
||||
--hash=sha256:6f4b27631e47f077871b7dc30e933ec0131c1390fbe0e387ea5574b5bac9ccf1
|
||||
# via specify-cli (pyproject.toml)
|
||||
1746
.github/workflows/add-community-bundle.lock.yml
generated
vendored
1746
.github/workflows/add-community-bundle.lock.yml
generated
vendored
File diff suppressed because one or more lines are too long
288
.github/workflows/add-community-bundle.md
vendored
288
.github/workflows/add-community-bundle.md
vendored
@@ -1,288 +0,0 @@
|
||||
---
|
||||
description: "Process community bundle submission issues - validate, add to catalog, and open a PR for maintainer review"
|
||||
emoji: "📦"
|
||||
|
||||
on:
|
||||
issues:
|
||||
types: [labeled]
|
||||
names: [bundle-submission]
|
||||
skip-bots: [github-actions, copilot, dependabot]
|
||||
|
||||
tools:
|
||||
edit:
|
||||
bash: ["echo", "grep", "sort", "python3", "jq", "date"]
|
||||
github:
|
||||
toolsets: [issues, repos]
|
||||
min-integrity: none
|
||||
web-fetch:
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
issues: read
|
||||
|
||||
checkout:
|
||||
fetch-depth: 0
|
||||
|
||||
safe-outputs:
|
||||
noop:
|
||||
report-as-issue: false
|
||||
create-pull-request:
|
||||
title-prefix: "[bundle] "
|
||||
labels: [bundle-submission, automated]
|
||||
draft: true
|
||||
max: 1
|
||||
allowed-files:
|
||||
- bundles/catalog.community.json
|
||||
- docs/community/bundles.md
|
||||
protected-files:
|
||||
policy: blocked
|
||||
exclude:
|
||||
- README.md
|
||||
- CHANGELOG.md
|
||||
add-comment:
|
||||
max: 2
|
||||
add-labels:
|
||||
allowed: [bundle-submission, validation-passed, validation-failed, needs-info]
|
||||
max: 3
|
||||
remove-labels:
|
||||
allowed: [validation-passed, validation-failed, needs-info]
|
||||
---
|
||||
|
||||
# Add Community Bundle from Issue Submission
|
||||
|
||||
You are a catalog maintenance agent for the Spec Kit project. Process community
|
||||
bundle submission issues and create draft pull requests that add or update
|
||||
entries in the community bundle catalog.
|
||||
|
||||
Community bundles are untrusted. Validate metadata and distribution evidence,
|
||||
but do not claim to audit, endorse, or support bundle code or the components it
|
||||
installs. Never register a submitted companion catalog automatically.
|
||||
|
||||
## Triggering Conditions
|
||||
|
||||
This workflow is triggered by an `issues: labeled` event and is gated to the
|
||||
`bundle-submission` label. Before processing, verify that the issue title starts
|
||||
with `[Bundle]:`. If it does not, stop without commenting.
|
||||
|
||||
## Step 1 - Read and Parse the Issue
|
||||
|
||||
Read issue #${{ github.event.issue.number }} and extract these issue-form fields:
|
||||
|
||||
| Field | Issue Form ID | Required |
|
||||
|-------|---------------|----------|
|
||||
| Bundle ID | `bundle-id` | Yes |
|
||||
| Bundle Name | `bundle-name` | Yes |
|
||||
| Version | `version` | Yes |
|
||||
| Role or Team | `role` | Yes |
|
||||
| Description | `description` | Yes |
|
||||
| Author | `author` | Yes |
|
||||
| Repository URL | `repository` | Yes |
|
||||
| Download URL | `download-url` | Yes |
|
||||
| Documentation URL | `documentation` | Yes |
|
||||
| License | `license` | Yes |
|
||||
| Required Spec Kit Version | `speckit-version` | Yes |
|
||||
| Integration Target | `integration` | No |
|
||||
| Components Provided | `components-provided` | Yes |
|
||||
| Required Component Catalogs | `required-catalogs` | Yes |
|
||||
| Tags | `tags` | Yes |
|
||||
| Key Features | `features` | Yes |
|
||||
| Testing Details | `testing-details` | Yes |
|
||||
| Example Usage | `example-usage` | Yes |
|
||||
| Proposed Catalog Entry | `catalog-entry` | Yes |
|
||||
|
||||
Issue-form values appear beneath headings matching their labels.
|
||||
|
||||
## Step 2 - Validate the Submission
|
||||
|
||||
Run every check and collect all failures before deciding the outcome.
|
||||
|
||||
### 2a. Bundle ID and version
|
||||
|
||||
- The bundle ID must match
|
||||
`^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$`.
|
||||
- The version must be semantic version `X.Y.Z` with digits only and no `v`
|
||||
prefix.
|
||||
|
||||
### 2b. Repository and documentation
|
||||
|
||||
- Restrict repository and documentation URLs to public GitHub URLs before
|
||||
fetching them.
|
||||
- Confirm the repository exists and contains `bundle.yml`, `README.md`, and a
|
||||
license file (`LICENSE`, `LICENSE.md`, or `LICENSE.txt`).
|
||||
- The documentation URL must resolve to a readable Markdown file that explains
|
||||
the bundle's intended role, installed components, required catalogs, and
|
||||
installation steps.
|
||||
- Confirm the repository's `bundle.yml` matches the submitted bundle ID,
|
||||
version, role, author, license, Spec Kit requirement, integration target, and
|
||||
component summary.
|
||||
|
||||
### 2c. Release artifact
|
||||
|
||||
- The download URL must be an HTTPS GitHub release asset URL under the submitted
|
||||
repository:
|
||||
`https://github.com/<owner>/<repo>/releases/download/<tag>/<asset>.zip`.
|
||||
- Confirm the release exists, its tag corresponds to the submitted version
|
||||
(`vX.Y.Z` or `X.Y.Z`), and the exact ZIP asset is attached to that release.
|
||||
- Confirm the asset name is versioned and consistent with the submitted bundle
|
||||
ID and version.
|
||||
|
||||
Do not fetch arbitrary user-provided URLs. Do not claim the artifact was
|
||||
executed or audited; rely on the required submission attestations for build and
|
||||
installation evidence.
|
||||
|
||||
### 2d. Catalog entry
|
||||
|
||||
Parse the proposed JSON and require one entry under the submitted bundle ID.
|
||||
Confirm that:
|
||||
|
||||
- `id`, `name`, `version`, `role`, `description`, `author`, `license`,
|
||||
`download_url`, and `repository` match the submission and manifest.
|
||||
- `requires.speckit_version` matches the submission.
|
||||
- `provides` contains non-negative integer counts for `extensions`, `presets`,
|
||||
`steps`, and `workflows`, matching the manifest.
|
||||
- `tags` contains 2-5 lowercase strings and matches the submitted tags.
|
||||
- `verified` is the boolean value `false`. Community entries must never be
|
||||
marked verified.
|
||||
|
||||
### 2e. Component resolution
|
||||
|
||||
- `Required Component Catalogs` must explicitly say `None` or list every
|
||||
non-default extension, preset, workflow, and step catalog needed by the
|
||||
bundle.
|
||||
- Compare the manifest references, README, required-catalog field, testing
|
||||
details, and example usage for consistency.
|
||||
- If non-default catalogs are required, ensure each URL is HTTPS, the README
|
||||
documents the corresponding `catalog add` command, and the testing details
|
||||
say those catalogs were registered in the clean-project test.
|
||||
- If the field says `None` but a component is not bundled and cannot be
|
||||
installed from a default Spec Kit catalog, fail validation and ask the
|
||||
submitter to list and document an install-allowed companion catalog.
|
||||
|
||||
The community bundle catalog itself remains discovery-only. Companion catalog
|
||||
URLs are documentation and validation metadata, not catalogs this workflow
|
||||
should add to Spec Kit.
|
||||
|
||||
### 2f. Checklists and testing evidence
|
||||
|
||||
- Confirm every required checkbox in Testing Checklist and Submission
|
||||
Requirements is checked (`[x]`).
|
||||
- Confirm Testing Details describe validation, build, artifact installation,
|
||||
and clean-project testing.
|
||||
- Confirm Example Usage includes artifact installation and, when applicable,
|
||||
all required catalog setup commands.
|
||||
|
||||
### Validation outcome
|
||||
|
||||
If any check fails:
|
||||
|
||||
1. Comment once with every failed check and a specific correction.
|
||||
2. Remove `validation-passed`.
|
||||
3. Add `validation-failed`; add `needs-info` when submitter input is needed.
|
||||
4. Stop without editing files or creating a pull request.
|
||||
|
||||
If all checks pass, remove `validation-failed` and `needs-info`, add
|
||||
`validation-passed`, and continue.
|
||||
|
||||
## Step 3 - Determine Add or Update
|
||||
|
||||
Search `bundles/catalog.community.json` for the bundle ID.
|
||||
|
||||
- If absent, add a new entry.
|
||||
- If present, update the existing entry in place.
|
||||
|
||||
Treat a submitted version lower than or equal to the existing catalog version
|
||||
as a validation failure unless the issue clearly documents a metadata-only
|
||||
correction at the same version.
|
||||
|
||||
## Step 4 - Update the Community Catalog
|
||||
|
||||
Edit `bundles/catalog.community.json`. Insert new entries alphabetically by
|
||||
bundle ID. The entry shape is:
|
||||
|
||||
```json
|
||||
{
|
||||
"<bundle-id>": {
|
||||
"name": "<bundle-name>",
|
||||
"id": "<bundle-id>",
|
||||
"version": "<version>",
|
||||
"role": "<role>",
|
||||
"description": "<description>",
|
||||
"author": "<author>",
|
||||
"license": "<license>",
|
||||
"download_url": "<download-url>",
|
||||
"repository": "<repository>",
|
||||
"requires": {
|
||||
"speckit_version": "<speckit-version>"
|
||||
},
|
||||
"provides": {
|
||||
"extensions": 0,
|
||||
"presets": 0,
|
||||
"steps": 0,
|
||||
"workflows": 0
|
||||
},
|
||||
"tags": ["<tag>"],
|
||||
"verified": false
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
Use the validated proposed entry rather than inventing metadata. Keep
|
||||
`verified: false`. Update the top-level `updated_at` to today's UTC date at
|
||||
midnight and preserve the top-level `catalog_url`.
|
||||
|
||||
Validate the complete file:
|
||||
|
||||
```bash
|
||||
python3 -c "import json; json.load(open('bundles/catalog.community.json')); print('Valid JSON')"
|
||||
```
|
||||
|
||||
## Step 5 - Update Community Documentation
|
||||
|
||||
Add or update the bundle in `docs/community/bundles.md`. Keep rows alphabetical
|
||||
by bundle name:
|
||||
|
||||
```text
|
||||
| <Name> | <Description> | `<role>` | <component counts> | <None or documented> | [<repo-name>](<repository>) |
|
||||
```
|
||||
|
||||
Before rendering the row, convert every user-derived display value to
|
||||
single-line plain text: collapse CR/LF sequences to spaces, remove control
|
||||
characters, and backslash-escape `\`, `|`, backticks, `*`, `_`, `[`, `]`, `<`,
|
||||
and `>`. Use the validated HTTPS GitHub repository URL unchanged only as the
|
||||
Markdown link destination.
|
||||
|
||||
Render component counts compactly, omitting zero-valued component types. Use
|
||||
`None` when no companion catalogs are needed and `Documented` otherwise; the
|
||||
repository README remains the source for the actual URLs.
|
||||
|
||||
## Step 6 - Create a Draft Pull Request
|
||||
|
||||
Create one draft pull request.
|
||||
|
||||
- New entry branch:
|
||||
`community/${{ github.event.issue.number }}-add-<bundle-id>-bundle`
|
||||
- Update branch:
|
||||
`community/${{ github.event.issue.number }}-update-<bundle-id>-bundle`
|
||||
- New title: `Add <Bundle Name> bundle to community catalog`
|
||||
- Update title: `Update <Bundle Name> bundle to v<version>`
|
||||
|
||||
The commit and PR description must summarize the catalog and documentation
|
||||
changes, list the validation results, include
|
||||
`Closes #${{ github.event.issue.number }}`, and mention the submitter with
|
||||
`cc @<issue-author>`.
|
||||
|
||||
End the commit message with this authorship trailer:
|
||||
|
||||
```text
|
||||
Assisted-by: GitHub Copilot (model: <name-if-known>, autonomous)
|
||||
```
|
||||
|
||||
## Important Rules
|
||||
|
||||
- Modify only `bundles/catalog.community.json` and
|
||||
`docs/community/bundles.md`.
|
||||
- Keep JSON entries sorted by ID and documentation rows sorted by name.
|
||||
- Never set a community bundle's `verified` field to true.
|
||||
- Never add, enable, or change the policy of a submitted catalog.
|
||||
- Never describe validation as a security audit or endorsement.
|
||||
- Use `Closes`, not `Fixes`, for the submission issue.
|
||||
12
.github/workflows/add-community-extension.lock.yml
generated
vendored
12
.github/workflows/add-community-extension.lock.yml
generated
vendored
@@ -33,7 +33,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1332,7 +1332,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
@@ -1658,7 +1658,7 @@ jobs:
|
||||
await main();
|
||||
- name: Checkout repository (trusted default branch for comment events)
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -1666,7 +1666,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Checkout repository
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
12
.github/workflows/add-community-preset.lock.yml
generated
vendored
12
.github/workflows/add-community-preset.lock.yml
generated
vendored
@@ -33,7 +33,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1332,7 +1332,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
@@ -1658,7 +1658,7 @@ jobs:
|
||||
await main();
|
||||
- name: Checkout repository (trusted default branch for comment events)
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -1666,7 +1666,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Checkout repository
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
8
.github/workflows/bug-assess.lock.yml
generated
vendored
8
.github/workflows/bug-assess.lock.yml
generated
vendored
@@ -32,7 +32,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -430,7 +430,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1277,7 +1277,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
|
||||
12
.github/workflows/bug-fix.lock.yml
generated
vendored
12
.github/workflows/bug-fix.lock.yml
generated
vendored
@@ -33,7 +33,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -162,7 +162,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -434,7 +434,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1338,7 +1338,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
@@ -1664,7 +1664,7 @@ jobs:
|
||||
await main();
|
||||
- name: Checkout repository (trusted default branch for comment events)
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
@@ -1672,7 +1672,7 @@ jobs:
|
||||
fetch-depth: 0
|
||||
- name: Checkout repository
|
||||
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
|
||||
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
|
||||
|
||||
8
.github/workflows/bug-test.lock.yml
generated
vendored
8
.github/workflows/bug-test.lock.yml
generated
vendored
@@ -32,7 +32,7 @@
|
||||
# - GITHUB_TOKEN
|
||||
#
|
||||
# Custom actions used:
|
||||
# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
|
||||
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
|
||||
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
|
||||
@@ -161,7 +161,7 @@ jobs:
|
||||
env:
|
||||
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
|
||||
- name: Checkout .github and .agents folders
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
sparse-checkout: |
|
||||
@@ -431,7 +431,7 @@ jobs:
|
||||
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
|
||||
} >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
fetch-depth: 0
|
||||
@@ -1299,7 +1299,7 @@ jobs:
|
||||
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
|
||||
- name: Checkout repository for patch context
|
||||
if: needs.agent.outputs.has_patch == 'true'
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
persist-credentials: false
|
||||
# --- Threat Detection ---
|
||||
|
||||
6
.github/workflows/catalog-assign.yml
vendored
6
.github/workflows/catalog-assign.yml
vendored
@@ -9,13 +9,11 @@ jobs:
|
||||
if: >
|
||||
(github.event.action == 'opened' && (
|
||||
contains(github.event.issue.labels.*.name, 'extension-submission') ||
|
||||
contains(github.event.issue.labels.*.name, 'preset-submission') ||
|
||||
contains(github.event.issue.labels.*.name, 'bundle-submission')
|
||||
contains(github.event.issue.labels.*.name, 'preset-submission')
|
||||
)) ||
|
||||
(github.event.action == 'labeled' && (
|
||||
github.event.label.name == 'extension-submission' ||
|
||||
github.event.label.name == 'preset-submission' ||
|
||||
github.event.label.name == 'bundle-submission'
|
||||
github.event.label.name == 'preset-submission'
|
||||
))
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
|
||||
6
.github/workflows/codeql.yml
vendored
6
.github/workflows/codeql.yml
vendored
@@ -19,14 +19,14 @@ jobs:
|
||||
language: [ 'actions', 'python' ]
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Initialize CodeQL
|
||||
uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4
|
||||
with:
|
||||
languages: ${{ matrix.language }}
|
||||
|
||||
- name: Perform CodeQL Analysis
|
||||
uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
|
||||
uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4
|
||||
with:
|
||||
category: "/language:${{ matrix.language }}"
|
||||
|
||||
2
.github/workflows/docs.yml
vendored
2
.github/workflows/docs.yml
vendored
@@ -30,7 +30,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0 # Fetch all history for git info
|
||||
|
||||
|
||||
6
.github/workflows/lint.yml
vendored
6
.github/workflows/lint.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
@@ -37,7 +37,7 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Run markdownlint-cli2
|
||||
uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
|
||||
uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
|
||||
with:
|
||||
globs: |
|
||||
'**/*.md'
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
# shellcheck is preinstalled on ubuntu-latest runners.
|
||||
# Start at --severity=error to block real bugs without flagging style
|
||||
|
||||
6
.github/workflows/publish-pypi.yml
vendored
6
.github/workflows/publish-pypi.yml
vendored
@@ -27,12 +27,12 @@ jobs:
|
||||
fi
|
||||
|
||||
- name: Checkout release tag
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
ref: refs/tags/${{ inputs.tag }}
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
@@ -74,7 +74,7 @@ jobs:
|
||||
path: dist/
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- name: Publish to PyPI
|
||||
run: uv publish
|
||||
|
||||
2
.github/workflows/release-trigger.yml
vendored
2
.github/workflows/release-trigger.yml
vendored
@@ -16,7 +16,7 @@ jobs:
|
||||
pull-requests: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.RELEASE_PAT }}
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -12,7 +12,7 @@ jobs:
|
||||
contents: write
|
||||
steps:
|
||||
- name: Checkout repository
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
token: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
||||
78
.github/workflows/security.yml
vendored
78
.github/workflows/security.yml
vendored
@@ -1,78 +0,0 @@
|
||||
name: Security Audit
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: ["main"]
|
||||
pull_request:
|
||||
types: [opened, synchronize, reopened]
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
dependency-audit:
|
||||
name: Dependency audit
|
||||
if: ${{ github.event_name != 'schedule' }}
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Check committed audit requirements are current
|
||||
env:
|
||||
DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
|
||||
DEPENDENCY_DIFF_HEAD: ${{ github.sha }}
|
||||
GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt
|
||||
run: python .github/scripts/check_security_requirements.py
|
||||
|
||||
- name: Run pip-audit (committed requirements)
|
||||
run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off
|
||||
|
||||
dependency-audit-scheduled:
|
||||
name: Dependency audit scheduled (${{ matrix.os }}, Python ${{ matrix.python-version }})
|
||||
if: ${{ github.event_name == 'schedule' }}
|
||||
runs-on: ${{ matrix.os }}
|
||||
strategy:
|
||||
fail-fast: false
|
||||
matrix:
|
||||
os: [ubuntu-latest, windows-latest]
|
||||
python-version: ["3.11", "3.12", "3.13", "3.14"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
# The committed .github/security-audit-requirements.txt is generated with
|
||||
# --universal (resolves across all interpreters/platforms) and is what
|
||||
# push/PR/workflow_dispatch runs audit. The scheduled job instead compiles
|
||||
# per matrix entry with --python-version so it can surface advisories in
|
||||
# wheels that only resolve on a specific interpreter (e.g. 3.11-only) —
|
||||
# coverage the universal file may not exercise. This broadening is
|
||||
# intentional; non-scheduled runs trade that depth for determinism against
|
||||
# the committed snapshot.
|
||||
- name: Compile scheduled audit requirements
|
||||
run: |
|
||||
uv pip compile pyproject.toml --extra test --python-version "${{ matrix.python-version }}" --upgrade --generate-hashes --quiet --output-file "${{ runner.temp }}/spec-kit-audit-requirements.txt"
|
||||
|
||||
- name: Run pip-audit (scheduled live resolution)
|
||||
run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r "${{ runner.temp }}/spec-kit-audit-requirements.txt" --progress-spinner off
|
||||
10
.github/workflows/test.yml
vendored
10
.github/workflows/test.yml
vendored
@@ -13,10 +13,10 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
@@ -24,7 +24,7 @@ jobs:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Run ruff check
|
||||
run: uvx ruff@0.15.0 check src tests
|
||||
run: uvx ruff check src/
|
||||
|
||||
pytest:
|
||||
runs-on: ${{ matrix.os }}
|
||||
@@ -34,10 +34,10 @@ jobs:
|
||||
python-version: ["3.13", "3.14"]
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
|
||||
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
|
||||
54
AGENTS.md
54
AGENTS.md
@@ -64,14 +64,13 @@ class KilocodeIntegration(MarkdownIntegration):
|
||||
key = "kilocode"
|
||||
config = {
|
||||
"name": "Kilo Code",
|
||||
"folder": ".kilo/",
|
||||
"commands_subdir": "commands",
|
||||
"folder": ".kilocode/",
|
||||
"commands_subdir": "workflows",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".kilo/commands",
|
||||
"legacy_dir": ".kilocode/workflows",
|
||||
"dir": ".kilocode/workflows",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
@@ -188,7 +187,7 @@ context_markers:
|
||||
end: "<!-- SPECKIT END -->"
|
||||
```
|
||||
|
||||
- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh`, `.ps1`, and `extensions/agent-context/scripts/python/update_agent_context.py`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension.
|
||||
- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted — all agent→context-file knowledge lives inside the extension.
|
||||
- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly.
|
||||
|
||||
Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run.
|
||||
@@ -202,8 +201,8 @@ Only add custom setup logic when the agent needs non-standard behavior. Integrat
|
||||
specify init my-project --integration <key>
|
||||
|
||||
# Verify files were created in the commands directory configured by
|
||||
# config["folder"] + config["commands_subdir"] (for example, .kilo/commands/)
|
||||
ls -R my-project/.kilo/commands/
|
||||
# config["folder"] + config["commands_subdir"] (for example, .kilocode/workflows/)
|
||||
ls -R my-project/.kilocode/workflows/
|
||||
|
||||
# Uninstall cleanly
|
||||
cd my-project && specify integration uninstall <key>
|
||||
@@ -269,25 +268,6 @@ echo "✅ Done"
|
||||
|
||||
## Command File Formats
|
||||
|
||||
### Script References (`scripts:` frontmatter)
|
||||
|
||||
Core command templates (`templates/commands/*.md`) that invoke a helper script declare it in a `scripts:` frontmatter block with one line per supported script type. The `{SCRIPT}` placeholder in the command body is replaced at install time with the entry matching the project's selected script type (`--script sh|ps|py`):
|
||||
|
||||
```yaml
|
||||
scripts:
|
||||
sh: scripts/bash/setup-plan.sh --json
|
||||
ps: scripts/powershell/setup-plan.ps1 -Json
|
||||
py: scripts/python/setup_plan.py --json
|
||||
```
|
||||
|
||||
| Key | Script type | Location |
|
||||
| ---- | ---------------------- | -------------------------- |
|
||||
| `sh` | POSIX shell (bash/zsh) | `scripts/bash/*.sh` |
|
||||
| `ps` | PowerShell | `scripts/powershell/*.ps1` |
|
||||
| `py` | Python | `scripts/python/*.py` |
|
||||
|
||||
All three entries must be present and behaviorally equivalent — agents parse the same stdout contract (`FEATURE_DIR:…`, `AVAILABLE_DOCS:…`, `--json` shapes) regardless of which one runs. (The bundled `agent-context` and `git` extension command templates also invoke helpers but do not yet use `scripts:` frontmatter — see [Script Types and Migration](#script-types-and-migration).)
|
||||
|
||||
### Markdown Format
|
||||
|
||||
**Standard format:**
|
||||
@@ -348,29 +328,9 @@ Different agents use different argument placeholders. The placeholder used in co
|
||||
- **TOML-based**: `{{args}}` (e.g., Gemini)
|
||||
- **YAML-based**: `{{args}}` (e.g., Goose)
|
||||
- **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`)
|
||||
- **Script placeholders**: `{SCRIPT}` (replaced with the resolved command from the template's `scripts:` frontmatter, per the project's `--script sh|ps|py` selection)
|
||||
- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)
|
||||
- **Agent placeholders**: `__AGENT__` (replaced with agent name)
|
||||
|
||||
## Script Types and Migration
|
||||
|
||||
Spec Kit ships every core workflow script in three interchangeable variants — POSIX shell (`sh`), PowerShell (`ps`), and Python (`py`) — selected per project with `specify init --script sh|ps|py`. Each core command template that invokes a helper script carries all three in its `scripts:` frontmatter (templates that don't call a script, e.g. `constitution`/`specify`, have no `scripts:` block); see [Script References](#script-references-scripts-frontmatter).
|
||||
|
||||
### Why Python is recommended
|
||||
|
||||
- **No extra runtime.** The `specify` CLI is already Python, so the interpreter is guaranteed present — `py` adds no new dependency.
|
||||
- **Path toward a single source of truth.** The shell variants require paired `.sh` + `.ps1` maintenance and diverge on JSON handling (`jq` vs manual parsing). The Python variant avoids `jq` and is intended to eventually replace that dual-maintenance — but that consolidation has not happened yet: all three variants are still maintained in parallel (see the parity rule below).
|
||||
- **Parity-tested.** The Python ports are covered by tests — output-parity tests against the shell scripts where the contract is stdout-based, and direct unit tests elsewhere — so the stdout contract agents rely on stays stable.
|
||||
|
||||
### Defaults and availability
|
||||
|
||||
- `py` is available today for the core command templates (via their `scripts:` frontmatter). The bundled extensions (`agent-context`, `git`) ship Python script variants on disk, but their command templates still hard-code the Bash/PowerShell invocations, so `--script py` does not yet route those extension commands to Python — wiring `py` into the extension command templates is tracked separately.
|
||||
- Selection is per project: interactive `specify init` prompts for the script type, while non-interactive runs default to a shell variant by OS (`sh` on Linux/macOS, `ps` on Windows). `py` is chosen at the prompt or via `--script py`.
|
||||
- `sh` and `ps` remain fully supported. Nothing is removed, and `py` is not yet the default.
|
||||
|
||||
### Parity rule for contributors
|
||||
|
||||
All three script types are first-class: any change to a workflow script must update `sh`, `ps`, and `py` together and keep their tests (parity and unit) green. Making `py` the default and eventually retiring `sh`/`ps` is future work gated on adoption, tracked under the script-unification epic ([#3277](https://github.com/github/spec-kit/issues/3277)) — not something to act on from this doc.
|
||||
|
||||
## Special Processing Requirements
|
||||
|
||||
Some agents require custom processing beyond the standard template transformations:
|
||||
|
||||
155
CHANGELOG.md
155
CHANGELOG.md
@@ -2,161 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.14.2] - 2026-07-24
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Intake Review Governance preset to v0.1.1 (#3729)
|
||||
- Update Verify Review Ship extension to v0.3.0 (#3728)
|
||||
- Update Architecture Guard extension to v1.13.1 (#3724)
|
||||
- docs(upgrade): Claude Code files live in .claude/skills, not .claude/commands (#3708)
|
||||
- fix(kilocode): install commands under .kilo/commands (#3672)
|
||||
- fix(auth): normalize whitespace in auth-config env-var/id references at store time (#3691)
|
||||
- fix(workflows): guard non-mapping 'inputs:' block in engine._resolve_inputs (#3696)
|
||||
- Update Intake Authoring Governance preset to v0.2.0 (#3721)
|
||||
- docs: clarify shell-step interpolation safety (#3719)
|
||||
- [extension] Add Blueprint Index — Living Architecture Map extension to community catalog (#3718)
|
||||
- fix(github-http): return None on malformed host in resolve_github_release_asset_api_url (#3715)
|
||||
- fix(integrations): declare PiIntegration multi_install_safe (#3652)
|
||||
- harden: remove shell parameter from run_command() (#3716)
|
||||
- chore(deps): bump github/codeql-action/init from 4.37.1 to 4.37.3 (#3699)
|
||||
- fix: auto-correct conflicting feature prefixes (#1829)
|
||||
- chore(deps): bump actions/checkout from 6.0.3 to 7.0.1 (#3703)
|
||||
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#3702)
|
||||
- chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#3701)
|
||||
- chore(deps): bump astral-sh/setup-uv from 8.3.2 to 9.0.0 (#3700)
|
||||
- chore: release 0.14.1, begin 0.14.2.dev0 development (#3698)
|
||||
|
||||
## [0.14.1] - 2026-07-23
|
||||
|
||||
### Changed
|
||||
|
||||
- Update Agent Parity Governance preset to v0.4.0 (#3697)
|
||||
- fix(bundler): InstallResult.changed counts uninstalled as a change (#3692)
|
||||
- [preset] Update Cross-Platform Governance preset to v0.2.1 (#3695)
|
||||
- Update A11Y Governance preset to v0.4.1 (#3693)
|
||||
- fix(workflows): escape step-graph brackets in `workflow info` so the type shows (#3690)
|
||||
- fix(workflows): filter parser rejects trailing tokens (fullmatch, not match) (#3689)
|
||||
- Update iSAQB Architecture Governance preset to v0.2.1 (#3687)
|
||||
- fix(extensions): parse SKILL.md on the --- delimiter line during removal (#3634)
|
||||
- fix(cli): guard lazy .hostname ValueError in extension/preset add --from (#3651)
|
||||
- Update Architecture Governance preset to v0.5.1 (#3686)
|
||||
- fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config (#3659)
|
||||
- Update Security Governance preset to v0.6.1 (#3685)
|
||||
- fix(integrations): declare OmpIntegration multi_install_safe (#3650)
|
||||
- feat(git-extension): add configurable Conventional Commit support (#3390) (#3413)
|
||||
- fix(extensions): hyphenate command names in the Forge post-install listing (#3669)
|
||||
- fix(bundler): reject falsy non-mapping requires/provides in CatalogEntry.from_dict (#3667)
|
||||
- fix(bundler): reject falsy non-list bundles/contributed_components in records (#3666)
|
||||
- Update Intake Authoring Governance preset to v0.1.1 (#3678)
|
||||
- docs(extensions): clarify agent-context README and add config examples (#3389)
|
||||
- chore: release 0.14.0, begin 0.14.1.dev0 development (#3677)
|
||||
|
||||
## [0.14.0] - 2026-07-23
|
||||
|
||||
### Changed
|
||||
|
||||
- docs: add spec-kit-copilot to community friends (#3675)
|
||||
- fix(integrations): recompute invoke_separator from retained parsed_options (#3664)
|
||||
- fix(workflows): preserve intra-overlay order for multiple insert_after edits (#3662)
|
||||
- fix(bundler): reject falsy non-mapping requires/provides in manifest from_dict (#3661)
|
||||
- fix(bundler): dump_yaml writes literal UTF-8 (allow_unicode=True) (#3660)
|
||||
- fix(integrations): declare kiro-cli multi-install safe (#3477)
|
||||
- fix(git-extension): trim trailing whitespace before stripping commit-message quotes (#3673)
|
||||
- fix(bundler): order bundle members by canonical POSIX arcname (reproducible builds) (#3658)
|
||||
- fix(integrations): Cline overrides post_process_command_content (correct hook name) (#3657)
|
||||
- docs(workflows): gate step docstring lists the 'retry' on_reject behaviour (#3656)
|
||||
- fix: harden bounded reads and redirect validation (#3671)
|
||||
- fix(packaging): bundle scripts/python into the wheel core_pack (#3665) (#3670)
|
||||
- fix: bundle scripts/python in wheel so --script py works (#3665) (#3668)
|
||||
- docs(workflows): init step docstring lists the 'py' script type (#3655)
|
||||
- fix(integrations): declare LingmaIntegration multi_install_safe (#3654)
|
||||
- fix: guard constitution command against feature execution (#3646)
|
||||
- Fix duplicate step numbering in specify command (#3647)
|
||||
- docs(scripts): document the 'py' script type and sh/ps migration plan (#3284) (#3653)
|
||||
- harden: bound HTTP reads and enforce strict redirects (#3140)
|
||||
- chore: release 0.13.4, begin 0.13.5.dev0 development (#3649)
|
||||
|
||||
## [0.13.4] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- docs(concepts): document the spec-of-specs feature breakdown approach (#3648)
|
||||
- fix(scripts): git-ext PowerShell emits the '# To persist' SPECIFY_FEATURE hint (parity) (#3632)
|
||||
- fix(integrations): validate cached catalog shape before returning it (#3627)
|
||||
- fix(bundler): reject non-list 'catalogs' in bundle-catalogs.yml with a clean error (#3623)
|
||||
- fix(bundler): guard lazy .hostname ValueError in catalog add_source (#3644)
|
||||
- Add Intake Authoring Governance preset to community catalog (#3643)
|
||||
- feat: add Factory Droid CLI integration (#822) (#3587)
|
||||
- docs(installation): document the 'py' (Python) script type (#3640)
|
||||
- fix(init): show hyphenated /speckit-<name> in Next Steps for Forge projects (#3642)
|
||||
- fix(extensions): render hyphenated hook invocations for Forge projects (#3641)
|
||||
- fix(workflows): workflow add detects local YAML files case-insensitively (#3633)
|
||||
- fix(workflows): list-literal expression ignores trailing/empty commas (#3631)
|
||||
- fix(workflows): StepRegistry.add tolerates a corrupted non-dict existing entry (#3630)
|
||||
- fix(bundler): reject non-mapping 'integration' in a bundle manifest (#3629)
|
||||
- fix(workflows): command/prompt steps fail cleanly on a non-string integration (#3626)
|
||||
- docs(core): document the 'py' (Python) --script type in the init option table (#3625)
|
||||
- fix(workflows): gate prompt uses isdecimal() so a superscript digit doesn't crash (#3624)
|
||||
- fix(integrations): Cline dispatches hyphenated /speckit-<cmd> invocations (#3622)
|
||||
- docs(upgrade): document integration upgrade / extension update as the project-files upgrade path (#3326)
|
||||
- chore: release 0.13.3, begin 0.13.4.dev0 development (#3645)
|
||||
|
||||
## [0.13.3] - 2026-07-22
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(integrations): escape Rich markup in --integration-options error messages (#3458)
|
||||
- docs: document __SPECKIT_COMMAND_ token for portable cross-command references (#3503)
|
||||
- [preset] Add Parallel Autonomous Run Governance preset to community catalog (#3614)
|
||||
- docs(workflows): fix stale FanOutStep docstring claiming sequential-only execution (#3639)
|
||||
- [bundle] Add SicarioSpec Security & Governance Bundle to community catalog (#3636)
|
||||
- [preset] Update Autonomous Run Governance preset to v0.3.2 (#3615)
|
||||
- fix(workflows): validate every redirect hop when fetching workflow/step catalogs (#3637)
|
||||
- Add pipeline workflow to community catalog (#3338)
|
||||
- [extension] Add Linear Weave extension to community catalog (#3609)
|
||||
- docs: clarify hook priority validation semantics (#3594)
|
||||
- fix(workflows): reject a non-string 'integration'/'model' in command & prompt steps (#3597)
|
||||
- ci: add dependency audit workflow (#3138)
|
||||
- Add Intake Review Governance preset to community catalog (#3613)
|
||||
- fix(workflows): reject non-list input 'enum' instead of crashing (#3601)
|
||||
- chore: release 0.13.2, begin 0.13.3.dev0 development (#3617)
|
||||
|
||||
## [0.13.2] - 2026-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(workflows): reject a non-string 'command' in command-step (#3596)
|
||||
- fix(workflows): fail gate step loudly on a malformed 'options' (#3595)
|
||||
- fix(extensions): re-validate catalog URL after redirects (HTTPS parity/security) (#3524)
|
||||
- Add community bundle submission automation (#3553)
|
||||
- fix(presets): re-validate catalog URL after redirects (HTTPS parity/security) (#3523)
|
||||
- feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python (#3386)
|
||||
- fix(agents): parse frontmatter on the --- delimiter line, not any --- substring (#3590)
|
||||
- [bug-fix] Fix reinstall-overwrites-kept-config: preserve config on plain reinstall after --keep-config (#3449)
|
||||
- feat: update Bob integration to skills-based layout for Bob 2.0 (#3415)
|
||||
- Update OKF Knowledge Bundle Generator to v0.3.0 (#3608)
|
||||
- Add Test Coverage Drift Control extension to community catalog (#3607)
|
||||
- chore: align ruff lint scope (#3139)
|
||||
- feat(workflows): WorkflowResolver standalone (PR 1) (#3557)
|
||||
- fix(extensions,presets): surface clean error on malformed download URL (#3577)
|
||||
- chore: release 0.13.1, begin 0.13.2.dev0 development (#3610)
|
||||
|
||||
## [0.13.1] - 2026-07-21
|
||||
|
||||
### Changed
|
||||
|
||||
- fix(integrations): catch OverflowError on a `priority: .inf` in add/remove (#3589)
|
||||
- fix(workflows): reject bool / .inf catalog priority in workflow & step catalog loaders (#3526)
|
||||
- fix(catalogs): 'priority: .inf' yields a clean validation error instead of crashing (#3525)
|
||||
- docs(integrations): document the 'integration list --catalog' flag (#3530)
|
||||
- fix(workflows): fail fan-in loudly on a non-string wait_for entry (#3579)
|
||||
- fix(workflows): fail fan-out loudly on a truthy non-mapping step template (#3537)
|
||||
- fix(workflows): reject a non-string prompt in prompt-step validate() (#3582)
|
||||
- fix(workflows): route 'workflow status --json' errors to stderr (#3520)
|
||||
- fix(integrations): Forge dispatches hyphenated /speckit-<cmd> invocations (#3529)
|
||||
- chore: release 0.13.0, begin 0.13.1.dev0 development (#3588)
|
||||
|
||||
## [0.13.0] - 2026-07-17
|
||||
|
||||
### Changed
|
||||
|
||||
@@ -113,27 +113,6 @@ uv pip install -e ".[test]"
|
||||
> `specify_cli` to this checkout's `src/`. This matches the gotcha documented in
|
||||
> `AGENTS.md` (Common Pitfalls).
|
||||
|
||||
#### Security checks
|
||||
|
||||
```bash
|
||||
uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off
|
||||
```
|
||||
|
||||
This command audits the committed hashed requirements snapshot. Pull request,
|
||||
push, and manual CI runs use the same snapshot so their results stay
|
||||
deterministic. If dependency metadata changes, refresh and commit the snapshot
|
||||
before auditing it:
|
||||
|
||||
```bash
|
||||
uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes --quiet --no-header --output-file .github/security-audit-requirements.txt
|
||||
```
|
||||
|
||||
The scheduled CI audit resolves the runtime and `test` extra dependency set
|
||||
across the supported Python and OS matrix to catch newly published advisories.
|
||||
Upstream package releases drift over time, so even an unrelated PR touching
|
||||
`pyproject.toml` can fail the `dependency-audit` check until the committed file
|
||||
is regenerated with the command above and re-committed.
|
||||
|
||||
#### Shell scripts
|
||||
|
||||
```bash
|
||||
|
||||
@@ -1,35 +0,0 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json",
|
||||
"bundles": {
|
||||
"sicario-spec": {
|
||||
"name": "SicarioSpec Security & Governance Bundle",
|
||||
"id": "sicario-spec",
|
||||
"version": "0.5.1",
|
||||
"role": "security-engineer",
|
||||
"description": "Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates.",
|
||||
"author": "SicarioSpec Contributors",
|
||||
"license": "MIT",
|
||||
"download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.5.1/sicario-spec-0.5.1.zip",
|
||||
"repository": "https://github.com/dfirs1car1o/sicario-spec",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.9.0"
|
||||
},
|
||||
"provides": {
|
||||
"extensions": 1,
|
||||
"presets": 11,
|
||||
"steps": 0,
|
||||
"workflows": 0
|
||||
},
|
||||
"tags": [
|
||||
"security",
|
||||
"governance",
|
||||
"compliance",
|
||||
"appsec",
|
||||
"threat-modeling"
|
||||
],
|
||||
"verified": false
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,7 @@
|
||||
|
||||
Bundles compose existing Spec Kit components — extensions, presets, workflows, and steps — into a single role or team stack. They are useful when a user should be able to install a tested set of components together instead of following several separate install commands.
|
||||
|
||||
Accepted community bundle entries are published in [`bundles/catalog.community.json`](https://github.com/github/spec-kit/blob/main/bundles/catalog.community.json) and listed below. The built-in community source is discovery-only: `specify bundle search` and `specify bundle info` can inspect entries, but installing by ID requires explicitly adding an install-allowed catalog. Explicit catalogs use a higher default precedence than the built-in community source. To submit a bundle for review, file a [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue.
|
||||
|
||||
| Bundle | Purpose | Role or team | Provides | Required catalogs | URL |
|
||||
|--------|---------|--------------|----------|-------------------|-----|
|
||||
| SicarioSpec Security & Governance Bundle | Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates. | `security-engineer` | 1 extension, 11 presets | Documented | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
Accepted community bundle entries will be listed here once a community bundle catalog is available. To submit a bundle for review, file a [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue.
|
||||
|
||||
## What to Submit
|
||||
|
||||
|
||||
@@ -36,7 +36,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
|
||||
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
|
||||
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
|
||||
| Blueprint Index — Living Architecture Map | A living architecture map for spec-driven projects, kept honest by a deterministic, low-friction, machine-first CI gate (JSON, self-healable) that blocks only when the map contradicts the specs or code. Brownfield or greenfield. | `process` | Read+Write | [spec-kit-blueprint](https://github.com/ogil109/spec-kit-blueprint) |
|
||||
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
|
||||
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases — auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
|
||||
| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) |
|
||||
@@ -71,7 +70,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| 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) |
|
||||
| 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) |
|
||||
| LLM Wiki | LLM-maintained compounding project wiki: source ingestion, cited answers, and consistency linting | `docs` | Read+Write | [spec-kit-wiki](https://github.com/formin/spec-kit-wiki) |
|
||||
| Loop Engineering | Engineer safe autonomous agent loops for spec-driven development: a maker/checker split, externalized loop state, and stay-the-engineer guardrails against comprehension debt and cognitive surrender | `process` | Read+Write | [spec-kit-loop](https://github.com/formin/spec-kit-loop) |
|
||||
| MAQA — Multi-Agent & Quality Assurance | Coordinator → feature → QA agent workflow with parallel worktree-based implementation. Language-agnostic. Auto-detects installed board plugins. Optional CI gate. | `process` | Read+Write | [spec-kit-maqa-ext](https://github.com/GenieRobot/spec-kit-maqa-ext) |
|
||||
@@ -91,7 +89,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Multi-Repo Branch Sync | Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks | `process` | Read+Write | [multi-repo-sync](https://github.com/fyloss/spec-kit-multi-repo-sync) |
|
||||
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
|
||||
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
|
||||
| OKF Knowledge Bundle Generator | Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for significance and rationale, and resolving open questions with the user | `docs` | Read+Write | [speckit_ofk](https://github.com/alexcpn/speckit_ofk) |
|
||||
| OKF Knowledge Bundle Generator | Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository | `docs` | Read+Write | [speckit_ofk](https://github.com/alexcpn/speckit_ofk) |
|
||||
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
|
||||
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
|
||||
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
|
||||
@@ -151,7 +149,6 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
|
||||
| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) |
|
||||
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
|
||||
| Test Coverage Drift Control | Generate incremental coverage drift reports and planned remediation tasks after implementation | `code` | Read+Write | [spec-kit-test-coverage-drift-control](https://github.com/benizzio/spec-kit-test-coverage-drift-control) |
|
||||
| Time Machine | Retroactively apply the full SDD workflow to existing codebases — analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
|
||||
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
|
||||
| Token Budget | Reduces LLM token consumption in Spec Kit workflows: compact artifacts in-place, scope per-phase reading, suppress prose padding, and report token usage | `process` | Read+Write | [spec-kit-token-budget](https://github.com/tinesoft/spec-kit-token-budget) |
|
||||
@@ -159,7 +156,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
|
||||
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
|
||||
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
|
||||
| Verify Review Ship | Adds verify and review quality gates plus transactional merge, cleanup, and delivery summary | `process` | Read+Write | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
|
||||
| Verify Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
|
||||
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
|
||||
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
|
||||
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# Community Friends
|
||||
|
||||
> [!NOTE]
|
||||
> Community projects listed here are independently created and maintained by their respective authors. Unless explicitly marked as a **first-party GitHub project**, they are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion.
|
||||
> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion.
|
||||
|
||||
Community projects that extend, visualize, or build on Spec Kit:
|
||||
|
||||
@@ -16,5 +16,3 @@ Community projects that extend, visualize, or build on Spec Kit:
|
||||
- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** — Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace.
|
||||
|
||||
- **[spectatui](https://github.com/tinesoft/spectatui)** — A terminal UI (TUI) dashboard for Spec Kit that lets you track features, manage specifications, integrations, presets, workflows, and extensions, and monitor AI agent workflows. Attach to existing AI sessions or launch new ones from your terminal. Keyboard and mouse support. Light/dark theme support. Customizable and performance-oriented. Requires the `specify` CLI in your PATH.
|
||||
|
||||
- **[spec-kit-copilot](https://github.com/github/spec-kit-copilot)** — _First-party GitHub project._ A GitHub Copilot **skills plugin** that exposes the Spec Kit `specify` CLI to the Copilot agent in both the Copilot CLI and the GitHub Copilot app. It provides a focused skill per `specify` command group — setup, init, check, extensions, presets, bundles, workflows, workflow steps, and self-upgrade — so you can navigate and drive the entire Spec Kit ecosystem through natural language, letting Copilot decide when and how to run the right `specify` commands on your behalf.
|
||||
|
||||
@@ -7,28 +7,25 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Architecture Governance | Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Architecture Governance | Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery with validated status, stop, resume, exact-head proof, closeout, and learner guidance. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
| Cross-Platform Governance | Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence. | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Cross-Platform Governance | Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
||||
| Intake Authoring Governance | Governs traceable intake CRUD, bounded public HTTPS sources, and explicitly approved single or series authoring without granting execution authority. | 10 templates, 5 commands, 4 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
|
||||
| Intake Review Governance | Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
|
||||
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Parallel Autonomous Run Governance | Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | — | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
|
||||
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
|
||||
|
||||
@@ -63,14 +63,10 @@ independently specified sub-features. Each sub-feature gets its own
|
||||
`spec.md`, `plan.md`, and `tasks.md`, and runs through its own
|
||||
specify/plan/tasks/implement cycle.
|
||||
|
||||
This is the "spec of specs" approach: a first pass breaks a massive feature into
|
||||
smaller, self-contained specs that can each be implemented without overwhelming the
|
||||
model. It adds the most overhead, so reserve it for features that are too large to
|
||||
handle any other way.
|
||||
|
||||
See [Spec of Specs](spec-of-specs.md) for the full procedure — how to run the
|
||||
roadmap pass, structure the roadmap artifact, link sub-specs back to it, and a worked
|
||||
example.
|
||||
This is the "spec of specs" approach: the first iteration breaks a massive
|
||||
feature into smaller, self-contained specs that can each be implemented without
|
||||
overwhelming the model. It adds the most overhead, so reserve it for features
|
||||
that are too large to handle any other way.
|
||||
|
||||
## Which Approach to Choose
|
||||
|
||||
|
||||
@@ -1,171 +0,0 @@
|
||||
# Spec of Specs
|
||||
|
||||
When a feature is too large to run through a single
|
||||
`/speckit.specify` → `/speckit.plan` → `/speckit.tasks` → `/speckit.implement`
|
||||
cycle without the model losing track mid-implementation, you can break it into a
|
||||
**roadmap** of smaller, independently-specified sub-features. This is the "spec of
|
||||
specs" approach: one up-front pass decomposes a massive feature into self-contained
|
||||
specs, and each of those runs through its own specify/plan/tasks/implement cycle.
|
||||
|
||||
> **When to reach for this.** Decomposition adds the most overhead of any strategy
|
||||
> in [Handling Complex Features](complex-features.md). Use it **only when the lighter
|
||||
> options there are insufficient** — first try limiting how many tasks run per
|
||||
> `/speckit.implement` invocation, then sub-agent delegation, then a combination.
|
||||
> Reach for a spec of specs only when even a single phase is too large to handle in
|
||||
> one run.
|
||||
|
||||
The rest of this page describes *how* to do it with the tools you already have. No
|
||||
new commands or extensions are required.
|
||||
|
||||
## The roadmap pass
|
||||
|
||||
Before writing any sub-spec, do a single decomposition pass to produce a roadmap.
|
||||
Treat this as a lightweight planning conversation with your agent, not a full spec:
|
||||
|
||||
1. **State the whole feature.** Describe the large feature (the "epic") in a
|
||||
sentence or two so the agent has the full picture up front.
|
||||
2. **Identify independent slices.** Ask the agent to propose a small set of
|
||||
sub-features that each deliver a coherent piece of the epic and can be specified
|
||||
on their own. Aim for slices that are independently testable — implementing just
|
||||
one should leave you with something demonstrable.
|
||||
3. **Draw the boundaries.** For each slice, write one line of intent and an explicit
|
||||
scope boundary (what is in, what is deferred to a sibling slice). Sharp
|
||||
boundaries are what keep each sub-spec small enough to fit in context.
|
||||
4. **Order by dependency.** Note which slices depend on others and sequence them so
|
||||
prerequisites come first. Slices with no dependency on each other can be built in
|
||||
any order. To build independent slices in parallel, use separate worktrees so each
|
||||
run has isolated active-feature state.
|
||||
5. **Record the result as a roadmap.** Capture the slices in a durable roadmap file
|
||||
(below) so every later sub-spec can point back to it.
|
||||
|
||||
The roadmap is deliberately shallow: it names and orders the sub-features but does
|
||||
**not** design them. The design happens when each slice runs through its own
|
||||
`/speckit.specify`.
|
||||
|
||||
## The roadmap artifact
|
||||
|
||||
The roadmap is an ordinary Markdown file you author and keep under version control —
|
||||
there is no special tooling behind it. Put it where the sub-specs can find it:
|
||||
|
||||
- For a feature-scoped epic: `specs/<epic-slug>/roadmap.md`.
|
||||
- For a larger, cross-cutting epic: a top-level `ROADMAP.md`.
|
||||
|
||||
Each roadmap entry carries a stable id (used later for linking), a name, its intent,
|
||||
its scope boundary, its dependencies, a status, and — once the sub-spec exists — a
|
||||
link to it. A minimal template:
|
||||
|
||||
```markdown
|
||||
# Roadmap: <epic name>
|
||||
|
||||
<One or two sentences: what the epic is and why it is being decomposed.>
|
||||
|
||||
**Status legend**: planned · in-progress · done
|
||||
|
||||
| ID | Sub-feature | Intent | Scope boundary | Depends on | Status | Sub-spec |
|
||||
|----|-------------|--------|----------------|-----------|--------|----------|
|
||||
| R1 | <name> | <one line> | <in / deferred> | — | planned | — |
|
||||
| R2 | <name> | <one line> | <in / deferred> | R1 | planned | — |
|
||||
| R3 | <name> | <one line> | <in / deferred> | R1 | planned | — |
|
||||
```
|
||||
|
||||
Keep the `ID` column immutable once a sub-spec references it — it is the anchor for
|
||||
traceability. Fill in the `Sub-spec` column with the path to each sub-feature's spec
|
||||
directory as you create it, and update `Status` as work progresses.
|
||||
|
||||
## Specifying each sub-feature
|
||||
|
||||
With the roadmap in hand, work through the entries one at a time using the normal
|
||||
Spec Kit flow — nothing new to learn:
|
||||
|
||||
1. Pick the next roadmap entry whose dependencies are already `done` (or have none).
|
||||
2. Run `/speckit.specify` for just that slice, describing only its intent and scope
|
||||
from the roadmap entry. Because the slice is bounded, its spec, plan, and tasks
|
||||
stay well within the context window.
|
||||
3. Run `/speckit.plan`, `/speckit.tasks`, and `/speckit.implement` for that slice as
|
||||
usual.
|
||||
4. Mark the roadmap entry `done` and move to the next one.
|
||||
|
||||
Each slice is a complete, independent Spec Kit feature with its own
|
||||
`spec.md`/`plan.md`/`tasks.md`. The roadmap is what ties them together.
|
||||
|
||||
## Linking sub-specs to the roadmap
|
||||
|
||||
To keep scope and intent from drifting across separate runs, every sub-spec
|
||||
references its roadmap entry, and the roadmap links back — a simple, greppable,
|
||||
bidirectional convention:
|
||||
|
||||
- **Sub-spec → roadmap.** In the sub-feature's `spec.md`, name the parent roadmap
|
||||
and entry id in the `Input` / summary line, for example:
|
||||
|
||||
```markdown
|
||||
**Input**: Parent roadmap: `specs/<epic>/roadmap.md` → entry **R3**. <feature description>
|
||||
```
|
||||
|
||||
- **Roadmap → sub-spec.** In the roadmap table, set the entry's `Sub-spec` column to
|
||||
the sub-feature's directory, e.g. `specs/<epic>-part-3/`.
|
||||
|
||||
Because both directions are plain text, you can trace any sub-spec back to its place
|
||||
in the epic (and find its siblings) with a quick search — no tooling, no metadata
|
||||
schema.
|
||||
|
||||
## Keeping the roadmap and sub-specs in sync
|
||||
|
||||
The roadmap is a living document. As you learn more, keep it and the sub-specs
|
||||
aligned:
|
||||
|
||||
- **Roadmap first, then reconcile.** When scope shifts, update the roadmap entry
|
||||
first, then update any sub-specs it affects. The roadmap is the source of truth for
|
||||
how the epic is divided.
|
||||
- **Respect dependencies and ordering.** If a slice depends on another, build the
|
||||
prerequisite first and cross-reference the dependent sub-spec so the relationship
|
||||
is visible from both sides.
|
||||
- **Recurse when a slice is still too big.** If a sub-feature turns out to be too
|
||||
large to specify in one cycle, give it its own roadmap and decompose it further —
|
||||
the same approach applies one level down. Recursion adds overhead, so only go as
|
||||
deep as the context problem actually requires.
|
||||
|
||||
## Worked example
|
||||
|
||||
Suppose the epic is **"Add a self-service billing portal"** — far too large for a
|
||||
single cycle. The roadmap pass breaks it into three independently-specifiable
|
||||
slices.
|
||||
|
||||
`specs/billing-portal/roadmap.md`:
|
||||
|
||||
```markdown
|
||||
# Roadmap: Self-service billing portal
|
||||
|
||||
Let customers view invoices, manage payment methods, and change plans without
|
||||
contacting support. Too large for one cycle, so it is split into independent slices.
|
||||
|
||||
**Status legend**: planned · in-progress · done
|
||||
|
||||
| ID | Sub-feature | Intent | Scope boundary | Depends on | Status | Sub-spec |
|
||||
|----|--------------------|------------------------------------------|---------------------------------------------|-----------|---------|----------|
|
||||
| R1 | Invoice history | Customers view and download past invoices | Read-only; no payment actions | — | done | specs/billing-invoices/ |
|
||||
| R2 | Payment methods | Add, remove, and set a default card | No plan changes; assumes invoices exist | R1 | in-progress | specs/billing-payment-methods/ |
|
||||
| R3 | Plan changes | Upgrade/downgrade the subscription plan | Uses R2's default payment method | R1, R2 | planned | — |
|
||||
```
|
||||
|
||||
Each slice is then specified on its own. For example, the **R2** sub-feature's
|
||||
`spec.md` opens with a back-reference:
|
||||
|
||||
```markdown
|
||||
# Feature Specification: Billing — payment methods
|
||||
|
||||
**Input**: Parent roadmap: `specs/billing-portal/roadmap.md` → entry **R2**.
|
||||
Let customers add, remove, and set a default payment method in the billing portal.
|
||||
```
|
||||
|
||||
From here a reader can trace **R2** back to the roadmap, see that it depends on
|
||||
**R1** (invoice history, already `done`), and see that **R3** (plan changes) is
|
||||
waiting on it. Building R1, then R2, then R3 keeps every run small while the roadmap
|
||||
preserves the shape of the whole epic.
|
||||
|
||||
## For automation (optional)
|
||||
|
||||
If you would rather automate roadmap capture and consistency checks than maintain
|
||||
the file by hand, the community-maintained
|
||||
[Spec Roadmap extension](https://github.com/srobroek/speckit-roadmap) explores that
|
||||
direction. It is a third-party extension and is not required — the manual convention
|
||||
above is enough on its own.
|
||||
@@ -77,9 +77,9 @@ specify init <project_name> --integration pi
|
||||
specify init <project_name> --integration omp
|
||||
```
|
||||
|
||||
### Specify Script Type (Shell, PowerShell, or Python)
|
||||
### Specify Script Type (Shell vs PowerShell)
|
||||
|
||||
Automation scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants.
|
||||
All automation scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants.
|
||||
|
||||
Auto behavior:
|
||||
|
||||
@@ -92,7 +92,6 @@ Force a specific script type:
|
||||
```bash
|
||||
specify init <project_name> --script sh
|
||||
specify init <project_name> --script ps
|
||||
specify init <project_name> --script py
|
||||
```
|
||||
|
||||
### Ignore Agent Tools Check
|
||||
@@ -132,7 +131,6 @@ Scripts are installed into a variant subdirectory matching the chosen script typ
|
||||
|
||||
- `.specify/scripts/bash/` — contains `.sh` scripts (default on Linux/macOS)
|
||||
- `.specify/scripts/powershell/` — contains `.ps1` scripts (default on Windows)
|
||||
- `.specify/scripts/python/` — contains `.py` scripts (chosen with `--script py`; also installs the platform shell fallback)
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
This guide shows how to iterate on the `specify` CLI locally without publishing a release or committing to `main` first.
|
||||
|
||||
> Scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
|
||||
> Scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants. The CLI auto-selects based on OS unless you pass `--script sh|ps`.
|
||||
|
||||
## 1. Clone and Switch Branches
|
||||
|
||||
@@ -120,10 +120,10 @@ generated metadata, then add the import and `_register()` call in
|
||||
|
||||
## 7. Run Lint / Basic Checks
|
||||
|
||||
CI enforces `ruff check src tests` (see `.github/workflows/test.yml`), so run it locally before pushing:
|
||||
CI enforces `ruff check src/` (see `.github/workflows/test.yml`), so run it locally before pushing:
|
||||
|
||||
```bash
|
||||
uvx ruff check src tests
|
||||
uvx ruff check src/
|
||||
```
|
||||
|
||||
You can also quickly sanity check importability:
|
||||
@@ -189,7 +189,7 @@ rm -rf .venv dist build *.egg-info
|
||||
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
|
||||
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
|
||||
| Git commands unavailable | Install the git extension with `specify extension add git` |
|
||||
| Wrong script type downloaded | Pass `--script sh`, `--script ps`, or `--script py` explicitly |
|
||||
| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
|
||||
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
|
||||
|
||||
## 14. Next Steps
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
This guide will help you get started with Spec-Driven Development using Spec Kit. Throughout, we illustrate each step with a running example: **Taskify**, a small team productivity platform.
|
||||
|
||||
> [!NOTE]
|
||||
> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
|
||||
> Automation scripts are provided as both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on your OS unless you pass `--script sh|ps`.
|
||||
|
||||
> [!NOTE]
|
||||
> Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes — the steps are otherwise identical.
|
||||
|
||||
@@ -12,7 +12,7 @@ specify init [<project_name>]
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--integration <key>` | AI coding agent integration to use (e.g. `copilot`, `claude`, `gemini`). See the [Integrations reference](integrations.md) for all available keys |
|
||||
| `--integration-options` | Options for the integration (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
|
||||
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--here` | Initialize in the current directory instead of creating a new one |
|
||||
| `--force` | Force merge/overwrite when initializing in an existing directory |
|
||||
| `--ignore-agent-tools` | Skip checks for AI coding agent CLI tools |
|
||||
|
||||
@@ -221,14 +221,12 @@ Each hook entry supports the following fields:
|
||||
| `command` | Extension command associated with the hook. |
|
||||
| `enabled` | Whether the hook is active. Hooks with `enabled: false` are skipped. |
|
||||
| `optional` | Whether the hook is optional. If `true`, the hook is presented with its `prompt` and can be skipped; if `false`, the hook is emitted as an automatic hook (includes `EXECUTE_COMMAND` markers). |
|
||||
| `priority` | Priority metadata for the hook. Registered hook entries use integer values >= 1; entries installed from manifests default to `10` when no priority is declared. Current command templates surface hooks in their configured YAML order and do not sort them by `priority`. |
|
||||
| `priority` | Priority metadata for the hook. Values must be integers >= 1; invalid values fall back to the default priority `10`. Current command templates surface hooks in their configured YAML order and do not sort them by `priority`. |
|
||||
| `prompt` | Message shown when asking whether to run an optional hook. |
|
||||
| `description` | Human-readable explanation of what the hook does. |
|
||||
| `condition` | Optional expression evaluated by `HookExecutor` (using `config.<path>` or `env.<VAR>` with `is set`, `==`, or `!=`). Current command templates do not evaluate conditions and skip hooks with a non-empty condition. |
|
||||
Hook event names identify when a hook is invoked. They generally use `before_<command>` or `after_<command>`, such as `before_implement`, `after_implement`, `before_tasks`, and `after_tasks`.
|
||||
|
||||
Extension manifests reject invalid hook priorities during installation. For existing `.specify/extensions.yml` entries, `HookExecutor.get_hooks_for_event()` sorts with `normalize_priority()`: missing values, booleans, non-numeric values rejected by `int()`, and values less than `1` fall back to `10`; numeric strings and finite floats are coerced with `int()`, while non-finite floats are unsupported and may fail instead of falling back.
|
||||
|
||||
`HookExecutor.get_hooks_for_event()` returns hooks ordered by `priority`, with lower values first. However, current command templates read hook lists directly and surface them in their configured YAML order rather than using priority ordering.
|
||||
|
||||
## FAQ
|
||||
|
||||
@@ -15,7 +15,6 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
|
||||
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
|
||||
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-<command>` |
|
||||
| [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-<command>` |
|
||||
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
|
||||
| [Forge](https://forgecode.dev/) | `forge` | |
|
||||
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
|
||||
@@ -23,9 +22,9 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
|
||||
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-<command>` |
|
||||
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
|
||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | Skills-based integration by default; installs skills as `speckit-<command>/SKILL.md` under `.bob/skills/` and invokes them as `/speckit-<command>`. Pass `--integration-options="--legacy-commands"` to scaffold the deprecated Bob 1.x layout (`.bob/commands/*.md`) instead; that flag will be removed in a future release. Existing legacy installs can migrate with `specify integration upgrade bob --integration-options="--skills"`, which converts them to the skills layout and removes the old command files. If preset overrides are installed, the migration is rejected with an actionable error (preset artifacts cannot yet be reconciled across a layout change) — remove the preset(s), migrate, then reinstall them. |
|
||||
| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
|
||||
| [Junie](https://junie.jetbrains.com/) | `junie` | |
|
||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | Installs commands into `.kilo/commands`; legacy `.kilocode/workflows` installs remain supported as a registration fallback |
|
||||
| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
|
||||
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
|
||||
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
|
||||
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
|
||||
@@ -49,11 +48,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
|
||||
specify integration list
|
||||
```
|
||||
|
||||
| Option | Description |
|
||||
| ----------- | ----------------------------------------------------------------------------------------------------------------------- |
|
||||
| `--catalog` | Also browse the catalog (built-in **and** community). Community integrations that are not built in are only shown here. |
|
||||
|
||||
Shows the built-in integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based.
|
||||
Shows all available integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based.
|
||||
When multiple integrations are installed, the list marks the default integration separately from the other installed integrations.
|
||||
The list also shows whether each built-in integration is declared multi-install safe.
|
||||
|
||||
@@ -86,7 +81,7 @@ specify integration install <key>
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--force` | Opt in to installing alongside integrations that are not declared multi-install safe |
|
||||
| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
|
||||
|
||||
@@ -122,7 +117,7 @@ specify integration switch <key>
|
||||
|
||||
| Option | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default |
|
||||
| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) |
|
||||
| `--integration-options` | Options for the target integration when it is not already installed |
|
||||
@@ -150,7 +145,7 @@ specify integration upgrade [<key>]
|
||||
| Option | Description |
|
||||
| ------------------------ | ------------------------------------------------------------------------ |
|
||||
| `--force` | Overwrite files even if they have been modified |
|
||||
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
|
||||
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--integration-options` | Options for the integration |
|
||||
|
||||
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
|
||||
@@ -257,34 +252,31 @@ Spec Kit tracks one default integration in `.specify/integration.json` with `def
|
||||
|
||||
An integration is multi-install safe when it uses a static, unique agent root and command directory, stable command invocation settings, and a separate install manifest whose managed files do not overlap another safe integration. Registry tests enforce those path and manifest invariants. Shared Spec Kit templates remain aligned to the single default integration.
|
||||
|
||||
The Command directory column below lists the directory each integration installs its commands or skills into. Context-file targeting is a separate concern from integration multi-install safety: `multi_install_safe` is an integration declaration about command/skill paths, whereas the optional agent-context extension manages a per-agent context file (for example `AGENTS.md` or `CLAUDE.md`) and can even synchronize several anchors at once via its `context_files` setting. Multiple agents mapping to the same context file is expected there and does not affect whether an integration is multi-install safe; see the agent-context extension for details.
|
||||
The Isolation column below lists paths Spec Kit manages for that integration (skills/commands roots and any integration-owned rule files). It is not a full inventory of every file an agent may read.
|
||||
|
||||
**Agent-context defaults are separate.** The optional agent-context extension maps each integration to a default context file in `extensions/agent-context/agent-context-defaults.json`. Those defaults are independent of multi-install safety: several agents may share a root file such as `AGENTS.md` when the extension is enabled. Multi-install safety does not require a unique context file per safe integration.
|
||||
|
||||
The currently declared multi-install safe integrations are:
|
||||
|
||||
| Key | Command directory |
|
||||
| --- | ----------------- |
|
||||
| `auggie` | `.augment/commands` |
|
||||
| `claude` | `.claude/skills` |
|
||||
| `cline` | `.clinerules/workflows` |
|
||||
| `codebuddy` | `.codebuddy/commands` |
|
||||
| `codex` | `.agents/skills` |
|
||||
| `cursor-agent` | `.cursor/skills` |
|
||||
| `droid` | `.factory/skills` |
|
||||
| `firebender` | `.firebender/commands` |
|
||||
| `gemini` | `.gemini/commands` |
|
||||
| Key | Isolation |
|
||||
| --- | --------- |
|
||||
| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` |
|
||||
| `claude` | `.claude/skills`, `CLAUDE.md` |
|
||||
| `cline` | `.clinerules/workflows`, `.clinerules/specify-rules.md` |
|
||||
| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
|
||||
| `codex` | `.agents/skills`, `AGENTS.md` |
|
||||
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
|
||||
| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` |
|
||||
| `gemini` | `.gemini/commands`, `GEMINI.md` |
|
||||
| `grok` | `.grok/skills` |
|
||||
| `junie` | `.junie/commands` |
|
||||
| `kilocode` | `.kilo/commands` |
|
||||
| `kiro-cli` | `.kiro/prompts` |
|
||||
| `lingma` | `.lingma/skills` |
|
||||
| `omp` | `.omp/commands` |
|
||||
| `pi` | `.pi/prompts` |
|
||||
| `qodercli` | `.qoder/commands` |
|
||||
| `qwen` | `.qwen/commands` |
|
||||
| `shai` | `.shai/commands` |
|
||||
| `tabnine` | `.tabnine/agent/commands` |
|
||||
| `trae` | `.trae/skills` |
|
||||
| `zcode` | `.zcode/skills` |
|
||||
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
|
||||
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
|
||||
| `qodercli` | `.qoder/commands`, `QODER.md` |
|
||||
| `qwen` | `.qwen/commands`, `QWEN.md` |
|
||||
| `shai` | `.shai/commands`, `SHAI.md` |
|
||||
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
|
||||
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
|
||||
| `zcode` | `.zcode/skills`, `ZCODE.md` |
|
||||
|
||||
Integrations that share a command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
|
||||
|
||||
|
||||
@@ -91,192 +91,8 @@ specify workflow add <source>
|
||||
| `--dev` | Install from a local workflow YAML file or directory |
|
||||
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
|
||||
|
||||
Installs a workflow from the catalog, a URL (HTTPS required), a local YAML file, or a local directory containing `workflow.yml`.
|
||||
Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
|
||||
|
||||
## Workflow Overlays
|
||||
|
||||
Workflow overlays let a project extend or override an installed workflow without editing the installed `workflow.yml`. This keeps local customizations safe across `specify bundle update` or `specify workflow add` upgrades.
|
||||
|
||||
When `specify workflow run <workflow-id>` loads a workflow, the engine composes the base workflow with all enabled overlays for that workflow id. The result is validated like any other workflow definition.
|
||||
|
||||
### How Overlays Work
|
||||
|
||||
An overlay is a YAML file that declares a set of edit operations against the step list of a base workflow. Overlays use lower-wins precedence: higher priority numbers are applied first and lower numbers last. Equal-priority overlays are applied alphabetically by ID, with the last ID winning conflicts.
|
||||
|
||||
Project overlay files live at:
|
||||
|
||||
| Location | Purpose |
|
||||
| --- | --- |
|
||||
| `.specify/workflows/overlays/<id>/*.yml` | Project-local customizations |
|
||||
|
||||
### Overlay File Format
|
||||
|
||||
The recommended edit format uses the operation name as the key and the anchor step id as the value:
|
||||
|
||||
```yaml
|
||||
id: "my-overlay"
|
||||
extends: "speckit"
|
||||
priority: 10
|
||||
enabled: true
|
||||
edits:
|
||||
- insert_after: implement
|
||||
step:
|
||||
id: run-lint
|
||||
type: shell
|
||||
run: "ruff check src/"
|
||||
|
||||
- replace: review-spec
|
||||
step:
|
||||
id: review-spec
|
||||
type: gate
|
||||
message: "Review the generated spec (overlay override)."
|
||||
options: [approve, reject]
|
||||
on_reject: abort
|
||||
```
|
||||
|
||||
The explicit form is also supported:
|
||||
|
||||
```yaml
|
||||
edits:
|
||||
- operation: insert_after
|
||||
anchor: implement
|
||||
step:
|
||||
id: run-lint
|
||||
type: shell
|
||||
run: "ruff check src/"
|
||||
```
|
||||
|
||||
#### Fields
|
||||
|
||||
| Field | Required | Description |
|
||||
| --- | --- | --- |
|
||||
| `id` | yes | Identifier for this overlay. Used in `specify workflow overlay *` commands. Must be lowercase letters, digits, and hyphens only; no dots, underscores, path separators, or `overlays`. |
|
||||
| `extends` | yes | The workflow id this overlay applies to. Uses the same safe-id format as `id`; `overlays`, `runs`, and `steps` are reserved. |
|
||||
| `priority` | no | Integer; defaults to `10`. Lower values have higher precedence and win conflicts. Missing or invalid values fall back to `10`. |
|
||||
| `enabled` | no | Boolean. Defaults to `true`. Disabled overlays are ignored. |
|
||||
| `edits` | yes | Non-empty list of edit operations. |
|
||||
|
||||
#### Edit Operations
|
||||
|
||||
| Operation | `step` required | Effect |
|
||||
| --- | --- | --- |
|
||||
| `insert_after` | yes | Insert `step` immediately after the anchor step. |
|
||||
| `insert_before` | yes | Insert `step` immediately before the anchor step. |
|
||||
| `replace` | yes | Replace the anchor step with `step`. |
|
||||
| `remove` | no | Remove the anchor step from the list. |
|
||||
|
||||
The `anchor` is the `id` of a step in the base workflow. Anchors are resolved recursively inside `then`, `else`, `steps`, `cases.*`, and `default` blocks, so nested base steps can also be targeted. Fan-out templates (`step` inside a `fan-out` step) are **not** valid anchors.
|
||||
|
||||
Step ids must not contain `:` — that character is reserved for engine-generated nested ids.
|
||||
|
||||
### Overlay CLI Commands
|
||||
|
||||
#### Add a Project Overlay
|
||||
|
||||
```bash
|
||||
specify workflow overlay add <path-to-overlay.yml> --priority <n>
|
||||
```
|
||||
|
||||
Validates the overlay file and copies it to `.specify/workflows/overlays/<extends>/<id>.yml`. `--priority` defaults to `10` and overrides the `priority` field in the file.
|
||||
|
||||
#### List Overlays
|
||||
|
||||
```bash
|
||||
specify workflow overlay list <workflow-id>
|
||||
```
|
||||
|
||||
Shows all overlays for the workflow, ordered by resolver precedence. Disabled overlays are marked as disabled in the listing and are ignored during workflow resolution.
|
||||
|
||||
#### Change Priority
|
||||
|
||||
```bash
|
||||
specify workflow overlay set-priority <workflow-id> <overlay-id> <n>
|
||||
```
|
||||
|
||||
#### Enable or Disable
|
||||
|
||||
```bash
|
||||
specify workflow overlay disable <workflow-id> <overlay-id>
|
||||
specify workflow overlay enable <workflow-id> <overlay-id>
|
||||
```
|
||||
|
||||
#### Remove
|
||||
|
||||
```bash
|
||||
specify workflow overlay remove <workflow-id> <overlay-id>
|
||||
```
|
||||
|
||||
Removes the project overlay file.
|
||||
|
||||
#### Inspect the Composed Workflow
|
||||
|
||||
```bash
|
||||
specify workflow resolve <workflow-id>
|
||||
```
|
||||
|
||||
Prints the layer stack (base + overlays) and the source attribution for each step after composition. Useful for debugging which overlay contributed or overrode a step.
|
||||
|
||||
### Example: Adding Automated Linting after Implementation
|
||||
|
||||
Given the built-in `speckit` workflow, create `project-overlay.yml`:
|
||||
|
||||
```yaml
|
||||
id: "add-lint"
|
||||
extends: "speckit"
|
||||
priority: 10
|
||||
edits:
|
||||
- insert_after: implement
|
||||
step:
|
||||
id: run-lint
|
||||
type: shell
|
||||
run: "ruff check src/"
|
||||
```
|
||||
|
||||
Install it:
|
||||
|
||||
```bash
|
||||
specify workflow overlay add project-overlay.yml --priority 10
|
||||
```
|
||||
|
||||
Run the workflow:
|
||||
|
||||
```bash
|
||||
specify workflow run speckit -i spec="Build a kanban board"
|
||||
```
|
||||
|
||||
The composed workflow will now run the full SDD cycle and execute `ruff check src/` automatically after the `implement` step.
|
||||
|
||||
### Example: Replacing a Gate
|
||||
|
||||
```yaml
|
||||
id: "skip-plan-review"
|
||||
extends: "speckit"
|
||||
priority: 5
|
||||
edits:
|
||||
- replace: review-plan
|
||||
step:
|
||||
id: review-plan
|
||||
type: command
|
||||
command: speckit.plan
|
||||
input:
|
||||
args: "{{ inputs.spec }}"
|
||||
```
|
||||
|
||||
Lower priority values have higher precedence. Change this overlay to `priority: 5` if it must win a conflict with the `add-lint` overlay above. It replaces the `review-plan` gate with a non-interactive command.
|
||||
|
||||
### Interaction with Bundles and Updates
|
||||
|
||||
`specify workflow add <local-directory>` installs `workflow.yml` from the local directory into `.specify/workflows/<id>/`.
|
||||
|
||||
When an installed workflow is refreshed or reinstalled, project overlays in `.specify/workflows/overlays/<id>/` are preserved because they live outside the installed workflow directory.
|
||||
|
||||
### Limitations
|
||||
|
||||
- Overlays operate on the step list only. They cannot change workflow metadata (name, description, inputs, `requires`) or expression logic.
|
||||
- Fan-out templates cannot be used as anchors.
|
||||
- An overlay that targets a step id that does not exist in the base workflow will raise a validation error when the workflow is resolved.
|
||||
- Overlays cannot target steps added by other overlays.
|
||||
- Overlays cannot add new inputs or change the input schema of the base workflow.
|
||||
## Update Workflows
|
||||
|
||||
```bash
|
||||
@@ -502,32 +318,6 @@ args: "{{ inputs.spec }}"
|
||||
message: "{{ status | default('pending') }}"
|
||||
```
|
||||
|
||||
### Interpolation and shell safety
|
||||
|
||||
Expressions are resolved by **plain string substitution** — the value of `{{ ... }}` is spliced into the surrounding text exactly as-is, with no quoting or escaping added. That is convenient for building `args` and `message` strings, but it has an important consequence for `shell` steps: a `run` field is handed to the system shell (`/bin/sh -c` on POSIX), so any interpolated value is interpreted as **shell syntax**, not just data.
|
||||
|
||||
If an interpolated value can contain characters like `;`, `|`, `&`, `$( )`, backticks, or quotes, it can change or extend the command that actually runs. This matters most when the value is not fully under the workflow author's control:
|
||||
|
||||
- **Workflow `inputs.*`** — supplied by whoever runs the workflow.
|
||||
- **A prior step's output**, e.g. `{{ steps.plan.output.stdout }}` — for a `prompt` step this is **text produced by the AI agent**, which can in turn be influenced by files, tickets, or web content the agent read. Treat agent output as untrusted when it flows into a `shell` step.
|
||||
|
||||
There is **no shell-escaping filter** in the expression language and **no sandbox** around a `shell` step, so none of the practices below can be treated as a guarantee that a hostile value is neutralised. The only reliable control is to constrain what an interpolated value *can* be, and to keep values you cannot constrain out of `run` fields entirely. Scrutinise every `run` field that interpolates a value you do not control, and at minimum:
|
||||
|
||||
- **Constrain the value at the source with `enum`/an allowlist.** When `inputs.*` feeds a `run` field, restrict it to a fixed set of known-safe values so a caller cannot supply arbitrary shell text at all. This is the strongest control the engine offers — prefer it over any downstream mitigation.
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
target:
|
||||
type: string
|
||||
enum: [staging, production] # caller cannot inject arbitrary text
|
||||
```
|
||||
|
||||
- **Keep unconstrained values out of `run`.** If a value cannot be constrained to an allowlist — most agent/`prompt` output — do not interpolate it into a `run` field. Branch on it with `if`/`switch` against fixed conditions, or act on it in a `command`/`prompt` step rather than a shell command built from it.
|
||||
- **Quoting is not a security boundary.** Surrounding a substitution with quotes (`'{{ inputs.x }}'`) helps the shell treat a *trusted* value as a single argument and avoids word-splitting on spaces, but a value that itself contains the matching quote character can still break out and inject shell syntax. Quote for correctness on constrained values; never rely on quoting to make an *unconstrained* substitution safe.
|
||||
- **Gates do not inspect the next step, and `message` is printed verbatim.** A `gate` step renders only its own `message`/`show_file` — it does not display, resolve, or sanitise the command that follows it, and approval never neutralises an injectable interpolation. Do **not** interpolate raw untrusted data into `message`: it is printed as-is with no control-character stripping, so agent or caller output could inject terminal/ANSI escapes that alter or hide the approval prompt. Keep `message` to trusted, constrained text, and surface untrusted material for review via `show_file` instead — its path and contents are control/ANSI-stripped before display.
|
||||
|
||||
A `shell` step is an arbitrary-command primitive by design; these practices reduce exposure and keep *which* command runs under the author's control, but they do not eliminate the risk of interpolating values you do not fully control.
|
||||
|
||||
## Shell Step Environment Variables
|
||||
|
||||
Shell steps automatically receive the following environment variables:
|
||||
|
||||
@@ -55,8 +55,6 @@
|
||||
href: concepts/spec-persistence.md
|
||||
- name: Handling Complex Features
|
||||
href: concepts/complex-features.md
|
||||
- name: Spec of Specs
|
||||
href: concepts/spec-of-specs.md
|
||||
|
||||
# Development workflows
|
||||
- name: Development
|
||||
|
||||
198
docs/upgrade.md
198
docs/upgrade.md
@@ -12,7 +12,7 @@
|
||||
| **CLI Tool — pin a version** | `specify self upgrade --tag vX.Y.Z[suffix]` | Upgrade to a specific release tag instead of the latest stable. Suffixes are limited to dev, alpha/beta/rc, and/or build metadata forms. |
|
||||
| **CLI Tool — manual fallback** | `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git@vX.Y.Z` | When `specify self upgrade` isn't available (older installs) or when you want explicit control. |
|
||||
| **CLI Tool — manual fallback (pipx)** | `pipx install --force git+https://github.com/github/spec-kit.git@vX.Y.Z` | Same as above, for pipx installs. |
|
||||
| **Project Files** | Run `specify integration upgrade <key>`, then `specify extension update` | Refresh installed integration files and extensions in your project |
|
||||
| **Project Files** | `specify init --here --force --integration <your-agent>` | Update slash commands, templates, and scripts in your project |
|
||||
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
|
||||
|
||||
---
|
||||
@@ -89,94 +89,91 @@ specify self check
|
||||
|
||||
## Part 2: Updating Project Files
|
||||
|
||||
When Spec Kit releases new features (like new slash commands, updated templates, or extension changes), you need to refresh the Spec Kit files that were installed into your project.
|
||||
When Spec Kit releases new features (like new slash commands or updated templates), you need to refresh your project's Spec Kit files.
|
||||
|
||||
### What gets updated?
|
||||
|
||||
For existing Spec Kit projects, use the manifest-aware upgrade path first:
|
||||
Running `specify init --here --force` will update:
|
||||
|
||||
- ✅ **Integration command/skill files** (`.claude/skills/`, `.github/prompts/`, `.agents/skills/`, etc.)
|
||||
- ✅ **Managed shared scripts and templates** (`.specify/scripts/`, `.specify/templates/`) when they are unchanged from the previous managed copy
|
||||
- ✅ **Installed extensions** when you run `specify extension update`
|
||||
|
||||
The integration upgrade command uses the install manifest to detect local edits. If a managed integration file was modified after install, the command stops and asks you to inspect the change or rerun with `--force`.
|
||||
- ✅ **Slash command files** (`.claude/commands/`, `.github/prompts/`, etc.)
|
||||
- ✅ **Script files** (`.specify/scripts/`) — **only with `--force`**; without it, only missing files are added
|
||||
- ✅ **Template files** (`.specify/templates/`) — **only with `--force`**; without it, only missing files are added
|
||||
- ✅ **Shared memory files** (`.specify/memory/`) - **⚠️ See warnings below**
|
||||
|
||||
### What stays safe?
|
||||
|
||||
These files are **never touched** by the manifest-aware integration/extension upgrade path:
|
||||
These files are **never touched** by the upgrade—the template packages don't even contain them:
|
||||
|
||||
- ✅ **Your specifications** (`specs/001-my-feature/spec.md`, etc.) - **CONFIRMED SAFE**
|
||||
- ✅ **Your implementation plans** (`specs/001-my-feature/plan.md`, `tasks.md`, etc.) - **CONFIRMED SAFE**
|
||||
- ✅ **Your constitution** (`.specify/memory/constitution.md`) when using `specify integration upgrade`
|
||||
- ✅ **Your source code** - **CONFIRMED SAFE**
|
||||
- ✅ **Your git history** - **CONFIRMED SAFE**
|
||||
|
||||
The `specs/` directory is completely excluded from template packages and will never be modified during upgrades.
|
||||
|
||||
### 1. Check installed integrations
|
||||
### Update command
|
||||
|
||||
Run this inside your project directory:
|
||||
|
||||
```bash
|
||||
specify integration status
|
||||
```
|
||||
|
||||
This reports the default integration, all installed integrations, and any modified or missing managed files. You can also inspect `.specify/integration.json`; installed integrations are listed under `installed_integrations`.
|
||||
|
||||
### 2. Upgrade each installed integration
|
||||
|
||||
Run this inside your project directory:
|
||||
|
||||
```bash
|
||||
specify integration upgrade <key>
|
||||
```
|
||||
|
||||
Replace `<key>` with an installed integration key such as `copilot`, `claude`, or `codex`. In projects with multiple installed integrations, run the command once per installed key.
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
specify integration upgrade claude
|
||||
specify integration upgrade codex
|
||||
```
|
||||
|
||||
See the [integration reference](reference/integrations.md#upgrade-an-integration) for options such as `--script`, `--integration-options`, and `--force`.
|
||||
|
||||
### 3. Update installed extensions
|
||||
|
||||
Run:
|
||||
|
||||
```bash
|
||||
specify extension update
|
||||
```
|
||||
|
||||
With no extension argument, this updates all installed extensions. Use `specify extension update <extension-id-or-name>` to update only one extension. See the [extensions reference](reference/extensions.md#update-extensions) for details.
|
||||
|
||||
### Fallback: re-run init
|
||||
|
||||
If a project predates manifests, has missing integration metadata, or needs a broader recovery, you can still re-run init:
|
||||
|
||||
```bash
|
||||
specify init --here --force --integration <your-agent>
|
||||
```
|
||||
|
||||
Use this as an escape hatch rather than the default project-file upgrade path. It refreshes the selected integration and shared project scaffolding, but it does not use the same per-integration manifest checks before overwriting files.
|
||||
Replace `<your-agent>` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md)
|
||||
|
||||
**Example:**
|
||||
|
||||
```bash
|
||||
specify init --here --force --integration copilot
|
||||
```
|
||||
|
||||
### Understanding the `--force` flag
|
||||
|
||||
Without `--force`, the CLI warns you and asks for confirmation:
|
||||
|
||||
```text
|
||||
Warning: Current directory is not empty (25 items)
|
||||
Template files will be merged with existing content and may overwrite existing files
|
||||
Proceed? [y/N]
|
||||
```
|
||||
|
||||
With `--force`, it skips the confirmation and proceeds immediately. It also **overwrites shared infrastructure files** (`.specify/scripts/` and `.specify/templates/`) with the latest versions from the installed Spec Kit release.
|
||||
|
||||
Without `--force`, shared infrastructure files that already exist are skipped — the CLI will print a warning listing the skipped files so you know which ones were not updated.
|
||||
|
||||
**Important: Your `specs/` directory is always safe.** The `--force` flag only affects template files (commands, scripts, templates, memory). Your feature specifications, plans, and tasks in `specs/` are never included in upgrade packages and cannot be overwritten.
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ Important Warnings
|
||||
|
||||
### 1. Constitution file and memory customizations
|
||||
### 1. Constitution file will be overwritten
|
||||
|
||||
`specify integration upgrade <key>` does not update `.specify/memory/constitution.md`.
|
||||
**Known issue:** `specify init --here --force` currently overwrites `.specify/memory/constitution.md` with the default template, erasing any customizations you made.
|
||||
|
||||
The fallback `specify init --here --force --integration <your-agent>` path also preserves an existing `.specify/memory/constitution.md`; if the file is missing, init creates it from the current constitution template. You do not need a constitution backup/restore step for the manifest-aware upgrade path.
|
||||
**Workaround:**
|
||||
|
||||
As with any broad fallback refresh, commit or back up local customizations before using `init --here --force` so you can review the resulting diff.
|
||||
```bash
|
||||
# 1. Back up your constitution before upgrading
|
||||
cp .specify/memory/constitution.md .specify/memory/constitution-backup.md
|
||||
|
||||
### 2. Custom integration, script, or template modifications
|
||||
# 2. Run the upgrade
|
||||
specify init --here --force --integration copilot
|
||||
|
||||
`specify integration upgrade <key>` blocks when manifest-tracked integration files were modified locally, unless you pass `--force`.
|
||||
# 3. Restore your customized constitution
|
||||
mv .specify/memory/constitution-backup.md .specify/memory/constitution.md
|
||||
```
|
||||
|
||||
Shared scripts and templates are refreshed when they still match the previously recorded managed copy. Local customizations are preserved unless you explicitly use a force/refresh option that overwrites them. If you customized files in `.specify/scripts/` or `.specify/templates/`, commit or back them up first:
|
||||
Or use git to restore it:
|
||||
|
||||
```bash
|
||||
# After upgrade, restore from git history
|
||||
git restore .specify/memory/constitution.md
|
||||
```
|
||||
|
||||
### 2. Custom script or template modifications
|
||||
|
||||
If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first:
|
||||
|
||||
```bash
|
||||
# Back up custom templates and scripts
|
||||
@@ -195,13 +192,15 @@ Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash command
|
||||
**Example for Kilo Code:**
|
||||
|
||||
```bash
|
||||
# List current and legacy Kilo command folders
|
||||
ls -la .kilo/commands/
|
||||
ls -la .kilocode/workflows/
|
||||
# Navigate to the agent's commands folder
|
||||
cd .kilocode/workflows/
|
||||
|
||||
# List files and identify duplicates
|
||||
ls -la
|
||||
|
||||
# Delete old versions (example filenames - yours may differ)
|
||||
rm .kilocode/workflows/speckit.specify-old.md
|
||||
rm .kilocode/workflows/speckit.plan-v1.md
|
||||
rm speckit.specify-old.md
|
||||
rm speckit.plan-v1.md
|
||||
```
|
||||
|
||||
Restart your IDE to refresh the command list.
|
||||
@@ -216,29 +215,29 @@ Restart your IDE to refresh the command list.
|
||||
# Upgrade CLI (auto-detects uv tool vs pipx install)
|
||||
specify self upgrade
|
||||
|
||||
# Inspect installed integrations
|
||||
specify integration status
|
||||
|
||||
# Update project files to get new commands
|
||||
specify integration upgrade <key>
|
||||
specify extension update
|
||||
specify init --here --force --integration copilot
|
||||
|
||||
# Restore your constitution if customized
|
||||
git restore .specify/memory/constitution.md
|
||||
```
|
||||
|
||||
### Scenario 2: "I customized templates and constitution"
|
||||
|
||||
```bash
|
||||
# 1. Commit or back up customizations
|
||||
git status
|
||||
# 1. Back up customizations
|
||||
cp .specify/memory/constitution.md /tmp/constitution-backup.md
|
||||
cp -r .specify/templates /tmp/templates-backup
|
||||
|
||||
# 2. Upgrade CLI
|
||||
specify self upgrade
|
||||
|
||||
# 3. Use the manifest-aware project update first
|
||||
specify integration upgrade <key>
|
||||
specify extension update
|
||||
# 3. Update project
|
||||
specify init --here --force --integration copilot
|
||||
|
||||
# 4. If the upgrade reports modified managed files, inspect the diff before using --force
|
||||
# 4. Restore customizations
|
||||
mv /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||
# Manually merge template changes if needed
|
||||
```
|
||||
|
||||
### Scenario 3: "I see duplicate slash commands in my IDE"
|
||||
@@ -246,12 +245,14 @@ specify extension update
|
||||
This happens with IDE-based agents (Kilo Code, Cline, etc.).
|
||||
|
||||
```bash
|
||||
# For Kilo Code, inspect both current and legacy command folders
|
||||
ls -la .kilo/commands/
|
||||
ls -la .kilocode/workflows/
|
||||
# Find the agent folder (example: .kilocode/workflows/)
|
||||
cd .kilocode/workflows/
|
||||
|
||||
# List all files
|
||||
ls -la
|
||||
|
||||
# Delete old command files
|
||||
rm .kilocode/workflows/speckit.old-command-name.md
|
||||
rm speckit.old-command-name.md
|
||||
|
||||
# Restart your IDE
|
||||
```
|
||||
@@ -261,14 +262,14 @@ rm .kilocode/workflows/speckit.old-command-name.md
|
||||
The git extension is now opt-in, so upgrades do not install it unless you add it explicitly.
|
||||
|
||||
```bash
|
||||
# Upgrade CLI
|
||||
specify self upgrade
|
||||
# Manually back up files you customized
|
||||
cp .specify/memory/constitution.md .specify/memory/constitution.backup.md
|
||||
|
||||
# Refresh integration files and installed extensions
|
||||
specify integration upgrade <key>
|
||||
specify extension update
|
||||
# Run upgrade
|
||||
specify init --here --force --integration copilot
|
||||
|
||||
# The git extension is not added unless you run `specify extension add git`
|
||||
# Restore customizations
|
||||
mv .specify/memory/constitution.backup.md .specify/memory/constitution.md
|
||||
```
|
||||
|
||||
If you later decide you want the git extension's commands and hooks, install it explicitly:
|
||||
@@ -303,7 +304,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
|
||||
2. **For CLI-based agents**, verify files exist:
|
||||
|
||||
```bash
|
||||
ls -la .claude/skills/ # Claude Code
|
||||
ls -la .claude/commands/ # Claude Code
|
||||
ls -la .gemini/commands/ # Gemini
|
||||
ls -la .cursor/skills/ # Cursor
|
||||
ls -la .pi/prompts/ # Pi Coding Agent
|
||||
@@ -314,21 +315,19 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
|
||||
- Codex requires `CODEX_HOME` environment variable
|
||||
- Some agents need workspace restart or cache clearing
|
||||
|
||||
### "Will init overwrite my constitution customizations?"
|
||||
### "I lost my constitution customizations"
|
||||
|
||||
Current `specify init --here --force` preserves an existing `.specify/memory/constitution.md`; it creates the file from the template only when it is missing.
|
||||
|
||||
If you previously lost constitution changes through an older workflow or manual replacement, restore from git or backup:
|
||||
**Fix:** Restore from git or backup:
|
||||
|
||||
```bash
|
||||
# If you committed the customized constitution
|
||||
# If you committed before upgrading
|
||||
git restore .specify/memory/constitution.md
|
||||
|
||||
# If you backed up manually
|
||||
cp /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||
```
|
||||
|
||||
**Prevention:** Use `specify integration upgrade <key>` for routine project-file updates. If you need the fallback `specify init --here --force` path, commit first so you can review the full diff afterward.
|
||||
**Prevention:** Always commit or back up `constitution.md` before upgrading.
|
||||
|
||||
### "Warning: Current directory is not empty"
|
||||
|
||||
@@ -352,10 +351,10 @@ This warning appears when you run `specify init --here` (or `specify init .`) in
|
||||
|
||||
Only Spec Kit infrastructure files:
|
||||
|
||||
- Agent command/skill files (`.claude/skills/`, `.github/prompts/`, etc.)
|
||||
- Agent command files (`.claude/commands/`, `.github/prompts/`, etc.)
|
||||
- Scripts in `.specify/scripts/`
|
||||
- Templates in `.specify/templates/`
|
||||
- Missing memory files such as `.specify/memory/constitution.md` may be created from templates; an existing constitution is preserved
|
||||
- Memory files in `.specify/memory/` (including constitution)
|
||||
|
||||
**What stays untouched:**
|
||||
|
||||
@@ -366,7 +365,7 @@ Only Spec Kit infrastructure files:
|
||||
|
||||
**How to respond:**
|
||||
|
||||
- **Type `y` and press Enter** - Proceed with the merge when using the fallback init path
|
||||
- **Type `y` and press Enter** - Proceed with the merge (recommended if upgrading)
|
||||
- **Type `n` and press Enter** - Cancel the operation
|
||||
- **Use `--force` flag** - Skip this confirmation entirely:
|
||||
|
||||
@@ -376,11 +375,11 @@ Only Spec Kit infrastructure files:
|
||||
|
||||
**When you see this warning:**
|
||||
|
||||
- ✅ **Expected** when using the fallback init path in an existing Spec Kit project
|
||||
- ✅ **Expected** when upgrading an existing Spec Kit project
|
||||
- ✅ **Expected** when adding Spec Kit to an existing codebase
|
||||
- ⚠️ **Unexpected** if you thought you were creating a new project in an empty directory
|
||||
|
||||
**Prevention tip:** Before using the fallback init path, commit your current work so any refreshed files are easy to review or restore.
|
||||
**Prevention tip:** Before upgrading, commit or back up your `.specify/memory/constitution.md` if you customized it.
|
||||
|
||||
### "CLI upgrade doesn't seem to work"
|
||||
|
||||
@@ -419,15 +418,14 @@ uv tool install specify-cli --from git+https://github.com/github/spec-kit.git
|
||||
|
||||
### "Do I need to run specify every time I open my project?"
|
||||
|
||||
**Short answer:** No, you only run `specify init` once per project, or later as a fallback recovery path.
|
||||
**Short answer:** No, you only run `specify init` once per project (or when upgrading).
|
||||
|
||||
**Explanation:**
|
||||
|
||||
The `specify` CLI tool is used for:
|
||||
|
||||
- **Initial setup:** `specify init` to bootstrap Spec Kit in your project
|
||||
- **Routine project-file upgrades:** `specify integration upgrade <key>` and `specify extension update`
|
||||
- **Fallback recovery:** `specify init --here --force` when integration metadata is missing or the manifest-aware path cannot be used
|
||||
- **Upgrades:** `specify init --here --force` to update templates and commands
|
||||
- **Diagnostics:** `specify check` to verify tool installation
|
||||
|
||||
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, `.omp/commands/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.
|
||||
@@ -441,7 +439,7 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
|
||||
ls -la .github/prompts/
|
||||
|
||||
# For Claude
|
||||
ls -la .claude/skills/
|
||||
ls -la .claude/commands/
|
||||
|
||||
# For Pi
|
||||
ls -la .pi/prompts/
|
||||
|
||||
@@ -252,7 +252,6 @@ Use standard Markdown with special placeholders:
|
||||
|
||||
- `$ARGUMENTS`: User-provided arguments
|
||||
- `{SCRIPT}`: Replaced with script path during registration
|
||||
- `__SPECKIT_COMMAND_<NAME>__`: Replaced with the invocation of another command, rendered using the active integration's separator (see [Referencing other commands](#referencing-other-commands))
|
||||
|
||||
**Example**:
|
||||
|
||||
@@ -268,40 +267,6 @@ echo "Running with args: $args"
|
||||
```
|
||||
````
|
||||
|
||||
### Referencing other commands
|
||||
|
||||
A command body is a *template* that Spec Kit renders once per agent. Different agents invoke commands with different surface syntax — for example `/speckit.plan` (dot separator) or `/speckit-plan` (hyphen separator). Some agents also use different prefixes in skills mode (e.g. Kimi `/skill:speckit-plan`, Codex/ZCode `$speckit-plan`). So when you reference a sibling command from a body, **do not hard-code a literal invocation** like `/speckit.my-ext.prepare`. A literal is correct for exactly one agent and breaks on the rest.
|
||||
|
||||
Instead use the agent-neutral token `__SPECKIT_COMMAND_<NAME>__`. Spec Kit resolves it to a `/speckit<separator>...` invocation using the active integration's `invoke_separator` (and integrations may post-process that further in skills output).
|
||||
|
||||
Encode the command name in upper case, dropping the `speckit.` prefix and turning each dotted segment separator into an underscore:
|
||||
|
||||
| Command file | Token |
|
||||
| --- | --- |
|
||||
| `speckit.plan.md` | `__SPECKIT_COMMAND_PLAN__` |
|
||||
| `speckit.bug.fix.md` | `__SPECKIT_COMMAND_BUG_FIX__` |
|
||||
| `speckit.git.commit.md` | `__SPECKIT_COMMAND_GIT_COMMIT__` |
|
||||
|
||||
The resolver maps each underscore back to the active agent's separator, so use tokens to reference commands whose name segments are single words. (Command names are dotted segments like `git.commit`; the token scheme rebuilds those dots and does not carry hyphens within a segment.)
|
||||
|
||||
**Example** — a command body that points the user at the next step:
|
||||
|
||||
```markdown
|
||||
Once the assessment exists, the next step is `__SPECKIT_COMMAND_BUG_FIX__ slug=<slug>`.
|
||||
```
|
||||
|
||||
This renders as `/speckit.bug.fix slug=<slug>` for a slash-based agent, `/speckit-bug-fix slug=<slug>` for a skills-based agent, and so on — the author writes it once and it stays portable. The first-party `bug` and `git` extensions use this token exclusively; see `extensions/bug/commands/` for working examples.
|
||||
|
||||
> **Current limitation — skills mode.** Token resolution runs in the
|
||||
> command-rendering path (`CommandRegistrar`), so it applies when an extension
|
||||
> installs *command files*. It does **not** yet run when an extension is
|
||||
> registered as *skills* for a skills-based agent: `_register_extension_skills`
|
||||
> resolves placeholders and post-processes content but never calls
|
||||
> `resolve_command_refs`, so a `__SPECKIT_COMMAND_<NAME>__` token reaches
|
||||
> agents such as Codex, ZCode, and Kimi verbatim in that mode. Until that
|
||||
> rendering step lands, prefer the token for command-file extensions and avoid
|
||||
> relying on it inside skill bodies destined for skills-based agents.
|
||||
|
||||
### Script Path Rewriting
|
||||
|
||||
Extension commands use relative paths that get rewritten during registration:
|
||||
|
||||
@@ -2,55 +2,55 @@
|
||||
|
||||
This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, …) for the active integration.
|
||||
|
||||
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`). For `.mdc` files, it also ensures the YAML frontmatter (the metadata block at the top of the file) contains `alwaysApply: true`. Otherwise, everything outside the managed section is untouched.
|
||||
|
||||
> NOTE: Spec Kit itself never touches your agent context file. This extension is the only thing that does, and it's opt-in: install it if you want the block kept in sync, skip it if you'd rather manage that file yourself.
|
||||
It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `<!-- SPECKIT START -->` / `<!-- SPECKIT END -->`).
|
||||
|
||||
## Why an extension?
|
||||
|
||||
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** extension lets users:
|
||||
|
||||
- **Choose whether to install it at all** - `specify init` does **not** install it. Add it explicitly when you want Spec Kit to manage the agent context file; when it is absent, the file is never modified, and when it is disabled, its automatic hooks do not run.
|
||||
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in this repo) - the bundled scripts honor the `context_markers` value.
|
||||
- **Choose whether to install it at all** — `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file.
|
||||
- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` — the bundled scripts honor the `context_markers` value.
|
||||
- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`.
|
||||
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in [extension.yml](./extension.yml) (`after_specify`, `after_plan`).
|
||||
|
||||
## Installation
|
||||
|
||||
To install the extension, from the root of an initialized Spec Kit project, run:
|
||||
|
||||
```bash
|
||||
specify extension add agent-context
|
||||
```
|
||||
|
||||
## Disabling
|
||||
|
||||
```bash
|
||||
specify extension disable agent-context
|
||||
|
||||
# Re-enable it
|
||||
specify extension enable agent-context
|
||||
```
|
||||
|
||||
While this extension is disabled (or not installed), nothing in Spec Kit creates, updates, or removes the managed block - the `__CONTEXT_FILE__` placeholder in any template is left as-is, and the extension's own config is never read.
|
||||
- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator — `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
|
||||
|
||||
## Commands
|
||||
|
||||
| Command | Description |
|
||||
| ------------------------------ | --------------------------------------------------------------------------------- |
|
||||
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
|
||||
The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
|
||||
|
||||
> NOTE: The command ID above is canonical. Invoke it using the syntax for your integration: `/speckit.agent-context.update` for dot-command integrations; `/speckit-agent-context-update` for hyphen/skills integrations (including Forge and Cline); `$speckit-agent-context-update` for Codex or ZCode in skills mode; or `/skill:speckit-agent-context-update` for Kimi.
|
||||
| Command | Description |
|
||||
|---------|-------------|
|
||||
| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
|
||||
|
||||
## Configuration
|
||||
|
||||
All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in the repo).
|
||||
All configuration flows through the extension's own config file at
|
||||
`.specify/extensions/agent-context/agent-context-config.yml`:
|
||||
|
||||
```yaml
|
||||
# Path to the coding agent context file managed by this extension
|
||||
context_file: CLAUDE.md
|
||||
|
||||
# Optional list of coding agent context files to manage together.
|
||||
# When non-empty, this takes precedence over context_file.
|
||||
context_files:
|
||||
- AGENTS.md
|
||||
- CLAUDE.md
|
||||
|
||||
# Delimiters for the managed Spec Kit section
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
```
|
||||
|
||||
- `context_file` — the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted.
|
||||
- `context_files` — optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
- `context_markers.start` / `.end` — the delimiters around the managed section. Edit these to use custom markers.
|
||||
|
||||
## Requirements
|
||||
|
||||
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
|
||||
|
||||
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports _"PyYAML is required … not available in the current Python environment"_, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||
PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required … not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
|
||||
|
||||
```bash
|
||||
pip install pyyaml
|
||||
@@ -58,6 +58,10 @@ pip install pyyaml
|
||||
/path/to/speckit-python -m pip install pyyaml
|
||||
```
|
||||
|
||||
## Issues
|
||||
## Disable
|
||||
|
||||
For any other issues, please create an issue in the [official GitHub repo](https://github.com/github/spec-kit/issues).
|
||||
```bash
|
||||
specify extension disable agent-context
|
||||
```
|
||||
|
||||
When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal — the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge — including the per-agent default mapping in `agent-context-defaults.json` — lives entirely within this extension, so disabling it is a complete opt-out.
|
||||
|
||||
@@ -1,24 +1,20 @@
|
||||
# Coding Agent Context Extension Configuration
|
||||
# These values are populated automatically by `specify init` and
|
||||
# `specify integration use` / `specify integration install`.
|
||||
|
||||
# WHAT: The single agent context file relative to the project root (the directory containing .specify/). Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
# REQUIREMENT: OPTIONAL. Use this if you want to manually specify a single context file. If you leave this entry blank, it will use the default context file for the coding agent you picked when you set up Spec Kit. See `agent-context-defaults.json` for the defaults.
|
||||
# EXAMPLE: context_file: CLAUDE.md
|
||||
# Path (relative to the project root) to the default coding agent context file
|
||||
# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
|
||||
# .github/copilot-instructions.md). Set automatically from the active
|
||||
# integration and regenerated during `specify init` or integration switches.
|
||||
context_file: ""
|
||||
|
||||
# WHAT: List of agent context files relative to the project root (the directory containing .specify/). If you have both `context_file` and `context_files` filled, then this (`context_files`) takes precedence. Absolute paths, backslash separators, and `..` path segments are rejected.
|
||||
# REQUIREMENT: OPTIONAL. Use this if your project requires you to keep multiple agent context files in sync.
|
||||
# EXAMPLE:
|
||||
# context_files:
|
||||
# - AGENTS.md
|
||||
# - CLAUDE.md
|
||||
# Optional list of project-relative coding agent context files managed by this
|
||||
# extension. When non-empty, this list takes precedence over `context_file`.
|
||||
# Use this for projects that intentionally keep multiple agent anchors in sync.
|
||||
context_files: []
|
||||
|
||||
# WHAT: Markers (delimiters) for the managed Spec Kit section. This extension injects information only between these markers.
|
||||
# REQUIREMENT: OPTIONAL. Only change if you wish to have a custom marker name.
|
||||
# EXAMPLE:
|
||||
# context_markers:
|
||||
# start: "<!-- AGENT SPEC KIT CONTEXT START -->"
|
||||
# end: "<!-- AGENT SPEC KIT CONTEXT END -->"
|
||||
# Delimiters for the managed Spec Kit section.
|
||||
# Edit these to use custom markers.
|
||||
context_markers:
|
||||
start: "<!-- SPECKIT START -->"
|
||||
end: "<!-- SPECKIT END -->"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-24T00:00:00Z",
|
||||
"updated_at": "2026-07-17T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -290,8 +290,8 @@
|
||||
"id": "architecture-guard",
|
||||
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
|
||||
"author": "DyanGalih",
|
||||
"version": "1.13.1",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.13.1.zip",
|
||||
"version": "1.8.17",
|
||||
"download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.17.zip",
|
||||
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
|
||||
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/docs/architecture-overview.md",
|
||||
@@ -303,7 +303,7 @@
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 14,
|
||||
"commands": 10,
|
||||
"hooks": 3
|
||||
},
|
||||
"tags": [
|
||||
@@ -313,14 +313,13 @@
|
||||
"refactor",
|
||||
"workflow",
|
||||
"governance",
|
||||
"guardrails",
|
||||
"hygiene"
|
||||
"guardrails"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-05-05T07:26:00Z",
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
"updated_at": "2026-06-08T00:00:00Z"
|
||||
},
|
||||
"archive": {
|
||||
"name": "Archive Extension",
|
||||
@@ -490,46 +489,6 @@
|
||||
"created_at": "2026-04-17T00:00:00Z",
|
||||
"updated_at": "2026-04-17T00:00:00Z"
|
||||
},
|
||||
"blueprint-index": {
|
||||
"name": "Blueprint Index — Living Architecture Map",
|
||||
"id": "blueprint-index",
|
||||
"description": "Living architecture map for brownfield and greenfield projects, with a deterministic CI gate that blocks contradictions between the map, specs, and code while warning on non-blocking drift.",
|
||||
"author": "ogil109",
|
||||
"version": "0.2.0",
|
||||
"download_url": "https://github.com/ogil109/spec-kit-blueprint/releases/download/v0.2.0/blueprint.zip",
|
||||
"repository": "https://github.com/ogil109/spec-kit-blueprint",
|
||||
"homepage": "https://github.com/ogil109/spec-kit-blueprint/tree/main",
|
||||
"documentation": "https://github.com/ogil109/spec-kit-blueprint/blob/main/README.md",
|
||||
"changelog": "https://github.com/ogil109/spec-kit-blueprint/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.10.0",
|
||||
"tools": [
|
||||
{ "name": "bash", "required": false },
|
||||
{ "name": "git", "required": false }
|
||||
]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"blueprint",
|
||||
"architecture",
|
||||
"coherence",
|
||||
"drift",
|
||||
"brownfield",
|
||||
"autonomous",
|
||||
"ci"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-24T00:00:00Z",
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
},
|
||||
"branch-convention": {
|
||||
"name": "Branch Convention",
|
||||
"id": "branch-convention",
|
||||
@@ -2070,40 +2029,6 @@
|
||||
"created_at": "2026-06-01T00:00:00Z",
|
||||
"updated_at": "2026-06-22T00:00:00Z"
|
||||
},
|
||||
"linear-weave": {
|
||||
"name": "Linear Weave",
|
||||
"id": "linear-weave",
|
||||
"description": "Weave Spec Kit into Linear: pull requirements, mirror tasks.md into sub-issues, sync statuses.",
|
||||
"author": "Tony Woodhouse",
|
||||
"version": "1.0.0",
|
||||
"download_url": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/archive/refs/tags/v1.0.0.zip",
|
||||
"repository": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
|
||||
"homepage": "https://github.com/tonydwoodhouse/spec-kit-linear-weave",
|
||||
"documentation": "https://github.com/tonydwoodhouse/spec-kit-linear-weave#readme",
|
||||
"changelog": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "integration",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.13.0,<1.0.0",
|
||||
"tools": [{ "name": "linear-mcp", "required": true }]
|
||||
},
|
||||
"provides": {
|
||||
"commands": 5,
|
||||
"hooks": 5
|
||||
},
|
||||
"tags": [
|
||||
"linear",
|
||||
"issue-tracking",
|
||||
"integration",
|
||||
"workflow"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
},
|
||||
"loop": {
|
||||
"name": "Loop Engineering",
|
||||
"id": "loop",
|
||||
@@ -2758,10 +2683,10 @@
|
||||
"okf": {
|
||||
"name": "OKF Knowledge Bundle Generator",
|
||||
"id": "okf",
|
||||
"description": "Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for significance and rationale, and resolving open questions with the user.",
|
||||
"description": "Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository.",
|
||||
"author": "Alex Punnen",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/alexcpn/speckit_ofk/archive/refs/tags/v0.3.0.zip",
|
||||
"version": "0.2.0",
|
||||
"download_url": "https://github.com/alexcpn/speckit_ofk/archive/refs/tags/v0.2.0.zip",
|
||||
"repository": "https://github.com/alexcpn/speckit_ofk",
|
||||
"homepage": "https://github.com/alexcpn/speckit_ofk",
|
||||
"documentation": "https://github.com/alexcpn/speckit_ofk/blob/main/README.md",
|
||||
@@ -2773,7 +2698,7 @@
|
||||
"speckit_version": ">=0.12.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"commands": 3,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
@@ -2787,7 +2712,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-17T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
"updated_at": "2026-07-17T00:00:00Z"
|
||||
},
|
||||
"onboard": {
|
||||
"name": "Onboard",
|
||||
@@ -4417,40 +4342,6 @@
|
||||
"created_at": "2026-05-20T00:00:00Z",
|
||||
"updated_at": "2026-05-20T00:00:00Z"
|
||||
},
|
||||
"test-coverage-drift-control": {
|
||||
"name": "Test Coverage Drift Control",
|
||||
"id": "test-coverage-drift-control",
|
||||
"description": "Generate incremental coverage drift reports and planned remediation tasks after implementation",
|
||||
"author": "Igor Benicio de Mesquita",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/benizzio/spec-kit-test-coverage-drift-control/archive/refs/tags/v0.3.0.zip",
|
||||
"repository": "https://github.com/benizzio/spec-kit-test-coverage-drift-control",
|
||||
"homepage": "https://github.com/benizzio/spec-kit-test-coverage-drift-control",
|
||||
"documentation": "https://github.com/benizzio/spec-kit-test-coverage-drift-control#readme",
|
||||
"changelog": "https://github.com/benizzio/spec-kit-test-coverage-drift-control/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "code",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.2.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 2,
|
||||
"hooks": 1
|
||||
},
|
||||
"tags": [
|
||||
"analysis",
|
||||
"coverage",
|
||||
"testing",
|
||||
"quality",
|
||||
"maintenance"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
},
|
||||
"time-machine": {
|
||||
"name": "Time Machine",
|
||||
"id": "time-machine",
|
||||
@@ -4818,18 +4709,17 @@
|
||||
"verify-review-ship": {
|
||||
"name": "Verify Review Ship",
|
||||
"id": "verify-review-ship",
|
||||
"description": "Adds verify and review quality gates plus transactional merge, cleanup, and delivery summary.",
|
||||
"description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
|
||||
"author": "Carlos Eduardo Gevaerd Araujo",
|
||||
"version": "0.3.0",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.3.0.zip",
|
||||
"sha256": "a7326c899855f46ff28e9f03ede2f89c4db0fd2b8a64c85017b3ab639e004fd3",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
|
||||
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
|
||||
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
|
||||
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
|
||||
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
|
||||
"license": "MIT",
|
||||
"category": "process",
|
||||
"effect": "read-write",
|
||||
"effect": "read-only",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.1.0"
|
||||
},
|
||||
@@ -4841,14 +4731,14 @@
|
||||
"quality",
|
||||
"review",
|
||||
"shipping",
|
||||
"merge",
|
||||
"workflow"
|
||||
"workflow",
|
||||
"testing"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
"updated_at": "2026-07-10T00:00:00Z"
|
||||
},
|
||||
"verify-tasks": {
|
||||
"name": "Verify Tasks Extension",
|
||||
|
||||
@@ -10,7 +10,7 @@ This extension provides Git operations as an optional, self-contained module. It
|
||||
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces
|
||||
- **Branch validation** to ensure branches follow naming conventions
|
||||
- **Git remote detection** for GitHub integration (e.g., issue creation)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages, or Conventional Commit messages generated by the agent)
|
||||
- **Auto-commit** after core commands (configurable per-command with custom messages)
|
||||
|
||||
## Commands
|
||||
|
||||
@@ -66,11 +66,6 @@ branch_prefix: ""
|
||||
# Custom commit message for git init
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style for auto-commit hooks: "fixed" (default) uses the
|
||||
# messages below; "conventional" asks the agent to generate a Conventional
|
||||
# Commit message (e.g. "feat: add OAuth spec") from the diff instead.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit per command (all disabled by default)
|
||||
# Example: enable auto-commit after specify
|
||||
auto_commit:
|
||||
|
||||
@@ -14,37 +14,23 @@ This command is invoked as a hook after (or before) core commands. It:
|
||||
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
|
||||
3. Looks up the specific event key to see if auto-commit is enabled
|
||||
4. Falls back to `auto_commit.default` if no event-specific key exists
|
||||
5. Determines the commit message based on `commit_style` (see below)
|
||||
5. Uses the per-command `message` if configured, otherwise a default message
|
||||
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
|
||||
|
||||
## Commit Message Styles
|
||||
|
||||
Controlled by the `commit_style` key in `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
- **`fixed`** (default): use the per-command `message` if configured, otherwise a generic `[Spec Kit] Auto-commit <phase> <command>` message.
|
||||
- **`conventional`**: inspect the actual changes (`git diff` / `git status`) since the last commit and generate a single-line [Conventional Commit](https://www.conventionalcommits.org/) message (`type(scope): subject`, e.g. `feat: add OAuth specification` or `docs: update implementation plan`) that accurately summarizes the change. Write this message to a temporary file and pass the file's path to the script (see Execution below). The configured `message` values are ignored in this mode.
|
||||
|
||||
## Execution
|
||||
|
||||
Determine the event name from the hook that triggered this command, then run the script:
|
||||
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name> [--message-file <path>]`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name> [-MessageFile <path>]`
|
||||
- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh <event_name>`
|
||||
- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 <event_name>`
|
||||
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). Only pass a generated message when `commit_style: conventional` is configured — first check `.specify/extensions/git/git-config.yml` for the value of `commit_style`:
|
||||
|
||||
- If `conventional`: inspect the diff and generate a Conventional Commit message. **Do not interpolate the generated message directly into a shell command string** — its content is derived from repository changes and may contain characters (quotes, `$(...)`, backticks) that a shell would execute or that would break command quoting. Instead, write the message to a temporary file using your file-editing tool (not a shell `echo`/`printf`), then pass that file's path via `--message-file <path>` (Bash) or `-MessageFile <path>` (PowerShell).
|
||||
- If `fixed` or absent: run the script with just `<event_name>`; it uses the configured/static message.
|
||||
Replace `<event_name>` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
|
||||
|
||||
## Configuration
|
||||
|
||||
In `.specify/extensions/git/git-config.yml`:
|
||||
|
||||
```yaml
|
||||
# "fixed" (default) uses the messages below; "conventional" asks the agent
|
||||
# to generate a Conventional Commit message from the diff instead.
|
||||
commit_style: fixed
|
||||
|
||||
auto_commit:
|
||||
default: false # Global toggle — set true to enable for all commands
|
||||
after_specify:
|
||||
@@ -60,4 +46,3 @@ auto_commit:
|
||||
- If Git is not available or the current directory is not a repository: skips with a warning
|
||||
- If no config file exists: skips (disabled by default)
|
||||
- If no changes to commit: skips with a message
|
||||
- If `commit_style: conventional` is set and no generated message was supplied: fails with a clear error instead of silently falling back to the fixed message format
|
||||
|
||||
@@ -17,13 +17,6 @@ branch_prefix: ""
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style used by auto-commit hooks (speckit.git.commit):
|
||||
# "fixed" - default; use the configured/static messages below.
|
||||
# "conventional" - ask the agent to inspect the diff and generate a
|
||||
# Conventional Commit message (e.g. "feat: add OAuth spec")
|
||||
# instead of using the messages configured below.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
|
||||
@@ -17,13 +17,6 @@ branch_prefix: ""
|
||||
# Commit message used by `git commit` during repository initialization
|
||||
init_commit_message: "[Spec Kit] Initial commit"
|
||||
|
||||
# Commit message style used by auto-commit hooks (speckit.git.commit):
|
||||
# "fixed" - default; use the configured/static messages below.
|
||||
# "conventional" - ask the agent to inspect the diff and generate a
|
||||
# Conventional Commit message (e.g. "feat: add OAuth spec")
|
||||
# instead of using the messages configured below.
|
||||
commit_style: fixed
|
||||
|
||||
# Auto-commit before/after core commands.
|
||||
# Set "default" to enable for all commands, then override per-command.
|
||||
# Each key can be true/false. Message is customizable per-command.
|
||||
|
||||
@@ -3,57 +3,16 @@
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.sh <event_name> [generated_message]
|
||||
# auto-commit.sh <event_name> --message-file <path>
|
||||
# Usage: auto-commit.sh <event_name>
|
||||
# e.g.: auto-commit.sh after_specify
|
||||
# e.g.: auto-commit.sh after_specify --message-file /tmp/commit-msg.txt (commit_style: conventional)
|
||||
#
|
||||
# --message-file is the preferred way to supply an agent-generated commit
|
||||
# message: it reads the message from a file instead of a shell argument,
|
||||
# so message content (which may contain quotes, `$(...)`, backticks, etc.)
|
||||
# is never interpolated into a shell command line.
|
||||
|
||||
set -e
|
||||
|
||||
EVENT_NAME="${1:-}"
|
||||
if [ -z "$EVENT_NAME" ]; then
|
||||
echo "Usage: $0 <event_name> [generated_message | --message-file <path>]" >&2
|
||||
echo "Usage: $0 <event_name>" >&2
|
||||
exit 1
|
||||
fi
|
||||
shift || true
|
||||
|
||||
# Optional second argument: an agent-generated commit message (used when
|
||||
# commit_style: conventional is configured). Prefer --message-file over
|
||||
# passing the message directly as a shell argument.
|
||||
GENERATED_MESSAGE=""
|
||||
while [ $# -gt 0 ]; do
|
||||
case "$1" in
|
||||
--message-file)
|
||||
_message_file="${2:-}"
|
||||
if [ -z "$_message_file" ]; then
|
||||
echo "[specify] Error: --message-file requires a path argument" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -f "$_message_file" ]; then
|
||||
echo "[specify] Error: message file '$_message_file' not found" >&2
|
||||
exit 1
|
||||
fi
|
||||
GENERATED_MESSAGE="$(cat "$_message_file")"
|
||||
# The message file is a transport-only artifact: its content is
|
||||
# now captured above, so remove it immediately. Otherwise, if it
|
||||
# was written inside the worktree, it would be picked up as an
|
||||
# untracked change by both the "any changes?" check below and by
|
||||
# `git add .`, polluting the commit or defeating the no-changes
|
||||
# short-circuit even when nothing else changed.
|
||||
rm -f "$_message_file"
|
||||
shift 2
|
||||
;;
|
||||
*)
|
||||
GENERATED_MESSAGE="$1"
|
||||
shift
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
|
||||
@@ -87,22 +46,8 @@ fi
|
||||
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
|
||||
_enabled=false
|
||||
_commit_msg=""
|
||||
_commit_style="fixed"
|
||||
|
||||
if [ -f "$_config_file" ]; then
|
||||
# Top-level scalar key: commit_style (fixed | conventional)
|
||||
_style_val=$(grep -m1 '^commit_style:' "$_config_file" 2>/dev/null | sed 's/^commit_style:[[:space:]]*//' | sed 's/[[:space:]]\{1,\}#.*$//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//' | tr '[:upper:]' '[:lower:]')
|
||||
if [ -n "$_style_val" ]; then
|
||||
case "$_style_val" in
|
||||
fixed|conventional)
|
||||
_commit_style="$_style_val"
|
||||
;;
|
||||
*)
|
||||
echo "[specify] Warning: unknown commit_style '$_style_val' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'" >&2
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
# Parse the auto_commit section for this event.
|
||||
# Look for auto_commit.<event_name>.enabled and .message
|
||||
# Also check auto_commit.default as fallback.
|
||||
@@ -149,12 +94,7 @@ if [ -f "$_config_file" ]; then
|
||||
[ "$_val" = "false" ] && _enabled=false
|
||||
fi
|
||||
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
|
||||
# Trim trailing whitespace before stripping the closing quote:
|
||||
# a value like `message: "Done" ` (trailing spaces after the
|
||||
# quote) would otherwise leave the quote dangling (`Done" `),
|
||||
# since the closing-quote strip is anchored to end-of-string.
|
||||
# The PowerShell twin .Trim()s first; match it for parity.
|
||||
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
||||
_commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
@@ -183,17 +123,6 @@ if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null &&
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# In conventional mode, the commit message must be supplied by the agent
|
||||
# (via the generated_message argument); never fall back to the fixed message.
|
||||
if [ "$_commit_style" = "conventional" ]; then
|
||||
if [ -n "$GENERATED_MESSAGE" ]; then
|
||||
_commit_msg="$GENERATED_MESSAGE"
|
||||
else
|
||||
echo "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass --message-file <path>, or a raw message as arg 2, or set commit_style: fixed)" >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
# e.g., after_specify -> specify, before_plan -> plan
|
||||
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
|
||||
|
||||
@@ -3,47 +3,14 @@
|
||||
# Automatically commit changes after a Spec Kit command completes.
|
||||
# Checks per-command config keys in git-config.yml before committing.
|
||||
#
|
||||
# Usage: auto-commit.ps1 <event_name> [generated_message]
|
||||
# auto-commit.ps1 <event_name> -MessageFile <path>
|
||||
# Usage: auto-commit.ps1 <event_name>
|
||||
# e.g.: auto-commit.ps1 after_specify
|
||||
# e.g.: auto-commit.ps1 after_specify -MessageFile C:\temp\commit-msg.txt (commit_style: conventional)
|
||||
#
|
||||
# -MessageFile is the preferred way to supply an agent-generated commit
|
||||
# message: it reads the message from a file instead of a shell argument,
|
||||
# so message content (which may contain quotes, $(...), backticks, etc.)
|
||||
# is never interpolated into a shell command line.
|
||||
param(
|
||||
[Parameter(Position = 0, Mandatory = $true)]
|
||||
[string]$EventName,
|
||||
|
||||
# Optional agent-generated commit message (used when commit_style: conventional is configured).
|
||||
# Prefer -MessageFile over passing the message directly as a shell argument.
|
||||
[Parameter(Position = 1, Mandatory = $false)]
|
||||
[string]$GeneratedMessage = "",
|
||||
|
||||
[Parameter(Mandatory = $false)]
|
||||
[string]$MessageFile = ""
|
||||
[string]$EventName
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($MessageFile) {
|
||||
if (-not (Test-Path $MessageFile -PathType Leaf)) {
|
||||
Write-Warning "[specify] Error: message file '$MessageFile' not found"
|
||||
exit 1
|
||||
}
|
||||
$GeneratedMessage = (Get-Content -Path $MessageFile -Raw)
|
||||
if ($null -ne $GeneratedMessage) {
|
||||
$GeneratedMessage = $GeneratedMessage.TrimEnd("`r", "`n")
|
||||
}
|
||||
# The message file is a transport-only artifact: its content is now
|
||||
# captured above, so remove it immediately. Otherwise, if it was written
|
||||
# inside the worktree, it would be picked up as an untracked change by
|
||||
# both the "any changes?" check below and by `git add .`, polluting the
|
||||
# commit or defeating the no-changes short-circuit even when nothing
|
||||
# else changed.
|
||||
Remove-Item -Path $MessageFile -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
function Find-ProjectRoot {
|
||||
param([string]$StartDir)
|
||||
$current = Resolve-Path $StartDir
|
||||
@@ -88,25 +55,8 @@ if (-not $isRepo) {
|
||||
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
|
||||
$enabled = $false
|
||||
$commitMsg = ""
|
||||
$commitStyle = "fixed"
|
||||
|
||||
if (Test-Path $configFile) {
|
||||
# Top-level scalar key: commit_style (fixed | conventional)
|
||||
foreach ($line in Get-Content $configFile) {
|
||||
if ($line -match '^commit_style:\s*(.+)$') {
|
||||
$styleVal = (($matches[1] -replace '\s+#.*$', '').Trim()) -replace '^["'']' -replace '["'']$'
|
||||
if ($styleVal) {
|
||||
$styleVal = $styleVal.ToLower()
|
||||
if ($styleVal -eq 'fixed' -or $styleVal -eq 'conventional') {
|
||||
$commitStyle = $styleVal
|
||||
} else {
|
||||
Write-Warning "[specify] Warning: unknown commit_style '$styleVal' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'"
|
||||
}
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
# Parse YAML to find auto_commit section
|
||||
$inAutoCommit = $false
|
||||
$inEvent = $false
|
||||
@@ -190,17 +140,6 @@ if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
|
||||
exit 0
|
||||
}
|
||||
|
||||
# In conventional mode, the commit message must be supplied by the agent
|
||||
# (via the GeneratedMessage argument); never fall back to the fixed message.
|
||||
if ($commitStyle -eq 'conventional') {
|
||||
if ($GeneratedMessage) {
|
||||
$commitMsg = $GeneratedMessage
|
||||
} else {
|
||||
Write-Warning "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass -MessageFile <path>, or a raw message as arg 2, or set commit_style: fixed)"
|
||||
exit 1
|
||||
}
|
||||
}
|
||||
|
||||
# Derive a human-readable command name from the event
|
||||
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
|
||||
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
|
||||
|
||||
@@ -565,12 +565,6 @@ if (-not $DryRun) {
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
}
|
||||
|
||||
# Build the PowerShell-idiomatic persist hint, mirroring the core
|
||||
# create-new-feature.ps1 twin (and the bash/python twins of this script), which
|
||||
# all emit "# To persist in your shell: ...".
|
||||
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
|
||||
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
|
||||
|
||||
if ($Json) {
|
||||
$obj = [PSCustomObject]@{
|
||||
BRANCH_NAME = $branchName
|
||||
@@ -587,6 +581,6 @@ if ($Json) {
|
||||
Write-Output "BRANCH_NAME: $branchName"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "# To persist in your shell: $featureAssignment"
|
||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,15 +33,7 @@ def _value_after_colon(line: str) -> str:
|
||||
|
||||
|
||||
def _strip_quotes(value: str) -> str:
|
||||
"""Strip surrounding whitespace, then one leading quote and all trailing quotes.
|
||||
|
||||
Trimming first matters when the YAML value has trailing whitespace after a
|
||||
closing quote (``message: "Done" ``): stripping quotes anchored to the end
|
||||
of string would leave the closing quote dangling (``Done" ``) because the
|
||||
quote is no longer at the end. The PowerShell twin ``.Trim()``s before
|
||||
stripping, so trim here too to keep all three script variants in parity.
|
||||
"""
|
||||
value = value.strip()
|
||||
"""Strip one leading quote and all trailing quotes, mirroring the bash sed."""
|
||||
value = re.sub(r"^[\"']", "", value)
|
||||
return re.sub(r"[\"']*$", "", value)
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-17T00:00:00Z",
|
||||
"updated_at": "2026-07-15T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
|
||||
"integrations": {
|
||||
"claude": {
|
||||
@@ -48,15 +48,6 @@
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide"]
|
||||
},
|
||||
"droid": {
|
||||
"id": "droid",
|
||||
"name": "Factory Droid",
|
||||
"version": "1.0.0",
|
||||
"description": "Factory Droid CLI skills-based integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["cli", "skills", "factory"]
|
||||
},
|
||||
"amp": {
|
||||
"id": "amp",
|
||||
"name": "Amp",
|
||||
@@ -186,11 +177,11 @@
|
||||
"bob": {
|
||||
"id": "bob",
|
||||
"name": "IBM Bob",
|
||||
"version": "2.0.0",
|
||||
"description": "IBM Bob 2.0 IDE skills-based integration",
|
||||
"version": "1.0.0",
|
||||
"description": "IBM Bob IDE integration",
|
||||
"author": "spec-kit-core",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"tags": ["ide", "ibm", "skills"]
|
||||
"tags": ["ide", "ibm"]
|
||||
},
|
||||
"trae": {
|
||||
"id": "trae",
|
||||
|
||||
@@ -1,19 +1,18 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-24T00:00:00Z",
|
||||
|
||||
"updated_at": "2026-07-17T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
"name": "A11Y Governance",
|
||||
"id": "a11y-governance",
|
||||
"version": "0.4.1",
|
||||
"description": "Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence.",
|
||||
"version": "0.4.0",
|
||||
"description": "Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -34,18 +33,18 @@
|
||||
"didactic-comments"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
"id": "agent-parity-governance",
|
||||
"version": "0.4.0",
|
||||
"description": "Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing.",
|
||||
"version": "0.3.0",
|
||||
"description": "Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.3.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.0/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -64,13 +63,13 @@
|
||||
"multi-agent"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"aide-in-place": {
|
||||
"name": "AIDE In-Place Migration",
|
||||
"id": "aide-in-place",
|
||||
"version": "1.0.0",
|
||||
"description": "Adapts the AIDE workflow for in-place technology migrations (X \u2192 Y pattern). Overrides vision, roadmap, progress, and work item commands with migration-specific guidance.",
|
||||
"description": "Adapts the AIDE workflow for in-place technology migrations (X → Y pattern). Overrides vision, roadmap, progress, and work item commands with migration-specific guidance.",
|
||||
"author": "mnriem",
|
||||
"repository": "https://github.com/mnriem/spec-kit-presets",
|
||||
"download_url": "https://github.com/mnriem/spec-kit-presets/releases/download/aide-in-place-v1.0.0/aide-in-place.zip",
|
||||
@@ -97,13 +96,13 @@
|
||||
"architecture-governance": {
|
||||
"name": "Architecture Governance",
|
||||
"id": "architecture-governance",
|
||||
"version": "0.5.1",
|
||||
"description": "Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"version": "0.5.0",
|
||||
"description": "Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -130,18 +129,18 @@
|
||||
"assurance"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
"id": "autonomous-run-governance",
|
||||
"version": "0.3.2",
|
||||
"description": "Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation.",
|
||||
"version": "0.2.2",
|
||||
"description": "Adds permission-bounded, evidence-first governance for autonomous Spec Kit delivery with validated status, stop, resume, exact-head proof, closeout, and learner guidance.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.2.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.2.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
@@ -156,11 +155,10 @@
|
||||
"governance",
|
||||
"evidence",
|
||||
"permissions",
|
||||
"resume",
|
||||
"intake-review"
|
||||
"resume"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
"updated_at": "2026-07-17T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
@@ -244,13 +242,13 @@
|
||||
"cross-platform-governance": {
|
||||
"name": "Cross-Platform Governance",
|
||||
"id": "cross-platform-governance",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence.",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -272,7 +270,7 @@
|
||||
"linux"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"explicit-task-dependencies": {
|
||||
"name": "Explicit Task Dependencies",
|
||||
@@ -365,74 +363,16 @@
|
||||
"created_at": "2026-05-05T08:00:00Z",
|
||||
"updated_at": "2026-06-22T00:00:00Z"
|
||||
},
|
||||
"intake-authoring-governance": {
|
||||
"name": "Intake Authoring Governance",
|
||||
"id": "intake-authoring-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Governs traceable intake CRUD, bounded public HTTPS sources, and explicitly approved single or series authoring without granting execution authority.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.2.0/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 10,
|
||||
"commands": 5,
|
||||
"scripts": 4
|
||||
},
|
||||
"tags": [
|
||||
"intake",
|
||||
"authoring",
|
||||
"governance",
|
||||
"provenance",
|
||||
"requirements"
|
||||
],
|
||||
"created_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
},
|
||||
"intake-review-governance": {
|
||||
"name": "Intake Review Governance",
|
||||
"id": "intake-review-governance",
|
||||
"version": "0.1.1",
|
||||
"description": "Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 8,
|
||||
"commands": 3,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"intake",
|
||||
"review",
|
||||
"governance",
|
||||
"quality-gate",
|
||||
"autonomous"
|
||||
],
|
||||
"created_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
},
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
"id": "isaqb-architecture-governance",
|
||||
"version": "0.2.1",
|
||||
"description": "Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt.",
|
||||
"version": "0.2.0",
|
||||
"description": "Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -453,7 +393,7 @@
|
||||
"technical-debt"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"jira": {
|
||||
"name": "Jira Issue Tracking",
|
||||
@@ -540,36 +480,6 @@
|
||||
"created_at": "2026-04-09T00:00:00Z",
|
||||
"updated_at": "2026-04-09T00:00:00Z"
|
||||
},
|
||||
"parallel-autonomous-run-governance": {
|
||||
"name": "Parallel Autonomous Run Governance",
|
||||
"id": "parallel-autonomous-run-governance",
|
||||
"version": "0.2.3",
|
||||
"description": "Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.3.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.3/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 9,
|
||||
"commands": 5,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"parallel",
|
||||
"autonomous",
|
||||
"governance",
|
||||
"orchestration",
|
||||
"resume",
|
||||
"intake-review"
|
||||
],
|
||||
"created_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z"
|
||||
},
|
||||
"pirate": {
|
||||
"name": "Pirate Speak (Full)",
|
||||
"id": "pirate",
|
||||
@@ -599,7 +509,7 @@
|
||||
"name": "Screenwriting",
|
||||
"id": "screenwriting",
|
||||
"version": "1.0.0",
|
||||
"description": "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 \u2014 slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.",
|
||||
"description": "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 replace prose fiction conventions. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks.",
|
||||
"author": "Andreas Daumann",
|
||||
"repository": "https://github.com/adaumann/speckit-preset-screenwriting",
|
||||
"download_url": "https://github.com/adaumann/speckit-preset-screenwriting/archive/refs/tags/v1.0.0.zip",
|
||||
@@ -636,13 +546,13 @@
|
||||
"security-governance": {
|
||||
"name": "Security Governance",
|
||||
"id": "security-governance",
|
||||
"version": "0.6.1",
|
||||
"description": "Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening.",
|
||||
"version": "0.6.0",
|
||||
"description": "Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA to Spec Kit.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.0"
|
||||
@@ -681,7 +591,7 @@
|
||||
"regulatory"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-06-14T00:00:00Z"
|
||||
},
|
||||
"sicario-core": {
|
||||
"name": "SicarioSpec Core",
|
||||
@@ -714,7 +624,7 @@
|
||||
"name": "Spec2Cloud",
|
||||
"id": "spec2cloud",
|
||||
"version": "1.1.0",
|
||||
"description": "Spec-driven workflow tuned for shipping to Azure: spec \u2192 plan \u2192 tasks \u2192 implement \u2192 deploy.",
|
||||
"description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy.",
|
||||
"author": "Azure Samples",
|
||||
"repository": "https://github.com/Azure-Samples/Spec2Cloud",
|
||||
"download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/preset.zip",
|
||||
@@ -742,7 +652,7 @@
|
||||
"id": "test-first-governance",
|
||||
"version": "1.3.0",
|
||||
"description": "Governs TDD with coverage-complete BDD/ATDD Gherkin scenarios, explicit suite ownership, professional test reports, traceability, and risk-based quality gates.",
|
||||
"author": "Zolt\u00e1n Katona, PhD",
|
||||
"author": "Zoltán Katona, PhD",
|
||||
"repository": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
"download_url": "https://github.com/ka-zo/spec-kit-preset-test-first-governance/archive/refs/tags/1.3.0.zip",
|
||||
"homepage": "https://github.com/ka-zo/spec-kit-preset-test-first-governance",
|
||||
|
||||
@@ -8,24 +8,6 @@ description: Create or update the project constitution.
|
||||
$ARGUMENTS
|
||||
```
|
||||
|
||||
## Scope Guard
|
||||
|
||||
This command's own work is limited to creating or updating the project constitution and
|
||||
propagating constitution-driven changes to dependent Spec Kit artifacts.
|
||||
|
||||
- Classify every part of the user input as constitution content or a separate non-governance
|
||||
intent. Feature implementation, code generation, refactoring, build, and deployment requests
|
||||
are examples of non-governance intents.
|
||||
- You **MUST NOT** execute any non-governance intent. Defer each one to `Next Actions`.
|
||||
- You **MUST NOT** create, modify, or delete application source files or other artifacts
|
||||
unrelated to the constitution workflow.
|
||||
- If an instruction could be either constitution content or a non-governance intent, ask for
|
||||
clarification before making changes.
|
||||
- After updating the constitution, list each deferred intent in a `Next Actions` section with an
|
||||
appropriate follow-up Spec Kit command, such as `__SPECKIT_COMMAND_SPECIFY__`, but do not
|
||||
invoke it.
|
||||
- Omit `Next Actions` when there are no non-governance intents.
|
||||
|
||||
## Outline
|
||||
|
||||
1. Create or update the project constitution and store it in `.specify/memory/constitution.md`.
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.14.2"
|
||||
version = "0.13.0"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
@@ -39,7 +39,6 @@ packages = ["src/specify_cli"]
|
||||
"templates/commands" = "specify_cli/core_pack/commands"
|
||||
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
|
||||
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
|
||||
"scripts/python" = "specify_cli/core_pack/scripts/python"
|
||||
# Bundled extensions (installable via `specify extension add <name>`)
|
||||
"extensions/git" = "specify_cli/core_pack/extensions/git"
|
||||
"extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context"
|
||||
@@ -49,8 +48,6 @@ packages = ["src/specify_cli"]
|
||||
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
|
||||
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
|
||||
"presets/lean" = "specify_cli/core_pack/presets/lean"
|
||||
# Community bundle catalog snapshot (used for offline discovery)
|
||||
"bundles/catalog.community.json" = "specify_cli/core_pack/bundles/catalog.community.json"
|
||||
|
||||
[project.optional-dependencies]
|
||||
test = [
|
||||
|
||||
@@ -8,7 +8,6 @@ ALLOW_EXISTING=false
|
||||
SHORT_NAME=""
|
||||
BRANCH_NUMBER=""
|
||||
USE_TIMESTAMP=false
|
||||
NUMBER_EXPLICIT=false
|
||||
ARGS=()
|
||||
i=1
|
||||
while [ $i -le $# ]; do
|
||||
@@ -49,9 +48,6 @@ while [ $i -le $# ]; do
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER="$next_arg"
|
||||
if [ -n "$BRANCH_NUMBER" ]; then
|
||||
NUMBER_EXPLICIT=true
|
||||
fi
|
||||
;;
|
||||
--timestamp)
|
||||
USE_TIMESTAMP=true
|
||||
@@ -64,7 +60,7 @@ while [ $i -le $# ]; do
|
||||
echo " --dry-run Compute feature name and paths without creating directories or files"
|
||||
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
|
||||
echo " --short-name <name> Provide a custom short name (2-4 words) for the feature"
|
||||
echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)"
|
||||
echo " --number N Specify branch number manually (overrides auto-detection)"
|
||||
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
echo " --help, -h Show this help message"
|
||||
echo ""
|
||||
@@ -94,20 +90,6 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
|
||||
exit 1
|
||||
fi
|
||||
|
||||
MAX_FEATURE_NUMBER=9223372036854775807
|
||||
MAX_BRANCH_LENGTH=244
|
||||
|
||||
is_feature_number_in_range() {
|
||||
local value="$1"
|
||||
local normalized="${value#"${value%%[!0]*}"}"
|
||||
[ -n "$normalized" ] || normalized=0
|
||||
[ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0
|
||||
[ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1
|
||||
# Equal-length digit strings must be compared without arithmetic overflow.
|
||||
# shellcheck disable=SC2071
|
||||
[[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]]
|
||||
}
|
||||
|
||||
# Function to get highest number from specs directory
|
||||
get_highest_from_specs() {
|
||||
local specs_dir="$1"
|
||||
@@ -120,11 +102,9 @@ get_highest_from_specs() {
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
|
||||
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
|
||||
if is_feature_number_in_range "$number"; then
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
number=$((10#$number))
|
||||
if [ "$number" -gt "$highest" ]; then
|
||||
highest=$number
|
||||
fi
|
||||
fi
|
||||
done
|
||||
@@ -133,53 +113,12 @@ get_highest_from_specs() {
|
||||
echo "$highest"
|
||||
}
|
||||
|
||||
# Return success when a spec directory owns the given numeric prefix.
|
||||
spec_prefix_exists() {
|
||||
local specs_dir="$1"
|
||||
local feature_num="$2"
|
||||
|
||||
for spec_path in "$specs_dir/${feature_num}-"*; do
|
||||
[ -d "$spec_path" ] && return 0
|
||||
done
|
||||
return 1
|
||||
}
|
||||
|
||||
# Function to clean and format a branch name
|
||||
clean_branch_name() {
|
||||
local name="$1"
|
||||
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
|
||||
}
|
||||
|
||||
# Fit a feature prefix and suffix within GitHub's branch-name limit.
|
||||
fit_branch_name() {
|
||||
local feature_num="$1"
|
||||
local branch_suffix="$2"
|
||||
local branch_name="${feature_num}-${branch_suffix}"
|
||||
|
||||
if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then
|
||||
local prefix_length=$(( ${#feature_num} + 1 ))
|
||||
local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length))
|
||||
local truncated_suffix
|
||||
truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//')
|
||||
branch_name="${feature_num}-${truncated_suffix}"
|
||||
fi
|
||||
|
||||
printf '%s' "$branch_name"
|
||||
}
|
||||
|
||||
# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
|
||||
# so the persistence hints match the Python variant exactly (printf %q output
|
||||
# differs between bash versions and from shlex.quote for spaces/metachars).
|
||||
shell_quote() {
|
||||
local value="$1" LC_ALL=C
|
||||
if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then
|
||||
printf '%s' "$value"
|
||||
else
|
||||
local q="'\"'\"'"
|
||||
printf "'%s'" "${value//\'/$q}"
|
||||
fi
|
||||
}
|
||||
|
||||
# Resolve repository root using common.sh functions which prioritize .specify
|
||||
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/common.sh"
|
||||
@@ -263,64 +202,34 @@ if [ "$USE_TIMESTAMP" = true ]; then
|
||||
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
else
|
||||
if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
|
||||
echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bash arithmetic is signed 64-bit; reject digit strings that would wrap.
|
||||
if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then
|
||||
echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Determine branch number from existing feature directories
|
||||
if [ -z "$BRANCH_NUMBER" ]; then
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then
|
||||
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER=$((HIGHEST + 1))
|
||||
fi
|
||||
|
||||
# Force base-10 interpretation to prevent octal conversion (e.g., 010 → 8 in octal, but should be 10 in decimal)
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
|
||||
# Treat an explicit number as a preference when its prefix is already used
|
||||
# by a feature directory. Auto-detected numbers are already conflict-free.
|
||||
if [ "$NUMBER_EXPLICIT" = true ]; then
|
||||
SPEC_CONFLICT=false
|
||||
REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME"
|
||||
if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then
|
||||
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true
|
||||
fi
|
||||
|
||||
if [ "$SPEC_CONFLICT" = true ]; then
|
||||
REQUESTED_NUM="$FEATURE_NUM"
|
||||
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
|
||||
BRANCH_NUMBER=$HIGHEST
|
||||
while true; do
|
||||
if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then
|
||||
echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
|
||||
exit 1
|
||||
fi
|
||||
BRANCH_NUMBER=$((BRANCH_NUMBER + 1))
|
||||
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
|
||||
spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break
|
||||
done
|
||||
>&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead"
|
||||
fi
|
||||
fi
|
||||
|
||||
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
fi
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
|
||||
BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
|
||||
if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
|
||||
MAX_BRANCH_LENGTH=244
|
||||
if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
|
||||
# Calculate how much we need to trim from suffix
|
||||
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||
PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
|
||||
MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
|
||||
|
||||
# Truncate suffix at word boundary if possible
|
||||
TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
|
||||
# Remove trailing hyphen if truncation created one
|
||||
TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
|
||||
|
||||
ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
|
||||
BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
|
||||
|
||||
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
|
||||
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
|
||||
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
|
||||
@@ -355,8 +264,8 @@ if [ "$DRY_RUN" != true ]; then
|
||||
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
|
||||
|
||||
# Inform the user how to set feature state in their own shell
|
||||
printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2
|
||||
printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
|
||||
fi
|
||||
|
||||
if $JSON_MODE; then
|
||||
@@ -386,7 +295,7 @@ else
|
||||
echo "SPEC_FILE: $SPEC_FILE"
|
||||
echo "FEATURE_NUM: $FEATURE_NUM"
|
||||
if [ "$DRY_RUN" != true ]; then
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")"
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")"
|
||||
printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
|
||||
printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
|
||||
fi
|
||||
fi
|
||||
|
||||
@@ -29,16 +29,13 @@ function Find-SpecifyRoot {
|
||||
# command against a member project from a monorepo root without cd.
|
||||
#
|
||||
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
|
||||
# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by
|
||||
# design: the path must exist and
|
||||
# or writes an error and exits 1. Strict by design: the path must exist and
|
||||
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
|
||||
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
|
||||
#
|
||||
# This is the single resolver: bundled extensions inherit it by sourcing core
|
||||
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
|
||||
function Resolve-SpecifyInitDir {
|
||||
param([switch]$ReturnNullOnError)
|
||||
|
||||
$initDir = $env:SPECIFY_INIT_DIR
|
||||
# Normalize: relative paths resolve against the current directory.
|
||||
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
|
||||
@@ -50,7 +47,6 @@ function Resolve-SpecifyInitDir {
|
||||
# "not a Spec Kit project" error below.
|
||||
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
# Resolve-Path echoes back any trailing separator from the input; trim it so
|
||||
@@ -60,7 +56,6 @@ function Resolve-SpecifyInitDir {
|
||||
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
|
||||
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
|
||||
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
return $initRoot
|
||||
@@ -69,11 +64,9 @@ function Resolve-SpecifyInitDir {
|
||||
# Get repository root, prioritizing .specify directory
|
||||
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
|
||||
function Get-RepoRoot {
|
||||
param([switch]$ReturnNullOnError)
|
||||
|
||||
# Explicit project override wins (see Resolve-SpecifyInitDir).
|
||||
if ($env:SPECIFY_INIT_DIR) {
|
||||
return (Resolve-SpecifyInitDir -ReturnNullOnError:$ReturnNullOnError)
|
||||
return (Resolve-SpecifyInitDir)
|
||||
}
|
||||
|
||||
# First, look for .specify directory (spec-kit's own marker)
|
||||
@@ -154,12 +147,10 @@ function Get-FeaturePathsEnv {
|
||||
# so pure path resolution never writes .specify/feature.json, which would
|
||||
# dirty the working tree or overwrite a pinned value (issue #3025).
|
||||
param(
|
||||
[switch]$NoPersist,
|
||||
[switch]$ReturnNullOnError
|
||||
[switch]$NoPersist
|
||||
)
|
||||
|
||||
$repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError
|
||||
if (-not $repoRoot) { return $null }
|
||||
$repoRoot = Get-RepoRoot
|
||||
$currentBranch = Get-CurrentBranch
|
||||
|
||||
# Resolve feature directory. Priority:
|
||||
@@ -183,8 +174,7 @@ function Get-FeaturePathsEnv {
|
||||
try {
|
||||
$featureConfig = $featureJsonRaw | ConvertFrom-Json
|
||||
} catch {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
[Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
|
||||
exit 1
|
||||
}
|
||||
if ($featureConfig.feature_directory) {
|
||||
@@ -195,12 +185,10 @@ function Get-FeaturePathsEnv {
|
||||
}
|
||||
} else {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
} else {
|
||||
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
|
||||
if ($ReturnNullOnError) { return $null }
|
||||
exit 1
|
||||
}
|
||||
|
||||
@@ -346,64 +334,30 @@ function Resolve-Template {
|
||||
if (Test-Path $presetsDir) {
|
||||
$registryFile = Join-Path $presetsDir '.registry'
|
||||
$sortedPresets = @()
|
||||
$registryParsed = $false
|
||||
if (Test-Path $registryFile) {
|
||||
try {
|
||||
$registryData = Get-Content $registryFile -Raw | 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 $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] } |
|
||||
$presets = $registryData.presets
|
||||
if ($presets) {
|
||||
$sortedPresets = $presets.PSObject.Properties |
|
||||
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
|
||||
Sort-Object { & $priorityFor $_ } |
|
||||
Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
|
||||
ForEach-Object { $_.Name }
|
||||
}
|
||||
$registryParsed = $true
|
||||
} catch {
|
||||
$registryParsed = $false
|
||||
# Fallback: alphabetical directory order
|
||||
$sortedPresets = @()
|
||||
}
|
||||
}
|
||||
|
||||
if ($registryParsed) {
|
||||
if ($sortedPresets.Count -gt 0) {
|
||||
foreach ($presetId in $sortedPresets) {
|
||||
$candidate = Join-Path $presetsDir "$presetId/templates/$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) {
|
||||
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) { return $candidate }
|
||||
}
|
||||
|
||||
@@ -7,14 +7,13 @@ param(
|
||||
[switch]$DryRun,
|
||||
[string]$ShortName,
|
||||
[Parameter()]
|
||||
[string]$Number = '',
|
||||
[long]$Number = 0,
|
||||
[switch]$Timestamp,
|
||||
[switch]$Help,
|
||||
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
|
||||
[string[]]$FeatureDescription
|
||||
)
|
||||
$ErrorActionPreference = 'Stop'
|
||||
$maxBranchLength = 244
|
||||
|
||||
# Show help if requested
|
||||
if ($Help) {
|
||||
@@ -25,7 +24,7 @@ if ($Help) {
|
||||
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
|
||||
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
|
||||
Write-Host " -ShortName <name> Provide a custom short name (2-4 words) for the feature"
|
||||
Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
|
||||
Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
|
||||
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
|
||||
Write-Host " -Help Show this help message"
|
||||
Write-Host ""
|
||||
@@ -68,44 +67,11 @@ function Get-HighestNumberFromSpecs {
|
||||
return $highest
|
||||
}
|
||||
|
||||
function Test-SpecPrefixInUse {
|
||||
param(
|
||||
[string]$SpecsDir,
|
||||
[string]$FeatureNum
|
||||
)
|
||||
|
||||
if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
|
||||
return $false
|
||||
}
|
||||
|
||||
return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
|
||||
Where-Object { $_.Name -like "$FeatureNum-*" } |
|
||||
Select-Object -First 1)
|
||||
}
|
||||
|
||||
function ConvertTo-CleanBranchName {
|
||||
param([string]$Name)
|
||||
|
||||
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
|
||||
}
|
||||
|
||||
function Get-FittedBranchName {
|
||||
param(
|
||||
[string]$FeatureNum,
|
||||
[string]$BranchSuffix
|
||||
)
|
||||
|
||||
$fittedName = "$FeatureNum-$BranchSuffix"
|
||||
if ($fittedName.Length -gt $maxBranchLength) {
|
||||
$prefixLength = $FeatureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
$truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
$fittedName = "$FeatureNum-$truncatedSuffix"
|
||||
}
|
||||
|
||||
return $fittedName
|
||||
}
|
||||
# Load common functions (includes Get-RepoRoot and Resolve-Template)
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
@@ -176,13 +142,12 @@ if ($ShortName) {
|
||||
$branchSuffix = Get-BranchName -Description $featureDesc
|
||||
}
|
||||
|
||||
# Treat an explicit empty string as omitted, matching the bash and Python twins.
|
||||
$hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne ''
|
||||
|
||||
# Warn if -Number and -Timestamp are both specified.
|
||||
if ($Timestamp -and $hasNumber) {
|
||||
[Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used")
|
||||
$Number = ''
|
||||
# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
|
||||
# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
|
||||
# `[ -n "$BRANCH_NUMBER" ]` check.
|
||||
if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
|
||||
Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
|
||||
$Number = 0
|
||||
}
|
||||
|
||||
# Determine branch prefix
|
||||
@@ -193,60 +158,34 @@ if ($Timestamp) {
|
||||
# Determine branch number from existing feature directories. Auto-detect only
|
||||
# when -Number was not supplied; an explicit value (including 0) is honored,
|
||||
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
|
||||
[long]$resolvedNumber = 0
|
||||
if (-not $hasNumber) {
|
||||
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
|
||||
if ($highestNumber -eq [long]::MaxValue) {
|
||||
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
|
||||
exit 1
|
||||
}
|
||||
$resolvedNumber = $highestNumber + 1
|
||||
} elseif ($Number -notmatch '^[0-9]+$') {
|
||||
Write-Error "Error: -Number must be an unsigned integer, got '$Number'"
|
||||
exit 1
|
||||
} elseif (-not [long]::TryParse($Number, [ref]$resolvedNumber)) {
|
||||
Write-Error "Error: -Number must be between 0 and $([long]::MaxValue), got '$Number'"
|
||||
exit 1
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $resolvedNumber)
|
||||
|
||||
# Treat an explicit number as a preference when its prefix is already used
|
||||
# by a feature directory. Auto-detected numbers are already conflict-free.
|
||||
$specConflict = $false
|
||||
if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
|
||||
$requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
$requestedDir = Join-Path $specsDir $requestedBranchName
|
||||
if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
|
||||
$specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
|
||||
}
|
||||
}
|
||||
|
||||
if ($specConflict) {
|
||||
$requestedNum = $featureNum
|
||||
$highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
|
||||
$resolvedNumber = $highestNumber
|
||||
do {
|
||||
if ($resolvedNumber -eq [long]::MaxValue) {
|
||||
Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
|
||||
exit 1
|
||||
}
|
||||
$resolvedNumber++
|
||||
$featureNum = ('{0:000}' -f $resolvedNumber)
|
||||
} while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
|
||||
[Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
|
||||
if (-not $PSBoundParameters.ContainsKey('Number')) {
|
||||
$Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
|
||||
}
|
||||
|
||||
$featureNum = ('{0:000}' -f $Number)
|
||||
$branchName = "$featureNum-$branchSuffix"
|
||||
}
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names
|
||||
# Validate and truncate if necessary
|
||||
$originalBranchName = "$featureNum-$branchSuffix"
|
||||
$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
|
||||
if ($branchName -ne $originalBranchName) {
|
||||
[Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
|
||||
[Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
|
||||
[Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
|
||||
$maxBranchLength = 244
|
||||
if ($branchName.Length -gt $maxBranchLength) {
|
||||
# Calculate how much we need to trim from suffix
|
||||
# Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
|
||||
$prefixLength = $featureNum.Length + 1
|
||||
$maxSuffixLength = $maxBranchLength - $prefixLength
|
||||
|
||||
# Truncate suffix
|
||||
$truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
|
||||
# Remove trailing hyphen if truncation created one
|
||||
$truncatedSuffix = $truncatedSuffix -replace '-$', ''
|
||||
|
||||
$originalBranchName = $branchName
|
||||
$branchName = "$featureNum-$truncatedSuffix"
|
||||
|
||||
Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
|
||||
Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
|
||||
Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
|
||||
}
|
||||
|
||||
$featureDir = Join-Path $specsDir $branchName
|
||||
@@ -286,13 +225,6 @@ if (-not $DryRun) {
|
||||
# Set environment variables for the current session
|
||||
$env:SPECIFY_FEATURE = $branchName
|
||||
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
|
||||
|
||||
$quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
|
||||
$quotedFeatureDir = "'" + $featureDir.Replace("'", "''") + "'"
|
||||
$featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
|
||||
$directoryAssignment = '$env:SPECIFY_FEATURE_DIRECTORY = ' + $quotedFeatureDir
|
||||
[Console]::Error.WriteLine("# To persist: $featureAssignment")
|
||||
[Console]::Error.WriteLine("# $directoryAssignment")
|
||||
}
|
||||
|
||||
if ($Json) {
|
||||
@@ -310,7 +242,7 @@ if ($Json) {
|
||||
Write-Output "SPEC_FILE: $specFile"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "# To persist in your shell: $featureAssignment"
|
||||
Write-Output "# $directoryAssignment"
|
||||
Write-Output "SPECIFY_FEATURE set to: $branchName"
|
||||
Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,10 +4,7 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help,
|
||||
# Capture extra positional arguments to match Bash/Python behavior.
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$RemainingArgs
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
@@ -24,11 +21,7 @@ if ($Help) {
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get all paths and variables from common functions
|
||||
$paths = Get-FeaturePathsEnv -ReturnNullOnError
|
||||
if (-not $paths) {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
|
||||
exit 1
|
||||
}
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
# Ensure the feature directory exists
|
||||
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
|
||||
|
||||
@@ -3,34 +3,21 @@
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[switch]$Json,
|
||||
[switch]$Help,
|
||||
[Parameter(ValueFromRemainingArguments = $true)]
|
||||
[string[]]$RemainingArgs
|
||||
[switch]$Help
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
# Help wins over unknown-argument validation to match the Bash/Python
|
||||
# variants, which stop at --help and exit 0.
|
||||
if ($Help) {
|
||||
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
|
||||
exit 0
|
||||
}
|
||||
|
||||
if ($RemainingArgs.Count -gt 0) {
|
||||
[Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'")
|
||||
exit 1
|
||||
}
|
||||
|
||||
# Source common functions
|
||||
. "$PSScriptRoot/common.ps1"
|
||||
|
||||
# Get feature paths
|
||||
$paths = Get-FeaturePathsEnv -ReturnNullOnError
|
||||
if (-not $paths) {
|
||||
[Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
|
||||
exit 1
|
||||
}
|
||||
$paths = Get-FeaturePathsEnv
|
||||
|
||||
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
|
||||
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
|
||||
@@ -58,8 +45,8 @@ 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)) {
|
||||
[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.")
|
||||
$expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md'
|
||||
[Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.")
|
||||
exit 1
|
||||
}
|
||||
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
|
||||
|
||||
@@ -84,7 +84,7 @@ def read_feature_json_feature_directory(repo_root: Path) -> str:
|
||||
return ""
|
||||
try:
|
||||
data = json.loads(feature_json.read_text(encoding="utf-8"))
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
except (OSError, json.JSONDecodeError):
|
||||
return ""
|
||||
value = data.get("feature_directory") if isinstance(data, dict) else None
|
||||
return value if isinstance(value, str) else ""
|
||||
@@ -95,17 +95,16 @@ def _json_dump(data: dict[str, str]) -> str:
|
||||
|
||||
|
||||
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
|
||||
# Strip the repo root prefix lexically (no resolve()) to mirror the
|
||||
# Bash/PowerShell helpers: with a symlinked <repo>/specs, resolve() would
|
||||
# escape the repo and persist a machine-specific absolute path instead of
|
||||
# the relative "specs/NNN-name" the other variants store.
|
||||
value = feature_dir_value
|
||||
relative = Path(value)
|
||||
if relative.is_absolute():
|
||||
try:
|
||||
value = relative.relative_to(repo_root).as_posix()
|
||||
except ValueError:
|
||||
value = str(relative)
|
||||
try:
|
||||
relative = Path(value)
|
||||
if relative.is_absolute():
|
||||
try:
|
||||
value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
|
||||
except ValueError:
|
||||
value = str(relative)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
current = read_feature_json_feature_directory(repo_root)
|
||||
if current == value:
|
||||
@@ -113,8 +112,9 @@ def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
|
||||
|
||||
specify_dir = repo_root / ".specify"
|
||||
specify_dir.mkdir(parents=True, exist_ok=True)
|
||||
(specify_dir / "feature.json").write_bytes(
|
||||
_json_dump({"feature_directory": value}).encode("utf-8")
|
||||
(specify_dir / "feature.json").write_text(
|
||||
_json_dump({"feature_directory": value}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
|
||||
@@ -182,78 +182,6 @@ def get_feature_paths(
|
||||
)
|
||||
|
||||
|
||||
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.
|
||||
try:
|
||||
data = json.loads(registry.read_text(encoding="utf-8"))
|
||||
presets = data.get("presets", {})
|
||||
return [
|
||||
pid
|
||||
for pid, meta in sorted(
|
||||
presets.items(),
|
||||
key=lambda kv: kv[1].get("priority", 10)
|
||||
if isinstance(kv[1], dict)
|
||||
else 10,
|
||||
)
|
||||
if isinstance(meta, dict) and meta.get("enabled", True) is not False
|
||||
]
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
return sorted(
|
||||
p.name
|
||||
for p in presets_dir.iterdir()
|
||||
if p.is_dir() and not p.name.startswith(".")
|
||||
)
|
||||
except OSError:
|
||||
return []
|
||||
|
||||
|
||||
def resolve_template(template_name: str, repo_root: Path) -> Path | None:
|
||||
"""Resolve a template name to a file path using the priority stack.
|
||||
|
||||
Order (mirrors resolve_template in scripts/bash/common.sh):
|
||||
1. .specify/templates/overrides/
|
||||
2. .specify/presets/<preset-id>/templates/ (sorted by .registry priority)
|
||||
3. .specify/extensions/<ext-id>/templates/ (hidden directories skipped)
|
||||
4. .specify/templates/ (core)
|
||||
"""
|
||||
base = repo_root / ".specify" / "templates"
|
||||
|
||||
override = base / "overrides" / f"{template_name}.md"
|
||||
if override.is_file():
|
||||
return override
|
||||
|
||||
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():
|
||||
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():
|
||||
return candidate
|
||||
|
||||
core = base / f"{template_name}.md"
|
||||
if core.is_file():
|
||||
return core
|
||||
return None
|
||||
|
||||
|
||||
def get_invoke_separator(repo_root: Path) -> str:
|
||||
integration_json = repo_root / ".specify" / "integration.json"
|
||||
if not integration_json.is_file():
|
||||
|
||||
@@ -1,422 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Create a new feature directory and spec file."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
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
|
||||
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
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
_STOP_WORDS = frozenset(
|
||||
"""
|
||||
i a an the to for of in on at by with from is are was were be been being
|
||||
have has had do does did will would should could can may might must shall
|
||||
this that these those my your our their want need add get set
|
||||
""".split()
|
||||
)
|
||||
|
||||
_MAX_BRANCH_LENGTH = 244
|
||||
_MAX_FEATURE_NUMBER = 2**63 - 1
|
||||
|
||||
|
||||
def _int64_from_digits(value: str) -> int | None:
|
||||
normalized = value.lstrip("0") or "0"
|
||||
maximum = str(_MAX_FEATURE_NUMBER)
|
||||
if len(normalized) > len(maximum) or (
|
||||
len(normalized) == len(maximum) and normalized > maximum
|
||||
):
|
||||
return None
|
||||
return int(normalized, 10)
|
||||
|
||||
|
||||
def _persistence_assignments(
|
||||
branch_name: str, feature_dir: str, *, powershell: bool
|
||||
) -> tuple[str, str]:
|
||||
if powershell:
|
||||
quoted_branch = "'" + branch_name.replace("'", "''") + "'"
|
||||
quoted_dir = "'" + feature_dir.replace("'", "''") + "'"
|
||||
return (
|
||||
f"$env:SPECIFY_FEATURE = {quoted_branch}",
|
||||
f"$env:SPECIFY_FEATURE_DIRECTORY = {quoted_dir}",
|
||||
)
|
||||
return (
|
||||
f"export SPECIFY_FEATURE={shlex.quote(branch_name)}",
|
||||
f"export SPECIFY_FEATURE_DIRECTORY={shlex.quote(feature_dir)}",
|
||||
)
|
||||
|
||||
|
||||
def _usage(argv0: str) -> str:
|
||||
return (
|
||||
f"Usage: {argv0} [--json] [--dry-run] [--allow-existing-branch] "
|
||||
"[--short-name <name>] [--number N] [--timestamp] <feature_description>"
|
||||
)
|
||||
|
||||
|
||||
def _help_text(argv0: str) -> str:
|
||||
return f"""{_usage(argv0)}
|
||||
|
||||
Options:
|
||||
--json Output in JSON format
|
||||
--dry-run Compute feature name and paths without creating directories or files
|
||||
--allow-existing-branch Reuse an existing feature directory if it already exists
|
||||
--short-name <name> Provide a custom short name (2-4 words) for the feature
|
||||
--number N Prefer a feature number (auto-corrected if its specs prefix exists)
|
||||
--timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
|
||||
--help, -h Show this help message
|
||||
|
||||
Examples:
|
||||
{argv0} 'Add user authentication system' --short-name 'user-auth'
|
||||
{argv0} 'Implement OAuth2 integration for API' --number 5
|
||||
{argv0} --timestamp --short-name 'user-auth' 'Add user authentication'
|
||||
"""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class Args:
|
||||
json_mode: bool = False
|
||||
dry_run: bool = False
|
||||
allow_existing: bool = False
|
||||
short_name: str = ""
|
||||
branch_number: str = ""
|
||||
use_timestamp: bool = False
|
||||
description: str = ""
|
||||
|
||||
|
||||
def _parse_args(argv: list[str], argv0: str) -> Args:
|
||||
json_mode = False
|
||||
dry_run = False
|
||||
allow_existing = False
|
||||
short_name = ""
|
||||
branch_number = ""
|
||||
use_timestamp = False
|
||||
rest: list[str] = []
|
||||
|
||||
i = 0
|
||||
while i < len(argv):
|
||||
arg = argv[i]
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg == "--dry-run":
|
||||
dry_run = True
|
||||
elif arg == "--allow-existing-branch":
|
||||
allow_existing = True
|
||||
elif arg in {"--short-name", "--number"}:
|
||||
if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
|
||||
print(f"Error: {arg} requires a value", file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
i += 1
|
||||
if arg == "--short-name":
|
||||
short_name = argv[i]
|
||||
else:
|
||||
branch_number = argv[i]
|
||||
elif arg == "--timestamp":
|
||||
use_timestamp = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(_help_text(argv0))
|
||||
raise SystemExit(0)
|
||||
else:
|
||||
rest.append(arg)
|
||||
i += 1
|
||||
|
||||
description = " ".join(rest).strip()
|
||||
if not description:
|
||||
if rest:
|
||||
print(
|
||||
"Error: Feature description cannot be empty or contain only whitespace",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(_usage(argv0), file=sys.stderr)
|
||||
raise SystemExit(1)
|
||||
|
||||
return Args(
|
||||
json_mode=json_mode,
|
||||
dry_run=dry_run,
|
||||
allow_existing=allow_existing,
|
||||
short_name=short_name,
|
||||
branch_number=branch_number,
|
||||
use_timestamp=use_timestamp,
|
||||
description=description,
|
||||
)
|
||||
|
||||
|
||||
def _clean_branch_name(name: str) -> str:
|
||||
cleaned = re.sub(r"[^a-z0-9]", "-", name.lower())
|
||||
cleaned = re.sub(r"-+", "-", cleaned)
|
||||
return cleaned.strip("-")
|
||||
|
||||
|
||||
def _generate_branch_name(description: str) -> str:
|
||||
clean = re.sub(r"[^a-z0-9]", " ", description.lower())
|
||||
meaningful: list[str] = []
|
||||
for word in clean.split():
|
||||
if word in _STOP_WORDS:
|
||||
continue
|
||||
if len(word) >= 3:
|
||||
meaningful.append(word)
|
||||
# Keep short words that appear as an uppercase acronym in the original,
|
||||
# mirroring the bash twin's case-sensitive `grep -qw` check.
|
||||
elif re.search(
|
||||
rf"(?<![0-9A-Za-z_]){re.escape(word.upper())}(?![0-9A-Za-z_])",
|
||||
description,
|
||||
):
|
||||
meaningful.append(word)
|
||||
|
||||
if meaningful:
|
||||
max_words = 4 if len(meaningful) == 4 else 3
|
||||
return "-".join(meaningful[:max_words])
|
||||
|
||||
cleaned = _clean_branch_name(description)
|
||||
return "-".join([part for part in cleaned.split("-") if part][:3])
|
||||
|
||||
|
||||
def _get_highest_from_specs(specs_dir: Path) -> int:
|
||||
highest = 0
|
||||
if not specs_dir.is_dir():
|
||||
return highest
|
||||
for entry in specs_dir.iterdir():
|
||||
if not entry.is_dir():
|
||||
continue
|
||||
name = entry.name
|
||||
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
|
||||
if re.match(r"^[0-9]{3,}-", name) and not re.match(
|
||||
r"^[0-9]{8}-[0-9]{6}-", name
|
||||
):
|
||||
number = _int64_from_digits(re.match(r"^[0-9]+", name).group())
|
||||
if number is not None:
|
||||
highest = max(highest, number)
|
||||
return highest
|
||||
|
||||
|
||||
def _fit_branch_name(feature_num: str, branch_suffix: str) -> str:
|
||||
"""Fit a feature prefix and suffix within GitHub's branch-name limit."""
|
||||
branch_name = f"{feature_num}-{branch_suffix}"
|
||||
if len(branch_name) <= _MAX_BRANCH_LENGTH:
|
||||
return branch_name
|
||||
|
||||
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
|
||||
truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
|
||||
return f"{feature_num}-{truncated_suffix}"
|
||||
|
||||
|
||||
def _spec_prefix_exists(specs_dir: Path, feature_num: str) -> bool:
|
||||
"""Return whether a spec directory owns the given numeric prefix."""
|
||||
try:
|
||||
return any(
|
||||
entry.is_dir() and entry.name.startswith(f"{feature_num}-")
|
||||
for entry in specs_dir.iterdir()
|
||||
)
|
||||
except OSError:
|
||||
# Match Bash globbing and PowerShell's ErrorAction=SilentlyContinue.
|
||||
return False
|
||||
|
||||
|
||||
def _has_spec_prefix_conflict(
|
||||
specs_dir: Path,
|
||||
feature_num: str,
|
||||
requested_dir: Path,
|
||||
*,
|
||||
allow_existing: bool,
|
||||
) -> bool:
|
||||
"""Return whether another spec directory owns the requested prefix."""
|
||||
if allow_existing and requested_dir.is_dir():
|
||||
return False
|
||||
|
||||
return _spec_prefix_exists(specs_dir, feature_num)
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
argv0 = sys.argv[0]
|
||||
args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
|
||||
|
||||
repo_root = get_repo_root(Path(__file__))
|
||||
specs_dir = repo_root / "specs"
|
||||
if not args.dry_run:
|
||||
specs_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
if args.short_name:
|
||||
branch_suffix = _clean_branch_name(args.short_name)
|
||||
else:
|
||||
branch_suffix = _generate_branch_name(args.description)
|
||||
|
||||
branch_number = args.branch_number
|
||||
if args.use_timestamp and branch_number:
|
||||
print(
|
||||
"[specify] Warning: --number is ignored when --timestamp is used",
|
||||
file=sys.stderr,
|
||||
)
|
||||
branch_number = ""
|
||||
|
||||
if args.use_timestamp:
|
||||
feature_num = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
|
||||
else:
|
||||
if branch_number:
|
||||
# Mirrors bash: $((10#$BRANCH_NUMBER)) only accepts unsigned
|
||||
# decimal digits, rejecting signs, whitespace, and other
|
||||
# characters that int() would otherwise tolerate.
|
||||
if not re.fullmatch(r"[0-9]+", branch_number):
|
||||
print(
|
||||
"Error: --number must be an unsigned integer, "
|
||||
f"got '{branch_number}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
number = _int64_from_digits(branch_number)
|
||||
if number is None:
|
||||
print(
|
||||
"Error: --number must be between 0 and "
|
||||
f"{_MAX_FEATURE_NUMBER}, got '{branch_number}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
else:
|
||||
number = _get_highest_from_specs(specs_dir) + 1
|
||||
if number > _MAX_FEATURE_NUMBER:
|
||||
rejected_number = branch_number or str(number)
|
||||
number_label = "--number" if branch_number else "feature number"
|
||||
print(
|
||||
f"Error: {number_label} must be between 0 and "
|
||||
f"{_MAX_FEATURE_NUMBER}, got '{rejected_number}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
feature_num = f"{number:03d}"
|
||||
|
||||
# Treat an explicit number as a preference when its prefix is already used
|
||||
# by a feature directory. Auto-detected numbers are already conflict-free.
|
||||
if branch_number:
|
||||
requested_branch_name = _fit_branch_name(feature_num, branch_suffix)
|
||||
requested_dir = specs_dir / requested_branch_name
|
||||
spec_conflict = _has_spec_prefix_conflict(
|
||||
specs_dir,
|
||||
feature_num,
|
||||
requested_dir,
|
||||
allow_existing=args.allow_existing,
|
||||
)
|
||||
if spec_conflict:
|
||||
requested_num = feature_num
|
||||
number = _get_highest_from_specs(specs_dir)
|
||||
while True:
|
||||
number += 1
|
||||
if number > _MAX_FEATURE_NUMBER:
|
||||
print(
|
||||
f"Error: feature number must be between 0 and "
|
||||
f"{_MAX_FEATURE_NUMBER}, got '{number}'",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
feature_num = f"{number:03d}"
|
||||
if not _spec_prefix_exists(specs_dir, feature_num):
|
||||
break
|
||||
print(
|
||||
f"[specify] Warning: --number {requested_num} conflicts with "
|
||||
f"an existing spec directory; using {feature_num} instead",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
|
||||
if max_suffix_length <= 0:
|
||||
print("Error: feature number is too long for a branch name", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
original_branch_name = f"{feature_num}-{branch_suffix}"
|
||||
branch_name = _fit_branch_name(feature_num, branch_suffix)
|
||||
|
||||
# GitHub enforces a 244-byte limit on branch names.
|
||||
if branch_name != original_branch_name:
|
||||
print(
|
||||
"[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"[specify] Original: {original_branch_name} "
|
||||
f"({len(original_branch_name)} bytes)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
f"[specify] Truncated to: {branch_name} ({len(branch_name)} bytes)",
|
||||
file=sys.stderr,
|
||||
)
|
||||
|
||||
feature_dir = specs_dir / branch_name
|
||||
spec_file = feature_dir / "spec.md"
|
||||
|
||||
if not args.dry_run:
|
||||
if feature_dir.is_dir() and not args.allow_existing:
|
||||
if args.use_timestamp:
|
||||
print(
|
||||
f"Error: Feature directory '{feature_dir}' already exists. "
|
||||
"Rerun to get a new timestamp or use a different --short-name.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
else:
|
||||
print(
|
||||
f"Error: Feature directory '{feature_dir}' already exists. "
|
||||
"Please use a different feature name or specify a different "
|
||||
"number with --number.",
|
||||
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)
|
||||
else:
|
||||
print(
|
||||
"Warning: Spec template not found; created empty spec file",
|
||||
file=sys.stderr,
|
||||
)
|
||||
spec_file.touch()
|
||||
|
||||
# Persist to .specify/feature.json so downstream commands can find the feature.
|
||||
persist_feature_json(repo_root, f"specs/{branch_name}")
|
||||
|
||||
# Inform the user how to set feature state in their own shell.
|
||||
feature_assignment, directory_assignment = _persistence_assignments(
|
||||
branch_name,
|
||||
str(feature_dir),
|
||||
powershell=sys.platform == "win32",
|
||||
)
|
||||
print(f"# To persist: {feature_assignment}", file=sys.stderr)
|
||||
print(f"# {directory_assignment}", file=sys.stderr)
|
||||
|
||||
if args.json_mode:
|
||||
payload: dict[str, object] = {
|
||||
"BRANCH_NAME": branch_name,
|
||||
"SPEC_FILE": str(spec_file),
|
||||
"FEATURE_NUM": feature_num,
|
||||
}
|
||||
if args.dry_run:
|
||||
payload["DRY_RUN"] = True
|
||||
sys.stdout.write(_json_line(payload))
|
||||
else:
|
||||
print(f"BRANCH_NAME: {branch_name}")
|
||||
print(f"SPEC_FILE: {spec_file}")
|
||||
print(f"FEATURE_NUM: {feature_num}")
|
||||
if not args.dry_run:
|
||||
print(f"# To persist in your shell: {feature_assignment}")
|
||||
print(f"# {directory_assignment}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,86 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Setup implementation plan for a feature."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import shutil
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import get_feature_paths, resolve_template
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import get_feature_paths, resolve_template
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def _help_text(argv0: str) -> str:
|
||||
return f"""Usage: {argv0} [--json]
|
||||
--json Output results in JSON format
|
||||
--help Show this help message
|
||||
"""
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
args = list(argv if argv is not None else sys.argv[1:])
|
||||
json_mode = False
|
||||
for arg in args:
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(_help_text(sys.argv[0]))
|
||||
return 0
|
||||
# Other arguments are accepted and silently ignored, matching setup-plan.sh.
|
||||
|
||||
try:
|
||||
paths = get_feature_paths(script_file=Path(__file__))
|
||||
except SystemExit as exc:
|
||||
if exc.code == 0:
|
||||
return 0
|
||||
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
|
||||
return int(exc.code) if isinstance(exc.code, int) else 1
|
||||
|
||||
paths.feature_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
# Status messages go to stderr in JSON mode so stdout stays pure JSON.
|
||||
status_stream = sys.stderr if json_mode else sys.stdout
|
||||
if paths.impl_plan.is_file():
|
||||
print(
|
||||
f"Plan already exists at {paths.impl_plan}, skipping template copy",
|
||||
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)
|
||||
print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
|
||||
else:
|
||||
print("Warning: Plan template not found", file=status_stream)
|
||||
paths.impl_plan.touch()
|
||||
|
||||
if json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line(
|
||||
{
|
||||
"FEATURE_SPEC": str(paths.feature_spec),
|
||||
"IMPL_PLAN": str(paths.impl_plan),
|
||||
"SPECS_DIR": str(paths.feature_dir),
|
||||
"BRANCH": paths.current_branch,
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(f"FEATURE_SPEC: {paths.feature_spec}")
|
||||
print(f"IMPL_PLAN: {paths.impl_plan}")
|
||||
print(f"SPECS_DIR: {paths.feature_dir}")
|
||||
print(f"BRANCH: {paths.current_branch}")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -1,145 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Check tasks prerequisites and resolve the tasks template."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
try:
|
||||
from common import (
|
||||
FeaturePaths,
|
||||
format_speckit_command,
|
||||
get_feature_paths,
|
||||
resolve_template,
|
||||
)
|
||||
except ImportError: # pragma: no cover - direct execution from unusual cwd
|
||||
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
||||
from common import (
|
||||
FeaturePaths,
|
||||
format_speckit_command,
|
||||
get_feature_paths,
|
||||
resolve_template,
|
||||
)
|
||||
|
||||
|
||||
def _json_line(payload: object) -> str:
|
||||
return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
|
||||
|
||||
|
||||
def _help_text(argv0: str) -> str:
|
||||
return f"""Usage: {argv0} [--json]
|
||||
--json Output results in JSON format
|
||||
--help Show this help message
|
||||
"""
|
||||
|
||||
|
||||
def _dir_has_entries(path: Path) -> bool:
|
||||
try:
|
||||
return path.is_dir() and any(path.iterdir())
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def _available_docs(paths: FeaturePaths) -> list[str]:
|
||||
docs: list[str] = []
|
||||
if paths.research.is_file():
|
||||
docs.append("research.md")
|
||||
if paths.data_model.is_file():
|
||||
docs.append("data-model.md")
|
||||
if _dir_has_entries(paths.contracts_dir):
|
||||
docs.append("contracts/")
|
||||
if paths.quickstart.is_file():
|
||||
docs.append("quickstart.md")
|
||||
return docs
|
||||
|
||||
|
||||
def _check_file(path: Path, description: str) -> None:
|
||||
marker = "✓" if path.is_file() else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def _check_dir(path: Path, description: str) -> None:
|
||||
marker = "✓" if _dir_has_entries(path) else "✗"
|
||||
print(f" {marker} {description}")
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
json_mode = False
|
||||
for arg in list(argv if argv is not None else sys.argv[1:]):
|
||||
if arg == "--json":
|
||||
json_mode = True
|
||||
elif arg in {"--help", "-h"}:
|
||||
sys.stdout.write(_help_text(sys.argv[0]))
|
||||
return 0
|
||||
else:
|
||||
print(f"ERROR: Unknown option '{arg}'", file=sys.stderr)
|
||||
return 1
|
||||
|
||||
try:
|
||||
paths = get_feature_paths(script_file=Path(__file__))
|
||||
except SystemExit as exc:
|
||||
if exc.code == 0:
|
||||
return 0
|
||||
print("ERROR: Failed to resolve feature paths", file=sys.stderr)
|
||||
return int(exc.code) if isinstance(exc.code, int) else 1
|
||||
|
||||
if not paths.impl_plan.is_file():
|
||||
print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if not paths.feature_spec.is_file():
|
||||
print(f"ERROR: spec.md not found in {paths.feature_dir}", file=sys.stderr)
|
||||
print(
|
||||
f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
docs = _available_docs(paths)
|
||||
|
||||
tasks_template = resolve_template("tasks-template", paths.repo_root)
|
||||
if tasks_template is None or not tasks_template.is_file():
|
||||
print(
|
||||
"ERROR: Could not resolve required tasks-template from the template "
|
||||
f"override stack for {paths.repo_root}",
|
||||
file=sys.stderr,
|
||||
)
|
||||
print(
|
||||
"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.",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 1
|
||||
|
||||
if json_mode:
|
||||
sys.stdout.write(
|
||||
_json_line(
|
||||
{
|
||||
"FEATURE_DIR": str(paths.feature_dir),
|
||||
"AVAILABLE_DOCS": docs,
|
||||
"TASKS_TEMPLATE": str(tasks_template),
|
||||
}
|
||||
)
|
||||
)
|
||||
else:
|
||||
print(f"FEATURE_DIR: {paths.feature_dir}")
|
||||
print(f"TASKS_TEMPLATE: {tasks_template}")
|
||||
print("AVAILABLE_DOCS:")
|
||||
_check_file(paths.research, "research.md")
|
||||
_check_file(paths.data_model, "data-model.md")
|
||||
_check_dir(paths.contracts_dir, "contracts/")
|
||||
_check_file(paths.quickstart, "quickstart.md")
|
||||
return 0
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -140,9 +140,10 @@ def _install_shared_infra(
|
||||
"""Install shared infrastructure files into *project_path*.
|
||||
|
||||
Copies ``.specify/scripts/<variant>/`` and ``.specify/templates/`` from
|
||||
the bundled core_pack or source checkout. ``sh`` installs Bash, ``ps``
|
||||
installs PowerShell, and ``py`` installs Python plus the platform shell
|
||||
fallback. Tracks all installed files in ``speckit.manifest.json``.
|
||||
the bundled core_pack or source checkout, where ``<variant>`` is
|
||||
``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
|
||||
and ``powershell`` when it is ``"ps"``. Tracks all installed files in
|
||||
``speckit.manifest.json``.
|
||||
|
||||
Shared scripts and page templates are processed to resolve
|
||||
``__SPECKIT_COMMAND_<NAME>__`` placeholders using *invoke_separator*
|
||||
|
||||
@@ -1,218 +0,0 @@
|
||||
"""Helpers for bounded HTTP downloads."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import socket
|
||||
from ipaddress import IPv4Address, IPv6Address, ip_address
|
||||
from typing import NoReturn, TypeVar
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
|
||||
|
||||
ErrorT = TypeVar("ErrorT", bound=Exception)
|
||||
|
||||
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
|
||||
READ_CHUNK_SIZE = 64 * 1024
|
||||
|
||||
# Tighter ceiling for responses that are read fully into memory and parsed as
|
||||
# JSON. The 50 MiB MAX_DOWNLOAD_BYTES default is sized for archive/payload
|
||||
# downloads; JSON metadata responses are far smaller, so capping them close to
|
||||
# their real size shrinks the memory-DoS surface and keeps the "too large"
|
||||
# error reachable (rather than only triggering on tens of MiB). Pass it
|
||||
# explicitly at each JSON call site so the intended bound is pinned there.
|
||||
# METADATA covers fixed-shape single-object responses (an OAuth token, one
|
||||
# release's metadata): a few KiB in practice, 1 MiB is already generous.
|
||||
MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
|
||||
|
||||
|
||||
def _ip_address_without_scope(
|
||||
hostname: str,
|
||||
) -> IPv4Address | IPv6Address | None:
|
||||
"""Parse a canonical IP literal, validating an optional IPv6 zone ID."""
|
||||
if "%" in hostname:
|
||||
# Accept only the RFC 6874 ``%25<zone>`` spelling. Other escapes can
|
||||
# alter the IPv6 address when urllib unquotes the authority.
|
||||
address_text, separator, zone = hostname.partition("%25")
|
||||
if (
|
||||
not separator
|
||||
or ":" not in address_text
|
||||
or "%" in address_text
|
||||
or "%" in zone
|
||||
):
|
||||
return None
|
||||
if not zone or any(
|
||||
not (character.isascii() and (character.isalnum() or character in "._~-"))
|
||||
for character in zone
|
||||
):
|
||||
return None
|
||||
else:
|
||||
address_text = hostname
|
||||
try:
|
||||
address = ip_address(address_text)
|
||||
except ValueError:
|
||||
return None
|
||||
if "%" in hostname and not isinstance(address, IPv6Address):
|
||||
return None
|
||||
return address
|
||||
|
||||
|
||||
def _is_ip_loopback(address: IPv4Address | IPv6Address | None) -> bool:
|
||||
if address is None:
|
||||
return False
|
||||
mapped = getattr(address, "ipv4_mapped", None)
|
||||
return address.is_loopback or bool(mapped and mapped.is_loopback)
|
||||
|
||||
|
||||
def _is_ip_local_redirect_target(
|
||||
address: IPv4Address | IPv6Address | None,
|
||||
) -> bool:
|
||||
"""Treat loopback and unspecified listener aliases as local targets."""
|
||||
if address is None:
|
||||
return False
|
||||
mapped = getattr(address, "ipv4_mapped", None)
|
||||
return _is_ip_loopback(address) or address.is_unspecified or bool(
|
||||
mapped and mapped.is_unspecified
|
||||
)
|
||||
|
||||
|
||||
def _parse_url(url: str) -> ParseResult | None:
|
||||
"""Parse *url*, rejecting missing hosts and malformed ports."""
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
# Accessing ``port`` performs urllib's range and syntax validation.
|
||||
parsed.port
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if not hostname:
|
||||
return None
|
||||
|
||||
if "%" in hostname:
|
||||
# urllib unquotes reg-name/IPv4 authorities before connecting. Reject
|
||||
# them so encoded dots, characters, ports, or brackets cannot make the
|
||||
# validated hostname differ from the effective target. The only safe
|
||||
# percent form retained is a validated bracketed IPv6 zone ID.
|
||||
if _ip_address_without_scope(hostname) is None:
|
||||
return None
|
||||
elif ":" not in hostname:
|
||||
try:
|
||||
hostname.encode("idna")
|
||||
except UnicodeError:
|
||||
return None
|
||||
return parsed
|
||||
|
||||
|
||||
def _is_definite_loopback_host(hostname: str) -> bool:
|
||||
"""Recognize only unambiguous hosts that may safely authorize HTTP."""
|
||||
if not hostname.isascii():
|
||||
return False
|
||||
if hostname == "localhost":
|
||||
return True
|
||||
return _is_ip_loopback(_ip_address_without_scope(hostname))
|
||||
|
||||
|
||||
def _is_potential_local_target_host(hostname: str) -> bool:
|
||||
"""Conservatively classify aliases that could reach a local listener."""
|
||||
if ":" in hostname:
|
||||
return _is_ip_local_redirect_target(_ip_address_without_scope(hostname))
|
||||
try:
|
||||
host = hostname.encode("idna").decode("ascii").lower().removesuffix(".")
|
||||
except UnicodeError:
|
||||
return False
|
||||
if host == "localhost" or host.endswith(".localhost"):
|
||||
return True
|
||||
|
||||
address = _ip_address_without_scope(host)
|
||||
if address is None:
|
||||
# Historical IPv4 spellings are resolver-dependent. They are never
|
||||
# trusted to authorize HTTP, but treating them as potentially local
|
||||
# prevents them from bypassing a remote-to-loopback redirect check.
|
||||
try:
|
||||
address = ip_address(socket.inet_aton(host))
|
||||
except OSError:
|
||||
return False
|
||||
return _is_ip_local_redirect_target(address)
|
||||
|
||||
|
||||
def is_loopback_url(url: str) -> bool:
|
||||
"""Return whether *url* has an unambiguous loopback host."""
|
||||
parsed = _parse_url(url)
|
||||
return parsed is not None and _is_definite_loopback_host(parsed.hostname)
|
||||
|
||||
|
||||
def _is_potential_local_target_url(url: str) -> bool:
|
||||
parsed = _parse_url(url)
|
||||
return parsed is not None and _is_potential_local_target_host(parsed.hostname)
|
||||
|
||||
|
||||
def is_https_or_localhost_http(url: str) -> bool:
|
||||
"""Return True if *url* is HTTPS, or HTTP limited to loopback hosts.
|
||||
|
||||
Shared scheme-safety predicate used by the auth HTTP redirect handler and
|
||||
direct URL validations in CLI download flows.
|
||||
|
||||
A hostname is always required: a URL without one (e.g. ``https:///x``)
|
||||
has no real target and is rejected regardless of scheme.
|
||||
|
||||
The HTTP exception is deliberately limited to unambiguous ``localhost``
|
||||
and canonical IPv4/IPv6 loopback literals. Ambiguous numeric, Unicode, and
|
||||
unspecified-address aliases are classified defensively for redirects but
|
||||
never authorize HTTP. No DNS lookup is performed; DNS and hosts-file
|
||||
aliases require connection-level rebinding protection outside this helper.
|
||||
"""
|
||||
parsed = _parse_url(url)
|
||||
if parsed is None:
|
||||
return False
|
||||
return parsed.scheme == "https" or (
|
||||
parsed.scheme == "http" and _is_definite_loopback_host(parsed.hostname)
|
||||
)
|
||||
|
||||
|
||||
def is_safe_download_redirect(old_url: str, new_url: str) -> bool:
|
||||
"""Return whether a redirect preserves the shared download URL policy."""
|
||||
if not is_https_or_localhost_http(new_url):
|
||||
return False
|
||||
return not _is_potential_local_target_url(new_url) or is_loopback_url(old_url)
|
||||
|
||||
|
||||
def _raise(error_type: type[ErrorT], message: str) -> NoReturn:
|
||||
raise error_type(message)
|
||||
|
||||
|
||||
def read_response_limited(
|
||||
response,
|
||||
*,
|
||||
max_bytes: int = MAX_DOWNLOAD_BYTES,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
label: str = "download",
|
||||
) -> bytes:
|
||||
"""Read at most *max_bytes* from a response object.
|
||||
|
||||
``response.read(n)`` is only guaranteed to return *up to* ``n`` bytes and may
|
||||
return fewer even when more data is pending (e.g. chunked transfer encoding),
|
||||
so a single ``read(max_bytes + 1)`` cannot enforce the bound on its own. Read
|
||||
in a loop until EOF or until one byte past the limit has been accumulated.
|
||||
|
||||
*max_bytes* is keyword-only. It defaults to the module-wide
|
||||
``MAX_DOWNLOAD_BYTES`` (50 MiB) ceiling for archive/payload downloads;
|
||||
callers with a tighter budget (e.g. small JSON responses) should pass an
|
||||
explicit value so the intended bound is pinned at the call site rather than
|
||||
tracking changes to the shared default.
|
||||
"""
|
||||
if isinstance(max_bytes, bool) or not isinstance(max_bytes, int):
|
||||
raise TypeError("max_bytes must be an integer")
|
||||
if max_bytes < 0:
|
||||
raise ValueError("max_bytes must be non-negative")
|
||||
|
||||
output = io.BytesIO()
|
||||
total = 0
|
||||
limit = max_bytes + 1
|
||||
while total < limit:
|
||||
chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
|
||||
if not chunk:
|
||||
break
|
||||
total += len(chunk)
|
||||
if total > max_bytes:
|
||||
_raise(error_type, f"{label} exceeds maximum size of {max_bytes} bytes")
|
||||
output.write(chunk)
|
||||
return output.getvalue()
|
||||
@@ -100,19 +100,8 @@ def resolve_github_release_asset_api_url(
|
||||
import json
|
||||
import urllib.error
|
||||
|
||||
from specify_cli._download_security import read_response_limited
|
||||
|
||||
# Accessing ``.hostname`` (like ``.port`` below) raises ValueError on a
|
||||
# malformed authority, e.g. an invalid bracketed IPv6 host
|
||||
# ``https://[not-an-ip]/...``. The function's contract is to return None for
|
||||
# anything it can't resolve, not to raise, so guard the read. ``download_url``
|
||||
# is server-controlled here (a catalog ``download_url`` payload), so a
|
||||
# malformed value must not leak a raw traceback past the caller.
|
||||
try:
|
||||
parsed = urlparse(download_url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
except ValueError:
|
||||
return None
|
||||
parsed = urlparse(download_url)
|
||||
hostname = (parsed.hostname or "").lower()
|
||||
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
|
||||
|
||||
is_ghes = (
|
||||
@@ -169,13 +158,10 @@ def resolve_github_release_asset_api_url(
|
||||
if redirect_validator is not None:
|
||||
open_kwargs["redirect_validator"] = redirect_validator
|
||||
with open_url_fn(release_url, **open_kwargs) as response:
|
||||
release_data = json.loads(
|
||||
read_response_limited(
|
||||
response,
|
||||
max_bytes=max_metadata_bytes,
|
||||
label=f"GitHub release metadata {release_url}",
|
||||
)
|
||||
)
|
||||
raw_release_data = response.read(max_metadata_bytes + 1)
|
||||
if len(raw_release_data) > max_metadata_bytes:
|
||||
raise ValueError("GitHub release metadata exceeds size limit")
|
||||
release_data = json.loads(raw_release_data)
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
json.JSONDecodeError,
|
||||
|
||||
@@ -18,7 +18,6 @@ ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "grok", "trae", "zed"}
|
||||
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
|
||||
{
|
||||
"agy",
|
||||
"bob",
|
||||
"claude",
|
||||
"copilot",
|
||||
"cursor-agent",
|
||||
|
||||
@@ -69,14 +69,21 @@ def run_command(
|
||||
cmd: list[str],
|
||||
check_return: bool = True,
|
||||
capture: bool = False,
|
||||
shell: bool = False,
|
||||
) -> str | None:
|
||||
"""Run a command without invoking a shell and optionally capture output.
|
||||
|
||||
Commands are always executed with ``shell=False`` and must be passed as an
|
||||
argv ``list[str]``. There is deliberately no ``shell`` parameter: the
|
||||
argv-list contract makes shell interpolation impossible by construction, so
|
||||
the shell-injection surface cannot be re-enabled at a call site.
|
||||
The ``shell`` parameter is kept in the signature so existing keyword
|
||||
callers (and the re-export from ``specify_cli``) don't raise ``TypeError``,
|
||||
but only the default ``shell=False`` is honoured. ``shell=True`` is
|
||||
rejected with ``ValueError`` rather than silently ignored, so the
|
||||
unsupported mode fails loudly instead of running with a different meaning.
|
||||
"""
|
||||
if shell:
|
||||
raise ValueError(
|
||||
"run_command() does not support shell=True; pass argv as a list"
|
||||
)
|
||||
|
||||
try:
|
||||
if capture:
|
||||
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True)
|
||||
|
||||
@@ -4,8 +4,8 @@ Pure helpers for comparing PEP 440 versions and fetching the latest GitHub
|
||||
release tag. The ``self_app`` Typer sub-command group is co-located here so
|
||||
all version-related logic lives in one place.
|
||||
|
||||
Dependencies: stdlib + packaging + ._console + ._download_security only
|
||||
(keeping this layer thin and circular-import-safe).
|
||||
Dependencies: stdlib + packaging + ._console only (no other internal imports
|
||||
at module level, keeping this layer thin and circular-import-safe).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
@@ -28,7 +28,6 @@ from pathlib import Path
|
||||
import typer
|
||||
from packaging.version import InvalidVersion, Version
|
||||
|
||||
from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
|
||||
from ._console import console
|
||||
|
||||
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
|
||||
@@ -120,13 +119,7 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
|
||||
timeout=5,
|
||||
extra_headers={"Accept": "application/vnd.github+json"},
|
||||
) as resp:
|
||||
payload = json.loads(
|
||||
read_response_limited(
|
||||
resp,
|
||||
max_bytes=MAX_JSON_METADATA_BYTES,
|
||||
label="GitHub latest release",
|
||||
).decode("utf-8")
|
||||
)
|
||||
payload = json.loads(resp.read().decode("utf-8"))
|
||||
tag = payload.get("tag_name")
|
||||
if not isinstance(tag, str) or not tag:
|
||||
raise ValueError("GitHub API response missing valid tag_name")
|
||||
|
||||
@@ -7,6 +7,7 @@ command files into agent-specific directories in the correct format.
|
||||
"""
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
from copy import deepcopy
|
||||
from pathlib import Path
|
||||
@@ -113,24 +114,13 @@ class CommandRegistrar:
|
||||
if not content.startswith("---"):
|
||||
return {}, content
|
||||
|
||||
# The closing delimiter is a line that is exactly ``---`` (a YAML
|
||||
# document separator), not any ``---`` substring. Scanning with
|
||||
# ``content.find("---", 3)`` stops at the first ``---`` *anywhere* —
|
||||
# including one embedded in a frontmatter value (e.g. a description like
|
||||
# "Separate sections with ---") or inside an indented literal block —
|
||||
# which truncates the frontmatter and spills the remainder into the
|
||||
# body. Match on line boundaries instead, mirroring the line-anchored
|
||||
# scan in ``VibeIntegration._inject_frontmatter_flag``.
|
||||
lines = content.splitlines(keepends=True)
|
||||
end_line = next(
|
||||
(i for i in range(1, len(lines)) if lines[i].rstrip() == "---"),
|
||||
None,
|
||||
)
|
||||
if end_line is None:
|
||||
# Find second ---
|
||||
end_marker = content.find("---", 3)
|
||||
if end_marker == -1:
|
||||
return {}, content
|
||||
|
||||
frontmatter_str = "".join(lines[1:end_line]).strip()
|
||||
body = "".join(lines[end_line + 1 :]).strip()
|
||||
frontmatter_str = content[3:end_marker].strip()
|
||||
body = content[end_marker + 3 :].strip()
|
||||
|
||||
try:
|
||||
frontmatter = yaml.safe_load(frontmatter_str) or {}
|
||||
@@ -485,19 +475,26 @@ class CommandRegistrar:
|
||||
init_opts = {}
|
||||
|
||||
script_variant = init_opts.get("script")
|
||||
if scripts:
|
||||
from specify_cli.integrations.base import IntegrationBase
|
||||
|
||||
script_variant = IntegrationBase.select_script_variant(
|
||||
script_variant, scripts
|
||||
if script_variant not in {"sh", "ps"}:
|
||||
fallback_order = []
|
||||
default_variant = (
|
||||
"ps" if platform.system().lower().startswith("win") else "sh"
|
||||
)
|
||||
secondary_variant = "sh" if default_variant == "ps" else "ps"
|
||||
|
||||
if default_variant in scripts:
|
||||
fallback_order.append(default_variant)
|
||||
if secondary_variant in scripts:
|
||||
fallback_order.append(secondary_variant)
|
||||
|
||||
for key in scripts:
|
||||
if key not in fallback_order:
|
||||
fallback_order.append(key)
|
||||
|
||||
script_variant = fallback_order[0] if fallback_order else None
|
||||
|
||||
script_command = scripts.get(script_variant) if script_variant else None
|
||||
if script_command:
|
||||
if script_variant == "py":
|
||||
script_command = IntegrationBase.build_python_invocation(
|
||||
script_command, project_root
|
||||
)
|
||||
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
|
||||
body = body.replace("{SCRIPT}", script_command)
|
||||
|
||||
@@ -640,37 +637,6 @@ class CommandRegistrar:
|
||||
is_cline_ext = agent_name == "cline" and source_id != "core"
|
||||
source_root = source_dir.resolve()
|
||||
|
||||
# Resolve the command-reference separator for the file THIS registrar
|
||||
# is about to write. The separator must match the *output layout* the
|
||||
# registrar produces for this agent — not the project's persisted
|
||||
# ``ai_skills`` flag, and not unrelated sibling directories on disk. A
|
||||
# skill scaffold ("/SKILL.md") uses the skills separator; any
|
||||
# command-layout output (".md", ".agent.md", ".toml", …) uses the
|
||||
# command separator.
|
||||
#
|
||||
# This holds for the *active* agent too. Dual-layout agents (Bob,
|
||||
# Copilot) write their skills via their own setup()/skills path, so
|
||||
# ``register_commands`` only ever emits their command-layout files.
|
||||
# Deriving the separator from ``ai_skills`` would render such a
|
||||
# ``.bob/commands/*.md`` (or ``.github/agents/*.agent.md``) file with
|
||||
# ``/speckit-*`` whenever that agent is active in skills mode — even
|
||||
# though a command-layout file must use ``/speckit.*``. Deriving it
|
||||
# from the agent's static output config avoids that mismatch and stays
|
||||
# correct when a stale ``.bob/skills`` directory coexists with
|
||||
# ``.bob/commands``.
|
||||
_sep = agent_config.get("invoke_separator", ".")
|
||||
try:
|
||||
from specify_cli.integrations import get_integration # noqa: PLC0415
|
||||
|
||||
_integ = get_integration(agent_name)
|
||||
if _integ is not None:
|
||||
registrar_writes_skills = (
|
||||
agent_config.get("extension") == "/SKILL.md"
|
||||
)
|
||||
_sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for cmd_info in commands:
|
||||
cmd_name = cmd_info["name"]
|
||||
aliases = cmd_info.get("aliases", [])
|
||||
@@ -743,18 +709,13 @@ class CommandRegistrar:
|
||||
)
|
||||
|
||||
# Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator.
|
||||
# For dual-layout agents (e.g. Bob) the separator differs between the
|
||||
# skills and command layouts, so a single static AGENT_CONFIGS value is
|
||||
# insufficient. ``_sep`` (resolved above) is derived from the *output
|
||||
# layout* this registrar writes — a "/SKILL.md" scaffold uses the skills
|
||||
# separator, any command-layout file uses the command separator — not
|
||||
# the project's persisted ai_skills state. Single-layout agents fall back
|
||||
# to the static AGENT_CONFIGS value unchanged (invoke_separator_for_mode
|
||||
# default).
|
||||
# The separator is sourced from agent_config (populated by _build_agent_configs,
|
||||
# which propagates each integration's invoke_separator class attribute).
|
||||
# Deferred import of IntegrationBase avoids a circular import at module load
|
||||
# (base.py itself imports CommandRegistrar lazily).
|
||||
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415
|
||||
|
||||
_sep = agent_config.get("invoke_separator", ".")
|
||||
body = IntegrationBase.resolve_command_refs(body, _sep)
|
||||
|
||||
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
|
||||
|
||||
@@ -8,7 +8,6 @@ import os
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
|
||||
from .base import AuthProvider
|
||||
|
||||
if TYPE_CHECKING:
|
||||
@@ -18,20 +17,6 @@ if TYPE_CHECKING:
|
||||
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
|
||||
|
||||
|
||||
class _TokenResponseTooLarge(Exception):
|
||||
"""Raised when an Azure AD token response exceeds the bounded read limit."""
|
||||
|
||||
|
||||
def _extract_token(payload: object, key: str) -> str | None:
|
||||
"""Return a normalized token from a JSON object, or None for other shapes."""
|
||||
if not isinstance(payload, dict):
|
||||
return None
|
||||
token = payload.get(key)
|
||||
if not isinstance(token, str):
|
||||
return None
|
||||
return token.strip() or None
|
||||
|
||||
|
||||
class AzureDevOpsAuth(AuthProvider):
|
||||
"""Azure DevOps authentication provider.
|
||||
|
||||
@@ -89,7 +74,8 @@ class AzureDevOpsAuth(AuthProvider):
|
||||
if result.returncode != 0:
|
||||
return None
|
||||
payload = _json.loads(result.stdout)
|
||||
return _extract_token(payload, "accessToken")
|
||||
token = payload.get("accessToken", "").strip()
|
||||
return token or None
|
||||
except (
|
||||
OSError,
|
||||
subprocess.TimeoutExpired,
|
||||
@@ -133,37 +119,9 @@ class AzureDevOpsAuth(AuthProvider):
|
||||
headers={"Content-Type": "application/x-www-form-urlencoded"},
|
||||
)
|
||||
try:
|
||||
from specify_cli.authentication.http import _StripAuthOnRedirect
|
||||
|
||||
def reject_token_redirect(_old_url: str, new_url: str) -> None:
|
||||
# A 307/308 redirect preserves this POST body, including the
|
||||
# client_secret. Refuse every redirect so credentials cannot
|
||||
# leave the fixed Microsoft token endpoint.
|
||||
raise urllib.error.URLError(
|
||||
f"Azure AD token request must not be redirected to {new_url}"
|
||||
)
|
||||
|
||||
opener = urllib.request.build_opener(
|
||||
_StripAuthOnRedirect((), reject_token_redirect)
|
||||
)
|
||||
with opener.open(req, timeout=30) as resp: # noqa: S310
|
||||
payload = _json.loads(
|
||||
read_response_limited(
|
||||
resp,
|
||||
max_bytes=MAX_JSON_METADATA_BYTES,
|
||||
error_type=_TokenResponseTooLarge,
|
||||
label="Azure DevOps token response",
|
||||
).decode("utf-8")
|
||||
)
|
||||
return _extract_token(payload, "access_token")
|
||||
except (
|
||||
urllib.error.URLError,
|
||||
OSError,
|
||||
_json.JSONDecodeError,
|
||||
UnicodeDecodeError,
|
||||
_TokenResponseTooLarge,
|
||||
):
|
||||
# Network failure, malformed JSON, or an oversized response — fall
|
||||
# through to the next strategy. Unrelated programming errors (other
|
||||
# ValueErrors, KeyErrors) intentionally propagate so they surface.
|
||||
with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
|
||||
payload = _json.loads(resp.read().decode("utf-8"))
|
||||
token = payload.get("access_token", "").strip()
|
||||
return token or None
|
||||
except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
|
||||
return None
|
||||
|
||||
@@ -13,7 +13,6 @@ import stat
|
||||
from dataclasses import dataclass
|
||||
from fnmatch import fnmatch
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import urlparse
|
||||
|
||||
|
||||
@@ -54,19 +53,6 @@ def _is_valid_host_pattern(pattern: str) -> bool:
|
||||
return pattern.startswith("*.") and "*" not in pattern[2:]
|
||||
|
||||
|
||||
def _norm(value: Any) -> Any:
|
||||
"""Strip surrounding whitespace from a whitespace-insignificant string
|
||||
config reference (env-var names, tenant/client ids) before it is stored.
|
||||
|
||||
These fields are validated on their ``.strip()``ed form, so an accidentally
|
||||
padded value passes validation but then silently breaks the verbatim
|
||||
``os.environ.get(...)`` / URL lookups downstream. Normalizing at store time
|
||||
mirrors how ``hosts`` is already handled (``h.strip().lower()``). Non-string
|
||||
values (e.g. ``None``) pass through unchanged.
|
||||
"""
|
||||
return value.strip() if isinstance(value, str) else value
|
||||
|
||||
|
||||
def load_auth_config(
|
||||
path: Path | None = None,
|
||||
) -> list[AuthConfigEntry]:
|
||||
@@ -196,10 +182,10 @@ def load_auth_config(
|
||||
provider=provider,
|
||||
auth=auth,
|
||||
token=token,
|
||||
token_env=_norm(token_env),
|
||||
tenant_id=_norm(entry_raw.get("tenant_id")),
|
||||
client_id=_norm(entry_raw.get("client_id")),
|
||||
client_secret_env=_norm(entry_raw.get("client_secret_env")),
|
||||
token_env=token_env,
|
||||
tenant_id=entry_raw.get("tenant_id"),
|
||||
client_id=entry_raw.get("client_id"),
|
||||
client_secret_env=entry_raw.get("client_secret_env"),
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@@ -17,7 +17,6 @@ from fnmatch import fnmatch
|
||||
from typing import Callable
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .._download_security import is_safe_download_redirect
|
||||
from . import get_provider
|
||||
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
|
||||
|
||||
@@ -61,23 +60,8 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
|
||||
RedirectValidator = Callable[[str, str], None]
|
||||
|
||||
|
||||
def _validate_strict_redirect(old_url: str, new_url: str) -> None:
|
||||
if not is_safe_download_redirect(old_url, new_url):
|
||||
raise urllib.error.URLError(
|
||||
f"unsafe redirect to {new_url}: target must use HTTPS with a hostname, "
|
||||
"must not enter a local target from a remote host, and may use HTTP only "
|
||||
"within loopback (for example localhost, 127.0.0.1, ::1)"
|
||||
)
|
||||
|
||||
|
||||
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
"""Redirect handler that guards every redirect it is installed for.
|
||||
|
||||
1. Run any caller-provided redirect validator.
|
||||
2. Reject redirects that are not HTTPS with a hostname. HTTP loopback is
|
||||
allowed only when the previous hop is also loopback.
|
||||
3. Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades.
|
||||
"""
|
||||
"""Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@@ -91,8 +75,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
def redirect_request(self, req, fp, code, msg, headers, newurl):
|
||||
try:
|
||||
new_parsed = urlparse(newurl)
|
||||
# Force urllib's syntax and range validation before following.
|
||||
new_parsed.port
|
||||
except ValueError as exc:
|
||||
# Malformed redirect target (e.g. unterminated IPv6 bracket).
|
||||
# Surface as URLError so callers' download error handling applies.
|
||||
@@ -100,7 +82,6 @@ class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
|
||||
|
||||
if self._redirect_validator is not None:
|
||||
self._redirect_validator(req.full_url, newurl)
|
||||
_validate_strict_redirect(req.full_url, newurl)
|
||||
|
||||
original_auth = (
|
||||
req.get_header("Authorization")
|
||||
@@ -174,12 +155,6 @@ def open_url(
|
||||
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
|
||||
*redirect_validator*, when provided, is called with ``(old_url, new_url)``
|
||||
before following each redirect and may raise to reject the redirect.
|
||||
|
||||
Every attempt uses an isolated opener so a process-wide opener installed
|
||||
with ``urllib.request.install_opener`` cannot replace the redirect guard.
|
||||
Redirect scheme safety: every attempt goes through
|
||||
``_StripAuthOnRedirect``, which rejects redirects to non-HTTPS URLs except
|
||||
HTTP between loopback URLs, and rejects remote-to-local redirects.
|
||||
"""
|
||||
entries = find_entries_for_url(url, _load_config())
|
||||
|
||||
@@ -213,7 +188,7 @@ def open_url(
|
||||
|
||||
# No entry worked (or none matched) — unauthenticated fallback
|
||||
req = _make_req({})
|
||||
# No auth is attached on this path, so the handler's host list is empty:
|
||||
# here it runs redirect validation only, not auth stripping.
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
|
||||
return opener.open(req, timeout=timeout)
|
||||
if redirect_validator is not None:
|
||||
opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
|
||||
return opener.open(req, timeout=timeout)
|
||||
return urllib.request.urlopen(req, timeout=timeout) # noqa: S310
|
||||
|
||||
@@ -40,12 +40,9 @@ def _read(project_root: Path) -> list[dict]:
|
||||
path = ensure_within(project_root, _config_path(project_root))
|
||||
if not path.exists():
|
||||
return []
|
||||
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
|
||||
# otherwise, so a non-mapping top level — a falsy ``[]``/``false``/``0``/``''``
|
||||
# or an explicit null (``load_yaml`` -> ``None``) — is caught by the isinstance
|
||||
# guard below and raised like a truthy one, staying consistent with the other
|
||||
# reader of this file (models/catalog._merge_config).
|
||||
data = load_yaml(path)
|
||||
if data is None:
|
||||
return []
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {path}: expected a mapping at the top "
|
||||
@@ -146,13 +143,6 @@ def add_source(
|
||||
raise BundlerError("A catalog url is required.")
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
# Read .hostname inside the try: a bracketed-but-invalid IPv6 authority
|
||||
# (e.g. "https://[not-an-ip]/c.json") parses cleanly under urlparse() on
|
||||
# Python < 3.14 but raises ValueError lazily on the first .hostname access
|
||||
# (the raise moved eager into urlparse() only in 3.14). Reading it here
|
||||
# keeps that ValueError inside the guard instead of leaking a raw
|
||||
# traceback past the CLI's `except BundlerError`. Reuse the value below.
|
||||
hostname = parsed.hostname
|
||||
except ValueError as exc:
|
||||
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
|
||||
if not (parsed.scheme or parsed.path):
|
||||
@@ -171,13 +161,13 @@ def add_source(
|
||||
# netloc — netloc is truthy for host-less URLs like "https://:8080"
|
||||
# or "https://user@". Validating here keeps junk out of
|
||||
# bundle-catalogs.yml instead of failing later at fetch time.
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme.lower() != "https" and not is_localhost:
|
||||
raise BundlerError(
|
||||
f"Catalog url must use HTTPS (got {parsed.scheme}://). "
|
||||
"HTTP is only allowed for localhost."
|
||||
)
|
||||
if not hostname:
|
||||
if not parsed.hostname:
|
||||
raise BundlerError(f"Catalog url must be a valid URL with a host: {url}")
|
||||
|
||||
url = _canonicalize_url(url)
|
||||
|
||||
@@ -39,35 +39,17 @@ def ensure_within(root: Path, candidate: Path) -> Path:
|
||||
|
||||
|
||||
def load_yaml(path: Path) -> Any:
|
||||
"""Parse a YAML file, returning ``{}`` only for an *empty* document.
|
||||
|
||||
A non-empty document is returned exactly as parsed — including a
|
||||
non-mapping such as ``[]``, ``false``, ``0``, ``''``, or an explicit null
|
||||
(``null``/``~``) — so callers can validate the top-level shape (e.g. reject
|
||||
a non-mapping config) instead of having it silently coerced to an empty
|
||||
mapping.
|
||||
|
||||
``yaml.safe_load`` returns ``None`` for *both* an empty document and an
|
||||
explicit null scalar, so ``yaml.compose`` (which yields no node only for a
|
||||
truly empty document) is used to tell them apart: an empty document becomes
|
||||
``{}`` while an explicit ``null``/``~`` is returned as ``None`` for the
|
||||
caller to reject.
|
||||
"""
|
||||
"""Parse a YAML file, returning ``{}`` for an empty document."""
|
||||
path = Path(path)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"File not found: {path}")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not read {path}: {exc}") from exc
|
||||
try:
|
||||
has_node = yaml.compose(text) is not None
|
||||
data = yaml.safe_load(text)
|
||||
with path.open("r", encoding="utf-8") as handle:
|
||||
return yaml.safe_load(handle) or {}
|
||||
except yaml.YAMLError as exc:
|
||||
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
|
||||
if data is None and not has_node:
|
||||
return {}
|
||||
return data
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not read {path}: {exc}") from exc
|
||||
|
||||
|
||||
def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
|
||||
@@ -78,13 +60,7 @@ def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
|
||||
try:
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with path.open("w", encoding="utf-8") as handle:
|
||||
yaml.safe_dump(
|
||||
data,
|
||||
handle,
|
||||
sort_keys=False,
|
||||
default_flow_style=False,
|
||||
allow_unicode=True,
|
||||
)
|
||||
yaml.safe_dump(data, handle, sort_keys=False, default_flow_style=False)
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not write {path}: {exc}") from exc
|
||||
return path
|
||||
|
||||
@@ -43,7 +43,7 @@ class Scope(str, Enum):
|
||||
BUILTIN_DEFAULT_STACK: tuple[dict[str, Any], ...] = (
|
||||
{"id": "default", "url": "builtin://default", "priority": 1,
|
||||
"install_policy": InstallPolicy.INSTALL_ALLOWED.value},
|
||||
{"id": "community", "url": "builtin://community", "priority": 20,
|
||||
{"id": "community", "url": "builtin://community", "priority": 2,
|
||||
"install_policy": InstallPolicy.DISCOVERY_ONLY.value},
|
||||
)
|
||||
|
||||
@@ -152,21 +152,14 @@ class CatalogEntry:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each catalog entry must be a mapping.")
|
||||
entry_id = str(data.get("id", "")).strip()
|
||||
# `or {}` would coerce a FALSY non-mapping (0, '', False, []) to {} before
|
||||
# the isinstance guard, silently accepting a corrupt catalog entry; only
|
||||
# an absent/None value means "not present".
|
||||
requires = data.get("requires")
|
||||
if requires is None:
|
||||
requires = {}
|
||||
elif not isinstance(requires, dict):
|
||||
requires = data.get("requires") or {}
|
||||
if not isinstance(requires, dict):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id or '<unknown>'}': 'requires' must be a "
|
||||
"mapping when present."
|
||||
)
|
||||
provides_raw = data.get("provides")
|
||||
if provides_raw is None:
|
||||
provides_raw = {}
|
||||
elif not isinstance(provides_raw, dict):
|
||||
provides_raw = data.get("provides") or {}
|
||||
if not isinstance(provides_raw, dict):
|
||||
raise BundlerError(
|
||||
f"Catalog entry '{entry_id or '<unknown>'}': 'provides' must be a "
|
||||
"mapping when present."
|
||||
@@ -256,34 +249,10 @@ def load_source_stack(project_root: Path, user_config_dir: Path | None = None) -
|
||||
def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None:
|
||||
if not config_path.exists():
|
||||
return
|
||||
# ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
|
||||
# otherwise, so a non-mapping top level (a YAML list or scalar, including
|
||||
# the falsy ``[]``/``false``/``0``/``''``) is caught here and raised —
|
||||
# matching the sibling reader commands_impl/catalog_config._read. #3623
|
||||
# aligned the inner non-list ``catalogs`` value between the two readers.
|
||||
data = load_yaml(config_path)
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {config_path}: expected a mapping at "
|
||||
f"the top level, got {type(data).__name__}."
|
||||
)
|
||||
catalogs = data.get("catalogs")
|
||||
if catalogs is None:
|
||||
catalogs = data.get("catalogs") if isinstance(data, dict) else None
|
||||
if not catalogs:
|
||||
return
|
||||
if not isinstance(catalogs, list):
|
||||
# Treat only an absent/``None`` ``catalogs`` as "nothing to merge"; any
|
||||
# other non-list value (``catalogs: 5``, ``false``, ``0``, ``''``,
|
||||
# ``{}``) is a malformed config and must raise, not be silently skipped
|
||||
# by a falsy check. Otherwise a truthy scalar would raise a raw
|
||||
# ``TypeError: 'int' object is not iterable`` from the loop below, while
|
||||
# falsy non-lists would be swallowed. Report the same actionable
|
||||
# BundlerError the sibling reader of this file raises
|
||||
# (commands_impl/catalog_config.py) so both readers of
|
||||
# bundle-catalogs.yml agree. An empty list stays valid (loop is a no-op).
|
||||
raise BundlerError(
|
||||
f"Malformed catalog config at {config_path}: 'catalogs' must be a "
|
||||
f"list, got {type(catalogs).__name__}."
|
||||
)
|
||||
for raw in catalogs:
|
||||
src = CatalogSource.from_dict(raw, scope)
|
||||
by_id[src.id] = src
|
||||
|
||||
@@ -111,10 +111,8 @@ class BundleManifest:
|
||||
license=str(bundle_raw.get("license", "")).strip(),
|
||||
)
|
||||
|
||||
requires_raw = data.get("requires")
|
||||
if requires_raw is None:
|
||||
requires_raw = {}
|
||||
elif not isinstance(requires_raw, dict):
|
||||
requires_raw = data.get("requires") or {}
|
||||
if not isinstance(requires_raw, dict):
|
||||
raise BundlerError("'requires' must be a mapping when present.")
|
||||
requires = Requires(
|
||||
speckit_version=str(requires_raw.get("speckit_version", "")).strip(),
|
||||
@@ -124,18 +122,11 @@ class BundleManifest:
|
||||
|
||||
integration = None
|
||||
integration_raw = data.get("integration")
|
||||
# Mirror the requires/provides guards above: a present-but-non-mapping
|
||||
# 'integration' (e.g. a bare string "copilot") was silently dropped,
|
||||
# leaving the bundle wrongly integration-agnostic. Reject it instead.
|
||||
if integration_raw is not None and not isinstance(integration_raw, dict):
|
||||
raise BundlerError("'integration' must be a mapping when present.")
|
||||
if isinstance(integration_raw, dict) and integration_raw.get("id"):
|
||||
integration = IntegrationRef(id=str(integration_raw["id"]).strip())
|
||||
|
||||
provides = data.get("provides")
|
||||
if provides is None:
|
||||
provides = {}
|
||||
elif not isinstance(provides, dict):
|
||||
provides = data.get("provides") or {}
|
||||
if not isinstance(provides, dict):
|
||||
raise BundlerError("'provides' must be a mapping when present.")
|
||||
|
||||
tags_raw = data.get("tags")
|
||||
|
||||
@@ -55,13 +55,8 @@ class InstalledBundleRecord:
|
||||
def from_dict(cls, data: Any) -> "InstalledBundleRecord":
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError("Each installed-bundle record must be a mapping.")
|
||||
components_raw = data.get("contributed_components")
|
||||
if components_raw is None:
|
||||
components_raw = []
|
||||
elif not isinstance(components_raw, list):
|
||||
# `or []` would coerce a FALSY non-list (0, '', False, {}) to []
|
||||
# before this guard, silently accepting a corrupt record; only an
|
||||
# absent/None value means "no components".
|
||||
components_raw = data.get("contributed_components") or []
|
||||
if not isinstance(components_raw, list):
|
||||
raise BundlerError(
|
||||
"Corrupt record: 'contributed_components' must be a list."
|
||||
)
|
||||
@@ -126,13 +121,8 @@ def load_records(project_root: Path) -> list[InstalledBundleRecord]:
|
||||
if not isinstance(data, dict):
|
||||
raise BundlerError(f"Corrupt records file: {path}")
|
||||
_check_schema_version(data.get("schema_version"), path=path, required=True)
|
||||
bundles = data.get("bundles")
|
||||
if bundles is None:
|
||||
bundles = []
|
||||
elif not isinstance(bundles, list):
|
||||
# `or []` would coerce a FALSY non-list (0, '', False, {}) to [] before
|
||||
# this guard, silently treating a corrupt file as "no bundles"; only an
|
||||
# absent/None value means empty.
|
||||
bundles = data.get("bundles") or []
|
||||
if not isinstance(bundles, list):
|
||||
raise BundlerError(
|
||||
f"Corrupt records file: {path} — 'bundles' must be a list."
|
||||
)
|
||||
|
||||
@@ -15,26 +15,25 @@ from pathlib import Path
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
from urllib.request import url2pathname
|
||||
|
||||
from ..._assets import _locate_core_pack, _repo_root
|
||||
from .. import BundlerError
|
||||
from ..lib.yamlio import loads_json
|
||||
from ..models.catalog import CatalogSource
|
||||
from ..models.manifest import ComponentRef
|
||||
|
||||
COMMUNITY_CATALOG_URL = (
|
||||
"https://raw.githubusercontent.com/github/spec-kit/main/"
|
||||
"bundles/catalog.community.json"
|
||||
)
|
||||
|
||||
# The default catalog is reserved for first-party bundles. The community
|
||||
# catalog is loaded from the repository online and from the packaged snapshot
|
||||
# offline so discovery remains useful without network access.
|
||||
# Built-in catalog payloads ship empty by default; a host distribution can
|
||||
# replace these with curated content. Keeping them here makes ``search``/``info``
|
||||
# work fully offline against the default stack.
|
||||
_BUILTIN_CATALOGS: dict[str, dict] = {
|
||||
"builtin://default": {
|
||||
"schema_version": "1.0",
|
||||
"catalog_url": "builtin://default",
|
||||
"bundles": {},
|
||||
},
|
||||
"builtin://community": {
|
||||
"schema_version": "1.0",
|
||||
"catalog_url": "builtin://community",
|
||||
"bundles": {},
|
||||
},
|
||||
}
|
||||
|
||||
HTTP_TIMEOUT_SECONDS = 10
|
||||
@@ -96,18 +95,6 @@ def _validate_remote_url(source_id: str, url: str) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _load_packaged_community_catalog() -> dict:
|
||||
core_pack = _locate_core_pack()
|
||||
path = (
|
||||
core_pack / "bundles" / "catalog.community.json"
|
||||
if core_pack is not None
|
||||
else _repo_root() / "bundles" / "catalog.community.json"
|
||||
)
|
||||
if not path.is_file():
|
||||
raise BundlerError(f"Bundled community catalog not found: {path}")
|
||||
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
|
||||
|
||||
|
||||
def make_catalog_fetcher(*, allow_network: bool = True):
|
||||
"""Return a fetcher callable suitable for :class:`CatalogStack`.
|
||||
|
||||
@@ -121,10 +108,6 @@ def make_catalog_fetcher(*, allow_network: bool = True):
|
||||
scheme = parsed.scheme.lower()
|
||||
|
||||
if scheme == "builtin":
|
||||
if url == "builtin://community":
|
||||
if allow_network:
|
||||
return _http_get_json(source.id, COMMUNITY_CATALOG_URL)
|
||||
return _load_packaged_community_catalog()
|
||||
payload = _BUILTIN_CATALOGS.get(url)
|
||||
if payload is None:
|
||||
raise BundlerError(f"Unknown built-in catalog '{url}'.")
|
||||
|
||||
@@ -50,10 +50,7 @@ class InstallResult:
|
||||
|
||||
@property
|
||||
def changed(self) -> bool:
|
||||
# `uninstalled` is a mutating outcome too: a `bundle update` whose new
|
||||
# manifest drops components (removing them via the refresh path) with no
|
||||
# new install/refresh must still report changed=True, not a no-op.
|
||||
return bool(self.installed or self.refreshed or self.uninstalled)
|
||||
return bool(self.installed or self.refreshed)
|
||||
|
||||
|
||||
def install_bundle(
|
||||
|
||||
@@ -142,10 +142,4 @@ def _collect_files(
|
||||
# Skip symlinked files to avoid escaping the bundle directory.
|
||||
continue
|
||||
collected.append(path)
|
||||
# Order by the canonical POSIX arcname (the same key build_bundle uses to
|
||||
# NAME each member), not by pathlib.Path comparison. Path ordering is
|
||||
# platform-dependent (Windows folds case and uses backslash separators),
|
||||
# which would lay out zip members differently across build hosts and break
|
||||
# the byte-for-byte reproducible-build guarantee even though the member
|
||||
# names are identical.
|
||||
return sorted(collected, key=lambda p: p.relative_to(bundle_dir).as_posix())
|
||||
return sorted(collected)
|
||||
|
||||
@@ -149,10 +149,7 @@ class CatalogStackBase:
|
||||
)
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — a YAML ``priority: .inf``
|
||||
# would otherwise escape as an uncaught traceback instead of the
|
||||
# clean validation error.
|
||||
except (TypeError, ValueError):
|
||||
raise self._validation_error(
|
||||
f"Invalid catalog config {config_path}: "
|
||||
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
|
||||
|
||||
@@ -86,7 +86,7 @@ def register(app: typer.Typer) -> None:
|
||||
help="Name for your new project directory (optional if using --here, or use '.' for current directory)",
|
||||
),
|
||||
script_type: str = typer.Option(
|
||||
None, "--script", help="Script type to use: sh, ps, or py"
|
||||
None, "--script", help="Script type to use: sh or ps"
|
||||
),
|
||||
ignore_agent_tools: bool = typer.Option(
|
||||
False,
|
||||
@@ -458,7 +458,6 @@ def register(app: typer.Typer) -> None:
|
||||
script_type=selected_script,
|
||||
raw_options=integration_options,
|
||||
parsed_options=integration_parsed_options or None,
|
||||
project_root=project_path,
|
||||
)
|
||||
_write_integration_json(
|
||||
project_path,
|
||||
@@ -479,7 +478,7 @@ def register(app: typer.Typer) -> None:
|
||||
tracker=tracker,
|
||||
force=force,
|
||||
invoke_separator=resolved_integration.effective_invoke_separator(
|
||||
integration_parsed_options, project_root=project_path
|
||||
integration_parsed_options
|
||||
),
|
||||
)
|
||||
tracker.complete(
|
||||
@@ -533,8 +532,10 @@ def register(app: typer.Typer) -> None:
|
||||
"feature_numbering": "sequential",
|
||||
"speckit_version": get_speckit_version(),
|
||||
}
|
||||
if resolved_integration.is_skills_mode(
|
||||
integration_parsed_options or None, project_root=project_path
|
||||
from ..integrations.base import SkillsIntegration as _SkillsPersist
|
||||
|
||||
if isinstance(resolved_integration, _SkillsPersist) or getattr(
|
||||
resolved_integration, "_skills_mode", False
|
||||
):
|
||||
init_opts["ai_skills"] = True
|
||||
save_init_options(project_path, init_opts)
|
||||
@@ -682,9 +683,11 @@ def register(app: typer.Typer) -> None:
|
||||
steps_lines.append("1. You're already in the project directory!")
|
||||
step_num = 2
|
||||
|
||||
_is_skills_integration = resolved_integration.is_skills_mode(
|
||||
integration_parsed_options or None, project_root=project_path
|
||||
)
|
||||
from ..integrations.base import SkillsIntegration as _SkillsInt
|
||||
|
||||
_is_skills_integration = isinstance(
|
||||
resolved_integration, _SkillsInt
|
||||
) or getattr(resolved_integration, "_skills_mode", False)
|
||||
|
||||
codex_skill_mode = selected_ai == "codex" and _is_skills_integration
|
||||
zcode_skill_mode = selected_ai == "zcode" and _is_skills_integration
|
||||
@@ -700,8 +703,6 @@ def register(app: typer.Typer) -> None:
|
||||
zed_skill_mode = selected_ai == "zed" and _is_skills_integration
|
||||
grok_skill_mode = selected_ai == "grok" and _is_skills_integration
|
||||
cline_skill_mode = selected_ai == "cline"
|
||||
forge_skill_mode = selected_ai == "forge"
|
||||
bob_skill_mode = selected_ai == "bob" and _is_skills_integration
|
||||
native_skill_mode = (
|
||||
codex_skill_mode
|
||||
or zcode_skill_mode
|
||||
@@ -714,7 +715,6 @@ def register(app: typer.Typer) -> None:
|
||||
or devin_skill_mode
|
||||
or zed_skill_mode
|
||||
or grok_skill_mode
|
||||
or bob_skill_mode
|
||||
)
|
||||
|
||||
if codex_skill_mode:
|
||||
@@ -752,11 +752,6 @@ def register(app: typer.Typer) -> None:
|
||||
f"{step_num}. Start Grok Build in this project directory; spec-kit skills were installed to [cyan].grok/skills[/cyan]"
|
||||
)
|
||||
step_num += 1
|
||||
if bob_skill_mode:
|
||||
steps_lines.append(
|
||||
f"{step_num}. Start Bob in this project directory; spec-kit skills were installed to [cyan].bob/skills[/cyan]"
|
||||
)
|
||||
step_num += 1
|
||||
usage_label = "skills" if native_skill_mode else "slash commands"
|
||||
|
||||
from .._invocation_style import (
|
||||
@@ -777,7 +772,6 @@ def register(app: typer.Typer) -> None:
|
||||
if (
|
||||
_is_slash_skills_agent(selected_ai, _ai_skills_enabled)
|
||||
or cline_skill_mode
|
||||
or forge_skill_mode
|
||||
):
|
||||
return f"/speckit-{name}"
|
||||
return f"/speckit.{name}"
|
||||
|
||||
@@ -9,13 +9,11 @@ without bloating the core framework.
|
||||
from __future__ import annotations
|
||||
|
||||
import copy
|
||||
import errno
|
||||
import hashlib
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
from dataclasses import dataclass
|
||||
@@ -103,51 +101,6 @@ def _load_core_command_names() -> frozenset[str]:
|
||||
CORE_COMMAND_NAMES = _load_core_command_names()
|
||||
|
||||
|
||||
def _fsync_fd(fd: int) -> None:
|
||||
"""Sync a file descriptor, raising on real storage errors."""
|
||||
try:
|
||||
os.fsync(fd)
|
||||
except AttributeError:
|
||||
return
|
||||
except NotImplementedError:
|
||||
return
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
|
||||
return
|
||||
raise
|
||||
|
||||
|
||||
def _fsync_directory(path: Path) -> None:
|
||||
"""Sync a directory when the platform supports it."""
|
||||
if not path.exists():
|
||||
return
|
||||
if os.name == "nt":
|
||||
return
|
||||
try:
|
||||
dir_fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
|
||||
except (AttributeError, NotImplementedError):
|
||||
return
|
||||
except OSError as exc:
|
||||
if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
|
||||
return
|
||||
try:
|
||||
dir_fd = os.open(str(path), os.O_RDONLY)
|
||||
except (AttributeError, NotImplementedError):
|
||||
return
|
||||
except OSError as exc2:
|
||||
if exc2.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
|
||||
return
|
||||
raise
|
||||
try:
|
||||
_fsync_fd(dir_fd)
|
||||
finally:
|
||||
try:
|
||||
os.close(dir_fd)
|
||||
except OSError:
|
||||
# Cleanup after an fsync failure should not mask the original error.
|
||||
pass
|
||||
|
||||
|
||||
class ExtensionError(Exception):
|
||||
"""Base exception for extension-related errors."""
|
||||
|
||||
@@ -183,7 +136,7 @@ def normalize_priority(value: Any, default: int = DEFAULT_HOOK_PRIORITY) -> int:
|
||||
return default
|
||||
try:
|
||||
priority = int(value)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
except (TypeError, ValueError):
|
||||
return default
|
||||
return priority if priority >= 1 else default
|
||||
|
||||
@@ -746,55 +699,6 @@ class ExtensionManager:
|
||||
self.extensions_dir = project_root / ".specify" / "extensions"
|
||||
self.registry = ExtensionRegistry(self.extensions_dir)
|
||||
|
||||
def _rescue_staging_dir(self, extension_id: str) -> Path:
|
||||
"""Fixed-length staging directory path for a preserved-config rescue.
|
||||
|
||||
The extension ID can be arbitrarily long (manifest validation caps only
|
||||
the character set, not the length), so embedding it verbatim in a single
|
||||
path component could push the ``.rescue-staging-<id>`` directory past a
|
||||
filesystem's per-component byte limit and make every reinstall after
|
||||
``--keep-config`` fail with ``ENAMETOOLONG`` even though the extension
|
||||
installs fine at ``dest_dir``. Hash the ID to a fixed-length suffix so
|
||||
the component length is bounded regardless of ID length.
|
||||
"""
|
||||
digest = hashlib.sha256(extension_id.encode("utf-8")).hexdigest()[:16]
|
||||
return self.extensions_dir / f".rescue-staging-{digest}"
|
||||
|
||||
@staticmethod
|
||||
def _has_keep_config_marker(directory: Path) -> bool:
|
||||
"""Return True when *directory* contains a valid ``.keep-config`` marker.
|
||||
|
||||
The marker is a regular (non-symlink) file written by
|
||||
``remove(..., keep_config=True)`` to record explicit provenance. Its
|
||||
content is intentionally empty — only presence matters, not content.
|
||||
The symlink guard prevents a crafted symlink from fooling the check.
|
||||
"""
|
||||
marker = directory / ".keep-config"
|
||||
return marker.is_file() and not marker.is_symlink()
|
||||
|
||||
@staticmethod
|
||||
def _is_legacy_keep_config_leftover(directory: Path) -> bool:
|
||||
"""Return True for the pre-marker ``remove(..., keep_config=True)`` layout.
|
||||
|
||||
Older CLI releases preserved only top-level config files and removed every
|
||||
other entry, but they did not write ``.keep-config``. Recognize that exact
|
||||
config-only leftover so upgrades still preserve user config, while
|
||||
excluding partially-failed installs that still contain copied payload such
|
||||
as ``extension.yml`` or command directories.
|
||||
"""
|
||||
if not directory.is_dir() or directory.is_symlink():
|
||||
return False
|
||||
|
||||
has_config = False
|
||||
for entry in directory.iterdir():
|
||||
if entry.name.endswith(("-config.yml", "-config.local.yml")) and (
|
||||
entry.is_file() or entry.is_symlink()
|
||||
):
|
||||
has_config = True
|
||||
continue
|
||||
return False
|
||||
return has_config
|
||||
|
||||
@staticmethod
|
||||
def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]:
|
||||
"""Collect command and alias names declared by a manifest.
|
||||
@@ -1330,20 +1234,19 @@ class ExtensionManager:
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
try:
|
||||
from ..agents import CommandRegistrar as _Registrar
|
||||
import yaml as _yaml
|
||||
|
||||
raw = skill_md.read_text(encoding="utf-8")
|
||||
# Parse on the ``---`` delimiter *line*, not any ``---``
|
||||
# substring: a description containing ``---`` would trip a
|
||||
# raw ``split("---", 2)`` and hide metadata.source, so this
|
||||
# extension's own skill would look unrelated and be left
|
||||
# orphaned. Mirrors the #3590 parse_frontmatter fix.
|
||||
fm, _ = _Registrar.parse_frontmatter(raw)
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
source = ""
|
||||
if raw.startswith("---"):
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm = _yaml.safe_load(parts[1]) or {}
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
if source != f"extension:{extension_id}":
|
||||
continue
|
||||
except (OSError, UnicodeDecodeError, Exception):
|
||||
@@ -1387,20 +1290,19 @@ class ExtensionManager:
|
||||
if not skill_md.is_file():
|
||||
continue
|
||||
try:
|
||||
from ..agents import CommandRegistrar as _Registrar
|
||||
import yaml as _yaml
|
||||
|
||||
raw = skill_md.read_text(encoding="utf-8")
|
||||
# Parse on the ``---`` delimiter *line*, not any ``---``
|
||||
# substring: a description containing ``---`` would trip
|
||||
# a raw ``split("---", 2)`` and hide metadata.source, so
|
||||
# this extension's own skill would look unrelated and be
|
||||
# left orphaned. Mirrors the #3590 parse_frontmatter fix.
|
||||
fm, _ = _Registrar.parse_frontmatter(raw)
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
source = ""
|
||||
if raw.startswith("---"):
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
fm = _yaml.safe_load(parts[1]) or {}
|
||||
source = (
|
||||
fm.get("metadata", {}).get("source", "")
|
||||
if isinstance(fm, dict)
|
||||
else ""
|
||||
)
|
||||
# Only remove skills explicitly created by this extension
|
||||
if source != f"extension:{extension_id}":
|
||||
continue
|
||||
@@ -1526,421 +1428,12 @@ class ExtensionManager:
|
||||
backup_config_dir.unlink()
|
||||
did_remove = self.remove(manifest.id)
|
||||
|
||||
# Load and validate .extensionignore BEFORE reading/creating the rescue
|
||||
# staging directory (and thus before deleting dest_dir). The loader can
|
||||
# raise ValidationError (invalid UTF-8) or OSError; doing it first means
|
||||
# such a failure aborts while the kept config is still authoritative in
|
||||
# its documented location, rather than leaving a freshly published
|
||||
# staging copy that a later retry (after the user edits the kept config)
|
||||
# would reload and use to overwrite the newer bytes. Any staging left by
|
||||
# an earlier destructive attempt is intentionally left intact here.
|
||||
ignore_fn = self._load_extensionignore(source_dir)
|
||||
|
||||
# Rescue any config files left behind by a prior `remove --keep-config`.
|
||||
# When an extension is removed with --keep-config, it is no longer in
|
||||
# the registry but its config files remain in dest_dir. A subsequent
|
||||
# plain (non-force) install would delete that directory unconditionally,
|
||||
# silently discarding the preserved config. We read those files into
|
||||
# memory and also write a durable staging copy outside dest_dir so
|
||||
# that a partial rmtree, failed copytree, or partial restore cannot
|
||||
# permanently discard the user's original bytes on a retry. The
|
||||
# staging dir is removed only after every config has been successfully
|
||||
# restored.
|
||||
stranded_configs: dict[str, tuple[bytes, int]] = {}
|
||||
rescue_staging_dir = self._rescue_staging_dir(manifest.id)
|
||||
# A staging directory is trusted only when this completion marker is
|
||||
# present. The marker is written after every staged file is complete
|
||||
# and removed before the non-atomic cleanup, so a crash mid-staging or
|
||||
# mid-cleanup can never leave a partial directory that a retry mistakes
|
||||
# for a complete durable backup.
|
||||
rescue_complete_marker = rescue_staging_dir / ".rescue-complete"
|
||||
staging_is_complete = (
|
||||
rescue_staging_dir.is_dir()
|
||||
and not rescue_staging_dir.is_symlink()
|
||||
and rescue_complete_marker.is_file()
|
||||
and not rescue_complete_marker.is_symlink()
|
||||
)
|
||||
|
||||
if staging_is_complete and not self.registry.is_installed(manifest.id):
|
||||
# A previous install attempt staged the configs but never
|
||||
# completed cleanly. Reload from the durable backup so the
|
||||
# original bytes are used on retry rather than whatever
|
||||
# mixture of packaged defaults and partial restores remains
|
||||
# on disk. Only load non-symlinked files whose names match
|
||||
# the two recognised config suffixes so a tampered staging
|
||||
# directory cannot inject arbitrary files.
|
||||
#
|
||||
# A complete staging directory proves only that staging finished,
|
||||
# not that dest_dir was ever modified: a crash after staging was
|
||||
# synced but before the rmtree below leaves the live kept config
|
||||
# intact. If the user then edits that live config before retrying,
|
||||
# blindly preferring the staged bytes would silently overwrite the
|
||||
# newer config. The staged and live copies are indistinguishable
|
||||
# in provenance from disk alone (a genuine post-crash edit vs. a
|
||||
# packaged default written by a partially-completed copytree), so
|
||||
# when a live config disagrees with its staged copy we must not
|
||||
# silently pick either — preserve both and abort, letting the user
|
||||
# resolve it. dest_dir is still untouched here, so raising is safe.
|
||||
def _recognized_config_names(
|
||||
directory: Path, *, follow_symlinks: bool = True
|
||||
) -> set[str]:
|
||||
names: set[str] = set()
|
||||
if not directory.is_dir():
|
||||
return names
|
||||
for entry in directory.iterdir():
|
||||
if not entry.name.endswith(
|
||||
("-config.yml", "-config.local.yml")
|
||||
):
|
||||
continue
|
||||
if follow_symlinks:
|
||||
if entry.is_file() and not entry.is_symlink():
|
||||
names.add(entry.name)
|
||||
else:
|
||||
# Include symlinks without following them so that
|
||||
# live-only symlinked configs are detected and
|
||||
# preserved rather than silently deleted.
|
||||
if entry.is_file() or entry.is_symlink():
|
||||
names.add(entry.name)
|
||||
return names
|
||||
|
||||
conflicting: set[str] = set()
|
||||
staged_names = _recognized_config_names(rescue_staging_dir)
|
||||
live_names = _recognized_config_names(
|
||||
dest_dir, follow_symlinks=False
|
||||
)
|
||||
|
||||
def _matches_source_config_baseline(config_name: str) -> bool:
|
||||
source_file = source_dir / config_name
|
||||
live_file = dest_dir / config_name
|
||||
if source_file.is_symlink() or live_file.is_symlink():
|
||||
return False
|
||||
if not source_file.is_file() or not live_file.is_file():
|
||||
return False
|
||||
try:
|
||||
source_stat = source_file.stat()
|
||||
source_bytes = source_file.read_bytes()
|
||||
live_stat = live_file.stat()
|
||||
live_bytes = live_file.read_bytes()
|
||||
except OSError:
|
||||
return False
|
||||
return live_bytes == source_bytes and stat.S_IMODE(
|
||||
live_stat.st_mode
|
||||
) == stat.S_IMODE(source_stat.st_mode)
|
||||
|
||||
# A live-only config created after the interrupted attempt is not
|
||||
# enumerated by staging, so without this it would be silently
|
||||
# deleted by the rmtree below and its bytes lost. Live-only files
|
||||
# that still match the current package baseline are safe: they were
|
||||
# copied by the interrupted install and can be recreated on retry.
|
||||
# Only truly divergent live-only configs are conflicts.
|
||||
live_only = live_names - staged_names
|
||||
conflicting.update(
|
||||
name
|
||||
for name in live_only
|
||||
if not _matches_source_config_baseline(name)
|
||||
)
|
||||
# Load original permission bits from the sidecar JSON written by
|
||||
# the staging step. Staged files are kept at mode 0o600 so that
|
||||
# rmtree always succeeds on Windows, so staged_stat.st_mode would
|
||||
# always be 0o600 and must not be used for mode comparisons or
|
||||
# restoration; the sidecar records the true original mode.
|
||||
rescue_modes_file = rescue_staging_dir / ".rescue-modes.json"
|
||||
_staged_modes: dict[str, int] = {}
|
||||
if rescue_modes_file.is_file() and not rescue_modes_file.is_symlink():
|
||||
try:
|
||||
_loaded_modes = json.loads(rescue_modes_file.read_bytes())
|
||||
except (OSError, ValueError):
|
||||
# Ignore unreadable/invalid sidecar metadata and fall back
|
||||
# to each staged file's mode for compatibility.
|
||||
pass
|
||||
else:
|
||||
# json.loads() succeeds for any valid JSON document, so a
|
||||
# sidecar containing e.g. `[]` or a string would otherwise
|
||||
# crash later at _staged_modes.get() or stat.S_IMODE().
|
||||
# Accept only a mapping of string filenames to integer modes
|
||||
# (bool is rejected despite subclassing int); anything else
|
||||
# falls back to each staged file's own mode.
|
||||
if isinstance(_loaded_modes, dict) and all(
|
||||
isinstance(name, str)
|
||||
and isinstance(recorded_mode, int)
|
||||
and not isinstance(recorded_mode, bool)
|
||||
for name, recorded_mode in _loaded_modes.items()
|
||||
):
|
||||
_staged_modes = _loaded_modes
|
||||
for staged_name in sorted(staged_names):
|
||||
staged_file = rescue_staging_dir / staged_name
|
||||
staged_stat = staged_file.stat()
|
||||
staged_bytes = staged_file.read_bytes()
|
||||
# Prefer the sidecar-recorded mode; fall back to the staged
|
||||
# file's own mode for backwards-compat with staging dirs
|
||||
# written before the sidecar was introduced.
|
||||
staged_mode = _staged_modes.get(
|
||||
staged_name, stat.S_IMODE(staged_stat.st_mode)
|
||||
)
|
||||
live_file = dest_dir / staged_name
|
||||
if live_file.is_symlink():
|
||||
# A user may have replaced the live config with a symlink
|
||||
# after the interrupted attempt. It cannot be compared by
|
||||
# bytes/mode against the staged copy, and the rmtree below
|
||||
# would silently delete this newer choice and restore the
|
||||
# older staged file. Treat any live symlink as a conflict so
|
||||
# both are preserved and the user resolves it.
|
||||
conflicting.add(staged_name)
|
||||
elif live_file.is_file():
|
||||
# A live config that cannot be read or stat'ed must not be
|
||||
# treated as non-conflicting: the rmtree below would delete
|
||||
# it and restore the stale staged copy. Abort while dest_dir
|
||||
# is untouched so no newer or permission-restricted config is
|
||||
# lost. Divergence also includes permission-only edits (for
|
||||
# example tightening a secret-bearing config from 0644 to
|
||||
# 0600), which byte equality alone would miss and then revert.
|
||||
try:
|
||||
live_stat = live_file.stat()
|
||||
live_bytes = live_file.read_bytes()
|
||||
except OSError:
|
||||
conflicting.add(staged_name)
|
||||
else:
|
||||
if live_bytes != staged_bytes or stat.S_IMODE(
|
||||
live_stat.st_mode
|
||||
) != staged_mode:
|
||||
conflicting.add(staged_name)
|
||||
stranded_configs[staged_name] = (staged_bytes, staged_mode)
|
||||
if conflicting:
|
||||
# Split into two cases for accurate user guidance: files that
|
||||
# exist in both locations but have diverged, and files that
|
||||
# exist only in the live directory with no rescue-backup copy.
|
||||
both_diverged = conflicting - live_only
|
||||
live_only_conflict = conflicting & live_only
|
||||
msg_parts: list[str] = [
|
||||
f"Preserved extension config conflict for '{manifest.id}':"
|
||||
]
|
||||
if both_diverged:
|
||||
names = ", ".join(sorted(both_diverged))
|
||||
msg_parts.append(
|
||||
f"The current config(s) ({names}) in {dest_dir} differ"
|
||||
f" from their rescued backup in {rescue_staging_dir}."
|
||||
" Both copies have been preserved."
|
||||
)
|
||||
if live_only_conflict:
|
||||
names = ", ".join(sorted(live_only_conflict))
|
||||
msg_parts.append(
|
||||
f"The config(s) ({names}) exist only in {dest_dir}"
|
||||
f" with no counterpart in the rescued backup at"
|
||||
f" {rescue_staging_dir}."
|
||||
)
|
||||
msg_parts.append(
|
||||
f"Reconcile {dest_dir} and {rescue_staging_dir} to the"
|
||||
f" desired final state, delete {rescue_staging_dir},"
|
||||
" then reinstall."
|
||||
)
|
||||
raise ValidationError(" ".join(msg_parts))
|
||||
elif (
|
||||
dest_dir.exists()
|
||||
and not self.registry.is_installed(manifest.id)
|
||||
and (
|
||||
self._has_keep_config_marker(dest_dir)
|
||||
or self._is_legacy_keep_config_leftover(dest_dir)
|
||||
)
|
||||
):
|
||||
for cfg_file in (
|
||||
list(dest_dir.glob("*-config.yml"))
|
||||
+ list(dest_dir.glob("*-config.local.yml"))
|
||||
):
|
||||
if cfg_file.is_symlink():
|
||||
# `remove --keep-config` preserves a symlinked config
|
||||
# because Path.is_file() follows symlinks. Its bytes cannot
|
||||
# be safely rescued (the target may live outside dest_dir),
|
||||
# and the rmtree below would delete the link and silently
|
||||
# discard the kept configuration. Reject the reinstall while
|
||||
# dest_dir is untouched so the user resolves it rather than
|
||||
# losing the linked config.
|
||||
raise ValidationError(
|
||||
"Preserved extension config for "
|
||||
f"'{manifest.id}' is a symlink ({cfg_file.name}) in "
|
||||
f"{dest_dir}, which cannot be safely rescued during "
|
||||
"reinstall. Resolve manually — replace the symlink with "
|
||||
"a regular file or remove it — then reinstall."
|
||||
)
|
||||
if cfg_file.is_file():
|
||||
stranded_configs[cfg_file.name] = (
|
||||
cfg_file.read_bytes(),
|
||||
cfg_file.stat().st_mode,
|
||||
)
|
||||
|
||||
if stranded_configs and not staging_is_complete:
|
||||
# Write a durable backup outside dest_dir before any
|
||||
# destructive operation so the original bytes survive a
|
||||
# crash or partial failure at any later step. The staging
|
||||
# dir is cleaned up only after every restore succeeds.
|
||||
#
|
||||
# Any pre-existing staging dir here lacks the completion marker
|
||||
# (staging_is_complete is False), so it is a stale partial from an
|
||||
# interrupted attempt — remove it first for a clean write.
|
||||
if rescue_staging_dir.is_symlink():
|
||||
rescue_staging_dir.unlink()
|
||||
elif rescue_staging_dir.is_dir():
|
||||
shutil.rmtree(rescue_staging_dir)
|
||||
elif rescue_staging_dir.exists():
|
||||
rescue_staging_dir.unlink()
|
||||
try:
|
||||
rescue_staging_dir.mkdir(parents=True, exist_ok=True)
|
||||
for filename, (content, mode) in stranded_configs.items():
|
||||
staged = rescue_staging_dir / filename
|
||||
# Create the staging file with mode 0600 before writing so
|
||||
# the preserved bytes are never transiently readable by other
|
||||
# local users, even on a umask that would produce 0644.
|
||||
# O_BINARY (0 on POSIX) is required so Windows does not open
|
||||
# the descriptor in text mode and translate the preserved
|
||||
# bytes' "\n" into "\r\n" as they are written.
|
||||
fd = os.open(
|
||||
str(staged),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
# os.write() may write fewer bytes than requested, so
|
||||
# loop until the whole buffer is on disk — a truncated
|
||||
# "durable" backup would be trusted over the intact
|
||||
# config on a retry and cause silent data loss.
|
||||
view = memoryview(content)
|
||||
written = 0
|
||||
while written < len(view):
|
||||
written += os.write(fd, view[written:])
|
||||
# Do NOT chmod the staged file: setting a read-only
|
||||
# mode (e.g. 0o444) makes the file undeletable on
|
||||
# Windows and causes shutil.rmtree to fail during
|
||||
# cleanup. Original modes are recorded separately in
|
||||
# .rescue-modes.json so they can be reapplied when the
|
||||
# config is actually restored.
|
||||
_fsync_fd(fd)
|
||||
finally:
|
||||
os.close(fd)
|
||||
# Persist the original permission bits in a sidecar JSON file
|
||||
# so a retry can correctly reapply them even though the staged
|
||||
# files themselves are kept at their creation mode (0o600).
|
||||
rescue_modes_file = rescue_staging_dir / ".rescue-modes.json"
|
||||
modes_payload = json.dumps(
|
||||
{
|
||||
filename: stat.S_IMODE(mode)
|
||||
for filename, (_, mode) in stranded_configs.items()
|
||||
},
|
||||
sort_keys=True,
|
||||
).encode()
|
||||
modes_fd = os.open(
|
||||
str(rescue_modes_file),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
view = memoryview(modes_payload)
|
||||
written = 0
|
||||
while written < len(view):
|
||||
written += os.write(modes_fd, view[written:])
|
||||
_fsync_fd(modes_fd)
|
||||
finally:
|
||||
os.close(modes_fd)
|
||||
# Flush the staging directory metadata before publishing the
|
||||
# completion marker so a crash cannot leave a visible marker with
|
||||
# only a subset of staged files.
|
||||
_fsync_directory(rescue_staging_dir)
|
||||
# Write the completion marker only after every staged file is
|
||||
# fully written so a retry trusts staging only when it is whole.
|
||||
marker_fd = os.open(
|
||||
str(rescue_complete_marker),
|
||||
os.O_WRONLY | os.O_CREAT | os.O_EXCL,
|
||||
0o600,
|
||||
)
|
||||
try:
|
||||
_fsync_fd(marker_fd)
|
||||
finally:
|
||||
os.close(marker_fd)
|
||||
_fsync_directory(rescue_staging_dir)
|
||||
_fsync_directory(rescue_staging_dir.parent)
|
||||
except BaseException:
|
||||
# Durable staging failed (or was interrupted). Continuing with
|
||||
# only the in-memory copy would reintroduce the permanent-loss
|
||||
# path this staging exists to close: the rmtree below could
|
||||
# delete the originals and a later restore failure would leave
|
||||
# no on-disk copy. dest_dir is still untouched here, so clean
|
||||
# up the partial staging dir and abort the install instead of
|
||||
# proceeding destructively.
|
||||
shutil.rmtree(rescue_staging_dir, ignore_errors=True)
|
||||
raise
|
||||
|
||||
# Install extension (dest_dir computed above during self-install guard)
|
||||
if dest_dir.exists():
|
||||
shutil.rmtree(dest_dir)
|
||||
|
||||
def _restore_stranded_config_file(
|
||||
target: Path, content: bytes, preserved_mode: int
|
||||
) -> None:
|
||||
tmp_path: Path | None = None
|
||||
try:
|
||||
# A short fixed prefix, not f".{target.name}.": the preserved
|
||||
# config filename may itself already be near the filesystem's
|
||||
# per-component byte limit, and NamedTemporaryFile appends a
|
||||
# random suffix to the prefix — reusing the full name would push
|
||||
# the temp file past the limit and raise ENAMETOOLONG on every
|
||||
# retry. tempfile already guarantees collision avoidance.
|
||||
with tempfile.NamedTemporaryFile(
|
||||
mode="wb",
|
||||
dir=target.parent,
|
||||
prefix=".cfg-restore.",
|
||||
delete=False,
|
||||
) as tmp:
|
||||
tmp_path = Path(tmp.name)
|
||||
tmp.write(content)
|
||||
tmp.flush()
|
||||
_fsync_fd(tmp.fileno())
|
||||
try:
|
||||
tmp_path.chmod(stat.S_IMODE(preserved_mode))
|
||||
except (NotImplementedError, OSError):
|
||||
pass # Best-effort; chmod may not be supported on all platforms.
|
||||
os.replace(tmp_path, target)
|
||||
try:
|
||||
target_fd = os.open(str(target), os.O_RDONLY)
|
||||
except (AttributeError, OSError, NotImplementedError):
|
||||
target_fd = None
|
||||
try:
|
||||
if target_fd is not None:
|
||||
_fsync_fd(target_fd)
|
||||
finally:
|
||||
if target_fd is not None:
|
||||
try:
|
||||
os.close(target_fd)
|
||||
except OSError:
|
||||
pass # best-effort close during cleanup; ignore errors
|
||||
_fsync_directory(target.parent)
|
||||
except BaseException:
|
||||
if tmp_path is not None and tmp_path.exists():
|
||||
tmp_path.unlink()
|
||||
raise
|
||||
|
||||
try:
|
||||
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
|
||||
except BaseException:
|
||||
# copytree failed — dest_dir may be absent or only partially
|
||||
# created. Write the rescued configs back now so they are not
|
||||
# permanently lost even though the install did not complete.
|
||||
if stranded_configs:
|
||||
dest_dir.mkdir(parents=True, exist_ok=True)
|
||||
for filename, (content, mode) in stranded_configs.items():
|
||||
target = dest_dir / filename
|
||||
_restore_stranded_config_file(target, content, mode)
|
||||
raise
|
||||
|
||||
# Restore stranded configs rescued before the rmtree above.
|
||||
for filename, (content, mode) in stranded_configs.items():
|
||||
target = dest_dir / filename
|
||||
_restore_stranded_config_file(target, content, mode)
|
||||
|
||||
# NOTE: the durable staging backup is intentionally NOT cleaned up
|
||||
# here. Command/skill/hook registration and the final registry.add()
|
||||
# below can still fail; if we discarded the backup and provenance now,
|
||||
# such a failure would leave the extension unregistered with no durable
|
||||
# rescue copy, so the next plain retry would skip rescue and overwrite
|
||||
# the restored user config with packaged defaults. Cleanup is deferred
|
||||
# until after registry.add() succeeds (see post-commit cleanup below).
|
||||
ignore_fn = self._load_extensionignore(source_dir)
|
||||
shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
|
||||
|
||||
# Register commands with AI agents
|
||||
registered_commands = {}
|
||||
@@ -2003,24 +1496,6 @@ class ExtensionManager:
|
||||
},
|
||||
)
|
||||
|
||||
# Post-commit cleanup: the registry now records this extension as
|
||||
# installed, so the rescue guard (`not self.registry.is_installed`)
|
||||
# will never misread a leftover staging dir on a future run. The
|
||||
# durable backup has therefore served its purpose and can be removed
|
||||
# best-effort — a cleanup failure must not fail an install that has
|
||||
# already committed successfully.
|
||||
if rescue_staging_dir.is_dir() and not rescue_staging_dir.is_symlink():
|
||||
# Remove the completion marker before the non-atomic rmtree so a
|
||||
# crash mid-cleanup cannot leave a staging dir that a retry would
|
||||
# wrongly trust as a complete durable backup.
|
||||
try:
|
||||
rescue_complete_marker.unlink(missing_ok=True)
|
||||
_fsync_directory(rescue_staging_dir)
|
||||
shutil.rmtree(rescue_staging_dir)
|
||||
_fsync_directory(rescue_staging_dir.parent)
|
||||
except OSError:
|
||||
pass # Best-effort; install already committed to the registry.
|
||||
|
||||
return manifest
|
||||
|
||||
def install_from_zip(
|
||||
@@ -2137,12 +1612,6 @@ class ExtensionManager:
|
||||
shutil.rmtree(child)
|
||||
else:
|
||||
child.unlink()
|
||||
# Write a provenance marker so install_from_directory can
|
||||
# distinguish this --keep-config leftover from a directory left
|
||||
# by a partially-failed install (which must not have its
|
||||
# packaged default configs treated as user-preserved data).
|
||||
# Content is intentionally empty — only presence matters.
|
||||
(extension_dir / ".keep-config").write_text("")
|
||||
else:
|
||||
# Backup config files before deleting
|
||||
if extension_dir.exists():
|
||||
@@ -2179,13 +1648,7 @@ class ExtensionManager:
|
||||
return []
|
||||
return [item for item in value if isinstance(item, str)]
|
||||
|
||||
def unregister_agent_artifacts(
|
||||
self,
|
||||
agent_name: str,
|
||||
*,
|
||||
enabled_only: bool = False,
|
||||
commands_only: bool = False,
|
||||
) -> None:
|
||||
def unregister_agent_artifacts(self, agent_name: str) -> None:
|
||||
"""Remove extension files registered for a specific agent.
|
||||
|
||||
Extension command files are tracked per agent in ``registered_commands``.
|
||||
@@ -2193,14 +1656,6 @@ class ExtensionManager:
|
||||
from that agent's skills directory (resolved via its integration config)
|
||||
and the registry field is cleared.
|
||||
|
||||
Set ``enabled_only=True`` when a caller is about to re-register enabled
|
||||
extensions and must preserve disabled extensions' existing artifacts and
|
||||
registry entries.
|
||||
|
||||
Set ``commands_only=True`` for command-directory reconciliation where
|
||||
skill artifacts are outside the target agent's lifecycle and must not
|
||||
be touched.
|
||||
|
||||
Skips cleanup when *agent_name* is not a supported agent to avoid
|
||||
losing registry entries while leaving orphaned files on disk.
|
||||
"""
|
||||
@@ -2219,9 +1674,6 @@ class ExtensionManager:
|
||||
agent_skills_dir = resolve_skills_dir(self.project_root, agent_name)
|
||||
|
||||
for ext_id, metadata in self.registry.list().items():
|
||||
if enabled_only and not metadata.get("enabled", True):
|
||||
continue
|
||||
|
||||
updates: Dict[str, Any] = {}
|
||||
|
||||
registered_commands = metadata.get("registered_commands", {})
|
||||
@@ -2244,7 +1696,7 @@ class ExtensionManager:
|
||||
registered_skills = self._valid_name_list(
|
||||
metadata.get("registered_skills", [])
|
||||
)
|
||||
if registered_skills and not commands_only:
|
||||
if registered_skills:
|
||||
# Only pass the resolved skills_dir when it actually exists.
|
||||
# Otherwise let _unregister_extension_skills fall back to
|
||||
# scanning all known agent skills directories, which is useful
|
||||
@@ -2621,23 +2073,14 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
url: str,
|
||||
timeout: int = 10,
|
||||
extra_headers: Optional[Dict[str, str]] = None,
|
||||
redirect_validator=None,
|
||||
):
|
||||
"""Open a URL with provider-based auth, trying each configured provider.
|
||||
|
||||
Delegates to :func:`specify_cli.authentication.http.open_url`.
|
||||
*redirect_validator*, when provided, is invoked as ``(old_url, new_url)``
|
||||
before EACH redirect hop so an HTTPS host guarantee can be enforced on
|
||||
every intermediate URL, not just the terminal one.
|
||||
"""
|
||||
from specify_cli.authentication.http import open_url
|
||||
|
||||
return open_url(
|
||||
url,
|
||||
timeout,
|
||||
extra_headers=extra_headers,
|
||||
redirect_validator=redirect_validator,
|
||||
)
|
||||
return open_url(url, timeout, extra_headers=extra_headers)
|
||||
|
||||
def _resolve_github_release_asset_api_url(
|
||||
self,
|
||||
@@ -2861,24 +2304,7 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
|
||||
# Fetch from network
|
||||
try:
|
||||
# Validate EVERY redirect hop, not just the terminal URL. _open_url
|
||||
# follows redirects; _StripAuthOnRedirect drops auth on an HTTPS->HTTP
|
||||
# downgrade AND whenever the redirect leaves the configured trusted
|
||||
# hosts, but the payload itself is still fetched and trusted, and it
|
||||
# supplies each extension's download_url + sha256 (so a redirected
|
||||
# payload defeats sha256 verification). A terminal-only check also
|
||||
# misses an https -> http -> attacker-https chain. redirect_validator
|
||||
# runs before each hop; the final geturl() check is kept as a
|
||||
# belt-and-braces guard. Mirrors bundler/services/adapters.py.
|
||||
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
||||
self._validate_catalog_url(new_url)
|
||||
|
||||
with self._open_url(
|
||||
entry.url, timeout=10, redirect_validator=_validate_redirect
|
||||
) as response:
|
||||
final_url = response.geturl()
|
||||
if final_url != entry.url:
|
||||
self._validate_catalog_url(final_url)
|
||||
with self._open_url(entry.url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
self._validate_catalog_payload(catalog_data, entry.url)
|
||||
@@ -3055,18 +2481,7 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
try:
|
||||
import urllib.error
|
||||
|
||||
# Same redirect hardening as _fetch_single_catalog: validate every
|
||||
# redirect hop AND the final URL so this legacy single-catalog path
|
||||
# is not vulnerable to an HTTPS->HTTP redirected payload either.
|
||||
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
||||
self._validate_catalog_url(new_url)
|
||||
|
||||
with self._open_url(
|
||||
catalog_url, timeout=10, redirect_validator=_validate_redirect
|
||||
) as response:
|
||||
final_url = response.geturl()
|
||||
if final_url != catalog_url:
|
||||
self._validate_catalog_url(final_url)
|
||||
with self._open_url(catalog_url, timeout=10) as response:
|
||||
catalog_data = json.loads(response.read())
|
||||
|
||||
# Validate catalog structure. Reuses the same helper as
|
||||
@@ -3216,20 +2631,8 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
# Validate download URL requires HTTPS (prevent man-in-the-middle attacks)
|
||||
from urllib.parse import urlparse
|
||||
|
||||
# A malformed authority (e.g. an unterminated IPv6 bracket
|
||||
# "https://[::1") makes urlparse / hostname access raise ValueError.
|
||||
# The download_url comes from catalog payload data, so surface a clean
|
||||
# ExtensionError rather than leaking a raw ValueError past the command
|
||||
# handler (which only catches ExtensionError). Mirrors catalogs (#3435)
|
||||
# and workflows/catalog.py (#3484).
|
||||
try:
|
||||
parsed = urlparse(download_url)
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
raise ExtensionError(
|
||||
f"Extension download URL is malformed: {download_url}"
|
||||
) from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
parsed = urlparse(download_url)
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
raise ExtensionError(
|
||||
f"Extension download URL must use HTTPS: {download_url}"
|
||||
@@ -3627,7 +3030,6 @@ class HookExecutor:
|
||||
dollar_skill_mode = is_dollar_skills_agent(selected_ai, ai_skills_enabled)
|
||||
kimi_skill_mode = selected_ai == "kimi"
|
||||
cline_mode = selected_ai == "cline"
|
||||
forge_mode = selected_ai == "forge"
|
||||
|
||||
skill_name = self._skill_name_from_command(command_id)
|
||||
if dollar_skill_mode and skill_name:
|
||||
@@ -3638,10 +3040,6 @@ class HookExecutor:
|
||||
from ..integrations.cline import format_cline_command_name
|
||||
|
||||
return f"/{format_cline_command_name(command_id)}"
|
||||
if forge_mode:
|
||||
from ..integrations.forge import format_forge_command_name
|
||||
|
||||
return f"/{format_forge_command_name(command_id)}"
|
||||
|
||||
use_slash = is_slash_skills_agent(selected_ai, ai_skills_enabled)
|
||||
|
||||
|
||||
@@ -428,17 +428,10 @@ def extension_add(
|
||||
|
||||
try:
|
||||
parsed = urlparse(from_url)
|
||||
# Read .hostname inside the try: parsing a malformed authority -- or
|
||||
# accessing .hostname on one, e.g. an invalid bracketed IPv6 host like
|
||||
# "https://[not-an-ip]/x.zip" -- can raise ValueError. Keeping both the
|
||||
# parse and the .hostname read inside the guard surfaces a clean
|
||||
# "Invalid URL" message instead of leaking a raw traceback past the
|
||||
# CLI. Reuse the value below.
|
||||
hostname = parsed.hostname
|
||||
except ValueError:
|
||||
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
|
||||
raise typer.Exit(1)
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
|
||||
console.print("[red]Error:[/red] URL must use HTTPS for security.")
|
||||
@@ -630,22 +623,16 @@ def extension_add(
|
||||
for warning in manifest.warnings:
|
||||
console.print(f"\n[yellow]⚠ Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
|
||||
|
||||
selected_ai = load_init_options(project_root).get("ai")
|
||||
is_cline = selected_ai == "cline"
|
||||
is_forge = selected_ai == "forge"
|
||||
is_cline = load_init_options(project_root).get("ai") == "cline"
|
||||
|
||||
if is_cline:
|
||||
from specify_cli.integrations.cline import format_cline_command_name
|
||||
if is_forge:
|
||||
from specify_cli.integrations.forge import format_forge_command_name
|
||||
|
||||
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
|
||||
for cmd in manifest.commands:
|
||||
cmd_name = cmd['name']
|
||||
if is_cline:
|
||||
cmd_name = format_cline_command_name(cmd_name)
|
||||
elif is_forge:
|
||||
cmd_name = format_forge_command_name(cmd_name)
|
||||
console.print(f" • {_escape_markup(str(cmd_name))} - {_escape_markup(str(cmd.get('description', '')))}")
|
||||
|
||||
# Report agent skills registration
|
||||
|
||||
@@ -46,7 +46,6 @@ def with_integration_setting(
|
||||
script_type: str | None = None,
|
||||
raw_options: str | None = None,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Any = None,
|
||||
) -> dict[str, dict[str, Any]]:
|
||||
"""Return integration settings with *key* updated."""
|
||||
settings = integration_settings(state)
|
||||
@@ -64,16 +63,7 @@ def with_integration_setting(
|
||||
elif raw_options is not None:
|
||||
current.pop("parsed_options", None)
|
||||
|
||||
# Recompute the separator from the options actually STORED on ``current``
|
||||
# after the update, not the raw ``parsed_options`` argument. When only
|
||||
# ``script_type`` changes (``parsed_options`` and ``raw_options`` both
|
||||
# None), the previously-stored ``parsed_options`` are retained above, so
|
||||
# deriving the separator from the argument (None) would drop an
|
||||
# options-dependent separator (e.g. Copilot ``--skills`` -> "-") back to
|
||||
# the default ".".
|
||||
current["invoke_separator"] = integration.effective_invoke_separator(
|
||||
current.get("parsed_options"), project_root
|
||||
)
|
||||
current["invoke_separator"] = integration.effective_invoke_separator(parsed_options)
|
||||
settings[key] = current
|
||||
return settings
|
||||
|
||||
@@ -83,11 +73,10 @@ def invoke_separator_for_integration(
|
||||
state: dict[str, Any],
|
||||
key: str,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Any = None,
|
||||
) -> str:
|
||||
"""Resolve the invocation separator for stored/default integration state."""
|
||||
if parsed_options is not None:
|
||||
return integration.effective_invoke_separator(parsed_options, project_root)
|
||||
return integration.effective_invoke_separator(parsed_options)
|
||||
|
||||
setting = integration_setting(state, key)
|
||||
stored_separator = setting.get("invoke_separator")
|
||||
@@ -96,6 +85,6 @@ def invoke_separator_for_integration(
|
||||
|
||||
stored_parsed = setting.get("parsed_options")
|
||||
if isinstance(stored_parsed, dict):
|
||||
return integration.effective_invoke_separator(stored_parsed, project_root)
|
||||
return integration.effective_invoke_separator(stored_parsed)
|
||||
|
||||
return integration.effective_invoke_separator(None, project_root)
|
||||
return integration.effective_invoke_separator(None)
|
||||
|
||||
@@ -58,7 +58,6 @@ def _register_builtins() -> None:
|
||||
from .copilot import CopilotIntegration
|
||||
from .cursor_agent import CursorAgentIntegration
|
||||
from .devin import DevinIntegration
|
||||
from .droid import DroidIntegration
|
||||
from .firebender import FirebenderIntegration
|
||||
from .forge import ForgeIntegration
|
||||
from .gemini import GeminiIntegration
|
||||
@@ -96,7 +95,6 @@ def _register_builtins() -> None:
|
||||
_register(CopilotIntegration())
|
||||
_register(CursorAgentIntegration())
|
||||
_register(DevinIntegration())
|
||||
_register(DroidIntegration())
|
||||
_register(FirebenderIntegration())
|
||||
_register(ForgeIntegration())
|
||||
_register(GeminiIntegration())
|
||||
|
||||
@@ -6,7 +6,6 @@ from pathlib import Path
|
||||
from typing import Any, Callable
|
||||
|
||||
import typer
|
||||
from rich.markup import escape
|
||||
|
||||
from .._agent_config import SCRIPT_TYPE_CHOICES
|
||||
from .._console import console
|
||||
@@ -207,7 +206,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
|
||||
while i < len(tokens):
|
||||
token = tokens[i]
|
||||
if not token.startswith("-"):
|
||||
console.print(f"[red]Error:[/red] Unexpected integration option value '{escape(token)}'.")
|
||||
console.print(f"[red]Error:[/red] Unexpected integration option value '{token}'.")
|
||||
if allowed:
|
||||
console.print(f"Allowed options: {allowed}")
|
||||
raise typer.Exit(1)
|
||||
@@ -218,7 +217,7 @@ def _parse_integration_options(integration: Any, raw_options: str) -> dict[str,
|
||||
name, value = name.split("=", 1)
|
||||
opt = declared.get(name)
|
||||
if not opt:
|
||||
console.print(f"[red]Error:[/red] Unknown integration option '{escape(token)}'.")
|
||||
console.print(f"[red]Error:[/red] Unknown integration option '{token}'.")
|
||||
if allowed:
|
||||
console.print(f"Allowed options: {allowed}")
|
||||
raise typer.Exit(1)
|
||||
@@ -273,19 +272,24 @@ def _update_init_options_for_integration(
|
||||
load_init_options,
|
||||
save_init_options,
|
||||
)
|
||||
from .base import SkillsIntegration
|
||||
opts = load_init_options(project_root)
|
||||
opts["integration"] = integration.key
|
||||
opts["ai"] = integration.key
|
||||
opts["speckit_version"] = _get_speckit_version()
|
||||
if script_type:
|
||||
opts["script"] = script_type
|
||||
# Whether skills mode is active is owned by each integration via the
|
||||
# ``is_skills_mode`` hook (base default honors ``--skills``;
|
||||
# SkillsIntegration returns True; skills-first integrations with a legacy
|
||||
# opt-out such as Bob override it). This keeps shared code free of
|
||||
# ``isinstance`` / ``_skills_mode`` probing. Passing parsed_options lets it
|
||||
# work on the ``use``/``install`` path where no setup() runs (issue #3550).
|
||||
if integration.is_skills_mode(parsed_options, project_root=project_root):
|
||||
# Skills mode is either intrinsic (SkillsIntegration), set on the instance
|
||||
# during setup() (_skills_mode), or requested via parsed options (e.g.
|
||||
# Copilot's --skills, persisted as parsed_options["skills"]). The latter is
|
||||
# the only signal available on the `use` path, where no setup() runs and a
|
||||
# fresh integration instance has _skills_mode == False (issue #3550).
|
||||
skills_mode = (
|
||||
isinstance(integration, SkillsIntegration)
|
||||
or getattr(integration, "_skills_mode", False)
|
||||
or bool((parsed_options or {}).get("skills"))
|
||||
)
|
||||
if skills_mode:
|
||||
opts["ai_skills"] = True
|
||||
else:
|
||||
opts.pop("ai_skills", None)
|
||||
@@ -321,7 +325,6 @@ def _set_default_integration(
|
||||
script_type=resolved_script,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
project_root=project_root,
|
||||
)
|
||||
|
||||
if refresh_templates:
|
||||
@@ -330,8 +333,7 @@ def _set_default_integration(
|
||||
project_root,
|
||||
resolved_script,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
integration, {"integration_settings": settings}, key, parsed_options,
|
||||
project_root=project_root,
|
||||
integration, {"integration_settings": settings}, key, parsed_options
|
||||
),
|
||||
force=refresh_templates_force,
|
||||
refresh_managed=True,
|
||||
@@ -443,26 +445,6 @@ def _unregister_extensions_for_agent(
|
||||
)
|
||||
|
||||
|
||||
def _unregister_enabled_extension_commands_for_agent(
|
||||
project_root: Path,
|
||||
agent_key: str,
|
||||
*,
|
||||
continuing: str,
|
||||
) -> None:
|
||||
"""Best-effort removal of enabled extension command artifacts for ``agent_key``."""
|
||||
_best_effort_extension_op(
|
||||
project_root,
|
||||
agent_key,
|
||||
lambda mgr, key: mgr.unregister_agent_artifacts(
|
||||
key,
|
||||
enabled_only=True,
|
||||
commands_only=True,
|
||||
),
|
||||
phase="clean up enabled extension command artifacts for",
|
||||
continuing=continuing,
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# CLI formatting helpers (re-exported from _commands.py)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@@ -38,7 +38,7 @@ from ._helpers import (
|
||||
@integration_app.command("install")
|
||||
def integration_install(
|
||||
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
|
||||
):
|
||||
@@ -127,8 +127,7 @@ def integration_install(
|
||||
project_root,
|
||||
selected_script,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
infra_integration, current, infra_key, infra_parsed,
|
||||
project_root=project_root,
|
||||
infra_integration, current, infra_key, infra_parsed
|
||||
),
|
||||
)
|
||||
if os.name != "nt":
|
||||
@@ -156,16 +155,10 @@ def integration_install(
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
project_root=project_root,
|
||||
)
|
||||
_write_integration_json(project_root, new_default, new_installed, settings)
|
||||
if new_default == integration.key:
|
||||
_update_init_options_for_integration(
|
||||
project_root,
|
||||
integration,
|
||||
script_type=selected_script,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
||||
else:
|
||||
_refresh_init_options_speckit_version(project_root)
|
||||
|
||||
|
||||
@@ -1,9 +1,8 @@
|
||||
"""specify integration switch / upgrade command handlers."""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
from pathlib import Path, PurePath
|
||||
from pathlib import PurePath
|
||||
|
||||
import typer
|
||||
|
||||
@@ -35,182 +34,16 @@ from ._helpers import (
|
||||
_resolve_script_type,
|
||||
_set_default_integration,
|
||||
_set_default_integration_or_exit,
|
||||
_unregister_enabled_extension_commands_for_agent,
|
||||
_unregister_extensions_for_agent,
|
||||
_update_init_options_for_integration,
|
||||
_write_integration_json,
|
||||
)
|
||||
|
||||
|
||||
def _manifest_tracks_skill_layout(manifest) -> bool:
|
||||
"""Return True when *manifest* tracks any skills-layout artifact.
|
||||
|
||||
A skill scaffold is written as ``.../speckit-<name>/SKILL.md``, so a
|
||||
manifest whose tracked files include a ``/SKILL.md`` key is in the skills
|
||||
layout; otherwise it is in the command layout. Used by ``upgrade`` to
|
||||
detect a dual-mode agent (e.g. Bob) flipping between the legacy commands
|
||||
layout and the skills layout so orphaned extension artifacts from the old
|
||||
layout can be reconciled.
|
||||
"""
|
||||
return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)
|
||||
|
||||
|
||||
def _manifest_path_under(rel_path: str, root: str) -> bool:
|
||||
"""Return True when manifest key *rel_path* is inside project-relative *root*."""
|
||||
normalized_root = PurePath(root).as_posix().strip("/")
|
||||
normalized_rel = PurePath(rel_path).as_posix().strip("/")
|
||||
if not normalized_root:
|
||||
return False
|
||||
return normalized_rel == normalized_root or normalized_rel.startswith(
|
||||
f"{normalized_root}/"
|
||||
)
|
||||
|
||||
|
||||
def _legacy_command_root_changed(
|
||||
integration,
|
||||
project_root: Path,
|
||||
old_manifest,
|
||||
new_manifest,
|
||||
) -> bool:
|
||||
"""Return True when command artifacts moved from legacy_dir to canonical dir."""
|
||||
config = integration.registrar_config or {}
|
||||
canonical = config.get("dir")
|
||||
legacy = config.get("legacy_dir")
|
||||
if (
|
||||
not isinstance(canonical, str)
|
||||
or not canonical.strip()
|
||||
or not isinstance(legacy, str)
|
||||
or not legacy.strip()
|
||||
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
|
||||
):
|
||||
return False
|
||||
|
||||
canonical_dir = project_root / canonical
|
||||
legacy_dir = project_root / legacy
|
||||
if not canonical_dir.is_dir() or not legacy_dir.is_dir():
|
||||
return False
|
||||
|
||||
old_had_legacy = any(
|
||||
_manifest_path_under(rel, legacy) for rel in old_manifest.files
|
||||
)
|
||||
new_has_canonical = any(
|
||||
_manifest_path_under(rel, canonical) for rel in new_manifest.files
|
||||
)
|
||||
return old_had_legacy and new_has_canonical
|
||||
|
||||
|
||||
def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool:
|
||||
"""Return True when the old manifest tracks command files under legacy_dir."""
|
||||
config = integration.registrar_config or {}
|
||||
canonical = config.get("dir")
|
||||
legacy = config.get("legacy_dir")
|
||||
if (
|
||||
not isinstance(canonical, str)
|
||||
or not canonical.strip()
|
||||
or not isinstance(legacy, str)
|
||||
or not legacy.strip()
|
||||
or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
|
||||
):
|
||||
return False
|
||||
return any(_manifest_path_under(rel, legacy) for rel in old_manifest.files)
|
||||
|
||||
|
||||
class _PresetRegistryUnreadableError(Exception):
|
||||
"""Raised when an existing preset registry cannot be read or parsed.
|
||||
|
||||
Distinct from a *genuinely absent* registry (no presets installed): an
|
||||
unreadable registry means we cannot verify whether preset overrides would
|
||||
be orphaned by a layout change, so the migration must be rejected rather
|
||||
than proceeding on a false "no presets" assumption.
|
||||
"""
|
||||
|
||||
|
||||
def _installed_presets_affecting_agent(
|
||||
project_root,
|
||||
agent_key: str,
|
||||
*,
|
||||
include_skills: bool = True,
|
||||
) -> list[str]:
|
||||
"""Return IDs of installed presets with artifacts registered for *agent_key*.
|
||||
|
||||
Presets register command overrides for every detected agent and mirror
|
||||
skills for the active skills agent, tracking the result in each preset's
|
||||
``registered_commands`` / ``registered_skills`` metadata. There is no
|
||||
agent-scoped preset re-registration mechanism, so a command↔skills *layout
|
||||
change* cannot reconcile those artifacts (see ``integration_upgrade``).
|
||||
Callers use this to detect the unsafe case and reject the migration rather
|
||||
than silently orphaning preset files / leaving stale registry entries.
|
||||
|
||||
Fails **closed**: a genuinely absent registry (no presets ever installed)
|
||||
returns an empty list, but if the registry file exists and cannot be read
|
||||
or parsed (e.g. a permission error or corruption) this raises
|
||||
:class:`_PresetRegistryUnreadableError`. Reporting "no presets" in that
|
||||
case would let a ``--force`` layout-changing upgrade delete
|
||||
preset-overridden files while their registry state can't be reconciled —
|
||||
the exact inconsistency the guard exists to prevent.
|
||||
"""
|
||||
from ..presets import PresetRegistry
|
||||
|
||||
registry_path = (
|
||||
Path(project_root) / ".specify" / "presets" / PresetRegistry.REGISTRY_FILE
|
||||
)
|
||||
# Genuinely absent registry → no presets installed → safe to proceed.
|
||||
if not registry_path.exists():
|
||||
return []
|
||||
|
||||
# The registry exists: any failure to read or parse it must surface as an
|
||||
# error, not be swallowed into an empty ("no presets") result.
|
||||
try:
|
||||
data = json.loads(registry_path.read_text(encoding="utf-8"))
|
||||
except (OSError, ValueError) as exc:
|
||||
raise _PresetRegistryUnreadableError(str(exc)) from exc
|
||||
if not isinstance(data, dict) or not isinstance(data.get("presets", {}), dict):
|
||||
raise _PresetRegistryUnreadableError(
|
||||
"preset registry structure is malformed"
|
||||
)
|
||||
|
||||
affected: list[str] = []
|
||||
for preset_id, meta in data.get("presets", {}).items():
|
||||
# A malformed entry means we cannot verify whether this preset owns
|
||||
# artifacts for the agent, so fail closed rather than skip it.
|
||||
if not isinstance(meta, dict):
|
||||
raise _PresetRegistryUnreadableError(
|
||||
f"preset '{preset_id}' entry is malformed"
|
||||
)
|
||||
registered_commands = meta.get("registered_commands", {})
|
||||
if not isinstance(registered_commands, dict):
|
||||
raise _PresetRegistryUnreadableError(
|
||||
f"preset '{preset_id}' registered_commands is malformed"
|
||||
)
|
||||
registered_skills = meta.get("registered_skills", [])
|
||||
if include_skills:
|
||||
if not isinstance(registered_skills, (list, tuple)):
|
||||
raise _PresetRegistryUnreadableError(
|
||||
f"preset '{preset_id}' registered_skills is malformed"
|
||||
)
|
||||
has_commands = bool(registered_commands.get(agent_key))
|
||||
has_skills = include_skills and bool(registered_skills)
|
||||
if has_commands or has_skills:
|
||||
affected.append(preset_id)
|
||||
return affected
|
||||
|
||||
|
||||
def _installed_command_presets_affecting_agent(
|
||||
project_root,
|
||||
agent_key: str,
|
||||
) -> list[str]:
|
||||
"""Return installed presets with command artifacts registered for *agent_key*."""
|
||||
return _installed_presets_affecting_agent(
|
||||
project_root,
|
||||
agent_key,
|
||||
include_skills=False,
|
||||
)
|
||||
|
||||
|
||||
@integration_app.command("switch")
|
||||
def integration_switch(
|
||||
target: str = typer.Argument(help="Integration key to switch to"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
|
||||
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
|
||||
@@ -403,8 +236,7 @@ def integration_switch(
|
||||
force=refresh_shared_infra,
|
||||
refresh_managed=True,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
target_integration, current, target, parsed_options,
|
||||
project_root=project_root,
|
||||
target_integration, current, target, parsed_options
|
||||
),
|
||||
refresh_hint=(
|
||||
"To overwrite customizations, re-run with "
|
||||
@@ -504,7 +336,7 @@ def integration_switch(
|
||||
def integration_upgrade(
|
||||
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
|
||||
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
|
||||
script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
|
||||
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
|
||||
):
|
||||
"""Upgrade an integration by reinstalling with diff-aware file handling.
|
||||
@@ -566,110 +398,6 @@ def integration_upgrade(
|
||||
integration, current, key, integration_options
|
||||
)
|
||||
|
||||
legacy_command_root_upgrade_pending = _legacy_command_root_upgrade_pending(
|
||||
integration,
|
||||
old_manifest,
|
||||
)
|
||||
|
||||
# Guard: Kilo's legacy command root moves from .kilocode/workflows to
|
||||
# .kilo/commands. Preset command artifacts are registered only during
|
||||
# preset install/remove, with no agent-scoped re-registration hook to
|
||||
# recreate them at the new command root while preserving priority and
|
||||
# composition semantics. Refuse before setup writes .kilo/commands rather
|
||||
# than leaving legacy preset files orphaned or registry-tracked overrides
|
||||
# missing from the canonical directory.
|
||||
if key == "kilocode" and legacy_command_root_upgrade_pending:
|
||||
config = integration.registrar_config or {}
|
||||
legacy = config.get("legacy_dir", "legacy command directory")
|
||||
canonical = config.get("dir", "canonical command directory")
|
||||
try:
|
||||
affected_presets = _installed_command_presets_affecting_agent(
|
||||
project_root,
|
||||
key,
|
||||
)
|
||||
except _PresetRegistryUnreadableError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
|
||||
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan]: "
|
||||
"the preset registry could not be read to verify installed presets."
|
||||
)
|
||||
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
|
||||
console.print(
|
||||
"A command directory migration cannot reconcile preset command "
|
||||
"artifacts while the preset registry state is unknown. Fix or "
|
||||
"restore [cyan].specify/presets/.registry[/cyan] and retry."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if affected_presets:
|
||||
preset_list = ", ".join(sorted(affected_presets))
|
||||
console.print(
|
||||
f"[red]Error:[/red] Cannot migrate '{key}' command directory "
|
||||
f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan] while "
|
||||
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
|
||||
)
|
||||
console.print(
|
||||
"Preset command artifacts cannot yet be reconciled across this "
|
||||
"command directory migration, so the upgrade is refused before "
|
||||
"changing files."
|
||||
)
|
||||
console.print(
|
||||
"Remove the preset(s), run the upgrade, then reinstall them:\n"
|
||||
f" [cyan]specify preset remove <id>[/cyan]\n"
|
||||
f" [cyan]specify integration upgrade {key} --script {selected_script} --force[/cyan]\n"
|
||||
f" [cyan]specify preset add <id>[/cyan]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Guard: reject a command↔skills layout change while preset overrides are
|
||||
# installed for this agent (review #3415). A dual-mode agent (e.g. Bob)
|
||||
# can flip layout across an upgrade (``--skills`` / ``--legacy-commands``).
|
||||
# Extension artifacts are reconciled after the flip (see below), but preset
|
||||
# artifacts cannot be: there is no agent-scoped preset re-registration
|
||||
# anywhere in the CLI, so migrating would delete a preset's old-layout
|
||||
# files without recreating them in the new layout and leave the preset
|
||||
# registry claiming artifacts that no longer exist. Detect the intended
|
||||
# layout (``is_skills_mode`` reflects the resolved flags/disk state, so a
|
||||
# plain same-layout upgrade is unaffected) and bail out *before* any
|
||||
# mutation with an actionable error so the project is never left in a
|
||||
# half-migrated, inconsistent state.
|
||||
if _manifest_tracks_skill_layout(old_manifest) != integration.is_skills_mode(
|
||||
parsed_options, project_root
|
||||
):
|
||||
try:
|
||||
affected_presets = _installed_presets_affecting_agent(project_root, key)
|
||||
except _PresetRegistryUnreadableError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Cannot change '{key}' command layout: the "
|
||||
f"preset registry could not be read to verify installed presets."
|
||||
)
|
||||
console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
|
||||
console.print(
|
||||
"A layout change cannot reconcile preset artifacts, so the "
|
||||
"migration is refused while the preset registry state is "
|
||||
"unknown. Fix or restore "
|
||||
"[cyan].specify/presets/.registry[/cyan] and retry."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if affected_presets:
|
||||
preset_list = ", ".join(sorted(affected_presets))
|
||||
console.print(
|
||||
f"[red]Error:[/red] Cannot change '{key}' command layout while "
|
||||
f"preset override(s) are installed: [bold]{preset_list}[/bold]."
|
||||
)
|
||||
console.print(
|
||||
"Preset artifacts cannot yet be reconciled across a command↔skills "
|
||||
"layout change, so the migration would orphan their files and leave "
|
||||
"the preset registry inconsistent."
|
||||
)
|
||||
console.print(
|
||||
"Remove the preset(s), run the upgrade, then reinstall them:\n"
|
||||
f" [cyan]specify preset remove <id>[/cyan]\n"
|
||||
f" [cyan]specify integration upgrade {key} "
|
||||
f"--integration-options \"...\"[/cyan]\n"
|
||||
f" [cyan]specify preset add <id>[/cyan]"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Ensure shared infrastructure is up to date; --force overwrites existing files.
|
||||
infra_integration = integration
|
||||
infra_key = key
|
||||
@@ -687,8 +415,7 @@ def integration_upgrade(
|
||||
selected_script,
|
||||
force=force,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
infra_integration, current, infra_key, infra_parsed,
|
||||
project_root=project_root,
|
||||
infra_integration, current, infra_key, infra_parsed
|
||||
),
|
||||
)
|
||||
if os.name != "nt":
|
||||
@@ -714,7 +441,6 @@ def integration_upgrade(
|
||||
script_type=selected_script,
|
||||
raw_options=raw_options,
|
||||
parsed_options=parsed_options,
|
||||
project_root=project_root,
|
||||
)
|
||||
if installed_key == key:
|
||||
try:
|
||||
@@ -722,8 +448,7 @@ def integration_upgrade(
|
||||
project_root,
|
||||
selected_script,
|
||||
invoke_separator=_invoke_separator_for_integration(
|
||||
integration, {"integration_settings": settings}, key, parsed_options,
|
||||
project_root=project_root,
|
||||
integration, {"integration_settings": settings}, key, parsed_options
|
||||
),
|
||||
force=force,
|
||||
refresh_managed=True,
|
||||
@@ -738,12 +463,7 @@ def integration_upgrade(
|
||||
new_manifest.save()
|
||||
_write_integration_json(project_root, installed_key, installed_keys, settings)
|
||||
if installed_key == key:
|
||||
_update_init_options_for_integration(
|
||||
project_root,
|
||||
integration,
|
||||
script_type=selected_script,
|
||||
parsed_options=parsed_options,
|
||||
)
|
||||
_update_init_options_for_integration(project_root, integration, script_type=selected_script)
|
||||
else:
|
||||
_refresh_init_options_speckit_version(project_root)
|
||||
except Exception as exc:
|
||||
@@ -767,89 +487,16 @@ def integration_upgrade(
|
||||
if stale_keys:
|
||||
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
|
||||
stale_manifest._files = {k: old_files[k] for k in stale_keys}
|
||||
# remove_manifest=False: this throwaway manifest shares ``key`` with the
|
||||
# real one just saved above (new_manifest.save()). Letting uninstall()
|
||||
# delete ``{key}.manifest.json`` would wipe the freshly-written manifest
|
||||
# whenever an upgrade shrinks the tracked file set (e.g. Bob migrating
|
||||
# from the legacy commands layout to skills), leaving the integration
|
||||
# untracked and un-upgradeable.
|
||||
stale_removed, _ = stale_manifest.uninstall(
|
||||
project_root, force=True, remove_manifest=False
|
||||
)
|
||||
stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
|
||||
if stale_removed:
|
||||
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
|
||||
|
||||
legacy_command_root_changed = _legacy_command_root_changed(
|
||||
integration,
|
||||
project_root,
|
||||
old_manifest,
|
||||
new_manifest,
|
||||
)
|
||||
if legacy_command_root_changed:
|
||||
_unregister_enabled_extension_commands_for_agent(
|
||||
project_root,
|
||||
key,
|
||||
continuing=(
|
||||
"The integration command directory changed, but legacy enabled "
|
||||
"extension artifacts may need manual cleanup."
|
||||
),
|
||||
)
|
||||
|
||||
# Re-register enabled extensions for the upgraded agent so its extension
|
||||
# commands are (re)created — including agents installed before this
|
||||
# back-fill existed. Mirrors switch for command registration; see #2886.
|
||||
# Done after the upgrade has fully settled (Phase 2 included) and outside
|
||||
# the try/except above so this best-effort step cannot affect upgrade
|
||||
# success.
|
||||
#
|
||||
# Layout-change reconciliation: a dual-mode agent (e.g. Bob) can flip
|
||||
# between the legacy commands layout and the skills layout across an
|
||||
# upgrade (``upgrade bob --integration-options "--skills"`` / reverse
|
||||
# ``--legacy-commands``). Phase 2 above only removes stale files tracked by
|
||||
# the *integration* manifest (core commands); extension artifacts are
|
||||
# tracked separately in the extension registry, so the old layout's
|
||||
# extension command/skill files would otherwise linger as orphans. When the
|
||||
# layout actually changed, first unregister the agent's extension artifacts
|
||||
# (removing old-layout files and clearing per-agent registry entries) so the
|
||||
# re-registration below recreates them in the new layout. ``upgrade``s that
|
||||
# don't change layout skip this to avoid needless remove/re-add churn.
|
||||
#
|
||||
# Only the *active* integration is reconciled this way (``installed_key ==
|
||||
# key``). ``ExtensionManager.unregister_agent_artifacts`` treats the
|
||||
# per-extension ``registered_skills`` list as belonging to the passed agent
|
||||
# and, when that agent's skills directory is absent, falls back to scanning
|
||||
# every agent's skills directory — so running it for a *secondary*
|
||||
# (non-active) agent could delete or untrack the *active* agent's extension
|
||||
# skills. The subsequent re-registration cannot repair that because
|
||||
# extension skill rendering is intentionally scoped to the active agent
|
||||
# (#2948). Extension skills only ever exist for the active agent, so
|
||||
# skipping the unregister for a secondary agent orphans nothing new: a
|
||||
# secondary agent only has extension *command* files, which the
|
||||
# re-registration below rewrites in place regardless of layout.
|
||||
#
|
||||
# Known limitation: preset command/skill artifacts are NOT reconciled on a
|
||||
# layout change. There is no agent-scoped preset re-registration mechanism
|
||||
# anywhere in the CLI — ``use`` / ``switch`` / ``upgrade`` never reconcile
|
||||
# presets for any agent (presets are only (un)registered at preset
|
||||
# install/remove time). Rather than silently orphan them, the guard near
|
||||
# the top of this function rejects a layout-changing upgrade while preset
|
||||
# overrides are installed, so control only reaches here (with a changed
|
||||
# layout) when no preset artifacts are at stake. Full preset reconciliation
|
||||
# would require a new cross-cutting PresetManager subsystem affecting every
|
||||
# dual-layout agent, which is out of scope for this Bob migration.
|
||||
if (
|
||||
installed_key == key
|
||||
and _manifest_tracks_skill_layout(old_manifest)
|
||||
!= _manifest_tracks_skill_layout(new_manifest)
|
||||
):
|
||||
_unregister_extensions_for_agent(
|
||||
project_root,
|
||||
key,
|
||||
continuing=(
|
||||
"The integration layout changed, but old-layout extension "
|
||||
"artifacts may need manual cleanup."
|
||||
),
|
||||
)
|
||||
_register_extensions_for_agent(
|
||||
project_root,
|
||||
key,
|
||||
|
||||
@@ -14,7 +14,6 @@ Provides:
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import platform
|
||||
import re
|
||||
import shlex
|
||||
import shutil
|
||||
@@ -161,66 +160,17 @@ class IntegrationBase(ABC):
|
||||
return []
|
||||
|
||||
def effective_invoke_separator(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
self, parsed_options: dict[str, Any] | None = None
|
||||
) -> str:
|
||||
"""Return the invoke separator for the given options.
|
||||
|
||||
Subclasses whose separator depends on runtime options (e.g.
|
||||
Copilot in ``--skills`` mode) should override this method.
|
||||
The default implementation ignores *parsed_options* and
|
||||
*project_root* and returns the class-level ``invoke_separator``.
|
||||
The default implementation ignores *parsed_options* and returns
|
||||
the class-level ``invoke_separator``.
|
||||
"""
|
||||
return self.invoke_separator
|
||||
|
||||
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
|
||||
"""Command-ref separator given the project's *resolved* skills state.
|
||||
|
||||
Registration paths (extension / preset command rendering) have no CLI
|
||||
``parsed_options`` — only the persisted ``ai_skills`` flag — so they
|
||||
resolve the command-reference separator through this hook rather than
|
||||
the static ``AGENT_CONFIGS[key]["invoke_separator"]`` value, which
|
||||
cannot represent an agent whose separator differs between its skills
|
||||
and command layouts.
|
||||
|
||||
The default is mode-independent and returns exactly what
|
||||
``_build_agent_configs`` would place in ``AGENT_CONFIGS`` (the
|
||||
``registrar_config`` override if present, else the class-level
|
||||
``invoke_separator``), so single-layout agents are unaffected.
|
||||
Dual-mode agents whose separator depends on the layout (e.g. Bob:
|
||||
``-`` for skills, ``.`` for legacy commands) override this.
|
||||
"""
|
||||
cfg = self.registrar_config or {}
|
||||
return cfg.get("invoke_separator", self.invoke_separator)
|
||||
|
||||
def is_skills_mode(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> bool:
|
||||
"""Return whether this integration scaffolds skills for these options.
|
||||
|
||||
This is the single, well-defined hook the shared init/install/upgrade
|
||||
machinery consults to decide whether to persist ``ai_skills=True`` and
|
||||
render skill invocations. It replaces ad-hoc ``isinstance`` /
|
||||
``getattr(self, "_skills_mode", ...)`` probing so an integration's
|
||||
internal representation never has to leak into shared dispatch code.
|
||||
|
||||
*project_root* is optional context for the ``use`` / ``switch`` /
|
||||
``upgrade`` path, where no ``setup()`` runs and *parsed_options* may be
|
||||
empty: dual-mode integrations can consult the already-installed
|
||||
on-disk layout to avoid silently migrating an existing project to a
|
||||
different mode. The default ignores it.
|
||||
|
||||
The default (command-first integrations, e.g. Copilot's default
|
||||
layout) is skills mode only when ``--skills`` was requested.
|
||||
``SkillsIntegration`` overrides this to return ``True`` by default;
|
||||
skills-first integrations that expose a legacy opt-out (e.g. Bob)
|
||||
override it to honor their own flag.
|
||||
"""
|
||||
return bool((parsed_options or {}).get("skills"))
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
@@ -669,46 +619,6 @@ class IntegrationBase(ABC):
|
||||
return name
|
||||
return sys.executable or "python3"
|
||||
|
||||
@staticmethod
|
||||
def build_python_invocation(
|
||||
script_command: str, project_root: Path | None = None
|
||||
) -> str:
|
||||
"""Build a Python script command for the current platform shell."""
|
||||
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
|
||||
if os.name == "nt" and not re.fullmatch(r"[A-Za-z0-9_./:\\-]+", interpreter):
|
||||
quoted_interpreter = interpreter.replace("'", "''")
|
||||
interpreter = f"& '{quoted_interpreter}'"
|
||||
elif os.name != "nt":
|
||||
interpreter = shlex.quote(interpreter)
|
||||
return f"{interpreter} {script_command}"
|
||||
|
||||
@staticmethod
|
||||
def select_script_variant(
|
||||
requested: object, script_commands: dict[str, str]
|
||||
) -> str:
|
||||
"""Select the requested variant or a runnable platform fallback."""
|
||||
if isinstance(requested, str) and requested in script_commands:
|
||||
return requested
|
||||
|
||||
platform_variant = (
|
||||
"ps" if platform.system().lower().startswith("win") else "sh"
|
||||
)
|
||||
secondary_variant = "sh" if platform_variant == "ps" else "ps"
|
||||
fallbacks = (
|
||||
(platform_variant, "py")
|
||||
if requested == "py"
|
||||
else (platform_variant, secondary_variant, "py")
|
||||
)
|
||||
for candidate in fallbacks:
|
||||
if candidate in script_commands:
|
||||
return candidate
|
||||
|
||||
available = ", ".join(sorted(script_commands)) or "none"
|
||||
raise ValueError(
|
||||
"No runnable script variant for this platform: "
|
||||
f"requested {requested!r}; available: {available}"
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _interpreter_runs(path: str) -> bool:
|
||||
"""Return True when *path* executes as a Python interpreter.
|
||||
@@ -743,8 +653,7 @@ class IntegrationBase(ABC):
|
||||
"""Process a raw command template into agent-ready content.
|
||||
|
||||
Performs the same transformations as the release script:
|
||||
1. Select ``scripts.<script_type>`` from YAML frontmatter, falling
|
||||
back to a runnable platform shell or Python variant when unavailable
|
||||
1. Extract ``scripts.<script_type>`` value from YAML frontmatter
|
||||
2. Replace ``{SCRIPT}`` with the extracted script command
|
||||
3. Strip ``scripts:`` section from frontmatter
|
||||
4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
|
||||
@@ -753,46 +662,37 @@ class IntegrationBase(ABC):
|
||||
7. Replace ``__SPECKIT_COMMAND_<NAME>__`` with invocation strings
|
||||
"""
|
||||
# 1. Extract script command from frontmatter
|
||||
script_commands: dict[str, str] = {}
|
||||
script_pattern = re.compile(r"^\s*([A-Za-z0-9_-]+):\s*(.+)$")
|
||||
script_command = ""
|
||||
script_pattern = re.compile(
|
||||
rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE
|
||||
)
|
||||
# Find the scripts: block
|
||||
in_frontmatter = False
|
||||
in_scripts = False
|
||||
for line in content.splitlines():
|
||||
if line == "---":
|
||||
if in_frontmatter:
|
||||
break
|
||||
in_frontmatter = True
|
||||
continue
|
||||
if not in_frontmatter:
|
||||
continue
|
||||
if line == "scripts:":
|
||||
if line.strip() == "scripts:":
|
||||
in_scripts = True
|
||||
continue
|
||||
if in_scripts and line and not line[0].isspace():
|
||||
break
|
||||
in_scripts = False
|
||||
if in_scripts:
|
||||
m = script_pattern.match(line)
|
||||
if m:
|
||||
script_commands[m.group(1)] = m.group(2).strip()
|
||||
|
||||
selected_script_type = (
|
||||
IntegrationBase.select_script_variant(script_type, script_commands)
|
||||
if script_commands
|
||||
else ""
|
||||
)
|
||||
|
||||
script_command = script_commands.get(selected_script_type, "")
|
||||
script_command = m.group(1).strip()
|
||||
break
|
||||
|
||||
# 2. Replace {SCRIPT}
|
||||
if script_command:
|
||||
# For the Python script type, prefix the resolved interpreter so
|
||||
# the command is portable (``.py`` files are not directly
|
||||
# executable on Windows).
|
||||
if selected_script_type == "py":
|
||||
script_command = IntegrationBase.build_python_invocation(
|
||||
script_command, project_root
|
||||
)
|
||||
if script_type == "py":
|
||||
interpreter = IntegrationBase.resolve_python_interpreter(project_root)
|
||||
# Quote the interpreter if it contains whitespace (e.g. an
|
||||
# absolute ``sys.executable`` path under Windows
|
||||
# ``Program Files``) so it isn't split into multiple args.
|
||||
if any(ch.isspace() for ch in interpreter):
|
||||
interpreter = f'"{interpreter}"'
|
||||
script_command = f"{interpreter} {script_command}"
|
||||
content = content.replace("{SCRIPT}", script_command)
|
||||
|
||||
# 3. Strip scripts: section from frontmatter
|
||||
@@ -1476,14 +1376,6 @@ class SkillsIntegration(IntegrationBase):
|
||||
|
||||
invoke_separator = "-"
|
||||
|
||||
def is_skills_mode(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> bool:
|
||||
"""Skills-native integrations scaffold skills unconditionally."""
|
||||
return True
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
|
||||
@@ -1,140 +1,10 @@
|
||||
"""IBM Bob integration.
|
||||
"""IBM Bob integration."""
|
||||
|
||||
Bob 2.0 uses the ``.bob/skills/speckit-<name>/SKILL.md`` layout by default.
|
||||
The legacy ``.bob/commands/*.md`` layout (Bob 1.x) remains available as an
|
||||
opt-in via ``--integration-options "--legacy-commands"``.
|
||||
|
||||
Bob is a *dual-mode* integration: whether it scaffolds skills or commands is
|
||||
a per-project **configuration** decision (the ``--legacy-commands`` option,
|
||||
persisted as ``ai_skills`` in init-options), not a property of the class.
|
||||
It therefore extends :class:`IntegrationBase` (like Copilot, the other
|
||||
dual-mode agent) and resolves the mode through the ``is_skills_mode`` hook,
|
||||
delegating the actual scaffolding to a per-layout helper.
|
||||
|
||||
Deprecation cycle:
|
||||
This release: Skills layout is the default; legacy ``.bob/commands/`` is
|
||||
opt-in via ``--legacy-commands``.
|
||||
Next cycle: ``--legacy-commands`` flag removed.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import warnings
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
import typer
|
||||
|
||||
from ..base import (
|
||||
IntegrationBase,
|
||||
IntegrationOption,
|
||||
MarkdownIntegration,
|
||||
SkillsIntegration,
|
||||
)
|
||||
from ..manifest import IntegrationManifest
|
||||
from ..base import MarkdownIntegration
|
||||
|
||||
|
||||
def _validate_mode_options(parsed_options: dict[str, Any] | None) -> None:
|
||||
"""Reject ``--skills`` and ``--legacy-commands`` used together.
|
||||
|
||||
The two flags select opposite layouts, so combining them is ambiguous.
|
||||
Fail fast with the same clean exit-1 UX as other bad-option paths rather
|
||||
than silently letting one win.
|
||||
"""
|
||||
opts = parsed_options or {}
|
||||
if opts.get("skills") and opts.get("legacy_commands"):
|
||||
from ..._console import console
|
||||
|
||||
console.print(
|
||||
"[red]Error:[/red] --skills and --legacy-commands are mutually "
|
||||
"exclusive; pass only one."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _warn_legacy_commands_deprecated() -> None:
|
||||
warnings.warn(
|
||||
"Bob legacy commands mode (.bob/commands/) is deprecated and will be "
|
||||
"removed in a future Spec Kit release. Omit --legacy-commands to use "
|
||||
"the default skills layout (.bob/skills/).",
|
||||
UserWarning,
|
||||
stacklevel=3,
|
||||
)
|
||||
|
||||
|
||||
class _BobSkillsHelper(SkillsIntegration):
|
||||
"""Default-mode helper: ``.bob/skills/speckit-<name>/SKILL.md``.
|
||||
|
||||
Not registered in the integration registry — used only as a delegate by
|
||||
:class:`BobIntegration` for skills-mode ``setup()``.
|
||||
"""
|
||||
|
||||
class BobIntegration(MarkdownIntegration):
|
||||
key = "bob"
|
||||
config = {
|
||||
"name": "IBM Bob",
|
||||
"folder": ".bob/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".bob/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Bob skills are intent-activated; no slash-command note is needed."""
|
||||
return content
|
||||
|
||||
|
||||
class _BobMarkdownHelper(MarkdownIntegration):
|
||||
"""Legacy-mode helper: ``.bob/commands/speckit.<name>.md`` (Bob 1.x).
|
||||
|
||||
Not registered in the integration registry — used only as a delegate by
|
||||
:class:`BobIntegration` when ``--legacy-commands`` is passed. Declares
|
||||
``invoke_separator="."`` so command-reference tokens render as Bob 1.x
|
||||
``/speckit.<name>`` invocations.
|
||||
"""
|
||||
|
||||
key = "bob"
|
||||
invoke_separator = "."
|
||||
config = {
|
||||
"name": "IBM Bob",
|
||||
"folder": ".bob/",
|
||||
"commands_subdir": "commands",
|
||||
"install_url": None,
|
||||
"requires_cli": False,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".bob/commands",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
"invoke_separator": ".",
|
||||
}
|
||||
|
||||
|
||||
class BobIntegration(IntegrationBase):
|
||||
"""Integration for IBM Bob IDE (dual-mode; skills by default).
|
||||
|
||||
Whether a project uses the skills or the legacy commands layout is a
|
||||
configuration choice resolved by :meth:`is_skills_mode`, not the class
|
||||
hierarchy. ``setup()`` delegates to the matching helper.
|
||||
|
||||
``registrar_config`` mirrors the *commands* layout (``extension: ".md"``,
|
||||
``dir: ".bob/commands"``) — the same pattern Copilot uses — so that
|
||||
``CommandRegistrar.AGENT_CONFIGS["bob"]`` drives extension/preset
|
||||
registration into ``.bob/commands/`` for legacy-mode projects, while
|
||||
skills-mode projects have that command registration transparently skipped
|
||||
(``skills_mode_active`` becomes ``True`` because ``ai_skills=True`` and
|
||||
``extension != "/SKILL.md"``) and receive extension skills instead.
|
||||
``invoke_separator = "-"`` matches the default (skills) layout.
|
||||
"""
|
||||
|
||||
key = "bob"
|
||||
invoke_separator = "-"
|
||||
config = {
|
||||
"name": "IBM Bob",
|
||||
"folder": ".bob/",
|
||||
@@ -148,136 +18,3 @@ class BobIntegration(IntegrationBase):
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": ".md",
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
IntegrationOption(
|
||||
"--skills",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Force the default skills layout (.bob/skills/), overriding "
|
||||
"on-disk auto-detection. Use this to migrate a legacy "
|
||||
"commands install to skills, e.g. "
|
||||
"`integration upgrade bob --integration-options \"--skills\"`"
|
||||
),
|
||||
),
|
||||
IntegrationOption(
|
||||
"--legacy-commands",
|
||||
is_flag=True,
|
||||
default=False,
|
||||
help=(
|
||||
"Scaffold commands as legacy .bob/commands/*.md files "
|
||||
"(Bob 1.x layout, deprecated) instead of the default "
|
||||
"skills layout"
|
||||
),
|
||||
),
|
||||
]
|
||||
|
||||
def is_skills_mode(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> bool:
|
||||
"""Bob is skills-first; ``--legacy-commands`` opts out.
|
||||
|
||||
Precedence:
|
||||
|
||||
1. Explicit ``--skills`` wins — it *forces* skills mode regardless of
|
||||
what is already on disk. This is the supported migration / opt-in
|
||||
path: ``integration upgrade bob --integration-options "--skills"``
|
||||
converts a legacy commands install to the skills layout (setup()
|
||||
scaffolds ``.bob/skills`` and the upgrade's stale-file pass removes
|
||||
the old ``.bob/commands`` files).
|
||||
2. Explicit ``--legacy-commands`` opts out to the Bob 1.x layout.
|
||||
3. Otherwise, when a *project_root* is supplied, the layout is inferred
|
||||
from **managed Spec Kit artifacts** (see below).
|
||||
4. A fresh project (no managed artifacts, no flags) defaults to skills.
|
||||
|
||||
The disk-detection fallback exists because on ``use`` / ``switch`` /
|
||||
``upgrade`` (without an explicit ``--skills`` / ``--legacy-commands``)
|
||||
*parsed_options* is typically empty: no flag was passed, and existing
|
||||
Bob 1.x installs never persisted a ``legacy_commands`` option to
|
||||
recover. This is independent of whether ``setup()`` runs — ``upgrade``
|
||||
*does* call :meth:`setup` (see ``_migrate_commands.integration_upgrade``),
|
||||
but it passes those same empty *parsed_options*, so without disk
|
||||
detection the mode would resolve to the skills default. Defaulting to
|
||||
skills there would rewrite such a project's ``ai_skills`` flag to
|
||||
``True`` even though it still only contains a command layout, silently
|
||||
switching its extension / command-reference handling. So the layout is
|
||||
inferred from managed Spec Kit artifacts, not the mere presence of a
|
||||
``.bob/skills/`` directory: a user may keep unrelated Bob 2 skills in
|
||||
``.bob/skills/`` while their Spec Kit commands still live in
|
||||
``.bob/commands/speckit.*.md``. We therefore treat the project as
|
||||
legacy (command) mode only when managed Spec Kit command files exist
|
||||
and no managed Spec Kit skills (``speckit-*`` skill dirs) do. Passing
|
||||
``--skills`` overrides this so users are never trapped in legacy mode.
|
||||
"""
|
||||
opts = parsed_options or {}
|
||||
_validate_mode_options(opts)
|
||||
if opts.get("skills", False):
|
||||
return True
|
||||
if opts.get("legacy_commands", False):
|
||||
return False
|
||||
if project_root is not None:
|
||||
bob_dir = Path(project_root) / ".bob"
|
||||
has_managed_skills = any((bob_dir / "skills").glob("speckit-*"))
|
||||
has_managed_commands = any((bob_dir / "commands").glob("speckit.*.md"))
|
||||
if has_managed_commands and not has_managed_skills:
|
||||
return False
|
||||
return True
|
||||
|
||||
def effective_invoke_separator(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> str:
|
||||
"""``"."`` for the legacy commands layout, ``"-"`` for skills.
|
||||
|
||||
*project_root* lets the ``use`` / ``switch`` / ``upgrade`` path — which
|
||||
refreshes shared infrastructure *before* persisting init-options —
|
||||
detect an already-installed legacy layout, so core command references
|
||||
are rendered with the correct separator instead of defaulting to the
|
||||
skills ``-``.
|
||||
"""
|
||||
return "-" if self.is_skills_mode(parsed_options, project_root) else "."
|
||||
|
||||
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
|
||||
"""Resolve the command-ref separator from a project's persisted mode.
|
||||
|
||||
Skills projects render ``/speckit-<cmd>``; legacy command projects
|
||||
render Bob 1.x ``/speckit.<cmd>``. Extension/preset registration
|
||||
consults this (via the persisted ``ai_skills`` flag) so both layouts
|
||||
get the correct separator despite sharing one static ``AGENT_CONFIGS``
|
||||
entry.
|
||||
"""
|
||||
return "-" if skills_enabled else "."
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Bob skills are intent-activated; no slash-command note is injected.
|
||||
|
||||
Preset/extension skill generators call this on the *registered*
|
||||
``BobIntegration`` instance, not on :class:`_BobSkillsHelper`, so the
|
||||
no-op must be repeated here (delegating to the helper) — otherwise
|
||||
those paths would inherit ``IntegrationBase``'s default and inject
|
||||
``/speckit-*`` hook guidance that core Bob skills intentionally omit.
|
||||
"""
|
||||
return _BobSkillsHelper().post_process_skill_content(content)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
project_root: Path,
|
||||
manifest: IntegrationManifest,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
**opts: Any,
|
||||
) -> list[Path]:
|
||||
parsed_options = parsed_options or {}
|
||||
if self.is_skills_mode(parsed_options, project_root):
|
||||
return _BobSkillsHelper().setup(
|
||||
project_root, manifest, parsed_options, **opts
|
||||
)
|
||||
_warn_legacy_commands_deprecated()
|
||||
return MarkdownIntegration.setup(
|
||||
_BobMarkdownHelper(), project_root, manifest, parsed_options, **opts
|
||||
)
|
||||
|
||||
@@ -40,25 +40,6 @@ class IntegrationDescriptorError(Exception):
|
||||
"""Raised when an integration.yml descriptor is invalid."""
|
||||
|
||||
|
||||
def _catalog_shape_error(payload: Any) -> Optional[str]:
|
||||
"""Return a human-readable reason if *payload* is not a valid integration
|
||||
catalog document, else ``None``.
|
||||
|
||||
Shared by the fresh-fetch and cache-read paths so both enforce the same
|
||||
format contract: a JSON object carrying ``schema_version`` and a mapping
|
||||
``integrations``. Keeping a single validator prevents the two paths from
|
||||
drifting (e.g. a cache that skips the ``schema_version`` check and lets an
|
||||
older/poisoned payload bypass validation).
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
return "expected a JSON object"
|
||||
if "schema_version" not in payload or "integrations" not in payload:
|
||||
return "missing required 'schema_version' or 'integrations' key"
|
||||
if not isinstance(payload.get("integrations"), dict):
|
||||
return "'integrations' must be a JSON object"
|
||||
return None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationCatalogEntry
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -172,18 +153,7 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
cached_at = cached_at.replace(tzinfo=timezone.utc)
|
||||
age = (datetime.now(timezone.utc) - cached_at).total_seconds()
|
||||
if age < self.CACHE_DURATION:
|
||||
cached = json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
# A poisoned/older-format cache must clear the SAME shape
|
||||
# contract as a fresh fetch (via the shared validator) —
|
||||
# otherwise a payload like [], {"integrations": []}, or one
|
||||
# missing "schema_version" is returned and later crashes on
|
||||
# .items()/.get() or silently bypasses the format contract.
|
||||
# The ValueError is caught just below, which drops the
|
||||
# corrupt cache and refetches from source.
|
||||
shape_error = _catalog_shape_error(cached)
|
||||
if shape_error is not None:
|
||||
raise ValueError(f"cached catalog has invalid shape: {shape_error}")
|
||||
return cached
|
||||
return json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError):
|
||||
# Cache is invalid or stale metadata; delete and refetch from source.
|
||||
try:
|
||||
@@ -202,10 +172,20 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
self._validate_catalog_url(final_url)
|
||||
catalog_data = json.loads(resp.read())
|
||||
|
||||
shape_error = _catalog_shape_error(catalog_data)
|
||||
if shape_error is not None:
|
||||
if not isinstance(catalog_data, dict):
|
||||
raise IntegrationCatalogError(
|
||||
f"Invalid catalog format from {entry.url}: {shape_error}"
|
||||
f"Invalid catalog format from {entry.url}: expected a JSON object"
|
||||
)
|
||||
if (
|
||||
"schema_version" not in catalog_data
|
||||
or "integrations" not in catalog_data
|
||||
):
|
||||
raise IntegrationCatalogError(
|
||||
f"Invalid catalog format from {entry.url}"
|
||||
)
|
||||
if not isinstance(catalog_data.get("integrations"), dict):
|
||||
raise IntegrationCatalogError(
|
||||
f"Invalid catalog format from {entry.url}: 'integrations' must be a JSON object"
|
||||
)
|
||||
|
||||
try:
|
||||
@@ -449,8 +429,7 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
)
|
||||
try:
|
||||
normalized_priority = int(raw_priority)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — a ``priority: .inf``.
|
||||
except (TypeError, ValueError):
|
||||
raise IntegrationValidationError(
|
||||
f"Invalid catalog entry at index {idx} in {config_path}: "
|
||||
f"'priority' must be an integer, got "
|
||||
@@ -558,8 +537,7 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
else:
|
||||
try:
|
||||
priority = int(raw_priority)
|
||||
except (TypeError, ValueError, OverflowError):
|
||||
# OverflowError: int(float("inf")) — a ``priority: .inf``.
|
||||
except (TypeError, ValueError):
|
||||
priority = yaml_idx + 1
|
||||
priority_pairs.append((priority, yaml_idx))
|
||||
if not priority_pairs:
|
||||
|
||||
@@ -77,19 +77,6 @@ class ClineIntegration(MarkdownIntegration):
|
||||
"""Cline uses hyphenated filenames (e.g. speckit-git-commit.md)."""
|
||||
return format_cline_command_name(template_name) + ".md"
|
||||
|
||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||
"""Cline installs hyphenated slash-commands (``/speckit-<name>``), so the
|
||||
dispatch invocation must match. The inherited MarkdownIntegration default
|
||||
builds the dotted ``/speckit.<name>``, which references a command Cline
|
||||
never registered. Reuse the same hyphenation as command_filename /
|
||||
the injected frontmatter name (see ``format_cline_command_name``),
|
||||
mirroring the forge integration.
|
||||
"""
|
||||
invocation = "/" + format_cline_command_name(command_name)
|
||||
if args:
|
||||
invocation = f"{invocation} {args}"
|
||||
return invocation
|
||||
|
||||
def process_template(self, *args, **kwargs):
|
||||
"""Ensure shared templates render Cline command references with hyphens."""
|
||||
kwargs.setdefault("invoke_separator", self.invoke_separator)
|
||||
@@ -138,14 +125,8 @@ class ClineIntegration(MarkdownIntegration):
|
||||
content,
|
||||
)
|
||||
|
||||
def post_process_command_content(self, content: str) -> str:
|
||||
"""Apply Cline-specific transformations to command content.
|
||||
|
||||
Overrides the ``IntegrationBase`` hook of the same name so that
|
||||
``CommandRegistrar.register_commands()`` (which dispatches to
|
||||
``post_process_command_content``) applies these transforms to
|
||||
extension/preset command files too, not just core commands.
|
||||
"""
|
||||
def post_process_content(self, content: str) -> str:
|
||||
"""Apply Cline-specific transformations to command content."""
|
||||
updated = self._inject_hook_command_note(content)
|
||||
updated = self._rewrite_handoff_references(updated)
|
||||
return updated
|
||||
@@ -175,7 +156,7 @@ class ClineIntegration(MarkdownIntegration):
|
||||
content_bytes = path.read_bytes()
|
||||
content = content_bytes.decode("utf-8")
|
||||
|
||||
updated = self.post_process_command_content(content)
|
||||
updated = self.post_process_content(content)
|
||||
|
||||
if updated != content:
|
||||
path.write_bytes(updated.encode("utf-8"))
|
||||
|
||||
@@ -122,9 +122,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
_skills_mode: bool = False
|
||||
|
||||
def effective_invoke_separator(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
self, parsed_options: dict[str, Any] | None = None
|
||||
) -> str:
|
||||
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
|
||||
if parsed_options and parsed_options.get("skills"):
|
||||
@@ -133,33 +131,6 @@ class CopilotIntegration(IntegrationBase):
|
||||
return "-"
|
||||
return self.invoke_separator
|
||||
|
||||
def is_skills_mode(
|
||||
self,
|
||||
parsed_options: dict[str, Any] | None = None,
|
||||
project_root: Path | None = None,
|
||||
) -> bool:
|
||||
"""Copilot is skills mode when ``--skills`` was requested.
|
||||
|
||||
On the init path ``setup()`` has already recorded the choice in
|
||||
``self._skills_mode``; on the ``use``/``install`` path (where no
|
||||
``setup()`` runs) the signal comes from *parsed_options* (#3550), which
|
||||
round-trips because ``--skills`` is persisted in the stored options.
|
||||
"""
|
||||
if parsed_options and parsed_options.get("skills"):
|
||||
return True
|
||||
return self._skills_mode
|
||||
|
||||
def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
|
||||
"""Skills projects render ``/speckit-<cmd>``; default markdown ``.``.
|
||||
|
||||
Copilot is dual-layout, so — like Bob — the command-reference
|
||||
separator depends on the persisted ``ai_skills`` state rather than a
|
||||
single static value. This keeps preset/extension command refs in a
|
||||
Copilot skills project consistent with ``build_command_invocation``
|
||||
(which emits ``/speckit-<stem>``).
|
||||
"""
|
||||
return "-" if skills_enabled else self.invoke_separator
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
return [
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user