mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ee883a1d4e | ||
|
|
bb5a2c5424 | ||
|
|
8db722842f | ||
|
|
a6743ab5e0 | ||
|
|
3b9deeca69 | ||
|
|
bd90f766fb | ||
|
|
b91e30a113 | ||
|
|
470ac5b6e6 | ||
|
|
11ef1b35e2 | ||
|
|
5674fd03a9 | ||
|
|
735fe0c5da | ||
|
|
0add7131c9 | ||
|
|
cef00a1cb3 | ||
|
|
03f9013a7b | ||
|
|
cbfb9f01f7 | ||
|
|
aee9df00d4 | ||
|
|
4f4d19ba93 | ||
|
|
9ef477167d | ||
|
|
d39f8fd5e5 | ||
|
|
914d7b887f | ||
|
|
29877825ef | ||
|
|
d9e4565cf8 | ||
|
|
840fb8d786 | ||
|
|
0d2e3b5e76 | ||
|
|
41a8e07f4c | ||
|
|
01d07e2f87 | ||
|
|
956ecab230 | ||
|
|
30e99ec083 | ||
|
|
717d7c4b89 | ||
|
|
b79ae330fd | ||
|
|
b6b3ec49d9 | ||
|
|
48686521ff | ||
|
|
ebd3097eb3 | ||
|
|
115bc94cce | ||
|
|
d7699c39f2 | ||
|
|
e9d84ca4fb |
@@ -97,6 +97,17 @@ 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, 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, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
|
||||
|
||||
- type: input
|
||||
id: agent-name
|
||||
|
||||
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
1
.github/ISSUE_TEMPLATE/bug_report.yml
vendored
@@ -71,6 +71,7 @@ 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,6 +65,7 @@ body:
|
||||
- Codex CLI
|
||||
- Cursor
|
||||
- Devin for Terminal
|
||||
- Factory Droid
|
||||
- Firebender
|
||||
- Forge
|
||||
- Gemini CLI
|
||||
|
||||
115
.github/scripts/check_security_requirements.py
vendored
Normal file
115
.github/scripts/check_security_requirements.py
vendored
Normal file
@@ -0,0 +1,115 @@
|
||||
"""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
Normal file
253
.github/security-audit-requirements.txt
vendored
Normal file
@@ -0,0 +1,253 @@
|
||||
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)
|
||||
78
.github/workflows/security.yml
vendored
Normal file
78
.github/workflows/security.yml
vendored
Normal file
@@ -0,0 +1,78 @@
|
||||
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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
with:
|
||||
fetch-depth: 0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- 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@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
|
||||
|
||||
- name: Install uv
|
||||
uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
|
||||
|
||||
- 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
|
||||
65
CHANGELOG.md
65
CHANGELOG.md
@@ -2,6 +2,71 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [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
|
||||
|
||||
@@ -113,6 +113,27 @@ 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,6 +1,35 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-15T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json",
|
||||
"bundles": {}
|
||||
"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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@ Accepted community bundle entries are published in [`bundles/catalog.community.j
|
||||
|
||||
| Bundle | Purpose | Role or team | Provides | Required catalogs | URL |
|
||||
|--------|---------|--------------|----------|-------------------|-----|
|
||||
| SicarioSpec Security & Governance Bundle | Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates. | `security-engineer` | 1 extension, 11 presets | Documented | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
|
||||
|
||||
## What to Submit
|
||||
|
||||
|
||||
@@ -70,6 +70,7 @@ 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) |
|
||||
|
||||
@@ -11,7 +11,7 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| 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, 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) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
@@ -19,10 +19,13 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
||||
| Intake Authoring Governance | Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries. | 7 templates, 2 commands, 2 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
|
||||
| Intake Review Governance | Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
|
||||
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Parallel Autonomous Run Governance | Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
|
||||
@@ -63,10 +63,14 @@ 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: 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.
|
||||
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.
|
||||
|
||||
## Which Approach to Choose
|
||||
|
||||
|
||||
171
docs/concepts/spec-of-specs.md
Normal file
171
docs/concepts/spec-of-specs.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# 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 vs PowerShell)
|
||||
### Specify Script Type (Shell, PowerShell, or Python)
|
||||
|
||||
All automation scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants.
|
||||
Automation scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants.
|
||||
|
||||
Auto behavior:
|
||||
|
||||
@@ -92,6 +92,7 @@ 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
|
||||
@@ -131,6 +132,7 @@ 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
|
||||
|
||||
|
||||
@@ -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` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
|
||||
| `--script sh\|ps\|py` | Script type: `sh` (bash/zsh), `ps` (PowerShell), or `py` (Python) |
|
||||
| `--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,12 +221,14 @@ 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. 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`. |
|
||||
| `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`. |
|
||||
| `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,6 +15,7 @@ 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` | |
|
||||
|
||||
@@ -55,6 +55,8 @@
|
||||
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
|
||||
|
||||
170
docs/upgrade.md
170
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** | `specify init --here --force --integration <your-agent>` | Update slash commands, templates, and scripts in your project |
|
||||
| **Project Files** | Run `specify integration upgrade <key>`, then `specify extension update` | Refresh installed integration files and extensions in your project |
|
||||
| **Both** | Run CLI upgrade, then project update | Recommended for major version updates |
|
||||
|
||||
---
|
||||
@@ -89,91 +89,94 @@ specify self check
|
||||
|
||||
## Part 2: Updating Project Files
|
||||
|
||||
When Spec Kit releases new features (like new slash commands or updated templates), you need to refresh your project's Spec Kit 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.
|
||||
|
||||
### What gets updated?
|
||||
|
||||
Running `specify init --here --force` will update:
|
||||
For existing Spec Kit projects, use the manifest-aware upgrade path first:
|
||||
|
||||
- ✅ **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**
|
||||
- ✅ **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`.
|
||||
|
||||
### What stays safe?
|
||||
|
||||
These files are **never touched** by the upgrade—the template packages don't even contain them:
|
||||
These files are **never touched** by the manifest-aware integration/extension upgrade path:
|
||||
|
||||
- ✅ **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.
|
||||
|
||||
### Update command
|
||||
### 1. Check installed integrations
|
||||
|
||||
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>
|
||||
```
|
||||
|
||||
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.
|
||||
|
||||
---
|
||||
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.
|
||||
|
||||
## ⚠️ Important Warnings
|
||||
|
||||
### 1. Constitution file will be overwritten
|
||||
### 1. Constitution file and memory customizations
|
||||
|
||||
**Known issue:** `specify init --here --force` currently overwrites `.specify/memory/constitution.md` with the default template, erasing any customizations you made.
|
||||
`specify integration upgrade <key>` does not update `.specify/memory/constitution.md`.
|
||||
|
||||
**Workaround:**
|
||||
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.
|
||||
|
||||
```bash
|
||||
# 1. Back up your constitution before upgrading
|
||||
cp .specify/memory/constitution.md .specify/memory/constitution-backup.md
|
||||
As with any broad fallback refresh, commit or back up local customizations before using `init --here --force` so you can review the resulting diff.
|
||||
|
||||
# 2. Run the upgrade
|
||||
specify init --here --force --integration copilot
|
||||
### 2. Custom integration, script, or template modifications
|
||||
|
||||
# 3. Restore your customized constitution
|
||||
mv .specify/memory/constitution-backup.md .specify/memory/constitution.md
|
||||
```
|
||||
`specify integration upgrade <key>` blocks when manifest-tracked integration files were modified locally, unless you pass `--force`.
|
||||
|
||||
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:
|
||||
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:
|
||||
|
||||
```bash
|
||||
# Back up custom templates and scripts
|
||||
@@ -215,29 +218,29 @@ Restart your IDE to refresh the command list.
|
||||
# Upgrade CLI (auto-detects uv tool vs pipx install)
|
||||
specify self upgrade
|
||||
|
||||
# Update project files to get new commands
|
||||
specify init --here --force --integration copilot
|
||||
# Inspect installed integrations
|
||||
specify integration status
|
||||
|
||||
# Restore your constitution if customized
|
||||
git restore .specify/memory/constitution.md
|
||||
# Update project files to get new commands
|
||||
specify integration upgrade <key>
|
||||
specify extension update
|
||||
```
|
||||
|
||||
### Scenario 2: "I customized templates and constitution"
|
||||
|
||||
```bash
|
||||
# 1. Back up customizations
|
||||
cp .specify/memory/constitution.md /tmp/constitution-backup.md
|
||||
# 1. Commit or back up customizations
|
||||
git status
|
||||
cp -r .specify/templates /tmp/templates-backup
|
||||
|
||||
# 2. Upgrade CLI
|
||||
specify self upgrade
|
||||
|
||||
# 3. Update project
|
||||
specify init --here --force --integration copilot
|
||||
# 3. Use the manifest-aware project update first
|
||||
specify integration upgrade <key>
|
||||
specify extension update
|
||||
|
||||
# 4. Restore customizations
|
||||
mv /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||
# Manually merge template changes if needed
|
||||
# 4. If the upgrade reports modified managed files, inspect the diff before using --force
|
||||
```
|
||||
|
||||
### Scenario 3: "I see duplicate slash commands in my IDE"
|
||||
@@ -262,14 +265,14 @@ rm speckit.old-command-name.md
|
||||
The git extension is now opt-in, so upgrades do not install it unless you add it explicitly.
|
||||
|
||||
```bash
|
||||
# Manually back up files you customized
|
||||
cp .specify/memory/constitution.md .specify/memory/constitution.backup.md
|
||||
# Upgrade CLI
|
||||
specify self upgrade
|
||||
|
||||
# Run upgrade
|
||||
specify init --here --force --integration copilot
|
||||
# Refresh integration files and installed extensions
|
||||
specify integration upgrade <key>
|
||||
specify extension update
|
||||
|
||||
# Restore customizations
|
||||
mv .specify/memory/constitution.backup.md .specify/memory/constitution.md
|
||||
# The git extension is not added unless you run `specify extension add git`
|
||||
```
|
||||
|
||||
If you later decide you want the git extension's commands and hooks, install it explicitly:
|
||||
@@ -315,19 +318,21 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
|
||||
- Codex requires `CODEX_HOME` environment variable
|
||||
- Some agents need workspace restart or cache clearing
|
||||
|
||||
### "I lost my constitution customizations"
|
||||
### "Will init overwrite my constitution customizations?"
|
||||
|
||||
**Fix:** Restore from git or backup:
|
||||
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:
|
||||
|
||||
```bash
|
||||
# If you committed before upgrading
|
||||
# If you committed the customized constitution
|
||||
git restore .specify/memory/constitution.md
|
||||
|
||||
# If you backed up manually
|
||||
cp /tmp/constitution-backup.md .specify/memory/constitution.md
|
||||
```
|
||||
|
||||
**Prevention:** Always commit or back up `constitution.md` before upgrading.
|
||||
**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.
|
||||
|
||||
### "Warning: Current directory is not empty"
|
||||
|
||||
@@ -354,7 +359,7 @@ Only Spec Kit infrastructure files:
|
||||
- Agent command files (`.claude/commands/`, `.github/prompts/`, etc.)
|
||||
- Scripts in `.specify/scripts/`
|
||||
- Templates in `.specify/templates/`
|
||||
- Memory files in `.specify/memory/` (including constitution)
|
||||
- Missing memory files such as `.specify/memory/constitution.md` may be created from templates; an existing constitution is preserved
|
||||
|
||||
**What stays untouched:**
|
||||
|
||||
@@ -365,7 +370,7 @@ Only Spec Kit infrastructure files:
|
||||
|
||||
**How to respond:**
|
||||
|
||||
- **Type `y` and press Enter** - Proceed with the merge (recommended if upgrading)
|
||||
- **Type `y` and press Enter** - Proceed with the merge when using the fallback init path
|
||||
- **Type `n` and press Enter** - Cancel the operation
|
||||
- **Use `--force` flag** - Skip this confirmation entirely:
|
||||
|
||||
@@ -375,11 +380,11 @@ Only Spec Kit infrastructure files:
|
||||
|
||||
**When you see this warning:**
|
||||
|
||||
- ✅ **Expected** when upgrading an existing Spec Kit project
|
||||
- ✅ **Expected** when using the fallback init path in 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 upgrading, commit or back up your `.specify/memory/constitution.md` if you customized it.
|
||||
**Prevention tip:** Before using the fallback init path, commit your current work so any refreshed files are easy to review or restore.
|
||||
|
||||
### "CLI upgrade doesn't seem to work"
|
||||
|
||||
@@ -418,14 +423,15 @@ 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 when upgrading).
|
||||
**Short answer:** No, you only run `specify init` once per project, or later as a fallback recovery path.
|
||||
|
||||
**Explanation:**
|
||||
|
||||
The `specify` CLI tool is used for:
|
||||
|
||||
- **Initial setup:** `specify init` to bootstrap Spec Kit in your project
|
||||
- **Upgrades:** `specify init --here --force` to update templates and commands
|
||||
- **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
|
||||
- **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.
|
||||
|
||||
@@ -252,6 +252,7 @@ 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**:
|
||||
|
||||
@@ -267,6 +268,40 @@ 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:
|
||||
|
||||
@@ -2029,6 +2029,40 @@
|
||||
"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",
|
||||
|
||||
@@ -565,6 +565,12 @@ 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
|
||||
@@ -581,6 +587,6 @@ if ($Json) {
|
||||
Write-Output "BRANCH_NAME: $branchName"
|
||||
Write-Output "FEATURE_NUM: $featureNum"
|
||||
if (-not $DryRun) {
|
||||
Write-Output "SPECIFY_FEATURE environment variable set to: $branchName"
|
||||
Write-Output "# To persist in your shell: $featureAssignment"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-15T00:00:00Z",
|
||||
"updated_at": "2026-07-17T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
|
||||
"integrations": {
|
||||
"claude": {
|
||||
@@ -48,6 +48,15 @@
|
||||
"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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-17T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
|
||||
"presets": {
|
||||
"a11y-governance": {
|
||||
@@ -69,7 +69,7 @@
|
||||
"name": "AIDE In-Place Migration",
|
||||
"id": "aide-in-place",
|
||||
"version": "1.0.0",
|
||||
"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.",
|
||||
"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.",
|
||||
"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",
|
||||
@@ -134,13 +134,13 @@
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
"id": "autonomous-run-governance",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.2.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.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.2.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
@@ -155,10 +155,11 @@
|
||||
"governance",
|
||||
"evidence",
|
||||
"permissions",
|
||||
"resume"
|
||||
"resume",
|
||||
"intake-review"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-17T00:00:00Z"
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
@@ -363,6 +364,64 @@
|
||||
"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.1.0",
|
||||
"description": "Creates traceable Spec Kit intake files and receipts from ordered text sources while preserving clarification, update, and delivery-authority boundaries.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.1.0/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 7,
|
||||
"commands": 2,
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"intake",
|
||||
"authoring",
|
||||
"governance",
|
||||
"traceability",
|
||||
"clarification"
|
||||
],
|
||||
"created_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z"
|
||||
},
|
||||
"intake-review-governance": {
|
||||
"name": "Intake Review Governance",
|
||||
"id": "intake-review-governance",
|
||||
"version": "0.1.0",
|
||||
"description": "Adds hash-bound review, repair, and status gates for single, series, and campaign intake files before interactive, autonomous, or parallel Spec Kit execution.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.0/README.md",
|
||||
"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-21T00:00:00Z"
|
||||
},
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
"id": "isaqb-architecture-governance",
|
||||
@@ -480,6 +539,36 @@
|
||||
"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",
|
||||
@@ -509,7 +598,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 — 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 \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.",
|
||||
"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",
|
||||
@@ -624,7 +713,7 @@
|
||||
"name": "Spec2Cloud",
|
||||
"id": "spec2cloud",
|
||||
"version": "1.1.0",
|
||||
"description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy.",
|
||||
"description": "Spec-driven workflow tuned for shipping to Azure: spec \u2192 plan \u2192 tasks \u2192 implement \u2192 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",
|
||||
@@ -652,7 +741,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án Katona, PhD",
|
||||
"author": "Zolt\u00e1n 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",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.13.2.dev0"
|
||||
version = "0.13.4"
|
||||
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"
|
||||
|
||||
@@ -143,6 +143,13 @@ 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):
|
||||
@@ -161,13 +168,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 = parsed.hostname in ("localhost", "127.0.0.1", "::1")
|
||||
is_localhost = 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 parsed.hostname:
|
||||
if not hostname:
|
||||
raise BundlerError(f"Catalog url must be a valid URL with a host: {url}")
|
||||
|
||||
url = _canonicalize_url(url)
|
||||
|
||||
@@ -251,8 +251,22 @@ def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Sco
|
||||
return
|
||||
data = load_yaml(config_path)
|
||||
catalogs = data.get("catalogs") if isinstance(data, dict) else None
|
||||
if not catalogs:
|
||||
if catalogs is None:
|
||||
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
|
||||
|
||||
@@ -122,6 +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())
|
||||
|
||||
|
||||
@@ -700,6 +700,7 @@ 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
|
||||
@@ -776,6 +777,7 @@ 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}"
|
||||
|
||||
@@ -3608,6 +3608,7 @@ 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:
|
||||
@@ -3618,6 +3619,10 @@ 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)
|
||||
|
||||
|
||||
@@ -58,6 +58,7 @@ 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
|
||||
@@ -95,6 +96,7 @@ def _register_builtins() -> None:
|
||||
_register(CopilotIntegration())
|
||||
_register(CursorAgentIntegration())
|
||||
_register(DevinIntegration())
|
||||
_register(DroidIntegration())
|
||||
_register(FirebenderIntegration())
|
||||
_register(ForgeIntegration())
|
||||
_register(GeminiIntegration())
|
||||
|
||||
@@ -6,6 +6,7 @@ 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
|
||||
@@ -206,7 +207,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 '{token}'.")
|
||||
console.print(f"[red]Error:[/red] Unexpected integration option value '{escape(token)}'.")
|
||||
if allowed:
|
||||
console.print(f"Allowed options: {allowed}")
|
||||
raise typer.Exit(1)
|
||||
@@ -217,7 +218,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 '{token}'.")
|
||||
console.print(f"[red]Error:[/red] Unknown integration option '{escape(token)}'.")
|
||||
if allowed:
|
||||
console.print(f"Allowed options: {allowed}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -40,6 +40,25 @@ 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
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -153,7 +172,18 @@ 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:
|
||||
return json.loads(cache_file.read_text(encoding="utf-8"))
|
||||
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
|
||||
except (json.JSONDecodeError, ValueError, KeyError, TypeError, AttributeError, OSError, UnicodeError):
|
||||
# Cache is invalid or stale metadata; delete and refetch from source.
|
||||
try:
|
||||
@@ -172,20 +202,10 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
self._validate_catalog_url(final_url)
|
||||
catalog_data = json.loads(resp.read())
|
||||
|
||||
if not isinstance(catalog_data, dict):
|
||||
shape_error = _catalog_shape_error(catalog_data)
|
||||
if shape_error is not None:
|
||||
raise IntegrationCatalogError(
|
||||
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"
|
||||
f"Invalid catalog format from {entry.url}: {shape_error}"
|
||||
)
|
||||
|
||||
try:
|
||||
|
||||
@@ -77,6 +77,19 @@ 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)
|
||||
|
||||
135
src/specify_cli/integrations/droid/__init__.py
Normal file
135
src/specify_cli/integrations/droid/__init__.py
Normal file
@@ -0,0 +1,135 @@
|
||||
"""Factory Droid CLI integration — skills-based agent.
|
||||
|
||||
Droid discovers project skills from
|
||||
``.factory/skills/speckit-<name>/SKILL.md``. Spec Kit installs into that
|
||||
native tree so the generated skills are visible to Droid without extra
|
||||
configuration.
|
||||
|
||||
See: https://docs.factory.ai/cli/configuration/skills
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import SkillsIntegration
|
||||
|
||||
|
||||
class DroidIntegration(SkillsIntegration):
|
||||
"""Integration for Factory Droid CLI."""
|
||||
|
||||
key = "droid"
|
||||
config = {
|
||||
"name": "Factory Droid",
|
||||
"folder": ".factory/",
|
||||
"commands_subdir": "skills",
|
||||
"install_url": "https://docs.factory.ai/cli/getting-started/overview",
|
||||
"requires_cli": True,
|
||||
}
|
||||
registrar_config = {
|
||||
"dir": ".factory/skills",
|
||||
"format": "markdown",
|
||||
"args": "$ARGUMENTS",
|
||||
"extension": "/SKILL.md",
|
||||
}
|
||||
multi_install_safe = True
|
||||
|
||||
@staticmethod
|
||||
def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
|
||||
"""Insert ``key: value`` before the closing ``---`` if not already present.
|
||||
|
||||
Mirrors the helper used by ``ClaudeIntegration`` / ``VibeIntegration``
|
||||
so per-agent frontmatter injection stays consistent across skills-based
|
||||
integrations. Pre-scans for the key to keep injection idempotent.
|
||||
"""
|
||||
lines = content.splitlines(keepends=True)
|
||||
|
||||
# Pre-scan: bail out if already present in frontmatter
|
||||
dash_count = 0
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2:
|
||||
break
|
||||
continue
|
||||
if dash_count == 1 and stripped.startswith(f"{key}:"):
|
||||
return content
|
||||
|
||||
# Inject before the closing --- of frontmatter. Always emit a
|
||||
# newline after the injected key so the key and the closing ---
|
||||
# stay on separate lines even when the closing delimiter is the
|
||||
# last line of the file with no trailing newline.
|
||||
out: list[str] = []
|
||||
dash_count = 0
|
||||
injected = False
|
||||
for line in lines:
|
||||
stripped = line.rstrip("\n\r")
|
||||
if stripped == "---":
|
||||
dash_count += 1
|
||||
if dash_count == 2 and not injected:
|
||||
out.append(f"{key}: {value}\n")
|
||||
injected = True
|
||||
out.append(line)
|
||||
return "".join(out)
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Inject Droid-specific skill frontmatter flags.
|
||||
|
||||
Applies the shared hook-command normalization note (skills agents use
|
||||
hyphenated ``/speckit-<name>`` invocations, not dotted ``/speckit.<name>``)
|
||||
and the Droid-specific ``user-invocable`` / ``disable-model-invocation``
|
||||
frontmatter flags so skills are both user- and Droid-invocable.
|
||||
"""
|
||||
updated = super().post_process_skill_content(content)
|
||||
updated = self._inject_frontmatter_flag(updated, "user-invocable")
|
||||
updated = self._inject_frontmatter_flag(updated, "disable-model-invocation", "false")
|
||||
return updated
|
||||
|
||||
def build_exec_args(
|
||||
self,
|
||||
prompt: str,
|
||||
*,
|
||||
model: str | None = None,
|
||||
output_json: bool = True,
|
||||
) -> list[str] | None:
|
||||
"""Build CLI arguments for non-interactive ``droid`` execution.
|
||||
|
||||
Uses ``droid exec "<prompt>"`` for headless dispatch. Spec Kit does
|
||||
not auto-apply any permission-bypass flag: operators who want to
|
||||
skip interactive confirmation can pass it through
|
||||
``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS`` (e.g.
|
||||
``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS="--skip-permissions-unsafe"``).
|
||||
|
||||
Output format and model selection mirror the documented CLI flags:
|
||||
``--output-format json`` (when ``output_json`` is set) and
|
||||
``--model <id>``. Operator-supplied extra args via
|
||||
``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS`` are appended after the
|
||||
canonical Spec Kit flags so the canonical flags are guaranteed to
|
||||
be present in argv. Note that with duplicate-flag CLI parsing the
|
||||
later (operator-supplied) value may take precedence over the
|
||||
canonical one, so operators can still override ``--model`` or
|
||||
``--output-format``.
|
||||
"""
|
||||
if not self.config or not self.config.get("requires_cli"):
|
||||
return None
|
||||
args = [
|
||||
self._resolve_executable(),
|
||||
"exec",
|
||||
prompt,
|
||||
]
|
||||
# Operator-injected extra args are appended after Spec Kit's
|
||||
# canonical --model / --output-format flags so the canonical
|
||||
# flags are guaranteed to be present in argv regardless of
|
||||
# whatever the operator passes via SPECKIT_INTEGRATION_DROID_EXTRA_ARGS.
|
||||
# This is a deliberate inversion of the cursor-agent / opencode /
|
||||
# codex ordering (which all apply extra args first, then append
|
||||
# canonical flags so the canonical values win under duplicate-flag
|
||||
# parsing). For Droid the canonical flag values are written into
|
||||
# argv first, then the operator-supplied values follow; with
|
||||
# duplicate-flag parsing the later (operator) value may therefore
|
||||
# take precedence.
|
||||
if model:
|
||||
args.extend(["--model", model])
|
||||
if output_json:
|
||||
args.extend(["--output-format", "json"])
|
||||
self._apply_extra_args_env_var(args)
|
||||
return args
|
||||
@@ -1555,7 +1555,7 @@ def workflow_add(
|
||||
# precedence over --from so a URL that would be ignored is never fetched.
|
||||
if dev:
|
||||
dev_path = Path(source).expanduser()
|
||||
if dev_path.is_file() and dev_path.suffix in (".yml", ".yaml"):
|
||||
if dev_path.is_file() and dev_path.suffix.lower() in (".yml", ".yaml"):
|
||||
_validate_and_install_local(dev_path, str(dev_path))
|
||||
return
|
||||
if dev_path.is_dir():
|
||||
@@ -1714,7 +1714,7 @@ def workflow_add(
|
||||
# Try as a local file/directory
|
||||
source_path = Path(source)
|
||||
if source_path.exists():
|
||||
if source_path.is_file() and source_path.suffix in (".yml", ".yaml"):
|
||||
if source_path.is_file() and source_path.suffix.lower() in (".yml", ".yaml"):
|
||||
_validate_and_install_local(source_path, str(source_path))
|
||||
return
|
||||
elif source_path.is_dir():
|
||||
|
||||
@@ -523,8 +523,20 @@ class WorkflowCatalog:
|
||||
|
||||
_validate_catalog_url(entry.url)
|
||||
|
||||
# Validate EVERY redirect hop, not just the final URL: _open_url follows
|
||||
# redirects, so an https:// entry that 30x-redirects through http:// (or
|
||||
# to a non-HTTPS host mid-chain) could otherwise let a network attacker
|
||||
# rewrite the next hop and slip a payload past a final-URL-only check.
|
||||
# redirect_validator runs before each hop; the geturl() check below is
|
||||
# retained as a defense-in-depth backstop. Mirrors the presets/extensions
|
||||
# catalog fix (#3523 / #3524).
|
||||
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
||||
_validate_catalog_url(new_url)
|
||||
|
||||
try:
|
||||
with _open_url(entry.url, timeout=30) as resp:
|
||||
with _open_url(
|
||||
entry.url, timeout=30, redirect_validator=_validate_redirect
|
||||
) as resp:
|
||||
_validate_catalog_url(resp.geturl())
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
@@ -882,7 +894,11 @@ class StepRegistry:
|
||||
import copy
|
||||
from datetime import datetime, timezone
|
||||
|
||||
existing = self.data["steps"].get(step_id, {})
|
||||
raw_existing = self.data["steps"].get(step_id)
|
||||
# Corrupted-but-parseable registries may hold non-dict entries; treat
|
||||
# them as absent rather than crashing on existing.get() (mirrors
|
||||
# WorkflowRegistry.add).
|
||||
existing = raw_existing if isinstance(raw_existing, dict) else {}
|
||||
metadata_to_store = copy.deepcopy(metadata)
|
||||
metadata_to_store["installed_at"] = existing.get(
|
||||
"installed_at", datetime.now(timezone.utc).isoformat()
|
||||
@@ -1180,8 +1196,20 @@ class StepCatalog:
|
||||
|
||||
_validate_url(entry.url)
|
||||
|
||||
# Validate EVERY redirect hop, not just the final URL: _open_url follows
|
||||
# redirects, so an https:// entry that 30x-redirects through http:// (or
|
||||
# to a non-HTTPS host mid-chain) could otherwise let a network attacker
|
||||
# rewrite the next hop and slip a payload past a final-URL-only check.
|
||||
# redirect_validator runs before each hop; the geturl() check below is
|
||||
# retained as a defense-in-depth backstop. Mirrors the presets/extensions
|
||||
# catalog fix (#3523 / #3524).
|
||||
def _validate_redirect(_old_url: str, new_url: str) -> None:
|
||||
_validate_url(new_url)
|
||||
|
||||
try:
|
||||
with _open_url(entry.url, timeout=30) as resp:
|
||||
with _open_url(
|
||||
entry.url, timeout=30, redirect_validator=_validate_redirect
|
||||
) as resp:
|
||||
_validate_url(resp.geturl())
|
||||
data = json.loads(resp.read().decode("utf-8"))
|
||||
except Exception as exc:
|
||||
|
||||
@@ -201,6 +201,20 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
f"Must be 'string', 'number', or 'boolean'."
|
||||
)
|
||||
|
||||
# ``enum`` must be a list. Checked here — not only via the
|
||||
# ``_coerce_input`` call below — because that call is reached only
|
||||
# when a ``default`` is present, and the ``integration: auto`` case
|
||||
# strips ``enum`` before coercing; a scalar/string ``enum`` on an
|
||||
# input with no default (or the auto-integration default) would
|
||||
# otherwise slip through here and then crash ``_resolve_inputs`` with
|
||||
# a raw ``TypeError`` at run time. ``None`` means "no enum".
|
||||
enum_values = input_def.get("enum")
|
||||
if enum_values is not None and not isinstance(enum_values, list):
|
||||
errors.append(
|
||||
f"Input {input_name!r} has invalid 'enum': must be a list, "
|
||||
f"got {type(enum_values).__name__}."
|
||||
)
|
||||
|
||||
# Validate the default eagerly so authoring mistakes (e.g. a
|
||||
# default not in the declared enum, or a non-numeric default for
|
||||
# a number input) surface at install/validation time instead of
|
||||
@@ -209,13 +223,28 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
# enum-membership check is exempted for that exact case — the
|
||||
# declared type is still enforced (e.g. ``type: number`` paired
|
||||
# with ``default: "auto"`` is still rejected).
|
||||
enum_is_valid = enum_values is None or isinstance(enum_values, list)
|
||||
if "default" in input_def:
|
||||
default_value = input_def["default"]
|
||||
is_auto_integration = (
|
||||
input_name == "integration" and default_value == "auto"
|
||||
)
|
||||
# Strip ``enum`` from the definition handed to ``_coerce_input``
|
||||
# when either:
|
||||
# * this is the auto-integration sentinel (enum-membership is
|
||||
# a runtime concern, exempted for ``"auto"``), or
|
||||
# * the ``enum`` is malformed (non-list) and already reported
|
||||
# above — leaving it in would make ``_coerce_input`` re-raise
|
||||
# the same enum-shape error re-framed as an "invalid default"
|
||||
# (a confusing duplicate).
|
||||
# Removing *only* ``enum`` (rather than skipping the check
|
||||
# entirely) preserves the default's type validation: a
|
||||
# ``type: string`` input with ``default: 5, enum: 5`` still
|
||||
# reports the wrong-typed default alongside the enum error,
|
||||
# instead of hiding it.
|
||||
strip_enum = is_auto_integration or not enum_is_valid
|
||||
validation_input_def: dict[str, Any] = input_def
|
||||
if is_auto_integration and "enum" in input_def:
|
||||
if strip_enum and "enum" in input_def:
|
||||
validation_input_def = {
|
||||
key: value
|
||||
for key, value in input_def.items()
|
||||
@@ -1400,11 +1429,18 @@ class WorkflowEngine:
|
||||
# definition (``string`` rejects non-strings, ``number`` rejects
|
||||
# bools and uncoercible values, ``boolean`` rejects non-bools),
|
||||
# so ill-typed values still fail fast here.
|
||||
#
|
||||
# ``execute()`` accepts unvalidated definitions, so a malformed
|
||||
# (non-list) ``enum`` can reach here. Only strip a *list* ``enum``:
|
||||
# a scalar/string ``enum`` must stay in the definition so
|
||||
# ``_coerce_input`` raises the clean shape ``ValueError`` instead of
|
||||
# being silently exempted by the ``auto`` membership skip (which
|
||||
# would otherwise let ``enum: 5`` resolve successfully).
|
||||
coerce_input_def = input_def
|
||||
if (
|
||||
name == "integration"
|
||||
and value == "auto"
|
||||
and "enum" in input_def
|
||||
and isinstance(input_def.get("enum"), list)
|
||||
):
|
||||
coerce_input_def = {
|
||||
key: val
|
||||
@@ -1450,6 +1486,22 @@ class WorkflowEngine:
|
||||
input_type = input_def.get("type", "string")
|
||||
enum_values = input_def.get("enum")
|
||||
|
||||
# ``enum`` must be a list. A scalar (``enum: 5``, ``enum: true``) makes
|
||||
# the ``value not in enum_values`` membership test below raise a raw
|
||||
# ``TypeError`` ("argument of type 'int' is not ... iterable"), which
|
||||
# escapes ``validate_workflow``'s ``except ValueError`` and breaks its
|
||||
# "return errors, never raise" contract — and crashes ``_resolve_inputs``
|
||||
# outright at run time. A bare string is just as wrong: ``value in "abc"``
|
||||
# is a silent substring/character test, not enum membership. Require a
|
||||
# list so both forms fail fast with a clear message. ``None`` means "no
|
||||
# enum" and is left alone.
|
||||
if enum_values is not None and not isinstance(enum_values, list):
|
||||
msg = (
|
||||
f"Input {name!r} has invalid 'enum': must be a list, got "
|
||||
f"{type(enum_values).__name__}."
|
||||
)
|
||||
raise ValueError(msg)
|
||||
|
||||
if input_type == "number":
|
||||
# Reject bools explicitly: ``bool`` is a subclass of ``int`` so
|
||||
# ``float(True)`` succeeds and would silently coerce a YAML
|
||||
|
||||
@@ -535,6 +535,10 @@ def _evaluate_simple_expression(expr: str, namespace: dict[str, Any]) -> Any:
|
||||
items = [
|
||||
_evaluate_simple_expression(i.strip(), namespace)
|
||||
for i in _split_top_level_commas(inner)
|
||||
# Drop empty segments from trailing/leading/double commas ([1, 2,] ->
|
||||
# [1, 2], not [1, 2, None]). An intentional empty-string element
|
||||
# ('') strips to "''" (truthy), so ['', 'a'] is preserved.
|
||||
if i.strip()
|
||||
]
|
||||
return items
|
||||
|
||||
|
||||
@@ -66,16 +66,52 @@ class CommandStep(StepBase):
|
||||
for key, value in input_data.items():
|
||||
resolved_input[key] = evaluate_expression(value, context)
|
||||
|
||||
# Resolve integration (step → workflow default → project default)
|
||||
integration = config.get("integration") or context.default_integration
|
||||
# Resolve integration (step → workflow default → project default).
|
||||
# Fall back to the workflow default ONLY for a genuinely-unset value
|
||||
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
|
||||
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
|
||||
# it to the default before the guard below runs — so on an unvalidated
|
||||
# execute() such a step would silently dispatch with the configured
|
||||
# default instead of failing. Fall through instead, so every non-string
|
||||
# reaches the type guard.
|
||||
integration = config.get("integration")
|
||||
if integration is None or integration == "":
|
||||
integration = context.default_integration
|
||||
if integration and isinstance(integration, str) and "{{" in integration:
|
||||
integration = evaluate_expression(integration, context)
|
||||
|
||||
# Resolve model
|
||||
model = config.get("model") or context.default_model
|
||||
# Resolve model (same fallback rationale as 'integration' above).
|
||||
model = config.get("model")
|
||||
if model is None or model == "":
|
||||
model = context.default_model
|
||||
if model and isinstance(model, str) and "{{" in model:
|
||||
model = evaluate_expression(model, context)
|
||||
|
||||
# A non-string integration/model — a literal list/dict/number that
|
||||
# skipped validation, an unvalidated workflow-level default, or an
|
||||
# expression that resolved to one — crashes downstream: get_integration()
|
||||
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
|
||||
# even on a *validated* run) and build_exec_args() feeds model into the
|
||||
# CLI argv. Fail the step with the contract error rather than taking down
|
||||
# the whole run, mirroring the 'input'/'options' guards above. ``None``
|
||||
# stays valid — it means "unset" and falls back to dispatch-not-possible.
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'integration' must "
|
||||
f"be a string, got {type(integration).__name__}."
|
||||
),
|
||||
)
|
||||
if model is not None and not isinstance(model, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Command step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Merge options (workflow defaults ← step overrides)
|
||||
options = dict(context.default_options)
|
||||
step_options = config.get("options", {})
|
||||
@@ -153,7 +189,11 @@ class CommandStep(StepBase):
|
||||
not possible (integration not found, CLI not installed, or
|
||||
dispatch not supported).
|
||||
"""
|
||||
if not integration_key:
|
||||
if not integration_key or not isinstance(integration_key, str):
|
||||
# A non-string integration (a list/dict/expression that resolved to
|
||||
# one) would raise TypeError: unhashable type from get_integration's
|
||||
# dict lookup below and abort the whole run. Treat it as "not
|
||||
# dispatchable" so execute() falls through to its FAILED StepResult.
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -217,4 +257,23 @@ class CommandStep(StepBase):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'options' must be a mapping."
|
||||
)
|
||||
# execute() passes 'integration' to get_integration(), which uses it as a
|
||||
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
|
||||
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
|
||||
# literal non-string here, mirroring the sibling type checks. ``None``
|
||||
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
|
||||
# workflow default" and stays valid; an expression like "{{ ... }}" is
|
||||
# still a str, so it stays valid too.
|
||||
integration = config.get("integration")
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'integration' must be a "
|
||||
f"string, got {type(integration).__name__}."
|
||||
)
|
||||
model = config.get("model")
|
||||
if model is not None and not isinstance(model, str):
|
||||
errors.append(
|
||||
f"Command step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -12,9 +12,10 @@ class FanOutStep(StepBase):
|
||||
"""Dispatch a step template for each item in a collection.
|
||||
|
||||
The engine executes the nested ``step:`` template once per item,
|
||||
setting ``context.item`` for each iteration. Execution is
|
||||
currently sequential; ``max_concurrency`` is accepted but not
|
||||
enforced.
|
||||
setting ``context.item`` for each iteration. ``max_concurrency``
|
||||
controls parallelism: ``<= 1`` (the default) runs items
|
||||
sequentially, while ``> 1`` runs up to that many items concurrently
|
||||
on a bounded thread pool (see ``WorkflowEngine._run_fan_out``).
|
||||
"""
|
||||
|
||||
type_key = "fan-out"
|
||||
|
||||
@@ -168,7 +168,11 @@ class GateStep(StepBase):
|
||||
except (EOFError, KeyboardInterrupt):
|
||||
print()
|
||||
return options[-1] # default to last (usually reject)
|
||||
if raw.isdigit() and 1 <= int(raw) <= len(options):
|
||||
# isdecimal() (not isdigit()): int() accepts exactly the decimal-digit
|
||||
# set, whereas isdigit() also returns True for superscripts/subscripts
|
||||
# (e.g. "²") that int() then rejects with ValueError — crashing
|
||||
# this interactive loop.
|
||||
if raw.isdecimal() and 1 <= int(raw) <= len(options):
|
||||
return options[int(raw) - 1]
|
||||
# Also accept the option name directly
|
||||
if raw.lower() in [o.lower() for o in options]:
|
||||
|
||||
@@ -42,16 +42,52 @@ class PromptStep(StepBase):
|
||||
if not isinstance(prompt, str):
|
||||
prompt = str(prompt)
|
||||
|
||||
# Resolve integration (step → workflow default)
|
||||
integration = config.get("integration") or context.default_integration
|
||||
# Resolve integration (step → workflow default).
|
||||
# Fall back to the workflow default ONLY for a genuinely-unset value
|
||||
# (missing / YAML-null / empty string). A ``config.get(...) or ...``
|
||||
# would also swallow a falsey *non-string* ([], {}, 0, False), coercing
|
||||
# it to the default before the guard below runs — so on an unvalidated
|
||||
# execute() such a step would silently dispatch with the configured
|
||||
# default instead of failing. Fall through instead, so every non-string
|
||||
# reaches the type guard.
|
||||
integration = config.get("integration")
|
||||
if integration is None or integration == "":
|
||||
integration = context.default_integration
|
||||
if integration and isinstance(integration, str) and "{{" in integration:
|
||||
integration = evaluate_expression(integration, context)
|
||||
|
||||
# Resolve model
|
||||
model = config.get("model") or context.default_model
|
||||
# Resolve model (same fallback rationale as 'integration' above).
|
||||
model = config.get("model")
|
||||
if model is None or model == "":
|
||||
model = context.default_model
|
||||
if model and isinstance(model, str) and "{{" in model:
|
||||
model = evaluate_expression(model, context)
|
||||
|
||||
# A non-string integration/model — a literal list/dict/number that
|
||||
# skipped validation, an unvalidated workflow-level default, or an
|
||||
# expression that resolved to one — crashes downstream: get_integration()
|
||||
# uses the value as a dict key (raw TypeError on an unhashable list/dict,
|
||||
# even on a *validated* run) and build_exec_args() feeds model into the
|
||||
# CLI argv. Fail the step with the contract error rather than taking down
|
||||
# the whole run. ``None`` stays valid — it means "unset" and falls back
|
||||
# to dispatch-not-possible.
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'integration' must "
|
||||
f"be a string, got {type(integration).__name__}."
|
||||
),
|
||||
)
|
||||
if model is not None and not isinstance(model, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
),
|
||||
)
|
||||
|
||||
# Attempt CLI dispatch
|
||||
dispatch_result = self._try_dispatch(
|
||||
prompt, integration, model, context
|
||||
@@ -102,7 +138,10 @@ class PromptStep(StepBase):
|
||||
context: StepContext,
|
||||
) -> dict[str, Any] | None:
|
||||
"""Dispatch *prompt* directly through the integration CLI."""
|
||||
if not integration_key or not prompt:
|
||||
if not integration_key or not isinstance(integration_key, str) or not prompt:
|
||||
# A non-string integration would raise TypeError: unhashable type
|
||||
# from get_integration's dict lookup and abort the run; treat it as
|
||||
# not dispatchable so execute() falls through to its FAILED result.
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -172,4 +211,23 @@ class PromptStep(StepBase):
|
||||
f"Prompt step {config.get('id', '?')!r}: 'prompt' must be a "
|
||||
f"string, got {type(config['prompt']).__name__}."
|
||||
)
|
||||
# execute() passes 'integration' to get_integration(), which uses it as a
|
||||
# dict key — a non-string (list/dict) raises a raw TypeError (unhashable),
|
||||
# even on a validated run — and feeds 'model' into the CLI argv. Reject a
|
||||
# literal non-string here, mirroring the 'prompt' check above. ``None``
|
||||
# (an explicit ``integration:``/``model:`` YAML null) means "inherit the
|
||||
# workflow default" and stays valid; an expression like "{{ ... }}" is
|
||||
# still a str, so it stays valid too.
|
||||
integration = config.get("integration")
|
||||
if integration is not None and not isinstance(integration, str):
|
||||
errors.append(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'integration' must be a "
|
||||
f"string, got {type(integration).__name__}."
|
||||
)
|
||||
model = config.get("model")
|
||||
if model is not None and not isinstance(model, str):
|
||||
errors.append(
|
||||
f"Prompt step {config.get('id', '?')!r}: 'model' must be a "
|
||||
f"string, got {type(model).__name__}."
|
||||
)
|
||||
return errors
|
||||
|
||||
@@ -43,6 +43,42 @@ def test_builtin_default_stack_when_no_config(tmp_path: Path):
|
||||
assert all(s.scope is Scope.BUILTIN for s in sources)
|
||||
|
||||
|
||||
def test_non_list_catalogs_raises_actionable_error(tmp_path: Path):
|
||||
"""A scalar ``catalogs:`` value raises a clean BundlerError, not a raw
|
||||
'int object is not iterable' TypeError — matching what the sibling reader
|
||||
(bundle catalog list) already reports for the same file."""
|
||||
make_project(tmp_path)
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
"catalogs: 5\n", encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(BundlerError, match="must be a list"):
|
||||
load_source_stack(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("value", ["false", "0", "''", "{}"])
|
||||
def test_falsy_non_list_catalogs_still_raises(tmp_path: Path, value: str):
|
||||
"""A *falsy* non-list ``catalogs:`` value (false/0/''/{}) must also raise —
|
||||
only an absent/``None`` value means "nothing to merge". A plain falsy check
|
||||
would silently swallow these, diverging from the sibling reader."""
|
||||
make_project(tmp_path)
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
|
||||
f"catalogs: {value}\n", encoding="utf-8"
|
||||
)
|
||||
with pytest.raises(BundlerError, match="must be a list"):
|
||||
load_source_stack(tmp_path)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("body", ["catalogs:\n", "catalogs: []\n"])
|
||||
def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str):
|
||||
"""An absent (``None``) or empty-list ``catalogs:`` is valid: it contributes
|
||||
no project sources and falls back to the built-in default stack."""
|
||||
make_project(tmp_path)
|
||||
(tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
|
||||
# Does not raise; still yields the built-in defaults.
|
||||
sources = load_source_stack(tmp_path)
|
||||
assert len(sources) > 0
|
||||
|
||||
|
||||
def test_project_config_overrides_same_id(tmp_path: Path):
|
||||
make_project(tmp_path)
|
||||
config = {
|
||||
|
||||
@@ -124,3 +124,13 @@ def test_string_mcp_rejected_not_split_per_character():
|
||||
data["requires"]["mcp"] = "github"
|
||||
with pytest.raises(BundlerError, match="'requires.mcp' must be a list of strings"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
|
||||
def test_string_integration_rejected_not_silently_dropped():
|
||||
# A present-but-non-mapping 'integration' (a bare string) was silently
|
||||
# dropped, leaving the bundle wrongly integration-agnostic. Reject it like
|
||||
# the sibling requires/provides mapping fields.
|
||||
data = valid_manifest_dict()
|
||||
data["integration"] = "copilot"
|
||||
with pytest.raises(BundlerError, match="'integration' must be a mapping when present"):
|
||||
BundleManifest.from_dict(data)
|
||||
|
||||
@@ -698,6 +698,22 @@ class TestCreateFeaturePowerShell:
|
||||
assert rt.returncode == 0, rt.stderr
|
||||
assert "HAS_GIT" not in rt.stdout
|
||||
|
||||
def test_persist_hint_matches_twins(self, tmp_path: Path):
|
||||
"""The non-JSON SPECIFY_FEATURE hint must use the '# To persist in your
|
||||
shell: $env:SPECIFY_FEATURE = '<name>' form — matching the core
|
||||
create-new-feature.ps1 twin and the bash/python twins of this script —
|
||||
not the old 'environment variable set to:' wording (the env var is only
|
||||
set in this child process, so the actionable output is the persist hint)."""
|
||||
project = _setup_project(tmp_path)
|
||||
result = _run_pwsh(
|
||||
"create-new-feature-branch.ps1", project,
|
||||
"-ShortName", "persist", "Persist hint feature",
|
||||
)
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert "# To persist in your shell:" in result.stdout
|
||||
assert "$env:SPECIFY_FEATURE = '001-persist'" in result.stdout
|
||||
assert "environment variable set to:" not in result.stdout
|
||||
|
||||
def test_help_documents_branch_prefix(self, tmp_path: Path):
|
||||
"""-Help documents both template config knobs."""
|
||||
project = _setup_project(tmp_path)
|
||||
|
||||
@@ -236,6 +236,24 @@ class TestBuildCommandInvocation:
|
||||
== "/speckit-git-commit fix typo"
|
||||
)
|
||||
|
||||
def test_cline_core_command_hyphenated(self):
|
||||
"""Cline installs hyphenated slash-commands (/speckit-<name>), so the
|
||||
dispatch invocation must be hyphenated too — not the dotted default it
|
||||
would inherit from MarkdownIntegration."""
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("cline")
|
||||
assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
|
||||
assert i.build_command_invocation("plan") == "/speckit-plan"
|
||||
|
||||
def test_cline_extension_command_hyphenated(self):
|
||||
from specify_cli.integrations import get_integration
|
||||
i = get_integration("cline")
|
||||
assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
|
||||
assert (
|
||||
i.build_command_invocation("speckit.git.commit", "fix typo")
|
||||
== "/speckit-git-commit fix typo"
|
||||
)
|
||||
|
||||
|
||||
class TestResolveCommandRefs:
|
||||
"""Tests for __SPECKIT_COMMAND_<NAME>__ placeholder resolution."""
|
||||
|
||||
@@ -13,9 +13,34 @@ from specify_cli.integrations.catalog import (
|
||||
IntegrationDescriptor,
|
||||
IntegrationDescriptorError,
|
||||
IntegrationValidationError,
|
||||
_catalog_shape_error,
|
||||
)
|
||||
|
||||
|
||||
class TestCatalogShapeValidator:
|
||||
"""The shared shape validator used by BOTH the fresh-fetch and cache-read
|
||||
paths, so a poisoned/older cache can't bypass the format contract the fresh
|
||||
fetch enforces (dict + 'schema_version' + dict 'integrations')."""
|
||||
|
||||
def test_valid_payload_returns_none(self):
|
||||
assert _catalog_shape_error({"schema_version": "1.0", "integrations": {}}) is None
|
||||
|
||||
def test_missing_schema_version_is_rejected(self):
|
||||
# The exact bypass the two paths used to disagree on: a dict with a dict
|
||||
# 'integrations' but no 'schema_version'.
|
||||
assert _catalog_shape_error({"integrations": {}}) is not None
|
||||
|
||||
def test_missing_integrations_is_rejected(self):
|
||||
assert _catalog_shape_error({"schema_version": "1.0"}) is not None
|
||||
|
||||
def test_non_dict_integrations_is_rejected(self):
|
||||
assert _catalog_shape_error({"schema_version": "1.0", "integrations": []}) is not None
|
||||
|
||||
@pytest.mark.parametrize("payload", [[], "x", 5, None])
|
||||
def test_non_dict_payload_is_rejected(self, payload):
|
||||
assert _catalog_shape_error(payload) is not None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# IntegrationCatalogEntry
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -251,6 +276,48 @@ class TestCatalogFetch:
|
||||
ids = [r["id"] for r in results]
|
||||
assert "acme-coder" in ids
|
||||
|
||||
def test_poisoned_cache_shape_is_dropped_and_refetched(self, tmp_path, monkeypatch):
|
||||
"""A fresh-but-mis-shaped cache (e.g. integrations as a list) must be
|
||||
dropped and refetched, not returned — otherwise it later crashes on
|
||||
.items(). The cache path must clear the same shape checks as a fresh
|
||||
fetch."""
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
catalog = {
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-01-01T00:00:00Z",
|
||||
"integrations": {
|
||||
"acme-coder": {
|
||||
"id": "acme-coder", "name": "Acme Coder", "version": "2.0.0",
|
||||
"description": "Community integration", "author": "acme-org",
|
||||
"tags": ["cli"],
|
||||
},
|
||||
},
|
||||
}
|
||||
self._patch_urlopen(monkeypatch, catalog)
|
||||
cat.search() # populate the cache legitimately
|
||||
|
||||
# Poison the cached payload (integrations as a list), keeping the fresh
|
||||
# metadata so the age check passes and the cache branch is taken.
|
||||
cache_dir = tmp_path / ".specify" / "integrations" / ".cache"
|
||||
data_files = [
|
||||
f for f in cache_dir.glob("catalog-*.json")
|
||||
if not f.name.endswith("-metadata.json")
|
||||
]
|
||||
assert data_files, "cache was not populated"
|
||||
data_files[0].write_text(
|
||||
json.dumps({"schema_version": "1.0", "integrations": []}),
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
# The poisoned cache is dropped and the (valid) source is refetched.
|
||||
results = cat.search()
|
||||
assert "acme-coder" in [r["id"] for r in results]
|
||||
|
||||
def test_search_by_tag(self, tmp_path, monkeypatch):
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
|
||||
262
tests/integrations/test_integration_droid.py
Normal file
262
tests/integrations/test_integration_droid.py
Normal file
@@ -0,0 +1,262 @@
|
||||
"""Tests for DroidIntegration (Factory Droid CLI)."""
|
||||
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.integrations import get_integration
|
||||
from specify_cli.integrations.droid import DroidIntegration
|
||||
from specify_cli.integrations.manifest import IntegrationManifest
|
||||
|
||||
from .test_integration_base_skills import SkillsIntegrationTests
|
||||
|
||||
|
||||
class TestDroidIntegration(SkillsIntegrationTests):
|
||||
KEY = "droid"
|
||||
FOLDER = ".factory/"
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".factory/skills"
|
||||
|
||||
def test_options_include_skills_flag(self):
|
||||
"""Not applicable — Droid only supports the skills layout."""
|
||||
pytest.skip("Droid is always skills-based and does not expose a --skills option")
|
||||
|
||||
def test_options_do_not_include_skills_flag(self):
|
||||
"""Droid is always skills-based; no --skills option is exposed."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i is not None
|
||||
opts = i.options()
|
||||
skills_opts = [o for o in opts if o.name == "--skills"]
|
||||
assert len(skills_opts) == 0, (
|
||||
"Droid is always skills-based and should not expose a --skills option"
|
||||
)
|
||||
|
||||
def test_requires_cli_is_true(self):
|
||||
"""Droid is a CLI tool; requires_cli must be True."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i is not None
|
||||
assert i.config["requires_cli"] is True
|
||||
assert i.config["name"] == "Factory Droid"
|
||||
|
||||
def test_multi_install_safe_is_true(self):
|
||||
"""Droid uses an isolated .factory/ root — safe to install alongside others."""
|
||||
i = get_integration(self.KEY)
|
||||
assert i.multi_install_safe is True
|
||||
|
||||
def test_install_url_points_to_factory(self):
|
||||
i = get_integration(self.KEY)
|
||||
url = i.config.get("install_url")
|
||||
assert url is not None
|
||||
host = (urlparse(url).hostname or "").lower()
|
||||
assert host == "factory.ai" or host.endswith(".factory.ai"), (
|
||||
f"install_url must point at the Factory domain, got: {url}"
|
||||
)
|
||||
|
||||
|
||||
class TestDroidInitFlow:
|
||||
"""--integration droid creates expected files."""
|
||||
|
||||
def test_integration_droid_creates_skills(self, tmp_path):
|
||||
"""--integration droid should create skills under .factory/skills."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
runner = CliRunner()
|
||||
target = tmp_path / "test-proj"
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"init",
|
||||
str(target),
|
||||
"--integration",
|
||||
"droid",
|
||||
"--ignore-agent-tools",
|
||||
"--script",
|
||||
"sh",
|
||||
],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init --integration droid failed: {result.output}"
|
||||
assert (target / ".factory" / "skills" / "speckit-plan" / "SKILL.md").exists()
|
||||
assert (target / ".factory" / "skills" / "speckit-specify" / "SKILL.md").exists()
|
||||
|
||||
|
||||
class TestDroidBuildExecArgs:
|
||||
"""Droid non-interactive execution argument building."""
|
||||
|
||||
def test_default_argv_uses_exec_subcommand(self):
|
||||
"""Default argv: ``droid exec <prompt> --output-format json``.
|
||||
|
||||
No permission-bypass flag is auto-applied — operators who need it
|
||||
must pass it through ``SPECKIT_INTEGRATION_DROID_EXTRA_ARGS``.
|
||||
"""
|
||||
i = get_integration("droid")
|
||||
args = i.build_exec_args("/speckit-specify some-feature")
|
||||
assert args == [
|
||||
"droid",
|
||||
"exec",
|
||||
"/speckit-specify some-feature",
|
||||
"--output-format",
|
||||
"json",
|
||||
]
|
||||
assert "--skip-permissions-unsafe" not in args, (
|
||||
"Spec Kit must not auto-apply --skip-permissions-unsafe; "
|
||||
"it is a dangerous flag and operators must opt in explicitly"
|
||||
)
|
||||
|
||||
def test_text_output_omits_format_flag(self):
|
||||
i = get_integration("droid")
|
||||
args = i.build_exec_args("/speckit-plan", output_json=False)
|
||||
assert args == [
|
||||
"droid",
|
||||
"exec",
|
||||
"/speckit-plan",
|
||||
]
|
||||
assert "--skip-permissions-unsafe" not in args
|
||||
|
||||
def test_model_is_appended(self):
|
||||
i = get_integration("droid")
|
||||
args = i.build_exec_args(
|
||||
"/speckit-specify", model="claude-opus-4-7", output_json=False
|
||||
)
|
||||
assert args == [
|
||||
"droid",
|
||||
"exec",
|
||||
"/speckit-specify",
|
||||
"--model",
|
||||
"claude-opus-4-7",
|
||||
]
|
||||
assert "--skip-permissions-unsafe" not in args
|
||||
|
||||
def test_extra_args_inserted_after_canonical_flags(self, monkeypatch):
|
||||
"""Operator-injected extra args land after Spec Kit's canonical
|
||||
``--model`` / ``--output-format`` flags so the canonical flags are
|
||||
always present in argv regardless of operator override."""
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
i = get_integration("droid")
|
||||
monkeypatch.setenv("SPECKIT_INTEGRATION_DROID_EXTRA_ARGS", "--foo bar")
|
||||
args = i.build_exec_args(
|
||||
"/speckit-plan", model="claude-sonnet", output_json=True
|
||||
)
|
||||
|
||||
assert "--foo" in args
|
||||
assert "bar" in args
|
||||
assert args.index("bar") == args.index("--foo") + 1
|
||||
# Extra args land AFTER the canonical flags so the canonical flags
|
||||
# are always present in argv.
|
||||
assert args.index("--model") < args.index("--foo")
|
||||
assert args.index("--output-format") < args.index("--foo")
|
||||
assert args[args.index("--model") + 1] == "claude-sonnet"
|
||||
assert args[args.index("--output-format") + 1] == "json"
|
||||
|
||||
def test_executable_override(self, monkeypatch):
|
||||
"""``SPECKIT_INTEGRATION_DROID_EXECUTABLE`` overrides argv[0]."""
|
||||
monkeypatch.setenv(
|
||||
"SPECKIT_INTEGRATION_DROID_EXECUTABLE", "/custom/droid"
|
||||
)
|
||||
i = get_integration("droid")
|
||||
args = i.build_exec_args("/speckit-plan", output_json=False)
|
||||
assert args[0] == "/custom/droid"
|
||||
# No dangerous permission-bypass flag should leak in via the override path.
|
||||
assert "--skip-permissions-unsafe" not in args
|
||||
|
||||
def test_returns_none_when_requires_cli_is_false(self, monkeypatch):
|
||||
"""When ``requires_cli`` is False, ``build_exec_args`` returns None."""
|
||||
i = get_integration("droid")
|
||||
monkeypatch.setitem(i.config, "requires_cli", False)
|
||||
assert i.build_exec_args("/speckit-plan") is None
|
||||
|
||||
|
||||
class TestDroidFrontmatter:
|
||||
"""Every generated SKILL.md must carry Droid-specific frontmatter flags."""
|
||||
|
||||
def test_skills_carry_user_invocable_true(self, tmp_path):
|
||||
i = get_integration("droid")
|
||||
m = IntegrationManifest("droid", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
|
||||
skill_files = [
|
||||
f
|
||||
for f in (tmp_path / ".factory" / "skills").rglob("SKILL.md")
|
||||
]
|
||||
assert skill_files, "expected at least one SKILL.md"
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "user-invocable: true" in content, (
|
||||
f"{f} missing user-invocable: true"
|
||||
)
|
||||
|
||||
def test_skills_carry_disable_model_invocation_false(self, tmp_path):
|
||||
i = get_integration("droid")
|
||||
m = IntegrationManifest("droid", tmp_path)
|
||||
i.setup(tmp_path, m, script_type="sh")
|
||||
|
||||
skill_files = [
|
||||
f
|
||||
for f in (tmp_path / ".factory" / "skills").rglob("SKILL.md")
|
||||
]
|
||||
assert skill_files, "expected at least one SKILL.md"
|
||||
for f in skill_files:
|
||||
content = f.read_text(encoding="utf-8")
|
||||
assert "disable-model-invocation: false" in content, (
|
||||
f"{f} missing disable-model-invocation: false"
|
||||
)
|
||||
|
||||
def test_inject_frontmatter_flag_adds_key_when_absent(self):
|
||||
"""Fresh content (key absent) gets the flag injected on its own line."""
|
||||
content = "---\nname: x\ndescription: y\n---\n\nBody.\n"
|
||||
result = DroidIntegration._inject_frontmatter_flag(content, "user-invocable")
|
||||
assert "user-invocable: true" in result
|
||||
# The injected key must sit on its own line, not glued to the closing ---.
|
||||
assert "\nuser-invocable: true\n---" in result, (
|
||||
"Injected key must be on its own line, not fused to closing ---"
|
||||
)
|
||||
|
||||
def test_inject_frontmatter_flag_injects_custom_value(self):
|
||||
"""The value parameter must be honored (used for disable-model-invocation: false)."""
|
||||
content = "---\nname: x\n---\n\nBody.\n"
|
||||
result = DroidIntegration._inject_frontmatter_flag(
|
||||
content, "disable-model-invocation", "false"
|
||||
)
|
||||
assert "disable-model-invocation: false" in result
|
||||
|
||||
def test_inject_frontmatter_flag_no_trailing_newline(self):
|
||||
"""Regression for the frontmatter-fusion P2 bug.
|
||||
|
||||
When the closing ``---`` is the literal last line of the file with
|
||||
no trailing newline, the injected key must still land on its own
|
||||
line (not fused onto the closing delimiter). Previously this
|
||||
produced ``user-invocable: true---``, an unparseable YAML line.
|
||||
"""
|
||||
content = "---\nname: x\ndescription: y\n---"
|
||||
result = DroidIntegration._inject_frontmatter_flag(content, "user-invocable")
|
||||
assert "user-invocable: true" in result
|
||||
# The injected key and the closing delimiter must NOT be fused.
|
||||
assert "user-invocable: true---" not in result, (
|
||||
"Injected key fused onto closing ---; no-trailing-newline regression"
|
||||
)
|
||||
# And the injected key must be on its own line.
|
||||
assert "\nuser-invocable: true\n---" in result
|
||||
|
||||
def test_frontmatter_injection_is_idempotent(self):
|
||||
"""Running the post-processor twice must not duplicate the flag."""
|
||||
content = "---\nname: x\n---\n\nBody.\n"
|
||||
once = DroidIntegration._inject_frontmatter_flag(content, "user-invocable")
|
||||
twice = DroidIntegration._inject_frontmatter_flag(once, "user-invocable")
|
||||
assert once == twice, "Frontmatter injection must be idempotent"
|
||||
# Belt-and-braces: the flag must appear exactly once.
|
||||
assert once.count("user-invocable: true") == 1
|
||||
|
||||
|
||||
class TestDroidCommandInvocation:
|
||||
"""Skills agents use the hyphenated ``/speckit-<name>`` slash form."""
|
||||
|
||||
def test_build_command_invocation_uses_hyphenated_skill_name(self):
|
||||
i = get_integration("droid")
|
||||
assert i.build_command_invocation("speckit.plan", "feature-x") == (
|
||||
"/speckit-plan feature-x"
|
||||
)
|
||||
assert i.build_command_invocation("plan") == "/speckit-plan"
|
||||
@@ -475,3 +475,39 @@ class TestForgeCommandRegistrar:
|
||||
"Found '/speckit.specify' (dot notation) in generated Forge git.feature command body. "
|
||||
"Forge requires hyphen notation for ZSH compatibility."
|
||||
)
|
||||
|
||||
|
||||
class TestForgeInitNextSteps:
|
||||
"""The post-init 'Next steps' panel must show hyphenated /speckit-<name>
|
||||
commands for Forge, since Forge only registers the hyphenated form
|
||||
(see the generated command-file tests above)."""
|
||||
|
||||
def test_init_next_steps_show_hyphenated_commands(self, tmp_path):
|
||||
import os
|
||||
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / "forge-nextsteps"
|
||||
project.mkdir()
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
["init", "--here", "--integration", "forge", "--ignore-agent-tools"],
|
||||
catch_exceptions=False,
|
||||
)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
|
||||
assert result.exit_code == 0, f"init failed: {result.output}"
|
||||
# Forge registers /speckit-<name>; the next-steps panel must match.
|
||||
assert "/speckit-plan" in result.output, (
|
||||
f"Expected /speckit-plan in next steps but got:\n{result.output}"
|
||||
)
|
||||
# Must NOT show the dotted /speckit.plan form Forge can't invoke.
|
||||
assert "/speckit.plan" not in result.output, (
|
||||
f"Should not show dotted /speckit.plan for Forge:\n{result.output}"
|
||||
)
|
||||
|
||||
@@ -3119,6 +3119,30 @@ class TestParseIntegrationOptionsEqualsForm:
|
||||
assert excinfo.value.exit_code == 1
|
||||
assert "Error: Could not parse integration options: No closing quotation." in capsys.readouterr().out
|
||||
|
||||
def test_bad_option_token_with_rich_markup_exits_cleanly(self):
|
||||
"""A bad option token carrying Rich markup must exit cleanly, not crash.
|
||||
|
||||
The token is user-controlled and gets interpolated into console.print.
|
||||
A value like '[/red]foo' parses fine through shlex but is an unexpected
|
||||
value / unknown option — and an unbalanced Rich tag would raise
|
||||
rich.errors.MarkupError inside console.print, leaking a traceback
|
||||
instead of the intended typer.Exit(1). The token must be escaped."""
|
||||
import typer
|
||||
|
||||
from specify_cli.integrations._commands import _parse_integration_options
|
||||
from specify_cli.integrations import get_integration
|
||||
|
||||
integration = get_integration("generic")
|
||||
assert integration is not None
|
||||
|
||||
# Unexpected value token carrying markup.
|
||||
with pytest.raises(typer.Exit):
|
||||
_parse_integration_options(integration, "[/red]foo")
|
||||
|
||||
# Unknown option token carrying markup.
|
||||
with pytest.raises(typer.Exit):
|
||||
_parse_integration_options(integration, "--[/red]bad")
|
||||
|
||||
|
||||
class TestUninstallNoManifestClearsInitOptions:
|
||||
def test_init_options_cleared_on_no_manifest_uninstall(self, tmp_path):
|
||||
|
||||
@@ -28,6 +28,7 @@ ALL_INTEGRATION_KEYS = [
|
||||
"gemini", "tabnine",
|
||||
# Stage 5 — skills, generic & option-driven integrations
|
||||
"codex", "kimi", "agy", "zed", "generic",
|
||||
"droid",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -20,6 +20,7 @@ ISSUE_TEMPLATE_AGENT_KEYS = [
|
||||
"codex",
|
||||
"cursor-agent",
|
||||
"devin",
|
||||
"droid",
|
||||
"firebender",
|
||||
"forge",
|
||||
"gemini",
|
||||
|
||||
@@ -8238,6 +8238,42 @@ class TestHookInvocationRendering:
|
||||
assert execution["command"] == "my-extension.do-something"
|
||||
assert execution["invocation"] == "/speckit-my-extension-do-something"
|
||||
|
||||
def test_forge_hooks_render_hyphenated_invocation(self, project_dir):
|
||||
"""Forge projects should render /speckit-* invocations (like Cline)."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "forge"}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
execution = hook_executor.execute_hook(
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "speckit.tasks",
|
||||
"optional": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert execution["command"] == "speckit.tasks"
|
||||
assert execution["invocation"] == "/speckit-tasks"
|
||||
|
||||
def test_forge_hooks_render_extension_command(self, project_dir):
|
||||
"""Forge projects should render /speckit-my-ext-cmd for extension hooks."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
init_options.parent.mkdir(parents=True, exist_ok=True)
|
||||
init_options.write_text(json.dumps({"ai": "forge"}))
|
||||
|
||||
hook_executor = HookExecutor(project_dir)
|
||||
execution = hook_executor.execute_hook(
|
||||
{
|
||||
"extension": "test-ext",
|
||||
"command": "my-extension.do-something",
|
||||
"optional": False,
|
||||
}
|
||||
)
|
||||
|
||||
assert execution["command"] == "my-extension.do-something"
|
||||
assert execution["invocation"] == "/speckit-my-extension-do-something"
|
||||
|
||||
def test_non_skill_command_keeps_slash_invocation(self, project_dir):
|
||||
"""Custom hook commands should keep slash invocation style."""
|
||||
init_options = project_dir / ".specify" / "init-options.json"
|
||||
|
||||
349
tests/test_security_workflow.py
Normal file
349
tests/test_security_workflow.py
Normal file
@@ -0,0 +1,349 @@
|
||||
"""Static checks for the dependency-audit security workflow."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import importlib.util
|
||||
import re
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
import yaml
|
||||
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
SECURITY_WORKFLOW = REPO_ROOT / ".github" / "workflows" / "security.yml"
|
||||
CONTRIBUTING = REPO_ROOT / "CONTRIBUTING.md"
|
||||
SECURITY_REQUIREMENTS = REPO_ROOT / ".github" / "security-audit-requirements.txt"
|
||||
SECURITY_REQUIREMENTS_SYNC_SCRIPT = (
|
||||
REPO_ROOT / ".github" / "scripts" / "check_security_requirements.py"
|
||||
)
|
||||
|
||||
WORKFLOW_LIVE_AUDIT_REQUIREMENTS = '"${{ runner.temp }}/spec-kit-audit-requirements.txt"'
|
||||
COMMITTED_AUDIT_REQUIREMENTS = ".github/security-audit-requirements.txt"
|
||||
WORKFLOW_COMPILE_SCHEDULED_TEST_EXTRA_DEPS = (
|
||||
"uv pip compile pyproject.toml --extra test "
|
||||
'--python-version "${{ matrix.python-version }}" --upgrade --generate-hashes --quiet '
|
||||
f"--output-file {WORKFLOW_LIVE_AUDIT_REQUIREMENTS}"
|
||||
)
|
||||
LOCAL_REFRESH_TEST_EXTRA_DEPS = (
|
||||
"uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes "
|
||||
f"--quiet --no-header --output-file {COMMITTED_AUDIT_REQUIREMENTS}"
|
||||
)
|
||||
WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS = (
|
||||
"uv pip compile pyproject.toml --extra test --universal --upgrade --generate-hashes "
|
||||
"--quiet --no-header --output-file"
|
||||
)
|
||||
WORKFLOW_SYNC_SCRIPT = "python .github/scripts/check_security_requirements.py"
|
||||
WORKFLOW_LIVE_PIP_AUDIT = (
|
||||
"uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes "
|
||||
f"-r {WORKFLOW_LIVE_AUDIT_REQUIREMENTS} --progress-spinner off"
|
||||
)
|
||||
LOCAL_PIP_AUDIT = (
|
||||
"uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes "
|
||||
f"-r {COMMITTED_AUDIT_REQUIREMENTS} --progress-spinner off"
|
||||
)
|
||||
|
||||
|
||||
def _load_security_workflow() -> dict:
|
||||
return yaml.safe_load(SECURITY_WORKFLOW.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
def _workflow_triggers() -> dict:
|
||||
workflow = _load_security_workflow()
|
||||
return workflow.get("on") or workflow[True]
|
||||
|
||||
|
||||
def _step(job_name: str, step_name: str) -> dict:
|
||||
workflow = _load_security_workflow()
|
||||
for step in workflow["jobs"][job_name]["steps"]:
|
||||
if step.get("name") == step_name:
|
||||
return step
|
||||
raise AssertionError(f"Step {step_name!r} not found in job {job_name!r}.")
|
||||
|
||||
|
||||
def _job_run_text(*job_names: str) -> str:
|
||||
workflow = _load_security_workflow()
|
||||
return "\n".join(
|
||||
step.get("run", "")
|
||||
for job_name in job_names
|
||||
for step in workflow["jobs"][job_name]["steps"]
|
||||
)
|
||||
|
||||
|
||||
def _load_sync_script():
|
||||
spec = importlib.util.spec_from_file_location(
|
||||
"check_security_requirements",
|
||||
SECURITY_REQUIREMENTS_SYNC_SCRIPT,
|
||||
)
|
||||
assert spec is not None
|
||||
assert spec.loader is not None
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
|
||||
class TestDependencyAuditWorkflow:
|
||||
"""Guard the dependency-audit security workflow."""
|
||||
|
||||
def test_dependency_audit_uses_committed_requirements_for_prs_and_pushes(self):
|
||||
workflow = _load_security_workflow()
|
||||
job = workflow["jobs"]["dependency-audit"]
|
||||
committed_audit = _step("dependency-audit", "Run pip-audit (committed requirements)")
|
||||
sync_check = _step("dependency-audit", "Check committed audit requirements are current")
|
||||
setup_python = _step("dependency-audit", "Set up Python")
|
||||
|
||||
assert job["if"] == "${{ github.event_name != 'schedule' }}"
|
||||
assert job["runs-on"] == "ubuntu-latest"
|
||||
assert "strategy" not in job
|
||||
assert setup_python["with"]["python-version"] == "3.14"
|
||||
assert sync_check["env"]["DEPENDENCY_DIFF_BASE"] == (
|
||||
"${{ github.event.pull_request.base.sha || github.event.before || '' }}"
|
||||
)
|
||||
assert sync_check["env"]["DEPENDENCY_DIFF_HEAD"] == "${{ github.sha }}"
|
||||
assert sync_check["run"] == WORKFLOW_SYNC_SCRIPT
|
||||
assert committed_audit["run"] == LOCAL_PIP_AUDIT
|
||||
|
||||
dependency_job_text = _job_run_text(
|
||||
"dependency-audit",
|
||||
"dependency-audit-scheduled",
|
||||
)
|
||||
protection_text = (
|
||||
dependency_job_text
|
||||
+ "\n"
|
||||
+ SECURITY_REQUIREMENTS_SYNC_SCRIPT.read_text(encoding="utf-8")
|
||||
)
|
||||
assert "--generate-hashes" in protection_text
|
||||
assert "--no-header" in protection_text
|
||||
assert "--require-hashes" in protection_text
|
||||
assert "--disable-pip" in protection_text
|
||||
assert WORKFLOW_LIVE_AUDIT_REQUIREMENTS in dependency_job_text
|
||||
assert COMMITTED_AUDIT_REQUIREMENTS in protection_text
|
||||
assert "uv export" not in protection_text
|
||||
assert "--frozen" not in protection_text
|
||||
assert "--locked" not in protection_text
|
||||
assert "uv.lock" not in protection_text
|
||||
assert "/tmp/" not in protection_text
|
||||
|
||||
def test_dependency_audit_checkout_fetches_full_history_for_diff_base(self):
|
||||
checkout = _step("dependency-audit", "Checkout")
|
||||
|
||||
assert checkout["with"]["fetch-depth"] == 0
|
||||
|
||||
def test_security_workflow_triggers(self):
|
||||
triggers = _workflow_triggers()
|
||||
|
||||
assert triggers["push"]["branches"] == ["main"]
|
||||
# Asserted by inclusion so later PRs (e.g. baseline-growth gates) can add
|
||||
# labeled/unlabeled without rewriting this test.
|
||||
assert {"opened", "synchronize", "reopened"} <= set(
|
||||
triggers["pull_request"]["types"]
|
||||
)
|
||||
assert "workflow_dispatch" in triggers
|
||||
assert triggers["schedule"] == [{"cron": "17 4 * * 1"}]
|
||||
|
||||
def test_scheduled_dependency_audit_runs_supported_python_os_matrix(self):
|
||||
workflow = _load_security_workflow()
|
||||
job = workflow["jobs"]["dependency-audit-scheduled"]
|
||||
matrix = job["strategy"]["matrix"]
|
||||
scheduled_compile = _step(
|
||||
"dependency-audit-scheduled",
|
||||
"Compile scheduled audit requirements",
|
||||
)
|
||||
scheduled_audit = _step(
|
||||
"dependency-audit-scheduled",
|
||||
"Run pip-audit (scheduled live resolution)",
|
||||
)
|
||||
|
||||
assert job["if"] == "${{ github.event_name == 'schedule' }}"
|
||||
assert matrix["os"] == ["ubuntu-latest", "windows-latest"]
|
||||
assert matrix["python-version"] == ["3.11", "3.12", "3.13", "3.14"]
|
||||
assert job["runs-on"] == "${{ matrix.os }}"
|
||||
assert WORKFLOW_COMPILE_SCHEDULED_TEST_EXTRA_DEPS in scheduled_compile["run"]
|
||||
assert scheduled_audit["run"] == WORKFLOW_LIVE_PIP_AUDIT
|
||||
|
||||
def test_pip_audit_is_pinned(self):
|
||||
workflow_text = SECURITY_WORKFLOW.read_text(encoding="utf-8")
|
||||
|
||||
assert WORKFLOW_LIVE_PIP_AUDIT in workflow_text
|
||||
assert LOCAL_PIP_AUDIT in workflow_text
|
||||
assert re.search(r"\buvx\s+pip-audit\b", workflow_text) is None
|
||||
|
||||
def test_actions_are_pinned_to_full_commit_shas(self):
|
||||
workflow = _load_security_workflow()
|
||||
uses_refs = [
|
||||
step["uses"]
|
||||
for job in workflow["jobs"].values()
|
||||
for step in job["steps"]
|
||||
if "uses" in step
|
||||
]
|
||||
|
||||
assert uses_refs
|
||||
for uses_ref in uses_refs:
|
||||
assert re.search(r"@[0-9a-f]{40}$", uses_ref), uses_ref
|
||||
assert re.search(r"@v\d+", uses_ref) is None
|
||||
|
||||
def test_setup_python_pin_matches_repo_standard(self):
|
||||
workflow = _load_security_workflow()
|
||||
security_refs = {
|
||||
step["uses"]
|
||||
for job in workflow["jobs"].values()
|
||||
for step in job["steps"]
|
||||
if step.get("uses", "").startswith("actions/setup-python@")
|
||||
}
|
||||
repo_standard_refs = set()
|
||||
for workflow_path in (
|
||||
REPO_ROOT / ".github" / "workflows" / "test.yml",
|
||||
REPO_ROOT / ".github" / "workflows" / "publish-pypi.yml",
|
||||
):
|
||||
workflow_data = yaml.safe_load(workflow_path.read_text(encoding="utf-8"))
|
||||
repo_standard_refs.update(
|
||||
step["uses"]
|
||||
for job in workflow_data["jobs"].values()
|
||||
for step in job["steps"]
|
||||
if step.get("uses", "").startswith("actions/setup-python@")
|
||||
)
|
||||
|
||||
assert len(repo_standard_refs) == 1
|
||||
assert security_refs == repo_standard_refs
|
||||
|
||||
def test_setup_uv_pin_matches_repo_standard(self):
|
||||
workflow = _load_security_workflow()
|
||||
security_refs = {
|
||||
step["uses"]
|
||||
for job in workflow["jobs"].values()
|
||||
for step in job["steps"]
|
||||
if step.get("uses", "").startswith("astral-sh/setup-uv@")
|
||||
}
|
||||
test_workflow = yaml.safe_load(
|
||||
(REPO_ROOT / ".github" / "workflows" / "test.yml").read_text(
|
||||
encoding="utf-8"
|
||||
)
|
||||
)
|
||||
repo_standard_refs = {
|
||||
step["uses"]
|
||||
for job in test_workflow["jobs"].values()
|
||||
for step in job["steps"]
|
||||
if step.get("uses", "").startswith("astral-sh/setup-uv@")
|
||||
}
|
||||
|
||||
assert len(repo_standard_refs) == 1
|
||||
assert security_refs == repo_standard_refs
|
||||
|
||||
def test_committed_audit_requirements_are_hashed(self):
|
||||
requirements = SECURITY_REQUIREMENTS.read_text(encoding="utf-8")
|
||||
|
||||
assert "--hash=sha256:" in requirements
|
||||
assert not requirements.startswith("#")
|
||||
assert "pytest==" in requirements
|
||||
assert "pytest-cov==" in requirements
|
||||
|
||||
def test_sync_script_skips_when_dependency_inputs_are_unchanged(self, monkeypatch, capsys):
|
||||
sync_script = _load_sync_script()
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
assert command == [
|
||||
"git", "diff", "--name-only", "HEAD^", "HEAD", "--",
|
||||
"pyproject.toml", ".github/security-audit-requirements.txt",
|
||||
]
|
||||
assert kwargs["check"] is True
|
||||
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script.main() == 0
|
||||
assert "sync check skipped" in capsys.readouterr().out
|
||||
|
||||
def test_sync_script_uses_github_diff_refs_when_available(self, monkeypatch):
|
||||
sync_script = _load_sync_script()
|
||||
monkeypatch.setenv("DEPENDENCY_DIFF_BASE", "abc123")
|
||||
monkeypatch.setenv("DEPENDENCY_DIFF_HEAD", "def456")
|
||||
|
||||
def fake_run(command, **_kwargs):
|
||||
assert command == [
|
||||
"git", "diff", "--name-only", "abc123", "def456", "--",
|
||||
"pyproject.toml", ".github/security-audit-requirements.txt",
|
||||
]
|
||||
return subprocess.CompletedProcess(command, 0, stdout="", stderr="")
|
||||
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script._dependency_inputs_changed() is False
|
||||
|
||||
def test_sync_script_compiles_and_compares_when_dependency_inputs_changed(
|
||||
self, monkeypatch, tmp_path
|
||||
):
|
||||
sync_script = _load_sync_script()
|
||||
committed_requirements = tmp_path / ".github" / "security-audit-requirements.txt"
|
||||
generated_requirements = tmp_path / "generated-requirements.txt"
|
||||
committed_requirements.parent.mkdir()
|
||||
committed_requirements.write_text("pytest==1\n", encoding="utf-8")
|
||||
compile_commands = []
|
||||
|
||||
monkeypatch.setattr(sync_script, "REPO_ROOT", tmp_path)
|
||||
monkeypatch.setattr(sync_script, "COMMITTED_REQUIREMENTS", committed_requirements)
|
||||
monkeypatch.setenv("GENERATED_REQUIREMENTS", str(generated_requirements))
|
||||
|
||||
def fake_run(command, **kwargs):
|
||||
if command[0] == "git":
|
||||
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
|
||||
compile_commands.append(command)
|
||||
assert kwargs["check"] is True
|
||||
generated_requirements.write_text("pytest==1\n", encoding="utf-8")
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script.main() == 0
|
||||
assert len(compile_commands) == 1
|
||||
compile_command = " ".join(compile_commands[0])
|
||||
assert WORKFLOW_SYNC_COMPILE_TEST_EXTRA_DEPS in compile_command
|
||||
assert "--output-file" in compile_commands[0]
|
||||
assert str(generated_requirements) in compile_commands[0]
|
||||
|
||||
def test_sync_script_reports_missing_generated_requirements_env(
|
||||
self, monkeypatch, capsys
|
||||
):
|
||||
sync_script = _load_sync_script()
|
||||
monkeypatch.delenv("GENERATED_REQUIREMENTS", raising=False)
|
||||
|
||||
def fake_run(command, **_kwargs):
|
||||
if command[0] == "git":
|
||||
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
|
||||
raise AssertionError("compile should not run without GENERATED_REQUIREMENTS")
|
||||
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script.main() == 1
|
||||
assert "GENERATED_REQUIREMENTS must be set" in capsys.readouterr().err
|
||||
|
||||
def test_sync_script_fails_when_generated_requirements_differ(
|
||||
self, monkeypatch, tmp_path, capsys
|
||||
):
|
||||
sync_script = _load_sync_script()
|
||||
committed_requirements = tmp_path / ".github" / "security-audit-requirements.txt"
|
||||
generated_requirements = tmp_path / "generated-requirements.txt"
|
||||
committed_requirements.parent.mkdir()
|
||||
committed_requirements.write_text("pytest==1\n", encoding="utf-8")
|
||||
|
||||
monkeypatch.setattr(sync_script, "REPO_ROOT", tmp_path)
|
||||
monkeypatch.setattr(sync_script, "COMMITTED_REQUIREMENTS", committed_requirements)
|
||||
monkeypatch.setenv("GENERATED_REQUIREMENTS", str(generated_requirements))
|
||||
|
||||
def fake_run(command, **_kwargs):
|
||||
if command[0] == "git":
|
||||
return subprocess.CompletedProcess(command, 0, stdout="pyproject.toml\n", stderr="")
|
||||
generated_requirements.write_text("pytest==2\n", encoding="utf-8")
|
||||
return subprocess.CompletedProcess(command, 0)
|
||||
|
||||
monkeypatch.setattr(sync_script.subprocess, "run", fake_run)
|
||||
|
||||
assert sync_script.main() == 1
|
||||
assert "Regenerate .github/security-audit-requirements.txt" in capsys.readouterr().err
|
||||
|
||||
def test_contributing_documents_security_commands(self):
|
||||
contributing_text = CONTRIBUTING.read_text(encoding="utf-8")
|
||||
|
||||
assert LOCAL_REFRESH_TEST_EXTRA_DEPS in contributing_text
|
||||
assert LOCAL_PIP_AUDIT in contributing_text
|
||||
assert "/tmp/" not in contributing_text
|
||||
assert "uv export" not in contributing_text
|
||||
@@ -404,6 +404,17 @@ class TestExpressions:
|
||||
assert evaluate_expression('{{ [["a", "b"], "c"] }}', ctx) == [["a", "b"], "c"]
|
||||
assert evaluate_expression("{{ [[1, 2], [3, 4]] }}", ctx) == [[1, 2], [3, 4]]
|
||||
|
||||
def test_list_literal_ignores_trailing_and_empty_commas(self):
|
||||
from specify_cli.workflows.expressions import evaluate_expression
|
||||
from specify_cli.workflows.base import StepContext
|
||||
|
||||
ctx = StepContext()
|
||||
# A trailing comma must not append a spurious None element.
|
||||
assert evaluate_expression("{{ [1, 2,] }}", ctx) == [1, 2]
|
||||
assert evaluate_expression("{{ [1,, 2] }}", ctx) == [1, 2]
|
||||
# …but an intentional empty-string element is still preserved.
|
||||
assert evaluate_expression("{{ ['', 'a'] }}", ctx) == ["", "a"]
|
||||
|
||||
def test_operator_splitting_is_quote_aware(self):
|
||||
from specify_cli.workflows.expressions import (
|
||||
evaluate_condition,
|
||||
@@ -1026,6 +1037,41 @@ class TestCommandStep:
|
||||
assert res_opt.status is StepStatus.FAILED
|
||||
assert "'options' must be a mapping" in (res_opt.error or "")
|
||||
|
||||
@pytest.mark.parametrize("bad", [["claude"], {"a": 1}, 5, True])
|
||||
def test_validate_rejects_non_string_integration_and_model(self, bad):
|
||||
"""A non-string 'integration'/'model' must be rejected at validation.
|
||||
|
||||
execute() passes 'integration' to get_integration(), which uses it as a
|
||||
dict key — an unhashable list/dict raises a raw TypeError there, even on
|
||||
a validated run — and feeds 'model' into the CLI argv. Mirrors the
|
||||
'command'/'input'/'options' type checks.
|
||||
"""
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
|
||||
step = CommandStep()
|
||||
errs = step.validate({"id": "c", "command": "/x", "integration": bad})
|
||||
assert any("'integration' must be a string" in e for e in errs), bad
|
||||
errs = step.validate({"id": "c", "command": "/x", "model": bad})
|
||||
assert any("'model' must be a string" in e for e in errs), bad
|
||||
|
||||
def test_validate_accepts_none_and_expression_integration_model(self):
|
||||
"""An explicit YAML-null (inherit default) or a '{{ ... }}' expression
|
||||
integration/model stays valid — only literal non-strings are rejected."""
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
|
||||
step = CommandStep()
|
||||
assert step.validate(
|
||||
{"id": "c", "command": "/x", "integration": None, "model": None}
|
||||
) == []
|
||||
assert step.validate(
|
||||
{
|
||||
"id": "c",
|
||||
"command": "/x",
|
||||
"integration": "{{ inputs.agent }}",
|
||||
"model": "{{ inputs.model }}",
|
||||
}
|
||||
) == []
|
||||
|
||||
def test_validate_rejects_non_string_command(self):
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
|
||||
@@ -1061,6 +1107,55 @@ class TestCommandStep:
|
||||
assert result.status is StepStatus.FAILED, bad
|
||||
assert "'command' must be a string" in (result.error or ""), bad
|
||||
|
||||
def test_execute_non_string_integration_fails_loudly(self):
|
||||
"""On an unvalidated run, an unhashable 'integration' would crash
|
||||
get_integration() (dict.get on a list) with a raw TypeError. execute()
|
||||
must fail the step with the contract error instead."""
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = CommandStep()
|
||||
res = step.execute(
|
||||
{"id": "c", "command": "speckit.specify", "integration": ["claude"]},
|
||||
StepContext(),
|
||||
)
|
||||
assert res.status is StepStatus.FAILED
|
||||
assert "'integration' must be a string" in (res.error or "")
|
||||
# non-string model likewise fails before build_exec_args
|
||||
res = step.execute(
|
||||
{"id": "c", "command": "speckit.specify", "integration": "claude", "model": ["m"]},
|
||||
StepContext(),
|
||||
)
|
||||
assert res.status is StepStatus.FAILED
|
||||
assert "'model' must be a string" in (res.error or "")
|
||||
|
||||
@pytest.mark.parametrize("falsey", [[], {}, 0, False])
|
||||
def test_execute_falsey_non_string_integration_fails_loudly(self, falsey):
|
||||
"""A *falsey* non-string ([], {}, 0, False) must fail the step, not be
|
||||
swallowed by an ``or``-fallback to the workflow default.
|
||||
|
||||
A ``config.get('integration') or context.default_integration`` coerces a
|
||||
falsey non-string to the default *before* the type guard runs, so with a
|
||||
configured default the step would silently dispatch using the wrong
|
||||
integration instead of surfacing the contract error. The default is set
|
||||
here so a regression dispatches rather than fails-not-possible."""
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = CommandStep()
|
||||
ctx = StepContext(default_integration="claude", default_model="sonnet")
|
||||
res = step.execute(
|
||||
{"id": "c", "command": "speckit.specify", "integration": falsey}, ctx
|
||||
)
|
||||
assert res.status is StepStatus.FAILED, falsey
|
||||
assert "'integration' must be a string" in (res.error or ""), falsey
|
||||
# a falsey non-string model likewise reaches the guard
|
||||
res = step.execute(
|
||||
{"id": "c", "command": "speckit.specify", "model": falsey}, ctx
|
||||
)
|
||||
assert res.status is StepStatus.FAILED, falsey
|
||||
assert "'model' must be a string" in (res.error or ""), falsey
|
||||
|
||||
def test_step_override_integration(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
@@ -1078,6 +1173,21 @@ class TestCommandStep:
|
||||
result = step.execute(config, ctx)
|
||||
assert result.output["integration"] == "gemini"
|
||||
|
||||
def test_execute_non_string_integration_fails_cleanly(self):
|
||||
"""A non-string integration (e.g. a list from an expression that resolved
|
||||
to one) must FAIL the step cleanly, not crash the run with
|
||||
'TypeError: unhashable type: list' from get_integration's dict lookup."""
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = CommandStep()
|
||||
config = {
|
||||
"id": "s", "command": "speckit.plan",
|
||||
"integration": ["claude"], "input": {},
|
||||
}
|
||||
result = step.execute(config, StepContext())
|
||||
assert result.status == StepStatus.FAILED
|
||||
|
||||
def test_step_override_model(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.command import CommandStep
|
||||
@@ -1275,6 +1385,20 @@ class TestPromptStep:
|
||||
assert result.output["integration"] == "claude"
|
||||
assert result.output["dispatched"] is False
|
||||
|
||||
def test_execute_non_string_integration_fails_cleanly(self):
|
||||
"""A non-string integration must FAIL the step cleanly, not crash with
|
||||
'TypeError: unhashable type: list' from get_integration's dict lookup."""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = PromptStep()
|
||||
config = {
|
||||
"id": "p", "type": "prompt", "prompt": "do it",
|
||||
"integration": ["claude"],
|
||||
}
|
||||
result = step.execute(config, StepContext())
|
||||
assert result.status == StepStatus.FAILED
|
||||
|
||||
def test_execute_with_step_integration(self):
|
||||
from unittest.mock import patch
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
@@ -1448,6 +1572,82 @@ class TestPromptStep:
|
||||
)
|
||||
assert errors == []
|
||||
|
||||
@pytest.mark.parametrize("bad", [["claude"], {"a": 1}, 5, True])
|
||||
def test_validate_rejects_non_string_integration_and_model(self, bad):
|
||||
"""A non-string 'integration'/'model' must be rejected at validation.
|
||||
|
||||
execute() passes 'integration' to get_integration(), which uses it as a
|
||||
dict key — an unhashable list/dict raises a raw TypeError there, even on
|
||||
a validated run — and feeds 'model' into the CLI argv."""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
|
||||
step = PromptStep()
|
||||
errs = step.validate({"id": "p", "prompt": "hi", "integration": bad})
|
||||
assert any("'integration' must be a string" in e for e in errs), bad
|
||||
errs = step.validate({"id": "p", "prompt": "hi", "model": bad})
|
||||
assert any("'model' must be a string" in e for e in errs), bad
|
||||
|
||||
def test_validate_accepts_none_and_expression_integration_model(self):
|
||||
"""An explicit YAML-null (inherit default) or a '{{ ... }}' expression
|
||||
integration/model stays valid — only literal non-strings are rejected."""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
|
||||
step = PromptStep()
|
||||
assert step.validate(
|
||||
{"id": "p", "prompt": "hi", "integration": None, "model": None}
|
||||
) == []
|
||||
assert step.validate(
|
||||
{
|
||||
"id": "p",
|
||||
"prompt": "hi",
|
||||
"integration": "{{ inputs.agent }}",
|
||||
"model": "{{ inputs.model }}",
|
||||
}
|
||||
) == []
|
||||
|
||||
def test_execute_non_string_integration_fails_loudly(self):
|
||||
"""On an unvalidated run, an unhashable 'integration' would crash
|
||||
get_integration() (dict.get on a dict) with a raw TypeError. execute()
|
||||
must fail the step with the contract error instead."""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = PromptStep()
|
||||
res = step.execute(
|
||||
{"id": "p", "prompt": "hi", "integration": {"a": 1}}, StepContext()
|
||||
)
|
||||
assert res.status is StepStatus.FAILED
|
||||
assert "'integration' must be a string" in (res.error or "")
|
||||
res = step.execute(
|
||||
{"id": "p", "prompt": "hi", "integration": "claude", "model": ["m"]},
|
||||
StepContext(),
|
||||
)
|
||||
assert res.status is StepStatus.FAILED
|
||||
assert "'model' must be a string" in (res.error or "")
|
||||
|
||||
@pytest.mark.parametrize("falsey", [[], {}, 0, False])
|
||||
def test_execute_falsey_non_string_integration_fails_loudly(self, falsey):
|
||||
"""A *falsey* non-string ([], {}, 0, False) must fail the step, not be
|
||||
swallowed by an ``or``-fallback to the workflow default.
|
||||
|
||||
A ``config.get('integration') or context.default_integration`` coerces a
|
||||
falsey non-string to the default *before* the type guard runs, so with a
|
||||
configured default the step would silently dispatch using the wrong
|
||||
integration instead of surfacing the contract error. The default is set
|
||||
here so a regression dispatches rather than fails-not-possible."""
|
||||
from specify_cli.workflows.steps.prompt import PromptStep
|
||||
from specify_cli.workflows.base import StepContext, StepStatus
|
||||
|
||||
step = PromptStep()
|
||||
ctx = StepContext(default_integration="claude", default_model="sonnet")
|
||||
res = step.execute({"id": "p", "prompt": "hi", "integration": falsey}, ctx)
|
||||
assert res.status is StepStatus.FAILED, falsey
|
||||
assert "'integration' must be a string" in (res.error or ""), falsey
|
||||
# a falsey non-string model likewise reaches the guard
|
||||
res = step.execute({"id": "p", "prompt": "hi", "model": falsey}, ctx)
|
||||
assert res.status is StepStatus.FAILED, falsey
|
||||
assert "'model' must be a string" in (res.error or ""), falsey
|
||||
|
||||
|
||||
class TestShellStep:
|
||||
"""Test the shell step type."""
|
||||
@@ -1985,6 +2185,19 @@ class TestGateStep:
|
||||
assert result.status == StepStatus.COMPLETED
|
||||
assert result.output["choice"] == "approve"
|
||||
|
||||
def test_interactive_prompt_rejects_non_decimal_digit(self, monkeypatch, capsys):
|
||||
"""A Unicode digit int() can't parse — e.g. the superscript '²', which
|
||||
str.isdigit() accepts but int() rejects — must be treated as an invalid
|
||||
choice, not crash the prompt loop with an uncaught ValueError."""
|
||||
from specify_cli.workflows.steps.gate import GateStep
|
||||
|
||||
_force_gate_stdin(monkeypatch, tty=True)
|
||||
inputs = iter(["²", "1"]) # superscript-two, then a real "1"
|
||||
monkeypatch.setattr("builtins.input", lambda _prompt="": next(inputs))
|
||||
|
||||
choice = GateStep._prompt("Review the spec.", ["approve", "reject"])
|
||||
assert choice == "approve"
|
||||
|
||||
def test_interactive_prompt_missing_show_file_does_not_crash(
|
||||
self, tmp_path, monkeypatch, capsys
|
||||
):
|
||||
@@ -4349,6 +4562,108 @@ steps:
|
||||
assert WorkflowEngine._coerce_input("count", 5.0, {"type": "number"}) == 5
|
||||
assert WorkflowEngine._coerce_input("count", 3.5, {"type": "number"}) == 3.5
|
||||
|
||||
def test_coerce_input_rejects_non_list_enum_cleanly(self):
|
||||
"""A non-list ``enum`` (scalar or string) must raise a clean ValueError,
|
||||
not the raw ``TypeError`` from the ``value not in enum`` membership test.
|
||||
|
||||
A scalar (``enum: 5``) makes ``value not in 5`` raise
|
||||
``TypeError: argument of type 'int' is not iterable``. A bare string
|
||||
(``enum: "abc"``) is silently wrong instead — ``value in "abc"`` is a
|
||||
substring test, not enum membership — so it must be rejected too.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine
|
||||
|
||||
for bad_enum in (5, True, "abc", {"a": 1}):
|
||||
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
|
||||
WorkflowEngine._coerce_input(
|
||||
"scope", "x", {"type": "string", "enum": bad_enum}
|
||||
)
|
||||
# A valid list ``enum`` still works, and ``None`` means "no enum".
|
||||
assert (
|
||||
WorkflowEngine._coerce_input(
|
||||
"scope", "a", {"type": "string", "enum": ["a", "b"]}
|
||||
)
|
||||
== "a"
|
||||
)
|
||||
assert (
|
||||
WorkflowEngine._coerce_input("scope", "x", {"type": "string"}) == "x"
|
||||
)
|
||||
|
||||
def test_validate_workflow_rejects_non_list_enum(self):
|
||||
"""A non-list ``enum`` must be reported as an error, not crash
|
||||
``validate_workflow``. The membership test would raise ``TypeError``,
|
||||
which escapes its ``except ValueError`` and breaks the "return a list of
|
||||
errors, never raise" contract. This must surface even with no ``default``
|
||||
present (the coercion path that would otherwise catch it is only reached
|
||||
when a default exists).
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "bad-enum"
|
||||
name: "Bad Enum"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
scope:
|
||||
type: string
|
||||
enum: 5
|
||||
steps:
|
||||
- id: noop
|
||||
type: gate
|
||||
message: "noop"
|
||||
options: [approve]
|
||||
""")
|
||||
errors = validate_workflow(definition)
|
||||
assert any("invalid 'enum': must be a list" in e for e in errors), errors
|
||||
|
||||
def test_resolve_inputs_rejects_non_list_enum_at_runtime(self, project_dir):
|
||||
"""``execute()`` accepts unvalidated definitions, so a non-list ``enum``
|
||||
can reach ``_resolve_inputs`` at run time. It must fail with a clean
|
||||
ValueError rather than the raw ``TypeError`` from the membership test.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "runtime-bad-enum"
|
||||
name: "Runtime Bad Enum"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
scope:
|
||||
type: string
|
||||
enum: 5
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
|
||||
engine._resolve_inputs(definition, {"scope": "x"})
|
||||
|
||||
def test_non_list_enum_on_integration_auto_still_rejected(self, project_dir):
|
||||
"""The ``integration: auto`` sentinel strips a *list* ``enum`` before
|
||||
coercion (enum-membership is a runtime concern for ``auto``). A non-list
|
||||
``enum`` must NOT be silently stripped by that path — it is still an
|
||||
authoring error and must fail with the clean shape ValueError.
|
||||
"""
|
||||
from specify_cli.workflows.engine import WorkflowEngine, WorkflowDefinition
|
||||
|
||||
definition = WorkflowDefinition.from_string("""
|
||||
schema_version: "1.0"
|
||||
workflow:
|
||||
id: "auto-bad-enum"
|
||||
name: "Auto Bad Enum"
|
||||
version: "1.0.0"
|
||||
inputs:
|
||||
integration:
|
||||
type: string
|
||||
default: "auto"
|
||||
enum: 5
|
||||
""")
|
||||
engine = WorkflowEngine(project_dir)
|
||||
with pytest.raises(ValueError, match="invalid 'enum': must be a list"):
|
||||
engine._resolve_inputs(definition, {})
|
||||
|
||||
def test_validate_workflow_rejects_infinite_default_for_number_type(self):
|
||||
"""``type: number`` with an infinite default (YAML ``.inf``) must be
|
||||
reported as an error, not raise. ``int(inf)`` raises OverflowError during
|
||||
@@ -6059,7 +6374,9 @@ class TestWorkflowCatalog:
|
||||
return "https://[::1"
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_http, "open_url", lambda url, timeout=30: _FakeResponse()
|
||||
auth_http,
|
||||
"open_url",
|
||||
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
|
||||
)
|
||||
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
@@ -6074,6 +6391,41 @@ class TestWorkflowCatalog:
|
||||
with pytest.raises(WorkflowCatalogError, match="malformed"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
def test_fetch_validates_every_redirect_hop(self, project_dir, monkeypatch):
|
||||
"""A redirect_validator is passed to open_url and rejects a non-HTTPS
|
||||
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
|
||||
terminal-URL-only check would miss. Mirrors presets/extensions
|
||||
(#3523 / #3524)."""
|
||||
from specify_cli.workflows.catalog import (
|
||||
WorkflowCatalog,
|
||||
WorkflowCatalogEntry,
|
||||
WorkflowCatalogError,
|
||||
)
|
||||
from specify_cli.authentication import http as auth_http
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_open(url, timeout=30, redirect_validator=None):
|
||||
captured["rv"] = redirect_validator
|
||||
# Simulate the hop urllib validates before following the redirect.
|
||||
redirect_validator(
|
||||
"https://good.example/catalog.json", "http://evil.test/hop"
|
||||
)
|
||||
raise AssertionError("redirect_validator should have raised")
|
||||
|
||||
monkeypatch.setattr(auth_http, "open_url", fake_open)
|
||||
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
entry = WorkflowCatalogEntry(
|
||||
url="https://good.example/catalog.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
with pytest.raises(WorkflowCatalogError, match="HTTPS"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
assert captured["rv"] is not None
|
||||
|
||||
def test_add_catalog(self, project_dir):
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
@@ -6618,7 +6970,9 @@ class TestStepCatalog:
|
||||
return "https://[not-an-ip]/x"
|
||||
|
||||
monkeypatch.setattr(
|
||||
auth_http, "open_url", lambda url, timeout=30: _FakeResponse()
|
||||
auth_http,
|
||||
"open_url",
|
||||
lambda url, timeout=30, redirect_validator=None: _FakeResponse(),
|
||||
)
|
||||
|
||||
catalog = StepCatalog(project_dir)
|
||||
@@ -6633,6 +6987,41 @@ class TestStepCatalog:
|
||||
with pytest.raises(StepCatalogError, match="malformed"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
|
||||
def test_fetch_validates_every_redirect_hop(self, project_dir, monkeypatch):
|
||||
"""A redirect_validator is passed to open_url and rejects a non-HTTPS
|
||||
INTERMEDIATE hop — closing the https -> http -> attacker-https chain a
|
||||
terminal-URL-only check would miss. Mirrors presets/extensions
|
||||
(#3523 / #3524)."""
|
||||
from specify_cli.workflows.catalog import (
|
||||
StepCatalog,
|
||||
StepCatalogEntry,
|
||||
StepCatalogError,
|
||||
)
|
||||
from specify_cli.authentication import http as auth_http
|
||||
|
||||
captured = {}
|
||||
|
||||
def fake_open(url, timeout=30, redirect_validator=None):
|
||||
captured["rv"] = redirect_validator
|
||||
# Simulate the hop urllib validates before following the redirect.
|
||||
redirect_validator(
|
||||
"https://good.example/steps.json", "http://evil.test/hop"
|
||||
)
|
||||
raise AssertionError("redirect_validator should have raised")
|
||||
|
||||
monkeypatch.setattr(auth_http, "open_url", fake_open)
|
||||
|
||||
catalog = StepCatalog(project_dir)
|
||||
entry = StepCatalogEntry(
|
||||
url="https://good.example/steps.json",
|
||||
name="test",
|
||||
priority=1,
|
||||
install_allowed=True,
|
||||
)
|
||||
with pytest.raises(StepCatalogError, match="HTTPS"):
|
||||
catalog._fetch_single_catalog(entry, force_refresh=True)
|
||||
assert captured["rv"] is not None
|
||||
|
||||
def test_add_catalog(self, project_dir):
|
||||
from specify_cli.workflows.catalog import StepCatalog
|
||||
|
||||
@@ -7397,6 +7786,63 @@ class TestWorkflowRemoveGuard:
|
||||
assert "[stage]permissiondenied" in output_compact
|
||||
assert "[reg]diskfull" in output_compact
|
||||
|
||||
|
||||
class TestWorkflowAddCaseInsensitiveSuffix:
|
||||
"""`workflow add` must detect a local YAML file case-insensitively, matching
|
||||
`workflow run` (_commands.py:workflow_run) and the engine loader
|
||||
(engine.py:WorkflowEngine.load_workflow), which both use `.suffix.lower()`.
|
||||
Without it, `workflow run Sample.YAML` works but `workflow add Sample.YAML`
|
||||
fails — an add/run inconsistency for an uppercase extension."""
|
||||
|
||||
def test_plain_path_accepts_uppercase_extension(self, temp_dir, monkeypatch, sample_workflow_yaml):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
|
||||
src = temp_dir / "Sample.YAML"
|
||||
src.write_text(sample_workflow_yaml, encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
|
||||
|
||||
# Before the fix: `.suffix in (...)` is case-sensitive, so ".YAML" is not
|
||||
# recognized as a local file; the path falls through to catalog lookup
|
||||
# and fails. After the fix it installs like the lowercase happy path.
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "installed" in result.output
|
||||
|
||||
def test_dev_path_accepts_uppercase_extension(self, temp_dir, monkeypatch, sample_workflow_yaml):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
|
||||
src = temp_dir / "Sample.YAML"
|
||||
src.write_text(sample_workflow_yaml, encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", "--dev", str(src)])
|
||||
|
||||
# Before the fix the --dev branch rejects ".YAML" with
|
||||
# "--dev source must be a workflow YAML file ...".
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "installed" in result.output
|
||||
|
||||
def test_lowercase_extension_still_installs(self, temp_dir, monkeypatch, sample_workflow_yaml):
|
||||
"""Happy path (lowercase .yml) is unchanged by the case-normalization."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
(temp_dir / ".specify" / "workflows").mkdir(parents=True)
|
||||
src = temp_dir / "sample.yml"
|
||||
src.write_text(sample_workflow_yaml, encoding="utf-8")
|
||||
|
||||
monkeypatch.chdir(temp_dir)
|
||||
result = CliRunner().invoke(app, ["workflow", "add", str(src)])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "installed" in result.output
|
||||
|
||||
|
||||
class TestWorkflowAddSymlinkGuard:
|
||||
def test_add_malformed_ipv6_url_exits_cleanly(self, temp_dir, monkeypatch):
|
||||
"""A malformed IPv6 URL must produce a clean error, not a ValueError traceback."""
|
||||
@@ -9637,6 +10083,17 @@ steps:
|
||||
registry.add("align-wf", {"version": "1.0.0", "source": "catalog"})
|
||||
assert registry.get("align-wf")["version"] == "1.0.0"
|
||||
|
||||
def test_step_registry_add_survives_non_dict_existing_entry(self, project_dir):
|
||||
"""StepRegistry.add must treat a corrupted non-dict existing entry as
|
||||
absent rather than crash on existing.get() (parity with
|
||||
WorkflowRegistry.add)."""
|
||||
from specify_cli.workflows.catalog import StepRegistry
|
||||
|
||||
registry = StepRegistry(project_dir)
|
||||
registry.data["steps"]["my-step"] = "corrupted"
|
||||
registry.add("my-step", {"version": "1.0.0"})
|
||||
assert registry.get("my-step")["version"] == "1.0.0"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"contents",
|
||||
[
|
||||
|
||||
@@ -253,6 +253,47 @@ def test_add_source_wraps_invalid_ipv6_as_bundler_error(tmp_path: Path):
|
||||
cc.add_source(project, "https://[::1/c.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_add_source_wraps_bracketed_non_ip_host_as_bundler_error(tmp_path: Path):
|
||||
# A bracketed-but-invalid IPv6 authority (e.g. "https://[not-an-ip]/c.json")
|
||||
# parses cleanly under urlparse() on Python < 3.14 and only raises ValueError
|
||||
# lazily on the first .hostname access; the raise moved eager into urlparse()
|
||||
# in 3.14. add_source must surface its own BundlerError on every supported
|
||||
# version, never leak a raw ValueError past the CLI's `except BundlerError`.
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="Invalid catalog url"):
|
||||
cc.add_source(project, "https://[not-an-ip]/c.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_add_source_wraps_lazy_hostname_valueerror(tmp_path: Path, monkeypatch):
|
||||
# Simulate the Python < 3.14 shape explicitly (independent of the running
|
||||
# interpreter): urlparse() succeeds but .hostname raises ValueError lazily.
|
||||
# This is the exact path the fix guards; it fails with a raw ValueError if
|
||||
# .hostname is read outside the try/except.
|
||||
from urllib.parse import urlparse as _real_urlparse
|
||||
|
||||
class _LazyHostnameRaiser:
|
||||
def __init__(self, parsed):
|
||||
self._parsed = parsed
|
||||
|
||||
@property
|
||||
def hostname(self):
|
||||
raise ValueError("simulated lazy IPv6 hostname failure")
|
||||
|
||||
def __getattr__(self, name):
|
||||
return getattr(self._parsed, name)
|
||||
|
||||
def _fake_urlparse(url, *args, **kwargs):
|
||||
return _LazyHostnameRaiser(_real_urlparse(url, *args, **kwargs))
|
||||
|
||||
monkeypatch.setattr(cc, "urlparse", _fake_urlparse)
|
||||
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
with pytest.raises(BundlerError, match="Invalid catalog url"):
|
||||
cc.add_source(project, "https://example.com/c.json", policy="install-allowed", priority=50)
|
||||
|
||||
|
||||
def test_remove_source_does_not_crash_on_invalid_ipv6(tmp_path: Path):
|
||||
project = tmp_path / "proj"
|
||||
(project / ".specify").mkdir(parents=True)
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-04-10T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/workflows/catalog.community.json",
|
||||
"workflows": {}
|
||||
"workflows": {
|
||||
"pipeline": {
|
||||
"id": "pipeline",
|
||||
"name": "Guided SDD Pipeline",
|
||||
"description": "Chains specify, clarify, plan, tasks, analyze, implement, and converge into one guided run with a single clarify gate and a post-implement convergence loop",
|
||||
"author": "domattioli",
|
||||
"version": "1.1.0",
|
||||
"url": "https://raw.githubusercontent.com/domattioli/spec-kit-workflow-pipeline/v1.1.0/workflow.yml",
|
||||
"repository": "https://github.com/domattioli/spec-kit-workflow-pipeline",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.11.2"
|
||||
},
|
||||
"tags": [
|
||||
"sdd",
|
||||
"pipeline",
|
||||
"automation"
|
||||
],
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user