diff --git a/.devcontainer/devcontainer.json b/.devcontainer/devcontainer.json
index 89c090a828..e0720dd78e 100644
--- a/.devcontainer/devcontainer.json
+++ b/.devcontainer/devcontainer.json
@@ -65,7 +65,8 @@
},
"chat.tools.terminal.autoApprove": {
".specify/scripts/bash/": true,
- ".specify/scripts/powershell/": true
+ ".specify/scripts/powershell/": true,
+ ".specify/scripts/python/": true
}
}
}
diff --git a/.devcontainer/post-create.sh b/.devcontainer/post-create.sh
index 5aa0a076c1..a702d3eb8a 100755
--- a/.devcontainer/post-create.sh
+++ b/.devcontainer/post-create.sh
@@ -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"
diff --git a/.github/ISSUE_TEMPLATE/agent_request.yml b/.github/ISSUE_TEMPLATE/agent_request.yml
index e30f773edc..360370165e 100644
--- a/.github/ISSUE_TEMPLATE/agent_request.yml
+++ b/.github/ISSUE_TEMPLATE/agent_request.yml
@@ -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**: Alquimia AI, Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Factory Droid, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Grok Build, Hermes Agent, IBM Bob, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, ZCode, Zed
- type: input
id: agent-name
diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml
index dd7c55d1ca..03a7e97931 100644
--- a/.github/ISSUE_TEMPLATE/bug_report.yml
+++ b/.github/ISSUE_TEMPLATE/bug_report.yml
@@ -62,6 +62,7 @@ body:
label: AI Agent
description: Which AI agent are you using?
options:
+ - Alquimia AI
- Amp
- Antigravity
- Auggie CLI
@@ -71,6 +72,7 @@ body:
- Codex CLI
- Cursor
- Devin for Terminal
+ - Factory Droid
- Firebender
- Forge
- Gemini CLI
diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml
index 6a037ae1eb..08e1075038 100644
--- a/.github/ISSUE_TEMPLATE/feature_request.yml
+++ b/.github/ISSUE_TEMPLATE/feature_request.yml
@@ -56,6 +56,7 @@ body:
description: Does this feature relate to a specific AI agent?
options:
- All agents
+ - Alquimia AI
- Amp
- Antigravity
- Auggie CLI
@@ -65,6 +66,7 @@ body:
- Codex CLI
- Cursor
- Devin for Terminal
+ - Factory Droid
- Firebender
- Forge
- Gemini CLI
diff --git a/.github/aw/actions-lock.json b/.github/aw/actions-lock.json
index 580017a28f..5d7a62fd96 100644
--- a/.github/aw/actions-lock.json
+++ b/.github/aw/actions-lock.json
@@ -1,10 +1,30 @@
{
"entries": {
+ "actions/checkout@v6.0.3": {
+ "repo": "actions/checkout",
+ "version": "v6.0.3",
+ "sha": "df4cb1c069e1874edd31b4311f1884172cec0e10"
+ },
+ "actions/download-artifact@v8.0.1": {
+ "repo": "actions/download-artifact",
+ "version": "v8.0.1",
+ "sha": "3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c"
+ },
"actions/github-script@v9.0.0": {
"repo": "actions/github-script",
"version": "v9.0.0",
"sha": "3a2844b7e9c422d3c10d287c895573f7108da1b3"
},
+ "actions/setup-node@v6.4.0": {
+ "repo": "actions/setup-node",
+ "version": "v6.4.0",
+ "sha": "48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e"
+ },
+ "actions/upload-artifact@v7.0.1": {
+ "repo": "actions/upload-artifact",
+ "version": "v7.0.1",
+ "sha": "043fb46d1a93c77aae656e7c1c64a875d1fc6a0a"
+ },
"github/gh-aw-actions/setup@v0.79.8": {
"repo": "github/gh-aw-actions/setup",
"version": "v0.79.8",
diff --git a/.github/scripts/check_security_requirements.py b/.github/scripts/check_security_requirements.py
new file mode 100644
index 0000000000..18f8053528
--- /dev/null
+++ b/.github/scripts/check_security_requirements.py
@@ -0,0 +1,123 @@
+"""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:
+ merge_base = subprocess.run(
+ ["git", "merge-base", base_ref, head_ref],
+ check=True,
+ cwd=REPO_ROOT,
+ stderr=subprocess.PIPE,
+ stdout=subprocess.PIPE,
+ text=True,
+ ).stdout.strip()
+ result = subprocess.run(
+ [
+ "git",
+ "diff",
+ "--name-only",
+ merge_base,
+ 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)
+ generated_requirements.write_bytes(COMMITTED_REQUIREMENTS.read_bytes())
+
+ subprocess.run(
+ [
+ "uv",
+ "pip",
+ "compile",
+ "pyproject.toml",
+ "--extra",
+ "test",
+ "--universal",
+ "--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())
diff --git a/.github/security-audit-requirements.txt b/.github/security-audit-requirements.txt
new file mode 100644
index 0000000000..7acbecd440
--- /dev/null
+++ b/.github/security-audit-requirements.txt
@@ -0,0 +1,253 @@
+annotated-doc==0.0.5 \
+ --hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
+ --hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
+ # 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)
diff --git a/.github/workflows/add-community-bundle.lock.yml b/.github/workflows/add-community-bundle.lock.yml
new file mode 100644
index 0000000000..f4841c97e8
--- /dev/null
+++ b/.github/workflows/add-community-bundle.lock.yml
@@ -0,0 +1,1746 @@
+# gh-aw-metadata: {"schema_version":"v4","frontmatter_hash":"c64e3dc29aca89e48108bb6d4eb877f6264b4cec9cd56dcd36827893802d2a64","body_hash":"cade22e5083254b735200f4ff7d686104e4ccab848ff7355141b9689354834db","compiler_version":"v0.79.8","strict":true,"agent_id":"copilot","engine_versions":{"copilot":"1.0.60"}}
+# gh-aw-manifest: {"version":1,"secrets":["COPILOT_GITHUB_TOKEN","GH_AW_CI_TRIGGER_TOKEN","GH_AW_GITHUB_MCP_SERVER_TOKEN","GH_AW_GITHUB_TOKEN","GITHUB_TOKEN"],"actions":[{"repo":"actions/checkout","sha":"df4cb1c069e1874edd31b4311f1884172cec0e10","version":"v6.0.3"},{"repo":"actions/download-artifact","sha":"3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c","version":"v8.0.1"},{"repo":"actions/github-script","sha":"3a2844b7e9c422d3c10d287c895573f7108da1b3","version":"v9.0.0"},{"repo":"actions/setup-node","sha":"48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e","version":"v6.4.0"},{"repo":"actions/upload-artifact","sha":"043fb46d1a93c77aae656e7c1c64a875d1fc6a0a","version":"v7.0.1"},{"repo":"github/gh-aw-actions/setup","sha":"c0338fef4749d08c21f8f975fb0e37efa17dda47","version":"v0.79.8"}],"containers":[{"image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2","digest":"sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6","pinned_image":"ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6"},{"image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2","digest":"sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4","pinned_image":"ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4"},{"image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2","digest":"sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591","pinned_image":"ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591"},{"image":"ghcr.io/github/gh-aw-mcpg:v0.3.25","digest":"sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa","pinned_image":"ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa"},{"image":"ghcr.io/github/github-mcp-server:v1.1.2","digest":"sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c","pinned_image":"ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c"}]}
+# This file was automatically generated by gh-aw (v0.79.8). DO NOT EDIT. To debug this workflow, load the skill at https://github.com/github/gh-aw/blob/main/debug.md
+#
+# ___ _ _
+# / _ \ | | (_)
+# | |_| | __ _ ___ _ __ | |_ _ ___
+# | _ |/ _` |/ _ \ '_ \| __| |/ __|
+# | | | | (_| | __/ | | | |_| | (__
+# \_| |_/\__, |\___|_| |_|\__|_|\___|
+# __/ |
+# _ _ |___/
+# | | | | / _| |
+# | | | | ___ _ __ _ __| |_| | _____ ____
+# | |/\| |/ _ \ '__| |/ /| _| |/ _ \ \ /\ / / ___|
+# \ /\ / (_) | | | | ( | | | | (_) \ V V /\__ \
+# \/ \/ \___/|_| |_|\_\|_| |_|\___/ \_/\_/ |___/
+#
+#
+# To update this file, edit the corresponding .md file and run:
+# gh aw compile
+# Not all edits will cause changes to this file.
+#
+# For more information: https://github.github.com/gh-aw/introduction/overview/
+#
+# Process community bundle submission issues - validate, add to catalog, and open a PR for maintainer review
+#
+# Secrets used:
+# - COPILOT_GITHUB_TOKEN
+# - GH_AW_CI_TRIGGER_TOKEN
+# - GH_AW_GITHUB_MCP_SERVER_TOKEN
+# - GH_AW_GITHUB_TOKEN
+# - GITHUB_TOKEN
+#
+# Custom actions used:
+# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+# - actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+# - github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
+#
+# Container images used:
+# - ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6
+# - ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4
+# - ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591
+# - ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa
+# - ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c
+
+name: "Add Community Bundle from Issue Submission"
+on:
+ issues:
+ # names: # Label filtering applied via job conditions
+ # - bundle-submission # Label filtering applied via job conditions
+ types:
+ - labeled
+ # skip-bots: # Skip-bots processed as bot check in pre-activation job
+ # - github-actions # Skip-bots processed as bot check in pre-activation job
+ # - copilot # Skip-bots processed as bot check in pre-activation job
+ # - dependabot # Skip-bots processed as bot check in pre-activation job
+
+permissions: {}
+
+concurrency:
+ group: "gh-aw-${{ github.workflow }}-${{ github.event.issue.number || github.run_id }}"
+
+run-name: "Add Community Bundle from Issue Submission"
+
+jobs:
+ activation:
+ needs: pre_activation
+ if: >
+ needs.pre_activation.outputs.activated == 'true' && (github.event_name != 'issues' || github.event.action != 'labeled' ||
+ github.event.label.name == 'bundle-submission')
+ runs-on: ubuntu-slim
+ permissions:
+ actions: read
+ contents: read
+ env:
+ GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
+ outputs:
+ body: ${{ steps.sanitized.outputs.body }}
+ comment_id: ""
+ comment_repo: ""
+ daily_ai_credits_exceeded: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_exceeded == 'true' }}
+ daily_ai_credits_threshold: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_threshold || '' }}
+ daily_ai_credits_total_effective_tokens: ${{ steps.daily-effective-workflow-guardrail.outputs.daily_ai_credits_total_effective_tokens || '' }}
+ engine_id: ${{ steps.generate_aw_info.outputs.engine_id }}
+ lockdown_check_failed: ${{ steps.generate_aw_info.outputs.lockdown_check_failed == 'true' }}
+ model: ${{ steps.generate_aw_info.outputs.model }}
+ secret_verification_result: ${{ steps.validate-secret.outputs.verification_result }}
+ setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
+ setup-span-id: ${{ steps.setup.outputs.span-id }}
+ setup-trace-id: ${{ steps.setup.outputs.trace-id }}
+ stale_lock_file_failed: ${{ steps.check-lock-file.outputs.stale_lock_file_failed == 'true' }}
+ text: ${{ steps.sanitized.outputs.text }}
+ title: ${{ steps.sanitized.outputs.title }}
+ steps:
+ - name: Setup Scripts
+ id: setup
+ uses: github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ trace-id: ${{ needs.pre_activation.outputs.setup-trace-id }}
+ parent-span-id: ${{ needs.pre_activation.outputs.setup-parent-span-id || needs.pre_activation.outputs.setup-span-id }}
+ safe-output-artifact-client: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
+ env:
+ GH_AW_SETUP_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/add-community-bundle.lock.yml@${{ github.ref }}
+ GH_AW_INFO_VERSION: "1.0.60"
+ GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_ENGINE_ID: "copilot"
+ - name: Generate agentic run info
+ id: generate_aw_info
+ env:
+ GH_AW_INFO_ENGINE_ID: "copilot"
+ GH_AW_INFO_ENGINE_NAME: "GitHub Copilot CLI"
+ GH_AW_INFO_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
+ GH_AW_INFO_VERSION: "1.0.60"
+ GH_AW_INFO_AGENT_VERSION: "1.0.60"
+ GH_AW_INFO_CLI_VERSION: "v0.79.8"
+ GH_AW_INFO_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_INFO_EXPERIMENTAL: "false"
+ GH_AW_INFO_SUPPORTS_TOOLS_ALLOWLIST: "true"
+ GH_AW_INFO_STAGED: "false"
+ GH_AW_INFO_ALLOWED_DOMAINS: '["defaults"]'
+ GH_AW_INFO_FIREWALL_ENABLED: "true"
+ GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_AWMG_VERSION: ""
+ GH_AW_INFO_FIREWALL_TYPE: "squid"
+ GH_AW_INFO_FRONTMATTER_EMOJI: "š¦"
+ GH_AW_COMPILED_STRICT: "true"
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_aw_info.cjs');
+ await main(core, context);
+ - name: Check daily workflow token guardrail
+ id: daily-effective-workflow-guardrail
+ if: ${{ env.GH_AW_MAX_DAILY_AI_CREDITS != '' }}
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_WORKFLOW_ID: "add-community-bundle"
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_WORKFLOW_DISPATCH_AW_CONTEXT: ${{ github.event.inputs.aw_context || '' }}
+ GH_AW_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ GH_AW_MAX_DAILY_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_DAILY_AI_CREDITS || '5000' }}
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/check_daily_aic_workflow_guardrail.cjs');
+ await main();
+ - name: Validate COPILOT_GITHUB_TOKEN secret
+ id: validate-secret
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_multi_secret.sh" COPILOT_GITHUB_TOKEN 'GitHub Copilot CLI' https://github.github.com/gh-aw/reference/engines/#github-copilot-default
+ env:
+ COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ - name: Checkout .github and .agents folders
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ sparse-checkout: |
+ .github
+ .agents
+ .antigravity
+ .claude
+ .codex
+ .crush
+ .gemini
+ .opencode
+ .pi
+ sparse-checkout-cone-mode: true
+ fetch-depth: 1
+ - name: Save agent config folders for base branch restoration
+ env:
+ GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi"
+ GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
+ # poutine:ignore untrusted_checkout_exec
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/save_base_github_folders.sh"
+ - name: Check workflow lock file
+ id: check-lock-file
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_WORKFLOW_FILE: "add-community-bundle.lock.yml"
+ GH_AW_CONTEXT_WORKFLOW_REF: "${{ github.workflow_ref }}"
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/check_workflow_timestamp_api.cjs');
+ await main();
+ - name: Check compile-agentic version
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_COMPILED_VERSION: "v0.79.8"
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/check_version_updates.cjs');
+ await main();
+ - name: Compute current body text
+ id: sanitized
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/compute_text.cjs');
+ await main();
+ - name: Create prompt with built-in context
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_SAFE_OUTPUTS: ${{ runner.temp }}/gh-aw/safeoutputs/outputs.jsonl
+ GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
+ GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
+ GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
+ GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
+ GH_AW_GITHUB_ACTOR: ${{ github.actor }}
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
+ GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ # poutine:ignore untrusted_checkout_exec
+ run: |
+ bash "${RUNNER_TEMP}/gh-aw/actions/create_prompt_first.sh"
+ {
+ cat << 'GH_AW_PROMPT_07ade6c8459f95fd_EOF'
+
+ GH_AW_PROMPT_07ade6c8459f95fd_EOF
+ cat "${RUNNER_TEMP}/gh-aw/prompts/xpia.md"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/temp_folder_prompt.md"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/markdown.md"
+ cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_prompt.md"
+ cat << 'GH_AW_PROMPT_07ade6c8459f95fd_EOF'
+
+ Tools: add_comment(max:2), create_pull_request, add_labels(max:3), remove_labels, missing_tool, missing_data, noop
+ GH_AW_PROMPT_07ade6c8459f95fd_EOF
+ cat "${RUNNER_TEMP}/gh-aw/prompts/safe_outputs_create_pull_request.md"
+ cat << 'GH_AW_PROMPT_07ade6c8459f95fd_EOF'
+
+ GH_AW_PROMPT_07ade6c8459f95fd_EOF
+ cat "${RUNNER_TEMP}/gh-aw/prompts/mcp_cli_tools_prompt.md"
+ cat << 'GH_AW_PROMPT_07ade6c8459f95fd_EOF'
+
+ The following GitHub context information is available for this workflow:
+ {{#if github.actor}}
+ - **actor**: __GH_AW_GITHUB_ACTOR__
+ {{/if}}
+ {{#if github.repository}}
+ - **repository**: __GH_AW_GITHUB_REPOSITORY__
+ {{/if}}
+ {{#if github.workspace}}
+ - **workspace**: __GH_AW_GITHUB_WORKSPACE__
+ {{/if}}
+ {{#if github.event.issue.number || (github.aw.context.item_type == 'issue' && github.aw.context.item_number)}}
+ - **issue-number**: #__GH_AW_EXPR_802A9F6A__
+ {{/if}}
+ {{#if github.event.discussion.number || (github.aw.context.item_type == 'discussion' && github.aw.context.item_number)}}
+ - **discussion-number**: #__GH_AW_EXPR_1A3A194A__
+ {{/if}}
+ {{#if github.event.pull_request.number || (github.aw.context.item_type == 'pull_request' && github.aw.context.item_number)}}
+ - **pull-request-number**: #__GH_AW_EXPR_463A214A__
+ {{/if}}
+ {{#if github.event.comment.id || github.aw.context.comment_id}}
+ - **comment-id**: __GH_AW_EXPR_FF1D34CE__
+ {{/if}}
+ {{#if github.run_id}}
+ - **workflow-run-id**: __GH_AW_GITHUB_RUN_ID__
+ {{/if}}
+ - **checkouts**: The following repositories have been checked out and are available in the workspace:
+ - repo `__GH_AW_GITHUB_REPOSITORY__` ā `$GITHUB_WORKSPACE` (cwd) [full history, all branches available as remote-tracking refs]
+ - **Note**: If a branch you need is not in the list above and is not listed as an additional fetched ref, it has NOT been checked out. For private repositories you cannot fetch it. If the branch is required and not available, exit with an error and ask the user to add it to the `fetch:` option of the `checkout:` configuration (e.g., `fetch: ["refs/pulls/open/*"]` for all open PR refs, or `fetch: ["main", "feature/my-branch"]` for specific branches).
+ - **Warning: No git credentials are available to the agent.** Credentials are
+ intentionally removed after the checkout step for security. This means any git
+ operation that needs to authenticate to the remote will fail. In private repositories, that includes:
+ - `git fetch`, `git pull`, `git clone`, and `git push` (direct push, not via safe-output tools)
+ - Checking out or switching to a remote branch that is not already fetched
+ - Deepening a shallow clone (`git fetch --unshallow`)
+ - On-demand blob fetches in partial/blobless clones (operations on files not in the initial checkout)
+ Do NOT attempt to configure credentials, run `git credential fill`, or modify `.gitconfig` ā
+ authentication will not succeed. If you encounter credential prompts or authentication errors,
+ stop immediately and report the limitation rather than spending turns trying to work around it.
+
+
+ GH_AW_PROMPT_07ade6c8459f95fd_EOF
+ cat "${RUNNER_TEMP}/gh-aw/prompts/github_mcp_tools_with_safeoutputs_prompt.md"
+ cat << 'GH_AW_PROMPT_07ade6c8459f95fd_EOF'
+
+ {{#runtime-import .github/workflows/add-community-bundle.md}}
+ GH_AW_PROMPT_07ade6c8459f95fd_EOF
+ } > "$GH_AW_PROMPT"
+ - name: Interpolate variables and render templates
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_ENGINE_ID: "copilot"
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/interpolate_prompt.cjs');
+ await main();
+ - name: Substitute placeholders
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_EXPR_1A3A194A: ${{ github.event.discussion.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'discussion' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
+ GH_AW_EXPR_463A214A: ${{ github.event.pull_request.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'pull_request' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
+ GH_AW_EXPR_802A9F6A: ${{ github.event.issue.number || (fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_type == 'issue' && fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').item_number) }}
+ GH_AW_EXPR_FF1D34CE: ${{ github.event.comment.id || fromJSON(github.event.inputs.aw_context || github.event.client_payload.aw_context || '{}').comment_id }}
+ GH_AW_GITHUB_ACTOR: ${{ github.actor }}
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: ${{ github.event.issue.number }}
+ GH_AW_GITHUB_REPOSITORY: ${{ github.repository }}
+ GH_AW_GITHUB_RUN_ID: ${{ github.run_id }}
+ GH_AW_GITHUB_WORKSPACE: ${{ github.workspace }}
+ GH_AW_MCP_CLI_SERVERS_LIST: '- `safeoutputs` ā run `safeoutputs --help` to see available tools'
+ GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: ${{ needs.pre_activation.outputs.activated }}
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+
+ const substitutePlaceholders = require('${{ runner.temp }}/gh-aw/actions/substitute_placeholders.cjs');
+
+ // Call the substitution function
+ return await substitutePlaceholders({
+ file: process.env.GH_AW_PROMPT,
+ substitutions: {
+ GH_AW_EXPR_1A3A194A: process.env.GH_AW_EXPR_1A3A194A,
+ GH_AW_EXPR_463A214A: process.env.GH_AW_EXPR_463A214A,
+ GH_AW_EXPR_802A9F6A: process.env.GH_AW_EXPR_802A9F6A,
+ GH_AW_EXPR_FF1D34CE: process.env.GH_AW_EXPR_FF1D34CE,
+ GH_AW_GITHUB_ACTOR: process.env.GH_AW_GITHUB_ACTOR,
+ GH_AW_GITHUB_EVENT_ISSUE_NUMBER: process.env.GH_AW_GITHUB_EVENT_ISSUE_NUMBER,
+ GH_AW_GITHUB_REPOSITORY: process.env.GH_AW_GITHUB_REPOSITORY,
+ GH_AW_GITHUB_RUN_ID: process.env.GH_AW_GITHUB_RUN_ID,
+ GH_AW_GITHUB_WORKSPACE: process.env.GH_AW_GITHUB_WORKSPACE,
+ GH_AW_MCP_CLI_SERVERS_LIST: process.env.GH_AW_MCP_CLI_SERVERS_LIST,
+ GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED: process.env.GH_AW_NEEDS_PRE_ACTIVATION_OUTPUTS_ACTIVATED
+ }
+ });
+ - name: Validate prompt placeholders
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ # poutine:ignore untrusted_checkout_exec
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/validate_prompt_placeholders.sh"
+ - name: Print prompt
+ env:
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ # poutine:ignore untrusted_checkout_exec
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/print_prompt_summary.sh"
+ - name: Upload activation artifact
+ if: success()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: activation
+ include-hidden-files: true
+ path: |
+ /tmp/gh-aw/aw_info.json
+ /tmp/gh-aw/models.json
+ /tmp/gh-aw/aw-prompts/prompt.txt
+ /tmp/gh-aw/aw-prompts/prompt-template.txt
+ /tmp/gh-aw/aw-prompts/prompt-import-tree.json
+ /tmp/gh-aw/github_rate_limits.jsonl
+ /tmp/gh-aw/base
+ /tmp/gh-aw/.github/agents
+ /tmp/gh-aw/.github/skills
+ if-no-files-found: ignore
+ retention-days: 1
+
+ agent:
+ needs: activation
+ if: needs.activation.outputs.daily_ai_credits_exceeded != 'true'
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ issues: read
+ env:
+ DEFAULT_BRANCH: ${{ github.event.repository.default_branch }}
+ GH_AW_ASSETS_ALLOWED_EXTS: ""
+ GH_AW_ASSETS_BRANCH: ""
+ GH_AW_ASSETS_MAX_SIZE_KB: 0
+ GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
+ GH_AW_WORKFLOW_ID_SANITIZED: addcommunitybundle
+ outputs:
+ agentic_engine_timeout: ${{ steps.detect-agent-errors.outputs.agentic_engine_timeout || 'false' }}
+ ai_credits_rate_limit_error: ${{ steps.parse-mcp-gateway.outputs.ai_credits_rate_limit_error || 'false' }}
+ aic: ${{ steps.parse-mcp-gateway.outputs.aic }}
+ ambient_context: ${{ steps.parse-mcp-gateway.outputs.ambient_context }}
+ checkout_pr_success: ${{ steps.checkout-pr.outputs.checkout_pr_success || 'true' }}
+ effective_tokens: ${{ steps.parse-mcp-gateway.outputs.effective_tokens }}
+ has_patch: ${{ steps.collect_output.outputs.has_patch }}
+ inference_access_error: ${{ steps.detect-agent-errors.outputs.inference_access_error || 'false' }}
+ mcp_policy_error: ${{ steps.detect-agent-errors.outputs.mcp_policy_error || 'false' }}
+ model: ${{ needs.activation.outputs.model }}
+ model_not_supported_error: ${{ steps.detect-agent-errors.outputs.model_not_supported_error || 'false' }}
+ output: ${{ steps.collect_output.outputs.output }}
+ output_types: ${{ steps.collect_output.outputs.output_types }}
+ setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
+ setup-span-id: ${{ steps.setup.outputs.span-id }}
+ setup-trace-id: ${{ steps.setup.outputs.trace-id }}
+ unknown_model_ai_credits: ${{ steps.parse-mcp-gateway.outputs.unknown_model_ai_credits || 'false' }}
+ steps:
+ - name: Setup Scripts
+ id: setup
+ uses: github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ trace-id: ${{ needs.activation.outputs.setup-trace-id }}
+ parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
+ env:
+ GH_AW_SETUP_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/add-community-bundle.lock.yml@${{ github.ref }}
+ GH_AW_INFO_VERSION: "1.0.60"
+ GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_ENGINE_ID: "copilot"
+ - name: Set runtime paths
+ id: set-runtime-paths
+ run: |
+ {
+ echo "GH_AW_SAFE_OUTPUTS=${RUNNER_TEMP}/gh-aw/safeoutputs/outputs.jsonl"
+ echo "GH_AW_SAFE_OUTPUTS_CONFIG_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/config.json"
+ echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
+ } >> "$GITHUB_OUTPUT"
+ - name: Checkout repository
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ fetch-depth: 0
+ - name: Create gh-aw temp directory
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/create_gh_aw_tmp_dir.sh"
+ - name: Configure gh CLI for GitHub Enterprise
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/configure_gh_for_ghe.sh"
+ env:
+ GH_TOKEN: ${{ github.token }}
+ - name: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ GITHUB_TOKEN: ${{ github.token }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ git config --global am.keepcr true
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - name: Checkout PR branch
+ id: checkout-pr
+ if: |
+ github.event.pull_request || github.event.issue.pull_request || github.event_name == 'workflow_dispatch' && fromJSON(github.event.inputs.aw_context || '{}').item_type == 'pull_request'
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/checkout_pr_branch.cjs');
+ await main();
+ - name: Install GitHub Copilot CLI
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60
+ env:
+ GH_HOST: github.com
+ - name: Install AWF binary
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2
+ - name: Parse integrity filter lists
+ id: parse-guard-vars
+ env:
+ GH_AW_BLOCKED_USERS_VAR: ${{ vars.GH_AW_GITHUB_BLOCKED_USERS || '' }}
+ GH_AW_TRUSTED_USERS_VAR: ${{ vars.GH_AW_GITHUB_TRUSTED_USERS || '' }}
+ GH_AW_APPROVAL_LABELS_VAR: ${{ vars.GH_AW_GITHUB_APPROVAL_LABELS || '' }}
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/parse_guard_list.sh"
+ - name: Download activation artifact
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: activation
+ path: /tmp/gh-aw
+ - name: Restore agent config folders from base branch
+ if: steps.checkout-pr.outcome == 'success'
+ env:
+ GH_AW_AGENT_FOLDERS: ".agents .antigravity .claude .codex .crush .gemini .github .opencode .pi"
+ GH_AW_AGENT_FILES: ".crush.json AGENTS.md ANTIGRAVITY.md CLAUDE.md GEMINI.md PI.md opencode.jsonc"
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_base_github_folders.sh"
+ - name: Restore inline sub-agents from activation artifact
+ env:
+ GH_AW_SUB_AGENT_DIR: ".github/agents"
+ GH_AW_SUB_AGENT_EXT: ".agent.md"
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_sub_agents.sh"
+ - name: Restore inline skills from activation artifact
+ env:
+ GH_AW_SKILL_DIR: ".github/skills"
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/restore_inline_skills.sh"
+ - name: Download container images
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4 ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591 ghcr.io/github/gh-aw-mcpg:v0.3.25@sha256:c10331ad17668ef89f38f5e356678788a40b0cd5fef96e8f92e1d9c1de47cbaa ghcr.io/github/github-mcp-server:v1.1.2@sha256:30197479d8036c7811892bc07e06f9a05c9ef3cdd79bc59f256d50647f95788c
+ - name: Generate Safe Outputs Config
+ run: |
+ mkdir -p "${RUNNER_TEMP}/gh-aw/safeoutputs"
+ mkdir -p /tmp/gh-aw/safeoutputs
+ mkdir -p /tmp/gh-aw/mcp-logs/safeoutputs
+ cat > "${RUNNER_TEMP}/gh-aw/safeoutputs/config.json" << 'GH_AW_SAFE_OUTPUTS_CONFIG_4035fa7fdfc4247a_EOF'
+ {"add_comment":{"max":2},"add_labels":{"allowed":["bundle-submission","validation-passed","validation-failed","needs-info"],"max":3},"create_pull_request":{"allowed_files":["bundles/catalog.community.json","docs/community/bundles.md"],"draft":true,"labels":["bundle-submission","automated"],"max":1,"max_patch_files":100,"max_patch_size":1024,"protect_top_level_dot_folders":true,"protected_files":["package.json","bun.lockb","bunfig.toml","deno.json","deno.jsonc","deno.lock","global.json","NuGet.Config","Directory.Packages.props","mix.exs","mix.lock","go.mod","go.sum","stack.yaml","stack.yaml.lock","pom.xml","build.gradle","build.gradle.kts","settings.gradle","settings.gradle.kts","gradle.properties","package-lock.json","yarn.lock","pnpm-lock.yaml","npm-shrinkwrap.json","requirements.txt","Pipfile","Pipfile.lock","pyproject.toml","setup.py","setup.cfg","Gemfile","Gemfile.lock","uv.lock","CODEOWNERS","DESIGN.md","CONTRIBUTING.md","SECURITY.md","CODE_OF_CONDUCT.md","AGENTS.md","CLAUDE.md","GEMINI.md"],"protected_files_policy":"blocked","title_prefix":"[bundle] "},"create_report_incomplete_issue":{},"missing_data":{},"missing_tool":{},"noop":{"max":1,"report-as-issue":"false"},"remove_labels":{"allowed":["validation-passed","validation-failed","needs-info"]},"report_incomplete":{}}
+ GH_AW_SAFE_OUTPUTS_CONFIG_4035fa7fdfc4247a_EOF
+ - name: Generate Safe Outputs Tools
+ env:
+ GH_AW_TOOLS_META_JSON: |
+ {
+ "description_suffixes": {
+ "add_comment": " CONSTRAINTS: Maximum 2 comment(s) can be added. Supports reply_to_id for discussion threading.",
+ "add_labels": " CONSTRAINTS: Maximum 3 label(s) can be added. Only these labels are allowed: [\"bundle-submission\" \"validation-passed\" \"validation-failed\" \"needs-info\"].",
+ "create_pull_request": " CONSTRAINTS: Maximum 1 pull request(s) can be created. Title will be prefixed with \"[bundle] \". Labels [\"bundle-submission\" \"automated\"] will be automatically added. PRs will be created as drafts.",
+ "remove_labels": " CONSTRAINTS: Only these labels can be removed: [validation-passed validation-failed needs-info]."
+ },
+ "repo_params": {},
+ "dynamic_tools": []
+ }
+ GH_AW_VALIDATION_JSON: |
+ {
+ "add_comment": {
+ "defaultMax": 1,
+ "fields": {
+ "body": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "item_number": {
+ "issueOrPRNumber": true
+ },
+ "reply_to_id": {
+ "type": "string",
+ "maxLength": 256
+ },
+ "repo": {
+ "type": "string",
+ "maxLength": 256
+ }
+ }
+ },
+ "add_labels": {
+ "defaultMax": 5,
+ "fields": {
+ "item_number": {
+ "issueNumberOrTemporaryId": true
+ },
+ "labels": {
+ "required": true,
+ "type": "array",
+ "itemType": "string",
+ "itemSanitize": true,
+ "itemMaxLength": 128
+ },
+ "repo": {
+ "type": "string",
+ "maxLength": 256
+ }
+ }
+ },
+ "create_pull_request": {
+ "defaultMax": 1,
+ "fields": {
+ "base": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ },
+ "body": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "branch": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "draft": {
+ "type": "boolean"
+ },
+ "labels": {
+ "type": "array",
+ "itemType": "string",
+ "itemSanitize": true,
+ "itemMaxLength": 128
+ },
+ "repo": {
+ "type": "string",
+ "maxLength": 256
+ },
+ "title": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ }
+ }
+ },
+ "missing_data": {
+ "defaultMax": 20,
+ "fields": {
+ "alternatives": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "context": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "data_type": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ },
+ "reason": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ }
+ }
+ },
+ "missing_tool": {
+ "defaultMax": 20,
+ "fields": {
+ "alternatives": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 512
+ },
+ "reason": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 256
+ },
+ "tool": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 128
+ }
+ }
+ },
+ "noop": {
+ "defaultMax": 1,
+ "fields": {
+ "message": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ }
+ }
+ },
+ "remove_labels": {
+ "defaultMax": 5,
+ "fields": {
+ "item_number": {
+ "issueNumberOrTemporaryId": true
+ },
+ "labels": {
+ "required": true,
+ "type": "array",
+ "itemType": "string",
+ "itemSanitize": true,
+ "itemMaxLength": 128
+ },
+ "repo": {
+ "type": "string",
+ "maxLength": 256
+ }
+ }
+ },
+ "report_incomplete": {
+ "defaultMax": 5,
+ "fields": {
+ "details": {
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 65000
+ },
+ "reason": {
+ "required": true,
+ "type": "string",
+ "sanitize": true,
+ "maxLength": 1024
+ }
+ }
+ }
+ }
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/generate_safe_outputs_tools.cjs');
+ await main();
+ - name: Generate Safe Outputs MCP Server Config
+ id: safe-outputs-config
+ run: |
+ # Generate a secure random API key (360 bits of entropy, 40+ chars)
+ # Mask immediately to prevent timing vulnerabilities
+ API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "::add-mask::${API_KEY}"
+
+ PORT=3001
+
+ # Set outputs for next steps
+ {
+ echo "safe_outputs_api_key=${API_KEY}"
+ echo "safe_outputs_port=${PORT}"
+ } >> "$GITHUB_OUTPUT"
+
+ echo "Safe Outputs MCP server will run on port ${PORT}"
+
+ - name: Start Safe Outputs MCP HTTP Server
+ id: safe-outputs-start
+ env:
+ DEBUG: '*'
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-config.outputs.safe_outputs_port }}
+ GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-config.outputs.safe_outputs_api_key }}
+ GH_AW_SAFE_OUTPUTS_TOOLS_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/tools.json
+ GH_AW_SAFE_OUTPUTS_CONFIG_PATH: ${{ runner.temp }}/gh-aw/safeoutputs/config.json
+ GH_AW_MCP_LOG_DIR: /tmp/gh-aw/mcp-logs/safeoutputs
+ run: |
+ # Environment variables are set above to prevent template injection
+ export DEBUG
+ export GH_AW_SAFE_OUTPUTS
+ export GH_AW_SAFE_OUTPUTS_PORT
+ export GH_AW_SAFE_OUTPUTS_API_KEY
+ export GH_AW_SAFE_OUTPUTS_TOOLS_PATH
+ export GH_AW_SAFE_OUTPUTS_CONFIG_PATH
+ export GH_AW_MCP_LOG_DIR
+
+ bash "${RUNNER_TEMP}/gh-aw/actions/start_safe_outputs_server.sh"
+
+ - name: Start MCP Gateway
+ id: start-mcp-gateway
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_SAFE_OUTPUTS_API_KEY: ${{ steps.safe-outputs-start.outputs.api_key }}
+ GH_AW_SAFE_OUTPUTS_PORT: ${{ steps.safe-outputs-start.outputs.port }}
+ GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ run: |
+ set -eo pipefail
+ mkdir -p "${RUNNER_TEMP}/gh-aw/mcp-config"
+
+ # Export gateway environment variables for MCP config and gateway script
+ export MCP_GATEWAY_PORT="8080"
+ export MCP_GATEWAY_DOMAIN="host.docker.internal"
+ export MCP_GATEWAY_HOST_DOMAIN="localhost"
+ MCP_GATEWAY_API_KEY=$(openssl rand -base64 45 | tr -d '/+=')
+ echo "::add-mask::${MCP_GATEWAY_API_KEY}"
+ export MCP_GATEWAY_API_KEY
+ export MCP_GATEWAY_PAYLOAD_DIR="/tmp/gh-aw/mcp-payloads"
+ mkdir -p "${MCP_GATEWAY_PAYLOAD_DIR}"
+ export MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD="524288"
+ export DEBUG="*"
+
+ export GH_AW_ENGINE="copilot"
+ MCP_GATEWAY_UID=$(id -u 2>/dev/null || echo '0')
+ MCP_GATEWAY_GID=$(id -g 2>/dev/null || echo '0')
+ case "${DOCKER_HOST:-}" in
+ unix://* ) DOCKER_SOCK_PATH="${DOCKER_HOST#unix://}" ;;
+ /* ) DOCKER_SOCK_PATH="$DOCKER_HOST" ;;
+ * ) DOCKER_SOCK_PATH=/var/run/docker.sock ;;
+ esac
+ DOCKER_SOCK_GID=$(stat -c '%g' "$DOCKER_SOCK_PATH" 2>/dev/null || echo '0')
+ export MCP_GATEWAY_DOCKER_COMMAND='docker run -i --rm --network host --add-host host.docker.internal:127.0.0.1 --user '"${MCP_GATEWAY_UID}"':'"${MCP_GATEWAY_GID}"' --group-add '"${DOCKER_SOCK_GID}"' -v '"${DOCKER_SOCK_PATH}"':/var/run/docker.sock -e MCP_GATEWAY_PORT -e MCP_GATEWAY_DOMAIN -e MCP_GATEWAY_API_KEY -e MCP_GATEWAY_PAYLOAD_DIR -e MCP_GATEWAY_PAYLOAD_SIZE_THRESHOLD -e DOCKER_HOST=unix:///var/run/docker.sock -e DEBUG -e MCP_GATEWAY_LOG_DIR -e GH_AW_MCP_LOG_DIR -e GH_AW_SAFE_OUTPUTS -e GH_AW_SAFE_OUTPUTS_CONFIG_PATH -e GH_AW_SAFE_OUTPUTS_TOOLS_PATH -e GH_AW_ASSETS_BRANCH -e GH_AW_ASSETS_MAX_SIZE_KB -e GH_AW_ASSETS_ALLOWED_EXTS -e DEFAULT_BRANCH -e GITHUB_MCP_SERVER_TOKEN -e GITHUB_MCP_GUARD_MIN_INTEGRITY -e GITHUB_MCP_GUARD_REPOS -e GITHUB_REPOSITORY -e GITHUB_SERVER_URL -e GITHUB_SHA -e GITHUB_WORKSPACE -e GITHUB_TOKEN -e GITHUB_RUN_ID -e GITHUB_RUN_NUMBER -e GITHUB_RUN_ATTEMPT -e GITHUB_JOB -e GITHUB_ACTION -e GITHUB_EVENT_NAME -e GITHUB_EVENT_PATH -e GITHUB_ACTOR -e GITHUB_ACTOR_ID -e GITHUB_TRIGGERING_ACTOR -e GITHUB_WORKFLOW -e GITHUB_WORKFLOW_REF -e GITHUB_WORKFLOW_SHA -e GITHUB_REF -e GITHUB_REF_NAME -e GITHUB_REF_TYPE -e GITHUB_HEAD_REF -e GITHUB_BASE_REF -e GH_AW_SAFE_OUTPUTS_PORT -e GH_AW_SAFE_OUTPUTS_API_KEY -v /tmp/gh-aw/mcp-payloads:/tmp/gh-aw/mcp-payloads:rw -v /opt:/opt:ro -v /tmp:/tmp:rw -v '"${GITHUB_WORKSPACE}"':'"${GITHUB_WORKSPACE}"':rw ghcr.io/github/gh-aw-mcpg:v0.3.25'
+
+ mkdir -p "$HOME/.copilot"
+ GH_AW_NODE=$(which node 2>/dev/null || command -v node 2>/dev/null || echo node)
+ cat << GH_AW_MCP_CONFIG_e6668539766ebde6_EOF | "$GH_AW_NODE" "${RUNNER_TEMP}/gh-aw/actions/start_mcp_gateway.cjs"
+ {
+ "mcpServers": {
+ "github": {
+ "type": "stdio",
+ "container": "ghcr.io/github/github-mcp-server:v1.1.2",
+ "env": {
+ "GITHUB_HOST": "\${GITHUB_SERVER_URL}",
+ "GITHUB_PERSONAL_ACCESS_TOKEN": "\${GITHUB_MCP_SERVER_TOKEN}",
+ "GITHUB_READ_ONLY": "1",
+ "GITHUB_TOOLSETS": "issues,repos"
+ },
+ "guard-policies": {
+ "allow-only": {
+ "approval-labels": ${{ steps.parse-guard-vars.outputs.approval_labels }},
+ "blocked-users": ${{ steps.parse-guard-vars.outputs.blocked_users }},
+ "min-integrity": "none",
+ "repos": "all",
+ "trusted-users": ${{ steps.parse-guard-vars.outputs.trusted_users }}
+ }
+ }
+ },
+ "safeoutputs": {
+ "type": "http",
+ "url": "http://host.docker.internal:$GH_AW_SAFE_OUTPUTS_PORT",
+ "headers": {
+ "Authorization": "\${GH_AW_SAFE_OUTPUTS_API_KEY}"
+ },
+ "guard-policies": {
+ "write-sink": {
+ "accept": [
+ "*"
+ ]
+ }
+ }
+ }
+ },
+ "gateway": {
+ "port": $MCP_GATEWAY_PORT,
+ "domain": "${MCP_GATEWAY_DOMAIN}",
+ "apiKey": "${MCP_GATEWAY_API_KEY}",
+ "payloadDir": "${MCP_GATEWAY_PAYLOAD_DIR}"
+ }
+ }
+ GH_AW_MCP_CONFIG_e6668539766ebde6_EOF
+ - name: Mount MCP servers as CLIs
+ id: mount-mcp-clis
+ continue-on-error: true
+ env:
+ MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
+ MCP_GATEWAY_DOMAIN: ${{ steps.start-mcp-gateway.outputs.gateway-domain }}
+ MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/mount_mcp_as_cli.cjs');
+ await main();
+ - name: Clean credentials
+ continue-on-error: true
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/clean_git_credentials.sh"
+ - name: Audit pre-agent workspace
+ id: pre_agent_audit
+ continue-on-error: true
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/audit_pre_agent_workspace.sh"
+ - name: Execute GitHub Copilot CLI
+ id: agentic_execution
+ # Copilot CLI tool arguments (sorted):
+ # --allow-tool github
+ # --allow-tool safeoutputs
+ # --allow-tool shell(cat)
+ # --allow-tool shell(date)
+ # --allow-tool shell(echo)
+ # --allow-tool shell(git add:*)
+ # --allow-tool shell(git branch:*)
+ # --allow-tool shell(git checkout:*)
+ # --allow-tool shell(git commit:*)
+ # --allow-tool shell(git merge:*)
+ # --allow-tool shell(git rm:*)
+ # --allow-tool shell(git status)
+ # --allow-tool shell(git switch:*)
+ # --allow-tool shell(grep)
+ # --allow-tool shell(head)
+ # --allow-tool shell(jq)
+ # --allow-tool shell(ls)
+ # --allow-tool shell(printf)
+ # --allow-tool shell(pwd)
+ # --allow-tool shell(python3)
+ # --allow-tool shell(safeoutputs:*)
+ # --allow-tool shell(sort)
+ # --allow-tool shell(tail)
+ # --allow-tool shell(uniq)
+ # --allow-tool shell(wc)
+ # --allow-tool shell(yq)
+ # --allow-tool web_fetch
+ # --allow-tool write
+ timeout-minutes: 20
+ run: |
+ set -o pipefail
+ printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
+ trap 'rm -f "$HOME/.copilot/settings.json"' EXIT
+ mkdir -p "$HOME/.copilot"
+ printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json"
+ export XDG_CONFIG_HOME="$HOME"
+ export GH_AW_MCP_CONFIG="$HOME/.copilot/mcp-config.json"
+ touch /tmp/gh-aw/agent-step-summary.md
+ GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
+ export GH_AW_NODE_BIN
+ export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
+ (umask 177 && touch /tmp/gh-aw/agent-stdio.log)
+ GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}"
+ printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"api.snapcraft.io\",\"archive.ubuntu.com\",\"azure.archive.ubuntu.com\",\"crl.geotrust.com\",\"crl.globalsign.com\",\"crl.identrust.com\",\"crl.sectigo.com\",\"crl.thawte.com\",\"crl.usertrust.com\",\"crl.verisign.com\",\"crl3.digicert.com\",\"crl4.digicert.com\",\"crls.ssl.com\",\"github.com\",\"host.docker.internal\",\"json-schema.org\",\"json.schemastore.org\",\"keyserver.ubuntu.com\",\"ocsp.digicert.com\",\"ocsp.geotrust.com\",\"ocsp.globalsign.com\",\"ocsp.identrust.com\",\"ocsp.sectigo.com\",\"ocsp.ssl.com\",\"ocsp.thawte.com\",\"ocsp.usertrust.com\",\"ocsp.verisign.com\",\"packagecloud.io\",\"packages.cloud.google.com\",\"packages.microsoft.com\",\"ppa.launchpad.net\",\"raw.githubusercontent.com\",\"registry.npmjs.org\",\"s.symcb.com\",\"s.symcd.com\",\"security.ubuntu.com\",\"telemetry.enterprise.githubcopilot.com\",\"ts-crl.ws.symantec.com\",\"ts-ocsp.ws.symantec.com\",\"www.googleapis.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS},\"models\":{\"agent\":[\"sonnet-6x\",\"gpt-5.4\",\"gpt-5.3\",\"gemini-pro\",\"any\"],\"antigravity\":[\"copilot/antigravity*\",\"google/antigravity*\",\"gemini/antigravity*\"],\"any\":[\"copilot/*\",\"anthropic/*\",\"openai/*\",\"google/*\",\"gemini/*\"],\"claude\":[\"agent\"],\"codex\":[\"agent\"],\"coding\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\",\"gpt-5-codex\"],\"computer-use\":[\"copilot/*computer-use*\",\"google/*computer-use*\",\"gemini/*computer-use*\",\"openai/*computer-use*\"],\"copilot\":[\"agent\"],\"deep-research\":[\"copilot/deep-research*\",\"copilot/o3-deep-research*\",\"copilot/o4-mini-deep-research*\",\"google/deep-research*\",\"gemini/deep-research*\",\"openai/o3-deep-research*\",\"openai/o4-mini-deep-research*\"],\"gemini\":[\"agent\"],\"gemini-3-flash\":[\"copilot/gemini-3*flash*\",\"google/gemini-3*flash*\",\"gemini/gemini-3*flash*\"],\"gemini-3-pro\":[\"copilot/gemini-3*pro*\",\"google/gemini-3*pro*\",\"google/nano-banana*\",\"gemini/gemini-3*pro*\"],\"gemini-3.1-flash\":[\"copilot/gemini-3.1*flash*\",\"google/gemini-3.1*flash*\",\"gemini/gemini-3.1*flash*\"],\"gemini-3.1-pro\":[\"copilot/gemini-3.1*pro*\",\"google/gemini-3.1*pro*\",\"gemini/gemini-3.1*pro*\"],\"gemini-3.5-flash\":[\"copilot/gemini-3.5*flash*\",\"google/gemini-3.5*flash*\",\"gemini/gemini-3.5*flash*\"],\"gemini-flash\":[\"copilot/gemini-*flash*\",\"google/gemini-*flash*\",\"gemini/gemini-*flash*\"],\"gemini-flash-lite\":[\"copilot/gemini-*flash*lite*\",\"google/gemini-*flash*lite*\",\"gemini/gemini-*flash*lite*\"],\"gemini-pro\":[\"copilot/gemini-*pro*\",\"google/gemini-*pro*\",\"gemini/gemini-*pro*\"],\"gemma\":[\"copilot/gemma*\",\"google/gemma*\",\"gemini/gemma*\"],\"gpt-5\":[\"copilot/gpt-5*\",\"openai/gpt-5*\"],\"gpt-5-codex\":[\"copilot/gpt-5*codex*\",\"openai/gpt-5*codex*\"],\"gpt-5-mini\":[\"copilot/gpt-5*mini*\",\"openai/gpt-5*mini*\"],\"gpt-5-nano\":[\"copilot/gpt-5*nano*\",\"openai/gpt-5*nano*\"],\"gpt-5-pro\":[\"copilot/gpt-5*pro*\",\"openai/gpt-5*pro*\"],\"gpt-5.2\":[\"copilot/gpt-5.2*\",\"openai/gpt-5.2*\"],\"gpt-5.3\":[\"copilot/gpt-5.3*\",\"openai/gpt-5.3*\"],\"gpt-5.4\":[\"copilot/gpt-5.4*\",\"openai/gpt-5.4*\"],\"gpt-5.5\":[\"copilot/gpt-5.5*\",\"openai/gpt-5.5*\"],\"haiku\":[\"copilot/*haiku*\",\"anthropic/*haiku*\"],\"large\":[\"sonnet\",\"gpt-5-pro\",\"gpt-5\",\"gemini-pro\"],\"mai-code\":[\"copilot/MAI-Code*\",\"copilot/mai-code*\",\"openai/MAI-Code*\"],\"mini\":[\"haiku\",\"gpt-5-mini\",\"gpt-5-nano\",\"gemini-flash-lite\"],\"nano-banana\":[\"copilot/nano-banana*\",\"google/nano-banana*\",\"gemini/nano-banana*\"],\"opus\":[\"copilot/*opus*\",\"anthropic/*opus*\"],\"opusplan\":[\"opus?effort=high\"],\"reasoning\":[\"copilot/o1*\",\"copilot/o3*\",\"copilot/o4*\",\"openai/o1*\",\"openai/o3*\",\"openai/o4*\"],\"robotics\":[\"copilot/*robotics*\",\"google/*robotics*\",\"gemini/*robotics*\"],\"small\":[\"mini\"],\"small-agent\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash\"],\"sonnet\":[\"copilot/*sonnet*\",\"anthropic/*sonnet*\"],\"sonnet-6x\":[\"copilot/*sonnet-4.5*\",\"copilot/*sonnet-4.6*\",\"copilot/*sonnet-4-5-*\",\"anthropic/*sonnet-4-5-*\",\"copilot/*sonnet-4-6*\",\"anthropic/*sonnet-4-6*\"],\"summarization\":[\"haiku\",\"gpt-5-mini\",\"gemini-flash-lite\",\"mini\"],\"vision\":[\"copilot/gemini-*image*\",\"gemini/gemini-*image*\",\"copilot/gemini-*flash*\",\"gemini/gemini-*flash*\"]}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
+ cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
+ export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
+ GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS=""
+ if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
+ GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw"
+ fi
+ GH_AW_TOOL_CACHE_MOUNT=""
+ GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"
+ if [ -d "$GH_AW_TOOL_CACHE" ]; then
+ if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
+ GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
+ fi
+ elif [ -d "/home/runner/work/_tool" ]; then
+ GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro"
+ fi
+ # shellcheck disable=SC1003
+ sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --exclude-env GITHUB_MCP_SERVER_TOKEN --exclude-env MCP_GATEWAY_API_KEY --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
+ -- /bin/bash -c 'set +o histexpand; export PATH="${RUNNER_TEMP}/gh-aw/mcp-cli/bin:$PATH" && GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner ā check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-tool github --allow-tool safeoutputs --allow-tool '\''shell(cat)'\'' --allow-tool '\''shell(date)'\'' --allow-tool '\''shell(echo)'\'' --allow-tool '\''shell(git add:*)'\'' --allow-tool '\''shell(git branch:*)'\'' --allow-tool '\''shell(git checkout:*)'\'' --allow-tool '\''shell(git commit:*)'\'' --allow-tool '\''shell(git merge:*)'\'' --allow-tool '\''shell(git rm:*)'\'' --allow-tool '\''shell(git status)'\'' --allow-tool '\''shell(git switch:*)'\'' --allow-tool '\''shell(grep)'\'' --allow-tool '\''shell(head)'\'' --allow-tool '\''shell(jq)'\'' --allow-tool '\''shell(ls)'\'' --allow-tool '\''shell(printf)'\'' --allow-tool '\''shell(pwd)'\'' --allow-tool '\''shell(python3)'\'' --allow-tool '\''shell(safeoutputs:*)'\'' --allow-tool '\''shell(sort)'\'' --allow-tool '\''shell(tail)'\'' --allow-tool '\''shell(uniq)'\'' --allow-tool '\''shell(wc)'\'' --allow-tool '\''shell(yq)'\'' --allow-tool web_fetch --allow-tool write --allow-all-paths --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/agent-stdio.log
+ env:
+ AWF_REFLECT_ENABLED: 1
+ COPILOT_AGENT_RUNNER_TYPE: STANDALONE
+ COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
+ COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ COPILOT_MODEL: ${{ vars.GH_AW_MODEL_AGENT_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
+ GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
+ GH_AW_PHASE: agent
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_TIMEOUT_MINUTES: 20
+ GH_AW_VERSION: v0.79.8
+ GITHUB_API_URL: ${{ github.api_url }}
+ GITHUB_AW: true
+ GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
+ GITHUB_HEAD_REF: ${{ github.head_ref }}
+ GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN || secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ GITHUB_REF_NAME: ${{ github.ref_name }}
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+ GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
+ GIT_AUTHOR_NAME: github-actions[bot]
+ GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
+ GIT_COMMITTER_NAME: github-actions[bot]
+ RUNNER_TEMP: ${{ runner.temp }}
+ - name: Detect agent errors
+ if: always()
+ id: detect-agent-errors
+ continue-on-error: true
+ run: node "${RUNNER_TEMP}/gh-aw/actions/detect_agent_errors.cjs"
+ - name: Configure Git credentials
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ GITHUB_TOKEN: ${{ github.token }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ git config --global am.keepcr true
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${GITHUB_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - name: Copy Copilot session state files to logs
+ if: always()
+ continue-on-error: true
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/copy_copilot_session_state.sh"
+ - name: Stop MCP Gateway
+ if: always()
+ continue-on-error: true
+ env:
+ MCP_GATEWAY_PORT: ${{ steps.start-mcp-gateway.outputs.gateway-port }}
+ MCP_GATEWAY_API_KEY: ${{ steps.start-mcp-gateway.outputs.gateway-api-key }}
+ GATEWAY_PID: ${{ steps.start-mcp-gateway.outputs.gateway-pid }}
+ run: |
+ bash "${RUNNER_TEMP}/gh-aw/actions/stop_mcp_gateway.sh" "$GATEWAY_PID"
+ - name: Redact secrets in logs
+ if: always()
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/redact_secrets.cjs');
+ await main();
+ env:
+ GH_AW_SECRET_NAMES: 'COPILOT_GITHUB_TOKEN,GH_AW_GITHUB_MCP_SERVER_TOKEN,GH_AW_GITHUB_TOKEN,GITHUB_TOKEN'
+ SECRET_COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ SECRET_GH_AW_GITHUB_MCP_SERVER_TOKEN: ${{ secrets.GH_AW_GITHUB_MCP_SERVER_TOKEN }}
+ SECRET_GH_AW_GITHUB_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN }}
+ SECRET_GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+ - name: Append agent step summary
+ if: always()
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/append_agent_step_summary.sh"
+ - name: Copy Safe Outputs
+ if: always()
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ run: |
+ mkdir -p /tmp/gh-aw
+ cp "$GH_AW_SAFE_OUTPUTS" /tmp/gh-aw/safeoutputs.jsonl 2>/dev/null || true
+ - name: Ingest agent output
+ id: collect_output
+ if: always()
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_SAFE_OUTPUTS: ${{ steps.set-runtime-paths.outputs.GH_AW_SAFE_OUTPUTS }}
+ GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_API_URL: ${{ github.api_url }}
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/collect_ndjson_output.cjs');
+ await main();
+ - name: Parse agent logs for step summary
+ if: always()
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_AGENT_OUTPUT: /tmp/gh-aw/sandbox/agent/logs/
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_copilot_log.cjs');
+ await main();
+ - name: Parse MCP Gateway logs for step summary
+ if: always()
+ id: parse-mcp-gateway
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_mcp_gateway_log.cjs');
+ await main();
+ - name: Print firewall logs
+ if: always()
+ continue-on-error: true
+ env:
+ AWF_LOGS_DIR: /tmp/gh-aw/sandbox/firewall/logs
+ run: |
+ # Fix permissions on firewall logs/audit dirs so they can be uploaded as artifacts
+ # AWF runs with sudo, creating files owned by root
+ sudo chmod -R a+rX /tmp/gh-aw/sandbox/firewall 2>/dev/null || true
+ # Only run awf logs summary if awf command exists (it may not be installed if workflow failed before install step)
+ if command -v awf &> /dev/null; then
+ awf logs summary | tee -a "$GITHUB_STEP_SUMMARY"
+ else
+ echo 'AWF binary not installed, skipping firewall log summary'
+ fi
+ - name: Parse token usage for step summary
+ if: always()
+ continue-on-error: true
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs');
+ await main();
+ - name: Print AWF reflect summary
+ if: always()
+ continue-on-error: true
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/awf_reflect_summary.cjs');
+ await main();
+ - name: Write agent output placeholder if missing
+ if: always()
+ run: |
+ if [ ! -f /tmp/gh-aw/agent_output.json ]; then
+ echo '{"items":[]}' > /tmp/gh-aw/agent_output.json
+ fi
+ - name: Upload agent artifacts
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: agent
+ path: |
+ /tmp/gh-aw/aw-prompts/prompt.txt
+ /tmp/gh-aw/sandbox/agent/logs/
+ /tmp/gh-aw/redacted-urls.log
+ /tmp/gh-aw/mcp-logs/
+ /tmp/gh-aw/proxy-logs/
+ !/tmp/gh-aw/proxy-logs/proxy-tls/
+ /tmp/gh-aw/agent_usage.json
+ /tmp/gh-aw/agent-stdio.log
+ /tmp/gh-aw/pre-agent-audit.txt
+ /tmp/gh-aw/agent/
+ /tmp/gh-aw/github_rate_limits.jsonl
+ /tmp/gh-aw/safeoutputs.jsonl
+ /tmp/gh-aw/agent_output.json
+ /tmp/gh-aw/aw-*.patch
+ /tmp/gh-aw/aw-*.bundle
+ /tmp/gh-aw/awf-config.json
+ /tmp/gh-aw/sandbox/firewall/logs/
+ /tmp/gh-aw/sandbox/firewall/audit/
+ /tmp/gh-aw/sandbox/firewall/awf-reflect.json
+ if-no-files-found: ignore
+
+ conclusion:
+ needs:
+ - activation
+ - agent
+ - detection
+ - safe_outputs
+ if: >
+ always() && (needs.agent.result != 'skipped' || needs.activation.outputs.lockdown_check_failed == 'true' ||
+ needs.activation.outputs.stale_lock_file_failed == 'true' || needs.activation.outputs.daily_ai_credits_exceeded == 'true')
+ runs-on: ubuntu-slim
+ permissions:
+ contents: write
+ discussions: write
+ issues: write
+ pull-requests: write
+ concurrency:
+ group: "gh-aw-conclusion-add-community-bundle"
+ cancel-in-progress: false
+ queue: max
+ outputs:
+ incomplete_count: ${{ steps.report_incomplete.outputs.incomplete_count }}
+ noop_message: ${{ steps.noop.outputs.noop_message }}
+ tools_reported: ${{ steps.missing_tool.outputs.tools_reported }}
+ total_count: ${{ steps.missing_tool.outputs.total_count }}
+ steps:
+ - name: Setup Scripts
+ id: setup
+ uses: github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ trace-id: ${{ needs.activation.outputs.setup-trace-id }}
+ parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
+ env:
+ GH_AW_SETUP_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/add-community-bundle.lock.yml@${{ github.ref }}
+ GH_AW_INFO_VERSION: "1.0.60"
+ GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_ENGINE_ID: "copilot"
+ - name: Download agent output artifact
+ id: download-agent-output
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: agent
+ path: /tmp/gh-aw/
+ - name: Setup agent output environment variable
+ id: setup-agent-output-env
+ if: steps.download-agent-output.outcome == 'success'
+ run: |
+ mkdir -p /tmp/gh-aw/
+ find "/tmp/gh-aw/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
+ - name: Collect usage artifact files
+ if: always()
+ continue-on-error: true
+ run: |
+ mkdir -p /tmp/gh-aw/usage/agent /tmp/gh-aw/usage/detection
+ echo "Usage artifact source file status:"
+ for file in /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl; do
+ [ -f "$file" ] && echo "FOUND: $file" || echo "MISSING: $file"
+ done
+ [ -f /tmp/gh-aw/aw-info.jsonl ] && cp /tmp/gh-aw/aw-info.jsonl /tmp/gh-aw/usage/aw-info.jsonl || true
+ [ -f /tmp/gh-aw/agent_usage.jsonl ] && cp /tmp/gh-aw/agent_usage.jsonl /tmp/gh-aw/usage/agent_usage.jsonl || true
+ [ -f /tmp/gh-aw/detection_usage.jsonl ] && cp /tmp/gh-aw/detection_usage.jsonl /tmp/gh-aw/usage/detection_usage.jsonl || true
+ [ -f /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -f /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -f /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/agent/token_usage.jsonl || true
+ [ -f /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall-audit-logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -f /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/logs/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -f /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl ] && cp /tmp/gh-aw/threat-detection/sandbox/firewall/audit/api-proxy-logs/token-usage.jsonl /tmp/gh-aw/usage/detection/token_usage.jsonl || true
+ [ -f /tmp/gh-aw/usage/agent/token_usage.jsonl ] || : > /tmp/gh-aw/usage/agent/token_usage.jsonl
+ [ -f /tmp/gh-aw/usage/detection/token_usage.jsonl ] || : > /tmp/gh-aw/usage/detection/token_usage.jsonl
+ find /tmp/gh-aw/usage -type f -print | sort
+ - name: Upload usage artifact
+ if: always()
+ continue-on-error: true
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: usage
+ path: |
+ /tmp/gh-aw/usage/aw-info.jsonl
+ /tmp/gh-aw/usage/agent_usage.jsonl
+ /tmp/gh-aw/usage/detection_usage.jsonl
+ /tmp/gh-aw/usage/agent/token_usage.jsonl
+ /tmp/gh-aw/usage/detection/token_usage.jsonl
+ if-no-files-found: ignore
+ - name: Process no-op messages
+ id: noop
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_NOOP_MAX: "1"
+ GH_AW_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/add-community-bundle.md"
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_NOOP_REPORT_AS_ISSUE: "false"
+ GH_AW_AIC: ${{ needs.agent.outputs.aic }}
+ GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
+ GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
+ GH_AW_WORKFLOW_ID: "add-community-bundle"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_noop_message.cjs');
+ await main();
+ - name: Log detection run
+ id: detection_runs
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/add-community-bundle.md"
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }}
+ GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_detection_runs.cjs');
+ await main();
+ - name: Record missing tool
+ id: missing_tool
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_MISSING_TOOL_CREATE_ISSUE: "true"
+ GH_AW_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/add-community-bundle.md"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/missing_tool.cjs');
+ await main();
+ - name: Record incomplete
+ id: report_incomplete
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_REPORT_INCOMPLETE_CREATE_ISSUE: "true"
+ GH_AW_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/add-community-bundle.md"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/report_incomplete_handler.cjs');
+ await main();
+ - name: Handle agent failure
+ id: handle_agent_failure
+ if: always()
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/add-community-bundle.md"
+ GH_AW_RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }}
+ GH_AW_AGENT_CONCLUSION: ${{ needs.agent.result }}
+ GH_AW_WORKFLOW_ID: "add-community-bundle"
+ GH_AW_ACTION_FAILURE_ISSUE_EXPIRES_HOURS: "168"
+ GH_AW_ENGINE_ID: "copilot"
+ GH_AW_SECRET_VERIFICATION_RESULT: ${{ needs.activation.outputs.secret_verification_result }}
+ GH_AW_CHECKOUT_PR_SUCCESS: ${{ needs.agent.outputs.checkout_pr_success }}
+ GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens || '' }}
+ GH_AW_AI_CREDITS_RATE_LIMIT_ERROR: ${{ needs.agent.outputs.ai_credits_rate_limit_error || 'false' }}
+ GH_AW_UNKNOWN_MODEL_AI_CREDITS: ${{ needs.agent.outputs.unknown_model_ai_credits || 'false' }}
+ GH_AW_AIC: ${{ needs.agent.outputs.aic }}
+ GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
+ GH_AW_MAX_AI_CREDITS: ${{ vars.GH_AW_DEFAULT_MAX_AI_CREDITS || '1000' }}
+ GH_AW_INFERENCE_ACCESS_ERROR: ${{ needs.agent.outputs.inference_access_error }}
+ GH_AW_MCP_POLICY_ERROR: ${{ needs.agent.outputs.mcp_policy_error }}
+ GH_AW_AGENTIC_ENGINE_TIMEOUT: ${{ needs.agent.outputs.agentic_engine_timeout }}
+ GH_AW_MODEL_NOT_SUPPORTED_ERROR: ${{ needs.agent.outputs.model_not_supported_error }}
+ GH_AW_ENGINE_API_HOSTS: "api.enterprise.githubcopilot.com,api.githubcopilot.com,api.business.githubcopilot.com,api.individual.githubcopilot.com"
+ GH_AW_CODE_PUSH_FAILURE_ERRORS: ${{ needs.safe_outputs.outputs.code_push_failure_errors }}
+ GH_AW_CODE_PUSH_FAILURE_COUNT: ${{ needs.safe_outputs.outputs.code_push_failure_count }}
+ GH_AW_LOCKDOWN_CHECK_FAILED: ${{ needs.activation.outputs.lockdown_check_failed }}
+ GH_AW_STALE_LOCK_FILE_FAILED: ${{ needs.activation.outputs.stale_lock_file_failed }}
+ GH_AW_DAILY_AI_CREDITS_EXCEEDED: ${{ needs.activation.outputs.daily_ai_credits_exceeded }}
+ GH_AW_DAILY_AI_CREDITS_TOTAL_EFFECTIVE_TOKENS: ${{ needs.activation.outputs.daily_ai_credits_total_effective_tokens }}
+ GH_AW_DAILY_AI_CREDITS_THRESHOLD: ${{ needs.activation.outputs.daily_ai_credits_threshold }}
+ GH_AW_GROUP_REPORTS: "false"
+ GH_AW_FAILURE_REPORT_AS_ISSUE: "true"
+ GH_AW_MISSING_TOOL_REPORT_AS_FAILURE: "true"
+ GH_AW_MISSING_DATA_REPORT_AS_FAILURE: "true"
+ GH_AW_TIMEOUT_MINUTES: "20"
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/handle_agent_failure.cjs');
+ await main();
+
+ detection:
+ needs:
+ - activation
+ - agent
+ if: >
+ always() && needs.agent.result != 'skipped' && (needs.agent.outputs.output_types != '' || needs.agent.outputs.has_patch == 'true')
+ runs-on: ubuntu-latest
+ permissions:
+ contents: read
+ outputs:
+ aic: ${{ steps.parse_detection_token_usage.outputs.aic }}
+ detection_conclusion: ${{ steps.detection_conclusion.outputs.conclusion }}
+ detection_reason: ${{ steps.detection_conclusion.outputs.reason }}
+ detection_success: ${{ steps.detection_conclusion.outputs.success }}
+ steps:
+ - name: Setup Scripts
+ id: setup
+ uses: github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ trace-id: ${{ needs.activation.outputs.setup-trace-id }}
+ parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
+ env:
+ GH_AW_SETUP_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/add-community-bundle.lock.yml@${{ github.ref }}
+ GH_AW_INFO_VERSION: "1.0.60"
+ GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_ENGINE_ID: "copilot"
+ - name: Download agent output artifact
+ id: download-agent-output
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: agent
+ path: /tmp/gh-aw/
+ - name: Setup agent output environment variable
+ id: setup-agent-output-env
+ if: steps.download-agent-output.outcome == 'success'
+ run: |
+ mkdir -p /tmp/gh-aw/
+ find "/tmp/gh-aw/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
+ - name: Checkout repository for patch context
+ if: needs.agent.outputs.has_patch == 'true'
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ persist-credentials: false
+ # --- Threat Detection ---
+ - name: Clean stale firewall files from agent artifact
+ run: |
+ rm -rf /tmp/gh-aw/sandbox/firewall/logs
+ rm -rf /tmp/gh-aw/sandbox/firewall/audit
+ - name: Download container images
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/download_docker_images.sh" ghcr.io/github/gh-aw-firewall/agent:0.27.2@sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6 ghcr.io/github/gh-aw-firewall/api-proxy:0.27.2@sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4 ghcr.io/github/gh-aw-firewall/squid:0.27.2@sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591
+ - name: Check if detection needed
+ id: detection_guard
+ if: always()
+ env:
+ OUTPUT_TYPES: ${{ needs.agent.outputs.output_types }}
+ HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
+ run: |
+ if [[ -n "$OUTPUT_TYPES" || "$HAS_PATCH" == "true" ]]; then
+ echo "run_detection=true" >> "$GITHUB_OUTPUT"
+ echo "Detection will run: output_types=$OUTPUT_TYPES, has_patch=$HAS_PATCH"
+ else
+ echo "run_detection=false" >> "$GITHUB_OUTPUT"
+ echo "Detection skipped: no agent outputs or patches to analyze"
+ fi
+ - name: Clear MCP Config for detection
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ run: |
+ rm -f "${RUNNER_TEMP}/gh-aw/mcp-config/mcp-servers.json"
+ rm -f "$HOME/.copilot/mcp-config.json"
+ rm -f "$GITHUB_WORKSPACE/.gemini/settings.json"
+ - name: Prepare threat detection files
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ run: |
+ mkdir -p /tmp/gh-aw/threat-detection/aw-prompts
+ rm -f /tmp/gh-aw/agent_usage.json
+ cp /tmp/gh-aw/aw-prompts/prompt.txt /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt 2>/dev/null || true
+ if [ ! -s /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt ]; then
+ echo "::warning::ERR_VALIDATION: Missing or empty detection context prompt at /tmp/gh-aw/threat-detection/aw-prompts/prompt.txt. Ensure the agent artifact includes /tmp/gh-aw/aw-prompts/prompt.txt. Detection will continue with fallback workflow context."
+ fi
+ cp /tmp/gh-aw/agent_output.json /tmp/gh-aw/threat-detection/agent_output.json 2>/dev/null || true
+ for f in /tmp/gh-aw/aw-*.patch; do
+ [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true
+ done
+ for f in /tmp/gh-aw/aw-*.bundle; do
+ [ -f "$f" ] && cp "$f" /tmp/gh-aw/threat-detection/ 2>/dev/null || true
+ done
+ echo "Prepared threat detection files:"
+ ls -la /tmp/gh-aw/threat-detection/ 2>/dev/null || true
+ - name: Setup threat detection
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ WORKFLOW_DESCRIPTION: "Process community bundle submission issues - validate, add to catalog, and open a PR for maintainer review"
+ HAS_PATCH: ${{ needs.agent.outputs.has_patch }}
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/setup_threat_detection.cjs');
+ await main();
+ - name: Ensure threat-detection directory and log
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ run: |
+ mkdir -p /tmp/gh-aw/threat-detection
+ touch /tmp/gh-aw/threat-detection/detection.log
+ - name: Setup Node.js
+ uses: actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
+ with:
+ node-version: '24'
+ package-manager-cache: false
+ - name: Install GitHub Copilot CLI
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/install_copilot_cli.sh" 1.0.60
+ env:
+ GH_HOST: github.com
+ - name: Install AWF binary
+ run: bash "${RUNNER_TEMP}/gh-aw/actions/install_awf_binary.sh" v0.27.2
+ - name: Execute GitHub Copilot CLI
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ continue-on-error: true
+ id: detection_agentic_execution
+ # Copilot CLI tool arguments (sorted):
+ timeout-minutes: 20
+ run: |
+ set -o pipefail
+ printf '%s' "$(date +%s%3N)" > /tmp/gh-aw/agent_cli_start_ms.txt
+ trap 'rm -f "$HOME/.copilot/settings.json"' EXIT
+ mkdir -p "$HOME/.copilot"
+ printf '%s' '{"builtInAgents":{"rubberDuck":false}}' > "$HOME/.copilot/settings.json"
+ export XDG_CONFIG_HOME="$HOME"
+ touch /tmp/gh-aw/agent-step-summary.md
+ GH_AW_NODE_BIN=$(command -v node 2>/dev/null || true)
+ export GH_AW_NODE_BIN
+ export COPILOT_API_KEY="$COPILOT_DUMMY_BYOK"
+ (umask 177 && touch /tmp/gh-aw/threat-detection/detection.log)
+ GH_AW_MAX_AI_CREDITS="${{ vars.GH_AW_DEFAULT_DETECTION_MAX_AI_CREDITS || '400' }}"
+ printf '%s\n' "{\"\$schema\":\"https://github.com/github/gh-aw-firewall/releases/download/v0.27.2/awf-config.schema.json\",\"network\":{\"allowDomains\":[\"api.business.githubcopilot.com\",\"api.enterprise.githubcopilot.com\",\"api.github.com\",\"api.githubcopilot.com\",\"api.individual.githubcopilot.com\",\"github.com\",\"host.docker.internal\",\"registry.npmjs.org\",\"telemetry.enterprise.githubcopilot.com\"]},\"apiProxy\":{\"enabled\":true,\"enableTokenSteering\":true,\"maxRuns\":500,\"maxAiCredits\":${GH_AW_MAX_AI_CREDITS}},\"container\":{\"imageTag\":\"0.27.2,squid=sha256:2e3a717e5f19a654cd9a2263beb52012b56bcb68562ec5ae2e42f9d156b49591,agent=sha256:f88e5b17b6b7a600117bc121114d6ce2155c88c983c0c939c5df884f730fa1d6,api-proxy=sha256:ee39841d980878ebbb87592903b06d31a1af500c71525c9616f7e8e2a27041a4,cli-proxy=sha256:02f3ec08f32dc26c5427920c6a2e2f3036238fce44802f2f11ef49ed8621b5d0\"}}" > "${RUNNER_TEMP}/gh-aw/awf-config.json"
+ cp "${RUNNER_TEMP}/gh-aw/awf-config.json" /tmp/gh-aw/awf-config.json
+ export GH_AW_MODELS_JSON_PATH="/tmp/gh-aw/models.json"
+ GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS=""
+ if [[ "${DOCKER_HOST:-}" =~ ^tcp:// ]]; then
+ GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS="--docker-host-path-prefix /tmp/gh-aw"
+ fi
+ GH_AW_TOOL_CACHE_MOUNT=""
+ GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"
+ if [ -d "$GH_AW_TOOL_CACHE" ]; then
+ if [[ "$GH_AW_TOOL_CACHE" != /opt/* ]]; then
+ GH_AW_TOOL_CACHE_MOUNT="$GH_AW_TOOL_CACHE:$GH_AW_TOOL_CACHE:ro"
+ fi
+ elif [ -d "/home/runner/work/_tool" ]; then
+ GH_AW_TOOL_CACHE_MOUNT="/home/runner/work/_tool:/home/runner/work/_tool:ro"
+ fi
+ # shellcheck disable=SC1003
+ sudo -E awf --config "${RUNNER_TEMP}/gh-aw/awf-config.json" --container-workdir "${GITHUB_WORKSPACE}" --mount "${RUNNER_TEMP}/gh-aw:${RUNNER_TEMP}/gh-aw:ro" --mount "${RUNNER_TEMP}/gh-aw:/host${RUNNER_TEMP}/gh-aw:ro" ${GH_AW_TOOL_CACHE_MOUNT:+--mount "$GH_AW_TOOL_CACHE_MOUNT"} ${GH_AW_DOCKER_HOST_PATH_PREFIX_ARGS} --env-all --exclude-env COPILOT_GITHUB_TOKEN --log-level info --proxy-logs-dir /tmp/gh-aw/sandbox/firewall/logs --audit-dir /tmp/gh-aw/sandbox/firewall/audit --enable-host-access --allow-host-ports 80,443,8080 --skip-pull \
+ -- /bin/bash -c 'set +o histexpand; GH_AW_TOOL_CACHE="${RUNNER_TOOL_CACHE:-/opt/hostedtoolcache}"; export PATH="$(find "$GH_AW_TOOL_CACHE" /opt/hostedtoolcache /home/runner/work/_tool -maxdepth 5 -type d -name bin 2>/dev/null | tr '\''\n'\'' '\'':'\'')$PATH"; [ -n "$GOROOT" ] && export PATH="$GOROOT/bin:$PATH" || true && GH_AW_NODE_EXEC="${GH_AW_NODE_BIN:-}"; if [ -z "$GH_AW_NODE_EXEC" ] || [ ! -x "$GH_AW_NODE_EXEC" ]; then GH_AW_NODE_EXEC="$(command -v node 2>/dev/null || true)"; fi; if [ -z "$GH_AW_NODE_EXEC" ]; then echo "node runtime missing on this runner ā check runtimes.node in workflow YAML" >&2; exit 127; fi; GH_AW_NPM_GLOBAL_ROOT="$(npm root -g 2>/dev/null || true)"; if [ -n "$GH_AW_NPM_GLOBAL_ROOT" ]; then export NODE_PATH="${GH_AW_NPM_GLOBAL_ROOT}${NODE_PATH:+:${NODE_PATH}}"; fi; "$GH_AW_NODE_EXEC" ${RUNNER_TEMP}/gh-aw/actions/copilot_harness.cjs /usr/local/bin/copilot --add-dir /tmp/gh-aw/ --log-level all --log-dir /tmp/gh-aw/sandbox/agent/logs/ --disable-builtin-mcps --no-ask-user --allow-all-tools --add-dir "${GITHUB_WORKSPACE}" --prompt-file /tmp/gh-aw/aw-prompts/prompt.txt' 2>&1 | tee -a /tmp/gh-aw/threat-detection/detection.log
+ env:
+ AWF_REFLECT_ENABLED: 1
+ COPILOT_AGENT_RUNNER_TYPE: STANDALONE
+ COPILOT_DUMMY_BYOK: dummy-byok-key-for-offline-mode
+ COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
+ COPILOT_MODEL: ${{ vars.GH_AW_MODEL_DETECTION_COPILOT || vars.GH_AW_DEFAULT_MODEL_COPILOT || 'claude-sonnet-4.6' }}
+ GH_AW_MAX_TURNS: ${{ vars.GH_AW_DEFAULT_MAX_TURNS || '' }}
+ GH_AW_PHASE: detection
+ GH_AW_PROMPT: /tmp/gh-aw/aw-prompts/prompt.txt
+ GH_AW_TIMEOUT_MINUTES: 20
+ GH_AW_VERSION: v0.79.8
+ GITHUB_API_URL: ${{ github.api_url }}
+ GITHUB_AW: true
+ GITHUB_COPILOT_INTEGRATION_ID: agentic-workflows
+ GITHUB_HEAD_REF: ${{ github.head_ref }}
+ GITHUB_REF_NAME: ${{ github.ref_name }}
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_STEP_SUMMARY: /tmp/gh-aw/agent-step-summary.md
+ GITHUB_WORKSPACE: ${{ github.workspace }}
+ GIT_AUTHOR_EMAIL: github-actions[bot]@users.noreply.github.com
+ GIT_AUTHOR_NAME: github-actions[bot]
+ GIT_COMMITTER_EMAIL: github-actions[bot]@users.noreply.github.com
+ GIT_COMMITTER_NAME: github-actions[bot]
+ RUNNER_TEMP: ${{ runner.temp }}
+ - name: Parse threat detection token usage for step summary
+ id: parse_detection_token_usage
+ if: always()
+ continue-on-error: true
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_TOKEN_USAGE_SUMMARY_TITLE: Threat Detection Token Usage
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_token_usage.cjs');
+ await main();
+ - name: Upload threat detection log
+ if: always() && steps.detection_guard.outputs.run_detection == 'true'
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: detection
+ path: /tmp/gh-aw/threat-detection/detection.log
+ if-no-files-found: ignore
+ - name: Parse and conclude threat detection
+ id: detection_conclusion
+ if: always()
+ continue-on-error: true
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ RUN_DETECTION: ${{ steps.detection_guard.outputs.run_detection }}
+ DETECTION_AGENTIC_EXECUTION_OUTCOME: ${{ steps.detection_agentic_execution.outcome }}
+ GH_AW_DETECTION_CONTINUE_ON_ERROR: "true"
+ with:
+ script: |
+ try {
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/parse_threat_detection_results.cjs');
+ await main();
+ } catch (loadErr) {
+ const continueOnError = process.env.GH_AW_DETECTION_CONTINUE_ON_ERROR !== 'false';
+ const detectionExecutionFailed = process.env.DETECTION_AGENTIC_EXECUTION_OUTCOME === 'failure';
+ const msg = 'ERR_SYSTEM: \u274C Unexpected error loading threat detection module: ' + (loadErr && loadErr.message ? loadErr.message : String(loadErr));
+ core.error(msg);
+ core.setOutput('reason', 'parse_error');
+ if (continueOnError && !detectionExecutionFailed) {
+ core.warning('\u26A0\uFE0F ' + msg);
+ core.setOutput('conclusion', 'warning');
+ core.setOutput('success', 'false');
+ } else {
+ core.setOutput('conclusion', 'failure');
+ core.setOutput('success', 'false');
+ core.setFailed(msg);
+ }
+ }
+
+ pre_activation:
+ if: github.event_name != 'issues' || github.event.action != 'labeled' || github.event.label.name == 'bundle-submission'
+ runs-on: ubuntu-slim
+ outputs:
+ activated: ${{ steps.check_membership.outputs.is_team_member == 'true' && steps.check_skip_bots.outputs.skip_bots_ok == 'true' }}
+ matched_command: ''
+ setup-parent-span-id: ${{ steps.setup.outputs.parent-span-id || steps.setup.outputs.span-id }}
+ setup-span-id: ${{ steps.setup.outputs.span-id }}
+ setup-trace-id: ${{ steps.setup.outputs.trace-id }}
+ steps:
+ - name: Setup Scripts
+ id: setup
+ uses: github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ env:
+ GH_AW_SETUP_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/add-community-bundle.lock.yml@${{ github.ref }}
+ GH_AW_INFO_VERSION: "1.0.60"
+ GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_ENGINE_ID: "copilot"
+ - name: Check team membership for workflow
+ id: check_membership
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_REQUIRED_ROLES: "admin,maintainer,write"
+ with:
+ github-token: ${{ secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/check_membership.cjs');
+ await main();
+ - name: Check skip-bots
+ id: check_skip_bots
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_SKIP_BOTS: "github-actions,copilot-swe-agent,Copilot,copilot,@app/copilot-swe-agent,dependabot"
+ GH_AW_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/check_skip_bots.cjs');
+ await main();
+
+ safe_outputs:
+ needs:
+ - activation
+ - agent
+ - detection
+ if: (!cancelled()) && needs.agent.result != 'skipped' && needs.detection.result == 'success'
+ runs-on: ubuntu-slim
+ permissions:
+ contents: write
+ discussions: write
+ issues: write
+ pull-requests: write
+ timeout-minutes: 45
+ env:
+ GH_AW_AGENT_AIC: ${{ needs.agent.outputs.aic }}
+ GH_AW_AIC: ${{ needs.agent.outputs.aic }}
+ GH_AW_AMBIENT_CONTEXT: ${{ needs.agent.outputs.ambient_context }}
+ GH_AW_CALLER_WORKFLOW_ID: "${{ github.repository }}/add-community-bundle"
+ GH_AW_DETECTION_CONCLUSION: ${{ needs.detection.outputs.detection_conclusion }}
+ GH_AW_DETECTION_REASON: ${{ needs.detection.outputs.detection_reason }}
+ GH_AW_EFFECTIVE_TOKENS: ${{ needs.agent.outputs.effective_tokens }}
+ GH_AW_ENGINE_ID: "copilot"
+ GH_AW_ENGINE_MODEL: ${{ needs.agent.outputs.model }}
+ GH_AW_ENGINE_VERSION: "1.0.60"
+ GH_AW_THREAT_DETECTION_AIC: ${{ needs.detection.outputs.aic }}
+ GH_AW_WORKFLOW_EMOJI: "š¦"
+ GH_AW_WORKFLOW_ID: "add-community-bundle"
+ GH_AW_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_WORKFLOW_SOURCE_URL: "${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/.github/workflows/add-community-bundle.md"
+ outputs:
+ code_push_failure_count: ${{ steps.process_safe_outputs.outputs.code_push_failure_count }}
+ code_push_failure_errors: ${{ steps.process_safe_outputs.outputs.code_push_failure_errors }}
+ comment_id: ${{ steps.process_safe_outputs.outputs.comment_id }}
+ comment_url: ${{ steps.process_safe_outputs.outputs.comment_url }}
+ create_discussion_error_count: ${{ steps.process_safe_outputs.outputs.create_discussion_error_count }}
+ create_discussion_errors: ${{ steps.process_safe_outputs.outputs.create_discussion_errors }}
+ created_pr_number: ${{ steps.process_safe_outputs.outputs.created_pr_number }}
+ created_pr_url: ${{ steps.process_safe_outputs.outputs.created_pr_url }}
+ process_safe_outputs_processed_count: ${{ steps.process_safe_outputs.outputs.processed_count }}
+ process_safe_outputs_temporary_id_map: ${{ steps.process_safe_outputs.outputs.temporary_id_map }}
+ steps:
+ - name: Setup Scripts
+ id: setup
+ uses: github/gh-aw-actions/setup@c0338fef4749d08c21f8f975fb0e37efa17dda47 # v0.79.8
+ with:
+ destination: ${{ runner.temp }}/gh-aw/actions
+ job-name: ${{ github.job }}
+ trace-id: ${{ needs.activation.outputs.setup-trace-id }}
+ parent-span-id: ${{ needs.activation.outputs.setup-parent-span-id || needs.activation.outputs.setup-span-id }}
+ env:
+ GH_AW_SETUP_WORKFLOW_NAME: "Add Community Bundle from Issue Submission"
+ GH_AW_CURRENT_WORKFLOW_REF: ${{ github.repository }}/.github/workflows/add-community-bundle.lock.yml@${{ github.ref }}
+ GH_AW_INFO_VERSION: "1.0.60"
+ GH_AW_INFO_AWF_VERSION: "v0.27.2"
+ GH_AW_INFO_ENGINE_ID: "copilot"
+ - name: Download agent output artifact
+ id: download-agent-output
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: agent
+ path: /tmp/gh-aw/
+ - name: Setup agent output environment variable
+ id: setup-agent-output-env
+ if: steps.download-agent-output.outcome == 'success'
+ run: |
+ mkdir -p /tmp/gh-aw/
+ find "/tmp/gh-aw/" -type f -print
+ echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
+ - name: Download patch artifact
+ continue-on-error: true
+ uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
+ with:
+ name: agent
+ path: /tmp/gh-aw/
+ - name: Extract base branch from agent output
+ id: extract-base-branch
+ if: steps.download-agent-output.outcome == 'success'
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ with:
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/extract_base_branch_from_agent_output.cjs');
+ await main();
+ - name: Checkout repository (trusted default branch for comment events)
+ if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ github.event.repository.default_branch }}
+ token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ persist-credentials: false
+ fetch-depth: 0
+ - name: Checkout repository
+ if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
+ token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ persist-credentials: false
+ fetch-depth: 0
+ - name: Configure Git credentials
+ if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request')
+ env:
+ REPO_NAME: ${{ github.repository }}
+ SERVER_URL: ${{ github.server_url }}
+ GIT_TOKEN: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ run: |
+ git config --global user.email "github-actions[bot]@users.noreply.github.com"
+ git config --global user.name "github-actions[bot]"
+ git config --global am.keepcr true
+ # Re-authenticate git with GitHub token
+ SERVER_URL_STRIPPED="${SERVER_URL#https://}"
+ git remote set-url origin "https://x-access-token:${GIT_TOKEN}@${SERVER_URL_STRIPPED}/${REPO_NAME}.git"
+ echo "Git configured with standard GitHub Actions identity"
+ - name: Configure GH_HOST for enterprise compatibility
+ id: ghes-host-config
+ shell: bash
+ # zizmor: ignore[github-env] - GITHUB_SERVER_URL is set by GitHub Actions, not user input.
+ run: |
+ # Derive GH_HOST from GITHUB_SERVER_URL so the gh CLI targets the correct
+ # GitHub instance (GHES/GHEC). On github.com this is a harmless no-op.
+ GH_HOST="${GITHUB_SERVER_URL#https://}"
+ GH_HOST="${GH_HOST#http://}"
+ echo "GH_HOST=${GH_HOST}" >> "$GITHUB_ENV"
+ - name: Process Safe Outputs
+ id: process_safe_outputs
+ uses: actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
+ env:
+ GH_AW_AGENT_OUTPUT: ${{ steps.setup-agent-output-env.outputs.GH_AW_AGENT_OUTPUT }}
+ GH_AW_COMMENT_ID: ${{ needs.activation.outputs.comment_id }}
+ GH_AW_ALLOWED_DOMAINS: "api.business.githubcopilot.com,api.enterprise.githubcopilot.com,api.github.com,api.githubcopilot.com,api.individual.githubcopilot.com,api.snapcraft.io,archive.ubuntu.com,azure.archive.ubuntu.com,crl.geotrust.com,crl.globalsign.com,crl.identrust.com,crl.sectigo.com,crl.thawte.com,crl.usertrust.com,crl.verisign.com,crl3.digicert.com,crl4.digicert.com,crls.ssl.com,github.com,host.docker.internal,json-schema.org,json.schemastore.org,keyserver.ubuntu.com,ocsp.digicert.com,ocsp.geotrust.com,ocsp.globalsign.com,ocsp.identrust.com,ocsp.sectigo.com,ocsp.ssl.com,ocsp.thawte.com,ocsp.usertrust.com,ocsp.verisign.com,packagecloud.io,packages.cloud.google.com,packages.microsoft.com,ppa.launchpad.net,raw.githubusercontent.com,registry.npmjs.org,s.symcb.com,s.symcd.com,security.ubuntu.com,telemetry.enterprise.githubcopilot.com,ts-crl.ws.symantec.com,ts-ocsp.ws.symantec.com,www.googleapis.com"
+ GITHUB_SERVER_URL: ${{ github.server_url }}
+ GITHUB_API_URL: ${{ github.api_url }}
+ GH_AW_SAFE_OUTPUTS_HANDLER_CONFIG: "{\"add_comment\":{\"max\":2},\"add_labels\":{\"allowed\":[\"bundle-submission\",\"validation-passed\",\"validation-failed\",\"needs-info\"],\"max\":3},\"create_pull_request\":{\"allowed_files\":[\"bundles/catalog.community.json\",\"docs/community/bundles.md\"],\"draft\":true,\"labels\":[\"bundle-submission\",\"automated\"],\"max\":1,\"max_patch_files\":100,\"max_patch_size\":1024,\"protect_top_level_dot_folders\":true,\"protected_files\":[\"package.json\",\"bun.lockb\",\"bunfig.toml\",\"deno.json\",\"deno.jsonc\",\"deno.lock\",\"global.json\",\"NuGet.Config\",\"Directory.Packages.props\",\"mix.exs\",\"mix.lock\",\"go.mod\",\"go.sum\",\"stack.yaml\",\"stack.yaml.lock\",\"pom.xml\",\"build.gradle\",\"build.gradle.kts\",\"settings.gradle\",\"settings.gradle.kts\",\"gradle.properties\",\"package-lock.json\",\"yarn.lock\",\"pnpm-lock.yaml\",\"npm-shrinkwrap.json\",\"requirements.txt\",\"Pipfile\",\"Pipfile.lock\",\"pyproject.toml\",\"setup.py\",\"setup.cfg\",\"Gemfile\",\"Gemfile.lock\",\"uv.lock\",\"CODEOWNERS\",\"DESIGN.md\",\"CONTRIBUTING.md\",\"SECURITY.md\",\"CODE_OF_CONDUCT.md\",\"AGENTS.md\",\"CLAUDE.md\",\"GEMINI.md\"],\"protected_files_policy\":\"blocked\",\"title_prefix\":\"[bundle] \"},\"create_report_incomplete_issue\":{},\"missing_data\":{},\"missing_tool\":{},\"noop\":{\"max\":1,\"report-as-issue\":\"false\"},\"remove_labels\":{\"allowed\":[\"validation-passed\",\"validation-failed\",\"needs-info\"]},\"report_incomplete\":{}}"
+ GH_AW_CI_TRIGGER_TOKEN: ${{ secrets.GH_AW_CI_TRIGGER_TOKEN }}
+ with:
+ github-token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
+ script: |
+ const { setupGlobals } = require('${{ runner.temp }}/gh-aw/actions/setup_globals.cjs');
+ setupGlobals(core, github, context, exec, io, getOctokit);
+ const { main } = require('${{ runner.temp }}/gh-aw/actions/safe_output_handler_manager.cjs');
+ await main();
+ - name: Upload Safe Outputs Items
+ if: always()
+ uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
+ with:
+ name: safe-outputs-items
+ path: |
+ /tmp/gh-aw/safe-output-items.jsonl
+ /tmp/gh-aw/temporary-id-map.json
+ if-no-files-found: ignore
+
diff --git a/.github/workflows/add-community-bundle.md b/.github/workflows/add-community-bundle.md
new file mode 100644
index 0000000000..a54a35f890
--- /dev/null
+++ b/.github/workflows/add-community-bundle.md
@@ -0,0 +1,288 @@
+---
+description: "Process community bundle submission issues - validate, add to catalog, and open a PR for maintainer review"
+emoji: "š¦"
+
+on:
+ issues:
+ types: [labeled]
+ names: [bundle-submission]
+ skip-bots: [github-actions, copilot, dependabot]
+
+tools:
+ edit:
+ bash: ["echo", "grep", "sort", "python3", "jq", "date"]
+ github:
+ toolsets: [issues, repos]
+ min-integrity: none
+ web-fetch:
+
+permissions:
+ contents: read
+ issues: read
+
+checkout:
+ fetch-depth: 0
+
+safe-outputs:
+ noop:
+ report-as-issue: false
+ create-pull-request:
+ title-prefix: "[bundle] "
+ labels: [bundle-submission, automated]
+ draft: true
+ max: 1
+ allowed-files:
+ - bundles/catalog.community.json
+ - docs/community/bundles.md
+ protected-files:
+ policy: blocked
+ exclude:
+ - README.md
+ - CHANGELOG.md
+ add-comment:
+ max: 2
+ add-labels:
+ allowed: [bundle-submission, validation-passed, validation-failed, needs-info]
+ max: 3
+ remove-labels:
+ allowed: [validation-passed, validation-failed, needs-info]
+---
+
+# Add Community Bundle from Issue Submission
+
+You are a catalog maintenance agent for the Spec Kit project. Process community
+bundle submission issues and create draft pull requests that add or update
+entries in the community bundle catalog.
+
+Community bundles are untrusted. Validate metadata and distribution evidence,
+but do not claim to audit, endorse, or support bundle code or the components it
+installs. Never register a submitted companion catalog automatically.
+
+## Triggering Conditions
+
+This workflow is triggered by an `issues: labeled` event and is gated to the
+`bundle-submission` label. Before processing, verify that the issue title starts
+with `[Bundle]:`. If it does not, stop without commenting.
+
+## Step 1 - Read and Parse the Issue
+
+Read issue #${{ github.event.issue.number }} and extract these issue-form fields:
+
+| Field | Issue Form ID | Required |
+|-------|---------------|----------|
+| Bundle ID | `bundle-id` | Yes |
+| Bundle Name | `bundle-name` | Yes |
+| Version | `version` | Yes |
+| Role or Team | `role` | Yes |
+| Description | `description` | Yes |
+| Author | `author` | Yes |
+| Repository URL | `repository` | Yes |
+| Download URL | `download-url` | Yes |
+| Documentation URL | `documentation` | Yes |
+| License | `license` | Yes |
+| Required Spec Kit Version | `speckit-version` | Yes |
+| Integration Target | `integration` | No |
+| Components Provided | `components-provided` | Yes |
+| Required Component Catalogs | `required-catalogs` | Yes |
+| Tags | `tags` | Yes |
+| Key Features | `features` | Yes |
+| Testing Details | `testing-details` | Yes |
+| Example Usage | `example-usage` | Yes |
+| Proposed Catalog Entry | `catalog-entry` | Yes |
+
+Issue-form values appear beneath headings matching their labels.
+
+## Step 2 - Validate the Submission
+
+Run every check and collect all failures before deciding the outcome.
+
+### 2a. Bundle ID and version
+
+- The bundle ID must match
+ `^[a-z0-9](?:[a-z0-9._-]*[a-z0-9])?$`.
+- The version must be semantic version `X.Y.Z` with digits only and no `v`
+ prefix.
+
+### 2b. Repository and documentation
+
+- Restrict repository and documentation URLs to public GitHub URLs before
+ fetching them.
+- Confirm the repository exists and contains `bundle.yml`, `README.md`, and a
+ license file (`LICENSE`, `LICENSE.md`, or `LICENSE.txt`).
+- The documentation URL must resolve to a readable Markdown file that explains
+ the bundle's intended role, installed components, required catalogs, and
+ installation steps.
+- Confirm the repository's `bundle.yml` matches the submitted bundle ID,
+ version, role, author, license, Spec Kit requirement, integration target, and
+ component summary.
+
+### 2c. Release artifact
+
+- The download URL must be an HTTPS GitHub release asset URL under the submitted
+ repository:
+ `https://github.com///releases/download//.zip`.
+- Confirm the release exists, its tag corresponds to the submitted version
+ (`vX.Y.Z` or `X.Y.Z`), and the exact ZIP asset is attached to that release.
+- Confirm the asset name is versioned and consistent with the submitted bundle
+ ID and version.
+
+Do not fetch arbitrary user-provided URLs. Do not claim the artifact was
+executed or audited; rely on the required submission attestations for build and
+installation evidence.
+
+### 2d. Catalog entry
+
+Parse the proposed JSON and require one entry under the submitted bundle ID.
+Confirm that:
+
+- `id`, `name`, `version`, `role`, `description`, `author`, `license`,
+ `download_url`, and `repository` match the submission and manifest.
+- `requires.speckit_version` matches the submission.
+- `provides` contains non-negative integer counts for `extensions`, `presets`,
+ `steps`, and `workflows`, matching the manifest.
+- `tags` contains 2-5 lowercase strings and matches the submitted tags.
+- `verified` is the boolean value `false`. Community entries must never be
+ marked verified.
+
+### 2e. Component resolution
+
+- `Required Component Catalogs` must explicitly say `None` or list every
+ non-default extension, preset, workflow, and step catalog needed by the
+ bundle.
+- Compare the manifest references, README, required-catalog field, testing
+ details, and example usage for consistency.
+- If non-default catalogs are required, ensure each URL is HTTPS, the README
+ documents the corresponding `catalog add` command, and the testing details
+ say those catalogs were registered in the clean-project test.
+- If the field says `None` but a component is not bundled and cannot be
+ installed from a default Spec Kit catalog, fail validation and ask the
+ submitter to list and document an install-allowed companion catalog.
+
+The community bundle catalog itself remains discovery-only. Companion catalog
+URLs are documentation and validation metadata, not catalogs this workflow
+should add to Spec Kit.
+
+### 2f. Checklists and testing evidence
+
+- Confirm every required checkbox in Testing Checklist and Submission
+ Requirements is checked (`[x]`).
+- Confirm Testing Details describe validation, build, artifact installation,
+ and clean-project testing.
+- Confirm Example Usage includes artifact installation and, when applicable,
+ all required catalog setup commands.
+
+### Validation outcome
+
+If any check fails:
+
+1. Comment once with every failed check and a specific correction.
+2. Remove `validation-passed`.
+3. Add `validation-failed`; add `needs-info` when submitter input is needed.
+4. Stop without editing files or creating a pull request.
+
+If all checks pass, remove `validation-failed` and `needs-info`, add
+`validation-passed`, and continue.
+
+## Step 3 - Determine Add or Update
+
+Search `bundles/catalog.community.json` for the bundle ID.
+
+- If absent, add a new entry.
+- If present, update the existing entry in place.
+
+Treat a submitted version lower than or equal to the existing catalog version
+as a validation failure unless the issue clearly documents a metadata-only
+correction at the same version.
+
+## Step 4 - Update the Community Catalog
+
+Edit `bundles/catalog.community.json`. Insert new entries alphabetically by
+bundle ID. The entry shape is:
+
+```json
+{
+ "": {
+ "name": "",
+ "id": "",
+ "version": "",
+ "role": "",
+ "description": "",
+ "author": "",
+ "license": "",
+ "download_url": "",
+ "repository": "",
+ "requires": {
+ "speckit_version": ""
+ },
+ "provides": {
+ "extensions": 0,
+ "presets": 0,
+ "steps": 0,
+ "workflows": 0
+ },
+ "tags": [""],
+ "verified": false
+ }
+}
+```
+
+Use the validated proposed entry rather than inventing metadata. Keep
+`verified: false`. Update the top-level `updated_at` to today's UTC date at
+midnight and preserve the top-level `catalog_url`.
+
+Validate the complete file:
+
+```bash
+python3 -c "import json; json.load(open('bundles/catalog.community.json')); print('Valid JSON')"
+```
+
+## Step 5 - Update Community Documentation
+
+Add or update the bundle in `docs/community/bundles.md`. Keep rows alphabetical
+by bundle name:
+
+```text
+| | | `` | | | []() |
+```
+
+Before rendering the row, convert every user-derived display value to
+single-line plain text: collapse CR/LF sequences to spaces, remove control
+characters, and backslash-escape `\`, `|`, backticks, `*`, `_`, `[`, `]`, `<`,
+and `>`. Use the validated HTTPS GitHub repository URL unchanged only as the
+Markdown link destination.
+
+Render component counts compactly, omitting zero-valued component types. Use
+`None` when no companion catalogs are needed and `Documented` otherwise; the
+repository README remains the source for the actual URLs.
+
+## Step 6 - Create a Draft Pull Request
+
+Create one draft pull request.
+
+- New entry branch:
+ `community/${{ github.event.issue.number }}-add--bundle`
+- Update branch:
+ `community/${{ github.event.issue.number }}-update--bundle`
+- New title: `Add bundle to community catalog`
+- Update title: `Update bundle to v`
+
+The commit and PR description must summarize the catalog and documentation
+changes, list the validation results, include
+`Closes #${{ github.event.issue.number }}`, and mention the submitter with
+`cc @`.
+
+End the commit message with this authorship trailer:
+
+```text
+Assisted-by: GitHub Copilot (model: , autonomous)
+```
+
+## Important Rules
+
+- Modify only `bundles/catalog.community.json` and
+ `docs/community/bundles.md`.
+- Keep JSON entries sorted by ID and documentation rows sorted by name.
+- Never set a community bundle's `verified` field to true.
+- Never add, enable, or change the policy of a submitted catalog.
+- Never describe validation as a security audit or endorsement.
+- Use `Closes`, not `Fixes`, for the submission issue.
diff --git a/.github/workflows/add-community-extension.lock.yml b/.github/workflows/add-community-extension.lock.yml
index f6b64e6261..1d86dbcfe4 100644
--- a/.github/workflows/add-community-extension.lock.yml
+++ b/.github/workflows/add-community-extension.lock.yml
@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
-# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -1332,7 +1332,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1658,7 +1658,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1666,7 +1666,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/add-community-preset.lock.yml b/.github/workflows/add-community-preset.lock.yml
index 8c22a45a6e..c63f89df27 100644
--- a/.github/workflows/add-community-preset.lock.yml
+++ b/.github/workflows/add-community-preset.lock.yml
@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
-# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -1332,7 +1332,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1658,7 +1658,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1666,7 +1666,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/bug-assess.lock.yml b/.github/workflows/bug-assess.lock.yml
index cc4a0de05d..c6eb131fba 100644
--- a/.github/workflows/bug-assess.lock.yml
+++ b/.github/workflows/bug-assess.lock.yml
@@ -32,7 +32,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
-# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -161,7 +161,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -430,7 +430,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -1277,7 +1277,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---
diff --git a/.github/workflows/bug-fix.lock.yml b/.github/workflows/bug-fix.lock.yml
index db19fcd140..a3544d0a4f 100644
--- a/.github/workflows/bug-fix.lock.yml
+++ b/.github/workflows/bug-fix.lock.yml
@@ -33,7 +33,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
-# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -162,7 +162,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -434,7 +434,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -1338,7 +1338,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---
@@ -1664,7 +1664,7 @@ jobs:
await main();
- name: Checkout repository (trusted default branch for comment events)
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && (github.event_name == 'issue_comment' || github.event_name == 'pull_request_review_comment')
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
@@ -1672,7 +1672,7 @@ jobs:
fetch-depth: 0
- name: Checkout repository
if: (!cancelled()) && needs.agent.result != 'skipped' && contains(needs.agent.outputs.output_types, 'create_pull_request') && github.event_name != 'issue_comment' && github.event_name != 'pull_request_review_comment'
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: ${{ steps.extract-base-branch.outputs.base-branch || github.base_ref || github.event.pull_request.base.ref || github.ref_name || github.event.repository.default_branch }}
token: ${{ secrets.GH_AW_GITHUB_TOKEN || secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/bug-test.lock.yml b/.github/workflows/bug-test.lock.yml
index 3b2f9cc8bb..884c863d9c 100644
--- a/.github/workflows/bug-test.lock.yml
+++ b/.github/workflows/bug-test.lock.yml
@@ -32,7 +32,7 @@
# - GITHUB_TOKEN
#
# Custom actions used:
-# - actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+# - actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# - actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1
# - actions/github-script@3a2844b7e9c422d3c10d287c895573f7108da1b3 # v9.0.0
# - actions/setup-node@820762786026740c76f36085b0efc47a31fe5020 # v7.0.0
@@ -161,7 +161,7 @@ jobs:
env:
COPILOT_GITHUB_TOKEN: ${{ secrets.COPILOT_GITHUB_TOKEN }}
- name: Checkout .github and .agents folders
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
sparse-checkout: |
@@ -431,7 +431,7 @@ jobs:
echo "GH_AW_SAFE_OUTPUTS_TOOLS_PATH=${RUNNER_TEMP}/gh-aw/safeoutputs/tools.json"
} >> "$GITHUB_OUTPUT"
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
fetch-depth: 0
@@ -1299,7 +1299,7 @@ jobs:
echo "GH_AW_AGENT_OUTPUT=/tmp/gh-aw/agent_output.json" >> "$GITHUB_OUTPUT"
- name: Checkout repository for patch context
if: needs.agent.outputs.has_patch == 'true'
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
# --- Threat Detection ---
diff --git a/.github/workflows/catalog-assign.yml b/.github/workflows/catalog-assign.yml
index f828794864..9655394b06 100644
--- a/.github/workflows/catalog-assign.yml
+++ b/.github/workflows/catalog-assign.yml
@@ -9,11 +9,13 @@ jobs:
if: >
(github.event.action == 'opened' && (
contains(github.event.issue.labels.*.name, 'extension-submission') ||
- contains(github.event.issue.labels.*.name, 'preset-submission')
+ contains(github.event.issue.labels.*.name, 'preset-submission') ||
+ contains(github.event.issue.labels.*.name, 'bundle-submission')
)) ||
(github.event.action == 'labeled' && (
github.event.label.name == 'extension-submission' ||
- github.event.label.name == 'preset-submission'
+ github.event.label.name == 'preset-submission' ||
+ github.event.label.name == 'bundle-submission'
))
runs-on: ubuntu-latest
permissions:
diff --git a/.github/workflows/codeql.yml b/.github/workflows/codeql.yml
index e8f4b9d448..a854a09ab3 100644
--- a/.github/workflows/codeql.yml
+++ b/.github/workflows/codeql.yml
@@ -19,14 +19,14 @@ jobs:
language: [ 'actions', 'python' ]
steps:
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Initialize CodeQL
- uses: github/codeql-action/init@7188fc363630916deb702c7fdcf4e481b751f97a # v4
+ uses: github/codeql-action/init@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
languages: ${{ matrix.language }}
- name: Perform CodeQL Analysis
- uses: github/codeql-action/analyze@7188fc363630916deb702c7fdcf4e481b751f97a # v4
+ uses: github/codeql-action/analyze@e4fba868fa4b1b91e1fdab776edc8cfbe6e9fb81 # v4
with:
category: "/language:${{ matrix.language }}"
diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml
index 52e6ba469e..c5c0092be7 100644
--- a/.github/workflows/docs.yml
+++ b/.github/workflows/docs.yml
@@ -30,7 +30,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0 # Fetch all history for git info
diff --git a/.github/workflows/lint.yml b/.github/workflows/lint.yml
index 49bf14fa1a..637a4582b9 100644
--- a/.github/workflows/lint.yml
+++ b/.github/workflows/lint.yml
@@ -12,7 +12,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 1
@@ -37,7 +37,7 @@ jobs:
fi
- name: Run markdownlint-cli2
- uses: DavidAnson/markdownlint-cli2-action@8de2aa07cae85fd17c0b35642db70cf5495f1d25 # v24.0.0
+ uses: DavidAnson/markdownlint-cli2-action@6bf21b07787794f89a243495939cd651942aeabe # v24.1.0
with:
globs: |
'**/*.md'
@@ -47,7 +47,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
# shellcheck is preinstalled on ubuntu-latest runners.
# Start at --severity=error to block real bugs without flagging style
diff --git a/.github/workflows/publish-pypi.yml b/.github/workflows/publish-pypi.yml
index d935019068..eec0f4ea3f 100644
--- a/.github/workflows/publish-pypi.yml
+++ b/.github/workflows/publish-pypi.yml
@@ -27,12 +27,12 @@ jobs:
fi
- name: Checkout release tag
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
ref: refs/tags/${{ inputs.tag }}
- name: Install uv
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -74,7 +74,7 @@ jobs:
path: dist/
- name: Install uv
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Publish to PyPI
run: uv publish
diff --git a/.github/workflows/release-trigger.yml b/.github/workflows/release-trigger.yml
index 4b3082f9d5..666d85105f 100644
--- a/.github/workflows/release-trigger.yml
+++ b/.github/workflows/release-trigger.yml
@@ -16,7 +16,7 @@ jobs:
pull-requests: write
steps:
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
token: ${{ secrets.RELEASE_PAT }}
diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml
index 89afa864dd..dde3c0e055 100644
--- a/.github/workflows/release.yml
+++ b/.github/workflows/release.yml
@@ -12,7 +12,7 @@ jobs:
contents: write
steps:
- name: Checkout repository
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
fetch-depth: 0
token: ${{ secrets.GITHUB_TOKEN }}
diff --git a/.github/workflows/security.yml b/.github/workflows/security.yml
new file mode 100644
index 0000000000..f9eb6fd060
--- /dev/null
+++ b/.github/workflows/security.yml
@@ -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@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+ with:
+ fetch-depth: 0
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+
+ - name: Set up Python
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
+ with:
+ python-version: "3.14"
+
+ - name: Check committed audit requirements are current
+ env:
+ DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
+ DEPENDENCY_DIFF_HEAD: ${{ github.event.pull_request.head.sha || github.sha }}
+ GENERATED_REQUIREMENTS: ${{ runner.temp }}/security-audit-requirements.txt
+ run: python .github/scripts/check_security_requirements.py
+
+ - name: Run pip-audit (committed requirements)
+ run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r .github/security-audit-requirements.txt --progress-spinner off
+
+ dependency-audit-scheduled:
+ name: Dependency audit scheduled (${{ matrix.os }}, Python ${{ matrix.python-version }})
+ if: ${{ github.event_name == 'schedule' }}
+ runs-on: ${{ matrix.os }}
+ strategy:
+ fail-fast: false
+ matrix:
+ os: [ubuntu-latest, windows-latest]
+ python-version: ["3.11", "3.12", "3.13", "3.14"]
+ steps:
+ - name: Checkout
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
+
+ - name: Install uv
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
+
+ - name: Set up Python ${{ matrix.python-version }}
+ uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
+ with:
+ python-version: ${{ matrix.python-version }}
+
+ # The committed .github/security-audit-requirements.txt is generated with
+ # --universal (resolves across all interpreters/platforms) and is what
+ # push/PR/workflow_dispatch runs audit. The scheduled job instead compiles
+ # per matrix entry with --python-version so it can surface advisories in
+ # wheels that only resolve on a specific interpreter (e.g. 3.11-only) ā
+ # coverage the universal file may not exercise. This broadening is
+ # intentional; non-scheduled runs trade that depth for determinism against
+ # the committed snapshot.
+ - name: Compile scheduled audit requirements
+ run: |
+ uv pip compile pyproject.toml --extra test --python-version "${{ matrix.python-version }}" --upgrade --generate-hashes --quiet --output-file "${{ runner.temp }}/spec-kit-audit-requirements.txt"
+
+ - name: Run pip-audit (scheduled live resolution)
+ run: uvx --from pip-audit==2.10.0 pip-audit --disable-pip --require-hashes -r "${{ runner.temp }}/spec-kit-audit-requirements.txt" --progress-spinner off
diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml
index fa4c1e8b66..19c533c573 100644
--- a/.github/workflows/test.yml
+++ b/.github/workflows/test.yml
@@ -13,10 +13,10 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install uv
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
@@ -24,7 +24,7 @@ jobs:
python-version: "3.14"
- name: Run ruff check
- run: uvx ruff check src/
+ run: uvx ruff@0.15.0 check src tests
pytest:
runs-on: ${{ matrix.os }}
@@ -34,10 +34,10 @@ jobs:
python-version: ["3.13", "3.14"]
steps:
- name: Checkout
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
+ uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Install uv
- uses: astral-sh/setup-uv@11f9893b081a58869d3b5fccaea48c9e9e46f990 # v8.3.2
+ uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
- name: Set up Python ${{ matrix.python-version }}
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
diff --git a/AGENTS.md b/AGENTS.md
index c6a68b0614..a1f4ae7c45 100644
--- a/AGENTS.md
+++ b/AGENTS.md
@@ -10,6 +10,20 @@ The toolkit supports multiple AI coding assistants, allowing teams to use their
---
+## Quickstart ā Add a New Integration in 5 Steps
+
+If you are new to the codebase and want to add support for a new AI agent, here is the shortest path from zero to a working integration:
+
+1. **Choose a base class** ā most agents only need `MarkdownIntegration`. See [Choose a base class](#1-choose-a-base-class).
+2. **Create a subpackage** ā add `src/specify_cli/integrations//__init__.py` with the required `key`, `config`, and `registrar_config` fields.
+3. **Register it** ā add one import and one `_register()` call in `src/specify_cli/integrations/__init__.py` (both alphabetical).
+4. **Write a test file** ā create `tests/integrations/test_integration_.py` (hyphens in the key become underscores in the filename).
+5. **Run and verify** ā use `specify init --integration ` to exercise the full install/uninstall cycle.
+
+Each step is expanded under [Adding a New Integration](#adding-a-new-integration). Note that agent **context files** (`CLAUDE.md`, `AGENTS.md`, ā¦) are **not** handled by the integration ā that is owned by the opt-in `agent-context` extension; see [Context file behavior](#4-context-file-behavior).
+
+---
+
## Integration Architecture
Each AI agent is a self-contained **integration subpackage** under `src/specify_cli/integrations//`. The subpackage exposes a single class that declares all metadata and inherits setup/teardown logic from a base class. Built-in integrations are then instantiated and added to the global `INTEGRATION_REGISTRY` by `src/specify_cli/integrations/__init__.py` via `_register_builtins()`.
@@ -34,6 +48,30 @@ The registry is the **single source of truth for Python integration metadata**.
---
+## IntegrationManifest ā File Tracking
+
+`manifest.py` provides the `IntegrationManifest` class, which records every file an integration installs. This record is what makes uninstall reliable and safe.
+
+### How it works
+
+`setup()` receives an `IntegrationManifest` and writes files through it rather than touching the filesystem directly:
+
+```python
+# Produce a new file and record its hash for later verification.
+manifest.record_file("commands/speckit.plan.md", processed_content)
+
+# Adopt a pre-existing file the integration is now responsible for.
+manifest.record_existing(".vscode/settings.json")
+```
+
+The manifest is persisted at `.specify/integrations/.manifest.json` (one per integration, keyed by `key`) and stores a SHA-256 hash per file. When the user runs `specify integration uninstall `, `teardown()` delegates to `manifest.uninstall()`, which removes only files whose current hash still matches the recorded value ā so files the user later edited by hand are skipped, not clobbered (use `specify integration uninstall --force` to remove modified tracked files anyway).
+
+### Why this matters
+
+Without hash-tracked manifests, uninstall would either remove files it should not (destructive) or leave orphans behind (messy). If you write a custom `setup()`, route **every** file you create through `manifest.record_file(...)` (or `record_existing(...)` for files you adopt) so uninstall can reason about them.
+
+---
+
## Adding a New Integration
### 1. Choose a base class
@@ -64,13 +102,14 @@ class KilocodeIntegration(MarkdownIntegration):
key = "kilocode"
config = {
"name": "Kilo Code",
- "folder": ".kilocode/",
- "commands_subdir": "workflows",
+ "folder": ".kilo/",
+ "commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
- "dir": ".kilocode/workflows",
+ "dir": ".kilo/commands",
+ "legacy_dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
@@ -187,7 +226,7 @@ context_markers:
end: ""
```
-- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh` and `.ps1`). The CLI registry is never consulted ā all agentācontext-file knowledge lives inside the extension.
+- The Specify CLI does **not** write this config. When `context_file` is empty, the extension's bundled scripts self-seed it by looking up the active integration's key in the extension's own `agent-context-defaults.json` map (`extensions/agent-context/scripts/bash/update-agent-context.sh`, `.ps1`, and `extensions/agent-context/scripts/python/update_agent_context.py`). The CLI registry is never consulted ā all agentācontext-file knowledge lives inside the extension.
- `context_markers.{start,end}` are read solely by the extension's scripts; they default to the Spec Kit markers shown above and can be customized by editing `agent-context-config.yml` directly.
Existing projects created by older Spec Kit versions keep working: any previously written managed section or extension config is left intact and is only ever updated by the extension when run.
@@ -201,8 +240,8 @@ Only add custom setup logic when the agent needs non-standard behavior. Integrat
specify init my-project --integration
# Verify files were created in the commands directory configured by
-# config["folder"] + config["commands_subdir"] (for example, .kilocode/workflows/)
-ls -R my-project/.kilocode/workflows/
+# config["folder"] + config["commands_subdir"] (for example, .kilo/commands/)
+ls -R my-project/.kilo/commands/
# Uninstall cleanly
cd my-project && specify integration uninstall
@@ -268,6 +307,25 @@ echo "ā
Done"
## Command File Formats
+### Script References (`scripts:` frontmatter)
+
+Core command templates (`templates/commands/*.md`) that invoke a helper script declare it in a `scripts:` frontmatter block with one line per supported script type. The `{SCRIPT}` placeholder in the command body is replaced at install time with the entry matching the project's selected script type (`--script sh|ps|py`):
+
+```yaml
+scripts:
+ sh: scripts/bash/setup-plan.sh --json
+ ps: scripts/powershell/setup-plan.ps1 -Json
+ py: scripts/python/setup_plan.py --json
+```
+
+| Key | Script type | Location |
+| ---- | ---------------------- | -------------------------- |
+| `sh` | POSIX shell (bash/zsh) | `scripts/bash/*.sh` |
+| `ps` | PowerShell | `scripts/powershell/*.ps1` |
+| `py` | Python | `scripts/python/*.py` |
+
+All three entries must be present and behaviorally equivalent ā agents parse the same stdout contract (`FEATURE_DIR:ā¦`, `AVAILABLE_DOCS:ā¦`, `--json` shapes) regardless of which one runs. (The bundled `agent-context` and `git` extension command templates also invoke helpers but do not yet use `scripts:` frontmatter ā see [Script Types and Migration](#script-types-and-migration).)
+
### Markdown Format
**Standard format:**
@@ -328,9 +386,29 @@ Different agents use different argument placeholders. The placeholder used in co
- **TOML-based**: `{{args}}` (e.g., Gemini)
- **YAML-based**: `{{args}}` (e.g., Goose)
- **Custom**: some agents override the default (e.g., Forge uses `{{parameters}}`)
-- **Script placeholders**: `{SCRIPT}` (replaced with actual script path)
+- **Script placeholders**: `{SCRIPT}` (replaced with the resolved command from the template's `scripts:` frontmatter, per the project's `--script sh|ps|py` selection)
- **Agent placeholders**: `__AGENT__` (replaced with agent name)
+## Script Types and Migration
+
+Spec Kit ships every core workflow script in three interchangeable variants ā POSIX shell (`sh`), PowerShell (`ps`), and Python (`py`) ā selected per project with `specify init --script sh|ps|py`. Each core command template that invokes a helper script carries all three in its `scripts:` frontmatter (templates that don't call a script, e.g. `constitution`/`specify`, have no `scripts:` block); see [Script References](#script-references-scripts-frontmatter).
+
+### Why Python is recommended
+
+- **No extra runtime.** The `specify` CLI is already Python, so the interpreter is guaranteed present ā `py` adds no new dependency.
+- **Path toward a single source of truth.** The shell variants require paired `.sh` + `.ps1` maintenance and diverge on JSON handling (`jq` vs manual parsing). The Python variant avoids `jq` and is intended to eventually replace that dual-maintenance ā but that consolidation has not happened yet: all three variants are still maintained in parallel (see the parity rule below).
+- **Parity-tested.** The Python ports are covered by tests ā output-parity tests against the shell scripts where the contract is stdout-based, and direct unit tests elsewhere ā so the stdout contract agents rely on stays stable.
+
+### Defaults and availability
+
+- `py` is available today for the core command templates (via their `scripts:` frontmatter). The bundled extensions (`agent-context`, `git`) ship Python script variants on disk, but their command templates still hard-code the Bash/PowerShell invocations, so `--script py` does not yet route those extension commands to Python ā wiring `py` into the extension command templates is tracked separately.
+- Selection is per project: interactive `specify init` prompts for the script type, while non-interactive runs default to a shell variant by OS (`sh` on Linux/macOS, `ps` on Windows). `py` is chosen at the prompt or via `--script py`.
+- `sh` and `ps` remain fully supported. Nothing is removed, and `py` is not yet the default.
+
+### Parity rule for contributors
+
+All three script types are first-class: any change to a workflow script must update `sh`, `ps`, and `py` together and keep their tests (parity and unit) green. Making `py` the default and eventually retiring `sh`/`ps` is future work gated on adoption, tracked under the script-unification epic ([#3277](https://github.com/github/spec-kit/issues/3277)) ā not something to act on from this doc.
+
## Special Processing Requirements
Some agents require custom processing beyond the standard template transformations:
@@ -534,4 +612,54 @@ See `docs/fork-agent-parity.md` for the fork-agent parity audit (IPADP Phase 4.3
---
+## Error Handling and Debugging
+
+### Common Errors and Fixes
+
+| Symptom | Likely Cause | Fix |
+|---|---|---|
+| `Integration '' not found` | Missing `_register()` call | Add `_register(Integration())` inside `_register_builtins()` |
+| `NameError: name 'Integration' is not defined` at startup | Missing import | Add `from . import Integration` inside `_register_builtins()` |
+| CLI check fails for a `requires_cli: True` agent | `key` does not match the executable name | Set `key` to the exact name `shutil.which(key)` must resolve (e.g. `"cursor-agent"`, not `"cursor"`) |
+| Command files have the wrong argument syntax | Wrong `args` value in `registrar_config` | Use `$ARGUMENTS` for Markdown agents, `{{args}}` for TOML/YAML agents, or the agent's custom placeholder |
+| `ModuleNotFoundError` on a brand-new subpackage under pytest only | Ambient interpreter with a stale editable `.pth` | Run inside this tree's own venv (see Common Pitfall 6) |
+| Uninstall leaves files behind, or skips files you expected removed | Files not recorded via the manifest, or their hash changed after install | Route every created file through `manifest.record_file(...)`; user-edited files are intentionally skipped unless `force=True` |
+| Context file (`CLAUDE.md`, etc.) not updated | Expecting the CLI to manage it | Context files are owned by the opt-in `agent-context` extension, not the integration ā see [Context file behavior](#4-context-file-behavior) |
+
+### Debugging Tips
+
+**Inspect the manifest** to see what an installed integration tracks:
+
+```bash
+cat .specify/integrations/.manifest.json
+```
+
+**Verify a CLI tool is detected** before debugging a `requires_cli` agent:
+
+```bash
+which # Should print the executable path if installed
+```
+
+**Verify the installed output structure** after `specify init`:
+
+```bash
+find my-project/ -type f
+```
+
+---
+
+## Contribution Checklist
+
+Before opening or merging an integration PR, confirm the following:
+
+- [ ] Added the integration subpackage under `src/specify_cli/integrations//`.
+- [ ] Registered it (import **and** `_register()`) in `src/specify_cli/integrations/__init__.py`, both alphabetical.
+- [ ] Added or updated tests in `tests/integrations/test_integration_.py`.
+- [ ] Verified the install/uninstall flow with `specify init --integration `.
+- [ ] Did **not** add `context_file` handling to the CLI (that belongs to the `agent-context` extension).
+- [ ] Updated devcontainer files if the agent needs a VS Code extension or CLI install step.
+- [ ] Updated this guide or other relevant docs if the integration has special setup or limitations.
+
+---
+
*This documentation should be updated whenever new integrations are added to maintain accuracy and completeness.*
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 55705c13a1..d9778f4e63 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -2,64 +2,253 @@
-## [satware-0.13.0] - 2026-07-19
-
-### Changed
-
-- chore: sync fork with upstream v0.13.0 (integrates v0.12.18 ā v0.13.0)
-- fix(auth): Azure DevOps az-CLI token acquisition returns None on undecodable output (#3527)
-- feat(extensions): add assess idea assessment pipeline extension (#3568)
-- fix(bundle): surface a clean BundlerError on a malformed bundle download URL (#3586)
-- Add OKF Knowledge Bundle Generator extension to community catalog (#3585)
-- Update Autonomous Run Governance preset to v0.2.2 (#3584)
-- fix(presets): raise PresetValidationError, not raw ValueError, on malformed catalog URL (#3576)
-- chore(ipadp): bump `specs/metadata.json` version -> 0.13.0,
- `fork_version` -> satware-v0.13.0
-
-## [satware-0.12.17] - 2026-07-17
-
-### Changed
-
-- chore: sync fork with upstream v0.12.17 (integrates v0.12.5 ā v0.12.17)
-- feat(integrations): Grok Build skills-based integration (#3535)
-- fix(workflows): if/switch, fan-in/fan-out, while/do-while error handling
- (#3515, #3521, #3519, #3522, #3468)
-- feat(extensions): git extension scripts ported to Python (#3400)
-- fix(extensions): resolve __SPECKIT_COMMAND tokens in auto-registered skills (#3544)
-- fix(integrations): preserve ai_skills on `use` for skills-mode Copilot (#3551)
-- fix(presets): seed constitution from preset constitution-template (#3276)
-- chore(ipadp): bump `specs/metadata.json` version ā 0.12.17,
- `fork_version` ā satware-v0.12.17
-
-## [satware-0.12.4] - 2026-07-03
-
-### Changed
-
-- chore: sync fork with upstream v0.12.4 (integrates v0.11.9 ā v0.12.4)
-- chore(integrations): follow upstream retirement of `iflow` (v0.12.2,
- product discontinued), `windsurf` (v0.12.2, absorbed into Cognition Devin),
- and `roo` (v0.12.3, extension shut down); fork drops all three subpackages,
- registry entries, and parity-test coverage.
-- chore(ipadp): bump `specs/metadata.json` version ā 0.12.4,
- `fork_version` ā satware-v0.12.4; `custom_integrations` ā `[]`
- (no fork-only integrations remain after the retirement).
-- docs(llms.txt): refresh stale fork/upstream version pins (were v0.7.3).
-- docs(fork-agent-parity): remove `iflow` row; FORK_AGENTS list reduced to
- agy/bob/kimi/hermes/cline. Also drop the `context_file` assertion (25
- parametrised checks, down from 36) after v0.12.0 made `context_file` an
- agent-context-extension-owned concern (PR #3097).
-
-## [satware-0.11.8] - 2026-06-25
-
-### Changed
-
-- chore: sync fork with upstream v0.11.8 (integrates v0.9.x ā v0.11.x)
-
-## [satware-0.8.0] - 2026-04-24
-
-### Changed
-
-- chore: sync fork with upstream v0.8.0
+## [0.15.0] - 2026-07-30
+
+### Changed
+
+- Add yolo to community workflow catalog (#3864)
+- fix(workflows): guard the shell step's timeout check against OverflowError (#3865)
+- Add Intent Reconciliation extension to community catalog (#3858)
+- fix(workflows): validate prompt step 'timeout' like the shell step (#3847)
+- fix: add utf-8 encoding to registry file open calls (#3816)
+- fix: eliminate TOCTOU race in file unlink calls (#3815)
+- test(workflows): name the condition-rejection tests for the real boundary (#3808)
+- fix: eliminate TOCTOU race in file unlink calls (#3811)
+- fix(presets): escape user-supplied catalog name/URL in add/remove output (#3806)
+- fix: add missing utf-8 encoding to registry file open calls (#3810)
+- [bug-fix] Fix upgrade-overwrites-copilot-skills: pass force=True to extension skill re-registration after upgrade (#3853)
+- fix(integrations): don't abort uninstall when the manifest can't be deleted (#3805)
+- test(extensions): update stale manifest validation message assertion (#3859)
+- fix(agents): coerce a non-string description in TOML command rendering (#3799)
+- fix(workflows): make security requirements sync deterministic (#3832)
+- fix(cli): render the literal [suffix] in --tag help and rejection message (#3800)
+- fix(integrations): preserve non-UTF-8 VS Code settings (#3833)
+- fix(bundler): treat an explicit-null manifest field as missing, not the text "None" (#3798)
+- feat: first-class agent-native runtime hooks for integrations (#3704)
+- fix(extensions): guard the required manifest sections so one bad extension cannot break `extension list` (#3797)
+- fix(presets): escape installed preset metadata in Rich output (#3826)
+- fix(workflows): dispatch prompt steps via the resolved executable (#3793)
+- chore: release 0.14.4, begin 0.14.5.dev0 development (#3850)
+
+## [0.14.4] - 2026-07-29
+
+### Changed
+
+- fix(bundler): degrade non-UTF-8 config reads into BundlerError (#3784)
+- fix(workflows): escape the step-progress line so step ids render (and `/` stops failing the run) (#3783)
+- Update Agent Parity Governance preset to v0.4.1 (#3830)
+- fix(integrations): reject empty --commands-dir in generic raw_options (#3714)
+- fix(presets): guard non-list/non-mapping provides.templates in PresetManifest (#3712)
+- fix(auth): resolve az via shutil.which so azure-cli token works on Windows (#3709)
+- fix(workflows): reject falsy non-mapping workflow-catalogs.yml top level (#3707)
+- fix(integrations): render hyphenated /speckit- for Droid (always-slash agent) (#3688)
+- [preset] Update A11Y Governance preset to v0.4.2 (#3828)
+- [preset] Update Parallel Autonomous Run Governance to v0.2.4 (#3825)
+- fix: correct Optional type annotation for _resolved_dir parameter (#3801)
+- fix: add timeout to prompt step subprocess execution (#3768)
+- fix: handle tags containing / in GitHub release asset URL resolution (#3767)
+- fix(presets): escape catalog metadata in discovery output (#3773)
+- Update Autonomous Run Governance preset to v0.3.3 (#3823)
+- fix: use bounded read for integration catalog HTTP responses (#3763)
+- docs: add Simplified Chinese translation of README (#3740)
+- Update Intake Sequencing Governance preset to v0.2.2 (#3809)
+- fix(workflows): reject non-string/non-boolean 'condition' in if/while/do-while steps (#3706)
+- fix(bundle): escape catalog metadata in discovery output (#3774)
+- fix(workflows,extensions): tolerate non-list catalog tags in search/info display (#3770)
+- fix: correct nullable resolved directory annotation (#3771)
+- fix(presets): tolerate non-string and non-list catalog fields in preset search/info (#3769)
+- fix(integrations): escape catalog metadata in discovery output (#3772)
+- Update Verify Review Ship extension to v0.4.2 (#3792)
+- fix(integrations): preserve native skill invocation prefixes (#3663)
+- Update Intake Review Governance preset to v0.2.0 (#3796)
+- fix(constitution): stop propagating guidance into templates (#3737) (#3790)
+- chore: release 0.14.3, begin 0.14.4.dev0 development (#3795)
+
+## [0.14.3] - 2026-07-28
+
+### Changed
+
+- Update Intake Authoring Governance preset to v0.3.0 (#3788)
+- fix(copilot): honor preset command template overrides (#3592)
+- clarify: require real interrogatives, ban topic-label questions (#3745)
+- feat: Add Alquimia AI integration (#2734)
+- harden: secure extension and preset archive downloads (#3141)
+- fix: correct Optional type annotation for context_note parameter (#3765)
+- Update AGENTS.md (#2626)
+- fix(extensions): tolerate non-string catalog name in display-name lookup (#3747)
+- fix(presets): coerce non-string catalog tags before joining (#3743)
+- fix: register extensions for the active integration only (#3459)
+- fix(extensions): tolerate non-string tags in catalog search (#3746)
+- fix(extensions): hyphenate command names in 'extension info' listing (#3744)
+- fix(workflows): escape remaining untrusted fields in `workflow info` (#3731)
+- fix(extensions): guard non-numeric catalog downloads in search/info rendering (#3710)
+- fix(agent-context): apply default markers when config markers are blank (bash) (#3736)
+- fix: escape Rich markup in catalog list output (#3738)
+- fix(workflows): guard non-mapping 'workflow:' block in WorkflowDefinition (#3694)
+- fix(bundler): reject unsupported schema_version in _merge_config (align readers) (#3711)
+- Update Linear Weave extension to v1.0.1 (#3762)
+- Add Intake Sequencing Governance preset to community catalog (#3761)
+- Update Quality Gates (Enforcement Layer) extension to v0.3.3 (#3760)
+- Update Verify Review Ship extension to v0.4.1 (#3759)
+- fix(agent-context): discover nested plans in Python port mtime fallback (#3734)
+- fix(extensions): make shipped scripts executable after install (#3723)
+- docs(assess): clarify the pipeline works on an empty project (#3732)
+- chore: release 0.14.2, begin 0.14.3.dev0 development (#3730)
+
+## [0.14.2] - 2026-07-24
+
+### Changed
+
+- Update Intake Review Governance preset to v0.1.1 (#3729)
+- Update Verify Review Ship extension to v0.3.0 (#3728)
+- Update Architecture Guard extension to v1.13.1 (#3724)
+- docs(upgrade): Claude Code files live in .claude/skills, not .claude/commands (#3708)
+- fix(kilocode): install commands under .kilo/commands (#3672)
+- fix(auth): normalize whitespace in auth-config env-var/id references at store time (#3691)
+- fix(workflows): guard non-mapping 'inputs:' block in engine._resolve_inputs (#3696)
+- Update Intake Authoring Governance preset to v0.2.0 (#3721)
+- docs: clarify shell-step interpolation safety (#3719)
+- [extension] Add Blueprint Index ā Living Architecture Map extension to community catalog (#3718)
+- fix(github-http): return None on malformed host in resolve_github_release_asset_api_url (#3715)
+- fix(integrations): declare PiIntegration multi_install_safe (#3652)
+- harden: remove shell parameter from run_command() (#3716)
+- chore(deps): bump github/codeql-action/init from 4.37.1 to 4.37.3 (#3699)
+- fix: auto-correct conflicting feature prefixes (#1829)
+- chore(deps): bump actions/checkout from 6.0.3 to 7.0.1 (#3703)
+- chore(deps): bump DavidAnson/markdownlint-cli2-action (#3702)
+- chore(deps): bump actions/setup-node from 6.4.0 to 7.0.0 (#3701)
+- chore(deps): bump astral-sh/setup-uv from 8.3.2 to 9.0.0 (#3700)
+- chore: release 0.14.1, begin 0.14.2.dev0 development (#3698)
+
+## [0.14.1] - 2026-07-23
+
+### Changed
+
+- Update Agent Parity Governance preset to v0.4.0 (#3697)
+- fix(bundler): InstallResult.changed counts uninstalled as a change (#3692)
+- [preset] Update Cross-Platform Governance preset to v0.2.1 (#3695)
+- Update A11Y Governance preset to v0.4.1 (#3693)
+- fix(workflows): escape step-graph brackets in `workflow info` so the type shows (#3690)
+- fix(workflows): filter parser rejects trailing tokens (fullmatch, not match) (#3689)
+- Update iSAQB Architecture Governance preset to v0.2.1 (#3687)
+- fix(extensions): parse SKILL.md on the --- delimiter line during removal (#3634)
+- fix(cli): guard lazy .hostname ValueError in extension/preset add --from (#3651)
+- Update Architecture Governance preset to v0.5.1 (#3686)
+- fix(bundler): reject a top-level non-mapping bundle-catalogs.yml in _merge_config (#3659)
+- Update Security Governance preset to v0.6.1 (#3685)
+- fix(integrations): declare OmpIntegration multi_install_safe (#3650)
+- feat(git-extension): add configurable Conventional Commit support (#3390) (#3413)
+- fix(extensions): hyphenate command names in the Forge post-install listing (#3669)
+- fix(bundler): reject falsy non-mapping requires/provides in CatalogEntry.from_dict (#3667)
+- fix(bundler): reject falsy non-list bundles/contributed_components in records (#3666)
+- Update Intake Authoring Governance preset to v0.1.1 (#3678)
+- docs(extensions): clarify agent-context README and add config examples (#3389)
+- chore: release 0.14.0, begin 0.14.1.dev0 development (#3677)
+
+## [0.14.0] - 2026-07-23
+
+### Changed
+
+- docs: add spec-kit-copilot to community friends (#3675)
+- fix(integrations): recompute invoke_separator from retained parsed_options (#3664)
+- fix(workflows): preserve intra-overlay order for multiple insert_after edits (#3662)
+- fix(bundler): reject falsy non-mapping requires/provides in manifest from_dict (#3661)
+- fix(bundler): dump_yaml writes literal UTF-8 (allow_unicode=True) (#3660)
+- fix(integrations): declare kiro-cli multi-install safe (#3477)
+- fix(git-extension): trim trailing whitespace before stripping commit-message quotes (#3673)
+- fix(bundler): order bundle members by canonical POSIX arcname (reproducible builds) (#3658)
+- fix(integrations): Cline overrides post_process_command_content (correct hook name) (#3657)
+- docs(workflows): gate step docstring lists the 'retry' on_reject behaviour (#3656)
+- fix: harden bounded reads and redirect validation (#3671)
+- fix(packaging): bundle scripts/python into the wheel core_pack (#3665) (#3670)
+- fix: bundle scripts/python in wheel so --script py works (#3665) (#3668)
+- docs(workflows): init step docstring lists the 'py' script type (#3655)
+- fix(integrations): declare LingmaIntegration multi_install_safe (#3654)
+- fix: guard constitution command against feature execution (#3646)
+- Fix duplicate step numbering in specify command (#3647)
+- docs(scripts): document the 'py' script type and sh/ps migration plan (#3284) (#3653)
+- harden: bound HTTP reads and enforce strict redirects (#3140)
+- chore: release 0.13.4, begin 0.13.5.dev0 development (#3649)
+
+## [0.13.4] - 2026-07-22
+
+### Changed
+
+- docs(concepts): document the spec-of-specs feature breakdown approach (#3648)
+- fix(scripts): git-ext PowerShell emits the '# To persist' SPECIFY_FEATURE hint (parity) (#3632)
+- fix(integrations): validate cached catalog shape before returning it (#3627)
+- fix(bundler): reject non-list 'catalogs' in bundle-catalogs.yml with a clean error (#3623)
+- fix(bundler): guard lazy .hostname ValueError in catalog add_source (#3644)
+- Add Intake Authoring Governance preset to community catalog (#3643)
+- feat: add Factory Droid CLI integration (#822) (#3587)
+- docs(installation): document the 'py' (Python) script type (#3640)
+- fix(init): show hyphenated /speckit- 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- invocations (#3622)
+- docs(upgrade): document integration upgrade / extension update as the project-files upgrade path (#3326)
+- chore: release 0.13.3, begin 0.13.4.dev0 development (#3645)
+
+## [0.13.3] - 2026-07-22
+
+### Changed
+
+- fix(integrations): escape Rich markup in --integration-options error messages (#3458)
+- docs: document __SPECKIT_COMMAND_ token for portable cross-command references (#3503)
+- [preset] Add Parallel Autonomous Run Governance preset to community catalog (#3614)
+- docs(workflows): fix stale FanOutStep docstring claiming sequential-only execution (#3639)
+- [bundle] Add SicarioSpec Security & Governance Bundle to community catalog (#3636)
+- [preset] Update Autonomous Run Governance preset to v0.3.2 (#3615)
+- fix(workflows): validate every redirect hop when fetching workflow/step catalogs (#3637)
+- Add pipeline workflow to community catalog (#3338)
+- [extension] Add Linear Weave extension to community catalog (#3609)
+- docs: clarify hook priority validation semantics (#3594)
+- fix(workflows): reject a non-string 'integration'/'model' in command & prompt steps (#3597)
+- ci: add dependency audit workflow (#3138)
+- Add Intake Review Governance preset to community catalog (#3613)
+- fix(workflows): reject non-list input 'enum' instead of crashing (#3601)
+- chore: release 0.13.2, begin 0.13.3.dev0 development (#3617)
+
+## [0.13.2] - 2026-07-21
+
+### Changed
+
+- fix(workflows): reject a non-string 'command' in command-step (#3596)
+- fix(workflows): fail gate step loudly on a malformed 'options' (#3595)
+- fix(extensions): re-validate catalog URL after redirects (HTTPS parity/security) (#3524)
+- Add community bundle submission automation (#3553)
+- fix(presets): re-validate catalog URL after redirects (HTTPS parity/security) (#3523)
+- feat(scripts): port create-new-feature, setup-plan and setup-tasks to Python (#3386)
+- fix(agents): parse frontmatter on the --- delimiter line, not any --- substring (#3590)
+- [bug-fix] Fix reinstall-overwrites-kept-config: preserve config on plain reinstall after --keep-config (#3449)
+- feat: update Bob integration to skills-based layout for Bob 2.0 (#3415)
+- Update OKF Knowledge Bundle Generator to v0.3.0 (#3608)
+- Add Test Coverage Drift Control extension to community catalog (#3607)
+- chore: align ruff lint scope (#3139)
+- feat(workflows): WorkflowResolver standalone (PR 1) (#3557)
+- fix(extensions,presets): surface clean error on malformed download URL (#3577)
+- chore: release 0.13.1, begin 0.13.2.dev0 development (#3610)
+
+## [0.13.1] - 2026-07-21
+
+### Changed
+
+- fix(integrations): catch OverflowError on a `priority: .inf` in add/remove (#3589)
+- fix(workflows): reject bool / .inf catalog priority in workflow & step catalog loaders (#3526)
+- fix(catalogs): 'priority: .inf' yields a clean validation error instead of crashing (#3525)
+- docs(integrations): document the 'integration list --catalog' flag (#3530)
+- fix(workflows): fail fan-in loudly on a non-string wait_for entry (#3579)
+- fix(workflows): fail fan-out loudly on a truthy non-mapping step template (#3537)
+- fix(workflows): reject a non-string prompt in prompt-step validate() (#3582)
+- fix(workflows): route 'workflow status --json' errors to stderr (#3520)
+- fix(integrations): Forge dispatches hyphenated /speckit- invocations (#3529)
+- chore: release 0.13.0, begin 0.13.1.dev0 development (#3588)
## [0.13.0] - 2026-07-17
@@ -974,12 +1163,6 @@
- fix: `--force` now overwrites shared infra files during init and upgrade (#2320)
- chore: release 0.7.5, begin 0.7.6.dev0 development (#2322)
-## [satware-0.7.3+1] - 2026-04-20
-
-### Changed
-
-- chore: sync fork with upstream v0.7.3; accept upstream's marker-based context upsert (replacing shell-based `update-context.*` scripts) and verify all fork-only agents (agy, bob, iflow, kimi, hermes, cline) set `context_file` correctly
-
## [0.7.5] - 2026-04-22
### Changed
diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md
index 7cc6d28f86..8dcc6c1533 100644
--- a/CONTRIBUTING.md
+++ b/CONTRIBUTING.md
@@ -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
diff --git a/README.md b/README.md
index 793ee2f3c7..9d2c845a8b 100644
--- a/README.md
+++ b/README.md
@@ -15,6 +15,11 @@
+
+ English Ā·
+ ē®ä½äøę
+
+
---
## Table of Contents
diff --git a/README.zh-CN.md b/README.zh-CN.md
new file mode 100644
index 0000000000..b90809eee7
--- /dev/null
+++ b/README.zh-CN.md
@@ -0,0 +1,361 @@
+
+

+
š± Spec Kit
+
åØåØęē¼ē ä¹åļ¼å
å®ä¹č¦ę建ä»ä¹ āā éé
ä»»ę AI ē¼ē å©ęć
+
+
+
+ äøäøŖå¼ęŗå·„å
·å„ä»¶ļ¼åø®å©ä½ åå©ä»»ę AI ē¼ē å©ęę建é«č“Øé软件 āā å
ē½®å¼ē®±å³ēØēč§č驱åØęµēØļ¼ä¹åÆčŖåø¦ęµēØļ¼ļ¼åÆę éę©å±ćē±ē¤¾åŗé©±åØļ¼å¹¶äøŗę“äøŖē»ē»ēåä½č设讔ć
+
+
+
+
+
+
+
+
+
+
+ English Ā·
+ ē®ä½äøę
+
+
+---
+
+## ē®å½
+
+- [š¤ ä»ä¹ęÆč§č驱åØå¼åļ¼](#-ä»ä¹ęÆč§č驱åØå¼å)
+- [ā” åæ«éå¼å§](#-åæ«éå¼å§)
+- [š½ļø č§é¢ę¦č§](#ļø-č§é¢ę¦č§)
+- [š 社åŗ](#-社åŗ)
+- [š¤ ęÆęē AI ē¼ē å©ęéę](#-ęÆęē-ai-ē¼ē å©ęéę)
+- [š§ Specify CLI åč](#-specify-cli-åč)
+- [š§© ęé ä½ čŖå·±ē Spec Kitļ¼ę©å±äøé¢č®¾](#-ęé ä½ čŖå·±ē-spec-kitę©å±äøé¢č®¾)
+- [š¦ ęē»å
ļ¼é¢åč§č²ēäøé®é
ē½®](#-ęē»å
é¢åč§č²ēäøé®é
ē½®)
+- [š ę øåæēåæµ](#-ę øåæēåæµ)
+- [š å¼åé¶ę®µ](#-å¼åé¶ę®µ)
+- [šÆ å®éŖē®ę ](#-å®éŖē®ę )
+- [š§ ēÆå¢č¦ę±](#-ēÆå¢č¦ę±)
+- [š ę·±å
„äŗč§£](#-ę·±å
„äŗč§£)
+- [š¬ ęÆę](#-ęÆę)
+- [š č“č°¢](#-č“č°¢)
+- [š 许åÆčÆ](#-许åÆčÆ)
+
+## š¤ ä»ä¹ęÆč§č驱åØå¼åļ¼
+
+č§č驱åØå¼åļ¼Spec-Driven Developmentļ¼**é¢ č¦äŗ**ä¼ ē»č½Æä»¶å¼åēęč·Æćå å幓ę„ļ¼ä»£ē äøē“ęÆę øåæ āā č§čåŖęÆē¼ē čæé”¹"ę£äŗ"å¼å§åęčµ·ćéå就被丢å¼ēčęę¶ćč§č驱åØå¼åę¹åäŗčæäøē¹ļ¼**č§čę¬čŗ«åå¾åÆę§č”**ļ¼å®äøååŖęÆå¼åƼå®ē°ļ¼čęÆē“ę„ēęåÆčæč”ēå®ē°ć
+
+## ā” åæ«éå¼å§
+
+### 1. å®č£
Specify CLI
+
+éč¦ **[uv](https://docs.astral.sh/uv/)**ļ¼[å®č£
uv](./docs/install/uv.md)ļ¼ćå° `vX.Y.Z` ęæę¢äøŗ [Releases](https://github.com/github/spec-kit/releases) äøęę°ēååøę ē¾ āā č®°å¾äæēå¼å¤“ē `v`ļ¼ä¾å¦ `v0.12.11`ļ¼čäøęÆ `0.12.11`ļ¼ļ¼
+
+```bash
+uv tool install specify-cli --from git+https://github.com/github/spec-kit.git@vX.Y.Z
+```
+
+ę“å¾åä» PyPI å®č£
ļ¼`specify-cli` å
åę ·ååøåØé£éļ¼
+
+```bash
+uv tool install specify-cli
+```
+
+å
¶ä»å®č£
ę¹å¼ćå®č£
ę ”éŖćå级仄åę
éęę„ļ¼čÆ·åé
[å®č£
ęå](./docs/installation.md)ć
+
+### 2. åå§å锹ē®
+
+```bash
+specify init my-project --integration copilot
+cd my-project
+```
+
+č¦ę£ę„ę“ę°ęåēŗ§å·²å®č£
ē CLIļ¼åÆä½æēØčŖē®”ēå½ä»¤ćę“详ē»ēåŗęÆåčŖå®ä¹é锹请åé
[åēŗ§ęå](./docs/upgrade.md)ć
+
+```bash
+# ę£ę„ęÆå¦ęę“ę°ēę¬åÆēØļ¼åŖčÆ»ęä½ āā äøä¼äæ®ę¹ä»»ä½å
容ļ¼
+specify self check
+
+# é¢č§åēŗ§å°ę§č”ēęä½ļ¼ä½äøå®é
åēŗ§
+specify self upgrade --dry-run
+
+# å°±å°åēŗ§å°ęę°ēسå®ēļ¼čŖåØčÆå« uv tool äø pipx å®č£
ę¹å¼ļ¼
+specify self upgrade
+
+# ęéå®å°ęå®ēååøę ē¾ļ¼å° vX.Y.Z[suffix] ęæę¢äøŗä½ ę³č¦ēę ē¾ļ¼
+specify self upgrade --tag vX.Y.Z[suffix]
+```
+
+ē“ę„čæč” `specify self upgrade` ä¼ē«å³ę§č”ļ¼äø `pip install -U`ć`npm update` ēå½ä»¤äøę ·ę éé¢å¤ē”®č®¤ćåÆ¹äŗ `uv tool` å®č£
ēę
åµļ¼å®åØåŗå±ä¼ę§č” `uv tool install specify-cli --force --from `ļ¼å ę¤éå®ēååøę ē¾åę ·ęęļ¼å
ę¬ devćalpha/beta/rc ęåø¦ę建å
ę°ę®ēåē¼ć`uvx`ļ¼äø“ę¶čæč”ļ¼åęŗē ę£åŗä¼č¢«čŖåØčÆå«ļ¼ę¤ę¶ä¼ē»åŗé对å
·ä½č·Æå¾ēęä½å»ŗč®®ļ¼čäøä¼ę§č”å®č£
ēØåŗćåÆéčæč®¾ē½® `SPECIFY_UPGRADE_TIMEOUT_SECS` ę„éå¶å®č£
åčæēØēęéæčæč”ę¶é“ļ¼é»č®¤ę č¶
ę¶éå¶ āā åæ
č¦ę¶ēØ `Ctrl+C` äøęļ¼ć
+
+### 3. ē”®ē«é”¹ē®åå
+
+åØé”¹ē®ē®å½äøåÆåØä½ ēē¼ē å©ęć大å¤ę°å©ęå° spec-kit ę“é²äøŗ `/speckit.*` ęę å½ä»¤ļ¼å¤äŗęč½ļ¼skillsļ¼ęØ”å¼ē Codex CLI åä½æēØ `$speckit-*`ļ¼GitHub Copilot CLI ä½æēØ `/agents` ę„éę©å©ęļ¼ęē“ę„åØę示čÆäøęå®å®ć
+
+ä½æēØ **`/speckit.constitution`** å½ä»¤ę„å建锹ē®ēę²»ēåååå¼åęåļ¼å®ä»¬å°ę导åē»ęęå¼åå·„ä½ć
+
+```bash
+/speckit.constitution Create principles focused on code quality, testing standards, user experience consistency, and performance requirements
+```
+
+### 4. ē¼åč§č
+
+ä½æēØ **`/speckit.specify`** å½ä»¤ęčæ°ä½ ę³ę建ä»ä¹ćčē¦äŗ**åä»ä¹**å**äøŗä»ä¹å**ļ¼čäøęÆęęÆę ć
+
+```bash
+/speckit.specify Build an application that can help me organize my photos in separate photo albums. Albums are grouped by date and can be re-organized by dragging and dropping on the main page. Albums are never in other nested albums. Within each album, photos are previewed in a tile-like interface.
+```
+
+### 5. å¶å®ęęÆå®ē°ę¹ę”
+
+ä½æēØ **`/speckit.plan`** å½ä»¤ęä¾ä½ ēęęÆę åę¶ęéę©ć
+
+```bash
+/speckit.plan The application uses Vite with minimal number of libraries. Use vanilla HTML, CSS, and JavaScript as much as possible. Images are not uploaded anywhere and metadata is stored in a local SQLite database.
+```
+
+### 6. ę解为任å”
+
+ä½æēØ **`/speckit.tasks`** ä»å®ē°ę¹ę”ēęäøä»½åÆę§č”ēä»»å”ęø
åć
+
+```bash
+/speckit.tasks
+```
+
+### 7. ę§č”å®ē°
+
+ä½æēØ **`/speckit.implement`** ę§č”ęęä»»å”ļ¼ęę¹ę”ęå»ŗä½ ēåč½ć
+
+```bash
+/speckit.implement
+```
+
+详ē»ēåę„诓ęļ¼čÆ·åé
ę们ē[å®ę“ęå](./spec-driven.md)ć
+
+## š½ļø č§é¢ę¦č§
+
+ę³ēē Spec Kit ēå®é
ęęļ¼č§ēę们ē[č§é¢ę¦č§](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)ļ¼
+
+[](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
+
+## š 社åŗ
+
+åØ [Spec Kit ę攣ē«ē¹](https://github.github.io/spec-kit/)äøę¢ē“¢ē±ē¤¾åŗč“”ē®ēčµęŗļ¼
+
+- [ę©å±ļ¼Extensionsļ¼](https://github.github.io/spec-kit/community/extensions.html) āā å½ä»¤ćé©åäøåē±»č½å
+- [é¢č®¾ļ¼Presetsļ¼](https://github.github.io/spec-kit/community/presets.html) āā 樔ęæäøęÆčÆč¦ē
+- [ęē»å
ļ¼Bundlesļ¼](https://github.github.io/spec-kit/community/bundles.html) āā ē±ē°ęē»ä»¶ē»åčęēč§č²äøå¢éęęÆę
+- [å®ęę¼ē»ļ¼Walkthroughsļ¼](https://github.github.io/spec-kit/community/walkthroughs.html) āā 端å°ē«Æē SDD åŗęÆ
+- [ä¼ä¼“锹ē®ļ¼Friendsļ¼](https://github.github.io/spec-kit/community/friends.html) āā ę©å± Spec Kit ęåŗäŗå®ę建ē锹ē®
+
+> [!NOTE]
+> 社åŗč“”ē®ē±åčŖēä½č
ē¬ē«å建å结ę¤ć请åØå®č£
åå®”é
ęŗä»£ē ļ¼å¹¶čŖč”ęé
使ēØć
+
+ę³č¦åäøč“”ē®ļ¼čÆ·åé
[ę©å±ååøęå](extensions/EXTENSION-PUBLISHING-GUIDE.md)ć[é¢č®¾ååøęå](presets/PUBLISHING.md)ę[社åŗęē»å
ęå](docs/community/bundles.md)ć
+
+## š¤ ęÆęē AI ē¼ē å©ęéę
+
+Spec Kit åÆäø 30 å¤äøŖ AI ē¼ē å©ęåä½ āā ę¢å
ę¬ CLI å·„å
·ļ¼ä¹å
ę¬åŗäŗ IDE ēå©ęćå®ę“å蔨仄åēøå
³čÆ“ęå使ēØē»čļ¼čÆ·åé
[ęÆęē AI ē¼ē å©ęéę](https://github.github.io/spec-kit/reference/integrations.html)ęåć
+
+čæč” `specify integration list` åÆę„ēå½åå®č£
ēę¬äøęęåÆēØēéęć
+
+## åÆēØēęę å½ä»¤
+
+čæč” `specify init` åļ¼ä½ ē AI ē¼ē å©ęå°±č½ä½æēØčæäŗęę å½ä»¤ę„čæč”ē»ęåå¼åć对äŗęÆęęč½ęØ”å¼ēéęļ¼ä¼ å
„ `--integration --integration-options="--skills"` ä¼å®č£
å©ęęč½ļ¼čäøęÆęę å½ä»¤ēę示čÆęä»¶ć
+
+### ę øåæå½ä»¤
+
+č§č驱åØå¼åå·„ä½ęµäøåæ
äøåÆå°ēå½ä»¤ļ¼
+
+| å½ä»¤ | å©ęęč½ | 诓ę |
+| ------------------------ | ---------------------- | ---------------------------------------------------------- |
+| `/speckit.constitution` | `speckit-constitution` | å建ęę“ę°é”¹ē®ēę²»ēåååå¼åęå |
+| `/speckit.specify` | `speckit-specify` | å®ä¹ä½ ę³ę建ä»ä¹ļ¼éę±äøēØę·ę
äŗļ¼ |
+| `/speckit.plan` | `speckit-plan` | ē»åęéęęÆę å¶å®ęęÆå®ē°ę¹ę” |
+| `/speckit.tasks` | `speckit-tasks` | ēęåÆę§č”ēå®ē°ä»»å”ęø
å |
+| `/speckit.taskstoissues` | `speckit-taskstoissues`| å°ēęēä»»å”ęø
å转ę¢äøŗ GitHub issueļ¼ä¾æäŗč·čøŖäøę§č” |
+| `/speckit.implement` | `speckit-implement` | ę§č”ęęä»»å”ļ¼ęę¹ę”ę建åč½ |
+| `/speckit.converge` | `speckit-converge` | 对ē
§č§č/ę¹ę”/ä»»å”čÆä¼°ä»£ē åŗļ¼å¹¶å°å©ä½å·„ä½čæ½å äøŗę°ä»»å” |
+
+### åÆéå½ä»¤
+
+ēØäŗęå蓨éäøåę ”éŖēé¢å¤å½ä»¤ļ¼
+
+| å½ä»¤ | å©ęęč½ | 诓ę |
+| -------------------- | ---------------------- | ------------------------------------------------------------------------------------------------- |
+| `/speckit.clarify` | `speckit-clarify` | ę¾ęø
ęčæ°äøå
åēéØåļ¼å»ŗč®®åØ `/speckit.plan` ä¹å使ēØļ¼ę§ē§° `/quizme`ļ¼ |
+| `/speckit.analyze` | `speckit-analyze` | č·Øå¶åēäøč“ę§äøč¦ēåŗ¦åęļ¼åØ `/speckit.tasks` ä¹åć`/speckit.implement` ä¹åčæč”ļ¼ |
+| `/speckit.checklist` | `speckit-checklist` | ēęčŖå®ä¹č“Øéęø
åļ¼ę ”éŖéę±ēå®ę“ę§ćęø
ę°åŗ¦äøäøč“ę§ļ¼å„½ęÆ"äøŗčŖē¶čÆčØååå
ęµčÆ"ļ¼ |
+
+## š§ Specify CLI åč
+
+å®ę“ēå½ä»¤čƦę
ćé锹äøē¤ŗä¾ļ¼čÆ·åé
[CLI åčę攣](https://github.github.io/spec-kit/reference/overview.html)ć
+
+## š§© ęé ä½ čŖå·±ē Spec Kitļ¼ę©å±äøé¢č®¾
+
+Spec Kit åÆéčæäø¤å„äŗč”„ēęŗå¶čæč”深度å®å¶ āā **ę©å±ļ¼extensionsļ¼** å **é¢č®¾ļ¼presetsļ¼** āā 仄åé¢åå个锹ē®ēę¬å°č¦ēļ¼ēØäŗäø“ę¶ę§č°ę“ļ¼
+
+| ä¼å
ēŗ§ | ē»ä»¶ē±»å | ä½ē½® |
+| -----: | ---------------------------------- | -------------------------------- |
+| ⬠1 | 锹ē®ę¬å°č¦ē | `.specify/templates/overrides/` |
+| 2 | é¢č®¾ āā å®å¶ę øåæäøę©å± | `.specify/presets/templates/` |
+| 3 | ę©å± āā ę°å¢č½å | `.specify/extensions/templates/` |
+| ⬠4 | Spec Kit ę øåæ āā å
ē½® SDD å½ä»¤äøęØ”ęæ | `.specify/templates/` |
+
+- **樔ęæ**åØ**čæč”ę¶**č§£ę āā Spec Kit ä»é«å°ä½éåä¼å
ēŗ§ę ļ¼ä½æēØē¬¬äøäøŖå¹é
锹ć
+- 锹ē®ę¬å°č¦ēļ¼`.specify/templates/overrides/`ļ¼å
许对å个锹ē®åäøę¬”ę§č°ę“ļ¼ę éå建å®ę“ēé¢č®¾ć
+- **ę©å±/é¢č®¾å½ä»¤**åØ**å®č£
ę¶**ēę āā å½ä½ čæč” `specify extension add` ę `specify preset add` ę¶ļ¼å½ä»¤ęä»¶ä¼č¢«åå
„å©ęē®å½ļ¼å¦ `.claude/commands/`ļ¼ć
+- č„å¤äøŖé¢č®¾ęę©å±ęä¾äŗåäøå½ä»¤ļ¼ä¼å
ēŗ§ęé«ēēę¬ēęćē§»é¤ę¶ļ¼ę¬”ä¼å
ēŗ§ēēę¬ä¼čŖåØę¢å¤ć
+- č„äøååØä»»ä½č¦ēęčŖå®ä¹ļ¼Spec Kit 使ēØę øåæé»č®¤é
ē½®ć
+
+### ę©å± āā ę°å¢č½å
+
+å½ä½ éč¦ Spec Kit ę øåæä¹å¤ēåč½ę¶ļ¼ä½æēØ**ę©å±**ćę©å±åÆå¼å
„ę°å½ä»¤åęØ”ęæ āā ä¾å¦ę·»å ę øåæ SDD å½ä»¤ęŖč¦ēēé¢åē¹å®å·„ä½ęµćéęå¤éØå·„å
·ļ¼ęę°å¢å
Øę°ēå¼åé¶ę®µćå®ä»¬ę©å±äŗ *Spec Kit č½åä»ä¹*ć
+
+```bash
+# ęē“¢åÆēØę©å±
+specify extension search
+
+# å®č£
ę©å±
+specify extension add
+```
+
+äø¾ä¾ę„诓ļ¼ę©å±åÆä»„ę·»å Jira éęćå®ē°å代ē å®”ę„ćV 樔åęµčÆčæ½ęŗÆę§ļ¼ę锹ē®å„åŗ·čÆęēåč½ć
+
+å®ę“å½ä»¤ęå请åé
[ę©å±åčę攣](https://github.github.io/spec-kit/reference/extensions.html)ćęµč§[社åŗę©å±](https://github.github.io/spec-kit/community/extensions.html)äŗč§£ē°ęčµęŗć
+
+### é¢č®¾ āā å®å¶ē°ęå·„ä½ęµ
+
+å½ä½ ę³ę¹å Spec Kit ē*å·„ä½ę¹å¼*čäøęÆę°å¢č½åę¶ļ¼ä½æēØ**é¢č®¾**ćé¢č®¾ä¼č¦ēę øåæåå·²å®č£
ę©å±äøéåø¦ē樔ęæåå½ä»¤ āā ä¾å¦å¼ŗå¶ä½æēØé¢ååč§ēč§čę ¼å¼ćéēØé¢åē¹å®ęÆčÆļ¼ę对ę¹ę”åä»»å”åŗēØē»ē»č§čćé¢č®¾å®å¶ēęÆ Spec Kit åå
¶ę©å±ēęēå¶åäøę令ć
+
+```bash
+# ęē“¢åÆēØé¢č®¾
+specify preset search
+
+# å®č£
é¢č®¾
+specify preset add
+```
+
+äø¾ä¾ę„诓ļ¼é¢č®¾åÆä»„éęč§č樔ęæä»„č¦ę±ē箔追溯ę§ļ¼å°å·„ä½ęµéé
äøŗä½ ęēØēę¹ę³č®ŗļ¼å¦ęę·ćēęæćēåøćēØę·ä»»å”驱åØęé¢å驱åØč®¾č®”ļ¼ļ¼åØę¹ę”äøę·»å å¼ŗå¶å®å
Øå®”ę„å
³å”ļ¼å¼ŗå¶č¦ę±ęµčÆä¼å
ēä»»å”ęåŗļ¼ęå°ę“äøŖå·„ä½ęµę¬å°åäøŗå
¶ä»čÆčØć[ęµ·ēčÆę¼ē¤ŗ](https://github.com/mnriem/spec-kit-pirate-speak-preset-demo)å
åå±ē¤ŗäŗå®å¶ē深度ćå¤äøŖé¢č®¾åÆęä¼å
ēŗ§å å 使ēØć
+
+å®ę“å½ä»¤ęå仄åč§£ę锺åŗåä¼å
ēŗ§å å 诓ęļ¼čÆ·åé
[é¢č®¾åčę攣](https://github.github.io/spec-kit/reference/presets.html)ć
+
+## š¦ ęē»å
ļ¼é¢åč§č²ēäøé®é
ē½®
+
+ę©å±åé¢č®¾ęÆē¬ē«ēę建樔åćč**ęē»å
ļ¼bundleļ¼**å°äøē»ē²¾éēę©å±ćé¢č®¾ćę„éŖ¤åå·„ä½ęµęå
ęäøäøŖåø¦ēę¬ćé¢åč§č²ēé
ē½®ļ¼ä»čåÆä»„ēØäøę”å½ä»¤äøŗę“äøŖå¢éč§č²ļ¼äŗ§åē»ēćäøå”åęåøćå®å
Øē ē©¶åćå¼åč
ā¦ā¦ļ¼å®ęé
ē½®ć
+
+ęē»å
ē±äøä»½ęåē `bundle.yml` ęø
åęčæ°ćå®å°ęÆäøŖē»ä»¶éå®å°å
·ä½ēę¬ļ¼å¹¶åÆéę©ę§å°é¢åē¹å®éęļ¼ęŖęå® `integration` ēęē»å
ęÆ**äøē«ē**ļ¼ä¼ę²æēØé”¹ē®å½å已使ēØēéęć
+
+```bash
+# åØå½åęæę“»ēē®å½ę äøåē°ęē»å
+specify bundle search []
+
+# ę„ēęē»å
å°ę·»å ēē”®åē»ä»¶éåļ¼äøå®é
å®č£
ēå
容äøč“ļ¼
+specify bundle info
+
+# äøę„å®č£
ęē»å
ēå®ę“ē»ä»¶éå
+specify bundle install
+
+# ę„ēå·²å®č£
å
容ļ¼ē¶å仄éē “åę§ę¹å¼ę“ę°ęē§»é¤
+specify bundle list
+specify bundle update # ę --all
+specify bundle remove # ä»
ē§»é¤ę¤ęē»å
ēē»ä»¶
+```
+
+ęē»å
ä»äøäøŖ**ęä¼å
ēŗ§ęåŗēē®å½ę **ļ¼é”¹ē® > ēØę· > å
ē½®ļ¼äøč§£ęćęÆäøŖę„ęŗé½åø¦ęå®č£
ēē„ļ¼`install-allowed` ę„ęŗåÆēØäŗå®č£
ļ¼č `discovery-only` ę„ęŗåØ `search`/`info` äøåÆč§ä½ęē»å®č£
ćåÆéčæ `specify bundle catalog list|add|remove` ē®”ēē®å½ę ć
+
+ä½č
åØę¬å°ę ”éŖå¹¶ęå
ęē»å
ćååę¹å¼ęÆęē®”ę建产ē©å¹¶ę·»å äøäøŖē®å½ę„ęŗļ¼ē¤¾åŗęē»å
ęēØæčÆ·ä½æēØ [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue 樔ęæļ¼ä»„便对ęéēē»ä»¶ē®å½åå®č£
čÆę®čæč”å®”é
ļ¼
+
+```bash
+specify bundle validate --path ./my-bundle # ē»ęäøå¼ēØę£ę„
+specify bundle build --path ./my-bundle # ēęåø¦ēę¬ē .zip äŗ§ē©
+```
+
+[`examples/bundles/`](examples/bundles/) ē®å½äøęå份åÆē“ę„é
读ē示ä¾ęø
åļ¼äŗ§åē»ēćäøå”åęåøćå®å
Øē ē©¶åćå¼åč
ļ¼ć
+
+å
³é®äæčÆļ¼`info` å±ē¤ŗēå
å®¹äø `install` ę·»å ēå
容å®å
Øäøč“ļ¼éęę§ļ¼ļ¼å®č£
ęÆå¹ēēļ¼äøéå®åØé”¹ē®ę ¹ē®å½å
ļ¼`remove` ē»äøä¼č§¦ē¢°å
¶ä»å·²å®č£
ęē»å
ä»éč¦ēē»ä»¶ļ¼ęęę¶č“¹/åä½å½ä»¤é½č½é对ę¬å°ęéå®ēę„ęŗ**离线**å·„ä½ć
+
+### ä½ę¶ēØåŖäøŖ
+
+| ē®ę | ä½æēØ |
+| --- | --- |
+| ę·»å å
Øę°ēå½ä»¤ęå·„ä½ęµ | ę©å± |
+| å®å¶č§čćę¹ę”ęä»»å”ēę ¼å¼ | é¢č®¾ |
+| éęå¤éØå·„å
·ęęå” | ę©å± |
+| å¼ŗå¶ę§č”ē»ē»ęēē®”č§č | é¢č®¾ |
+| äŗ¤ä»åÆå¤ēØēé¢åē¹å®ęØ”ęæ | ååÆ āā é¢č®¾ēØäŗęØ”ęæč¦ēļ¼ę©å±ēØäŗéę°å½ä»¤äøčµ·ęå
ēęØ”ęæ |
+| ēØäøę”å½ä»¤å®ęå®ę“ēč§č²é
ē½® | ęē»å
|
+
+## š ę øåæēåæµ
+
+č§č驱åØå¼åęÆäøå„ē»ęåęµēØļ¼å®å¼ŗč°ļ¼
+
+- **ęå¾é©±åØå¼å** āā 让č§čå
å®ä¹"*åä»ä¹*"ļ¼åč°"*ęä¹å*"
+- **äø°åÆēč§čę°å** āā åå©ę¤ę äøē»ē»ååę„ē¼åč§č
+- **å¤ę„ē²¾ē¼** āā čéä»ę示čÆäøę¬”ę§ēę代ē
+- **å
åä¾čµ**å
čæ AI 樔å对č§čē解读č½å
+
+## š å¼åé¶ę®µ
+
+| é¶ę®µ | ä¾§éē¹ | å
³é®ę“»åØ |
+| ----------------------------------------- | ------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------- |
+| **ä» 0 å° 1 å¼å**ļ¼"绿å°/Greenfield"ļ¼ | ä»é¶ēę | - ä»é«å±éę±åŗå
- ēęč§č
- č§åå®ē°ę„éŖ¤
- ę建ē产就绪ēåŗēØ
|
+| **åęę¢ē“¢** | å¹¶č”å®ē° | - ę¢ē“¢å¤ę ·åēč§£å³ę¹ę”
- ęÆęå¤ē§ęęÆę äøę¶ę
- čÆéŖäøåēēØę·ä½éŖęØ”å¼
|
+| **čæä»£å¢å¼ŗ**ļ¼"ę£å°/Brownfield"ļ¼ | åéē³»ē»ē°ä»£å | - čæä»£å¼ę·»å åč½
- ē°ä»£åę¹é éēē³»ē»
- č°ę“ęµēØ
|
+
+对äŗå·²ę锹ē®ļ¼čÆ·å° Spec Kit å·„å
·ę¬čŗ«ēę“ę°äøåč½å¶åēę¼čæåå¼å¤ēļ¼åēŗ§ę¶å·ę°åē®”ēē锹ē®ęä»¶ļ¼čåØé¢ęč”äøŗåēååę¶ę“ę° `specs/` å¶åć[č§čę¼čæęå](./docs/guides/evolving-specs.md)ä»ē»äŗęØčēę£å°čæä»£å¾ŖēÆć
+
+## šÆ å®éŖē®ę
+
+ę们ēē ē©¶äøå®éŖčē¦äŗļ¼
+
+### ęęÆę å
³ę§
+
+- 使ēØå¤ę ·åēęęÆę ę建åŗēØ
+- éŖčÆčæäøå设ļ¼č§č驱åØå¼åęÆäøå„ęµēØļ¼äøäøē¹å®ęęÆćē¼ēØčÆčØęę”ę¶ē»å®
+
+### ä¼äøēŗ§ēŗ¦ę
+
+- å±ē¤ŗå
³é®äøå”åŗēØēå¼å
+- ēŗ³å
„ē»ē»å±é¢ēēŗ¦ęļ¼äŗęå”åćęęÆę ćå·„ēØå®č·µļ¼
+- ęÆęä¼äøč®¾č®”ē³»ē»äøåč§č¦ę±
+
+### 仄ēØę·äøŗäøåæēå¼å
+
+- äøŗäøåēēØę·ē¾¤ä½åå儽ę建åŗēØ
+- ęÆęå¤ē§å¼åę¹å¼ļ¼ä»"ę°å“ē¼ē "å° AI åēå¼åļ¼
+
+### åęäøčæä»£ęµēØ
+
+- éŖčÆå¹¶č”å®ē°ę¢ē“¢ēēåæµ
+- ęä¾ēسå„ēčæä»£å¼åč½å¼åå·„ä½ęµ
+- å°ęµēØę©å±å°åēŗ§äøē°ä»£åę¹é ä»»å”
+
+## š§ ēÆå¢č¦ę±
+
+- **Linux/macOS/Windows**
+- [åęÆęē](#-ęÆęē-ai-ē¼ē å©ęéę) AI ē¼ē å©ęć
+- [uv](https://docs.astral.sh/uv/) ēØäŗå
ē®”ēļ¼ęØčļ¼ļ¼ę [pipx](https://pipx.pypa.io/) ēØäŗęä¹
åå®č£
+- [Python 3.11+](https://www.python.org/downloads/)
+- [Git](https://git-scm.com/downloads)
+
+å¦ęä½ åØä½æēØęäøŖå©ęę¶éå°é®é¢ļ¼ę¬¢čæęäŗ¤ issueļ¼ä»„便ę们å®åēøåŗéęć
+
+## š ę·±å
„äŗč§£
+
+- **[å®ę“ēč§č驱åØå¼åę¹ę³č®ŗ](./spec-driven.md)** āā ę·±å
„äŗč§£ę“äøŖęµēØ
+- **[åæ«éäøęęå](https://github.github.io/spec-kit/quickstart.html)** āā åę„å®ē°ę¼ē»
+
+---
+
+## š¬ ęÆę
+
+å¦éåø®å©ļ¼čÆ·ęäŗ¤ [GitHub issue](https://github.com/github/spec-kit/issues/new)ćę们欢čæē¼ŗé·ę„åćåč½å»ŗč®®ļ¼ä»„åå
³äŗä½æēØč§č驱åØå¼åēåē±»é®é¢ć
+
+## š č“č°¢
+
+ę¬é”¹ē®ę·±å [John Lam](https://github.com/jflam) ēå·„ä½äøē ē©¶ēå½±åļ¼å¹¶åØå
¶åŗē”äøę建ć
+
+## š 许åÆčÆ
+
+ę¬é”¹ē®åŗäŗ MIT å¼ęŗč®øåÆčÆēę”款ęęćå®ę“ę”款请åé
[LICENSE](./LICENSE) ęä»¶ć
diff --git a/bundles/catalog.community.json b/bundles/catalog.community.json
new file mode 100644
index 0000000000..0a371c1814
--- /dev/null
+++ b/bundles/catalog.community.json
@@ -0,0 +1,35 @@
+{
+ "schema_version": "1.0",
+ "updated_at": "2026-07-22T00:00:00Z",
+ "catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/bundles/catalog.community.json",
+ "bundles": {
+ "sicario-spec": {
+ "name": "SicarioSpec Security & Governance Bundle",
+ "id": "sicario-spec",
+ "version": "0.5.1",
+ "role": "security-engineer",
+ "description": "Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates.",
+ "author": "SicarioSpec Contributors",
+ "license": "MIT",
+ "download_url": "https://github.com/dfirs1car1o/sicario-spec/releases/download/v0.5.1/sicario-spec-0.5.1.zip",
+ "repository": "https://github.com/dfirs1car1o/sicario-spec",
+ "requires": {
+ "speckit_version": ">=0.9.0"
+ },
+ "provides": {
+ "extensions": 1,
+ "presets": 11,
+ "steps": 0,
+ "workflows": 0
+ },
+ "tags": [
+ "security",
+ "governance",
+ "compliance",
+ "appsec",
+ "threat-modeling"
+ ],
+ "verified": false
+ }
+ }
+}
diff --git a/docs/community/bundles.md b/docs/community/bundles.md
index 101013034d..4ed15e0d36 100644
--- a/docs/community/bundles.md
+++ b/docs/community/bundles.md
@@ -5,7 +5,11 @@
Bundles compose existing Spec Kit components ā extensions, presets, workflows, and steps ā into a single role or team stack. They are useful when a user should be able to install a tested set of components together instead of following several separate install commands.
-Accepted community bundle entries will be listed here once a community bundle catalog is available. To submit a bundle for review, file a [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue.
+Accepted community bundle entries are published in [`bundles/catalog.community.json`](https://github.com/github/spec-kit/blob/main/bundles/catalog.community.json) and listed below. The built-in community source is discovery-only: `specify bundle search` and `specify bundle info` can inspect entries, but installing by ID requires explicitly adding an install-allowed catalog. Explicit catalogs use a higher default precedence than the built-in community source. To submit a bundle for review, file a [Bundle Submission](https://github.com/github/spec-kit/issues/new?template=bundle_submission.yml) issue.
+
+| Bundle | Purpose | Role or team | Provides | Required catalogs | URL |
+|--------|---------|--------------|----------|-------------------|-----|
+| SicarioSpec Security & Governance Bundle | Secure-by-default governance bundle for GitHub Spec Kit. Enforces data classification, threat modeling, and code-owned verification gates. | `security-engineer` | 1 extension, 11 presets | Documented | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
## What to Submit
diff --git a/docs/community/extensions.md b/docs/community/extensions.md
index 738caa203a..97707ad780 100644
--- a/docs/community/extensions.md
+++ b/docs/community/extensions.md
@@ -36,6 +36,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
+| Blueprint Index ā Living Architecture Map | A living architecture map for spec-driven projects, kept honest by a deterministic, low-friction, machine-first CI gate (JSON, self-healable) that blocks only when the map contradicts the specs or code. Brownfield or greenfield. | `process` | Read+Write | [spec-kit-blueprint](https://github.com/ogil109/spec-kit-blueprint) |
| Branch Convention | Configurable branch and folder naming conventions for /specify with presets and custom patterns | `process` | Read+Write | [spec-kit-branch-convention](https://github.com/Quratulain-bilal/spec-kit-branch-convention) |
| Brownfield Bootstrap | Bootstrap spec-kit for existing codebases ā auto-discover architecture and adopt SDD incrementally | `process` | Read+Write | [spec-kit-brownfield](https://github.com/Quratulain-bilal/spec-kit-brownfield) |
| BrownKit | Evidence-driven capability discovery, security and QA risk assessment for existing codebases | `process` | Read+Write | [BrownKit](https://github.com/MaksimShevtsov/BrownKit) |
@@ -65,11 +66,13 @@ The following community-contributed extensions are available in [`catalog.commun
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
| Intake | Normalize PRD, design, HTML SSOT, and test-case evidence into SDD-ready intake artifacts. | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
+| Intent Reconciliation | Reconcile implementation-discovered decisions against approved feature intent | `process` | Read+Write | [spec-kit-reconcile](https://github.com/SuhaibAslam/spec-kit-reconcile) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow ā refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
| Jira Integration (Sync Engine) | Idempotent, drift-aware, fail-closed reconcile engine mirroring spec-kit specs into Jira (Epic per repo, Story per spec, Subtask per phase) | `integration` | Read+Write | [spec-kit-jira-sync](https://github.com/ashbrener/spec-kit-jira-sync) |
| 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) |
@@ -89,7 +92,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Multi-Repo Branch Sync | Creates the feature branch in affected sub-repositories and git submodules via plan/tasks hooks | `process` | Read+Write | [multi-repo-sync](https://github.com/fyloss/spec-kit-multi-repo-sync) |
| Multi-Sites Spec Kit | Multi-site aware specify command with per-site spec folders, auto-increment, and Drupal support | `process` | Read+Write | [spec-kit-multi-sites](https://github.com/teeyo/spec-kit-multi-sites) |
| .NET Framework to Modern .NET Migration | Orchestrate end-to-end .NET Framework to modern .NET migration across 7 phases, with SDD lifecycle integration | `process` | Read+Write | [spec-kit-fx-to-net](https://github.com/RogerBestMsft/spec-kit-FxToNet) |
-| OKF Knowledge Bundle Generator | Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository | `docs` | Read+Write | [speckit_ofk](https://github.com/alexcpn/speckit_ofk) |
+| OKF Knowledge Bundle Generator | Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for significance and rationale, and resolving open questions with the user | `docs` | Read+Write | [speckit_ofk](https://github.com/alexcpn/speckit_ofk) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency ā token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| Orchestration Task Context Management | Adds subagent work-unit orchestration to generated Spec Kit task files | `process` | Read+Write | [spec-kit-orchestration-task-context-management](https://github.com/benizzio/spec-kit-orchestration-task-context-management) |
@@ -149,6 +152,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Superspec | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
| Tasks to GitHub Project | Publish and synchronize Spec Kit tasks as cards on a GitHub Project (v2) kanban board, with priority and status sync between spec.md/tasks.md and the board. | `integration` | Read+Write | [spec-kit-tasks-to-project](https://github.com/mancioshell/spec-kit-tasks-to-project) |
| Team Assign | Assign tasks.md items to human engineers, split into subtasks, and generate a per-engineer workboard | `process` | Read+Write | [spec-kit-team-assign](https://github.com/tarunkumarbhati/spec-kit-team-assign) |
+| Test Coverage Drift Control | Generate incremental coverage drift reports and planned remediation tasks after implementation | `code` | Read+Write | [spec-kit-test-coverage-drift-control](https://github.com/benizzio/spec-kit-test-coverage-drift-control) |
| Time Machine | Retroactively apply the full SDD workflow to existing codebases ā analyse, spec, and ship feature-by-feature | `process` | Read+Write | [spec-kit-time-machine](https://github.com/teeyo/spec-kit-time-machine) |
| TinySpec | Lightweight single-file workflow for small tasks ā skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
| Token Budget | Reduces LLM token consumption in Spec Kit workflows: compact artifacts in-place, scope per-phase reading, suppress prose padding, and report token usage | `process` | Read+Write | [spec-kit-token-budget](https://github.com/tinesoft/spec-kit-token-budget) |
@@ -156,7 +160,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Token Economy | Token routing, measured savings, and context audit workflows | `process` | Read+Write | [spec-kit-token-economy](https://github.com/formin/spec-kit-token-economy) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
-| Verify Review Ship | Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows | `process` | Read-only | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
+| Verify Review Ship | Post-convergence operational verification, technical review, learning governance, and transactional delivery. | `process` | Read+Write | [spec-kit-verify-review-ship](https://github.com/cadugevaerd/spec-kit-verify-review-ship) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
diff --git a/docs/community/friends.md b/docs/community/friends.md
index 82fd9f2bc7..2a7fdad5c1 100644
--- a/docs/community/friends.md
+++ b/docs/community/friends.md
@@ -1,7 +1,7 @@
# Community Friends
> [!NOTE]
-> Community projects listed here are independently created and maintained by their respective authors. They are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion.
+> Community projects listed here are independently created and maintained by their respective authors. Unless explicitly marked as a **first-party GitHub project**, they are **not reviewed, nor endorsed, nor supported by GitHub**. Review their source code before installation and use at your own discretion.
Community projects that extend, visualize, or build on Spec Kit:
@@ -16,3 +16,5 @@ Community projects that extend, visualize, or build on Spec Kit:
- **[cc-spec-kit](https://github.com/speckit-community/cc-spec-kit)** ā Community-maintained plugin for Claude Code and GitHub Copilot CLI that installs Spec Kit skills via the plugin marketplace.
- **[spectatui](https://github.com/tinesoft/spectatui)** ā A terminal UI (TUI) dashboard for Spec Kit that lets you track features, manage specifications, integrations, presets, workflows, and extensions, and monitor AI agent workflows. Attach to existing AI sessions or launch new ones from your terminal. Keyboard and mouse support. Light/dark theme support. Customizable and performance-oriented. Requires the `specify` CLI in your PATH.
+
+- **[spec-kit-copilot](https://github.com/github/spec-kit-copilot)** ā _First-party GitHub project._ A GitHub Copilot **skills plugin** that exposes the Spec Kit `specify` CLI to the Copilot agent in both the Copilot CLI and the GitHub Copilot app. It provides a focused skill per `specify` command group ā setup, init, check, extensions, presets, bundles, workflows, workflow steps, and self-upgrade ā so you can navigate and drive the entire Spec Kit ecosystem through natural language, letting Copilot decide when and how to run the right `specify` commands on your behalf.
diff --git a/docs/community/presets.md b/docs/community/presets.md
index bb68f25004..2d6cdb30a2 100644
--- a/docs/community/presets.md
+++ b/docs/community/presets.md
@@ -7,25 +7,29 @@ The following community-contributed presets customize how Spec Kit behaves ā o
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
-| A11Y Governance | Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence | 10 templates, 3 commands | ā | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
-| Agent Parity Governance | Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift. | 6 templates, 3 commands | ā | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
+| A11Y Governance | Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit | 10 templates, 3 commands | ā | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
+| Agent Parity Governance | Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces. | 6 templates, 3 commands | ā | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X ā Y pattern) ā adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
-| Architecture Governance | Adds secure 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) |
+| Architecture Governance | Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | ā | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
+| Autonomous Run Governance | Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract. | 13 templates, 5 commands, 4 scripts | ā | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | ā | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | ā | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | ā | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
-| Cross-Platform Governance | Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit | 8 templates, 3 commands | ā | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
+| Cross-Platform Governance | Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence. | 8 templates, 3 commands | ā | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | ā | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | ā | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | ā | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
-| 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) |
+| Intake Authoring Governance | Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring. | 12 templates, 5 commands, 7 scripts | ā | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
+| Intake Review Governance | Reviews single, series, campaign, and language-aware requirements collections before Spec Kit execution. | 8 templates, 3 commands, 4 scripts | ā | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
+| Intake Sequencing Governance | Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection. | 11 templates, 6 commands, 8 scripts | ā | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance) |
+| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | ā | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| 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 permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak ā specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | ā | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft ā slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | ā | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
-| Security Governance | Adds memory-safe-language 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) |
+| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | ā | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
| SicarioSpec Core | Baseline secure-by-default Spec Kit governance profile. | 5 templates | ā | [sicario-spec](https://github.com/dfirs1car1o/sicario-spec) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec ā plan ā tasks ā implement ā deploy | 5 templates, 8 commands | ā | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | ā | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
diff --git a/docs/concepts/complex-features.md b/docs/concepts/complex-features.md
index 10e814ba12..4fe9ae85d1 100644
--- a/docs/concepts/complex-features.md
+++ b/docs/concepts/complex-features.md
@@ -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
diff --git a/docs/concepts/spec-of-specs.md b/docs/concepts/spec-of-specs.md
new file mode 100644
index 0000000000..2439798e01
--- /dev/null
+++ b/docs/concepts/spec-of-specs.md
@@ -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//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:
+
+
+
+**Status legend**: planned Ā· in-progress Ā· done
+
+| ID | Sub-feature | Intent | Scope boundary | Depends on | Status | Sub-spec |
+|----|-------------|--------|----------------|-----------|--------|----------|
+| R1 | | | | ā | planned | ā |
+| R2 | | | | R1 | planned | ā |
+| R3 | | | | 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//roadmap.md` ā entry **R3**.
+ ```
+
+- **Roadmap ā sub-spec.** In the roadmap table, set the entry's `Sub-spec` column to
+ the sub-feature's directory, e.g. `specs/-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.
diff --git a/docs/installation.md b/docs/installation.md
index 9fb2bf519f..4fa2795647 100644
--- a/docs/installation.md
+++ b/docs/installation.md
@@ -77,9 +77,9 @@ specify init --integration pi
specify init --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 --script sh
specify init --script ps
+specify init --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
diff --git a/docs/local-development.md b/docs/local-development.md
index 286938c1cf..22e08fbbe7 100644
--- a/docs/local-development.md
+++ b/docs/local-development.md
@@ -2,7 +2,7 @@
This guide shows how to iterate on the `specify` CLI locally without publishing a release or committing to `main` first.
-> Scripts now have both Bash (`.sh`) and PowerShell (`.ps1`) variants. The CLI auto-selects based on OS unless you pass `--script sh|ps`.
+> Scripts are available as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
## 1. Clone and Switch Branches
@@ -120,10 +120,10 @@ generated metadata, then add the import and `_register()` call in
## 7. Run Lint / Basic Checks
-CI enforces `ruff check src/` (see `.github/workflows/test.yml`), so run it locally before pushing:
+CI enforces `ruff check src tests` (see `.github/workflows/test.yml`), so run it locally before pushing:
```bash
-uvx ruff check src/
+uvx ruff check src tests
```
You can also quickly sanity check importability:
@@ -189,7 +189,7 @@ rm -rf .venv dist build *.egg-info
| `ModuleNotFoundError: typer` | Run `uv pip install -e .` |
| Scripts not executable (Linux) | Re-run init or `chmod +x scripts/*.sh` |
| Git commands unavailable | Install the git extension with `specify extension add git` |
-| Wrong script type downloaded | Pass `--script sh` or `--script ps` explicitly |
+| Wrong script type downloaded | Pass `--script sh`, `--script ps`, or `--script py` explicitly |
| TLS errors on corporate network | Configure your environment's certificate store or proxy. The `--skip-tls` flag is deprecated and has no effect. |
## 14. Next Steps
diff --git a/docs/quickstart.md b/docs/quickstart.md
index 582163a371..ddf6337356 100644
--- a/docs/quickstart.md
+++ b/docs/quickstart.md
@@ -3,7 +3,7 @@
This guide will help you get started with Spec-Driven Development using Spec Kit. Throughout, we illustrate each step with a running example: **Taskify**, a small team productivity platform.
> [!NOTE]
-> Automation scripts are provided as both Bash (`.sh`) and PowerShell (`.ps1`) variants. The `specify` CLI auto-selects based on your OS unless you pass `--script sh|ps`.
+> Automation scripts are provided as Bash (`.sh`), PowerShell (`.ps1`), and Python (`.py`) variants. Interactive `specify init` prompts you to choose one; non-interactive runs default to a shell variant for your OS. Pass `--script sh|ps|py` to select explicitly.
> [!NOTE]
> Commands are shown here in `/speckit.*` form, but the exact invocation depends on your agent. Some skills-based agents use `$speckit-*` (e.g. Codex, ZCode) or `/skill:speckit-*` (e.g. Kimi). Use whichever form your agent exposes ā the steps are otherwise identical.
diff --git a/docs/reference/core.md b/docs/reference/core.md
index ea3c4794a8..fad62fc36b 100644
--- a/docs/reference/core.md
+++ b/docs/reference/core.md
@@ -12,7 +12,7 @@ specify init []
| ------------------------ | ------------------------------------------------------------------------ |
| `--integration ` | 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 |
diff --git a/docs/reference/extensions.md b/docs/reference/extensions.md
index 3dc40a53c9..919617a087 100644
--- a/docs/reference/extensions.md
+++ b/docs/reference/extensions.md
@@ -178,7 +178,6 @@ Spec Kit stores project-level extension registration and hook configuration in:
```text
.specify/extensions.yml
```
-
The file contains installed extensions, global settings, and hooks that are surfaced before or after Spec Kit commands.
```yaml
@@ -222,13 +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.` or `env.` 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_` or `after_`, 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
diff --git a/docs/reference/integrations.md b/docs/reference/integrations.md
index 72044ec684..a12337316b 100644
--- a/docs/reference/integrations.md
+++ b/docs/reference/integrations.md
@@ -6,6 +6,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| Agent | Key | Notes |
| ------------------------------------------------------------------------------------ | ---------------- | ----------------------------------------------------------------------------------------------------------------------------------------- |
+| [Alquimia AI](https://docs.alquimia.ai) | `alquimia` | Skills-based integration; installs skills into `.alquimia/skills` and invokes them as `/speckit-` |
| [Amp](https://ampcode.com/) | `amp` | |
| [Antigravity (agy)](https://antigravity.google/) | `agy` | Skills-based integration; skills are installed automatically |
| [Auggie CLI](https://docs.augmentcode.com/cli/overview) | `auggie` | |
@@ -15,6 +16,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-` |
| [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-` |
+| [Factory Droid](https://docs.factory.ai/cli/getting-started/overview) | `droid` | Skills-based integration; installs skills into `.factory/skills/` and invokes them as `/speckit-` |
| [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` | |
@@ -22,9 +24,9 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Goose](https://goose-docs.ai/) | `goose` | Uses YAML recipe format in `.goose/recipes/` |
| [Grok Build](https://docs.x.ai/build/overview) | `grok` | Skills-based integration; installs skills into `.grok/skills` and invokes them as `/speckit-` |
| [Hermes](https://github.com/NousResearch/hermes-agent) | `hermes` | Skills-based integration; installs skills globally into `~/.hermes/skills/` |
-| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | IDE-based agent |
+| [IBM Bob](https://www.ibm.com/products/bob) | `bob` | Skills-based integration by default; installs skills as `speckit-/SKILL.md` under `.bob/skills/` and invokes them as `/speckit-`. Pass `--integration-options="--legacy-commands"` to scaffold the deprecated Bob 1.x layout (`.bob/commands/*.md`) instead; that flag will be removed in a future release. Existing legacy installs can migrate with `specify integration upgrade bob --integration-options="--skills"`, which converts them to the skills layout and removes the old command files. If preset overrides are installed, the migration is rejected with an actionable error (preset artifacts cannot yet be reconciled across a layout change) ā remove the preset(s), migrate, then reinstall them. |
| [Junie](https://junie.jetbrains.com/) | `junie` | |
-| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | |
+| [Kilo Code](https://github.com/Kilo-Org/kilocode) | `kilocode` | Installs commands into `.kilo/commands`; legacy `.kilocode/workflows` installs remain supported as a registration fallback |
| [Kimi Code](https://code.kimi.com/) | `kimi` | Skills-based integration; installs into `.kimi-code/skills/`. `--migrate-legacy` moves old `.kimi/skills/` installs to the new paths |
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
@@ -48,7 +50,11 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
specify integration list
```
-Shows all available integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based.
+| Option | Description |
+| ----------- | ----------------------------------------------------------------------------------------------------------------------- |
+| `--catalog` | Also browse the catalog (built-in **and** community). Community integrations that are not built in are only shown here. |
+
+Shows the built-in integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based.
When multiple integrations are installed, the list marks the default integration separately from the other installed integrations.
The list also shows whether each built-in integration is declared multi-install safe.
@@ -81,7 +87,7 @@ specify integration install
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
-| `--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) |
| `--force` | Opt in to installing alongside integrations that are not declared multi-install safe |
| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
@@ -89,6 +95,8 @@ Installs the specified integration into the current project. If another integrat
Installing an additional integration does not change the default integration. Use `specify integration use ` to change the default.
+Installed extensions and presets are not registered for a non-default integration at install time ā they follow the currently active (default) integration only. `specify integration use ` (or `switch `) is what rescaffolds them for the newly active integration.
+
> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init --integration ` instead.
**Version note:** Controlled multi-install support was introduced in Spec Kit 0.8.5. If `specify integration install ` says another integration is already installed and only suggests `switch` or `uninstall`, check your local CLI with `specify version` and upgrade it. Running a one-shot command such as `uvx --from git+https://github.com/github/spec-kit.git specify ...` uses a temporary copy for that command only; it does not update the persistent `specify` executable on your `PATH`.
@@ -117,12 +125,12 @@ specify integration switch
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
-| `--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) |
| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default |
| `--refresh-shared-infra` | Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved) |
| `--integration-options` | Options for the target integration when it is not already installed |
-If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade --integration-options ...` first, then `use `.
+If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade --integration-options ...` first, then `use `. Like `use`, `switch` rescaffolds installed extensions and presets for the target integration once it becomes the default.
## Use an Installed Integration
@@ -136,6 +144,8 @@ specify integration use
Sets the default integration without uninstalling any other installed integrations. This also refreshes managed shared templates so command references match the new default integration's invocation style. Modified or untracked shared templates are preserved unless `--force` is used.
+`use` is also the activation point for installed extensions and presets: it re-registers every enabled extension's and preset's command overrides (and skills, for skills-mode agents) for the newly active integration, so artifacts installed while a different integration was active are rescaffolded here rather than at install time.
+
## Upgrade an Integration
```bash
@@ -145,11 +155,15 @@ specify integration upgrade []
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--force` | Overwrite files even if they have been modified |
-| `--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) |
| `--integration-options` | Options for the integration |
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
+Enabled extensions and presets are re-registered only when upgrading the currently active (default) integration. A non-default upgrade still refreshes that integration's core commands, but does not re-register its extension or preset layers ā `use`/`switch` that integration afterward to rescaffold them.
+
+If an upgrade would change an integration between command and skills layouts while preset artifacts are registered for it, the upgrade is rejected before changing files. Remove the affected presets, run the layout-changing upgrade, then reinstall them.
+
## Report Integration Status
```bash
@@ -252,31 +266,35 @@ Spec Kit tracks one default integration in `.specify/integration.json` with `def
An integration is multi-install safe when it uses a static, unique agent root and command directory, stable command invocation settings, and a separate install manifest whose managed files do not overlap another safe integration. Registry tests enforce those path and manifest invariants. Shared Spec Kit templates remain aligned to the single default integration.
-The Isolation column below lists paths Spec Kit manages for that integration (skills/commands roots and any integration-owned rule files). It is not a full inventory of every file an agent may read.
-
-**Agent-context defaults are separate.** The optional agent-context extension maps each integration to a default context file in `extensions/agent-context/agent-context-defaults.json`. Those defaults are independent of multi-install safety: several agents may share a root file such as `AGENTS.md` when the extension is enabled. Multi-install safety does not require a unique context file per safe integration.
+The Command directory column below lists the directory each integration installs its commands or skills into. Context-file targeting is a separate concern from integration multi-install safety: `multi_install_safe` is an integration declaration about command/skill paths, whereas the optional agent-context extension manages a per-agent context file (for example `AGENTS.md` or `CLAUDE.md`) and can even synchronize several anchors at once via its `context_files` setting. Multiple agents mapping to the same context file is expected there and does not affect whether an integration is multi-install safe; see the agent-context extension for details.
The currently declared multi-install safe integrations are:
-| Key | Isolation |
-| --- | --------- |
-| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` |
-| `claude` | `.claude/skills`, `CLAUDE.md` |
-| `cline` | `.clinerules/workflows`, `.clinerules/specify-rules.md` |
-| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
-| `codex` | `.agents/skills`, `AGENTS.md` |
-| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
-| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` |
-| `gemini` | `.gemini/commands`, `GEMINI.md` |
+| Key | Command directory |
+| --- | ----------------- |
+| `alquimia` | `.alquimia/skills` |
+| `auggie` | `.augment/commands` |
+| `claude` | `.claude/skills` |
+| `cline` | `.clinerules/workflows` |
+| `codebuddy` | `.codebuddy/commands` |
+| `codex` | `.agents/skills` |
+| `cursor-agent` | `.cursor/skills` |
+| `droid` | `.factory/skills` |
+| `firebender` | `.firebender/commands` |
+| `gemini` | `.gemini/commands` |
| `grok` | `.grok/skills` |
-| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
-| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
-| `qodercli` | `.qoder/commands`, `QODER.md` |
-| `qwen` | `.qwen/commands`, `QWEN.md` |
-| `shai` | `.shai/commands`, `SHAI.md` |
-| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
-| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
-| `zcode` | `.zcode/skills`, `ZCODE.md` |
+| `junie` | `.junie/commands` |
+| `kilocode` | `.kilo/commands` |
+| `kiro-cli` | `.kiro/prompts` |
+| `lingma` | `.lingma/skills` |
+| `omp` | `.omp/commands` |
+| `pi` | `.pi/prompts` |
+| `qodercli` | `.qoder/commands` |
+| `qwen` | `.qwen/commands` |
+| `shai` | `.shai/commands` |
+| `tabnine` | `.tabnine/agent/commands` |
+| `trae` | `.trae/skills` |
+| `zcode` | `.zcode/skills` |
Integrations that share a command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
@@ -295,3 +313,7 @@ CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be ins
### When should I use `upgrade` vs `switch`?
Use `upgrade` when you've upgraded Spec Kit and want to refresh an installed integration's managed files. Use `switch` when you want to replace the current default with another integration; if the target is already installed, `switch` behaves like `use`.
+
+### Do extensions and presets I install apply to every installed integration?
+
+No. Extensions (`specify extension add`) and presets (`specify preset add`) register their command overrides for the currently active (default) integration only, even if other integrations are installed. A non-default integration does not receive those artifacts until it becomes the default: `specify integration use ` (or `switch `) rescaffolds every enabled extension and preset for the newly active integration. `specify integration upgrade` follows the same rule ā it only re-registers extensions and presets when upgrading the active integration.
diff --git a/docs/reference/presets.md b/docs/reference/presets.md
index 549177c1d6..b8f318ac9e 100644
--- a/docs/reference/presets.md
+++ b/docs/reference/presets.md
@@ -139,7 +139,7 @@ catalogs:
Presets can provide command files, template files (like `plan-template.md`), and script files. Each file name is evaluated independently against the priority stack, so different files can come from different layers.
-Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into detected agent directories instead of being re-resolved by agents. During preset install, Spec Kit registers command files for the preset being installed; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Agents do not re-resolve the stack each time they run a command.
+Templates and scripts are looked up from the stack when Spec Kit needs them. Commands use the same stack for replacement and composition, but are materialized into the active integration's directory only, instead of being re-resolved by agents or written to every detected agent directory (#2948). During preset install, Spec Kit registers command files for the preset being installed against the currently active integration; post-install and post-removal reconciliation then recomputes and writes the effective command content for affected command names based on the active stack. Install and rescaffold remain active-only, but removal may also update previously targeted inactive directories recorded by the removed preset to restore the surviving command or skill layer. A non-active installed integration does not otherwise receive these command files until it becomes the default ā `specify integration use ` (or `switch `) rescaffolds enabled presets for the newly active integration. Agents do not re-resolve the stack each time they run a command.
By default, files use a **replace** strategy: the first match in the priority stack wins and is used entirely. Templates and commands can also use composition strategies: **prepend** places preset content before lower-priority content, **append** places it after lower-priority content, and **wrap** replaces `{CORE_TEMPLATE}` with lower-priority content. Scripts support **replace** and **wrap**; script wrappers use `$CORE_SCRIPT` as the placeholder.
diff --git a/docs/reference/workflows.md b/docs/reference/workflows.md
index 1dfc5e6904..f790e50f8a 100644
--- a/docs/reference/workflows.md
+++ b/docs/reference/workflows.md
@@ -91,8 +91,192 @@ specify workflow add
| `--dev` | Install from a local workflow YAML file or directory |
| `--from ` | Install from a custom URL (`` names the expected workflow ID) |
-Installs a workflow from the catalog, a URL (HTTPS required), or a local file path.
+Installs a workflow from the catalog, a URL (HTTPS required), a local YAML file, or a local directory containing `workflow.yml`.
+## Workflow Overlays
+
+Workflow overlays let a project extend or override an installed workflow without editing the installed `workflow.yml`. This keeps local customizations safe across `specify bundle update` or `specify workflow add` upgrades.
+
+When `specify workflow run ` loads a workflow, the engine composes the base workflow with all enabled overlays for that workflow id. The result is validated like any other workflow definition.
+
+### How Overlays Work
+
+An overlay is a YAML file that declares a set of edit operations against the step list of a base workflow. Overlays use lower-wins precedence: higher priority numbers are applied first and lower numbers last. Equal-priority overlays are applied alphabetically by ID, with the last ID winning conflicts.
+
+Project overlay files live at:
+
+| Location | Purpose |
+| --- | --- |
+| `.specify/workflows/overlays//*.yml` | Project-local customizations |
+
+### Overlay File Format
+
+The recommended edit format uses the operation name as the key and the anchor step id as the value:
+
+```yaml
+id: "my-overlay"
+extends: "speckit"
+priority: 10
+enabled: true
+edits:
+ - insert_after: implement
+ step:
+ id: run-lint
+ type: shell
+ run: "ruff check src/"
+
+ - replace: review-spec
+ step:
+ id: review-spec
+ type: gate
+ message: "Review the generated spec (overlay override)."
+ options: [approve, reject]
+ on_reject: abort
+```
+
+The explicit form is also supported:
+
+```yaml
+edits:
+ - operation: insert_after
+ anchor: implement
+ step:
+ id: run-lint
+ type: shell
+ run: "ruff check src/"
+```
+
+#### Fields
+
+| Field | Required | Description |
+| --- | --- | --- |
+| `id` | yes | Identifier for this overlay. Used in `specify workflow overlay *` commands. Must be lowercase letters, digits, and hyphens only; no dots, underscores, path separators, or `overlays`. |
+| `extends` | yes | The workflow id this overlay applies to. Uses the same safe-id format as `id`; `overlays`, `runs`, and `steps` are reserved. |
+| `priority` | no | Integer; defaults to `10`. Lower values have higher precedence and win conflicts. Missing or invalid values fall back to `10`. |
+| `enabled` | no | Boolean. Defaults to `true`. Disabled overlays are ignored. |
+| `edits` | yes | Non-empty list of edit operations. |
+
+#### Edit Operations
+
+| Operation | `step` required | Effect |
+| --- | --- | --- |
+| `insert_after` | yes | Insert `step` immediately after the anchor step. |
+| `insert_before` | yes | Insert `step` immediately before the anchor step. |
+| `replace` | yes | Replace the anchor step with `step`. |
+| `remove` | no | Remove the anchor step from the list. |
+
+The `anchor` is the `id` of a step in the base workflow. Anchors are resolved recursively inside `then`, `else`, `steps`, `cases.*`, and `default` blocks, so nested base steps can also be targeted. Fan-out templates (`step` inside a `fan-out` step) are **not** valid anchors.
+
+Step ids must not contain `:` ā that character is reserved for engine-generated nested ids.
+
+### Overlay CLI Commands
+
+#### Add a Project Overlay
+
+```bash
+specify workflow overlay add --priority
+```
+
+Validates the overlay file and copies it to `.specify/workflows/overlays//.yml`. `--priority` defaults to `10` and overrides the `priority` field in the file.
+
+#### List Overlays
+
+```bash
+specify workflow overlay list
+```
+
+Shows all overlays for the workflow, ordered by resolver precedence. Disabled overlays are marked as disabled in the listing and are ignored during workflow resolution.
+
+#### Change Priority
+
+```bash
+specify workflow overlay set-priority
+```
+
+#### Enable or Disable
+
+```bash
+specify workflow overlay disable
+specify workflow overlay enable
+```
+
+#### Remove
+
+```bash
+specify workflow overlay remove
+```
+
+Removes the project overlay file.
+
+#### Inspect the Composed Workflow
+
+```bash
+specify workflow resolve
+```
+
+Prints the layer stack (base + overlays) and the source attribution for each step after composition. Useful for debugging which overlay contributed or overrode a step.
+
+### Example: Adding Automated Linting after Implementation
+
+Given the built-in `speckit` workflow, create `project-overlay.yml`:
+
+```yaml
+id: "add-lint"
+extends: "speckit"
+priority: 10
+edits:
+ - insert_after: implement
+ step:
+ id: run-lint
+ type: shell
+ run: "ruff check src/"
+```
+
+Install it:
+
+```bash
+specify workflow overlay add project-overlay.yml --priority 10
+```
+
+Run the workflow:
+
+```bash
+specify workflow run speckit -i spec="Build a kanban board"
+```
+
+The composed workflow will now run the full SDD cycle and execute `ruff check src/` automatically after the `implement` step.
+
+### Example: Replacing a Gate
+
+```yaml
+id: "skip-plan-review"
+extends: "speckit"
+priority: 5
+edits:
+ - replace: review-plan
+ step:
+ id: review-plan
+ type: command
+ command: speckit.plan
+ input:
+ args: "{{ inputs.spec }}"
+```
+
+Lower priority values have higher precedence. Change this overlay to `priority: 5` if it must win a conflict with the `add-lint` overlay above. It replaces the `review-plan` gate with a non-interactive command.
+
+### Interaction with Bundles and Updates
+
+`specify workflow add ` installs `workflow.yml` from the local directory into `.specify/workflows//`.
+
+When an installed workflow is refreshed or reinstalled, project overlays in `.specify/workflows/overlays//` are preserved because they live outside the installed workflow directory.
+
+### Limitations
+
+- Overlays operate on the step list only. They cannot change workflow metadata (name, description, inputs, `requires`) or expression logic.
+- Fan-out templates cannot be used as anchors.
+- An overlay that targets a step id that does not exist in the base workflow will raise a validation error when the workflow is resolved.
+- Overlays cannot target steps added by other overlays.
+- Overlays cannot add new inputs or change the input schema of the base workflow.
## Update Workflows
```bash
@@ -318,6 +502,32 @@ args: "{{ inputs.spec }}"
message: "{{ status | default('pending') }}"
```
+### Interpolation and shell safety
+
+Expressions are resolved by **plain string substitution** ā the value of `{{ ... }}` is spliced into the surrounding text exactly as-is, with no quoting or escaping added. That is convenient for building `args` and `message` strings, but it has an important consequence for `shell` steps: a `run` field is handed to the system shell (`/bin/sh -c` on POSIX), so any interpolated value is interpreted as **shell syntax**, not just data.
+
+If an interpolated value can contain characters like `;`, `|`, `&`, `$( )`, backticks, or quotes, it can change or extend the command that actually runs. This matters most when the value is not fully under the workflow author's control:
+
+- **Workflow `inputs.*`** ā supplied by whoever runs the workflow.
+- **A prior step's output**, e.g. `{{ steps.plan.output.stdout }}` ā for a `prompt` step this is **text produced by the AI agent**, which can in turn be influenced by files, tickets, or web content the agent read. Treat agent output as untrusted when it flows into a `shell` step.
+
+There is **no shell-escaping filter** in the expression language and **no sandbox** around a `shell` step, so none of the practices below can be treated as a guarantee that a hostile value is neutralised. The only reliable control is to constrain what an interpolated value *can* be, and to keep values you cannot constrain out of `run` fields entirely. Scrutinise every `run` field that interpolates a value you do not control, and at minimum:
+
+- **Constrain the value at the source with `enum`/an allowlist.** When `inputs.*` feeds a `run` field, restrict it to a fixed set of known-safe values so a caller cannot supply arbitrary shell text at all. This is the strongest control the engine offers ā prefer it over any downstream mitigation.
+
+ ```yaml
+ inputs:
+ target:
+ type: string
+ enum: [staging, production] # caller cannot inject arbitrary text
+ ```
+
+- **Keep unconstrained values out of `run`.** If a value cannot be constrained to an allowlist ā most agent/`prompt` output ā do not interpolate it into a `run` field. Branch on it with `if`/`switch` against fixed conditions, or act on it in a `command`/`prompt` step rather than a shell command built from it.
+- **Quoting is not a security boundary.** Surrounding a substitution with quotes (`'{{ inputs.x }}'`) helps the shell treat a *trusted* value as a single argument and avoids word-splitting on spaces, but a value that itself contains the matching quote character can still break out and inject shell syntax. Quote for correctness on constrained values; never rely on quoting to make an *unconstrained* substitution safe.
+- **Gates do not inspect the next step, and `message` is printed verbatim.** A `gate` step renders only its own `message`/`show_file` ā it does not display, resolve, or sanitise the command that follows it, and approval never neutralises an injectable interpolation. Do **not** interpolate raw untrusted data into `message`: it is printed as-is with no control-character stripping, so agent or caller output could inject terminal/ANSI escapes that alter or hide the approval prompt. Keep `message` to trusted, constrained text, and surface untrusted material for review via `show_file` instead ā its path and contents are control/ANSI-stripped before display.
+
+A `shell` step is an arbitrary-command primitive by design; these practices reduce exposure and keep *which* command runs under the author's control, but they do not eliminate the risk of interpolating values you do not fully control.
+
## Shell Step Environment Variables
Shell steps automatically receive the following environment variables:
diff --git a/docs/toc.yml b/docs/toc.yml
index 3f3255cdc1..a2e07b270c 100644
--- a/docs/toc.yml
+++ b/docs/toc.yml
@@ -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
diff --git a/docs/upgrade.md b/docs/upgrade.md
index 0e0824dc61..f234be3352 100644
--- a/docs/upgrade.md
+++ b/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 ` | Update slash commands, templates, and scripts in your project |
+| **Project Files** | Run `specify integration upgrade `, 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 init --here --force --integration
+specify integration status
```
-Replace `` with your AI coding agent. Refer to this list of [Supported AI Coding Agent Integrations](reference/integrations.md)
+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`.
-**Example:**
+### 2. Upgrade each installed integration
+
+Run this inside your project directory:
```bash
-specify init --here --force --integration copilot
+specify integration upgrade
```
-### Understanding the `--force` flag
+Replace `` with an installed integration key such as `copilot`, `claude`, or `codex`. In projects with multiple installed integrations, run the command once per installed key.
-Without `--force`, the CLI warns you and asks for confirmation:
+**Example:**
-```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]
+```bash
+specify integration upgrade claude
+specify integration upgrade codex
```
-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.
+See the [integration reference](reference/integrations.md#upgrade-an-integration) for options such as `--script`, `--integration-options`, and `--force`.
-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.
+### 3. Update installed extensions
-**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.
-
----
+Run:
-## ā ļø Important Warnings
+```bash
+specify extension update
+```
-### 1. Constitution file will be overwritten
+With no extension argument, this updates all installed extensions. Use `specify extension update ` to update only one extension. See the [extensions reference](reference/extensions.md#update-extensions) for details.
-**Known issue:** `specify init --here --force` currently overwrites `.specify/memory/constitution.md` with the default template, erasing any customizations you made.
+### Fallback: re-run init
-**Workaround:**
+If a project predates manifests, has missing integration metadata, or needs a broader recovery, you can still re-run init:
```bash
-# 1. Back up your constitution before upgrading
-cp .specify/memory/constitution.md .specify/memory/constitution-backup.md
+specify init --here --force --integration
+```
-# 2. Run the upgrade
-specify init --here --force --integration copilot
+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.
-# 3. Restore your customized constitution
-mv .specify/memory/constitution-backup.md .specify/memory/constitution.md
-```
+## ā ļø Important Warnings
-Or use git to restore it:
+### 1. Constitution file and memory customizations
-```bash
-# After upgrade, restore from git history
-git restore .specify/memory/constitution.md
-```
+`specify integration upgrade ` does not update `.specify/memory/constitution.md`.
+
+The fallback `specify init --here --force --integration ` 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.
+
+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. Custom script or template modifications
+### 2. Custom integration, script, or template modifications
-If you customized files in `.specify/scripts/` or `.specify/templates/`, the `--force` flag will overwrite them. Back them up first:
+`specify integration upgrade ` blocks when manifest-tracked integration files were modified locally, unless you pass `--force`.
+
+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
@@ -192,15 +195,13 @@ Some IDE-based agents (like Kilo Code, Cline) may show **duplicate slash command
**Example for Kilo Code:**
```bash
-# Navigate to the agent's commands folder
-cd .kilocode/workflows/
-
-# List files and identify duplicates
-ls -la
+# List current and legacy Kilo command folders
+ls -la .kilo/commands/
+ls -la .kilocode/workflows/
# Delete old versions (example filenames - yours may differ)
-rm speckit.specify-old.md
-rm speckit.plan-v1.md
+rm .kilocode/workflows/speckit.specify-old.md
+rm .kilocode/workflows/speckit.plan-v1.md
```
Restart your IDE to refresh the command list.
@@ -215,29 +216,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
+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
+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"
@@ -245,14 +246,12 @@ mv /tmp/constitution-backup.md .specify/memory/constitution.md
This happens with IDE-based agents (Kilo Code, Cline, etc.).
```bash
-# Find the agent folder (example: .kilocode/workflows/)
-cd .kilocode/workflows/
-
-# List all files
-ls -la
+# For Kilo Code, inspect both current and legacy command folders
+ls -la .kilo/commands/
+ls -la .kilocode/workflows/
# Delete old command files
-rm speckit.old-command-name.md
+rm .kilocode/workflows/speckit.old-command-name.md
# Restart your IDE
```
@@ -262,14 +261,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
+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:
@@ -304,7 +303,7 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
2. **For CLI-based agents**, verify files exist:
```bash
- ls -la .claude/commands/ # Claude Code
+ ls -la .claude/skills/ # Claude Code
ls -la .gemini/commands/ # Gemini
ls -la .cursor/skills/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
@@ -315,19 +314,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?"
+
+Current `specify init --here --force` preserves an existing `.specify/memory/constitution.md`; it creates the file from the template only when it is missing.
-**Fix:** Restore from git or backup:
+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 ` 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"
@@ -351,10 +352,10 @@ This warning appears when you run `specify init --here` (or `specify init .`) in
Only Spec Kit infrastructure files:
-- Agent command files (`.claude/commands/`, `.github/prompts/`, etc.)
+- Agent command/skill files (`.claude/skills/`, `.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 +366,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 +376,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 +419,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 ` 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.
@@ -439,7 +441,7 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
ls -la .github/prompts/
# For Claude
- ls -la .claude/commands/
+ ls -la .claude/skills/
# For Pi
ls -la .pi/prompts/
diff --git a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
index 77e79bd33c..5da95c9d54 100644
--- a/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
+++ b/extensions/EXTENSION-DEVELOPMENT-GUIDE.md
@@ -252,6 +252,7 @@ Use standard Markdown with special placeholders:
- `$ARGUMENTS`: User-provided arguments
- `{SCRIPT}`: Replaced with script path during registration
+- `__SPECKIT_COMMAND___`: 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___`. Spec Kit resolves it to a `/speckit...` 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=`.
+```
+
+This renders as `/speckit.bug.fix slug=` for a slash-based agent, `/speckit-bug-fix 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___` 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:
diff --git a/extensions/agent-context/README.md b/extensions/agent-context/README.md
index adc13e31e2..53602c3343 100644
--- a/extensions/agent-context/README.md
+++ b/extensions/agent-context/README.md
@@ -2,55 +2,55 @@
This bundled extension manages the **coding agent context/instruction file** (e.g. `CLAUDE.md`, `.github/copilot-instructions.md`, `AGENTS.md`, `GEMINI.md`, ā¦) for the active integration.
-It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `` / ``).
+It owns the lifecycle of the managed section delimited by the configurable start/end markers (defaults: `` / ``). For `.mdc` files, it also ensures the YAML frontmatter (the metadata block at the top of the file) contains `alwaysApply: true`. Otherwise, everything outside the managed section is untouched.
+
+> NOTE: Spec Kit itself never touches your agent context file. This extension is the only thing that does, and it's opt-in: install it if you want the block kept in sync, skip it if you'd rather manage that file yourself.
## Why an extension?
Not every Spec Kit user wants Spec Kit to write into the coding agent's context file. Keeping this behavior in a dedicated, **opt-in** extension lets users:
-- **Choose whether to install it at all** ā `specify init` does not install it. Add it explicitly when you want Spec Kit to manage the agent context file; if it is absent or disabled, Spec Kit never creates or modifies that file.
-- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` ā the bundled scripts honor the `context_markers` value.
+- **Choose whether to install it at all** - `specify init` does **not** install it. Add it explicitly when you want Spec Kit to manage the agent context file; when it is absent, the file is never modified, and when it is disabled, its automatic hooks do not run.
+- **Customize the markers** by editing `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in this repo) - the bundled scripts honor the `context_markers` value.
- **Synchronize multiple agent anchors** by setting `context_files` when a project intentionally uses more than one coding agent context file, such as `AGENTS.md` and `CLAUDE.md`.
-- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in `extension.yml` (`after_specify`, `after_plan`). Invoke it using your agent's slash-command separator ā `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
+- **Refresh on demand** by running the `speckit.agent-context.update` command in your agent, or automatically through the hooks declared in [extension.yml](./extension.yml) (`after_specify`, `after_plan`).
-## Commands
+## Installation
-The command ID below is canonical. When invoking it as a slash command, use your agent's separator: `/speckit.agent-context.update` for dot-separator agents or `/speckit-agent-context-update` for hyphen-separator agents (e.g. Forge, Cline).
+To install the extension, from the root of an initialized Spec Kit project, run:
-| Command | Description |
-|---------|-------------|
-| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
+```bash
+specify extension add agent-context
+```
-## Configuration
+## Disabling
-All configuration flows through the extension's own config file at
-`.specify/extensions/agent-context/agent-context-config.yml`:
+```bash
+specify extension disable agent-context
-```yaml
-# Path to the coding agent context file managed by this extension
-context_file: CLAUDE.md
+# Re-enable it
+specify extension enable agent-context
+```
-# Optional list of coding agent context files to manage together.
-# When non-empty, this takes precedence over context_file.
-context_files:
- - AGENTS.md
- - CLAUDE.md
+While this extension is disabled (or not installed), nothing in Spec Kit creates, updates, or removes the managed block - the `__CONTEXT_FILE__` placeholder in any template is left as-is, and the extension's own config is never read.
-# Delimiters for the managed Spec Kit section
-context_markers:
- start: ""
- end: ""
-```
+## Commands
+
+| Command | Description |
+| ------------------------------ | --------------------------------------------------------------------------------- |
+| `speckit.agent-context.update` | Refresh the managed section in the agent context file with the current plan path. |
+
+> NOTE: The command ID above is canonical. Invoke it using the syntax for your integration: `/speckit.agent-context.update` for dot-command integrations; `/speckit-agent-context-update` for hyphen/skills integrations (including Forge and Cline); `$speckit-agent-context-update` for Codex or ZCode in skills mode; or `/skill:speckit-agent-context-update` for Kimi.
+
+## Configuration
-- `context_file` ā the project-relative path to the coding agent context file. When empty, the bundled update scripts self-seed it by looking up the active integration's key in this extension's own `agent-context-defaults.json` map. The Specify CLI is never consulted.
-- `context_files` ā optional project-relative paths to multiple coding agent context files. When non-empty, the list takes precedence over `context_file`. Absolute paths, backslash separators, and `..` path segments are rejected.
-- `context_markers.start` / `.end` ā the delimiters around the managed section. Edit these to use custom markers.
+All configuration flows through the extension's own config file at `.specify/extensions/agent-context/agent-context-config.yml` ([agent-context-config.yml](./agent-context-config.yml) in the repo).
## Requirements
The bundled update scripts require **Python 3** with **PyYAML** for YAML/upsert processing (PowerShell can also use `ConvertFrom-Yaml` when available).
-PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports *"PyYAML is required ⦠not available in the current Python environment"*, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
+PyYAML ships with the `specify` CLI and is normally available via the same `python3` interpreter. If a hook reports _"PyYAML is required ⦠not available in the current Python environment"_, it means the system `python3` differs from the one used to install Spec Kit. To resolve, run:
```bash
pip install pyyaml
@@ -58,10 +58,6 @@ pip install pyyaml
/path/to/speckit-python -m pip install pyyaml
```
-## Disable
-
-```bash
-specify extension disable agent-context
-```
+## Issues
-When disabled (or never installed), Spec Kit performs no agent context file creation, updates, or removal ā the extension's bundled scripts are the only code that ever touches the managed section. The Specify CLI carries no agent-context state at all: it never reads this config, never resolves a context file, and the `__CONTEXT_FILE__` placeholder (if present in any template) is left untouched. All context-file knowledge ā including the per-agent default mapping in `agent-context-defaults.json` ā lives entirely within this extension, so disabling it is a complete opt-out.
+For any other issues, please create an issue in the [official GitHub repo](https://github.com/github/spec-kit/issues).
diff --git a/extensions/agent-context/agent-context-config.yml b/extensions/agent-context/agent-context-config.yml
index e73f8c7c50..89e54a2bd5 100644
--- a/extensions/agent-context/agent-context-config.yml
+++ b/extensions/agent-context/agent-context-config.yml
@@ -1,20 +1,24 @@
# Coding Agent Context Extension Configuration
-# These values are populated automatically by `specify init` and
-# `specify integration use` / `specify integration install`.
-# Path (relative to the project root) to the default coding agent context file
-# managed by this extension (e.g. CLAUDE.md, AGENTS.md,
-# .github/copilot-instructions.md). Set automatically from the active
-# integration and regenerated during `specify init` or integration switches.
+# WHAT: The single agent context file relative to the project root (the directory containing .specify/). Absolute paths, backslash separators, and `..` path segments are rejected.
+# REQUIREMENT: OPTIONAL. Use this if you want to manually specify a single context file. If you leave this entry blank, it will use the default context file for the coding agent you picked when you set up Spec Kit. See `agent-context-defaults.json` for the defaults.
+# EXAMPLE: context_file: CLAUDE.md
context_file: ""
-# Optional list of project-relative coding agent context files managed by this
-# extension. When non-empty, this list takes precedence over `context_file`.
-# Use this for projects that intentionally keep multiple agent anchors in sync.
+# WHAT: List of agent context files relative to the project root (the directory containing .specify/). If you have both `context_file` and `context_files` filled, then this (`context_files`) takes precedence. Absolute paths, backslash separators, and `..` path segments are rejected.
+# REQUIREMENT: OPTIONAL. Use this if your project requires you to keep multiple agent context files in sync.
+# EXAMPLE:
+# context_files:
+# - AGENTS.md
+# - CLAUDE.md
context_files: []
-# Delimiters for the managed Spec Kit section.
-# Edit these to use custom markers.
+# WHAT: Markers (delimiters) for the managed Spec Kit section. This extension injects information only between these markers.
+# REQUIREMENT: OPTIONAL. Only change if you wish to have a custom marker name.
+# EXAMPLE:
+# context_markers:
+# start: ""
+# end: ""
context_markers:
start: ""
end: ""
diff --git a/extensions/agent-context/agent-context-defaults.json b/extensions/agent-context/agent-context-defaults.json
index 1ef52b159d..b50c10d69e 100644
--- a/extensions/agent-context/agent-context-defaults.json
+++ b/extensions/agent-context/agent-context-defaults.json
@@ -2,6 +2,7 @@
"_comment": "Default coding agent context file per integration, owned by the agent-context extension. Used to self-seed agent-context-config.yml when it declares no context_file/context_files. Keyed by the Spec Kit integration key recorded in .specify/init-options.json. This mapping is independent of the Specify CLI by design.",
"agents": {
"agy": "AGENTS.md",
+ "alquimia": "ALQUIMIA.md",
"amp": "AGENTS.md",
"auggie": ".augment/rules/specify-rules.md",
"bob": "AGENTS.md",
diff --git a/extensions/agent-context/scripts/bash/update-agent-context.sh b/extensions/agent-context/scripts/bash/update-agent-context.sh
index 747a47a16d..7fbe3ef49a 100755
--- a/extensions/agent-context/scripts/bash/update-agent-context.sh
+++ b/extensions/agent-context/scripts/bash/update-agent-context.sh
@@ -176,13 +176,18 @@ _opts_lines=()
while IFS= read -r _line || [[ -n "$_line" ]]; do
_opts_lines+=("$_line")
done < <(printf '%s\n' "$_raw_opts")
-if (( ${#_opts_lines[@]} < 3 )); then
- echo "agent-context: malformed config parser output; expected 3 lines (context_files, marker_start, marker_end), got ${#_opts_lines[@]}; skipping update." >&2
+if (( ${#_opts_lines[@]} < 1 )); then
+ echo "agent-context: malformed config parser output; expected at least the context_files line, got ${#_opts_lines[@]}; skipping update." >&2
exit 0
fi
+# The marker lines may be absent: the $(...) capture above strips trailing
+# newlines, so blank markers (the config omitting context_markers and relying on
+# defaults) collapse the 3-line output to fewer lines. Default them to empty here
+# and let the DEFAULT_START/END substitution below fill them in, matching the
+# Python and PowerShell ports.
CONTEXT_FILES_JSON="${_opts_lines[0]}"
-MARKER_START="${_opts_lines[1]}"
-MARKER_END="${_opts_lines[2]}"
+MARKER_START="${_opts_lines[1]:-}"
+MARKER_END="${_opts_lines[2]:-}"
if ! _context_files_raw="$("$_python" - "$CONTEXT_FILES_JSON" <<'PY'
import json
diff --git a/extensions/agent-context/scripts/python/update_agent_context.py b/extensions/agent-context/scripts/python/update_agent_context.py
index 15c0dceef9..fc8894ee14 100644
--- a/extensions/agent-context/scripts/python/update_agent_context.py
+++ b/extensions/agent-context/scripts/python/update_agent_context.py
@@ -11,8 +11,9 @@
When ``plan_path`` is omitted, the script derives it from
``.specify/feature.json`` (written by /speckit-specify). Falls back to the most
-recently modified ``specs/*/plan.md`` only when feature.json is absent or its
-plan does not exist yet.
+recently modified ``plan.md`` anywhere under ``specs/`` (including nested scoped
+layouts such as ``specs///plan.md``) only when feature.json is
+absent or its plan does not exist yet.
"""
from __future__ import annotations
@@ -173,7 +174,7 @@ def _resolve_plan_path(project_root: str) -> str:
if not plan_path:
root = Path(project_root).resolve()
plans = sorted(
- (root / "specs").glob("*/plan.md"),
+ (root / "specs").rglob("plan.md"),
key=lambda p: p.stat().st_mtime,
reverse=True,
)
diff --git a/extensions/assess/README.md b/extensions/assess/README.md
index 06cf5d1af6..b6af9a2289 100644
--- a/extensions/assess/README.md
+++ b/extensions/assess/README.md
@@ -6,6 +6,8 @@ Discovery answers *"is this worth building?"* Delivery answers *"how do we build
## Overview
+`assess` runs inside an initialized Spec Kit project (it writes assessments under `.specify/assessments/`), but that project can be **completely empty of source code** ā a freshly initialized project with no code works just as well as an established codebase. The input is just an idea: pasted text, a URL, or a ticket need no existing code, while a codebase pointer lets you assess an idea for code that already exists. Neither starting point is more "correct" than the other.
+
Each idea lives in its own directory under `.specify/assessments//`, with one Markdown artifact per stage:
```
diff --git a/extensions/assess/commands/speckit.assess.intake.md b/extensions/assess/commands/speckit.assess.intake.md
index dac575c227..02cfec9f14 100644
--- a/extensions/assess/commands/speckit.assess.intake.md
+++ b/extensions/assess/commands/speckit.assess.intake.md
@@ -21,6 +21,8 @@ The user input is the idea and (optionally) a slug. Treat it as one of:
3. **A codebase pointer** ā phrasing like "an idea for this repo" or a path. Read enough of the repository to record what the idea relates to.
4. **A mix** of the above.
+There is **no requirement for existing source code**: within an initialized Spec Kit project, intake works just as well when the project is empty of code as when it already has a codebase. Pasted text or a URL (options 1ā2) need no existing codebase; a codebase pointer (option 3) targets existing code. Both are equally valid.
+
If the input is empty, ask the user for the idea (interactive), or stop with a note that there is nothing to intake (automated).
## Slug Resolution
diff --git a/extensions/catalog.community.json b/extensions/catalog.community.json
index 5e3072e47c..36da0fbdf9 100644
--- a/extensions/catalog.community.json
+++ b/extensions/catalog.community.json
@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
- "updated_at": "2026-07-17T00:00:00Z",
+ "updated_at": "2026-07-29T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -290,8 +290,8 @@
"id": "architecture-guard",
"description": "Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks.",
"author": "DyanGalih",
- "version": "1.8.17",
- "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.8.17.zip",
+ "version": "1.13.1",
+ "download_url": "https://github.com/DyanGalih/spec-kit-architecture-guard/archive/refs/tags/v1.13.1.zip",
"repository": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"homepage": "https://github.com/DyanGalih/spec-kit-architecture-guard",
"documentation": "https://github.com/DyanGalih/spec-kit-architecture-guard/blob/main/docs/architecture-overview.md",
@@ -303,7 +303,7 @@
"speckit_version": ">=0.1.0"
},
"provides": {
- "commands": 10,
+ "commands": 14,
"hooks": 3
},
"tags": [
@@ -313,13 +313,14 @@
"refactor",
"workflow",
"governance",
- "guardrails"
+ "guardrails",
+ "hygiene"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-05T07:26:00Z",
- "updated_at": "2026-06-08T00:00:00Z"
+ "updated_at": "2026-07-24T00:00:00Z"
},
"archive": {
"name": "Archive Extension",
@@ -489,6 +490,46 @@
"created_at": "2026-04-17T00:00:00Z",
"updated_at": "2026-04-17T00:00:00Z"
},
+ "blueprint-index": {
+ "name": "Blueprint Index ā Living Architecture Map",
+ "id": "blueprint-index",
+ "description": "Living architecture map for brownfield and greenfield projects, with a deterministic CI gate that blocks contradictions between the map, specs, and code while warning on non-blocking drift.",
+ "author": "ogil109",
+ "version": "0.2.0",
+ "download_url": "https://github.com/ogil109/spec-kit-blueprint/releases/download/v0.2.0/blueprint.zip",
+ "repository": "https://github.com/ogil109/spec-kit-blueprint",
+ "homepage": "https://github.com/ogil109/spec-kit-blueprint/tree/main",
+ "documentation": "https://github.com/ogil109/spec-kit-blueprint/blob/main/README.md",
+ "changelog": "https://github.com/ogil109/spec-kit-blueprint/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "category": "process",
+ "effect": "read-write",
+ "requires": {
+ "speckit_version": ">=0.10.0",
+ "tools": [
+ { "name": "bash", "required": false },
+ { "name": "git", "required": false }
+ ]
+ },
+ "provides": {
+ "commands": 4,
+ "hooks": 0
+ },
+ "tags": [
+ "blueprint",
+ "architecture",
+ "coherence",
+ "drift",
+ "brownfield",
+ "autonomous",
+ "ci"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-07-24T00:00:00Z",
+ "updated_at": "2026-07-24T00:00:00Z"
+ },
"branch-convention": {
"name": "Branch Convention",
"id": "branch-convention",
@@ -1578,8 +1619,8 @@
"id": "gates",
"description": "Deterministic quality enforcement for Spec Kit across agent hooks, git checks, and CI pipelines with one policy file and one verify entrypoint for identical results at every boundary.",
"author": "schwichtgit",
- "version": "0.3.2",
- "download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.3.2/gates-0.3.2.zip",
+ "version": "0.3.3",
+ "download_url": "https://github.com/schwichtgit/spec-gates/releases/download/v0.3.3/gates-0.3.3.zip",
"repository": "https://github.com/schwichtgit/spec-gates",
"homepage": "https://github.com/schwichtgit/spec-gates",
"documentation": "https://github.com/schwichtgit/spec-gates/blob/main/docs/how-it-works.md",
@@ -1623,7 +1664,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-07-09T00:00:00Z",
- "updated_at": "2026-07-15T00:00:00Z"
+ "updated_at": "2026-07-27T00:00:00Z"
},
"github-issues": {
"name": "GitHub Issues Integration 1",
@@ -1820,6 +1861,40 @@
"created_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-06-30T00:00:00Z"
},
+ "intent": {
+ "name": "Intent Reconciliation",
+ "id": "intent",
+ "description": "Reconcile implementation-discovered decisions against approved feature intent",
+ "author": "SuhaibAslam",
+ "version": "1.0.2",
+ "download_url": "https://github.com/SuhaibAslam/spec-kit-reconcile/archive/refs/tags/v1.0.2.zip",
+ "repository": "https://github.com/SuhaibAslam/spec-kit-reconcile",
+ "homepage": "https://github.com/SuhaibAslam/spec-kit-reconcile",
+ "documentation": "https://github.com/SuhaibAslam/spec-kit-reconcile/blob/main/README.md",
+ "changelog": "https://github.com/SuhaibAslam/spec-kit-reconcile/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "category": "process",
+ "effect": "read-write",
+ "requires": {
+ "speckit_version": ">=0.12.0"
+ },
+ "provides": {
+ "commands": 3,
+ "hooks": 0
+ },
+ "tags": [
+ "intent",
+ "decisions",
+ "reconciliation",
+ "drift",
+ "workflow"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-07-29T00:00:00Z",
+ "updated_at": "2026-07-29T00:00:00Z"
+ },
"issue": {
"name": "GitHub Issues Integration 2",
"id": "issue",
@@ -2029,6 +2104,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.1",
+ "download_url": "https://github.com/tonydwoodhouse/spec-kit-linear-weave/archive/refs/tags/v1.0.1.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/blob/main/README.md",
+ "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-27T00:00:00Z"
+ },
"loop": {
"name": "Loop Engineering",
"id": "loop",
@@ -2683,10 +2792,10 @@
"okf": {
"name": "OKF Knowledge Bundle Generator",
"id": "okf",
- "description": "Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository.",
+ "description": "Generates and maintains an Open Knowledge Format (OKF v0.1) knowledge bundle from a source-code repository, mining git history for significance and rationale, and resolving open questions with the user.",
"author": "Alex Punnen",
- "version": "0.2.0",
- "download_url": "https://github.com/alexcpn/speckit_ofk/archive/refs/tags/v0.2.0.zip",
+ "version": "0.3.0",
+ "download_url": "https://github.com/alexcpn/speckit_ofk/archive/refs/tags/v0.3.0.zip",
"repository": "https://github.com/alexcpn/speckit_ofk",
"homepage": "https://github.com/alexcpn/speckit_ofk",
"documentation": "https://github.com/alexcpn/speckit_ofk/blob/main/README.md",
@@ -2698,7 +2807,7 @@
"speckit_version": ">=0.12.0"
},
"provides": {
- "commands": 3,
+ "commands": 4,
"hooks": 0
},
"tags": [
@@ -2712,7 +2821,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-07-17T00:00:00Z",
- "updated_at": "2026-07-17T00:00:00Z"
+ "updated_at": "2026-07-21T00:00:00Z"
},
"onboard": {
"name": "Onboard",
@@ -4342,6 +4451,40 @@
"created_at": "2026-05-20T00:00:00Z",
"updated_at": "2026-05-20T00:00:00Z"
},
+ "test-coverage-drift-control": {
+ "name": "Test Coverage Drift Control",
+ "id": "test-coverage-drift-control",
+ "description": "Generate incremental coverage drift reports and planned remediation tasks after implementation",
+ "author": "Igor Benicio de Mesquita",
+ "version": "0.3.0",
+ "download_url": "https://github.com/benizzio/spec-kit-test-coverage-drift-control/archive/refs/tags/v0.3.0.zip",
+ "repository": "https://github.com/benizzio/spec-kit-test-coverage-drift-control",
+ "homepage": "https://github.com/benizzio/spec-kit-test-coverage-drift-control",
+ "documentation": "https://github.com/benizzio/spec-kit-test-coverage-drift-control#readme",
+ "changelog": "https://github.com/benizzio/spec-kit-test-coverage-drift-control/blob/main/CHANGELOG.md",
+ "license": "MIT",
+ "category": "code",
+ "effect": "read-write",
+ "requires": {
+ "speckit_version": ">=0.2.0"
+ },
+ "provides": {
+ "commands": 2,
+ "hooks": 1
+ },
+ "tags": [
+ "analysis",
+ "coverage",
+ "testing",
+ "quality",
+ "maintenance"
+ ],
+ "verified": false,
+ "downloads": 0,
+ "stars": 0,
+ "created_at": "2026-07-21T00:00:00Z",
+ "updated_at": "2026-07-21T00:00:00Z"
+ },
"time-machine": {
"name": "Time Machine",
"id": "time-machine",
@@ -4709,36 +4852,40 @@
"verify-review-ship": {
"name": "Verify Review Ship",
"id": "verify-review-ship",
- "description": "Adds post-implementation verify, review, and ship readiness gates to Spec Kit workflows.",
+ "description": "Post-convergence operational verification, technical review, learning governance, and transactional delivery.",
"author": "Carlos Eduardo Gevaerd Araujo",
- "version": "0.1.0",
- "download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.1.0.zip",
+ "version": "0.4.2",
+ "download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.4.2.zip",
+ "sha256": "71dceef5bf81d7ac54faa26bb5cf279554815a4928ee8d0c8e9bfb4c3e2bb0ab",
"repository": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"homepage": "https://github.com/cadugevaerd/spec-kit-verify-review-ship",
"documentation": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/README.md",
"changelog": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
- "effect": "read-only",
+ "effect": "read-write",
"requires": {
- "speckit_version": ">=0.1.0"
+ "speckit_version": ">=0.11.2"
},
"provides": {
"commands": 3,
- "hooks": 1
+ "hooks": 0
},
"tags": [
"quality",
"review",
"shipping",
- "workflow",
- "testing"
+ "merge",
+ "cleanup",
+ "learning",
+ "governance",
+ "agent-skills"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-07-10T00:00:00Z",
- "updated_at": "2026-07-10T00:00:00Z"
+ "updated_at": "2026-07-28T00:00:00Z"
},
"verify-tasks": {
"name": "Verify Tasks Extension",
diff --git a/extensions/git/README.md b/extensions/git/README.md
index b5df3e31ee..c0cb7b5d00 100644
--- a/extensions/git/README.md
+++ b/extensions/git/README.md
@@ -10,7 +10,7 @@ This extension provides Git operations as an optional, self-contained module. It
- **Feature branch creation** with sequential (`001-feature-name`) or timestamp (`20260319-143022-feature-name`) numbering and optional templates for branch namespaces
- **Branch validation** to ensure branches follow naming conventions
- **Git remote detection** for GitHub integration (e.g., issue creation)
-- **Auto-commit** after core commands (configurable per-command with custom messages)
+- **Auto-commit** after core commands (configurable per-command with custom messages, or Conventional Commit messages generated by the agent)
## Commands
@@ -66,6 +66,11 @@ branch_prefix: ""
# Custom commit message for git init
init_commit_message: "[Spec Kit] Initial commit"
+# Commit message style for auto-commit hooks: "fixed" (default) uses the
+# messages below; "conventional" asks the agent to generate a Conventional
+# Commit message (e.g. "feat: add OAuth spec") from the diff instead.
+commit_style: fixed
+
# Auto-commit per command (all disabled by default)
# Example: enable auto-commit after specify
auto_commit:
diff --git a/extensions/git/commands/speckit.git.commit.md b/extensions/git/commands/speckit.git.commit.md
index e606f911df..3270eb27c6 100644
--- a/extensions/git/commands/speckit.git.commit.md
+++ b/extensions/git/commands/speckit.git.commit.md
@@ -14,23 +14,37 @@ This command is invoked as a hook after (or before) core commands. It:
2. Checks `.specify/extensions/git/git-config.yml` for the `auto_commit` section
3. Looks up the specific event key to see if auto-commit is enabled
4. Falls back to `auto_commit.default` if no event-specific key exists
-5. Uses the per-command `message` if configured, otherwise a default message
+5. Determines the commit message based on `commit_style` (see below)
6. If enabled and there are uncommitted changes, runs `git add .` + `git commit`
+## Commit Message Styles
+
+Controlled by the `commit_style` key in `.specify/extensions/git/git-config.yml`:
+
+- **`fixed`** (default): use the per-command `message` if configured, otherwise a generic `[Spec Kit] Auto-commit ` message.
+- **`conventional`**: inspect the actual changes (`git diff` / `git status`) since the last commit and generate a single-line [Conventional Commit](https://www.conventionalcommits.org/) message (`type(scope): subject`, e.g. `feat: add OAuth specification` or `docs: update implementation plan`) that accurately summarizes the change. Write this message to a temporary file and pass the file's path to the script (see Execution below). The configured `message` values are ignored in this mode.
+
## Execution
Determine the event name from the hook that triggered this command, then run the script:
-- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh `
-- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 `
+- **Bash**: `.specify/extensions/git/scripts/bash/auto-commit.sh [--message-file ]`
+- **PowerShell**: `.specify/extensions/git/scripts/powershell/auto-commit.ps1 [-MessageFile ]`
-Replace `` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`).
+Replace `` with the actual hook event (e.g., `after_specify`, `before_plan`, `after_implement`). Only pass a generated message when `commit_style: conventional` is configured ā first check `.specify/extensions/git/git-config.yml` for the value of `commit_style`:
+
+- If `conventional`: inspect the diff and generate a Conventional Commit message. **Do not interpolate the generated message directly into a shell command string** ā its content is derived from repository changes and may contain characters (quotes, `$(...)`, backticks) that a shell would execute or that would break command quoting. Instead, write the message to a temporary file using your file-editing tool (not a shell `echo`/`printf`), then pass that file's path via `--message-file ` (Bash) or `-MessageFile ` (PowerShell).
+- If `fixed` or absent: run the script with just ``; it uses the configured/static message.
## Configuration
In `.specify/extensions/git/git-config.yml`:
```yaml
+# "fixed" (default) uses the messages below; "conventional" asks the agent
+# to generate a Conventional Commit message from the diff instead.
+commit_style: fixed
+
auto_commit:
default: false # Global toggle ā set true to enable for all commands
after_specify:
@@ -46,3 +60,4 @@ auto_commit:
- If Git is not available or the current directory is not a repository: skips with a warning
- If no config file exists: skips (disabled by default)
- If no changes to commit: skips with a message
+- If `commit_style: conventional` is set and no generated message was supplied: fails with a clear error instead of silently falling back to the fixed message format
diff --git a/extensions/git/config-template.yml b/extensions/git/config-template.yml
index 99e3d31692..2ea3471742 100644
--- a/extensions/git/config-template.yml
+++ b/extensions/git/config-template.yml
@@ -17,6 +17,13 @@ branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
+# Commit message style used by auto-commit hooks (speckit.git.commit):
+# "fixed" - default; use the configured/static messages below.
+# "conventional" - ask the agent to inspect the diff and generate a
+# Conventional Commit message (e.g. "feat: add OAuth spec")
+# instead of using the messages configured below.
+commit_style: fixed
+
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.
diff --git a/extensions/git/git-config.yml b/extensions/git/git-config.yml
index 99e3d31692..2ea3471742 100644
--- a/extensions/git/git-config.yml
+++ b/extensions/git/git-config.yml
@@ -17,6 +17,13 @@ branch_prefix: ""
# Commit message used by `git commit` during repository initialization
init_commit_message: "[Spec Kit] Initial commit"
+# Commit message style used by auto-commit hooks (speckit.git.commit):
+# "fixed" - default; use the configured/static messages below.
+# "conventional" - ask the agent to inspect the diff and generate a
+# Conventional Commit message (e.g. "feat: add OAuth spec")
+# instead of using the messages configured below.
+commit_style: fixed
+
# Auto-commit before/after core commands.
# Set "default" to enable for all commands, then override per-command.
# Each key can be true/false. Message is customizable per-command.
diff --git a/extensions/git/scripts/bash/auto-commit.sh b/extensions/git/scripts/bash/auto-commit.sh
index f0b423187b..17fec66f0b 100755
--- a/extensions/git/scripts/bash/auto-commit.sh
+++ b/extensions/git/scripts/bash/auto-commit.sh
@@ -3,16 +3,57 @@
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
-# Usage: auto-commit.sh
+# Usage: auto-commit.sh [generated_message]
+# auto-commit.sh --message-file
# e.g.: auto-commit.sh after_specify
+# e.g.: auto-commit.sh after_specify --message-file /tmp/commit-msg.txt (commit_style: conventional)
+#
+# --message-file is the preferred way to supply an agent-generated commit
+# message: it reads the message from a file instead of a shell argument,
+# so message content (which may contain quotes, `$(...)`, backticks, etc.)
+# is never interpolated into a shell command line.
set -e
EVENT_NAME="${1:-}"
if [ -z "$EVENT_NAME" ]; then
- echo "Usage: $0 " >&2
+ echo "Usage: $0 [generated_message | --message-file ]" >&2
exit 1
fi
+shift || true
+
+# Optional second argument: an agent-generated commit message (used when
+# commit_style: conventional is configured). Prefer --message-file over
+# passing the message directly as a shell argument.
+GENERATED_MESSAGE=""
+while [ $# -gt 0 ]; do
+ case "$1" in
+ --message-file)
+ _message_file="${2:-}"
+ if [ -z "$_message_file" ]; then
+ echo "[specify] Error: --message-file requires a path argument" >&2
+ exit 1
+ fi
+ if [ ! -f "$_message_file" ]; then
+ echo "[specify] Error: message file '$_message_file' not found" >&2
+ exit 1
+ fi
+ GENERATED_MESSAGE="$(cat "$_message_file")"
+ # The message file is a transport-only artifact: its content is
+ # now captured above, so remove it immediately. Otherwise, if it
+ # was written inside the worktree, it would be picked up as an
+ # untracked change by both the "any changes?" check below and by
+ # `git add .`, polluting the commit or defeating the no-changes
+ # short-circuit even when nothing else changed.
+ rm -f "$_message_file"
+ shift 2
+ ;;
+ *)
+ GENERATED_MESSAGE="$1"
+ shift
+ ;;
+ esac
+done
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
@@ -46,8 +87,22 @@ fi
_config_file="$REPO_ROOT/.specify/extensions/git/git-config.yml"
_enabled=false
_commit_msg=""
+_commit_style="fixed"
if [ -f "$_config_file" ]; then
+ # Top-level scalar key: commit_style (fixed | conventional)
+ _style_val=$(grep -m1 '^commit_style:' "$_config_file" 2>/dev/null | sed 's/^commit_style:[[:space:]]*//' | sed 's/[[:space:]]\{1,\}#.*$//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//' | tr '[:upper:]' '[:lower:]')
+ if [ -n "$_style_val" ]; then
+ case "$_style_val" in
+ fixed|conventional)
+ _commit_style="$_style_val"
+ ;;
+ *)
+ echo "[specify] Warning: unknown commit_style '$_style_val' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'" >&2
+ ;;
+ esac
+ fi
+
# Parse the auto_commit section for this event.
# Look for auto_commit..enabled and .message
# Also check auto_commit.default as fallback.
@@ -94,7 +149,12 @@ if [ -f "$_config_file" ]; then
[ "$_val" = "false" ] && _enabled=false
fi
if echo "$_line" | grep -Eq '[[:space:]]+message:'; then
- _commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
+ # Trim trailing whitespace before stripping the closing quote:
+ # a value like `message: "Done" ` (trailing spaces after the
+ # quote) would otherwise leave the quote dangling (`Done" `),
+ # since the closing-quote strip is anchored to end-of-string.
+ # The PowerShell twin .Trim()s first; match it for parity.
+ _commit_msg=$(echo "$_line" | sed 's/^[^:]*:[[:space:]]*//' | sed 's/[[:space:]]*$//' | sed 's/^["'\'']//' | sed 's/["'\'']*$//')
fi
fi
fi
@@ -123,6 +183,17 @@ if git diff --quiet HEAD 2>/dev/null && git diff --cached --quiet 2>/dev/null &&
exit 0
fi
+# In conventional mode, the commit message must be supplied by the agent
+# (via the generated_message argument); never fall back to the fixed message.
+if [ "$_commit_style" = "conventional" ]; then
+ if [ -n "$GENERATED_MESSAGE" ]; then
+ _commit_msg="$GENERATED_MESSAGE"
+ else
+ echo "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass --message-file , or a raw message as arg 2, or set commit_style: fixed)" >&2
+ exit 1
+ fi
+fi
+
# Derive a human-readable command name from the event
# e.g., after_specify -> specify, before_plan -> plan
_command_name=$(echo "$EVENT_NAME" | sed 's/^after_//' | sed 's/^before_//')
diff --git a/extensions/git/scripts/powershell/auto-commit.ps1 b/extensions/git/scripts/powershell/auto-commit.ps1
index 34767f8a36..d27b484bad 100644
--- a/extensions/git/scripts/powershell/auto-commit.ps1
+++ b/extensions/git/scripts/powershell/auto-commit.ps1
@@ -3,14 +3,47 @@
# Automatically commit changes after a Spec Kit command completes.
# Checks per-command config keys in git-config.yml before committing.
#
-# Usage: auto-commit.ps1
+# Usage: auto-commit.ps1 [generated_message]
+# auto-commit.ps1 -MessageFile
# e.g.: auto-commit.ps1 after_specify
+# e.g.: auto-commit.ps1 after_specify -MessageFile C:\temp\commit-msg.txt (commit_style: conventional)
+#
+# -MessageFile is the preferred way to supply an agent-generated commit
+# message: it reads the message from a file instead of a shell argument,
+# so message content (which may contain quotes, $(...), backticks, etc.)
+# is never interpolated into a shell command line.
param(
[Parameter(Position = 0, Mandatory = $true)]
- [string]$EventName
+ [string]$EventName,
+
+ # Optional agent-generated commit message (used when commit_style: conventional is configured).
+ # Prefer -MessageFile over passing the message directly as a shell argument.
+ [Parameter(Position = 1, Mandatory = $false)]
+ [string]$GeneratedMessage = "",
+
+ [Parameter(Mandatory = $false)]
+ [string]$MessageFile = ""
)
$ErrorActionPreference = 'Stop'
+if ($MessageFile) {
+ if (-not (Test-Path $MessageFile -PathType Leaf)) {
+ Write-Warning "[specify] Error: message file '$MessageFile' not found"
+ exit 1
+ }
+ $GeneratedMessage = (Get-Content -Path $MessageFile -Raw)
+ if ($null -ne $GeneratedMessage) {
+ $GeneratedMessage = $GeneratedMessage.TrimEnd("`r", "`n")
+ }
+ # The message file is a transport-only artifact: its content is now
+ # captured above, so remove it immediately. Otherwise, if it was written
+ # inside the worktree, it would be picked up as an untracked change by
+ # both the "any changes?" check below and by `git add .`, polluting the
+ # commit or defeating the no-changes short-circuit even when nothing
+ # else changed.
+ Remove-Item -Path $MessageFile -Force -ErrorAction SilentlyContinue
+}
+
function Find-ProjectRoot {
param([string]$StartDir)
$current = Resolve-Path $StartDir
@@ -55,8 +88,25 @@ if (-not $isRepo) {
$configFile = Join-Path $repoRoot ".specify/extensions/git/git-config.yml"
$enabled = $false
$commitMsg = ""
+$commitStyle = "fixed"
if (Test-Path $configFile) {
+ # Top-level scalar key: commit_style (fixed | conventional)
+ foreach ($line in Get-Content $configFile) {
+ if ($line -match '^commit_style:\s*(.+)$') {
+ $styleVal = (($matches[1] -replace '\s+#.*$', '').Trim()) -replace '^["'']' -replace '["'']$'
+ if ($styleVal) {
+ $styleVal = $styleVal.ToLower()
+ if ($styleVal -eq 'fixed' -or $styleVal -eq 'conventional') {
+ $commitStyle = $styleVal
+ } else {
+ Write-Warning "[specify] Warning: unknown commit_style '$styleVal' in git-config.yml (expected 'fixed' or 'conventional'); defaulting to 'fixed'"
+ }
+ }
+ break
+ }
+ }
+
# Parse YAML to find auto_commit section
$inAutoCommit = $false
$inEvent = $false
@@ -140,6 +190,17 @@ if ($d1 -eq 0 -and $d2 -eq 0 -and -not $untracked) {
exit 0
}
+# In conventional mode, the commit message must be supplied by the agent
+# (via the GeneratedMessage argument); never fall back to the fixed message.
+if ($commitStyle -eq 'conventional') {
+ if ($GeneratedMessage) {
+ $commitMsg = $GeneratedMessage
+ } else {
+ Write-Warning "[specify] Error: commit_style is 'conventional' but no generated commit message was supplied; aborting auto-commit (pass -MessageFile , or a raw message as arg 2, or set commit_style: fixed)"
+ exit 1
+ }
+}
+
# Derive a human-readable command name from the event
$commandName = $EventName -replace '^after_', '' -replace '^before_', ''
$phase = if ($EventName -match '^before_') { 'before' } else { 'after' }
diff --git a/extensions/git/scripts/powershell/create-new-feature-branch.ps1 b/extensions/git/scripts/powershell/create-new-feature-branch.ps1
index 1536f9a2ba..2d6f2bcfec 100644
--- a/extensions/git/scripts/powershell/create-new-feature-branch.ps1
+++ b/extensions/git/scripts/powershell/create-new-feature-branch.ps1
@@ -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"
}
}
diff --git a/extensions/git/scripts/python/auto_commit.py b/extensions/git/scripts/python/auto_commit.py
index ebf23a9454..c6692895b8 100644
--- a/extensions/git/scripts/python/auto_commit.py
+++ b/extensions/git/scripts/python/auto_commit.py
@@ -33,7 +33,15 @@ def _value_after_colon(line: str) -> str:
def _strip_quotes(value: str) -> str:
- """Strip one leading quote and all trailing quotes, mirroring the bash sed."""
+ """Strip surrounding whitespace, then one leading quote and all trailing quotes.
+
+ Trimming first matters when the YAML value has trailing whitespace after a
+ closing quote (``message: "Done" ``): stripping quotes anchored to the end
+ of string would leave the closing quote dangling (``Done" ``) because the
+ quote is no longer at the end. The PowerShell twin ``.Trim()``s before
+ stripping, so trim here too to keep all three script variants in parity.
+ """
+ value = value.strip()
value = re.sub(r"^[\"']", "", value)
return re.sub(r"[\"']*$", "", value)
diff --git a/integrations/catalog.json b/integrations/catalog.json
index 40dc13d84c..abaabb8ece 100644
--- a/integrations/catalog.json
+++ b/integrations/catalog.json
@@ -1,8 +1,17 @@
{
"schema_version": "1.0",
- "updated_at": "2026-07-15T00:00:00Z",
+ "updated_at": "2026-07-27T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
+ "alquimia": {
+ "id": "alquimia",
+ "name": "Alquimia AI",
+ "version": "1.0.0",
+ "description": "Alquimia AI CLI integration",
+ "author": "spec-kit-core",
+ "repository": "https://github.com/github/spec-kit",
+ "tags": ["alquimia"]
+ },
"claude": {
"id": "claude",
"name": "Claude Code",
@@ -48,6 +57,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",
@@ -177,11 +195,11 @@
"bob": {
"id": "bob",
"name": "IBM Bob",
- "version": "1.0.0",
- "description": "IBM Bob IDE integration",
+ "version": "2.0.0",
+ "description": "IBM Bob 2.0 IDE skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
- "tags": ["ide", "ibm"]
+ "tags": ["ide", "ibm", "skills"]
},
"trae": {
"id": "trae",
diff --git a/presets/catalog.community.json b/presets/catalog.community.json
index 925bc12ca1..751d57d318 100644
--- a/presets/catalog.community.json
+++ b/presets/catalog.community.json
@@ -1,18 +1,19 @@
{
"schema_version": "1.0",
- "updated_at": "2026-07-17T00:00:00Z",
+ "updated_at": "2026-07-28T00:00:00Z",
+
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
"name": "A11Y Governance",
"id": "a11y-governance",
- "version": "0.4.0",
- "description": "Adds accessibility (WCAG 2.2 AA), bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec Kit run evidence.",
+ "version": "0.4.2",
+ "description": "Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
- "download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.0.zip",
+ "download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.2.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
- "documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
+ "documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.2/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -33,18 +34,18 @@
"didactic-comments"
],
"created_at": "2026-04-27T00:00:00Z",
- "updated_at": "2026-06-14T00:00:00Z"
+ "updated_at": "2026-07-28T00:00:00Z"
},
"agent-parity-governance": {
"name": "Agent Parity Governance",
"id": "agent-parity-governance",
- "version": "0.3.0",
- "description": "Adds shared-guidance parity, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across a project's declared AI-agent instruction surfaces so agent guidance does not drift.",
+ "version": "0.4.1",
+ "description": "Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
- "download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.3.0.zip",
+ "download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
- "documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/main/README.md",
+ "documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -63,13 +64,13 @@
"multi-agent"
],
"created_at": "2026-04-27T00:00:00Z",
- "updated_at": "2026-06-14T00:00:00Z"
+ "updated_at": "2026-07-28T00:00:00Z"
},
"aide-in-place": {
"name": "AIDE In-Place Migration",
"id": "aide-in-place",
"version": "1.0.0",
- "description": "Adapts the AIDE workflow for in-place technology migrations (X ā 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",
@@ -96,13 +97,13 @@
"architecture-governance": {
"name": "Architecture Governance",
"id": "architecture-governance",
- "version": "0.5.0",
- "description": "Adds secure software architecture, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
+ "version": "0.5.1",
+ "description": "Adds secure software architecture, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
- "download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.0.zip",
+ "download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.5.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
- "documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md",
+ "documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/v0.5.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -129,18 +130,18 @@
"assurance"
],
"created_at": "2026-04-27T00:00:00Z",
- "updated_at": "2026-06-14T00:00:00Z"
+ "updated_at": "2026-07-23T00:00:00Z"
},
"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.3",
+ "description": "Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract.",
"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.3.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.3/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.3"
@@ -155,10 +156,10 @@
"governance",
"evidence",
"permissions",
- "resume"
+ "accessibility"
],
"created_at": "2026-07-13T00:00:00Z",
- "updated_at": "2026-07-17T00:00:00Z"
+ "updated_at": "2026-07-28T00:00:00Z"
},
"canon-core": {
"name": "Canon Core",
@@ -242,13 +243,13 @@
"cross-platform-governance": {
"name": "Cross-Platform Governance",
"id": "cross-platform-governance",
- "version": "0.2.0",
- "description": "Adds Bash + PowerShell parity, Unix man-pages, bilingual comment-based help, Verb-Noun Cmdlet discipline, and audit-ready Spec Kit run evidence for scripting projects managed with Spec Kit.",
+ "version": "0.2.1",
+ "description": "Adds Bash/PowerShell and read-only check parity, root-path and native-override review, Unix man pages, bilingual help, Verb-Noun discipline, and audit-ready evidence.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
- "download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.0.zip",
+ "download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.2.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
- "documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md",
+ "documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/v0.2.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -270,7 +271,7 @@
"linux"
],
"created_at": "2026-04-27T00:00:00Z",
- "updated_at": "2026-06-14T00:00:00Z"
+ "updated_at": "2026-07-23T00:00:00Z"
},
"explicit-task-dependencies": {
"name": "Explicit Task Dependencies",
@@ -363,16 +364,103 @@
"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.3.0",
+ "description": "Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring.",
+ "author": "Thorsten Hindermann",
+ "repository": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
+ "download_url": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/archive/refs/tags/v0.3.0.zip",
+ "homepage": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance",
+ "documentation": "https://github.com/hindermath/spec-kit-preset-intake-authoring-governance/blob/v0.3.0/README.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.8.3"
+ },
+ "provides": {
+ "templates": 12,
+ "commands": 5,
+ "scripts": 7
+ },
+ "tags": [
+ "intake",
+ "authoring",
+ "governance",
+ "requirements",
+ "migration"
+ ],
+ "created_at": "2026-07-22T00:00:00Z",
+ "updated_at": "2026-07-28T00:00:00Z"
+ },
+ "intake-review-governance": {
+ "name": "Intake Review Governance",
+ "id": "intake-review-governance",
+ "version": "0.2.0",
+ "description": "Reviews single, series, campaign, and language-aware requirements collections before 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.2.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.2.0/README.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.8.3"
+ },
+ "provides": {
+ "templates": 8,
+ "commands": 3,
+ "scripts": 4
+ },
+ "tags": [
+ "intake",
+ "review",
+ "governance",
+ "requirements",
+ "quality-gate"
+ ],
+ "created_at": "2026-07-21T00:00:00Z",
+ "updated_at": "2026-07-28T00:00:00Z"
+ },
+ "intake-sequencing-governance": {
+ "name": "Intake Sequencing Governance",
+ "id": "intake-sequencing-governance",
+ "version": "0.2.2",
+ "description": "Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection.",
+ "author": "Thorsten Hindermann",
+ "repository": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
+ "download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.2.2.zip",
+ "homepage": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
+ "documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.2.2/README.md",
+ "license": "MIT",
+ "requires": {
+ "speckit_version": ">=0.8.3"
+ },
+ "provides": {
+ "templates": 11,
+ "commands": 6,
+ "scripts": 8
+ },
+ "tags": [
+ "intake",
+ "sequencing",
+ "governance",
+ "dag",
+ "lifecycle"
+ ],
+ "created_at": "2026-07-27T00:00:00Z",
+ "updated_at": "2026-07-28T00:00:00Z"
+ },
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
"id": "isaqb-architecture-governance",
- "version": "0.2.0",
- "description": "Adds general iSAQB/CPSA-F and arc42 software-architecture governance, including audit-ready Spec Kit run evidence for architecture goals, views, quality scenarios, ADRs, risks, and technical debt.",
+ "version": "0.2.1",
+ "description": "Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
- "download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.0.zip",
+ "download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.2.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
- "documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md",
+ "documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/v0.2.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -393,7 +481,7 @@
"technical-debt"
],
"created_at": "2026-04-27T00:00:00Z",
- "updated_at": "2026-06-14T00:00:00Z"
+ "updated_at": "2026-07-23T00:00:00Z"
},
"jira": {
"name": "Jira Issue Tracking",
@@ -480,6 +568,35 @@
"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.4",
+ "description": "Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation.",
+ "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.4.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.4/README.md",
+ "license": "MIT",
+ "requires": {
+"speckit_version": ">=0.8.3"
+ },
+ "provides": {
+ "templates": 9,
+ "commands": 5,
+ "scripts": 2
+ },
+ "tags": [
+ "parallel",
+ "autonomous",
+ "governance",
+ "accessibility",
+ "orchestration"
+ ],
+ "created_at": "2026-07-22T00:00:00Z",
+ "updated_at": "2026-07-28T00:00:00Z"
+ },
"pirate": {
"name": "Pirate Speak (Full)",
"id": "pirate",
@@ -509,7 +626,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",
@@ -546,13 +663,13 @@
"security-governance": {
"name": "Security Governance",
"id": "security-governance",
- "version": "0.6.0",
- "description": "Adds memory-safe-language preference, language-specific secure coding profiles, audit-ready Spec-Kit run evidence, ASVS verification, SBOM/AI-SBOM supply-chain transparency, CRA awareness, and regulatory applicability screening for NIS2, CRA, EU AI Act, and DORA to Spec Kit.",
+ "version": "0.6.1",
+ "description": "Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
- "download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.0.zip",
+ "download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.6.1.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
- "documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
+ "documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/v0.6.1/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
@@ -591,7 +708,7 @@
"regulatory"
],
"created_at": "2026-04-27T00:00:00Z",
- "updated_at": "2026-06-14T00:00:00Z"
+ "updated_at": "2026-07-23T00:00:00Z"
},
"sicario-core": {
"name": "SicarioSpec Core",
@@ -624,7 +741,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 +769,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",
diff --git a/presets/lean/commands/speckit.constitution.md b/presets/lean/commands/speckit.constitution.md
index 920337003e..32dcd6b3df 100644
--- a/presets/lean/commands/speckit.constitution.md
+++ b/presets/lean/commands/speckit.constitution.md
@@ -8,6 +8,24 @@ description: Create or update the project constitution.
$ARGUMENTS
```
+## Scope Guard
+
+This command's own work is limited to creating or updating the project constitution and
+propagating constitution-driven changes to dependent Spec Kit artifacts.
+
+- Classify every part of the user input as constitution content or a separate non-governance
+ intent. Feature implementation, code generation, refactoring, build, and deployment requests
+ are examples of non-governance intents.
+- You **MUST NOT** execute any non-governance intent. Defer each one to `Next Actions`.
+- You **MUST NOT** create, modify, or delete application source files or other artifacts
+ unrelated to the constitution workflow.
+- If an instruction could be either constitution content or a non-governance intent, ask for
+ clarification before making changes.
+- After updating the constitution, list each deferred intent in a `Next Actions` section with an
+ appropriate follow-up Spec Kit command, such as `__SPECKIT_COMMAND_SPECIFY__`, but do not
+ invoke it.
+- Omit `Next Actions` when there are no non-governance intents.
+
## Outline
1. Create or update the project constitution and store it in `.specify/memory/constitution.md`.
diff --git a/pyproject.toml b/pyproject.toml
index feb1e72349..8838cefee3 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
-version = "0.13.0"
+version = "0.15.0"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
readme = "README.md"
requires-python = ">=3.11"
@@ -39,6 +39,7 @@ packages = ["src/specify_cli"]
"templates/commands" = "specify_cli/core_pack/commands"
"scripts/bash" = "specify_cli/core_pack/scripts/bash"
"scripts/powershell" = "specify_cli/core_pack/scripts/powershell"
+"scripts/python" = "specify_cli/core_pack/scripts/python"
# Bundled extensions (installable via `specify extension add `)
"extensions/git" = "specify_cli/core_pack/extensions/git"
"extensions/agent-context" = "specify_cli/core_pack/extensions/agent-context"
@@ -48,6 +49,8 @@ packages = ["src/specify_cli"]
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
# Bundled presets (installable via `specify preset add ` or `specify init --preset `)
"presets/lean" = "specify_cli/core_pack/presets/lean"
+# Community bundle catalog snapshot (used for offline discovery)
+"bundles/catalog.community.json" = "specify_cli/core_pack/bundles/catalog.community.json"
[project.optional-dependencies]
test = [
diff --git a/scripts/bash/create-new-feature.sh b/scripts/bash/create-new-feature.sh
index 50b2ce08de..c1b189dc08 100755
--- a/scripts/bash/create-new-feature.sh
+++ b/scripts/bash/create-new-feature.sh
@@ -8,6 +8,7 @@ ALLOW_EXISTING=false
SHORT_NAME=""
BRANCH_NUMBER=""
USE_TIMESTAMP=false
+NUMBER_EXPLICIT=false
ARGS=()
i=1
while [ $i -le $# ]; do
@@ -48,6 +49,9 @@ while [ $i -le $# ]; do
exit 1
fi
BRANCH_NUMBER="$next_arg"
+ if [ -n "$BRANCH_NUMBER" ]; then
+ NUMBER_EXPLICIT=true
+ fi
;;
--timestamp)
USE_TIMESTAMP=true
@@ -60,7 +64,7 @@ while [ $i -le $# ]; do
echo " --dry-run Compute feature name and paths without creating directories or files"
echo " --allow-existing-branch Reuse an existing feature directory if it already exists"
echo " --short-name Provide a custom short name (2-4 words) for the feature"
- echo " --number N Specify branch number manually (overrides auto-detection)"
+ echo " --number N Prefer a feature number (auto-corrected if its specs prefix exists)"
echo " --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
echo " --help, -h Show this help message"
echo ""
@@ -90,6 +94,20 @@ if [ -z "$FEATURE_DESCRIPTION" ]; then
exit 1
fi
+MAX_FEATURE_NUMBER=9223372036854775807
+MAX_BRANCH_LENGTH=244
+
+is_feature_number_in_range() {
+ local value="$1"
+ local normalized="${value#"${value%%[!0]*}"}"
+ [ -n "$normalized" ] || normalized=0
+ [ ${#normalized} -lt ${#MAX_FEATURE_NUMBER} ] && return 0
+ [ ${#normalized} -gt ${#MAX_FEATURE_NUMBER} ] && return 1
+ # Equal-length digit strings must be compared without arithmetic overflow.
+ # shellcheck disable=SC2071
+ [[ "$normalized" < "$MAX_FEATURE_NUMBER" || "$normalized" == "$MAX_FEATURE_NUMBER" ]]
+}
+
# Function to get highest number from specs directory
get_highest_from_specs() {
local specs_dir="$1"
@@ -102,9 +120,11 @@ get_highest_from_specs() {
# Match sequential prefixes (>=3 digits), but skip timestamp dirs.
if echo "$dirname" | grep -Eq '^[0-9]{3,}-' && ! echo "$dirname" | grep -Eq '^[0-9]{8}-[0-9]{6}-'; then
number=$(echo "$dirname" | grep -Eo '^[0-9]+')
- number=$((10#$number))
- if [ "$number" -gt "$highest" ]; then
- highest=$number
+ if is_feature_number_in_range "$number"; then
+ number=$((10#$number))
+ if [ "$number" -gt "$highest" ]; then
+ highest=$number
+ fi
fi
fi
done
@@ -113,12 +133,53 @@ get_highest_from_specs() {
echo "$highest"
}
+# Return success when a spec directory owns the given numeric prefix.
+spec_prefix_exists() {
+ local specs_dir="$1"
+ local feature_num="$2"
+
+ for spec_path in "$specs_dir/${feature_num}-"*; do
+ [ -d "$spec_path" ] && return 0
+ done
+ return 1
+}
+
# Function to clean and format a branch name
clean_branch_name() {
local name="$1"
echo "$name" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9]/-/g' | sed 's/-\+/-/g' | sed 's/^-//' | sed 's/-$//'
}
+# Fit a feature prefix and suffix within GitHub's branch-name limit.
+fit_branch_name() {
+ local feature_num="$1"
+ local branch_suffix="$2"
+ local branch_name="${feature_num}-${branch_suffix}"
+
+ if [ ${#branch_name} -gt $MAX_BRANCH_LENGTH ]; then
+ local prefix_length=$(( ${#feature_num} + 1 ))
+ local max_suffix_length=$((MAX_BRANCH_LENGTH - prefix_length))
+ local truncated_suffix
+ truncated_suffix=$(printf '%s' "$branch_suffix" | cut -c "1-$max_suffix_length" | sed 's/-$//')
+ branch_name="${feature_num}-${truncated_suffix}"
+ fi
+
+ printf '%s' "$branch_name"
+}
+
+# Quote a value for POSIX shell reuse, byte-identical to Python's shlex.quote
+# so the persistence hints match the Python variant exactly (printf %q output
+# differs between bash versions and from shlex.quote for spaces/metachars).
+shell_quote() {
+ local value="$1" LC_ALL=C
+ if [[ "$value" =~ ^[A-Za-z0-9_@%+=:,./-]+$ ]]; then
+ printf '%s' "$value"
+ else
+ local q="'\"'\"'"
+ printf "'%s'" "${value//\'/$q}"
+ fi
+}
+
# Resolve repository root using common.sh functions which prioritize .specify
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
@@ -202,34 +263,64 @@ if [ "$USE_TIMESTAMP" = true ]; then
FEATURE_NUM=$(date +%Y%m%d-%H%M%S)
BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
else
+ if [ -n "$BRANCH_NUMBER" ] && [[ ! "$BRANCH_NUMBER" =~ ^[0-9]+$ ]]; then
+ echo "Error: --number must be an unsigned integer, got '$BRANCH_NUMBER'" >&2
+ exit 1
+ fi
+
+ # Bash arithmetic is signed 64-bit; reject digit strings that would wrap.
+ if [ -n "$BRANCH_NUMBER" ] && ! is_feature_number_in_range "$BRANCH_NUMBER"; then
+ echo "Error: --number must be between 0 and $MAX_FEATURE_NUMBER, got '$BRANCH_NUMBER'" >&2
+ exit 1
+ fi
+
# Determine branch number from existing feature directories
if [ -z "$BRANCH_NUMBER" ]; then
HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
+ if [ "$HIGHEST" -eq "$MAX_FEATURE_NUMBER" ]; then
+ echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
+ exit 1
+ fi
BRANCH_NUMBER=$((HIGHEST + 1))
fi
# Force base-10 interpretation to prevent octal conversion (e.g., 010 ā 8 in octal, but should be 10 in decimal)
FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
- BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
-fi
-# GitHub enforces a 244-byte limit on branch names
-# Validate and truncate if necessary
-MAX_BRANCH_LENGTH=244
-if [ ${#BRANCH_NAME} -gt $MAX_BRANCH_LENGTH ]; then
- # Calculate how much we need to trim from suffix
- # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
- PREFIX_LENGTH=$(( ${#FEATURE_NUM} + 1 ))
- MAX_SUFFIX_LENGTH=$((MAX_BRANCH_LENGTH - PREFIX_LENGTH))
+ # Treat an explicit number as a preference when its prefix is already used
+ # by a feature directory. Auto-detected numbers are already conflict-free.
+ if [ "$NUMBER_EXPLICIT" = true ]; then
+ SPEC_CONFLICT=false
+ REQUESTED_BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
+ REQUESTED_DIR="$SPECS_DIR/$REQUESTED_BRANCH_NAME"
+ if [ "$ALLOW_EXISTING" != true ] || [ ! -d "$REQUESTED_DIR" ]; then
+ spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" && SPEC_CONFLICT=true
+ fi
- # Truncate suffix at word boundary if possible
- TRUNCATED_SUFFIX=$(echo "$BRANCH_SUFFIX" | cut -c1-$MAX_SUFFIX_LENGTH)
- # Remove trailing hyphen if truncation created one
- TRUNCATED_SUFFIX=$(echo "$TRUNCATED_SUFFIX" | sed 's/-$//')
+ if [ "$SPEC_CONFLICT" = true ]; then
+ REQUESTED_NUM="$FEATURE_NUM"
+ HIGHEST=$(get_highest_from_specs "$SPECS_DIR")
+ BRANCH_NUMBER=$HIGHEST
+ while true; do
+ if [ "$BRANCH_NUMBER" -eq "$MAX_FEATURE_NUMBER" ]; then
+ echo "Error: feature number must be between 0 and $MAX_FEATURE_NUMBER, got '9223372036854775808'" >&2
+ exit 1
+ fi
+ BRANCH_NUMBER=$((BRANCH_NUMBER + 1))
+ FEATURE_NUM=$(printf "%03d" "$((10#$BRANCH_NUMBER))")
+ spec_prefix_exists "$SPECS_DIR" "$FEATURE_NUM" || break
+ done
+ >&2 echo "[specify] Warning: --number $REQUESTED_NUM conflicts with an existing spec directory; using $FEATURE_NUM instead"
+ fi
+ fi
- ORIGINAL_BRANCH_NAME="$BRANCH_NAME"
- BRANCH_NAME="${FEATURE_NUM}-${TRUNCATED_SUFFIX}"
+fi
+# GitHub enforces a 244-byte limit on branch names
+# Validate and truncate if necessary
+ORIGINAL_BRANCH_NAME="${FEATURE_NUM}-${BRANCH_SUFFIX}"
+BRANCH_NAME=$(fit_branch_name "$FEATURE_NUM" "$BRANCH_SUFFIX")
+if [ "$BRANCH_NAME" != "$ORIGINAL_BRANCH_NAME" ]; then
>&2 echo "[specify] Warning: Branch name exceeded GitHub's 244-byte limit"
>&2 echo "[specify] Original: $ORIGINAL_BRANCH_NAME (${#ORIGINAL_BRANCH_NAME} bytes)"
>&2 echo "[specify] Truncated to: $BRANCH_NAME (${#BRANCH_NAME} bytes)"
@@ -264,8 +355,8 @@ if [ "$DRY_RUN" != true ]; then
_persist_feature_json "$REPO_ROOT" "$FEATURE_DIR"
# Inform the user how to set feature state in their own shell
- printf '# To persist: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME" >&2
- printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR" >&2
+ printf '# To persist: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")" >&2
+ printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")" >&2
fi
if $JSON_MODE; then
@@ -295,7 +386,7 @@ else
echo "SPEC_FILE: $SPEC_FILE"
echo "FEATURE_NUM: $FEATURE_NUM"
if [ "$DRY_RUN" != true ]; then
- printf '# To persist in your shell: export SPECIFY_FEATURE=%q\n' "$BRANCH_NAME"
- printf '# export SPECIFY_FEATURE_DIRECTORY=%q\n' "$FEATURE_DIR"
+ printf '# To persist in your shell: export SPECIFY_FEATURE=%s\n' "$(shell_quote "$BRANCH_NAME")"
+ printf '# export SPECIFY_FEATURE_DIRECTORY=%s\n' "$(shell_quote "$FEATURE_DIR")"
fi
fi
diff --git a/scripts/bash/pre-pr.sh b/scripts/bash/pre-pr.sh
index 07e0d3aa13..5d7af02c12 100755
--- a/scripts/bash/pre-pr.sh
+++ b/scripts/bash/pre-pr.sh
@@ -20,10 +20,10 @@ date
fail=0
-# 1. Ruff lint (matches CI: uvx ruff check src/)
+# 1. Ruff lint (matches CI: uvx ruff@0.15.0 check src tests)
echo ""
echo "[1/5] ruff check src/..."
-if uvx --from "ruff>=0.14" ruff check "$REPO_ROOT/src/"; then
+if uvx ruff@0.15.0 check "$REPO_ROOT/src/" "$REPO_ROOT/tests/"; then
echo " ruff passed"
else
echo " ruff FAILED"
diff --git a/scripts/powershell/common.ps1 b/scripts/powershell/common.ps1
index bb9d19062b..afc226ea00 100644
--- a/scripts/powershell/common.ps1
+++ b/scripts/powershell/common.ps1
@@ -29,13 +29,16 @@ function Find-SpecifyRoot {
# command against a member project from a monorepo root without cd.
#
# Precondition: $env:SPECIFY_INIT_DIR is set. Returns the validated project root,
-# or writes an error and exits 1. Strict by design: the path must exist and
+# or writes an error and exits 1 unless -ReturnNullOnError is set. Strict by
+# design: the path must exist and
# contain .specify/, with no silent fallback. (An empty string is falsy, so the
# caller's `if ($env:SPECIFY_INIT_DIR)` guard treats empty as unset.)
#
# This is the single resolver: bundled extensions inherit it by sourcing core
# (e.g. the git extension's create-new-feature-branch) rather than duplicating it.
function Resolve-SpecifyInitDir {
+ param([switch]$ReturnNullOnError)
+
$initDir = $env:SPECIFY_INIT_DIR
# Normalize: relative paths resolve against the current directory.
if (-not [System.IO.Path]::IsPathRooted($initDir)) {
@@ -47,6 +50,7 @@ function Resolve-SpecifyInitDir {
# "not a Spec Kit project" error below.
if (-not $resolved -or -not (Test-Path -LiteralPath $resolved.Path -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR does not point to an existing directory: $($env:SPECIFY_INIT_DIR)")
+ if ($ReturnNullOnError) { return $null }
exit 1
}
# Resolve-Path echoes back any trailing separator from the input; trim it so
@@ -56,6 +60,7 @@ function Resolve-SpecifyInitDir {
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
if (-not (Test-Path -LiteralPath (Join-Path $initRoot '.specify') -PathType Container)) {
[Console]::Error.WriteLine("ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): $initRoot")
+ if ($ReturnNullOnError) { return $null }
exit 1
}
return $initRoot
@@ -64,9 +69,11 @@ function Resolve-SpecifyInitDir {
# Get repository root, prioritizing .specify directory
# This prevents using a parent repository when spec-kit is initialized in a subdirectory
function Get-RepoRoot {
+ param([switch]$ReturnNullOnError)
+
# Explicit project override wins (see Resolve-SpecifyInitDir).
if ($env:SPECIFY_INIT_DIR) {
- return (Resolve-SpecifyInitDir)
+ return (Resolve-SpecifyInitDir -ReturnNullOnError:$ReturnNullOnError)
}
# First, look for .specify directory (spec-kit's own marker)
@@ -147,10 +154,12 @@ function Get-FeaturePathsEnv {
# so pure path resolution never writes .specify/feature.json, which would
# dirty the working tree or overwrite a pinned value (issue #3025).
param(
- [switch]$NoPersist
+ [switch]$NoPersist,
+ [switch]$ReturnNullOnError
)
- $repoRoot = Get-RepoRoot
+ $repoRoot = Get-RepoRoot -ReturnNullOnError:$ReturnNullOnError
+ if (-not $repoRoot) { return $null }
$currentBranch = Get-CurrentBranch
# Resolve feature directory. Priority:
@@ -174,7 +183,8 @@ function Get-FeaturePathsEnv {
try {
$featureConfig = $featureJsonRaw | ConvertFrom-Json
} catch {
- [Console]::Error.WriteLine("ERROR: Failed to parse .specify/feature.json: $_")
+ [Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
+ if ($ReturnNullOnError) { return $null }
exit 1
}
if ($featureConfig.feature_directory) {
@@ -185,10 +195,12 @@ function Get-FeaturePathsEnv {
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or ensure .specify/feature.json contains feature_directory.")
+ if ($ReturnNullOnError) { return $null }
exit 1
}
} else {
[Console]::Error.WriteLine("ERROR: Feature directory not found. Set SPECIFY_FEATURE_DIRECTORY or run the specify command to create .specify/feature.json.")
+ if ($ReturnNullOnError) { return $null }
exit 1
}
@@ -334,30 +346,64 @@ function Resolve-Template {
if (Test-Path $presetsDir) {
$registryFile = Join-Path $presetsDir '.registry'
$sortedPresets = @()
+ $registryParsed = $false
if (Test-Path $registryFile) {
try {
$registryData = Get-Content $registryFile -Raw | ConvertFrom-Json
- $presets = $registryData.presets
- if ($presets) {
- $sortedPresets = $presets.PSObject.Properties |
+ if ($null -eq $registryData -or $registryData -isnot [PSCustomObject]) {
+ throw 'Registry root must be an object'
+ }
+ $presetsProperty = $registryData.PSObject.Properties['presets']
+ if ($presetsProperty) {
+ $presets = $presetsProperty.Value
+ if ($null -eq $presets -or $presets -isnot [PSCustomObject]) {
+ throw 'Registry presets must be an object'
+ }
+ $presetEntries = @($presets.PSObject.Properties)
+ $priorityFor = {
+ param($Entry)
+ if ($Entry.Value -is [PSCustomObject]) {
+ $priorityProperty = $Entry.Value.PSObject.Properties['priority']
+ if ($priorityProperty) { return $priorityProperty.Value }
+ }
+ return 10
+ }
+ if ($presetEntries.Count -gt 1) {
+ $allNumeric = $true
+ $allStrings = $true
+ foreach ($entry in $presetEntries) {
+ $priority = & $priorityFor $entry
+ if ($null -eq $priority -or $priority -isnot [ValueType]) {
+ $allNumeric = $false
+ }
+ if ($null -eq $priority -or $priority -isnot [string]) {
+ $allStrings = $false
+ }
+ }
+ if (-not $allNumeric -and -not $allStrings) {
+ throw 'Registry priorities are not mutually orderable'
+ }
+ }
+ $sortedPresets = $presetEntries |
+ Where-Object { $_.Value -is [PSCustomObject] } |
Where-Object { $null -eq $_.Value.enabled -or $_.Value.enabled -ne $false } |
- Sort-Object { if ($null -ne $_.Value.priority) { $_.Value.priority } else { 10 } } |
+ Sort-Object { & $priorityFor $_ } |
ForEach-Object { $_.Name }
}
+ $registryParsed = $true
} catch {
- # Fallback: alphabetical directory order
- $sortedPresets = @()
+ $registryParsed = $false
}
}
- if ($sortedPresets.Count -gt 0) {
+ if ($registryParsed) {
foreach ($presetId in $sortedPresets) {
$candidate = Join-Path $presetsDir "$presetId/templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
} else {
# Fallback: alphabetical directory order
- foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' }) {
+ foreach ($preset in Get-ChildItem -Path $presetsDir -Directory -ErrorAction SilentlyContinue | Where-Object { $_.Name -notlike '.*' } | Sort-Object Name) {
$candidate = Join-Path $preset.FullName "templates/$TemplateName.md"
if (Test-Path $candidate) { return $candidate }
}
diff --git a/scripts/powershell/create-new-feature.ps1 b/scripts/powershell/create-new-feature.ps1
index 91b36bebdb..abe70f65ed 100644
--- a/scripts/powershell/create-new-feature.ps1
+++ b/scripts/powershell/create-new-feature.ps1
@@ -7,13 +7,14 @@ param(
[switch]$DryRun,
[string]$ShortName,
[Parameter()]
- [long]$Number = 0,
+ [string]$Number = '',
[switch]$Timestamp,
[switch]$Help,
[Parameter(Position = 0, ValueFromRemainingArguments = $true)]
[string[]]$FeatureDescription
)
$ErrorActionPreference = 'Stop'
+$maxBranchLength = 244
# Show help if requested
if ($Help) {
@@ -24,7 +25,7 @@ if ($Help) {
Write-Host " -DryRun Compute feature name and paths without creating directories or files"
Write-Host " -AllowExistingBranch Reuse an existing feature directory if it already exists"
Write-Host " -ShortName Provide a custom short name (2-4 words) for the feature"
- Write-Host " -Number N Specify branch number manually (overrides auto-detection)"
+ Write-Host " -Number N Prefer a feature number (auto-corrected if its specs prefix exists)"
Write-Host " -Timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering"
Write-Host " -Help Show this help message"
Write-Host ""
@@ -67,11 +68,44 @@ function Get-HighestNumberFromSpecs {
return $highest
}
+function Test-SpecPrefixInUse {
+ param(
+ [string]$SpecsDir,
+ [string]$FeatureNum
+ )
+
+ if (-not (Test-Path -LiteralPath $SpecsDir -PathType Container)) {
+ return $false
+ }
+
+ return $null -ne (Get-ChildItem -LiteralPath $SpecsDir -Directory -ErrorAction SilentlyContinue |
+ Where-Object { $_.Name -like "$FeatureNum-*" } |
+ Select-Object -First 1)
+}
+
function ConvertTo-CleanBranchName {
param([string]$Name)
return $Name.ToLower() -replace '[^a-z0-9]', '-' -replace '-{2,}', '-' -replace '^-', '' -replace '-$', ''
}
+
+function Get-FittedBranchName {
+ param(
+ [string]$FeatureNum,
+ [string]$BranchSuffix
+ )
+
+ $fittedName = "$FeatureNum-$BranchSuffix"
+ if ($fittedName.Length -gt $maxBranchLength) {
+ $prefixLength = $FeatureNum.Length + 1
+ $maxSuffixLength = $maxBranchLength - $prefixLength
+ $truncatedSuffix = $BranchSuffix.Substring(0, [Math]::Min($BranchSuffix.Length, $maxSuffixLength))
+ $truncatedSuffix = $truncatedSuffix -replace '-$', ''
+ $fittedName = "$FeatureNum-$truncatedSuffix"
+ }
+
+ return $fittedName
+}
# Load common functions (includes Get-RepoRoot and Resolve-Template)
. "$PSScriptRoot/common.ps1"
@@ -142,12 +176,13 @@ if ($ShortName) {
$branchSuffix = Get-BranchName -Description $featureDesc
}
-# Warn if -Number and -Timestamp are both specified. Use ContainsKey (not
-# `-ne 0`) so an explicit `-Number 0` is also detected, matching the bash twin's
-# `[ -n "$BRANCH_NUMBER" ]` check.
-if ($Timestamp -and $PSBoundParameters.ContainsKey('Number')) {
- Write-Warning "[specify] Warning: -Number is ignored when -Timestamp is used"
- $Number = 0
+# Treat an explicit empty string as omitted, matching the bash and Python twins.
+$hasNumber = $PSBoundParameters.ContainsKey('Number') -and $Number -ne ''
+
+# Warn if -Number and -Timestamp are both specified.
+if ($Timestamp -and $hasNumber) {
+ [Console]::Error.WriteLine("[specify] Warning: -Number is ignored when -Timestamp is used")
+ $Number = ''
}
# Determine branch prefix
@@ -158,34 +193,60 @@ if ($Timestamp) {
# Determine branch number from existing feature directories. Auto-detect only
# when -Number was not supplied; an explicit value (including 0) is honored,
# matching the bash twin's `[ -z "$BRANCH_NUMBER" ]` check.
- if (-not $PSBoundParameters.ContainsKey('Number')) {
- $Number = (Get-HighestNumberFromSpecs -SpecsDir $specsDir) + 1
+ [long]$resolvedNumber = 0
+ if (-not $hasNumber) {
+ $highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
+ if ($highestNumber -eq [long]::MaxValue) {
+ Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
+ exit 1
+ }
+ $resolvedNumber = $highestNumber + 1
+ } elseif ($Number -notmatch '^[0-9]+$') {
+ Write-Error "Error: -Number must be an unsigned integer, got '$Number'"
+ exit 1
+ } elseif (-not [long]::TryParse($Number, [ref]$resolvedNumber)) {
+ Write-Error "Error: -Number must be between 0 and $([long]::MaxValue), got '$Number'"
+ exit 1
+ }
+
+ $featureNum = ('{0:000}' -f $resolvedNumber)
+
+ # Treat an explicit number as a preference when its prefix is already used
+ # by a feature directory. Auto-detected numbers are already conflict-free.
+ $specConflict = $false
+ if ($hasNumber -and (Test-Path -LiteralPath $specsDir -PathType Container)) {
+ $requestedBranchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
+ $requestedDir = Join-Path $specsDir $requestedBranchName
+ if (-not $AllowExistingBranch -or -not (Test-Path -LiteralPath $requestedDir -PathType Container)) {
+ $specConflict = Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum
+ }
+ }
+
+ if ($specConflict) {
+ $requestedNum = $featureNum
+ $highestNumber = Get-HighestNumberFromSpecs -SpecsDir $specsDir
+ $resolvedNumber = $highestNumber
+ do {
+ if ($resolvedNumber -eq [long]::MaxValue) {
+ Write-Error "Error: feature number must be between 0 and $([long]::MaxValue), got '9223372036854775808'"
+ exit 1
+ }
+ $resolvedNumber++
+ $featureNum = ('{0:000}' -f $resolvedNumber)
+ } while (Test-SpecPrefixInUse -SpecsDir $specsDir -FeatureNum $featureNum)
+ [Console]::Error.WriteLine("[specify] Warning: -Number $requestedNum conflicts with an existing spec directory; using $featureNum instead")
}
- $featureNum = ('{0:000}' -f $Number)
- $branchName = "$featureNum-$branchSuffix"
}
# GitHub enforces a 244-byte limit on branch names
# Validate and truncate if necessary
-$maxBranchLength = 244
-if ($branchName.Length -gt $maxBranchLength) {
- # Calculate how much we need to trim from suffix
- # Account for prefix length: timestamp (15) + hyphen (1) = 16, or sequential (3) + hyphen (1) = 4
- $prefixLength = $featureNum.Length + 1
- $maxSuffixLength = $maxBranchLength - $prefixLength
-
- # Truncate suffix
- $truncatedSuffix = $branchSuffix.Substring(0, [Math]::Min($branchSuffix.Length, $maxSuffixLength))
- # Remove trailing hyphen if truncation created one
- $truncatedSuffix = $truncatedSuffix -replace '-$', ''
-
- $originalBranchName = $branchName
- $branchName = "$featureNum-$truncatedSuffix"
-
- Write-Warning "[specify] Branch name exceeded GitHub's 244-byte limit"
- Write-Warning "[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)"
- Write-Warning "[specify] Truncated to: $branchName ($($branchName.Length) bytes)"
+$originalBranchName = "$featureNum-$branchSuffix"
+$branchName = Get-FittedBranchName -FeatureNum $featureNum -BranchSuffix $branchSuffix
+if ($branchName -ne $originalBranchName) {
+ [Console]::Error.WriteLine("[specify] Warning: Branch name exceeded GitHub's 244-byte limit")
+ [Console]::Error.WriteLine("[specify] Original: $originalBranchName ($($originalBranchName.Length) bytes)")
+ [Console]::Error.WriteLine("[specify] Truncated to: $branchName ($($branchName.Length) bytes)")
}
$featureDir = Join-Path $specsDir $branchName
@@ -225,6 +286,13 @@ if (-not $DryRun) {
# Set environment variables for the current session
$env:SPECIFY_FEATURE = $branchName
$env:SPECIFY_FEATURE_DIRECTORY = $featureDir
+
+ $quotedBranchName = "'" + $branchName.Replace("'", "''") + "'"
+ $quotedFeatureDir = "'" + $featureDir.Replace("'", "''") + "'"
+ $featureAssignment = '$env:SPECIFY_FEATURE = ' + $quotedBranchName
+ $directoryAssignment = '$env:SPECIFY_FEATURE_DIRECTORY = ' + $quotedFeatureDir
+ [Console]::Error.WriteLine("# To persist: $featureAssignment")
+ [Console]::Error.WriteLine("# $directoryAssignment")
}
if ($Json) {
@@ -242,7 +310,7 @@ if ($Json) {
Write-Output "SPEC_FILE: $specFile"
Write-Output "FEATURE_NUM: $featureNum"
if (-not $DryRun) {
- Write-Output "SPECIFY_FEATURE set to: $branchName"
- Write-Output "SPECIFY_FEATURE_DIRECTORY set to: $featureDir"
+ Write-Output "# To persist in your shell: $featureAssignment"
+ Write-Output "# $directoryAssignment"
}
}
diff --git a/scripts/powershell/setup-plan.ps1 b/scripts/powershell/setup-plan.ps1
index 9e0403eba6..6ed0344dd9 100644
--- a/scripts/powershell/setup-plan.ps1
+++ b/scripts/powershell/setup-plan.ps1
@@ -4,7 +4,10 @@
[CmdletBinding()]
param(
[switch]$Json,
- [switch]$Help
+ [switch]$Help,
+ # Capture extra positional arguments to match Bash/Python behavior.
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$RemainingArgs
)
$ErrorActionPreference = 'Stop'
@@ -21,7 +24,11 @@ if ($Help) {
. "$PSScriptRoot/common.ps1"
# Get all paths and variables from common functions
-$paths = Get-FeaturePathsEnv
+$paths = Get-FeaturePathsEnv -ReturnNullOnError
+if (-not $paths) {
+ [Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
+ exit 1
+}
# Ensure the feature directory exists
New-Item -ItemType Directory -Path $paths.FEATURE_DIR -Force | Out-Null
diff --git a/scripts/powershell/setup-tasks.ps1 b/scripts/powershell/setup-tasks.ps1
index c7d85fc2a6..1d091360e7 100644
--- a/scripts/powershell/setup-tasks.ps1
+++ b/scripts/powershell/setup-tasks.ps1
@@ -3,21 +3,34 @@
[CmdletBinding()]
param(
[switch]$Json,
- [switch]$Help
+ [switch]$Help,
+ [Parameter(ValueFromRemainingArguments = $true)]
+ [string[]]$RemainingArgs
)
$ErrorActionPreference = 'Stop'
+# Help wins over unknown-argument validation to match the Bash/Python
+# variants, which stop at --help and exit 0.
if ($Help) {
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
exit 0
}
+if ($RemainingArgs.Count -gt 0) {
+ [Console]::Error.WriteLine("ERROR: Unknown option '$($RemainingArgs[0])'")
+ exit 1
+}
+
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths
-$paths = Get-FeaturePathsEnv
+$paths = Get-FeaturePathsEnv -ReturnNullOnError
+if (-not $paths) {
+ [Console]::Error.WriteLine("ERROR: Failed to resolve feature paths")
+ exit 1
+}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
@@ -45,8 +58,8 @@ if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Resolve tasks template through override stack
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
- $expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md'
- [Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.")
+ [Console]::Error.WriteLine("ERROR: Could not resolve required tasks-template from the template override stack for $($paths.REPO_ROOT)")
+ [Console]::Error.WriteLine("Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template.")
exit 1
}
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
diff --git a/scripts/python/common.py b/scripts/python/common.py
index 77c13eefbb..b39df712c5 100644
--- a/scripts/python/common.py
+++ b/scripts/python/common.py
@@ -84,7 +84,7 @@ def read_feature_json_feature_directory(repo_root: Path) -> str:
return ""
try:
data = json.loads(feature_json.read_text(encoding="utf-8"))
- except (OSError, json.JSONDecodeError):
+ except (OSError, UnicodeError, json.JSONDecodeError):
return ""
value = data.get("feature_directory") if isinstance(data, dict) else None
return value if isinstance(value, str) else ""
@@ -95,16 +95,17 @@ def _json_dump(data: dict[str, str]) -> str:
def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
+ # Strip the repo root prefix lexically (no resolve()) to mirror the
+ # Bash/PowerShell helpers: with a symlinked /specs, resolve() would
+ # escape the repo and persist a machine-specific absolute path instead of
+ # the relative "specs/NNN-name" the other variants store.
value = feature_dir_value
- try:
- relative = Path(value)
- if relative.is_absolute():
- try:
- value = relative.resolve().relative_to(repo_root.resolve()).as_posix()
- except ValueError:
- value = str(relative)
- except OSError:
- pass
+ relative = Path(value)
+ if relative.is_absolute():
+ try:
+ value = relative.relative_to(repo_root).as_posix()
+ except ValueError:
+ value = str(relative)
current = read_feature_json_feature_directory(repo_root)
if current == value:
@@ -112,9 +113,8 @@ def persist_feature_json(repo_root: Path, feature_dir_value: str) -> None:
specify_dir = repo_root / ".specify"
specify_dir.mkdir(parents=True, exist_ok=True)
- (specify_dir / "feature.json").write_text(
- _json_dump({"feature_directory": value}),
- encoding="utf-8",
+ (specify_dir / "feature.json").write_bytes(
+ _json_dump({"feature_directory": value}).encode("utf-8")
)
@@ -182,6 +182,78 @@ def get_feature_paths(
)
+def _sorted_preset_ids(presets_dir: Path) -> list[str]:
+ registry = presets_dir / ".registry"
+ if registry.is_file():
+ # Mirrors bash: any failure while reading or sorting the registry
+ # (invalid JSON, non-dict shapes, unorderable priority values) falls
+ # back to the directory scan below.
+ try:
+ data = json.loads(registry.read_text(encoding="utf-8"))
+ presets = data.get("presets", {})
+ return [
+ pid
+ for pid, meta in sorted(
+ presets.items(),
+ key=lambda kv: kv[1].get("priority", 10)
+ if isinstance(kv[1], dict)
+ else 10,
+ )
+ if isinstance(meta, dict) and meta.get("enabled", True) is not False
+ ]
+ except Exception:
+ pass
+ try:
+ return sorted(
+ p.name
+ for p in presets_dir.iterdir()
+ if p.is_dir() and not p.name.startswith(".")
+ )
+ except OSError:
+ return []
+
+
+def resolve_template(template_name: str, repo_root: Path) -> Path | None:
+ """Resolve a template name to a file path using the priority stack.
+
+ Order (mirrors resolve_template in scripts/bash/common.sh):
+ 1. .specify/templates/overrides/
+ 2. .specify/presets//templates/ (sorted by .registry priority)
+ 3. .specify/extensions//templates/ (hidden directories skipped)
+ 4. .specify/templates/ (core)
+ """
+ base = repo_root / ".specify" / "templates"
+
+ override = base / "overrides" / f"{template_name}.md"
+ if override.is_file():
+ return override
+
+ presets_dir = repo_root / ".specify" / "presets"
+ if presets_dir.is_dir():
+ for preset_id in _sorted_preset_ids(presets_dir):
+ candidate = presets_dir / preset_id / "templates" / f"{template_name}.md"
+ if candidate.is_file():
+ return candidate
+
+ ext_dir = repo_root / ".specify" / "extensions"
+ if ext_dir.is_dir():
+ try:
+ extensions = sorted(p for p in ext_dir.iterdir() if p.is_dir())
+ except OSError:
+ extensions = []
+ for ext in extensions:
+ if ext.name.startswith("."):
+ continue
+ candidate = ext / "templates" / f"{template_name}.md"
+ if candidate.is_file():
+ return candidate
+
+ core = base / f"{template_name}.md"
+ if core.is_file():
+ return core
+ return None
+
+
def get_invoke_separator(repo_root: Path) -> str:
integration_json = repo_root / ".specify" / "integration.json"
if not integration_json.is_file():
diff --git a/scripts/python/create_new_feature.py b/scripts/python/create_new_feature.py
new file mode 100644
index 0000000000..c46837d9d5
--- /dev/null
+++ b/scripts/python/create_new_feature.py
@@ -0,0 +1,422 @@
+#!/usr/bin/env python3
+"""Create a new feature directory and spec file."""
+
+from __future__ import annotations
+
+import datetime
+import json
+import re
+import shlex
+import shutil
+import sys
+from dataclasses import dataclass
+from pathlib import Path
+
+try:
+ from common import get_repo_root, persist_feature_json, resolve_template
+except ImportError: # pragma: no cover - direct execution from unusual cwd
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
+ from common import get_repo_root, persist_feature_json, resolve_template
+
+
+def _json_line(payload: object) -> str:
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
+
+
+_STOP_WORDS = frozenset(
+ """
+ i a an the to for of in on at by with from is are was were be been being
+ have has had do does did will would should could can may might must shall
+ this that these those my your our their want need add get set
+ """.split()
+)
+
+_MAX_BRANCH_LENGTH = 244
+_MAX_FEATURE_NUMBER = 2**63 - 1
+
+
+def _int64_from_digits(value: str) -> int | None:
+ normalized = value.lstrip("0") or "0"
+ maximum = str(_MAX_FEATURE_NUMBER)
+ if len(normalized) > len(maximum) or (
+ len(normalized) == len(maximum) and normalized > maximum
+ ):
+ return None
+ return int(normalized, 10)
+
+
+def _persistence_assignments(
+ branch_name: str, feature_dir: str, *, powershell: bool
+) -> tuple[str, str]:
+ if powershell:
+ quoted_branch = "'" + branch_name.replace("'", "''") + "'"
+ quoted_dir = "'" + feature_dir.replace("'", "''") + "'"
+ return (
+ f"$env:SPECIFY_FEATURE = {quoted_branch}",
+ f"$env:SPECIFY_FEATURE_DIRECTORY = {quoted_dir}",
+ )
+ return (
+ f"export SPECIFY_FEATURE={shlex.quote(branch_name)}",
+ f"export SPECIFY_FEATURE_DIRECTORY={shlex.quote(feature_dir)}",
+ )
+
+
+def _usage(argv0: str) -> str:
+ return (
+ f"Usage: {argv0} [--json] [--dry-run] [--allow-existing-branch] "
+ "[--short-name ] [--number N] [--timestamp] "
+ )
+
+
+def _help_text(argv0: str) -> str:
+ return f"""{_usage(argv0)}
+
+Options:
+ --json Output in JSON format
+ --dry-run Compute feature name and paths without creating directories or files
+ --allow-existing-branch Reuse an existing feature directory if it already exists
+ --short-name Provide a custom short name (2-4 words) for the feature
+ --number N Prefer a feature number (auto-corrected if its specs prefix exists)
+ --timestamp Use timestamp prefix (YYYYMMDD-HHMMSS) instead of sequential numbering
+ --help, -h Show this help message
+
+Examples:
+ {argv0} 'Add user authentication system' --short-name 'user-auth'
+ {argv0} 'Implement OAuth2 integration for API' --number 5
+ {argv0} --timestamp --short-name 'user-auth' 'Add user authentication'
+"""
+
+
+@dataclass(frozen=True)
+class Args:
+ json_mode: bool = False
+ dry_run: bool = False
+ allow_existing: bool = False
+ short_name: str = ""
+ branch_number: str = ""
+ use_timestamp: bool = False
+ description: str = ""
+
+
+def _parse_args(argv: list[str], argv0: str) -> Args:
+ json_mode = False
+ dry_run = False
+ allow_existing = False
+ short_name = ""
+ branch_number = ""
+ use_timestamp = False
+ rest: list[str] = []
+
+ i = 0
+ while i < len(argv):
+ arg = argv[i]
+ if arg == "--json":
+ json_mode = True
+ elif arg == "--dry-run":
+ dry_run = True
+ elif arg == "--allow-existing-branch":
+ allow_existing = True
+ elif arg in {"--short-name", "--number"}:
+ if i + 1 >= len(argv) or argv[i + 1].startswith("--"):
+ print(f"Error: {arg} requires a value", file=sys.stderr)
+ raise SystemExit(1)
+ i += 1
+ if arg == "--short-name":
+ short_name = argv[i]
+ else:
+ branch_number = argv[i]
+ elif arg == "--timestamp":
+ use_timestamp = True
+ elif arg in {"--help", "-h"}:
+ sys.stdout.write(_help_text(argv0))
+ raise SystemExit(0)
+ else:
+ rest.append(arg)
+ i += 1
+
+ description = " ".join(rest).strip()
+ if not description:
+ if rest:
+ print(
+ "Error: Feature description cannot be empty or contain only whitespace",
+ file=sys.stderr,
+ )
+ else:
+ print(_usage(argv0), file=sys.stderr)
+ raise SystemExit(1)
+
+ return Args(
+ json_mode=json_mode,
+ dry_run=dry_run,
+ allow_existing=allow_existing,
+ short_name=short_name,
+ branch_number=branch_number,
+ use_timestamp=use_timestamp,
+ description=description,
+ )
+
+
+def _clean_branch_name(name: str) -> str:
+ cleaned = re.sub(r"[^a-z0-9]", "-", name.lower())
+ cleaned = re.sub(r"-+", "-", cleaned)
+ return cleaned.strip("-")
+
+
+def _generate_branch_name(description: str) -> str:
+ clean = re.sub(r"[^a-z0-9]", " ", description.lower())
+ meaningful: list[str] = []
+ for word in clean.split():
+ if word in _STOP_WORDS:
+ continue
+ if len(word) >= 3:
+ meaningful.append(word)
+ # Keep short words that appear as an uppercase acronym in the original,
+ # mirroring the bash twin's case-sensitive `grep -qw` check.
+ elif re.search(
+ rf"(? int:
+ highest = 0
+ if not specs_dir.is_dir():
+ return highest
+ for entry in specs_dir.iterdir():
+ if not entry.is_dir():
+ continue
+ name = entry.name
+ # Match sequential prefixes (>=3 digits), but skip timestamp dirs.
+ if re.match(r"^[0-9]{3,}-", name) and not re.match(
+ r"^[0-9]{8}-[0-9]{6}-", name
+ ):
+ number = _int64_from_digits(re.match(r"^[0-9]+", name).group())
+ if number is not None:
+ highest = max(highest, number)
+ return highest
+
+
+def _fit_branch_name(feature_num: str, branch_suffix: str) -> str:
+ """Fit a feature prefix and suffix within GitHub's branch-name limit."""
+ branch_name = f"{feature_num}-{branch_suffix}"
+ if len(branch_name) <= _MAX_BRANCH_LENGTH:
+ return branch_name
+
+ max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
+ truncated_suffix = re.sub(r"-$", "", branch_suffix[:max_suffix_length])
+ return f"{feature_num}-{truncated_suffix}"
+
+
+def _spec_prefix_exists(specs_dir: Path, feature_num: str) -> bool:
+ """Return whether a spec directory owns the given numeric prefix."""
+ try:
+ return any(
+ entry.is_dir() and entry.name.startswith(f"{feature_num}-")
+ for entry in specs_dir.iterdir()
+ )
+ except OSError:
+ # Match Bash globbing and PowerShell's ErrorAction=SilentlyContinue.
+ return False
+
+
+def _has_spec_prefix_conflict(
+ specs_dir: Path,
+ feature_num: str,
+ requested_dir: Path,
+ *,
+ allow_existing: bool,
+) -> bool:
+ """Return whether another spec directory owns the requested prefix."""
+ if allow_existing and requested_dir.is_dir():
+ return False
+
+ return _spec_prefix_exists(specs_dir, feature_num)
+
+
+def main(argv: list[str] | None = None) -> int:
+ argv0 = sys.argv[0]
+ args = _parse_args(list(argv if argv is not None else sys.argv[1:]), argv0)
+
+ repo_root = get_repo_root(Path(__file__))
+ specs_dir = repo_root / "specs"
+ if not args.dry_run:
+ specs_dir.mkdir(parents=True, exist_ok=True)
+
+ if args.short_name:
+ branch_suffix = _clean_branch_name(args.short_name)
+ else:
+ branch_suffix = _generate_branch_name(args.description)
+
+ branch_number = args.branch_number
+ if args.use_timestamp and branch_number:
+ print(
+ "[specify] Warning: --number is ignored when --timestamp is used",
+ file=sys.stderr,
+ )
+ branch_number = ""
+
+ if args.use_timestamp:
+ feature_num = datetime.datetime.now().strftime("%Y%m%d-%H%M%S")
+ else:
+ if branch_number:
+ # Mirrors bash: $((10#$BRANCH_NUMBER)) only accepts unsigned
+ # decimal digits, rejecting signs, whitespace, and other
+ # characters that int() would otherwise tolerate.
+ if not re.fullmatch(r"[0-9]+", branch_number):
+ print(
+ "Error: --number must be an unsigned integer, "
+ f"got '{branch_number}'",
+ file=sys.stderr,
+ )
+ return 1
+ number = _int64_from_digits(branch_number)
+ if number is None:
+ print(
+ "Error: --number must be between 0 and "
+ f"{_MAX_FEATURE_NUMBER}, got '{branch_number}'",
+ file=sys.stderr,
+ )
+ return 1
+ else:
+ number = _get_highest_from_specs(specs_dir) + 1
+ if number > _MAX_FEATURE_NUMBER:
+ rejected_number = branch_number or str(number)
+ number_label = "--number" if branch_number else "feature number"
+ print(
+ f"Error: {number_label} must be between 0 and "
+ f"{_MAX_FEATURE_NUMBER}, got '{rejected_number}'",
+ file=sys.stderr,
+ )
+ return 1
+ feature_num = f"{number:03d}"
+
+ # Treat an explicit number as a preference when its prefix is already used
+ # by a feature directory. Auto-detected numbers are already conflict-free.
+ if branch_number:
+ requested_branch_name = _fit_branch_name(feature_num, branch_suffix)
+ requested_dir = specs_dir / requested_branch_name
+ spec_conflict = _has_spec_prefix_conflict(
+ specs_dir,
+ feature_num,
+ requested_dir,
+ allow_existing=args.allow_existing,
+ )
+ if spec_conflict:
+ requested_num = feature_num
+ number = _get_highest_from_specs(specs_dir)
+ while True:
+ number += 1
+ if number > _MAX_FEATURE_NUMBER:
+ print(
+ f"Error: feature number must be between 0 and "
+ f"{_MAX_FEATURE_NUMBER}, got '{number}'",
+ file=sys.stderr,
+ )
+ return 1
+ feature_num = f"{number:03d}"
+ if not _spec_prefix_exists(specs_dir, feature_num):
+ break
+ print(
+ f"[specify] Warning: --number {requested_num} conflicts with "
+ f"an existing spec directory; using {feature_num} instead",
+ file=sys.stderr,
+ )
+
+ max_suffix_length = _MAX_BRANCH_LENGTH - (len(feature_num) + 1)
+ if max_suffix_length <= 0:
+ print("Error: feature number is too long for a branch name", file=sys.stderr)
+ return 1
+
+ original_branch_name = f"{feature_num}-{branch_suffix}"
+ branch_name = _fit_branch_name(feature_num, branch_suffix)
+
+ # GitHub enforces a 244-byte limit on branch names.
+ if branch_name != original_branch_name:
+ print(
+ "[specify] Warning: Branch name exceeded GitHub's 244-byte limit",
+ file=sys.stderr,
+ )
+ print(
+ f"[specify] Original: {original_branch_name} "
+ f"({len(original_branch_name)} bytes)",
+ file=sys.stderr,
+ )
+ print(
+ f"[specify] Truncated to: {branch_name} ({len(branch_name)} bytes)",
+ file=sys.stderr,
+ )
+
+ feature_dir = specs_dir / branch_name
+ spec_file = feature_dir / "spec.md"
+
+ if not args.dry_run:
+ if feature_dir.is_dir() and not args.allow_existing:
+ if args.use_timestamp:
+ print(
+ f"Error: Feature directory '{feature_dir}' already exists. "
+ "Rerun to get a new timestamp or use a different --short-name.",
+ file=sys.stderr,
+ )
+ else:
+ print(
+ f"Error: Feature directory '{feature_dir}' already exists. "
+ "Please use a different feature name or specify a different "
+ "number with --number.",
+ file=sys.stderr,
+ )
+ return 1
+
+ feature_dir.mkdir(parents=True, exist_ok=True)
+
+ if not spec_file.is_file():
+ template = resolve_template("spec-template", repo_root)
+ if template is not None and template.is_file():
+ shutil.copy(template, spec_file)
+ else:
+ print(
+ "Warning: Spec template not found; created empty spec file",
+ file=sys.stderr,
+ )
+ spec_file.touch()
+
+ # Persist to .specify/feature.json so downstream commands can find the feature.
+ persist_feature_json(repo_root, f"specs/{branch_name}")
+
+ # Inform the user how to set feature state in their own shell.
+ feature_assignment, directory_assignment = _persistence_assignments(
+ branch_name,
+ str(feature_dir),
+ powershell=sys.platform == "win32",
+ )
+ print(f"# To persist: {feature_assignment}", file=sys.stderr)
+ print(f"# {directory_assignment}", file=sys.stderr)
+
+ if args.json_mode:
+ payload: dict[str, object] = {
+ "BRANCH_NAME": branch_name,
+ "SPEC_FILE": str(spec_file),
+ "FEATURE_NUM": feature_num,
+ }
+ if args.dry_run:
+ payload["DRY_RUN"] = True
+ sys.stdout.write(_json_line(payload))
+ else:
+ print(f"BRANCH_NAME: {branch_name}")
+ print(f"SPEC_FILE: {spec_file}")
+ print(f"FEATURE_NUM: {feature_num}")
+ if not args.dry_run:
+ print(f"# To persist in your shell: {feature_assignment}")
+ print(f"# {directory_assignment}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/python/setup_plan.py b/scripts/python/setup_plan.py
new file mode 100644
index 0000000000..7b8e77ce5a
--- /dev/null
+++ b/scripts/python/setup_plan.py
@@ -0,0 +1,86 @@
+#!/usr/bin/env python3
+"""Setup implementation plan for a feature."""
+
+from __future__ import annotations
+
+import json
+import shutil
+import sys
+from pathlib import Path
+
+try:
+ from common import get_feature_paths, resolve_template
+except ImportError: # pragma: no cover - direct execution from unusual cwd
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
+ from common import get_feature_paths, resolve_template
+
+
+def _json_line(payload: object) -> str:
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
+
+
+def _help_text(argv0: str) -> str:
+ return f"""Usage: {argv0} [--json]
+ --json Output results in JSON format
+ --help Show this help message
+"""
+
+
+def main(argv: list[str] | None = None) -> int:
+ args = list(argv if argv is not None else sys.argv[1:])
+ json_mode = False
+ for arg in args:
+ if arg == "--json":
+ json_mode = True
+ elif arg in {"--help", "-h"}:
+ sys.stdout.write(_help_text(sys.argv[0]))
+ return 0
+ # Other arguments are accepted and silently ignored, matching setup-plan.sh.
+
+ try:
+ paths = get_feature_paths(script_file=Path(__file__))
+ except SystemExit as exc:
+ if exc.code == 0:
+ return 0
+ print("ERROR: Failed to resolve feature paths", file=sys.stderr)
+ return int(exc.code) if isinstance(exc.code, int) else 1
+
+ paths.feature_dir.mkdir(parents=True, exist_ok=True)
+
+ # Status messages go to stderr in JSON mode so stdout stays pure JSON.
+ status_stream = sys.stderr if json_mode else sys.stdout
+ if paths.impl_plan.is_file():
+ print(
+ f"Plan already exists at {paths.impl_plan}, skipping template copy",
+ file=status_stream,
+ )
+ else:
+ template = resolve_template("plan-template", paths.repo_root)
+ if template is not None and template.is_file():
+ shutil.copy(template, paths.impl_plan)
+ print(f"Copied plan template to {paths.impl_plan}", file=status_stream)
+ else:
+ print("Warning: Plan template not found", file=status_stream)
+ paths.impl_plan.touch()
+
+ if json_mode:
+ sys.stdout.write(
+ _json_line(
+ {
+ "FEATURE_SPEC": str(paths.feature_spec),
+ "IMPL_PLAN": str(paths.impl_plan),
+ "SPECS_DIR": str(paths.feature_dir),
+ "BRANCH": paths.current_branch,
+ }
+ )
+ )
+ else:
+ print(f"FEATURE_SPEC: {paths.feature_spec}")
+ print(f"IMPL_PLAN: {paths.impl_plan}")
+ print(f"SPECS_DIR: {paths.feature_dir}")
+ print(f"BRANCH: {paths.current_branch}")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/scripts/python/setup_tasks.py b/scripts/python/setup_tasks.py
new file mode 100644
index 0000000000..b3abb6dc1a
--- /dev/null
+++ b/scripts/python/setup_tasks.py
@@ -0,0 +1,145 @@
+#!/usr/bin/env python3
+"""Check tasks prerequisites and resolve the tasks template."""
+
+from __future__ import annotations
+
+import json
+import sys
+from pathlib import Path
+
+try:
+ from common import (
+ FeaturePaths,
+ format_speckit_command,
+ get_feature_paths,
+ resolve_template,
+ )
+except ImportError: # pragma: no cover - direct execution from unusual cwd
+ sys.path.insert(0, str(Path(__file__).resolve().parent))
+ from common import (
+ FeaturePaths,
+ format_speckit_command,
+ get_feature_paths,
+ resolve_template,
+ )
+
+
+def _json_line(payload: object) -> str:
+ return json.dumps(payload, ensure_ascii=False, separators=(",", ":")) + "\n"
+
+
+def _help_text(argv0: str) -> str:
+ return f"""Usage: {argv0} [--json]
+ --json Output results in JSON format
+ --help Show this help message
+"""
+
+
+def _dir_has_entries(path: Path) -> bool:
+ try:
+ return path.is_dir() and any(path.iterdir())
+ except OSError:
+ return False
+
+
+def _available_docs(paths: FeaturePaths) -> list[str]:
+ docs: list[str] = []
+ if paths.research.is_file():
+ docs.append("research.md")
+ if paths.data_model.is_file():
+ docs.append("data-model.md")
+ if _dir_has_entries(paths.contracts_dir):
+ docs.append("contracts/")
+ if paths.quickstart.is_file():
+ docs.append("quickstart.md")
+ return docs
+
+
+def _check_file(path: Path, description: str) -> None:
+ marker = "ā" if path.is_file() else "ā"
+ print(f" {marker} {description}")
+
+
+def _check_dir(path: Path, description: str) -> None:
+ marker = "ā" if _dir_has_entries(path) else "ā"
+ print(f" {marker} {description}")
+
+
+def main(argv: list[str] | None = None) -> int:
+ json_mode = False
+ for arg in list(argv if argv is not None else sys.argv[1:]):
+ if arg == "--json":
+ json_mode = True
+ elif arg in {"--help", "-h"}:
+ sys.stdout.write(_help_text(sys.argv[0]))
+ return 0
+ else:
+ print(f"ERROR: Unknown option '{arg}'", file=sys.stderr)
+ return 1
+
+ try:
+ paths = get_feature_paths(script_file=Path(__file__))
+ except SystemExit as exc:
+ if exc.code == 0:
+ return 0
+ print("ERROR: Failed to resolve feature paths", file=sys.stderr)
+ return int(exc.code) if isinstance(exc.code, int) else 1
+
+ if not paths.impl_plan.is_file():
+ print(f"ERROR: plan.md not found in {paths.feature_dir}", file=sys.stderr)
+ print(
+ f"Run {format_speckit_command('plan', paths.repo_root)} first to create the implementation plan.",
+ file=sys.stderr,
+ )
+ return 1
+
+ if not paths.feature_spec.is_file():
+ print(f"ERROR: spec.md not found in {paths.feature_dir}", file=sys.stderr)
+ print(
+ f"Run {format_speckit_command('specify', paths.repo_root)} first to create the feature structure.",
+ file=sys.stderr,
+ )
+ return 1
+
+ docs = _available_docs(paths)
+
+ tasks_template = resolve_template("tasks-template", paths.repo_root)
+ if tasks_template is None or not tasks_template.is_file():
+ print(
+ "ERROR: Could not resolve required tasks-template from the template "
+ f"override stack for {paths.repo_root}",
+ file=sys.stderr,
+ )
+ print(
+ "Template 'tasks-template' was not found in any supported location "
+ "(overrides, presets, extensions, or shared core). Add an override at "
+ ".specify/templates/overrides/tasks-template.md, or run 'specify init' "
+ "/ reinstall shared infra to restore the core "
+ ".specify/templates/tasks-template.md template.",
+ file=sys.stderr,
+ )
+ return 1
+
+ if json_mode:
+ sys.stdout.write(
+ _json_line(
+ {
+ "FEATURE_DIR": str(paths.feature_dir),
+ "AVAILABLE_DOCS": docs,
+ "TASKS_TEMPLATE": str(tasks_template),
+ }
+ )
+ )
+ else:
+ print(f"FEATURE_DIR: {paths.feature_dir}")
+ print(f"TASKS_TEMPLATE: {tasks_template}")
+ print("AVAILABLE_DOCS:")
+ _check_file(paths.research, "research.md")
+ _check_file(paths.data_model, "data-model.md")
+ _check_dir(paths.contracts_dir, "contracts/")
+ _check_file(paths.quickstart, "quickstart.md")
+ return 0
+
+
+if __name__ == "__main__":
+ raise SystemExit(main())
diff --git a/src/specify_cli/__init__.py b/src/specify_cli/__init__.py
index 110234a03e..33bb8f5c26 100644
--- a/src/specify_cli/__init__.py
+++ b/src/specify_cli/__init__.py
@@ -114,6 +114,7 @@ def _refresh_shared_templates(
project_path: Path,
*,
invoke_separator: str,
+ invoke_prefix: str = "/",
force: bool = False,
) -> None:
"""Refresh default-sensitive shared templates without touching scripts."""
@@ -124,6 +125,7 @@ def _refresh_shared_templates(
repo_root=_repo_root(),
console=console,
invoke_separator=invoke_separator,
+ invoke_prefix=invoke_prefix,
force=force,
)
@@ -134,16 +136,16 @@ def _install_shared_infra(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
+ invoke_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
"""Install shared infrastructure files into *project_path*.
Copies ``.specify/scripts//`` and ``.specify/templates/`` from
- the bundled core_pack or source checkout, where ```` is
- ``bash`` when *script_type* is ``"sh"``, ``python`` when it is ``"py"``,
- and ``powershell`` when it is ``"ps"``. Tracks all installed files in
- ``speckit.manifest.json``.
+ the bundled core_pack or source checkout. ``sh`` installs Bash, ``ps``
+ installs PowerShell, and ``py`` installs Python plus the platform shell
+ fallback. Tracks all installed files in ``speckit.manifest.json``.
Shared scripts and page templates are processed to resolve
``__SPECKIT_COMMAND___`` placeholders using *invoke_separator*
@@ -178,6 +180,7 @@ def _install_shared_infra(
console=console,
force=force,
invoke_separator=invoke_separator,
+ invoke_prefix=invoke_prefix,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)
@@ -189,6 +192,7 @@ def _install_shared_infra_or_exit(
tracker: StepTracker | None = None,
force: bool = False,
invoke_separator: str = ".",
+ invoke_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
@@ -199,6 +203,7 @@ def _install_shared_infra_or_exit(
tracker=tracker,
force=force,
invoke_separator=invoke_separator,
+ invoke_prefix=invoke_prefix,
refresh_managed=refresh_managed,
refresh_hint=refresh_hint,
)
@@ -509,6 +514,11 @@ def version(
from .integrations._commands import register as _register_integration_cmds # noqa: E402
_register_integration_cmds(app)
+
+# ===== Event Commands =====
+from .commands.event import register as _register_event_cmds # noqa: E402
+_register_event_cmds(app)
+
# Re-export selected helpers to preserve the public import surface.
from .integrations._helpers import ( # noqa: E402
_clear_init_options_for_integration as _clear_init_options_for_integration,
diff --git a/src/specify_cli/_download_security.py b/src/specify_cli/_download_security.py
new file mode 100644
index 0000000000..131e68087a
--- /dev/null
+++ b/src/specify_cli/_download_security.py
@@ -0,0 +1,896 @@
+"""Helpers for bounded downloads and archive extraction."""
+
+from __future__ import annotations
+
+import io
+import re
+import socket
+import stat
+import struct
+import unicodedata
+import zipfile
+from collections.abc import Iterator
+from contextlib import ExitStack, contextmanager
+from ipaddress import IPv4Address, IPv6Address, ip_address
+from itertools import pairwise
+from pathlib import Path, PurePosixPath, PureWindowsPath
+from typing import NoReturn, TypeVar
+from urllib.parse import ParseResult, urlparse
+
+
+ErrorT = TypeVar("ErrorT", bound=Exception)
+
+MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
+MAX_ZIP_ENTRIES = 512
+MAX_ZIP_MEMBER_BYTES = 10 * 1024 * 1024
+MAX_ZIP_TOTAL_BYTES = 50 * 1024 * 1024
+MAX_ZIP_PATH_BYTES = 4096
+MAX_ZIP_COMPONENT_BYTES = 255
+# ``ZipFile`` reads this whole structure into memory. Four MiB leaves roughly
+# 8 KiB of filename/extra/comment metadata for each of the 512 allowed entries.
+MAX_ZIP_CENTRAL_DIRECTORY_BYTES = 4 * 1024 * 1024
+READ_CHUNK_SIZE = 64 * 1024
+
+# Tighter ceilings for responses that are read fully into memory and parsed as
+# JSON. The 50 MiB MAX_DOWNLOAD_BYTES default is sized for archive/payload
+# downloads; JSON responses are far smaller, so capping them close to their real
+# size shrinks the memory-DoS surface and keeps the "too large" error reachable
+# (rather than only triggering on tens of MiB). Pass the matching constant
+# explicitly at each JSON call site so the intended bound is pinned there.
+# * METADATA - fixed-shape single-object responses (an OAuth token, one
+# release's metadata): a few KiB in practice, 1 MiB is already generous.
+# * CATALOG - listings that grow with the number of published items. The
+# largest bundled catalog is ~130 KiB today, so 8 MiB leaves ~60x headroom
+# for growth while staying well under the download ceiling.
+MAX_JSON_METADATA_BYTES = 1 * 1024 * 1024
+MAX_JSON_CATALOG_BYTES = 8 * 1024 * 1024
+
+_WINDOWS_INVALID_FILENAME_CHARS = frozenset('<>:"|?*')
+_WINDOWS_RESERVED_FILENAME = re.compile(
+ r"^(?:con|prn|aux|nul|conin\$|conout\$|"
+ r"com[1-9\u00b9\u00b2\u00b3]|lpt[1-9\u00b9\u00b2\u00b3])$",
+ re.IGNORECASE,
+)
+_ZIP_EOCD = struct.Struct("<4s4H2LH")
+_ZIP_EOCD_SIGNATURE = b"PK\x05\x06"
+_ZIP64_LOCATOR_SIGNATURE = b"PK\x06\x07"
+_ZIP_CENTRAL_HEADER_SIZE = 46
+_ZIP_CENTRAL_SIGNATURE = b"PK\x01\x02"
+_ZIP_LOCAL_HEADER_SIZE = 30
+_ZIP_LOCAL_SIGNATURE = b"PK\x03\x04"
+_ZIP_EXTRA_HEADER = struct.Struct(" IPv4Address | IPv6Address | None:
+ """Parse a canonical IP literal, validating an optional IPv6 zone ID."""
+ if "%" in hostname:
+ # Accept only the RFC 6874 ``%25`` spelling. Other escapes can
+ # alter the IPv6 address when urllib unquotes the authority.
+ address_text, separator, zone = hostname.partition("%25")
+ if (
+ not separator
+ or ":" not in address_text
+ or "%" in address_text
+ or "%" in zone
+ ):
+ return None
+ if not zone or any(
+ not (character.isascii() and (character.isalnum() or character in "._~-"))
+ for character in zone
+ ):
+ return None
+ else:
+ address_text = hostname
+ try:
+ address = ip_address(address_text)
+ except ValueError:
+ return None
+ if "%" in hostname and not isinstance(address, IPv6Address):
+ return None
+ return address
+
+
+def _is_ip_loopback(address: IPv4Address | IPv6Address | None) -> bool:
+ if address is None:
+ return False
+ mapped = getattr(address, "ipv4_mapped", None)
+ return address.is_loopback or bool(mapped and mapped.is_loopback)
+
+
+def _is_ip_local_redirect_target(
+ address: IPv4Address | IPv6Address | None,
+) -> bool:
+ """Treat loopback and unspecified listener aliases as local targets."""
+ if address is None:
+ return False
+ mapped = getattr(address, "ipv4_mapped", None)
+ return _is_ip_loopback(address) or address.is_unspecified or bool(
+ mapped and mapped.is_unspecified
+ )
+
+
+def _parse_url(url: str) -> ParseResult | None:
+ """Parse *url*, rejecting missing hosts and malformed ports."""
+ try:
+ parsed = urlparse(url)
+ hostname = parsed.hostname
+ # Accessing ``port`` performs urllib's range and syntax validation.
+ parsed.port
+ except (TypeError, ValueError):
+ return None
+ if not hostname:
+ return None
+
+ if "%" in hostname:
+ # urllib unquotes reg-name/IPv4 authorities before connecting. Reject
+ # them so encoded dots, characters, ports, or brackets cannot make the
+ # validated hostname differ from the effective target. The only safe
+ # percent form retained is a validated bracketed IPv6 zone ID.
+ if _ip_address_without_scope(hostname) is None:
+ return None
+ elif ":" not in hostname:
+ try:
+ hostname.encode("idna")
+ except UnicodeError:
+ return None
+ return parsed
+
+
+def _is_definite_loopback_host(hostname: str) -> bool:
+ """Recognize only unambiguous hosts that may safely authorize HTTP."""
+ if not hostname.isascii():
+ return False
+ if hostname == "localhost":
+ return True
+ return _is_ip_loopback(_ip_address_without_scope(hostname))
+
+
+def _is_potential_local_target_host(hostname: str) -> bool:
+ """Conservatively classify aliases that could reach a local listener."""
+ if ":" in hostname:
+ return _is_ip_local_redirect_target(_ip_address_without_scope(hostname))
+ try:
+ host = hostname.encode("idna").decode("ascii").lower().removesuffix(".")
+ except UnicodeError:
+ return False
+ if host == "localhost" or host.endswith(".localhost"):
+ return True
+
+ address = _ip_address_without_scope(host)
+ if address is None:
+ # Historical IPv4 spellings are resolver-dependent. They are never
+ # trusted to authorize HTTP, but treating them as potentially local
+ # prevents them from bypassing a remote-to-loopback redirect check.
+ try:
+ address = ip_address(socket.inet_aton(host))
+ except OSError:
+ return False
+ return _is_ip_local_redirect_target(address)
+
+
+def is_loopback_url(url: str) -> bool:
+ """Return whether *url* has an unambiguous loopback host."""
+ parsed = _parse_url(url)
+ return parsed is not None and _is_definite_loopback_host(parsed.hostname)
+
+
+def _is_potential_local_target_url(url: str) -> bool:
+ parsed = _parse_url(url)
+ return parsed is not None and _is_potential_local_target_host(parsed.hostname)
+
+
+def is_https_or_localhost_http(url: str) -> bool:
+ """Return True if *url* is HTTPS, or HTTP limited to loopback hosts.
+
+ Shared scheme-safety predicate used by the auth HTTP redirect handler and
+ direct URL validations in CLI download flows.
+
+ A hostname is always required: a URL without one (e.g. ``https:///x``)
+ has no real target and is rejected regardless of scheme.
+
+ The HTTP exception is deliberately limited to unambiguous ``localhost``
+ and canonical IPv4/IPv6 loopback literals. Ambiguous numeric, Unicode, and
+ unspecified-address aliases are classified defensively for redirects but
+ never authorize HTTP. No DNS lookup is performed; DNS and hosts-file
+ aliases require connection-level rebinding protection outside this helper.
+ """
+ parsed = _parse_url(url)
+ if parsed is None:
+ return False
+ return parsed.scheme == "https" or (
+ parsed.scheme == "http" and _is_definite_loopback_host(parsed.hostname)
+ )
+
+
+def is_safe_download_redirect(old_url: str, new_url: str) -> bool:
+ """Return whether a redirect preserves the shared download URL policy."""
+ if not is_https_or_localhost_http(new_url):
+ return False
+ return not _is_potential_local_target_url(new_url) or is_loopback_url(old_url)
+
+
+def _raise(error_type: type[ErrorT], message: str) -> NoReturn:
+ raise error_type(message)
+
+
+def _raise_from(error_type: type[ErrorT], message: str, exc: Exception) -> NoReturn:
+ raise error_type(message) from exc
+
+
+class _ReadLimitExceeded(Exception):
+ """Internal signal used to keep domain-specific errors at call sites."""
+
+
+def _validate_non_negative_int(value: int, name: str) -> None:
+ if isinstance(value, bool) or not isinstance(value, int):
+ raise TypeError(f"{name} must be an integer")
+ if value < 0:
+ raise ValueError(f"{name} must be non-negative")
+
+
+def _validate_max_bytes(max_bytes: int) -> None:
+ _validate_non_negative_int(max_bytes, "max_bytes")
+
+
+def _read_limited(response, max_bytes: int) -> bytes:
+ """Read a stream with bounded requests and without retaining fragments."""
+ output = io.BytesIO()
+ total = 0
+ limit = max_bytes + 1
+ while total < limit:
+ chunk = response.read(min(READ_CHUNK_SIZE, limit - total))
+ if not chunk:
+ break
+ total += len(chunk)
+ if total > max_bytes:
+ raise _ReadLimitExceeded
+ output.write(chunk)
+ return output.getvalue()
+
+
+def read_response_limited(
+ response,
+ *,
+ max_bytes: int = MAX_DOWNLOAD_BYTES,
+ error_type: type[ErrorT] = ValueError,
+ label: str = "download",
+) -> bytes:
+ """Read at most *max_bytes* from a response object.
+
+ ``response.read(n)`` is only guaranteed to return *up to* ``n`` bytes and may
+ return fewer even when more data is pending (e.g. chunked transfer encoding),
+ so a single ``read(max_bytes + 1)`` cannot enforce the bound on its own. Read
+ in a loop until EOF or until one byte past the limit has been accumulated.
+
+ *max_bytes* is keyword-only. It defaults to the module-wide
+ ``MAX_DOWNLOAD_BYTES`` (50 MiB) ceiling for archive/payload downloads;
+ callers with a tighter budget (e.g. small JSON responses) should pass an
+ explicit value so the intended bound is pinned at the call site rather than
+ tracking changes to the shared default.
+ """
+ _validate_max_bytes(max_bytes)
+ try:
+ return _read_limited(response, max_bytes)
+ except _ReadLimitExceeded:
+ _raise(error_type, f"{label!r} exceeds maximum size of {max_bytes} bytes")
+
+
+def build_safe_download_path(
+ target_dir: Path,
+ identifier: object,
+ version: object,
+ *,
+ error_type: type[ErrorT] = ValueError,
+ label: str = "archive",
+) -> Path:
+ """Build a portable single-component archive path inside *target_dir*."""
+ if not isinstance(identifier, str) or not isinstance(version, str):
+ _raise(
+ error_type,
+ f"Unsafe {label} download filename derived from "
+ f"{identifier!r} and {version!r}",
+ )
+
+ filename = f"{identifier}-{version}.zip"
+ try:
+ filename_too_long = (
+ len(filename.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
+ )
+ except UnicodeEncodeError:
+ filename_too_long = True
+ posix_path = PurePosixPath(filename)
+ windows_path = PureWindowsPath(filename)
+ if (
+ filename_too_long
+ or posix_path.name != filename
+ or windows_path.name != filename
+ or any(unicodedata.category(character) == "Cc" for character in filename)
+ or any(
+ character in _WINDOWS_INVALID_FILENAME_CHARS
+ for character in filename
+ )
+ or filename.endswith((" ", "."))
+ ):
+ _raise(
+ error_type,
+ f"Unsafe {label} download filename derived from "
+ f"{identifier!r} and {version!r}",
+ )
+ return Path(target_dir) / filename
+
+
+def read_zip_member_limited(
+ zf: zipfile.ZipFile,
+ name: str,
+ *,
+ max_bytes: int = MAX_ZIP_MEMBER_BYTES,
+ error_type: type[ErrorT] = ValueError,
+ label: str | None = None,
+) -> bytes:
+ """Read a single ZIP member into memory under a hard size cap.
+
+ Reading a member with ``zf.open(name).read()`` is unbounded: a crafted
+ archive can declare a tiny ``file_size`` yet decompress to many gigabytes (a
+ "zip bomb"), exhausting memory before the caller ever inspects the data.
+ This rejects members whose *declared* size already exceeds *max_bytes* and,
+ to defend against headers that lie, also reads in bounded chunks and stops
+ one byte past the limit.
+
+ Use this for any inline manifest/metadata read that happens *before*
+ :func:`safe_extract_zip` (which already enforces the same per-member bound
+ during extraction); a raw ``zf.open(...).read()`` bypasses that protection.
+ """
+ _validate_max_bytes(max_bytes)
+ member_label = label or name
+ try:
+ info = zf.getinfo(name)
+ except KeyError as exc:
+ _raise_from(error_type, f"ZIP member not found: {name!r}", exc)
+ if info.file_size > max_bytes:
+ _raise(
+ error_type,
+ f"ZIP member {member_label!r} exceeds maximum size of {max_bytes} bytes",
+ )
+
+ try:
+ with zf.open(name, "r") as source:
+ return _read_limited(source, max_bytes)
+ except _ReadLimitExceeded:
+ _raise(
+ error_type,
+ f"ZIP member {member_label!r} exceeds maximum size of {max_bytes} bytes",
+ )
+ except Exception as exc:
+ _raise_from(
+ error_type,
+ f"Failed to read ZIP member {member_label!r}: {exc!r}",
+ exc,
+ )
+
+
+def normalize_zip_member_name(
+ name: str,
+ *,
+ error_type: type[ErrorT] = ValueError,
+) -> str:
+ """Return a normalized, portable ZIP member name or raise if unsafe."""
+ if "\x00" in name:
+ _raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
+
+ normalized = name.replace("\\", "/")
+ try:
+ encoded_name = normalized.encode("utf-8")
+ except UnicodeEncodeError:
+ _raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
+ if len(encoded_name) > MAX_ZIP_PATH_BYTES:
+ _raise(
+ error_type,
+ f"Unsafe path in ZIP archive: {name!r} "
+ "(not portable across supported filesystems)",
+ )
+ path = PurePosixPath(normalized)
+ raw_parts = normalized.split("/")
+ # Strip a single trailing empty segment, i.e. the one-slash directory
+ # marker that legitimate ZIPs use ("mydir/", "mydir/subdir/"). Anything
+ # else that produces an empty segment - consecutive slashes ("a//b") or a
+ # second trailing slash - is left in place and rejected below as malformed.
+ if raw_parts and raw_parts[-1] == "":
+ raw_parts = raw_parts[:-1]
+ has_windows_drive = re.match(r"^[A-Za-z]:", normalized) is not None
+ if (
+ not raw_parts
+ or path.is_absolute()
+ or has_windows_drive
+ or any(part in {"", ".", ".."} for part in raw_parts)
+ ):
+ _raise(
+ error_type,
+ f"Unsafe path in ZIP archive: {name!r} (potential path traversal)",
+ )
+ for part in raw_parts:
+ reserved_stem = part.partition(".")[0].partition(":")[0].rstrip(" ")
+ if (
+ len(part.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
+ or any(
+ unicodedata.category(character) == "Cc"
+ for character in part
+ )
+ or any(character in _WINDOWS_INVALID_FILENAME_CHARS for character in part)
+ or part.startswith(" ")
+ or part.endswith((" ", "."))
+ or _WINDOWS_RESERVED_FILENAME.fullmatch(reserved_stem)
+ ):
+ _raise(
+ error_type,
+ f"Unsafe path in ZIP archive: {name!r} "
+ "(not portable across supported filesystems)",
+ )
+ return normalized
+
+
+def portable_zip_path_key(name: str) -> tuple[str, ...]:
+ """Return a comparison key for filesystems with case/Unicode folding."""
+ normalized_name = name.replace("\\", "/")
+ return tuple(
+ unicodedata.normalize("NFC", part.casefold())
+ for part in normalized_name.removesuffix("/").split("/")
+ )
+
+
+def _raise_zip64(error_type: type[ErrorT]) -> NoReturn:
+ _raise(
+ error_type,
+ "ZIP64 archives are not supported by the bounded extractor",
+ )
+
+
+def _preflight_zip_entry_features(
+ extract_version: int,
+ compression_method: int,
+ *,
+ error_type: type[ErrorT],
+) -> None:
+ """Enforce the formats whose output can be bounded by ``ZipExtFile``.
+
+ Python's BZIP2 and LZMA ``ZipExtFile`` paths do not pass the requested
+ output length to the decompressor; only STORED and DEFLATED preserve this
+ module's hard memory bound. APPNOTE assigns extract version 4.5 to ZIP64
+ size extensions. Because this field declares the minimum extractor feature
+ level, reject 4.5 and every newer level for the supported methods,
+ independently of the usual size sentinels and extra field.
+ """
+ if compression_method not in _BOUNDED_ZIP_COMPRESSION_METHODS:
+ _raise(
+ error_type,
+ f"Unsupported ZIP compression method {compression_method}; "
+ "the bounded extractor supports only STORED and DEFLATED",
+ )
+ if extract_version >= _ZIP64_MIN_EXTRACT_VERSION:
+ _raise(
+ error_type,
+ "ZIP64 or newer ZIP features requiring extractor version 4.5 or "
+ "newer are not supported by the bounded extractor",
+ )
+
+
+def _reject_zip64_extra_fields(
+ extra: bytes,
+ zip_path: Path,
+ *,
+ error_type: type[ErrorT],
+) -> None:
+ """Reject ZIP64 extra fields and malformed complete extra records."""
+ offset = 0
+ while offset + _ZIP_EXTRA_HEADER.size <= len(extra):
+ field_id, field_size = _ZIP_EXTRA_HEADER.unpack_from(extra, offset)
+ field_end = offset + _ZIP_EXTRA_HEADER.size + field_size
+ if field_id == _ZIP64_EXTRA_FIELD_ID:
+ _raise_zip64(error_type)
+ if field_end > len(extra):
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+ offset = field_end
+
+
+def _preflight_zip_local_header(
+ archive_file,
+ zip_path: Path,
+ *,
+ error_type: type[ErrorT],
+ archive_prefix_size: int,
+ central_directory_start: int,
+ local_header_offset: int,
+) -> None:
+ """Reject local-entry ZIP64 indicators before ``ZipFile`` is constructed."""
+ physical_offset = archive_prefix_size + local_header_offset
+ if (
+ physical_offset < archive_prefix_size
+ or physical_offset + _ZIP_LOCAL_HEADER_SIZE > central_directory_start
+ ):
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+
+ archive_file.seek(physical_offset)
+ header = archive_file.read(_ZIP_LOCAL_HEADER_SIZE)
+ if (
+ len(header) != _ZIP_LOCAL_HEADER_SIZE
+ or header[:4] != _ZIP_LOCAL_SIGNATURE
+ ):
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+
+ extract_version = struct.unpack_from(" central_directory_start:
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+
+ archive_file.seek(extra_offset)
+ extra = archive_file.read(extra_size)
+ if len(extra) != extra_size:
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+ _reject_zip64_extra_fields(extra, zip_path, error_type=error_type)
+
+
+def _preflight_zip_central_directory(
+ archive_file,
+ zip_path: Path,
+ *,
+ error_type: type[ErrorT],
+ max_entries: int,
+) -> None:
+ """Bound and count the central directory before ``ZipFile`` materializes it."""
+ archive_file.seek(0, 2)
+ file_size = archive_file.tell()
+ tail_size = min(file_size, _ZIP_EOCD.size + _ZIP_MAX_COMMENT_BYTES)
+ archive_file.seek(file_size - tail_size)
+ tail = archive_file.read(tail_size)
+
+ # ZipFile selects the last EOCD signature in the search window. Inspect
+ # exactly that record too: falling back to an earlier signature would let
+ # the preflight validate one central directory while ZipFile materializes
+ # another.
+ eocd_index = tail.rfind(_ZIP_EOCD_SIGNATURE)
+ if eocd_index < 0 or eocd_index + _ZIP_EOCD.size > len(tail):
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+ eocd = _ZIP_EOCD.unpack_from(tail, eocd_index)
+ comment_size = eocd[-1]
+ if eocd_index + _ZIP_EOCD.size + comment_size != len(tail):
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+
+ eocd_offset = file_size - len(tail) + eocd_index
+ if eocd_offset >= 20:
+ archive_file.seek(eocd_offset - 20)
+ if archive_file.read(4) == _ZIP64_LOCATOR_SIGNATURE:
+ _raise_zip64(error_type)
+
+ (
+ _signature,
+ disk_number,
+ central_directory_disk,
+ entries_on_disk,
+ declared_entries,
+ central_directory_size,
+ central_directory_offset,
+ _comment_size,
+ ) = eocd
+ if (
+ disk_number != 0
+ or central_directory_disk != 0
+ or entries_on_disk != declared_entries
+ ):
+ _raise(error_type, "Multi-disk ZIP archives are not supported")
+ if (
+ declared_entries == _ZIP_UINT16_MAX
+ or central_directory_size == _ZIP_UINT32_MAX
+ or central_directory_offset == _ZIP_UINT32_MAX
+ ):
+ _raise_zip64(error_type)
+ if declared_entries > max_entries:
+ _raise(
+ error_type,
+ f"ZIP archive contains too many entries "
+ f"({declared_entries} > {max_entries})",
+ )
+ if central_directory_size > MAX_ZIP_CENTRAL_DIRECTORY_BYTES:
+ _raise(
+ error_type,
+ f"ZIP central directory exceeds maximum size of "
+ f"{MAX_ZIP_CENTRAL_DIRECTORY_BYTES} bytes",
+ )
+
+ central_directory_start = eocd_offset - central_directory_size
+ if (
+ central_directory_start < 0
+ or central_directory_offset > central_directory_start
+ ):
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+ archive_prefix_size = central_directory_start - central_directory_offset
+
+ consumed = 0
+ actual_entries = 0
+ local_header_offsets: list[int] = []
+ while consumed < central_directory_size:
+ archive_file.seek(central_directory_start + consumed)
+ remaining = central_directory_size - consumed
+ if remaining < _ZIP_CENTRAL_HEADER_SIZE:
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+ header = archive_file.read(_ZIP_CENTRAL_HEADER_SIZE)
+ if (
+ len(header) != _ZIP_CENTRAL_HEADER_SIZE
+ or header[:4] != _ZIP_CENTRAL_SIGNATURE
+ ):
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+
+ extract_version = struct.unpack_from(" remaining:
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+ variable_data = archive_file.read(variable_size)
+ if len(variable_data) != variable_size:
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+ extra = variable_data[filename_size : filename_size + extra_size]
+ _reject_zip64_extra_fields(extra, zip_path, error_type=error_type)
+ local_header_offsets.append(local_header_offset)
+
+ consumed += record_size
+ actual_entries += 1
+ if actual_entries > max_entries:
+ _raise(
+ error_type,
+ f"ZIP archive contains too many entries "
+ f"({actual_entries} > {max_entries})",
+ )
+
+ if actual_entries != declared_entries:
+ _raise(error_type, f"Invalid ZIP archive: {zip_path}")
+
+ for local_header_offset in local_header_offsets:
+ _preflight_zip_local_header(
+ archive_file,
+ zip_path,
+ error_type=error_type,
+ archive_prefix_size=archive_prefix_size,
+ central_directory_start=central_directory_start,
+ local_header_offset=local_header_offset,
+ )
+
+
+@contextmanager
+def open_zip_bounded(
+ zip_path: Path,
+ *,
+ error_type: type[ErrorT] = ValueError,
+ max_entries: int = MAX_ZIP_ENTRIES,
+) -> Iterator[zipfile.ZipFile]:
+ """Open an untrusted ZIP after a bounded-memory header preflight."""
+ _validate_non_negative_int(max_entries, "max_entries")
+ zip_path = Path(zip_path)
+ with ExitStack() as stack:
+ try:
+ archive_file = stack.enter_context(zip_path.open("rb"))
+ except OSError as exc:
+ _raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
+ try:
+ _preflight_zip_central_directory(
+ archive_file,
+ zip_path,
+ error_type=error_type,
+ max_entries=max_entries,
+ )
+ except OSError as exc:
+ _raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
+ try:
+ archive_file.seek(0)
+ zf = stack.enter_context(zipfile.ZipFile(archive_file, "r"))
+ except Exception as exc:
+ _raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
+ yield zf
+
+
+def safe_extract_zip(
+ zip_path: Path,
+ target_dir: Path,
+ *,
+ error_type: type[ErrorT] = ValueError,
+ max_entries: int = MAX_ZIP_ENTRIES,
+ max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
+ max_total_bytes: int = MAX_ZIP_TOTAL_BYTES,
+) -> None:
+ """Extract a ZIP archive after path, symlink, and size validation."""
+ _validate_non_negative_int(max_member_bytes, "max_member_bytes")
+ _validate_non_negative_int(max_total_bytes, "max_total_bytes")
+ try:
+ target_root = target_dir.resolve()
+ except OSError as exc:
+ _raise_from(error_type, f"Invalid ZIP extraction target: {target_dir}", exc)
+
+ with open_zip_bounded(
+ zip_path,
+ error_type=error_type,
+ max_entries=max_entries,
+ ) as zf:
+ try:
+ members = zf.infolist()
+ except zipfile.BadZipFile as exc:
+ _raise_from(error_type, f"Invalid ZIP archive: {zip_path}", exc)
+ if len(members) > max_entries:
+ _raise(
+ error_type,
+ f"ZIP archive contains too many entries ({len(members)} > {max_entries})",
+ )
+
+ normalized_members: list[tuple[zipfile.ZipInfo, str, bool]] = []
+ validated_paths: dict[tuple[str, ...], tuple[str, bool]] = {}
+ total_size = 0
+ for member in members:
+ normalized_name = normalize_zip_member_name(
+ member.filename,
+ error_type=error_type,
+ )
+ is_dir = member.is_dir() or normalized_name.endswith("/")
+ path_key = portable_zip_path_key(normalized_name)
+
+ existing = validated_paths.get(path_key)
+ if existing is not None:
+ _raise(
+ error_type,
+ f"Conflicting path in ZIP archive: {member.filename} conflicts "
+ f"with {existing[0]}",
+ )
+ validated_paths[path_key] = (member.filename, is_dir)
+
+ mode = member.external_attr >> 16
+ if stat.S_ISLNK(mode):
+ _raise(error_type, f"Unsafe symlink in ZIP archive: {member.filename}")
+
+ member_path = (target_dir / normalized_name).resolve()
+ try:
+ member_path.relative_to(target_root)
+ except ValueError:
+ _raise(
+ error_type,
+ f"Unsafe path in ZIP archive: {member.filename} "
+ "(potential path traversal)",
+ )
+
+ if not is_dir:
+ if member.file_size > max_member_bytes:
+ _raise(
+ error_type,
+ f"ZIP member {member.filename} exceeds maximum size "
+ f"of {max_member_bytes} bytes",
+ )
+ total_size += member.file_size
+ if total_size > max_total_bytes:
+ _raise(
+ error_type,
+ f"ZIP archive exceeds maximum uncompressed size "
+ f"of {max_total_bytes} bytes",
+ )
+
+ normalized_members.append((member, normalized_name, is_dir))
+
+ # Tuple sorting places every path immediately before its descendants.
+ # One adjacent comparison per entry detects file/directory conflicts
+ # without repeatedly rebuilding every path prefix.
+ for (
+ (path_key, (original, is_dir)),
+ (next_key, (next_original, _next_is_dir)),
+ ) in pairwise(sorted(validated_paths.items())):
+ if (
+ not is_dir
+ and len(next_key) > len(path_key)
+ and next_key[: len(path_key)] == path_key
+ ):
+ _raise(
+ error_type,
+ f"Conflicting path in ZIP archive: {original} conflicts "
+ f"with {next_original}",
+ )
+
+ # The loop above bounds the *declared* total via member.file_size, but a
+ # crafted archive can understate those headers. Mirror the per-member
+ # guard below with a cumulative count of the bytes actually written so
+ # the total-size bound holds even when the headers lie.
+ total_written = 0
+ for member, normalized_name, is_dir in normalized_members:
+ member_path = target_dir / normalized_name
+ if is_dir:
+ try:
+ member_path.mkdir(parents=True, exist_ok=True)
+ except OSError as exc:
+ _raise_from(
+ error_type,
+ f"Failed to create ZIP directory {member.filename}: {exc}",
+ exc,
+ )
+ continue
+
+ try:
+ member_path.parent.mkdir(parents=True, exist_ok=True)
+ except OSError as exc:
+ _raise_from(
+ error_type,
+ f"Failed to create parent directory for ZIP member {member.filename}: {exc}",
+ exc,
+ )
+ written = 0
+ # Raised outside the try below: if error_type subclasses OSError or
+ # RuntimeError, raising inside would re-wrap the limit error as
+ # "Failed to extract" and lose the size-bound message.
+ limit_error: str | None = None
+ try:
+ with zf.open(member, "r") as source, member_path.open("wb") as dest:
+ while True:
+ chunk = source.read(READ_CHUNK_SIZE)
+ if not chunk:
+ break
+ written += len(chunk)
+ if written > max_member_bytes:
+ limit_error = (
+ f"ZIP member {member.filename} exceeds maximum size "
+ f"of {max_member_bytes} bytes"
+ )
+ break
+ total_written += len(chunk)
+ if total_written > max_total_bytes:
+ limit_error = (
+ f"ZIP archive exceeds maximum uncompressed size "
+ f"of {max_total_bytes} bytes"
+ )
+ break
+ dest.write(chunk)
+ except Exception as exc:
+ _raise_from(
+ error_type,
+ f"Failed to extract ZIP member {member.filename}: {exc}",
+ exc,
+ )
+ if limit_error is not None:
+ _raise(error_type, limit_error)
diff --git a/src/specify_cli/_github_http.py b/src/specify_cli/_github_http.py
index aa8223bcad..017f50b5d1 100644
--- a/src/specify_cli/_github_http.py
+++ b/src/specify_cli/_github_http.py
@@ -100,8 +100,19 @@ def resolve_github_release_asset_api_url(
import json
import urllib.error
- parsed = urlparse(download_url)
- hostname = (parsed.hostname or "").lower()
+ from specify_cli._download_security import read_response_limited
+
+ # Accessing ``.hostname`` (like ``.port`` below) raises ValueError on a
+ # malformed authority, e.g. an invalid bracketed IPv6 host
+ # ``https://[not-an-ip]/...``. The function's contract is to return None for
+ # anything it can't resolve, not to raise, so guard the read. ``download_url``
+ # is server-controlled here (a catalog ``download_url`` payload), so a
+ # malformed value must not leak a raw traceback past the caller.
+ try:
+ parsed = urlparse(download_url)
+ hostname = (parsed.hostname or "").lower()
+ except ValueError:
+ return None
parts = [unquote(part) for part in parsed.path.strip("/").split("/")]
is_ghes = (
@@ -148,8 +159,9 @@ def _is_asset_path(segments: list[str]) -> bool:
if len(parts) < 6 or parts[2:4] != ["releases", "download"]:
return None
- owner, repo, tag = parts[0], parts[1], parts[4]
- asset_name = "/".join(parts[5:])
+ owner, repo = parts[0], parts[1]
+ tag = "/".join(parts[4:-1])
+ asset_name = parts[-1]
encoded_tag = quote(tag, safe="")
release_url = f"{api_base}/repos/{owner}/{repo}/releases/tags/{encoded_tag}"
@@ -158,10 +170,13 @@ def _is_asset_path(segments: list[str]) -> bool:
if redirect_validator is not None:
open_kwargs["redirect_validator"] = redirect_validator
with open_url_fn(release_url, **open_kwargs) as response:
- raw_release_data = response.read(max_metadata_bytes + 1)
- if len(raw_release_data) > max_metadata_bytes:
- raise ValueError("GitHub release metadata exceeds size limit")
- release_data = json.loads(raw_release_data)
+ release_data = json.loads(
+ read_response_limited(
+ response,
+ max_bytes=max_metadata_bytes,
+ label=f"GitHub release metadata {release_url}",
+ )
+ )
except (
urllib.error.URLError,
json.JSONDecodeError,
diff --git a/src/specify_cli/_init_options.py b/src/specify_cli/_init_options.py
index dd225d8251..9f509da256 100644
--- a/src/specify_cli/_init_options.py
+++ b/src/specify_cli/_init_options.py
@@ -3,12 +3,22 @@
import json
from collections.abc import Mapping
from pathlib import Path
-from typing import Any
+from typing import Any, Union
INIT_OPTIONS_FILE = ".specify/init-options.json"
+class _MissingInitOptionsFile:
+ """Sentinel: init-options.json does not exist at all (legacy layout)."""
+
+ def __repr__(self) -> str: # pragma: no cover - debug aid only
+ return "MISSING_INIT_OPTIONS_FILE"
+
+
+MISSING_INIT_OPTIONS_FILE = _MissingInitOptionsFile()
+
+
def save_init_options(project_path: Path, options: dict[str, Any]) -> None:
"""Persist the CLI options used during ``specify init``."""
dest = project_path / INIT_OPTIONS_FILE
@@ -34,3 +44,40 @@ def load_init_options(project_path: Path) -> dict[str, Any]:
def is_ai_skills_enabled(opts: Mapping[str, Any] | None) -> bool:
"""Return True only when init options explicitly enable AI skills."""
return isinstance(opts, Mapping) and opts.get("ai_skills") is True
+
+
+def resolve_active_agent_for_registration(
+ project_path: Path,
+) -> Union[str, None, _MissingInitOptionsFile]:
+ """Resolve the active integration key for active-only registration (#2948).
+
+ ``load_init_options`` collapses "no file", "unreadable/malformed file",
+ and "valid file with no recorded active agent" into the same ``{}``
+ result, which previously made corrupted-but-present init-options behave
+ like a legacy pre-init-options project and fall back to registering
+ every detected agent. This helper distinguishes those cases explicitly:
+
+ - Returns :data:`MISSING_INIT_OPTIONS_FILE` when init-options.json does
+ not exist at all (pre-init-options layout or direct library use).
+ Callers should fall back to detection-based registration for all
+ agents, matching the original pre-#2948 behavior for such projects.
+ - Returns ``None`` when init-options.json exists but could not provide a
+ valid non-empty string active agent (malformed/unreadable JSON,
+ non-object payload, or a non-string/empty ``ai`` value). Callers must
+ fail closed (register nothing) rather than treat this like "no file"
+ or pass a non-string key into agent-config lookups.
+ - Returns the active agent key (a non-empty string) otherwise.
+ """
+ path = project_path / INIT_OPTIONS_FILE
+ # A dangling symlink's target doesn't exist, so Path.exists() (which
+ # follows symlinks) returns False even though the path itself is
+ # present as a broken/corrupted entry. Treat any symlink as "present"
+ # so a dangling one fails closed via the invalid-file branch below
+ # instead of being mistaken for "no file at all" (legacy fallback).
+ if not path.is_symlink() and not path.exists():
+ return MISSING_INIT_OPTIONS_FILE
+
+ active_agent = load_init_options(project_path).get("ai")
+ if isinstance(active_agent, str) and active_agent:
+ return active_agent
+ return None
diff --git a/src/specify_cli/_invocation_style.py b/src/specify_cli/_invocation_style.py
index 874903abec..29018e863a 100644
--- a/src/specify_cli/_invocation_style.py
+++ b/src/specify_cli/_invocation_style.py
@@ -12,12 +12,13 @@
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode"})
# Agents that always render /speckit-, regardless of ai_skills.
-ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "grok", "trae", "zed"})
+ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"})
# Agents that render /speckit- only when ai_skills is enabled.
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
{
"agy",
+ "bob",
"claude",
"copilot",
"cursor-agent",
@@ -28,6 +29,9 @@
}
)
+# Agents that render /skill: (skill-colon invocation) when in skills mode.
+SKILL_COLON_AGENTS: frozenset[str] = frozenset({"kimi"})
+
def is_dollar_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool:
"""Return ``True`` if *selected_ai* uses ``$speckit-`` invocations.
@@ -40,6 +44,21 @@ def is_dollar_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) ->
return selected_ai in DOLLAR_SKILLS_AGENTS and ai_skills_enabled
+def get_invocation_prefix(selected_ai: str | None, ai_skills_enabled: bool) -> str:
+ """Return the native invocation prefix for *selected_ai* in skills mode.
+
+ Returns ``"$"`` for dollar-skills agents (Codex, ZCode),
+ ``"/skill:"`` for skill-colon agents (Kimi), and ``"/"`` for all others.
+ """
+ if not isinstance(selected_ai, str):
+ return "/"
+ if selected_ai in DOLLAR_SKILLS_AGENTS and ai_skills_enabled:
+ return "$"
+ if selected_ai in SKILL_COLON_AGENTS and ai_skills_enabled:
+ return "/skill:"
+ return "/"
+
+
def is_slash_skills_agent(selected_ai: str | None, ai_skills_enabled: bool) -> bool:
"""Return ``True`` if *selected_ai* uses ``/speckit-`` invocations.
diff --git a/src/specify_cli/_utils.py b/src/specify_cli/_utils.py
index 6603d65c45..85b659d67b 100644
--- a/src/specify_cli/_utils.py
+++ b/src/specify_cli/_utils.py
@@ -12,6 +12,7 @@
from pathlib import Path, PurePosixPath, PureWindowsPath
from typing import Any
from ._console import console
+from ._download_security import normalize_zip_member_name
CLAUDE_LOCAL_PATH = Path.home() / ".claude" / "local" / "claude"
CLAUDE_NPM_LOCAL_PATH = Path.home() / ".claude" / "local" / "node_modules" / ".bin" / "claude"
@@ -27,19 +28,22 @@ def relative_extension_path_violation(value: Any) -> str | None:
``None`` when it is an acceptable relative path within the extension
directory.
- Policy: the value must be a non-empty string with no leading/trailing
- whitespace, no absolute/anchored form, and no ``..`` traversal. The value is
+ Policy: the value must be a non-empty, portable file path with no
+ leading/trailing whitespace, absolute/anchored form, ``..`` traversal,
+ platform-reserved component, or directory-only suffix. The value is
evaluated under both POSIX and Windows path semantics because a native
``Path`` is OS-dependent (a ``PurePosixPath`` on POSIX does not interpret
- Windows drive/UNC forms, and ``C:foo`` is anchored but not ``is_absolute()``
- yet resolves against the CWD on its drive). Rejecting any non-empty anchor
- covers POSIX-absolute (``/abs``), Windows drive-relative (``C:foo``), Windows
- absolute (``C:\\foo``), and UNC/rooted forms.
+ Windows drive/UNC forms, and ``C:foo`` is anchored but not
+ ``is_absolute()`` yet resolves against the CWD on its drive). Rejecting any
+ non-empty anchor covers POSIX-absolute (``/abs``), Windows drive-relative
+ (``C:foo``), Windows absolute (``C:\\foo``), and UNC/rooted forms.
"""
if not isinstance(value, str) or not value:
return "must be a non-empty string"
if value.strip() != value:
return "must not have leading or trailing whitespace"
+ if "\\" in value:
+ return "must use forward slashes as path separators"
posix_path = PurePosixPath(value)
win_path = PureWindowsPath(value)
if (
@@ -52,6 +56,15 @@ def relative_extension_path_violation(value: Any) -> str | None:
"must be a relative path within the extension directory "
"(no absolute paths, drive letters, or '..' segments)"
)
+ if value.endswith(("/", "\\")):
+ return "must name a file or command, not a directory"
+ try:
+ normalize_zip_member_name(value)
+ except ValueError:
+ return (
+ "must use portable path components "
+ "(no reserved names or platform-invalid characters)"
+ )
return None
@@ -69,21 +82,14 @@ def run_command(
cmd: list[str],
check_return: bool = True,
capture: bool = False,
- shell: bool = False,
) -> str | None:
"""Run a command without invoking a shell and optionally capture output.
- The ``shell`` parameter is kept in the signature so existing keyword
- callers (and the re-export from ``specify_cli``) don't raise ``TypeError``,
- but only the default ``shell=False`` is honoured. ``shell=True`` is
- rejected with ``ValueError`` rather than silently ignored, so the
- unsupported mode fails loudly instead of running with a different meaning.
+ Commands are always executed with ``shell=False`` and must be passed as an
+ argv ``list[str]``. There is deliberately no ``shell`` parameter: the
+ argv-list contract makes shell interpolation impossible by construction, so
+ the shell-injection surface cannot be re-enabled at a call site.
"""
- if shell:
- raise ValueError(
- "run_command() does not support shell=True; pass argv as a list"
- )
-
try:
if capture:
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True)
diff --git a/src/specify_cli/_version.py b/src/specify_cli/_version.py
index e634a4f286..962e3adfff 100644
--- a/src/specify_cli/_version.py
+++ b/src/specify_cli/_version.py
@@ -4,8 +4,8 @@
release tag. The ``self_app`` Typer sub-command group is co-located here so
all version-related logic lives in one place.
-Dependencies: stdlib + packaging + ._console only (no other internal imports
-at module level, keeping this layer thin and circular-import-safe).
+Dependencies: stdlib + packaging + ._console + ._download_security only
+(keeping this layer thin and circular-import-safe).
"""
from __future__ import annotations
@@ -27,7 +27,9 @@
import typer
from packaging.version import InvalidVersion, Version
+from rich.markup import escape as _escape_markup
+from ._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from ._console import console
GITHUB_API_LATEST = "https://api.github.com/repos/github/spec-kit/releases/latest"
@@ -119,7 +121,13 @@ def _fetch_latest_release_tag() -> tuple[str | None, str | None]:
timeout=5,
extra_headers={"Accept": "application/vnd.github+json"},
) as resp:
- payload = json.loads(resp.read().decode("utf-8"))
+ payload = json.loads(
+ read_response_limited(
+ resp,
+ max_bytes=MAX_JSON_METADATA_BYTES,
+ label="GitHub latest release",
+ ).decode("utf-8")
+ )
tag = payload.get("tag_name")
if not isinstance(tag, str) or not tag:
raise ValueError("GitHub API response missing valid tag_name")
@@ -1223,7 +1231,10 @@ def self_upgrade(
tag: str | None = typer.Option(
None,
"--tag",
- help="Pin the target version (vX.Y.Z[suffix]). Without --tag, the "
+ # Typer renders help through Rich, so escape the literal bracket (\[)
+ # or `[suffix]` is parsed as a style tag and dropped -- `--help` then
+ # advertises only `(vX.Y.Z)`, contradicting docs/upgrade.md and README.
+ help="Pin the target version (vX.Y.Z\\[suffix]). Without --tag, the "
"latest stable release is resolved via GitHub Releases.",
),
) -> None:
@@ -1263,7 +1274,14 @@ def self_upgrade(
try:
tag = _validate_tag(tag)
except typer.BadParameter as exc:
- console.print(str(exc), soft_wrap=True)
+ # Escape at the print site rather than baking `\[` into
+ # _INVALID_TAG_MESSAGE: the message is also raised through
+ # typer.BadParameter, which Click renders without Rich, so the
+ # constant must stay plain text. Unescaped, Rich parses the literal
+ # `[suffix]` as a style tag and drops it, leaving the user with
+ # "expected vMAJOR.MINOR.PATCH" -- implying a bare vX.Y.Z is the only
+ # accepted form when -rc1 / .dev0 / +build.42 are all valid.
+ console.print(_escape_markup(str(exc)), soft_wrap=True)
raise typer.Exit(1) from exc
plan, failure_reason = _build_upgrade_plan(target_tag_override=tag)
diff --git a/src/specify_cli/agents.py b/src/specify_cli/agents.py
index e4d09ffe99..b2861d0ad2 100644
--- a/src/specify_cli/agents.py
+++ b/src/specify_cli/agents.py
@@ -7,15 +7,15 @@
"""
import os
-import platform
import re
from copy import deepcopy
from pathlib import Path
-from typing import Any, Dict, List, Optional
+from typing import Any, Dict, Iterable, List, Optional
import yaml
from ._init_options import is_ai_skills_enabled, load_init_options
+from ._invocation_style import get_invocation_prefix
from ._toml_string import escape_toml_basic as _escape_toml_basic
from ._toml_string import has_illegal_toml_control as _has_illegal_toml_control
from ._utils import relative_extension_path_violation
@@ -114,13 +114,24 @@ def parse_frontmatter(content: str) -> tuple[dict, str]:
if not content.startswith("---"):
return {}, content
- # Find second ---
- end_marker = content.find("---", 3)
- if end_marker == -1:
+ # The closing delimiter is a line that is exactly ``---`` (a YAML
+ # document separator), not any ``---`` substring. Scanning with
+ # ``content.find("---", 3)`` stops at the first ``---`` *anywhere* ā
+ # including one embedded in a frontmatter value (e.g. a description like
+ # "Separate sections with ---") or inside an indented literal block ā
+ # which truncates the frontmatter and spills the remainder into the
+ # body. Match on line boundaries instead, mirroring the line-anchored
+ # scan in ``VibeIntegration._inject_frontmatter_flag``.
+ lines = content.splitlines(keepends=True)
+ end_line = next(
+ (i for i in range(1, len(lines)) if lines[i].rstrip() == "---"),
+ None,
+ )
+ if end_line is None:
return {}, content
- frontmatter_str = content[3:end_marker].strip()
- body = content[end_marker + 3 :].strip()
+ frontmatter_str = "".join(lines[1:end_line]).strip()
+ body = "".join(lines[end_line + 1 :]).strip()
try:
frontmatter = yaml.safe_load(frontmatter_str) or {}
@@ -260,7 +271,7 @@ def rewrite_extension_paths(
return text
def render_markdown_command(
- self, frontmatter: dict, body: str, source_id: str, context_note: str = None
+ self, frontmatter: dict, body: str, source_id: str, context_note: Optional[str] = None
) -> str:
"""Render command in Markdown format.
@@ -291,8 +302,20 @@ def render_toml_command(self, frontmatter: dict, body: str, source_id: str) -> s
toml_lines = []
if "description" in frontmatter:
+ # Frontmatter comes from ``yaml.safe_load``, so ``description`` can
+ # be any YAML type: ``description:`` with no value yields None,
+ # ``description: 2`` an int, an unquoted ``true`` a bool.
+ # ``_render_basic_toml_string`` iterates the value and calls ord()
+ # on each character, so a non-string raises a raw TypeError -- and a
+ # list of single-character items is silently concatenated into a
+ # wrong value (``["a", "b"]`` -> ``"ab"``). Coerce first, matching
+ # ``render_yaml_command`` below and ``TomlIntegration
+ # ._extract_description``, which both normalise it already.
+ description = frontmatter["description"]
+ if not isinstance(description, str):
+ description = str(description) if description is not None else ""
toml_lines.append(
- f"description = {self._render_basic_toml_string(frontmatter['description'])}"
+ f"description = {self._render_basic_toml_string(description)}"
)
toml_lines.append("")
@@ -475,26 +498,19 @@ def resolve_skill_placeholders(
init_opts = {}
script_variant = init_opts.get("script")
- if script_variant not in {"sh", "ps"}:
- fallback_order = []
- default_variant = (
- "ps" if platform.system().lower().startswith("win") else "sh"
- )
- secondary_variant = "sh" if default_variant == "ps" else "ps"
-
- if default_variant in scripts:
- fallback_order.append(default_variant)
- if secondary_variant in scripts:
- fallback_order.append(secondary_variant)
+ if scripts:
+ from specify_cli.integrations.base import IntegrationBase
- for key in scripts:
- if key not in fallback_order:
- fallback_order.append(key)
-
- script_variant = fallback_order[0] if fallback_order else None
+ script_variant = IntegrationBase.select_script_variant(
+ script_variant, scripts
+ )
script_command = scripts.get(script_variant) if script_variant else None
if script_command:
+ if script_variant == "py":
+ script_command = IntegrationBase.build_python_invocation(
+ script_command, project_root
+ )
script_command = script_command.replace("{ARGS}", "$ARGUMENTS")
body = body.replace("{SCRIPT}", script_command)
@@ -594,8 +610,8 @@ def register_commands(
source_id: str,
source_dir: Path,
project_root: Path,
- context_note: str = None,
- _resolved_dir: Path = None,
+ context_note: Optional[str] = None,
+ _resolved_dir: Optional[Path] = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
) -> List[str]:
@@ -637,10 +653,57 @@ def register_commands(
is_cline_ext = agent_name == "cline" and source_id != "core"
source_root = source_dir.resolve()
+ # Resolve the command-reference separator for the file THIS registrar
+ # is about to write. The separator must match the *output layout* the
+ # registrar produces for this agent ā not the project's persisted
+ # ``ai_skills`` flag, and not unrelated sibling directories on disk. A
+ # skill scaffold ("/SKILL.md") uses the skills separator; any
+ # command-layout output (".md", ".agent.md", ".toml", ā¦) uses the
+ # command separator.
+ #
+ # This holds for the *active* agent too. Dual-layout agents (Bob,
+ # Copilot) write their skills via their own setup()/skills path, so
+ # ``register_commands`` only ever emits their command-layout files.
+ # Deriving the separator from ``ai_skills`` would render such a
+ # ``.bob/commands/*.md`` (or ``.github/agents/*.agent.md``) file with
+ # ``/speckit-*`` whenever that agent is active in skills mode ā even
+ # though a command-layout file must use ``/speckit.*``. Deriving it
+ # from the agent's static output config avoids that mismatch and stays
+ # correct when a stale ``.bob/skills`` directory coexists with
+ # ``.bob/commands``.
+ _sep = agent_config.get("invoke_separator", ".")
+ registrar_writes_skills = agent_config.get("extension") == "/SKILL.md"
+ try:
+ from specify_cli.integrations import get_integration # noqa: PLC0415
+
+ _integ = get_integration(agent_name)
+ if _integ is not None:
+ _sep = _integ.invoke_separator_for_mode(registrar_writes_skills)
+ except Exception:
+ pass
+ _prefix = get_invocation_prefix(agent_name, registrar_writes_skills)
+
for cmd_info in commands:
cmd_name = cmd_info["name"]
aliases = cmd_info.get("aliases", [])
cmd_file = cmd_info["file"]
+ name_reason = relative_extension_path_violation(cmd_name)
+ if name_reason:
+ raise ValueError(
+ f"Invalid command name {cmd_name!r}: {name_reason}"
+ )
+ if aliases is None:
+ aliases = []
+ if not isinstance(aliases, list):
+ raise ValueError(
+ f"Aliases for command {cmd_name!r} must be a list"
+ )
+ for alias in aliases:
+ alias_reason = relative_extension_path_violation(alias)
+ if alias_reason:
+ raise ValueError(
+ f"Invalid command alias {alias!r}: {alias_reason}"
+ )
# Guard against path traversal using the single shared policy in
# relative_extension_path_violation(), so the runtime guard stays
@@ -709,14 +772,19 @@ def register_commands(
)
# Resolve __SPECKIT_COMMAND_*__ tokens using the agent's invoke separator.
- # The separator is sourced from agent_config (populated by _build_agent_configs,
- # which propagates each integration's invoke_separator class attribute).
+ # For dual-layout agents (e.g. Bob) the separator differs between the
+ # skills and command layouts, so a single static AGENT_CONFIGS value is
+ # insufficient. ``_sep`` (resolved above) is derived from the *output
+ # layout* this registrar writes ā a "/SKILL.md" scaffold uses the skills
+ # separator, any command-layout file uses the command separator ā not
+ # the project's persisted ai_skills state. Single-layout agents fall back
+ # to the static AGENT_CONFIGS value unchanged (invoke_separator_for_mode
+ # default).
# Deferred import of IntegrationBase avoids a circular import at module load
# (base.py itself imports CommandRegistrar lazily).
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415
- _sep = agent_config.get("invoke_separator", ".")
- body = IntegrationBase.resolve_command_refs(body, _sep)
+ body = IntegrationBase.resolve_command_refs(body, _sep, _prefix)
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
@@ -918,10 +986,16 @@ def write_copilot_prompt(project_root: Path, cmd_name: str) -> None:
project_root: Path to project root
cmd_name: Command name (e.g. 'speckit.my-ext.example')
"""
+ name_reason = relative_extension_path_violation(cmd_name)
+ if name_reason:
+ raise ValueError(
+ f"Invalid Copilot prompt name {cmd_name!r}: {name_reason}"
+ )
prompts_dir = project_root / ".github" / "prompts"
prompts_dir.mkdir(parents=True, exist_ok=True)
prompt_file = prompts_dir / f"{cmd_name}.prompt.md"
CommandRegistrar._ensure_inside(prompt_file, prompts_dir)
+ prompt_file.parent.mkdir(parents=True, exist_ok=True)
prompt_file.write_text(f"---\nagent: {cmd_name}\n---\n", encoding="utf-8")
@staticmethod
@@ -977,10 +1051,11 @@ def register_commands_for_all_agents(
source_id: str,
source_dir: Path,
project_root: Path,
- context_note: str = None,
+ context_note: Optional[str] = None,
link_outputs: bool = False,
create_missing_active_skills_dir: bool = False,
extension_id: Optional[str] = None,
+ only_agent: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register commands for all detected agents in the project.
@@ -998,6 +1073,8 @@ def register_commands_for_all_agents(
skills directory) and is skipped when safe resolution or
creation fails.
extension_id: Extension id when rendering extension-owned commands.
+ only_agent: If set, restrict registration to this single agent
+ while keeping all detection and recovery safeguards (#2948).
Returns:
Dictionary mapping agent names to list of registered commands
@@ -1021,6 +1098,8 @@ def register_commands_for_all_agents(
)
active_created_skills_dir: Optional[Path] = None
for agent_name, agent_config in self.AGENT_CONFIGS.items():
+ if only_agent is not None and agent_name != only_agent:
+ continue
active_skills_output = (
agent_name == active_skills_agent
and agent_config.get("extension") == "/SKILL.md"
@@ -1126,6 +1205,8 @@ def register_commands_for_non_skill_agents(
context_note: Optional[str] = None,
link_outputs: bool = False,
extension_id: Optional[str] = None,
+ only_agent: Optional[str] = None,
+ extra_agents: Optional[Iterable[str]] = None,
) -> Dict[str, List[str]]:
"""Register commands for all non-skill agents in the project.
@@ -1142,13 +1223,29 @@ def register_commands_for_non_skill_agents(
link_outputs: If True, create dev-mode symlinks for rendered
command files when supported by the OS.
extension_id: Extension id when rendering extension-owned commands.
+ only_agent: If set, restrict registration to this single agent
+ (#2948). An agent name that matches no configured agent
+ (e.g. an empty string) yields no registrations at all.
+ extra_agents: Additional agent names to register for besides
+ ``only_agent``. Used by post-removal reconciliation to also
+ restore surviving content into historical agent directories
+ a just-removed preset actually wrote to, not only the
+ currently active agent (#2948). Ignored when ``only_agent``
+ is ``None`` (already unrestricted).
Returns:
Dictionary mapping agent names to list of registered commands
"""
results = {}
self._ensure_configs()
+ extra_agents_set = frozenset(extra_agents) if extra_agents else frozenset()
for agent_name, agent_config in self.AGENT_CONFIGS.items():
+ if (
+ only_agent is not None
+ and agent_name != only_agent
+ and agent_name not in extra_agents_set
+ ):
+ continue
if agent_config.get("extension") == "/SKILL.md":
continue
detect_dir_str = agent_config.get("detect_dir")
diff --git a/src/specify_cli/authentication/azure_devops.py b/src/specify_cli/authentication/azure_devops.py
index 0ccdd5afb3..91578bd418 100644
--- a/src/specify_cli/authentication/azure_devops.py
+++ b/src/specify_cli/authentication/azure_devops.py
@@ -5,9 +5,11 @@
import base64
import json as _json
import os
+import shutil
import subprocess
from typing import TYPE_CHECKING
+from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from .base import AuthProvider
if TYPE_CHECKING:
@@ -17,6 +19,20 @@
_ADO_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"
+class _TokenResponseTooLarge(Exception):
+ """Raised when an Azure AD token response exceeds the bounded read limit."""
+
+
+def _extract_token(payload: object, key: str) -> str | None:
+ """Return a normalized token from a JSON object, or None for other shapes."""
+ if not isinstance(payload, dict):
+ return None
+ token = payload.get(key)
+ if not isinstance(token, str):
+ return None
+ return token.strip() or None
+
+
class AzureDevOpsAuth(AuthProvider):
"""Azure DevOps authentication provider.
@@ -56,9 +72,27 @@ def resolve_token(self, entry: AuthConfigEntry) -> str | None:
def _acquire_via_az_cli() -> str | None:
"""Run ``az account get-access-token`` and return the access token."""
try:
+ # Windows: ``subprocess.run`` calls ``CreateProcess``, which does
+ # not consult ``PATHEXT``, so a bare ``"az"`` (installed as
+ # ``az.cmd``) fails with ``WinError 2`` even after ``az login``.
+ # Resolve via ``shutil.which`` (which honors ``PATHEXT``) so the
+ # ``.cmd`` shim works. On POSIX this is a harmless lookup that
+ # returns the same executable.
+ #
+ # Require an ABSOLUTE result: on Windows ``shutil.which`` prepends
+ # the current directory to the search path (unless
+ # ``NoDefaultCurrentDirectoryInExePath`` is set), so a stray
+ # ``.\az.cmd`` in the working directory would otherwise be resolved
+ # ahead of the real Azure CLI and run for a credential operation. A
+ # legitimate install always resolves to an absolute path, so this
+ # costs nothing; falling back to the bare ``"az"`` preserves the
+ # prior behavior (and the existing OSError path) when ``az`` is
+ # absent.
+ resolved = shutil.which("az")
+ az = resolved if resolved and os.path.isabs(resolved) else "az"
result = subprocess.run( # noqa: S603, S607
[
- "az",
+ az,
"account",
"get-access-token",
"--resource",
@@ -74,8 +108,7 @@ def _acquire_via_az_cli() -> str | None:
if result.returncode != 0:
return None
payload = _json.loads(result.stdout)
- token = payload.get("accessToken", "").strip()
- return token or None
+ return _extract_token(payload, "accessToken")
except (
OSError,
subprocess.TimeoutExpired,
@@ -119,9 +152,37 @@ def _acquire_via_client_credentials(entry: AuthConfigEntry) -> str | None:
headers={"Content-Type": "application/x-www-form-urlencoded"},
)
try:
- with urllib.request.urlopen(req, timeout=30) as resp: # noqa: S310
- payload = _json.loads(resp.read().decode("utf-8"))
- token = payload.get("access_token", "").strip()
- return token or None
- except (urllib.error.URLError, OSError, _json.JSONDecodeError, KeyError):
+ from specify_cli.authentication.http import _StripAuthOnRedirect
+
+ def reject_token_redirect(_old_url: str, new_url: str) -> None:
+ # A 307/308 redirect preserves this POST body, including the
+ # client_secret. Refuse every redirect so credentials cannot
+ # leave the fixed Microsoft token endpoint.
+ raise urllib.error.URLError(
+ f"Azure AD token request must not be redirected to {new_url}"
+ )
+
+ opener = urllib.request.build_opener(
+ _StripAuthOnRedirect((), reject_token_redirect)
+ )
+ with opener.open(req, timeout=30) as resp: # noqa: S310
+ payload = _json.loads(
+ read_response_limited(
+ resp,
+ max_bytes=MAX_JSON_METADATA_BYTES,
+ error_type=_TokenResponseTooLarge,
+ label="Azure DevOps token response",
+ ).decode("utf-8")
+ )
+ return _extract_token(payload, "access_token")
+ except (
+ urllib.error.URLError,
+ OSError,
+ _json.JSONDecodeError,
+ UnicodeDecodeError,
+ _TokenResponseTooLarge,
+ ):
+ # Network failure, malformed JSON, or an oversized response ā fall
+ # through to the next strategy. Unrelated programming errors (other
+ # ValueErrors, KeyErrors) intentionally propagate so they surface.
return None
diff --git a/src/specify_cli/authentication/config.py b/src/specify_cli/authentication/config.py
index 8d1faf80c3..829940d6f7 100644
--- a/src/specify_cli/authentication/config.py
+++ b/src/specify_cli/authentication/config.py
@@ -13,6 +13,7 @@
from dataclasses import dataclass
from fnmatch import fnmatch
from pathlib import Path
+from typing import Any
from urllib.parse import urlparse
@@ -53,6 +54,19 @@ def _is_valid_host_pattern(pattern: str) -> bool:
return pattern.startswith("*.") and "*" not in pattern[2:]
+def _norm(value: Any) -> Any:
+ """Strip surrounding whitespace from a whitespace-insignificant string
+ config reference (env-var names, tenant/client ids) before it is stored.
+
+ These fields are validated on their ``.strip()``ed form, so an accidentally
+ padded value passes validation but then silently breaks the verbatim
+ ``os.environ.get(...)`` / URL lookups downstream. Normalizing at store time
+ mirrors how ``hosts`` is already handled (``h.strip().lower()``). Non-string
+ values (e.g. ``None``) pass through unchanged.
+ """
+ return value.strip() if isinstance(value, str) else value
+
+
def load_auth_config(
path: Path | None = None,
) -> list[AuthConfigEntry]:
@@ -182,10 +196,10 @@ def load_auth_config(
provider=provider,
auth=auth,
token=token,
- token_env=token_env,
- tenant_id=entry_raw.get("tenant_id"),
- client_id=entry_raw.get("client_id"),
- client_secret_env=entry_raw.get("client_secret_env"),
+ token_env=_norm(token_env),
+ tenant_id=_norm(entry_raw.get("tenant_id")),
+ client_id=_norm(entry_raw.get("client_id")),
+ client_secret_env=_norm(entry_raw.get("client_secret_env")),
)
)
diff --git a/src/specify_cli/authentication/http.py b/src/specify_cli/authentication/http.py
index 782403cc2f..aa643c908e 100644
--- a/src/specify_cli/authentication/http.py
+++ b/src/specify_cli/authentication/http.py
@@ -17,6 +17,7 @@
from typing import Callable
from urllib.parse import urlparse
+from .._download_security import is_safe_download_redirect
from . import get_provider
from .config import AuthConfigEntry, _default_config_path, find_entries_for_url, load_auth_config
@@ -60,8 +61,23 @@ def _hostname_in_hosts(hostname: str, hosts: tuple[str, ...]) -> bool:
RedirectValidator = Callable[[str, str], None]
+def _validate_strict_redirect(old_url: str, new_url: str) -> None:
+ if not is_safe_download_redirect(old_url, new_url):
+ raise urllib.error.URLError(
+ f"unsafe redirect to {new_url}: target must use HTTPS with a hostname, "
+ "must not enter a local target from a remote host, and may use HTTP only "
+ "within loopback (for example localhost, 127.0.0.1, ::1)"
+ )
+
+
class _StripAuthOnRedirect(urllib.request.HTTPRedirectHandler):
- """Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades."""
+ """Redirect handler that guards every redirect it is installed for.
+
+ 1. Run any caller-provided redirect validator.
+ 2. Reject redirects that are not HTTPS with a hostname. HTTP loopback is
+ allowed only when the previous hop is also loopback.
+ 3. Drop ``Authorization`` when a redirect leaves trusted hosts or downgrades.
+ """
def __init__(
self,
@@ -75,6 +91,8 @@ def __init__(
def redirect_request(self, req, fp, code, msg, headers, newurl):
try:
new_parsed = urlparse(newurl)
+ # Force urllib's syntax and range validation before following.
+ new_parsed.port
except ValueError as exc:
# Malformed redirect target (e.g. unterminated IPv6 bracket).
# Surface as URLError so callers' download error handling applies.
@@ -82,6 +100,7 @@ def redirect_request(self, req, fp, code, msg, headers, newurl):
if self._redirect_validator is not None:
self._redirect_validator(req.full_url, newurl)
+ _validate_strict_redirect(req.full_url, newurl)
original_auth = (
req.get_header("Authorization")
@@ -155,6 +174,12 @@ def open_url(
*extra_headers* (e.g. ``Accept``) are merged into every attempt.
*redirect_validator*, when provided, is called with ``(old_url, new_url)``
before following each redirect and may raise to reject the redirect.
+
+ Every attempt uses an isolated opener so a process-wide opener installed
+ with ``urllib.request.install_opener`` cannot replace the redirect guard.
+ Redirect scheme safety: every attempt goes through
+ ``_StripAuthOnRedirect``, which rejects redirects to non-HTTPS URLs except
+ HTTP between loopback URLs, and rejects remote-to-local redirects.
"""
entries = find_entries_for_url(url, _load_config())
@@ -188,7 +213,7 @@ def _make_req(auth_headers: dict[str, str]) -> urllib.request.Request:
# No entry worked (or none matched) ā unauthenticated fallback
req = _make_req({})
- if redirect_validator is not None:
- opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
- return opener.open(req, timeout=timeout)
- return urllib.request.urlopen(req, timeout=timeout) # noqa: S310
+ # No auth is attached on this path, so the handler's host list is empty:
+ # here it runs redirect validation only, not auth stripping.
+ opener = urllib.request.build_opener(_StripAuthOnRedirect((), redirect_validator))
+ return opener.open(req, timeout=timeout)
diff --git a/src/specify_cli/bundler/commands_impl/catalog_config.py b/src/specify_cli/bundler/commands_impl/catalog_config.py
index 54839192c4..f763a21c65 100644
--- a/src/specify_cli/bundler/commands_impl/catalog_config.py
+++ b/src/specify_cli/bundler/commands_impl/catalog_config.py
@@ -14,14 +14,13 @@
from ..lib.yamlio import dump_yaml, ensure_within, load_yaml
from ..models.catalog import (
CONFIG_FILENAME,
+ CONFIG_SCHEMA_VERSION,
BUILTIN_DEFAULT_STACK,
CatalogSource,
InstallPolicy,
Scope,
)
-CONFIG_SCHEMA_VERSION = "1.0"
-
_BUILTIN_IDS = {raw["id"] for raw in BUILTIN_DEFAULT_STACK}
# Windows absolute paths like ``C:\catalog.json`` parse with a single-letter
@@ -40,9 +39,12 @@ def _read(project_root: Path) -> list[dict]:
path = ensure_within(project_root, _config_path(project_root))
if not path.exists():
return []
+ # ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
+ # otherwise, so a non-mapping top level ā a falsy ``[]``/``false``/``0``/``''``
+ # or an explicit null (``load_yaml`` -> ``None``) ā is caught by the isinstance
+ # guard below and raised like a truthy one, staying consistent with the other
+ # reader of this file (models/catalog._merge_config).
data = load_yaml(path)
- if data is None:
- return []
if not isinstance(data, dict):
raise BundlerError(
f"Malformed catalog config at {path}: expected a mapping at the top "
@@ -143,6 +145,15 @@ 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
+ # Accessing ``port`` performs urllib's syntax/range validation.
+ _ = parsed.port
except ValueError as exc:
raise BundlerError(f"Invalid catalog url: '{url}'.") from exc
if not (parsed.scheme or parsed.path):
@@ -161,13 +172,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)
diff --git a/src/specify_cli/bundler/lib/yamlio.py b/src/specify_cli/bundler/lib/yamlio.py
index fc90580275..a63d05ba4e 100644
--- a/src/specify_cli/bundler/lib/yamlio.py
+++ b/src/specify_cli/bundler/lib/yamlio.py
@@ -39,17 +39,40 @@ def ensure_within(root: Path, candidate: Path) -> Path:
def load_yaml(path: Path) -> Any:
- """Parse a YAML file, returning ``{}`` for an empty document."""
+ """Parse a YAML file, returning ``{}`` only for an *empty* document.
+
+ A non-empty document is returned exactly as parsed ā including a
+ non-mapping such as ``[]``, ``false``, ``0``, ``''``, or an explicit null
+ (``null``/``~``) ā so callers can validate the top-level shape (e.g. reject
+ a non-mapping config) instead of having it silently coerced to an empty
+ mapping.
+
+ ``yaml.safe_load`` returns ``None`` for *both* an empty document and an
+ explicit null scalar, so ``yaml.compose`` (which yields no node only for a
+ truly empty document) is used to tell them apart: an empty document becomes
+ ``{}`` while an explicit ``null``/``~`` is returned as ``None`` for the
+ caller to reject.
+ """
path = Path(path)
if not path.exists():
raise BundlerError(f"File not found: {path}")
try:
- with path.open("r", encoding="utf-8") as handle:
- return yaml.safe_load(handle) or {}
+ text = path.read_text(encoding="utf-8")
+ except (OSError, UnicodeError) as exc:
+ # A non-UTF-8 file raises UnicodeDecodeError, which is a ValueError --
+ # NOT an OSError -- so it escaped this module's "IO failures degrade
+ # into actionable BundlerError" contract as a raw traceback. Realistic
+ # on Windows, where PowerShell 5.1's `Out-File`/`>` default to UTF-16.
+ # Matches the sibling catalog readers (catalogs.py, workflows/catalog.py).
+ raise BundlerError(f"Could not read {path}: {exc}") from exc
+ try:
+ has_node = yaml.compose(text) is not None
+ data = yaml.safe_load(text)
except yaml.YAMLError as exc:
raise BundlerError(f"Invalid YAML in {path}: {exc}") from exc
- except OSError as exc:
- raise BundlerError(f"Could not read {path}: {exc}") from exc
+ if data is None and not has_node:
+ return {}
+ return data
def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
@@ -60,7 +83,13 @@ def dump_yaml(path: Path, data: Any, *, within: Path | None = None) -> Path:
try:
path.parent.mkdir(parents=True, exist_ok=True)
with path.open("w", encoding="utf-8") as handle:
- yaml.safe_dump(data, handle, sort_keys=False, default_flow_style=False)
+ yaml.safe_dump(
+ data,
+ handle,
+ sort_keys=False,
+ default_flow_style=False,
+ allow_unicode=True,
+ )
except OSError as exc:
raise BundlerError(f"Could not write {path}: {exc}") from exc
return path
@@ -74,9 +103,15 @@ def load_json(path: Path) -> Any:
try:
with path.open("r", encoding="utf-8") as handle:
return json.load(handle)
+ # JSONDecodeError stays FIRST: it and UnicodeDecodeError are sibling
+ # ValueError subclasses (neither subsumes the other), so malformed-but-
+ # decodable JSON keeps its more specific "Invalid JSON" message while a
+ # decode failure falls through to the read-error clause below.
except json.JSONDecodeError as exc:
raise BundlerError(f"Invalid JSON in {path}: {exc}") from exc
- except OSError as exc:
+ except (OSError, UnicodeError) as exc:
+ # See load_yaml: a non-UTF-8 file raises UnicodeDecodeError, which is
+ # not an OSError, and previously escaped as a raw traceback.
raise BundlerError(f"Could not read {path}: {exc}") from exc
diff --git a/src/specify_cli/bundler/models/catalog.py b/src/specify_cli/bundler/models/catalog.py
index dd069f5bc9..53e83a52e7 100644
--- a/src/specify_cli/bundler/models/catalog.py
+++ b/src/specify_cli/bundler/models/catalog.py
@@ -15,6 +15,11 @@
from ..lib.yamlio import ensure_within, load_yaml
CONFIG_FILENAME = "bundle-catalogs.yml"
+# Supported bundle-catalogs.yml schema (major version). Both readers of the
+# file ā this module's _merge_config and commands_impl/catalog_config._read ā
+# reject an unsupported major version so a file written by a newer/incompatible
+# Spec Kit fails fast instead of being parsed under the wrong assumptions.
+CONFIG_SCHEMA_VERSION = "1.0"
class InstallPolicy(str, Enum):
@@ -43,7 +48,7 @@ class Scope(str, Enum):
BUILTIN_DEFAULT_STACK: tuple[dict[str, Any], ...] = (
{"id": "default", "url": "builtin://default", "priority": 1,
"install_policy": InstallPolicy.INSTALL_ALLOWED.value},
- {"id": "community", "url": "builtin://community", "priority": 2,
+ {"id": "community", "url": "builtin://community", "priority": 20,
"install_policy": InstallPolicy.DISCOVERY_ONLY.value},
)
@@ -139,6 +144,7 @@ class CatalogEntry:
license: str
download_url: str
requires_speckit_version: str
+ sha256: str | None = None
provides: dict[str, int] = field(default_factory=dict)
repository: str | None = None
tags: tuple[str, ...] = ()
@@ -152,14 +158,21 @@ def from_dict(cls, data: Any) -> "CatalogEntry":
if not isinstance(data, dict):
raise BundlerError("Each catalog entry must be a mapping.")
entry_id = str(data.get("id", "")).strip()
- requires = data.get("requires") or {}
- if not isinstance(requires, dict):
+ # `or {}` would coerce a FALSY non-mapping (0, '', False, []) to {} before
+ # the isinstance guard, silently accepting a corrupt catalog entry; only
+ # an absent/None value means "not present".
+ requires = data.get("requires")
+ if requires is None:
+ requires = {}
+ elif not isinstance(requires, dict):
raise BundlerError(
f"Catalog entry '{entry_id or ''}': 'requires' must be a "
"mapping when present."
)
- provides_raw = data.get("provides") or {}
- if not isinstance(provides_raw, dict):
+ provides_raw = data.get("provides")
+ if provides_raw is None:
+ provides_raw = {}
+ elif not isinstance(provides_raw, dict):
raise BundlerError(
f"Catalog entry '{entry_id or ''}': 'provides' must be a "
"mapping when present."
@@ -174,6 +187,11 @@ def from_dict(cls, data: Any) -> "CatalogEntry":
license=str(data.get("license", "")).strip(),
download_url=str(data.get("download_url", "")).strip(),
requires_speckit_version=str(requires.get("speckit_version", "")).strip(),
+ sha256=(
+ None
+ if data.get("sha256") is None
+ else str(data["sha256"]).strip()
+ ),
provides=dict(provides_raw),
repository=(str(data["repository"]) if data.get("repository") else None),
tags=_parse_tags(data.get("tags"), entry_id),
@@ -186,6 +204,7 @@ def with_provenance(self, source: CatalogSource) -> "CatalogEntry":
description=self.description, author=self.author, license=self.license,
download_url=self.download_url,
requires_speckit_version=self.requires_speckit_version,
+ sha256=self.sha256,
provides=self.provides, repository=self.repository, tags=self.tags,
verified=self.verified, source_id=source.id,
source_policy=source.install_policy,
@@ -249,10 +268,51 @@ def load_source_stack(project_root: Path, user_config_dir: Path | None = None) -
def _merge_config(by_id: dict[str, CatalogSource], config_path: Path, scope: Scope) -> None:
if not config_path.exists():
return
+ # ``load_yaml`` returns ``{}`` only for an empty document and the raw parse
+ # otherwise, so a non-mapping top level (a YAML list or scalar, including
+ # the falsy ``[]``/``false``/``0``/``''``) is caught here and raised ā
+ # matching the sibling reader commands_impl/catalog_config._read. #3623
+ # aligned the inner non-list ``catalogs`` value between the two readers.
data = load_yaml(config_path)
- catalogs = data.get("catalogs") if isinstance(data, dict) else None
- if not catalogs:
+ if not isinstance(data, dict):
+ raise BundlerError(
+ f"Malformed catalog config at {config_path}: expected a mapping at "
+ f"the top level, got {type(data).__name__}."
+ )
+ # Reject an unsupported major schema version, matching the sibling reader
+ # commands_impl/catalog_config._read. Without this, a file written by a
+ # newer/incompatible Spec Kit was silently parsed under v1 assumptions on
+ # the resolution path (bundle search/install), while the other reader
+ # rejected it ā the two readers disagreed. An absent schema_version stays
+ # valid (backward compatible with configs that omit it).
+ schema_version = data.get("schema_version")
+ if schema_version is not None and (
+ str(schema_version).strip().split(".")[0]
+ != CONFIG_SCHEMA_VERSION.split(".")[0]
+ ):
+ raise BundlerError(
+ f"Unsupported catalog config schema version "
+ f"'{str(schema_version).strip()}' at {config_path}; this Spec Kit "
+ f"understands version {CONFIG_SCHEMA_VERSION}. The file may have been "
+ "written by a newer version or is corrupt."
+ )
+ catalogs = data.get("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
diff --git a/src/specify_cli/bundler/models/manifest.py b/src/specify_cli/bundler/models/manifest.py
index 4a903fbd18..032863a2e8 100644
--- a/src/specify_cli/bundler/models/manifest.py
+++ b/src/specify_cli/bundler/models/manifest.py
@@ -96,37 +96,46 @@ def from_dict(cls, data: Any) -> "BundleManifest":
if not isinstance(data, dict):
raise BundlerError("Manifest must be a YAML mapping at the top level.")
- schema_version = str(data.get("schema_version", "")).strip()
+ schema_version = _text(data.get("schema_version"))
bundle_raw = data.get("bundle")
if not isinstance(bundle_raw, dict):
raise BundlerError("Manifest is missing the required 'bundle' mapping.")
meta = BundleMeta(
- id=str(bundle_raw.get("id", "")).strip(),
- name=str(bundle_raw.get("name", "")).strip(),
- version=str(bundle_raw.get("version", "")).strip(),
- role=str(bundle_raw.get("role", "")).strip(),
- description=str(bundle_raw.get("description", "")).strip(),
- author=str(bundle_raw.get("author", "")).strip(),
- license=str(bundle_raw.get("license", "")).strip(),
+ id=_text(bundle_raw.get("id")),
+ name=_text(bundle_raw.get("name")),
+ version=_text(bundle_raw.get("version")),
+ role=_text(bundle_raw.get("role")),
+ description=_text(bundle_raw.get("description")),
+ author=_text(bundle_raw.get("author")),
+ license=_text(bundle_raw.get("license")),
)
- requires_raw = data.get("requires") or {}
- if not isinstance(requires_raw, dict):
+ requires_raw = data.get("requires")
+ if requires_raw is None:
+ requires_raw = {}
+ elif not isinstance(requires_raw, dict):
raise BundlerError("'requires' must be a mapping when present.")
requires = Requires(
- speckit_version=str(requires_raw.get("speckit_version", "")).strip(),
+ speckit_version=_text(requires_raw.get("speckit_version")),
tools=_parse_str_list(requires_raw.get("tools"), "requires.tools"),
mcp=_parse_str_list(requires_raw.get("mcp"), "requires.mcp"),
)
integration = None
integration_raw = data.get("integration")
+ # Mirror the requires/provides guards above: a present-but-non-mapping
+ # 'integration' (e.g. a bare string "copilot") was silently dropped,
+ # leaving the bundle wrongly integration-agnostic. Reject it instead.
+ if integration_raw is not None and not isinstance(integration_raw, dict):
+ raise BundlerError("'integration' must be a mapping when present.")
if isinstance(integration_raw, dict) and integration_raw.get("id"):
integration = IntegrationRef(id=str(integration_raw["id"]).strip())
- provides = data.get("provides") or {}
- if not isinstance(provides, dict):
+ provides = data.get("provides")
+ if provides is None:
+ provides = {}
+ elif not isinstance(provides, dict):
raise BundlerError("'provides' must be a mapping when present.")
tags_raw = data.get("tags")
@@ -211,6 +220,22 @@ def is_agnostic(self) -> bool:
return self.integration is None
+def _text(raw: Any) -> str:
+ """Coerce a manifest scalar into stripped text, mapping an explicit null to ``""``.
+
+ A ``.get(key, "")`` default only covers a *missing* key. A key that is
+ present but null -- how YAML spells an empty field (``author:`` with nothing
+ after it) -- yields ``None``, and ``str(None)`` is the literal ``"None"``.
+ That text is non-empty, so it sailed past the ``if not value`` required-field
+ checks in :meth:`BundleManifest.structural_errors`: an empty required field
+ was silently accepted and the bundle shipped ``"None"`` as its
+ author/license/description.
+ """
+ if raw is None:
+ return ""
+ return str(raw).strip()
+
+
def _parse_str_list(raw: Any, field_name: str) -> tuple[str, ...]:
"""Coerce a manifest list-of-strings field into a tuple of strings.
@@ -238,7 +263,7 @@ def _parse_refs(kind: str, raw: Any) -> list[ComponentRef]:
refs.append(
ComponentRef(
kind=kind,
- id=str(item.get("id", "")).strip(),
+ id=_text(item.get("id")),
version=(str(item["version"]).strip() if item.get("version") else None),
source=(str(item["source"]).strip() if item.get("source") else None),
priority=priority,
diff --git a/src/specify_cli/bundler/models/records.py b/src/specify_cli/bundler/models/records.py
index 15c53523c3..2d0c8b73a0 100644
--- a/src/specify_cli/bundler/models/records.py
+++ b/src/specify_cli/bundler/models/records.py
@@ -55,8 +55,13 @@ def to_dict(self) -> dict[str, Any]:
def from_dict(cls, data: Any) -> "InstalledBundleRecord":
if not isinstance(data, dict):
raise BundlerError("Each installed-bundle record must be a mapping.")
- components_raw = data.get("contributed_components") or []
- if not isinstance(components_raw, list):
+ components_raw = data.get("contributed_components")
+ if components_raw is None:
+ components_raw = []
+ elif not isinstance(components_raw, list):
+ # `or []` would coerce a FALSY non-list (0, '', False, {}) to []
+ # before this guard, silently accepting a corrupt record; only an
+ # absent/None value means "no components".
raise BundlerError(
"Corrupt record: 'contributed_components' must be a list."
)
@@ -121,8 +126,13 @@ def load_records(project_root: Path) -> list[InstalledBundleRecord]:
if not isinstance(data, dict):
raise BundlerError(f"Corrupt records file: {path}")
_check_schema_version(data.get("schema_version"), path=path, required=True)
- bundles = data.get("bundles") or []
- if not isinstance(bundles, list):
+ bundles = data.get("bundles")
+ if bundles is None:
+ bundles = []
+ elif not isinstance(bundles, list):
+ # `or []` would coerce a FALSY non-list (0, '', False, {}) to [] before
+ # this guard, silently treating a corrupt file as "no bundles"; only an
+ # absent/None value means empty.
raise BundlerError(
f"Corrupt records file: {path} ā 'bundles' must be a list."
)
diff --git a/src/specify_cli/bundler/services/adapters.py b/src/specify_cli/bundler/services/adapters.py
index 80fd7fc4fc..403232a7f0 100644
--- a/src/specify_cli/bundler/services/adapters.py
+++ b/src/specify_cli/bundler/services/adapters.py
@@ -15,25 +15,27 @@
from urllib.parse import ParseResult, urlparse
from urllib.request import url2pathname
+from ..._assets import _locate_core_pack, _repo_root
+from ..._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
from .. import BundlerError
from ..lib.yamlio import loads_json
from ..models.catalog import CatalogSource
from ..models.manifest import ComponentRef
-# Built-in catalog payloads ship empty by default; a host distribution can
-# replace these with curated content. Keeping them here makes ``search``/``info``
-# work fully offline against the default stack.
+COMMUNITY_CATALOG_URL = (
+ "https://raw.githubusercontent.com/github/spec-kit/main/"
+ "bundles/catalog.community.json"
+)
+
+# The default catalog is reserved for first-party bundles. The community
+# catalog is loaded from the repository online and from the packaged snapshot
+# offline so discovery remains useful without network access.
_BUILTIN_CATALOGS: dict[str, dict] = {
"builtin://default": {
"schema_version": "1.0",
"catalog_url": "builtin://default",
"bundles": {},
},
- "builtin://community": {
- "schema_version": "1.0",
- "catalog_url": "builtin://community",
- "bundles": {},
- },
}
HTTP_TIMEOUT_SECONDS = 10
@@ -75,6 +77,8 @@ def _validate_remote_url(source_id: str, url: str) -> None:
try:
parsed = urlparse(url)
hostname = parsed.hostname
+ # Accessing ``port`` performs urllib's syntax/range validation.
+ _ = parsed.port
except ValueError:
raise BundlerError(
f"Catalog '{source_id}' URL is malformed: {url}"
@@ -95,6 +99,18 @@ def _validate_remote_url(source_id: str, url: str) -> None:
)
+def _load_packaged_community_catalog() -> dict:
+ core_pack = _locate_core_pack()
+ path = (
+ core_pack / "bundles" / "catalog.community.json"
+ if core_pack is not None
+ else _repo_root() / "bundles" / "catalog.community.json"
+ )
+ if not path.is_file():
+ raise BundlerError(f"Bundled community catalog not found: {path}")
+ return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
+
+
def make_catalog_fetcher(*, allow_network: bool = True):
"""Return a fetcher callable suitable for :class:`CatalogStack`.
@@ -104,10 +120,22 @@ def make_catalog_fetcher(*, allow_network: bool = True):
def fetch(source: CatalogSource) -> dict:
url = source.url
- parsed = urlparse(url)
+ try:
+ parsed = urlparse(url)
+ # Keep malformed authorities and ports inside the BundlerError
+ # contract even when a config file was edited by hand.
+ _ = parsed.port
+ except ValueError:
+ raise BundlerError(
+ f"Catalog {source.id!r} URL is malformed: {url!r}"
+ ) from None
scheme = parsed.scheme.lower()
if scheme == "builtin":
+ if url == "builtin://community":
+ if allow_network:
+ return _http_get_json(source.id, COMMUNITY_CATALOG_URL)
+ return _load_packaged_community_catalog()
payload = _BUILTIN_CATALOGS.get(url)
if payload is None:
raise BundlerError(f"Unknown built-in catalog '{url}'.")
@@ -163,7 +191,12 @@ def _validate_redirect(_old_url: str, new_url: str) -> None:
) as response:
final_url = response.geturl()
_validate_remote_url(source_id, final_url)
- raw = response.read().decode("utf-8")
+ raw = read_response_limited(
+ response,
+ max_bytes=MAX_JSON_CATALOG_BYTES,
+ error_type=BundlerError,
+ label=f"bundle catalog '{source_id}'",
+ ).decode("utf-8")
except BundlerError:
raise
except Exception as exc: # noqa: BLE001
diff --git a/src/specify_cli/bundler/services/installer.py b/src/specify_cli/bundler/services/installer.py
index 7c5abf84db..58e220638d 100644
--- a/src/specify_cli/bundler/services/installer.py
+++ b/src/specify_cli/bundler/services/installer.py
@@ -50,7 +50,10 @@ class InstallResult:
@property
def changed(self) -> bool:
- return bool(self.installed or self.refreshed)
+ # `uninstalled` is a mutating outcome too: a `bundle update` whose new
+ # manifest drops components (removing them via the refresh path) with no
+ # new install/refresh must still report changed=True, not a no-op.
+ return bool(self.installed or self.refreshed or self.uninstalled)
def install_bundle(
diff --git a/src/specify_cli/bundler/services/packager.py b/src/specify_cli/bundler/services/packager.py
index 481a0a2bb8..6a0778e3ab 100644
--- a/src/specify_cli/bundler/services/packager.py
+++ b/src/specify_cli/bundler/services/packager.py
@@ -142,4 +142,10 @@ def _collect_files(
# Skip symlinked files to avoid escaping the bundle directory.
continue
collected.append(path)
- return sorted(collected)
+ # Order by the canonical POSIX arcname (the same key build_bundle uses to
+ # NAME each member), not by pathlib.Path comparison. Path ordering is
+ # platform-dependent (Windows folds case and uses backslash separators),
+ # which would lay out zip members differently across build hosts and break
+ # the byte-for-byte reproducible-build guarantee even though the member
+ # names are identical.
+ return sorted(collected, key=lambda p: p.relative_to(bundle_dir).as_posix())
diff --git a/src/specify_cli/catalogs.py b/src/specify_cli/catalogs.py
index e4df8eae28..774aaa51d7 100644
--- a/src/specify_cli/catalogs.py
+++ b/src/specify_cli/catalogs.py
@@ -149,7 +149,10 @@ def _load_catalog_config(self, config_path: Path) -> list[CatalogEntry] | None:
)
try:
priority = int(raw_priority)
- except (TypeError, ValueError):
+ except (TypeError, ValueError, OverflowError):
+ # OverflowError: int(float("inf")) ā a YAML ``priority: .inf``
+ # would otherwise escape as an uncaught traceback instead of the
+ # clean validation error.
raise self._validation_error(
f"Invalid catalog config {config_path}: "
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
diff --git a/src/specify_cli/commands/bundle/__init__.py b/src/specify_cli/commands/bundle/__init__.py
index 6a2bd25583..38100be3d8 100644
--- a/src/specify_cli/commands/bundle/__init__.py
+++ b/src/specify_cli/commands/bundle/__init__.py
@@ -12,8 +12,10 @@
from pathlib import Path
import typer
+from rich.markup import escape as _escape_markup
from ..._console import console, err_console
+from ..._download_security import MAX_DOWNLOAD_BYTES, read_response_limited
from ...bundler import BundlerError
from ...bundler.lib.project import (
active_integration,
@@ -184,11 +186,16 @@ def bundle_search(
else ""
)
console.print(
- f" [bold]{r.entry.id}[/bold] v{r.entry.version} ā {r.entry.name} "
- f"[dim]({r.entry.role})[/dim] {_trust_badge(r.entry.verified)} {policy}"
+ f" [bold]{_escape_markup(str(r.entry.id))}[/bold] "
+ f"v{_escape_markup(str(r.entry.version))} ā "
+ f"{_escape_markup(str(r.entry.name))} "
+ f"[dim]({_escape_markup(str(r.entry.role))})[/dim] "
+ f"{_trust_badge(r.entry.verified)} {policy}"
+ )
+ console.print(f" {_escape_markup(str(r.entry.description))}")
+ console.print(
+ f" [dim]source: {_escape_markup(str(r.source.id))}[/dim]"
)
- console.print(f" {r.entry.description}")
- console.print(f" [dim]source: {r.source.id}[/dim]")
@bundle_app.command("info")
@@ -241,16 +248,31 @@ def bundle_info(
print(_json.dumps(payload, indent=2))
return
- console.print(f"\n[bold cyan]{entry.id}[/bold cyan] v{entry.version} ā {entry.name}")
- console.print(f" Role: {entry.role}")
- console.print(f" {entry.description}")
- console.print(f" Author: {entry.author} License: {entry.license}")
- console.print(f" Source: {resolved.source.id} ({resolved.source.install_policy.value})")
+ console.print(
+ f"\n[bold cyan]{_escape_markup(str(entry.id))}[/bold cyan] "
+ f"v{_escape_markup(str(entry.version))} ā "
+ f"{_escape_markup(str(entry.name))}"
+ )
+ console.print(f" Role: {_escape_markup(str(entry.role))}")
+ console.print(f" {_escape_markup(str(entry.description))}")
+ console.print(
+ f" Author: {_escape_markup(str(entry.author))} "
+ f"License: {_escape_markup(str(entry.license))}"
+ )
+ console.print(
+ f" Source: {_escape_markup(str(resolved.source.id))} "
+ f"({resolved.source.install_policy.value})"
+ )
console.print(f" Trust: {_trust_badge(entry.verified)}")
if entry.requires_speckit_version:
- console.print(f" Requires Spec Kit: {entry.requires_speckit_version}")
+ console.print(
+ f" Requires Spec Kit: "
+ f"{_escape_markup(str(entry.requires_speckit_version))}"
+ )
if manifest and manifest.integration:
- console.print(f" Integration: {manifest.integration.id}")
+ console.print(
+ f" Integration: {_escape_markup(str(manifest.integration.id))}"
+ )
if components:
console.print("\n [bold]Components[/bold] (added on install):")
@@ -260,18 +282,22 @@ def bundle_info(
continue
console.print(f" [bold]{kind}:[/bold]")
for item in items:
- console.print(f" - {_format_component(item)}")
+ console.print(
+ f" - {_escape_markup(_format_component(item))}"
+ )
else:
console.print("\n [bold]Provides:[/bold]")
for kind in ("extensions", "presets", "steps", "workflows"):
count = entry.provides.get(kind, 0)
if count:
- console.print(f" {kind}: {count}")
+ console.print(f" {kind}: {_escape_markup(str(count))}")
if overlaps:
console.print("\n [yellow]Overlaps with already-installed bundles:[/yellow]")
for overlap in overlaps:
- console.print(f" [yellow]-[/yellow] {overlap}")
+ console.print(
+ f" [yellow]-[/yellow] {_escape_markup(str(overlap))}"
+ )
if not resolved.install_allowed:
console.print(
@@ -337,6 +363,10 @@ def bundle_install(
local_manifest = _local_manifest_source(bundle_id)
if local_manifest is not None:
manifest = local_manifest
+ _validate_manifest_structure(
+ manifest,
+ source=f"Local bundle source {bundle_id!r}",
+ )
else:
stack = _build_stack(project_root or Path.cwd(), offline=offline)
resolved = stack.resolve(bundle_id)
@@ -350,6 +380,16 @@ def bundle_install(
if project_root is None:
init_integration = _resolve_init_integration(integration, manifest)
+ # Resolve all hard compatibility gates before ``specify init``.
+ # Otherwise an incompatible but structurally valid bundle would
+ # initialize a project and only then fail its version/integration
+ # checks, leaving state behind after a failed install.
+ resolve_install_plan(
+ manifest,
+ speckit_version=_speckit_version(),
+ active_integration=init_integration,
+ integration_explicit=True,
+ )
console.print(
f"[cyan]No Spec Kit project here; initializing with integration "
f"'{init_integration}'ā¦[/cyan]"
@@ -711,17 +751,24 @@ def _local_manifest_source(arg: str):
if candidate.suffix == ".zip":
import io
- import zipfile
import yaml as _yaml
- with zipfile.ZipFile(candidate) as archive:
+ from ..._download_security import open_zip_bounded, read_zip_member_limited
+
+ with open_zip_bounded(candidate, error_type=BundlerError) as archive:
try:
- raw = archive.read("bundle.yml")
+ archive.getinfo("bundle.yml")
except KeyError as exc:
raise BundlerError(
f"Artifact '{candidate}' does not contain a bundle.yml."
) from exc
+ raw = read_zip_member_limited(
+ archive,
+ "bundle.yml",
+ error_type=BundlerError,
+ label="bundle manifest",
+ )
data = _yaml.safe_load(io.BytesIO(raw))
return BundleManifest.from_dict(data)
@@ -805,7 +852,13 @@ def _download_manifest(resolved, *, offline: bool):
f"Network access disabled; cannot download bundle '{resolved.entry.id}' "
f"from {url}."
)
- return _download_remote_manifest(resolved.entry.id, url)
+ manifest = _download_remote_manifest(
+ resolved.entry.id,
+ url,
+ expected_sha256=getattr(resolved.entry, "sha256", None),
+ )
+ _validate_catalog_manifest(resolved.entry, manifest)
+ return manifest
def _require_https(label: str, url: str) -> None:
@@ -817,6 +870,8 @@ def _require_https(label: str, url: str) -> None:
try:
parsed = urlparse(url)
hostname = parsed.hostname
+ # Accessing ``port`` performs urllib's syntax/range validation.
+ _ = parsed.port
except ValueError:
raise BundlerError(
f"Refusing to download {label}: URL is malformed: {url}"
@@ -826,11 +881,16 @@ def _require_https(label: str, url: str) -> None:
raise BundlerError(
f"Refusing to download {label} over non-HTTPS URL: {url}"
)
- if not hostname:
+ if not parsed.hostname:
raise BundlerError(f"Refusing to download {label} from URL with no host: {url}")
-def _download_remote_manifest(entry_id: str, url: str):
+def _download_remote_manifest(
+ entry_id: str,
+ url: str,
+ *,
+ expected_sha256: str | None = None,
+):
"""Fetch a remote bundle artifact over HTTPS and extract its manifest."""
import io
import tempfile
@@ -842,6 +902,7 @@ def _download_remote_manifest(entry_id: str, url: str):
from ...authentication.http import github_provider_hosts, open_url
from ..._github_http import resolve_github_release_asset_api_url
from ...bundler.models.manifest import BundleManifest
+ from ...shared_infra import verify_archive_sha256
def _validate_redirect(old_url: str, new_url: str) -> None:
_require_https(f"bundle '{entry_id}'", new_url)
@@ -879,7 +940,18 @@ def _validate_redirect(old_url: str, new_url: str) -> None:
extra_headers=extra_headers,
) as resp:
_require_https(f"bundle '{entry_id}'", resp.geturl())
- raw = resp.read()
+ raw = read_response_limited(
+ resp,
+ max_bytes=MAX_DOWNLOAD_BYTES,
+ error_type=BundlerError,
+ label=f"bundle '{entry_id}' download",
+ )
+ verify_archive_sha256(
+ raw,
+ expected_sha256,
+ entry_id,
+ BundlerError,
+ )
except BundlerError:
raise
except Exception as exc: # noqa: BLE001
@@ -940,6 +1012,38 @@ def _validate_redirect(old_url: str, new_url: str) -> None:
) from exc
+def _validate_manifest_structure(manifest, *, source: str) -> None:
+ """Reject a malformed manifest before any project mutation can occur."""
+ from ...bundler.services.validator import validate_manifest
+
+ report = validate_manifest(manifest)
+ if report.ok:
+ return
+ raise BundlerError(
+ f"{source} contains an invalid bundle manifest:\n - "
+ + "\n - ".join(report.errors)
+ )
+
+
+def _validate_catalog_manifest(entry, manifest) -> None:
+ """Bind a downloaded manifest to the catalog identity that selected it."""
+ if manifest.bundle.id != entry.id:
+ raise BundlerError(
+ f"Downloaded bundle id mismatch: catalog entry {entry.id!r} points to "
+ f"a manifest for {manifest.bundle.id!r}."
+ )
+ if manifest.bundle.version != entry.version:
+ raise BundlerError(
+ f"Downloaded bundle version mismatch for {entry.id!r}: catalog declares "
+ f"{entry.version!r}, but the manifest declares "
+ f"{manifest.bundle.version!r}."
+ )
+ _validate_manifest_structure(
+ manifest,
+ source=f"Downloaded bundle {entry.id!r}",
+ )
+
+
def register(app: typer.Typer) -> None:
"""Attach the bundle command group to the root Typer app."""
app.add_typer(bundle_app, name="bundle")
diff --git a/src/specify_cli/commands/event.py b/src/specify_cli/commands/event.py
new file mode 100644
index 0000000000..764fde7f6b
--- /dev/null
+++ b/src/specify_cli/commands/event.py
@@ -0,0 +1,39 @@
+"""specify event * command handlers."""
+
+from __future__ import annotations
+
+from pathlib import Path
+import sys
+import typer
+
+event_app = typer.Typer(
+ name="event",
+ help="Manage and execute event-driven commands",
+ add_completion=False,
+)
+
+
+@event_app.command("run")
+def event_run(
+ command_name: str = typer.Argument(..., help="Name of the command to execute"),
+ event_name: str = typer.Argument(..., help="Canonical event name (e.g., session_start)"),
+ timeout: int = typer.Argument(
+ 120, help="Per-handler timeout in seconds (passed through from the native hook config)"
+ ),
+):
+ """Resolve and run an event-driven command script with stdin payload."""
+ from ..events import resolve_and_run_event_command
+
+ # Read payload from stdin if available
+ payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
+
+ # Run the event command
+ project_root = Path.cwd() # The agent runs events from project root
+ exit_code = resolve_and_run_event_command(
+ command_name, event_name, payload, project_root, timeout=timeout
+ )
+ raise typer.Exit(code=exit_code)
+
+
+def register(app: typer.Typer) -> None:
+ app.add_typer(event_app, name="event")
diff --git a/src/specify_cli/commands/init.py b/src/specify_cli/commands/init.py
index 1a1dd046a8..20471d7220 100644
--- a/src/specify_cli/commands/init.py
+++ b/src/specify_cli/commands/init.py
@@ -86,7 +86,7 @@ def init(
help="Name for your new project directory (optional if using --here, or use '.' for current directory)",
),
script_type: str = typer.Option(
- None, "--script", help="Script type to use: sh or ps"
+ None, "--script", help="Script type to use: sh, ps, or py"
),
ignore_agent_tools: bool = typer.Option(
False,
@@ -183,6 +183,7 @@ def init(
save_init_options,
)
from ..integration_runtime import (
+ invoke_prefix_for_integration as _invoke_prefix_for_integration,
with_integration_setting as _with_integration_setting,
)
from ..integrations._commands import (
@@ -442,12 +443,20 @@ def init(
if extra:
integration_parsed_options.update(extra)
+ from ..events import resolve_events
+ events_map = resolve_events(
+ resolved_integration.key,
+ resolved_integration.config,
+ project_path,
+ integration_parsed_options or None,
+ )
resolved_integration.setup(
project_path,
manifest,
parsed_options=integration_parsed_options or None,
script_type=selected_script,
raw_options=integration_options,
+ events=events_map,
)
manifest.save()
@@ -458,6 +467,7 @@ def init(
script_type=selected_script,
raw_options=integration_options,
parsed_options=integration_parsed_options or None,
+ project_root=project_path,
)
_write_integration_json(
project_path,
@@ -478,7 +488,13 @@ def init(
tracker=tracker,
force=force,
invoke_separator=resolved_integration.effective_invoke_separator(
- integration_parsed_options
+ integration_parsed_options, project_root=project_path
+ ),
+ invoke_prefix=_invoke_prefix_for_integration(
+ resolved_integration,
+ resolved_integration.key,
+ integration_parsed_options,
+ project_path,
),
)
tracker.complete(
@@ -532,10 +548,8 @@ def init(
"feature_numbering": "sequential",
"speckit_version": get_speckit_version(),
}
- from ..integrations.base import SkillsIntegration as _SkillsPersist
-
- if isinstance(resolved_integration, _SkillsPersist) or getattr(
- resolved_integration, "_skills_mode", False
+ if resolved_integration.is_skills_mode(
+ integration_parsed_options or None, project_root=project_path
):
init_opts["ai_skills"] = True
save_init_options(project_path, init_opts)
@@ -683,11 +697,9 @@ def init(
steps_lines.append("1. You're already in the project directory!")
step_num = 2
- from ..integrations.base import SkillsIntegration as _SkillsInt
-
- _is_skills_integration = isinstance(
- resolved_integration, _SkillsInt
- ) or getattr(resolved_integration, "_skills_mode", False)
+ _is_skills_integration = resolved_integration.is_skills_mode(
+ integration_parsed_options or None, project_root=project_path
+ )
codex_skill_mode = selected_ai == "codex" and _is_skills_integration
zcode_skill_mode = selected_ai == "zcode" and _is_skills_integration
@@ -703,6 +715,8 @@ def init(
zed_skill_mode = selected_ai == "zed" and _is_skills_integration
grok_skill_mode = selected_ai == "grok" and _is_skills_integration
cline_skill_mode = selected_ai == "cline"
+ forge_skill_mode = selected_ai == "forge"
+ bob_skill_mode = selected_ai == "bob" and _is_skills_integration
native_skill_mode = (
codex_skill_mode
or zcode_skill_mode
@@ -715,6 +729,7 @@ def init(
or devin_skill_mode
or zed_skill_mode
or grok_skill_mode
+ or bob_skill_mode
)
if codex_skill_mode:
@@ -752,6 +767,11 @@ def init(
f"{step_num}. Start Grok Build in this project directory; spec-kit skills were installed to [cyan].grok/skills[/cyan]"
)
step_num += 1
+ if bob_skill_mode:
+ steps_lines.append(
+ f"{step_num}. Start Bob in this project directory; spec-kit skills were installed to [cyan].bob/skills[/cyan]"
+ )
+ step_num += 1
usage_label = "skills" if native_skill_mode else "slash commands"
from .._invocation_style import (
@@ -772,6 +792,7 @@ def _display_cmd(name: str) -> str:
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}"
diff --git a/src/specify_cli/events.py b/src/specify_cli/events.py
new file mode 100644
index 0000000000..686ed410e3
--- /dev/null
+++ b/src/specify_cli/events.py
@@ -0,0 +1,2096 @@
+"""Agent runtime events for integrations.
+
+Provides:
+- ``resolve_events`` ā layered event resolution (CLI flag ā YAML override ā extension-declared ā built-in).
+- ``collect_extension_events`` ā scan installed extension.yml files for ``events:``.
+- ``install_integration_events`` / ``remove_integration_events`` ā entry points called from ``IntegrationBase.setup()`` / ``teardown()``.
+"""
+
+from __future__ import annotations
+
+import json
+import logging
+import os
+import re
+import shlex
+import shutil
+import sys
+import subprocess
+import platform
+from pathlib import Path
+from typing import TYPE_CHECKING, Any
+
+import yaml
+
+if TYPE_CHECKING:
+ from .integrations.base import IntegrationBase
+ from .integrations.manifest import IntegrationManifest
+
+logger = logging.getLogger(__name__)
+
+# -- Constants -------------------------------------------------------------
+
+EVENTS_DISPATCHER_DIR = Path(".specify")
+EVENTS_DISPATCHER_FILENAME = "events.py"
+# POSIX-form (forward-slash) relative path so it matches manifest keys, which
+# are always stored in POSIX form (record_file/record_existing normalize via
+# .as_posix()). On Windows, str(Path(".specify")/"events.py") yields
+# ".specify\\events.py", which never matched a manifest key, so the shared-
+# dispatcher manifest-claim drop was skipped and uninstall(force=True) deleted
+# the dispatcher another integration still depended on.
+EVENTS_DISPATCHER_REL = (EVENTS_DISPATCHER_DIR / EVENTS_DISPATCHER_FILENAME).as_posix()
+
+YAML_OVERRIDE_FILENAME = Path(".specify") / "integration-events.yml"
+
+_SPECKIT_MARKER = "__speckit_event__"
+
+# Buffer (seconds) added to the native hook timeout so the agent's outer cap
+# fires after the dispatcher's inner subprocess timeout, letting the inner
+# kill its child cleanly instead of being killed mid-flight (which orphans
+# the grandchild script process). The dispatcher receives the raw seconds
+# (no buffer); the native config field gets seconds + buffer (R2).
+EVENT_TIMEOUT_BUFFER = 5
+
+# Canonical event names (snake_case)
+CANONICAL_EVENTS = frozenset({
+ "session_start",
+ "pre_tool_use",
+ "post_tool_use",
+ "session_end",
+ "user_prompt_submit",
+ "stop",
+})
+
+# -- Events Dispatcher template ---------------------------------------------
+
+_EVENTS_DISPATCHER_TEMPLATE = '''#!/usr/bin/env python3
+"""Specify CLI Event Dispatcher ā dispatches agent runtime events.
+
+Generated by: specify integration install/upgrade
+Do not edit manually.
+
+Self-contained: it prefers `specify_cli` when the package is importable
+(durable pip/pipx/uv-tool install) and falls back to an inline stdlib-only
+resolver when Spec Kit is not installed at runtime ā e.g. a one-time `uvx`
+init whose environment is discarded after `specify init` finishes (R1). In
+both cases it resolves the event's command template and runs its script
+directly, without requiring a persistent `specify` executable on PATH.
+"""
+import json
+import os
+import re
+import shlex
+import shutil
+import subprocess
+import sys
+from pathlib import Path
+
+
+def _find_command_template(command_name, project_root):
+ """Locate the command's .md template. Returns (path, ext_id|None)."""
+ exts_dir = project_root / ".specify" / "extensions"
+ disabled_ids = set()
+ registry_file = exts_dir / ".registry"
+ if registry_file.is_file():
+ try:
+ reg_data = json.loads(registry_file.read_text(encoding="utf-8"))
+ for ext_id, meta in reg_data.get("extensions", {}).items():
+ if isinstance(meta, dict) and meta.get("enabled") is False:
+ disabled_ids.add(ext_id)
+ except Exception:
+ pass
+
+ stem = command_name.replace("speckit.", "").replace("spec.", "")
+
+ # 1. Manifest-driven resolution from extension.yml in enabled extensions (Suppressed #1)
+ if exts_dir.is_dir():
+ for ext_dir in sorted(exts_dir.iterdir()):
+ if not ext_dir.is_dir() or ext_dir.name in disabled_ids:
+ continue
+ ext_yml = ext_dir / "extension.yml"
+ if ext_yml.is_file():
+ try:
+ yml_text = ext_yml.read_text(encoding="utf-8")
+ cur_name = None
+ cur_file = None
+ in_provides = False
+ in_commands = False
+ for line in yml_text.splitlines():
+ stripped = line.strip()
+ if stripped == "provides:":
+ in_provides = True
+ continue
+ if in_provides and stripped == "commands:":
+ in_commands = True
+ continue
+ if in_commands and stripped and not line[0].isspace():
+ in_provides = False
+ in_commands = False
+ continue
+ if in_commands:
+ if "name:" in line:
+ cur_name = line.split("name:", 1)[1].strip().strip('"').strip("'")
+ if "file:" in line:
+ cur_file = line.split("file:", 1)[1].strip().strip('"').strip("'")
+ if cur_name and cur_file:
+ if cur_name == command_name:
+ candidate = ext_dir / cur_file
+ if candidate.exists():
+ return candidate, ext_dir.name
+ cur_name = None
+ cur_file = None
+ except Exception:
+ pass
+
+ # 2. On-disk extension commands by file stem (non-disabled extensions)
+ if exts_dir.is_dir():
+ for ext_dir in sorted(exts_dir.iterdir()):
+ if not ext_dir.is_dir() or ext_dir.name in disabled_ids:
+ continue
+ cmds_dir = ext_dir / "commands"
+ if cmds_dir.is_dir():
+ for f in cmds_dir.glob("*.md"):
+ if f.stem == command_name or f.stem == stem:
+ return f, ext_dir.name
+
+ # 3. Core templates in the project
+ core = project_root / ".specify" / "templates" / "commands"
+ if core.is_dir():
+ candidate = core / (stem + ".md")
+ if candidate.exists():
+ return candidate, None
+ return None, None
+
+
+def _script_variant(project_root):
+ """Return the project's persisted script type ('sh'|'ps'|'py')."""
+ default = "ps" if os.name == "nt" else "sh"
+ init_opts = project_root / ".specify" / "init-options.json"
+ try:
+ data = json.loads(init_opts.read_text(encoding="utf-8"))
+ script = data.get("script")
+ if script in ("sh", "ps", "py"):
+ return script
+ except Exception:
+ pass
+ return default
+
+
+def _extract_scripts(template_path):
+ """Parse the scripts: block from a command template's frontmatter."""
+ try:
+ content = template_path.read_text(encoding="utf-8")
+ except Exception:
+ return {}
+ m = re.match(r"^---\\n(.*?)\\n---", content, re.DOTALL)
+ if not m:
+ return {}
+ scripts = {}
+ in_scripts = False
+ for line in m.group(1).splitlines():
+ if line.rstrip() == "scripts:":
+ in_scripts = True
+ continue
+ if in_scripts and line and not line[0].isspace():
+ break
+ if in_scripts and ":" in line:
+ k, _, v = line.partition(":")
+ scripts[k.strip()] = v.strip()
+ return scripts
+
+
+def _resolve_argv(template_path, project_root, ext_id):
+ """Resolve the command's script to a runnable argv (stdlib only)."""
+ scripts = _extract_scripts(template_path)
+ if not scripts:
+ return None
+ requested = _script_variant(project_root)
+ order = (requested,) if requested in scripts else ()
+ fallbacks = (requested, "ps" if requested != "ps" else "sh", "py", "sh")
+ seen = set()
+ for cand in order + fallbacks:
+ if cand in seen:
+ continue
+ seen.add(cand)
+ if cand in scripts:
+ variant = cand
+ break
+ else:
+ return None
+ script_cmd = scripts.get(variant, "").strip()
+ if not script_cmd:
+ return None
+
+ base = (project_root / ".specify" / "extensions" / ext_id) if ext_id else (project_root / ".specify")
+ try:
+ tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
+ except ValueError:
+ return None
+ if not tokens:
+ return None
+ script_abs = base / tokens[0]
+ if not script_abs.exists():
+ return None
+ rest = tokens[1:]
+
+ if variant == "py":
+ # .py files aren't directly executable; run under the dispatcher's own
+ # Python (sys.executable), which is always available here.
+ return [sys.executable or "python3", str(script_abs), *rest]
+ if variant == "ps":
+ launcher = shutil.which("pwsh") or shutil.which("powershell")
+ if not launcher:
+ return None
+ return [launcher, "-File", str(script_abs), *rest]
+ # sh: direct on POSIX; a bash/sh launcher on Windows.
+ if os.name == "nt":
+ launcher = shutil.which("bash") or shutil.which("sh")
+ if launcher:
+ return [launcher, str(script_abs), *rest]
+ return None
+ return [str(script_abs), *rest]
+
+
+def _run_inline(command_name, payload, project_root, timeout):
+ """Resolve and run the event command with stdlib only (no specify_cli)."""
+ template_path, ext_id = _find_command_template(command_name, project_root)
+ if not template_path:
+ return 0 # command not found: fail open (no-op) for lifecycle events
+ argv = _resolve_argv(template_path, project_root, ext_id)
+ if not argv:
+ return 0
+ try:
+ result = subprocess.run(
+ argv,
+ input=payload,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ cwd=str(project_root),
+ )
+ if result.stdout:
+ sys.stdout.write(result.stdout)
+ if result.returncode != 0:
+ if result.stderr:
+ sys.stderr.write(result.stderr)
+ return result.returncode
+ return 0
+ except subprocess.TimeoutExpired:
+ print(f"Event {command_name} timed out", file=sys.stderr)
+ return 2
+ except Exception as e:
+ print(f"Event {command_name} error: {e}", file=sys.stderr)
+ return 2
+
+
+def main():
+ if len(sys.argv) < 3:
+ sys.exit(0)
+ command_name = sys.argv[1]
+ # event_name is accepted for argv-compat with the native hook command but
+ # is not needed for resolution (the command template drives everything).
+ _event_name = sys.argv[2]
+ # Optional 4th arg: per-handler timeout in seconds (S4).
+ timeout = 120
+ if len(sys.argv) >= 4:
+ try:
+ timeout = int(sys.argv[3])
+ except (TypeError, ValueError):
+ timeout = 120
+ payload = sys.stdin.read() if not sys.stdin.isatty() else "{}"
+ project_root = Path(__file__).parent.parent.resolve()
+
+ # Preferred path: specify_cli is importable (durable install) ā delegate to
+ # the full resolver, which also handles extension manifests whose file stem
+ # differs from the command name and the project's custom script selection.
+ try:
+ from specify_cli.events import resolve_and_run_event_command
+ sys.exit(
+ resolve_and_run_event_command(
+ command_name, _event_name, payload, project_root, timeout=timeout
+ )
+ )
+ except ImportError:
+ pass
+
+ # Fallback: self-contained stdlib resolver (one-time/temporary installs).
+ sys.exit(_run_inline(command_name, payload, project_root, timeout))
+
+
+if __name__ == "__main__":
+ main()
+'''
+
+# -- TS plugin template (opencode) ----------------------------------------
+
+_TS_PLUGIN_TEMPLATE = '''import {{ execFileSync }} from 'child_process';
+import * as path from 'path';
+
+// The dispatcher + interpreter are resolved per-project at plugin load from
+// the `directory` OpenCode passes to the plugin factory (C8), not
+// process.cwd() ā OpenCode may be launched from a parent directory or host
+// another workspace, in which case process.cwd() points at the wrong project.
+let DISPATCHER = '';
+let INTERPRETER = '';
+
+function canImportSpecifyCli(py: string): boolean {{
+ // R2: a project-local venv commonly lacks Spec Kit (installed globally or
+ // via uv tool). Probe the interpreter can import specify_cli before
+ // selecting it, so an unrelated venv doesn't shadow the PATH fallback.
+ try {{
+ execFileSync(py, ['-c', 'import specify_cli'], {{
+ stdio: ['ignore', 'ignore', 'ignore'],
+ timeout: 10000,
+ }});
+ return true;
+ }} catch (e) {{
+ return false;
+ }}
+}}
+
+function resolveDispatcher(directory: string): void {{
+ DISPATCHER = path.join(directory, '.specify', 'events.py');
+ // Prefer a project-local venv interpreter that can import specify_cli (R2),
+ // then fall back to a platform-appropriate PATH interpreter (S2: python on
+ // Windows, where python3 is commonly absent; python3 on POSIX).
+ const venvPy = path.join(directory, '.venv', 'bin', 'python');
+ const venvWin = path.join(directory, '.venv', 'Scripts', 'python.exe');
+ INTERPRETER = (
+ (require('fs').existsSync(venvPy) && canImportSpecifyCli(venvPy) && venvPy) ||
+ (require('fs').existsSync(venvWin) && canImportSpecifyCli(venvWin) && venvWin) ||
+ (process.platform === 'win32' ? 'python' : 'python3')
+ ) as string;
+}}
+
+function runEvent(command: string, event: string, input: any, output: any, timeoutSec: number): void {{
+ if (!DISPATCHER) return;
+ try {{
+ // execFileSync with an argv array invokes the interpreter directly ā no
+ // shell ā so command/event strings with metacharacters can't break out
+ // of the dispatcher argument (C9). The dispatcher arg is seconds; the
+ // execFileSync timeout is ms with a buffer so the outer cap fires after
+ // the dispatcher's inner subprocess (S3).
+ execFileSync(INTERPRETER, [DISPATCHER, command, event, String(timeoutSec)], {{
+ input: JSON.stringify({{ input, output }}),
+ stdio: ['pipe', 'inherit', 'inherit'],
+ timeout: (timeoutSec + {buffer}) * 1000,
+ }});
+ }} catch (e) {{
+ // Propagate to OpenCode's hook machinery so only this hook is rejected,
+ // not the entire host process. process.exit() would kill the agent.
+ throw new Error(`specify event ${{command}} (${{event}}) failed: ${{(e as Error).message}}`);
+ }}
+}}
+
+{event_entries}
+
+export default (async ({{ client, project, directory, $ }}) => {{
+ resolveDispatcher(directory);
+ return {{
+{plugin_returns}
+ }};
+}});
+'''
+
+
+# -- Command runner logic (core) --------------------------------------------
+
+def _find_command_template(command_name: str, project_root: Path) -> tuple[Path | None, str | None]:
+ # 1. Resolve via installed extension manifests (authoritative). The
+ # registry stores per-agent ``registered_commands`` name-lists, not a
+ # ``{name, file}`` map, so the commandāfile mapping lives only in each
+ # extension's ``extension.yml`` ``provides.commands`` (S8). Match the
+ # command name to its declared ``file`` so commands whose file stem
+ # differs from the command name (e.g. ``speckit.selftest.extension`` ā
+ # ``commands/selftest.md``) resolve correctly.
+ exts_dir = project_root / ".specify" / "extensions"
+ # S1: build the set of explicitly-disabled extension IDs so dispatch skips
+ # disabled extensions (a stale hook would otherwise keep executing a
+ # disabled extension's command). Applied to both the manifest loop and the
+ # on-disk fallback below.
+ disabled_ids = _disabled_extension_ids(project_root)
+ try:
+ from .extensions import ExtensionManager
+ manager = ExtensionManager(project_root)
+ for ext_id in sorted(manager.registry.keys()):
+ if ext_id in disabled_ids:
+ continue
+ manifest = manager.get_extension(ext_id)
+ if manifest is None:
+ continue
+ for cmd in manifest.commands:
+ if not isinstance(cmd, dict):
+ continue
+ if cmd.get("name") == command_name and cmd.get("file"):
+ candidate = exts_dir / ext_id / cmd["file"]
+ if candidate.exists():
+ return candidate, ext_id
+ except Exception:
+ # Fall through to the on-disk scan if the registry/manifests can't be
+ # read; event dispatch should degrade gracefully, not crash.
+ pass
+
+ # 2. Scan extension directories by file stem (covers extensions present on
+ # disk but not resolvable via the manifest above). S1: skip disabled
+ # extensions here too so the disk fallback can't re-enable them.
+ if exts_dir.is_dir():
+ for ext_dir in sorted(exts_dir.iterdir()):
+ if ext_dir.name in disabled_ids:
+ continue
+ cmds_dir = ext_dir / "commands"
+ if cmds_dir.is_dir():
+ for f in cmds_dir.glob("*.md"):
+ if f.stem == command_name:
+ return f, ext_dir.name
+
+ # 3. Check core templates in the project
+ core = project_root / ".specify" / "templates" / "commands"
+ if core.is_dir():
+ stem = command_name.replace("speckit.", "").replace("spec.", "")
+ candidate = core / f"{stem}.md"
+ if candidate.exists():
+ return candidate, None
+
+ # 4. Fallback to package-bundled templates via the canonical asset
+ # resolvers (wheel: core_pack/commands; source: repo-root
+ # templates/commands). The previous bespoke inspect.getfile() math
+ # pointed at core_pack/templates/commands, which never exists in a
+ # wheel build (force-include maps templates/commands -> core_pack/commands).
+ from ._assets import _locate_core_pack, _repo_root
+ core_pack = _locate_core_pack()
+ candidate_dirs = [
+ core_pack / "commands" if core_pack is not None else None,
+ _repo_root() / "templates" / "commands",
+ ]
+ stem = command_name.replace("speckit.", "").replace("spec.", "")
+ for candidate_dir in candidate_dirs:
+ if candidate_dir is None or not candidate_dir.is_dir():
+ continue
+ candidate = candidate_dir / f"{stem}.md"
+ if candidate.exists():
+ return candidate, None
+
+ return None, None
+
+
+def _resolve_event_command_argv(
+ template_path: Path, project_root: Path, ext_id: str | None
+) -> list[str] | None:
+ """Resolve a command template's ``scripts:`` entry to a runnable argv.
+
+ ``scripts:`` values are command strings (e.g. ``scripts/bash/setup-plan.sh --json``),
+ not bare paths, so joining the whole value into a ``Path`` made ``exists()``
+ false and real commands silently no-op'd. This resolves the stored variant
+ (honoring the project's sh/ps/py selection), splits the command string
+ safely into argv, and prepends the appropriate interpreter (Python for
+ ``.py``, the platform shell otherwise). Returns ``None`` if no runnable
+ script is declared.
+ """
+ from .integrations.base import IntegrationBase
+
+ content = template_path.read_text(encoding="utf-8")
+ m = re.match(r'^---\n(.*?)\n---', content, re.DOTALL)
+ if not m:
+ return None
+ fm = m.group(1)
+ try:
+ fm_data = yaml.safe_load(fm) or {}
+ except Exception:
+ return None
+ if not isinstance(fm_data, dict):
+ return None
+ scripts = fm_data.get("scripts", {})
+ if not isinstance(scripts, dict):
+ return None
+ # Determine the requested variant from the project's persisted selection,
+ # falling back to the platform default ā same logic MarkdownIntegration
+ # uses for command scaffolding.
+ requested = _load_project_script_type(project_root)
+ try:
+ variant = IntegrationBase.select_script_variant(requested, scripts)
+ except ValueError:
+ return None
+ script_cmd = scripts.get(variant)
+ if not isinstance(script_cmd, str) or not script_cmd.strip():
+ return None
+
+ # Base under which the script's leading path component is anchored ā
+ # .specify/ (core) or .specify/extensions// (extension). All variants
+ # share this anchoring so a `scripts/...` token resolves correctly (S2:
+ # the py branch previously invoked build_python_invocation() on the raw
+ # command string, leaving `scripts/...` anchored at the project root).
+ if ext_id:
+ base = project_root / ".specify" / "extensions" / ext_id
+ else:
+ base = project_root / ".specify"
+
+ tokens = shlex.split(script_cmd, posix=(os.name != "nt"))
+ if not tokens:
+ return None
+ script_abs = base / tokens[0]
+ if not script_abs.exists():
+ return None
+ rest_args = tokens[1:]
+
+ if variant == "py":
+ # .py files aren't directly executable on Windows; prefix the resolved
+ # interpreter. argv is passed to subprocess.run(shell=False), so no
+ # shell quoting is needed.
+ interpreter = IntegrationBase.resolve_python_interpreter(project_root)
+ return [interpreter, str(script_abs), *rest_args]
+
+ if variant == "ps":
+ # PowerShell scripts cannot be executed directly by
+ # subprocess.run(shell=False); invoke via `pwsh -File` (PowerShell 7+),
+ # falling back to `powershell -File` (Windows PowerShell) when pwsh is
+ # absent (S6). The default Windows script type would otherwise fail.
+ launcher = shutil.which("pwsh") or shutil.which("powershell") or "pwsh"
+ return [launcher, "-File", str(script_abs), *rest_args]
+
+ # sh: the script is chmod'd executable during install on POSIX. On Windows
+ # subprocess.run(shell=False) can't execute a .sh directly, so prefix a
+ # bash/sh launcher when one is available (mirroring the ps branch's
+ # pwsh -File handling, S5).
+ if os.name == "nt":
+ launcher = shutil.which("bash") or shutil.which("sh")
+ if launcher:
+ return [launcher, str(script_abs), *rest_args]
+ return [str(script_abs), *rest_args]
+
+
+def _load_project_script_type(project_root: Path) -> str:
+ """Return the project's persisted script type ('sh'|'ps'|'py').
+
+ Falls back to the platform default when init-options are absent or
+ unreadable so event dispatch still works in a partially-initialized
+ project.
+ """
+ default = "ps" if platform.system().lower().startswith("win") else "sh"
+ try:
+ from ._init_options import load_init_options
+ opts = load_init_options(project_root)
+ if isinstance(opts, dict):
+ script = opts.get("script")
+ if isinstance(script, str) and script in ("sh", "ps", "py"):
+ return script
+ except Exception:
+ pass
+ return default
+
+
+def resolve_and_run_event_command(
+ command_name: str,
+ event_name: str,
+ payload: str,
+ project_root: Path,
+ *,
+ timeout: int = 120,
+) -> int:
+ """Core entry point to resolve and execute an event-driven command.
+
+ *timeout* is the per-handler timeout in seconds, passed through from the
+ native hook config via the dispatcher (S4) so a handler configured above
+ the previous fixed 120s cap can run for its full duration.
+ """
+ template_path, ext_id = _find_command_template(command_name, project_root)
+ if not template_path:
+ logger.warning("Event command '%s' not found", command_name)
+ return 0
+ argv = _resolve_event_command_argv(template_path, project_root, ext_id)
+ if not argv:
+ logger.warning("No script found for event command '%s'", command_name)
+ return 0
+ try:
+ result = subprocess.run(
+ argv,
+ input=payload,
+ capture_output=True,
+ text=True,
+ timeout=timeout,
+ cwd=str(project_root),
+ )
+ if result.stdout:
+ sys.stdout.write(result.stdout)
+ if result.returncode != 0:
+ if result.stderr:
+ sys.stderr.write(result.stderr)
+ return result.returncode
+ return 0
+ except subprocess.TimeoutExpired:
+ sys.stderr.write(f"Event command {command_name} timed out\n")
+ return 2
+ except Exception as e:
+ sys.stderr.write(f"Event command {command_name} error: {e}\n")
+ return 2
+
+
+# -- Sourcing events map (CLI/Orchestration domain) -------------------------
+
+# Resolved events map: each canonical event name maps to an *ordered list* of
+# handler configs. Built-in defaults and per-extension declarations both
+# contribute, so two extensions declaring ``session_start`` both run (finding
+# #2) instead of the last one silently winning.
+ResolvedEvents = dict[str, list[dict[str, Any]]]
+
+
+def _normalize_handlers(value: Any) -> list[dict[str, Any]]:
+ """Coerce a single handler config or a list of them into a validated list.
+
+ Accepts both the legacy single-mapping shape (``{command: ...}``) and the
+ explicit list shape (``[{command: ...}, ...]``). Drops any entry that is
+ not a mapping or lacks a ``command`` with a warning, so a malformed user
+ override never reaches installation and crashes on ``cfg.get(...)`` (#21).
+ """
+ if isinstance(value, dict):
+ value = [value]
+ if not isinstance(value, list):
+ return []
+ handlers: list[dict[str, Any]] = []
+ for entry in value:
+ if not isinstance(entry, dict):
+ logger.warning("Skipping malformed event handler (expected a mapping): %r", entry)
+ continue
+ handlers.append(entry)
+ return handlers
+
+
+def _validate_resolved_event(event_name: str, handlers: list[dict[str, Any]]) -> None:
+ """Validate a resolved event's handlers, raising a user-facing error.
+
+ Raised for structural problems the user must fix (unknown event name,
+ handler missing a ``command``, or ``command`` not a non-empty string per
+ #17). Malformed-but-skipable entries are already dropped by
+ ``_normalize_handlers``.
+ """
+ from .extensions import ValidationError
+
+ if event_name not in CANONICAL_EVENTS:
+ raise ValidationError(
+ f"Unknown event '{event_name}': must be one of {sorted(CANONICAL_EVENTS)}"
+ )
+ for handler in handlers:
+ command = handler.get("command")
+ if not isinstance(command, str) or not command.strip():
+ raise ValidationError(
+ f"Event '{event_name}' handler missing required non-empty 'command' string"
+ )
+ # C10: matcher must be a string (or absent). A non-string matcher such
+ # as `matcher: []` passes extension validation but later crashes
+ # by_matcher.setdefault(matcher, ...) with TypeError: unhashable type.
+ matcher = handler.get("matcher")
+ if matcher is not None and not isinstance(matcher, str):
+ raise ValidationError(
+ f"Event '{event_name}' handler has invalid 'matcher': "
+ "must be a string"
+ )
+ timeout = handler.get("timeout")
+ if timeout is not None:
+ if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0:
+ raise ValidationError(
+ f"Event '{event_name}' handler has invalid 'timeout': must be a positive integer"
+ )
+
+
+def resolve_events(
+ integration_key: str,
+ integration_config: dict[str, Any] | None,
+ project_root: Path,
+ parsed_options: dict[str, Any] | None,
+) -> ResolvedEvents:
+ """Resolve the final event set for an integration.
+
+ Returns a mapping of canonical event name ā ordered list of handler
+ configs. Layers (lowest ā highest precedence):
+
+ 1. CLI gate ``--events false`` ā empty map (caller still removes prior
+ native hooks; see ``install_integration_events``).
+ 2. Built-in defaults from ``integration_config["events"]`` (single-config
+ per event, wrapped as one-element lists).
+ 3. Extension-declared ``events:`` ā appended per extension so multiple
+ extensions can declare the same event (#2).
+ 4. User YAML override (``.specify/integration-events.yml``) ā replaces the
+ accumulated set entirely when the integration key is present. Validated
+ (#21) before returning; a malformed override is warned about and
+ ignored rather than crashing downstream.
+ """
+ # Layer 1: CLI flag gate
+ if parsed_options:
+ events_flag = str(parsed_options.get("events", "true")).lower()
+ if events_flag in ("false", "0", "no", "off"):
+ return {}
+
+ events: ResolvedEvents = {}
+
+ # Layer 2: built-in defaults from integration config
+ if integration_config and isinstance(integration_config.get("events"), dict):
+ for ev, cfg in integration_config["events"].items():
+ handlers = _normalize_handlers(cfg)
+ if handlers:
+ events.setdefault(ev, []).extend(handlers)
+
+ # Layer 3: extension-declared events (accumulated, not overwriting)
+ for ev, handlers in collect_extension_events(project_root).items():
+ events.setdefault(ev, []).extend(handlers)
+
+ # Layer 4: user YAML override (replaces entirely if key present)
+ override_file = project_root / YAML_OVERRIDE_FILENAME
+ if override_file.exists():
+ try:
+ override = yaml.safe_load(override_file.read_text(encoding="utf-8")) or {}
+ except yaml.YAMLError:
+ logger.warning("Could not parse %s; ignoring override", override_file)
+ override = {}
+ integrations = override.get("integrations", {}) if isinstance(override, dict) else {}
+ if isinstance(integrations, dict) and integration_key in integrations:
+ key_data = integrations[integration_key]
+ if not isinstance(key_data, dict):
+ # C6: a non-mapping integration entry (e.g. `claude: bad`) must
+ # not be treated as a valid explicit disable. Warn and abandon
+ # the override, keeping the accumulated built-in + extension
+ # layers. Only an explicitly present, mapping-valued `events`
+ # field replaces the prior layers.
+ logger.warning(
+ "Override %s: entry for '%s' is not a mapping; ignoring override",
+ override_file, integration_key,
+ )
+ else:
+ key_events = key_data.get("events", {})
+ if not isinstance(key_events, dict):
+ logger.warning(
+ "Override %s: 'events' for '%s' is not a mapping; ignoring override",
+ override_file, integration_key,
+ )
+ else:
+ # Validate every entry before adopting the override. A single
+ # invalid entry abandons the whole override and keeps the
+ # accumulated built-in + extension layers (#10): previously a
+ # typo reset resolved_override to {} and then assigned that
+ # empty map to events, silently disabling all hooks despite
+ # the "ignored" warning. Only a fully-valid override (including
+ # an explicit `events: {}`) replaces the prior layers.
+ resolved_override: ResolvedEvents = {}
+ override_valid = True
+ for ev, raw in key_events.items():
+ handlers = _normalize_handlers(raw)
+ if not handlers:
+ # C4: a malformed handler (e.g. `stop: []` or
+ # `stop: bad-value`) normalizes to no handlers.
+ # Abandon the whole override (keep prior layers)
+ # rather than skipping the entry ā otherwise an
+ # override whose only entry is malformed silently
+ # disabled every built-in and extension hook. An
+ # explicit `events: {}` (no entries) remains a
+ # valid disable.
+ logger.warning(
+ "Override %s: event '%s' has no valid handler; ignoring entire override",
+ override_file, ev,
+ )
+ override_valid = False
+ break
+ try:
+ _validate_resolved_event(ev, handlers)
+ except Exception as exc:
+ logger.warning(
+ "Override %s: invalid event '%s': %s; ignoring entire override",
+ override_file, ev, exc,
+ )
+ override_valid = False
+ break
+ resolved_override[ev] = handlers
+ if override_valid:
+ events = resolved_override
+ # else: keep the accumulated built-in + extension layers.
+
+ return events
+
+
+def _disabled_extension_ids(project_root: Path) -> set[str]:
+ """Return the set of explicitly-disabled extension IDs.
+
+ Extensions not tracked in the registry are treated as enabled (backward
+ compat). Used by ``collect_extension_events`` and ``_find_command_template``
+ so a disabled extension's events and commands are never emitted or
+ executed (S1) ā otherwise a stale native hook would keep running a
+ disabled extension after its config file was preserved (e.g. a JSONC
+ parse failure that skipped native cleanup).
+ """
+ from .extensions import ExtensionRegistry
+
+ exts_dir = project_root / ".specify" / "extensions"
+ disabled_ids: set[str] = set()
+ if not exts_dir.is_dir():
+ return disabled_ids
+ try:
+ registry = ExtensionRegistry(exts_dir)
+ for ext_id, meta in registry.list_by_priority(include_disabled=True):
+ if not isinstance(meta, dict) or not meta.get("enabled", True):
+ disabled_ids.add(ext_id)
+ except Exception:
+ pass
+ return disabled_ids
+
+
+def collect_extension_events(project_root: Path) -> ResolvedEvents:
+ """Scan all installed extensions for ``events:`` declarations.
+
+ Returns a mapping of event name ā list of handler configs. Multiple
+ extensions declaring the same event each contribute a handler (in
+ extension-directory sort order), so callers can emit all of them (#2).
+
+ Honors the extension registry's ``enabled`` flag (#1): an explicitly
+ disabled extension's events are skipped so disabling an extension actually
+ deactivates its runtime hooks. Extensions absent from the registry (e.g.
+ a partially-staged install) are still included to preserve the on-disk
+ scan behavior.
+
+ Events are read from a validated ``ExtensionManifest`` (R1) rather than
+ the raw ``extension.yml`` YAML, so the command-reference canonicalization
+ applied during install validation (C11, e.g. ``my-ext.boot`` ā
+ ``speckit.my-ext.boot``) is reflected ā otherwise refresh would emit the
+ obsolete name and ``_find_command_template`` could not match it, leaving
+ the hook silently inert.
+ """
+ from .extensions import ExtensionManager
+
+ events: ResolvedEvents = {}
+ exts_dir = project_root / ".specify" / "extensions"
+ if not exts_dir.is_dir():
+ return events
+
+ manager = ExtensionManager(project_root)
+
+ # Build the set of explicitly-disabled extension IDs. Extensions not
+ # tracked in the registry are treated as enabled (backward compat).
+ disabled_ids = _disabled_extension_ids(project_root)
+
+ # Union of extension IDs to consider: registry-tracked IDs (validated
+ # manifests, canonicalized refs) plus on-disk dirs not yet in the registry
+ # (partially-staged installs). The latter fall back to the raw YAML since
+ # no validated manifest is available, preserving the on-disk scan behavior.
+ registry_ids = set()
+ try:
+ registry_ids = set(manager.registry.keys())
+ except Exception:
+ pass
+ on_disk_ids = {
+ d.name for d in exts_dir.iterdir() if d.is_dir() and (d / "extension.yml").exists()
+ }
+ for ext_id in sorted(registry_ids | on_disk_ids):
+ if ext_id in disabled_ids:
+ continue
+ # Prefer the validated manifest (canonicalized command refs, R1);
+ # fall back to the raw YAML for an on-disk extension not yet
+ # registered (a malformed extension shouldn't abort collection).
+ runtime: dict[str, Any] = {}
+ if ext_id in registry_ids:
+ try:
+ manifest = manager.get_extension(ext_id)
+ except Exception:
+ manifest = None
+ if manifest is not None:
+ runtime = manifest.data.get("events", {}) or {}
+ if not runtime:
+ ext_yml = exts_dir / ext_id / "extension.yml"
+ if not ext_yml.exists():
+ continue
+ try:
+ data = yaml.safe_load(ext_yml.read_text(encoding="utf-8")) or {}
+ except yaml.YAMLError:
+ continue
+ if not isinstance(data, dict):
+ continue
+ runtime = data.get("events", {}) or {}
+ if not isinstance(runtime, dict):
+ continue
+ for event, config in runtime.items():
+ handlers = _normalize_handlers(config)
+ if handlers:
+ events.setdefault(event, []).extend(handlers)
+ return events
+
+
+# -- Writing/Merging Config (Integration domain) ---------------------------
+
+def _resolve_interpreter(project_root: Path) -> str:
+ """Resolve a portable Python interpreter for native hook commands (#16).
+
+ Delegates to ``IntegrationBase.resolve_python_interpreter`` so generated
+ commands honor the project venv and never hard-code ``python3`` (which is
+ commonly absent on Windows even when ``py.exe``/``python.exe`` exist).
+ """
+ from .integrations.base import IntegrationBase
+ return IntegrationBase.resolve_python_interpreter(project_root)
+
+
+def _resolve_interpreter_for_target(target_os: str) -> str:
+ """Resolve a Python interpreter for a target OS, independent of the host (#S4).
+
+ Copilot's native config carries both a ``bash`` (POSIX) and a
+ ``powershell`` (Windows) variant in the same checked-in file. Resolving
+ both with the *host* interpreter writes a Linux venv path into the
+ PowerShell hook (or vice-versa), so the config fails on the other OS.
+ Each variant instead gets a portable interpreter for its target shell;
+ the dispatcher script's own ``_find_specify()`` does per-OS venv
+ resolution at runtime.
+ """
+ if target_os == "windows":
+ # Windows: ``python`` is the most portable on PATH; the py launcher
+ # (``py -3``) is the recommended fallback when ``python`` is absent.
+ return "python"
+ # POSIX (bash): ``python3`` is universally available.
+ return "python3"
+
+
+def _native_timeout(integration: IntegrationBase, timeout_seconds: Any) -> int:
+ """Return the timeout in the unit the integration's native config expects.
+
+ Claude/Cursor/Codex/Copilot measure timeouts in seconds; Gemini measures
+ in milliseconds (#7). An integration declares its unit via
+ ``events_timeout_unit`` (``"s"`` default, ``"ms"`` for Gemini).
+ """
+ try:
+ seconds = int(timeout_seconds)
+ except (TypeError, ValueError):
+ seconds = 60
+ if getattr(integration, "events_timeout_unit", "s") == "ms":
+ return seconds * 1000
+ return seconds
+
+
+def _shell_quote(value: str, target_os: str) -> str:
+ """Quote *value* as one argument for the target shell (R2).
+
+ ``host`` and ``posix`` targets use ``shlex.quote`` (POSIX shells). Safe
+ tokens ā ``python3``, ``speckit.ext.cmd`` ā pass through bare, so a
+ single-``command``-string hook (Claude/Gemini/etc.) stays invocable on
+ every platform. ``windows`` targets use a PowerShell single-quoted literal
+ with embedded quotes doubled, for Copilot's dedicated ``powershell`` field.
+
+ Prevents a component containing spaces (e.g. a venv interpreter path under
+ a directory with spaces) or shell metacharacters (a malformed
+ extension/override ``command``) from breaking the hook or being
+ interpreted by the native shell instead of passed as one dispatcher
+ argument.
+ """
+ if target_os == "windows":
+ return "'" + value.replace("'", "''") + "'"
+ # "host" and "posix" both use POSIX quoting. On Windows the single-
+ # command-string formats (Claude/Gemini/Qwen/Devin/Tabnine) are run via
+ # Git Bash or the agent's POSIX-ish shell, so POSIX quoting is correct and
+ # avoids emitting 'python' (which PowerShell wouldn't invoke without &).
+ return shlex.quote(value)
+
+
+def _dispatcher_command(
+ integration: IntegrationBase,
+ project_root: Path,
+ command_name: str,
+ event_name: str,
+ *,
+ target_os: str = "host",
+ timeout_seconds: Any = None,
+) -> str:
+ """Build the single shell command string that invokes the dispatcher (#6).
+
+ Claude/Gemini/Qwen/Devin/Tabnine accept one ``command`` string (not a
+ ``command``+``args`` split), so each adapter renders a complete invocation:
+ `` []``. The
+ interpreter is resolved portably (#16); Claude's dispatcher path is
+ prefixed with ``${CLAUDE_PROJECT_DIR}/`` (Claude expands it before shell
+ execution).
+
+ ``target_os`` selects an OS-appropriate interpreter for adapters that emit
+ both POSIX and Windows variants into one checked-in file (Copilot): ``host``
+ uses the host-resolved interpreter (venv-aware), while ``posix``/``windows``
+ emit portable interpreters so the config works on either OS (#S4).
+
+ Each component is shell-quoted for the target shell (R2) so an interpreter
+ path with spaces or a command/event containing shell metacharacters is
+ passed as a single argument rather than reinterpreted by the native shell.
+ The Claude dispatcher is double-quoted (``"${CLAUDE_PROJECT_DIR}/..."``) so
+ the variable still expands but a project path with spaces doesn't
+ word-split (C2). For the explicit ``windows`` target (Copilot's
+ powershell field) the quoted interpreter is prefixed with PowerShell's
+ call operator ``&`` so the quoted command is actually invoked (C1).
+
+ When *timeout_seconds* is given, the resolved timeout (in the
+ integration's native unit) is appended as a 4th argument so the dispatcher
+ and inner runner honor the per-handler timeout instead of a fixed 120s cap
+ that would kill a handler configured for longer (S4).
+ """
+ if target_os == "host":
+ interpreter = _resolve_interpreter(project_root)
+ else:
+ interpreter = _resolve_interpreter_for_target(target_os)
+ q_interp = _shell_quote(interpreter, target_os)
+ q_command = _shell_quote(command_name, target_os)
+ q_event = _shell_quote(event_name, target_os)
+ if integration.key == "claude":
+ # C2: double-quote so ${CLAUDE_PROJECT_DIR} still expands (double
+ # quotes allow variable expansion in POSIX shells) but a project path
+ # containing spaces doesn't word-split.
+ dispatcher = '"${CLAUDE_PROJECT_DIR}/' + EVENTS_DISPATCHER_REL + '"'
+ else:
+ dispatcher = _shell_quote(EVENTS_DISPATCHER_REL, target_os)
+ # C1: PowerShell won't invoke a single-quoted command without the call
+ # operator. Prefix & for the explicit windows target only.
+ prefix = "& " if target_os == "windows" else ""
+ base = f"{prefix}{q_interp} {dispatcher} {q_command} {q_event}"
+ if timeout_seconds is not None:
+ # R2: the dispatcher interprets this argument as seconds, so pass the
+ # raw seconds ā NOT _native_timeout(...) (which converts to ms for
+ # Gemini/Qwen/Tabnine and would yield 60000 seconds). The buffer is
+ # applied to the native hook timeout field (in the adapter formatters)
+ # so the agent's outer cap fires after the inner subprocess timeout.
+ base += f" {_shell_quote(str(int(timeout_seconds)), target_os)}"
+ return base
+
+
+def install_integration_events(
+ integration: IntegrationBase,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ events: ResolvedEvents,
+) -> list[Path]:
+ """Generate dispatcher, merge native config, return created files.
+
+ ``events`` maps each canonical event to an ordered list of handler configs
+ (#2); every handler is emitted as a separate native hook entry so two
+ extensions declaring ``session_start`` both run.
+ """
+ canonical_to_native = getattr(integration, "CANONICAL_TO_NATIVE", {})
+ if not canonical_to_native:
+ return []
+
+ # Filter to only supported events, preserving all handlers per event.
+ filtered: ResolvedEvents = {}
+ for ev, handlers in events.items():
+ if not isinstance(handlers, list):
+ continue
+ if ev in canonical_to_native:
+ filtered[ev] = handlers
+ else:
+ print(
+ f"\u26a0\ufe0f {integration.key} does not support '{ev}' events; skipping",
+ file=sys.stderr,
+ )
+
+ # #3: an empty resolved map (--events false, or override disabling events)
+ # must still strip prior Specify hooks from this integration's native
+ # config rather than leaving them active. S3: also run the shared-
+ # dispatcher refcount cleanup so an --events false upgrade of the last
+ # event integration doesn't orphan .specify/events.py permanently (the
+ # new manifest no longer claims it and stale cleanup excludes it).
+ if not filtered:
+ _remove_native_event_hooks(integration, project_root, manifest)
+ _cleanup_shared_dispatcher(integration, project_root, manifest)
+ return []
+
+ created: list[Path] = []
+
+ # 1. Generate events.py dispatcher script (#12: validate destination first)
+ dispatcher_dir = project_root / EVENTS_DISPATCHER_DIR
+ dispatcher_path = dispatcher_dir / EVENTS_DISPATCHER_FILENAME
+ _ensure_safe_destination(dispatcher_path)
+ dispatcher_dir.mkdir(parents=True, exist_ok=True)
+ dispatcher_path.write_text(_EVENTS_DISPATCHER_TEMPLATE, encoding="utf-8")
+ dispatcher_path.chmod(0o755)
+ manifest.record_file(
+ str(dispatcher_path.relative_to(project_root)),
+ dispatcher_path.read_bytes(),
+ )
+ created.append(dispatcher_path)
+
+ # 2. Format-specific merge/write
+ fmt = getattr(integration, "events_format", "json-nested")
+ config_file = getattr(integration, "events_config_file", None)
+ if not config_file:
+ return created
+
+ config_path = project_root / config_file
+
+ if fmt == "ts-plugin":
+ # Opencode TS plugin custom merge
+ plugin_rel = ".opencode/plugin/speckit-events.ts"
+ plugin_path = project_root / plugin_rel
+ _ensure_safe_destination(plugin_path)
+ plugin_path.parent.mkdir(parents=True, exist_ok=True)
+ plugin_path.write_text(
+ _build_opencode_plugin(filtered, canonical_to_native),
+ encoding="utf-8",
+ )
+ manifest.record_file(
+ plugin_rel,
+ plugin_path.read_bytes(),
+ )
+ created.append(plugin_path)
+
+ # Merge plugin path into opencode.json. S5: only track the config
+ # file when the merge actually wrote; a skipped merge (JSONC/malformed)
+ # must not be tracked or manifest.uninstall() would later delete the
+ # user's untouched file.
+ if _merge_opencode_plugin_ref(config_path, f"./{plugin_rel}"):
+ rel = str(config_path.relative_to(project_root))
+ if rel not in manifest.files:
+ manifest.record_existing(rel)
+ created.append(config_path)
+
+ elif fmt == "copilot-json":
+ # Copilot dedicated .github/hooks/speckit.json. Each handler becomes
+ # its own entry in the native event's list (#2). The bash and
+ # powershell variants get independent OS-targeted interpreters (#S4)
+ # so a config generated on Linux doesn't write a POSIX venv path into
+ # the PowerShell hook (and vice-versa). Entries carry the ownership
+ # marker so a pre-existing user-authored file is merged (owned entries
+ # replaced) rather than overwritten (#8), and teardown removes only
+ # owned entries.
+ copilot_hooks: dict[str, list[dict[str, Any]]] = {}
+ for ev, handlers in filtered.items():
+ native = canonical_to_native[ev]
+ entries: list[dict[str, Any]] = []
+ for cfg in handlers:
+ command = cfg.get("command", "")
+ bash_cmd = _dispatcher_command(
+ integration, project_root, command, ev, target_os="posix",
+ timeout_seconds=cfg.get("timeout", 60),
+ )
+ ps_cmd = _dispatcher_command(
+ integration, project_root, command, ev, target_os="windows",
+ timeout_seconds=cfg.get("timeout", 60),
+ )
+ entries.append(
+ {
+ "type": "command",
+ "bash": bash_cmd,
+ "powershell": ps_cmd,
+ "timeoutSec": _native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER),
+ _SPECKIT_MARKER: True,
+ }
+ )
+ copilot_hooks[native] = entries
+ # S5: only track when the merge wrote (skips on JSONC/malformed).
+ if _merge_copilot_json(config_path, copilot_hooks):
+ rel = str(config_path.relative_to(project_root))
+ if rel not in manifest.files:
+ manifest.record_existing(rel)
+ created.append(config_path)
+
+ elif fmt == "toml":
+ # Codex config.toml custom merge. One [[hooks..hooks]] block
+ # per handler so multiple handlers per event all emit (#2).
+ lines: list[str] = []
+ for ev, handlers in filtered.items():
+ native = canonical_to_native[ev]
+ for cfg in handlers:
+ command = cfg.get("command", "")
+ dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev, timeout_seconds=cfg.get("timeout", 60))
+ lines.append(f'[[hooks.{native}]]')
+ lines.append(f'matcher = {_toml_quote(str(cfg.get("matcher", "*")))}')
+ lines.append('')
+ lines.append(f'[[hooks.{native}.hooks]]')
+ lines.append('type = "command"')
+ lines.append(f'command = {_toml_quote(dispatcher_cmd)}')
+ lines.append(f'timeout = {_native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER)}')
+ lines.append('speckit_marker = true')
+ lines.append('')
+ _merge_toml_fragment(config_path, "\n".join(lines))
+ rel = str(config_path.relative_to(project_root))
+ if rel not in manifest.files:
+ manifest.record_existing(rel)
+ created.append(config_path)
+
+ elif fmt == "json-flat":
+ # Cursor hooks.json custom merge. Flat command-string entries, one
+ # per handler (#2), single resolved command string (#6/#16).
+ cursor_hooks: dict[str, list[dict[str, Any]]] = {}
+ for ev, handlers in filtered.items():
+ native = canonical_to_native[ev]
+ entries: list[dict[str, Any]] = []
+ for cfg in handlers:
+ command = cfg.get("command", "")
+ dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev, timeout_seconds=cfg.get("timeout", 60))
+ entries.append(
+ {
+ "command": dispatcher_cmd,
+ "type": "command",
+ "timeout": _native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER),
+ "matcher": cfg.get("matcher", "*"),
+ _SPECKIT_MARKER: True,
+ }
+ )
+ cursor_hooks[native] = entries
+ # #7: Cursor's .cursor/hooks.json schema requires top-level
+ # "version": 1; ensure it (preserving a user's value if present).
+ # S5: only track when the merge wrote (skips on JSONC/malformed).
+ if _merge_json_fragment(config_path, cursor_hooks, version=1):
+ rel = str(config_path.relative_to(project_root))
+ if rel not in manifest.files:
+ manifest.record_existing(rel)
+ created.append(config_path)
+
+ elif fmt == "json-nested":
+ # Claude/Qwen/Gemini/Devin/Tabnine nested config JSON merge.
+ # Native schema is a single ``command`` string per hook (not
+ # command+args), so each handler renders one complete dispatcher
+ # invocation (#6). Gemini timeouts are converted to ms (#7).
+ # Handlers are grouped by distinct matcher so each matcher gets its
+ # own matcher-group (S3); previously all handlers were placed under
+ # the first handler's matcher, so two extensions registering the same
+ # event with different matchers both ran for the first matcher and
+ # neither for the later.
+ nested_hooks: dict[str, list[dict[str, Any]]] = {}
+ for ev, handlers in filtered.items():
+ native = canonical_to_native[ev]
+ by_matcher: dict[str, list[dict[str, Any]]] = {}
+ for cfg in handlers:
+ matcher = cfg.get("matcher", "*")
+ command = cfg.get("command", "")
+ dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev, timeout_seconds=cfg.get("timeout", 60))
+ by_matcher.setdefault(matcher, []).append(
+ {
+ "type": "command",
+ "command": dispatcher_cmd,
+ "timeout": _native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER),
+ _SPECKIT_MARKER: True,
+ }
+ )
+ nested_hooks[native] = [
+ {"matcher": matcher, "hooks": inner}
+ for matcher, inner in by_matcher.items()
+ ]
+ # S5: only track when the merge wrote (skips on JSONC/malformed).
+ if _merge_json_fragment(config_path, nested_hooks):
+ rel = str(config_path.relative_to(project_root))
+ if rel not in manifest.files:
+ manifest.record_existing(rel)
+ created.append(config_path)
+
+ elif fmt == "json-root-nested":
+ # Devin hooks.v1.json: a root event map ({"PreToolUse": [...]}) with
+ # no top-level "hooks" wrapper (U2). Same matcher-grouping and single
+ # command string as json-nested, but written to the root.
+ root_hooks: dict[str, list[dict[str, Any]]] = {}
+ for ev, handlers in filtered.items():
+ native = canonical_to_native[ev]
+ by_matcher: dict[str, list[dict[str, Any]]] = {}
+ for cfg in handlers:
+ matcher = cfg.get("matcher", "*")
+ command = cfg.get("command", "")
+ dispatcher_cmd = _dispatcher_command(integration, project_root, command, ev, timeout_seconds=cfg.get("timeout", 60))
+ by_matcher.setdefault(matcher, []).append(
+ {
+ "type": "command",
+ "command": dispatcher_cmd,
+ "timeout": _native_timeout(integration, cfg.get("timeout", 60) + EVENT_TIMEOUT_BUFFER),
+ _SPECKIT_MARKER: True,
+ }
+ )
+ root_hooks[native] = [
+ {"matcher": matcher, "hooks": inner}
+ for matcher, inner in by_matcher.items()
+ ]
+ if _merge_json_root(config_path, root_hooks):
+ rel = str(config_path.relative_to(project_root))
+ if rel not in manifest.files:
+ manifest.record_existing(rel)
+ created.append(config_path)
+
+ return created
+
+
+def _remove_native_event_hooks(
+ integration: IntegrationBase,
+ project_root: Path,
+ manifest: IntegrationManifest,
+) -> None:
+ """Remove Specify-authored hooks from *this* integration's native config.
+
+ Used both by full teardown and by the empty-resolved-map install path (#3).
+ Does NOT touch the shared dispatcher (another integration may still
+ reference it ā #10).
+ """
+ fmt = getattr(integration, "events_format", None)
+ config_file = getattr(integration, "events_config_file", None)
+ if not config_file:
+ return
+ config_path = project_root / config_file
+ if not config_path.exists():
+ return
+ _ensure_safe_destination(config_path)
+ if fmt == "copilot-json":
+ _remove_copilot_entries(config_path)
+ elif fmt == "toml":
+ _remove_toml_entries(config_path)
+ elif fmt in ("json-nested", "json-flat"):
+ _remove_json_entries(config_path)
+ elif fmt == "json-root-nested":
+ _remove_json_root_entries(config_path)
+ elif fmt == "ts-plugin":
+ _remove_opencode_entries(config_path)
+ # Always drop this integration's manifest claim on the native config,
+ # whether the file was deleted or retained with user content (S9). If we
+ # kept a retained file tracked, teardown()'s manifest.uninstall(force=True)
+ # would delete the entire user-owned settings file. After cleanup the file
+ # is either gone or contains only user content, so this integration must
+ # no longer claim it for teardown purposes.
+ manifest.remove(config_file)
+
+
+def _other_event_integrations_reference_dispatcher(
+ project_root: Path, excluding_key: str
+) -> bool:
+ """Return True if another installed event-capable integration still
+ references the shared ``.specify/events.py`` dispatcher (#10).
+
+ Inspects each installed integration's manifest (excluding *excluding_key*)
+ for the dispatcher path so uninstalling one multi-install event-capable
+ integration doesn't delete the dispatcher the others still rely on.
+ """
+ from .integrations._helpers import _read_integration_json
+ from .integrations.manifest import IntegrationManifest
+ from .integration_state import installed_integration_keys
+
+ state = _read_integration_json(project_root)
+ for key in installed_integration_keys(state):
+ if key == excluding_key:
+ continue
+ try:
+ manifest = IntegrationManifest.load(key, project_root)
+ except Exception:
+ continue
+ if EVENTS_DISPATCHER_REL in manifest.files:
+ return True
+ return False
+
+
+def _cleanup_shared_dispatcher(
+ integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest
+) -> None:
+ """Drop this integration's manifest claim on the shared dispatcher and
+ delete the file only when no other installed event-capable integration
+ still references it (#10, S3).
+
+ The manifest.remove() runs in both branches (S1): if we retain the file
+ but leave it tracked, the subsequent manifest.uninstall() in teardown()
+ sees the matching hash and deletes the file another integration still
+ depends on. Used by full teardown and by the empty-resolved-map install
+ path so an ``--events false`` upgrade of the last event integration
+ doesn't orphan ``.specify/events.py`` permanently (S3).
+ """
+ dispatcher_rel = EVENTS_DISPATCHER_REL
+ # Drop this integration's manifest claim if present. The remove() is
+ # conditional (S1): an upgrade passes a *fresh* manifest that may never
+ # have claimed the dispatcher, so the key may be absent ā that's a no-op.
+ if dispatcher_rel in manifest.files:
+ manifest.remove(dispatcher_rel)
+ # S2: run the no-other-references deletion independently of whether the
+ # new manifest currently contains the key. An ``integration upgrade
+ # --events false`` passes a fresh manifest that never recorded the
+ # dispatcher, so gating the deletion on its presence orphans the file the
+ # old on-disk manifest owned ā and stale cleanup excludes it (C3). If no
+ # other installed event-capable integration references the dispatcher,
+ # delete it; otherwise leave it for them.
+ if not _other_event_integrations_reference_dispatcher(project_root, integration.key):
+ dispatcher_path = project_root / dispatcher_rel
+ if dispatcher_path.exists():
+ _ensure_safe_destination(dispatcher_path)
+ dispatcher_path.unlink(missing_ok=True)
+
+
+def remove_integration_events(
+ integration: IntegrationBase, project_root: Path, manifest: IntegrationManifest
+) -> None:
+ """Remove Specify-authored event entries from native config.
+
+ The shared ``.specify/events.py`` dispatcher is deleted only when no other
+ installed event-capable integration still references it (#10); otherwise
+ it is left in place so multi-install setups don't lose the dispatcher
+ mid-stream.
+ """
+ _remove_native_event_hooks(integration, project_root, manifest)
+ _cleanup_shared_dispatcher(integration, project_root, manifest)
+
+ # Clean up opencode TS plugin (owned solely by the opencode integration).
+ if integration.key == "opencode":
+ plugin_rel = ".opencode/plugin/speckit-events.ts"
+ if plugin_rel in manifest.files:
+ plugin_path = project_root / plugin_rel
+ if plugin_path.exists():
+ _ensure_safe_destination(plugin_path)
+ plugin_path.unlink(missing_ok=True)
+ manifest.remove(plugin_rel)
+
+
+def events_stale_exclusions(integration_key: str) -> set[str]:
+ """Return project-relative paths to protect from stale cleanup."""
+ from .integrations import get_integration
+ integration = get_integration(integration_key)
+ if not integration:
+ return set()
+ exclusions = set()
+ config_file = getattr(integration, "events_config_file", None)
+ if config_file:
+ exclusions.add(config_file)
+ if integration_key == "opencode":
+ exclusions.add(".opencode/plugin/speckit-events.ts")
+ # C3: the shared dispatcher is written into every event-capable
+ # integration's manifest but is reference-counted across them. An upgrade
+ # with --events false omits events.py from the new manifest, so the generic
+ # stale pass would delete it without the refcount check, breaking any other
+ # installed event-capable integration. Protect it here; its deletion is
+ # left to remove_integration_events(), which checks the refcount.
+ exclusions.add(EVENTS_DISPATCHER_REL)
+ return exclusions
+
+
+class EventRefreshError(RuntimeError):
+ """Raised when refreshing one or more integrations' event config failed.
+
+ Aggregates per-integration failures so a lifecycle command
+ (extension add/remove/enable/disable) can surface that an extension was
+ not fully deactivated ā a stale native hook may still be active (R3).
+ """
+
+ def __init__(self, failures: list[tuple[str, str]]) -> None:
+ self.failures = failures
+ details = "; ".join(f"{key}: {detail}" for key, detail in failures)
+ super().__init__(
+ f"event refresh failed for {len(failures)} integration(s): {details}"
+ )
+
+
+def refresh_integration_events(project_root: Path) -> None:
+ """Re-resolve and re-emit native event config for every installed
+ event-capable integration (#1).
+
+ Called after extension state changes (install/uninstall/enable/disable)
+ so that extension-declared events are regenerated in each installed
+ integration's native config ā otherwise the documented install-after-
+ ``specify init`` flow is inert and disabled/removed extension events stay
+ active. Each integration is refreshed independently; a failure for one
+ is logged and accumulated but does not abort the others. If any
+ integration failed, :class:`EventRefreshError` is raised at the end so
+ the lifecycle command can't claim the extension was fully deactivated
+ while a stale native hook may still be active (R3).
+ """
+ from .integrations import get_integration
+ from .integrations._helpers import _read_integration_json, _resolve_integration_options
+ from .integrations.manifest import IntegrationManifest
+ from .integration_state import installed_integration_keys
+
+ state = _read_integration_json(project_root)
+ failures: list[tuple[str, str]] = []
+ for key in installed_integration_keys(state):
+ integration = get_integration(key)
+ if integration is None or not integration.supports_events():
+ continue
+ try:
+ manifest = IntegrationManifest.load(key, project_root)
+ except Exception as exc:
+ logger.warning("Could not load manifest for '%s'; skipping event refresh: %s", key, exc)
+ failures.append((key, f"manifest load: {exc}"))
+ continue
+ try:
+ # C12: resolve first, then call install_integration_events once.
+ # The previous flow ran _remove_native_event_hooks *before*
+ # resolution, so any later failure (invalid destination, write
+ # error, formatter error) destroyed the working native config
+ # before the new one was written. install_integration_events
+ # already removes stale Specify-marked entries and handles an
+ # empty map (stripping prior hooks), so the destructive pre-step
+ # is both unsafe and redundant.
+ # S7: resolve this integration's persisted parsed_options so a
+ # stored --events false is honored across extension lifecycle
+ # changes; passing None would re-enable events the user disabled.
+ _, parsed_options = _resolve_integration_options(integration, state, key, None)
+ events_map = resolve_events(
+ key, integration.config, project_root, parsed_options
+ )
+ # install_integration_events handles both the populated case
+ # (writes new config, stripping stale owned entries) and the empty
+ # case (strips prior hooks for --events false / disabled override).
+ install_integration_events(integration, project_root, manifest, events_map)
+ manifest.save()
+ except Exception as exc:
+ logger.warning("Failed to refresh events for '%s': %s", key, exc)
+ failures.append((key, str(exc)))
+
+ if failures:
+ raise EventRefreshError(failures)
+
+
+# -- Manifest validation ---------------------------------------------------
+
+def validate_events(data: dict[str, Any]) -> None:
+ """Validate ``events`` field in extension manifest data."""
+ from .extensions import ValidationError
+
+ events = data.get("events")
+ if "events" in data and not isinstance(events, dict):
+ raise ValidationError("Invalid events: expected a mapping")
+ if events:
+ for event_name, event_config in events.items():
+ if not isinstance(event_config, dict):
+ raise ValidationError(
+ f"Invalid event '{event_name}': expected a mapping"
+ )
+ command = event_config.get("command")
+ # #17: command must be a non-empty string. A truthy non-string
+ # (e.g. command: [foo]) would pass a bare truthiness check and
+ # later render into invalid native configuration.
+ if not isinstance(command, str) or not command.strip():
+ raise ValidationError(
+ f"Event '{event_name}' missing required 'command' string"
+ )
+ if event_name not in CANONICAL_EVENTS:
+ raise ValidationError(
+ f"Unknown event '{event_name}': "
+ f"must be one of {sorted(CANONICAL_EVENTS)}"
+ )
+ # C10: matcher must be a string (or absent). A non-string matcher
+ # such as `matcher: []` would later crash by_matcher.setdefault.
+ matcher = event_config.get("matcher")
+ if matcher is not None and not isinstance(matcher, str):
+ raise ValidationError(
+ f"Event '{event_name}' has invalid 'matcher': must be a string"
+ )
+ timeout = event_config.get("timeout")
+ if timeout is not None:
+ if not isinstance(timeout, int) or isinstance(timeout, bool) or timeout <= 0:
+ raise ValidationError(
+ f"Event '{event_name}' has invalid 'timeout': must be a positive integer"
+ )
+
+
+def has_events(data: dict[str, Any]) -> bool:
+ """Return True if ``events`` is present and non-empty."""
+ return bool(data.get("events"))
+
+
+# -- Helper merging functions ----------------------------------------------
+
+def _toml_quote(value: str) -> str:
+ """Render *value* as a TOML basic string via the shared escaper."""
+ from ._toml_string import escape_toml_basic
+ return escape_toml_basic(value)
+
+
+def _build_opencode_plugin(
+ filtered_events: ResolvedEvents,
+ canonical_to_native: dict[str, str],
+) -> str:
+ """Render the opencode TS plugin for the resolved event set.
+
+ Each canonical event may carry multiple handlers (#2); all handlers for a
+ native event are invoked from one generated function. The dispatcher and
+ interpreter are resolved per-project at plugin load from the ``directory``
+ OpenCode passes (C8); the dispatcher is launched with ``execFileSync`` and
+ an argv array (C9). Both the ``input`` and ``output`` callback arguments
+ are forwarded to ``runEvent`` (C7) so pre_tool_use can inspect tool
+ arguments and post_tool_use can inspect the result.
+ """
+ event_entries: list[str] = []
+ plugin_returns: list[str] = []
+ event_handlers: list[str] = []
+
+ for ev, handlers in filtered_events.items():
+ native = canonical_to_native[ev]
+ # S1: serialize every interpolated value as a JSON string literal so a
+ # quote/backslash/backtick in a command or matcher can't break the
+ # generated TypeScript or inject code. json.dumps produces a valid
+ # TS/JS string literal (double-quoted, fully escaped).
+ ev_lit = json.dumps(ev)
+ native_lit = json.dumps(native)
+
+ # Build the body: one runEvent() call per handler wrapped in try/catch,
+ # forwarding both input and output (C7). An optional tool-name matcher
+ # guard applies to tool.execute.* hooks. All handlers execute before
+ # any aggregate error is thrown.
+ body_lines: list[str] = [" const errors: string[] = [];"]
+ for cfg in handlers:
+ command = str(cfg.get("command", ""))
+ command_lit = json.dumps(command)
+ matcher = cfg.get("matcher", "*")
+ # S3: thread the per-handler timeout (seconds) to runEvent so the
+ # execFileSync cap and dispatcher arg match the configuration
+ # instead of a fixed 60000ms / 120s.
+ timeout_sec = int(cfg.get("timeout", 60))
+ if native.startswith("tool.execute."):
+ if matcher and matcher != "*":
+ tools = [t.strip().strip('"') for t in matcher.split("|")]
+ checks = " || ".join(
+ f"input.tool === {json.dumps(t.lower())}" for t in tools
+ )
+ body_lines.append(
+ f" try {{ if ({checks}) {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} }} catch (e) {{ errors.push((e as Error).message); }}"
+ )
+ else:
+ body_lines.append(
+ f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
+ )
+ else:
+ body_lines.append(
+ f" try {{ runEvent({command_lit}, {ev_lit}, input, output, {timeout_sec}); }} catch (e) {{ errors.push((e as Error).message); }}"
+ )
+ body_lines.append(" if (errors.length > 0) { throw new Error(errors.join('; ')); }")
+
+ if native.startswith("tool.execute."):
+ ts_hook = native
+ event_entries.append(
+ f"function _{ev}(input: any, output: any) {{\n"
+ + "\n".join(body_lines) + "\n"
+ " }"
+ )
+ plugin_returns.append(
+ f" {json.dumps(ts_hook)}: async (input: any, output: any) => {{\n"
+ f" _{ev}(input, output);\n"
+ f" }},"
+ )
+ else:
+ event_entries.append(
+ f"function _{ev}(input: any, output: any) {{\n"
+ + "\n".join(body_lines) + "\n"
+ " }"
+ )
+ event_handlers.append(
+ f" if (event.type === {native_lit}) {{ _{ev}(event, event); }}"
+ )
+
+ if event_handlers:
+ plugin_returns.append(
+ " event: async ({ event }) => {\n"
+ + "\n".join(event_handlers) + "\n"
+ " },"
+ )
+
+ return _TS_PLUGIN_TEMPLATE.format(
+ buffer=EVENT_TIMEOUT_BUFFER,
+ event_entries="\n\n".join(event_entries),
+ plugin_returns="\n".join(plugin_returns),
+ )
+
+
+def _merge_opencode_plugin_ref(config_path: Path, ref: str) -> bool:
+ """Merge the speckit-events plugin ref into opencode.json.
+
+ Aborts with a warning (#23) when the file cannot be parsed (e.g. JSONC or
+ malformed JSON) instead of resetting user configuration to ``{}``. Returns
+ False when skipped so callers avoid tracking the untouched file (S5).
+ """
+ existing = _load_user_json(config_path)
+ if existing is None:
+ return False
+ plugins = existing.get("plugin", [])
+ if not isinstance(plugins, list):
+ plugins = []
+ if ref not in plugins:
+ plugins.append(ref)
+ existing["plugin"] = plugins
+ _safe_write_json(config_path, existing)
+ return True
+
+
+def _remove_opencode_entries(config_path: Path) -> bool:
+ """Remove the speckit-events plugin ref from opencode.json (#23).
+
+ Returns True if the file was deleted (now empty of user content), False
+ otherwise. Aborts without writing when the file cannot be parsed.
+ """
+ _ensure_safe_destination(config_path)
+ existing = _load_user_json(config_path)
+ if existing is None:
+ return False
+ plugins = existing.get("plugin", [])
+ if isinstance(plugins, list):
+ ref = "./.opencode/plugin/speckit-events.ts"
+ plugins = [p for p in plugins if p != ref]
+ if plugins:
+ existing["plugin"] = plugins
+ else:
+ existing.pop("plugin", None)
+ if not existing:
+ config_path.unlink(missing_ok=True)
+ return True
+ _safe_write_json(config_path, existing)
+ return False
+
+
+def _merge_toml_fragment(dst: Path, fragment: str) -> None:
+ _ensure_safe_destination(dst)
+ existing = ""
+ if dst.exists():
+ existing = dst.read_text(encoding="utf-8")
+ existing = re.sub(
+ r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
+ "",
+ existing,
+ flags=re.DOTALL,
+ )
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ dst.write_text(existing.rstrip() + "\n\n" + fragment + "\n", encoding="utf-8")
+
+
+def _remove_toml_entries(dst: Path) -> bool:
+ """Remove Specify-marked TOML entries; delete the file if now empty (#14).
+
+ Returns True if the file was deleted (no user content remained).
+ """
+ if not dst.exists():
+ return False
+ # R3: validate the destination before reading/writing so a symlink swap of
+ # the config after install can't make teardown overwrite a file outside
+ # the project (the merge/write path already validates; teardown must too).
+ _ensure_safe_destination(dst)
+ existing = dst.read_text(encoding="utf-8")
+ cleaned = re.sub(
+ r'\[\[hooks\.\w+\]\]\n(?:(?!\[\[hooks\.\w+\]\]).)*?speckit_marker = true\n*',
+ "",
+ existing,
+ flags=re.DOTALL,
+ )
+ # If only whitespace/comments remain, the file had no user content ā
+ # delete it rather than leaving an empty stub that confuses uninstall.
+ stripped = "\n".join(
+ line for line in cleaned.splitlines()
+ if line.strip() and not line.strip().startswith("#")
+ )
+ if not stripped:
+ dst.unlink(missing_ok=True)
+ return True
+ dst.write_text(cleaned, encoding="utf-8")
+ return False
+
+
+def _merge_copilot_json(dst: Path, new_hooks: dict[str, list]) -> bool:
+ """Merge Specify-owned hooks into Copilot's dedicated hooks JSON (#8).
+
+ A pre-existing user-authored ``.github/hooks/speckit.json`` is merged
+ (owned entries replaced via markers) rather than overwritten, and a
+ parse failure aborts instead of resetting user content (#22). Returns
+ False when skipped so callers avoid tracking the untouched file (S5).
+ """
+ existing = _load_user_json(dst)
+ if existing is None:
+ return False
+ if not isinstance(existing, dict):
+ existing = {}
+ existing.setdefault("version", 1)
+ existing_hooks = existing.get("hooks", {})
+ if not isinstance(existing_hooks, dict):
+ existing_hooks = {}
+ # #11: strip ALL Specify-marked entries from every event first.
+ cleaned_hooks: dict[str, list] = {}
+ for event, entries in existing_hooks.items():
+ if not isinstance(entries, list):
+ continue
+ kept_entries = _drop_marked_entries(entries)
+ if kept_entries:
+ cleaned_hooks[event] = kept_entries
+ for event, entries in new_hooks.items():
+ cleaned_hooks.setdefault(event, []).extend(entries)
+ if cleaned_hooks:
+ existing["hooks"] = cleaned_hooks
+ else:
+ existing.pop("hooks", None)
+ _safe_write_json(dst, existing)
+ return True
+
+
+def _remove_copilot_entries(dst: Path) -> bool:
+ """Remove Specify-owned hooks from Copilot's hooks JSON (#8, #14).
+
+ Deletes the file when no user-authored hooks remain; otherwise keeps the
+ file with user content. Aborts (no write) on parse failure (#22).
+ """
+ _ensure_safe_destination(dst)
+ existing = _load_user_json(dst)
+ if existing is None:
+ return False
+ if not isinstance(existing, dict):
+ return False
+ hooks = existing.get("hooks", {})
+ if not isinstance(hooks, dict):
+ hooks = {}
+ cleaned: dict[str, list] = {}
+ for event, entries in hooks.items():
+ if not isinstance(entries, list):
+ continue
+ kept_entries = _drop_marked_entries(entries)
+ if kept_entries:
+ cleaned[event] = kept_entries
+ if cleaned:
+ existing["hooks"] = cleaned
+ else:
+ existing.pop("hooks", None)
+ # Dedicated Spec-Kit file: delete when only the (Spec-Kit-invented)
+ # ``version`` key would remain ā no user content to preserve.
+ user_keys = {k for k in existing if k != "version"}
+ if not user_keys:
+ dst.unlink(missing_ok=True)
+ return True
+ _safe_write_json(dst, existing)
+ return False
+
+
+def _merge_json_fragment(dst: Path, new_hooks: dict, *, version: int | None = None) -> bool:
+ """Merge Specify-authored hook entries into a native JSON config.
+
+ Idempotent: removes ALL prior Specify-marked entries from every event in
+ the existing config first (#11), so an override that drops an event (e.g.
+ ``pre_tool_use`` ā ``stop``) doesn't leave stale marked entries behind.
+ Marker detection recurses into nested ``hooks`` arrays (#9) so a
+ matcher-group containing Specify-owned inner hooks is recognized and
+ replaced rather than duplicated on every upgrade.
+
+ Aborts with a warning (no write) when the existing file cannot be parsed
+ (#22) ā e.g. JSONC with comments ā instead of resetting user content to
+ ``{}``. Returns False when the merge was skipped so callers avoid tracking
+ the untouched file (S5: otherwise manifest.uninstall() later deletes the
+ user's JSONC/malformed file).
+
+ When *version* is given, the top-level ``version`` field is ensured
+ (preserving a user's value if present) so formats that require it ā e.g.
+ Cursor's ``.cursor/hooks.json`` schema (``version: 1``) ā stay valid on a
+ freshly generated file (#7).
+ """
+ existing = _load_user_json(dst)
+ if existing is None:
+ return False
+ if not isinstance(existing, dict):
+ existing = {}
+
+ if version is not None:
+ existing.setdefault("version", version)
+
+ hooks_key = "hooks"
+ existing_hooks = existing.get(hooks_key, {})
+ if not isinstance(existing_hooks, dict):
+ existing_hooks = {}
+
+ # #11: strip ALL Specify-marked entries from every event first.
+ cleaned_hooks: dict[str, list] = {}
+ for event, entries in existing_hooks.items():
+ if not isinstance(entries, list):
+ continue
+ kept_entries = _drop_marked_entries(entries)
+ if kept_entries:
+ cleaned_hooks[event] = kept_entries
+
+ # Then add the newly resolved set.
+ for event, entries in new_hooks.items():
+ cleaned_hooks.setdefault(event, []).extend(entries)
+
+ if cleaned_hooks:
+ existing[hooks_key] = cleaned_hooks
+ else:
+ existing.pop(hooks_key, None)
+ _safe_write_json(dst, existing)
+ return True
+
+
+def _merge_json_root(dst: Path, new_hooks: dict) -> bool:
+ """Merge Specify-authored hooks into a root-nested JSON config (Devin U2).
+
+ Devin's ``.devin/hooks.v1.json`` is a root event map
+ (``{"PreToolUse": [...]}``) with no ``hooks`` wrapper, so the event keys
+ are top-level. Same idempotent strip-all-marked-then-add semantics and
+ JSONC-abort behavior as ``_merge_json_fragment``.
+ """
+ existing = _load_user_json(dst)
+ if existing is None:
+ return False
+ if not isinstance(existing, dict):
+ existing = {}
+
+ # #11: strip ALL Specify-marked entries from every root event first.
+ cleaned: dict[str, list] = {}
+ for event, entries in existing.items():
+ if not isinstance(entries, list):
+ # Preserve non-list user fields at the root (Devin has none, but
+ # be defensive against a mixed user file).
+ cleaned[event] = entries # type: ignore[assignment]
+ continue
+ kept_entries = _drop_marked_entries(entries)
+ if kept_entries:
+ cleaned[event] = kept_entries
+
+ # Then add the newly resolved set (list values only).
+ for event, entries in new_hooks.items():
+ cleaned.setdefault(event, []).extend(entries)
+
+ if cleaned:
+ existing = cleaned
+ else:
+ existing = {}
+ if not existing:
+ dst.unlink(missing_ok=True)
+ return True
+ _safe_write_json(dst, existing)
+ return True
+
+
+def _remove_json_root_entries(dst: Path) -> bool:
+ """Remove Specify-authored entries from a root-nested JSON config (Devin U2).
+
+ Deletes the file when no user content remains (C5/#14 mirror).
+ """
+ _ensure_safe_destination(dst)
+ existing = _load_user_json(dst)
+ if existing is None:
+ return False
+ if not isinstance(existing, dict):
+ return False
+ cleaned: dict[str, Any] = {}
+ for event, entries in existing.items():
+ if not isinstance(entries, list):
+ cleaned[event] = entries
+ continue
+ kept_entries = _drop_marked_entries(entries)
+ if kept_entries:
+ cleaned[event] = kept_entries
+ if not cleaned:
+ dst.unlink(missing_ok=True)
+ return True
+ _safe_write_json(dst, cleaned)
+ return False
+
+
+def _drop_marked_entries(entries: list) -> list:
+ """Return *entries* with Specify-marked hooks removed, preserving user hooks.
+
+ Handles both flat entries (marker on the entry itself) and nested entries
+ (marker on inner ``hooks`` elements). A nested matcher-group whose inner
+ hooks are all Specify-owned is dropped; one with surviving user inner
+ hooks is kept with only the user hooks retained (#9).
+ """
+ kept: list = []
+ for entry in entries:
+ if not isinstance(entry, dict):
+ kept.append(entry)
+ continue
+ inner = entry.get("hooks")
+ if isinstance(inner, list):
+ kept_inner = [h for h in inner if not _has_marker(h)]
+ if kept_inner:
+ entry["hooks"] = kept_inner
+ kept.append(entry)
+ # else: outer group was entirely Specify-owned ā drop
+ elif _has_marker(entry):
+ pass # flat Specify-owned entry ā drop
+ else:
+ kept.append(entry)
+ return kept
+
+
+def _load_user_json(path: Path) -> dict | None:
+ """Load a user-owned JSON file, aborting (None) on parse failure (#22/#23).
+
+ Returns the parsed dict, or ``None`` when the file is missing or cannot be
+ parsed (e.g. JSONC with comments, or temporarily malformed JSON). Callers
+ must skip the merge rather than resetting user content to ``{}``.
+ """
+ if not path.exists():
+ return {}
+ try:
+ data = json.loads(path.read_text(encoding="utf-8"))
+ except (json.JSONDecodeError, ValueError) as exc:
+ logger.warning(
+ "Could not parse %s (may contain JSONC comments or be malformed); "
+ "skipping event-config merge to preserve user content.",
+ path,
+ )
+ logger.debug("Parse error detail: %s", exc)
+ return None
+ if not isinstance(data, dict):
+ logger.warning("%s is not a JSON object; skipping event-config merge.", path)
+ return None
+ return data
+
+
+def _safe_write_json(dst: Path, data: dict) -> None:
+ """Write *data* as JSON to *dst* after validating the destination (#12)."""
+ _ensure_safe_destination(dst)
+ dst.parent.mkdir(parents=True, exist_ok=True)
+ dst.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
+
+
+def _ensure_safe_destination(dst: Path) -> None:
+ """Validate a write target is a regular path inside the project (#12).
+
+ Walks each path component and rejects symlinks (which could escape the
+ project ā e.g. a symlinked ``.claude`` or ``.specify`` directory pointing
+ outside the repo would redirect writes to external files). Then validates
+ lexical containment so ``..`` traversal is also rejected.
+ """
+ from .agents import CommandRegistrar
+
+ # Walk each component so a symlinked ancestor (e.g. ``.claude`` ā outside)
+ # cannot be silently followed. Mirrors IntegrationManifest.record_existing.
+ walked = dst.anchor and Path(dst.anchor) or Path("/")
+ for part in dst.relative_to(dst.anchor).parts if dst.anchor else dst.parts:
+ walked = walked / part
+ if walked.is_symlink():
+ raise ValueError(
+ f"Refusing to write event config through a symlink: {walked}"
+ )
+
+ # Containment check against the nearest existing ancestor directory.
+ base = dst.parent
+ while not base.exists() and base != base.parent:
+ base = base.parent
+ CommandRegistrar._ensure_inside(dst, base)
+
+
+def _remove_json_entries(dst: Path) -> bool:
+ """Remove Specify-authored entries; delete the file if now empty (#14).
+
+ Returns True if the file was deleted (Spec Kit created it and no user
+ content remains), False otherwise.
+ """
+ _ensure_safe_destination(dst)
+ existing = _load_user_json(dst)
+ if existing is None:
+ return False
+ hooks = existing.get("hooks", {})
+ if not isinstance(hooks, dict):
+ return False
+ cleaned: dict[str, list] = {}
+ for event, entries in hooks.items():
+ if not isinstance(entries, list):
+ continue
+ kept_entries = _drop_marked_entries(entries)
+ if kept_entries:
+ cleaned[event] = kept_entries
+ if cleaned:
+ existing["hooks"] = cleaned
+ else:
+ existing.pop("hooks", None)
+ # #14/C5: if the config is now empty of user content, delete the file
+ # rather than leaving a stub that confuses manifest.uninstall(). A
+ # Spec-Kit-created Cursor file retains {"version": 1} after all owned
+ # hooks are removed (we added the version field); treat the version-only
+ # case as empty too, mirroring _remove_copilot_entries, so clean teardown
+ # doesn't leave a generated stub behind.
+ user_keys = {k for k in existing if k != "version"}
+ if not user_keys:
+ dst.unlink(missing_ok=True)
+ return True
+ _safe_write_json(dst, existing)
+ return False
+
+
+def _has_marker(entry: Any) -> bool:
+ """Return True if *entry* (or any nested inner hook) is Specify-marked (#9).
+
+ Flat entries carry the marker directly; nested matcher-groups carry it on
+ their inner ``hooks`` elements, so detection recurses one level to
+ recognize groups that are (wholly or partly) Specify-owned.
+ """
+ if not isinstance(entry, dict):
+ return False
+ if entry.get(_SPECKIT_MARKER, False) is True:
+ return True
+ inner = entry.get("hooks")
+ if isinstance(inner, list):
+ return any(_has_marker(h) for h in inner)
+ return False
diff --git a/src/specify_cli/extensions/__init__.py b/src/specify_cli/extensions/__init__.py
index 05aa35f7fb..354393b0da 100644
--- a/src/specify_cli/extensions/__init__.py
+++ b/src/specify_cli/extensions/__init__.py
@@ -9,13 +9,14 @@
from __future__ import annotations
import copy
+import errno
import hashlib
import json
import os
import re
import shutil
+import stat
import tempfile
-import zipfile
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
@@ -27,6 +28,13 @@
from packaging.specifiers import InvalidSpecifier, SpecifierSet
from .._assets import _locate_core_pack, _repo_root
+from .._download_security import (
+ MAX_JSON_CATALOG_BYTES,
+ build_safe_download_path,
+ is_https_or_localhost_http,
+ read_response_limited,
+ safe_extract_zip,
+)
from .._init_options import is_ai_skills_enabled
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
from .._utils import dump_frontmatter, relative_extension_path_violation, version_satisfies
@@ -101,6 +109,51 @@ def _load_core_command_names() -> frozenset[str]:
CORE_COMMAND_NAMES = _load_core_command_names()
+def _fsync_fd(fd: int) -> None:
+ """Sync a file descriptor, raising on real storage errors."""
+ try:
+ os.fsync(fd)
+ except AttributeError:
+ return
+ except NotImplementedError:
+ return
+ except OSError as exc:
+ if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
+ return
+ raise
+
+
+def _fsync_directory(path: Path) -> None:
+ """Sync a directory when the platform supports it."""
+ if not path.exists():
+ return
+ if os.name == "nt":
+ return
+ try:
+ dir_fd = os.open(str(path), os.O_RDONLY | getattr(os, "O_DIRECTORY", 0))
+ except (AttributeError, NotImplementedError):
+ return
+ except OSError as exc:
+ if exc.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
+ return
+ try:
+ dir_fd = os.open(str(path), os.O_RDONLY)
+ except (AttributeError, NotImplementedError):
+ return
+ except OSError as exc2:
+ if exc2.errno in {errno.ENOTSUP, errno.EOPNOTSUPP, errno.EINVAL, errno.EBADF}:
+ return
+ raise
+ try:
+ _fsync_fd(dir_fd)
+ finally:
+ try:
+ os.close(dir_fd)
+ except OSError:
+ # Cleanup after an fsync failure should not mask the original error.
+ pass
+
+
class ExtensionError(Exception):
"""Base exception for extension-related errors."""
@@ -136,7 +189,7 @@ def normalize_priority(value: Any, default: int = DEFAULT_HOOK_PRIORITY) -> int:
return default
try:
priority = int(value)
- except (TypeError, ValueError):
+ except (TypeError, ValueError, OverflowError):
return default
return priority if priority >= 1 else default
@@ -210,8 +263,25 @@ def _validate(self):
f"(expected {self.SCHEMA_VERSION})"
)
+ # The REQUIRED_FIELDS loop above only checks key PRESENCE, so a section
+ # that is written but left empty (``provides:`` -> None) or given the
+ # wrong shape (``provides: []``) passes it and then fails on first use:
+ # ``field not in None`` raises TypeError and ``None.get(...)`` raises
+ # AttributeError. Neither is a ValidationError, so both escape the
+ # callers that already handle malformed manifests -- list_installed()'s
+ # "Corrupted extension" fallback catches ValidationError only, so one bad
+ # extension made ``specify extension list`` exit 1 with a raw
+ # AttributeError instead of listing the rest. Guard each required
+ # section's shape, mirroring the nested guards below ("Invalid
+ # provides.commands: expected a list", "Invalid hooks: expected a
+ # mapping") and _load_yaml's document-root check.
+
# Validate extension metadata
ext = self.data["extension"]
+ if not isinstance(ext, dict):
+ raise ValidationError(
+ f"Invalid extension: expected a mapping, got {type(ext).__name__}"
+ )
for field in ["id", "name", "version", "description"]:
if field not in ext:
raise ValidationError(f"Missing extension.{field}")
@@ -246,24 +316,37 @@ def _validate(self):
# Validate requires section
requires = self.data["requires"]
+ if not isinstance(requires, dict):
+ raise ValidationError(
+ f"Invalid requires: expected a mapping, got {type(requires).__name__}"
+ )
if "speckit_version" not in requires:
raise ValidationError("Missing requires.speckit_version")
# Validate provides section
provides = self.data["provides"]
+ if not isinstance(provides, dict):
+ raise ValidationError(
+ f"Invalid provides: expected a mapping, got {type(provides).__name__}"
+ )
commands = provides.get("commands", [])
hooks = self.data.get("hooks")
+ events = self.data.get("events")
if "commands" in provides and not isinstance(commands, list):
raise ValidationError("Invalid provides.commands: expected a list")
if "hooks" in self.data and not isinstance(hooks, dict):
raise ValidationError("Invalid hooks: expected a mapping")
+ if "events" in self.data:
+ from ..events import validate_events
+ validate_events(self.data)
has_commands = bool(commands)
has_hooks = bool(hooks)
+ has_events = bool(events)
- if not has_commands and not has_hooks:
- raise ValidationError("Extension must provide at least one command or hook")
+ if not has_commands and not has_hooks and not has_events:
+ raise ValidationError("Extension must provide at least one command, hook, or event")
# Validate hook values (if present).
# Each event is a single mapping or a list of mappings.
@@ -351,6 +434,12 @@ def _validate(self):
raise ValidationError(
f"Aliases for command '{cmd['name']}' must be strings"
)
+ alias_reason = relative_extension_path_violation(alias)
+ if alias_reason:
+ raise ValidationError(
+ f"Invalid alias {alias!r} for command "
+ f"'{cmd['name']}': {alias_reason}"
+ )
# Rewrite any hook command references that pointed at a renamed command or
# an alias-form ref (ext.cmd ā speckit.ext.cmd). Always emit a warning when
@@ -381,6 +470,33 @@ def _validate(self):
f"The extension author should update the manifest."
)
+ # C11: apply the same rename + alias-lift canonicalization to event
+ # command references. Without this, an event referencing a command
+ # that was auto-corrected (e.g. speckit.boot -> speckit..boot)
+ # keeps the obsolete name, dispatch reports no command, and the event
+ # silently no-ops.
+ events_data = self.data.get("events", {})
+ if isinstance(events_data, dict):
+ for event_name, event_config in events_data.items():
+ if not isinstance(event_config, dict):
+ continue
+ command_ref = event_config.get("command")
+ if not isinstance(command_ref, str):
+ continue
+ after_rename = rename_map.get(command_ref, command_ref)
+ parts = after_rename.split(".")
+ if len(parts) == 2 and parts[0] == ext["id"]:
+ final_ref = f"speckit.{ext['id']}.{parts[1]}"
+ else:
+ final_ref = after_rename
+ if final_ref != command_ref:
+ event_config["command"] = final_ref
+ self.warnings.append(
+ f"Event '{event_name}' referenced command '{command_ref}'; "
+ f"updated to canonical form '{final_ref}'. "
+ f"The extension author should update the manifest."
+ )
+
@staticmethod
def _try_correct_command_name(name: str, ext_id: str) -> Optional[str]:
"""Try to auto-correct a non-conforming command name to the required pattern.
@@ -476,7 +592,7 @@ def _load(self) -> dict:
return {"schema_version": self.SCHEMA_VERSION, "extensions": {}}
try:
- with open(self.registry_path, "r") as f:
+ with open(self.registry_path, "r", encoding="utf-8") as f:
data = json.load(f)
# Validate loaded data is a dict (handles corrupted registry files)
if not isinstance(data, dict):
@@ -492,7 +608,7 @@ def _load(self) -> dict:
def _save(self):
"""Save registry to disk."""
self.extensions_dir.mkdir(parents=True, exist_ok=True)
- with open(self.registry_path, "w") as f:
+ with open(self.registry_path, "w", encoding="utf-8") as f:
json.dump(self.data, f, indent=2)
def add(self, extension_id: str, metadata: dict):
@@ -699,6 +815,55 @@ def __init__(self, project_root: Path):
self.extensions_dir = project_root / ".specify" / "extensions"
self.registry = ExtensionRegistry(self.extensions_dir)
+ def _rescue_staging_dir(self, extension_id: str) -> Path:
+ """Fixed-length staging directory path for a preserved-config rescue.
+
+ The extension ID can be arbitrarily long (manifest validation caps only
+ the character set, not the length), so embedding it verbatim in a single
+ path component could push the ``.rescue-staging-`` directory past a
+ filesystem's per-component byte limit and make every reinstall after
+ ``--keep-config`` fail with ``ENAMETOOLONG`` even though the extension
+ installs fine at ``dest_dir``. Hash the ID to a fixed-length suffix so
+ the component length is bounded regardless of ID length.
+ """
+ digest = hashlib.sha256(extension_id.encode("utf-8")).hexdigest()[:16]
+ return self.extensions_dir / f".rescue-staging-{digest}"
+
+ @staticmethod
+ def _has_keep_config_marker(directory: Path) -> bool:
+ """Return True when *directory* contains a valid ``.keep-config`` marker.
+
+ The marker is a regular (non-symlink) file written by
+ ``remove(..., keep_config=True)`` to record explicit provenance. Its
+ content is intentionally empty ā only presence matters, not content.
+ The symlink guard prevents a crafted symlink from fooling the check.
+ """
+ marker = directory / ".keep-config"
+ return marker.is_file() and not marker.is_symlink()
+
+ @staticmethod
+ def _is_legacy_keep_config_leftover(directory: Path) -> bool:
+ """Return True for the pre-marker ``remove(..., keep_config=True)`` layout.
+
+ Older CLI releases preserved only top-level config files and removed every
+ other entry, but they did not write ``.keep-config``. Recognize that exact
+ config-only leftover so upgrades still preserve user config, while
+ excluding partially-failed installs that still contain copied payload such
+ as ``extension.yml`` or command directories.
+ """
+ if not directory.is_dir() or directory.is_symlink():
+ return False
+
+ has_config = False
+ for entry in directory.iterdir():
+ if entry.name.endswith(("-config.yml", "-config.local.yml")) and (
+ entry.is_file() or entry.is_symlink()
+ ):
+ has_config = True
+ continue
+ return False
+ return has_config
+
@staticmethod
def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, str]:
"""Collect command and alias names declared by a manifest.
@@ -708,7 +873,7 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st
- primary commands must use this extension's namespace
- command namespaces must not shadow core commands
- duplicate command/alias names inside one manifest are rejected
- - aliases are validated for type and uniqueness only (no pattern enforcement)
+ - aliases are free-form but must remain safe relative output paths
Args:
manifest: Parsed extension manifest
@@ -745,6 +910,12 @@ def _collect_manifest_command_names(manifest: ExtensionManifest) -> Dict[str, st
f"{kind.capitalize()} for command '{primary_name}' must be a string"
)
+ path_reason = relative_extension_path_violation(name)
+ if path_reason:
+ raise ValidationError(
+ f"Invalid {kind} {name!r}: {path_reason}"
+ )
+
# Enforce canonical pattern only for primary command names;
# aliases are free-form to preserve community extension compat.
if kind == "command":
@@ -910,18 +1081,21 @@ def _ignore(directory: str, entries: List[str]) -> Set[str]:
return _ignore
- def _get_skills_dir(self) -> Optional[Path]:
+ def _get_skills_dir(self, *, create: bool = True) -> Optional[Path]:
"""Return the active skills directory for extension skill registration.
Delegates to :func:`resolve_active_skills_dir` which reads
init-options, applies the Kimi native-skills fallback, and
- safely creates the directory when ``ai_skills`` is enabled.
+ safely creates the directory when ``ai_skills`` is enabled and
+ ``create`` is true. Read-only callers can pass ``create=False`` to
+ resolve the configured target without changing the filesystem.
Returns ``None`` (instead of raising) when the directory cannot
be created due to symlink, containment, or permission issues so
that callers can fall back gracefully.
"""
from .. import (
+ _get_skills_dir as resolve_configured_skills_dir,
_print_cli_warning,
load_init_options,
resolve_active_skills_dir,
@@ -943,6 +1117,41 @@ def _ensure_usable(skills_dir: Path) -> Optional[Path]:
return None
return skills_dir
+ opts = load_init_options(self.project_root)
+ if not isinstance(opts, dict):
+ return None
+ selected_ai = opts.get("ai")
+ if not isinstance(selected_ai, str) or not selected_ai:
+ return None
+
+ from ..agents import CommandRegistrar
+
+ registrar = CommandRegistrar()
+ agent_config = registrar.AGENT_CONFIGS.get(selected_ai)
+ ai_skills_enabled = is_ai_skills_enabled(opts)
+ if not create:
+ if not ai_skills_enabled and selected_ai != "kimi":
+ return None
+ configured_skills_dir = resolve_configured_skills_dir(
+ self.project_root, selected_ai
+ )
+ from ..shared_infra import _validate_safe_shared_directory
+
+ try:
+ _validate_safe_shared_directory(
+ self.project_root, configured_skills_dir
+ )
+ except (OSError, ValueError):
+ return None
+ skills_dir = configured_skills_dir
+ if agent_config and agent_config.get("extension") == "/SKILL.md":
+ skills_dir = registrar._resolve_agent_dir(
+ selected_ai, agent_config, self.project_root
+ )
+ if ai_skills_enabled:
+ return skills_dir
+ return skills_dir if skills_dir.is_dir() else None
+
try:
skills_dir = resolve_active_skills_dir(self.project_root)
except (ValueError, OSError) as exc:
@@ -957,29 +1166,165 @@ def _ensure_usable(skills_dir: Path) -> Optional[Path]:
if skills_dir is None:
return None
- opts = load_init_options(self.project_root)
- if not isinstance(opts, dict):
- return _ensure_usable(skills_dir)
- selected_ai = opts.get("ai")
- if not isinstance(selected_ai, str) or not selected_ai:
- return _ensure_usable(skills_dir)
-
- from ..agents import CommandRegistrar
-
- registrar = CommandRegistrar()
- agent_config = registrar.AGENT_CONFIGS.get(selected_ai)
if agent_config and agent_config.get("extension") == "/SKILL.md":
- agent_skills_dir = registrar._resolve_agent_dir(
+ skills_dir = registrar._resolve_agent_dir(
selected_ai, agent_config, self.project_root
)
- return _ensure_usable(agent_skills_dir)
return _ensure_usable(skills_dir)
+ @staticmethod
+ def _skill_name_for_command(command_name: str) -> str:
+ """Return the generated skill directory name for an extension command."""
+ short_name = command_name
+ if short_name.startswith("speckit."):
+ short_name = short_name[len("speckit.") :]
+ return f"speckit-{short_name.replace('.', '-')}"
+
+ def _active_command_registration_scope(self) -> Optional[set[str]]:
+ """Return the agents a new extension install may render commands for.
+
+ ``None`` means legacy detection-based registration when init-options
+ is absent. An empty set means registration must fail closed.
+ """
+ from .. import load_init_options
+ from .._init_options import (
+ MISSING_INIT_OPTIONS_FILE,
+ resolve_active_agent_for_registration,
+ )
+
+ active_agent = resolve_active_agent_for_registration(self.project_root)
+ if active_agent is MISSING_INIT_OPTIONS_FILE:
+ return None
+ if active_agent is None:
+ return set()
+
+ from ..agents import CommandRegistrar as AgentRegistrar
+
+ agent_config = AgentRegistrar().AGENT_CONFIGS.get(active_agent)
+ if (
+ agent_config
+ and is_ai_skills_enabled(load_init_options(self.project_root))
+ and agent_config.get("extension") != "/SKILL.md"
+ ):
+ # Command-backed integrations render extension artifacts through
+ # _register_extension_skills while their skills mode is active.
+ return set()
+ return {active_agent}
+
+ def _command_registration_targets(self) -> Dict[str, Path]:
+ """Return current or recoverable command roots for a new install."""
+ from ..agents import CommandRegistrar as AgentRegistrar
+
+ registrar = AgentRegistrar()
+ agent_scope = self._active_command_registration_scope()
+ active_skills_agent = registrar._active_skills_agent(self.project_root)
+ recoverable_active_skills_dir = (
+ self._get_skills_dir(create=False)
+ if active_skills_agent is not None
+ else None
+ )
+ targets: Dict[str, Path] = {}
+
+ for agent_name, agent_config in registrar.AGENT_CONFIGS.items():
+ if agent_scope is not None and agent_name not in agent_scope:
+ continue
+
+ active_skills_output = (
+ agent_name == active_skills_agent
+ and agent_config.get("extension") == "/SKILL.md"
+ )
+ commands_dir = registrar._resolve_agent_dir(
+ agent_name, agent_config, self.project_root
+ )
+ active_output_is_recoverable = (
+ active_skills_output
+ and recoverable_active_skills_dir is not None
+ and registrar._same_lexical_path(
+ commands_dir, recoverable_active_skills_dir
+ )
+ )
+ detect_dir = agent_config.get("detect_dir")
+ if (
+ detect_dir
+ and not (self.project_root / detect_dir).is_dir()
+ and not active_output_is_recoverable
+ ):
+ continue
+
+ if commands_dir.is_dir() or active_output_is_recoverable:
+ targets[agent_name] = commands_dir
+
+ return targets
+
+ def _register_commands_for_active_agent(
+ self,
+ manifest: ExtensionManifest,
+ extension_dir: Path,
+ link_outputs: bool = False,
+ ) -> Dict[str, List[str]]:
+ """Register extension commands for the active integration only.
+
+ Maintainer-requested behavior for #2948: ``extension add`` treats the
+ project as single-active ā only the integration recorded in
+ init-options gets command files. Non-active integrations receive them
+ when selected via ``integration use`` / ``switch`` (rescaffold).
+
+ Projects without a recorded active integration at all (pre-init-options
+ layouts or direct library use, i.e. init-options.json does not
+ exist) fall back to detection-based registration for all agents. A
+ *recorded* active key that has no registrar config (e.g. ``generic``,
+ which is deliberately excluded from ``AGENT_CONFIGS``) is not treated
+ as "no active integration" ā it must not cause registration to
+ target other detected agents.
+
+ An init-options.json that exists but is corrupted, unreadable, or
+ has a malformed/empty ``ai`` value (e.g. ``[]`` or ``null``) is also
+ not "no active integration" ā fail closed (register nothing) rather
+ than fall back to registering every detected agent, which would
+ otherwise happen because a corrupted file loads the same as an
+ absent one.
+
+ Returns:
+ Mapping of agent name to registered command names, matching the
+ ``registered_commands`` registry shape.
+ """
+ registrar = CommandRegistrar()
+ agent_scope = self._active_command_registration_scope()
+
+ if agent_scope is None:
+ return registrar.register_commands_for_all_agents(
+ manifest,
+ extension_dir,
+ self.project_root,
+ link_outputs=link_outputs,
+ create_missing_active_skills_dir=True,
+ )
+
+ if not agent_scope:
+ # init-options.json exists but could not provide a valid active
+ # agent, or the active command-backed integration is in skills
+ # mode. Fail closed instead of falling back to all agents.
+ return {}
+
+ active_agent = next(iter(agent_scope))
+
+ # Route through the all-agents pass restricted to the active agent so
+ # detection and missing-skills-dir recovery safeguards still apply.
+ return registrar.register_commands_for_all_agents(
+ manifest,
+ extension_dir,
+ self.project_root,
+ link_outputs=link_outputs,
+ create_missing_active_skills_dir=True,
+ only_agent=active_agent,
+ )
+
def _register_extension_skills(
self,
manifest: ExtensionManifest,
extension_dir: Path,
link_outputs: bool = False,
+ force: bool = False,
) -> List[str]:
"""Generate SKILL.md files for extension commands as agent skills.
@@ -993,6 +1338,11 @@ def _register_extension_skills(
extension_dir: Installed extension directory.
link_outputs: If True, create dev-mode symlinks for rendered
skill files when supported by the OS.
+ force: If True, overwrite existing SKILL.md files even when they
+ are not dev-mode symlinks. Use in the upgrade path, where
+ ``setup()`` has just freshly regenerated core-template skill
+ files and the skip guard would otherwise prevent extension
+ content from being layered on top.
Returns:
List of skill names that were created (for registry storage).
@@ -1062,10 +1412,7 @@ def _replacement(match: re.Match[str]) -> str:
# Derive skill name from command name using the same hyphenated
# convention as hook rendering and preset skill registration.
- short_name_raw = cmd_name
- if short_name_raw.startswith("speckit."):
- short_name_raw = short_name_raw[len("speckit.") :]
- skill_name = f"speckit-{short_name_raw.replace('.', '-')}"
+ skill_name = self._skill_name_for_command(cmd_name)
# Check if skill already exists before creating the directory
skill_subdir = skills_dir / skill_name
@@ -1073,6 +1420,9 @@ def _replacement(match: re.Match[str]) -> str:
cache_root = extension_dir / ".specify-dev" / "extension-skills"
cache_file = cache_root / skill_name / "SKILL.md"
use_dev_symlink = link_outputs and not agent_config.get("dev_no_symlink")
+ skill_dir_preexists = (
+ skill_subdir.exists() or skill_subdir.is_symlink()
+ )
CommandRegistrar._ensure_inside(cache_file, cache_root)
if skill_file.exists() or skill_file.is_symlink():
is_expected_dev_symlink = self._is_expected_dev_symlink(
@@ -1080,9 +1430,17 @@ def _replacement(match: re.Match[str]) -> str:
)
# Do not overwrite user-customized skills, but allow dev-mode
# symlinks that point back to this extension's generated cache
- # to be refreshed on a subsequent dev install.
- if not is_expected_dev_symlink:
+ # to be refreshed on a subsequent dev install. In the upgrade
+ # path (force=True) the file was just written by setup(), so
+ # overwriting it with the composed extension content is correct.
+ if not is_expected_dev_symlink and not force:
continue
+ elif skill_dir_preexists and not force:
+ # Never add files to a pre-existing user directory. Without a
+ # verifiable SKILL.md ownership marker, rollback/removal cannot
+ # distinguish our output from unrelated user artifacts.
+ # Skipped when force=True (upgrade path).
+ continue
# Create skill directory; track whether we created it so we can clean
# up safely if reading the source file subsequently fails.
@@ -1175,6 +1533,141 @@ def _is_expected_dev_symlink(skill_file: Path, cache_file: Path) -> bool:
except OSError:
return False
+ def _find_extension_skill_dirs(
+ self,
+ skill_names: List[str],
+ extension_id: str,
+ skills_dir: Optional[Path] = None,
+ *,
+ create_skills_dir: bool = True,
+ ) -> List[Path]:
+ """Return owned skill directories that removal is allowed to delete.
+
+ This is the single discovery path used by both update backups and
+ unregistration. Keeping the ownership and containment checks shared
+ prevents rollback from backing up a different set of artifacts than
+ ``remove()`` later deletes.
+ """
+ if not skill_names:
+ return []
+
+ requested_skills_dir = skills_dir
+
+ project_root = Path(os.path.abspath(self.project_root))
+ fallback_candidates = {
+ candidate: trusted_root
+ for candidate, trusted_root in self._extension_skill_candidate_dirs().items()
+ if trusted_root == project_root
+ }
+ if requested_skills_dir is None:
+ candidate_dirs = dict(fallback_candidates)
+ elif skills_dir:
+ candidate = Path(os.path.abspath(skills_dir))
+ trusted_root = self._extension_skill_trusted_root(candidate)
+ candidate_dirs = (
+ {candidate: trusted_root} if trusted_root is not None else {}
+ )
+ else:
+ candidate_dirs = {}
+
+ from ..shared_infra import _validate_safe_shared_directory
+
+ owned_dirs: List[Path] = []
+ seen_dirs: set[Path] = set()
+ for skills_candidate, trusted_root in candidate_dirs.items():
+ try:
+ # Validate roots lexically before resolving them. Otherwise a
+ # symlinked root resolves to its target and makes descendants
+ # appear contained within itself. Explicit configured roots can
+ # legitimately be global (for example Hermes), while fallback
+ # roots remain restricted to this project.
+ _validate_safe_shared_directory(trusted_root, skills_candidate)
+ except (OSError, ValueError):
+ continue
+ if not skills_candidate.is_dir():
+ continue
+ for skill_name in skill_names:
+ # Guard against path traversal from a corrupted registry entry.
+ sn_path = Path(skill_name)
+ if sn_path.is_absolute() or len(sn_path.parts) != 1:
+ continue
+ skill_subdir = skills_candidate / skill_name
+ try:
+ _validate_safe_shared_directory(trusted_root, skill_subdir)
+ resolved_skill_dir = skill_subdir.resolve()
+ except (OSError, ValueError):
+ continue
+ if resolved_skill_dir in seen_dirs or not skill_subdir.is_dir():
+ continue
+
+ skill_md = skill_subdir / "SKILL.md"
+ if not skill_md.is_file():
+ continue
+ try:
+ from ..agents import CommandRegistrar as _Registrar
+
+ raw = skill_md.read_text(encoding="utf-8")
+ fm, _ = _Registrar.parse_frontmatter(raw)
+ source = (
+ fm.get("metadata", {}).get("source", "")
+ if isinstance(fm, dict)
+ else ""
+ )
+ if source != f"extension:{extension_id}":
+ continue
+ except Exception:
+ # If ownership cannot be verified, preserve the directory.
+ continue
+
+ seen_dirs.add(resolved_skill_dir)
+ owned_dirs.append(resolved_skill_dir)
+
+ return owned_dirs
+
+ def _extension_skill_trusted_root(self, candidate: Path) -> Optional[Path]:
+ """Return the project or home root allowed to contain *candidate*."""
+ candidate = Path(os.path.abspath(candidate))
+ for root in (
+ Path(os.path.abspath(self.project_root)),
+ Path(os.path.abspath(Path.home())),
+ ):
+ if candidate.is_relative_to(root):
+ return root
+ return None
+
+ def _extension_skill_candidate_dirs(self) -> Dict[Path, Path]:
+ """Return every configured skill output and its trusted root."""
+ from .. import AGENT_CONFIG, DEFAULT_SKILLS_DIR
+ from ..agents import CommandRegistrar
+
+ candidates: Dict[Path, Path] = {}
+
+ def add_candidate(candidate: Path) -> None:
+ candidate = Path(os.path.abspath(candidate))
+ trusted_root = self._extension_skill_trusted_root(candidate)
+ if trusted_root is not None:
+ candidates[candidate] = trusted_root
+
+ for cfg in AGENT_CONFIG.values():
+ folder = cfg.get("folder", "")
+ if folder:
+ add_candidate(
+ self.project_root / folder.rstrip("/") / "skills"
+ )
+ add_candidate(self.project_root / DEFAULT_SKILLS_DIR)
+
+ registrar = CommandRegistrar()
+ for agent_name, agent_config in registrar.AGENT_CONFIGS.items():
+ if agent_config.get("extension") != "/SKILL.md":
+ continue
+ add_candidate(
+ registrar._resolve_agent_dir(
+ agent_name, agent_config, self.project_root
+ )
+ )
+
+ return candidates
+
def _unregister_extension_skills(
self,
skill_names: List[str],
@@ -1186,130 +1679,119 @@ def _unregister_extension_skills(
Called during extension removal to clean up skill files that
were created by ``_register_extension_skills()``.
- If *skills_dir* is not provided and ``_get_skills_dir()`` returns
- ``None`` (e.g. the user removed init-options.json or toggled
- ai_skills after installation), we fall back to scanning all known
- agent skills directories so that orphaned skill directories are
- still cleaned up. In that case each candidate directory is
- verified against the SKILL.md ``metadata.source`` field before
- removal to avoid accidentally deleting user-created skills with
- the same name.
+ When *skills_dir* is omitted, project-local agent skill directories
+ are scanned. Home-scoped outputs require explicit agent provenance:
+ the legacy flat registry and ``metadata.source`` marker do not
+ identify which project created a global skill.
Args:
skill_names: List of skill names to remove.
extension_id: Extension ID used to verify ownership during
fallback candidate scanning.
- skills_dir: Optional explicit skills directory to use instead
- of resolving via ``_get_skills_dir()``. Useful when the
- caller needs to target a specific agent's skills directory
- regardless of the currently-active agent in init-options.
+ skills_dir: Optional explicit skills directory to scope
+ cleanup to. Useful when the caller needs to target a
+ specific agent's skills directory regardless of the
+ currently-active agent in init-options. When omitted,
+ every configured agent's skills directory is scanned
+ instead of resolving just the currently active one.
"""
- if not skill_names:
- return
+ for skill_subdir in self._find_extension_skill_dirs(
+ skill_names, extension_id, skills_dir=skills_dir
+ ):
+ shutil.rmtree(skill_subdir)
- if skills_dir is None:
- skills_dir = self._get_skills_dir()
+ def _extension_owned_skill_names(
+ self, skill_names: List[str], extension_id: str
+ ) -> List[str]:
+ """Return the subset of *skill_names* still marker-verified anywhere.
+
+ ``registered_skills`` is a single flat list shared across every
+ agent this extension has ever been activated under (skills are
+ only ever rendered for the currently active agent, so there is no
+ per-agent registry key to consult). A name can therefore still be
+ globally owned by this extension even after it's removed from one
+ particular agent's directory, if an earlier activation under a
+ *different* agent left its own marker-verified mirror behind.
+
+ This scans the same candidate directories (every configured
+ agent's skills folder, deduped by shared path, plus the default
+ skills directory) as the fallback branch of
+ :meth:`_unregister_extension_skills`, but read-only: no directory
+ is created and a name is only kept if at least one candidate
+ directory contains a ``SKILL.md`` whose ``metadata.source`` field
+ matches this exact extension (the same ownership marker
+ :meth:`_register_extension_skills` writes), so an unrelated
+ directory or user-created skill of the same name can't cause a
+ false positive. Symlink/containment safety mirrors the existing
+ fallback scan: each candidate path is resolved and the resulting
+ skill subdirectory is required to stay within it before any file
+ is read.
+ """
+ if not skill_names:
+ return []
- if skills_dir:
- # Fast path: we know the exact skills directory
+ from ..shared_infra import _validate_safe_shared_directory
+
+ marker = f"extension:{extension_id}"
+ owned: set = set()
+ for (
+ skills_candidate,
+ trusted_root,
+ ) in self._extension_skill_candidate_dirs().items():
+ if len(owned) == len(skill_names):
+ break # every name already confirmed owned somewhere
+ if not skills_candidate.is_dir():
+ continue
+ # Reject the candidate directory itself (any path component)
+ # if it's a symlink escaping the project root, before probing
+ # anything inside it. Resolving it and only checking children
+ # relative to the already-resolved candidate (the previous
+ # approach) would silently follow the symlink instead of
+ # rejecting it, letting a marker-matching SKILL.md outside the
+ # project be falsely attributed.
+ try:
+ _validate_safe_shared_directory(trusted_root, skills_candidate)
+ except (ValueError, OSError):
+ continue
for skill_name in skill_names:
- # Guard against path traversal from a corrupted registry entry:
- # reject names that are absolute, contain path separators, or
- # resolve to a path outside the skills directory.
+ if skill_name in owned:
+ continue
sn_path = Path(skill_name)
if sn_path.is_absolute() or len(sn_path.parts) != 1:
continue
+ skill_subdir = skills_candidate / skill_name
+ # Validate every path component down to the skill's own
+ # subdirectory, not just the already-validated candidate
+ # parent: a per-skill child can itself be a symlink to
+ # another directory whose resolved target still lands
+ # inside this same candidate, which the previous
+ # resolve()+relative_to() containment check alone would
+ # not catch (#2948).
try:
- skill_subdir = (skills_dir / skill_name).resolve()
- skill_subdir.relative_to(skills_dir.resolve()) # raises if outside
- except (OSError, ValueError):
+ _validate_safe_shared_directory(trusted_root, skill_subdir)
+ except (ValueError, OSError):
continue
if not skill_subdir.is_dir():
continue
- # Safety check: only delete if SKILL.md exists and its
- # metadata.source matches exactly this extension ā mirroring
- # the fallback branch ā so a corrupted registry entry cannot
- # delete an unrelated user skill.
skill_md = skill_subdir / "SKILL.md"
if not skill_md.is_file():
continue
try:
- import yaml as _yaml
+ from ..agents import CommandRegistrar as _Registrar
raw = skill_md.read_text(encoding="utf-8")
- source = ""
- if raw.startswith("---"):
- parts = raw.split("---", 2)
- if len(parts) >= 3:
- fm = _yaml.safe_load(parts[1]) or {}
- source = (
- fm.get("metadata", {}).get("source", "")
- if isinstance(fm, dict)
- else ""
- )
- if source != f"extension:{extension_id}":
- continue
+ fm, _ = _Registrar.parse_frontmatter(raw)
+ source = (
+ fm.get("metadata", {}).get("source", "")
+ if isinstance(fm, dict)
+ else ""
+ )
except (OSError, UnicodeDecodeError, Exception):
continue
- shutil.rmtree(skill_subdir)
- else:
- # Fallback: scan all possible agent skills directories
- from .. import AGENT_CONFIG, DEFAULT_SKILLS_DIR
-
- candidate_dirs: set[Path] = set()
- for cfg in AGENT_CONFIG.values():
- folder = cfg.get("folder", "")
- if folder:
- candidate_dirs.add(
- self.project_root / folder.rstrip("/") / "skills"
- )
- candidate_dirs.add(self.project_root / DEFAULT_SKILLS_DIR)
+ if source == marker:
+ owned.add(skill_name)
- for skills_candidate in candidate_dirs:
- if not skills_candidate.is_dir():
- continue
- for skill_name in skill_names:
- # Same path-traversal guard as the fast path above
- sn_path = Path(skill_name)
- if sn_path.is_absolute() or len(sn_path.parts) != 1:
- continue
- try:
- skill_subdir = (skills_candidate / skill_name).resolve()
- skill_subdir.relative_to(
- skills_candidate.resolve()
- ) # raises if outside
- except (OSError, ValueError):
- continue
- if not skill_subdir.is_dir():
- continue
- # Safety check: only delete if SKILL.md exists and its
- # metadata.source matches exactly this extension. If the
- # file is missing or unreadable we skip to avoid deleting
- # unrelated user-created directories.
- skill_md = skill_subdir / "SKILL.md"
- if not skill_md.is_file():
- continue
- try:
- import yaml as _yaml
-
- raw = skill_md.read_text(encoding="utf-8")
- source = ""
- if raw.startswith("---"):
- parts = raw.split("---", 2)
- if len(parts) >= 3:
- fm = _yaml.safe_load(parts[1]) or {}
- source = (
- fm.get("metadata", {}).get("source", "")
- if isinstance(fm, dict)
- else ""
- )
- # Only remove skills explicitly created by this extension
- if source != f"extension:{extension_id}":
- continue
- except (OSError, UnicodeDecodeError, Exception):
- # If we can't verify, skip to avoid accidental deletion
- continue
- shutil.rmtree(skill_subdir)
+ return [name for name in skill_names if name in owned]
def check_compatibility(
self, manifest: ExtensionManifest, speckit_version: str
@@ -1428,24 +1910,427 @@ def install_from_directory(
backup_config_dir.unlink()
did_remove = self.remove(manifest.id)
+ # Load and validate .extensionignore BEFORE reading/creating the rescue
+ # staging directory (and thus before deleting dest_dir). The loader can
+ # raise ValidationError (invalid UTF-8) or OSError; doing it first means
+ # such a failure aborts while the kept config is still authoritative in
+ # its documented location, rather than leaving a freshly published
+ # staging copy that a later retry (after the user edits the kept config)
+ # would reload and use to overwrite the newer bytes. Any staging left by
+ # an earlier destructive attempt is intentionally left intact here.
+ ignore_fn = self._load_extensionignore(source_dir)
+
+ # Rescue any config files left behind by a prior `remove --keep-config`.
+ # When an extension is removed with --keep-config, it is no longer in
+ # the registry but its config files remain in dest_dir. A subsequent
+ # plain (non-force) install would delete that directory unconditionally,
+ # silently discarding the preserved config. We read those files into
+ # memory and also write a durable staging copy outside dest_dir so
+ # that a partial rmtree, failed copytree, or partial restore cannot
+ # permanently discard the user's original bytes on a retry. The
+ # staging dir is removed only after every config has been successfully
+ # restored.
+ stranded_configs: dict[str, tuple[bytes, int]] = {}
+ rescue_staging_dir = self._rescue_staging_dir(manifest.id)
+ # A staging directory is trusted only when this completion marker is
+ # present. The marker is written after every staged file is complete
+ # and removed before the non-atomic cleanup, so a crash mid-staging or
+ # mid-cleanup can never leave a partial directory that a retry mistakes
+ # for a complete durable backup.
+ rescue_complete_marker = rescue_staging_dir / ".rescue-complete"
+ staging_is_complete = (
+ rescue_staging_dir.is_dir()
+ and not rescue_staging_dir.is_symlink()
+ and rescue_complete_marker.is_file()
+ and not rescue_complete_marker.is_symlink()
+ )
+
+ if staging_is_complete and not self.registry.is_installed(manifest.id):
+ # A previous install attempt staged the configs but never
+ # completed cleanly. Reload from the durable backup so the
+ # original bytes are used on retry rather than whatever
+ # mixture of packaged defaults and partial restores remains
+ # on disk. Only load non-symlinked files whose names match
+ # the two recognised config suffixes so a tampered staging
+ # directory cannot inject arbitrary files.
+ #
+ # A complete staging directory proves only that staging finished,
+ # not that dest_dir was ever modified: a crash after staging was
+ # synced but before the rmtree below leaves the live kept config
+ # intact. If the user then edits that live config before retrying,
+ # blindly preferring the staged bytes would silently overwrite the
+ # newer config. The staged and live copies are indistinguishable
+ # in provenance from disk alone (a genuine post-crash edit vs. a
+ # packaged default written by a partially-completed copytree), so
+ # when a live config disagrees with its staged copy we must not
+ # silently pick either ā preserve both and abort, letting the user
+ # resolve it. dest_dir is still untouched here, so raising is safe.
+ def _recognized_config_names(
+ directory: Path, *, follow_symlinks: bool = True
+ ) -> set[str]:
+ names: set[str] = set()
+ if not directory.is_dir():
+ return names
+ for entry in directory.iterdir():
+ if not entry.name.endswith(
+ ("-config.yml", "-config.local.yml")
+ ):
+ continue
+ if follow_symlinks:
+ if entry.is_file() and not entry.is_symlink():
+ names.add(entry.name)
+ else:
+ # Include symlinks without following them so that
+ # live-only symlinked configs are detected and
+ # preserved rather than silently deleted.
+ if entry.is_file() or entry.is_symlink():
+ names.add(entry.name)
+ return names
+
+ conflicting: set[str] = set()
+ staged_names = _recognized_config_names(rescue_staging_dir)
+ live_names = _recognized_config_names(
+ dest_dir, follow_symlinks=False
+ )
+
+ def _matches_source_config_baseline(config_name: str) -> bool:
+ source_file = source_dir / config_name
+ live_file = dest_dir / config_name
+ if source_file.is_symlink() or live_file.is_symlink():
+ return False
+ if not source_file.is_file() or not live_file.is_file():
+ return False
+ try:
+ source_stat = source_file.stat()
+ source_bytes = source_file.read_bytes()
+ live_stat = live_file.stat()
+ live_bytes = live_file.read_bytes()
+ except OSError:
+ return False
+ return live_bytes == source_bytes and stat.S_IMODE(
+ live_stat.st_mode
+ ) == stat.S_IMODE(source_stat.st_mode)
+
+ # A live-only config created after the interrupted attempt is not
+ # enumerated by staging, so without this it would be silently
+ # deleted by the rmtree below and its bytes lost. Live-only files
+ # that still match the current package baseline are safe: they were
+ # copied by the interrupted install and can be recreated on retry.
+ # Only truly divergent live-only configs are conflicts.
+ live_only = live_names - staged_names
+ conflicting.update(
+ name
+ for name in live_only
+ if not _matches_source_config_baseline(name)
+ )
+ # Load original permission bits from the sidecar JSON written by
+ # the staging step. Staged files are kept at mode 0o600 so that
+ # rmtree always succeeds on Windows, so staged_stat.st_mode would
+ # always be 0o600 and must not be used for mode comparisons or
+ # restoration; the sidecar records the true original mode.
+ rescue_modes_file = rescue_staging_dir / ".rescue-modes.json"
+ _staged_modes: dict[str, int] = {}
+ if rescue_modes_file.is_file() and not rescue_modes_file.is_symlink():
+ try:
+ _loaded_modes = json.loads(rescue_modes_file.read_bytes())
+ except (OSError, ValueError):
+ # Ignore unreadable/invalid sidecar metadata and fall back
+ # to each staged file's mode for compatibility.
+ pass
+ else:
+ # json.loads() succeeds for any valid JSON document, so a
+ # sidecar containing e.g. `[]` or a string would otherwise
+ # crash later at _staged_modes.get() or stat.S_IMODE().
+ # Accept only a mapping of string filenames to integer modes
+ # (bool is rejected despite subclassing int); anything else
+ # falls back to each staged file's own mode.
+ if isinstance(_loaded_modes, dict) and all(
+ isinstance(name, str)
+ and isinstance(recorded_mode, int)
+ and not isinstance(recorded_mode, bool)
+ for name, recorded_mode in _loaded_modes.items()
+ ):
+ _staged_modes = _loaded_modes
+ for staged_name in sorted(staged_names):
+ staged_file = rescue_staging_dir / staged_name
+ staged_stat = staged_file.stat()
+ staged_bytes = staged_file.read_bytes()
+ # Prefer the sidecar-recorded mode; fall back to the staged
+ # file's own mode for backwards-compat with staging dirs
+ # written before the sidecar was introduced.
+ staged_mode = _staged_modes.get(
+ staged_name, stat.S_IMODE(staged_stat.st_mode)
+ )
+ live_file = dest_dir / staged_name
+ if live_file.is_symlink():
+ # A user may have replaced the live config with a symlink
+ # after the interrupted attempt. It cannot be compared by
+ # bytes/mode against the staged copy, and the rmtree below
+ # would silently delete this newer choice and restore the
+ # older staged file. Treat any live symlink as a conflict so
+ # both are preserved and the user resolves it.
+ conflicting.add(staged_name)
+ elif live_file.is_file():
+ # A live config that cannot be read or stat'ed must not be
+ # treated as non-conflicting: the rmtree below would delete
+ # it and restore the stale staged copy. Abort while dest_dir
+ # is untouched so no newer or permission-restricted config is
+ # lost. Divergence also includes permission-only edits (for
+ # example tightening a secret-bearing config from 0644 to
+ # 0600), which byte equality alone would miss and then revert.
+ try:
+ live_stat = live_file.stat()
+ live_bytes = live_file.read_bytes()
+ except OSError:
+ conflicting.add(staged_name)
+ else:
+ if live_bytes != staged_bytes or stat.S_IMODE(
+ live_stat.st_mode
+ ) != staged_mode:
+ conflicting.add(staged_name)
+ stranded_configs[staged_name] = (staged_bytes, staged_mode)
+ if conflicting:
+ # Split into two cases for accurate user guidance: files that
+ # exist in both locations but have diverged, and files that
+ # exist only in the live directory with no rescue-backup copy.
+ both_diverged = conflicting - live_only
+ live_only_conflict = conflicting & live_only
+ msg_parts: list[str] = [
+ f"Preserved extension config conflict for '{manifest.id}':"
+ ]
+ if both_diverged:
+ names = ", ".join(sorted(both_diverged))
+ msg_parts.append(
+ f"The current config(s) ({names}) in {dest_dir} differ"
+ f" from their rescued backup in {rescue_staging_dir}."
+ " Both copies have been preserved."
+ )
+ if live_only_conflict:
+ names = ", ".join(sorted(live_only_conflict))
+ msg_parts.append(
+ f"The config(s) ({names}) exist only in {dest_dir}"
+ f" with no counterpart in the rescued backup at"
+ f" {rescue_staging_dir}."
+ )
+ msg_parts.append(
+ f"Reconcile {dest_dir} and {rescue_staging_dir} to the"
+ f" desired final state, delete {rescue_staging_dir},"
+ " then reinstall."
+ )
+ raise ValidationError(" ".join(msg_parts))
+ elif (
+ dest_dir.exists()
+ and not self.registry.is_installed(manifest.id)
+ and (
+ self._has_keep_config_marker(dest_dir)
+ or self._is_legacy_keep_config_leftover(dest_dir)
+ )
+ ):
+ for cfg_file in (
+ list(dest_dir.glob("*-config.yml"))
+ + list(dest_dir.glob("*-config.local.yml"))
+ ):
+ if cfg_file.is_symlink():
+ # `remove --keep-config` preserves a symlinked config
+ # because Path.is_file() follows symlinks. Its bytes cannot
+ # be safely rescued (the target may live outside dest_dir),
+ # and the rmtree below would delete the link and silently
+ # discard the kept configuration. Reject the reinstall while
+ # dest_dir is untouched so the user resolves it rather than
+ # losing the linked config.
+ raise ValidationError(
+ "Preserved extension config for "
+ f"'{manifest.id}' is a symlink ({cfg_file.name}) in "
+ f"{dest_dir}, which cannot be safely rescued during "
+ "reinstall. Resolve manually ā replace the symlink with "
+ "a regular file or remove it ā then reinstall."
+ )
+ if cfg_file.is_file():
+ stranded_configs[cfg_file.name] = (
+ cfg_file.read_bytes(),
+ cfg_file.stat().st_mode,
+ )
+
+ if stranded_configs and not staging_is_complete:
+ # Write a durable backup outside dest_dir before any
+ # destructive operation so the original bytes survive a
+ # crash or partial failure at any later step. The staging
+ # dir is cleaned up only after every restore succeeds.
+ #
+ # Any pre-existing staging dir here lacks the completion marker
+ # (staging_is_complete is False), so it is a stale partial from an
+ # interrupted attempt ā remove it first for a clean write.
+ if rescue_staging_dir.is_symlink():
+ rescue_staging_dir.unlink()
+ elif rescue_staging_dir.is_dir():
+ shutil.rmtree(rescue_staging_dir)
+ elif rescue_staging_dir.exists():
+ rescue_staging_dir.unlink()
+ try:
+ rescue_staging_dir.mkdir(parents=True, exist_ok=True)
+ for filename, (content, mode) in stranded_configs.items():
+ staged = rescue_staging_dir / filename
+ # Create the staging file with mode 0600 before writing so
+ # the preserved bytes are never transiently readable by other
+ # local users, even on a umask that would produce 0644.
+ # O_BINARY (0 on POSIX) is required so Windows does not open
+ # the descriptor in text mode and translate the preserved
+ # bytes' "\n" into "\r\n" as they are written.
+ fd = os.open(
+ str(staged),
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
+ 0o600,
+ )
+ try:
+ # os.write() may write fewer bytes than requested, so
+ # loop until the whole buffer is on disk ā a truncated
+ # "durable" backup would be trusted over the intact
+ # config on a retry and cause silent data loss.
+ view = memoryview(content)
+ written = 0
+ while written < len(view):
+ written += os.write(fd, view[written:])
+ # Do NOT chmod the staged file: setting a read-only
+ # mode (e.g. 0o444) makes the file undeletable on
+ # Windows and causes shutil.rmtree to fail during
+ # cleanup. Original modes are recorded separately in
+ # .rescue-modes.json so they can be reapplied when the
+ # config is actually restored.
+ _fsync_fd(fd)
+ finally:
+ os.close(fd)
+ # Persist the original permission bits in a sidecar JSON file
+ # so a retry can correctly reapply them even though the staged
+ # files themselves are kept at their creation mode (0o600).
+ rescue_modes_file = rescue_staging_dir / ".rescue-modes.json"
+ modes_payload = json.dumps(
+ {
+ filename: stat.S_IMODE(mode)
+ for filename, (_, mode) in stranded_configs.items()
+ },
+ sort_keys=True,
+ ).encode()
+ modes_fd = os.open(
+ str(rescue_modes_file),
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL | getattr(os, "O_BINARY", 0),
+ 0o600,
+ )
+ try:
+ view = memoryview(modes_payload)
+ written = 0
+ while written < len(view):
+ written += os.write(modes_fd, view[written:])
+ _fsync_fd(modes_fd)
+ finally:
+ os.close(modes_fd)
+ # Flush the staging directory metadata before publishing the
+ # completion marker so a crash cannot leave a visible marker with
+ # only a subset of staged files.
+ _fsync_directory(rescue_staging_dir)
+ # Write the completion marker only after every staged file is
+ # fully written so a retry trusts staging only when it is whole.
+ marker_fd = os.open(
+ str(rescue_complete_marker),
+ os.O_WRONLY | os.O_CREAT | os.O_EXCL,
+ 0o600,
+ )
+ try:
+ _fsync_fd(marker_fd)
+ finally:
+ os.close(marker_fd)
+ _fsync_directory(rescue_staging_dir)
+ _fsync_directory(rescue_staging_dir.parent)
+ except BaseException:
+ # Durable staging failed (or was interrupted). Continuing with
+ # only the in-memory copy would reintroduce the permanent-loss
+ # path this staging exists to close: the rmtree below could
+ # delete the originals and a later restore failure would leave
+ # no on-disk copy. dest_dir is still untouched here, so clean
+ # up the partial staging dir and abort the install instead of
+ # proceeding destructively.
+ shutil.rmtree(rescue_staging_dir, ignore_errors=True)
+ raise
+
# Install extension (dest_dir computed above during self-install guard)
if dest_dir.exists():
shutil.rmtree(dest_dir)
- ignore_fn = self._load_extensionignore(source_dir)
- shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
+ def _restore_stranded_config_file(
+ target: Path, content: bytes, preserved_mode: int
+ ) -> None:
+ tmp_path: Path | None = None
+ try:
+ # A short fixed prefix, not f".{target.name}.": the preserved
+ # config filename may itself already be near the filesystem's
+ # per-component byte limit, and NamedTemporaryFile appends a
+ # random suffix to the prefix ā reusing the full name would push
+ # the temp file past the limit and raise ENAMETOOLONG on every
+ # retry. tempfile already guarantees collision avoidance.
+ with tempfile.NamedTemporaryFile(
+ mode="wb",
+ dir=target.parent,
+ prefix=".cfg-restore.",
+ delete=False,
+ ) as tmp:
+ tmp_path = Path(tmp.name)
+ tmp.write(content)
+ tmp.flush()
+ _fsync_fd(tmp.fileno())
+ try:
+ tmp_path.chmod(stat.S_IMODE(preserved_mode))
+ except (NotImplementedError, OSError):
+ pass # Best-effort; chmod may not be supported on all platforms.
+ os.replace(tmp_path, target)
+ try:
+ target_fd = os.open(str(target), os.O_RDONLY)
+ except (AttributeError, OSError, NotImplementedError):
+ target_fd = None
+ try:
+ if target_fd is not None:
+ _fsync_fd(target_fd)
+ finally:
+ if target_fd is not None:
+ try:
+ os.close(target_fd)
+ except OSError:
+ pass # best-effort close during cleanup; ignore errors
+ _fsync_directory(target.parent)
+ except BaseException:
+ if tmp_path is not None and tmp_path.exists():
+ tmp_path.unlink()
+ raise
- # Register commands with AI agents
+ try:
+ shutil.copytree(source_dir, dest_dir, ignore=ignore_fn)
+ except BaseException:
+ # copytree failed ā dest_dir may be absent or only partially
+ # created. Write the rescued configs back now so they are not
+ # permanently lost even though the install did not complete.
+ if stranded_configs:
+ dest_dir.mkdir(parents=True, exist_ok=True)
+ for filename, (content, mode) in stranded_configs.items():
+ target = dest_dir / filename
+ _restore_stranded_config_file(target, content, mode)
+ raise
+
+ # Restore stranded configs rescued before the rmtree above.
+ for filename, (content, mode) in stranded_configs.items():
+ target = dest_dir / filename
+ _restore_stranded_config_file(target, content, mode)
+
+ # NOTE: the durable staging backup is intentionally NOT cleaned up
+ # here. Command/skill/hook registration and the final registry.add()
+ # below can still fail; if we discarded the backup and provenance now,
+ # such a failure would leave the extension unregistered with no durable
+ # rescue copy, so the next plain retry would skip rescue and overwrite
+ # the restored user config with packaged defaults. Cleanup is deferred
+ # until after registry.add() succeeds (see post-commit cleanup below).
+
+ # Register commands with AI agents (active integration only, #2948)
registered_commands = {}
if register_commands:
- registrar = CommandRegistrar()
- # Register for all detected agents
- registered_commands = registrar.register_commands_for_all_agents(
- manifest,
- dest_dir,
- self.project_root,
- link_outputs=link_commands,
- create_missing_active_skills_dir=True,
+ registered_commands = self._register_commands_for_active_agent(
+ manifest, dest_dir, link_outputs=link_commands
)
# Auto-register extension commands as agent skills when skills mode
@@ -1496,6 +2381,42 @@ def install_from_directory(
},
)
+ # Post-commit cleanup: the registry now records this extension as
+ # installed, so the rescue guard (`not self.registry.is_installed`)
+ # will never misread a leftover staging dir on a future run. The
+ # durable backup has therefore served its purpose and can be removed
+ # best-effort ā a cleanup failure must not fail an install that has
+ # already committed successfully.
+ if rescue_staging_dir.is_dir() and not rescue_staging_dir.is_symlink():
+ # Remove the completion marker before the non-atomic rmtree so a
+ # crash mid-cleanup cannot leave a staging dir that a retry would
+ # wrongly trust as a complete durable backup.
+ try:
+ rescue_complete_marker.unlink(missing_ok=True)
+ _fsync_directory(rescue_staging_dir)
+ shutil.rmtree(rescue_staging_dir)
+ _fsync_directory(rescue_staging_dir.parent)
+ except OSError:
+ pass # Best-effort; install already committed to the registry.
+
+ # Restore execute bits on shipped POSIX scripts. copytree here (and the
+ # zipfile.extractall in install_from_zip, which delegates to this method) does
+ # not restore a stripped Unix mode, so a bundled *.sh would land non-executable
+ # and a documented `.specify/extensions//scripts/...` invocation would fail
+ # with "Permission denied". This is the single sink every install route funnels
+ # through (extension add / update / bundle), so fixing it here covers them all.
+ # No-op on Windows (helper returns early).
+ #
+ # Deliberately the whole-project call, not a scoped one. The helper's contract
+ # is "make .specify scripts executable" ā the same idempotent invariant that
+ # init and migrate restore ā so re-establishing it after an install is exactly
+ # its job. A scoped variant would only spare re-walking already-correct files,
+ # a handful of stats that are negligible beside the copy/extract this method just
+ # did, and it would cost a per-caller scan-scope argument on an otherwise simple,
+ # widely-used interface. The simpler call wins.
+ from .. import ensure_executable_scripts
+ ensure_executable_scripts(self.project_root)
+
return manifest
def install_from_zip(
@@ -1528,21 +2449,7 @@ def install_from_zip(
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
- # Extract ZIP safely (prevent Zip Slip attack)
- with zipfile.ZipFile(zip_path, "r") as zf:
- # Validate all paths first before extracting anything
- temp_path_resolved = temp_path.resolve()
- for member in zf.namelist():
- member_path = (temp_path / member).resolve()
- # Use is_relative_to for safe path containment check
- try:
- member_path.relative_to(temp_path_resolved)
- except ValueError:
- raise ValidationError(
- f"Unsafe path in ZIP archive: {member} (potential path traversal)"
- )
- # Only extract after all paths are validated
- zf.extractall(temp_path)
+ safe_extract_zip(zip_path, temp_path, error_type=ValidationError)
# Find extension directory (may be nested)
extension_dir = temp_path
@@ -1612,6 +2519,12 @@ def remove(self, extension_id: str, keep_config: bool = False) -> bool:
shutil.rmtree(child)
else:
child.unlink()
+ # Write a provenance marker so install_from_directory can
+ # distinguish this --keep-config leftover from a directory left
+ # by a partially-failed install (which must not have its
+ # packaged default configs treated as user-preserved data).
+ # Content is intentionally empty ā only presence matters.
+ (extension_dir / ".keep-config").write_text("")
else:
# Backup config files before deleting
if extension_dir.exists():
@@ -1648,7 +2561,13 @@ def _valid_name_list(value: Any) -> List[str]:
return []
return [item for item in value if isinstance(item, str)]
- def unregister_agent_artifacts(self, agent_name: str) -> None:
+ def unregister_agent_artifacts(
+ self,
+ agent_name: str,
+ *,
+ enabled_only: bool = False,
+ commands_only: bool = False,
+ ) -> None:
"""Remove extension files registered for a specific agent.
Extension command files are tracked per agent in ``registered_commands``.
@@ -1656,6 +2575,14 @@ def unregister_agent_artifacts(self, agent_name: str) -> None:
from that agent's skills directory (resolved via its integration config)
and the registry field is cleared.
+ Set ``enabled_only=True`` when a caller is about to re-register enabled
+ extensions and must preserve disabled extensions' existing artifacts and
+ registry entries.
+
+ Set ``commands_only=True`` for command-directory reconciliation where
+ skill artifacts are outside the target agent's lifecycle and must not
+ be touched.
+
Skips cleanup when *agent_name* is not a supported agent to avoid
losing registry entries while leaving orphaned files on disk.
"""
@@ -1674,6 +2601,9 @@ def unregister_agent_artifacts(self, agent_name: str) -> None:
agent_skills_dir = resolve_skills_dir(self.project_root, agent_name)
for ext_id, metadata in self.registry.list().items():
+ if enabled_only and not metadata.get("enabled", True):
+ continue
+
updates: Dict[str, Any] = {}
registered_commands = metadata.get("registered_commands", {})
@@ -1696,47 +2626,66 @@ def unregister_agent_artifacts(self, agent_name: str) -> None:
registered_skills = self._valid_name_list(
metadata.get("registered_skills", [])
)
- if registered_skills:
- # Only pass the resolved skills_dir when it actually exists.
- # Otherwise let _unregister_extension_skills fall back to
- # scanning all known agent skills directories, which is useful
- # for cleaning up stale entries created by earlier installs.
- skills_dir = agent_skills_dir if agent_skills_dir.is_dir() else None
+ if registered_skills and not commands_only:
+ # Always pass the explicit, agent-scoped skills_dir ā even
+ # when it doesn't currently exist on disk. This method must
+ # stay scoped to *this* agent only; omitting skills_dir (a
+ # bare ``None``) tells _unregister_extension_skills "this is
+ # a genuinely unscoped removal", which triggers its
+ # all-configured-agents fallback scan ā reserved for
+ # ExtensionManager.remove()'s full project cleanup. If this
+ # agent's directory doesn't exist, there is nothing under it
+ # to clean up; the fast path below is a safe no-op in that
+ # case (every candidate skill_subdir.is_dir() check fails).
self._unregister_extension_skills(
- registered_skills, ext_id, skills_dir=skills_dir
+ registered_skills, ext_id, skills_dir=agent_skills_dir
)
- # Only reconcile registry state when cleanup was scoped to a
- # specific existing directory. When skills_dir is None,
- # _unregister_extension_skills falls back to scanning multiple
- # candidate directories, so agent_skills_dir cannot be used to
- # infer what was removed. When skills_dir is set,
- # _unregister_extension_skills may intentionally skip deletion
- # when ownership cannot be verified (e.g., corrupted/missing
- # SKILL.md or mismatching metadata.source). Only drop registry
- # entries for skill directories that were actually removed so
- # future cleanup attempts can still find skipped ones.
- if skills_dir is not None:
- remaining_skills = [
- skill_name
- for skill_name in registered_skills
- if (skills_dir / skill_name).is_dir()
- ]
+ # Only reconcile registry state when this agent's directory
+ # actually exists. When it's absent, this agent never had
+ # any of these skills mirrored under its own directory in
+ # the first place, so there is nothing to conclude about
+ # global ``registered_skills`` tracking from that absence ā
+ # other agents' directories may still legitimately hold
+ # live mirrors for these same names (the flat list is
+ # agent-agnostic). Recomputing "remaining" against an
+ # absent directory would incorrectly conclude every name
+ # was removed and drop them all from the registry, silently
+ # orphaning any still-live mirrors under other agents'
+ # directories from future cleanup/removal.
+ #
+ # When the directory does exist, _unregister_extension_skills
+ # may intentionally skip deletion when ownership cannot be
+ # verified (e.g., corrupted/missing SKILL.md or mismatching
+ # metadata.source). A name no longer present under *this*
+ # agent's directory isn't necessarily gone everywhere either
+ # ā registered_skills is a single flat list shared across
+ # every agent this extension was ever activated under, so
+ # an earlier activation under a different, still-active
+ # agent may have left its own marker-verified mirror behind.
+ # Recompute across every safe, supported skills directory
+ # (the same helper used for the analogous toggle-cleanup
+ # case) rather than just this one, or a still-existing
+ # mirror elsewhere would be silently dropped from tracking
+ # and orphaned on later removal (#2948).
+ if agent_skills_dir.is_dir():
+ remaining_skills = self._extension_owned_skill_names(
+ registered_skills, ext_id
+ )
if remaining_skills != registered_skills:
updates["registered_skills"] = remaining_skills
if updates:
self.registry.update(ext_id, updates)
- def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
+ def register_enabled_extensions_for_agent(self, agent_name: str, *, force: bool = False) -> None:
"""Register installed, enabled extensions for ``agent_name``.
Command-file registration is scoped to the explicit ``agent_name``
- argument, so this method can be used after install, upgrade, or switch.
- Extension skill rendering is still scoped to the active ``ai`` /
- ``ai_skills`` settings in init-options, so non-active skills-mode
- targets receive command files here. Per-agent skills parity is tracked
- separately in #2948.
+ argument. Since #2948, callers pass the active agent only (``use`` /
+ ``switch`` activate the target first; ``upgrade`` calls it only for
+ the active integration), so extension skill rendering ā scoped to the
+ active ``ai`` / ``ai_skills`` init-options ā matches ``agent_name``.
"""
if not agent_name:
return
@@ -1757,6 +2706,22 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
and bool(agent_config)
and agent_config.get("extension") != "/SKILL.md"
)
+ # Mirror image of skills_mode_active: this agent is command-backed,
+ # active, and currently in command mode. Used to detect a
+ # skills -> command toggle for this same agent, where the skills
+ # phase below returns empty (its directory no longer resolves) but
+ # a previously-written extension SKILL.md is now stale (#2948).
+ command_mode_active = (
+ active_agent == agent_name
+ and not ai_skills_enabled
+ and bool(agent_config)
+ and agent_config.get("extension") != "/SKILL.md"
+ )
+ agent_skills_dir = None
+ if agent_config and agent_config.get("extension") != "/SKILL.md":
+ from .. import _get_skills_dir as _resolve_agent_skills_dir
+
+ agent_skills_dir = _resolve_agent_skills_dir(self.project_root, agent_name)
for ext_id, metadata in self.registry.list().items():
if not metadata.get("enabled", True):
@@ -1773,6 +2738,10 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
# registration of the remaining enabled extensions for this agent.
try:
updates: Dict[str, Any] = {}
+ # Set when a command -> skills toggle for this same agent
+ # defers stale command-mode cleanup until the skills
+ # replacement below confirms success (#2948).
+ deferred_stale_commands: Optional[List[str]] = None
if agent_config and not skills_mode_active:
registered = registrar.register_commands_for_agent(
@@ -1792,21 +2761,42 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
new_registered.pop(agent_name, None)
if new_registered != registered_commands:
updates["registered_commands"] = new_registered
+ elif agent_config and skills_mode_active:
+ # Toggled command -> skills for this same agent: the
+ # commands phase above is skipped. A command file this
+ # extension previously wrote for this agent while
+ # command mode was active is still on disk and still
+ # tracked, but it must NOT be removed yet ā the skills
+ # phase below is an independently fallible replacement
+ # step, and deleting the old artifact before it
+ # succeeds would leave neither the old command file nor
+ # a new skill file if skills registration raises. The
+ # actual removal is deferred until after the skills
+ # phase below completes without raising (#2948).
+ registered_commands = metadata.get("registered_commands", {})
+ if isinstance(registered_commands, dict) and registered_commands.get(
+ agent_name
+ ):
+ deferred_stale_commands = self._valid_name_list(
+ registered_commands.get(agent_name)
+ )
+ else:
+ deferred_stale_commands = None
+ else:
+ deferred_stale_commands = None
# Extension *skills* are only ever rendered for the active agent:
# `_register_extension_skills` resolves the skills dir and
# frontmatter from init-options["ai"], ignoring ``agent_name``.
- # When this method runs for a non-active agent ā as install/upgrade
- # now do for a secondary integration (#2886) ā the skills pass would
- # re-render the *active* agent's extension skills as a side effect,
+ # Running the skills pass for a non-active agent would re-render
+ # the *active* agent's extension skills as a side effect,
# resurrecting skill files the user deliberately deleted. Skip it
- # unless the target is the active agent; `switch` is unaffected
- # because it activates the target before registering. (Rendering
- # skills for a non-active target is tracked separately in #2948.)
+ # unless the target is the active agent (defense in depth: since
+ # #2948 callers only pass the active agent anyway).
if agent_name == active_agent:
try:
registered_skills = self._register_extension_skills(
- manifest, ext_dir
+ manifest, ext_dir, force=force
)
except Exception as skills_err:
# Skills are a companion artifact. If command registration
@@ -1833,6 +2823,140 @@ def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
dict.fromkeys(existing_skills + registered_skills)
)
updates["registered_skills"] = merged_skills
+ elif command_mode_active and agent_skills_dir is not None:
+ # Mirror image: toggled skills -> command for
+ # this same agent. _register_extension_skills
+ # returned empty because this agent's skills
+ # directory no longer resolves once ai_skills is
+ # off, but a SKILL.md this extension wrote while
+ # skills mode was active may still be tracked
+ # and still on disk. Remove it narrowly for this
+ # agent's directory only (#2948).
+ existing_skills = self._valid_name_list(
+ metadata.get("registered_skills", [])
+ )
+ owned_here = [
+ name
+ for name in existing_skills
+ if (agent_skills_dir / name).is_dir()
+ ]
+ # Only retire a skill mirror when the
+ # replacement command for the same logical
+ # command was actually written this call ā
+ # `registered` (from register_commands_for_agent
+ # above) may be empty or a partial subset
+ # (missing source file, safety rejection,
+ # corrupted manifest), and removing a skill
+ # mirror whose command replacement never
+ # landed would leave neither artifact (#2948).
+ replaced_skill_names = {
+ HookExecutor._skill_name_from_command(cmd_name)
+ for cmd_name in (registered or [])
+ }
+ to_remove = [
+ name for name in owned_here
+ if name in replaced_skill_names
+ ]
+ if to_remove:
+ self._unregister_extension_skills(
+ to_remove, ext_id, skills_dir=agent_skills_dir
+ )
+ # registered_skills is a single flat list
+ # shared across every agent this extension
+ # was ever activated under (unlike presets'
+ # per-agent dict), so a name removed from
+ # *this* agent's directory may still have a
+ # marker-verified mirror under a different,
+ # previously-active agent's directory.
+ # Recompute across every safe, supported
+ # skills directory rather than just this
+ # one, or a still-existing mirror elsewhere
+ # would be silently dropped from tracking
+ # and orphaned on later removal (#2948).
+ remaining = self._extension_owned_skill_names(
+ existing_skills, ext_id
+ )
+ if remaining != existing_skills:
+ updates["registered_skills"] = remaining
+
+ # The skills phase above completed without raising
+ # (this ``else:`` is only reached on success), so a
+ # deferred command -> skills toggle cleanup queued
+ # above is now safe to apply: the replacement skill
+ # registration is confirmed, so the stale
+ # command-mode artifact can finally be removed
+ # without risking a transient state where neither
+ # artifact exists. Only retire a stale command
+ # whose corresponding skill was actually returned
+ # this call ā `registered_skills` may be empty or
+ # a partial subset (missing source file, safety
+ # rejection, corrupted manifest), and unregistering
+ # a command whose skill replacement never landed
+ # would leave neither artifact (#2948).
+ if deferred_stale_commands:
+ replaced_skill_names = set(registered_skills or [])
+ # Commands may carry aliases (CommandRegistrar.
+ # register_commands_for_agent() tracks and
+ # returns primary + alias names flattened
+ # together into one list), but
+ # _register_extension_skills() only ever
+ # renders/returns the *primary* command name's
+ # skill ā running an alias's own name through
+ # _skill_name_from_command() never matches
+ # anything real, so an alias would stay
+ # tracked/on-disk forever even after its
+ # primary's skill replacement landed. Map each
+ # stale name back to its manifest command's
+ # primary so the whole primary+alias group is
+ # retired or kept together, based solely on
+ # whether the *primary*'s skill replacement
+ # actually landed (#2948).
+ alias_to_primary: Dict[str, str] = {}
+ for cmd_info in manifest.commands:
+ primary_name = cmd_info.get("name")
+ if not isinstance(primary_name, str):
+ continue
+ for alias in cmd_info.get("aliases", []) or []:
+ if isinstance(alias, str):
+ alias_to_primary[alias] = primary_name
+
+ group_fully_replaced: Dict[str, bool] = {}
+ for cmd_name in deferred_stale_commands:
+ primary_name = alias_to_primary.get(cmd_name, cmd_name)
+ if primary_name in group_fully_replaced:
+ continue
+ group_fully_replaced[primary_name] = (
+ HookExecutor._skill_name_from_command(primary_name)
+ in replaced_skill_names
+ )
+
+ fully_replaced = [
+ cmd_name for cmd_name in deferred_stale_commands
+ if group_fully_replaced.get(
+ alias_to_primary.get(cmd_name, cmd_name), False
+ )
+ ]
+ if fully_replaced:
+ registrar.unregister_commands(
+ {agent_name: fully_replaced}, self.project_root
+ )
+ registered_commands = metadata.get(
+ "registered_commands", {}
+ )
+ if isinstance(registered_commands, dict) and (
+ registered_commands.get(agent_name)
+ ):
+ new_registered = copy.deepcopy(registered_commands)
+ remaining_commands = [
+ c for c in new_registered[agent_name]
+ if c not in fully_replaced
+ ]
+ if remaining_commands:
+ new_registered[agent_name] = remaining_commands
+ else:
+ new_registered.pop(agent_name, None)
+ if new_registered != registered_commands:
+ updates["registered_commands"] = new_registered
if updates:
self.registry.update(ext_id, updates)
@@ -2001,6 +3125,7 @@ def register_commands_for_all_agents(
project_root: Path,
link_outputs: bool = False,
create_missing_active_skills_dir: bool = False,
+ only_agent: Optional[str] = None,
) -> Dict[str, List[str]]:
"""Register extension commands for all detected agents."""
context_note = f"\n\n\n"
@@ -2012,6 +3137,7 @@ def register_commands_for_all_agents(
context_note=context_note,
link_outputs=link_outputs,
create_missing_active_skills_dir=create_missing_active_skills_dir,
+ only_agent=only_agent,
extension_id=manifest.id,
)
@@ -2073,14 +3199,23 @@ def _open_url(
url: str,
timeout: int = 10,
extra_headers: Optional[Dict[str, str]] = None,
+ redirect_validator=None,
):
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli.authentication.http.open_url`.
+ *redirect_validator*, when provided, is invoked as ``(old_url, new_url)``
+ before EACH redirect hop so an HTTPS host guarantee can be enforced on
+ every intermediate URL, not just the terminal one.
"""
from specify_cli.authentication.http import open_url
- return open_url(url, timeout, extra_headers=extra_headers)
+ return open_url(
+ url,
+ timeout,
+ extra_headers=extra_headers,
+ redirect_validator=redirect_validator,
+ )
def _resolve_github_release_asset_api_url(
self,
@@ -2304,8 +3439,32 @@ def _fetch_single_catalog(
# Fetch from network
try:
- with self._open_url(entry.url, timeout=10) as response:
- catalog_data = json.loads(response.read())
+ # Validate EVERY redirect hop, not just the terminal URL. _open_url
+ # follows redirects; _StripAuthOnRedirect drops auth on an HTTPS->HTTP
+ # downgrade AND whenever the redirect leaves the configured trusted
+ # hosts, but the payload itself is still fetched and trusted, and it
+ # supplies each extension's download_url + sha256 (so a redirected
+ # payload defeats sha256 verification). A terminal-only check also
+ # misses an https -> http -> attacker-https chain. redirect_validator
+ # runs before each hop; the final geturl() check is kept as a
+ # belt-and-braces guard. Mirrors bundler/services/adapters.py.
+ def _validate_redirect(_old_url: str, new_url: str) -> None:
+ self._validate_catalog_url(new_url)
+
+ with self._open_url(
+ entry.url, timeout=10, redirect_validator=_validate_redirect
+ ) as response:
+ final_url = response.geturl()
+ if final_url != entry.url:
+ self._validate_catalog_url(final_url)
+ catalog_data = json.loads(
+ read_response_limited(
+ response,
+ max_bytes=MAX_JSON_CATALOG_BYTES,
+ error_type=ExtensionError,
+ label=f"extension catalog {entry.url}",
+ )
+ )
self._validate_catalog_payload(catalog_data, entry.url)
@@ -2481,8 +3640,26 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
try:
import urllib.error
- with self._open_url(catalog_url, timeout=10) as response:
- catalog_data = json.loads(response.read())
+ # Same redirect hardening as _fetch_single_catalog: validate every
+ # redirect hop AND the final URL so this legacy single-catalog path
+ # is not vulnerable to an HTTPS->HTTP redirected payload either.
+ def _validate_redirect(_old_url: str, new_url: str) -> None:
+ self._validate_catalog_url(new_url)
+
+ with self._open_url(
+ catalog_url, timeout=10, redirect_validator=_validate_redirect
+ ) as response:
+ final_url = response.geturl()
+ if final_url != catalog_url:
+ self._validate_catalog_url(final_url)
+ catalog_data = json.loads(
+ read_response_limited(
+ response,
+ max_bytes=MAX_JSON_CATALOG_BYTES,
+ error_type=ExtensionError,
+ label=f"extension catalog {catalog_url}",
+ )
+ )
# Validate catalog structure. Reuses the same helper as
# ``_fetch_single_catalog`` so all three branches (root type,
@@ -2551,22 +3728,35 @@ def search(
if verified_only and not ext_data.get("verified", False):
continue
- if author and ext_data.get("author", "").lower() != author.lower():
- continue
+ if author:
+ author_val = ext_data.get("author", "")
+ if not isinstance(author_val, str):
+ author_val = str(author_val) if author_val is not None else ""
+ if author_val.lower() != author.lower():
+ continue
- if tag and tag.lower() not in [t.lower() for t in ext_data.get("tags", [])]:
- continue
+ if tag:
+ raw_tags = ext_data.get("tags", [])
+ tags_list = raw_tags if isinstance(raw_tags, list) else []
+ if tag.lower() not in [
+ t.lower() for t in tags_list if isinstance(t, str)
+ ]:
+ continue
if query:
# Search in name, description, and tags
query_lower = query.lower()
+ raw_tags = ext_data.get("tags", [])
+ tags_list = raw_tags if isinstance(raw_tags, list) else []
+ name_val = ext_data.get("name", "")
+ desc_val = ext_data.get("description", "")
searchable_text = " ".join(
[
- ext_data.get("name", ""),
- ext_data.get("description", ""),
+ str(name_val) if name_val else "",
+ str(desc_val) if desc_val else "",
ext_id,
]
- + ext_data.get("tags", [])
+ + [t for t in tags_list if isinstance(t, str)]
).lower()
if query_lower not in searchable_text:
@@ -2627,13 +3817,33 @@ def download_extension(
download_url = ext_info.get("download_url")
if not download_url:
raise ExtensionError(f"Extension '{extension_id}' has no download URL")
+ if not isinstance(download_url, str):
+ raise ExtensionError(
+ f"Extension download URL is malformed: {download_url}"
+ )
# Validate download URL requires HTTPS (prevent man-in-the-middle attacks)
from urllib.parse import urlparse
- parsed = urlparse(download_url)
- is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
- if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
+ # A malformed authority (e.g. an unterminated IPv6 bracket
+ # "https://[::1") makes urlparse / hostname access raise ValueError.
+ # The download_url comes from catalog payload data, so surface a clean
+ # ExtensionError rather than leaking a raw ValueError past the command
+ # handler (which only catches ExtensionError). Mirrors catalogs (#3435)
+ # and workflows/catalog.py (#3484).
+ try:
+ parsed = urlparse(download_url)
+ hostname = parsed.hostname
+ parsed.port
+ except ValueError:
+ raise ExtensionError(
+ f"Extension download URL is malformed: {download_url}"
+ ) from None
+ if not hostname:
+ raise ExtensionError(
+ f"Extension download URL is malformed: {download_url}"
+ )
+ if not is_https_or_localhost_http(download_url):
raise ExtensionError(
f"Extension download URL must use HTTPS: {download_url}"
)
@@ -2641,11 +3851,16 @@ def download_extension(
# Determine target path
if target_dir is None:
target_dir = self.cache_dir / "downloads"
- target_dir.mkdir(parents=True, exist_ok=True)
-
+ target_dir = Path(target_dir)
version = ext_info.get("version", "unknown")
- zip_filename = f"{extension_id}-{version}.zip"
- zip_path = target_dir / zip_filename
+ zip_path = build_safe_download_path(
+ target_dir,
+ extension_id,
+ version,
+ error_type=ExtensionError,
+ label="extension",
+ )
+ target_dir.mkdir(parents=True, exist_ok=True)
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
@@ -2658,7 +3873,11 @@ def download_extension(
with self._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
- zip_data = response.read()
+ zip_data = read_response_limited(
+ response,
+ error_type=ExtensionError,
+ label=f"extension '{extension_id}' download",
+ )
verify_archive_sha256(
zip_data, ext_info.get("sha256"), extension_id, ExtensionError
@@ -2676,10 +3895,8 @@ def download_extension(
def clear_cache(self):
"""Clear the catalog cache (both legacy and URL-hash-based files)."""
- if self.cache_file.exists():
- self.cache_file.unlink()
- if self.cache_metadata_file.exists():
- self.cache_metadata_file.unlink()
+ self.cache_file.unlink(missing_ok=True)
+ self.cache_metadata_file.unlink(missing_ok=True)
# Also clear any per-URL hash-based cache files
if self.cache_dir.exists():
for extra_cache in self.cache_dir.glob("catalog-*.json"):
@@ -3030,6 +4247,7 @@ def _render_hook_invocation(self, command: Any) -> str:
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:
@@ -3040,6 +4258,10 @@ def _render_hook_invocation(self, command: Any) -> str:
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)
diff --git a/src/specify_cli/extensions/_commands.py b/src/specify_cli/extensions/_commands.py
index 4494e15114..166364920b 100644
--- a/src/specify_cli/extensions/_commands.py
+++ b/src/specify_cli/extensions/_commands.py
@@ -8,12 +8,14 @@
"""
from __future__ import annotations
+import hashlib
import os
import shutil
import tempfile
import zipfile
from pathlib import Path
from typing import Optional
+from uuid import uuid4
import typer
import yaml
@@ -23,6 +25,15 @@
from .._console import console
from .._assets import get_speckit_version
+from .._download_security import (
+ is_https_or_localhost_http,
+ normalize_zip_member_name,
+ open_zip_bounded,
+ portable_zip_path_key,
+ read_response_limited,
+ read_zip_member_limited,
+)
+from .._init_options import is_ai_skills_enabled
extension_app = typer.Typer(
name="extension",
@@ -60,6 +71,30 @@ def _display_project_path(*args, **kwargs):
return _f(*args, **kwargs)
+def _refresh_events_and_warn(project_root: Path) -> None:
+ """Refresh native event config and surface failures (R3).
+
+ The extension has already been added/removed/enabled/disabled by the time
+ this runs, so a refresh failure must not abort the command ā but it must
+ be surfaced, because a stale native hook may still be active (e.g. a
+ disabled extension's hook still resolves and runs). Prints a warning with
+ the per-integration failures so the user knows deactivation was incomplete.
+ """
+ from ..events import EventRefreshError, refresh_integration_events
+
+ try:
+ refresh_integration_events(project_root)
+ except EventRefreshError as exc:
+ console.print(
+ f"\n[yellow]ā [/yellow] Extension updated, but event refresh failed "
+ f"for {len(exc.failures)} integration(s); a stale native hook may "
+ f"still be active. Re-run [cyan]specify integration upgrade "
+ f"[cyan][/cyan][/cyan] to retry."
+ )
+ for key, detail in exc.failures:
+ console.print(f" {key}: {_escape_markup(detail)}")
+
+
def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict:
"""Load extension catalog CLI config with user-facing shape errors."""
try:
@@ -166,9 +201,17 @@ def _resolve_catalog_extension(
if ext_info:
return (ext_info, None)
- # Try by display name - search using argument as query, then filter for exact match
- search_results = catalog.search(query=argument)
- name_matches = [ext for ext in search_results if ext["name"].lower() == argument.lower()]
+ # Try by display name - search using argument as query, then filter for exact match.
+ # Coerce name defensively: catalog JSON is user-editable, so a hand-authored
+ # non-string/missing name must not crash the match (the ambiguous-match display
+ # below already str()-coerces name for the same reason).
+ search_results = catalog.search()
+ argument_lower = argument.lower()
+ name_matches = [
+ ext
+ for ext in search_results
+ if str(ext.get("name", "")).lower() == argument_lower
+ ]
if len(name_matches) == 1:
return (name_matches[0], None)
@@ -428,14 +471,24 @@ def extension_add(
try:
parsed = urlparse(from_url)
+ # Read .hostname inside the try: parsing a malformed authority -- or
+ # accessing .hostname on one, e.g. an invalid bracketed IPv6 host like
+ # "https://[not-an-ip]/x.zip" -- can raise ValueError. Keeping both the
+ # parse and the .hostname read inside the guard surfaces a clean
+ # "Invalid URL" message instead of leaking a raw traceback past the
+ # CLI. Reuse the value below.
+ hostname = parsed.hostname
+ parsed.port
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
- is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
+ if not hostname:
+ console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
+ raise typer.Exit(1)
- if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
+ if not is_https_or_localhost_http(from_url):
console.print("[red]Error:[/red] URL must use HTTPS for security.")
- console.print("HTTP is only allowed for localhost URLs.")
+ console.print("HTTP is only allowed for loopback URLs.")
raise typer.Exit(1)
safe_url = _escape_markup(from_url)
@@ -518,7 +571,11 @@ def extension_add(
with dl_catalog._open_url(
download_url, timeout=60, extra_headers=extra_headers
) as response:
- zip_data = response.read()
+ zip_data = read_response_limited(
+ response,
+ error_type=ExtensionError,
+ label=f"extension {from_url}",
+ )
if not zipfile.is_zipfile(io.BytesIO(zip_data)):
console.print(
@@ -620,19 +677,29 @@ def extension_add(
console.print(f"\n[bold]{_escape_markup(str(manifest.name))}[/bold] (v{_escape_markup(str(manifest.version))})")
console.print(f" {_escape_markup(str(manifest.description))}")
+ # #1: regenerate native event config for installed event-capable
+ # integrations so the new extension's events take effect immediately.
+ _refresh_events_and_warn(project_root)
+
for warning in manifest.warnings:
console.print(f"\n[yellow]ā Compatibility warning:[/yellow] {_escape_markup(str(warning))}")
- is_cline = load_init_options(project_root).get("ai") == "cline"
+ selected_ai = load_init_options(project_root).get("ai")
+ is_cline = selected_ai == "cline"
+ is_forge = selected_ai == "forge"
if is_cline:
from specify_cli.integrations.cline import format_cline_command_name
+ if is_forge:
+ from specify_cli.integrations.forge import format_forge_command_name
console.print("\n[bold cyan]Provided commands:[/bold cyan]")
for cmd in manifest.commands:
cmd_name = cmd['name']
if is_cline:
cmd_name = format_cline_command_name(cmd_name)
+ elif is_forge:
+ cmd_name = format_forge_command_name(cmd_name)
console.print(f" ⢠{_escape_markup(str(cmd_name))} - {_escape_markup(str(cmd.get('description', '')))}")
# Report agent skills registration
@@ -720,6 +787,10 @@ def extension_remove(
console.print(f"\nConfig files preserved in .specify/extensions/{safe_extension_id}/")
else:
console.print(f"\nConfig files backed up to .specify/extensions/.backup/{safe_extension_id}/")
+
+ # #1: regenerate native event config so the removed extension's events
+ # are stripped from installed integrations.
+ _refresh_events_and_warn(project_root)
console.print(f"\nTo reinstall: specify extension add {safe_extension_id}")
else:
console.print("[red]Error:[/red] Failed to remove extension")
@@ -762,8 +833,9 @@ def extension_search(
# Metadata
console.print(f"\n [dim]Author:[/dim] {_escape_markup(str(ext.get('author', 'Unknown')))}")
- if ext.get('tags'):
- tags_str = ", ".join(str(t) for t in ext['tags'])
+ ext_tags = ext.get('tags', [])
+ if isinstance(ext_tags, list) and ext_tags:
+ tags_str = ", ".join(str(t) for t in ext_tags)
console.print(f" [dim]Tags:[/dim] {_escape_markup(tags_str)}")
# Source catalog
@@ -777,10 +849,24 @@ def extension_search(
# Stats
stats = []
- if ext.get('downloads') is not None:
- stats.append(f"Downloads: {ext['downloads']:,}")
- if ext.get('stars') is not None:
- stats.append(f"Stars: {ext['stars']}")
+ downloads = ext.get('downloads')
+ if downloads is not None:
+ # Catalog fields are untrusted; a non-numeric ``downloads``
+ # (e.g. the JSON string "1500") would crash the ``:,`` format
+ # with "Cannot specify ',' with 's'". Only group-format numbers,
+ # and escape the fallback: the joined stats are rendered as Rich
+ # markup, so a value like "[/red]foo" would raise MarkupError
+ # (matching how every other catalog field here is escaped).
+ stats.append(
+ f"Downloads: {downloads:,}"
+ if isinstance(downloads, (int, float))
+ else f"Downloads: {_escape_markup(str(downloads))}"
+ )
+ stars = ext.get('stars')
+ if stars is not None:
+ # Same untrusted-value/Rich-markup hazard as `downloads` above,
+ # in the same joined string.
+ stats.append(f"Stars: {_escape_markup(str(stars))}")
if stats:
console.print(f" [dim]{' | '.join(stats)}[/dim]")
@@ -866,9 +952,30 @@ def extension_info(
console.print()
if ext_manifest.commands:
+ # Print each command the way the active agent registers it.
+ # Cline and Forge hyphenate command names (e.g. Forge invokes
+ # `/speckit-jira-sync`, not the manifest's dotted
+ # `speckit.jira.sync`), so mirror the same formatting used by
+ # `extension add`'s "Provided commands" listing ā otherwise the
+ # names shown here don't match what the user actually types.
+ selected_ai = load_init_options(project_root).get("ai")
+ if selected_ai == "cline":
+ from specify_cli.integrations.cline import (
+ format_cline_command_name as _format_command_name,
+ )
+ elif selected_ai == "forge":
+ from specify_cli.integrations.forge import (
+ format_forge_command_name as _format_command_name,
+ )
+ else:
+ _format_command_name = None
+
console.print("[bold]Commands:[/bold]")
for cmd in ext_manifest.commands:
- console.print(f" ⢠{_escape_markup(str(cmd['name']))}: {_escape_markup(str(cmd.get('description', '')))}")
+ cmd_name = cmd['name']
+ if _format_command_name is not None:
+ cmd_name = _format_command_name(cmd_name)
+ console.print(f" ⢠{_escape_markup(str(cmd_name))}: {_escape_markup(str(cmd.get('description', '')))}")
console.print()
# Show catalog status
@@ -951,17 +1058,32 @@ def _print_extension_info(ext_info: dict, manager):
console.print()
# Tags
- if ext_info.get('tags'):
- tags_str = ", ".join(str(t) for t in ext_info['tags'])
+ info_tags = ext_info.get('tags', [])
+ if isinstance(info_tags, list) and info_tags:
+ tags_str = ", ".join(str(t) for t in info_tags)
console.print(f"[bold]Tags:[/bold] {_escape_markup(tags_str)}")
console.print()
# Statistics
stats = []
- if ext_info.get('downloads') is not None:
- stats.append(f"Downloads: {ext_info['downloads']:,}")
- if ext_info.get('stars') is not None:
- stats.append(f"Stars: {ext_info['stars']}")
+ downloads = ext_info.get('downloads')
+ if downloads is not None:
+ # Catalog fields are untrusted; a non-numeric ``downloads`` (e.g. the
+ # JSON string "1500") would crash the ``:,`` format with "Cannot
+ # specify ',' with 's'". Only group-format numbers, and escape the
+ # fallback: the joined stats are rendered as Rich markup, so a value
+ # like "[/red]foo" would raise MarkupError (matching how every other
+ # catalog field here is escaped).
+ stats.append(
+ f"Downloads: {downloads:,}"
+ if isinstance(downloads, (int, float))
+ else f"Downloads: {_escape_markup(str(downloads))}"
+ )
+ stars = ext_info.get('stars')
+ if stars is not None:
+ # Same untrusted-value/Rich-markup hazard as `downloads` above, in the
+ # same joined string.
+ stats.append(f"Stars: {_escape_markup(str(stars))}")
if stats:
console.print(f"[bold]Statistics:[/bold] {' | '.join(stats)}")
console.print()
@@ -1009,6 +1131,7 @@ def extension_update(
from . import (
ExtensionManager,
ExtensionCatalog,
+ ExtensionManifest,
ExtensionError,
ValidationError,
CommandRegistrar,
@@ -1123,9 +1246,17 @@ def extension_update(
console.print(f"š¦ Updating {safe_ext_name}...")
# Backup paths
- backup_base = manager.extensions_dir / ".backup" / f"{extension_id}-update"
+ backup_root = manager.extensions_dir / ".backup"
+ backup_key = hashlib.sha256(
+ extension_id.encode("utf-8")
+ ).hexdigest()[:16]
+ backup_base = (
+ backup_root
+ / f"update-{backup_key}-{uuid4().hex}"
+ )
backup_ext_dir = backup_base / "extension"
backup_commands_dir = backup_base / "commands"
+ backup_skills_dir = backup_base / "skills"
backup_config_dir = backup_base / "config"
# Store backup state
@@ -1133,14 +1264,125 @@ def extension_update(
backup_installed = UNSET # Original installed list from extensions.yml
backup_hooks = None # None means backup step 4 not yet reached; {} or {...} means backup was captured
backed_up_command_files = {}
+ backed_up_command_symlinks = {}
+ backed_up_skill_dirs = {}
+ new_command_dirs_absent_before_update = []
+ new_command_paths_absent_before_update = []
+ new_skill_names = []
+ new_skill_paths_absent_before_update = []
+ # Validation failures must not rewrite an untouched installation.
+ installation_modified = False
+ zip_cleanup_error = None
+ backup_created_by_attempt = False
+
+ def backup_command_artifact(original_file, backup_file):
+ """Back up one command artifact once, preserving its full path."""
+ nonlocal backup_created_by_attempt
+ original_key = str(original_file)
+ if original_key in backed_up_command_files:
+ return
+ if original_file.is_symlink():
+ backed_up_command_symlinks[original_key] = os.readlink(
+ original_file
+ )
+ else:
+ if original_file.stat().st_nlink > 1:
+ raise RuntimeError(
+ "Cannot safely update hard-linked generated "
+ f"artifact '{original_file}'"
+ )
+ backup_created_by_attempt = True
+ backup_file.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(original_file, backup_file)
+ backed_up_command_files[original_key] = str(backup_file)
+
+ def restore_command_artifact(original_path, backup_path):
+ """Restore one regular file or symlink without following it."""
+ original_key = str(original_path)
+ original_file = Path(original_path)
+ backup_file = Path(backup_path)
+ symlink_state = backed_up_command_symlinks.get(
+ original_key
+ )
+
+ if symlink_state is not None:
+ if original_file.is_symlink() or original_file.is_file():
+ original_file.unlink()
+ elif original_file.exists():
+ raise RuntimeError(
+ "Command rollback found an unexpected directory "
+ f"at '{original_file}'"
+ )
+ original_file.parent.mkdir(parents=True, exist_ok=True)
+ os.symlink(symlink_state, original_file)
+ return
+
+ if not backup_file.is_file() or backup_file.is_symlink():
+ raise RuntimeError(
+ "Command rollback backup is missing for "
+ f"'{original_file}'"
+ )
+ if original_file.is_symlink() or original_file.is_file():
+ original_file.unlink()
+ elif original_file.exists():
+ raise RuntimeError(
+ "Command rollback found an unexpected directory "
+ f"at '{original_file}'"
+ )
+ original_file.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copy2(backup_file, original_file)
+
+ def remember_absent_parent_dirs(artifact_path, root_dir):
+ """Remember absent parents a failed renderer may create."""
+ boundary = root_dir.parent
+ if root_dir.is_relative_to(project_root):
+ boundary = project_root
+ parent = artifact_path.parent
+ while parent != boundary:
+ if parent.exists() or parent.is_symlink():
+ break
+ new_command_dirs_absent_before_update.append(parent)
+ parent = parent.parent
+
+ def backup_extension_skills(skill_names, *, skills_dir=None):
+ """Back up every owned skill directory that remove() may delete."""
+ nonlocal backup_created_by_attempt
+ for skill_dir in manager._find_extension_skill_dirs(
+ skill_names,
+ extension_id,
+ skills_dir=skills_dir,
+ create_skills_dir=False,
+ ):
+ original_key = str(skill_dir)
+ if original_key in backed_up_skill_dirs:
+ continue
+ backup_created_by_attempt = True
+ backup_skills_dir.mkdir(parents=True, exist_ok=True)
+ backup_skill_dir = backup_skills_dir / str(
+ len(backed_up_skill_dirs)
+ )
+ shutil.copytree(skill_dir, backup_skill_dir, symlinks=True)
+ backed_up_skill_dirs[original_key] = str(backup_skill_dir)
try:
+ if backup_root.is_symlink():
+ raise RuntimeError(
+ "Cannot safely create update backup under symlinked "
+ f"directory '{backup_root}'"
+ )
+ if backup_base.exists() or backup_base.is_symlink():
+ raise RuntimeError(
+ "Cannot safely reuse an existing update backup "
+ f"directory '{backup_base}'"
+ )
+
# 1. Backup registry entry (always, even if extension dir doesn't exist)
backup_registry_entry = manager.registry.get(extension_id)
# 2. Backup extension directory
extension_dir = manager.extensions_dir / extension_id
if extension_dir.exists():
+ backup_created_by_attempt = True
backup_base.mkdir(parents=True, exist_ok=True)
if backup_ext_dir.exists():
shutil.rmtree(backup_ext_dir)
@@ -1164,30 +1406,91 @@ def extension_update(
commands_dir = _AgentReg._resolve_agent_dir(
agent_name, agent_config, project_root
)
+ dirs_to_backup = [commands_dir]
+ legacy = agent_config.get("legacy_dir")
+ if legacy:
+ legacy_dir = project_root / legacy
+ if (
+ legacy_dir.exists()
+ and legacy_dir != commands_dir
+ ):
+ dirs_to_backup.append(legacy_dir)
for cmd_name in cmd_names:
- output_name = _AgentReg._compute_output_name(agent_name, cmd_name, agent_config)
- cmd_file = commands_dir / f"{output_name}{agent_config['extension']}"
- if cmd_file.exists():
- # Mirror the real on-disk layout under the backup dir.
- # Skills agents (extension == "/SKILL.md") name every
- # command file "SKILL.md", living in a per-command
- # subdir (e.g. speckit-plan/SKILL.md). Using cmd_file.name
- # alone would collide all of them onto one backup path and
- # break rollback; keep the relative path to stay unique.
- backup_cmd_path = backup_commands_dir / agent_name / cmd_file.relative_to(commands_dir)
- backup_cmd_path.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(cmd_file, backup_cmd_path)
- backed_up_command_files[str(cmd_file)] = str(backup_cmd_path)
+ output_name = _AgentReg._compute_output_name(
+ agent_name, cmd_name, agent_config
+ )
+ names_to_backup = [output_name]
+ if (
+ output_name != cmd_name
+ and _AgentReg._is_safe_command_name(cmd_name)
+ ):
+ names_to_backup.append(cmd_name)
+
+ for dir_index, target_dir in enumerate(
+ dirs_to_backup
+ ):
+ for name in names_to_backup:
+ cmd_file = (
+ target_dir
+ / f"{name}{agent_config['extension']}"
+ )
+ try:
+ _AgentReg._ensure_inside(
+ cmd_file, target_dir
+ )
+ except ValueError:
+ continue
+ if (
+ cmd_file.exists()
+ or cmd_file.is_symlink()
+ ):
+ # Keep both the directory location and
+ # relative path unique. unregister_commands()
+ # removes legacy and canonical copies, and
+ # skills agents place every SKILL.md in its
+ # own command subdirectory.
+ backup_cmd_path = (
+ backup_commands_dir
+ / agent_name
+ / f"location-{dir_index}"
+ / cmd_file.relative_to(target_dir)
+ )
+ backup_command_artifact(
+ cmd_file, backup_cmd_path
+ )
# Also backup copilot prompt files
if agent_name == "copilot":
- prompt_file = project_root / ".github" / "prompts" / f"{cmd_name}.prompt.md"
- if prompt_file.exists():
- backup_prompt_path = backup_commands_dir / "copilot-prompts" / prompt_file.name
- backup_prompt_path.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(prompt_file, backup_prompt_path)
- backed_up_command_files[str(prompt_file)] = str(backup_prompt_path)
+ prompts_dir = (
+ project_root / ".github" / "prompts"
+ )
+ prompt_file = (
+ prompts_dir / f"{cmd_name}.prompt.md"
+ )
+ try:
+ _AgentReg._ensure_inside(
+ prompt_file, prompts_dir
+ )
+ except ValueError:
+ continue
+ if prompt_file.exists() or prompt_file.is_symlink():
+ backup_prompt_path = (
+ backup_commands_dir
+ / "copilot-prompts"
+ / prompt_file.relative_to(prompts_dir)
+ )
+ backup_command_artifact(
+ prompt_file, backup_prompt_path
+ )
+
+ raw_registered_skills = (
+ backup_registry_entry.get("registered_skills", [])
+ if isinstance(backup_registry_entry, dict)
+ else []
+ )
+ registered_skills = manager._valid_name_list(raw_registered_skills)
+ backup_extension_skills(registered_skills)
# 4. Backup hooks and installed list from extensions.yml
# get_project_config() always normalizes installed->[] and hooks->{},
@@ -1211,24 +1514,107 @@ def extension_update(
try:
# 6. Validate extension ID from ZIP BEFORE modifying installation
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
- with zipfile.ZipFile(zip_path, "r") as zf:
+ with open_zip_bounded(zip_path) as zf:
import yaml
manifest_data = None
+ manifest_bytes = None
namelist = zf.namelist()
- # First try root-level extension.yml
- if "extension.yml" in namelist:
- with zf.open("extension.yml") as f:
- parsed_manifest = yaml.safe_load(f)
- manifest_data = parsed_manifest if parsed_manifest is not None else {}
- else:
- # Look for extension.yml in a single top-level subdirectory
- # (e.g., "repo-name-branch/extension.yml")
- manifest_paths = [n for n in namelist if n.endswith("/extension.yml") and n.count("/") == 1]
- if len(manifest_paths) == 1:
- with zf.open(manifest_paths[0]) as f:
- parsed_manifest = yaml.safe_load(f)
- manifest_data = parsed_manifest if parsed_manifest is not None else {}
+ # Read the manifest under a hard size cap: this happens
+ # before install_from_zip()'s safe_extract_zip(), so a
+ # raw zf.open().read() here would bypass that bound and
+ # let a zip-bomb extension.yml exhaust memory.
+ # Normalize separators before choosing the manifest so
+ # this pre-scan cannot approve one entry while extraction
+ # later overwrites it with a backslash alias.
+ manifest_candidates = []
+ archive_entries = []
+ for name in namelist:
+ normalized_name = normalize_zip_member_name(name)
+ parts = normalized_name.removesuffix("/").split(
+ "/"
+ )
+ path_key = portable_zip_path_key(normalized_name)
+ archive_entries.append(
+ (normalized_name, parts)
+ )
+ if (
+ len(parts) in {1, 2}
+ and path_key[-1] == "extension.yml"
+ ):
+ manifest_candidates.append(
+ (name, normalized_name, path_key)
+ )
+
+ seen_manifest_keys = {}
+ for name, _normalized_name, path_key in manifest_candidates:
+ previous = seen_manifest_keys.get(path_key)
+ if previous is not None:
+ raise ValueError(
+ "Downloaded extension archive contains multiple "
+ "extension.yml manifests"
+ )
+ seen_manifest_keys[path_key] = name
+
+ for _name, normalized_name, _path_key in manifest_candidates:
+ if normalized_name.split("/")[-1] != "extension.yml":
+ raise ValueError(
+ "Downloaded extension archive manifest "
+ "filenames must use canonical "
+ "'extension.yml' casing"
+ )
+
+ root_manifest = next(
+ (
+ name
+ for name, _normalized_name, path_key
+ in manifest_candidates
+ if path_key == ("extension.yml",)
+ ),
+ None,
+ )
+ nested_manifests = [
+ (name, normalized_name)
+ for name, normalized_name, path_key
+ in manifest_candidates
+ if len(path_key) == 2
+ and path_key[-1] == "extension.yml"
+ ]
+ manifest_path = root_manifest
+ if manifest_path is None and len(nested_manifests) == 1:
+ manifest_path, normalized_manifest_path = (
+ nested_manifests[0]
+ )
+ manifest_root = normalized_manifest_path.split(
+ "/", 1
+ )[0]
+ top_level_dirs = {
+ parts[0]
+ for normalized_name, parts in archive_entries
+ if (
+ len(parts) > 1
+ or normalized_name.endswith("/")
+ )
+ }
+ if top_level_dirs != {manifest_root}:
+ raise ValueError(
+ "Downloaded extension archive with a "
+ "nested extension.yml must contain exactly "
+ "one top-level directory"
+ )
+
+ if manifest_path is not None:
+ manifest_bytes = read_zip_member_limited(
+ zf, manifest_path
+ )
+ parsed_manifest = yaml.safe_load(
+ manifest_bytes
+ )
+ manifest_data = (
+ parsed_manifest
+ if parsed_manifest is not None
+ else {}
+ )
if manifest_data is None:
raise ValueError("Downloaded extension archive is missing 'extension.yml'")
@@ -1242,13 +1628,205 @@ def extension_update(
"Invalid extension manifest in downloaded archive: expected 'extension' mapping"
)
- zip_extension_id = extension_data.get("id")
+ # Run the same manifest and compatibility validation as a
+ # normal install while the existing extension is still
+ # untouched. Reuse the exact bounded bytes selected above.
+ if manifest_bytes is None:
+ raise ValueError(
+ "Downloaded extension archive is missing 'extension.yml'"
+ )
+ with tempfile.TemporaryDirectory(
+ prefix="speckit-update-manifest-"
+ ) as manifest_tmpdir:
+ manifest_file = Path(manifest_tmpdir) / "extension.yml"
+ manifest_file.write_bytes(manifest_bytes)
+ preflight_manifest = ExtensionManifest(manifest_file)
+ manager.check_compatibility(
+ preflight_manifest, speckit_version
+ )
+
+ zip_extension_id = preflight_manifest.id
if zip_extension_id != extension_id:
raise ValueError(
f"Extension ID mismatch: expected '{extension_id}', got '{zip_extension_id}'"
)
+ expected_version = pkg_version.Version(update["available"])
+ archive_version = pkg_version.Version(
+ preflight_manifest.version
+ )
+ if archive_version != expected_version:
+ raise ValueError(
+ "Extension version mismatch: "
+ f"expected '{update['available']}', "
+ f"got '{preflight_manifest.version}'"
+ )
+
+ # Match the remaining deterministic install validation
+ # before crossing the destructive boundary. The helper
+ # excludes this extension's current registry entry while
+ # still detecting namespace, core, duplicate, and
+ # cross-extension command conflicts.
+ manager._validate_install_conflicts(preflight_manifest)
+
+ new_command_names = list(
+ manager._collect_manifest_command_names(
+ preflight_manifest
+ )
+ )
+ new_skill_names = list(
+ dict.fromkeys(
+ manager._skill_name_for_command(command_name)
+ for command_name in new_command_names
+ )
+ )
+
+ # Command rendering happens before hook registration and
+ # registry.add(). Preserve every candidate output that
+ # already exists, and remember paths that are absent now so
+ # rollback can remove files created before registry state is
+ # available. Include aliases and Copilot companion prompts.
+ for (
+ agent_name,
+ commands_dir,
+ ) in manager._command_registration_targets().items():
+ agent_config = registrar.AGENT_CONFIGS[agent_name]
+ for command_name in new_command_names:
+ output_name = _AgentReg._compute_output_name(
+ agent_name, command_name, agent_config
+ )
+ command_file = (
+ commands_dir
+ / f"{output_name}{agent_config['extension']}"
+ )
+ _AgentReg._ensure_inside(command_file, commands_dir)
+ backup_command_path = (
+ backup_commands_dir
+ / agent_name
+ / command_file.relative_to(commands_dir)
+ )
+ if command_file.exists() or command_file.is_symlink():
+ backup_command_artifact(
+ command_file, backup_command_path
+ )
+ else:
+ new_command_paths_absent_before_update.append(
+ command_file
+ )
+ remember_absent_parent_dirs(
+ command_file, commands_dir
+ )
+
+ if agent_name == "copilot":
+ prompts_dir = (
+ project_root / ".github" / "prompts"
+ )
+ prompt_file = (
+ prompts_dir / f"{command_name}.prompt.md"
+ )
+ _AgentReg._ensure_inside(
+ prompt_file, prompts_dir
+ )
+ if prompt_file.is_symlink():
+ raise RuntimeError(
+ "Cannot safely update symlinked Copilot "
+ f"prompt artifact '{prompt_file}'"
+ )
+ backup_prompt_path = (
+ backup_commands_dir
+ / "copilot-prompts"
+ / prompt_file.relative_to(prompts_dir)
+ )
+ if (
+ prompt_file.exists()
+ or prompt_file.is_symlink()
+ ):
+ backup_command_artifact(
+ prompt_file, backup_prompt_path
+ )
+ else:
+ new_command_paths_absent_before_update.append(
+ prompt_file
+ )
+ remember_absent_parent_dirs(
+ prompt_file, prompts_dir
+ )
+
+ new_command_paths_absent_before_update = list(
+ dict.fromkeys(
+ new_command_paths_absent_before_update
+ )
+ )
+ new_command_dirs_absent_before_update = list(
+ dict.fromkeys(
+ new_command_dirs_absent_before_update
+ )
+ )
+
+ # A newly introduced command may reuse an existing
+ # extension-owned skill directory that was not present in
+ # the old registry. Back it up before cleanup can touch it.
+ backup_extension_skills(new_skill_names)
+ new_skills_dir = manager._get_skills_dir(create=False)
+ if new_skills_dir is not None:
+ # Unscoped removal deliberately ignores home-scoped
+ # outputs because the flat registry cannot establish
+ # project ownership. The active install can still
+ # replace a marker-owned skill in its explicit root,
+ # so back up that exact project/home target separately.
+ backup_extension_skills(
+ list(
+ dict.fromkeys(
+ registered_skills + new_skill_names
+ )
+ ),
+ skills_dir=new_skills_dir,
+ )
+ init_options = load_init_options(project_root)
+ if (
+ isinstance(init_options, dict)
+ and is_ai_skills_enabled(init_options)
+ and isinstance(init_options.get("ai"), str)
+ and init_options["ai"]
+ ):
+ # resolve_active_skills_dir() first creates the
+ # configured project-local skills marker. Some
+ # agents (notably Hermes) then redirect rendered
+ # skills to a different global root, so snapshot
+ # both locations for exact rollback.
+ from .. import _get_skills_dir
+
+ configured_skills_dir = _get_skills_dir(
+ project_root, init_options["ai"]
+ )
+ remember_absent_parent_dirs(
+ configured_skills_dir / ".update-marker",
+ configured_skills_dir,
+ )
+ new_skills_root = new_skills_dir.resolve()
+ for skill_name in new_skill_names:
+ skill_path = new_skills_dir / skill_name
+ resolved_skill_path = skill_path.resolve(strict=False)
+ resolved_skill_path.relative_to(new_skills_root)
+ if not (
+ skill_path.exists() or skill_path.is_symlink()
+ ):
+ new_skill_paths_absent_before_update.append(
+ skill_path
+ )
+ remember_absent_parent_dirs(
+ skill_path / "SKILL.md",
+ new_skills_dir,
+ )
+
+ new_command_dirs_absent_before_update = list(
+ dict.fromkeys(
+ new_command_dirs_absent_before_update
+ )
+ )
+
# 7. Remove old extension (handles command file cleanup and registry removal)
+ installation_modified = True
manager.remove(extension_id, keep_config=True)
# 8. Install new version
@@ -1298,15 +1876,42 @@ def extension_update(
hook["enabled"] = False
hook_executor.save_project_config(config)
finally:
- # Clean up downloaded ZIP
+ # ZIP cleanup is housekeeping: never replace an install
+ # error or roll back an already committed update because a
+ # scanner temporarily locks the download on Windows.
if zip_path.exists():
- zip_path.unlink()
-
- # 10. Clean up backup on success
- if backup_base.exists():
- shutil.rmtree(backup_base)
+ try:
+ zip_path.unlink()
+ except OSError as error:
+ zip_cleanup_error = error
+
+ # 10. Clean up backup on success. The update has committed at
+ # this point, so a locked backup file must not trigger rollback
+ # of an otherwise successful installation.
+ cleanup_error = None
+ if backup_created_by_attempt and backup_base.exists():
+ try:
+ shutil.rmtree(backup_base)
+ except OSError as error:
+ cleanup_error = error
console.print(f" [green]ā[/green] Updated to v{update['available']}")
+ if cleanup_error is not None:
+ console.print(
+ " [yellow]Warning:[/yellow] Could not fully remove "
+ "update backup: "
+ f"{_escape_markup(str(cleanup_error))}"
+ )
+ console.print(
+ " [dim]Backup may remain at: "
+ f"{_escape_markup(str(backup_base))}[/dim]"
+ )
+ if zip_cleanup_error is not None:
+ console.print(
+ " [yellow]Warning:[/yellow] Could not remove "
+ "downloaded update archive: "
+ f"{_escape_markup(str(zip_cleanup_error))}"
+ )
updated_extensions.append(ext_name)
except KeyboardInterrupt:
@@ -1314,6 +1919,24 @@ def extension_update(
except Exception as e:
console.print(f" [red]ā[/red] Failed: {_escape_markup(str(e))}")
failed_updates.append((ext_name, str(e)))
+ if zip_cleanup_error is not None:
+ console.print(
+ " [yellow]Warning:[/yellow] Could not remove "
+ "downloaded update archive: "
+ f"{_escape_markup(str(zip_cleanup_error))}"
+ )
+
+ if not installation_modified:
+ if backup_created_by_attempt and backup_base.exists():
+ try:
+ shutil.rmtree(backup_base)
+ except OSError as cleanup_error:
+ console.print(
+ " [yellow]Warning:[/yellow] Could not remove "
+ "untouched-update backup: "
+ f"{_escape_markup(str(cleanup_error))}"
+ )
+ continue
# Rollback on failure
console.print(f" [yellow]ā©[/yellow] Rolling back {safe_ext_name}...")
@@ -1330,13 +1953,28 @@ def extension_update(
shutil.copytree(backup_ext_dir, extension_dir)
# Remove any NEW command files created by failed install
- # (files that weren't in the original backup)
+ # (files that weren't in the original backup). Registration
+ # writes before registry.add(), so start with the paths that
+ # were absent at the destructive boundary instead of relying
+ # only on a possibly missing new registry entry.
+ for command_path in new_command_paths_absent_before_update:
+ if command_path.is_symlink() or command_path.is_file():
+ command_path.unlink()
+ elif command_path.exists():
+ raise RuntimeError(
+ "Command rollback found an unexpected directory "
+ f"at '{command_path}'"
+ )
+ new_registered_skills = []
try:
new_registry_entry = manager.registry.get(extension_id)
if new_registry_entry is None or not isinstance(new_registry_entry, dict):
new_registered_commands = {}
else:
new_registered_commands = new_registry_entry.get("registered_commands", {})
+ new_registered_skills = manager._valid_name_list(
+ new_registry_entry.get("registered_skills", [])
+ )
for agent_name, cmd_names in new_registered_commands.items():
if agent_name not in registrar.AGENT_CONFIGS:
continue
@@ -1360,13 +1998,78 @@ def extension_update(
except KeyError:
pass # No new registry entry exists, nothing to clean up
- # Restore backed up command files
+ # Restore command artifacts that existed before the update
+ # before extension-skill cleanup inspects ownership. A
+ # failed skills registrar may have overwritten a user's
+ # pre-existing SKILL.md with extension metadata; restoring
+ # it first prevents the conservative skill unregistrar from
+ # misclassifying and deleting the user's whole directory.
for original_path, backup_path in backed_up_command_files.items():
- backup_file = Path(backup_path)
- if backup_file.exists():
- original_file = Path(original_path)
- original_file.parent.mkdir(parents=True, exist_ok=True)
- shutil.copy2(backup_file, original_file)
+ restore_command_artifact(
+ original_path, backup_path
+ )
+
+ # Skill generation happens before hooks and registry.add(),
+ # so a failed install may have created skills that are not
+ # recorded in any registry entry yet. Derive names from the
+ # preflighted manifest as well as any partial new entry.
+ skills_to_remove = list(
+ dict.fromkeys(new_skill_names + new_registered_skills)
+ )
+ # A write failure can leave a partial skill without valid
+ # ownership metadata, which the normal conservative
+ # unregistrar intentionally refuses to delete. Paths that
+ # were absent at the destructive boundary are safe to
+ # remove directly during rollback.
+ for skill_path in new_skill_paths_absent_before_update:
+ if skill_path.is_symlink() or skill_path.is_file():
+ skill_path.unlink()
+ elif skill_path.exists():
+ shutil.rmtree(skill_path)
+ manager._unregister_extension_skills(
+ skills_to_remove, extension_id
+ )
+
+ # Restore all original registered skill artifacts after
+ # removing skills created by the failed installation.
+ for original_path, backup_path in backed_up_skill_dirs.items():
+ backup_skill_dir = Path(backup_path)
+ if not backup_skill_dir.is_dir():
+ raise RuntimeError(
+ "Skill rollback backup is missing for "
+ f"'{original_path}'"
+ )
+ original_skill_dir = Path(original_path)
+ if (
+ original_skill_dir.is_symlink()
+ or original_skill_dir.is_file()
+ ):
+ original_skill_dir.unlink()
+ elif original_skill_dir.exists():
+ shutil.rmtree(original_skill_dir)
+ original_skill_dir.parent.mkdir(parents=True, exist_ok=True)
+ shutil.copytree(
+ backup_skill_dir,
+ original_skill_dir,
+ symlinks=True,
+ )
+
+ # Remove empty artifact directories that did not exist at
+ # the destructive boundary. Do this after skill cleanup and
+ # restoration so newly created skills roots and their
+ # project-local parents can also be removed exactly.
+ for command_dir in sorted(
+ new_command_dirs_absent_before_update,
+ key=lambda path: len(path.parts),
+ reverse=True,
+ ):
+ if command_dir.is_dir() and not command_dir.is_symlink():
+ try:
+ command_dir.rmdir()
+ except OSError:
+ # Preserve any non-empty directory: other
+ # content may belong to the user.
+ pass
# Restore metadata in extensions.yml (hooks and installed list).
# Only run if backup step 4 was reached (backup_hooks is not None);
@@ -1421,10 +2124,26 @@ def extension_update(
if backup_registry_entry:
manager.registry.restore(extension_id, backup_registry_entry)
+ # Backup cleanup is post-rollback housekeeping. A locked
+ # file (notably on Windows) must not turn successfully
+ # restored state into a contradictory "Rollback failed".
+ cleanup_error = None
+ if backup_created_by_attempt and backup_base.exists():
+ try:
+ shutil.rmtree(backup_base)
+ except OSError as error:
+ cleanup_error = error
console.print(" [green]ā[/green] Rollback successful")
- # Clean up backup directory only on successful rollback
- if backup_base.exists():
- shutil.rmtree(backup_base)
+ if cleanup_error is not None:
+ console.print(
+ " [yellow]Warning:[/yellow] Could not fully "
+ "remove rollback backup: "
+ f"{_escape_markup(str(cleanup_error))}"
+ )
+ console.print(
+ " [dim]Backup may remain at: "
+ f"{_escape_markup(str(backup_base))}[/dim]"
+ )
except Exception as rollback_error:
console.print(f" [red]ā[/red] Rollback failed: {_escape_markup(str(rollback_error))}")
console.print(f" [dim]Backup preserved at: {_escape_markup(str(backup_base))}[/dim]")
@@ -1439,6 +2158,13 @@ def extension_update(
console.print(f" ⢠{_escape_markup(str(ext_name))}: {_escape_markup(str(error))}")
raise typer.Exit(1)
+ # S4: regenerate native event config after a successful update. An
+ # update replaces the installed extension.yml, so any added/removed/
+ # changed event declarations would otherwise leave native configs
+ # stale until a manual integration upgrade.
+ if updated_extensions:
+ _refresh_events_and_warn(project_root)
+
except ValidationError as e:
console.print(f"\n[red]Validation Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
@@ -1488,6 +2214,10 @@ def extension_enable(
console.print(f"[green]ā[/green] Extension '{_escape_markup(str(display_name))}' enabled")
+ # #1: regenerate native event config so the enabled extension's events
+ # are re-emitted in installed integrations.
+ _refresh_events_and_warn(project_root)
+
@extension_app.command("disable")
def extension_disable(
@@ -1532,6 +2262,10 @@ def extension_disable(
console.print("\nCommands will no longer be available. Hooks will not execute.")
console.print(f"To re-enable: specify extension enable {_escape_markup(str(extension_id))}")
+ # #1: regenerate native event config so the disabled extension's events
+ # are stripped from installed integrations.
+ _refresh_events_and_warn(project_root)
+
@extension_app.command("set-priority")
def extension_set_priority(
diff --git a/src/specify_cli/integration_runtime.py b/src/specify_cli/integration_runtime.py
index a36dcc672c..eef44574cb 100644
--- a/src/specify_cli/integration_runtime.py
+++ b/src/specify_cli/integration_runtime.py
@@ -5,6 +5,7 @@
from collections.abc import Callable
from typing import Any
+from ._invocation_style import get_invocation_prefix
from .integration_state import integration_setting, integration_settings
@@ -46,6 +47,7 @@ def with_integration_setting(
script_type: str | None = None,
raw_options: str | None = None,
parsed_options: dict[str, Any] | None = None,
+ project_root: Any = None,
) -> dict[str, dict[str, Any]]:
"""Return integration settings with *key* updated."""
settings = integration_settings(state)
@@ -63,7 +65,16 @@ def with_integration_setting(
elif raw_options is not None:
current.pop("parsed_options", None)
- current["invoke_separator"] = integration.effective_invoke_separator(parsed_options)
+ # Recompute the separator from the options actually STORED on ``current``
+ # after the update, not the raw ``parsed_options`` argument. When only
+ # ``script_type`` changes (``parsed_options`` and ``raw_options`` both
+ # None), the previously-stored ``parsed_options`` are retained above, so
+ # deriving the separator from the argument (None) would drop an
+ # options-dependent separator (e.g. Copilot ``--skills`` -> "-") back to
+ # the default ".".
+ current["invoke_separator"] = integration.effective_invoke_separator(
+ current.get("parsed_options"), project_root
+ )
settings[key] = current
return settings
@@ -73,10 +84,11 @@ def invoke_separator_for_integration(
state: dict[str, Any],
key: str,
parsed_options: dict[str, Any] | None = None,
+ project_root: Any = None,
) -> str:
"""Resolve the invocation separator for stored/default integration state."""
if parsed_options is not None:
- return integration.effective_invoke_separator(parsed_options)
+ return integration.effective_invoke_separator(parsed_options, project_root)
setting = integration_setting(state, key)
stored_separator = setting.get("invoke_separator")
@@ -85,6 +97,17 @@ def invoke_separator_for_integration(
stored_parsed = setting.get("parsed_options")
if isinstance(stored_parsed, dict):
- return integration.effective_invoke_separator(stored_parsed)
+ return integration.effective_invoke_separator(stored_parsed, project_root)
- return integration.effective_invoke_separator(None)
+ return integration.effective_invoke_separator(None, project_root)
+
+
+def invoke_prefix_for_integration(
+ integration: Any,
+ key: str,
+ parsed_options: dict[str, Any] | None = None,
+ project_root: Any = None,
+) -> str:
+ """Resolve the native invocation prefix for an integration's output mode."""
+ skills_mode = integration.is_skills_mode(parsed_options, project_root)
+ return get_invocation_prefix(key, skills_mode)
diff --git a/src/specify_cli/integrations/__init__.py b/src/specify_cli/integrations/__init__.py
index 1d8ccc5ebb..e251395b72 100644
--- a/src/specify_cli/integrations/__init__.py
+++ b/src/specify_cli/integrations/__init__.py
@@ -48,6 +48,7 @@ def _register_builtins() -> None:
"""
# -- Imports (alphabetical) -------------------------------------------
from .agy import AgyIntegration
+ from .alquimia import AlquimiaAIIntegration
from .amp import AmpIntegration
from .auggie import AuggieIntegration
from .bob import BobIntegration
@@ -58,6 +59,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
@@ -85,6 +87,7 @@ def _register_builtins() -> None:
# -- Registration (alphabetical) --------------------------------------
_register(AgyIntegration())
+ _register(AlquimiaAIIntegration())
_register(AmpIntegration())
_register(AuggieIntegration())
_register(BobIntegration())
@@ -95,6 +98,7 @@ def _register_builtins() -> None:
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(DevinIntegration())
+ _register(DroidIntegration())
_register(FirebenderIntegration())
_register(ForgeIntegration())
_register(GeminiIntegration())
diff --git a/src/specify_cli/integrations/_helpers.py b/src/specify_cli/integrations/_helpers.py
index 07a62efeed..2b7fc65db1 100644
--- a/src/specify_cli/integrations/_helpers.py
+++ b/src/specify_cli/integrations/_helpers.py
@@ -6,10 +6,12 @@
from typing import Any, Callable
import typer
+from rich.markup import escape
from .._agent_config import SCRIPT_TYPE_CHOICES
from .._console import console
from ..integration_runtime import (
+ invoke_prefix_for_integration as _invoke_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
resolve_integration_options as _resolve_integration_options_impl,
with_integration_setting as _with_integration_setting,
@@ -119,8 +121,7 @@ def _clear_init_options_for_integration(project_root: Path, integration_key: str
def _remove_integration_json(project_root: Path) -> None:
"""Remove ``.specify/integration.json`` if it exists."""
path = project_root / INTEGRATION_JSON
- if path.exists():
- path.unlink()
+ path.unlink(missing_ok=True)
# ---------------------------------------------------------------------------
@@ -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)
@@ -272,24 +273,19 @@ def _update_init_options_for_integration(
load_init_options,
save_init_options,
)
- from .base import SkillsIntegration
opts = load_init_options(project_root)
opts["integration"] = integration.key
opts["ai"] = integration.key
opts["speckit_version"] = _get_speckit_version()
if script_type:
opts["script"] = script_type
- # Skills mode is either intrinsic (SkillsIntegration), set on the instance
- # during setup() (_skills_mode), or requested via parsed options (e.g.
- # Copilot's --skills, persisted as parsed_options["skills"]). The latter is
- # the only signal available on the `use` path, where no setup() runs and a
- # fresh integration instance has _skills_mode == False (issue #3550).
- skills_mode = (
- isinstance(integration, SkillsIntegration)
- or getattr(integration, "_skills_mode", False)
- or bool((parsed_options or {}).get("skills"))
- )
- if skills_mode:
+ # Whether skills mode is active is owned by each integration via the
+ # ``is_skills_mode`` hook (base default honors ``--skills``;
+ # SkillsIntegration returns True; skills-first integrations with a legacy
+ # opt-out such as Bob override it). This keeps shared code free of
+ # ``isinstance`` / ``_skills_mode`` probing. Passing parsed_options lets it
+ # work on the ``use``/``install`` path where no setup() runs (issue #3550).
+ if integration.is_skills_mode(parsed_options, project_root=project_root):
opts["ai_skills"] = True
else:
opts.pop("ai_skills", None)
@@ -325,6 +321,7 @@ def _set_default_integration(
script_type=resolved_script,
raw_options=raw_options,
parsed_options=parsed_options,
+ project_root=project_root,
)
if refresh_templates:
@@ -333,7 +330,11 @@ def _set_default_integration(
project_root,
resolved_script,
invoke_separator=_invoke_separator_for_integration(
- integration, {"integration_settings": settings}, key, parsed_options
+ integration, {"integration_settings": settings}, key, parsed_options,
+ project_root=project_root,
+ ),
+ invoke_prefix=_invoke_prefix_for_integration(
+ integration, key, parsed_options, project_root
),
force=refresh_templates_force,
refresh_managed=True,
@@ -393,23 +394,24 @@ def _register_extensions_for_agent(
agent_key: str,
*,
continuing: str,
+ force: bool = False,
) -> None:
"""Register all enabled extensions' commands/skills for ``agent_key``.
``use`` / ``switch`` re-register enabled extensions for the agent they
- activate; ``upgrade`` backfills them for the refreshed agent. Plain
- ``install`` deliberately does not call this helper so adding a secondary
- integration has no extension side effects until it is selected or upgraded.
- See issue #2886.
-
- Known limitation: extension *skill* rendering is scoped to the active
- agent (init-options track a single ``ai`` / ``ai_skills`` pair). A
- skills-mode agent registered while it is *not* the active agent (e.g.
- Copilot ``--skills`` registered while non-active) therefore
- receives command files rather than skills here ā matching ``extension
- add``'s multi-agent behavior. ``use`` / ``switch`` avoid this because they
- make the target the active agent first. Per-agent skills parity is tracked in
- #2948.
+ activate (rescaffold); ``upgrade`` does so only for the *active*
+ integration. Plain ``install`` and upgrade of a non-active integration
+ deliberately skip this helper so a secondary integration has no extension
+ side effects until it is selected. See issues #2886 and #2948.
+
+ Callers always pass the active agent (use/switch activate the target
+ before registering), so extension *skill* rendering ā which is scoped to
+ the active ``ai`` / ``ai_skills`` init-options ā matches ``agent_key``.
+
+ When ``force=True``, existing skill files are overwritten even when they
+ are not dev-mode symlinks. Pass ``force=True`` in the upgrade path so that
+ extension content is layered on top of the core-template files that
+ ``setup()`` just regenerated (fixes the skip-guard bug for skills mode).
Best-effort: never aborts the surrounding integration operation. Callers
invoke it *after* the use/upgrade/switch transaction has committed so a
@@ -418,7 +420,7 @@ def _register_extensions_for_agent(
_best_effort_extension_op(
project_root,
agent_key,
- lambda mgr, key: mgr.register_enabled_extensions_for_agent(key),
+ lambda mgr, key: mgr.register_enabled_extensions_for_agent(key, force=force),
phase="register extension artifacts for",
continuing=continuing,
)
@@ -445,6 +447,91 @@ def _unregister_extensions_for_agent(
)
+def _register_presets_for_agent(
+ project_root: Path,
+ agent_key: str,
+ *,
+ continuing: str,
+) -> None:
+ """Register all enabled presets' command overrides/skills for ``agent_key``.
+
+ Presets follow the same single-active rule as extensions (#2948):
+ ``use`` / ``switch`` re-register enabled presets for the agent they
+ activate (rescaffold), so a preset installed while a different
+ integration was active is not left targeting that inactive integration.
+
+ Best-effort: never aborts the surrounding integration operation.
+ """
+ try:
+ from ..presets import PresetManager
+
+ preset_mgr = PresetManager(project_root)
+ preset_mgr.register_enabled_presets_for_agent(agent_key)
+ except Exception as preset_err:
+ from .. import _print_cli_warning
+
+ _print_cli_warning(
+ "register preset artifacts for",
+ "integration",
+ agent_key,
+ preset_err,
+ continuing=continuing,
+ )
+
+
+def _unregister_presets_for_agent(
+ project_root: Path,
+ agent_key: str,
+ *,
+ continuing: str,
+) -> None:
+ """Best-effort removal of ``agent_key``'s preset command/skill artifacts.
+
+ Mirrors ``_unregister_extensions_for_agent``: used by ``switch`` when
+ uninstalling the previous integration so its preset command overrides
+ and skill mirrors don't linger as orphans in the old agent's directory
+ once a different (possibly not-yet-installed) integration becomes
+ active (#2948).
+
+ Best-effort: never aborts the surrounding integration operation.
+ """
+ try:
+ from ..presets import PresetManager
+
+ preset_mgr = PresetManager(project_root)
+ preset_mgr.unregister_agent_artifacts(agent_key)
+ except Exception as preset_err:
+ from .. import _print_cli_warning
+
+ _print_cli_warning(
+ "clean up preset artifacts for",
+ "integration",
+ agent_key,
+ preset_err,
+ continuing=continuing,
+ )
+
+
+def _unregister_enabled_extension_commands_for_agent(
+ project_root: Path,
+ agent_key: str,
+ *,
+ continuing: str,
+) -> None:
+ """Best-effort removal of enabled extension command artifacts for ``agent_key``."""
+ _best_effort_extension_op(
+ project_root,
+ agent_key,
+ lambda mgr, key: mgr.unregister_agent_artifacts(
+ key,
+ enabled_only=True,
+ commands_only=True,
+ ),
+ phase="clean up enabled extension command artifacts for",
+ continuing=continuing,
+ )
+
+
# ---------------------------------------------------------------------------
# CLI formatting helpers (re-exported from _commands.py)
# ---------------------------------------------------------------------------
diff --git a/src/specify_cli/integrations/_install_commands.py b/src/specify_cli/integrations/_install_commands.py
index 66fd2b2d26..fc39dc8863 100644
--- a/src/specify_cli/integrations/_install_commands.py
+++ b/src/specify_cli/integrations/_install_commands.py
@@ -8,6 +8,7 @@
from .._console import console
from .._utils import _display_project_path
from ..integration_runtime import (
+ invoke_prefix_for_integration as _invoke_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
@@ -38,7 +39,7 @@
@integration_app.command("install")
def integration_install(
key: str = typer.Argument(help="Integration key to install (e.g. claude, copilot)"),
- script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
+ script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
force: bool = typer.Option(False, "--force", help="Allow multi-install when integrations are not declared safe"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")'),
):
@@ -127,7 +128,11 @@ def integration_install(
project_root,
selected_script,
invoke_separator=_invoke_separator_for_integration(
- infra_integration, current, infra_key, infra_parsed
+ infra_integration, current, infra_key, infra_parsed,
+ project_root=project_root,
+ ),
+ invoke_prefix=_invoke_prefix_for_integration(
+ infra_integration, infra_key, infra_parsed, project_root
),
)
if os.name != "nt":
@@ -138,12 +143,21 @@ def integration_install(
integration.key, project_root, version=_get_speckit_version()
)
+ from ..events import resolve_events
+ events_map = resolve_events(
+ integration.key,
+ integration.config,
+ project_root,
+ parsed_options,
+ )
+
try:
integration.setup(
project_root, manifest,
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
+ events=events_map,
)
manifest.save()
new_installed = _dedupe_integration_keys([*installed_keys, integration.key])
@@ -155,10 +169,16 @@ def integration_install(
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
+ project_root=project_root,
)
_write_integration_json(project_root, new_default, new_installed, settings)
if new_default == integration.key:
- _update_init_options_for_integration(project_root, integration, script_type=selected_script)
+ _update_init_options_for_integration(
+ project_root,
+ integration,
+ script_type=selected_script,
+ parsed_options=parsed_options,
+ )
else:
_refresh_init_options_speckit_version(project_root)
diff --git a/src/specify_cli/integrations/_migrate_commands.py b/src/specify_cli/integrations/_migrate_commands.py
index 6568d1af18..6f0a51b81c 100644
--- a/src/specify_cli/integrations/_migrate_commands.py
+++ b/src/specify_cli/integrations/_migrate_commands.py
@@ -1,13 +1,15 @@
"""specify integration switch / upgrade command handlers."""
from __future__ import annotations
+import json
import os
-from pathlib import PurePath
+from pathlib import Path, PurePath
import typer
from .._console import console
from ..integration_runtime import (
+ invoke_prefix_for_integration as _invoke_prefix_for_integration,
invoke_separator_for_integration as _invoke_separator_for_integration,
with_integration_setting as _with_integration_setting,
)
@@ -28,22 +30,208 @@
_read_integration_json,
_refresh_init_options_speckit_version,
_register_extensions_for_agent,
+ _register_presets_for_agent,
_remove_integration_json,
_resolve_integration_options,
_resolve_integration_script_type,
_resolve_script_type,
_set_default_integration,
_set_default_integration_or_exit,
+ _unregister_enabled_extension_commands_for_agent,
_unregister_extensions_for_agent,
+ _unregister_presets_for_agent,
_update_init_options_for_integration,
_write_integration_json,
)
+def _manifest_tracks_skill_layout(manifest) -> bool:
+ """Return True when *manifest* tracks any skills-layout artifact.
+
+ A skill scaffold is written as ``.../speckit-/SKILL.md``, so a
+ manifest whose tracked files include a ``/SKILL.md`` key is in the skills
+ layout; otherwise it is in the command layout. Used by ``upgrade`` to
+ detect a dual-mode agent (e.g. Bob) flipping between the legacy commands
+ layout and the skills layout so orphaned extension artifacts from the old
+ layout can be reconciled.
+ """
+ return any(str(rel).endswith("/SKILL.md") for rel in manifest.files)
+
+
+def _manifest_path_under(rel_path: str, root: str) -> bool:
+ """Return True when manifest key *rel_path* is inside project-relative *root*."""
+ normalized_root = PurePath(root).as_posix().strip("/")
+ normalized_rel = PurePath(rel_path).as_posix().strip("/")
+ if not normalized_root:
+ return False
+ return normalized_rel == normalized_root or normalized_rel.startswith(
+ f"{normalized_root}/"
+ )
+
+
+def _legacy_command_root_changed(
+ integration,
+ project_root: Path,
+ old_manifest,
+ new_manifest,
+) -> bool:
+ """Return True when command artifacts moved from legacy_dir to canonical dir."""
+ config = integration.registrar_config or {}
+ canonical = config.get("dir")
+ legacy = config.get("legacy_dir")
+ if (
+ not isinstance(canonical, str)
+ or not canonical.strip()
+ or not isinstance(legacy, str)
+ or not legacy.strip()
+ or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
+ ):
+ return False
+
+ canonical_dir = project_root / canonical
+ legacy_dir = project_root / legacy
+ if not canonical_dir.is_dir() or not legacy_dir.is_dir():
+ return False
+
+ old_had_legacy = any(
+ _manifest_path_under(rel, legacy) for rel in old_manifest.files
+ )
+ new_has_canonical = any(
+ _manifest_path_under(rel, canonical) for rel in new_manifest.files
+ )
+ return old_had_legacy and new_has_canonical
+
+
+def _legacy_command_root_upgrade_pending(integration, old_manifest) -> bool:
+ """Return True when the old manifest tracks command files under legacy_dir."""
+ config = integration.registrar_config or {}
+ canonical = config.get("dir")
+ legacy = config.get("legacy_dir")
+ if (
+ not isinstance(canonical, str)
+ or not canonical.strip()
+ or not isinstance(legacy, str)
+ or not legacy.strip()
+ or PurePath(canonical).as_posix() == PurePath(legacy).as_posix()
+ ):
+ return False
+ return any(_manifest_path_under(rel, legacy) for rel in old_manifest.files)
+
+
+class _PresetRegistryUnreadableError(Exception):
+ """Raised when an existing preset registry cannot be read or parsed.
+
+ Distinct from a *genuinely absent* registry (no presets installed): an
+ unreadable registry means we cannot verify whether preset overrides would
+ be orphaned by a layout change, so the migration must be rejected rather
+ than proceeding on a false "no presets" assumption.
+ """
+
+
+def _installed_presets_affecting_agent(
+ project_root,
+ agent_key: str,
+ *,
+ include_skills: bool = True,
+) -> list[str]:
+ """Return IDs of installed presets with artifacts registered for *agent_key*.
+
+ Preset registration is active-agent-only (#2948): command overrides are
+ written for the active non-skills agent and skills for the active skills
+ agent, tracked per preset in ``registered_commands`` /
+ ``registered_skills``. Entries for *other* agents may still exist from
+ when those agents were active. Callers use this to reject command-root or
+ commandāskills layout migrations before mutation: preset rescaffolding is
+ best-effort and cannot guarantee every tracked artifact has a replacement.
+
+ Fails **closed**: a genuinely absent registry (no presets ever installed)
+ returns an empty list, but if the registry file exists and cannot be read
+ or parsed (e.g. a permission error or corruption) this raises
+ :class:`_PresetRegistryUnreadableError`. Reporting "no presets" in that
+ case would let a ``--force`` layout-changing upgrade delete
+ preset-overridden files while their registry state can't be reconciled ā
+ the exact inconsistency the guard exists to prevent.
+ """
+ from ..presets import PresetRegistry
+
+ registry_path = (
+ Path(project_root) / ".specify" / "presets" / PresetRegistry.REGISTRY_FILE
+ )
+ # Genuinely absent registry ā no presets installed ā safe to proceed.
+ if not registry_path.exists():
+ return []
+
+ # The registry exists: any failure to read or parse it must surface as an
+ # error, not be swallowed into an empty ("no presets") result.
+ try:
+ data = json.loads(registry_path.read_text(encoding="utf-8"))
+ except (OSError, ValueError) as exc:
+ raise _PresetRegistryUnreadableError(str(exc)) from exc
+ if not isinstance(data, dict) or not isinstance(data.get("presets", {}), dict):
+ raise _PresetRegistryUnreadableError(
+ "preset registry structure is malformed"
+ )
+
+ affected: list[str] = []
+ for preset_id, meta in data.get("presets", {}).items():
+ # A malformed entry means we cannot verify whether this preset owns
+ # artifacts for the agent, so fail closed rather than skip it.
+ if not isinstance(meta, dict):
+ raise _PresetRegistryUnreadableError(
+ f"preset '{preset_id}' entry is malformed"
+ )
+ registered_commands = meta.get("registered_commands", {})
+ if not isinstance(registered_commands, dict) or not all(
+ isinstance(names, list) for names in registered_commands.values()
+ ):
+ raise _PresetRegistryUnreadableError(
+ f"preset '{preset_id}' registered_commands is malformed"
+ )
+ registered_skills = meta.get("registered_skills", [])
+ if isinstance(registered_skills, dict):
+ # Per-agent provenance ({agent: [skill names]}): only entries for
+ # *this* agent make the preset affect it. Values must be lists ā
+ # anything else (e.g. null) leaves ownership undecidable, so fail
+ # closed rather than read it as "no artifacts".
+ if not all(
+ isinstance(names, list) for names in registered_skills.values()
+ ):
+ raise _PresetRegistryUnreadableError(
+ f"preset '{preset_id}' registered_skills is malformed"
+ )
+ has_skills = include_skills and bool(
+ registered_skills.get(agent_key)
+ )
+ elif isinstance(registered_skills, (list, tuple)):
+ # Legacy flat list: not agent-scoped, so any recorded skill may
+ # belong to this agent ā fail closed and count it as affecting.
+ has_skills = include_skills and bool(registered_skills)
+ else:
+ raise _PresetRegistryUnreadableError(
+ f"preset '{preset_id}' registered_skills is malformed"
+ )
+ has_commands = bool(registered_commands.get(agent_key))
+ if has_commands or has_skills:
+ affected.append(preset_id)
+ return affected
+
+
+def _installed_command_presets_affecting_agent(
+ project_root,
+ agent_key: str,
+) -> list[str]:
+ """Return installed presets with command artifacts registered for *agent_key*."""
+ return _installed_presets_affecting_agent(
+ project_root,
+ agent_key,
+ include_skills=False,
+ )
+
+
@integration_app.command("switch")
def integration_switch(
target: str = typer.Argument(help="Integration key to switch to"),
- script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
+ script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
force: bool = typer.Option(False, "--force", help="Force removal of modified files during uninstall of the previous integration"),
refresh_shared_infra: bool = typer.Option(False, "--refresh-shared-infra", help="Also overwrite shared infrastructure files even if you customized them (otherwise customizations are preserved)"),
integration_options: str | None = typer.Option(None, "--integration-options", help='Options for the target integration'),
@@ -130,6 +318,14 @@ def integration_switch(
"need re-registration."
),
)
+ _register_presets_for_agent(
+ project_root,
+ target,
+ continuing=(
+ "The integration switch succeeded, but installed presets may "
+ "need re-registration."
+ ),
+ )
console.print(f"\n[green]ā[/green] Default integration set to [bold]{target}[/bold].")
raise typer.Exit(0)
@@ -187,6 +383,19 @@ def integration_switch(
continuing="Continuing with integration switch; old extension artifacts may need manual cleanup.",
)
+ # Unregister preset commands/skills for the old agent for the same
+ # reason: without this, a preset's command overrides (including
+ # custom preset commands) and skill mirrors rendered for
+ # installed_key would remain orphaned in its directory once a
+ # different, possibly not-yet-installed integration becomes active
+ # (#2948). Scoped strictly to installed_key; other agents' files,
+ # tracking, and the preset packs themselves are untouched.
+ _unregister_presets_for_agent(
+ project_root,
+ installed_key,
+ continuing="Continuing with integration switch; old preset artifacts may need manual cleanup.",
+ )
+
# Clear metadata so a failed Phase 2 doesn't leave stale references
installed_keys = [installed for installed in installed_keys if installed != installed_key]
_clear_init_options_for_integration(project_root, installed_key)
@@ -236,7 +445,11 @@ def integration_switch(
force=refresh_shared_infra,
refresh_managed=True,
invoke_separator=_invoke_separator_for_integration(
- target_integration, current, target, parsed_options
+ target_integration, current, target, parsed_options,
+ project_root=project_root,
+ ),
+ invoke_prefix=_invoke_prefix_for_integration(
+ target_integration, target, parsed_options, project_root
),
refresh_hint=(
"To overwrite customizations, re-run with "
@@ -253,12 +466,20 @@ def integration_switch(
target_integration.key, project_root, version=_get_speckit_version()
)
+ from ..events import resolve_events
+ events_map = resolve_events(
+ target_integration.key,
+ target_integration.config,
+ project_root,
+ parsed_options,
+ )
try:
target_integration.setup(
project_root, manifest,
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
+ events=events_map,
)
manifest.save()
_set_default_integration(
@@ -307,6 +528,24 @@ def integration_switch(
f"[yellow]Warning:[/yellow] Failed to restore default "
f"integration '{fallback_key}': {restore_err}"
)
+ else:
+ # Under active-only registration the fallback may never
+ # have received any extension/preset artifacts (it was
+ # installed while another integration was active), and
+ # Phase 1 already unregistered the outgoing agent's
+ # artifacts. Rescaffold so the restored default is
+ # actually usable. Both helpers are best-effort and
+ # cannot raise past this point.
+ _register_extensions_for_agent(
+ project_root,
+ fallback_key,
+ continuing="The switch was rolled back; installed extensions may need re-registration.",
+ )
+ _register_presets_for_agent(
+ project_root,
+ fallback_key,
+ continuing="The switch was rolled back; installed presets may need re-registration.",
+ )
else:
_write_integration_json(
project_root, fallback_key, installed_keys, _integration_settings(current)
@@ -327,6 +566,11 @@ def integration_switch(
target,
continuing="The integration switch succeeded, but installed extensions may need re-registration.",
)
+ _register_presets_for_agent(
+ project_root,
+ target,
+ continuing="The integration switch succeeded, but installed presets may need re-registration.",
+ )
name = (target_integration.config or {}).get("name", target)
console.print(f"\n[green]ā[/green] Switched to integration '{name}'")
@@ -336,7 +580,7 @@ def integration_switch(
def integration_upgrade(
key: str | None = typer.Argument(None, help="Integration key to upgrade (default: current integration)"),
force: bool = typer.Option(False, "--force", help="Force upgrade even if files are modified"),
- script: str | None = typer.Option(None, "--script", help="Script type: sh or ps (default: from init-options.json or platform default)"),
+ script: str | None = typer.Option(None, "--script", help="Script type: sh, ps, or py (default: from init-options.json or platform default)"),
integration_options: str | None = typer.Option(None, "--integration-options", help="Options for the integration"),
):
"""Upgrade an integration by reinstalling with diff-aware file handling.
@@ -398,6 +642,103 @@ def integration_upgrade(
integration, current, key, integration_options
)
+ legacy_command_root_upgrade_pending = _legacy_command_root_upgrade_pending(
+ integration,
+ old_manifest,
+ )
+
+ # Guard: Kilo's legacy command root moves from .kilocode/workflows to
+ # .kilo/commands. Preset command artifacts are tracked outside the
+ # integration manifest, and their agent-scoped rescaffold is best-effort,
+ # not transactional with command-root cleanup. Refuse before setup writes
+ # .kilo/commands rather than risking orphaned legacy files or missing
+ # registry-tracked overrides in the canonical directory.
+ if key == "kilocode" and legacy_command_root_upgrade_pending:
+ config = integration.registrar_config or {}
+ legacy = config.get("legacy_dir", "legacy command directory")
+ canonical = config.get("dir", "canonical command directory")
+ try:
+ affected_presets = _installed_command_presets_affecting_agent(
+ project_root,
+ key,
+ )
+ except _PresetRegistryUnreadableError as exc:
+ console.print(
+ f"[red]Error:[/red] Cannot migrate '{key}' command directory "
+ f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan]: "
+ "the preset registry could not be read to verify installed presets."
+ )
+ console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
+ console.print(
+ "A command directory migration cannot reconcile preset command "
+ "artifacts while the preset registry state is unknown. Fix or "
+ "restore [cyan].specify/presets/.registry[/cyan] and retry."
+ )
+ raise typer.Exit(1)
+ if affected_presets:
+ preset_list = ", ".join(sorted(affected_presets))
+ console.print(
+ f"[red]Error:[/red] Cannot migrate '{key}' command directory "
+ f"from [cyan]{legacy}[/cyan] to [cyan]{canonical}[/cyan] while "
+ f"preset override(s) are installed: [bold]{preset_list}[/bold]."
+ )
+ console.print(
+ "Preset command artifacts cannot yet be reconciled across this "
+ "command directory migration, so the upgrade is refused before "
+ "changing files."
+ )
+ console.print(
+ "Remove the preset(s), run the upgrade, then reinstall them:\n"
+ f" [cyan]specify preset remove [/cyan]\n"
+ f" [cyan]specify integration upgrade {key} --script {selected_script} --force[/cyan]\n"
+ f" [cyan]specify preset add [/cyan]"
+ )
+ raise typer.Exit(1)
+
+ # Reject commandāskills layout changes while preset artifacts are tracked
+ # for the integration (review #3415). Preset rescaffolding is best-effort:
+ # an enabled preset can still have a missing/corrupt manifest or command
+ # source, or fail during a write. Phase 2 would otherwise delete the
+ # old-layout file before a replacement is known to exist. Refuse before
+ # any mutation; same-layout upgrades still rescaffold the active agent.
+ if _manifest_tracks_skill_layout(old_manifest) != integration.is_skills_mode(
+ parsed_options, project_root
+ ):
+ try:
+ affected_presets = _installed_presets_affecting_agent(project_root, key)
+ except _PresetRegistryUnreadableError as exc:
+ console.print(
+ f"[red]Error:[/red] Cannot change '{key}' command layout: the "
+ f"preset registry could not be read to verify installed presets."
+ )
+ console.print(f"[dim]Details:[/dim] {_cli_error_detail(exc)}")
+ console.print(
+ "A layout change cannot reconcile preset artifacts, so the "
+ "migration is refused while the preset registry state is "
+ "unknown. Fix or restore "
+ "[cyan].specify/presets/.registry[/cyan] and retry."
+ )
+ raise typer.Exit(1)
+ if affected_presets:
+ preset_list = ", ".join(sorted(affected_presets))
+ console.print(
+ f"[red]Error:[/red] Cannot change '{key}' command layout while "
+ f"preset override(s) are installed: [bold]{preset_list}[/bold]."
+ )
+ console.print(
+ "Preset artifacts cannot be safely reconciled across a "
+ "commandāskills layout change, so the migration is refused "
+ "before changing files."
+ )
+ console.print(
+ "Remove the preset(s), run the upgrade, then reinstall them:\n"
+ f" [cyan]specify preset remove [/cyan]\n"
+ f" [cyan]specify integration upgrade {key} "
+ f"--integration-options \"...\"[/cyan]\n"
+ f" [cyan]specify preset add [/cyan]"
+ )
+ raise typer.Exit(1)
+
# Ensure shared infrastructure is up to date; --force overwrites existing files.
infra_integration = integration
infra_key = key
@@ -415,7 +756,11 @@ def integration_upgrade(
selected_script,
force=force,
invoke_separator=_invoke_separator_for_integration(
- infra_integration, current, infra_key, infra_parsed
+ infra_integration, current, infra_key, infra_parsed,
+ project_root=project_root,
+ ),
+ invoke_prefix=_invoke_prefix_for_integration(
+ infra_integration, infra_key, infra_parsed, project_root
),
)
if os.name != "nt":
@@ -426,6 +771,13 @@ def integration_upgrade(
console.print(f"Upgrading integration: [cyan]{key}[/cyan]")
new_manifest = IntegrationManifest(key, project_root, version=_get_speckit_version())
+ from ..events import resolve_events
+ events_map = resolve_events(
+ key,
+ integration.config,
+ project_root,
+ parsed_options,
+ )
try:
integration.setup(
project_root,
@@ -433,6 +785,7 @@ def integration_upgrade(
parsed_options=parsed_options,
script_type=selected_script,
raw_options=raw_options,
+ events=events_map,
)
settings = _with_integration_setting(
current,
@@ -441,6 +794,7 @@ def integration_upgrade(
script_type=selected_script,
raw_options=raw_options,
parsed_options=parsed_options,
+ project_root=project_root,
)
if installed_key == key:
try:
@@ -448,7 +802,11 @@ def integration_upgrade(
project_root,
selected_script,
invoke_separator=_invoke_separator_for_integration(
- integration, {"integration_settings": settings}, key, parsed_options
+ integration, {"integration_settings": settings}, key, parsed_options,
+ project_root=project_root,
+ ),
+ invoke_prefix=_invoke_prefix_for_integration(
+ integration, key, parsed_options, project_root
),
force=force,
refresh_managed=True,
@@ -463,7 +821,12 @@ def integration_upgrade(
new_manifest.save()
_write_integration_json(project_root, installed_key, installed_keys, settings)
if installed_key == key:
- _update_init_options_for_integration(project_root, integration, script_type=selected_script)
+ _update_init_options_for_integration(
+ project_root,
+ integration,
+ script_type=selected_script,
+ parsed_options=parsed_options,
+ )
else:
_refresh_init_options_speckit_version(project_root)
except Exception as exc:
@@ -487,21 +850,50 @@ def integration_upgrade(
if stale_keys:
stale_manifest = IntegrationManifest(key, project_root, version="stale-cleanup")
stale_manifest._files = {k: old_files[k] for k in stale_keys}
- stale_removed, _ = stale_manifest.uninstall(project_root, force=True)
+ # remove_manifest=False: this throwaway manifest shares ``key`` with the
+ # real one just saved above (new_manifest.save()). Letting uninstall()
+ # delete ``{key}.manifest.json`` would wipe the freshly-written manifest
+ # whenever an upgrade shrinks the tracked file set (e.g. Bob migrating
+ # from the legacy commands layout to skills), leaving the integration
+ # untracked and un-upgradeable.
+ stale_removed, _ = stale_manifest.uninstall(
+ project_root, force=True, remove_manifest=False
+ )
if stale_removed:
console.print(f" Removed {len(stale_removed)} stale file(s) from previous install")
- # Re-register enabled extensions for the upgraded agent so its extension
- # commands are (re)created ā including agents installed before this
- # back-fill existed. Mirrors switch for command registration; see #2886.
- # Done after the upgrade has fully settled (Phase 2 included) and outside
- # the try/except above so this best-effort step cannot affect upgrade
- # success.
- _register_extensions_for_agent(
+ legacy_command_root_changed = _legacy_command_root_changed(
+ integration,
project_root,
- key,
- continuing="The integration was upgraded, but installed extensions may need re-registration.",
+ old_manifest,
+ new_manifest,
)
+ if legacy_command_root_changed:
+ _unregister_enabled_extension_commands_for_agent(
+ project_root,
+ key,
+ continuing=(
+ "The integration command directory changed, but legacy enabled "
+ "extension artifacts may need manual cleanup."
+ ),
+ )
+
+ # Re-register enabled extensions and presets only when upgrading the
+ # active integration. Inactive integrations remain untouched until
+ # `use` or `switch` activates and rescaffolds them (#2948). This runs
+ # after the core upgrade transaction, so failures remain best-effort.
+ if key == installed_key:
+ _register_extensions_for_agent(
+ project_root,
+ key,
+ force=True,
+ continuing="The integration was upgraded, but installed extensions may need re-registration.",
+ )
+ _register_presets_for_agent(
+ project_root,
+ key,
+ continuing="The integration was upgraded, but installed presets may need re-registration.",
+ )
name = (integration.config or {}).get("name", key)
console.print(f"\n[green]ā[/green] Integration '{name}' upgraded successfully")
diff --git a/src/specify_cli/integrations/_query_commands.py b/src/specify_cli/integrations/_query_commands.py
index bb47e6142e..0cd254879a 100644
--- a/src/specify_cli/integrations/_query_commands.py
+++ b/src/specify_cli/integrations/_query_commands.py
@@ -18,6 +18,7 @@
from ._helpers import (
_read_integration_json,
_register_extensions_for_agent,
+ _register_presets_for_agent,
_resolve_integration_options,
_set_default_integration_or_exit,
)
@@ -248,6 +249,11 @@ def integration_use(
key,
continuing="The integration was selected, but installed extensions may need re-registration.",
)
+ _register_presets_for_agent(
+ project_root,
+ key,
+ continuing="The integration was selected, but installed presets may need re-registration.",
+ )
console.print(f"[green]ā[/green] Default integration set to [bold]{key}[/bold].")
@@ -312,22 +318,26 @@ def integration_search(
console.print(f"\n[green]Found {len(results)} integration(s):[/green]\n")
for integ in sorted(results, key=lambda e: e.get("id", "")):
- iid = integ.get("id", "?")
- name = integ.get("name", iid)
- version = integ.get("version", "?")
+ iid_value = str(integ.get("id", "?"))
+ iid = _rich_escape(iid_value)
+ name = _rich_escape(str(integ.get("name", iid_value)))
+ version = _rich_escape(str(integ.get("version", "?")))
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
desc = integ.get("description", "")
if desc:
- console.print(f" {desc}")
+ console.print(f" {_rich_escape(str(desc))}")
- console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
+ author_value = _rich_escape(str(integ.get("author", "Unknown")))
+ console.print(f"\n [dim]Author:[/dim] {author_value}")
tags = integ.get("tags", [])
if isinstance(tags, list) and tags:
- console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
+ safe_tags = _rich_escape(", ".join(str(t) for t in tags))
+ console.print(f" [dim]Tags:[/dim] {safe_tags}")
- cat_name = integ.get("_catalog_name", "")
+ cat_name_value = integ.get("_catalog_name", "")
+ cat_name = _rich_escape(str(cat_name_value))
install_allowed = integ.get("_install_allowed", True)
- if cat_name:
+ if cat_name_value:
if install_allowed:
console.print(f" [dim]Catalog:[/dim] {cat_name}")
else:
@@ -336,9 +346,9 @@ def integration_search(
"[yellow](discovery only ā not installable)[/yellow]"
)
- if iid == installed_key:
+ if iid_value == installed_key:
console.print("\n [green]ā Installed[/green] (currently active)")
- elif iid in INTEGRATION_REGISTRY:
+ elif iid_value in INTEGRATION_REGISTRY:
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
elif install_allowed:
console.print(
@@ -368,6 +378,7 @@ def integration_info(
project_root = _require_specify_project()
catalog = IntegrationCatalog(project_root)
installed_key = _default_integration_key(_read_integration_json(project_root))
+ safe_integration_id = _rich_escape(str(integration_id))
try:
info = catalog.get_integration_info(integration_id)
@@ -380,29 +391,38 @@ def integration_info(
catalog_error = None
if info:
- name = info.get("name", integration_id)
- version = info.get("version", "?")
- console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
+ name = _rich_escape(str(info.get("name", integration_id)))
+ version = _rich_escape(str(info.get("version", "?")))
+ console.print(
+ f"\n[bold cyan]{name}[/bold cyan] ({safe_integration_id}) v{version}"
+ )
if info.get("description"):
- console.print(f" {info['description']}")
+ console.print(f" {_rich_escape(str(info['description']))}")
console.print()
- console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
+ author_value = _rich_escape(str(info.get("author", "Unknown")))
+ console.print(f" [dim]Author:[/dim] {author_value}")
if info.get("license"):
- console.print(f" [dim]License:[/dim] {info['license']}")
+ console.print(
+ f" [dim]License:[/dim] {_rich_escape(str(info['license']))}"
+ )
tags = info.get("tags", [])
if isinstance(tags, list) and tags:
- console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
+ safe_tags = _rich_escape(", ".join(str(t) for t in tags))
+ console.print(f" [dim]Tags:[/dim] {safe_tags}")
- cat_name = info.get("_catalog_name", "")
+ cat_name_value = info.get("_catalog_name", "")
+ cat_name = _rich_escape(str(cat_name_value))
install_allowed = info.get("_install_allowed", True)
- if cat_name:
+ if cat_name_value:
install_note = "" if install_allowed else " [yellow](discovery only)[/yellow]"
console.print(f" [dim]Source catalog:[/dim] {cat_name}{install_note}")
if info.get("repository"):
- console.print(f" [dim]Repository:[/dim] {info['repository']}")
+ console.print(
+ f" [dim]Repository:[/dim] {_rich_escape(str(info['repository']))}"
+ )
if integration_id == installed_key:
console.print("\n [green]ā Installed[/green] (currently active)")
@@ -438,7 +458,7 @@ def integration_info(
else:
console.print("\nTry again when online, or use a built-in integration ID directly.")
else:
- console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
+ console.print(f"[red]Error:[/red] Integration '{safe_integration_id}' not found")
console.print("\nTry: specify integration search")
raise typer.Exit(1)
@@ -489,13 +509,14 @@ def integration_catalog_list():
display_name = str(raw_name).strip() if raw_name is not None else ""
if not display_name:
display_name = f"catalog-{i + 1}"
+ safe_name = _rich_escape(display_name)
if env_override or project_configs is None:
- console.print(f" - [bold]{display_name}[/bold] ā {install_status}")
+ console.print(f" - [bold]{safe_name}[/bold] ā {install_status}")
else:
- console.print(f" [{i}] [bold]{display_name}[/bold] ā {install_status}")
- console.print(f" {cfg.get('url', '')}")
+ console.print(f" [{i}] [bold]{safe_name}[/bold] ā {install_status}")
+ console.print(f" {_rich_escape(str(cfg.get('url', '')))}")
if cfg.get("description"):
- console.print(f" [dim]{cfg['description']}[/dim]")
+ console.print(f" [dim]{_rich_escape(str(cfg['description']))}[/dim]")
console.print()
diff --git a/src/specify_cli/integrations/alquimia/__init__.py b/src/specify_cli/integrations/alquimia/__init__.py
new file mode 100644
index 0000000000..507ce879e7
--- /dev/null
+++ b/src/specify_cli/integrations/alquimia/__init__.py
@@ -0,0 +1,165 @@
+"""Alquimia AI integration."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from ..._utils import dump_frontmatter
+from ..base import SkillsIntegration
+
+# Mapping of command template stem ā argument-hint text shown inline
+# when a user invokes the slash command in Alquimia AI.
+ARGUMENT_HINTS: dict[str, str] = {
+ "specify": "Describe the feature you want to specify",
+ "plan": "Optional guidance for the planning phase",
+ "tasks": "Optional task generation constraints",
+ "implement": "Optional implementation guidance or task filter",
+ "analyze": "Optional focus areas for analysis",
+ "clarify": "Optional areas to clarify in the spec",
+ "constitution": "Principles or values for the project constitution",
+ "checklist": "Domain or focus area for the checklist",
+ "taskstoissues": "Optional filter or label for GitHub issues",
+}
+
+
+class AlquimiaAIIntegration(SkillsIntegration):
+ """Integration for Alquimia AI skills."""
+
+ key = "alquimia"
+ config = {
+ "name": "Alquimia AI",
+ "folder": ".alquimia/",
+ "commands_subdir": "skills",
+ "install_url": "https://docs.alquimia.ai",
+ "requires_cli": True,
+ }
+ registrar_config = {
+ "dir": ".alquimia/skills",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": "/SKILL.md",
+ }
+ multi_install_safe = True
+
+ def _render_skill(
+ self, template_name: str, frontmatter: dict[str, Any], body: str
+ ) -> str:
+ """Render a processed command template as an Alquimia skill."""
+ skill_name = f"speckit-{template_name.replace('.', '-')}"
+ description = frontmatter.get(
+ "description",
+ f"Spec-kit workflow command: {template_name}",
+ )
+ skill_frontmatter = self._build_skill_fm(
+ skill_name, description, f"templates/commands/{template_name}.md"
+ )
+ frontmatter_text = dump_frontmatter(skill_frontmatter)
+ return f"---\n{frontmatter_text}\n---\n\n{body.strip()}\n"
+
+ def _build_skill_fm(self, name: str, description: str, source: str) -> dict:
+ from specify_cli.agents import CommandRegistrar
+
+ return CommandRegistrar.build_skill_frontmatter(
+ self.key, name, description, source
+ )
+
+ @staticmethod
+ def inject_argument_hint(content: str, hint: str) -> str:
+ """Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
+
+ Skips injection if ``argument-hint:`` already exists in the
+ frontmatter to avoid duplicate keys.
+ """
+ lines = content.splitlines(keepends=True)
+
+ # Pre-scan: bail out if argument-hint 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("argument-hint:"):
+ return content # already present
+
+ out: list[str] = []
+ in_fm = False
+ dash_count = 0
+ injected = False
+ for line in lines:
+ stripped = line.rstrip("\n\r")
+ if stripped == "---":
+ dash_count += 1
+ in_fm = dash_count == 1
+ out.append(line)
+ continue
+ if in_fm and not injected and stripped.startswith("description:"):
+ out.append(line)
+ # Preserve the exact line-ending style (\r\n vs \n)
+ if line.endswith("\r\n"):
+ eol = "\r\n"
+ elif line.endswith("\n"):
+ eol = "\n"
+ else:
+ eol = ""
+ escaped = hint.replace("\\", "\\\\").replace('"', '\\"')
+ out.append(f'argument-hint: "{escaped}"{eol}')
+ injected = True
+ continue
+ out.append(line)
+ return "".join(out)
+
+ @staticmethod
+ def _inject_frontmatter_flag(content: str, key: str, value: str = "true") -> str:
+ """Insert ``key: value`` before the closing ``---`` if not already present."""
+ 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
+ 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:
+ if line.endswith("\r\n"):
+ eol = "\r\n"
+ elif line.endswith("\n"):
+ eol = "\n"
+ else:
+ eol = ""
+ out.append(f"{key}: {value}{eol}")
+ injected = True
+ out.append(line)
+ return "".join(out)
+
+ def post_process_skill_content(self, content: str) -> str:
+ """Inject Alquimia-specific frontmatter flags, hints and hook notes."""
+ 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"
+ )
+ for line in updated.splitlines():
+ if line.startswith("name:"):
+ name = line.removeprefix("name:").strip().strip("\"'")
+ hint = ARGUMENT_HINTS.get(name.removeprefix("speckit-"))
+ if hint:
+ updated = self.inject_argument_hint(updated, hint)
+ break
+ return updated
diff --git a/src/specify_cli/integrations/base.py b/src/specify_cli/integrations/base.py
index a425d4e012..cca4f13976 100644
--- a/src/specify_cli/integrations/base.py
+++ b/src/specify_cli/integrations/base.py
@@ -14,6 +14,7 @@
from __future__ import annotations
import os
+import platform
import re
import shlex
import shutil
@@ -26,14 +27,16 @@
import yaml
+from .._invocation_style import get_invocation_prefix, is_dollar_skills_agent
from .._toml_string import escape_toml_basic as _escape_toml_basic
from .._toml_string import has_illegal_toml_control as _has_illegal_toml_control
+from ..events import install_integration_events, remove_integration_events
if TYPE_CHECKING:
from .manifest import IntegrationManifest
_HOOK_COMMAND_NOTE = (
- "- When constructing slash commands from hook command names, "
+ "- When constructing command invocations from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` ā `/speckit-git-commit`.\n"
)
@@ -157,20 +160,79 @@ def post_process_command_content(self, content: str) -> str:
@classmethod
def options(cls) -> list[IntegrationOption]:
"""Return options this integration accepts. Default: none."""
- return []
+ opts = []
+ if bool(getattr(cls, "CANONICAL_TO_NATIVE", None) and getattr(cls, "events_config_file", None)):
+ opts.append(
+ IntegrationOption(
+ "--events",
+ is_flag=False,
+ default="true",
+ help="Enable/disable runtime events (true|false, default: true)",
+ )
+ )
+ return opts
def effective_invoke_separator(
- self, parsed_options: dict[str, Any] | None = None
+ self,
+ parsed_options: dict[str, Any] | None = None,
+ project_root: Path | None = None,
) -> str:
"""Return the invoke separator for the given options.
Subclasses whose separator depends on runtime options (e.g.
Copilot in ``--skills`` mode) should override this method.
- The default implementation ignores *parsed_options* and returns
- the class-level ``invoke_separator``.
+ The default implementation ignores *parsed_options* and
+ *project_root* and returns the class-level ``invoke_separator``.
"""
return self.invoke_separator
+ def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
+ """Command-ref separator given the project's *resolved* skills state.
+
+ Registration paths (extension / preset command rendering) have no CLI
+ ``parsed_options`` ā only the persisted ``ai_skills`` flag ā so they
+ resolve the command-reference separator through this hook rather than
+ the static ``AGENT_CONFIGS[key]["invoke_separator"]`` value, which
+ cannot represent an agent whose separator differs between its skills
+ and command layouts.
+
+ The default is mode-independent and returns exactly what
+ ``_build_agent_configs`` would place in ``AGENT_CONFIGS`` (the
+ ``registrar_config`` override if present, else the class-level
+ ``invoke_separator``), so single-layout agents are unaffected.
+ Dual-mode agents whose separator depends on the layout (e.g. Bob:
+ ``-`` for skills, ``.`` for legacy commands) override this.
+ """
+ cfg = self.registrar_config or {}
+ return cfg.get("invoke_separator", self.invoke_separator)
+
+ def is_skills_mode(
+ self,
+ parsed_options: dict[str, Any] | None = None,
+ project_root: Path | None = None,
+ ) -> bool:
+ """Return whether this integration scaffolds skills for these options.
+
+ This is the single, well-defined hook the shared init/install/upgrade
+ machinery consults to decide whether to persist ``ai_skills=True`` and
+ render skill invocations. It replaces ad-hoc ``isinstance`` /
+ ``getattr(self, "_skills_mode", ...)`` probing so an integration's
+ internal representation never has to leak into shared dispatch code.
+
+ *project_root* is optional context for the ``use`` / ``switch`` /
+ ``upgrade`` path, where no ``setup()`` runs and *parsed_options* may be
+ empty: dual-mode integrations can consult the already-installed
+ on-disk layout to avoid silently migrating an existing project to a
+ different mode. The default ignores it.
+
+ The default (command-first integrations, e.g. Copilot's default
+ layout) is skills mode only when ``--skills`` was requested.
+ ``SkillsIntegration`` overrides this to return ``True`` by default;
+ skills-first integrations that expose a legacy opt-out (e.g. Bob)
+ override it to honor their own flag.
+ """
+ return bool((parsed_options or {}).get("skills"))
+
def build_exec_args(
self,
prompt: str,
@@ -429,7 +491,11 @@ def stale_cleanup_exclusions(self) -> set[str]:
tracking) would otherwise be deleted even though they are still
managed. Subclasses list such paths here to protect them.
"""
- return set()
+ exclusions = set()
+ if self.supports_events():
+ from ..events import events_stale_exclusions
+ exclusions.update(events_stale_exclusions(self.key))
+ return exclusions
def commands_dest(self, project_root: Path) -> Path:
"""Return the absolute path to the commands output directory.
@@ -551,7 +617,9 @@ def install_scripts(
return created
@staticmethod
- def resolve_command_refs(content: str, separator: str = ".") -> str:
+ def resolve_command_refs(
+ content: str, separator: str = ".", prefix: str = "/"
+ ) -> str:
"""Replace ``__SPECKIT_COMMAND___`` placeholders with invocations.
Each placeholder encodes a command name in upper-case with
@@ -561,10 +629,16 @@ def resolve_command_refs(content: str, separator: str = ".") -> str:
* ``separator="."`` ā ``/speckit.plan``, ``/speckit.git.commit``
* ``separator="-"`` ā ``/speckit-plan``, ``/speckit-git-commit``
+
+ *prefix* defaults to ``"/"`` but may be ``"$"`` for agents whose
+ native skills invocation uses dollar-prefixed chat commands.
"""
return re.sub(
r"__SPECKIT_COMMAND_([A-Z][A-Z0-9_]*)__",
- lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator),
+ lambda m: prefix
+ + "speckit"
+ + separator
+ + m.group(1).lower().replace("_", separator),
content,
)
@@ -619,6 +693,46 @@ def resolve_python_interpreter(project_root: Path | None = None) -> str:
return name
return sys.executable or "python3"
+ @staticmethod
+ def build_python_invocation(
+ script_command: str, project_root: Path | None = None
+ ) -> str:
+ """Build a Python script command for the current platform shell."""
+ interpreter = IntegrationBase.resolve_python_interpreter(project_root)
+ if os.name == "nt" and not re.fullmatch(r"[A-Za-z0-9_./:\\-]+", interpreter):
+ quoted_interpreter = interpreter.replace("'", "''")
+ interpreter = f"& '{quoted_interpreter}'"
+ elif os.name != "nt":
+ interpreter = shlex.quote(interpreter)
+ return f"{interpreter} {script_command}"
+
+ @staticmethod
+ def select_script_variant(
+ requested: object, script_commands: dict[str, str]
+ ) -> str:
+ """Select the requested variant or a runnable platform fallback."""
+ if isinstance(requested, str) and requested in script_commands:
+ return requested
+
+ platform_variant = (
+ "ps" if platform.system().lower().startswith("win") else "sh"
+ )
+ secondary_variant = "sh" if platform_variant == "ps" else "ps"
+ fallbacks = (
+ (platform_variant, "py")
+ if requested == "py"
+ else (platform_variant, secondary_variant, "py")
+ )
+ for candidate in fallbacks:
+ if candidate in script_commands:
+ return candidate
+
+ available = ", ".join(sorted(script_commands)) or "none"
+ raise ValueError(
+ "No runnable script variant for this platform: "
+ f"requested {requested!r}; available: {available}"
+ )
+
@staticmethod
def _interpreter_runs(path: str) -> bool:
"""Return True when *path* executes as a Python interpreter.
@@ -653,7 +767,8 @@ def process_template(
"""Process a raw command template into agent-ready content.
Performs the same transformations as the release script:
- 1. Extract ``scripts.`` value from YAML frontmatter
+ 1. Select ``scripts.`` from YAML frontmatter, falling
+ back to a runnable platform shell or Python variant when unavailable
2. Replace ``{SCRIPT}`` with the extracted script command
3. Strip ``scripts:`` section from frontmatter
4. Replace ``{ARGS}`` and ``$ARGUMENTS`` with *arg_placeholder*
@@ -662,37 +777,46 @@ def process_template(
7. Replace ``__SPECKIT_COMMAND___`` with invocation strings
"""
# 1. Extract script command from frontmatter
- script_command = ""
- script_pattern = re.compile(
- rf"^\s*{re.escape(script_type)}:\s*(.+)$", re.MULTILINE
- )
+ script_commands: dict[str, str] = {}
+ script_pattern = re.compile(r"^\s*([A-Za-z0-9_-]+):\s*(.+)$")
# Find the scripts: block
+ in_frontmatter = False
in_scripts = False
for line in content.splitlines():
- if line.strip() == "scripts:":
+ if line == "---":
+ if in_frontmatter:
+ break
+ in_frontmatter = True
+ continue
+ if not in_frontmatter:
+ continue
+ if line == "scripts:":
in_scripts = True
continue
if in_scripts and line and not line[0].isspace():
- in_scripts = False
+ break
if in_scripts:
m = script_pattern.match(line)
if m:
- script_command = m.group(1).strip()
- break
+ script_commands[m.group(1)] = m.group(2).strip()
+
+ selected_script_type = (
+ IntegrationBase.select_script_variant(script_type, script_commands)
+ if script_commands
+ else ""
+ )
+
+ script_command = script_commands.get(selected_script_type, "")
# 2. Replace {SCRIPT}
if script_command:
# For the Python script type, prefix the resolved interpreter so
# the command is portable (``.py`` files are not directly
# executable on Windows).
- if script_type == "py":
- interpreter = IntegrationBase.resolve_python_interpreter(project_root)
- # Quote the interpreter if it contains whitespace (e.g. an
- # absolute ``sys.executable`` path under Windows
- # ``Program Files``) so it isn't split into multiple args.
- if any(ch.isspace() for ch in interpreter):
- interpreter = f'"{interpreter}"'
- script_command = f"{interpreter} {script_command}"
+ if selected_script_type == "py":
+ script_command = IntegrationBase.build_python_invocation(
+ script_command, project_root
+ )
content = content.replace("{SCRIPT}", script_command)
# 3. Strip scripts: section from frontmatter
@@ -738,7 +862,12 @@ def process_template(
content = CommandRegistrar.rewrite_project_relative_paths(content)
# 8. Replace __SPECKIT_COMMAND___ with invocation strings
- content = IntegrationBase.resolve_command_refs(content, invoke_separator)
+ invocation_prefix = get_invocation_prefix(
+ agent_name, invoke_separator == "-"
+ )
+ content = IntegrationBase.resolve_command_refs(
+ content, invoke_separator, invocation_prefix
+ )
return content
@@ -802,8 +931,32 @@ def teardown(
Returns ``(removed, skipped)`` file lists.
"""
+ self.remove_events(project_root, manifest)
return manifest.uninstall(project_root, force=force)
+ def emit_events(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ events: dict[str, dict[str, Any]] | None = None,
+ parsed_options: dict[str, Any] | None = None,
+ **opts: Any,
+ ) -> list[Path]:
+ """Emit native event configuration for this integration."""
+ return install_integration_events(self, project_root, manifest, events or {})
+
+ def remove_events(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ ) -> None:
+ """Remove Specify-authored event entries from native config."""
+ remove_integration_events(self, project_root, manifest)
+
+ def supports_events(self) -> bool:
+ """Return True if this integration supports agent-native events."""
+ return bool(getattr(self, "CANONICAL_TO_NATIVE", None) and getattr(self, "events_config_file", None))
+
# -- Convenience helpers for subclasses -------------------------------
def install(
@@ -908,6 +1061,12 @@ def setup(
created.append(dst_file)
+ # Install agent runtime events
+ event_files = self.emit_events(
+ project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
+ )
+ created.extend(event_files)
+
return created
@@ -1115,6 +1274,12 @@ def setup(
created.append(dst_file)
+ # Install agent runtime events
+ event_files = self.emit_events(
+ project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
+ )
+ created.extend(event_files)
+
return created
@@ -1351,6 +1516,12 @@ def setup(
created.append(dst_file)
+ # Install agent runtime events
+ event_files = self.emit_events(
+ project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
+ )
+ created.extend(event_files)
+
return created
@@ -1376,6 +1547,14 @@ class SkillsIntegration(IntegrationBase):
invoke_separator = "-"
+ def is_skills_mode(
+ self,
+ parsed_options: dict[str, Any] | None = None,
+ project_root: Path | None = None,
+ ) -> bool:
+ """Skills-native integrations scaffold skills unconditionally."""
+ return True
+
def build_exec_args(
self,
prompt: str,
@@ -1412,18 +1591,21 @@ def skills_dest(self, project_root: Path) -> Path:
return project_root / folder / subdir
def build_command_invocation(self, command_name: str, args: str = "") -> str:
- """Skills use ``/speckit-`` (hyphenated directory name)."""
+ """Build the agent's native invocation for a hyphenated skill name."""
stem = command_name
if stem.startswith("speckit."):
stem = stem[len("speckit."):]
- invocation = "/speckit-" + stem.replace(".", "-")
+ prefix = "$" if is_dollar_skills_agent(self.key, True) else "/"
+ invocation = prefix + "speckit-" + stem.replace(".", "-")
if args:
invocation = f"{invocation} {args}"
return invocation
@staticmethod
- def _inject_hook_command_note(content: str) -> str:
+ def _inject_hook_command_note(
+ content: str, invocation_prefix: str = "/"
+ ) -> str:
"""Insert a dot-to-hyphen note before each hook output instruction.
Targets the line ``- For each executable hook, output the following``
@@ -1432,6 +1614,11 @@ def _inject_hook_command_note(content: str) -> str:
above them.
"""
note = _HOOK_COMMAND_NOTE.rstrip("\n")
+ if invocation_prefix != "/":
+ note = note.replace(
+ "`/speckit-git-commit`",
+ f"`{invocation_prefix}speckit-git-commit`",
+ )
def repl(m: re.Match[str]) -> str:
indent = m.group(1)
@@ -1465,10 +1652,13 @@ def post_process_skill_content(self, content: str) -> str:
Called by external skill generators (presets, extensions) to let
the integration inject agent-specific frontmatter or body
transformations. The base implementation injects shared skills
- guidance for converting dotted hook command names to hyphenated
- slash commands. Subclasses may override ā see ``ClaudeIntegration``.
+ guidance for converting dotted hook command names to the agent-native
+ hyphenated command invocation (e.g. ``/speckit-git-commit`` or
+ ``$speckit-git-commit``). Subclasses may override -- see
+ ``ClaudeIntegration``.
"""
- return self._inject_hook_command_note(content)
+ invocation_prefix = get_invocation_prefix(self.key, True)
+ return self._inject_hook_command_note(content, invocation_prefix)
def setup(
self,
@@ -1519,13 +1709,27 @@ def setup(
command_name = src_file.stem # e.g. "plan"
skill_name = f"speckit-{command_name.replace('.', '-')}"
- # Parse frontmatter for description
+ # Parse frontmatter for description. Locate the closing ``---`` on
+ # its own line rather than with ``raw.split("---", 2)`` ā a bare
+ # substring split stops at the first ``---`` *anywhere*, including
+ # one inside a value such as ``description: Separate sections
+ # with ---``, which truncates the frontmatter and drops later keys.
+ # The block between the delimiters is parsed unstripped so trailing
+ # newlines in literal (``|``) block scalars survive.
frontmatter: dict[str, Any] = {}
if raw.startswith("---"):
- parts = raw.split("---", 2)
- if len(parts) >= 3:
+ fm_lines = raw.splitlines(keepends=True)
+ fm_close = next(
+ (
+ i
+ for i in range(1, len(fm_lines))
+ if fm_lines[i].rstrip() == "---"
+ ),
+ None,
+ )
+ if fm_close is not None:
try:
- fm = yaml.safe_load(parts[1])
+ fm = yaml.safe_load("".join(fm_lines[1:fm_close]))
if isinstance(fm, dict):
frontmatter = fm
except yaml.YAMLError:
@@ -1540,11 +1744,27 @@ def setup(
# Strip the processed frontmatter ā we rebuild it for skills.
# Preserve leading whitespace in the body to match release ZIP
# output byte-for-byte (the template body starts with \n after
- # the closing ---).
+ # the closing ---). Scan for the closing ``---`` on its own line
+ # rather than ``split("---", 2)`` so a ``---`` embedded in a value
+ # does not truncate the frontmatter and spill it into the body.
if processed_body.startswith("---"):
- parts = processed_body.split("---", 2)
- if len(parts) >= 3:
- processed_body = parts[2]
+ body_lines = processed_body.splitlines(keepends=True)
+ close_idx = next(
+ (
+ i
+ for i in range(1, len(body_lines))
+ if body_lines[i].rstrip() == "---"
+ ),
+ None,
+ )
+ if close_idx is not None:
+ # Keep whatever trails the ``---`` marker on the closing
+ # line (normally just the newline) so the body stays
+ # byte-for-byte identical to ``split("---", 2)[2]``. The
+ # line-anchored check guarantees ``---`` sits at index 0.
+ processed_body = body_lines[close_idx][3:] + "".join(
+ body_lines[close_idx + 1 :]
+ )
# Select description ā use the original template description
# to stay byte-for-byte identical with release ZIP output.
@@ -1578,4 +1798,10 @@ def setup(
created.append(dst)
+ # Install agent runtime events
+ event_files = self.emit_events(
+ project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
+ )
+ created.extend(event_files)
+
return created
diff --git a/src/specify_cli/integrations/bob/__init__.py b/src/specify_cli/integrations/bob/__init__.py
index b953151bd2..0d1f26dc29 100644
--- a/src/specify_cli/integrations/bob/__init__.py
+++ b/src/specify_cli/integrations/bob/__init__.py
@@ -1,10 +1,105 @@
-"""IBM Bob integration."""
+"""IBM Bob integration.
-from ..base import MarkdownIntegration
+Bob 2.0 uses the ``.bob/skills/speckit-/SKILL.md`` layout by default.
+The legacy ``.bob/commands/*.md`` layout (Bob 1.x) remains available as an
+opt-in via ``--integration-options "--legacy-commands"``.
+Bob is a *dual-mode* integration: whether it scaffolds skills or commands is
+a per-project **configuration** decision (the ``--legacy-commands`` option,
+persisted as ``ai_skills`` in init-options), not a property of the class.
+It therefore extends :class:`IntegrationBase` (like Copilot, the other
+dual-mode agent) and resolves the mode through the ``is_skills_mode`` hook,
+delegating the actual scaffolding to a per-layout helper.
+
+Deprecation cycle:
+ This release: Skills layout is the default; legacy ``.bob/commands/`` is
+ opt-in via ``--legacy-commands``.
+ Next cycle: ``--legacy-commands`` flag removed.
+"""
+
+from __future__ import annotations
+
+import warnings
+from pathlib import Path
+from typing import Any
+
+import typer
+
+from ..base import (
+ IntegrationBase,
+ IntegrationOption,
+ MarkdownIntegration,
+ SkillsIntegration,
+)
+from ..manifest import IntegrationManifest
+
+
+def _validate_mode_options(parsed_options: dict[str, Any] | None) -> None:
+ """Reject ``--skills`` and ``--legacy-commands`` used together.
+
+ The two flags select opposite layouts, so combining them is ambiguous.
+ Fail fast with the same clean exit-1 UX as other bad-option paths rather
+ than silently letting one win.
+ """
+ opts = parsed_options or {}
+ if opts.get("skills") and opts.get("legacy_commands"):
+ from ..._console import console
+
+ console.print(
+ "[red]Error:[/red] --skills and --legacy-commands are mutually "
+ "exclusive; pass only one."
+ )
+ raise typer.Exit(1)
+
+
+def _warn_legacy_commands_deprecated() -> None:
+ warnings.warn(
+ "Bob legacy commands mode (.bob/commands/) is deprecated and will be "
+ "removed in a future Spec Kit release. Omit --legacy-commands to use "
+ "the default skills layout (.bob/skills/).",
+ UserWarning,
+ stacklevel=3,
+ )
+
+
+class _BobSkillsHelper(SkillsIntegration):
+ """Default-mode helper: ``.bob/skills/speckit-/SKILL.md``.
+
+ Not registered in the integration registry ā used only as a delegate by
+ :class:`BobIntegration` for skills-mode ``setup()``.
+ """
-class BobIntegration(MarkdownIntegration):
key = "bob"
+ config = {
+ "name": "IBM Bob",
+ "folder": ".bob/",
+ "commands_subdir": "skills",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": ".bob/skills",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": "/SKILL.md",
+ }
+
+ def post_process_skill_content(self, content: str) -> str:
+ """Bob skills are intent-activated; no slash-command note is needed."""
+ return content
+
+
+class _BobMarkdownHelper(MarkdownIntegration):
+ """Legacy-mode helper: ``.bob/commands/speckit..md`` (Bob 1.x).
+
+ Not registered in the integration registry ā used only as a delegate by
+ :class:`BobIntegration` when ``--legacy-commands`` is passed. Declares
+ ``invoke_separator="."`` so command-reference tokens render as Bob 1.x
+ ``/speckit.`` invocations.
+ """
+
+ key = "bob"
+ invoke_separator = "."
config = {
"name": "IBM Bob",
"folder": ".bob/",
@@ -17,4 +112,172 @@ class BobIntegration(MarkdownIntegration):
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
+ "invoke_separator": ".",
}
+
+
+class BobIntegration(IntegrationBase):
+ """Integration for IBM Bob IDE (dual-mode; skills by default).
+
+ Whether a project uses the skills or the legacy commands layout is a
+ configuration choice resolved by :meth:`is_skills_mode`, not the class
+ hierarchy. ``setup()`` delegates to the matching helper.
+
+ ``registrar_config`` mirrors the *commands* layout (``extension: ".md"``,
+ ``dir: ".bob/commands"``) ā the same pattern Copilot uses ā so that
+ ``CommandRegistrar.AGENT_CONFIGS["bob"]`` drives extension/preset
+ registration into ``.bob/commands/`` for legacy-mode projects, while
+ skills-mode projects have that command registration transparently skipped
+ (``skills_mode_active`` becomes ``True`` because ``ai_skills=True`` and
+ ``extension != "/SKILL.md"``) and receive extension skills instead.
+ ``invoke_separator = "-"`` matches the default (skills) layout.
+ """
+
+ key = "bob"
+ invoke_separator = "-"
+ config = {
+ "name": "IBM Bob",
+ "folder": ".bob/",
+ "commands_subdir": "commands",
+ "install_url": None,
+ "requires_cli": False,
+ }
+ registrar_config = {
+ "dir": ".bob/commands",
+ "format": "markdown",
+ "args": "$ARGUMENTS",
+ "extension": ".md",
+ }
+
+ @classmethod
+ def options(cls) -> list[IntegrationOption]:
+ return [
+ IntegrationOption(
+ "--skills",
+ is_flag=True,
+ default=False,
+ help=(
+ "Force the default skills layout (.bob/skills/), overriding "
+ "on-disk auto-detection. Use this to migrate a legacy "
+ "commands install to skills, e.g. "
+ "`integration upgrade bob --integration-options \"--skills\"`"
+ ),
+ ),
+ IntegrationOption(
+ "--legacy-commands",
+ is_flag=True,
+ default=False,
+ help=(
+ "Scaffold commands as legacy .bob/commands/*.md files "
+ "(Bob 1.x layout, deprecated) instead of the default "
+ "skills layout"
+ ),
+ ),
+ ]
+
+ def is_skills_mode(
+ self,
+ parsed_options: dict[str, Any] | None = None,
+ project_root: Path | None = None,
+ ) -> bool:
+ """Bob is skills-first; ``--legacy-commands`` opts out.
+
+ Precedence:
+
+ 1. Explicit ``--skills`` wins ā it *forces* skills mode regardless of
+ what is already on disk. This is the supported migration / opt-in
+ path: ``integration upgrade bob --integration-options "--skills"``
+ converts a legacy commands install to the skills layout (setup()
+ scaffolds ``.bob/skills`` and the upgrade's stale-file pass removes
+ the old ``.bob/commands`` files).
+ 2. Explicit ``--legacy-commands`` opts out to the Bob 1.x layout.
+ 3. Otherwise, when a *project_root* is supplied, the layout is inferred
+ from **managed Spec Kit artifacts** (see below).
+ 4. A fresh project (no managed artifacts, no flags) defaults to skills.
+
+ The disk-detection fallback exists because on ``use`` / ``switch`` /
+ ``upgrade`` (without an explicit ``--skills`` / ``--legacy-commands``)
+ *parsed_options* is typically empty: no flag was passed, and existing
+ Bob 1.x installs never persisted a ``legacy_commands`` option to
+ recover. This is independent of whether ``setup()`` runs ā ``upgrade``
+ *does* call :meth:`setup` (see ``_migrate_commands.integration_upgrade``),
+ but it passes those same empty *parsed_options*, so without disk
+ detection the mode would resolve to the skills default. Defaulting to
+ skills there would rewrite such a project's ``ai_skills`` flag to
+ ``True`` even though it still only contains a command layout, silently
+ switching its extension / command-reference handling. So the layout is
+ inferred from managed Spec Kit artifacts, not the mere presence of a
+ ``.bob/skills/`` directory: a user may keep unrelated Bob 2 skills in
+ ``.bob/skills/`` while their Spec Kit commands still live in
+ ``.bob/commands/speckit.*.md``. We therefore treat the project as
+ legacy (command) mode only when managed Spec Kit command files exist
+ and no managed Spec Kit skills (``speckit-*`` skill dirs) do. Passing
+ ``--skills`` overrides this so users are never trapped in legacy mode.
+ """
+ opts = parsed_options or {}
+ _validate_mode_options(opts)
+ if opts.get("skills", False):
+ return True
+ if opts.get("legacy_commands", False):
+ return False
+ if project_root is not None:
+ bob_dir = Path(project_root) / ".bob"
+ has_managed_skills = any((bob_dir / "skills").glob("speckit-*"))
+ has_managed_commands = any((bob_dir / "commands").glob("speckit.*.md"))
+ if has_managed_commands and not has_managed_skills:
+ return False
+ return True
+
+ def effective_invoke_separator(
+ self,
+ parsed_options: dict[str, Any] | None = None,
+ project_root: Path | None = None,
+ ) -> str:
+ """``"."`` for the legacy commands layout, ``"-"`` for skills.
+
+ *project_root* lets the ``use`` / ``switch`` / ``upgrade`` path ā which
+ refreshes shared infrastructure *before* persisting init-options ā
+ detect an already-installed legacy layout, so core command references
+ are rendered with the correct separator instead of defaulting to the
+ skills ``-``.
+ """
+ return "-" if self.is_skills_mode(parsed_options, project_root) else "."
+
+ def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
+ """Resolve the command-ref separator from a project's persisted mode.
+
+ Skills projects render ``/speckit-``; legacy command projects
+ render Bob 1.x ``/speckit.``. Extension/preset registration
+ consults this (via the persisted ``ai_skills`` flag) so both layouts
+ get the correct separator despite sharing one static ``AGENT_CONFIGS``
+ entry.
+ """
+ return "-" if skills_enabled else "."
+
+ def post_process_skill_content(self, content: str) -> str:
+ """Bob skills are intent-activated; no slash-command note is injected.
+
+ Preset/extension skill generators call this on the *registered*
+ ``BobIntegration`` instance, not on :class:`_BobSkillsHelper`, so the
+ no-op must be repeated here (delegating to the helper) ā otherwise
+ those paths would inherit ``IntegrationBase``'s default and inject
+ ``/speckit-*`` hook guidance that core Bob skills intentionally omit.
+ """
+ return _BobSkillsHelper().post_process_skill_content(content)
+
+ def setup(
+ self,
+ project_root: Path,
+ manifest: IntegrationManifest,
+ parsed_options: dict[str, Any] | None = None,
+ **opts: Any,
+ ) -> list[Path]:
+ parsed_options = parsed_options or {}
+ if self.is_skills_mode(parsed_options, project_root):
+ return _BobSkillsHelper().setup(
+ project_root, manifest, parsed_options, **opts
+ )
+ _warn_legacy_commands_deprecated()
+ return MarkdownIntegration.setup(
+ _BobMarkdownHelper(), project_root, manifest, parsed_options, **opts
+ )
diff --git a/src/specify_cli/integrations/catalog.py b/src/specify_cli/integrations/catalog.py
index aba5877d8f..1794caad83 100644
--- a/src/specify_cli/integrations/catalog.py
+++ b/src/specify_cli/integrations/catalog.py
@@ -21,6 +21,7 @@
import yaml
from packaging import version as pkg_version
+from .._download_security import MAX_JSON_METADATA_BYTES, read_response_limited
from ..catalogs import CatalogEntry, CatalogStackBase
@@ -40,6 +41,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 +173,18 @@ def _fetch_single_catalog(
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:
@@ -170,22 +201,19 @@ def _fetch_single_catalog(
final_url = resp.geturl()
if final_url != entry.url:
self._validate_catalog_url(final_url)
- catalog_data = json.loads(resp.read())
-
- if not isinstance(catalog_data, dict):
- 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}"
+ catalog_data = json.loads(
+ read_response_limited(
+ resp,
+ max_bytes=MAX_JSON_METADATA_BYTES,
+ error_type=IntegrationCatalogError,
+ label=f"catalog from {entry.url}",
+ )
)
- if not isinstance(catalog_data.get("integrations"), dict):
+
+ shape_error = _catalog_shape_error(catalog_data)
+ if shape_error is not None:
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:
@@ -429,7 +457,8 @@ def add_catalog(self, url: str, name: Optional[str] = None) -> None:
)
try:
normalized_priority = int(raw_priority)
- except (TypeError, ValueError):
+ except (TypeError, ValueError, OverflowError):
+ # OverflowError: int(float("inf")) ā a ``priority: .inf``.
raise IntegrationValidationError(
f"Invalid catalog entry at index {idx} in {config_path}: "
f"'priority' must be an integer, got "
@@ -537,7 +566,8 @@ def _is_removable_catalog_entry(item: Any) -> bool:
else:
try:
priority = int(raw_priority)
- except (TypeError, ValueError):
+ except (TypeError, ValueError, OverflowError):
+ # OverflowError: int(float("inf")) ā a ``priority: .inf``.
priority = yaml_idx + 1
priority_pairs.append((priority, yaml_idx))
if not priority_pairs:
diff --git a/src/specify_cli/integrations/claude/__init__.py b/src/specify_cli/integrations/claude/__init__.py
index 923a77607a..39732794af 100644
--- a/src/specify_cli/integrations/claude/__init__.py
+++ b/src/specify_cli/integrations/claude/__init__.py
@@ -54,6 +54,17 @@ class ClaudeIntegration(SkillsIntegration):
}
multi_install_safe = True
+ CANONICAL_TO_NATIVE = {
+ "session_start": "SessionStart",
+ "pre_tool_use": "PreToolUse",
+ "post_tool_use": "PostToolUse",
+ "session_end": "SessionEnd",
+ "user_prompt_submit": "UserPromptSubmit",
+ "stop": "Stop",
+ }
+ events_config_file = ".claude/settings.json"
+ events_format = "json-nested"
+
@staticmethod
def inject_argument_hint(content: str, hint: str) -> str:
"""Insert ``argument-hint`` after the first ``description:`` in YAML frontmatter.
diff --git a/src/specify_cli/integrations/cline/__init__.py b/src/specify_cli/integrations/cline/__init__.py
index a9b43e99ad..c3ea3cc409 100644
--- a/src/specify_cli/integrations/cline/__init__.py
+++ b/src/specify_cli/integrations/cline/__init__.py
@@ -77,6 +77,19 @@ def command_filename(self, template_name: str) -> str:
"""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-``), so the
+ dispatch invocation must match. The inherited MarkdownIntegration default
+ builds the dotted ``/speckit.``, 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)
@@ -125,8 +138,14 @@ def _rewrite_handoff_references(content: str) -> str:
content,
)
- def post_process_content(self, content: str) -> str:
- """Apply Cline-specific transformations to command content."""
+ def post_process_command_content(self, content: str) -> str:
+ """Apply Cline-specific transformations to command content.
+
+ Overrides the ``IntegrationBase`` hook of the same name so that
+ ``CommandRegistrar.register_commands()`` (which dispatches to
+ ``post_process_command_content``) applies these transforms to
+ extension/preset command files too, not just core commands.
+ """
updated = self._inject_hook_command_note(content)
updated = self._rewrite_handoff_references(updated)
return updated
@@ -156,7 +175,7 @@ def setup(
content_bytes = path.read_bytes()
content = content_bytes.decode("utf-8")
- updated = self.post_process_content(content)
+ updated = self.post_process_command_content(content)
if updated != content:
path.write_bytes(updated.encode("utf-8"))
diff --git a/src/specify_cli/integrations/codex/__init__.py b/src/specify_cli/integrations/codex/__init__.py
index 7d1ff86e27..2ffa59ca4b 100644
--- a/src/specify_cli/integrations/codex/__init__.py
+++ b/src/specify_cli/integrations/codex/__init__.py
@@ -29,6 +29,17 @@ class CodexIntegration(SkillsIntegration):
dev_no_symlink = True
multi_install_safe = True
+ CANONICAL_TO_NATIVE = {
+ "session_start": "SessionStart",
+ "pre_tool_use": "PreToolUse",
+ "post_tool_use": "PostToolUse",
+ "session_end": "SessionEnd",
+ "user_prompt_submit": "UserPromptSubmit",
+ "stop": "Stop",
+ }
+ events_config_file = ".codex/config.toml"
+ events_format = "toml"
+
def build_exec_args(
self,
prompt: str,
@@ -49,11 +60,13 @@ def build_exec_args(
@classmethod
def options(cls) -> list[IntegrationOption]:
- return [
+ opts = super().options()
+ opts.append(
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Codex)",
- ),
- ]
+ )
+ )
+ return opts
diff --git a/src/specify_cli/integrations/copilot/__init__.py b/src/specify_cli/integrations/copilot/__init__.py
index 44bd47f353..e6f86e8991 100644
--- a/src/specify_cli/integrations/copilot/__init__.py
+++ b/src/specify_cli/integrations/copilot/__init__.py
@@ -118,11 +118,26 @@ class CopilotIntegration(IntegrationBase):
"extension": ".agent.md",
}
+ CANONICAL_TO_NATIVE = {
+ "session_start": "sessionStart",
+ "pre_tool_use": "preToolUse",
+ "post_tool_use": "postToolUse",
+ "session_end": "sessionEnd",
+ "user_prompt_submit": "userPromptSubmitted",
+ # Copilot CLI supports the canonical per-turn stop lifecycle as native
+ # agentStop (U3); mapping it so an extension's stop handler fires.
+ "stop": "agentStop",
+ }
+ events_config_file = ".github/hooks/speckit.json"
+ events_format = "copilot-json"
+
# Mutable flag set by setup() ā indicates the active scaffolding mode.
_skills_mode: bool = False
def effective_invoke_separator(
- self, parsed_options: dict[str, Any] | None = None
+ self,
+ parsed_options: dict[str, Any] | None = None,
+ project_root: Path | None = None,
) -> str:
"""Return ``"-"`` when skills mode is requested, ``"."`` otherwise."""
if parsed_options and parsed_options.get("skills"):
@@ -131,16 +146,48 @@ def effective_invoke_separator(
return "-"
return self.invoke_separator
+ def is_skills_mode(
+ self,
+ parsed_options: dict[str, Any] | None = None,
+ project_root: Path | None = None,
+ ) -> bool:
+ """Copilot is skills mode when ``--skills`` was requested.
+
+ On the init path ``setup()`` has already recorded the choice in
+ ``self._skills_mode``; on the ``use``/``install`` path (where no
+ ``setup()`` runs) the signal comes from *parsed_options* (#3550), which
+ round-trips because ``--skills`` is persisted in the stored options.
+ """
+ if parsed_options and parsed_options.get("skills"):
+ return True
+ return self._skills_mode
+
+ def invoke_separator_for_mode(self, skills_enabled: bool) -> str:
+ """Skills projects render ``/speckit-``; default markdown ``.``.
+
+ Copilot is dual-layout, so ā like Bob ā the command-reference
+ separator depends on the persisted ``ai_skills`` state rather than a
+ single static value. This keeps preset/extension command refs in a
+ Copilot skills project consistent with ``build_command_invocation``
+ (which emits ``/speckit-``).
+ """
+ return "-" if skills_enabled else self.invoke_separator
+
@classmethod
def options(cls) -> list[IntegrationOption]:
- return [
+ # Compose with super() so the base class declares --events for this
+ # event-capable integration; otherwise --integration-options
+ # "--events false" is rejected as unknown (#9).
+ opts = super().options()
+ opts.append(
IntegrationOption(
"--skills",
is_flag=True,
default=False,
help="Scaffold commands as agent skills (speckit-/SKILL.md) instead of .agent.md files",
),
- ]
+ )
+ return opts
def _resolve_executable(self) -> str:
"""Return the Copilot CLI executable, respecting the env-var override.
@@ -299,7 +346,9 @@ def stale_cleanup_exclusions(self) -> set[str]:
be flagged stale and deleted, destroying user settings (and the file
the integration still manages).
"""
- return {".vscode/settings.json"}
+ exclusions = super().stale_cleanup_exclusions()
+ exclusions.add(".vscode/settings.json")
+ return exclusions
def post_process_skill_content(self, content: str) -> str:
"""Inject shared hook guidance into Copilot skill content.
@@ -326,10 +375,18 @@ def setup(
parsed_options = parsed_options or {}
self._skills_mode = bool(parsed_options.get("skills"))
if self._skills_mode:
- return self._setup_skills(project_root, manifest, parsed_options, **opts)
- if "skills" not in parsed_options:
- _warn_legacy_markdown_default()
- return self._setup_default(project_root, manifest, parsed_options, **opts)
+ created = self._setup_skills(project_root, manifest, parsed_options, **opts)
+ else:
+ if "skills" not in parsed_options:
+ _warn_legacy_markdown_default()
+ created = self._setup_default(project_root, manifest, parsed_options, **opts)
+
+ # Install agent runtime events
+ event_files = self.emit_events(
+ project_root, manifest, events=opts.get("events"), parsed_options=parsed_options
+ )
+ created.extend(event_files)
+ return created
def _setup_default(
self,
@@ -350,6 +407,10 @@ def _setup_default(
if not templates:
return []
+ from ...presets import PresetResolver
+
+ preset_resolver = PresetResolver(project_root_resolved)
+
dest = self.commands_dest(project_root)
dest_resolved = dest.resolve()
try:
@@ -367,7 +428,11 @@ def _setup_default(
# 1. Process and write command files as .agent.md
for src_file in templates:
- raw = src_file.read_text(encoding="utf-8")
+ resolved_template = preset_resolver.resolve(
+ f"speckit.{src_file.stem}", template_type="command"
+ )
+ source_path = resolved_template or src_file
+ raw = source_path.read_text(encoding="utf-8")
processed = self.process_template(
raw, self.key, script_type, arg_placeholder,
project_root=project_root,
@@ -460,7 +525,7 @@ def _merge_vscode_settings(src: Path, dst: Path) -> None:
"""
try:
existing = json.loads(dst.read_text(encoding="utf-8"))
- except (json.JSONDecodeError, OSError):
+ except (json.JSONDecodeError, UnicodeDecodeError, OSError):
# Cannot parse existing file (likely JSONC with comments).
# Skip merge to preserve the user's settings, but show
# what they should add manually.
diff --git a/src/specify_cli/integrations/cursor_agent/__init__.py b/src/specify_cli/integrations/cursor_agent/__init__.py
index 07f2a6318b..58bd89b21f 100644
--- a/src/specify_cli/integrations/cursor_agent/__init__.py
+++ b/src/specify_cli/integrations/cursor_agent/__init__.py
@@ -38,6 +38,17 @@ class CursorAgentIntegration(SkillsIntegration):
multi_install_safe = True
+ CANONICAL_TO_NATIVE = {
+ "session_start": "sessionStart",
+ "pre_tool_use": "preToolUse",
+ "post_tool_use": "postToolUse",
+ "session_end": "sessionEnd",
+ "user_prompt_submit": "beforeSubmitPrompt",
+ "stop": "stop",
+ }
+ events_config_file = ".cursor/hooks.json"
+ events_format = "json-flat"
+
def build_exec_args(
self,
prompt: str,
@@ -92,11 +103,13 @@ def build_exec_args(
@classmethod
def options(cls) -> list[IntegrationOption]:
- return [
+ opts = super().options()
+ opts.append(
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (recommended for Cursor)",
- ),
- ]
+ )
+ )
+ return opts
diff --git a/src/specify_cli/integrations/devin/__init__.py b/src/specify_cli/integrations/devin/__init__.py
index 0d60bc954d..dea6b5d228 100644
--- a/src/specify_cli/integrations/devin/__init__.py
+++ b/src/specify_cli/integrations/devin/__init__.py
@@ -31,6 +31,20 @@ class DevinIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
+ CANONICAL_TO_NATIVE = {
+ "session_start": "SessionStart",
+ "pre_tool_use": "PreToolUse",
+ "post_tool_use": "PostToolUse",
+ "session_end": "SessionEnd",
+ "user_prompt_submit": "UserPromptSubmit",
+ "stop": "Stop",
+ }
+ events_config_file = ".devin/hooks.v1.json"
+ # Devin's hooks.v1.json is a root event map ({"PreToolUse": [...]}) with no
+ # top-level "hooks" wrapper (U2), unlike the settings.json formats. The
+ # json-root-nested writer/remover operate directly on the root event keys.
+ events_format = "json-root-nested"
+
def build_exec_args(
self,
prompt: str,
@@ -55,11 +69,16 @@ def build_exec_args(
@classmethod
def options(cls) -> list[IntegrationOption]:
- return [
+ # Compose with super() so the base class declares --events for this
+ # event-capable integration; otherwise --integration-options
+ # "--events false" is rejected as unknown (#8).
+ opts = super().options()
+ opts.append(
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Devin)",
),
- ]
+ )
+ return opts
diff --git a/src/specify_cli/integrations/droid/__init__.py b/src/specify_cli/integrations/droid/__init__.py
new file mode 100644
index 0000000000..d6a5c084ae
--- /dev/null
+++ b/src/specify_cli/integrations/droid/__init__.py
@@ -0,0 +1,135 @@
+"""Factory Droid CLI integration ā skills-based agent.
+
+Droid discovers project skills from
+``.factory/skills/speckit-/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-`` invocations, not dotted ``/speckit.``)
+ 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 ""`` 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 ``. 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
diff --git a/src/specify_cli/integrations/forge/__init__.py b/src/specify_cli/integrations/forge/__init__.py
index 49407c3a7a..f455556e36 100644
--- a/src/specify_cli/integrations/forge/__init__.py
+++ b/src/specify_cli/integrations/forge/__init__.py
@@ -91,6 +91,18 @@ class ForgeIntegration(MarkdownIntegration):
}
invoke_separator = "-"
+ def build_command_invocation(self, command_name: str, args: str = "") -> str:
+ """Forge installs hyphenated slash-commands (``/speckit-``), so the
+ dispatch invocation must match. The inherited MarkdownIntegration default
+ builds the dotted ``/speckit.``, which references a command Forge
+ never registered. Reuse the same hyphenation as the installed frontmatter
+ ``name`` (see ``format_forge_command_name``), mirroring the skills agents.
+ """
+ invocation = "/" + format_forge_command_name(command_name)
+ if args:
+ invocation = f"{invocation} {args}"
+ return invocation
+
def setup(
self,
project_root: Path,
diff --git a/src/specify_cli/integrations/gemini/__init__.py b/src/specify_cli/integrations/gemini/__init__.py
index 9a459862af..2200e707c8 100644
--- a/src/specify_cli/integrations/gemini/__init__.py
+++ b/src/specify_cli/integrations/gemini/__init__.py
@@ -19,3 +19,22 @@ class GeminiIntegration(TomlIntegration):
"extension": ".toml",
}
multi_install_safe = True
+
+ CANONICAL_TO_NATIVE = {
+ "session_start": "SessionStart",
+ "pre_tool_use": "BeforeTool",
+ "post_tool_use": "AfterTool",
+ "session_end": "SessionEnd",
+ # Gemini exposes BeforeAgent for the per-turn prompt-submit lifecycle
+ # point (S6); its own Claude-hook migration maps UserPromptSubmit to
+ # BeforeAgent. Mapping it so extension handlers fire.
+ "user_prompt_submit": "BeforeAgent",
+ "stop": "AfterAgent",
+ }
+ events_config_file = ".gemini/settings.json"
+ events_format = "json-nested"
+ # Gemini measures hook timeouts in milliseconds, unlike Claude/Cursor/Codex
+ # which use seconds. The shared formatter converts via _native_timeout (#7)
+ # so the default 60s becomes 60000ms instead of terminating the dispatcher
+ # after 60ms.
+ events_timeout_unit = "ms"
diff --git a/src/specify_cli/integrations/generic/__init__.py b/src/specify_cli/integrations/generic/__init__.py
index a2fd430f75..f8ea47ccb2 100644
--- a/src/specify_cli/integrations/generic/__init__.py
+++ b/src/specify_cli/integrations/generic/__init__.py
@@ -53,8 +53,16 @@ def _resolve_commands_dir(
"""
parsed_options = parsed_options or {}
+ # Accept a value only when it is non-BLANK. An empty value resolves to
+ # the project root (``project_root / ""``) and a whitespace-only one to
+ # a directory literally named " ", so either would silently scatter
+ # command files instead of failing with the documented "required"
+ # error. ``strip()`` is used ONLY to decide blankness -- the value
+ # itself is returned verbatim, so a deliberate (if unusual) padded
+ # directory name still targets exactly what the user asked for. Both
+ # branches below apply the same rule so they cannot drift apart.
commands_dir = parsed_options.get("commands_dir")
- if commands_dir:
+ if commands_dir and (not isinstance(commands_dir, str) or commands_dir.strip()):
return commands_dir
# Fall back to raw_options (--integration-options="--commands-dir ...")
@@ -64,9 +72,13 @@ def _resolve_commands_dir(
tokens = shlex.split(raw)
for i, token in enumerate(tokens):
if token == "--commands-dir" and i + 1 < len(tokens):
- return tokens[i + 1]
+ candidate = tokens[i + 1]
+ if candidate.strip():
+ return candidate
if token.startswith("--commands-dir="):
- return token.split("=", 1)[1]
+ candidate = token.split("=", 1)[1]
+ if candidate.strip():
+ return candidate
raise ValueError(
"--commands-dir is required for the generic integration"
diff --git a/src/specify_cli/integrations/kilocode/__init__.py b/src/specify_cli/integrations/kilocode/__init__.py
index 0924843286..00a832fe71 100644
--- a/src/specify_cli/integrations/kilocode/__init__.py
+++ b/src/specify_cli/integrations/kilocode/__init__.py
@@ -7,13 +7,14 @@ class KilocodeIntegration(MarkdownIntegration):
key = "kilocode"
config = {
"name": "Kilo Code",
- "folder": ".kilocode/",
- "commands_subdir": "workflows",
+ "folder": ".kilo/",
+ "commands_subdir": "commands",
"install_url": None,
"requires_cli": False,
}
registrar_config = {
- "dir": ".kilocode/workflows",
+ "dir": ".kilo/commands",
+ "legacy_dir": ".kilocode/workflows",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
diff --git a/src/specify_cli/integrations/kimi/__init__.py b/src/specify_cli/integrations/kimi/__init__.py
index 3320935a03..2b3d409b6f 100644
--- a/src/specify_cli/integrations/kimi/__init__.py
+++ b/src/specify_cli/integrations/kimi/__init__.py
@@ -59,8 +59,7 @@ def build_command_invocation(self, command_name: str, args: str = "") -> str:
def post_process_skill_content(self, content: str) -> str:
"""Ensure in-skill cross-command references use Kimi's `/skill:` syntax."""
- content = super().post_process_skill_content(content)
- return content.replace("/speckit-", "/skill:speckit-")
+ return super().post_process_skill_content(content)
@classmethod
def options(cls) -> list[IntegrationOption]:
diff --git a/src/specify_cli/integrations/kiro_cli/__init__.py b/src/specify_cli/integrations/kiro_cli/__init__.py
index 37f743d5c2..4c90d030a1 100644
--- a/src/specify_cli/integrations/kiro_cli/__init__.py
+++ b/src/specify_cli/integrations/kiro_cli/__init__.py
@@ -13,6 +13,13 @@
class KiroCliIntegration(MarkdownIntegration):
key = "kiro-cli"
+ # Kiro CLI keeps everything under a static, isolated agent root
+ # (``.kiro/`` with commands in ``.kiro/prompts``) that no other
+ # integration writes to, so it is safe to install alongside others
+ # (issue #3471). IntegrationBase defaults this to False; declaring it
+ # True here is the actual behavior change this integration opts into.
+ # The registry's multi-install-safe contract tests enforce that
+ # isolation for every integration setting this flag.
multi_install_safe = True
config = {
"name": "Kiro CLI",
@@ -27,10 +34,3 @@ class KiroCliIntegration(MarkdownIntegration):
"args": _KIRO_ARG_FALLBACK,
"extension": ".md",
}
-
- # Kiro CLI keeps everything under a static, isolated agent root
- # (``.kiro/`` with commands in ``.kiro/prompts``) that no other
- # integration writes to, so it is safe to install alongside others
- # (issue #3471). The registry's multi-install-safe contract tests
- # enforce that isolation for every integration setting this flag.
- multi_install_safe = True
diff --git a/src/specify_cli/integrations/lingma/__init__.py b/src/specify_cli/integrations/lingma/__init__.py
index 2cb74b2192..0d2fa824aa 100644
--- a/src/specify_cli/integrations/lingma/__init__.py
+++ b/src/specify_cli/integrations/lingma/__init__.py
@@ -27,6 +27,7 @@ class LingmaIntegration(SkillsIntegration):
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
+ multi_install_safe = True
@classmethod
def options(cls) -> list[IntegrationOption]:
diff --git a/src/specify_cli/integrations/manifest.py b/src/specify_cli/integrations/manifest.py
index 8c98243c9a..ac799ebee6 100644
--- a/src/specify_cli/integrations/manifest.py
+++ b/src/specify_cli/integrations/manifest.py
@@ -327,12 +327,18 @@ def uninstall(
project_root: Path | None = None,
*,
force: bool = False,
+ remove_manifest: bool = True,
) -> tuple[list[Path], list[Path]]:
"""Remove tracked files whose hash still matches.
Parameters:
- project_root: Override for the project root.
- force: If ``True``, remove files even if modified.
+ project_root: Override for the project root.
+ force: If ``True``, remove files even if modified.
+ remove_manifest: If ``True`` (default), also delete this
+ integration's ``{key}.manifest.json``. Set ``False`` for
+ *partial* cleanups (e.g. the upgrade stale-file pass, which
+ builds a throwaway manifest over a subset of files) so the
+ real, freshly-saved manifest for the same key is not destroyed.
Returns:
``(removed, skipped)`` ā absolute paths.
@@ -393,8 +399,20 @@ def uninstall(
# Remove the manifest file itself
manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json"
- if manifest.exists():
- manifest.unlink()
+ if remove_manifest and manifest.exists():
+ try:
+ manifest.unlink()
+ except OSError:
+ # An undeletable manifest (read-only file, a directory left at
+ # the path, a Windows lock) must not abort the uninstall after
+ # the tracked files were already removed: the caller would lose
+ # the (removed, skipped) result and never run its post-uninstall
+ # bookkeeping. Report it like any other file we could not
+ # remove, mirroring the path.unlink() guard above. The
+ # empty-parent cleanup below is left unconditional: with the
+ # manifest still on disk its parent is non-empty, so the first
+ # rmdir() raises and breaks immediately.
+ skipped.append(manifest)
parent = manifest.parent
while parent != root:
try:
diff --git a/src/specify_cli/integrations/omp/__init__.py b/src/specify_cli/integrations/omp/__init__.py
index 1565832989..0a93237d4f 100644
--- a/src/specify_cli/integrations/omp/__init__.py
+++ b/src/specify_cli/integrations/omp/__init__.py
@@ -20,6 +20,7 @@ class OmpIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
+ multi_install_safe = True
def build_exec_args(
self,
diff --git a/src/specify_cli/integrations/opencode/__init__.py b/src/specify_cli/integrations/opencode/__init__.py
index 0f734b7f41..660fd0b5fa 100644
--- a/src/specify_cli/integrations/opencode/__init__.py
+++ b/src/specify_cli/integrations/opencode/__init__.py
@@ -20,6 +20,15 @@ class OpencodeIntegration(MarkdownIntegration):
"extension": ".md",
}
+ CANONICAL_TO_NATIVE = {
+ "pre_tool_use": "tool.execute.before",
+ "post_tool_use": "tool.execute.after",
+ "session_start": "session.created",
+ "session_end": "session.deleted",
+ }
+ events_config_file = "opencode.json"
+ events_format = "ts-plugin"
+
def build_exec_args(
self,
prompt: str,
diff --git a/src/specify_cli/integrations/pi/__init__.py b/src/specify_cli/integrations/pi/__init__.py
index ceff628bdb..a43ee0c75f 100644
--- a/src/specify_cli/integrations/pi/__init__.py
+++ b/src/specify_cli/integrations/pi/__init__.py
@@ -18,3 +18,4 @@ class PiIntegration(MarkdownIntegration):
"args": "$ARGUMENTS",
"extension": ".md",
}
+ multi_install_safe = True
diff --git a/src/specify_cli/integrations/qwen/__init__.py b/src/specify_cli/integrations/qwen/__init__.py
index 1e8c15bf91..7ab55d978b 100644
--- a/src/specify_cli/integrations/qwen/__init__.py
+++ b/src/specify_cli/integrations/qwen/__init__.py
@@ -19,3 +19,20 @@ class QwenIntegration(MarkdownIntegration):
"extension": ".md",
}
multi_install_safe = True
+
+ CANONICAL_TO_NATIVE = {
+ "session_start": "SessionStart",
+ "pre_tool_use": "PreToolUse",
+ "post_tool_use": "PostToolUse",
+ "session_end": "SessionEnd",
+ "user_prompt_submit": "UserPromptSubmit",
+ "stop": "Stop",
+ }
+ events_config_file = ".qwen/settings.json"
+ events_format = "json-nested"
+ # Qwen Code's command hooks measure timeout in milliseconds (default
+ # 60000), per the Qwen Code hooks documentation. Declaring the unit makes
+ # the shared formatter convert the 60s default to 60000ms instead of
+ # emitting timeout: 60 (60 ms), which would terminate the dispatcher
+ # before it starts (U1).
+ events_timeout_unit = "ms"
diff --git a/src/specify_cli/integrations/tabnine/__init__.py b/src/specify_cli/integrations/tabnine/__init__.py
index 9edf1e1607..5e8a803e6c 100644
--- a/src/specify_cli/integrations/tabnine/__init__.py
+++ b/src/specify_cli/integrations/tabnine/__init__.py
@@ -19,3 +19,23 @@ class TabnineIntegration(TomlIntegration):
"extension": ".toml",
}
multi_install_safe = True
+
+ CANONICAL_TO_NATIVE = {
+ "session_start": "SessionStart",
+ "pre_tool_use": "BeforeTool",
+ "post_tool_use": "AfterTool",
+ "session_end": "SessionEnd",
+ # Tabnine's Gemini-compatible schema also provides BeforeAgent and
+ # AfterAgent (S7); mapping them so user_prompt_submit and stop
+ # extension handlers fire instead of being skipped.
+ "user_prompt_submit": "BeforeAgent",
+ "stop": "AfterAgent",
+ }
+ events_config_file = ".tabnine/agent/settings.json"
+ events_format = "json-nested"
+ # Tabnine mirrors Gemini's hook schema (BeforeTool/AfterTool) and, like
+ # Gemini, measures hook timeouts in milliseconds. Declaring the unit makes
+ # the shared formatter convert the 60s default to 60000ms instead of
+ # emitting timeout: 60 (60 ms), which would terminate the dispatcher
+ # before it starts (R5).
+ events_timeout_unit = "ms"
diff --git a/src/specify_cli/presets/__init__.py b/src/specify_cli/presets/__init__.py
index 97e15aa648..9461e4fc69 100644
--- a/src/specify_cli/presets/__init__.py
+++ b/src/specify_cli/presets/__init__.py
@@ -12,11 +12,10 @@
import hashlib
import os
import tempfile
-import zipfile
import shutil
from dataclasses import dataclass
from pathlib import Path
-from typing import TYPE_CHECKING, Optional, Dict, List, Any
+from typing import TYPE_CHECKING, Optional, Dict, List, Any, Union, Set
if TYPE_CHECKING:
from ..agents import CommandRegistrar
@@ -27,8 +26,21 @@
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
+from .._download_security import (
+ MAX_JSON_CATALOG_BYTES,
+ build_safe_download_path,
+ is_https_or_localhost_http,
+ read_response_limited,
+ safe_extract_zip,
+)
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
-from .._init_options import is_ai_skills_enabled
+from .._init_options import (
+ MISSING_INIT_OPTIONS_FILE,
+ is_ai_skills_enabled,
+ load_init_options,
+ resolve_active_agent_for_registration,
+)
+from .._invocation_style import get_invocation_prefix
from ..integrations.base import IntegrationBase
from .._utils import dump_frontmatter, version_satisfies
from ..shared_infra import (
@@ -310,13 +322,37 @@ def _validate(self):
# Validate provides section
provides = self.data["provides"]
- if "templates" not in provides or not provides["templates"]:
+ if "templates" not in provides:
raise PresetValidationError(
"Preset must provide at least one template"
)
- # Validate templates
- for tmpl in provides["templates"]:
+ # Validate templates. Guard the container and each entry's shape so a
+ # malformed third-party preset.yml (e.g. ``templates: 5`` or
+ # ``templates: [null]``) raises a clean PresetValidationError the
+ # install handler already catches, instead of a raw TypeError
+ # ('int'/'NoneType' object is not iterable) that escapes to an
+ # unhandled traceback. Mirrors the sibling ExtensionManifest guards.
+ #
+ # Order matters: the container's TYPE is checked before its emptiness,
+ # so a FALSY non-list (``templates: 0``/``false``/``null``/``''``/``{}``)
+ # reports the accurate type error rather than the misleading "must
+ # provide at least one template". An empty list still reports the
+ # latter, since that genuinely is a list with no templates.
+ templates = provides["templates"]
+ if not isinstance(templates, list):
+ raise PresetValidationError(
+ "Invalid provides.templates: expected a list"
+ )
+ if not templates:
+ raise PresetValidationError(
+ "Preset must provide at least one template"
+ )
+ for tmpl in templates:
+ if not isinstance(tmpl, dict):
+ raise PresetValidationError(
+ "Each template entry in 'provides.templates' must be a mapping"
+ )
if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl:
raise PresetValidationError(
"Template missing 'type', 'name', or 'file'"
@@ -445,7 +481,7 @@ def _load(self) -> dict:
}
try:
- with open(self.registry_path, 'r') as f:
+ with open(self.registry_path, 'r', encoding='utf-8') as f:
data = json.load(f)
# Validate loaded data is a dict (handles corrupted registry files)
if not isinstance(data, dict):
@@ -466,7 +502,7 @@ def _load(self) -> dict:
def _save(self):
"""Save registry to disk."""
self.packs_dir.mkdir(parents=True, exist_ok=True)
- with open(self.registry_path, 'w') as f:
+ with open(self.registry_path, 'w', encoding='utf-8') as f:
json.dump(self.data, f, indent=2)
def add(self, pack_id: str, metadata: dict):
@@ -696,6 +732,25 @@ def check_compatibility(
return True
+ def _extension_installed_for_command(self, command_name: str) -> bool:
+ """Whether *command_name* may be materialized in this project.
+
+ Extension command overrides follow ``speckit..``;
+ they must be skipped everywhere preset artifacts are written ā
+ registration *and* reconciliation ā when the extension isn't
+ installed, or reconciliation would materialize files that
+ registration refused to track. Core commands (single-dot names,
+ e.g. ``speckit.specify``) always pass.
+ """
+ parts = command_name.split(".")
+ if len(parts) >= 3 and parts[0] == "speckit":
+ ext_id = parts[1]
+ if not (
+ self.project_root / ".specify" / "extensions" / ext_id
+ ).is_dir():
+ return False
+ return True
+
def _register_commands(
self,
manifest: PresetManifest,
@@ -725,17 +780,11 @@ def _register_commands(
return {}
# Filter out extension command overrides if the extension isn't installed.
- # Command names follow the pattern: speckit..
- # Core commands (e.g. speckit.specify) have only one dot ā always register.
- extensions_dir = self.project_root / ".specify" / "extensions"
- filtered = []
- for cmd in command_templates:
- parts = cmd["name"].split(".")
- if len(parts) >= 3 and parts[0] == "speckit":
- ext_id = parts[1]
- if not (extensions_dir / ext_id).is_dir():
- continue
- filtered.append(cmd)
+ filtered = [
+ cmd
+ for cmd in command_templates
+ if self._extension_installed_for_command(cmd["name"])
+ ]
if not filtered:
return {}
@@ -790,10 +839,634 @@ def _register_commands(
return {}
registrar = CommandRegistrar()
+
+ # Single-active rule (#2948): preset command overrides register for
+ # the active integration only. A project without a recorded active
+ # integration (init-options.json does not exist at all ā a legacy
+ # pre-init-options layout or direct library use) falls back to
+ # detection-based registration for all agents. A recorded key with
+ # no registrar config (e.g. "generic") naturally yields no matches
+ # via only_agent instead of falling back.
+ #
+ # An init-options.json that exists but is corrupted, unreadable, or
+ # has a malformed/empty "ai" value must not be treated the same as
+ # "no file" ā that would silently reintroduce all-agent
+ # registration. Fail closed (register nothing) instead.
+ resolved_agent = resolve_active_agent_for_registration(self.project_root)
+ if resolved_agent is MISSING_INIT_OPTIONS_FILE:
+ active_agent = None
+ elif resolved_agent is None:
+ return {}
+ else:
+ active_agent = resolved_agent
+ # Mirror the extension path's ai_skills guard: when the active
+ # agent is a command-backed integration (extension != "/SKILL.md")
+ # running in skills mode, its preset command overrides render as
+ # skills via _register_skills, not as command files. Command-mode
+ # and skills-mode artifacts are mutually exclusive ā writing both
+ # (e.g. `integration use copilot` with `--skills`) leaves a stale
+ # command file alongside the SKILL.md that is actually active.
+ init_options = load_init_options(self.project_root)
+ agent_config = registrar.AGENT_CONFIGS.get(active_agent)
+ if (
+ agent_config
+ and is_ai_skills_enabled(init_options)
+ and agent_config.get("extension") != "/SKILL.md"
+ ):
+ return {}
+
return registrar.register_commands_for_all_agents(
- commands_to_register, manifest.id, preset_dir, self.project_root
+ commands_to_register,
+ manifest.id,
+ preset_dir,
+ self.project_root,
+ create_missing_active_skills_dir=True,
+ only_agent=active_agent,
+ )
+
+ def register_enabled_presets_for_agent(self, agent_name: str) -> None:
+ """Re-register enabled presets' command overrides and skills for ``agent_name``.
+
+ Mirrors ``ExtensionManager.register_enabled_extensions_for_agent`` for
+ presets (#2948): ``integration use`` / ``switch`` call this for the
+ newly active agent so a preset installed while a different
+ integration was active gets rescaffolded on activation, instead of
+ writing artifacts for inactive integrations at install time.
+ ``_register_commands`` / ``_register_skills`` already resolve the
+ active integration from init-options themselves, so this re-runs them
+ for every enabled preset and merges the fresh result for
+ ``agent_name`` into its stored registry metadata.
+
+ Presets are processed in *reverse* priority order (lowest-precedence
+ first). Each pass overwrites the same target command/skill files, so
+ writing the highest-precedence preset last is what makes it win when
+ two enabled presets override the same command ā matching the
+ priority stack documented for ``list_by_priority()``.
+ """
+ if not agent_name:
+ return
+
+ # Resolve once: whether agent_name is a command-backed integration
+ # (extension != "/SKILL.md") currently running in skills mode, or
+ # vice versa. Native skill-only agents (extension == "/SKILL.md",
+ # e.g. claude/codex) have no command/skill toggle at all ā both
+ # registered_commands and registered_skills legitimately co-exist
+ # for them by design, so this restriction only applies to
+ # command-backed integrations.
+ try:
+ from ..agents import CommandRegistrar
+
+ agent_config = CommandRegistrar().AGENT_CONFIGS.get(agent_name)
+ except ImportError:
+ agent_config = None
+ is_command_backed = bool(agent_config) and agent_config.get("extension") != "/SKILL.md"
+ ai_skills_now = is_command_backed and is_ai_skills_enabled(
+ load_init_options(self.project_root)
+ )
+
+ resolver = PresetResolver(self.project_root)
+ affected_cmd_names: set = set()
+ presets_by_priority = list(self.registry.list_by_priority())
+ winning_pack_by_command: Dict[str, str] = {}
+ winning_source_by_command: Dict[str, Path] = {}
+ project_override_commands: set[str] = set()
+ for candidate_pack_id, _candidate_metadata in presets_by_priority:
+ candidate_manifest = resolver._get_manifest(
+ self.presets_dir / candidate_pack_id
+ )
+ if candidate_manifest is None:
+ continue
+ for template in candidate_manifest.templates:
+ command_name = template.get("name")
+ if (
+ template.get("type") == "command"
+ and isinstance(command_name, str)
+ ):
+ if (
+ resolver.overrides_dir / f"{command_name}.md"
+ ).is_file():
+ project_override_commands.add(command_name)
+ winning_pack_by_command.setdefault(
+ command_name, candidate_pack_id
+ )
+ source_file = template.get("file")
+ if isinstance(source_file, str):
+ winning_source_by_command.setdefault(
+ command_name,
+ self.presets_dir
+ / candidate_pack_id
+ / source_file,
+ )
+
+ pending_command_cleanups: List[
+ tuple[
+ str,
+ Dict[str, List[str]],
+ List[str],
+ Dict[str, str],
+ ]
+ ] = []
+ successful_skill_replacements: set[tuple[str, str]] = set()
+ pending_skill_cleanups: List[
+ tuple[
+ str,
+ Path,
+ Dict[str, List[str]],
+ List[str],
+ Dict[str, str],
+ ]
+ ] = []
+ successful_command_replacements: set[tuple[str, str]] = set()
+ for pack_id, metadata in reversed(presets_by_priority):
+ pack_dir = self.presets_dir / pack_id
+ manifest = resolver._get_manifest(pack_dir)
+ if manifest is None:
+ continue
+
+ # Registration can write one command and then fail on a later
+ # template. Record names first so final reconciliation can repair
+ # any partial writes even when _register_commands never returns.
+ for tmpl in manifest.templates:
+ name = tmpl.get("name")
+ if tmpl.get("type") == "command" and isinstance(name, str):
+ affected_cmd_names.add(name)
+
+ # Isolate per-preset failures: one preset that fails to register
+ # must not abort registration of the remaining enabled presets.
+ try:
+ registered_commands = self._register_commands(manifest, pack_dir)
+ registered_command_names = set(
+ registered_commands.get(agent_name) or []
+ )
+ for tmpl in manifest.templates:
+ if tmpl.get("type") != "command":
+ continue
+ primary_name = tmpl.get("name")
+ if (
+ isinstance(primary_name, str)
+ and primary_name in registered_command_names
+ ):
+ successful_command_replacements.add(
+ (pack_id, primary_name)
+ )
+ existing_commands = metadata.get("registered_commands", {})
+ if not isinstance(existing_commands, dict):
+ existing_commands = {}
+ merged_commands = copy.deepcopy(existing_commands)
+ # Toggled command -> skills for this same agent:
+ # _register_commands's ai_skills guard just made this a
+ # no-op, but the command file this preset wrote while
+ # command mode was active is still on disk and still
+ # tracked. Do NOT unregister it yet ā _register_skills()
+ # below is an independently fallible replacement step, and
+ # deleting the old artifact before it succeeds would leave
+ # neither the old command file nor a new skill file if
+ # skills registration raises. The old artifact is only
+ # removed after the skills phase below completes without
+ # raising, preserving command/skill mutual exclusion while
+ # never leaving a transient failure with nothing in place
+ # (#2948).
+ stale_command_names: Optional[List[str]] = None
+ if registered_commands.get(agent_name):
+ existing_names = merged_commands.get(agent_name, [])
+ merged_commands[agent_name] = existing_names + [
+ name
+ for name in registered_commands[agent_name]
+ if name not in existing_names
+ ]
+ elif ai_skills_now and merged_commands.get(agent_name):
+ stale_command_names = merged_commands[agent_name]
+ # Persist the commands phase immediately, mirroring
+ # install_from_directory(): _register_skills is an
+ # independently fallible phase, and if it raises, the files
+ # the commands phase already wrote to disk must still be
+ # tracked so preset removal can clean them up (#2948).
+ if merged_commands != existing_commands:
+ self.registry.update(pack_id, {"registered_commands": merged_commands})
+
+ registered_skills = self._register_skills(manifest, pack_dir)
+ replaced_skill_names = set(registered_skills.get(agent_name) or [])
+ for tmpl in manifest.templates:
+ if tmpl.get("type") != "command":
+ continue
+ primary_name = tmpl.get("name")
+ if not isinstance(primary_name, str):
+ continue
+ modern_name, legacy_name = self._skill_names_for_command(
+ primary_name
+ )
+ if (
+ modern_name in replaced_skill_names
+ or legacy_name in replaced_skill_names
+ ):
+ successful_skill_replacements.add(
+ (pack_id, primary_name)
+ )
+ raw_existing_skills = metadata.get("registered_skills")
+ if isinstance(raw_existing_skills, list) and raw_existing_skills:
+ # Legacy flat-list value: don't assume agent_name wrote
+ # every name (the first post-upgrade operation may be a
+ # direct switch to a different skill-mode agent) ā
+ # infer real ownership from on-disk provenance instead
+ # (#2948).
+ existing_skills = self._infer_legacy_skill_provenance(
+ [n for n in raw_existing_skills if isinstance(n, str)],
+ pack_id,
+ fallback_agent=agent_name,
+ )
+ else:
+ existing_skills = self._normalize_registered_skills(
+ raw_existing_skills, fallback_agent=agent_name
+ )
+ merged_skills = copy.deepcopy(existing_skills)
+ if registered_skills.get(agent_name):
+ existing_names = merged_skills.get(agent_name, [])
+ merged_skills[agent_name] = existing_names + [
+ name
+ for name in registered_skills[agent_name]
+ if name not in existing_names
+ ]
+ elif is_command_backed and not ai_skills_now and merged_skills.get(agent_name):
+ # Mirror image: toggled skills -> command for this same
+ # agent. _get_skills_dir() no longer resolves a skills
+ # directory once ai_skills is off, so _register_skills
+ # is a no-op ā but the SKILL.md this preset wrote while
+ # skills mode was active is still tracked and still on
+ # disk. Restore/remove it narrowly for this agent. This
+ # direction is already register-new-then-remove-old:
+ # _register_commands (the replacement) ran unconditionally
+ # above and only reaches here once it has already
+ # succeeded ā but that call can still have returned
+ # empty or partial results (missing source template,
+ # safety-validation skip, corrupted manifest), so only
+ # retire the subset of stale skills whose corresponding
+ # command name was actually returned for this agent;
+ # anything unreplaced stays tracked and on disk (#2948).
+ stale_skill_names = merged_skills[agent_name]
+ skill_to_primary: Dict[str, str] = {}
+ for tmpl in manifest.templates:
+ if tmpl.get("type") != "command":
+ continue
+ primary_name = tmpl.get("name")
+ if not isinstance(primary_name, str):
+ continue
+ modern_name, legacy_name = self._skill_names_for_command(
+ primary_name
+ )
+ skill_to_primary[modern_name] = primary_name
+ skill_to_primary[legacy_name] = primary_name
+ pending_skill_cleanups.append(
+ (
+ pack_id,
+ pack_dir,
+ merged_skills,
+ stale_skill_names,
+ skill_to_primary,
+ )
+ )
+ # A legacy flat-list registered_skills value (predating
+ # per-agent provenance) must migrate to the dict format on
+ # disk even when the rescaffolded names are unchanged from
+ # what the list already held ā comparing only the
+ # *normalized* forms would otherwise treat that as a no-op
+ # and leave the raw un-migrated list in the registry, which
+ # later removal/switch handling treats as legacy
+ # best-effort (restoring only the currently active agent's
+ # directory) instead of per-agent provenance (#2948).
+ needs_migration = (
+ isinstance(raw_existing_skills, list) and raw_existing_skills
+ )
+ if merged_skills != existing_skills or needs_migration:
+ self.registry.update(pack_id, {"registered_skills": merged_skills})
+
+ # The skills phase above completed without raising, but a
+ # non-raising result can still be empty or partial (missing
+ # source template, safety-validation skip, corrupted
+ # manifest) ā retiring every stale command purely on "did
+ # not raise" would delete a command whose replacement skill
+ # never actually landed, leaving neither artifact. Only
+ # retire the subset of stale commands whose corresponding
+ # skill name was actually returned for this agent; anything
+ # unreplaced stays tracked and on disk (#2948).
+ if stale_command_names:
+ # Commands may carry aliases (CommandRegistrar.register_
+ # commands() tracks and returns primary + alias names
+ # flattened together into one list), but _register_
+ # skills() only ever renders/returns the *primary*
+ # command name's skill ā running an alias's own name
+ # through _skill_names_for_command() never matches
+ # anything real, so an alias would stay tracked/on-disk
+ # forever even after its primary's skill replacement
+ # landed. Map each stale name back to its template's
+ # primary via the manifest so the whole primary+alias
+ # group is retired or kept together, based solely on
+ # whether the *primary*'s skill replacement actually
+ # landed (#2948).
+ alias_to_primary: Dict[str, str] = {}
+ for tmpl in manifest.templates:
+ if tmpl.get("type") != "command":
+ continue
+ primary_name = tmpl.get("name")
+ if not isinstance(primary_name, str):
+ continue
+ for alias in tmpl.get("aliases", []):
+ if isinstance(alias, str):
+ alias_to_primary[alias] = primary_name
+
+ pending_command_cleanups.append(
+ (
+ pack_id,
+ merged_commands,
+ stale_command_names,
+ alias_to_primary,
+ )
+ )
+ except Exception as pack_err:
+ from .. import _print_cli_warning
+
+ _print_cli_warning(
+ "register preset artifacts for",
+ "preset",
+ pack_id,
+ pack_err,
+ continuing="Continuing with the remaining presets.",
+ )
+ continue
+
+ # Registration writes each preset's raw layer. Reconcile before
+ # retiring opposite-mode artifacts so project overrides and composed
+ # winners are materialized first, and so cleanup runs last instead of
+ # being undone by skill reconciliation.
+ reconciled_commands: set[str] = set()
+ reconciled_skills: set[str] = set()
+ if affected_cmd_names:
+ try:
+ reconciled_commands = self._reconcile_composed_commands(
+ list(affected_cmd_names), target_agent=agent_name
+ )
+ reconciled_skills = self._reconcile_skills(
+ list(affected_cmd_names), target_agent=agent_name
+ )
+ except Exception as exc:
+ import warnings
+
+ warnings.warn(
+ f"Post-rescaffold reconciliation failed for '{agent_name}': "
+ f"{exc}. Agent command files may be stale; re-run "
+ f"'specify integration use {agent_name}' or reinstall "
+ f"affected presets to refresh.",
+ stacklevel=2,
+ )
+
+ successfully_replaced_winners = {
+ command_name
+ for command_name, winning_pack_id in winning_pack_by_command.items()
+ if command_name not in project_override_commands
+ and (
+ (winning_pack_id, command_name)
+ in successful_skill_replacements
+ or (
+ command_name in reconciled_skills
+ and command_name in winning_source_by_command
+ and winning_source_by_command[command_name].is_file()
+ )
+ )
+ }
+ successfully_replaced_winners.update(
+ project_override_commands & reconciled_skills
+ )
+
+ for (
+ pack_id,
+ merged_commands,
+ stale_command_names,
+ alias_to_primary,
+ ) in pending_command_cleanups:
+ fully_replaced = [
+ command_name
+ for command_name in stale_command_names
+ if alias_to_primary.get(command_name, command_name)
+ in successfully_replaced_winners
+ ]
+ if not fully_replaced:
+ continue
+ remaining_stale = [
+ command_name
+ for command_name in stale_command_names
+ if command_name not in fully_replaced
+ ]
+ self._unregister_commands({agent_name: fully_replaced})
+ if remaining_stale:
+ merged_commands[agent_name] = remaining_stale
+ else:
+ merged_commands.pop(agent_name, None)
+ self.registry.update(
+ pack_id, {"registered_commands": merged_commands}
+ )
+
+ successfully_replaced_command_winners = {
+ command_name
+ for command_name, winning_pack_id in winning_pack_by_command.items()
+ if command_name not in project_override_commands
+ and (
+ (winning_pack_id, command_name)
+ in successful_command_replacements
+ or (
+ command_name in reconciled_commands
+ and command_name in winning_source_by_command
+ and winning_source_by_command[command_name].is_file()
+ )
+ )
+ }
+ successfully_replaced_command_winners.update(
+ project_override_commands & reconciled_commands
)
+ # Skill restoration walks the priority stack, so retire stale layers
+ # from highest to lowest. The last cleanup then restores the true
+ # non-preset fallback (or removes the skill) rather than cycling back
+ # to a lower-priority preset.
+ for (
+ pack_id,
+ pack_dir,
+ merged_skills,
+ stale_skill_names,
+ skill_to_primary,
+ ) in reversed(pending_skill_cleanups):
+ fully_replaced = [
+ skill_name
+ for skill_name in stale_skill_names
+ if skill_to_primary.get(skill_name)
+ in successfully_replaced_command_winners
+ ]
+ if not fully_replaced:
+ continue
+ remaining_stale = [
+ skill_name
+ for skill_name in stale_skill_names
+ if skill_name not in fully_replaced
+ ]
+ override_sources = {
+ skill_name: f"override:{skill_to_primary[skill_name]}"
+ for skill_name in fully_replaced
+ if skill_name in skill_to_primary
+ }
+ self._unregister_skills(
+ {agent_name: fully_replaced},
+ pack_dir,
+ additional_owned_sources=override_sources,
+ )
+ if remaining_stale:
+ merged_skills[agent_name] = remaining_stale
+ else:
+ merged_skills.pop(agent_name, None)
+ self.registry.update(
+ pack_id, {"registered_skills": merged_skills}
+ )
+
+ def unregister_agent_artifacts(self, agent_name: str) -> None:
+ """Remove ``agent_name``'s tracked preset command/skill artifacts.
+
+ Mirrors ``ExtensionManager.unregister_agent_artifacts()`` (#2948):
+ used by ``integration switch`` when deactivating the previous
+ integration, so a preset's command overrides and skill mirrors
+ written for that agent don't linger as orphans in its directory
+ once a different (possibly not-yet-installed) integration becomes
+ active ā including custom preset commands and files the registrar
+ would otherwise skip as user-modified.
+
+ Scoped strictly to ``agent_name``: only that agent's own tracked
+ artifacts and registry entries are touched. Other agents' files,
+ tracking, and preset packs themselves are left untouched, and no
+ priority-stack reconciliation runs ā this is agent-scoped cleanup
+ only, not preset removal.
+ """
+ if not agent_name:
+ return
+
+ try:
+ from ..agents import CommandRegistrar
+
+ registrar = CommandRegistrar()
+ agent_config = registrar.AGENT_CONFIGS.get(agent_name)
+ except ImportError:
+ registrar = None
+ agent_config = None
+ if agent_config is None or registrar is None:
+ return
+
+ for pack_id, metadata in list(self.registry.list().items()):
+ updates: Dict[str, Any] = {}
+
+ raw_skills = metadata.get("registered_skills", [])
+ if isinstance(raw_skills, list) and raw_skills:
+ # Legacy flat-list value predating per-agent provenance:
+ # infer real ownership from on-disk markers before removing
+ # anything, so only agent_name's actual share is unregistered
+ # and the rest migrates to per-agent form instead of either
+ # guessing every name belongs to agent_name or blindly
+ # leaving other agents' shares unrecoverable (#2948).
+ registered_skills_all = self._infer_legacy_skill_provenance(
+ [n for n in raw_skills if isinstance(n, str)],
+ pack_id,
+ fallback_agent=agent_name,
+ )
+ skills_migrated = True
+ elif isinstance(raw_skills, dict):
+ registered_skills_all = copy.deepcopy(raw_skills)
+ skills_migrated = False
+ else:
+ registered_skills_all = {}
+ skills_migrated = False
+
+ registered_commands = metadata.get("registered_commands", {})
+ if not isinstance(registered_commands, dict):
+ registered_commands = {}
+
+ agent_command_names = [
+ n for n in registered_commands.get(agent_name, []) if isinstance(n, str)
+ ]
+
+ # Native SKILL.md agents (claude/codex/agy/ā¦) materialize their
+ # preset override in _register_commands(), tracked under
+ # registered_commands, not registered_skills ā see
+ # _register_skills()'s own docstring ("Native skill agents ā¦
+ # materialize brand-new preset skills in _register_commands()").
+ # A legacy flat-list registered_skills value predating that
+ # split can still attribute the very same on-disk file to this
+ # agent via provenance inference; unregistering through both
+ # paths would double-process the identical directory (delete
+ # via the commands path, then no-op "restore" via the skills
+ # path since the directory is already gone). Mirror remove()'s
+ # own coordination: whenever this agent's artifact is already
+ # handled via registered_commands, never additionally treat it
+ # as a registered_skills entry for the same agent.
+ native_skills_entry_removed = False
+ if agent_command_names and agent_config.get("extension") == "/SKILL.md":
+ native_skills_entry_removed = agent_name in registered_skills_all
+ registered_skills_all.pop(agent_name, None)
+
+ if agent_command_names:
+ command_names_to_unregister = agent_command_names
+ if agent_config.get("extension") == "/SKILL.md":
+ agent_output = registrar._resolve_agent_dir(
+ agent_name, agent_config, self.project_root
+ )
+ shared_names: set[str] = set()
+ for other_agent, other_names in registered_commands.items():
+ if (
+ other_agent == agent_name
+ or not isinstance(other_names, list)
+ ):
+ continue
+ other_config = registrar.AGENT_CONFIGS.get(other_agent)
+ if (
+ not other_config
+ or other_config.get("extension") != "/SKILL.md"
+ ):
+ continue
+ other_output = registrar._resolve_agent_dir(
+ other_agent, other_config, self.project_root
+ )
+ if other_output == agent_output:
+ shared_names.update(
+ name
+ for name in other_names
+ if isinstance(name, str)
+ )
+ command_names_to_unregister = [
+ name
+ for name in agent_command_names
+ if name not in shared_names
+ ]
+ if command_names_to_unregister:
+ self._unregister_commands(
+ {agent_name: command_names_to_unregister}
+ )
+ new_registered_commands = copy.deepcopy(registered_commands)
+ new_registered_commands.pop(agent_name, None)
+ updates["registered_commands"] = new_registered_commands
+
+ agent_skill_names = registered_skills_all.get(agent_name) or []
+ if (
+ agent_skill_names
+ or skills_migrated
+ or native_skills_entry_removed
+ ):
+ if agent_skill_names:
+ self._delete_agent_preset_skills(
+ agent_name, agent_skill_names, pack_id
+ )
+ remaining = {
+ other_agent: names
+ for other_agent, names in registered_skills_all.items()
+ if other_agent != agent_name
+ }
+ updates["registered_skills"] = remaining
+
+ if updates:
+ self.registry.update(pack_id, updates)
+
def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> None:
"""Remove previously registered command files from agent directories.
@@ -808,7 +1481,80 @@ def _unregister_commands(self, registered_commands: Dict[str, List[str]]) -> Non
registrar = CommandRegistrar()
registrar.unregister_commands(registered_commands, self.project_root)
- def _reconcile_composed_commands(self, command_names: List[str]) -> None:
+ def _merge_pack_registered_commands(
+ self, pack_id: str, written: Optional[Dict[str, List[str]]]
+ ) -> None:
+ """Merge actually-written agent command registrations into a preset's metadata.
+
+ Reconciliation (``_reconcile_composed_commands``) can write a
+ preset's content into an agent directory the preset never wrote to
+ before ā most notably a historical (currently inactive) agent
+ supplied via ``extra_agents`` when a higher-priority preset is
+ removed. If that write isn't reflected back into the winning
+ preset's own ``registered_commands``, the registry silently lies
+ about which directories the preset owns: a later removal of this
+ same preset only cleans up the agents it already knew about,
+ orphaning the directory reconciliation just wrote to on its behalf
+ (#2948).
+
+ Args:
+ pack_id: The preset whose metadata should be updated.
+ written: ``{agent_name: [cmd_name, ...]}`` actually written by
+ the reconciliation call just made, exactly mirroring
+ ``CommandRegistrar.register_commands_for_non_skill_agents``'s
+ return value. A falsy value is a no-op.
+ """
+ if not written:
+ return
+ metadata = self.registry.get(pack_id)
+ if metadata is None:
+ return # pack_id no longer installed (e.g. removed mid-loop)
+ existing_commands = metadata.get("registered_commands", {})
+ if not isinstance(existing_commands, dict):
+ existing_commands = {}
+ merged_commands = copy.deepcopy(existing_commands)
+ changed = False
+ for agent_name, cmd_names in written.items():
+ if not cmd_names:
+ continue
+ existing_names = merged_commands.get(agent_name, [])
+ new_names = [n for n in cmd_names if n not in existing_names]
+ if new_names:
+ merged_commands[agent_name] = existing_names + new_names
+ changed = True
+ if changed:
+ self.registry.update(pack_id, {"registered_commands": merged_commands})
+
+ def _merge_extension_registered_commands(
+ self, extension_id: str, written: Optional[Dict[str, List[str]]]
+ ) -> None:
+ """Merge reconciliation writes into an extension's registry entry."""
+ if not written:
+ return
+ registry = ExtensionRegistry(self.project_root / ".specify" / "extensions")
+ metadata = registry.get(extension_id)
+ if metadata is None:
+ return
+ existing_commands = metadata.get("registered_commands", {})
+ if not isinstance(existing_commands, dict):
+ existing_commands = {}
+ merged_commands = copy.deepcopy(existing_commands)
+ changed = False
+ for agent_name, cmd_names in written.items():
+ existing_names = merged_commands.get(agent_name, [])
+ new_names = [name for name in cmd_names if name not in existing_names]
+ if new_names:
+ merged_commands[agent_name] = existing_names + new_names
+ changed = True
+ if changed:
+ registry.update(extension_id, {"registered_commands": merged_commands})
+
+ def _reconcile_composed_commands(
+ self,
+ command_names: List[str],
+ extra_agents: Optional[Set[str]] = None,
+ target_agent: Optional[str] = None,
+ ) -> Set[str]:
"""Re-resolve and re-register composed commands from the full stack.
After install or remove, recompute the effective content for each
@@ -817,19 +1563,99 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None:
reflect the current priority stack rather than depending on
install/remove order.
+ Single-active rule (#2948): non-skill command-file registration
+ performed by this pass is restricted to the active integration, the
+ same as ``_register_commands``. Without this, reconciliation after
+ install/remove would write command files for every detected
+ non-skill agent even though registration itself is active-only,
+ leaving inactive integrations with artifacts that are never
+ recorded in ``registered_commands`` (and therefore never cleaned up
+ on removal).
+
Args:
command_names: List of command names to reconcile
+ extra_agents: Additional agent names to also reconcile besides
+ the currently active one. Populated by ``remove()`` with the
+ historical agents a just-removed preset's
+ ``registered_commands`` actually targeted, so a surviving
+ lower-priority preset's content is restored there too ā not
+ only for the currently active agent (#2948). Install/use
+ callers omit this, preserving pure active-only behavior.
+ target_agent: If set, report only command names written for this
+ agent. Other callers receive the union of all written names.
+
+ Returns:
+ Command names successfully written by this reconciliation pass.
"""
if not command_names:
- return
+ return set()
+
+ # Never materialize extension-scoped commands whose extension isn't
+ # installed. Registration (_register_commands / _register_skills)
+ # already refuses them, so a reconciliation pass writing them would
+ # create files no registry entry tracks. Filtering here ā the single
+ # chokepoint every install/remove/rescaffold reconciliation funnels
+ # through ā keeps all callers consistent without each one re-applying
+ # the filter when seeding names from manifest templates.
+ command_names = [
+ name
+ for name in command_names
+ if self._extension_installed_for_command(name)
+ ]
+ if not command_names:
+ return set()
try:
from ..agents import CommandRegistrar
except ImportError:
- return
+ return set()
resolver = PresetResolver(self.project_root)
registrar = CommandRegistrar()
+ reconciled_commands: set[str] = set()
+
+ def record_written(written: Dict[str, List[str]]) -> None:
+ if target_agent is not None:
+ reconciled_commands.update(written.get(target_agent, []))
+ else:
+ for names in written.values():
+ reconciled_commands.update(names)
+
+ # Resolve the active-only restriction once. MISSING_INIT_OPTIONS_FILE
+ # (legacy pre-init-options project) keeps the pre-#2948 fallback of
+ # registering every detected non-skill agent; a corrupted/malformed
+ # init-options.json fails closed via a sentinel that matches no real
+ # agent name instead of silently falling back to "no restriction".
+ resolved_agent = resolve_active_agent_for_registration(self.project_root)
+ if resolved_agent is MISSING_INIT_OPTIONS_FILE:
+ only_agent: Optional[str] = None
+ elif resolved_agent is None:
+ only_agent = ""
+ else:
+ only_agent = resolved_agent
+ # Mirror _register_commands's ai_skills guard: a command-backed
+ # active agent running in skills mode renders preset/extension
+ # overrides as skills, not command files, so this non-skill
+ # command reconciliation pass must not target it either.
+ agent_config = registrar.AGENT_CONFIGS.get(only_agent)
+ if (
+ agent_config
+ and is_ai_skills_enabled(load_init_options(self.project_root))
+ and agent_config.get("extension") != "/SKILL.md"
+ ):
+ only_agent = ""
+
+ # The active agent's participation is decided exclusively by the
+ # only_agent guard above (which encodes the ai_skills mode). A
+ # partially failed commandāskills toggle can leave the active agent
+ # behind in extra_agents via its stale registered_commands entry,
+ # and register_commands_for_non_skill_agents admits every
+ # extra_agents member even when only_agent excludes the agent ā
+ # recreating a command file for an agent now running in skills
+ # mode. Never re-admit the active agent through the
+ # historical-agents side channel (#2948).
+ if extra_agents and isinstance(resolved_agent, str):
+ extra_agents = set(extra_agents) - {resolved_agent}
# Cache registry and manifests outside the loop to avoid
# repeated filesystem reads for each command name.
@@ -859,9 +1685,12 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None:
if manifest:
for tmpl in manifest.templates:
if tmpl.get("name") == cmd_name and tmpl.get("type") == "command":
- self._register_for_non_skill_agents(
- registrar, [tmpl], manifest.id, pack_dir
+ written = self._register_for_non_skill_agents(
+ registrar, [tmpl], manifest.id, pack_dir,
+ only_agent=only_agent, extra_agents=extra_agents,
)
+ record_written(written)
+ self._merge_pack_registered_commands(manifest.id, written)
registered = True
break
break
@@ -869,10 +1698,14 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None:
# Top layer is a non-preset source (extension, core, or
# project override). Register directly from the layer path.
source = layers[0]["source"]
+ extension_id = None
+ written: Dict[str, List[str]] = {}
if source.startswith("extension:"):
# Use extension's own registration to preserve context formatting
- ext_id = source.split(":", 1)[1].split(" ", 1)[0]
- ext_dir = self.project_root / ".specify" / "extensions" / ext_id
+ extension_id = source.split(":", 1)[1].split(" ", 1)[0]
+ ext_dir = (
+ self.project_root / ".specify" / "extensions" / extension_id
+ )
ext_manifest_path = ext_dir / "extension.yml"
if ext_manifest_path.exists():
try:
@@ -884,22 +1717,31 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None:
if c.get("name") == cmd_name
]
if matching_cmds:
- registrar.register_commands_for_non_skill_agents(
- matching_cmds, ext_id, ext_dir,
+ written = registrar.register_commands_for_non_skill_agents(
+ matching_cmds, extension_id, ext_dir,
self.project_root,
- context_note=f"\n\n\n",
- extension_id=ext_id,
+ context_note=f"\n\n\n",
+ extension_id=extension_id,
+ only_agent=only_agent,
+ extra_agents=extra_agents,
)
+ record_written(written)
registered = True
except Exception:
# Extension registration failed; fall back to
# generic path-based registration below.
pass
if not registered:
- source_id = source.split(":", 1)[1].split(" ", 1)[0] if source.startswith("extension:") else source
- self._register_command_from_path(
+ source_id = extension_id or source
+ written = self._register_command_from_path(
registrar, cmd_name, top_path,
source_id=source_id,
+ only_agent=only_agent, extra_agents=extra_agents,
+ )
+ record_written(written)
+ if extension_id:
+ self._merge_extension_registered_commands(
+ extension_id, written
)
else:
# Composed command ā resolve from full stack
@@ -926,9 +1768,22 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None:
if isinstance(alias, str):
cmd_names_to_unregister.append(alias)
break
+ # Mirror the active-only restriction used elsewhere in
+ # this pass: without it, unregistering a stale composed
+ # command would touch every non-skill agent's directory,
+ # deleting historical artifacts from integrations that
+ # were never active when this preset registered (#2948).
registrar.unregister_commands(
- {agent: cmd_names_to_unregister for agent in registrar.AGENT_CONFIGS
- if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"},
+ {
+ agent: cmd_names_to_unregister
+ for agent in registrar.AGENT_CONFIGS
+ if registrar.AGENT_CONFIGS[agent].get("extension") != "/SKILL.md"
+ and (
+ only_agent is None
+ or agent == only_agent
+ or agent in (extra_agents or ())
+ )
+ },
self.project_root,
)
continue
@@ -946,11 +1801,14 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None:
composed_dir.mkdir(parents=True, exist_ok=True)
composed_file = composed_dir / f"{cmd_name}.md"
composed_file.write_text(composed, encoding="utf-8")
- self._register_for_non_skill_agents(
+ written = self._register_for_non_skill_agents(
registrar,
[{**tmpl, "file": f".composed/{cmd_name}.md"}],
manifest.id, pack_dir,
+ only_agent=only_agent, extra_agents=extra_agents,
)
+ record_written(written)
+ self._merge_pack_registered_commands(manifest.id, written)
registered = True
break
else:
@@ -968,10 +1826,18 @@ def _reconcile_composed_commands(self, command_names: List[str]) -> None:
source_id = source.split(":", 1)[1].split(" ", 1)[0]
else:
source_id = source
- self._register_command_from_path(
+ written = self._register_command_from_path(
registrar, cmd_name, composed_file,
source_id=source_id,
+ only_agent=only_agent, extra_agents=extra_agents,
)
+ record_written(written)
+ if source.startswith("extension:"):
+ self._merge_extension_registered_commands(
+ source_id, written
+ )
+
+ return reconciled_commands
def _register_command_from_path(
self,
@@ -979,7 +1845,9 @@ def _register_command_from_path(
cmd_name: str,
cmd_path: Path,
source_id: str = "reconciled",
- ) -> None:
+ only_agent: Optional[str] = None,
+ extra_agents: Optional[Set[str]] = None,
+ ) -> Dict[str, List[str]]:
"""Register a single command from a file path (non-preset source).
Used by reconciliation when the winning layer is an extension,
@@ -990,9 +1858,17 @@ def _register_command_from_path(
cmd_name: Command name
cmd_path: Path to the command file
source_id: Source attribution for rendered output
+ only_agent: If set, restrict registration to this single agent (#2948).
+ extra_agents: Additional agent names to register for besides
+ ``only_agent`` (post-removal reconciliation only, #2948).
+
+ Returns:
+ ``{agent_name: [cmd_name, ...]}`` for every agent this call
+ actually registered the command for (empty if the source path
+ doesn't exist or nothing was written).
"""
if not cmd_path.exists():
- return
+ return {}
cmd_tmpl: Dict[str, Any] = {
"name": cmd_name,
"type": "command",
@@ -1018,8 +1894,9 @@ def _register_command_from_path(
break
except Exception:
pass # best-effort alias loading
- self._register_for_non_skill_agents(
- registrar, [cmd_tmpl], source_id, cmd_path.parent
+ return self._register_for_non_skill_agents(
+ registrar, [cmd_tmpl], source_id, cmd_path.parent,
+ only_agent=only_agent, extra_agents=extra_agents,
)
def _register_for_non_skill_agents(
@@ -1028,7 +1905,9 @@ def _register_for_non_skill_agents(
commands: List[Dict[str, Any]],
source_id: str,
source_dir: Path,
- ) -> None:
+ only_agent: Optional[str] = None,
+ extra_agents: Optional[Set[str]] = None,
+ ) -> Dict[str, List[str]]:
"""Register commands for non-skill agents during reconciliation.
Skill-based agents (``/SKILL.md`` layout) are handled separately:
@@ -1041,9 +1920,26 @@ def _register_for_non_skill_agents(
Writing raw command content to skill agents would produce invalid
SKILL.md files (missing skill frontmatter, descriptions, etc.).
+
+ Args:
+ only_agent: If set, restrict registration to this single agent,
+ matching the active-only rule applied by ``_register_commands``
+ (#2948).
+ extra_agents: Additional agent names to register for besides
+ ``only_agent``. Used by post-removal reconciliation to also
+ restore surviving content into historical agent directories
+ a just-removed preset actually wrote to (#2948).
+
+ Returns:
+ ``{agent_name: [cmd_name, ...]}`` for every agent this call
+ actually registered a command for, mirroring
+ ``CommandRegistrar.register_commands_for_non_skill_agents``'s
+ return value so callers can merge it into a preset's own
+ ``registered_commands`` tracking (#2948).
"""
- registrar.register_commands_for_non_skill_agents(
- commands, source_id, source_dir, self.project_root
+ return registrar.register_commands_for_non_skill_agents(
+ commands, source_id, source_dir, self.project_root,
+ only_agent=only_agent, extra_agents=extra_agents,
)
class _FilteredManifest:
@@ -1067,7 +1963,67 @@ def templates(self) -> List[Dict[str, Any]]:
if t.get("name") in self._cmd_names
]
- def _reconcile_skills(self, command_names: List[str]) -> None:
+ def _merge_pack_registered_skills(
+ self, pack_id: str, written: Optional[Dict[str, List[str]]]
+ ) -> None:
+ """Merge actually-written agent skill registrations into a preset's metadata.
+
+ Mirrors :meth:`_merge_pack_registered_commands` for the skills
+ side: ``_reconcile_skills`` can render a preset's SKILL.md content
+ into an agent directory the preset never wrote to before ā most
+ notably a historical (currently inactive) agent restored via
+ ``extra_skills_dirs`` when a higher-priority preset is removed. If
+ that write isn't reflected back into the winning preset's own
+ ``registered_skills``, a later removal of this same preset only
+ cleans up the agents it already knew about, orphaning the skill
+ directory reconciliation just wrote to on its behalf (#2948).
+
+ Args:
+ pack_id: The preset whose metadata should be updated.
+ written: ``{agent_name: [skill_name, ...]}`` actually written
+ by the ``_register_skills`` call just made. A falsy value
+ is a no-op.
+ """
+ if not written:
+ return
+ metadata = self.registry.get(pack_id)
+ if metadata is None:
+ return # pack_id no longer installed (e.g. removed mid-loop)
+ raw_existing_skills = metadata.get("registered_skills")
+ if isinstance(raw_existing_skills, list) and raw_existing_skills:
+ # Legacy flat-list value: infer real per-agent ownership from
+ # on-disk provenance rather than guessing (#2948).
+ fallback_agent = next(iter(written)) if written else None
+ existing_skills = self._infer_legacy_skill_provenance(
+ [n for n in raw_existing_skills if isinstance(n, str)],
+ pack_id,
+ fallback_agent=fallback_agent,
+ )
+ else:
+ existing_skills = self._normalize_registered_skills(raw_existing_skills)
+ merged_skills = copy.deepcopy(existing_skills)
+ changed = (
+ isinstance(raw_existing_skills, list) and bool(raw_existing_skills)
+ )
+ for agent_name, skill_names in written.items():
+ if not skill_names:
+ continue
+ existing_names = merged_skills.get(agent_name, [])
+ new_names = [n for n in skill_names if n not in existing_names]
+ if new_names:
+ merged_skills[agent_name] = existing_names + new_names
+ changed = True
+ if changed:
+ self.registry.update(pack_id, {"registered_skills": merged_skills})
+
+ def _reconcile_skills(
+ self,
+ command_names: List[str],
+ extra_skills_dirs: Optional[
+ Dict[Path, tuple[Optional[str], List[str]]]
+ ] = None,
+ target_agent: Optional[str] = None,
+ ) -> Set[str]:
"""Re-register skills for commands whose winning layer changed.
After a preset is removed, finds the next preset in the priority
@@ -1076,43 +2032,82 @@ def _reconcile_skills(self, command_names: List[str]) -> None:
Args:
command_names: List of command names to reconcile skills for
+ extra_skills_dirs: Additional
+ ``{skills_dir: (renderer_agent, managed_skill_names)}``
+ entries restored by ``_unregister_skills``. Reconciliation
+ is limited to the names actually managed in each directory.
+ target_agent: If set, report only command names written for this
+ agent. Other callers receive the union of all written names.
+
+ Returns:
+ Command names whose skill output was successfully written.
"""
if not command_names:
- return
+ return set()
+
+ command_names = [
+ name
+ for name in command_names
+ if self._extension_installed_for_command(name)
+ ]
+ if not command_names:
+ return set()
resolver = PresetResolver(self.project_root)
- skills_dir = self._get_skills_dir()
+ active_skills_dir = self._get_skills_dir()
+
+ from .. import load_init_options
+
+ init_opts = load_init_options(self.project_root)
+ active_ai = init_opts.get("ai") if isinstance(init_opts, dict) else None
+ if not isinstance(active_ai, str) or not active_ai:
+ active_ai = None
# Cache registry once to avoid repeated filesystem reads
presets_by_priority = list(self.registry.list_by_priority())
# Group command names by winning preset to batch _register_skills calls
- # while only registering skills for the specific commands being reconciled.
+ # while only registering skills for the specific commands being
+ # reconciled. This resolution (which preset/content wins) is
+ # directory-independent, so it's computed once and then applied to
+ # every affected directory below.
preset_cmds: Dict[str, List[str]] = {}
non_preset_skills: List[tuple] = []
+ managed_skill_names: set = set()
+ reconciled_skill_commands: set[str] = set()
for cmd_name in command_names:
layers = resolver.collect_all_layers(cmd_name, "command")
if not layers:
continue
- # Re-create the skill directory only if it was previously managed
- # (i.e., listed in some preset's registered_skills). This avoids
- # creating new skill dirs that _register_skills would normally skip.
- if skills_dir:
- skill_name, _ = self._skill_names_for_command(cmd_name)
- skill_subdir = skills_dir / skill_name
- if not skill_subdir.exists():
- # Check if any preset previously registered this skill
- was_managed = False
- for _pid, meta in presets_by_priority:
- if not isinstance(meta, dict):
- continue
- if skill_name in meta.get("registered_skills", []):
- was_managed = True
- break
- if was_managed:
- skill_subdir.mkdir(parents=True, exist_ok=True)
+ skill_name, legacy_skill_name = self._skill_names_for_command(
+ cmd_name
+ )
+ candidate_skill_names = {skill_name, legacy_skill_name}
+ # Track whether any preset previously registered this skill
+ # (i.e., it was actively managed), so a not-yet-existing skill
+ # dir can be re-created per affected directory below.
+ for _pid, meta in presets_by_priority:
+ if not isinstance(meta, dict):
+ continue
+ recorded = meta.get("registered_skills", [])
+ if isinstance(recorded, dict):
+ recorded_names = {
+ name
+ for names in recorded.values()
+ if isinstance(names, list)
+ for name in names
+ }
+ elif isinstance(recorded, list):
+ recorded_names = set(recorded)
+ else:
+ recorded_names = set()
+ recorded_candidates = (
+ candidate_skill_names & recorded_names
+ )
+ if recorded_candidates:
+ managed_skill_names.update(recorded_candidates)
top_path = layers[0]["path"]
# Find the preset that owns the winning layer
@@ -1126,34 +2121,48 @@ def _reconcile_skills(self, command_names: List[str]) -> None:
if not found_preset:
# Winner is a non-preset source (core/extension/override).
# Track the winning layer path for skill restoration.
- skill_name, _ = self._skill_names_for_command(cmd_name)
non_preset_skills.append((skill_name, cmd_name, layers[0]))
- # Restore skills for commands whose winner is non-preset.
- if non_preset_skills and skills_dir:
- # Separate override-backed skills from core/extension-backed ones.
- # _unregister_skills can rmtree the skill dir, so overrides must
- # be handled directly (create dir + write) without that call.
- core_ext_skills = []
- override_skills = []
- for item in non_preset_skills:
- if item[2]["source"] == "project override":
- override_skills.append(item)
- else:
- core_ext_skills.append(item)
-
- if core_ext_skills:
- self._unregister_skills(
- [s[0] for s in core_ext_skills], self.presets_dir
+ core_ext_skills = [s for s in non_preset_skills if s[2]["source"] != "project override"]
+ override_skills = [s for s in non_preset_skills if s[2]["source"] == "project override"]
+
+ def apply_to_dir(
+ skills_dir: Path,
+ dir_agent: Optional[str],
+ *,
+ is_active: bool,
+ managed_names: Optional[Set[str]] = None,
+ ) -> None:
+ dir_managed_names = (
+ managed_skill_names if managed_names is None else managed_names
+ )
+ # Restore skills for commands whose winner is non-preset.
+ # _unregister_skills_in_dir can rmtree the skill dir, so
+ # overrides must be handled directly (create dir + write)
+ # without that call.
+ dir_core_ext_names = [
+ candidate
+ for _skill_name, cmd_name, _top_layer in core_ext_skills
+ for candidate in self._skill_names_for_command(cmd_name)
+ if candidate in dir_managed_names
+ ]
+ if dir_core_ext_names:
+ self._unregister_skills_in_dir(
+ dir_core_ext_names, skills_dir, dir_agent
)
- for skill_name, cmd_name, top_layer in override_skills:
- skill_subdir = skills_dir / skill_name
- skill_subdir.mkdir(parents=True, exist_ok=True)
- skill_file = skill_subdir / "SKILL.md"
+ for _skill_name, cmd_name, top_layer in override_skills:
+ target_skill_names = [
+ name
+ for name in self._skill_names_for_command(cmd_name)
+ if name in dir_managed_names
+ ]
+ if not target_skill_names:
+ continue
try:
from ..agents import CommandRegistrar
- from .. import SKILL_DESCRIPTIONS, load_init_options
+ from .. import SKILL_DESCRIPTIONS
+ from ..shared_infra import _write_shared_text
registrar = CommandRegistrar()
content = top_layer["path"].read_text(encoding="utf-8")
fm, body = registrar.parse_frontmatter(content)
@@ -1164,72 +2173,251 @@ def _reconcile_skills(self, command_names: List[str]) -> None:
short_name.replace(".", "-"),
f"Command: {short_name}",
)
- init_opts = load_init_options(self.project_root)
- selected_ai = init_opts.get("ai") if isinstance(init_opts, dict) else ""
- if isinstance(selected_ai, str):
+ selected_ai = dir_agent if isinstance(dir_agent, str) else ""
+ if selected_ai:
body = registrar.resolve_skill_placeholders(
selected_ai, fm, body, self.project_root
)
body = self._resolve_skill_command_refs(
- body, registrar, selected_ai
+ body, registrar, selected_ai, self.project_root
)
from ..integrations import get_integration
- integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
- fm_data = registrar.build_skill_frontmatter(
- selected_ai if isinstance(selected_ai, str) else "",
- skill_name, desc,
- f"override:{cmd_name}",
- )
- registrar.apply_argument_hint(fm, fm_data, integration)
- fm_text = dump_frontmatter(fm_data)
+ integration = get_integration(selected_ai) if selected_ai else None
skill_title = self._skill_title_from_command(cmd_name)
- skill_content = (
- f"---\n{fm_text}\n---\n\n"
- f"# Speckit {skill_title} Skill\n\n{body}\n"
- )
- # Apply integration post-processing (e.g. Claude flags)
- if integration is not None and hasattr(integration, "post_process_skill_content"):
- skill_content = integration.post_process_skill_content(skill_content)
- skill_file.write_text(skill_content, encoding="utf-8")
+ wrote_override = False
+ for target_skill_name in target_skill_names:
+ skill_subdir = skills_dir / target_skill_name
+ # Same symlink guard as _register_skills's
+ # registration path (#2948).
+ if not self._validate_skill_subdir(
+ skill_subdir,
+ create=True,
+ skills_root=skills_dir,
+ ):
+ continue
+ fm_data = registrar.build_skill_frontmatter(
+ selected_ai,
+ target_skill_name,
+ desc,
+ f"override:{cmd_name}",
+ )
+ registrar.apply_argument_hint(
+ fm, fm_data, integration
+ )
+ fm_text = dump_frontmatter(fm_data)
+ skill_content = (
+ f"---\n{fm_text}\n---\n\n"
+ f"# Speckit {skill_title} Skill\n\n{body}\n"
+ )
+ if integration is not None and hasattr(
+ integration, "post_process_skill_content"
+ ):
+ skill_content = (
+ integration.post_process_skill_content(
+ skill_content
+ )
+ )
+ _write_shared_text(
+ skills_dir,
+ skill_subdir / "SKILL.md",
+ skill_content,
+ )
+ wrote_override = True
+ if (
+ wrote_override
+ and (
+ target_agent is None
+ or dir_agent == target_agent
+ )
+ ):
+ reconciled_skill_commands.add(cmd_name)
except Exception:
pass # best-effort override skill restoration
- # Register skills only for the specific commands being reconciled,
- # not all commands in each winning preset's manifest.
- for pack_id, cmds in preset_cmds.items():
- pack_dir = self.presets_dir / pack_id
- manifest_path = pack_dir / "preset.yml"
- if not manifest_path.exists():
- continue
- try:
- manifest = PresetManifest(manifest_path)
- except PresetValidationError:
- continue
- # Filter manifest to only the commands being reconciled
- cmds_set = set(cmds)
- filtered_manifest = self._FilteredManifest(manifest, cmds_set)
- self._register_skills(filtered_manifest, pack_dir)
+ # Register skills only for the specific commands being
+ # reconciled, not all commands in each winning preset's
+ # manifest.
+ for pack_id, cmds in preset_cmds.items():
+ dir_cmds = [
+ cmd
+ for cmd in cmds
+ if any(
+ name in dir_managed_names
+ for name in self._skill_names_for_command(cmd)
+ )
+ ]
+ if not dir_cmds:
+ continue
+ pack_dir = self.presets_dir / pack_id
+ manifest_path = pack_dir / "preset.yml"
+ if not manifest_path.exists():
+ continue
+ try:
+ manifest = PresetManifest(manifest_path)
+ except PresetValidationError:
+ continue
+ cmds_set = set(dir_cmds)
+ filtered_manifest = self._FilteredManifest(manifest, cmds_set)
+ # Not dead code: _register_skills only *overwrites* skill
+ # subdirectories that already exist (plus brand-new ones for
+ # the active ai_skills agent). For a restore into a
+ # historical directory, _unregister_skills has just deleted
+ # the retiring preset's subdirectory, so pre-create the
+ # tracked (dir_managed_names) subdirectories here ā under
+ # the same symlink guard ā or the surviving preset's
+ # override would be silently skipped (#2948).
+ for cmd_name in dir_cmds:
+ for skill_name in self._skill_names_for_command(cmd_name):
+ if skill_name not in dir_managed_names:
+ continue
+ skill_subdir = skills_dir / skill_name
+ if not self._validate_skill_subdir(
+ skill_subdir,
+ create=True,
+ skills_root=skills_dir,
+ ):
+ continue
+ if is_active:
+ # Preserve exact prior behaviour for the currently
+ # active directory (including the ability to create
+ # brand-new skill subdirectories when ai_skills is on).
+ written = self._register_skills(filtered_manifest, pack_dir)
+ else:
+ written = self._register_skills(
+ filtered_manifest, pack_dir,
+ target_dir=skills_dir, target_agent=dir_agent or "",
+ )
+ if target_agent is None:
+ written_names = {
+ name
+ for names in written.values()
+ for name in names
+ }
+ else:
+ written_names = set(written.get(target_agent, []))
+ for cmd_name in dir_cmds:
+ if written_names.intersection(
+ self._skill_names_for_command(cmd_name)
+ ):
+ reconciled_skill_commands.add(cmd_name)
+ # The winning preset may not have previously written to
+ # this directory's agent (most notably a historical agent
+ # reconciliation just restored content into via
+ # extra_skills_dirs). If that write isn't merged back into
+ # the preset's own registered_skills, its registry entry
+ # silently lies about which directories it owns and a
+ # later removal of this same preset orphans the directory
+ # reconciliation just wrote to on its behalf (#2948).
+ self._merge_pack_registered_skills(pack_id, written)
+
+ extra_dirs = extra_skills_dirs or {}
+ if active_skills_dir:
+ active_provenance = extra_dirs.get(active_skills_dir)
+ if extra_skills_dirs is None or active_provenance:
+ apply_to_dir(
+ active_skills_dir,
+ active_ai,
+ is_active=True,
+ managed_names=(
+ set(active_provenance[1])
+ if active_provenance
+ else None
+ ),
+ )
+
+ for extra_dir, (extra_agent, extra_names) in extra_dirs.items():
+ if extra_dir == active_skills_dir:
+ continue # already reconciled above as the active directory
+ apply_to_dir(
+ extra_dir,
+ extra_agent,
+ is_active=False,
+ managed_names=set(extra_names),
+ )
+
+ return reconciled_skill_commands
+
+ def _resolve_agent_skills_dir(self, agent_name: str) -> Path:
+ """Resolve the real skill output directory for an integration."""
+ from .. import _get_skills_dir as _project_skills_dir
+ from ..agents import CommandRegistrar
+
+ registrar = CommandRegistrar()
+ agent_config = registrar.AGENT_CONFIGS.get(agent_name)
+ if agent_config and agent_config.get("extension") == "/SKILL.md":
+ return registrar._resolve_agent_dir(
+ agent_name, agent_config, self.project_root
+ )
+ return _project_skills_dir(self.project_root, agent_name)
+
+ def _skills_validation_root(self, skills_dir: Path) -> Optional[Path]:
+ """Return the trusted root containing a project or user skill dir."""
+ for root in (self.project_root, Path.home()):
+ if skills_dir.is_relative_to(root):
+ return root
+ return None
def _get_skills_dir(self) -> Optional[Path]:
"""Return the active skills directory for preset skill overrides.
- Delegates to :func:`resolve_active_skills_dir` which reads
- init-options, applies the Kimi native-skills fallback, and
- safely creates the directory when ``ai_skills`` is enabled.
+ Uses :func:`resolve_active_skills_dir` for activation/detection,
+ then resolves native skill agents through the registrar's output
+ directory so integrations such as Hermes write to their global
+ skills path rather than their project-local detection marker.
Returns ``None`` (instead of raising) when the directory cannot
be created due to symlink, containment, or permission issues so
that callers can fall back gracefully.
"""
- from .. import resolve_active_skills_dir, _print_cli_warning
+ from .. import (
+ _print_cli_warning,
+ load_init_options,
+ resolve_active_skills_dir,
+ )
+ from ..shared_infra import _ensure_safe_shared_directory
try:
- return resolve_active_skills_dir(self.project_root)
+ skills_dir = resolve_active_skills_dir(self.project_root)
except (ValueError, OSError) as exc:
_print_cli_warning(
"resolve", "skills directory", None, exc,
continuing="Continuing without skill registration.",
)
return None
+ if skills_dir is None:
+ return None
+
+ opts = load_init_options(self.project_root)
+ selected_ai = opts.get("ai") if isinstance(opts, dict) else None
+ if not isinstance(selected_ai, str) or not selected_ai:
+ return skills_dir
+
+ agent_skills_dir = self._resolve_agent_skills_dir(selected_ai)
+ if agent_skills_dir == skills_dir:
+ return skills_dir
+
+ validation_root = self._skills_validation_root(agent_skills_dir)
+ if validation_root is None:
+ _print_cli_warning(
+ "resolve",
+ "skills directory",
+ str(agent_skills_dir),
+ ValueError("skills directory is outside trusted roots"),
+ continuing="Continuing without skill registration.",
+ )
+ return None
+ try:
+ _ensure_safe_shared_directory(
+ validation_root,
+ agent_skills_dir,
+ context="preset skills directory",
+ )
+ except (ValueError, OSError) as exc:
+ _print_cli_warning(
+ "resolve", "skills directory", str(agent_skills_dir), exc,
+ continuing="Continuing without skill registration.",
+ )
+ return None
+ return agent_skills_dir
@staticmethod
def _skill_names_for_command(cmd_name: str) -> tuple[str, str]:
@@ -1252,20 +2440,46 @@ def _skill_title_from_command(cmd_name: str) -> str:
@staticmethod
def _resolve_skill_command_refs(
- body: str, registrar: "CommandRegistrar", selected_ai: str
+ body: str,
+ registrar: "CommandRegistrar",
+ selected_ai: str,
+ project_root: "Path | None" = None,
) -> str:
"""Render ``__SPECKIT_COMMAND_*__`` tokens in a skill body as invocations.
Looks up the agent's invoke separator and rewrites each
``__SPECKIT_COMMAND___`` placeholder into the matching
- slash-command invocation ā ``/speckit-`` for a ``-`` separator,
- ``/speckit.`` for ``.`` ā the same rendering the command layer
- applies via ``CommandRegistrar.register_commands()``.
+ agent-native invocation -- ``/speckit-`` or ``$speckit-`` for
+ a ``-`` separator, ``/speckit.`` for ``.``, or
+ ``/skill:speckit-`` for skill-colon agents (e.g. Kimi) -- the
+ same rendering the command layer applies via
+ ``CommandRegistrar.register_commands()``.
+
+ For dual-layout agents (e.g. Bob) the separator depends on the
+ project's persisted skills state, so -- when *project_root* is provided
+ -- the separator is resolved from the integration via
+ ``invoke_separator_for_mode`` rather than the single static
+ ``AGENT_CONFIGS`` value.
"""
- separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
- "invoke_separator", "."
- )
- return IntegrationBase.resolve_command_refs(body, separator)
+ separator = None
+ if project_root is not None and isinstance(selected_ai, str):
+ try:
+ from .. import load_init_options
+ from ..integrations import get_integration
+
+ integration = get_integration(selected_ai)
+ if integration is not None:
+ separator = integration.invoke_separator_for_mode(
+ is_ai_skills_enabled(load_init_options(project_root))
+ )
+ except Exception:
+ separator = None
+ if separator is None:
+ separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
+ "invoke_separator", "."
+ )
+ prefix = get_invocation_prefix(selected_ai, separator == "-")
+ return IntegrationBase.resolve_command_refs(body, separator, prefix)
def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
"""Index extension-backed skill restore data by skill directory name."""
@@ -1324,7 +2538,10 @@ def _register_skills(
self,
manifest: "PresetManifest",
preset_dir: Path,
- ) -> List[str]:
+ *,
+ target_dir: Optional[Path] = None,
+ target_agent: Optional[str] = None,
+ ) -> Dict[str, List[str]]:
"""Generate SKILL.md files for preset command overrides.
For every command template in the preset, checks whether a
@@ -1337,46 +2554,64 @@ def _register_skills(
Args:
manifest: Preset manifest.
preset_dir: Installed preset directory.
+ target_dir: Explicit skills directory to render into, instead
+ of resolving the currently active one. Used by
+ ``_reconcile_skills`` to restore a surviving preset's
+ override into a historical (currently inactive) agent's
+ directory that removal of a higher-priority preset just
+ reverted (#2948).
+ target_agent: Explicit agent name to render for, paired with
+ ``target_dir``. When set, skills are only ever restored
+ into already-tracked directories/names ā brand-new skill
+ subdirectories are never created for a non-active,
+ explicitly targeted directory (that creation path is only
+ meaningful for the currently active agent).
Returns:
- List of skill names that were written (for registry storage).
+ ``{agent_name: [skill_name, ...]}`` for the single active
+ agent skills were written for (empty if none were written),
+ matching the shape ``registered_commands`` already uses so the
+ two can be tracked/restored consistently (#2948).
"""
command_templates = [
t for t in manifest.templates if t.get("type") == "command"
]
if not command_templates:
- return []
+ return {}
# Filter out extension command overrides if the extension isn't installed,
# matching the same logic used by _register_commands().
- extensions_dir = self.project_root / ".specify" / "extensions"
- filtered = []
- for cmd in command_templates:
- parts = cmd["name"].split(".")
- if len(parts) >= 3 and parts[0] == "speckit":
- ext_id = parts[1]
- if not (extensions_dir / ext_id).is_dir():
- continue
- filtered.append(cmd)
+ filtered = [
+ cmd
+ for cmd in command_templates
+ if self._extension_installed_for_command(cmd["name"])
+ ]
if not filtered:
- return []
+ return {}
- skills_dir = self._get_skills_dir()
+ skills_dir = target_dir if target_dir is not None else self._get_skills_dir()
if not skills_dir:
- return []
+ return {}
from .. import SKILL_DESCRIPTIONS, load_init_options
from ..agents import CommandRegistrar
from ..integrations import get_integration
+ from ..shared_infra import _write_shared_text
init_opts = load_init_options(self.project_root)
if not isinstance(init_opts, dict):
init_opts = {}
- selected_ai = init_opts.get("ai")
- if not isinstance(selected_ai, str):
- return []
- ai_skills_enabled = is_ai_skills_enabled(init_opts)
+ selected_ai = target_agent if target_agent is not None else init_opts.get("ai")
+ if not isinstance(selected_ai, str) or not selected_ai:
+ return {}
+ # A target_dir/target_agent call reconciles an explicitly-known,
+ # already-tracked directory (see _reconcile_skills) rather than the
+ # currently active agent, so ai_skills_enabled must not be derived
+ # from the *current* project-wide toggle for that other agent ā it
+ # only controls whether brand-new skill subdirectories may be
+ # created below, which is only meaningful for the active agent.
+ ai_skills_enabled = target_agent is None and is_ai_skills_enabled(init_opts)
registrar = CommandRegistrar()
integration = get_integration(selected_ai)
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
@@ -1445,13 +2680,21 @@ def _register_skills(
body = registrar.resolve_skill_placeholders(
selected_ai, frontmatter, body, self.project_root
)
- body = self._resolve_skill_command_refs(body, registrar, selected_ai)
+ body = self._resolve_skill_command_refs(body, registrar, selected_ai, self.project_root)
for target_skill_name in target_skill_names:
skill_subdir = skills_dir / target_skill_name
if skill_subdir.exists() and not skill_subdir.is_dir():
continue
- skill_subdir.mkdir(parents=True, exist_ok=True)
+ # Validate (and create, if missing) the skill's own
+ # subdirectory under the same symlink guard as its parent ā
+ # is_dir() above follows symlinks, so a symlinked subdir
+ # with a real parent would otherwise slip through and have
+ # SKILL.md written through it to an arbitrary location (#2948).
+ if not self._validate_skill_subdir(
+ skill_subdir, create=True, skills_root=skills_dir
+ ):
+ continue
frontmatter_data = registrar.build_skill_frontmatter(
selected_ai,
target_skill_name,
@@ -1473,44 +2716,499 @@ def _register_skills(
)
skill_file = skill_subdir / "SKILL.md"
- skill_file.write_text(skill_content, encoding="utf-8")
+ _write_shared_text(
+ skills_dir, skill_file, skill_content
+ )
written.append(target_skill_name)
+ self._merge_pack_registered_skills(
+ manifest.id, {selected_ai: [target_skill_name]}
+ )
+
+ return {selected_ai: written} if written else {}
- return written
+ def _infer_legacy_skill_provenance(
+ self, skill_names: List[str], pack_id: str, fallback_agent: str
+ ) -> Dict[str, List[str]]:
+ """Infer per-agent ownership of a legacy flat-list ``registered_skills`` value.
+
+ Pre-#2948 registries recorded ``registered_skills`` as a flat list
+ with no record of which agent directory each name was actually
+ written under. Blindly attributing every name to ``fallback_agent``
+ (the agent currently being processed) loses the real writer whenever
+ the *first* operation after upgrading is a direct switch to a
+ *different* agent ā e.g. a legacy Copilot override (written while
+ Copilot was active with ``ai_skills`` enabled) followed directly by
+ ``integration use claude``, with no intervening rescaffold for
+ Copilot ā permanently orphaning Copilot's override on later
+ removal.
+
+ Every project-local configured integration's skills directory is probed (via
+ the same safe, symlink-validated helpers used for
+ restore/removal), not only agents whose registrar config is
+ statically ``/SKILL.md``-only: a command-backed agent (e.g.
+ Copilot, whose command extension is ``.agent.md``) renders its
+ preset overrides as ``SKILL.md`` files exactly like a native
+ skill-only agent whenever it was the active agent with
+ ``ai_skills`` enabled, so excluding it would miss real,
+ preset-owned provenance and misattribute it to whichever agent
+ happens to be processed first. Each directory is probed for a
+ ``SKILL.md`` whose frontmatter records this exact preset as the
+ owner (``metadata.source == "preset:"``, the same marker
+ :meth:`_register_skills` writes) ā this marker check is what keeps
+ the broadened probe from falsely attributing ownership to an
+ agent's directory that never actually held this preset's override
+ (e.g. a command-mode agent that never rendered skills, or an
+ unrelated skill of the same name). A name can legitimately be
+ found under more than one agent's directory ā the preset may have
+ been active while the user switched between several agents before
+ provenance tracking existed ā so every matching agent is recorded,
+ not just the first. Names that can't be matched to any directory
+ (e.g. the file was deleted out of band) fall back to
+ ``fallback_agent``, preserving the previous best-effort behaviour
+ for the unrecoverable case.
+ """
+ from ..agents import CommandRegistrar
- def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
+ registrar = CommandRegistrar()
+ candidate_agents = sorted(registrar.AGENT_CONFIGS)
+
+ # Multiple agent names can resolve to the same physical directory
+ # (e.g. agy/amp/codex/zed all use .agents/skills); group by
+ # directory so each is probed once and attributed to a single
+ # deterministic canonical agent name, matching the tie-break
+ # already used by _unregister_skills's directory grouping. Deliberately
+ # keep the unresolved path (matching what _safe_skills_dir_for_agent
+ # already validated) rather than calling .resolve() here: on macOS
+ # /var is itself a symlink to /private/var, so resolving would make
+ # this path diverge from self.project_root's own resolution state
+ # and make every subsequent containment check in
+ # _validate_skill_subdir() spuriously fail.
+ dir_to_agents: Dict[Path, List[str]] = {}
+ for agent_name in candidate_agents:
+ skills_dir = self._safe_skills_dir_for_agent(agent_name)
+ if skills_dir is None:
+ continue
+ # Only project-local skills directories are eligible: the
+ # legacy provenance markers don't record which project owns a
+ # skill under a home directory, so deletion stays restricted
+ # to the project root. Revisit if provenance ever records the
+ # owning project.
+ if not Path(os.path.abspath(skills_dir)).is_relative_to(
+ Path(os.path.abspath(self.project_root))
+ ):
+ continue
+ dir_to_agents.setdefault(skills_dir, []).append(agent_name)
+
+ marker = f"preset:{pack_id}"
+ # Filter unsafe names once, up front, rather than only inside the
+ # matching loop: any name skipped there would otherwise still
+ # land in "unmatched" below and get blindly attributed to
+ # fallback_agent anyway, defeating the guard entirely (#2948).
+ safe_skill_names = [
+ name for name in skill_names if self._is_safe_registry_skill_name(name)
+ ]
+ inferred: Dict[str, List[str]] = {}
+ matched_names: set = set()
+ for resolved_dir, agents in dir_to_agents.items():
+ canonical_agent = fallback_agent if fallback_agent in agents else sorted(agents)[0]
+ for name in safe_skill_names:
+ skill_subdir = resolved_dir / name
+ if not self._validate_skill_subdir(
+ skill_subdir, create=False, skills_root=resolved_dir
+ ):
+ continue
+ skill_file = skill_subdir / "SKILL.md"
+ if not skill_file.is_file():
+ continue
+ try:
+ content = skill_file.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError):
+ continue
+ frontmatter, _ = registrar.parse_frontmatter(content)
+ skill_metadata = frontmatter.get("metadata")
+ source = (
+ skill_metadata.get("source")
+ if isinstance(skill_metadata, dict)
+ else None
+ )
+ if source == marker:
+ inferred.setdefault(canonical_agent, []).append(name)
+ matched_names.add(name)
+
+ unmatched = [name for name in safe_skill_names if name not in matched_names]
+ if unmatched and fallback_agent:
+ fallback_names = inferred.setdefault(fallback_agent, [])
+ for name in unmatched:
+ if name not in fallback_names:
+ fallback_names.append(name)
+
+ return inferred
+
+ @staticmethod
+ def _normalize_registered_skills(
+ value: Any, fallback_agent: Optional[str] = None
+ ) -> Dict[str, List[str]]:
+ """Normalize a ``registered_skills`` registry value to per-agent form.
+
+ The registry stores ``registered_skills`` as ``Dict[str, List[str]]``
+ (agent name -> skill names actually written for that agent),
+ mirroring ``registered_commands``. Older registries predate that
+ provenance and stored a flat ``List[str]`` with no record of which
+ agent directory the names were written under; since that can't be
+ recovered, ``fallback_agent`` (when given) attributes the legacy
+ list to the agent currently being processed so the format
+ self-migrates on the next write. Without a fallback agent, legacy
+ lists are dropped rather than guessed at.
+
+ Callers that can identify the owning preset (i.e. have a
+ ``pack_id``) should prefer :meth:`_infer_legacy_skill_provenance`
+ for a legacy flat-list value instead, which probes on-disk
+ provenance rather than assuming ``fallback_agent`` wrote every name.
+ """
+ if isinstance(value, dict):
+ return {
+ agent: list(names)
+ for agent, names in value.items()
+ if isinstance(agent, str) and isinstance(names, list)
+ }
+ if isinstance(value, list) and value and fallback_agent:
+ return {fallback_agent: [n for n in value if isinstance(n, str)]}
+ return {}
+
+ def _safe_skills_dir_for_agent(self, agent_name: str) -> Optional[Path]:
+ """Resolve ``agent_name``'s skills directory, validated for safety.
+
+ Unlike :meth:`_get_skills_dir` (which resolves only the *currently
+ active* integration via init-options), this resolves an arbitrary
+ agent's directory from persisted provenance so a preset's skill
+ registrations can be restored/cleaned up under an agent that isn't
+ currently active. The candidate directory is validated through the
+ project's shared symlink/containment guard before any file in it is
+ touched; directories that don't exist or fail validation are
+ skipped rather than raising.
+ """
+ from ..agents import CommandRegistrar
+ from ..shared_infra import _ensure_safe_shared_directory
+
+ if agent_name not in CommandRegistrar.AGENT_CONFIGS:
+ return None
+ skills_dir = self._resolve_agent_skills_dir(agent_name)
+ validation_root = self._skills_validation_root(skills_dir)
+ if validation_root is None:
+ return None
+ try:
+ _ensure_safe_shared_directory(
+ validation_root, skills_dir,
+ create=False, context="preset skills directory",
+ )
+ except (ValueError, OSError):
+ return None
+ return skills_dir
+
+ @staticmethod
+ def _is_safe_registry_skill_name(name: Any) -> bool:
+ """Validate a registry-provided skill name is a single safe path component.
+
+ ``registered_skills`` entries are persisted registry data, not
+ derived from the current preset manifest, so a corrupted or
+ maliciously edited registry could contain an absolute path, a
+ multi-segment path (containing ``/`` or ``\\``), or a traversal
+ component (``"."``/``".."``) instead of a plain skill directory
+ name. Any of these ā if joined directly onto a skills directory ā
+ can escape the intended skill subtree while still resolving to a
+ location inside the project root, which is enough to pass the
+ parent-directory containment/symlink check alone (#2948). This
+ centralizes the single boundary check every preset cleanup and
+ provenance loop that consumes registry-provided skill names must
+ apply before ever constructing a path from one.
+ """
+ if not isinstance(name, str) or not name:
+ return False
+ if name in (".", ".."):
+ return False
+ candidate = Path(name)
+ if candidate.is_absolute():
+ return False
+ if len(candidate.parts) != 1:
+ return False
+ if candidate.name != name:
+ return False
+ return True
+
+ def _validate_skill_subdir(
+ self,
+ skill_subdir: Path,
+ *,
+ create: bool,
+ skills_root: Optional[Path] = None,
+ ) -> bool:
+ """Validate a single skill's subdirectory is symlink-free.
+
+ Unlike :meth:`_safe_skills_dir_for_agent` (which only validates the
+ *parent* skills directory), this validates the skill's own
+ subdirectory ā e.g. ``.claude/skills/speckit-specify`` ā so a
+ symlink planted at that level (with a safe parent) can't be used to
+ write or delete through to a location outside the project. Shared by
+ both the registration path (``create=True``, so a missing directory
+ is created component-by-component under the same guard) and the
+ restore/removal path (``create=False``, so a missing directory is
+ left for the caller's own existence check to skip). Returns
+ ``False`` rather than raising when the path escapes the project
+ root or crosses a symlink. ``skills_root`` supplies the trusted
+ agent output boundary for native global skill integrations such as
+ Hermes; project-local callers default to ``self.project_root``.
+ """
+ from ..shared_infra import _ensure_safe_shared_directory, _validate_safe_shared_directory
+
+ validation_root = skills_root or self.project_root
+ if validation_root.is_symlink():
+ return False
+ try:
+ if create:
+ _ensure_safe_shared_directory(
+ validation_root, skill_subdir,
+ create=True, context="preset skill directory",
+ )
+ else:
+ _validate_safe_shared_directory(
+ validation_root, skill_subdir
+ )
+ except (ValueError, OSError):
+ return False
+ return True
+
+ def _unregister_skills(
+ self,
+ registered_skills: Union[Dict[str, List[str]], List[str]],
+ preset_dir: Union[Path, str],
+ *,
+ additional_owned_sources: Optional[Dict[str, str]] = None,
+ ) -> Dict[Path, tuple[Optional[str], List[str]]]:
"""Restore original SKILL.md files after a preset is removed.
For each skill that was overridden by the preset, attempts to
regenerate the skill from the core command template. If no core
template exists, the skill directory is removed.
+ ``registered_skills`` records exactly which agent directories this
+ preset actually wrote to (see :meth:`_register_skills`), so removal
+ restores precisely those directories rather than guessing at every
+ skill-mode agent that happens to exist on disk. Each directory is
+ re-resolved and safety-validated at removal time (see
+ :meth:`_safe_skills_dir_for_agent`) since it may belong to an agent
+ that isn't currently active.
+
Args:
- skill_names: List of skill names written by the preset.
+ registered_skills: Per-agent skill names written by the preset
+ (``{agent_name: [skill_name, ...]}``), or a legacy flat
+ ``List[str]`` from a registry written before this
+ provenance tracking existed.
preset_dir: The preset's installed directory (may already be deleted).
+ additional_owned_sources: Generated non-preset source markers
+ that this cleanup may also replace for specific skill names.
+
+ Returns:
+ ``{skills_dir: (renderer_agent, managed_skill_names)}`` for
+ every directory and skill name actually restored or removed.
"""
- if not skill_names:
- return
+ if not registered_skills:
+ return {}
+
+ pack_id = preset_dir if isinstance(preset_dir, str) else preset_dir.name
+
+ if isinstance(registered_skills, dict):
+ from .. import load_init_options
+
+ init_opts = load_init_options(self.project_root)
+ active_agent = init_opts.get("ai") if isinstance(init_opts, dict) else None
+ if not isinstance(active_agent, str) or not active_agent:
+ active_agent = None
+
+ # Multiple integration keys can share the same physical
+ # directory (e.g. agy/codex/zed all resolve to
+ # ``.agents/skills``). Restoring that directory once per
+ # recorded agent would have each pass's agent-specific
+ # rendering (frontmatter, post-processing) overwrite the
+ # previous one, with whichever agent is iterated *last* silently
+ # winning regardless of which agent is actually active. Group
+ # provenance by resolved directory so each physical directory is
+ # restored exactly once, using the active agent's renderer when
+ # it shares that directory (otherwise any recorded owner,
+ # chosen deterministically).
+ groups: Dict[Path, Dict[str, Any]] = {}
+ for agent_name, skill_names in registered_skills.items():
+ if not skill_names:
+ continue
+ skills_dir = self._safe_skills_dir_for_agent(agent_name)
+ if skills_dir is None:
+ continue
+ group = groups.setdefault(skills_dir, {"agents": [], "names": []})
+ group["agents"].append(agent_name)
+ for name in skill_names:
+ if (
+ self._is_safe_registry_skill_name(name)
+ and name not in group["names"]
+ ):
+ group["names"].append(name)
+
+ restored: Dict[Path, tuple[Optional[str], List[str]]] = {}
+ for skills_dir, group in groups.items():
+ agents = group["agents"]
+ renderer_agent = (
+ active_agent if active_agent in agents else sorted(agents)[0]
+ )
+ mutated_names = self._unregister_skills_in_dir(
+ group["names"],
+ skills_dir,
+ renderer_agent,
+ pack_id=pack_id,
+ additional_owned_sources=additional_owned_sources,
+ )
+ if mutated_names:
+ restored[skills_dir] = (
+ renderer_agent,
+ mutated_names,
+ )
+ return restored
+ # Legacy flat-list format: no record of which agent directory these
+ # names were written under, so best-effort restore is limited to the
+ # currently active agent's directory (the pre-provenance behaviour).
skills_dir = self._get_skills_dir()
if not skills_dir:
+ return {}
+ from .. import load_init_options
+
+ init_opts = load_init_options(self.project_root)
+ if not isinstance(init_opts, dict):
+ init_opts = {}
+ selected_ai = init_opts.get("ai")
+ selected_ai = selected_ai if isinstance(selected_ai, str) else None
+ safe_names = [
+ name
+ for name in registered_skills
+ if self._is_safe_registry_skill_name(name)
+ ]
+ mutated_names = self._unregister_skills_in_dir(
+ safe_names,
+ skills_dir,
+ selected_ai,
+ pack_id=pack_id,
+ additional_owned_sources=additional_owned_sources,
+ )
+ return (
+ {skills_dir: (selected_ai, mutated_names)}
+ if mutated_names
+ else {}
+ )
+
+ def _delete_agent_preset_skills(
+ self, agent_name: str, skill_names: List[str], pack_id: str
+ ) -> None:
+ """Delete still-preset-owned skills when an agent is deactivated."""
+ skills_dir = self._safe_skills_dir_for_agent(agent_name)
+ if skills_dir is None:
return
- from .. import SKILL_DESCRIPTIONS, load_init_options
+ from ..agents import CommandRegistrar
+
+ registrar = CommandRegistrar()
+ marker = f"preset:{pack_id}"
+ override_sources: Dict[str, str] = {}
+ manifest = PresetResolver(self.project_root)._get_manifest(
+ self.presets_dir / pack_id
+ )
+ if manifest is not None:
+ for template in manifest.templates:
+ command_name = template.get("name")
+ if (
+ template.get("type") == "command"
+ and isinstance(command_name, str)
+ ):
+ for skill_name in self._skill_names_for_command(
+ command_name
+ ):
+ override_sources[skill_name] = (
+ f"override:{command_name}"
+ )
+ for skill_name in skill_names:
+ if not self._is_safe_registry_skill_name(skill_name):
+ continue
+ skill_subdir = skills_dir / skill_name
+ if not self._validate_skill_subdir(
+ skill_subdir, create=False, skills_root=skills_dir
+ ):
+ continue
+ skill_file = skill_subdir / "SKILL.md"
+ if not skill_file.is_file():
+ continue
+ try:
+ content = skill_file.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError):
+ continue
+ frontmatter, _ = registrar.parse_frontmatter(content)
+ metadata = frontmatter.get("metadata")
+ source = (
+ metadata.get("source")
+ if isinstance(metadata, dict)
+ else None
+ )
+ owned_sources = {marker}
+ override_source = override_sources.get(skill_name)
+ if override_source:
+ owned_sources.add(override_source)
+ if source in owned_sources:
+ shutil.rmtree(skill_subdir)
+
+ def _unregister_skills_in_dir(
+ self,
+ skill_names: List[str],
+ skills_dir: Path,
+ selected_ai: Optional[str],
+ *,
+ pack_id: Optional[str] = None,
+ additional_owned_sources: Optional[Dict[str, str]] = None,
+ ) -> List[str]:
+ """Restore original SKILL.md files within a single skills directory.
+
+ Args:
+ skill_names: List of skill names written by the preset.
+ skills_dir: The skills directory to restore within.
+ selected_ai: The agent name that owns ``skills_dir``, used for
+ placeholder resolution and argument-hint formatting.
+ additional_owned_sources: Generated non-preset source markers
+ accepted as owned for specific skill names.
+
+ Returns:
+ Skill names whose files were restored or removed.
+ """
+ from .. import SKILL_DESCRIPTIONS
from ..agents import CommandRegistrar
from ..integrations import get_integration
+ from ..shared_infra import _write_shared_text
# Locate core command templates from the project's installed templates
core_templates_dir = self.project_root / ".specify" / "templates" / "commands"
- init_opts = load_init_options(self.project_root)
- if not isinstance(init_opts, dict):
- init_opts = {}
- selected_ai = init_opts.get("ai")
registrar = CommandRegistrar()
integration = get_integration(selected_ai) if isinstance(selected_ai, str) else None
extension_restore_index = self._build_extension_skill_restore_index()
+ mutated_names: List[str] = []
for skill_name in skill_names:
+ # Guard against a corrupted/malicious registry entry: a
+ # registered_skills name is persisted data, not derived from
+ # the current manifest, so it must be validated as a single,
+ # relative, non-"."/".." path component before ever being
+ # joined onto skills_dir. Without this, an absolute name
+ # discards skills_dir entirely (Path's "/" operator drops the
+ # left side for an absolute right side) or a multi-component
+ # name containing ".." can resolve to a different, unrelated
+ # directory that still happens to be inside the project root
+ # ā passing the containment-only symlink guard below and
+ # letting removal overwrite/delete it (#2948).
+ if not self._is_safe_registry_skill_name(skill_name):
+ continue
+
# Derive command name from skill name (speckit-specify -> specify)
short_name = skill_name
if short_name.startswith("speckit-"):
@@ -1522,9 +3220,38 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
skill_file = skill_subdir / "SKILL.md"
if not skill_subdir.is_dir():
continue
+ # is_dir() follows symlinks, so a symlinked skill subdirectory
+ # (with a safe, non-symlinked parent) would otherwise slip past
+ # _safe_skills_dir_for_agent's parent-only check and have
+ # write_text/rmtree operate through it (#2948).
+ if not self._validate_skill_subdir(
+ skill_subdir, create=False, skills_root=skills_dir
+ ):
+ continue
if not skill_file.is_file():
# Only manage directories that contain the expected skill entrypoint.
continue
+ if pack_id is not None:
+ try:
+ current_content = skill_file.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError):
+ continue
+ current_frontmatter, _ = registrar.parse_frontmatter(current_content)
+ current_metadata = current_frontmatter.get("metadata")
+ current_source = (
+ current_metadata.get("source")
+ if isinstance(current_metadata, dict)
+ else None
+ )
+ owned_sources = {f"preset:{pack_id}"}
+ if additional_owned_sources:
+ additional_source = additional_owned_sources.get(
+ skill_name
+ )
+ if additional_source:
+ owned_sources.add(additional_source)
+ if current_source not in owned_sources:
+ continue
# Try to find the core command template
core_file = core_templates_dir / f"{short_name}.md" if core_templates_dir.exists() else None
@@ -1540,7 +3267,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
selected_ai, frontmatter, body, self.project_root
)
body = self._resolve_skill_command_refs(
- body, registrar, selected_ai
+ body, registrar, selected_ai, self.project_root
)
original_desc = frontmatter.get("description", "")
@@ -1569,7 +3296,8 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
skill_content = integration.post_process_skill_content(
skill_content
)
- skill_file.write_text(skill_content, encoding="utf-8")
+ _write_shared_text(skills_dir, skill_file, skill_content)
+ mutated_names.append(skill_name)
continue
extension_restore = extension_restore_index.get(skill_name)
@@ -1592,7 +3320,7 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
selected_ai, frontmatter, body, self.project_root
)
body = self._resolve_skill_command_refs(
- body, registrar, selected_ai
+ body, registrar, selected_ai, self.project_root
)
command_name = extension_restore["command_name"]
@@ -1617,10 +3345,14 @@ def _unregister_skills(self, skill_names: List[str], preset_dir: Path) -> None:
skill_content = integration.post_process_skill_content(
skill_content
)
- skill_file.write_text(skill_content, encoding="utf-8")
+ _write_shared_text(skills_dir, skill_file, skill_content)
+ mutated_names.append(skill_name)
else:
# No core or extension template ā remove the skill entirely
shutil.rmtree(skill_subdir)
+ mutated_names.append(skill_name)
+
+ return mutated_names
def install_from_directory(
self,
@@ -1672,11 +3404,11 @@ def install_from_directory(
"enabled": True,
"priority": priority,
"registered_commands": {},
- "registered_skills": [],
+ "registered_skills": {},
})
registered_commands: Dict[str, List[str]] = {}
- registered_skills: List[str] = []
+ registered_skills: Dict[str, List[str]] = {}
try:
# Register command overrides with AI agents and persist the result
# immediately so cleanup can recover even if installation stops
@@ -1693,16 +3425,17 @@ def install_from_directory(
"registered_skills": registered_skills,
})
except Exception:
- # Roll back all side effects. Note: if _register_commands or
- # _register_skills raised mid-way (e.g. I/O error after writing
- # some files), registered_commands/registered_skills may be empty
- # and some agent command files could be orphaned. Removing dest_dir
- # (which contains .composed/) and the registry entry ensures the
- # preset system is consistent even if orphaned files remain.
+ # Roll back all side effects. _register_skills persists each
+ # successful write immediately, so reload that partial map when
+ # a later template fails before the call can return.
if registered_commands:
self._unregister_commands(registered_commands)
- if registered_skills:
- self._unregister_skills(registered_skills, dest_dir)
+ persisted_metadata = self.registry.get(manifest.id) or {}
+ persisted_skills = persisted_metadata.get(
+ "registered_skills", registered_skills
+ )
+ if persisted_skills:
+ self._unregister_skills(persisted_skills, dest_dir)
try:
if dest_dir.exists():
shutil.rmtree(dest_dir)
@@ -1713,20 +3446,11 @@ def install_from_directory(
# Reconcile all affected commands from the full priority stack so that
# install order doesn't determine the winning command file.
- # Apply the same extension-installed filter as _register_commands to
- # avoid reconciling extension commands when the extension isn't installed.
- extensions_dir = self.project_root / ".specify" / "extensions"
- cmd_names = []
- for t in manifest.templates:
- if t.get("type") != "command":
- continue
- name = t["name"]
- parts = name.split(".")
- if len(parts) >= 3 and parts[0] == "speckit":
- ext_id = parts[1]
- if not (extensions_dir / ext_id).is_dir():
- continue
- cmd_names.append(name)
+ cmd_names = [
+ t["name"]
+ for t in manifest.templates
+ if t.get("type") == "command"
+ ]
if cmd_names:
try:
self._reconcile_composed_commands(cmd_names)
@@ -1833,18 +3557,7 @@ def install_from_zip(
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
- with zipfile.ZipFile(zip_path, 'r') as zf:
- temp_path_resolved = temp_path.resolve()
- for member in zf.namelist():
- member_path = (temp_path / member).resolve()
- try:
- member_path.relative_to(temp_path_resolved)
- except ValueError:
- raise PresetValidationError(
- f"Unsafe path in ZIP archive: {member} "
- "(potential path traversal)"
- )
- zf.extractall(temp_path)
+ safe_extract_zip(zip_path, temp_path, error_type=PresetValidationError)
pack_dir = temp_path
manifest_path = pack_dir / "preset.yml"
@@ -1877,13 +3590,64 @@ def remove(self, pack_id: str) -> bool:
metadata = self.registry.get(pack_id)
# Restore original skills when preset is removed
registered_skills = metadata.get("registered_skills", []) if metadata else []
+ if isinstance(registered_skills, list) and registered_skills:
+ # Legacy flat-list registries predate per-agent provenance
+ # tracking. Migration to the per-agent dict form previously
+ # only happened during a rescaffold (register_enabled_presets_
+ # for_agent); if the *first* post-upgrade operation is instead
+ # `preset remove` (no intervening use/upgrade), the legacy
+ # branch of _unregister_skills restores only the currently
+ # active agent's directory, leaving this preset's overrides in
+ # every previously active agent's directory orphaned. Infer
+ # real per-agent ownership from the on-disk preset marker now,
+ # while pack_id is still known, and hand the resulting mapping
+ # through the same dict-based cleanup path already used for
+ # non-legacy registries (#2948).
+ from .. import load_init_options
+
+ init_opts = load_init_options(self.project_root)
+ fallback_agent = init_opts.get("ai") if isinstance(init_opts, dict) else None
+ if not isinstance(fallback_agent, str):
+ fallback_agent = ""
+ registered_skills = self._infer_legacy_skill_provenance(
+ [name for name in registered_skills if isinstance(name, str)],
+ pack_id,
+ fallback_agent=fallback_agent,
+ )
registered_commands = metadata.get("registered_commands", {}) if metadata else {}
pack_dir = self.presets_dir / pack_id
+ # Record which historical agents this preset's registered_commands
+ # actually targeted, *before* any filtering below, so post-removal
+ # reconciliation can restore a surviving lower-priority preset's
+ # override into every one of those directories too ā not only the
+ # currently active agent's. Without this, removing a preset that
+ # was rendered under a previously-active (now inactive) agent
+ # deletes that agent's command file via _unregister_commands below,
+ # but active-only reconciliation would only recreate the surviving
+ # winner for the current agent, leaving the inactive integration
+ # with a missing/stale file (#2948).
+ try:
+ from ..agents import CommandRegistrar as _CommandRegistrarForScope
+ except ImportError:
+ _CommandRegistrarForScope = None
+ affected_command_agents = {
+ agent_name
+ for agent_name in registered_commands
+ if _CommandRegistrarForScope is None
+ or _CommandRegistrarForScope.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md"
+ }
+
# Collect ALL command names before filtering for reconciliation,
- # so commands registered only for skill-based agents are also reconciled.
- # Also include aliases from the manifest as a safety net for registries
- # populated by older versions that may not track aliases.
+ # so commands registered only for skill-based agents are also
+ # reconciled. Every command-type template's primary name is added
+ # unconditionally (not just aliases) since ai_skills-mode presets
+ # never populate registered_commands for command-backed
+ # integrations (see _register_commands's ai_skills guard) ā without
+ # this, removing a skills-mode preset that overrides a command no
+ # other preset registered "the normal way" would skip reconciliation
+ # entirely and _unregister_skills would restore core/extension
+ # content instead of a surviving lower-priority preset's override.
removed_cmd_names = set()
removed_constitution = any(
path.exists()
@@ -1917,6 +3681,9 @@ def remove(self, pack_id: str) -> bool:
):
removed_constitution = True
if tmpl.get("type") == "command":
+ name = tmpl.get("name")
+ if isinstance(name, str):
+ removed_cmd_names.add(name)
for alias in tmpl.get("aliases", []):
if isinstance(alias, str):
removed_cmd_names.add(alias)
@@ -1925,18 +3692,112 @@ def remove(self, pack_id: str) -> bool:
# names from registered_commands are still unregistered.
pass
+ affected_skill_dirs: Dict[
+ Path, tuple[Optional[str], List[str]]
+ ] = {}
if registered_skills:
- self._unregister_skills(registered_skills, pack_dir)
+ restorable_skills = registered_skills
+ # A skill tracked for a command-backed agent whose ai_skills is
+ # now off is a leftover from a partially failed skillsācommand
+ # toggle. Restoring it via _unregister_skills (and letting
+ # _reconcile_skills reapply a surviving lower preset through
+ # extra_skills_dirs) would hand the active command-mode agent a
+ # skill artifact it must not have ā its current representation
+ # is the command file handled via registered_commands above.
+ # Delete the preset-owned skill instead and keep its directory
+ # out of restoration/reconciliation entirely (#2948). Inactive
+ # agents' entries still restore as before.
+ # The legacy branch above locally imports load_init_options,
+ # shadowing the module-level name for this whole function.
+ from .._init_options import load_init_options as _load_init_options
+
+ resolved_active = resolve_active_agent_for_registration(
+ self.project_root
+ )
+ if (
+ isinstance(registered_skills, dict)
+ and isinstance(resolved_active, str)
+ and resolved_active in registered_skills
+ and _CommandRegistrarForScope is not None
+ and _CommandRegistrarForScope.AGENT_CONFIGS.get(
+ resolved_active, {}
+ ).get("extension") != "/SKILL.md"
+ and not is_ai_skills_enabled(
+ _load_init_options(self.project_root)
+ )
+ ):
+ raw_names = registered_skills.get(resolved_active)
+ stale_names = [
+ name
+ for name in (
+ raw_names if isinstance(raw_names, list) else []
+ )
+ if isinstance(name, str)
+ ]
+ restorable_skills = {
+ agent_name: names
+ for agent_name, names in registered_skills.items()
+ if agent_name != resolved_active
+ }
+ if stale_names:
+ self._delete_agent_preset_skills(
+ resolved_active, stale_names, pack_id
+ )
+ override_sources = {
+ skill_name: f"override:{command_name}"
+ for command_name in removed_cmd_names
+ for skill_name in self._skill_names_for_command(command_name)
+ }
+ affected_skill_dirs = self._unregister_skills(
+ restorable_skills,
+ pack_dir,
+ additional_owned_sources=override_sources,
+ )
try:
from ..agents import CommandRegistrar
except ImportError:
CommandRegistrar = None
if CommandRegistrar is not None:
- registered_commands = {
- agent_name: cmd_names
- for agent_name, cmd_names in registered_commands.items()
- if CommandRegistrar.AGENT_CONFIGS.get(agent_name, {}).get("extension") != "/SKILL.md"
- }
+ skill_coverage = (
+ registered_skills
+ if isinstance(registered_skills, dict)
+ else {}
+ )
+ commands_to_unregister: Dict[str, List[str]] = {}
+ for agent_name, cmd_names in registered_commands.items():
+ is_native_skill_agent = (
+ CommandRegistrar.AGENT_CONFIGS.get(
+ agent_name, {}
+ ).get("extension")
+ == "/SKILL.md"
+ )
+ if not is_native_skill_agent:
+ commands_to_unregister[agent_name] = cmd_names
+ continue
+
+ raw_skill_names = skill_coverage.get(agent_name, [])
+ covered_skill_names = {
+ name
+ for name in (
+ raw_skill_names
+ if isinstance(raw_skill_names, list)
+ else []
+ )
+ if isinstance(name, str)
+ }
+ uncovered_commands = [
+ cmd_name
+ for cmd_name in cmd_names
+ if not isinstance(cmd_name, str)
+ or covered_skill_names.isdisjoint(
+ self._skill_names_for_command(cmd_name)
+ )
+ ]
+ if uncovered_commands:
+ commands_to_unregister[agent_name] = (
+ uncovered_commands
+ )
+ registered_commands = commands_to_unregister
# Unregister non-skill command files from AI agents.
if registered_commands:
@@ -1951,8 +3812,12 @@ def remove(self, pack_id: str) -> bool:
# re-resolve from the remaining stack so the next layer takes effect.
if removed_cmd_names:
try:
- self._reconcile_composed_commands(list(removed_cmd_names))
- self._reconcile_skills(list(removed_cmd_names))
+ self._reconcile_composed_commands(
+ list(removed_cmd_names), extra_agents=affected_command_agents
+ )
+ self._reconcile_skills(
+ list(removed_cmd_names), extra_skills_dirs=affected_skill_dirs
+ )
except Exception as exc:
import warnings
warnings.warn(
@@ -2108,13 +3973,22 @@ def _open_url(
url: str,
timeout: int = 10,
extra_headers: Optional[Dict[str, str]] = None,
+ redirect_validator=None,
):
"""Open a URL with provider-based auth, trying each configured provider.
Delegates to :func:`specify_cli.authentication.http.open_url`.
+ *redirect_validator*, when provided, is invoked as ``(old_url, new_url)``
+ before EACH redirect hop, so an HTTPS host guarantee can be enforced on
+ every intermediate URL, not just the terminal one.
"""
from specify_cli.authentication.http import open_url
- return open_url(url, timeout, extra_headers=extra_headers)
+ return open_url(
+ url,
+ timeout,
+ extra_headers=extra_headers,
+ redirect_validator=redirect_validator,
+ )
def _resolve_github_release_asset_api_url(
self,
@@ -2235,7 +4109,10 @@ def _load_catalog_config(self, config_path: Path) -> Optional[List[PresetCatalog
)
try:
priority = int(raw_priority)
- except (TypeError, ValueError):
+ except (TypeError, ValueError, OverflowError):
+ # OverflowError: int(float("inf")) ā a YAML ``priority: .inf``
+ # would otherwise escape as an uncaught traceback instead of the
+ # clean validation error (mirrors catalogs.py).
raise PresetValidationError(
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
@@ -2401,8 +4278,29 @@ def _fetch_single_catalog(self, entry: PresetCatalogEntry, force_refresh: bool =
pass
try:
- with self._open_url(entry.url, timeout=10) as response:
- catalog_data = json.loads(response.read())
+ # Validate EVERY redirect hop (not just the terminal URL): an
+ # https -> http -> attacker-controlled-https chain would pass a
+ # final-URL-only check while the insecure intermediate hop lets a
+ # network attacker rewrite the next redirect. redirect_validator runs
+ # before each hop; the final geturl() check is retained as a
+ # belt-and-braces guard. Mirrors bundler/services/adapters.py.
+ def _validate_redirect(_old_url: str, new_url: str) -> None:
+ self._validate_catalog_url(new_url)
+
+ with self._open_url(
+ entry.url, timeout=10, redirect_validator=_validate_redirect
+ ) as response:
+ final_url = response.geturl()
+ if final_url != entry.url:
+ self._validate_catalog_url(final_url)
+ catalog_data = json.loads(
+ read_response_limited(
+ response,
+ max_bytes=MAX_JSON_CATALOG_BYTES,
+ error_type=PresetError,
+ label=f"preset catalog {entry.url}",
+ )
+ )
self._validate_catalog_payload(catalog_data, entry.url)
@@ -2552,8 +4450,26 @@ def fetch_catalog(self, force_refresh: bool = False) -> Dict[str, Any]:
pass
try:
- with self._open_url(catalog_url, timeout=10) as response:
- catalog_data = json.loads(response.read())
+ # Same redirect hardening as _fetch_single_catalog: validate every
+ # redirect hop AND the final URL so this legacy single-catalog path
+ # is not vulnerable to an HTTPS->HTTP redirected payload either.
+ def _validate_redirect(_old_url: str, new_url: str) -> None:
+ self._validate_catalog_url(new_url)
+
+ with self._open_url(
+ catalog_url, timeout=10, redirect_validator=_validate_redirect
+ ) as response:
+ final_url = response.geturl()
+ if final_url != catalog_url:
+ self._validate_catalog_url(final_url)
+ catalog_data = json.loads(
+ read_response_limited(
+ response,
+ max_bytes=MAX_JSON_CATALOG_BYTES,
+ error_type=PresetError,
+ label=f"preset catalog {catalog_url}",
+ )
+ )
# Validate catalog structure. Reuses the same helper as
# ``_fetch_single_catalog`` so all three branches (root type,
@@ -2621,23 +4537,34 @@ def search(
results = []
for pack_id, pack_data in packs.items():
- if author and pack_data.get("author", "").lower() != author.lower():
- continue
+ if author:
+ author_val = pack_data.get("author", "")
+ if not isinstance(author_val, str):
+ author_val = str(author_val) if author_val is not None else ""
+ if author_val.lower() != author.lower():
+ continue
- if tag and tag.lower() not in [
- t.lower() for t in pack_data.get("tags", [])
- ]:
- continue
+ if tag:
+ raw_tags = pack_data.get("tags", [])
+ tags_list = raw_tags if isinstance(raw_tags, list) else []
+ if tag.lower() not in [
+ str(t).lower() for t in tags_list
+ ]:
+ continue
if query:
query_lower = query.lower()
+ raw_tags = pack_data.get("tags", [])
+ tags_list = raw_tags if isinstance(raw_tags, list) else []
+ name_val = pack_data.get("name", "")
+ desc_val = pack_data.get("description", "")
searchable_text = " ".join(
[
- pack_data.get("name", ""),
- pack_data.get("description", ""),
+ str(name_val) if name_val is not None else "",
+ str(desc_val) if desc_val is not None else "",
pack_id,
]
- + pack_data.get("tags", [])
+ + [str(t) for t in tags_list]
).lower()
if query_lower not in searchable_text:
@@ -2714,25 +4641,48 @@ def download_pack(
raise PresetError(
f"Preset '{pack_id}' has no download URL"
)
+ if not isinstance(download_url, str):
+ raise PresetError(
+ f"Preset download URL is malformed: {download_url}"
+ )
from urllib.parse import urlparse
- parsed = urlparse(download_url)
- is_localhost = parsed.hostname in ("localhost", "127.0.0.1", "::1")
- if parsed.scheme != "https" and not (
- parsed.scheme == "http" and is_localhost
- ):
+ # A malformed authority (e.g. an unterminated IPv6 bracket
+ # "https://[::1") makes urlparse / hostname access raise ValueError.
+ # The download_url comes from catalog payload data, so surface a clean
+ # PresetError rather than leaking a raw ValueError past the command
+ # handler (which only catches PresetError). Mirrors catalogs (#3435)
+ # and workflows/catalog.py (#3484).
+ try:
+ parsed = urlparse(download_url)
+ hostname = parsed.hostname
+ parsed.port
+ except ValueError:
+ raise PresetError(
+ f"Preset download URL is malformed: {download_url}"
+ ) from None
+ if not hostname:
+ raise PresetError(
+ f"Preset download URL is malformed: {download_url}"
+ )
+ if not is_https_or_localhost_http(download_url):
raise PresetError(
f"Preset download URL must use HTTPS: {download_url}"
)
if target_dir is None:
target_dir = self.cache_dir / "downloads"
- target_dir.mkdir(parents=True, exist_ok=True)
-
+ target_dir = Path(target_dir)
version = pack_info.get("version", "unknown")
- zip_filename = f"{pack_id}-{version}.zip"
- zip_path = target_dir / zip_filename
+ zip_path = build_safe_download_path(
+ target_dir,
+ pack_id,
+ version,
+ error_type=PresetError,
+ label="preset",
+ )
+ target_dir.mkdir(parents=True, exist_ok=True)
extra_headers = None
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
@@ -2742,7 +4692,11 @@ def download_pack(
try:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
- zip_data = response.read()
+ zip_data = read_response_limited(
+ response,
+ error_type=PresetError,
+ label=f"preset '{pack_id}' download",
+ )
verify_archive_sha256(
zip_data, pack_info.get("sha256"), pack_id, PresetError
diff --git a/src/specify_cli/presets/_commands.py b/src/specify_cli/presets/_commands.py
index 44402831e8..2b50b2dfce 100644
--- a/src/specify_cli/presets/_commands.py
+++ b/src/specify_cli/presets/_commands.py
@@ -13,8 +13,14 @@
import typer
import yaml
+from rich.markup import escape as _escape_markup
from .._console import console
+from .._download_security import (
+ is_https_or_localhost_http,
+ is_safe_download_redirect,
+ read_response_limited,
+)
preset_app = typer.Typer(
name="preset",
@@ -53,10 +59,14 @@ def preset_list():
for pack in installed:
status = "[green]enabled[/green]" if pack.get("enabled", True) else "[red]disabled[/red]"
pri = pack.get('priority', 10)
- console.print(f" [bold]{pack['name']}[/bold] ({pack['id']}) v{pack['version']} ā {status} ā priority {pri}")
- console.print(f" {pack['description']}")
- if pack.get("tags"):
- tags_str = ", ".join(pack["tags"])
+ name = _escape_markup(str(pack['name']))
+ pack_id = _escape_markup(str(pack['id']))
+ version = _escape_markup(str(pack['version']))
+ console.print(f" [bold]{name}[/bold] ({pack_id}) v{version} ā {status} ā priority {pri}")
+ console.print(f" {_escape_markup(str(pack['description']))}")
+ tags = pack.get("tags", [])
+ if isinstance(tags, list) and tags:
+ tags_str = _escape_markup(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
console.print()
@@ -101,52 +111,35 @@ def preset_add(
elif from_url:
# Validate URL scheme before downloading
- from ipaddress import ip_address
from urllib.parse import urlparse as _urlparse
try:
_parsed = _urlparse(from_url)
+ _parsed.port
except ValueError:
- from rich.markup import escape as _escape_markup
-
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(from_url)}")
raise typer.Exit(1)
- def _is_allowed_download_url(parsed_url):
- host = parsed_url.hostname
- if not host:
- return False
- is_loopback = host == "localhost"
- if not is_loopback:
- try:
- is_loopback = ip_address(host).is_loopback
- except ValueError:
- # Host is not an IP literal (e.g., a regular hostname); treat as non-loopback.
- pass
- return parsed_url.scheme == "https" or (parsed_url.scheme == "http" and is_loopback)
-
def _validate_download_redirect(old_url, new_url):
- if not _is_allowed_download_url(_urlparse(new_url)):
+ if not is_safe_download_redirect(old_url, new_url):
import urllib.error
raise urllib.error.URLError(
- "redirect target must use HTTPS with a hostname, "
- "or HTTP for localhost/loopback"
+ "redirect target must use HTTPS without entering a local "
+ "target, or stay within loopback over HTTP"
)
- if not _is_allowed_download_url(_parsed):
+ if not is_https_or_localhost_http(from_url):
console.print(
- "[red]Error:[/red] URL must use HTTPS with a hostname, "
- "or HTTP for localhost/loopback."
+ "[red]Error:[/red] URL must use HTTPS with a hostname and be "
+ "a valid URL with a host. HTTP is only allowed for localhost, "
+ "127.0.0.1, and ::1."
)
raise typer.Exit(1)
- from rich.markup import escape as _esc
-
- console.print(f"Installing preset from [cyan]{_esc(from_url)}[/cyan]...")
+ console.print(f"Installing preset from [cyan]{_escape_markup(from_url)}[/cyan]...")
import urllib.error
import tempfile
- import shutil
with tempfile.TemporaryDirectory() as tmpdir:
zip_path = Path(tmpdir) / "preset.zip"
@@ -170,20 +163,25 @@ def _validate_download_redirect(old_url, new_url):
redirect_validator=_validate_download_redirect,
) as response:
final_url = response.geturl() if hasattr(response, "geturl") else from_url
- if not _is_allowed_download_url(_urlparse(final_url)):
+ if not is_https_or_localhost_http(final_url):
console.print(
"[red]Error:[/red] Preset URL redirected to a disallowed URL: "
f"{final_url}. Redirect targets must use HTTPS with a hostname, "
- "or HTTP for localhost/loopback."
+ "or HTTP for localhost (127.0.0.1, ::1)."
)
raise typer.Exit(1)
- with zip_path.open("wb") as output:
- try:
- shutil.copyfileobj(response, output)
- except TypeError:
- output.write(response.read())
- except urllib.error.URLError as e:
- console.print(f"[red]Error:[/red] Failed to download: {e}")
+ zip_path.write_bytes(
+ read_response_limited(
+ response,
+ error_type=PresetError,
+ label=f"preset {from_url}",
+ )
+ )
+ except (urllib.error.URLError, PresetError) as e:
+ console.print(
+ f"[red]Error:[/red] Failed to download: "
+ f"{_escape_markup(str(e))}"
+ )
raise typer.Exit(1)
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
@@ -240,13 +238,13 @@ def _validate_download_redirect(old_url, new_url):
raise typer.Exit(1)
except PresetCompatibilityError as e:
- console.print(f"[red]Compatibility Error:[/red] {e}")
+ console.print(f"[red]Compatibility Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
except PresetValidationError as e:
- console.print(f"[red]Validation Error:[/red] {e}")
+ console.print(f"[red]Validation Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
except PresetError as e:
- console.print(f"[red]Error:[/red] {e}")
+ console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
@@ -288,7 +286,7 @@ def preset_search(
try:
results = catalog.search(query=query, tag=tag, author=author)
except PresetError as e:
- console.print(f"[red]Error:[/red] {e}")
+ console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
if not results:
@@ -297,10 +295,16 @@ def preset_search(
console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
for pack in results:
- console.print(f" [bold]{pack.get('name', pack['id'])}[/bold] ({pack['id']}) v{pack.get('version', '?')}")
- console.print(f" {pack.get('description', '')}")
- if pack.get("tags"):
- tags_str = ", ".join(pack["tags"])
+ name = _escape_markup(str(pack.get("name", pack["id"])))
+ pack_id = _escape_markup(str(pack["id"]))
+ version = _escape_markup(str(pack.get("version", "?")))
+ console.print(f" [bold]{name}[/bold] ({pack_id}) v{version}")
+ console.print(
+ f" {_escape_markup(str(pack.get('description', '')))}"
+ )
+ tags = pack.get("tags", [])
+ if isinstance(tags, list) and tags:
+ tags_str = _escape_markup(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags: {tags_str}[/dim]")
console.print()
@@ -316,13 +320,20 @@ def preset_resolve(
project_root = _require_specify_project()
resolver = PresetResolver(project_root)
layers = resolver.collect_all_layers(template_name)
+ safe_template_name = _escape_markup(str(template_name))
if layers:
# Use the highest-priority layer for display because the final output
# may be composed and may not map to resolve_with_source()'s single path.
display_layer = layers[0]
- console.print(f" [bold]{template_name}[/bold]: {display_layer['path']}")
- console.print(f" [dim](top layer from: {display_layer['source']})[/dim]")
+ console.print(
+ f" [bold]{safe_template_name}[/bold]: "
+ f"{_escape_markup(str(display_layer['path']))}"
+ )
+ console.print(
+ f" [dim](top layer from: "
+ f"{_escape_markup(str(display_layer['source']))})[/dim]"
+ )
has_composition = (
layers[0]["strategy"] != "replace"
@@ -334,7 +345,10 @@ def preset_resolve(
composed = resolver.resolve_content(template_name)
except Exception as exc:
composed = None
- console.print(f" [yellow]Warning: composition error: {exc}[/yellow]")
+ console.print(
+ f" [yellow]Warning: composition error: "
+ f"{_escape_markup(str(exc))}[/yellow]"
+ )
if composed is None:
console.print(" [yellow]Warning: composition cannot produce output (no base layer with 'replace' strategy)[/yellow]")
else:
@@ -357,15 +371,27 @@ def preset_resolve(
strategy_label = layer["strategy"]
if strategy_label == "replace" and i == 0:
strategy_label = "base"
- console.print(f" {i + 1}. [{strategy_label}] {layer['source']} ā {layer['path']}")
+ # Escape the literal bracket (\[) so Rich renders `[]`
+ # instead of parsing it as a style tag and swallowing the label,
+ # mirroring `workflow info`'s step-graph line.
+ console.print(
+ f" {i + 1}. \\[{_escape_markup(str(strategy_label))}] "
+ f"{_escape_markup(str(layer['source']))} ā "
+ f"{_escape_markup(str(layer['path']))}"
+ )
else:
# No layers found ā fall back to resolve_with_source for non-composition cases
result = resolver.resolve_with_source(template_name)
if result:
- console.print(f" [bold]{template_name}[/bold]: {result['path']}")
- console.print(f" [dim](from: {result['source']})[/dim]")
+ console.print(
+ f" [bold]{safe_template_name}[/bold]: "
+ f"{_escape_markup(str(result['path']))}"
+ )
+ console.print(
+ f" [dim](from: {_escape_markup(str(result['source']))})[/dim]"
+ )
else:
- console.print(f" [yellow]{template_name}[/yellow]: not found")
+ console.print(f" [yellow]{safe_template_name}[/yellow]: not found")
console.print(" [dim]No template with this name exists in the resolution stack[/dim]")
@@ -379,28 +405,38 @@ def preset_info(
from . import PresetCatalog, PresetManager, PresetError
project_root = _require_specify_project()
+ safe_preset_id = _escape_markup(str(preset_id))
# Check if installed locally first
manager = PresetManager(project_root)
local_pack = manager.get_pack(preset_id)
if local_pack:
- console.print(f"\n[bold cyan]Preset: {local_pack.name}[/bold cyan]\n")
- console.print(f" ID: {local_pack.id}")
- console.print(f" Version: {local_pack.version}")
- console.print(f" Description: {local_pack.description}")
+ console.print(
+ f"\n[bold cyan]Preset: {_escape_markup(str(local_pack.name))}[/bold cyan]\n"
+ )
+ console.print(f" ID: {_escape_markup(str(local_pack.id))}")
+ console.print(f" Version: {_escape_markup(str(local_pack.version))}")
+ console.print(
+ f" Description: {_escape_markup(str(local_pack.description))}"
+ )
if local_pack.author:
- console.print(f" Author: {local_pack.author}")
- if local_pack.tags:
- console.print(f" Tags: {', '.join(local_pack.tags)}")
+ console.print(f" Author: {_escape_markup(str(local_pack.author))}")
+ local_tags = local_pack.tags
+ if isinstance(local_tags, list) and local_tags:
+ tags_str = _escape_markup(", ".join(str(t) for t in local_tags))
+ console.print(f" Tags: {tags_str}")
console.print(f" Templates: {len(local_pack.templates)}")
for tmpl in local_pack.templates:
- console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
+ tmpl_name = _escape_markup(str(tmpl['name']))
+ tmpl_type = _escape_markup(str(tmpl['type']))
+ tmpl_desc = _escape_markup(str(tmpl.get('description', '')))
+ console.print(f" - {tmpl_name} ({tmpl_type}): {tmpl_desc}")
repo = local_pack.data.get("preset", {}).get("repository")
if repo:
- console.print(f" Repository: {repo}")
+ console.print(f" Repository: {_escape_markup(str(repo))}")
license_val = local_pack.data.get("preset", {}).get("license")
if license_val:
- console.print(f" License: {license_val}")
+ console.print(f" License: {_escape_markup(str(license_val))}")
console.print("\n [green]Status: installed[/green]")
# Get priority from registry
pack_metadata = manager.registry.get(preset_id)
@@ -420,20 +456,33 @@ def preset_info(
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)")
raise typer.Exit(1)
- console.print(f"\n[bold cyan]Preset: {pack_info.get('name', preset_id)}[/bold cyan]\n")
- console.print(f" ID: {pack_info['id']}")
- console.print(f" Version: {pack_info.get('version', '?')}")
- console.print(f" Description: {pack_info.get('description', '')}")
+ name = _escape_markup(str(pack_info.get("name", preset_id)))
+ console.print(f"\n[bold cyan]Preset: {name}[/bold cyan]\n")
+ console.print(f" ID: {_escape_markup(str(pack_info['id']))}")
+ console.print(
+ f" Version: {_escape_markup(str(pack_info.get('version', '?')))}"
+ )
+ console.print(
+ f" Description: {_escape_markup(str(pack_info.get('description', '')))}"
+ )
if pack_info.get("author"):
- console.print(f" Author: {pack_info['author']}")
- if pack_info.get("tags"):
- console.print(f" Tags: {', '.join(pack_info['tags'])}")
+ console.print(
+ f" Author: {_escape_markup(str(pack_info['author']))}"
+ )
+ catalog_tags = pack_info.get("tags", [])
+ if isinstance(catalog_tags, list) and catalog_tags:
+ catalog_tags_str = _escape_markup(", ".join(str(t) for t in catalog_tags))
+ console.print(f" Tags: {catalog_tags_str}")
if pack_info.get("repository"):
- console.print(f" Repository: {pack_info['repository']}")
+ console.print(
+ f" Repository: {_escape_markup(str(pack_info['repository']))}"
+ )
if pack_info.get("license"):
- console.print(f" License: {pack_info['license']}")
+ console.print(
+ f" License: {_escape_markup(str(pack_info['license']))}"
+ )
console.print("\n [yellow]Status: not installed[/yellow]")
- console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]")
+ console.print(f" Install with: [cyan]specify preset add {safe_preset_id}[/cyan]")
console.print()
@@ -582,7 +631,7 @@ def preset_catalog_list():
try:
active_catalogs = catalog.get_active_catalogs()
except PresetValidationError as e:
- console.print(f"[red]Error:[/red] {e}")
+ console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
console.print("\n[bold cyan]Active Preset Catalogs:[/bold cyan]\n")
@@ -592,10 +641,10 @@ def preset_catalog_list():
if entry.install_allowed
else "[yellow]discovery only[/yellow]"
)
- console.print(f" [bold]{entry.name}[/bold] (priority {entry.priority})")
+ console.print(f" [bold]{_escape_markup(str(entry.name))}[/bold] (priority {entry.priority})")
if entry.description:
- console.print(f" {entry.description}")
- console.print(f" URL: {entry.url}")
+ console.print(f" {_escape_markup(str(entry.description))}")
+ console.print(f" URL: {_escape_markup(str(entry.url))}")
console.print(f" Install: {install_str}")
console.print()
@@ -647,7 +696,7 @@ def preset_catalog_add(
try:
tmp_catalog._validate_catalog_url(url)
except PresetValidationError as e:
- console.print(f"[red]Error:[/red] {e}")
+ console.print(f"[red]Error:[/red] {_escape_markup(str(e))}")
raise typer.Exit(1)
config_path = specify_dir / "preset-catalogs.yml"
@@ -658,7 +707,7 @@ def preset_catalog_add(
config = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
except Exception as e:
config_label = _display_project_path(project_root, config_path)
- console.print(f"[red]Error:[/red] Failed to read {config_label}: {e}")
+ console.print(f"[red]Error:[/red] Failed to read {_escape_markup(str(config_label))}: {_escape_markup(str(e))}")
raise typer.Exit(1)
else:
config = {}
@@ -668,10 +717,15 @@ def preset_catalog_add(
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
+ # Only rendering is escaped ā the raw values are what get persisted and
+ # compared below, so a name containing markup still round-trips exactly.
+ safe_name = _escape_markup(str(name))
+ safe_url = _escape_markup(str(url))
+
# Check for duplicate name
for existing in catalogs:
if isinstance(existing, dict) and existing.get("name") == name:
- console.print(f"[yellow]Warning:[/yellow] A catalog named '{name}' already exists.")
+ console.print(f"[yellow]Warning:[/yellow] A catalog named '{safe_name}' already exists.")
console.print("Use 'specify preset catalog remove' first, or choose a different name.")
raise typer.Exit(1)
@@ -687,10 +741,11 @@ def preset_catalog_add(
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
install_label = "install allowed" if install_allowed else "discovery only"
- console.print(f"\n[green]ā[/green] Added catalog '[bold]{name}[/bold]' ({install_label})")
- console.print(f" URL: {url}")
+ console.print(f"\n[green]ā[/green] Added catalog '[bold]{safe_name}[/bold]' ({install_label})")
+ console.print(f" URL: {safe_url}")
console.print(f" Priority: {priority}")
- console.print(f"\nConfig saved to {_display_project_path(project_root, config_path)}")
+ config_label = _escape_markup(str(_display_project_path(project_root, config_path)))
+ console.print(f"\nConfig saved to {config_label}")
@preset_catalog_app.command("remove")
@@ -718,17 +773,20 @@ def preset_catalog_remove(
if not isinstance(catalogs, list):
console.print("[red]Error:[/red] Invalid catalog config: 'catalogs' must be a list.")
raise typer.Exit(1)
+ # Rendering only ā the raw name drives the comparison below.
+ safe_name = _escape_markup(str(name))
+
original_count = len(catalogs)
catalogs = [c for c in catalogs if isinstance(c, dict) and c.get("name") != name]
if len(catalogs) == original_count:
- console.print(f"[red]Error:[/red] Catalog '{name}' not found.")
+ console.print(f"[red]Error:[/red] Catalog '{safe_name}' not found.")
raise typer.Exit(1)
config["catalogs"] = catalogs
config_path.write_text(yaml.safe_dump(config, default_flow_style=False, sort_keys=False, allow_unicode=True), encoding="utf-8")
- console.print(f"[green]ā[/green] Removed catalog '{name}'")
+ console.print(f"[green]ā[/green] Removed catalog '{safe_name}'")
if not catalogs:
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
diff --git a/src/specify_cli/shared_infra.py b/src/specify_cli/shared_infra.py
index 1b07cc7712..1c8d727d73 100644
--- a/src/specify_cli/shared_infra.py
+++ b/src/specify_cli/shared_infra.py
@@ -272,27 +272,56 @@ def _write_shared_bytes(
_POWERSHELL_FORMAT_COMMAND_RE = re.compile(
r"Format-SpecKitCommand\s+-CommandName\s+(['\"])([A-Za-z0-9_.-]+)\1(?:\s+-RepoRoot\s+[^\r\n]+)?"
)
+_PYTHON_FORMAT_COMMAND_RETURN_RE = re.compile(
+ r'return f"/speckit\{separator\}\{name\}"'
+)
+_BASH_FORMATTER_RETURN_RE = re.compile(
+ r'''printf '/speckit%s%s\\n' "\$separator" "\$command_name"'''
+)
+_POWERSHELL_FORMATTER_RETURN_RE = re.compile(
+ r'return "/speckit\$separator\$name"'
+)
-def _format_speckit_command(command_name: str, separator: str) -> str:
+def _format_speckit_command(
+ command_name: str, separator: str, prefix: str = "/"
+) -> str:
name = command_name.strip().lstrip("/")
if name.startswith("speckit."):
name = name[len("speckit.") :]
elif name.startswith("speckit-"):
name = name[len("speckit-") :]
name = name.replace(".", separator)
- return f"/speckit{separator}{name}"
+ return f"{prefix}speckit{separator}{name}"
-def _resolve_dynamic_command_refs(content: str, separator: str) -> str:
+def _resolve_dynamic_command_refs(
+ content: str, separator: str, prefix: str = "/"
+) -> str:
"""Render script runtime command helpers for managed shared infra copies."""
+ bash_prefix = r"\$" if prefix == "$" else prefix
content = _BASH_FORMAT_COMMAND_RE.sub(
- lambda match: _format_speckit_command(match.group(2), separator),
+ lambda match: _format_speckit_command(
+ match.group(2), separator, bash_prefix
+ ),
+ content,
+ )
+ content = _POWERSHELL_FORMAT_COMMAND_RE.sub(
+ lambda match: f"'{_format_speckit_command(match.group(2), separator, prefix)}'",
+ content,
+ )
+ content = _BASH_FORMATTER_RETURN_RE.sub(
+ f'''printf '{prefix}speckit%s%s\\\\n' "$separator" "$command_name"''',
content,
)
- return _POWERSHELL_FORMAT_COMMAND_RE.sub(
- lambda match: f"'{_format_speckit_command(match.group(2), separator)}'",
+ powershell_prefix = "`$" if prefix == "$" else prefix
+ content = _POWERSHELL_FORMATTER_RETURN_RE.sub(
+ f'return "{powershell_prefix}speckit$separator$name"',
+ content,
+ )
+ return _PYTHON_FORMAT_COMMAND_RETURN_RE.sub(
+ f'return f"{prefix}speckit{{separator}}{{name}}"',
content,
)
@@ -305,6 +334,7 @@ def refresh_shared_templates(
repo_root: Path,
console: Any,
invoke_separator: str,
+ invoke_prefix: str = "/",
force: bool = False,
) -> None:
"""Refresh default-sensitive shared templates without touching scripts."""
@@ -336,7 +366,9 @@ def refresh_shared_templates(
continue
content = src.read_text(encoding="utf-8")
- content = IntegrationBase.resolve_command_refs(content, invoke_separator)
+ content = IntegrationBase.resolve_command_refs(
+ content, invoke_separator, invoke_prefix
+ )
planned_updates.append((dst, rel, content))
for dst, rel, content in planned_updates:
@@ -363,6 +395,7 @@ def install_shared_infra(
console: Any,
force: bool = False,
invoke_separator: str = ".",
+ invoke_prefix: str = "/",
refresh_managed: bool = False,
refresh_hint: str | None = None,
) -> bool:
@@ -402,8 +435,13 @@ def _is_managed(rel: str, dst: Path) -> bool:
# Track every shared path the current bundle produces so we can detect
# manifest entries the core no longer ships (stale-script cleanup, #3076).
seen_rels: set[str] = set()
- scripts_scanned = False
- variant_dir = {"sh": "bash", "py": "python"}.get(script_type, "powershell")
+ scanned_variant_dirs: set[str] = set()
+ shell_variant = "powershell" if os.name == "nt" else "bash"
+ variant_dirs = (
+ ("python", shell_variant)
+ if script_type == "py"
+ else ("bash" if script_type == "sh" else "powershell",)
+ )
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
@@ -458,69 +496,73 @@ def _ensure_or_bucket_dir(directory: Path) -> bool:
if scripts_src.is_dir():
dest_scripts = project_path / ".specify" / "scripts"
if _ensure_or_bucket_dir(dest_scripts):
- variant_src = scripts_src / variant_dir
- if variant_src.is_dir():
+ for variant_dir in variant_dirs:
+ variant_src = scripts_src / variant_dir
+ if not variant_src.is_dir():
+ continue
dest_variant = dest_scripts / variant_dir
- if _ensure_or_bucket_dir(dest_variant):
- for src_path in variant_src.rglob("*"):
- if not src_path.is_file():
- continue
- # Python bytecode caches are local artifacts, not
- # workflow scripts ā never install them.
- if "__pycache__" in src_path.parts:
- continue
- # Mark scanned only once a real source file is seen. An
- # empty (or symlink-skipped) variant keeps this False, so
- # stale-cleanup is skipped ā otherwise it would treat every
- # tracked script as obsolete and delete it. (The safety
- # hinge is this flag, not ``seen_rels``, which also holds
- # template paths populated later.)
- scripts_scanned = True
-
- rel_path = src_path.relative_to(variant_src)
- dst_path = dest_variant / rel_path
- rel = dst_path.relative_to(project_path).as_posix()
- seen_rels.add(rel)
- if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
- continue
- write, bucket = _decide_overwrite(rel, dst_path)
- if not write:
- if bucket == "preserved":
- preserved_user_files.append(rel)
- else:
- skipped_files.append(rel)
- # Record the existing-on-disk file in the manifest so a
- # fresh manifest run against an already-populated
- # ``.specify/`` tree does not silently drop it (#2107).
- # ``prior_hashes`` is the function-scope snapshot taken
- # at entry, so this membership check is O(1) and avoids
- # the repeated ``dict(self._files)`` copy that
- # ``manifest.files`` performs on every access.
- if dst_path.is_file() and rel not in prior_hashes:
- try:
- manifest.record_existing(rel, recovered=True)
- except (OSError, ValueError) as exc:
- # Tolerate races / permission issues / non-file
- # collisions so one weird path does not abort
- # the whole install.
- console.print(
- f"[yellow]ā [/yellow] could not record {rel} in manifest: {exc}"
- )
- continue
-
- if not _ensure_or_bucket_dir(dst_path.parent):
- continue
- content = src_path.read_text(encoding="utf-8")
- content = IntegrationBase.resolve_command_refs(content, invoke_separator)
- content = _resolve_dynamic_command_refs(content, invoke_separator)
- planned_copies.append(
- (
- dst_path,
- rel,
- content.encode("utf-8"),
- src_path.stat().st_mode & 0o777,
- )
+ if not _ensure_or_bucket_dir(dest_variant):
+ continue
+ for src_path in variant_src.rglob("*"):
+ if not src_path.is_file():
+ continue
+ # Python bytecode caches are local artifacts, not
+ # workflow scripts ā never install them.
+ if "__pycache__" in src_path.parts:
+ continue
+ # Mark scanned only once a real source file is seen. An
+ # empty (or symlink-skipped) variant stays untracked, so
+ # stale-cleanup cannot treat its managed scripts as obsolete.
+ scanned_variant_dirs.add(variant_dir)
+
+ rel_path = src_path.relative_to(variant_src)
+ dst_path = dest_variant / rel_path
+ rel = dst_path.relative_to(project_path).as_posix()
+ seen_rels.add(rel)
+ if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
+ continue
+ write, bucket = _decide_overwrite(rel, dst_path)
+ if not write:
+ if bucket == "preserved":
+ preserved_user_files.append(rel)
+ else:
+ skipped_files.append(rel)
+ # Record the existing-on-disk file in the manifest so a
+ # fresh manifest run against an already-populated
+ # ``.specify/`` tree does not silently drop it (#2107).
+ # ``prior_hashes`` is the function-scope snapshot taken
+ # at entry, so this membership check is O(1) and avoids
+ # the repeated ``dict(self._files)`` copy that
+ # ``manifest.files`` performs on every access.
+ if dst_path.is_file() and rel not in prior_hashes:
+ try:
+ manifest.record_existing(rel, recovered=True)
+ except (OSError, ValueError) as exc:
+ # Tolerate races / permission issues / non-file
+ # collisions so one weird path does not abort
+ # the whole install.
+ console.print(
+ f"[yellow]ā [/yellow] could not record {rel} in manifest: {exc}"
+ )
+ continue
+
+ if not _ensure_or_bucket_dir(dst_path.parent):
+ continue
+ content = src_path.read_text(encoding="utf-8")
+ content = IntegrationBase.resolve_command_refs(
+ content, invoke_separator, invoke_prefix
+ )
+ content = _resolve_dynamic_command_refs(
+ content, invoke_separator, invoke_prefix
+ )
+ planned_copies.append(
+ (
+ dst_path,
+ rel,
+ content.encode("utf-8"),
+ src_path.stat().st_mode & 0o777,
)
+ )
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
if templates_src.is_dir():
@@ -561,7 +603,9 @@ def _ensure_or_bucket_dir(directory: Path) -> bool:
continue
content = src.read_text(encoding="utf-8")
- content = IntegrationBase.resolve_command_refs(content, invoke_separator)
+ content = IntegrationBase.resolve_command_refs(
+ content, invoke_separator, invoke_prefix
+ )
planned_templates.append((dst, rel, content))
for dst_path, rel, content, mode in planned_copies:
@@ -618,14 +662,16 @@ def _ensure_or_bucket_dir(directory: Path) -> bool:
# agent-context extension. Left behind, such an orphan can crash when it
# sources a refreshed ``common.sh`` (#3076). Only run when the script source
# was actually scanned (so a missing/empty source never triggers mass
- # deletion), scoped to the active variant, and only for *managed* copies ā
+ # deletion), scoped to the selected variants, and only for *managed* copies ā
# a user-customized file (hash diverges), a symlink, or a recovered entry is
# preserved by ``_is_managed``.
- if scripts_scanned:
+ if scanned_variant_dirs:
stale_removed: list[str] = []
- script_prefix = f".specify/scripts/{variant_dir}/"
+ script_prefixes = tuple(
+ f".specify/scripts/{variant_dir}/" for variant_dir in scanned_variant_dirs
+ )
for rel in list(prior_hashes):
- if rel in seen_rels or not rel.startswith(script_prefix):
+ if rel in seen_rels or not rel.startswith(script_prefixes):
continue
# Guard corrupted/hand-edited manifest keys BEFORE any filesystem
# access: absolute, ``..``, or (on Windows) drive-relative keys such
diff --git a/src/specify_cli/workflows/_commands.py b/src/specify_cli/workflows/_commands.py
index 1ed75f59ce..86076f4604 100644
--- a/src/specify_cli/workflows/_commands.py
+++ b/src/specify_cli/workflows/_commands.py
@@ -12,7 +12,7 @@
import os
import re
import sys
-from pathlib import Path
+from pathlib import Path, PurePosixPath
from typing import Any
import typer
@@ -20,6 +20,10 @@
from rich.markup import escape as _escape_markup
from .._console import console, err_console
+from .._download_security import (
+ is_https_or_localhost_http,
+ is_safe_download_redirect,
+)
from .._project import _resolve_init_dir_override
workflow_app = typer.Typer(
@@ -49,25 +53,12 @@
)
workflow_step_app.add_typer(workflow_step_catalog_app, name="catalog")
-
-def _is_loopback_host(hostname: str | None) -> bool:
- """Return True if *hostname* is a loopback address.
-
- Covers the entire 127.0.0.0/8 range and all IPv6 loopback forms (e.g.
- ``::1``, ``127.0.0.2``) via :func:`ipaddress.ip_address`, plus the
- ``localhost`` DNS name. A non-IP hostname (regular DNS name) is treated
- as non-loopback unless it is literally ``localhost``.
- """
- host = (hostname or "").strip()
- if not host:
- return False
- if host == "localhost":
- return True
- try:
- from ipaddress import ip_address
- return ip_address(host).is_loopback
- except ValueError:
- return False
+workflow_overlay_app = typer.Typer(
+ name="overlay",
+ help="Manage workflow overlays",
+ add_completion=False,
+)
+workflow_app.add_typer(workflow_overlay_app, name="overlay")
def _error_console(json_output: bool):
@@ -212,6 +203,10 @@ def _reject_unsafe_workflow_storage(project_root: Path) -> None:
project_root / ".specify" / "workflows" / "runs",
".specify/workflows/runs",
)
+ _reject_unsafe_dir(
+ project_root / ".specify" / "workflows" / "overlays",
+ ".specify/workflows/overlays",
+ )
def _scan_for_workflow_owner(parts: tuple[str, ...]) -> int | None:
@@ -386,33 +381,18 @@ def ownership_for(candidate: Path) -> tuple[Path, str] | None:
_WORKFLOW_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
-_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"runs", "steps"})
+_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"})
def _reject_insecure_download_redirect(old_url: str, new_url: str) -> None:
"""Reject insecure redirects before they are followed."""
import urllib.error
- from ipaddress import ip_address
- from urllib.parse import urlparse
- def _is_loopback_http(url: str) -> bool:
- parsed = urlparse(url)
- if parsed.scheme != "http":
- return False
- host = parsed.hostname or ""
- if host == "localhost":
- return True
- try:
- return ip_address(host).is_loopback
- except ValueError:
- return False
-
- if urlparse(new_url).scheme == "https":
- return
- if _is_loopback_http(old_url) and _is_loopback_http(new_url):
+ if is_safe_download_redirect(old_url, new_url):
return
raise urllib.error.URLError(
- "redirect target must use HTTPS; loopback HTTP may only redirect from loopback HTTP"
+ "redirect target must use HTTPS without entering a local target; "
+ "loopback HTTP may only redirect from another loopback URL"
)
@@ -421,6 +401,12 @@ def _is_loopback_http(url: str) -> bool:
# a ceiling any legitimate workflow definition should ever approach.
_MAX_WORKFLOW_YAML_BYTES = 5 * 1024 * 1024 # 5 MiB
_DOWNLOAD_CHUNK_SIZE = 65536
+# Custom step packages contain executable Python, metadata, and optional helper
+# files downloaded one-by-one rather than as an archive. Mirror the archive
+# ceilings so a catalog cannot turn individually valid files into an unbounded
+# aggregate download.
+_MAX_STEP_PACKAGE_FILES = 512
+_MAX_STEP_PACKAGE_BYTES = 50 * 1024 * 1024 # 50 MiB
def _read_response_within_limit(response, max_bytes: int | None = None) -> bytes:
@@ -1068,7 +1054,18 @@ def workflow_run(
load_custom_steps(project_root)
engine = WorkflowEngine(project_root)
if not json_output:
- engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
+ # Escape the literal bracket (\[) so Rich renders `[]` instead
+ # of parsing it as a style tag named after the step id -- which it
+ # silently swallows (losing the only identifying content on the line),
+ # applies as formatting when the id happens to be a real style such as
+ # `bold`, or raises MarkupError when the id forms a closing tag (`/`),
+ # failing the whole run. Escape the interpolated values too, since both
+ # come from workflow YAML. Mirrors the `\[]` step-graph precedent
+ # in workflow_info below.
+ engine.on_step_start = lambda sid, label: console.print(
+ f" \u25b8 \\[{_escape_markup(str(sid))}] "
+ f"{_escape_markup(str(label))} \u2026"
+ )
err = _error_console(json_output)
@@ -1190,7 +1187,18 @@ def workflow_resume(
load_custom_steps(project_root)
engine = WorkflowEngine(project_root)
if not json_output:
- engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
+ # Escape the literal bracket (\[) so Rich renders `[]` instead
+ # of parsing it as a style tag named after the step id -- which it
+ # silently swallows (losing the only identifying content on the line),
+ # applies as formatting when the id happens to be a real style such as
+ # `bold`, or raises MarkupError when the id forms a closing tag (`/`),
+ # failing the whole run. Escape the interpolated values too, since both
+ # come from workflow YAML. Mirrors the `\[]` step-graph precedent
+ # in workflow_info below.
+ engine.on_step_start = lambda sid, label: console.print(
+ f" \u25b8 \\[{_escape_markup(str(sid))}] "
+ f"{_escape_markup(str(label))} \u2026"
+ )
inputs = _parse_input_values(input_values, json_output=json_output)
err = _error_console(json_output)
@@ -1282,14 +1290,18 @@ def workflow_status(
engine = WorkflowEngine(project_root)
if run_id:
+ # Route errors to stderr under --json so the stdout JSON stream stays
+ # parseable (mirrors `workflow run`/`workflow resume`); both handlers
+ # fire before the json_output branch below.
+ err = _error_console(json_output)
try:
from .engine import RunState
state = RunState.load(run_id, project_root)
except FileNotFoundError:
- console.print(f"[red]Error:[/red] Run not found: {run_id}")
+ err.print(f"[red]Error:[/red] Run not found: {run_id}")
raise typer.Exit(1)
except ValueError as exc:
- console.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
+ err.print(f"[red]Error:[/red] {_escape_markup(str(exc))}")
raise typer.Exit(1)
if json_output:
@@ -1560,7 +1572,7 @@ def _validate_and_install_local(
# 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():
@@ -1588,12 +1600,11 @@ def _validate_and_install_local(
from specify_cli.authentication.http import open_url as _open_url
try:
- parsed_src = urlparse(download_url)
+ urlparse(download_url).port
except ValueError:
console.print(f"[red]Error:[/red] Invalid URL: {_escape_markup(download_url)}")
raise typer.Exit(1)
- src_loopback = _is_loopback_host(parsed_src.hostname)
- if parsed_src.scheme != "https" and not (parsed_src.scheme == "http" and src_loopback):
+ if not is_https_or_localhost_http(download_url):
console.print("[red]Error:[/red] Only HTTPS URLs are allowed, except HTTP for localhost.")
raise typer.Exit(1)
@@ -1644,9 +1655,7 @@ def _validate_and_install_local(
redirect_validator=_reject_insecure_download_redirect,
) as resp:
final_url = resp.geturl()
- final_parsed = urlparse(final_url)
- final_lb = _is_loopback_host(final_parsed.hostname)
- if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_lb):
+ if not is_https_or_localhost_http(final_url):
console.print(
f"[red]Error:[/red] URL redirected to non-HTTPS: {_escape_markup(final_url)}"
)
@@ -1671,8 +1680,8 @@ def _validate_and_install_local(
except OSError as cleanup_exc:
console.print(
"[yellow]Warning:[/yellow] Could not remove temporary "
- f"download file {_escape_markup(str(tmp_path))}: "
- f"{_escape_markup(str(cleanup_exc))}"
+ f"workflow download file: {_escape_markup(str(cleanup_exc))} "
+ f"(path: {_escape_markup(str(tmp_path))})"
)
console.print(f"[red]Error:[/red] Failed to download workflow: {_escape_markup(str(exc))}")
raise typer.Exit(1)
@@ -1696,15 +1705,15 @@ def _validate_and_install_local(
except OSError as exc:
console.print(
"[yellow]Warning:[/yellow] Could not remove temporary "
- f"download file {_escape_markup(str(tmp_path))}: "
- f"{_escape_markup(str(exc))}"
+ f"workflow download file: {_escape_markup(str(exc))} "
+ f"(path: {_escape_markup(str(tmp_path))})"
)
return
# 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():
@@ -1782,13 +1791,13 @@ def versions_match(actual: object, expected: str) -> bool:
try:
parsed_url = urlparse(workflow_url)
+ parsed_url.port
except ValueError:
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' has a malformed install URL."
)
raise typer.Exit(1)
- is_loopback = _is_loopback_host(parsed_url.hostname)
- if parsed_url.scheme != "https" and not (parsed_url.scheme == "http" and is_loopback):
+ if not is_https_or_localhost_http(workflow_url):
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' has an invalid install URL. "
"Only HTTPS URLs are allowed, except HTTP for localhost/loopback."
@@ -1842,9 +1851,7 @@ def versions_match(actual: object, expected: str) -> bool:
) as response:
# Validate final URL after redirects
final_url = response.geturl()
- final_parsed = urlparse(final_url)
- final_loopback = _is_loopback_host(final_parsed.hostname)
- if final_parsed.scheme != "https" and not (final_parsed.scheme == "http" and final_loopback):
+ if not is_https_or_localhost_http(final_url):
_safe_discard_staged_workflow_file(staged_file, workflow_dir, existed_before)
console.print(
f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}"
@@ -2341,7 +2348,7 @@ def workflow_search(
if desc:
console.print(f" {_escape_markup(str(desc))}")
tags = wf.get("tags", [])
- if tags:
+ if isinstance(tags, list) and tags:
safe_tags = _escape_markup(", ".join(str(t) for t in tags))
console.print(f" [dim]Tags: {safe_tags}[/dim]")
console.print()
@@ -2370,16 +2377,30 @@ def workflow_info(
# Local workflow definition not found on disk; fall back to
# catalog/registry lookup below.
pass
+ except ValueError as exc:
+ console.print(f"[red]Error:[/red] Invalid workflow: {_escape_markup(str(exc))}")
+ raise typer.Exit(1)
if definition:
- console.print(f"\n[bold cyan]{definition.name}[/bold cyan] ({definition.id})")
- console.print(f" Version: {definition.version}")
+ # Escape every user-controlled field: workflow.yml values (name,
+ # version, author, description, integration, input names/types) are not
+ # trusted, and console.print has Rich markup enabled, so an unescaped
+ # `[...]` in any of them is parsed as a style tag and silently swallowed
+ # (same defect fixed for the step graph below; the sibling workflow_list
+ # already escapes all of these).
+ console.print(
+ f"\n[bold cyan]{_escape_markup(str(definition.name))}[/bold cyan] "
+ f"({_escape_markup(str(definition.id))})"
+ )
+ console.print(f" Version: {_escape_markup(str(definition.version))}")
if definition.author:
- console.print(f" Author: {definition.author}")
+ console.print(f" Author: {_escape_markup(str(definition.author))}")
if definition.description:
- console.print(f" Description: {definition.description}")
+ console.print(f" Description: {_escape_markup(str(definition.description))}")
if definition.default_integration:
- console.print(f" Integration: {definition.default_integration}")
+ console.print(
+ f" Integration: {_escape_markup(str(definition.default_integration))}"
+ )
if installed:
console.print(" [green]Installed[/green]")
@@ -2388,13 +2409,24 @@ def workflow_info(
for name, inp in definition.inputs.items():
if isinstance(inp, dict):
req = "required" if inp.get("required") else "optional"
- console.print(f" {name} ({inp.get('type', 'string')}) ā {req}")
+ console.print(
+ f" {_escape_markup(str(name))} "
+ f"({_escape_markup(str(inp.get('type', 'string')))}) ā {req}"
+ )
if definition.steps:
console.print(f"\n [bold]Steps ({len(definition.steps)}):[/bold]")
for step in definition.steps:
stype = step.get("type", "command")
- console.print(f" ā {step.get('id', '?')} [{stype}]")
+ # Escape the literal bracket (\[) so Rich renders `[]`
+ # instead of parsing it as a style tag named after the step
+ # type (which it silently swallows); escape id/type too, as
+ # the sibling workflow_list does. Mirrors the `\[disabled]`
+ # precedent above.
+ console.print(
+ f" ā {_escape_markup(str(step.get('id', '?')))} "
+ f"\\[{_escape_markup(str(stype))}]"
+ )
return
# Try catalog
@@ -2405,15 +2437,24 @@ def workflow_info(
info = None
if info:
- console.print(f"\n[bold cyan]{info.get('name', workflow_id)}[/bold cyan] ({workflow_id})")
- console.print(f" Version: {info.get('version', '?')}")
+ # Catalog-derived fields are untrusted; escape them so bracketed content
+ # is rendered literally rather than parsed (and swallowed) as Rich markup.
+ console.print(
+ f"\n[bold cyan]{_escape_markup(str(info.get('name', workflow_id)))}[/bold cyan] "
+ f"({_escape_markup(str(workflow_id))})"
+ )
+ console.print(f" Version: {_escape_markup(str(info.get('version', '?')))}")
if info.get("description"):
- console.print(f" Description: {info['description']}")
- if info.get("tags"):
- console.print(f" Tags: {', '.join(info['tags'])}")
+ console.print(f" Description: {_escape_markup(str(info['description']))}")
+ info_tags = info.get("tags", [])
+ if isinstance(info_tags, list) and info_tags:
+ safe_tags = _escape_markup(", ".join(str(t) for t in info_tags))
+ console.print(f" Tags: {safe_tags}")
console.print(" [yellow]Not installed[/yellow]")
else:
- console.print(f"[red]Error:[/red] Workflow '{workflow_id}' not found")
+ console.print(
+ f"[red]Error:[/red] Workflow '{_escape_markup(str(workflow_id))}' not found"
+ )
raise typer.Exit(1)
@@ -2434,10 +2475,10 @@ def workflow_catalog_list():
console.print("\n[bold cyan]Workflow Catalog Sources:[/bold cyan]\n")
for i, cfg in enumerate(configs):
install_status = "[green]install allowed[/green]" if cfg["install_allowed"] else "[yellow]discovery only[/yellow]"
- console.print(f" [{i}] [bold]{cfg['name']}[/bold] ā {install_status}")
- console.print(f" {cfg['url']}")
+ console.print(f" [{i}] [bold]{_escape_markup(str(cfg['name']))}[/bold] ā {install_status}")
+ console.print(f" {_escape_markup(str(cfg['url']))}")
if cfg.get("description"):
- console.print(f" [dim]{cfg['description']}[/dim]")
+ console.print(f" [dim]{_escape_markup(str(cfg['description']))}[/dim]")
console.print()
@@ -2646,14 +2687,39 @@ def workflow_step_add(
)
raise typer.Exit(1)
- step_yml_url = info.get("step_yml_url") or info.get("url")
- if not step_yml_url:
+ declared_step_yml_url = info.get("step_yml_url")
+ if declared_step_yml_url is not None and not isinstance(
+ declared_step_yml_url, str
+ ):
+ console.print(
+ f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
+ "step.yml URL; expected a non-empty string"
+ )
+ raise typer.Exit(1)
+ step_yml_url = declared_step_yml_url or info.get("url")
+ if step_yml_url is None or (
+ isinstance(step_yml_url, str) and not step_yml_url.strip()
+ ):
console.print(f"[red]Error:[/red] Catalog entry for '{step_id}' has no URL")
raise typer.Exit(1)
+ if not isinstance(step_yml_url, str):
+ console.print(
+ f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
+ "step.yml URL; expected a non-empty string"
+ )
+ raise typer.Exit(1)
# Derive __init__.py URL: replace trailing step.yml with __init__.py
# or use explicit init_url if provided.
init_url = info.get("init_url")
+ if init_url is not None and (
+ not isinstance(init_url, str) or not init_url.strip()
+ ):
+ console.print(
+ f"[red]Error:[/red] Catalog entry for '{step_id}' has a malformed "
+ "__init__.py URL; expected a non-empty string"
+ )
+ raise typer.Exit(1)
if not init_url:
if step_yml_url.endswith("step.yml"):
init_url = step_yml_url[: -len("step.yml")] + "__init__.py"
@@ -2664,28 +2730,52 @@ def workflow_step_add(
)
raise typer.Exit(1)
- from urllib.parse import urlparse
+ # Preflight the declared file count before creating a staging directory or
+ # issuing any request. The two required files are always part of the package;
+ # duplicate declarations for them in extra_files are ignored below and do
+ # not count twice.
+ extra_files = info.get("extra_files")
+ if extra_files is not None and not isinstance(extra_files, dict):
+ console.print(
+ "[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; "
+ "additional package files will not be downloaded."
+ )
+ extra_files = {}
+
+ def _is_required_package_file(rel_path: object) -> bool:
+ """Match portable path/case aliases of the two required package files."""
+ if not isinstance(rel_path, str):
+ return False
+ parts = PurePosixPath(rel_path.replace("\\", "/")).parts
+ return len(parts) == 1 and parts[0].casefold() in {
+ "step.yml",
+ "__init__.py",
+ }
+
+ declared_extra_count = sum(
+ 1
+ for rel_path in (extra_files or {})
+ if not _is_required_package_file(rel_path)
+ )
+ package_file_count = 2 + declared_extra_count
+ if package_file_count > _MAX_STEP_PACKAGE_FILES:
+ console.print(
+ f"[red]Error:[/red] Step package declares {package_file_count} files, "
+ f"exceeding the {_MAX_STEP_PACKAGE_FILES}-file limit"
+ )
+ raise typer.Exit(1)
+
from specify_cli.authentication.http import open_url as _open_url
def _safe_fetch(url: str) -> bytes:
- parsed = urlparse(url)
- is_localhost = _is_loopback_host(parsed.hostname)
- if parsed.scheme != "https" and not (parsed.scheme == "http" and is_localhost):
+ if not is_https_or_localhost_http(url):
raise ValueError(f"Refusing to fetch from non-HTTPS URL: {url}")
- if not parsed.hostname:
- raise ValueError(f"Refusing to fetch from URL with no hostname: {url}")
with _open_url(
url, timeout=30, redirect_validator=_reject_insecure_download_redirect
) as resp:
final_url = resp.geturl()
- final_parsed = urlparse(final_url)
- final_is_localhost = _is_loopback_host(final_parsed.hostname)
- if final_parsed.scheme != "https" and not (
- final_parsed.scheme == "http" and final_is_localhost
- ):
+ if not is_https_or_localhost_http(final_url):
raise ValueError(f"Redirect to non-HTTPS URL: {final_url}")
- if not final_parsed.hostname:
- raise ValueError(f"Redirect to URL with no hostname: {final_url}")
return _read_response_within_limit(resp)
_validate_step_id_or_exit(step_id)
@@ -2731,6 +2821,14 @@ def _safe_fetch(url: str) -> bytes:
console.print(f"[red]Error:[/red] Failed to download step files: {exc}")
raise typer.Exit(1)
+ package_bytes = len(step_yml_content) + len(init_py_content)
+ if package_bytes > _MAX_STEP_PACKAGE_BYTES:
+ console.print(
+ f"[red]Error:[/red] Step package exceeds the "
+ f"{_MAX_STEP_PACKAGE_BYTES}-byte total size limit"
+ )
+ raise typer.Exit(1)
+
# Validate step.yml
try:
import yaml as _yaml
@@ -2775,13 +2873,6 @@ def _safe_fetch(url: str) -> bytes:
# relative-path ā URL. step.yml and __init__.py are ignored here (already
# written). Paths are validated to stay within the step package directory to
# prevent path-traversal attacks.
- extra_files = info.get("extra_files")
- if extra_files is not None and not isinstance(extra_files, dict):
- console.print(
- "[yellow]Warning:[/yellow] Catalog entry 'extra_files' is not a mapping; "
- "additional package files will not be downloaded."
- )
- extra_files = {}
for rel_path, file_url in (extra_files or {}).items():
if not isinstance(rel_path, str) or not rel_path.strip():
console.print(
@@ -2789,7 +2880,7 @@ def _safe_fetch(url: str) -> bytes:
"empty or non-string path key"
)
raise typer.Exit(1)
- if rel_path in ("step.yml", "__init__.py"):
+ if _is_required_package_file(rel_path):
continue # already written above
# Reject dot-path segments ('', '.', '..') that would refer to the
# package directory itself (IsADirectoryError) or escape it.
@@ -2825,6 +2916,13 @@ def _safe_fetch(url: str) -> bytes:
f"[red]Error:[/red] Failed to download extra file '{rel_path}': {exc}"
)
raise typer.Exit(1)
+ package_bytes += len(file_content)
+ if package_bytes > _MAX_STEP_PACKAGE_BYTES:
+ console.print(
+ f"[red]Error:[/red] Step package exceeds the "
+ f"{_MAX_STEP_PACKAGE_BYTES}-byte total size limit"
+ )
+ raise typer.Exit(1)
try:
dest.parent.mkdir(parents=True, exist_ok=True)
dest.write_bytes(file_content)
@@ -2954,7 +3052,8 @@ def workflow_step_remove(
# which would overwrite timestamps).
try:
if registry_metadata is not None:
- registry.restore(step_id, registry_metadata)
+ registry.data["steps"][step_id] = registry_metadata
+ registry.save()
except Exception as restore_exc: # noqa: BLE001
console.print(
f"[yellow]Warning:[/yellow] Failed to restore registry entry "
@@ -3087,10 +3186,10 @@ def workflow_step_catalog_list():
if cfg["install_allowed"]
else "[yellow]discovery only[/yellow]"
)
- console.print(f" [{i}] [bold]{cfg['name']}[/bold] ā {install_status}")
- console.print(f" {cfg['url']}")
+ console.print(f" [{i}] [bold]{_escape_markup(str(cfg['name']))}[/bold] ā {install_status}")
+ console.print(f" {_escape_markup(str(cfg['url']))}")
if cfg.get("description"):
- console.print(f" [dim]{cfg['description']}[/dim]")
+ console.print(f" [dim]{_escape_markup(str(cfg['description']))}[/dim]")
console.print()
@@ -3135,6 +3234,102 @@ def workflow_step_catalog_remove(
console.print(f"[green]ā[/green] Step catalog source '{removed_name}' removed")
+@workflow_overlay_app.command("add")
+def workflow_overlay_add_cmd(
+ source: Path = typer.Argument(..., help="Path to overlay YAML file"),
+ priority: int = typer.Option(
+ 10,
+ "--priority",
+ help="Resolution priority (lower = higher precedence, default 10)",
+ ),
+):
+ """Add a project-local overlay for a workflow."""
+ from .overlays._commands import workflow_overlay_add
+
+ project_root = _require_specify_project()
+ if workflow_overlay_add(project_root, source, priority) is None:
+ raise typer.Exit(1)
+
+
+@workflow_overlay_app.command("set-priority")
+def workflow_overlay_set_priority_cmd(
+ workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
+ overlay_id: str = typer.Argument(..., help="Overlay ID"),
+ priority: int = typer.Argument(
+ ..., help="New priority (lower = higher precedence)"
+ ),
+):
+ """Set the priority of a project-local overlay."""
+ from .overlays._commands import workflow_overlay_set_priority
+
+ project_root = _require_specify_project()
+ if not workflow_overlay_set_priority(project_root, workflow_id, overlay_id, priority):
+ raise typer.Exit(1)
+
+
+@workflow_overlay_app.command("enable")
+def workflow_overlay_enable_cmd(
+ workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
+ overlay_id: str = typer.Argument(..., help="Overlay ID"),
+):
+ """Enable a project-local overlay."""
+ from .overlays._commands import workflow_overlay_enable
+
+ project_root = _require_specify_project()
+ if not workflow_overlay_enable(project_root, workflow_id, overlay_id):
+ raise typer.Exit(1)
+
+
+@workflow_overlay_app.command("disable")
+def workflow_overlay_disable_cmd(
+ workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
+ overlay_id: str = typer.Argument(..., help="Overlay ID"),
+):
+ """Disable a project-local overlay."""
+ from .overlays._commands import workflow_overlay_disable
+
+ project_root = _require_specify_project()
+ if not workflow_overlay_disable(project_root, workflow_id, overlay_id):
+ raise typer.Exit(1)
+
+
+@workflow_overlay_app.command("remove")
+def workflow_overlay_remove_cmd(
+ workflow_id: str = typer.Argument(..., help="Workflow ID the overlay extends"),
+ overlay_id: str = typer.Argument(..., help="Overlay ID"),
+):
+ """Remove a project-local overlay."""
+ from .overlays._commands import workflow_overlay_remove
+
+ project_root = _require_specify_project()
+ if not workflow_overlay_remove(project_root, workflow_id, overlay_id):
+ raise typer.Exit(1)
+
+
+@workflow_overlay_app.command("list")
+def workflow_overlay_list_cmd(
+ workflow_id: str = typer.Argument(..., help="Workflow ID"),
+):
+ """List overlays for a workflow."""
+ from .overlays._commands import workflow_overlay_list
+
+ project_root = _require_specify_project()
+ if workflow_overlay_list(project_root, workflow_id) is None:
+ raise typer.Exit(1)
+
+
+@workflow_app.command("resolve")
+def workflow_resolve_cmd(
+ workflow_id: str = typer.Argument(..., help="Workflow ID to resolve"),
+):
+ """Show layer attribution for a resolved workflow."""
+ from .overlays._commands import workflow_resolve
+
+ project_root = _require_specify_project()
+ if workflow_resolve(project_root, workflow_id) is None:
+ raise typer.Exit(1)
+
+
def register(app: typer.Typer) -> None:
"""Attach the workflow command group to the root Typer app."""
app.add_typer(workflow_app, name="workflow")
diff --git a/src/specify_cli/workflows/catalog.py b/src/specify_cli/workflows/catalog.py
index 8f9e1958f0..7cc02a058a 100644
--- a/src/specify_cli/workflows/catalog.py
+++ b/src/specify_cli/workflows/catalog.py
@@ -22,6 +22,8 @@
import yaml
+from .._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
+
# ---------------------------------------------------------------------------
# Errors
@@ -308,7 +310,8 @@ def _validate_catalog_url(self, url: str) -> None:
try:
parsed = urlparse(url)
hostname = parsed.hostname
- except ValueError:
+ _ = parsed.port
+ except (TypeError, ValueError):
raise WorkflowValidationError(
f"Catalog URL is malformed: {url}"
) from None
@@ -332,26 +335,45 @@ def _load_catalog_config(
if not config_path.exists():
return None
try:
- data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise WorkflowValidationError(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
+ # An empty document (or explicit ``null``) parses to None -> this config
+ # layer contributes nothing, so ``get_active_catalogs`` moves on to the
+ # next layer (this loader serves both the project and user configs;
+ # the built-in defaults apply only once every layer has returned None).
+ # Do NOT coerce with ``or {}`` here: that also turns a FALSY non-mapping
+ # (top-level ``[]``, ``false``, ``0``, ``''``) into ``{}`` and silently
+ # swallows it, while a TRUTHY non-mapping (``5``, a bare list) correctly
+ # raises below -- an inconsistency. Only None means "no document".
+ if data is None:
+ return None
if not isinstance(data, dict):
raise WorkflowValidationError(
f"Invalid catalog config: expected a mapping, "
f"got {type(data).__name__}"
)
- catalogs_data = data.get("catalogs", [])
- if not catalogs_data:
- # Empty catalogs list (e.g. after removing last entry)
- # is valid ā fall back to built-in defaults.
+ # Same asymmetry as the top level above, one nesting level down: the
+ # shape check has to run BEFORE the emptiness check, or a FALSY non-list
+ # (``catalogs: {}``/``''``/``0``/``false``) is silently swallowed as
+ # "no catalogs" while a TRUTHY non-list (``catalogs: 5``) correctly
+ # raises. An absent key, an explicit ``catalogs:`` null, and an empty
+ # list all keep their existing "nothing configured here" behavior --
+ # only the misreported shapes change.
+ catalogs_data = data.get("catalogs")
+ if catalogs_data is None:
return None
if not isinstance(catalogs_data, list):
raise WorkflowValidationError(
f"Invalid catalog config: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
+ if not catalogs_data:
+ # Empty catalogs list (e.g. after removing last entry)
+ # is valid ā fall back to built-in defaults.
+ return None
entries: list[WorkflowCatalogEntry] = []
for idx, item in enumerate(catalogs_data):
@@ -364,13 +386,24 @@ def _load_catalog_config(
if not url:
continue
self._validate_catalog_url(url)
+ raw_priority = item.get("priority", idx + 1)
+ # bool is an int subclass: int(True) == 1 would silently accept a
+ # ``priority: true`` as priority 1. Reject it explicitly, mirroring
+ # the base CatalogStackBase loader.
+ if isinstance(raw_priority, bool):
+ raise WorkflowValidationError(
+ f"Invalid priority for catalog "
+ f"'{item.get('name', idx + 1)}': "
+ f"expected integer, got {raw_priority!r}"
+ )
try:
- priority = int(item.get("priority", idx + 1))
- except (TypeError, ValueError):
+ priority = int(raw_priority)
+ except (TypeError, ValueError, OverflowError):
+ # OverflowError: int(float("inf")) ā a ``priority: .inf``.
raise WorkflowValidationError(
f"Invalid priority for catalog "
f"'{item.get('name', idx + 1)}': "
- f"expected integer, got {item.get('priority')!r}"
+ f"expected integer, got {raw_priority!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
@@ -494,7 +527,8 @@ def _validate_catalog_url(url: str) -> None:
try:
parsed = urlparse(url)
hostname = parsed.hostname
- except ValueError:
+ _ = parsed.port
+ except (TypeError, ValueError):
raise WorkflowCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
@@ -512,10 +546,29 @@ def _validate_catalog_url(url: str) -> None:
_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"))
+ data = json.loads(
+ read_response_limited(
+ resp,
+ max_bytes=MAX_JSON_CATALOG_BYTES,
+ error_type=WorkflowCatalogError,
+ label="workflow catalog",
+ ).decode("utf-8")
+ )
except Exception as exc:
# Fall back to cache if available
if cache_file.exists():
@@ -685,7 +738,9 @@ def add_catalog(self, url: str, name: str | None = None) -> None:
def _coerce_priority(value: Any) -> int:
try:
return int(value)
- except (TypeError, ValueError):
+ except (TypeError, ValueError, OverflowError):
+ # OverflowError: int(float("inf")) ā treat an uncoercible
+ # existing priority as 0 rather than crashing 'catalog add'.
return 0
max_priority = max(
@@ -869,7 +924,11 @@ def add(self, step_id: str, metadata: dict[str, Any]) -> None:
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()
@@ -967,7 +1026,8 @@ def _validate_catalog_url(self, url: str) -> None:
try:
parsed = urlparse(url)
hostname = parsed.hostname
- except ValueError:
+ _ = parsed.port
+ except (TypeError, ValueError):
raise StepValidationError(
f"Catalog URL is malformed: {url}"
) from None
@@ -991,24 +1051,33 @@ def _load_catalog_config(
if not config_path.exists():
return None
try:
- data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
+ data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise StepValidationError(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
+ # Same two guards as WorkflowCatalog._load_catalog_config above, kept in
+ # lockstep: this is the step-catalog twin of that loader and read the
+ # same way. Dropping ``or {}`` stops a falsy non-mapping top level from
+ # being coerced past the isinstance check, and the ``catalogs`` shape
+ # check runs before the emptiness check for the same reason.
+ if data is None:
+ return None
if not isinstance(data, dict):
raise StepValidationError(
f"Invalid catalog config: expected a mapping, "
f"got {type(data).__name__}"
)
- catalogs_data = data.get("catalogs", [])
- if not catalogs_data:
+ catalogs_data = data.get("catalogs")
+ if catalogs_data is None:
return None
if not isinstance(catalogs_data, list):
raise StepValidationError(
f"Invalid catalog config: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
+ if not catalogs_data:
+ return None
entries: list[StepCatalogEntry] = []
for idx, item in enumerate(catalogs_data):
@@ -1021,13 +1090,23 @@ def _load_catalog_config(
if not url:
continue
self._validate_catalog_url(url)
+ raw_priority = item.get("priority", idx + 1)
+ # bool is an int subclass: reject ``priority: true`` explicitly rather
+ # than silently coercing it to 1 (mirrors CatalogStackBase).
+ if isinstance(raw_priority, bool):
+ raise StepValidationError(
+ f"Invalid priority for catalog "
+ f"'{item.get('name', idx + 1)}': "
+ f"expected integer, got {raw_priority!r}"
+ )
try:
- priority = int(item.get("priority", idx + 1))
- except (TypeError, ValueError):
+ priority = int(raw_priority)
+ except (TypeError, ValueError, OverflowError):
+ # OverflowError: int(float("inf")) ā a ``priority: .inf``.
raise StepValidationError(
f"Invalid priority for catalog "
f"'{item.get('name', idx + 1)}': "
- f"expected integer, got {item.get('priority')!r}"
+ f"expected integer, got {raw_priority!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
@@ -1153,7 +1232,8 @@ def _validate_url(url: str) -> None:
try:
parsed = urlparse(url)
hostname = parsed.hostname
- except ValueError:
+ _ = parsed.port
+ except (TypeError, ValueError):
raise StepCatalogError(
f"Refusing to fetch catalog from malformed URL: {url}"
) from None
@@ -1171,10 +1251,29 @@ def _validate_url(url: str) -> None:
_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"))
+ data = json.loads(
+ read_response_limited(
+ resp,
+ max_bytes=MAX_JSON_CATALOG_BYTES,
+ error_type=StepCatalogError,
+ label="step catalog",
+ ).decode("utf-8")
+ )
except Exception as exc:
if cache_safe and cache_file.exists():
try:
@@ -1328,7 +1427,9 @@ def add_catalog(self, url: str, name: str | None = None) -> None:
def _coerce_priority(value: Any) -> int:
try:
return int(value)
- except (TypeError, ValueError):
+ except (TypeError, ValueError, OverflowError):
+ # OverflowError: int(float("inf")) ā treat an uncoercible
+ # existing priority as 0 rather than crashing 'catalog add'.
return 0
max_priority = max(
diff --git a/src/specify_cli/workflows/engine.py b/src/specify_cli/workflows/engine.py
index 91a9ac2ad5..13fd633338 100644
--- a/src/specify_cli/workflows/engine.py
+++ b/src/specify_cli/workflows/engine.py
@@ -42,6 +42,17 @@ def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> Non
self.source_path = source_path
workflow = data.get("workflow", {})
+ # A present-but-non-mapping ``workflow:`` block (bare ``workflow:`` ->
+ # None, or ``workflow: ``) would crash the following
+ # ``workflow.get(...)`` calls with AttributeError, so construction fails
+ # before any validation can run. Normalize the local to {} instead: the
+ # header fields fall back to their defaults and ``validate_workflow``
+ # (which reads those parsed attributes) reports the missing
+ # ``workflow.id``/``workflow.name``. ``self.data`` is deliberately left
+ # holding the raw value, since it is what gets written back out when a
+ # definition is serialized. Mirrors the default_options guard below.
+ if not isinstance(workflow, dict):
+ workflow = {}
self.id: str = workflow.get("id", "")
self.name: str = workflow.get("name", "")
self.version: str = workflow.get("version", "0.0.0")
@@ -79,7 +90,11 @@ def __init__(self, data: dict[str, Any], source_path: Path | None = None) -> Non
def from_yaml(cls, path: Path) -> WorkflowDefinition:
"""Load a workflow definition from a YAML file."""
with open(path, encoding="utf-8") as f:
- data = yaml.safe_load(f)
+ try:
+ data = yaml.safe_load(f)
+ except yaml.YAMLError as exc:
+ msg = f"Invalid YAML in {path}: {exc}"
+ raise ValueError(msg) from exc
if not isinstance(data, dict):
msg = f"Workflow YAML must be a mapping, got {type(data).__name__}."
raise ValueError(msg)
@@ -88,7 +103,11 @@ def from_yaml(cls, path: Path) -> WorkflowDefinition:
@classmethod
def from_string(cls, content: str) -> WorkflowDefinition:
"""Load a workflow definition from a YAML string."""
- data = yaml.safe_load(content)
+ try:
+ data = yaml.safe_load(content)
+ except yaml.YAMLError as exc:
+ msg = f"Invalid YAML: {exc}"
+ raise ValueError(msg) from exc
if not isinstance(data, dict):
msg = f"Workflow YAML must be a mapping, got {type(data).__name__}."
raise ValueError(msg)
@@ -193,6 +212,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
@@ -201,13 +234,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()
@@ -727,13 +775,24 @@ def load_workflow(self, source: str | Path) -> WorkflowDefinition:
ValueError:
If the workflow YAML is invalid.
"""
+ from .overlays import WorkflowResolver
+
path = Path(source).expanduser()
# Try as a direct file path first
if path.suffix.lower() in (".yml", ".yaml") and path.is_file():
return WorkflowDefinition.from_yaml(path)
- # Try as an installed workflow ID
+ # Try as an installed workflow ID, resolving any overlays.
+ resolver = WorkflowResolver(self.project_root)
+ try:
+ return resolver.resolve(str(source))
+ except FileNotFoundError:
+ # Fall back to the direct workflow.yml path so callers still get
+ # the original error when the workflow id is not installed.
+ pass
+
+ # Legacy direct path check for workflows installed without registry entries.
installed_path = (
self.project_root
/ ".specify"
@@ -1352,6 +1411,14 @@ def _resolve_inputs(
) -> dict[str, Any]:
"""Resolve workflow inputs against definitions and provided values."""
resolved: dict[str, Any] = {}
+ # execute()/resume() accept UNVALIDATED definitions (load_workflow does
+ # not validate). A non-mapping ``inputs:`` block (bare ``inputs:`` ->
+ # None, or ``inputs: []``) is stored raw, so iterating ``.items()`` here
+ # would crash the run with AttributeError. Treat a non-mapping inputs
+ # block as "no inputs"; validate_workflow reports the malformed shape
+ # via its own isinstance check.
+ if not isinstance(definition.inputs, dict):
+ return {}
for name, input_def in definition.inputs.items():
if not isinstance(input_def, dict):
continue
@@ -1381,11 +1448,18 @@ def _resolve_inputs(
# 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
@@ -1431,6 +1505,22 @@ def _coerce_input(
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
diff --git a/src/specify_cli/workflows/expressions.py b/src/specify_cli/workflows/expressions.py
index 0c70a7b284..a38cd6cb68 100644
--- a/src/specify_cli/workflows/expressions.py
+++ b/src/specify_cli/workflows/expressions.py
@@ -392,8 +392,14 @@ def _apply_filter(value: Any, filter_expr: str, namespace: dict[str, Any]) -> An
)
return _filter_from_json(value)
- # Parse filter name and argument
- filter_match = re.match(r"(\w+)\((.+)\)", filter_expr)
+ # Parse filter name and argument. Use fullmatch (not match) so trailing
+ # tokens after the closing paren ā e.g. a comparison/boolean operator that
+ # binds looser than the pipe, as in ``count | default(0) > 5`` ā are not
+ # silently discarded but fall through to the "unsupported form" ValueError
+ # below, mirroring the strict trailing-token handling of the from_json
+ # branch above. The greedy ``.+`` still handles literal ``)`` and ``|``
+ # inside quoted args.
+ filter_match = re.fullmatch(r"(\w+)\((.+)\)", filter_expr)
if filter_match:
fname = filter_match.group(1)
farg = _evaluate_simple_expression(filter_match.group(2).strip(), namespace)
@@ -535,6 +541,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
diff --git a/src/specify_cli/workflows/overlays/__init__.py b/src/specify_cli/workflows/overlays/__init__.py
new file mode 100644
index 0000000000..2bb87ffbed
--- /dev/null
+++ b/src/specify_cli/workflows/overlays/__init__.py
@@ -0,0 +1,95 @@
+"""Workflow overlay resolver ā composes installed workflows from layers."""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from ..engine import WorkflowDefinition
+from .composer import StepListComposer
+from .layer_sources import (
+ BaseWorkflowSource,
+ Layer,
+ ProjectOverlaySource,
+)
+from .merge import ComposedStep
+from .schema import _RESERVED_WORKFLOW_IDS, _SAFE_ID_PATTERN
+
+
+def _validate_workflow_id(workflow_id: str) -> None:
+ """Reject workflow IDs that are unsafe as installed-storage path segments."""
+ if (
+ not isinstance(workflow_id, str)
+ or not _SAFE_ID_PATTERN.fullmatch(workflow_id)
+ or workflow_id in _RESERVED_WORKFLOW_IDS
+ ):
+ raise ValueError(f"Invalid workflow ID: {workflow_id!r}")
+
+
+class WorkflowResolver:
+ """Resolves a workflow ID to its composed ``WorkflowDefinition``.
+
+ Collects layers from two tiers:
+ - project-local overlays (``.specify/workflows/overlays//*.yml``)
+ - the base workflow itself (``.specify/workflows//workflow.yml``)
+
+ Resolution is lower-wins: overlays with lower priority numbers are applied
+ later and override earlier edits on the same anchors.
+ """
+
+ def __init__(self, project_root: Path) -> None:
+ self.project_root = project_root
+ self._sources = [
+ ProjectOverlaySource(project_root),
+ BaseWorkflowSource(project_root),
+ ]
+ self._composer = StepListComposer()
+
+ def collect_all_layers(
+ self, workflow_id: str, *, include_disabled: bool = False
+ ) -> list[Layer]:
+ """Collect overlays sorted by precedence, followed by the base layer.
+
+ Lower priority numbers win. Ties are sorted alphabetically by source,
+ matching ``PresetRegistry.list_by_priority()``. The base workflow is a
+ foundation rather than a precedence candidate, so it is kept separate.
+ """
+ _validate_workflow_id(workflow_id)
+
+ all_layers: list[Layer] = []
+ for source in self._sources:
+ all_layers.extend(
+ source.collect(workflow_id, include_disabled=include_disabled)
+ )
+
+ overlays = [layer for layer in all_layers if layer.tier != "base"]
+ base_layers = [layer for layer in all_layers if layer.tier == "base"]
+ return (
+ sorted(overlays, key=lambda layer: (layer.priority, layer.source))
+ + base_layers
+ )
+
+ def resolve(self, workflow_id: str) -> WorkflowDefinition:
+ """Resolve a workflow ID to its composed definition.
+
+ This method composes layers but does not validate workflow semantics;
+ callers should validate the returned definition when needed.
+
+ Raises:
+ FileNotFoundError: if the workflow cannot be found.
+ ValueError: if layer collection/composition fails.
+ """
+ layers = self.collect_all_layers(workflow_id)
+ definition, _ = self._composer.compose(layers)
+ if definition is None:
+ raise FileNotFoundError(f"Workflow not found: {workflow_id}")
+ return definition
+
+ def resolve_with_layers(
+ self, workflow_id: str
+ ) -> tuple[WorkflowDefinition, list[Layer], list[ComposedStep]]:
+ """Resolve a workflow and return its definition plus layer attribution."""
+ layers = self.collect_all_layers(workflow_id)
+ definition, attribution = self._composer.compose(layers)
+ if definition is None:
+ raise FileNotFoundError(f"Workflow not found: {workflow_id}")
+ return definition, layers, attribution
diff --git a/src/specify_cli/workflows/overlays/_commands.py b/src/specify_cli/workflows/overlays/_commands.py
new file mode 100644
index 0000000000..cec7d8534a
--- /dev/null
+++ b/src/specify_cli/workflows/overlays/_commands.py
@@ -0,0 +1,442 @@
+"""CLI handlers for ``specify workflow overlay *`` and ``specify workflow resolve``."""
+
+from __future__ import annotations
+
+from pathlib import Path
+from typing import Any
+
+import typer
+import yaml
+
+from ..._console import console, err_console
+from ...extensions import normalize_priority
+from .._commands import (
+ _commit_workflow_file,
+ _discard_committed_backup_file,
+ _reject_unsafe_dir,
+ _reject_unsafe_workflow_storage,
+ _safe_discard_staged_workflow_file,
+ _stage_workflow_file,
+)
+from . import WorkflowResolver
+from .schema import _RESERVED_WORKFLOW_IDS, _SAFE_ID_PATTERN, validate_overlay_yaml
+
+
+def _validate_overlay_id_or_exit(id_value: str, label: str) -> None:
+ """Validate a single-segment overlay/workflow id from CLI arguments."""
+ if not isinstance(id_value, str) or not id_value:
+ err_console.print(f"[red]Error:[/red] {label} is required and must be a non-empty string.")
+ raise typer.Exit(1)
+ if not _SAFE_ID_PATTERN.fullmatch(id_value):
+ err_console.print(
+ f"[red]Error:[/red] Invalid {label} {id_value!r}: "
+ "only lowercase letters, digits, and hyphens are allowed."
+ )
+ raise typer.Exit(1)
+
+
+def _validate_workflow_id_or_exit(workflow_id: str) -> None:
+ """Validate a workflow id, treating the overlay root as reserved."""
+ _validate_overlay_id_or_exit(workflow_id, "workflow ID")
+ if workflow_id in _RESERVED_WORKFLOW_IDS:
+ err_console.print(
+ f"[red]Error:[/red] Invalid workflow ID {workflow_id!r}: "
+ "reserved name."
+ )
+ raise typer.Exit(1)
+
+
+def _overlay_root(project_root: Path) -> Path:
+ """Return the project-local overlay root after rejecting unsafe ancestors."""
+ _reject_unsafe_workflow_storage(project_root)
+ root = project_root / ".specify" / "workflows" / "overlays"
+ _reject_unsafe_dir(root, ".specify/workflows/overlays")
+ return root
+
+
+def _project_overlay_dir(project_root: Path, workflow_id: str) -> Path:
+ """Return the project-local overlay directory for a workflow id.
+
+ Raises typer.Exit if the resolved path escapes the overlay root.
+ """
+ _validate_workflow_id_or_exit(workflow_id)
+ root = _overlay_root(project_root)
+ target = root / workflow_id
+ return _ensure_contained_dir(target, root)
+
+
+def _ensure_contained_dir(path: Path, root: Path) -> Path:
+ """Ensure *path* resolves inside *root* and is not a symlink.
+
+ Returns *path* if safe. Raises typer.Exit on traversal or symlink.
+ """
+ _reject_unsafe_dir(root, ".specify/workflows/overlays")
+ if path.is_symlink():
+ err_console.print(
+ f"[red]Error:[/red] Refusing to use symlinked path {path}."
+ )
+ raise typer.Exit(1)
+ if path.exists() and not path.is_dir():
+ err_console.print(
+ f"[red]Error:[/red] Overlay directory path is not a directory: {path}."
+ )
+ raise typer.Exit(1)
+ try:
+ resolved = path.resolve()
+ root_resolved = root.resolve()
+ resolved.relative_to(root_resolved)
+ except ValueError:
+ err_console.print(
+ f"[red]Error:[/red] Path traversal detected: {path} is outside the allowed directory."
+ )
+ raise typer.Exit(1)
+ return path
+
+
+def _find_overlay_file(project_root: Path, workflow_id: str, overlay_id: str) -> Path | None:
+ """Locate a project-local overlay file by its manifest ID, not filename.
+
+ Scans all YAML files in the overlay directory and matches on the ``id``
+ field inside each manifest. This aligns with ``ProjectOverlaySource.collect()``
+ which also derives identity from the manifest, not the filename.
+ """
+ _validate_workflow_id_or_exit(workflow_id)
+ _validate_overlay_id_or_exit(overlay_id, "overlay ID")
+ overlay_dir = _project_overlay_dir(project_root, workflow_id)
+ if not overlay_dir.is_dir():
+ return None
+ try:
+ entries = sorted(overlay_dir.iterdir())
+ except OSError:
+ return None
+ matches: list[Path] = []
+ for path in entries:
+ if not path.is_file() or path.suffix not in (".yml", ".yaml"):
+ continue
+ if path.is_symlink():
+ continue
+ data, _ = _read_overlay(path)
+ if data is None:
+ continue
+ if data.get("id") == overlay_id:
+ matches.append(path)
+ if len(matches) > 1:
+ paths = ", ".join(str(path) for path in matches)
+ err_console.print(
+ f"[red]Error:[/red] Duplicate overlay ID '{overlay_id}' in {paths}. "
+ "Resolve the duplicate manifest IDs before continuing."
+ )
+ raise typer.Exit(1)
+ return matches[0] if matches else None
+
+
+def _ensure_contained_path(path: Path, root: Path) -> Path:
+ """Return *path* only if it resolves inside *root*; otherwise raise typer.Exit."""
+ _reject_unsafe_dir(root, ".specify/workflows/overlays")
+ if path.is_symlink():
+ err_console.print(
+ f"[red]Error:[/red] Refusing to use symlinked path {path}."
+ )
+ raise typer.Exit(1)
+ try:
+ resolved = path.resolve()
+ root_resolved = root.resolve()
+ resolved.relative_to(root_resolved)
+ except ValueError:
+ err_console.print(
+ f"[red]Error:[/red] Path traversal detected: {path} is outside the allowed directory."
+ )
+ raise typer.Exit(1)
+ return path
+
+
+def _read_overlay(path: Path) -> tuple[dict[str, Any] | None, list[str]]:
+ """Read and parse an overlay YAML file, returning (data, errors)."""
+ try:
+ content = path.read_text(encoding="utf-8")
+ except (OSError, UnicodeDecodeError) as exc:
+ return None, [f"Failed to read {path}: {exc}"]
+ try:
+ data = yaml.safe_load(content)
+ except yaml.YAMLError as exc:
+ return None, [f"Invalid YAML in {path}: {exc}"]
+ if not isinstance(data, dict):
+ return None, [f"Overlay {path} must be a YAML mapping."]
+ return data, []
+
+
+def workflow_overlay_add(
+ project_root: Path,
+ source: Path,
+ priority: int | None = None,
+) -> Path | None:
+ """Add a project-local overlay from a YAML file.
+
+ Returns the path of the installed overlay file, or None on failure.
+ """
+ _reject_unsafe_workflow_storage(project_root)
+ data, errors = _read_overlay(source)
+ if data is None:
+ for err in errors:
+ err_console.print(f"[red]Error:[/red] {err}")
+ return None
+
+ # Apply --priority override before validation so a valid CLI priority
+ # can fix a missing or invalid priority in the file.
+ if priority is not None:
+ if isinstance(priority, bool) or not isinstance(priority, int) or priority < 1:
+ err_console.print("[red]Error:[/red] Priority must be >= 1.")
+ return None
+ data["priority"] = normalize_priority(priority)
+
+ overlay, validation_errors = validate_overlay_yaml(data)
+ if overlay is None:
+ err_console.print("[red]Error:[/red] Overlay validation failed:")
+ for err in validation_errors:
+ err_console.print(f" \u2022 {err}")
+ return None
+ data["priority"] = overlay.priority
+
+ target_dir = _project_overlay_dir(project_root, overlay.extends)
+ # Reuse an existing .yaml file so we don't create a duplicate .yml layer.
+ existing = _find_overlay_file(project_root, overlay.extends, overlay.id)
+ if existing is not None:
+ target_path = existing
+ else:
+ target_path = _ensure_contained_path(
+ target_dir / f"{overlay.id}.yml", _overlay_root(project_root)
+ )
+
+ backup: Path | None = None
+ try:
+ target_dir.mkdir(parents=True, exist_ok=True)
+ existed_before = target_path.exists()
+ staged = _stage_workflow_file(target_path.parent)
+ try:
+ staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8"))
+ backup = _commit_workflow_file(staged, target_path, existed_before)
+ except BaseException:
+ _safe_discard_staged_workflow_file(
+ staged, target_path.parent, existed_before
+ )
+ raise
+ except OSError as exc:
+ err_console.print(f"[red]Error:[/red] Failed to write overlay: {exc}")
+ return None
+ _discard_committed_backup_file(backup)
+
+ console.print(
+ f"[green]\u2713[/green] Overlay '{overlay.id}' added for workflow '{overlay.extends}'"
+ )
+ return target_path
+
+
+def _update_overlay_field(
+ project_root: Path,
+ workflow_id: str,
+ overlay_id: str,
+ field: str,
+ value: Any,
+) -> bool:
+ """Update a single field in a project-local overlay file."""
+ _reject_unsafe_workflow_storage(project_root)
+ path = _find_overlay_file(project_root, workflow_id, overlay_id)
+ if path is None:
+ err_console.print(
+ f"[red]Error:[/red] Overlay '{overlay_id}' not found for workflow '{workflow_id}'"
+ )
+ return False
+
+ data, errors = _read_overlay(path)
+ if data is None:
+ for err in errors:
+ err_console.print(f"[red]Error:[/red] {err}")
+ return False
+
+ data[field] = value
+ overlay, validation_errors = validate_overlay_yaml(data)
+ if overlay is None:
+ err_console.print("[red]Error:[/red] Overlay validation failed:")
+ for err in validation_errors:
+ err_console.print(f" \u2022 {err}")
+ return False
+
+ backup: Path | None = None
+ try:
+ existed_before = path.exists()
+ staged = _stage_workflow_file(path.parent)
+ try:
+ staged.write_bytes(yaml.safe_dump(data, sort_keys=False).encode("utf-8"))
+ backup = _commit_workflow_file(staged, path, existed_before)
+ except BaseException:
+ _safe_discard_staged_workflow_file(staged, path.parent, existed_before)
+ raise
+ except OSError as exc:
+ err_console.print(f"[red]Error:[/red] Failed to write overlay: {exc}")
+ return False
+ _discard_committed_backup_file(backup)
+
+ return True
+
+
+def workflow_overlay_set_priority(
+ project_root: Path,
+ workflow_id: str,
+ overlay_id: str,
+ priority: int,
+) -> bool:
+ """Set the priority of a project-local overlay."""
+ if isinstance(priority, bool) or not isinstance(priority, int) or priority < 1:
+ err_console.print("[red]Error:[/red] Priority must be >= 1.")
+ raise typer.Exit(1)
+ normalized_priority = normalize_priority(priority)
+ if _update_overlay_field(
+ project_root, workflow_id, overlay_id, "priority", normalized_priority
+ ):
+ console.print(
+ f"[green]\u2713[/green] Priority of overlay '{overlay_id}' set to {normalized_priority}"
+ )
+ return True
+ return False
+
+
+def workflow_overlay_enable(
+ project_root: Path,
+ workflow_id: str,
+ overlay_id: str,
+) -> bool:
+ """Enable a project-local overlay."""
+ if _update_overlay_field(project_root, workflow_id, overlay_id, "enabled", True):
+ console.print(f"[green]\u2713[/green] Overlay '{overlay_id}' enabled")
+ return True
+ return False
+
+
+def workflow_overlay_disable(
+ project_root: Path,
+ workflow_id: str,
+ overlay_id: str,
+) -> bool:
+ """Disable a project-local overlay."""
+ if _update_overlay_field(project_root, workflow_id, overlay_id, "enabled", False):
+ console.print(f"[green]\u2713[/green] Overlay '{overlay_id}' disabled")
+ return True
+ return False
+
+
+def workflow_overlay_remove(
+ project_root: Path,
+ workflow_id: str,
+ overlay_id: str,
+) -> bool:
+ """Remove a project-local overlay file."""
+ _reject_unsafe_workflow_storage(project_root)
+ path = _find_overlay_file(project_root, workflow_id, overlay_id)
+ if path is None:
+ err_console.print(
+ f"[red]Error:[/red] Overlay '{overlay_id}' not found for workflow '{workflow_id}'"
+ )
+ return False
+
+ try:
+ path.unlink()
+ except OSError as exc:
+ err_console.print(f"[red]Error:[/red] Failed to remove overlay: {exc}")
+ return False
+
+ console.print(f"[green]\u2713[/green] Overlay '{overlay_id}' removed")
+ return True
+
+
+def workflow_overlay_list(project_root: Path, workflow_id: str) -> list[dict[str, Any]] | None:
+ """List all overlays for a workflow and print a summary table.
+
+ Returns the raw list data for machine-readable callers, or None on error.
+ """
+ _reject_unsafe_workflow_storage(project_root)
+ _validate_workflow_id_or_exit(workflow_id)
+ resolver = WorkflowResolver(project_root)
+ try:
+ layers = resolver.collect_all_layers(workflow_id, include_disabled=True)
+ except ValueError as exc:
+ err_console.print(f"[red]Error:[/red] {exc}")
+ return None
+ overlays = [layer for layer in layers if layer.tier != "base"]
+
+ if not overlays:
+ console.print(f"[yellow]No overlays found for workflow '{workflow_id}'.[/yellow]")
+ return []
+
+ console.print(f"Overlays for workflow '{workflow_id}':")
+ rows: list[dict[str, Any]] = []
+ for layer in overlays:
+ overlay = layer.content
+ rows.append({
+ "id": overlay.id,
+ "source": layer.source,
+ "tier": layer.tier,
+ "priority": normalize_priority(overlay.priority),
+ "enabled": overlay.enabled,
+ "path": str(layer.path) if layer.path else None,
+ })
+ enabled_marker = "enabled" if overlay.enabled else "disabled"
+ console.print(
+ f" \u2022 {overlay.id} (priority={normalize_priority(overlay.priority)}, "
+ f"source={layer.source}, {enabled_marker})"
+ )
+ return rows
+
+
+def workflow_resolve(project_root: Path, workflow_id: str) -> dict[str, Any] | None:
+ """Print layer attribution for a resolved workflow.
+
+ Returns a serializable attribution payload.
+ """
+ _reject_unsafe_workflow_storage(project_root)
+ _validate_workflow_id_or_exit(workflow_id)
+ resolver = WorkflowResolver(project_root)
+ try:
+ definition, layers, attribution = resolver.resolve_with_layers(workflow_id)
+ except FileNotFoundError:
+ err_console.print(
+ f"[red]Error:[/red] Workflow '{workflow_id}' not found"
+ )
+ return None
+ except ValueError as exc:
+ err_console.print(f"[red]Error:[/red] {exc}")
+ return None
+
+ console.print(f"Resolved workflow '{workflow_id}':")
+ console.print("Layers (highest precedence first):")
+ for layer in layers:
+ priority = (
+ "n/a" if layer.tier == "base" else str(normalize_priority(layer.priority))
+ )
+ console.print(
+ f" \u2022 [{layer.tier}] {layer.source} "
+ f"(priority={priority})"
+ )
+
+ console.print("Step attribution:")
+ for composed in attribution:
+ console.print(f" \u2022 {composed.step_id}: {composed.source}")
+
+ return {
+ "workflow_id": workflow_id,
+ "layers": [
+ {
+ "source": layer.source,
+ "tier": layer.tier,
+ "priority": (
+ None
+ if layer.tier == "base"
+ else normalize_priority(layer.priority)
+ ),
+ }
+ for layer in layers
+ ],
+ "attribution": [
+ {"step_id": composed.step_id, "source": composed.source}
+ for composed in attribution
+ ],
+ }
diff --git a/src/specify_cli/workflows/overlays/composer.py b/src/specify_cli/workflows/overlays/composer.py
new file mode 100644
index 0000000000..4a1941727f
--- /dev/null
+++ b/src/specify_cli/workflows/overlays/composer.py
@@ -0,0 +1,97 @@
+"""Workflow overlay composer ā builds a WorkflowDefinition from layers."""
+
+from __future__ import annotations
+
+from typing import Any
+
+from ..engine import WorkflowDefinition
+from .layer_sources import Layer
+from .merge import OverlayLayer, merge_steps, validate_edits
+
+
+class StepListComposer:
+ """Compose a workflow from a base layer and overlay layers.
+
+ - The base layer (tier="base") provides the full step list.
+ - Overlay layers provide edit operations.
+ - Overlays are applied in merge order: highest priority number first,
+ lowest last, so lower priority numbers win. Ties are applied by overlay
+ ID, with the alphabetically last ID winning.
+ - Returns a parsed WorkflowDefinition; callers must validate separately.
+ """
+
+ def compose(
+ self, layers: list[Layer]
+ ) -> tuple[WorkflowDefinition | None, list]:
+ """Compose a ``WorkflowDefinition`` from the given layers.
+
+ Returns ``(None, [])`` when no base layer is present.
+ """
+ base_layer: Layer | None = None
+ overlay_layers: list[Layer] = []
+ for layer in layers:
+ if layer.tier == "base":
+ base_layer = layer
+ else:
+ overlay_layers.append(layer)
+
+ if base_layer is None or base_layer.path is None:
+ return None, []
+
+ # Read the base workflow definition from disk.
+ base_definition = WorkflowDefinition.from_yaml(base_layer.path)
+ base_steps = base_definition.data.get("steps", [])
+ if not isinstance(base_steps, list):
+ # Preserve the invalid definition intact so validate_workflow can
+ # report "'steps' must be a list." to the caller; coercing to []
+ # here would mask that error.
+ return base_definition, []
+
+ # Last applied wins, so apply lower priority numbers last.
+ merge_order = sorted(
+ overlay_layers,
+ key=lambda layer: (-layer.priority, layer.content.id),
+ )
+
+ # Validate edits against base anchors before mutation.
+ base_step_ids = self._collect_base_step_ids(base_steps)
+ for layer in merge_order:
+ edit_errors = validate_edits(layer.content.edits, base_step_ids)
+ if edit_errors:
+ raise ValueError(
+ f"Overlay '{layer.content.id}' has invalid edits:\n - "
+ + "\n - ".join(edit_errors)
+ )
+
+ composed_steps, attribution = merge_steps(
+ base_steps,
+ [OverlayLayer(layer.content, layer.source) for layer in merge_order],
+ )
+
+ # Build composed data while preserving all non-step fields from base.
+ composed_data: dict[str, Any] = dict(base_definition.data)
+ composed_data["steps"] = composed_steps
+
+ composed_definition = WorkflowDefinition(composed_data, source_path=base_layer.path)
+
+ return composed_definition, attribution
+
+ def _collect_base_step_ids(self, steps: list[dict[str, Any]]) -> set[str]:
+ """Collect all base step IDs reachable in the step tree."""
+ ids: set[str] = set()
+ for step in steps:
+ if not isinstance(step, dict):
+ continue
+ step_id = step.get("id")
+ if isinstance(step_id, str):
+ ids.add(step_id)
+ for key in ("then", "else", "steps", "default"):
+ nested = step.get(key)
+ if isinstance(nested, list):
+ ids.update(self._collect_base_step_ids(nested))
+ cases = step.get("cases")
+ if isinstance(cases, dict):
+ for case_steps in cases.values():
+ if isinstance(case_steps, list):
+ ids.update(self._collect_base_step_ids(case_steps))
+ return ids
diff --git a/src/specify_cli/workflows/overlays/layer_sources.py b/src/specify_cli/workflows/overlays/layer_sources.py
new file mode 100644
index 0000000000..e51aaf70dd
--- /dev/null
+++ b/src/specify_cli/workflows/overlays/layer_sources.py
@@ -0,0 +1,234 @@
+"""Workflow overlay layer sources."""
+
+from __future__ import annotations
+
+from dataclasses import dataclass
+from pathlib import Path
+
+import yaml
+
+from .schema import Overlay, _RESERVED_WORKFLOW_IDS, _SAFE_ID_PATTERN, validate_overlay_yaml
+
+
+@dataclass
+class Layer:
+ """A single layer in the workflow overlay stack."""
+
+ content: Overlay
+ source: str
+ tier: str
+ priority: int
+ path: Path | None = None
+
+
+class OverlayLoadError(ValueError):
+ """Raised when an overlay file cannot be loaded or validated."""
+
+ def __init__(self, path: Path, errors: list[str]) -> None:
+ self.path = path
+ self.errors = errors
+ super().__init__(f"Invalid overlay {path}:\n - " + "\n - ".join(errors))
+
+
+def _validate_workflow_id(workflow_id: str, context_path: Path) -> None:
+ """Raise OverlayLoadError if workflow_id is not a safe path-segment identifier.
+
+ Mirrors the same check performed by WorkflowResolver so layer sources are
+ safe to call directly, without going through the resolver.
+ """
+ if (
+ not isinstance(workflow_id, str)
+ or not _SAFE_ID_PATTERN.fullmatch(workflow_id)
+ or workflow_id in _RESERVED_WORKFLOW_IDS
+ ):
+ raise OverlayLoadError(
+ context_path,
+ [f"Invalid workflow ID: {workflow_id!r}"],
+ )
+
+
+def _ensure_contained_dir(path: Path, root: Path) -> None:
+ """Raise OverlayLoadError if *path* is a symlink, a non-directory, or escapes *root*.
+
+ Mirrors the logic of ``_ensure_contained_dir`` in ``overlays/_commands.py``
+ but raises ``OverlayLoadError`` instead of ``typer.Exit`` so layer sources
+ can enforce the same invariants without a CLI dependency.
+
+ The caller is responsible for ensuring *root* itself is already validated
+ (e.g. via ``_resolve_project_overlay_root``).
+ """
+ if path.is_symlink():
+ raise OverlayLoadError(path, ["Symlinked overlay directories are not allowed"])
+ if path.exists() and not path.is_dir():
+ raise OverlayLoadError(path, ["Overlay directory path is not a directory"])
+ try:
+ path.resolve().relative_to(root.resolve())
+ except ValueError:
+ raise OverlayLoadError(
+ path, ["Path traversal detected: directory escapes allowed root"]
+ ) from None
+
+
+def _resolve_workflows_root(project_root: Path) -> Path:
+ """Return the workflow storage root after rejecting unsafe ancestors."""
+ project_root_resolved = project_root.resolve()
+ workflows_root = project_root / ".specify" / "workflows"
+
+ current = project_root
+ for part in (".specify", "workflows"):
+ current = current / part
+ if current.is_symlink():
+ raise OverlayLoadError(
+ current,
+ [f"Symlinked workflow directories are not allowed ({current})"],
+ )
+ if current.exists() and not current.is_dir():
+ raise OverlayLoadError(
+ current,
+ [f"Workflow directory path is not a directory ({current})"],
+ )
+
+ try:
+ workflows_root.resolve().relative_to(project_root_resolved)
+ except ValueError:
+ raise OverlayLoadError(
+ workflows_root,
+ ["Workflow directory escapes the project root"],
+ ) from None
+ return workflows_root
+
+
+def _resolve_project_overlay_root(project_root: Path) -> Path:
+ """Return the unresolved overlay root after rejecting unsafe ancestors."""
+ workflows_root = _resolve_workflows_root(project_root)
+ overlays_root = workflows_root / "overlays"
+ if overlays_root.is_symlink():
+ raise OverlayLoadError(
+ overlays_root,
+ [f"Symlinked overlay directories are not allowed ({overlays_root})"],
+ )
+ if overlays_root.exists() and not overlays_root.is_dir():
+ raise OverlayLoadError(
+ overlays_root,
+ [f"Overlay directory path is not a directory ({overlays_root})"],
+ )
+ return overlays_root
+
+
+class ProjectOverlaySource:
+ """Project-local overlays: ``.specify/workflows/overlays//*.yml``."""
+
+ tier = "project-overlay"
+
+ def __init__(self, project_root: Path) -> None:
+ self.project_root = project_root
+ self.overlays_dir = project_root / ".specify" / "workflows" / "overlays"
+
+ def collect(self, workflow_id: str, *, include_disabled: bool = False) -> list[Layer]:
+ """Collect project-local overlays for the given workflow id.
+
+ Args:
+ workflow_id: Workflow identifier whose overlay directory to scan.
+ include_disabled: When True, return disabled overlays for
+ management/list views. Resolution paths keep the default False.
+ """
+ self.overlays_dir = _resolve_project_overlay_root(self.project_root)
+ _validate_workflow_id(workflow_id, self.overlays_dir)
+ workflow_overlay_dir = self.overlays_dir / workflow_id
+ _ensure_contained_dir(workflow_overlay_dir, self.overlays_dir)
+ if not workflow_overlay_dir.is_dir():
+ return []
+ layers: list[Layer] = []
+ overlay_paths_by_id: dict[str, Path] = {}
+ try:
+ entries = sorted(workflow_overlay_dir.iterdir())
+ except OSError as exc:
+ raise OverlayLoadError(
+ workflow_overlay_dir, [f"Cannot enumerate overlays: {exc}"]
+ ) from exc
+ for path in entries:
+ if not path.is_file() or path.suffix not in (".yml", ".yaml"):
+ continue
+ if path.is_symlink():
+ raise OverlayLoadError(path, ["Symlinked overlay files are not allowed"])
+ try:
+ data = yaml.safe_load(path.read_text(encoding="utf-8")) or {}
+ except yaml.YAMLError as exc:
+ raise OverlayLoadError(path, [f"Invalid YAML: {exc}"]) from exc
+ except (OSError, UnicodeDecodeError) as exc:
+ raise OverlayLoadError(path, [f"Cannot load overlay: {exc}"]) from exc
+ if (
+ not include_disabled
+ and isinstance(data, dict)
+ and data.get("enabled", True) is False
+ ):
+ continue
+ overlay, errors = validate_overlay_yaml(data)
+ if overlay is None or errors:
+ raise OverlayLoadError(path, errors)
+ if overlay.extends != workflow_id:
+ raise OverlayLoadError(
+ path,
+ [
+ f"Overlay extends {overlay.extends!r}, but is stored under "
+ f"workflow {workflow_id!r}."
+ ],
+ )
+ first_path = overlay_paths_by_id.get(overlay.id)
+ if first_path is not None:
+ raise OverlayLoadError(
+ path,
+ [
+ f"Duplicate overlay id {overlay.id!r}; also declared in "
+ f"{first_path}."
+ ],
+ )
+ overlay_paths_by_id[overlay.id] = path
+ layers.append(
+ Layer(
+ content=overlay,
+ source=f"project:{overlay.id}",
+ tier=self.tier,
+ priority=overlay.priority,
+ path=path,
+ )
+ )
+ return layers
+
+
+class BaseWorkflowSource:
+ """Base workflow layer: ``.specify/workflows//workflow.yml``."""
+
+ tier = "base"
+
+ def __init__(self, project_root: Path) -> None:
+ self.project_root = project_root
+ self.workflows_dir = project_root / ".specify" / "workflows"
+
+ def collect(self, workflow_id: str, *, include_disabled: bool = False) -> list[Layer]:
+ """Return the base workflow as a single layer if it exists."""
+ self.workflows_dir = _resolve_workflows_root(self.project_root)
+ _validate_workflow_id(workflow_id, self.workflows_dir)
+ workflow_dir = self.workflows_dir / workflow_id
+ _ensure_contained_dir(workflow_dir, self.workflows_dir)
+ path = workflow_dir / "workflow.yml"
+ if path.is_symlink():
+ raise OverlayLoadError(path, ["Symlinked workflow files are not allowed"])
+ if not path.is_file():
+ return []
+ # The base layer is represented by an Overlay with empty edits.
+ overlay = Overlay(
+ id=workflow_id,
+ extends=workflow_id,
+ priority=0,
+ edits=[],
+ )
+ return [
+ Layer(
+ content=overlay,
+ source="base",
+ tier=self.tier,
+ priority=0,
+ path=path,
+ )
+ ]
diff --git a/src/specify_cli/workflows/overlays/merge.py b/src/specify_cli/workflows/overlays/merge.py
new file mode 100644
index 0000000000..bf28a1f133
--- /dev/null
+++ b/src/specify_cli/workflows/overlays/merge.py
@@ -0,0 +1,407 @@
+"""Pure-function merge engine for workflow step lists."""
+
+from __future__ import annotations
+
+import copy
+from dataclasses import dataclass
+from typing import Any
+
+from .schema import VALID_OPERATIONS, Overlay, OverlayEdit
+
+
+@dataclass(frozen=True)
+class ComposedStep:
+ """Attribution tracking for a single composed step."""
+
+ step_id: str
+ source: str
+
+
+@dataclass(frozen=True)
+class OverlayLayer:
+ """An overlay together with its layer source for attribution."""
+
+ overlay: Overlay
+ source: str
+
+
+# Nested step keys that may contain a list of steps.
+_NESTED_LIST_KEYS = ("then", "else", "steps", "default")
+
+
+def find_step(
+ steps: list[dict[str, Any]], step_id: str
+) -> tuple[list[dict[str, Any]], int] | None:
+ """Recursively locate a step by ID and return its (parent_list, index).
+
+ Searches flat lists and nested lists inside ``then``, ``else``, ``steps``,
+ ``default``, and ``cases.*``. Does *not* descend into ``fan-out`` template
+ steps because those are runtime-multiplied stamps, not uniquely-addressable
+ nodes.
+ """
+ for i, step in enumerate(steps):
+ if not isinstance(step, dict):
+ continue
+ if step.get("id") == step_id:
+ return (steps, i)
+ for key in _NESTED_LIST_KEYS:
+ nested = step.get(key)
+ if isinstance(nested, list):
+ result = find_step(nested, step_id)
+ if result is not None:
+ return result
+ cases = step.get("cases")
+ if isinstance(cases, dict):
+ for case_steps in cases.values():
+ if isinstance(case_steps, list):
+ result = find_step(case_steps, step_id)
+ if result is not None:
+ return result
+ return None
+
+
+def _all_base_step_ids(steps: list[dict[str, Any]]) -> set[str]:
+ """Collect all step IDs reachable in a step tree (excluding fan-out templates)."""
+ ids: set[str] = set()
+ for step in steps:
+ if not isinstance(step, dict):
+ continue
+ step_id = step.get("id")
+ if isinstance(step_id, str):
+ ids.add(step_id)
+ for key in _NESTED_LIST_KEYS:
+ nested = step.get(key)
+ if isinstance(nested, list):
+ ids.update(_all_base_step_ids(nested))
+ cases = step.get("cases")
+ if isinstance(cases, dict):
+ for case_steps in cases.values():
+ if isinstance(case_steps, list):
+ ids.update(_all_base_step_ids(case_steps))
+ return ids
+
+
+def _descendant_ids(step: dict[str, Any]) -> set[str]:
+ """Return all step IDs nested inside *step* (not including *step* itself)."""
+ ids: set[str] = set()
+ for key in _NESTED_LIST_KEYS:
+ nested = step.get(key)
+ if isinstance(nested, list):
+ ids.update(_all_base_step_ids(nested))
+ cases = step.get("cases")
+ if isinstance(cases, dict):
+ for case_steps in cases.values():
+ if isinstance(case_steps, list):
+ ids.update(_all_base_step_ids(case_steps))
+ return ids
+
+
+def _check_anchor_conflicts(
+ anchor_operations: dict[str, str],
+ base_steps: list[dict[str, Any]],
+) -> list[str]:
+ """Return error messages for anchor pairs where one is an ancestor of the other.
+
+ Only flags conflicts where the ancestor's winning edit is ``replace`` or
+ ``remove`` ā operations that destroy the subtree and make any descendant
+ anchor unresolvable. Pure insert operations on an ancestor leave it intact,
+ so its descendants remain reachable regardless of processing order.
+
+ Callers should raise on any returned errors before mutating the step tree.
+ """
+ errors: list[str] = []
+ for anchor, operation in sorted(anchor_operations.items()):
+ if operation in ("insert_after", "insert_before"):
+ # Inserts leave the ancestor step intact; descendants are unaffected.
+ continue
+ location = find_step(base_steps, anchor)
+ if location is None:
+ continue # missing anchors are reported by validate_edits
+ parent_list, idx = location
+ step = parent_list[idx]
+ conflicting = set(anchor_operations.keys()) & _descendant_ids(step)
+ for child_anchor in sorted(conflicting):
+ errors.append(
+ f"Anchor conflict: '{anchor}' is an ancestor of '{child_anchor}'. "
+ "Targeting both anchors in the same overlay set produces "
+ "order-dependent results; restructure edits to avoid nesting."
+ )
+ return errors
+
+
+def _init_sources_recursively(
+ steps: list[dict[str, Any]], sources: dict[str, str]
+) -> None:
+ """Initialize attribution sources for all base steps, recursively."""
+ for step in steps:
+ if not isinstance(step, dict):
+ continue
+ step_id = step.get("id")
+ if isinstance(step_id, str):
+ sources[step_id] = "base"
+ for key in _NESTED_LIST_KEYS:
+ nested = step.get(key)
+ if isinstance(nested, list):
+ _init_sources_recursively(nested, sources)
+ cases = step.get("cases")
+ if isinstance(cases, dict):
+ for case_steps in cases.values():
+ if isinstance(case_steps, list):
+ _init_sources_recursively(case_steps, sources)
+
+
+def _record_sources_recursively(
+ step: dict[str, Any],
+ source: str,
+ sources: dict[str, str],
+) -> None:
+ """Record *source* for a step and all its nested child steps.
+
+ Traverses ``then``, ``else``, ``steps``, ``default``, and ``cases.*``
+ so that ``workflow resolve`` attributes every step inside a composite
+ insert or replacement to the correct overlay layer.
+ """
+ step_id = step.get("id")
+ if isinstance(step_id, str):
+ sources[step_id] = source
+ for key in _NESTED_LIST_KEYS:
+ nested = step.get(key)
+ if isinstance(nested, list):
+ for child in nested:
+ if isinstance(child, dict):
+ _record_sources_recursively(child, source, sources)
+ cases = step.get("cases")
+ if isinstance(cases, dict):
+ for case_steps in cases.values():
+ if isinstance(case_steps, list):
+ for child in case_steps:
+ if isinstance(child, dict):
+ _record_sources_recursively(child, source, sources)
+
+
+def _remove_sources_recursively(
+ step: dict[str, Any],
+ sources: dict[str, str],
+) -> None:
+ """Remove source entries for a step and all its nested child steps.
+
+ Traverses the same nesting keys as ``_record_sources_recursively``.
+ """
+ step_id = step.get("id")
+ if isinstance(step_id, str) and sources.get(step_id) == "base":
+ sources.pop(step_id, None)
+ for key in _NESTED_LIST_KEYS:
+ nested = step.get(key)
+ if isinstance(nested, list):
+ for child in nested:
+ if isinstance(child, dict):
+ _remove_sources_recursively(child, sources)
+ cases = step.get("cases")
+ if isinstance(cases, dict):
+ for case_steps in cases.values():
+ if isinstance(case_steps, list):
+ for child in case_steps:
+ if isinstance(child, dict):
+ _remove_sources_recursively(child, sources)
+
+
+
+def _build_attribution(
+ steps: list[dict[str, Any]],
+ sources: dict[str, str],
+) -> list[ComposedStep]:
+ """Build an ordered attribution list from the composed step tree."""
+ result: list[ComposedStep] = []
+ for step in steps:
+ if not isinstance(step, dict):
+ continue
+ step_id = step.get("id")
+ if isinstance(step_id, str):
+ result.append(ComposedStep(step_id, sources.get(step_id, "unknown")))
+ for key in _NESTED_LIST_KEYS:
+ nested = step.get(key)
+ if isinstance(nested, list):
+ result.extend(_build_attribution(nested, sources))
+ cases = step.get("cases")
+ if isinstance(cases, dict):
+ for case_steps in cases.values():
+ if isinstance(case_steps, list):
+ result.extend(_build_attribution(case_steps, sources))
+ return result
+
+
+def _traverse_and_apply(
+ steps: list[dict[str, Any]],
+ edits_by_anchor: dict[str, list[tuple[OverlayLayer, OverlayEdit]]],
+ sources: dict[str, str],
+) -> list[dict[str, Any]]:
+ """Walk the original step tree and apply overlay edits as each step is encountered.
+
+ Edits are always resolved against the *original* structure ā this function
+ traverses the unmodified list passed in, so a replacement step's new ID can
+ never be mistaken for a base anchor. Nested lists (``then``, ``else``, etc.)
+ are recursed into only for steps that survive the edit (not for replaced
+ steps).
+
+ *edits* are expected to be in merge order (lowest priority first, highest
+ priority last); the winning edit for each anchor is ``edits[-1]``.
+ """
+ result: list[dict[str, Any]] = []
+
+ for step in steps:
+ if not isinstance(step, dict):
+ result.append(step)
+ continue
+
+ step_id = step.get("id")
+ edits = edits_by_anchor.get(step_id, []) if isinstance(step_id, str) else []
+ winning_edit = edits[-1][1] if edits else None
+
+ if winning_edit is not None and winning_edit.operation == "remove":
+ # Winning edit removes this step; ignore all other edits on this anchor.
+ # Do NOT call _remove_sources_recursively here: _build_attribution only
+ # traverses the result list, so stale sources entries for removed steps
+ # are never read. Calling it would incorrectly pop the attribution of a
+ # *surviving* step that reuses the same ID (e.g. a replacement step
+ # introduced by a higher-priority overlay targeting a different anchor).
+ continue
+
+ # Insert before (in merge order).
+ for layer, edit in edits:
+ if edit.operation == "insert_before":
+ new_step = copy.deepcopy(edit.step)
+ _record_sources_recursively(new_step, layer.source, sources)
+ result.append(new_step)
+
+ if winning_edit is not None and winning_edit.operation == "replace":
+ winning_layer = edits[-1][0]
+ new_step = copy.deepcopy(winning_edit.step)
+ _remove_sources_recursively(step, sources)
+ _record_sources_recursively(new_step, winning_layer.source, sources)
+ result.append(new_step)
+ else:
+ # No replacement: keep this step and recurse into its nested lists.
+ for key in _NESTED_LIST_KEYS:
+ nested = step.get(key)
+ if isinstance(nested, list):
+ step[key] = _traverse_and_apply(nested, edits_by_anchor, sources)
+ cases = step.get("cases")
+ if isinstance(cases, dict):
+ for case_key, case_steps in cases.items():
+ if isinstance(case_steps, list):
+ cases[case_key] = _traverse_and_apply(case_steps, edits_by_anchor, sources)
+ result.append(step)
+
+ # Insert after: higher-priority overlays land closer to the anchor
+ # (reversed merge order), but a single overlay's own inserts must keep
+ # their declared order ā mirroring the forward insert_before loop above.
+ # Reversing the whole flat list would also flip an overlay's own edits,
+ # so group contiguous same-layer edits and reverse the GROUP order only.
+ after_groups: list[list[tuple[OverlayLayer, OverlayEdit]]] = []
+ for layer, edit in edits:
+ if edit.operation != "insert_after":
+ continue
+ if after_groups and after_groups[-1][0][0] is layer:
+ after_groups[-1].append((layer, edit))
+ else:
+ after_groups.append([(layer, edit)])
+ for group in reversed(after_groups):
+ for layer, edit in group:
+ new_step = copy.deepcopy(edit.step)
+ _record_sources_recursively(new_step, layer.source, sources)
+ result.append(new_step)
+
+ return result
+
+
+def merge_steps(
+ base_steps: list[dict[str, Any]],
+ overlays: list[OverlayLayer],
+) -> tuple[list[dict[str, Any]], list[ComposedStep]]:
+ """Apply overlays to base steps in merge order and return composed steps.
+
+ *overlays* is expected to be sorted by merge order (lowest priority first,
+ highest priority last). The returned step list is a deep copy of the base;
+ base_steps is never mutated.
+
+ Higher-wins semantics are enforced for edits that target the same base
+ anchor: the highest-priority edit (last in *overlays*) decides the fate of
+ the anchor. A lower-priority ``remove`` cannot prevent a higher-priority
+ ``replace`` or ``insert_*`` on the same anchor.
+ """
+ steps = copy.deepcopy(base_steps)
+ sources: dict[str, str] = {}
+ _init_sources_recursively(steps, sources)
+
+ # Group edits by anchor, preserving merge order.
+ edits_by_anchor: dict[str, list[tuple[OverlayLayer, OverlayEdit]]] = {}
+ for layer in overlays:
+ for edit in layer.overlay.edits:
+ edits_by_anchor.setdefault(edit.anchor, []).append((layer, edit))
+
+ # Raise early for non-remove edits that target anchors not present in the base.
+ # Overlays always apply to the original tree; they cannot target steps introduced
+ # by other overlays.
+ base_ids = _all_base_step_ids(base_steps)
+ for anchor, anchor_edits in edits_by_anchor.items():
+ winning_op = anchor_edits[-1][1].operation
+ if winning_op != "remove" and anchor not in base_ids:
+ raise ValueError(f"Anchor '{anchor}' not found in workflow steps.")
+
+ # Reject edits that target anchors with a parent/descendant relationship when
+ # the ancestor edit replaces or removes its subtree ā those produce
+ # order-dependent results. Pure insert edits on an ancestor are safe because
+ # the ancestor step (and its descendants) remain intact.
+ anchor_winning_ops = {
+ anchor: anchor_edits[-1][1].operation
+ for anchor, anchor_edits in edits_by_anchor.items()
+ }
+ anchor_conflicts = _check_anchor_conflicts(anchor_winning_ops, base_steps)
+ if anchor_conflicts:
+ raise ValueError(
+ "Overlay anchor conflict(s) detected:\n - " + "\n - ".join(anchor_conflicts)
+ )
+
+ # Apply all overlay edits via a single-pass traversal of the original tree.
+ # Each edit is resolved against the original step structure, so a replacement
+ # step's new ID can never be mistaken for a base anchor in a later edit group.
+ result = _traverse_and_apply(steps, edits_by_anchor, sources)
+
+ attribution = _build_attribution(result, sources)
+ return result, attribution
+
+
+def validate_edits(
+ edits: list[OverlayEdit],
+ base_step_ids: set[str],
+) -> list[str]:
+ """Validate overlay edits against a set of known base step IDs.
+
+ Returns a list of human-readable error messages. Does not raise.
+ """
+ errors: list[str] = []
+ for idx, edit in enumerate(edits):
+ if edit.operation not in VALID_OPERATIONS:
+ errors.append(f"Edit {idx}: invalid operation {edit.operation!r}.")
+ continue
+ if edit.anchor not in base_step_ids:
+ errors.append(
+ f"Edit {idx}: anchor '{edit.anchor}' does not match any base step id."
+ )
+ if edit.operation == "remove":
+ if edit.step is not None:
+ errors.append(f"Edit {idx}: 'remove' must not include a step.")
+ continue
+ if not isinstance(edit.step, dict):
+ errors.append(f"Edit {idx}: '{edit.operation}' requires a step mapping.")
+ continue
+ step_id = edit.step.get("id")
+ if not isinstance(step_id, str) or not step_id:
+ errors.append(f"Edit {idx}: step is missing required 'id'.")
+ continue
+ if ":" in step_id:
+ errors.append(
+ f"Edit {idx}: step id {step_id!r} contains ':' which is reserved "
+ "for engine-generated nested IDs."
+ )
+ return errors
diff --git a/src/specify_cli/workflows/overlays/schema.py b/src/specify_cli/workflows/overlays/schema.py
new file mode 100644
index 0000000000..221d2fe8e5
--- /dev/null
+++ b/src/specify_cli/workflows/overlays/schema.py
@@ -0,0 +1,176 @@
+"""Workflow overlay schema ā dataclasses and validation for overlay manifests."""
+
+from __future__ import annotations
+
+import re
+from dataclasses import dataclass
+from typing import Any, Literal
+
+from ...extensions import normalize_priority
+
+# Safe single-segment identifiers: no path separators, no traversal, no dots.
+_SAFE_ID_PATTERN = re.compile(r"^[a-z0-9](?:[a-z0-9-]*[a-z0-9])?$")
+_RESERVED_OVERLAY_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays"})
+_RESERVED_WORKFLOW_IDS: frozenset[str] = frozenset({"overlays", "runs", "steps"})
+
+VALID_OPERATIONS = frozenset({"insert_after", "insert_before", "replace", "remove"})
+
+# Map shorthand keys to operation names.
+_SHORTHAND_OPERATION_KEYS: frozenset[str] = VALID_OPERATIONS
+
+
+@dataclass(frozen=True)
+class OverlayEdit:
+ """A single edit operation on a workflow step list."""
+
+ operation: Literal["insert_after", "insert_before", "replace", "remove"]
+ anchor: str
+ step: dict[str, Any] | None = None
+
+
+@dataclass
+class Overlay:
+ """A declared overlay (one YAML file)."""
+
+ id: str
+ extends: str
+ edits: list[OverlayEdit]
+ priority: int = 10
+ enabled: bool = True
+
+
+def _validate_safe_id(
+ value: str,
+ field_name: str,
+ allow_reserved: bool = False,
+ reserved_ids: frozenset[str] = _RESERVED_OVERLAY_WORKFLOW_IDS,
+) -> str | None:
+ """Return an error message if *value* is not a safe path segment ID."""
+ if not isinstance(value, str) or not value:
+ return f"Overlay '{field_name}' is required and must be a non-empty string."
+ if not _SAFE_ID_PATTERN.fullmatch(value):
+ return (
+ f"Overlay '{field_name}' {value!r} contains invalid characters; "
+ "only lowercase letters, digits, and hyphens are allowed."
+ )
+ if not allow_reserved and value in reserved_ids:
+ return f"Overlay '{field_name}' {value!r} is reserved."
+ return None
+
+
+def _parse_edit(edit_raw: dict[str, Any], idx: int) -> tuple[OverlayEdit | None, str | None]:
+ """Parse a single edit dict into an OverlayEdit or an error string."""
+ shorthand_keys = [key for key in _SHORTHAND_OPERATION_KEYS if key in edit_raw]
+ has_operation = "operation" in edit_raw
+
+ operation: str | None = None
+ anchor: Any = None
+
+ if shorthand_keys and has_operation:
+ return None, (
+ f"Edit at index {idx} mixes shorthand operation key "
+ f"({shorthand_keys[0]!r}) with explicit 'operation' field."
+ )
+
+ if len(shorthand_keys) > 1:
+ return None, (
+ f"Edit at index {idx} has multiple operation keys: "
+ f"{', '.join(repr(k) for k in shorthand_keys)}."
+ )
+
+ if shorthand_keys:
+ operation = shorthand_keys[0]
+ anchor = edit_raw[operation]
+ elif has_operation:
+ operation = edit_raw.get("operation")
+ anchor = edit_raw.get("anchor")
+ else:
+ return None, f"Edit at index {idx} has no operation; expected one of {sorted(VALID_OPERATIONS)}."
+
+ if operation not in VALID_OPERATIONS:
+ return None, f"Edit at index {idx} has invalid operation {operation!r}."
+
+ if not isinstance(anchor, str) or not anchor:
+ return None, f"Edit at index {idx} has invalid 'anchor'."
+
+ step = edit_raw.get("step")
+ if operation == "remove":
+ if step is not None:
+ return None, f"Edit at index {idx} ('remove') must not include 'step'."
+ return OverlayEdit(operation=operation, anchor=anchor), None
+
+ if not isinstance(step, dict):
+ return None, f"Edit at index {idx} ('{operation}') requires 'step' mapping."
+ step_id = step.get("id")
+ if not isinstance(step_id, str) or not step_id:
+ return None, f"Edit at index {idx} step is missing required 'id'."
+ if ":" in step_id:
+ return None, (
+ f"Edit at index {idx} step id {step_id!r} contains ':' "
+ "which is reserved for engine-generated nested IDs."
+ )
+ return OverlayEdit(operation=operation, anchor=anchor, step=step), None
+
+
+def validate_overlay_yaml(data: dict[str, Any]) -> tuple[Overlay | None, list[str]]:
+ """Validate an overlay manifest dict and return (Overlay, errors).
+
+ Errors are returned as a list of strings; validation never raises.
+ """
+ errors: list[str] = []
+
+ if not isinstance(data, dict):
+ return None, ["Overlay manifest must be a mapping."]
+
+ overlay_id = data.get("id")
+ if err := _validate_safe_id(overlay_id, "id"):
+ errors.append(err)
+ overlay_id = ""
+
+ extends = data.get("extends")
+ if err := _validate_safe_id(
+ extends,
+ "extends",
+ reserved_ids=_RESERVED_WORKFLOW_IDS,
+ ):
+ errors.append(err)
+ extends = ""
+
+ priority = normalize_priority(data.get("priority", 10))
+
+ edits_raw = data.get("edits")
+ edits: list[OverlayEdit] = []
+ if not isinstance(edits_raw, list):
+ errors.append("Overlay 'edits' is required and must be a list.")
+ elif not edits_raw:
+ errors.append("Overlay 'edits' must be a non-empty list.")
+ else:
+ for idx, edit_raw in enumerate(edits_raw):
+ if not isinstance(edit_raw, dict):
+ errors.append(f"Edit at index {idx} must be a mapping.")
+ continue
+ edit, err = _parse_edit(edit_raw, idx)
+ if err:
+ errors.append(err)
+ continue
+ if edit is not None:
+ edits.append(edit)
+
+ enabled = data.get("enabled", True)
+ if not isinstance(enabled, bool):
+ errors.append("Overlay 'enabled' must be a boolean.")
+ enabled = bool(enabled)
+
+ if errors:
+ return None, errors
+
+ return (
+ Overlay(
+ id=overlay_id,
+ extends=extends,
+ priority=priority,
+ edits=edits,
+ enabled=enabled,
+ ),
+ [],
+ )
diff --git a/src/specify_cli/workflows/steps/command/__init__.py b/src/specify_cli/workflows/steps/command/__init__.py
index 7a6d893ed0..8ab7770894 100644
--- a/src/specify_cli/workflows/steps/command/__init__.py
+++ b/src/specify_cli/workflows/steps/command/__init__.py
@@ -30,6 +30,21 @@ class CommandStep(StepBase):
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
command = config.get("command", "")
+ # validate() rejects a non-string 'command', but the engine does not
+ # auto-validate before execute(); an unvalidated run would pass the value
+ # to build_command_invocation() (via _try_dispatch) and crash there with a
+ # raw AttributeError (command_name.startswith(...) on a list/int/None).
+ # Fail the step with the same contract error instead, mirroring the
+ # 'input'/'options' guards below.
+ if not isinstance(command, str):
+ return StepResult(
+ status=StepStatus.FAILED,
+ error=(
+ f"Command step {config.get('id', '?')!r}: 'command' must be a "
+ f"string, got {type(command).__name__}."
+ ),
+ )
+
input_data = config.get("input", {})
# validate() rejects a non-mapping input, but the engine does not
# auto-validate before execute(); a workflow that skipped validation can
@@ -51,16 +66,52 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
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", {})
@@ -138,7 +189,11 @@ def _try_dispatch(
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:
@@ -179,6 +234,17 @@ def validate(self, config: dict[str, Any]) -> list[str]:
errors.append(
f"Command step {config.get('id', '?')!r} is missing 'command' field."
)
+ elif not isinstance(config["command"], str):
+ # execute() passes 'command' straight to the integration's
+ # build_command_invocation(), which does command_name.startswith(...);
+ # a non-string (null, list, int) crashes there with a raw
+ # AttributeError once dispatch is attempted. Reject it at validation,
+ # mirroring the prompt-step 'prompt' and shell-step 'run' type checks.
+ # An expression like "{{ ... }}" is still a str, so it stays valid.
+ errors.append(
+ f"Command step {config.get('id', '?')!r}: 'command' must be a "
+ f"string, got {type(config['command']).__name__}."
+ )
# execute() iterates input.items() and options.update(step_options); a
# non-mapping here would raise at run time. Validate the shape like the
# sibling steps (switch 'cases', fan-out 'step') so it is reported, not
@@ -191,4 +257,23 @@ def validate(self, config: dict[str, Any]) -> list[str]:
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
diff --git a/src/specify_cli/workflows/steps/do_while/__init__.py b/src/specify_cli/workflows/steps/do_while/__init__.py
index ca6047a57a..024ced55b5 100644
--- a/src/specify_cli/workflows/steps/do_while/__init__.py
+++ b/src/specify_cli/workflows/steps/do_while/__init__.py
@@ -70,6 +70,24 @@ def validate(self, config: dict[str, Any]) -> list[str]:
f"Do-while step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
+ elif not isinstance(config["condition"], (str, bool)):
+ # The engine re-evaluates 'condition' via evaluate_condition() after
+ # each iteration. That call first delegates to
+ # evaluate_expression() -- which returns a non-string unchanged --
+ # and then coerces the result with bool(). So a list/dict/number
+ # condition silently resolves to its truthiness (e.g.
+ # condition: [1, 2] is always truthy, looping to max_iterations)
+ # with no error. Reject those at validation, mirroring the
+ # prompt/shell/command 'must be a string' checks.
+ #
+ # A literal ``bool`` stays valid: an unquoted ``condition: false``
+ # is idiomatic YAML and evaluate_condition() already resolves it
+ # exactly (bool passthrough, then a no-op bool()). "true"/"false"
+ # and an expression like "{{ ... }}" stay valid too.
+ errors.append(
+ f"Do-while step {config.get('id', '?')!r}: 'condition' must be a "
+ f"string or boolean, got {type(config['condition']).__name__}."
+ )
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and
diff --git a/src/specify_cli/workflows/steps/fan_in/__init__.py b/src/specify_cli/workflows/steps/fan_in/__init__.py
index 1e466e5fa8..8ab6934a83 100644
--- a/src/specify_cli/workflows/steps/fan_in/__init__.py
+++ b/src/specify_cli/workflows/steps/fan_in/__init__.py
@@ -42,6 +42,28 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
output={"results": []},
)
+ # A non-string entry can never match a real step id. An unhashable one
+ # (a list/dict from a YAML indentation slip like ``wait_for: [[a, b]]``)
+ # crashes the whole run at ``context.steps.get(step_id, ...)`` below with
+ # a raw TypeError; a hashable-but-non-string one (``wait_for: [123]``)
+ # silently joins an empty ``{}`` and still reports COMPLETED ā the exact
+ # "silent empty result + COMPLETED" wiring bug the whole-list guard above
+ # and the engine's fan-in validation (engine.py) both reject. The engine
+ # does not auto-validate step config, so fail this step loudly on an
+ # unvalidated run too, using the engine's phrasing.
+ bad_entries = [w for w in wait_for if not isinstance(w, str)]
+ if bad_entries:
+ first = bad_entries[0]
+ return StepResult(
+ status=StepStatus.FAILED,
+ error=(
+ f"Fan-in step {config.get('id', '?')!r}: 'wait_for' entries "
+ f"must be step-id strings, got {type(first).__name__} "
+ f"({first!r})."
+ ),
+ output={"results": []},
+ )
+
# Collect results from referenced steps
results = []
for step_id in wait_for:
diff --git a/src/specify_cli/workflows/steps/fan_out/__init__.py b/src/specify_cli/workflows/steps/fan_out/__init__.py
index 22b9c37d43..d961170c8c 100644
--- a/src/specify_cli/workflows/steps/fan_out/__init__.py
+++ b/src/specify_cli/workflows/steps/fan_out/__init__.py
@@ -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"
@@ -25,6 +26,33 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
max_concurrency = config.get("max_concurrency", 1)
step_template = config.get("step", {})
+ # The engine does not auto-validate step config (see
+ # ``WorkflowEngine.load_workflow``). On a COMPLETED fan-out it reads the
+ # ``step_template`` back out and, when it is truthy, calls
+ # ``template.get("id", ...)`` in ``_run_fan_out``. A truthy non-mapping
+ # ``step`` (a scalar or list authoring mistake) would crash the whole
+ # run with AttributeError there ā the engine invokes ``execute`` and
+ # ``_run_fan_out`` with no surrounding try/except. ``validate`` already
+ # rejects a non-mapping ``step``; fail this step loudly on an
+ # unvalidated run instead, mirroring the ``items`` guard below. An empty
+ # or absent ``step`` defaults to ``{}`` (falsy) and the engine's
+ # ``if template and items`` skips fan-out, so it stays valid here.
+ if not isinstance(step_template, dict):
+ return StepResult(
+ status=StepStatus.FAILED,
+ error=(
+ f"Fan-out step {config.get('id', '?')!r}: 'step' must be a "
+ f"mapping (nested step template), got "
+ f"{type(step_template).__name__}."
+ ),
+ output={
+ "items": [],
+ "max_concurrency": max_concurrency,
+ "step_template": {},
+ "item_count": 0,
+ },
+ )
+
if not isinstance(items, list):
# A non-list here is a wiring error (the expression did not
# resolve to a collection); silently fanning out over zero
@@ -66,8 +94,13 @@ def validate(self, config: dict[str, Any]) -> list[str]:
f"Fan-out step {config.get('id', '?')!r} is missing "
f"'step' field (nested step template)."
)
- step = config.get("step")
- if step is not None and not isinstance(step, dict):
+ elif not isinstance(config["step"], dict):
+ # A present-but-non-mapping ``step`` (including an explicit
+ # ``step: null``) is an authoring mistake. ``config.get("step", {})``
+ # in ``execute`` only substitutes the ``{}`` default for an *absent*
+ # key, so an explicit ``None`` reaches the runtime guard and FAILS
+ # the step. Reject it here too so a workflow cannot pass validation
+ # and then fail during execution.
errors.append(
f"Fan-out step {config.get('id', '?')!r}: 'step' must be a mapping."
)
diff --git a/src/specify_cli/workflows/steps/gate/__init__.py b/src/specify_cli/workflows/steps/gate/__init__.py
index 0c9399ce3f..d32efdaaf4 100644
--- a/src/specify_cli/workflows/steps/gate/__init__.py
+++ b/src/specify_cli/workflows/steps/gate/__init__.py
@@ -26,7 +26,7 @@ class GateStep(StepBase):
later with ``specify workflow resume``.
The user's choice is stored in ``output.choice``. ``on_reject``
- controls abort / skip behaviour.
+ controls abort / skip / retry behaviour.
"""
type_key = "gate"
@@ -43,6 +43,35 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
options = config.get("options", ["approve", "reject"])
on_reject = config.get("on_reject", "abort")
+ # ``validate`` rejects a non-list (or empty) ``options``, and requires
+ # every option to be a string, but the engine does not auto-validate
+ # before ``execute``. An unvalidated run with a scalar/dict/None
+ # ``options`` would otherwise reach ``_prompt`` and crash the whole run
+ # with a raw ``TypeError`` (``enumerate``/``len`` on a non-iterable) or
+ # ``KeyError`` (indexing a dict); a non-string option would crash at the
+ # ``choice.lower()`` reject check with ``AttributeError``. Fail this step
+ # loudly instead ā mirroring the switch 'cases' and command 'input'
+ # guards. Checked before the non-TTY short-circuit so the error surfaces
+ # in CI too, rather than PAUSING and crashing later on interactive resume.
+ if (
+ not isinstance(options, list)
+ or not options
+ or not all(isinstance(o, str) for o in options)
+ ):
+ return StepResult(
+ status=StepStatus.FAILED,
+ error=(
+ f"Gate step {config.get('id', '?')!r}: 'options' must be a "
+ f"non-empty list of strings, got {type(options).__name__}."
+ ),
+ output={
+ "message": message,
+ "options": options,
+ "on_reject": on_reject,
+ "choice": None,
+ },
+ )
+
show_file = config.get("show_file")
if isinstance(show_file, str) and "{{" in show_file:
show_file = evaluate_expression(show_file, context)
@@ -139,7 +168,11 @@ def _prompt(message: str, options: list[str]) -> str:
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]:
diff --git a/src/specify_cli/workflows/steps/if_then/__init__.py b/src/specify_cli/workflows/steps/if_then/__init__.py
index b2ed880678..7189ff8150 100644
--- a/src/specify_cli/workflows/steps/if_then/__init__.py
+++ b/src/specify_cli/workflows/steps/if_then/__init__.py
@@ -61,6 +61,24 @@ def validate(self, config: dict[str, Any]) -> list[str]:
errors.append(
f"If step {config.get('id', '?')!r} is missing 'condition' field."
)
+ elif not isinstance(config["condition"], (str, bool)):
+ # execute() feeds 'condition' to evaluate_condition(), which first
+ # delegates to evaluate_expression() -- that returns a non-string
+ # unchanged -- and then coerces the result with bool(). So a
+ # list/dict/number condition silently resolves to its truthiness
+ # (e.g. condition: [1, 2] is always True) with no error, branching
+ # wrongly on an authoring mistake. Reject those at validation,
+ # mirroring the prompt/shell/command 'must be a string' checks.
+ #
+ # A literal ``bool`` stays valid: an unquoted ``condition: false``
+ # is idiomatic YAML, evaluate_condition() already resolves it
+ # exactly (bool passthrough, then a no-op bool()), and this step
+ # itself defaults ``condition`` to ``False``. "true"/"false" and an
+ # expression like "{{ ... }}" are strings, so they stay valid too.
+ errors.append(
+ f"If step {config.get('id', '?')!r}: 'condition' must be a "
+ f"string or boolean, got {type(config['condition']).__name__}."
+ )
if "then" not in config:
errors.append(
f"If step {config.get('id', '?')!r} is missing 'then' field."
diff --git a/src/specify_cli/workflows/steps/init/__init__.py b/src/specify_cli/workflows/steps/init/__init__.py
index 550e0624fe..5dc1ee9c02 100644
--- a/src/specify_cli/workflows/steps/init/__init__.py
+++ b/src/specify_cli/workflows/steps/init/__init__.py
@@ -59,7 +59,7 @@ class InitStep(StepBase):
Extra options for the integration (e.g. ``"--skills"`` or
``"--commands-dir .myagent/cmds"``).
``script``
- Script type, ``sh`` or ``ps``.
+ Script type, ``sh``, ``ps``, or ``py``.
``force``
Merge/overwrite without confirmation when the directory is not
empty.
diff --git a/src/specify_cli/workflows/steps/prompt/__init__.py b/src/specify_cli/workflows/steps/prompt/__init__.py
index 5ec99b794d..3bb9a2708c 100644
--- a/src/specify_cli/workflows/steps/prompt/__init__.py
+++ b/src/specify_cli/workflows/steps/prompt/__init__.py
@@ -2,6 +2,7 @@
from __future__ import annotations
+import math
import shutil
from pathlib import Path
from typing import Any
@@ -42,19 +43,65 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
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__}."
+ ),
+ )
+
+ # An invalid timeout reaches subprocess.run() and raises a raw
+ # TypeError ("unsupported operand type(s) for +: 'float' and 'str'")
+ # or ValueError, which the engine re-raises ā taking down the whole
+ # run with a message that names neither the step nor 'timeout'. Fail
+ # this step cleanly instead, mirroring the shell step.
+ timeout_error = self._timeout_error(config)
+ if timeout_error is not None:
+ return StepResult(status=StepStatus.FAILED, error=timeout_error)
+
# Attempt CLI dispatch
+ timeout = config.get("timeout", 300)
dispatch_result = self._try_dispatch(
- prompt, integration, model, context
+ prompt, integration, model, context, timeout=timeout
)
output: dict[str, Any] = {
@@ -94,15 +141,54 @@ def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
),
)
+ @staticmethod
+ def _timeout_error(config: dict[str, Any]) -> str | None:
+ """Return an error message if ``config['timeout']`` is invalid, else None.
+
+ Shared by execute() and validate() so both paths reject the same
+ values with the same message, mirroring the shell step. An absent
+ ``timeout`` is valid (the default is used). bool is a subclass of int,
+ but ``timeout: true`` is a config error rather than a duration, so it
+ is rejected explicitly. Non-finite floats (YAML ``.inf``/``.nan``) pass
+ a plain ``> 0`` check but would raise in subprocess.run(), and a
+ non-positive timeout makes subprocess.run() report an immediate
+ TimeoutExpired, so both are rejected too.
+ """
+ if "timeout" not in config:
+ return None
+ timeout = config["timeout"]
+ try:
+ valid_timeout = (
+ not isinstance(timeout, bool)
+ and isinstance(timeout, (int, float))
+ and timeout > 0
+ and math.isfinite(timeout)
+ )
+ except OverflowError:
+ # An int too large to convert to float (e.g. a 400-digit YAML
+ # scalar) clears every clause above and raises here ā and would
+ # raise the same from subprocess.run(timeout=...).
+ valid_timeout = False
+ if not valid_timeout:
+ return (
+ f"Prompt step {config.get('id', '?')!r}: 'timeout' must be a "
+ f"positive number of seconds, got {timeout!r}."
+ )
+ return None
+
@staticmethod
def _try_dispatch(
prompt: str,
integration_key: str | None,
model: str | None,
context: StepContext,
+ timeout: int = 300,
) -> 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:
@@ -128,6 +214,17 @@ def _try_dispatch(
if not exec_args:
return None
+ # Windows: ``subprocess.run`` calls ``CreateProcess``, which does not
+ # consult ``PATHEXT``, so a bare command name like ``claude`` installed
+ # as ``claude.cmd`` (the usual npm shim layout) fails with
+ # ``WinError 2``. That OSError is swallowed below and reported as "CLI
+ # not found or not installed" -- even though the preflight above just
+ # found it. Reuse the already-resolved path so the shim is executed,
+ # mirroring ``IntegrationBase.dispatch_command``, which the ``command``
+ # step already goes through. On POSIX this is the same executable.
+ if fallback_cli_path:
+ exec_args = [fallback_cli_path, *exec_args[1:]]
+
import subprocess
project_root = (
@@ -139,6 +236,7 @@ def _try_dispatch(
exec_args,
text=True,
cwd=str(project_root),
+ timeout=timeout,
)
return {
"exit_code": result.returncode,
@@ -151,6 +249,12 @@ def _try_dispatch(
"stdout": "",
"stderr": "Interrupted by user",
}
+ except subprocess.TimeoutExpired:
+ return {
+ "exit_code": -1,
+ "stdout": "",
+ "stderr": f"Prompt timed out after {timeout} seconds.",
+ }
except OSError:
return None
@@ -160,4 +264,38 @@ def validate(self, config: dict[str, Any]) -> list[str]:
errors.append(
f"Prompt step {config.get('id', '?')!r} is missing 'prompt' field."
)
+ elif not isinstance(config["prompt"], str):
+ # execute() str()-coerces prompt and dispatches it to the
+ # integration CLI, so a null or list 'prompt' would send the Python
+ # repr ('None', "['review', 'this']") to the model as instructions ā
+ # silently wrong, with no error. Reject non-strings at validation,
+ # mirroring the shell-step 'run' and command-step input/options type
+ # checks. An expression like "{{ ... }}" is still a str, so it stays
+ # valid.
+ errors.append(
+ 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__}."
+ )
+ timeout_error = self._timeout_error(config)
+ if timeout_error is not None:
+ errors.append(timeout_error)
return errors
diff --git a/src/specify_cli/workflows/steps/shell/__init__.py b/src/specify_cli/workflows/steps/shell/__init__.py
index fb19c33fc7..0b614b462f 100644
--- a/src/specify_cli/workflows/steps/shell/__init__.py
+++ b/src/specify_cli/workflows/steps/shell/__init__.py
@@ -121,12 +121,20 @@ def _timeout_error(config: dict[str, Any]) -> str | None:
if "timeout" not in config:
return None
timeout = config["timeout"]
- if (
- isinstance(timeout, bool)
- or not isinstance(timeout, (int, float))
- or not math.isfinite(timeout)
- or timeout <= 0
- ):
+ try:
+ invalid_timeout = (
+ isinstance(timeout, bool)
+ or not isinstance(timeout, (int, float))
+ or not math.isfinite(timeout)
+ or timeout <= 0
+ )
+ except OverflowError:
+ # An int too large to convert to float (e.g. a 400-digit YAML
+ # scalar) is not a bool and *is* an int, so it clears every clause
+ # before ``isfinite()`` and raises there ā and would raise the same
+ # from subprocess.run(timeout=...). Mirrors the prompt step.
+ invalid_timeout = True
+ if invalid_timeout:
return (
f"Shell step {config.get('id', '?')!r}: 'timeout' must be a "
f"positive number of seconds, got {timeout!r}."
diff --git a/src/specify_cli/workflows/steps/while_loop/__init__.py b/src/specify_cli/workflows/steps/while_loop/__init__.py
index e2dbb19305..e80b93d7f2 100644
--- a/src/specify_cli/workflows/steps/while_loop/__init__.py
+++ b/src/specify_cli/workflows/steps/while_loop/__init__.py
@@ -79,6 +79,24 @@ def validate(self, config: dict[str, Any]) -> list[str]:
f"While step {config.get('id', '?')!r} is missing "
f"'condition' field."
)
+ elif not isinstance(config["condition"], (str, bool)):
+ # execute() feeds 'condition' to evaluate_condition(), which first
+ # delegates to evaluate_expression() -- that returns a non-string
+ # unchanged -- and then coerces the result with bool(). So a
+ # list/dict/number condition silently resolves to its truthiness
+ # (e.g. condition: [1, 2] is always truthy, spinning the loop to
+ # max_iterations) with no error. Reject those at validation,
+ # mirroring the prompt/shell/command 'must be a string' checks.
+ #
+ # A literal ``bool`` stays valid: an unquoted ``condition: false``
+ # is idiomatic YAML, evaluate_condition() already resolves it
+ # exactly (bool passthrough, then a no-op bool()), and this step
+ # itself defaults ``condition`` to ``False``. "true"/"false" and an
+ # expression like "{{ ... }}" are strings, so they stay valid too.
+ errors.append(
+ f"While step {config.get('id', '?')!r}: 'condition' must be a "
+ f"string or boolean, got {type(config['condition']).__name__}."
+ )
max_iter = config.get("max_iterations")
if max_iter is not None:
# bool is a subclass of int, so isinstance(True, int) is True and
diff --git a/templates/commands/clarify.md b/templates/commands/clarify.md
index fb0e91281f..ea2f20d519 100644
--- a/templates/commands/clarify.md
+++ b/templates/commands/clarify.md
@@ -139,6 +139,12 @@ Execution steps:
5. Sequential questioning loop (interactive):
- Present EXACTLY ONE question at a time.
+ - **Question writing quality (applies to every question, MC or short-answer):**
+ - Lead with `**Question:**` followed by a full interrogative that ends with `?`. The question text before the `?` must make sense on its own.
+ - NEVER use a topic label, section heading, or requirement id as the question itself. For example, `Acceptance device/runtime matrix (FR-023)` is INVALID ā it is a label, not a question.
+ - After the `?`, the only permitted suffix is an optional parenthesized requirement/question id. Exact format: `**Question:** ?` or `**Question:** ? (FR-023)`. Never put the id before the `?`, and never use the id (alone or with a topic label) as the whole prompt.
+ - Immediately after the question line, add one plain-language "Why it matters" sentence (the stake for acceptance or shipping) before the recommendation/options.
+ - Use everyday wording; introduce jargon only if defined in the same sentence. Self-check: a reader who does not know Spec Kit must be able to answer from the Question line alone. Terse is fine; cryptic labels are not.
- For multipleāchoice questions:
- **Analyze all options** and determine the **most suitable option** based on:
- Best practices for the project type
diff --git a/templates/commands/constitution.md b/templates/commands/constitution.md
index 9d2d4ccc1a..c631e8e84c 100644
--- a/templates/commands/constitution.md
+++ b/templates/commands/constitution.md
@@ -1,5 +1,5 @@
---
-description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
+description: Create or update the project constitution from interactive or provided principle inputs.
handoffs:
- label: Build Specification
agent: speckit.specify
@@ -14,6 +14,25 @@ $ARGUMENTS
You **MUST** consider the user input before proceeding (if not empty).
+## Scope Guard
+
+This command's own work is limited to updating the project constitution itself. Dependent templates
+and commands read the constitution at runtime and are not modified here.
+
+- Classify every part of the user input as either constitution content or a separate,
+ non-governance intent.
+- If the input includes feature implementation, code generation, refactoring, building, or
+ deployment requests, you **MUST NOT** execute them. Extract them as deferred intents instead.
+- You **MUST NOT** create, modify, or delete application source files, feature routes,
+ components, tests, deployment files, or other artifacts unrelated to the constitution
+ workflow.
+- If it is unclear whether an instruction is constitution content, ask for clarification before
+ making changes.
+- After completing the constitution update, include a `Next Actions` section for each deferred
+ intent. List the original intent and suggest the appropriate follow-up Spec Kit command, such
+ as `__SPECKIT_COMMAND_SPECIFY__`, without invoking it.
+- If there are no non-governance intents, omit the `Next Actions` section.
+
## Pre-Execution Checks
**Check for extension hooks (before constitution update)**:
@@ -51,7 +70,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline
-You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values, (b) fill the template precisely, and (c) propagate any amendments across dependent artifacts.
+You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values and (b) fill the template precisely.
**Note**: If `.specify/memory/constitution.md` does not exist yet, it should have been initialized from `.specify/templates/constitution-template.md` during project setup. If it's missing, copy the template first.
@@ -77,33 +96,26 @@ Follow this execution flow:
- Ensure each Principle section: succinct name line, paragraph (or bullet list) capturing nonānegotiable rules, explicit rationale if not obvious.
- Ensure Governance section lists amendment procedure, versioning policy, and compliance review expectations.
-4. Consistency propagation checklist (convert prior checklist into active validations):
- - Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align with updated principles.
- - Read `.specify/templates/spec-template.md` for scope/requirements alignmentāupdate if constitution adds/removes mandatory sections or constraints.
- - Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or removed principle-driven task types (e.g., observability, versioning, testing discipline).
- - Read each installed Spec Kit command file for your agent (including this one) ā named `speckit.*` or `speckit-*` (dot or hyphen depending on the agent), or laid out as `speckit-/SKILL.md` for skills-based integrations, e.g. in `.github/agents/`, `.github/skills/`, `.claude/skills/`, or your agent's equivalent commands directory ā to verify no outdated references (CLAUDE-only or other agent-specific names) remain when generic guidance is required.
- - Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific guidance files if present). Update references to principles changed.
-
-5. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
+4. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
- Version change: old ā new
- List of modified principles (old title ā new title if renamed)
- Added sections
- Removed sections
- - Templates requiring updates (ā
updated / ā pending) with file paths
- Follow-up TODOs if any placeholders intentionally deferred.
-6. Validation before final output:
+5. Validation before final output:
- No remaining unexplained bracket tokens.
- Version line matches report.
- Dates ISO format YYYY-MM-DD.
- Principles are declarative, testable, and free of vague language ("should" ā replace with MUST/SHOULD rationale where appropriate).
-7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
+6. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
-8. Output a final summary to the user with:
+7. Output a final summary to the user with:
- New version and bump rationale.
- - Any files flagged for manual follow-up.
+ - Any TODO placeholders or deferred items requiring manual follow-up.
- Suggested commit message (e.g., `docs: amend constitution to vX.Y.Z (principle additions + governance update)`).
+ - A `Next Actions` section for any deferred non-governance intents.
Formatting & Style Requirements:
diff --git a/templates/commands/plan.md b/templates/commands/plan.md
index 312c5ab181..664f428114 100644
--- a/templates/commands/plan.md
+++ b/templates/commands/plan.md
@@ -11,6 +11,7 @@ handoffs:
scripts:
sh: scripts/bash/setup-plan.sh --json
ps: scripts/powershell/setup-plan.ps1 -Json
+ py: scripts/python/setup_plan.py --json
---
## User Input
diff --git a/templates/commands/specify.md b/templates/commands/specify.md
index e32fd48971..54151e8b42 100644
--- a/templates/commands/specify.md
+++ b/templates/commands/specify.md
@@ -139,9 +139,9 @@ Given that feature description, do this:
7. Identify Key Entities (if data involved)
8. Return: SUCCESS (spec ready for planning)
-6. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
+7. Write the specification to SPEC_FILE using the template structure, replacing placeholders with concrete details derived from the feature description (arguments) while preserving section order and headings.
-7. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
+8. **Specification Quality Validation**: After writing the initial spec, validate it against quality criteria:
a. **Create Spec Quality Checklist**: Generate a checklist file at `SPECIFY_FEATURE_DIRECTORY/checklists/requirements.md` using the checklist template structure with these validation items:
diff --git a/templates/commands/tasks.md b/templates/commands/tasks.md
index ae7192c3d3..00d73354e3 100644
--- a/templates/commands/tasks.md
+++ b/templates/commands/tasks.md
@@ -12,6 +12,7 @@ handoffs:
scripts:
sh: scripts/bash/setup-tasks.sh --json
ps: scripts/powershell/setup-tasks.ps1 -Json
+ py: scripts/python/setup_tasks.py --json
---
## User Input
diff --git a/tests/contract/test_bundle_cli.py b/tests/contract/test_bundle_cli.py
index db2bb0c948..830a22c5dc 100644
--- a/tests/contract/test_bundle_cli.py
+++ b/tests/contract/test_bundle_cli.py
@@ -17,6 +17,7 @@
from specify_cli import app
from specify_cli.bundler.services.packager import build_bundle
+from tests.conftest import strip_ansi
from tests.bundler_helpers import (
catalog_entry_dict,
valid_manifest_dict,
@@ -25,6 +26,42 @@
runner = CliRunner()
+MARKUP_BUNDLE_ID = "[red]markup-id[/red]"
+MARKUP_SOURCE_ID = "[underline]markup-source[/underline]"
+
+
+def _configure_markup_catalog(project: Path, **overrides: object) -> dict:
+ entry = catalog_entry_dict(
+ MARKUP_BUNDLE_ID,
+ name="[green]Markup Name[/green]",
+ version="[blue]1.0.0[/blue]",
+ role="[magenta]Markup Role[/magenta]",
+ description="[yellow]Markup Description[/yellow]",
+ author="[cyan]Markup Author[/cyan]",
+ license="[bold]Markup License[/bold]",
+ download_url="https://example.com/markup-bundle.zip",
+ requires={"speckit_version": "[italic]>=0.1.0[/italic]"},
+ **overrides,
+ )
+ catalog = project / "markup-catalog.json"
+ write_catalog_file(catalog, {MARKUP_BUNDLE_ID: entry})
+ config = {
+ "schema_version": "1.0",
+ "catalogs": [
+ {
+ "id": MARKUP_SOURCE_ID,
+ "url": str(catalog),
+ "priority": 1,
+ "install_policy": "install-allowed",
+ }
+ ],
+ }
+ (project / ".specify" / "bundle-catalogs.yml").write_text(
+ yaml.safe_dump(config),
+ encoding="utf-8",
+ )
+ return entry
+
@pytest.fixture()
def project(tmp_path: Path, monkeypatch) -> Path:
@@ -124,6 +161,24 @@ def test_search_works_without_a_project(tmp_path: Path, monkeypatch):
assert result.output.strip().startswith("[")
+def test_search_escapes_catalog_markup(project: Path):
+ entry = _configure_markup_catalog(project)
+
+ result = runner.invoke(app, ["bundle", "search", "--offline"])
+
+ assert result.exit_code == 0, result.output
+ output = " ".join(strip_ansi(result.output).split())
+ for value in (
+ entry["id"],
+ entry["name"],
+ entry["version"],
+ entry["role"],
+ entry["description"],
+ MARKUP_SOURCE_ID,
+ ):
+ assert value in output
+
+
def test_info_unknown_bundle_without_project_reports_not_found(tmp_path: Path, monkeypatch):
monkeypatch.chdir(tmp_path) # no .specify/
result = runner.invoke(app, ["bundle", "info", "does-not-exist", "--offline"])
@@ -261,6 +316,83 @@ def test_info_expands_full_component_set(project: Path, monkeypatch):
assert "Trust" in text.output
+def test_info_escapes_catalog_markup(project: Path, monkeypatch):
+ entry = _configure_markup_catalog(project)
+ bundle_dir = project / "markup-bundle"
+ bundle_dir.mkdir()
+ manifest_data = valid_manifest_dict()
+ manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID
+ manifest_data["integration"] = {
+ "id": "[conceal]markup-integration[/conceal]"
+ }
+ manifest_path = bundle_dir / "bundle.yml"
+ manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8")
+ _mock_manifest_download(monkeypatch, manifest_path)
+ monkeypatch.setattr(
+ "specify_cli.commands.bundle._manifest_component_view",
+ lambda manifest: [
+ {
+ "kind": "extensions",
+ "id": "[reverse]markup-component[/reverse]",
+ "version": "[strike]2.0.0[/strike]",
+ }
+ ],
+ )
+ monkeypatch.setattr(
+ "specify_cli.commands.bundle._bundle_overlaps",
+ lambda project_root, manifest, *, offline: [
+ "[blink]markup-overlap[/blink]"
+ ],
+ )
+
+ result = runner.invoke(
+ app,
+ ["bundle", "info", MARKUP_BUNDLE_ID, "--offline"],
+ )
+
+ assert result.exit_code == 0, result.output
+ output = " ".join(strip_ansi(result.output).split())
+ for value in (
+ entry["id"],
+ entry["name"],
+ entry["version"],
+ entry["role"],
+ entry["description"],
+ entry["author"],
+ entry["license"],
+ entry["requires"]["speckit_version"],
+ MARKUP_SOURCE_ID,
+ "[conceal]markup-integration[/conceal]",
+ "[reverse]markup-component[/reverse]",
+ "[strike]2.0.0[/strike]",
+ "[blink]markup-overlap[/blink]",
+ ):
+ assert value in output
+
+
+def test_info_escapes_catalog_provides_fallback_markup(project: Path, monkeypatch):
+ markup_count = "[bold]markup-count[/bold]"
+ _configure_markup_catalog(
+ project,
+ provides={"extensions": markup_count},
+ )
+ bundle_dir = project / "markup-bundle"
+ bundle_dir.mkdir()
+ manifest_data = valid_manifest_dict(provides={})
+ manifest_data["bundle"]["id"] = MARKUP_BUNDLE_ID
+ manifest_path = bundle_dir / "bundle.yml"
+ manifest_path.write_text(yaml.safe_dump(manifest_data), encoding="utf-8")
+ _mock_manifest_download(monkeypatch, manifest_path)
+
+ result = runner.invoke(
+ app,
+ ["bundle", "info", MARKUP_BUNDLE_ID, "--offline"],
+ )
+
+ assert result.exit_code == 0, result.output
+ assert markup_count in strip_ansi(result.output)
+
+
def test_info_expands_discovery_only_bundle(project: Path, monkeypatch):
# Discovery-only bundles must still be fully inspectable via `info`;
# only `install` is refused for them.
diff --git a/tests/contract/test_catalog_schema.py b/tests/contract/test_catalog_schema.py
index 2155a9f1fd..15a844118b 100644
--- a/tests/contract/test_catalog_schema.py
+++ b/tests/contract/test_catalog_schema.py
@@ -5,6 +5,8 @@
"""
from __future__ import annotations
+import json
+import tomllib
from pathlib import Path
import yaml
@@ -37,9 +39,118 @@ def test_builtin_default_stack_when_no_config(tmp_path: Path):
assert ids == ["default", "community"]
assert sources[0].install_policy is InstallPolicy.INSTALL_ALLOWED
assert sources[1].install_policy is InstallPolicy.DISCOVERY_ONLY
+ assert sources[1].priority == 20
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",
+ [
+ "- a\n- b\n", # truthy list
+ "42\n", # truthy scalar
+ "[]\n", # falsy list
+ "false\n", # falsy bool
+ "0\n", # falsy int
+ "''\n", # falsy empty string
+ "null\n", # explicit null scalar (safe_load -> None, but a real node)
+ "~\n", # explicit null scalar (alt spelling)
+ ],
+)
+def test_toplevel_non_mapping_raises(tmp_path: Path, body: str):
+ """A top-level non-mapping bundle-catalogs.yml (list/scalar/null) must raise,
+ matching the sibling reader (catalog_config._read) ā not silently fall back
+ to the built-in default stack. This includes FALSY non-mappings ([], false,
+ 0, '') and an explicit null (null/~); the shared load_yaml would coerce those
+ to {} and hide them, so it distinguishes them from a truly empty document."""
+ make_project(tmp_path)
+ (tmp_path / ".specify" / "bundle-catalogs.yml").write_text(body, encoding="utf-8")
+ with pytest.raises(BundlerError, match="expected a mapping at the top level"):
+ load_source_stack(tmp_path)
+
+
+@pytest.mark.parametrize(
+ "body",
+ [
+ "catalogs:\n", # present key, null value
+ "catalogs: []\n", # present key, empty list
+ "", # truly empty document
+ "# only a comment\n", # comment-only == empty document
+ ],
+)
+def test_absent_or_empty_catalogs_is_noop(tmp_path: Path, body: str):
+ """An empty document, comment-only file, or absent/empty-list ``catalogs:``
+ is valid: it contributes no project sources and falls back to the built-in
+ default stack (must not be confused with an explicit top-level null)."""
+ 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_load_source_stack_rejects_unknown_schema_version(tmp_path: Path):
+ """A bundle-catalogs.yml with an unsupported MAJOR schema_version must raise
+ on the resolution path (load_source_stack -> _merge_config), matching the
+ sibling reader commands_impl/catalog_config._read. Without this a file
+ written by a newer/incompatible Spec Kit was silently parsed under v1
+ assumptions on the install/search path, while the other reader rejected it."""
+ make_project(tmp_path)
+ config = {
+ "schema_version": "2.0",
+ "catalogs": [{"id": "corp", "url": "https://corp/catalog.json",
+ "priority": 1, "install_policy": "install-allowed"}],
+ }
+ (tmp_path / ".specify" / "bundle-catalogs.yml").write_text(
+ yaml.safe_dump(config), encoding="utf-8"
+ )
+ with pytest.raises(BundlerError, match="Unsupported catalog config schema version"):
+ load_source_stack(tmp_path)
+
+
+def test_load_source_stack_accepts_matching_or_absent_schema_version(tmp_path: Path):
+ """A matching major version (1.x) and an absent schema_version both stay
+ valid ā the guard rejects only a different major, so existing configs that
+ omit the key are unaffected."""
+ make_project(tmp_path)
+ cfg = tmp_path / ".specify" / "bundle-catalogs.yml"
+ cfg.write_text(yaml.safe_dump({
+ "schema_version": "1.5", # same major as CONFIG_SCHEMA_VERSION (1.0)
+ "catalogs": [{"id": "corp", "url": "https://corp/catalog.json",
+ "priority": 1, "install_policy": "install-allowed"}],
+ }), encoding="utf-8")
+ assert "corp" in {s.id for s in load_source_stack(tmp_path)}
+ cfg.write_text(yaml.safe_dump({ # no schema_version key
+ "catalogs": [{"id": "corp2", "url": "https://corp2/catalog.json",
+ "priority": 1, "install_policy": "install-allowed"}],
+ }), encoding="utf-8")
+ assert "corp2" in {s.id for s in load_source_stack(tmp_path)}
+
+
def test_project_config_overrides_same_id(tmp_path: Path):
make_project(tmp_path)
config = {
@@ -95,6 +206,29 @@ def test_builtin_default_stack_constant_shape():
assert ids == {"default", "community"}
+def test_repository_community_bundle_catalog_matches_contract():
+ catalog_path = Path(__file__).parents[2] / "bundles" / "catalog.community.json"
+ payload = json.loads(catalog_path.read_text(encoding="utf-8"))
+
+ assert payload["schema_version"] == "1.0"
+ assert payload["catalog_url"].endswith("/bundles/catalog.community.json")
+ entries = load_catalog_payload(payload)
+ assert all(entry.verified is False for entry in entries.values())
+
+
+def test_wheel_packages_community_bundle_catalog():
+ repo_root = Path(__file__).parents[2]
+ with (repo_root / "pyproject.toml").open("rb") as pyproject_file:
+ pyproject = tomllib.load(pyproject_file)
+
+ force_include = pyproject["tool"]["hatch"]["build"]["targets"]["wheel"][
+ "force-include"
+ ]
+ assert force_include["bundles/catalog.community.json"] == (
+ "specify_cli/core_pack/bundles/catalog.community.json"
+ )
+
+
def test_catalog_entry_rejects_string_tags():
from specify_cli.bundler.models.catalog import CatalogEntry
@@ -113,6 +247,25 @@ def test_catalog_entry_rejects_non_boolean_verified():
CatalogEntry.from_dict(data)
+def test_catalog_entry_preserves_sha256_through_provenance():
+ digest = "a" * 64
+ payload = catalog_payload(
+ {"demo": catalog_entry_dict("demo", sha256=f"sha256:{digest}")}
+ )
+
+ entry = load_catalog_payload(payload)["demo"]
+ source = CatalogSource(
+ id="team",
+ url="https://example.com/catalog.json",
+ priority=10,
+ install_policy=InstallPolicy.INSTALL_ALLOWED,
+ scope=Scope.PROJECT,
+ )
+
+ assert entry.sha256 == f"sha256:{digest}"
+ assert entry.with_provenance(source).sha256 == f"sha256:{digest}"
+
+
def test_load_payload_rejects_id_key_mismatch():
# The enclosing key is authoritative; an entry whose own id disagrees with
# the key must be rejected so a catalog can't list a spoofed/unresolvable id.
@@ -145,3 +298,17 @@ def test_catalog_entry_rejects_non_mapping_provides():
data["provides"] = "extensions"
with pytest.raises(BundlerError, match="'provides' must be a mapping"):
CatalogEntry.from_dict(data)
+
+
+@pytest.mark.parametrize("field", ["requires", "provides"])
+@pytest.mark.parametrize("bad", [[], "", 0, False])
+def test_catalog_entry_rejects_falsy_non_mapping(field, bad):
+ # `or {}` coerced a FALSY non-mapping ([], '', 0, False) to {} before the
+ # isinstance guard, silently accepting a corrupt entry; only absent/None
+ # means "not present". Mirrors the manifest requires/provides guard.
+ from specify_cli.bundler.models.catalog import CatalogEntry
+
+ data = catalog_entry_dict("demo")
+ data[field] = bad
+ with pytest.raises(BundlerError, match=f"'{field}' must be a mapping"):
+ CatalogEntry.from_dict(data)
diff --git a/tests/contract/test_manifest_schema.py b/tests/contract/test_manifest_schema.py
index 4d0d95f608..2f38620423 100644
--- a/tests/contract/test_manifest_schema.py
+++ b/tests/contract/test_manifest_schema.py
@@ -26,6 +26,45 @@ def test_missing_required_field_is_reported_by_name():
assert any("bundle.license" in e for e in errors)
+@pytest.mark.parametrize(
+ "field", ["name", "role", "description", "author", "license"]
+)
+def test_explicit_null_bundle_field_is_reported_as_missing(field):
+ """A field present but null is how YAML spells an empty value (`author:`).
+
+ `str(None)` is the literal text "None", which is non-empty, so it passed the
+ required-field checks: the bundle validated clean and shipped "None" as its
+ author/license/description.
+ """
+ data = valid_manifest_dict()
+ data["bundle"][field] = None
+ manifest = BundleManifest.from_dict(data)
+ assert getattr(manifest.bundle, field) == ""
+ assert any(f"bundle.{field}" in e for e in manifest.structural_errors())
+
+
+def test_explicit_null_speckit_version_is_reported_as_missing():
+ data = valid_manifest_dict()
+ data["requires"]["speckit_version"] = None
+ manifest = BundleManifest.from_dict(data)
+ assert manifest.requires.speckit_version == ""
+ assert any("speckit_version" in e for e in manifest.structural_errors())
+
+
+def test_explicit_null_component_id_is_not_named_none():
+ """A null component id must not become a component literally named "None"."""
+ data = valid_manifest_dict()
+ for kind, items in (data.get("provides") or {}).items():
+ if isinstance(items, list) and items and isinstance(items[0], dict):
+ items[0]["id"] = None
+ break
+ else: # pragma: no cover - fixture is expected to provide components
+ pytest.skip("fixture has no component list to null out")
+ manifest = BundleManifest.from_dict(data)
+ assert manifest.components, "fixture is expected to declare components"
+ assert all(ref.id != "None" for ref in manifest.components)
+
+
def test_unsupported_schema_version_is_rejected():
data = valid_manifest_dict(schema_version="9.9")
errors = BundleManifest.from_dict(data).structural_errors()
@@ -124,3 +163,44 @@ 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)
+
+
+@pytest.mark.parametrize("bad", [[], "", 0, False, "extensions"])
+def test_non_mapping_provides_rejected_including_falsy(bad):
+ # `data.get("provides") or {}` coerced a FALSY non-mapping ([], '', 0, False)
+ # to {} before the type check, so a malformed manifest passed validation as
+ # a bundle that provides nothing. Only an absent/None value means "empty".
+ data = valid_manifest_dict()
+ data["provides"] = bad
+ with pytest.raises(BundlerError, match="'provides' must be a mapping when present"):
+ BundleManifest.from_dict(data)
+
+
+@pytest.mark.parametrize("bad", [[], "", 0, False, "speckit>=0.1"])
+def test_non_mapping_requires_rejected_including_falsy(bad):
+ # Same falsy-coercion hole for `requires`.
+ data = valid_manifest_dict()
+ data["requires"] = bad
+ with pytest.raises(BundlerError, match="'requires' must be a mapping when present"):
+ BundleManifest.from_dict(data)
+
+
+def test_absent_provides_and_requires_do_not_raise_mapping_error():
+ # Absent (None) optional mappings default to empty and must NOT trigger the
+ # "must be a mapping when present" guard ā that is reserved for present
+ # non-mappings. (Structural completeness, e.g. requires.speckit_version, is
+ # a separate concern checked by structural_errors().)
+ data = valid_manifest_dict()
+ data.pop("provides", None)
+ data.pop("requires", None)
+ BundleManifest.from_dict(data) # does not raise BundlerError
diff --git a/tests/contract/test_wheel_core_pack_scripts.py b/tests/contract/test_wheel_core_pack_scripts.py
new file mode 100644
index 0000000000..559accc8f3
--- /dev/null
+++ b/tests/contract/test_wheel_core_pack_scripts.py
@@ -0,0 +1,40 @@
+"""Contract tests for the script variants bundled into the wheel's core_pack.
+
+``specify init --script `` installs from ``specify_cli/core_pack/scripts/``
+when the CLI runs from a wheel. Any script variant that lives in the repository
+must therefore be force-included at build time, otherwise the generated
+commands reference scripts the released package never ships (#3665).
+"""
+
+from __future__ import annotations
+
+import tomllib
+from pathlib import Path
+
+REPO_ROOT = Path(__file__).parents[2]
+
+
+def _force_include() -> dict[str, str]:
+ with (REPO_ROOT / "pyproject.toml").open("rb") as pyproject_file:
+ pyproject = tomllib.load(pyproject_file)
+ return pyproject["tool"]["hatch"]["build"]["targets"]["wheel"]["force-include"]
+
+
+def test_every_script_variant_is_bundled_into_core_pack():
+ force_include = _force_include()
+ variants = sorted(
+ path.name for path in (REPO_ROOT / "scripts").iterdir() if path.is_dir()
+ )
+
+ assert variants, "expected at least one script variant under scripts/"
+ for variant in variants:
+ assert force_include.get(f"scripts/{variant}") == (
+ f"specify_cli/core_pack/scripts/{variant}"
+ ), f"scripts/{variant} is missing from the wheel force-include list"
+
+
+def test_python_script_variant_is_bundled():
+ # Explicit regression guard for #3665: `--script py` shipped skills that
+ # invoked python3 .specify/scripts/python/*.py while the wheel bundled
+ # only the bash and PowerShell variants.
+ assert _force_include()["scripts/python"] == "specify_cli/core_pack/scripts/python"
diff --git a/tests/extensions/git/test_git_extension.py b/tests/extensions/git/test_git_extension.py
index 79acfcb79e..f6be51caf6 100644
--- a/tests/extensions/git/test_git_extension.py
+++ b/tests/extensions/git/test_git_extension.py
@@ -698,6 +698,22 @@ def test_output_omits_has_git_to_match_bash(self, tmp_path: Path):
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 = '' 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)
@@ -1151,6 +1167,295 @@ def test_success_message_no_unicode_checkmark(self, tmp_path: Path):
assert "\u2713" not in result.stderr, "Must not use Unicode checkmark"
+@requires_bash
+class TestAutoCommitBashCommitStyle:
+ """Tests for the `commit_style: conventional` option (issue #3390)."""
+
+ def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
+ """Omitting commit_style preserves the fixed/static message behavior."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_bash("auto-commit.sh", project, "after_specify")
+ assert result.returncode == 0
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "[Spec Kit] Add specification" in log.stdout
+
+ def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
+ """commit_style: fixed (explicit) still uses the configured static message,
+ not just the absent-key default."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: fixed\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_bash(
+ "auto-commit.sh", project, "after_specify", "feat: this should be ignored"
+ )
+ assert result.returncode == 0
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "[Spec Kit] Add specification" in log.stdout
+ assert "this should be ignored" not in log.stdout
+
+ def test_conventional_message_file_used(self, tmp_path: Path):
+ """--message-file reads the generated message from a file instead of argv,
+ avoiding shell interpolation of agent-controlled content."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ # Write the message file inside the worktree (as an agent invoking
+ # this from a working directory tool naturally would) to exercise
+ # the exclusion-from-staging behavior below.
+ msg_file = project / "commit-msg.txt"
+ msg_file.write_text("feat: add $(dangerous) `injection` test\n")
+ result = _run_bash(
+ "auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
+ )
+ assert result.returncode == 0
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "feat: add $(dangerous) `injection` test" in log.stdout
+
+ def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
+ """--message-file written inside the worktree must never be staged or
+ committed itself, and must be removed once its content is consumed."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ))
+ (project / "new-file.txt").write_text("content")
+ msg_file = project / "commit-msg.txt"
+ msg_file.write_text("feat: real change\n")
+ result = _run_bash(
+ "auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
+ )
+ assert result.returncode == 0
+ assert not msg_file.exists()
+ show = subprocess.run(
+ ["git", "show", "--stat", "--oneline", "HEAD"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "new-file.txt" in show.stdout
+ assert "commit-msg.txt" not in show.stdout
+
+ def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
+ """If the message file is the only 'change' in the worktree (no real
+ edits), auto-commit must still report no changes rather than
+ committing the transport file by itself."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ))
+ # Baseline-commit the scaffolding (and config) so the tree is
+ # genuinely clean before introducing the message file ā otherwise
+ # the untracked scaffold files would mask whether the message file
+ # alone is enough to (incorrectly) trigger a commit.
+ subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-q", "-m", "baseline"],
+ cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
+ )
+ msg_file = project / "commit-msg.txt"
+ msg_file.write_text("feat: no real changes\n")
+ result = _run_bash(
+ "auto-commit.sh", project, "after_specify", "--message-file", str(msg_file)
+ )
+ assert result.returncode == 0
+ assert "No changes to commit" in result.stderr
+ assert not msg_file.exists()
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "baseline" in log.stdout
+
+ def test_message_file_missing_fails(self, tmp_path: Path):
+ """--message-file pointing at a nonexistent file fails clearly."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ))
+ (project / "new-file.txt").write_text("content")
+ missing = tmp_path / "does-not-exist.txt"
+ result = _run_bash(
+ "auto-commit.sh", project, "after_specify", "--message-file", str(missing)
+ )
+ assert result.returncode != 0
+ assert "not found" in result.stderr.lower()
+
+ def test_conventional_uses_generated_message(self, tmp_path: Path):
+ """commit_style: conventional uses the generated_message argument as the commit message."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_bash(
+ "auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
+ )
+ assert result.returncode == 0
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "feat: add OAuth specification" in log.stdout
+ assert "[Spec Kit] Add specification" not in log.stdout
+
+ def test_conventional_without_generated_message_fails(self, tmp_path: Path):
+ """commit_style: conventional fails clearly instead of falling back to the fixed message."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_bash("auto-commit.sh", project, "after_specify")
+ assert result.returncode != 0
+ assert "conventional" in result.stderr.lower()
+
+ # No commit should have been made, and the fixed message must not be used.
+ log = subprocess.run(
+ ["git", "log", "--oneline"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "[Spec Kit] Add specification" not in log.stdout
+
+ def test_conventional_skips_cleanly_with_no_changes(self, tmp_path: Path):
+ """No pending changes short-circuits before the missing-message failure."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ))
+ subprocess.run(["git", "add", "."], cwd=project, check=True)
+ subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
+
+ result = _run_bash("auto-commit.sh", project, "after_specify")
+ assert result.returncode == 0
+ assert "No changes" in result.stderr
+
+ def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
+ """commit_style value with a trailing YAML inline comment is still recognized."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional # team standard\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_bash(
+ "auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
+ )
+ assert result.returncode == 0
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "feat: add OAuth specification" in log.stdout
+ assert "[Spec Kit] Add specification" not in log.stdout
+
+ def test_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
+ """An unrecognized commit_style value falls back to 'fixed' with a warning,
+ instead of silently mis-parsing or crashing."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventonal\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_bash("auto-commit.sh", project, "after_specify")
+ assert result.returncode == 0
+ assert "unknown commit_style" in result.stderr.lower()
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "[Spec Kit] Add specification" in log.stdout
+
+ def test_duplicate_commit_style_lines_use_first_match(self, tmp_path: Path):
+ """A config with multiple `commit_style:` lines (e.g. from a bad merge) uses only
+ the first match instead of concatenating values into an unrecognized style."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "commit_style: fixed\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_bash(
+ "auto-commit.sh", project, "after_specify", "feat: add OAuth specification"
+ )
+ assert result.returncode == 0
+ assert "unknown commit_style" not in result.stderr.lower()
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "feat: add OAuth specification" in log.stdout
+ assert "[Spec Kit] Add specification" not in log.stdout
+
+
@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
class TestAutoCommitPowerShell:
def test_disabled_by_default(self, tmp_path: Path):
@@ -1211,6 +1516,271 @@ def test_success_message_no_unicode_checkmark(self, tmp_path: Path):
assert "\u2713" not in result.stdout, "Must not use Unicode checkmark"
+@pytest.mark.skipif(not HAS_PWSH, reason="pwsh not available")
+class TestAutoCommitPowerShellCommitStyle:
+ """Tests for the `commit_style: conventional` option (issue #3390)."""
+
+ def test_fixed_is_default_when_commit_style_absent(self, tmp_path: Path):
+ """Omitting commit_style preserves the fixed/static message behavior."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_pwsh("auto-commit.ps1", project, "after_specify")
+ assert result.returncode == 0
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "[Spec Kit] Add specification" in log.stdout
+
+ def test_explicit_fixed_style_uses_configured_message(self, tmp_path: Path):
+ """commit_style: fixed (explicit) still uses the configured static message,
+ not just the absent-key default."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: fixed\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_pwsh(
+ "auto-commit.ps1", project, "after_specify", "feat: this should be ignored"
+ )
+ assert result.returncode == 0
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "[Spec Kit] Add specification" in log.stdout
+ assert "this should be ignored" not in log.stdout
+
+ def test_conventional_message_file_used(self, tmp_path: Path):
+ """-MessageFile reads the generated message from a file instead of argv,
+ avoiding shell interpolation of agent-controlled content."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ msg_file = project / "commit-msg.txt"
+ msg_file.write_text("feat: add $(dangerous) `injection` test\n")
+ result = _run_pwsh(
+ "auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
+ )
+ assert result.returncode == 0
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "feat: add $(dangerous) `injection` test" in log.stdout
+
+ def test_message_file_not_staged_or_left_behind(self, tmp_path: Path):
+ """-MessageFile written inside the worktree must never be staged or
+ committed itself, and must be removed once its content is consumed."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ))
+ (project / "new-file.txt").write_text("content")
+ msg_file = project / "commit-msg.txt"
+ msg_file.write_text("feat: real change\n")
+ result = _run_pwsh(
+ "auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
+ )
+ assert result.returncode == 0
+ assert not msg_file.exists()
+ show = subprocess.run(
+ ["git", "show", "--stat", "--oneline", "HEAD"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "new-file.txt" in show.stdout
+ assert "commit-msg.txt" not in show.stdout
+
+ def test_message_file_alone_does_not_defeat_no_changes_shortcircuit(self, tmp_path: Path):
+ """If the message file is the only 'change' in the worktree (no real
+ edits), auto-commit must still report no changes rather than
+ committing the transport file by itself."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ))
+ # Baseline-commit the scaffolding (and config) so the tree is
+ # genuinely clean before introducing the message file ā otherwise
+ # the untracked scaffold files would mask whether the message file
+ # alone is enough to (incorrectly) trigger a commit.
+ subprocess.run(["git", "add", "-A"], cwd=project, check=True, capture_output=True)
+ subprocess.run(
+ ["git", "commit", "-q", "-m", "baseline"],
+ cwd=project, check=True, capture_output=True, env={**os.environ, **_GIT_ENV},
+ )
+ msg_file = project / "commit-msg.txt"
+ msg_file.write_text("feat: no real changes\n")
+ result = _run_pwsh(
+ "auto-commit.ps1", project, "after_specify", "-MessageFile", str(msg_file)
+ )
+ assert result.returncode == 0
+ assert "No changes to commit" in (result.stdout + result.stderr)
+ assert not msg_file.exists()
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "baseline" in log.stdout
+
+ def test_message_file_missing_fails(self, tmp_path: Path):
+ """-MessageFile pointing at a nonexistent file fails clearly."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ))
+ (project / "new-file.txt").write_text("content")
+ missing = tmp_path / "does-not-exist.txt"
+ result = _run_pwsh(
+ "auto-commit.ps1", project, "after_specify", "-MessageFile", str(missing)
+ )
+ assert result.returncode != 0
+ assert "not found" in (result.stdout + result.stderr).lower()
+
+ def test_conventional_uses_generated_message(self, tmp_path: Path):
+ """commit_style: conventional uses the generated_message argument as the commit message."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_pwsh(
+ "auto-commit.ps1", project, "after_specify", "feat: add OAuth specification"
+ )
+ assert result.returncode == 0
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "feat: add OAuth specification" in log.stdout
+ assert "[Spec Kit] Add specification" not in log.stdout
+
+ def test_conventional_without_generated_message_fails(self, tmp_path: Path):
+ """commit_style: conventional fails clearly instead of falling back to the fixed message."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_pwsh("auto-commit.ps1", project, "after_specify")
+ assert result.returncode != 0
+ # Write-Warning output placement (stdout vs. stderr) is not deterministic
+ # across pwsh versions/platforms, so check the combined stream like the
+ # other pwsh tests above (e.g. test_not_a_repo_still_detected_with_autocrlf).
+ combined = result.stdout + result.stderr
+ assert "conventional" in combined.lower()
+
+ log = subprocess.run(
+ ["git", "log", "--oneline"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "[Spec Kit] Add specification" not in log.stdout
+
+ def test_conventional_skips_cleanly_with_no_changes(self, tmp_path: Path):
+ """No pending changes short-circuits before the missing-message failure."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ))
+ subprocess.run(["git", "add", "."], cwd=project, check=True)
+ subprocess.run(["git", "commit", "-m", "setup", "-q"], cwd=project, check=True)
+
+ result = _run_pwsh("auto-commit.ps1", project, "after_specify")
+ assert result.returncode == 0
+ combined = result.stdout + result.stderr
+ assert "No changes" in combined
+
+ def test_conventional_with_trailing_inline_comment(self, tmp_path: Path):
+ """commit_style value with a trailing YAML inline comment is still recognized."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventional # team standard\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_pwsh(
+ "auto-commit.ps1", project, "after_specify", "feat: add OAuth specification"
+ )
+ assert result.returncode == 0
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "feat: add OAuth specification" in log.stdout
+ assert "[Spec Kit] Add specification" not in log.stdout
+
+ def test_unknown_commit_style_defaults_to_fixed(self, tmp_path: Path):
+ """An unrecognized commit_style value falls back to 'fixed' with a warning,
+ instead of silently mis-parsing or crashing."""
+ project = _setup_project(tmp_path)
+ _write_config(project, (
+ "commit_style: conventonal\n"
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "[Spec Kit] Add specification"\n'
+ ))
+ (project / "new-file.txt").write_text("content")
+ result = _run_pwsh("auto-commit.ps1", project, "after_specify")
+ assert result.returncode == 0
+ combined = (result.stdout or "") + (result.stderr or "")
+ assert "unknown commit_style" in combined.lower()
+ log = subprocess.run(
+ ["git", "log", "--oneline", "-1"],
+ cwd=project, capture_output=True, text=True,
+ )
+ assert "[Spec Kit] Add specification" in log.stdout
+
+
# āā auto-commit.ps1 CRLF warning tests (issue #2253) āāāāāāāāāāāāāāāāāāāāāāāā
diff --git a/tests/extensions/git/test_git_extension_python_parity.py b/tests/extensions/git/test_git_extension_python_parity.py
index 894571fcb3..faeef00e7f 100644
--- a/tests/extensions/git/test_git_extension_python_parity.py
+++ b/tests/extensions/git/test_git_extension_python_parity.py
@@ -515,6 +515,32 @@ def test_enabled_per_command_with_custom_message(self, tmp_path: Path):
assert p.stderr.strip() == b.stderr.strip()
assert self._last_message(bash_proj) == self._last_message(py_proj) == "spec done"
+ def test_custom_message_with_trailing_whitespace_after_quote(self, tmp_path: Path):
+ """Trailing whitespace after a closing quote must not leave the quote
+ dangling in the commit message. A raw close-quote strip anchored to
+ end-of-string skips the quote when spaces follow it (``spec done" ``);
+ trimming first (matching the PowerShell twin) yields a clean message and
+ keeps bash/python in parity."""
+ bash_proj, py_proj = _twin_projects(tmp_path)
+ config = (
+ "auto_commit:\n"
+ " default: false\n"
+ " after_specify:\n"
+ " enabled: true\n"
+ ' message: "spec done" \n' # trailing spaces after the closing quote
+ )
+ for proj in (bash_proj, py_proj):
+ _write_config(proj, config)
+ self._dirty(proj)
+ b = _run_bash("auto-commit.sh", bash_proj, "after_specify")
+ p = _run_py("auto-commit", py_proj, "after_specify")
+ _assert_parity(b, p)
+ assert (
+ self._last_message(bash_proj)
+ == self._last_message(py_proj)
+ == "spec done"
+ )
+
def test_default_true_applies_to_unlisted_event(self, tmp_path: Path):
bash_proj, py_proj = _twin_projects(tmp_path)
for proj in (bash_proj, py_proj):
diff --git a/tests/extensions/test_update_agent_context_feature_json.py b/tests/extensions/test_update_agent_context_feature_json.py
index 957415708c..25b5ff9457 100644
--- a/tests/extensions/test_update_agent_context_feature_json.py
+++ b/tests/extensions/test_update_agent_context_feature_json.py
@@ -11,7 +11,6 @@
from tests.conftest import requires_bash
from tests.extensions.test_extension_agent_context import (
- BASH,
POWERSHELL,
_bash_posix_path,
_run_bash_agent_context_script,
diff --git a/tests/extensions/test_update_agent_context_python_parity.py b/tests/extensions/test_update_agent_context_python_parity.py
index 92e8e20f8b..969192eef3 100644
--- a/tests/extensions/test_update_agent_context_python_parity.py
+++ b/tests/extensions/test_update_agent_context_python_parity.py
@@ -222,6 +222,32 @@ def test_python_custom_markers_matching_bash(tmp_path: Path) -> None:
assert "old" not in content
+@requires_posix_bash
+def test_python_blank_markers_use_defaults_matching_bash(tmp_path: Path) -> None:
+ # Regression: with blank markers (config relying on the built-in defaults),
+ # the Bash port must fall back to DEFAULT_START/END, matching the Python and
+ # PowerShell ports. Previously the Bash config-parser transport dropped the
+ # trailing empty marker lines under $(...) command substitution, tripping the
+ # "malformed config parser output" guard so the default-marker substitution
+ # became unreachable and the context file was never updated.
+ markers = {"start": "", "end": ""}
+ repo_a, repo_b = twin_projects(
+ tmp_path, context_file="AGENTS.md", context_markers=markers
+ )
+ add_plan(repo_a)
+ add_plan(repo_b)
+
+ bash = run_bash(repo_a)
+ py = run_python(repo_b)
+
+ assert_parity(bash, py, repo_a, repo_b)
+ content = (repo_b / "AGENTS.md").read_bytes()
+ assert content == (repo_a / "AGENTS.md").read_bytes()
+ assert b"" in content
+ assert b"" in content
+ assert b"at specs/001-demo/plan.md" in content
+
+
@requires_posix_bash
def test_python_multiple_context_files_dedup_matching_bash(tmp_path: Path) -> None:
files = ["AGENTS.md", "docs/CONTEXT.md", "AGENTS.md"]
@@ -317,6 +343,27 @@ def test_python_mtime_fallback_matching_bash(tmp_path: Path) -> None:
assert b"at specs/001-new/plan.md" in content
+@requires_posix_bash
+def test_python_mtime_fallback_finds_nested_plan_matching_bash(tmp_path: Path) -> None:
+ # Regression: the mtime fallback must discover plan.md in nested scoped
+ # layouts (specs///plan.md), matching the Bash/PowerShell
+ # ports and the documented recursive-discovery contract (see #3024). A
+ # one-level scan (specs/*/plan.md) would miss this and omit the plan link.
+ repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
+ for repo in (repo_a, repo_b):
+ plan = repo / "specs" / "scope-a" / "002-nested" / "plan.md"
+ plan.parent.mkdir(parents=True, exist_ok=True)
+ plan.write_text("# plan\n", encoding="utf-8")
+
+ bash = run_bash(repo_a)
+ py = run_python(repo_b)
+
+ assert_parity(bash, py, repo_a, repo_b)
+ content = (repo_b / "AGENTS.md").read_bytes()
+ assert content == (repo_a / "AGENTS.md").read_bytes()
+ assert b"at specs/scope-a/002-nested/plan.md" in content
+
+
@requires_posix_bash
def test_python_prefers_feature_json_over_mtime_matching_bash(tmp_path: Path) -> None:
repo_a, repo_b = twin_projects(tmp_path, context_file="AGENTS.md")
diff --git a/tests/http_helpers.py b/tests/http_helpers.py
index 46e26806b4..04696d7c09 100644
--- a/tests/http_helpers.py
+++ b/tests/http_helpers.py
@@ -1,15 +1,46 @@
-"""HTTP test helpers shared by version-related CLI tests."""
+"""HTTP test helpers shared by CLI tests."""
+import io
import json
+import urllib.request
from unittest.mock import MagicMock
+import pytest
+
def mock_urlopen_response(payload: dict) -> MagicMock:
"""Build a urlopen context-manager mock whose read returns JSON."""
body = json.dumps(payload).encode("utf-8")
resp = MagicMock()
- resp.read.return_value = body
+ resp.read.side_effect = io.BytesIO(body).read
cm = MagicMock()
cm.__enter__.return_value = resp
cm.__exit__.return_value = False
return cm
+
+
+@pytest.fixture(autouse=True)
+def route_opener_open_through_urlopen(monkeypatch):
+ """Route build_opener().open through urllib.request.urlopen.
+
+ ``open_url(...)`` fetches via ``build_opener(...).open()``, which bypasses
+ ``urllib.request.urlopen`` ā and with it the urlopen patches these test
+ modules are built on.
+ Delegating ``open()`` to urlopen at call time keeps those patches
+ effective; the redirect handler's own behavior is covered by
+ ``TestRedirectStripping`` in test_authentication.py.
+
+ Import this fixture into a test module to activate it there.
+ """
+
+ class _UrlopenDelegatingOpener:
+ def open(self, req, data=None, timeout=None):
+ if data is None:
+ return urllib.request.urlopen(req, timeout=timeout)
+ return urllib.request.urlopen(req, data=data, timeout=timeout)
+
+ monkeypatch.setattr(
+ urllib.request,
+ "build_opener",
+ lambda *handlers: _UrlopenDelegatingOpener(),
+ )
diff --git a/tests/integration/test_bundler_catalog_stack.py b/tests/integration/test_bundler_catalog_stack.py
index e9ab8d912f..87d31581b1 100644
--- a/tests/integration/test_bundler_catalog_stack.py
+++ b/tests/integration/test_bundler_catalog_stack.py
@@ -38,6 +38,27 @@ def test_resolve_prefers_highest_precedence_source():
assert resolved.install_allowed is False
+def test_explicit_catalog_shadows_builtin_community_at_default_priority():
+ sources = [
+ _source("community", 20, "discovery-only"),
+ _source("explicit", 10, "install-allowed"),
+ ]
+ payloads = {
+ "community": catalog_payload({
+ "shared": catalog_entry_dict("shared", version="1.0.0"),
+ }),
+ "explicit": catalog_payload({
+ "shared": catalog_entry_dict("shared", version="2.0.0"),
+ }),
+ }
+
+ resolved = _stack(sources, payloads).resolve("shared")
+
+ assert resolved.source.id == "explicit"
+ assert resolved.entry.version == "2.0.0"
+ assert resolved.install_allowed is True
+
+
def test_resolve_unknown_bundle_errors():
stack = _stack(
[_source("only", 1, "install-allowed")],
diff --git a/tests/integration/test_bundler_install_flow.py b/tests/integration/test_bundler_install_flow.py
index 655094168d..0966008a74 100644
--- a/tests/integration/test_bundler_install_flow.py
+++ b/tests/integration/test_bundler_install_flow.py
@@ -491,3 +491,16 @@ def test_update_keeps_component_still_needed_by_sibling_bundle(tmp_path: Path):
assert ("extensions", "ext-b") not in {
(c.kind, c.id) for c in rec.contributed_components
}
+
+
+def test_install_result_changed_reports_uninstalled():
+ # A `bundle update` that only DROPS components (new manifest reduces
+ # provides) populates uninstalled with nothing installed/refreshed; that is
+ # still a mutating change, so `changed` must be True ā not a false no-op.
+ from specify_cli.bundler.services.installer import InstallResult
+ from specify_cli.bundler.models.manifest import ComponentRef
+
+ result = InstallResult(bundle_id="x")
+ assert result.changed is False # empty == no change
+ result.uninstalled.append(ComponentRef(kind="presets", id="p1"))
+ assert result.changed is True
diff --git a/tests/integration/test_bundler_local_install.py b/tests/integration/test_bundler_local_install.py
index 32972ac684..164de57006 100644
--- a/tests/integration/test_bundler_local_install.py
+++ b/tests/integration/test_bundler_local_install.py
@@ -7,7 +7,9 @@
from __future__ import annotations
import os
+import zipfile
from pathlib import Path
+from unittest.mock import patch
import pytest
import yaml
@@ -171,3 +173,62 @@ def test_download_manifest_rejects_non_https_url_even_offline(tmp_path: Path):
)
with pytest.raises(BundlerError, match="HTTPS"):
_download_manifest(resolved, offline=True)
+
+
+def test_local_zip_uses_bounded_archive_open(tmp_path: Path):
+ artifact = tmp_path / "too-many-entries.zip"
+ with zipfile.ZipFile(artifact, "w") as archive:
+ archive.writestr("bundle.yml", yaml.safe_dump(valid_manifest_dict()))
+ for index in range(512):
+ archive.writestr(f"assets/{index}.txt", "")
+
+ with pytest.raises(BundlerError, match="too many entries"):
+ _local_manifest_source(str(artifact))
+
+
+def test_invalid_local_manifest_is_rejected_before_project_init(
+ tmp_path: Path,
+ monkeypatch,
+):
+ bundle_dir = tmp_path / "invalid-bundle"
+ data = valid_manifest_dict()
+ data["bundle"]["author"] = ""
+ write_manifest(bundle_dir, data)
+ empty_cwd = tmp_path / "empty"
+ empty_cwd.mkdir()
+ monkeypatch.chdir(empty_cwd)
+
+ runner = CliRunner()
+ with patch("specify_cli.commands.bundle._run_init") as run_init:
+ result = runner.invoke(
+ app,
+ ["bundle", "install", str(bundle_dir), "--offline"],
+ )
+
+ assert result.exit_code == 1
+ assert "Missing required field: bundle.author" in result.output
+ run_init.assert_not_called()
+
+
+def test_incompatible_local_manifest_is_rejected_before_project_init(
+ tmp_path: Path,
+ monkeypatch,
+):
+ bundle_dir = tmp_path / "incompatible-bundle"
+ data = valid_manifest_dict()
+ data["requires"]["speckit_version"] = ">=999.0.0"
+ write_manifest(bundle_dir, data)
+ empty_cwd = tmp_path / "empty"
+ empty_cwd.mkdir()
+ monkeypatch.chdir(empty_cwd)
+
+ runner = CliRunner()
+ with patch("specify_cli.commands.bundle._run_init") as run_init:
+ result = runner.invoke(
+ app,
+ ["bundle", "install", str(bundle_dir), "--offline"],
+ )
+
+ assert result.exit_code == 1
+ assert "requires Spec Kit >=999.0.0" in result.output
+ run_init.assert_not_called()
diff --git a/tests/integration/test_bundler_offline.py b/tests/integration/test_bundler_offline.py
index 582f69cef0..8cbc7af9cc 100644
--- a/tests/integration/test_bundler_offline.py
+++ b/tests/integration/test_bundler_offline.py
@@ -31,6 +31,25 @@ def test_builtin_catalog_resolves_offline():
assert stack.search() == []
+def test_builtin_community_catalog_resolves_from_packaged_snapshot_offline():
+ fetcher = make_catalog_fetcher(allow_network=False)
+ source = _src(
+ "community",
+ "builtin://community",
+ priority=20,
+ policy="discovery-only",
+ )
+ payload = fetcher(source)
+ stack = CatalogStack([source], fetcher)
+
+ assert isinstance(payload.get("bundles"), dict)
+ assert all(
+ result.source.id == "community" and not result.install_allowed
+ for result in stack.search()
+ )
+ assert stack.sources[0].install_allowed is False
+
+
def test_file_catalog_resolves_offline(tmp_path: Path):
catalog_path = tmp_path / "catalog.json"
write_catalog_file(catalog_path, {"demo": catalog_entry_dict("demo")})
diff --git a/tests/integrations/test_base.py b/tests/integrations/test_base.py
index d03ea0cb25..5f99961804 100644
--- a/tests/integrations/test_base.py
+++ b/tests/integrations/test_base.py
@@ -1,6 +1,8 @@
"""Tests for IntegrationOption, IntegrationBase, MarkdownIntegration, and primitives."""
+import shlex
import sys
+from types import SimpleNamespace
import pytest
@@ -202,19 +204,105 @@ def test_base_extension_command_bare(self):
def test_skills_core_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
- assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
- assert i.build_command_invocation("plan") == "/speckit-plan"
+ assert i.build_command_invocation("speckit.plan") == "$speckit-plan"
+ assert i.build_command_invocation("plan") == "$speckit-plan"
def test_skills_extension_command(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
- assert i.build_command_invocation("speckit.git.commit") == "/speckit-git-commit"
- assert i.build_command_invocation("git.commit") == "/speckit-git-commit"
+ assert i.build_command_invocation("speckit.git.commit") == "$speckit-git-commit"
+ assert i.build_command_invocation("git.commit") == "$speckit-git-commit"
def test_skills_extension_command_with_args(self):
from specify_cli.integrations import get_integration
i = get_integration("codex")
- assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
+ assert i.build_command_invocation("speckit.git.commit", "fix typo") == "$speckit-git-commit fix typo"
+
+ @pytest.mark.parametrize("integration_key", ["codex", "zcode"])
+ def test_dollar_skill_post_processing_is_idempotent(self, integration_key):
+ from specify_cli.integrations import get_integration
+
+ content = (
+ "---\nname: test\n---\n\n"
+ "Literal slash invocation: /speckit-plan\n"
+ "- For each executable hook, output the following based on its flag:\n"
+ )
+ integration = get_integration(integration_key)
+ once = integration.post_process_skill_content(content)
+ twice = integration.post_process_skill_content(once)
+
+ assert twice == once
+ assert once.count("replace dots (`.`) with hyphens") == 1
+ assert "$speckit-git-commit" in once
+ assert "/speckit-plan" in once
+
+ def test_kimi_skill_post_processing_is_idempotent(self):
+ """Kimi's post_process_skill_content must be idempotent.
+
+ The hook-command note is injected with the /skill: prefix by the base
+ class (via get_invocation_prefix), so the idempotency check matches on
+ re-runs without requiring the broad /speckit- -> /skill:speckit- body
+ replacement to recognise a duplicate.
+ """
+ from specify_cli.integrations import get_integration
+
+ content = (
+ "---\nname: test\n---\n\n"
+ "Literal slash invocation: /speckit-plan\n"
+ "- For each executable hook, output the following based on its flag:\n"
+ )
+ integration = get_integration("kimi")
+ once = integration.post_process_skill_content(content)
+ twice = integration.post_process_skill_content(once)
+
+ assert twice == once
+ assert once.count("replace dots (`.`) with hyphens") == 1
+ assert "/skill:speckit-git-commit" in once
+
+ def test_get_invocation_prefix_skill_colon(self):
+ """get_invocation_prefix returns '/skill:' for Kimi in skills mode."""
+ from specify_cli._invocation_style import get_invocation_prefix
+
+ assert get_invocation_prefix("kimi", True) == "/skill:"
+ assert get_invocation_prefix("kimi", False) == "/"
+ assert get_invocation_prefix("codex", True) == "$"
+ assert get_invocation_prefix("claude", True) == "/"
+
+ def test_forge_core_command_hyphenated(self):
+ """Forge installs hyphenated slash-commands (/speckit-), 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("forge")
+ assert i.build_command_invocation("speckit.plan") == "/speckit-plan"
+ assert i.build_command_invocation("plan") == "/speckit-plan"
+
+ def test_forge_extension_command_hyphenated(self):
+ from specify_cli.integrations import get_integration
+ i = get_integration("forge")
+ 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"
+ )
+
+ def test_cline_core_command_hyphenated(self):
+ """Cline installs hyphenated slash-commands (/speckit-), 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:
@@ -230,6 +318,26 @@ def test_hyphen_separator_core_command(self):
result = IntegrationBase.resolve_command_refs(text, "-")
assert result == "Run `/speckit-plan` to plan."
+ def test_dollar_prefix_core_command(self):
+ text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
+ result = IntegrationBase.resolve_command_refs(text, "-", "$")
+ assert result == "Run `$speckit-plan` to plan."
+
+ def test_skill_colon_prefix_core_command(self):
+ text = "Run `__SPECKIT_COMMAND_PLAN__` to plan."
+ result = IntegrationBase.resolve_command_refs(text, "-", "/skill:")
+ assert result == "Run `/skill:speckit-plan` to plan."
+
+ def test_process_template_kimi_uses_skill_colon_prefix(self):
+ """process_template must use /skill: prefix for Kimi without relying on
+ post_process_skill_content's broad replacement."""
+ text = "---\ndescription: test\n---\nRun `__SPECKIT_COMMAND_PLAN__` to plan."
+ result = IntegrationBase.process_template(
+ text, "kimi", "sh", invoke_separator="-"
+ )
+ assert "/skill:speckit-plan" in result
+ assert "/speckit-plan" not in result
+
def test_multiple_placeholders(self):
text = "__SPECKIT_COMMAND_SPECIFY__ then __SPECKIT_COMMAND_PLAN__ then __SPECKIT_COMMAND_TASKS__"
result = IntegrationBase.resolve_command_refs(text, ".")
@@ -477,19 +585,41 @@ def test_sh_does_not_prefix_interpreter(self):
assert ".specify/scripts/bash/check-prerequisites.sh --json" in result
assert "python" not in result
+ def test_body_scripts_example_does_not_override_frontmatter(self):
+ content = (
+ "---\n"
+ "scripts:\n"
+ " sh: scripts/bash/real.sh --json\n"
+ "---\n"
+ "Run {SCRIPT} now.\n"
+ "```yaml\n"
+ "scripts:\n"
+ " sh: examples/not-the-command.sh\n"
+ "```\n"
+ )
+
+ result = IntegrationBase.process_template(content, "agent", "sh")
+
+ assert ".specify/scripts/bash/real.sh --json" in result
+ assert "examples/not-the-command.sh" in result
+
def test_py_quotes_interpreter_with_spaces(self, monkeypatch):
# An interpreter path containing whitespace (e.g. Windows
# ``Program Files``) must be quoted so it isn't split into args.
+ interpreter = r"C:\Program Files\Python\python.exe"
monkeypatch.setattr(
"specify_cli.integrations.base.shutil.which", lambda name: None
)
monkeypatch.setattr(
"specify_cli.integrations.base.sys.executable",
- r"C:\Program Files\Python\python.exe",
+ interpreter,
+ )
+ monkeypatch.setattr(
+ "specify_cli.integrations.base.os", SimpleNamespace(name="posix")
)
result = IntegrationBase.process_template(self.CONTENT, "agent", "py")
assert (
- '"C:\\Program Files\\Python\\python.exe" '
+ f"{shlex.quote(interpreter)} "
".specify/scripts/python/check-prerequisites.py --json"
) in result
@@ -511,6 +641,39 @@ def test_py_uses_project_venv(self, monkeypatch, tmp_path):
)
assert ".venv/bin/python .specify/scripts/python/check-prerequisites.py" in result
+ def test_setup_py_falls_back_to_platform_shell(
+ self, monkeypatch, tmp_path
+ ):
+ template = tmp_path / "fallback.md"
+ template.write_text(
+ "---\n"
+ "scripts:\n"
+ " sh: scripts/bash/check-prerequisites.sh --json\n"
+ " ps: scripts/powershell/check-prerequisites.ps1 -Json\n"
+ "---\n"
+ "Run {SCRIPT} now.\n",
+ encoding="utf-8",
+ )
+ integration = StubIntegration()
+ monkeypatch.setattr(
+ integration, "list_command_templates", lambda: [template]
+ )
+
+ created = integration.setup(
+ tmp_path,
+ IntegrationManifest("stub", tmp_path),
+ script_type="py",
+ )
+
+ rendered = created[0].read_text(encoding="utf-8")
+ expected = (
+ ".specify/scripts/powershell/check-prerequisites.ps1"
+ if sys.platform == "win32"
+ else ".specify/scripts/bash/check-prerequisites.sh"
+ )
+ assert "{SCRIPT}" not in rendered
+ assert expected in rendered
+
class TestInstallScriptsPython:
def _make_integration_with_scripts(self, monkeypatch, tmp_path):
diff --git a/tests/integrations/test_cli.py b/tests/integrations/test_cli.py
index 6899a5105a..01d35c027f 100644
--- a/tests/integrations/test_cli.py
+++ b/tests/integrations/test_cli.py
@@ -3,6 +3,7 @@
import io
import json
import os
+import runpy
import pytest
import yaml
@@ -1180,6 +1181,23 @@ def test_hyphen_separator_in_page_templates(self, tmp_path):
assert "__SPECKIT_COMMAND_" not in content
assert "/speckit-tasks" in content
+ def test_dollar_prefix_in_page_templates(self, tmp_path):
+ """Dollar-style skills agents get $speckit- in page templates."""
+ from specify_cli import _install_shared_infra
+
+ project = tmp_path / "dollar-test"
+ project.mkdir()
+ (project / ".specify").mkdir()
+
+ _install_shared_infra(
+ project, "sh", invoke_separator="-", invoke_prefix="$"
+ )
+
+ plan = project / ".specify" / "templates" / "plan-template.md"
+ content = plan.read_text(encoding="utf-8")
+ assert "$speckit-plan" in content
+ assert "/speckit-plan" not in content
+
@pytest.mark.parametrize("script_type", ["sh", "ps"])
def test_dot_separator_in_shared_scripts(self, tmp_path, script_type):
"""Markdown agents get /speckit. in shared script hints."""
@@ -1220,6 +1238,48 @@ def test_hyphen_separator_in_shared_scripts(self, tmp_path, script_type):
assert "/speckit.plan" not in content
assert "/speckit.tasks" not in content
+ @pytest.mark.parametrize("script_type", ["sh", "ps", "py"])
+ def test_dollar_prefix_in_shared_scripts(self, tmp_path, script_type):
+ """Dollar-style skills agents get native prefixes in shared script hints."""
+ from specify_cli import _install_shared_infra
+
+ project = tmp_path / f"dollar-script-{script_type}"
+ project.mkdir()
+ (project / ".specify").mkdir()
+
+ _install_shared_infra(
+ project, script_type, invoke_separator="-", invoke_prefix="$"
+ )
+
+ if script_type == "py":
+ state = {
+ "integration": "codex",
+ "integration_settings": {
+ "codex": {"invoke_separator": "-"},
+ },
+ }
+ (project / ".specify" / "integration.json").write_text(
+ json.dumps(state), encoding="utf-8"
+ )
+ common = project / ".specify" / "scripts" / "python" / "common.py"
+ namespace = runpy.run_path(str(common))
+ assert namespace["format_speckit_command"]("plan", project) == (
+ "$speckit-plan"
+ )
+ return
+
+ content = self._combined_script_content(project, script_type)
+ assert "$speckit-specify" in content
+ assert "$speckit-plan" in content
+ assert "$speckit-tasks" in content
+ assert "/speckit-specify" not in content
+ assert "/speckit-plan" not in content
+ assert "/speckit-tasks" not in content
+ if script_type == "sh":
+ assert r"\$speckit-specify" in content
+ assert r"\$speckit-plan" in content
+ assert r"\$speckit-tasks" in content
+
def test_full_init_claude_resolves_page_templates(self, tmp_path):
"""Full CLI init with Claude (skills agent) produces hyphen refs in page templates."""
from typer.testing import CliRunner
@@ -1343,6 +1403,18 @@ class TestIntegrationCatalogDiscoveryCLI:
"_install_allowed": True,
},
]
+ MARKUP_INTEGRATION = {
+ "id": "[red]markup-id[/red]",
+ "name": "[green]Markup Name[/green]",
+ "version": "[blue]1.0.0[/blue]",
+ "description": "[yellow]Markup Description[/yellow]",
+ "author": "[magenta]Markup Author[/magenta]",
+ "license": "[cyan]Markup License[/cyan]",
+ "repository": "[bold]Markup Repository[/bold]",
+ "tags": ["[italic]markup-tag[/italic]"],
+ "_catalog_name": "[underline]markup-catalog[/underline]",
+ "_install_allowed": False,
+ }
def _make_project(self, tmp_path):
project = tmp_path / "proj"
@@ -1806,6 +1878,25 @@ def test_search_marks_discovery_only_entry(self, tmp_path, monkeypatch):
# acme-coder is flagged _install_allowed=False, so we should warn
assert "Not directly installable" in result.output
+ def test_search_escapes_catalog_markup(self, tmp_path, monkeypatch):
+ project = self._make_project(tmp_path)
+ self._patch_catalog(monkeypatch, integrations=[self.MARKUP_INTEGRATION])
+
+ result = self._invoke(["integration", "search"], project)
+
+ assert result.exit_code == 0, result.output
+ output = _normalize_cli_output(result.output)
+ for value in (
+ self.MARKUP_INTEGRATION["id"],
+ self.MARKUP_INTEGRATION["name"],
+ self.MARKUP_INTEGRATION["version"],
+ self.MARKUP_INTEGRATION["description"],
+ self.MARKUP_INTEGRATION["author"],
+ self.MARKUP_INTEGRATION["tags"][0],
+ self.MARKUP_INTEGRATION["_catalog_name"],
+ ):
+ assert value in output
+
# -- info --------------------------------------------------------------
def test_info_found(self, tmp_path, monkeypatch):
@@ -1828,6 +1919,19 @@ def test_info_not_found(self, tmp_path, monkeypatch):
assert result.exit_code == 1
assert "not found" in result.output
+ def test_info_not_found_escapes_query_markup(self, tmp_path, monkeypatch):
+ project = self._make_project(tmp_path)
+ self._patch_catalog(monkeypatch)
+ integration_id = "[red]does-not-exist[/red]"
+
+ result = self._invoke(
+ ["integration", "info", integration_id],
+ project,
+ )
+
+ assert result.exit_code == 1
+ assert integration_id in _normalize_cli_output(result.output)
+
def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch):
project = self._make_project(tmp_path)
# Empty catalog, but copilot is a registered built-in.
@@ -1836,6 +1940,30 @@ def test_info_builtin_not_in_catalog(self, tmp_path, monkeypatch):
assert result.exit_code == 0, result.output
assert "Built-in integration" in result.output
+ def test_info_escapes_catalog_markup(self, tmp_path, monkeypatch):
+ project = self._make_project(tmp_path)
+ self._patch_catalog(monkeypatch, integrations=[self.MARKUP_INTEGRATION])
+
+ result = self._invoke(
+ ["integration", "info", self.MARKUP_INTEGRATION["id"]],
+ project,
+ )
+
+ assert result.exit_code == 0, result.output
+ output = _normalize_cli_output(result.output)
+ for value in (
+ self.MARKUP_INTEGRATION["id"],
+ self.MARKUP_INTEGRATION["name"],
+ self.MARKUP_INTEGRATION["version"],
+ self.MARKUP_INTEGRATION["description"],
+ self.MARKUP_INTEGRATION["author"],
+ self.MARKUP_INTEGRATION["license"],
+ self.MARKUP_INTEGRATION["repository"],
+ self.MARKUP_INTEGRATION["tags"][0],
+ self.MARKUP_INTEGRATION["_catalog_name"],
+ ):
+ assert value in output
+
# -- validation vs network guidance ------------------------------------
def test_search_local_config_error_shows_local_config_tip(
diff --git a/tests/integrations/test_events.py b/tests/integrations/test_events.py
new file mode 100644
index 0000000000..362a4aed9c
--- /dev/null
+++ b/tests/integrations/test_events.py
@@ -0,0 +1,2226 @@
+"""Tests for events module: integration runtime events."""
+
+from __future__ import annotations
+
+import json
+import os
+import platform
+import shlex
+from pathlib import Path, PurePath
+from unittest.mock import MagicMock, patch
+
+import pytest
+
+from specify_cli.events import (
+ CANONICAL_EVENTS,
+ EVENTS_DISPATCHER_REL,
+ collect_extension_events,
+ install_integration_events,
+ remove_integration_events,
+ resolve_events,
+ validate_events,
+ resolve_and_run_event_command,
+)
+from specify_cli.integrations.manifest import IntegrationManifest
+from specify_cli.integrations.claude import ClaudeIntegration
+from specify_cli.integrations.cursor_agent import CursorAgentIntegration
+from specify_cli.integrations.opencode import OpencodeIntegration
+from specify_cli.integrations.copilot import CopilotIntegration
+
+
+# -- resolve_events --------------------------------------------------------
+
+class TestResolveEvents:
+ """Test the 4-layer event resolution chain."""
+
+ def test_layer1_disabled_returns_empty(self, tmp_path):
+ """--events false returns empty dict."""
+ result = resolve_events(
+ "claude",
+ {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
+ tmp_path,
+ {"events": "false"},
+ )
+ assert result == {}
+
+ def test_layer4_built_in_defaults(self, tmp_path):
+ """Returns baseline defaults when no overrides exist."""
+ result = resolve_events(
+ "claude",
+ {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
+ tmp_path,
+ None,
+ )
+ assert result == {"post_tool_use": [{"command": "speckit.tdd.validate"}]}
+
+ def test_layer3_extension_events_appended(self, tmp_path):
+ """Extension-declared events are resolved and appended."""
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ ext_dir.mkdir(parents=True)
+ ext_yml = ext_dir / "extension.yml"
+ ext_yml.write_text(
+ "events:\n session_start:\n command: speckit.my-ext.boot\n",
+ encoding="utf-8",
+ )
+
+ result = resolve_events(
+ "claude",
+ {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
+ tmp_path,
+ None,
+ )
+ assert "post_tool_use" in result
+ assert "session_start" in result
+ assert result["session_start"] == [{"command": "speckit.my-ext.boot"}]
+
+ def test_layer3_multiple_extensions_same_event_accumulate(self, tmp_path):
+ """Two extensions declaring the same event both run (#2)."""
+ for ext_id, cmd in (("my-ext", "speckit.my-ext.boot"), ("other-ext", "speckit.other.boot")):
+ ext_dir = tmp_path / ".specify" / "extensions" / ext_id
+ ext_dir.mkdir(parents=True)
+ (ext_dir / "extension.yml").write_text(
+ f"events:\n session_start:\n command: {cmd}\n",
+ encoding="utf-8",
+ )
+ result = resolve_events("claude", None, tmp_path, None)
+ assert result["session_start"] == [
+ {"command": "speckit.my-ext.boot"},
+ {"command": "speckit.other.boot"},
+ ]
+
+ def test_layer2_yaml_override_replaces(self, tmp_path):
+ """integration-events.yml override replaces baseline entirely."""
+ override_file = tmp_path / ".specify" / "integration-events.yml"
+ override_file.parent.mkdir(parents=True, exist_ok=True)
+ override_file.write_text(
+ "integrations:\n"
+ " claude:\n"
+ " events:\n"
+ " stop:\n"
+ " command: speckit.override.stop\n",
+ encoding="utf-8",
+ )
+
+ result = resolve_events(
+ "claude",
+ {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
+ tmp_path,
+ None,
+ )
+ assert result == {"stop": [{"command": "speckit.override.stop"}]}
+
+ def test_layer2_empty_events_disables(self, tmp_path):
+ """Empty events override disables events."""
+ override_file = tmp_path / ".specify" / "integration-events.yml"
+ override_file.parent.mkdir(parents=True, exist_ok=True)
+ override_file.write_text(
+ "integrations:\n"
+ " claude:\n"
+ " events: {}\n",
+ encoding="utf-8",
+ )
+
+ result = resolve_events(
+ "claude",
+ {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
+ tmp_path,
+ None,
+ )
+ assert result == {}
+
+ def test_no_config_no_events(self, tmp_path):
+ """Safe fallback with empty config/options."""
+ result = resolve_events("claude", None, tmp_path, None)
+ assert result == {}
+
+
+# -- collect_extension_events -----------------------------------------------
+
+class TestCollectExtensionEvents:
+ """Test scanning extension.yml files for events: declarations."""
+
+ def test_no_extensions_dir(self, tmp_path):
+ assert collect_extension_events(tmp_path) == {}
+
+ def test_no_events_in_extension(self, tmp_path):
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ ext_dir.mkdir(parents=True)
+ (ext_dir / "extension.yml").write_text("extension:\n id: my-ext\n", encoding="utf-8")
+ assert collect_extension_events(tmp_path) == {}
+
+ def test_events_collected(self, tmp_path):
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ ext_dir.mkdir(parents=True)
+ (ext_dir / "extension.yml").write_text(
+ "events:\n pre_tool_use:\n command: speckit.my-ext.check\n",
+ encoding="utf-8",
+ )
+ result = collect_extension_events(tmp_path)
+ assert result == {"pre_tool_use": [{"command": "speckit.my-ext.check"}]}
+
+ def test_invalid_yaml_skipped(self, tmp_path):
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ ext_dir.mkdir(parents=True)
+ (ext_dir / "extension.yml").write_text("invalid: - - -", encoding="utf-8")
+ assert collect_extension_events(tmp_path) == {}
+
+ def test_event_command_ref_canonicalized_via_manifest(self, tmp_path):
+ """R1: events are read from a validated ExtensionManifest, so an
+ obsolete command ref (e.g. my-ext.boot) is canonicalized
+ (speckit.my-ext.boot) the same way hook refs are at install."""
+ from specify_cli.extensions import ExtensionRegistry
+ import yaml as _yaml
+
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ ext_dir.mkdir(parents=True)
+ # Manifest declares an alias-form event command ref (my-ext.boot)
+ # alongside the command it resolves to; the validated manifest lifts
+ # the ref to speckit.my-ext.boot (C11).
+ (ext_dir / "extension.yml").write_text(
+ _yaml.dump({
+ "schema_version": "1.0",
+ "extension": {
+ "id": "my-ext",
+ "name": "My Ext",
+ "version": "1.0.0",
+ "description": "test",
+ },
+ "requires": {"speckit_version": ">=0.1"},
+ "provides": {
+ "commands": [
+ {"name": "speckit.my-ext.boot", "file": "commands/boot.md"}
+ ]
+ },
+ "events": {"session_start": {"command": "my-ext.boot"}},
+ }),
+ encoding="utf-8",
+ )
+ ExtensionRegistry(tmp_path / ".specify" / "extensions").add(
+ "my-ext", {"enabled": True}
+ )
+
+ result = collect_extension_events(tmp_path)
+ # The ref was canonicalized to speckit.my-ext.boot by the validated
+ # manifest, so dispatch can match it (raw-YAML reading would have
+ # emitted the obsolete my-ext.boot and the hook would no-op).
+ assert result == {"session_start": [{"command": "speckit.my-ext.boot"}]}
+
+
+# -- Class-driven mappings --------------------------------------------------
+
+class TestCanonicalEventMapping:
+ """Verify registry-driven mapping is correct on integration classes."""
+
+ def test_claude_identity(self):
+ integration = ClaudeIntegration()
+ assert integration.supports_events() is True
+ assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "PreToolUse"
+ assert integration.CANONICAL_TO_NATIVE["session_start"] == "SessionStart"
+
+ def test_cursor_camelcase(self):
+ integration = CursorAgentIntegration()
+ assert integration.supports_events() is True
+ assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "preToolUse"
+ assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "beforeSubmitPrompt"
+
+ def test_opencode_limited(self):
+ integration = OpencodeIntegration()
+ assert integration.supports_events() is True
+ assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "tool.execute.before"
+ assert "stop" not in integration.CANONICAL_TO_NATIVE
+
+ def test_copilot_mapping(self):
+ integration = CopilotIntegration()
+ assert integration.supports_events() is True
+ assert integration.CANONICAL_TO_NATIVE["session_start"] == "sessionStart"
+ assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "userPromptSubmitted"
+
+ def test_gemini_mapping_includes_before_agent(self):
+ # S6: Gemini exposes BeforeAgent for user_prompt_submit and AfterAgent
+ # for stop (verified against Gemini CLI's hooks docs).
+ from specify_cli.integrations.gemini import GeminiIntegration
+ integration = GeminiIntegration()
+ assert integration.CANONICAL_TO_NATIVE["pre_tool_use"] == "BeforeTool"
+ assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "BeforeAgent"
+ assert integration.CANONICAL_TO_NATIVE["stop"] == "AfterAgent"
+
+ def test_tabnine_mapping_includes_before_agent(self):
+ # S7: Tabnine's Gemini-compatible schema provides BeforeAgent/AfterAgent.
+ from specify_cli.integrations.tabnine import TabnineIntegration
+ integration = TabnineIntegration()
+ assert integration.CANONICAL_TO_NATIVE["user_prompt_submit"] == "BeforeAgent"
+ assert integration.CANONICAL_TO_NATIVE["stop"] == "AfterAgent"
+
+
+# -- Event-capable adapters declare --events (#8, #9) ------------------------
+
+class TestEventCapableOptionsComposition:
+ """Event-capable integrations must declare --events so the documented
+ --events false opt-out is accepted."""
+
+ def _has_option(self, opts, name):
+ return any(o.name == name for o in opts)
+
+ def test_copilot_declares_events(self):
+ # #9: CopilotIntegration.options() composed with super() so --events
+ # is declared alongside --skills.
+ opts = CopilotIntegration().options()
+ assert self._has_option(opts, "--skills")
+ assert self._has_option(opts, "--events"), (
+ "Copilot is event-capable but --events is not declared; "
+ "--integration-options \"--events false\" would be rejected."
+ )
+
+ def test_devin_declares_events(self):
+ # #8: DevinIntegration.options() composed with super() so --events
+ # is declared alongside --skills.
+ from specify_cli.integrations.devin import DevinIntegration
+ opts = DevinIntegration().options()
+ assert self._has_option(opts, "--skills")
+ assert self._has_option(opts, "--events"), (
+ "Devin is event-capable but --events is not declared; "
+ "--integration-options \"--events false\" would be rejected."
+ )
+
+ def test_cursor_declares_events(self):
+ # Cursor already composed correctly; assert it stays that way.
+ opts = CursorAgentIntegration().options()
+ assert self._has_option(opts, "--skills")
+ assert self._has_option(opts, "--events")
+
+ def test_codex_declares_events(self):
+ # Codex already composed correctly; assert it stays that way.
+ from specify_cli.integrations.codex import CodexIntegration
+ opts = CodexIntegration().options()
+ assert self._has_option(opts, "--skills")
+ assert self._has_option(opts, "--events")
+
+
+# -- validate_events --------------------------------------------------------
+
+class TestValidateEvents:
+ """Test manifest validation."""
+
+ def test_unknown_event_rejected(self):
+ from specify_cli.extensions import ValidationError
+ data = {"events": {"unknown_event": {"command": "speckit.tdd.validate"}}}
+ with pytest.raises(ValidationError) as exc:
+ validate_events(data)
+ assert "Unknown event" in str(exc.value)
+
+ def test_known_event_accepted(self):
+ data = {"events": {"pre_tool_use": {"command": "speckit.tdd.validate"}}}
+ validate_events(data) # no raise
+
+ def test_all_canonical_events_accepted(self):
+ data = {
+ "events": {
+ name: {"command": "speckit.test"}
+ for name in CANONICAL_EVENTS
+ }
+ }
+ validate_events(data) # no raise
+
+
+# -- Claude settings JSON merging -------------------------------------------
+
+class TestClaudeJsonMerging:
+ """Test Claude settings JSON merging and cleanup."""
+
+ def test_merge_into_empty_file(self, tmp_path):
+ integration = ClaudeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ events = {
+ "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit|Write"}],
+ }
+ install_integration_events(integration, tmp_path, manifest, events)
+
+ config_path = tmp_path / ".claude/settings.json"
+ assert config_path.is_file()
+ data = json.loads(config_path.read_text())
+ assert "hooks" in data
+ assert "PreToolUse" in data["hooks"]
+ assert data["hooks"]["PreToolUse"][0]["matcher"] == "Edit|Write"
+ # #6: native schema is a single `command` string, not command+args.
+ inner = data["hooks"]["PreToolUse"][0]["hooks"][0]
+ assert isinstance(inner["command"], str)
+ assert "args" not in inner
+ assert "speckit.tdd.validate" in inner["command"]
+ assert "pre_tool_use" in inner["command"]
+ # The dispatcher path must be prefixed with ${CLAUDE_PROJECT_DIR}/ for
+ # Claude, and double-quoted so a project path with spaces doesn't
+ # word-split (C2) while the variable still expands.
+ assert "${CLAUDE_PROJECT_DIR}/" in inner["command"]
+ assert '"${CLAUDE_PROJECT_DIR}/.specify/events.py"' in inner["command"]
+
+ def test_claude_emits_all_handlers_for_same_event(self, tmp_path):
+ """#2: two handlers on the same event both appear in the native config."""
+ integration = ClaudeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ events = {
+ "pre_tool_use": [
+ {"command": "speckit.tdd.validate"},
+ {"command": "speckit.other.check"},
+ ],
+ }
+ install_integration_events(integration, tmp_path, manifest, events)
+
+ data = json.loads((tmp_path / ".claude/settings.json").read_text())
+ inner_hooks = data["hooks"]["PreToolUse"][0]["hooks"]
+ commands = [h["command"] for h in inner_hooks]
+ assert any("speckit.tdd.validate" in c for c in commands)
+ assert any("speckit.other.check" in c for c in commands)
+
+ def test_remove_preserves_user_hooks(self, tmp_path):
+ integration = ClaudeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ # Pre-seed user setting
+ config_path = tmp_path / ".claude/settings.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ config_path.write_text(
+ json.dumps(
+ {
+ "hooks": {
+ "PreToolUse": [
+ {
+ "matcher": "Bash",
+ "hooks": [
+ {
+ "type": "command",
+ "command": "user-check",
+ }
+ ],
+ }
+ ]
+ }
+ }
+ )
+ )
+
+ events = {
+ "pre_tool_use": [{"command": "speckit.tdd.validate"}],
+ }
+ install_integration_events(integration, tmp_path, manifest, events)
+ remove_integration_events(integration, tmp_path, manifest)
+
+ data = json.loads(config_path.read_text())
+ assert "hooks" in data
+ assert "PreToolUse" in data["hooks"]
+ assert len(data["hooks"]["PreToolUse"]) == 1
+ assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash"
+
+
+# -- Copilot events JSON writing --------------------------------------------
+
+class TestCopilotJsonWriting:
+ """Test Copilot dedicated .github/hooks/speckit.json generation."""
+
+ def test_copilot_json_generation(self, tmp_path):
+ integration = CopilotIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ events = {
+ "session_start": [{"command": "speckit.agent-context.update", "timeout": 60}],
+ }
+ install_integration_events(integration, tmp_path, manifest, events)
+
+ config_path = tmp_path / ".github/hooks/speckit.json"
+ assert config_path.is_file()
+ data = json.loads(config_path.read_text())
+ assert data["version"] == 1
+ assert "hooks" in data
+ assert "sessionStart" in data["hooks"]
+ entry = data["hooks"]["sessionStart"][0]
+ assert entry["type"] == "command"
+ # #6: a complete shell command string (not command+args).
+ assert "speckit.agent-context.update" in entry["bash"]
+ assert "session_start" in entry["bash"]
+ # S4: bash and powershell get independent OS-targeted interpreters so
+ # a config generated on one OS works on the other. R2: command/event
+ # args are shell-quoted for each target shell.
+ assert "speckit.agent-context.update" in entry["powershell"]
+ assert entry["bash"] != entry["powershell"]
+ # bash uses POSIX interpreter python3 (shlex.quote leaves safe tokens
+ # bare); powershell uses python single-quoted with the & call operator
+ # so the quoted command is actually invoked (C1).
+ assert entry["bash"].startswith("python3 ")
+ assert entry["powershell"].startswith("& 'python' ")
+ # PowerShell always single-quotes; POSIX leaves metacharacter-free
+ # identifiers bare (shlex.quote only quotes when needed).
+ assert "'speckit.agent-context.update'" in entry["powershell"]
+ assert "speckit.agent-context.update" in entry["bash"]
+ # R2: native timeout gets the buffer (60 + 5 = 65) so the agent's
+ # outer cap fires after the dispatcher's inner subprocess timeout.
+ assert entry["timeoutSec"] == 65
+
+
+# -- Cursor hooks.json version + matcher grouping (#7, S3) -------------------
+
+class TestCursorJsonWriting:
+ """#7: .cursor/hooks.json requires top-level version:1; S3: matcher grouping."""
+
+ def test_cursor_json_includes_version(self, tmp_path):
+ integration = CursorAgentIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"session_start": [{"command": "speckit.boot"}]},
+ )
+ data = json.loads((tmp_path / ".cursor/hooks.json").read_text())
+ assert data["version"] == 1
+ assert "sessionStart" in data["hooks"]
+
+ def test_cursor_json_preserves_user_version(self, tmp_path):
+ integration = CursorAgentIntegration()
+ config_path = tmp_path / ".cursor/hooks.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ config_path.write_text(json.dumps({"version": 1, "hooks": {}}))
+
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"session_start": [{"command": "speckit.boot"}]},
+ )
+ data = json.loads(config_path.read_text())
+ assert data["version"] == 1
+
+ def test_nested_matcher_grouping_per_distinct_matcher(self, tmp_path):
+ """S3: two handlers with different matchers produce two matcher-groups."""
+ integration = ClaudeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ events = {
+ "pre_tool_use": [
+ {"command": "speckit.first", "matcher": "Edit"},
+ {"command": "speckit.second", "matcher": "Bash"},
+ ],
+ }
+ install_integration_events(integration, tmp_path, manifest, events)
+ data = json.loads((tmp_path / ".claude/settings.json").read_text())
+ groups = data["hooks"]["PreToolUse"]
+ matchers = sorted(g["matcher"] for g in groups)
+ assert matchers == ["Bash", "Edit"]
+ # Each group holds exactly its own handler.
+ by_matcher = {g["matcher"]: g["hooks"] for g in groups}
+ assert len(by_matcher["Edit"]) == 1
+ assert "speckit.first" in by_matcher["Edit"][0]["command"]
+ assert len(by_matcher["Bash"]) == 1
+ assert "speckit.second" in by_matcher["Bash"][0]["command"]
+
+ def test_nested_shared_matcher_stays_one_group(self, tmp_path):
+ """S3: handlers sharing a matcher stay in a single matcher-group."""
+ integration = ClaudeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ events = {
+ "pre_tool_use": [
+ {"command": "speckit.first", "matcher": "Edit"},
+ {"command": "speckit.second", "matcher": "Edit"},
+ ],
+ }
+ install_integration_events(integration, tmp_path, manifest, events)
+ data = json.loads((tmp_path / ".claude/settings.json").read_text())
+ groups = data["hooks"]["PreToolUse"]
+ assert len(groups) == 1
+ assert groups[0]["matcher"] == "Edit"
+ assert len(groups[0]["hooks"]) == 2
+
+
+# -- Gemini timeout unit (#7) ------------------------------------------------
+
+class TestGeminiTimeoutUnit:
+ """Gemini measures hook timeouts in milliseconds, not seconds."""
+
+ def test_gemini_timeout_converted_to_ms(self, tmp_path):
+ from specify_cli.integrations.gemini import GeminiIntegration
+ from specify_cli.events import _native_timeout
+
+ integration = GeminiIntegration()
+ # 60 (seconds) -> 60000 (ms) for Gemini; unchanged for seconds-based agents.
+ assert _native_timeout(integration, 60) == 60000
+ assert _native_timeout(ClaudeIntegration(), 60) == 60
+
+ def test_tabnine_timeout_converted_to_ms(self):
+ """R5: Tabnine mirrors Gemini's ms-based hook schema."""
+ from specify_cli.integrations.tabnine import TabnineIntegration
+ from specify_cli.events import _native_timeout
+
+ assert _native_timeout(TabnineIntegration(), 60) == 60000
+
+ def test_qwen_timeout_converted_to_ms(self):
+ """U1: Qwen Code command hooks use milliseconds (default 60000)."""
+ from specify_cli.integrations.qwen import QwenIntegration
+ from specify_cli.events import _native_timeout
+
+ assert _native_timeout(QwenIntegration(), 60) == 60000
+
+
+# -- Devin root-nested format (U2) + Copilot agentStop (U3) ------------------
+
+class TestDevinRootNestedFormat:
+ """U2: Devin's hooks.v1.json is a root event map with no 'hooks' wrapper."""
+
+ def test_devin_events_written_at_root(self, tmp_path):
+ from specify_cli.integrations.devin import DevinIntegration
+ integration = DevinIntegration()
+ assert integration.events_format == "json-root-nested"
+
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+ manifest.remove = MagicMock()
+
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ data = json.loads((tmp_path / ".devin/hooks.v1.json").read_text())
+ # Event keys are top-level (no "hooks" wrapper).
+ assert "PreToolUse" in data
+ assert "hooks" not in data
+
+ def test_devin_teardown_removes_owned_and_preserves_user(self, tmp_path):
+ from specify_cli.integrations.devin import DevinIntegration
+ integration = DevinIntegration()
+ config_path = tmp_path / ".devin/hooks.v1.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ config_path.write_text(json.dumps({
+ "PreToolUse": [{
+ "matcher": "exec",
+ "hooks": [{"type": "command", "command": "user-check"}],
+ }]
+ }))
+
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+ manifest.remove = MagicMock()
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"stop": [{"command": "speckit.end"}]},
+ )
+ remove_integration_events(integration, tmp_path, manifest)
+
+ data = json.loads(config_path.read_text())
+ # User hook preserved at the root; Specify's Stop gone.
+ assert "Stop" not in data
+ assert data["PreToolUse"][0]["matcher"] == "exec"
+
+
+class TestCopilotAgentStop:
+ """U3: Copilot maps the canonical stop lifecycle to native agentStop."""
+
+ def test_copilot_stop_mapping(self):
+ integration = CopilotIntegration()
+ assert integration.CANONICAL_TO_NATIVE.get("stop") == "agentStop"
+
+
+# -- Shell quoting & matcher escaping (R2, R4) -------------------------------
+
+class TestDispatcherCommandQuoting:
+ """R2: dispatcher command components are shell-quoted so spaces and shell
+ metacharacters are passed as single arguments, not reinterpreted."""
+
+ def test_command_metacharacters_are_quoted_posix(self, tmp_path):
+ from specify_cli.events import _dispatcher_command
+
+ cmd = _dispatcher_command(
+ ClaudeIntegration(), tmp_path, "speckit.x; rm -rf /", "pre_tool_use",
+ target_os="posix",
+ )
+ # The metacharacter-bearing command is single-quoted as one argument.
+ assert "'speckit.x; rm -rf /'" in cmd
+
+ def test_interpreter_with_space_is_quoted_posix(self, tmp_path):
+ import shlex
+ from specify_cli.events import _dispatcher_command
+ # Simulate a venv interpreter under a path with spaces.
+ venv = tmp_path / ".venv" / "bin" / "python"
+ venv.parent.mkdir(parents=True)
+ venv.write_text("#!/bin/sh\n")
+ proj = tmp_path
+ cmd = _dispatcher_command(
+ ClaudeIntegration(), proj, "speckit.x.y", "stop", target_os="host",
+ )
+ # The command must tokenize back into interpreter + dispatcher + 2 args.
+ tokens = shlex.split(cmd)
+ # dispatcher token carries the ${CLAUDE_PROJECT_DIR} prefix (double-
+ # quoted in the raw string, but shlex.split strips the quotes).
+ assert any("events.py" in t for t in tokens)
+ assert "speckit.x.y" in tokens
+ assert "stop" in tokens
+
+ def test_windows_target_uses_powershell_quoting(self, tmp_path):
+ from specify_cli.events import _dispatcher_command
+
+ cmd = _dispatcher_command(
+ CopilotIntegration(), tmp_path, "speckit.x.y", "session_start",
+ target_os="windows",
+ )
+ # PowerShell single-quoted literals, and the & call operator so the
+ # quoted interpreter is actually invoked (C1).
+ assert cmd.startswith("& ")
+ assert "'speckit.x.y'" in cmd
+ assert "'session_start'" in cmd
+
+ def test_host_target_never_emits_powershell_quotes(self, tmp_path):
+ """C1: the host target uses POSIX quoting on every platform so a
+ single-command-string hook (Claude/Gemini/etc.) stays invocable ā
+ never 'python' (which PowerShell wouldn't invoke without &)."""
+ from specify_cli.events import _shell_quote
+ # Safe tokens pass through bare under host (POSIX), not PS-quoted.
+ assert _shell_quote("python3", "host") == "python3"
+ assert _shell_quote("speckit.x.y", "host") == "speckit.x.y"
+
+ def test_claude_dispatcher_double_quoted_for_spaces(self, tmp_path):
+ """C2: Claude's ${CLAUDE_PROJECT_DIR} dispatcher path is double-quoted
+ so a project path containing spaces doesn't word-split."""
+ from specify_cli.events import _dispatcher_command
+ cmd = _dispatcher_command(
+ ClaudeIntegration(), tmp_path, "speckit.x.y", "stop", target_os="host",
+ )
+ assert '"${CLAUDE_PROJECT_DIR}/.specify/events.py"' in cmd
+
+
+class TestTomlMatcherEscaping:
+ """R4: the TOML matcher is escaped like command, not raw-interpolated."""
+
+ def test_matcher_with_quote_stays_valid_toml(self, tmp_path):
+ from specify_cli.integrations.codex import CodexIntegration
+
+ integration = CodexIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+ manifest.remove = MagicMock()
+
+ # A matcher containing a double quote would break a raw TOML basic
+ # string; it must be escaped.
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"pre_tool_use": [{"command": "speckit.x.y", "matcher": 'Ba"sh'}]},
+ )
+ content = (tmp_path / ".codex" / "config.toml").read_text()
+ # Round-trips through a TOML parser without error.
+ try:
+ import tomllib
+ parsed = tomllib.loads(content)
+ except ModuleNotFoundError:
+ import tomli as tomllib # type: ignore
+ parsed = tomllib.loads(content)
+ # The matcher value survived intact.
+ group = parsed["hooks"]["PreToolUse"][0]
+ assert group["matcher"] == 'Ba"sh'
+
+
+# -- Opencode TS Plugin merging ---------------------------------------------
+
+class TestOpencodePluginMerging:
+ """Test Opencode typescript plugin generation."""
+
+ def test_opencode_ts_plugin_generation(self, tmp_path):
+ integration = OpencodeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ events = {
+ "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit"}],
+ "session_start": [{"command": "speckit.agent-context.update"}],
+ }
+ install_integration_events(integration, tmp_path, manifest, events)
+
+ plugin_path = tmp_path / ".opencode/plugin/speckit-events.ts"
+ assert plugin_path.is_file()
+ content = plugin_path.read_text()
+ assert "runEvent" in content
+ assert "tool.execute.before" in content
+ assert "session.created" in content
+ assert "speckit.tdd.validate" in content
+ assert "speckit.agent-context.update" in content
+ # #13: failures must propagate via throw, not process.exit(2) which
+ # would kill the OpenCode host process.
+ assert "process.exit(2)" not in content
+ assert "throw new Error" in content
+
+ def test_opencode_ts_plugin_resolves_interpreter_and_directory_at_load(self, tmp_path):
+ """C8/C9: the dispatcher + interpreter are resolved per-project at
+ plugin load from the `directory` OpenCode passes (not process.cwd()),
+ preferring a project venv, and the dispatcher is launched via
+ execFileSync (argv, no shell)."""
+ integration = OpencodeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ # Create a project venv so the plugin's runtime resolver prefers it.
+ venv_bin = tmp_path / ".venv" / "bin" / "python"
+ venv_bin.parent.mkdir(parents=True)
+ venv_bin.write_text("#!/bin/sh\n")
+
+ events = {"session_start": [{"command": "speckit.boot"}]}
+ install_integration_events(integration, tmp_path, manifest, events)
+ content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text()
+ # Runtime venv-interpreter preference is baked into the resolver.
+ assert ".venv" in content and "python" in content
+ # Dispatcher is resolved from `directory`, not process.cwd() (C8).
+ assert "path.join(process.cwd()" not in content
+ assert "directory" in content
+ # execFileSync (argv, no shell) instead of a shell command string (C9).
+ assert "execFileSync" in content
+ assert "execSync(`" not in content
+ # R2: venv interpreter is probed for specify_cli importability before
+ # selection (an unrelated project venv shouldn't shadow the fallback).
+ assert "canImportSpecifyCli" in content
+ # S2: the PATH fallback is python on Windows (python3 is commonly
+ # absent there), python3 on POSIX.
+ assert "process.platform === 'win32'" in content
+ assert "'python'" in content
+ assert "'python3'" in content
+
+ def test_opencode_ts_plugin_emits_all_handlers(self, tmp_path):
+ """#2: multiple handlers on the same native event all invoke runEvent."""
+ integration = OpencodeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ events = {
+ "session_start": [
+ {"command": "speckit.first.boot"},
+ {"command": "speckit.second.boot"},
+ ],
+ }
+ install_integration_events(integration, tmp_path, manifest, events)
+ content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text()
+ assert "speckit.first.boot" in content
+ assert "speckit.second.boot" in content
+ # Suppressed #6: each handler call is wrapped in try/catch and errors aggregated.
+ assert "try {" in content
+ assert "errors.push(" in content
+ assert "throw new Error(errors.join" in content
+
+ def test_opencode_ts_plugin_forwards_output(self, tmp_path):
+ """C7: tool callbacks forward both input and output to runEvent so
+ pre_tool_use can inspect tool args and post_tool_use the result."""
+ integration = OpencodeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ events = {
+ "pre_tool_use": [{"command": "speckit.tdd.validate", "matcher": "Edit"}],
+ "post_tool_use": [{"command": "speckit.tdd.after"}],
+ }
+ install_integration_events(integration, tmp_path, manifest, events)
+ content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text()
+ # runEvent signature carries both input and output. S1: command/event
+ # are JSON string literals (double-quoted, escaped). S3: the per-
+ # handler timeout (seconds) is threaded as the 5th arg.
+ assert 'runEvent("speckit.tdd.validate", "pre_tool_use", input, output, 60)' in content
+ assert 'runEvent("speckit.tdd.after", "post_tool_use", input, output, 60)' in content
+ # Tool callbacks pass both arguments through.
+ assert "_pre_tool_use(input, output)" in content
+ assert "_post_tool_use(input, output)" in content
+
+ def test_opencode_ts_plugin_escapes_metacharacters(self, tmp_path):
+ """S1: command/matcher values with quotes/backticks are serialized as
+ JSON string literals so they can't break the generated TypeScript or
+ inject code."""
+ integration = OpencodeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+
+ # A command and matcher containing characters that would break a
+ # single-quoted TS literal.
+ events = {
+ "pre_tool_use": [{"command": "speckit.x'y`code", "matcher": "Ed'it"}],
+ }
+ install_integration_events(integration, tmp_path, manifest, events)
+ content = (tmp_path / ".opencode/plugin/speckit-events.ts").read_text()
+ # The value must appear inside a JSON double-quoted literal, not a
+ # single-quoted TS literal (which a quote/backtick would break).
+ assert json.dumps("speckit.x'y`code") in content
+ # The dangerous single-quoted form (runEvent('speckit.x'y...')) ā
+ # where the embedded quote would terminate the literal ā is absent.
+ assert "runEvent('speckit.x" not in content
+ assert json.dumps("ed'it") in content
+
+
+# -- Command runner test (core execution) -----------------------------------
+
+class TestCommandRunner:
+ """Test the core command/script resolution and runner."""
+
+ def test_run_command_not_found(self, tmp_path):
+ code = resolve_and_run_event_command("nonexistent.command", "session_start", "{}", tmp_path)
+ assert code == 0 # no-ops gracefully
+
+ def test_extension_command_resolves_when_file_stem_differs(self, tmp_path):
+ """S8: an extension command whose declared file differs from its
+ command name resolves via the manifest, not a file-stem scan."""
+ from specify_cli.events import _find_command_template
+ from specify_cli.extensions import ExtensionRegistry
+
+ ext_id = "selftest"
+ ext_dir = tmp_path / ".specify" / "extensions" / ext_id
+ cmds_dir = ext_dir / "commands"
+ cmds_dir.mkdir(parents=True)
+ # Command name is speckit.selftest.extension but the file is selftest.md.
+ (ext_dir / "extension.yml").write_text(
+ "schema_version: '1.0'\n"
+ "extension:\n"
+ " id: selftest\n"
+ " name: Selftest\n"
+ " version: 1.0.0\n"
+ " description: test\n"
+ "requires:\n"
+ " speckit_version: '>=0.1'\n"
+ "provides:\n"
+ " commands:\n"
+ " - name: speckit.selftest.extension\n"
+ " file: commands/selftest.md\n",
+ encoding="utf-8",
+ )
+ (cmds_dir / "selftest.md").write_text(
+ "---\ndescription: \"x\"\n---\nBody\n", encoding="utf-8"
+ )
+ ExtensionRegistry(tmp_path / ".specify" / "extensions").add(
+ ext_id, {"enabled": True}
+ )
+
+ template, resolved_ext = _find_command_template(
+ "speckit.selftest.extension", tmp_path
+ )
+ assert template is not None
+ assert template.name == "selftest.md"
+ assert resolved_ext == ext_id
+
+ def test_disabled_extension_command_not_resolved(self, tmp_path):
+ """S1: a disabled extension's command is skipped by
+ _find_command_template (both the manifest loop and the disk-fallback
+ scan), so a stale hook can't execute a disabled extension."""
+ from specify_cli.events import _find_command_template
+ from specify_cli.extensions import ExtensionRegistry
+ import yaml as _yaml
+
+ # Manifest-resolvable path (step 1).
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ cmds_dir = ext_dir / "commands"
+ cmds_dir.mkdir(parents=True)
+ (ext_dir / "extension.yml").write_text(
+ _yaml.dump({
+ "schema_version": "1.0",
+ "extension": {"id": "my-ext", "name": "My Ext", "version": "1.0.0",
+ "description": "test"},
+ "requires": {"speckit_version": ">=0.1"},
+ "provides": {"commands": [{"name": "speckit.my-ext.boot",
+ "file": "commands/boot.md"}]},
+ "events": {"session_start": {"command": "speckit.my-ext.boot"}},
+ }),
+ encoding="utf-8",
+ )
+ (cmds_dir / "boot.md").write_text("---\ndescription: \"x\"\n---\nBody\n", encoding="utf-8")
+ ExtensionRegistry(tmp_path / ".specify" / "extensions").add(
+ "my-ext", {"enabled": False}
+ )
+ template, _ = _find_command_template("speckit.my-ext.boot", tmp_path)
+ assert template is None, "Disabled extension's command was resolved (manifest loop)."
+
+ # Disk-fallback path (step 2): stem == command name.
+ (cmds_dir / "speckit.my-ext.boot.md").write_text("---\ndescription: \"x\"\n---\nBody\n", encoding="utf-8")
+ template, _ = _find_command_template("speckit.my-ext.boot", tmp_path)
+ assert template is None, "Disabled extension's command was resolved (disk fallback)."
+
+ def test_run_command_resolves_and_executes(self, tmp_path):
+ # Create a mock core command md file
+ cmd_dir = tmp_path / ".specify" / "templates" / "commands"
+ cmd_dir.mkdir(parents=True)
+ cmd_file = cmd_dir / "test.md"
+ cmd_file.write_text(
+ "---\n"
+ "description: \"Test\"\n"
+ "scripts:\n"
+ " sh: scripts/test.sh\n"
+ "---\n"
+ "Body\n",
+ encoding="utf-8",
+ )
+
+ script_dir = tmp_path / ".specify" / "scripts"
+ script_dir.mkdir(parents=True)
+ script_file = script_dir / "test.sh"
+ script_file.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
+ script_file.chmod(0o755)
+
+ # Skip on Windows because sh is POSIX
+ if platform.system().lower().startswith("win"):
+ return
+
+ code = resolve_and_run_event_command("speckit.test", "session_start", "{}", tmp_path)
+ assert code == 0
+
+ def test_py_variant_anchored_under_specify(self, tmp_path):
+ """S2: the py variant resolves scripts/... under .specify/, not the
+ project root, and prepends the resolved interpreter."""
+ from specify_cli.events import _resolve_event_command_argv
+
+ cmd_dir = tmp_path / ".specify" / "templates" / "commands"
+ cmd_dir.mkdir(parents=True)
+ (cmd_dir / "boot.md").write_text(
+ "---\n"
+ "description: \"Boot\"\n"
+ "scripts:\n"
+ " py: scripts/python/boot.py\n"
+ "---\nBody\n",
+ encoding="utf-8",
+ )
+ py_dir = tmp_path / ".specify" / "scripts" / "python"
+ py_dir.mkdir(parents=True)
+ (py_dir / "boot.py").write_text("import sys; sys.exit(0)\n", encoding="utf-8")
+
+ argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
+ assert argv is not None
+ # Interpreter first, then the .specify-anchored script path. Compare in
+ # POSIX form so the assertion holds on Windows (backslash paths) too.
+ assert len(argv) >= 2
+ assert PurePath(argv[1]).as_posix().endswith(".specify/scripts/python/boot.py")
+ assert ".specify" in argv[1]
+
+ def test_ps_variant_prefixed_with_powershell_launcher(self, tmp_path):
+ """S6: the ps variant prefixes argv with pwsh/powershell -File so
+ subprocess.run(shell=False) can execute the .ps1 script."""
+ from specify_cli.events import _resolve_event_command_argv
+
+ cmd_dir = tmp_path / ".specify" / "templates" / "commands"
+ cmd_dir.mkdir(parents=True)
+ (cmd_dir / "boot.md").write_text(
+ "---\n"
+ "description: \"Boot\"\n"
+ "scripts:\n"
+ " ps: scripts/powershell/boot.ps1\n"
+ "---\nBody\n",
+ encoding="utf-8",
+ )
+ ps_dir = tmp_path / ".specify" / "scripts" / "powershell"
+ ps_dir.mkdir(parents=True)
+ (ps_dir / "boot.ps1").write_text("exit 0\n", encoding="utf-8")
+
+ argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
+ assert argv is not None
+ # Launcher (pwsh or powershell), -File, then the .specify-anchored
+ # script. shutil.which may return a full path with an .EXE suffix on
+ # Windows, so match by stem (case-insensitive).
+ assert PurePath(argv[0]).stem.lower() in ("pwsh", "powershell")
+ assert argv[1] == "-File"
+ assert PurePath(argv[2]).as_posix().endswith(".specify/scripts/powershell/boot.ps1")
+
+ def test_run_command_executes_with_project_root_cwd(self, tmp_path):
+ """R1: the event command runs with cwd set to the project root, not the
+ caller's arbitrary working directory, so project-relative script logic
+ resolves correctly even when the agent fires the hook elsewhere."""
+ if platform.system().lower().startswith("win"):
+ return
+ cmd_dir = tmp_path / ".specify" / "templates" / "commands"
+ cmd_dir.mkdir(parents=True)
+ (cmd_dir / "cwd.md").write_text(
+ "---\ndescription: \"cwd\"\nscripts:\n sh: scripts/cwd.sh\n---\nBody\n",
+ encoding="utf-8",
+ )
+ script_dir = tmp_path / ".specify" / "scripts"
+ script_dir.mkdir(parents=True)
+ out_file = tmp_path / "cwd.out"
+ script = script_dir / "cwd.sh"
+ # The script records its working directory.
+ script.write_text(f"#!/bin/sh\npwd > {shlex.quote(str(out_file))}\nexit 0\n", encoding="utf-8")
+ script.chmod(0o755)
+
+ # Invoke from a different working directory to prove cwd is forced.
+ import os as _os
+ prev = _os.getcwd()
+ subdir = tmp_path / "sub"
+ subdir.mkdir()
+ try:
+ _os.chdir(subdir)
+ code = resolve_and_run_event_command("speckit.cwd", "session_start", "{}", tmp_path)
+ finally:
+ _os.chdir(prev)
+ assert code == 0
+ recorded = out_file.read_text().strip()
+ assert Path(recorded).resolve() == tmp_path.resolve()
+
+ def test_dispatcher_is_self_contained(self, tmp_path):
+ """R1: the generated dispatcher prefers `import specify_cli` (durable
+ install) and falls back to an inline stdlib resolver so it works
+ without a persistent `specify` executable (e.g. one-time uvx)."""
+ integration = ClaudeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ content = (tmp_path / EVENTS_DISPATCHER_REL).read_text()
+ # Delegates to specify_cli when importable.
+ assert "from specify_cli.events import resolve_and_run_event_command" in content
+ assert "except ImportError" in content
+ # Inline stdlib fallback resolver for one-time/temporary installs.
+ assert "_run_inline" in content
+ assert "_find_command_template" in content
+ # No dependency on a persistent `specify` executable.
+ assert '["specify"]' not in content
+
+ def test_dispatcher_inline_fallback_runs_script(self, tmp_path):
+ """R1: with specify_cli.events NOT importable, the inline resolver
+ finds the command template and runs its script (stdlib only)."""
+ import subprocess as _sp
+ import sys as _sys
+
+ # Install events (generates the dispatcher + native config).
+ integration = ClaudeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"session_start": [{"command": "speckit.boot"}]},
+ )
+ dispatcher = tmp_path / EVENTS_DISPATCHER_REL
+ assert dispatcher.is_file()
+
+ # Create a core command template whose script writes its payload.
+ cmd_dir = tmp_path / ".specify" / "templates" / "commands"
+ cmd_dir.mkdir(parents=True)
+ out_file = tmp_path / "payload.out"
+ (cmd_dir / "boot.md").write_text(
+ "---\ndescription: \"Boot\"\nscripts:\n sh: scripts/boot.sh\n---\nBody\n",
+ encoding="utf-8",
+ )
+ script_dir = tmp_path / ".specify" / "scripts"
+ script_dir.mkdir(parents=True)
+ script = script_dir / "boot.sh"
+ script.write_text(f"#!/bin/sh\ncat > {shlex.quote(str(out_file))}\nexit 0\n", encoding="utf-8")
+ script.chmod(0o755)
+
+ if platform.system().lower().startswith("win"):
+ return # sh is POSIX
+
+ # Force the inline fallback: shadow `specify_cli` with an empty package
+ # (no `events` submodule) so `from specify_cli.events import ...` raises
+ # ModuleNotFoundError (an ImportError subclass), simulating a one-time
+ # install where the package is unavailable at runtime.
+ fake_dir = tmp_path / "_fake"
+ (fake_dir / "specify_cli").mkdir(parents=True)
+ (fake_dir / "specify_cli" / "__init__.py").write_text("", encoding="utf-8")
+ env = dict(os.environ)
+ env["PYTHONPATH"] = str(fake_dir)
+ result = _sp.run(
+ [_sys.executable, str(dispatcher), "speckit.boot", "session_start", "60"],
+ input='{"tool_name":"x"}',
+ capture_output=True,
+ text=True,
+ env=env,
+ cwd=str(tmp_path),
+ )
+ # The inline resolver ran the script with the payload.
+ assert out_file.exists(), f"inline fallback did not run script; stderr={result.stderr!r} rc={result.returncode}"
+ assert out_file.read_text() == '{"tool_name":"x"}'
+
+ def test_dispatcher_threads_per_handler_timeout(self, tmp_path):
+ """S4: the generated dispatcher reads an optional 4th timeout arg and
+ uses it for the inner subprocess, instead of a fixed 120s cap that
+ would kill a handler configured for longer."""
+ integration = ClaudeIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"pre_tool_use": [{"command": "speckit.tdd.validate", "timeout": 300}]},
+ )
+ content = (tmp_path / EVENTS_DISPATCHER_REL).read_text()
+ # The dispatcher accepts a 4th argv element as the timeout.
+ assert "sys.argv[3]" in content
+ assert "timeout=timeout" in content
+
+ def test_native_command_carries_resolved_timeout(self, tmp_path):
+ """S4: the generated native hook command appends the resolved timeout
+ so the dispatcher receives it. Claude uses seconds (default unit)."""
+ from specify_cli.events import _dispatcher_command
+ cmd = _dispatcher_command(
+ ClaudeIntegration(), tmp_path, "speckit.x.y", "stop",
+ timeout_seconds=300,
+ )
+ # R2: the raw seconds (no conversion, no buffer) are appended as the
+ # 4th arg; the buffer goes on the native hook timeout field instead.
+ assert " 300" in cmd
+
+ def test_dispatcher_timeout_not_unit_converted_for_ms_adapters(self, tmp_path):
+ """R2: the dispatcher arg is always seconds ā for Gemini/Qwen/Tabnine
+ (ms adapters) the timeout must NOT be converted to milliseconds
+ (which previously yielded 60000 seconds)."""
+ from specify_cli.events import _dispatcher_command
+ from specify_cli.integrations.gemini import GeminiIntegration
+ cmd = _dispatcher_command(
+ GeminiIntegration(), tmp_path, "speckit.x.y", "pre_tool_use",
+ timeout_seconds=60,
+ )
+ # 60 seconds (not 60000) is passed to the dispatcher.
+ assert " 60" in cmd
+ assert " 60000" not in cmd
+
+ def test_sh_variant_uses_launcher_on_windows(self, tmp_path):
+ """S5: on Windows the sh variant prefixes a bash/sh launcher so
+ subprocess.run(shell=False) can execute the .sh script."""
+ from specify_cli.events import _resolve_event_command_argv
+
+ cmd_dir = tmp_path / ".specify" / "templates" / "commands"
+ cmd_dir.mkdir(parents=True)
+ (cmd_dir / "boot.md").write_text(
+ "---\ndescription: \"Boot\"\nscripts:\n sh: scripts/bash/boot.sh\n---\nBody\n",
+ encoding="utf-8",
+ )
+ sh_dir = tmp_path / ".specify" / "scripts" / "bash"
+ sh_dir.mkdir(parents=True)
+ (sh_dir / "boot.sh").write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
+
+ argv = _resolve_event_command_argv(cmd_dir / "boot.md", tmp_path, None)
+ assert argv is not None
+ # On POSIX the script runs directly; on Windows a launcher prefixes it.
+ if platform.system().lower().startswith("win"):
+ assert PurePath(argv[0]).stem.lower() in ("bash", "sh")
+ assert PurePath(argv[1]).as_posix().endswith(".specify/scripts/bash/boot.sh")
+ else:
+ assert PurePath(argv[0]).as_posix().endswith(".specify/scripts/bash/boot.sh")
+
+
+# -- Merge/teardown idempotency & safety (Tier 3) ----------------------------
+
+def _claude_manifest(tmp_path):
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+ manifest.remove = MagicMock()
+ return manifest
+
+
+class TestMergeIdempotency:
+ """#9/#11: marker recursion and full-clean-before-add."""
+
+ def test_upgrade_does_not_duplicate_nested_hooks(self, tmp_path):
+ """#9: re-running install replaces prior Specify inner hooks instead of
+ appending a second matcher-group on every upgrade."""
+ integration = ClaudeIntegration()
+ config_path = tmp_path / ".claude/settings.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+
+ events = {"pre_tool_use": [{"command": "speckit.tdd.validate"}]}
+ for _ in range(2):
+ manifest = _claude_manifest(tmp_path)
+ install_integration_events(integration, tmp_path, manifest, events)
+
+ data = json.loads(config_path.read_text())
+ groups = data["hooks"]["PreToolUse"]
+ # Exactly one matcher-group for Specify (no duplication).
+ assert len(groups) == 1
+ inner = groups[0]["hooks"]
+ assert len(inner) == 1
+ assert "speckit.tdd.validate" in inner[0]["command"]
+
+ def test_override_change_removes_stale_event(self, tmp_path):
+ """#11: when the resolved set changes from pre_tool_use to stop, the
+ old marked pre_tool_use entry is removed, not left active."""
+ integration = ClaudeIntegration()
+ config_path = tmp_path / ".claude/settings.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+
+ install_integration_events(
+ integration, tmp_path, _claude_manifest(tmp_path),
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ install_integration_events(
+ integration, tmp_path, _claude_manifest(tmp_path),
+ {"stop": [{"command": "speckit.end"}]},
+ )
+
+ data = json.loads(config_path.read_text())
+ assert "PreToolUse" not in data["hooks"]
+ assert "Stop" in data["hooks"]
+
+
+class TestEmptyMapRemoval:
+ """#3: --events false / empty resolved map strips prior hooks."""
+
+ def test_empty_events_removes_prior_hooks(self, tmp_path):
+ integration = ClaudeIntegration()
+ config_path = tmp_path / ".claude/settings.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+
+ install_integration_events(
+ integration, tmp_path, _claude_manifest(tmp_path),
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ assert config_path.is_file()
+
+ # Now resolve to empty (--events false): prior hooks must be removed.
+ install_integration_events(integration, tmp_path, _claude_manifest(tmp_path), {})
+
+ # The dispatcher is shared and left in place (#10); only native hooks
+ # are stripped. The settings file had no user content ā deleted (#14).
+ assert not config_path.exists() or "hooks" not in json.loads(config_path.read_text())
+
+
+class TestTeardownDataSafety:
+ """#14/#22/#23: preserve user content, delete Spec-Kit-created empties."""
+
+ def test_remove_deletes_spec_kit_created_config(self, tmp_path):
+ """#14: a config Spec Kit created from scratch is deleted (not left as
+ ``{}``) so manifest.uninstall() doesn't preserve an empty stub."""
+ integration = ClaudeIntegration()
+ manifest = _claude_manifest(tmp_path)
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ config_path = tmp_path / ".claude/settings.json"
+ assert config_path.is_file()
+
+ remove_integration_events(integration, tmp_path, manifest)
+ assert not config_path.exists()
+
+ def test_remove_preserves_user_content_in_config(self, tmp_path):
+ """#14: a pre-existing config with user content is kept (user hooks
+ survive teardown)."""
+ integration = ClaudeIntegration()
+ config_path = tmp_path / ".claude/settings.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ config_path.write_text(json.dumps({
+ "hooks": {
+ "PreToolUse": [{
+ "matcher": "Bash",
+ "hooks": [{"type": "command", "command": "user-check"}],
+ }]
+ },
+ "userSetting": True,
+ }))
+
+ manifest = _claude_manifest(tmp_path)
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"stop": [{"command": "speckit.end"}]},
+ )
+ remove_integration_events(integration, tmp_path, manifest)
+
+ data = json.loads(config_path.read_text())
+ # User hook and setting preserved; Specify hook gone.
+ assert data["userSetting"] is True
+ assert "Stop" not in data.get("hooks", {})
+ assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash"
+
+ def test_forced_full_teardown_preserves_user_config(self, tmp_path):
+ """S9: a full teardown(force=True) ā which runs manifest.uninstall(
+ force=True) after remove_events ā must not delete a pre-existing user
+ settings file whose owned entries were cleaned but user content kept.
+ Uses a real manifest to exercise the uninstall path."""
+ from specify_cli.integrations.manifest import IntegrationManifest
+
+ integration = ClaudeIntegration()
+ config_path = tmp_path / ".claude/settings.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ config_path.write_text(json.dumps({
+ "hooks": {
+ "PreToolUse": [{
+ "matcher": "Bash",
+ "hooks": [{"type": "command", "command": "user-check"}],
+ }]
+ },
+ "userSetting": True,
+ }))
+
+ manifest = IntegrationManifest(integration.key, tmp_path, version="test")
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"stop": [{"command": "speckit.end"}]},
+ )
+ manifest.save()
+
+ # Full teardown: remove_events + manifest.uninstall(force=True).
+ integration.teardown(tmp_path, manifest, force=True)
+
+ assert config_path.exists(), (
+ "Forced teardown deleted the user's settings file (S9)."
+ )
+ data = json.loads(config_path.read_text())
+ assert data["userSetting"] is True
+ assert data["hooks"]["PreToolUse"][0]["matcher"] == "Bash"
+
+ def test_jsonc_config_not_reset_on_merge(self, tmp_path):
+ """#22: a JSONC/unparseable native config is left untouched on merge."""
+ integration = ClaudeIntegration()
+ config_path = tmp_path / ".claude/settings.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ jsonc = '{\n // my comment\n "hooks": {}\n}\n'
+ config_path.write_text(jsonc)
+
+ install_integration_events(
+ integration, tmp_path, _claude_manifest(tmp_path),
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ # User content preserved verbatim ā not reset to {}.
+ assert config_path.read_text() == jsonc
+
+ def test_jsonc_opencode_config_not_reset(self, tmp_path):
+ """#23: a malformed opencode.json is preserved, not reset to {}."""
+ integration = OpencodeIntegration()
+ config_path = tmp_path / "opencode.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ malformed = "{ not valid json"
+ config_path.write_text(malformed)
+
+ install_integration_events(
+ integration, tmp_path, _claude_manifest(tmp_path),
+ {"session_start": [{"command": "speckit.boot"}]},
+ )
+ assert config_path.read_text() == malformed
+
+
+class TestCopilotMergeTeardown:
+ """#8: Copilot dedicated hooks JSON merges owned entries / teardown
+ removes only owned entries."""
+
+ def test_copilot_merge_preserves_user_hooks(self, tmp_path):
+ integration = CopilotIntegration()
+ config_path = tmp_path / ".github/hooks/speckit.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ config_path.write_text(json.dumps({
+ "version": 1,
+ "hooks": {
+ "sessionStart": [{"type": "command", "bash": "user-hook"}],
+ },
+ }))
+
+ install_integration_events(
+ integration, tmp_path, _claude_manifest(tmp_path),
+ {"session_start": [{"command": "speckit.boot"}]},
+ )
+ data = json.loads(config_path.read_text())
+ entries = data["hooks"]["sessionStart"]
+ bash_cmds = [e.get("bash") for e in entries]
+ assert "user-hook" in bash_cmds
+ assert any("speckit.boot" in c for c in bash_cmds)
+
+ def test_copilot_teardown_removes_only_owned_entries(self, tmp_path):
+ integration = CopilotIntegration()
+ config_path = tmp_path / ".github/hooks/speckit.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ config_path.write_text(json.dumps({
+ "version": 1,
+ "hooks": {
+ "sessionStart": [{"type": "command", "bash": "user-hook"}],
+ },
+ }))
+
+ manifest = _claude_manifest(tmp_path)
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"session_start": [{"command": "speckit.boot"}]},
+ )
+ remove_integration_events(integration, tmp_path, manifest)
+
+ data = json.loads(config_path.read_text())
+ # User hook preserved; Spec-Kit entry gone.
+ assert data["hooks"]["sessionStart"][0]["bash"] == "user-hook"
+
+ def test_copilot_teardown_deletes_spec_kit_only_file(self, tmp_path):
+ """#8/#14: when the file held only Spec-Kit entries, teardown deletes it."""
+ integration = CopilotIntegration()
+ manifest = _claude_manifest(tmp_path)
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"session_start": [{"command": "speckit.boot"}]},
+ )
+ config_path = tmp_path / ".github/hooks/speckit.json"
+ assert config_path.is_file()
+ remove_integration_events(integration, tmp_path, manifest)
+ assert not config_path.exists()
+
+
+class TestSharedDispatcherRefcount:
+ """#10: the shared .specify/events.py dispatcher is not deleted while
+ another installed event-capable integration still references it."""
+
+ def test_dispatcher_kept_when_other_integration_references_it(self, tmp_path):
+ # Simulate two event-capable integrations installed: claude (the one
+ # being uninstalled) and codex (still installed). The codex manifest
+ # lists the dispatcher, so removing claude must not delete it.
+ from specify_cli.integrations.codex import CodexIntegration
+
+ claude = ClaudeIntegration()
+ codex = CodexIntegration()
+
+ # Install claude's events (writes dispatcher + claude config).
+ claude_manifest = _claude_manifest(tmp_path)
+ install_integration_events(
+ claude, tmp_path, claude_manifest,
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ # Install codex's events (re-writes shared dispatcher + codex config).
+ codex_manifest = MagicMock(spec=IntegrationManifest)
+ codex_manifest.files = {}
+ codex_manifest.record_file = MagicMock()
+ codex_manifest.record_existing = MagicMock()
+ codex_manifest.remove = MagicMock()
+ install_integration_events(
+ codex, tmp_path, codex_manifest,
+ {"pre_tool_use": [{"command": "speckit.codex.check"}]},
+ )
+
+ # Persist a codex manifest on disk so the refcount check finds it.
+ codex_disk = IntegrationManifest(codex.key, tmp_path, version="test")
+ codex_disk._files = {EVENTS_DISPATCHER_REL: "x"}
+ codex_disk.save()
+
+ # Write the integration-state JSON so installed_integration_keys sees codex.
+ import json as _json
+ state_path = tmp_path / ".specify" / "integration.json"
+ state_path.parent.mkdir(parents=True, exist_ok=True)
+ state_path.write_text(_json.dumps({
+ "default_integration": "claude",
+ "installed_integrations": ["claude", "codex"],
+ }))
+
+ dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL
+ assert dispatcher_path.exists()
+
+ # Removing claude should leave the dispatcher (codex still uses it).
+ remove_integration_events(claude, tmp_path, claude_manifest)
+ assert dispatcher_path.exists()
+
+
+class TestSafeWriteDestination:
+ """#12: write targets are validated before any bytes are written."""
+
+ def test_symlinked_config_dir_rejected(self, tmp_path):
+ integration = ClaudeIntegration()
+ # Create a symlinked .claude directory pointing outside the project.
+ outside = tmp_path / "outside"
+ outside.mkdir()
+ linked = tmp_path / ".claude"
+ os.symlink(outside, linked)
+
+ with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"):
+ install_integration_events(
+ integration, tmp_path, _claude_manifest(tmp_path),
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ # No content written through the symlink.
+ assert not (outside / "settings.json").exists()
+
+ def test_toml_teardown_rejects_symlinked_config(self, tmp_path):
+ """R3: TOML teardown validates the destination before read/write, so a
+ symlink swap after install can't make uninstall overwrite an external
+ file."""
+ from specify_cli.integrations.codex import CodexIntegration
+
+ integration = CodexIntegration()
+ manifest = _claude_manifest(tmp_path)
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ config_path = tmp_path / ".codex" / "config.toml"
+ assert config_path.is_file()
+
+ # Swap the config for a symlink pointing outside the project.
+ outside = tmp_path / "outside.toml"
+ outside.write_text("external = true\n")
+ config_path.unlink()
+ os.symlink(outside, config_path)
+
+ with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"):
+ remove_integration_events(integration, tmp_path, manifest)
+ # External file untouched.
+ assert outside.read_text() == "external = true\n"
+
+ def test_json_remover_rejects_symlinked_config(self, tmp_path):
+ """Removers validate destination before reading or unlinking."""
+ integration = ClaudeIntegration()
+ manifest = _claude_manifest(tmp_path)
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ config_path = tmp_path / ".claude" / "settings.json"
+ assert config_path.is_file()
+
+ outside = tmp_path / "outside.json"
+ outside.write_text('{"external": true}')
+ config_path.unlink()
+ os.symlink(outside, config_path)
+
+ with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"):
+ remove_integration_events(integration, tmp_path, manifest)
+ assert outside.read_text() == '{"external": true}'
+
+ def test_plugin_remover_rejects_symlinked_plugin(self, tmp_path):
+ """OpenCode plugin cleanup validates destination before unlinking."""
+ integration = OpencodeIntegration()
+ manifest = _claude_manifest(tmp_path)
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"session_start": [{"command": "speckit.boot"}]},
+ )
+ plugin_path = tmp_path / ".opencode" / "plugin" / "speckit-events.ts"
+ assert plugin_path.is_file()
+
+ outside = tmp_path / "outside.ts"
+ outside.write_text("// external")
+ plugin_path.unlink()
+ os.symlink(outside, plugin_path)
+
+ manifest.files[".opencode/plugin/speckit-events.ts"] = "hash"
+ with pytest.raises(ValueError, match="(?i)symlink|escapes|outside"):
+ remove_integration_events(integration, tmp_path, manifest)
+ assert outside.read_text() == "// external"
+
+
+# -- Validation & lifecycle (Tier 4) -----------------------------------------
+
+class TestValidateEventsCommandType:
+ """#17: command must be a non-empty string, not just truthy."""
+
+ def test_non_string_command_rejected(self):
+ from specify_cli.extensions import ValidationError
+ data = {"events": {"pre_tool_use": {"command": ["speckit.tdd.validate"]}}}
+ with pytest.raises(ValidationError, match="(?i)command.*string"):
+ validate_events(data)
+
+ def test_empty_string_command_rejected(self):
+ from specify_cli.extensions import ValidationError
+ data = {"events": {"pre_tool_use": {"command": " "}}}
+ with pytest.raises(ValidationError, match="(?i)command.*string"):
+ validate_events(data)
+
+
+class TestCollectExtensionEventsEnabledFlag:
+ """#1: collect_extension_events honors the registry's enabled flag."""
+
+ def test_disabled_extension_events_skipped(self, tmp_path):
+ from specify_cli.extensions import ExtensionRegistry
+
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ ext_dir.mkdir(parents=True)
+ (ext_dir / "extension.yml").write_text(
+ "extension:\n id: my-ext\n name: My Ext\n version: 1.0.0\n"
+ " description: test\n"
+ "schema_version: '1.0'\n"
+ "requires:\n speckit_version: '>=0.1'\n"
+ "provides:\n commands: []\n"
+ "events:\n session_start:\n command: speckit.my-ext.boot\n",
+ encoding="utf-8",
+ )
+ registry = ExtensionRegistry(tmp_path / ".specify" / "extensions")
+ registry.add("my-ext", {"enabled": False})
+
+ result = collect_extension_events(tmp_path)
+ assert result == {}
+
+ def test_enabled_extension_events_collected(self, tmp_path):
+ from specify_cli.extensions import ExtensionRegistry
+
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ ext_dir.mkdir(parents=True)
+ (ext_dir / "extension.yml").write_text(
+ "extension:\n id: my-ext\n name: My Ext\n version: 1.0.0\n"
+ " description: test\n"
+ "schema_version: '1.0'\n"
+ "requires:\n speckit_version: '>=0.1'\n"
+ "provides:\n commands: []\n"
+ "events:\n session_start:\n command: speckit.my-ext.boot\n",
+ encoding="utf-8",
+ )
+ registry = ExtensionRegistry(tmp_path / ".specify" / "extensions")
+ registry.add("my-ext", {"enabled": True})
+
+ result = collect_extension_events(tmp_path)
+ assert result == {"session_start": [{"command": "speckit.my-ext.boot"}]}
+
+
+class TestRefreshIntegrationEvents:
+ """#1: refresh_integration_events regenerates native config after
+ extension state changes."""
+
+ def test_refresh_strips_removed_extension_events(self, tmp_path):
+ from specify_cli.events import refresh_integration_events
+ from specify_cli.integrations.manifest import IntegrationManifest
+
+ # Install claude with an event sourced from a (simulated) extension.
+ integration = ClaudeIntegration()
+ manifest = IntegrationManifest(integration.key, tmp_path, version="test")
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"pre_tool_use": [{"command": "speckit.my-ext.check"}]},
+ )
+ manifest.save()
+ config_path = tmp_path / ".claude/settings.json"
+ assert config_path.is_file()
+
+ # Record claude as installed so refresh finds it.
+ state_path = tmp_path / ".specify" / "integration.json"
+ state_path.parent.mkdir(parents=True, exist_ok=True)
+ state_path.write_text(json.dumps({
+ "default_integration": "claude",
+ "installed_integrations": ["claude"],
+ }))
+ # No extension declares events now ā refresh should strip the prior hook
+ # (the config is deleted when no user content remains, #14).
+ refresh_integration_events(tmp_path)
+
+ if config_path.exists():
+ data = json.loads(config_path.read_text())
+ assert "PreToolUse" not in data.get("hooks", {})
+ # If the file is gone, the hooks were stripped (and the empty config
+ # deleted) ā also correct.
+
+ def test_refresh_emits_newly_declared_extension_events(self, tmp_path):
+ from specify_cli.events import refresh_integration_events
+ from specify_cli.integrations.manifest import IntegrationManifest
+
+ integration = ClaudeIntegration()
+ manifest = IntegrationManifest(integration.key, tmp_path, version="test")
+ # Initially no events.
+ manifest.save()
+ config_path = tmp_path / ".claude/settings.json"
+
+ # Declare an extension event on disk.
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ ext_dir.mkdir(parents=True)
+ (ext_dir / "extension.yml").write_text(
+ "events:\n session_start:\n command: speckit.my-ext.boot\n",
+ encoding="utf-8",
+ )
+ state_path = tmp_path / ".specify" / "integration.json"
+ state_path.parent.mkdir(parents=True, exist_ok=True)
+ state_path.write_text(json.dumps({
+ "default_integration": "claude",
+ "installed_integrations": ["claude"],
+ }))
+
+ refresh_integration_events(tmp_path)
+
+ data = json.loads(config_path.read_text())
+ assert "SessionStart" in data["hooks"]
+
+ def test_refresh_honors_stored_events_false(self, tmp_path):
+ """S7: a stored --events false must be honored across extension
+ lifecycle refresh; passing None would re-enable events."""
+ from specify_cli.events import refresh_integration_events
+ from specify_cli.integrations.manifest import IntegrationManifest
+
+ integration = ClaudeIntegration()
+ manifest = IntegrationManifest(integration.key, tmp_path, version="test")
+ manifest.save()
+ config_path = tmp_path / ".claude/settings.json"
+
+ # Declare an extension event on disk.
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ ext_dir.mkdir(parents=True)
+ (ext_dir / "extension.yml").write_text(
+ "events:\n session_start:\n command: speckit.my-ext.boot\n",
+ encoding="utf-8",
+ )
+ # Store the integration with --events false in parsed_options.
+ state_path = tmp_path / ".specify" / "integration.json"
+ state_path.parent.mkdir(parents=True, exist_ok=True)
+ state_path.write_text(json.dumps({
+ "default_integration": "claude",
+ "installed_integrations": ["claude"],
+ "integration_settings": {
+ "claude": {"parsed_options": {"events": "false"}},
+ },
+ }))
+
+ refresh_integration_events(tmp_path)
+
+ # No hooks should have been re-created (CLI gate honored).
+ if config_path.exists():
+ assert "hooks" not in json.loads(config_path.read_text())
+
+
+# -- Override preserve-layers (#10) ------------------------------------------
+
+class TestOverridePreserveLayers:
+ """#10: an invalid override entry abandons the whole override and keeps
+ the accumulated built-in + extension layers, instead of disabling all
+ hooks."""
+
+ def test_invalid_override_entry_keeps_prior_layers(self, tmp_path):
+ override_file = tmp_path / ".specify" / "integration-events.yml"
+ override_file.parent.mkdir(parents=True, exist_ok=True)
+ # One valid entry, one invalid (non-string command) ā the whole
+ # override is ignored, built-in defaults survive.
+ override_file.write_text(
+ "integrations:\n"
+ " claude:\n"
+ " events:\n"
+ " stop:\n"
+ " command: speckit.valid.stop\n"
+ " pre_tool_use:\n"
+ " command: [not-a-string]\n",
+ encoding="utf-8",
+ )
+ result = resolve_events(
+ "claude",
+ {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
+ tmp_path,
+ None,
+ )
+ # Built-in default survived (override was abandoned on the invalid entry).
+ assert "post_tool_use" in result
+ assert result["post_tool_use"] == [{"command": "speckit.tdd.validate"}]
+
+ def test_explicit_empty_override_disables(self, tmp_path):
+ """A fully-valid explicit `events: {}` override still disables."""
+ override_file = tmp_path / ".specify" / "integration-events.yml"
+ override_file.parent.mkdir(parents=True, exist_ok=True)
+ override_file.write_text(
+ "integrations:\n"
+ " claude:\n"
+ " events: {}\n",
+ encoding="utf-8",
+ )
+ result = resolve_events(
+ "claude",
+ {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
+ tmp_path,
+ None,
+ )
+ assert result == {}
+
+ def test_empty_handler_override_abandons_override(self, tmp_path):
+ """C4: a malformed handler (`stop: []` or `stop: bad-value`) abandons
+ the whole override and keeps prior layers, rather than disabling."""
+ override_file = tmp_path / ".specify" / "integration-events.yml"
+ override_file.parent.mkdir(parents=True, exist_ok=True)
+ override_file.write_text(
+ "integrations:\n"
+ " claude:\n"
+ " events:\n"
+ " stop: []\n",
+ encoding="utf-8",
+ )
+ result = resolve_events(
+ "claude",
+ {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
+ tmp_path,
+ None,
+ )
+ # Built-in default survived (override abandoned on the empty handler).
+ assert "post_tool_use" in result
+
+ def test_non_mapping_integration_entry_abandons_override(self, tmp_path):
+ """C6: a non-mapping integration entry (`claude: bad`) is ignored as
+ malformed, not treated as a valid explicit disable."""
+ override_file = tmp_path / ".specify" / "integration-events.yml"
+ override_file.parent.mkdir(parents=True, exist_ok=True)
+ override_file.write_text(
+ "integrations:\n"
+ " claude: bad\n",
+ encoding="utf-8",
+ )
+ result = resolve_events(
+ "claude",
+ {"events": {"post_tool_use": {"command": "speckit.tdd.validate"}}},
+ tmp_path,
+ None,
+ )
+ # Built-in default survived (override ignored as malformed).
+ assert "post_tool_use" in result
+
+
+# -- Matcher string validation (C10) -----------------------------------------
+
+class TestMatcherValidation:
+ """C10: matcher must be a string; a non-string matcher is rejected at
+ validation time so it can't crash by_matcher.setdefault later."""
+
+ def test_non_string_matcher_rejected_in_manifest(self):
+ from specify_cli.extensions import ValidationError
+ data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "matcher": []}}}
+ with pytest.raises(ValidationError, match="(?i)matcher.*string"):
+ validate_events(data)
+
+ def test_non_string_matcher_rejected_in_override(self, tmp_path):
+ override_file = tmp_path / ".specify" / "integration-events.yml"
+ override_file.parent.mkdir(parents=True, exist_ok=True)
+ # A matcher of [] would crash by_matcher.setdefault; the override must
+ # be abandoned (built-in defaults survive) rather than crash.
+ override_file.write_text(
+ "integrations:\n"
+ " claude:\n"
+ " events:\n"
+ " pre_tool_use:\n"
+ " command: speckit.x.y\n"
+ " matcher: []\n",
+ encoding="utf-8",
+ )
+ result = resolve_events(
+ "claude",
+ {"events": {"stop": {"command": "speckit.end"}}},
+ tmp_path,
+ None,
+ )
+ # Built-in default survived (override abandoned on the bad matcher).
+ assert "stop" in result
+
+
+class TestTimeoutValidation:
+ """Validate that non-integer, boolean, or non-positive timeouts are rejected."""
+
+ def test_non_int_timeout_rejected_in_manifest(self):
+ from specify_cli.extensions import ValidationError
+ data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "timeout": "60"}}}
+ with pytest.raises(ValidationError, match="(?i)timeout.*positive integer"):
+ validate_events(data)
+
+ def test_boolean_timeout_rejected_in_manifest(self):
+ from specify_cli.extensions import ValidationError
+ data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "timeout": True}}}
+ with pytest.raises(ValidationError, match="(?i)timeout.*positive integer"):
+ validate_events(data)
+
+ def test_zero_or_negative_timeout_rejected_in_manifest(self):
+ from specify_cli.extensions import ValidationError
+ data = {"events": {"pre_tool_use": {"command": "speckit.x.y", "timeout": 0}}}
+ with pytest.raises(ValidationError, match="(?i)timeout.*positive integer"):
+ validate_events(data)
+
+
+# -- Event command-ref canonicalization (C11) --------------------------------
+
+class TestEventCommandRefCanonicalization:
+ """C11: an event referencing a command that was auto-corrected is itself
+ rewritten to the canonical name (mirrors hook reference rewriting)."""
+
+ def test_event_command_ref_lifted_to_canonical(self, tmp_path):
+ from specify_cli.extensions import ExtensionManifest
+ import yaml as _yaml
+
+ manifest_path = tmp_path / "extension.yml"
+ manifest_path.write_text(
+ _yaml.dump({
+ "schema_version": "1.0",
+ "extension": {
+ "id": "my-ext",
+ "name": "My Ext",
+ "version": "1.0.0",
+ "description": "test",
+ },
+ "requires": {"speckit_version": ">=0.1"},
+ "provides": {
+ "commands": [
+ {"name": "speckit.my-ext.boot", "file": "commands/boot.md"}
+ ]
+ },
+ "events": {
+ "session_start": {"command": "my-ext.boot"},
+ },
+ }),
+ encoding="utf-8",
+ )
+ manifest = ExtensionManifest(manifest_path)
+ assert manifest.data["events"]["session_start"]["command"] == "speckit.my-ext.boot"
+ assert any(
+ "Event 'session_start' referenced command 'my-ext.boot'" in w
+ for w in manifest.warnings
+ )
+
+
+# -- Skipped-merge not tracked (S5) ------------------------------------------
+
+class TestSkippedMergeNotTracked:
+ """S5: when a merge is skipped on parse failure, the untouched file is
+ not recorded in the manifest, so uninstall() won't later delete it."""
+
+ def test_jsonc_native_config_not_tracked(self, tmp_path):
+ integration = ClaudeIntegration()
+ config_path = tmp_path / ".claude/settings.json"
+ config_path.parent.mkdir(parents=True, exist_ok=True)
+ jsonc = '{\n // my comment\n "hooks": {}\n}\n'
+ config_path.write_text(jsonc)
+
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+ manifest.remove = MagicMock()
+
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ # The JSONC file must NOT have been recorded (would cause uninstall()
+ # to delete it later). Only the dispatcher (which we wrote) is tracked.
+ recorded_rels = [c.args[0] for c in manifest.record_existing.call_args_list]
+ assert str(config_path.relative_to(tmp_path)) not in recorded_rels
+ # User content preserved verbatim.
+ assert config_path.read_text() == jsonc
+
+
+# -- Dispatcher manifest claim dropped on retain (S1) ------------------------
+
+class TestDispatcherManifestClaimDroppedOnRetain:
+ """S1: when the dispatcher is retained (another integration references
+ it), this integration's manifest still drops its claim so the subsequent
+ manifest.uninstall() in teardown() doesn't delete the shared file."""
+
+ def test_full_teardown_keeps_dispatcher_when_other_references_it(self, tmp_path):
+ from specify_cli.integrations.codex import CodexIntegration
+ from specify_cli.integrations.manifest import IntegrationManifest
+
+ claude = ClaudeIntegration()
+ codex = CodexIntegration()
+
+ # Install claude's events (writes dispatcher + claude config).
+ claude_manifest = IntegrationManifest(claude.key, tmp_path, version="test")
+ install_integration_events(
+ claude, tmp_path, claude_manifest,
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ claude_manifest.save()
+
+ # Install codex's events (re-writes shared dispatcher + codex config).
+ codex_manifest = IntegrationManifest(codex.key, tmp_path, version="test")
+ install_integration_events(
+ codex, tmp_path, codex_manifest,
+ {"pre_tool_use": [{"command": "speckit.codex.check"}]},
+ )
+ codex_manifest.save()
+
+ # integration.json: both installed, codex is default.
+ state_path = tmp_path / ".specify" / "integration.json"
+ state_path.parent.mkdir(parents=True, exist_ok=True)
+ state_path.write_text(json.dumps({
+ "default_integration": "codex",
+ "installed_integrations": ["claude", "codex"],
+ }))
+
+ dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL
+ assert dispatcher_path.exists()
+
+ # Full teardown of claude (remove + manifest.uninstall): the dispatcher
+ # must survive because codex's manifest still references it.
+ remove_integration_events(claude, tmp_path, claude_manifest)
+ claude_manifest.uninstall(tmp_path, force=True)
+
+ assert dispatcher_path.exists(), (
+ "Shared dispatcher was deleted by teardown() despite another "
+ "integration referencing it (S1)."
+ )
+
+ def test_empty_map_upgrade_deletes_dispatcher_for_last_integration(self, tmp_path):
+ """S3: an --events false upgrade (empty resolved map) of the last
+ event-capable integration deletes the shared dispatcher instead of
+ orphaning it (the new manifest wouldn't claim it and stale cleanup
+ excludes it)."""
+ from specify_cli.integrations.codex import CodexIntegration
+ from specify_cli.integrations.manifest import IntegrationManifest
+
+ claude = ClaudeIntegration()
+ codex = CodexIntegration()
+ # Install both so the dispatcher is shared.
+ cm = IntegrationManifest(claude.key, tmp_path, version="test")
+ install_integration_events(claude, tmp_path, cm, {"pre_tool_use": [{"command": "speckit.x"}]})
+ cm.save()
+ xm = IntegrationManifest(codex.key, tmp_path, version="test")
+ install_integration_events(codex, tmp_path, xm, {"pre_tool_use": [{"command": "speckit.y"}]})
+ xm.save()
+ state_path = tmp_path / ".specify" / "integration.json"
+ state_path.parent.mkdir(parents=True, exist_ok=True)
+ state_path.write_text(json.dumps({
+ "default_integration": "codex",
+ "installed_integrations": ["claude", "codex"],
+ }))
+ dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL
+ assert dispatcher_path.exists()
+
+ # Codex upgrades to --events false (empty map): claude still references
+ # the dispatcher, so it must be retained.
+ install_integration_events(codex, tmp_path, xm, {})
+ xm.save()
+ assert dispatcher_path.exists(), "Dispatcher deleted while claude still references it."
+
+ # Now claude also goes --events false: no integration references the
+ # dispatcher, so it must be deleted (not orphaned).
+ install_integration_events(claude, tmp_path, cm, {})
+ cm.save()
+ assert not dispatcher_path.exists(), (
+ "Dispatcher orphaned after the last event integration disabled events (S3)."
+ )
+
+ def test_fresh_manifest_upgrade_deletes_dispatcher_when_last(self, tmp_path):
+ """S2: an upgrade passing a *fresh* manifest (that never recorded the
+ dispatcher) still deletes the shared dispatcher when no other
+ integration references it ā the deletion isn't gated on the new
+ manifest's claim."""
+ from specify_cli.integrations.manifest import IntegrationManifest
+
+ claude = ClaudeIntegration()
+ # Install claude with an old manifest that records the dispatcher.
+ old = IntegrationManifest(claude.key, tmp_path, version="test")
+ install_integration_events(claude, tmp_path, old, {"pre_tool_use": [{"command": "speckit.x"}]})
+ old.save()
+ state_path = tmp_path / ".specify" / "integration.json"
+ state_path.parent.mkdir(parents=True, exist_ok=True)
+ state_path.write_text(json.dumps({
+ "default_integration": "claude",
+ "installed_integrations": ["claude"],
+ }))
+ dispatcher_path = tmp_path / EVENTS_DISPATCHER_REL
+ assert dispatcher_path.exists()
+
+ # Simulate the upgrade path: a fresh manifest (like
+ # IntegrationManifest(key, project_root, version=...) in
+ # _migrate_commands) that never recorded the dispatcher.
+ fresh = IntegrationManifest(claude.key, tmp_path, version="test")
+ assert EVENTS_DISPATCHER_REL not in fresh.files
+ install_integration_events(claude, tmp_path, fresh, {})
+
+ # The dispatcher must be deleted (no other integration references it),
+ # not orphaned just because the fresh manifest didn't claim it.
+ assert not dispatcher_path.exists(), (
+ "Dispatcher orphaned after upgrade with a fresh manifest (S2)."
+ )
+
+
+# -- Dispatcher stale-cleanup exclusion (C3) ---------------------------------
+
+class TestDispatcherStaleExclusion:
+ """C3: the shared dispatcher is excluded from the generic upgrade stale
+ pass so an --events false upgrade doesn't delete it and break other
+ installed event-capable integrations."""
+
+ def test_dispatcher_in_stale_exclusions(self):
+ from specify_cli.events import events_stale_exclusions
+ exclusions = events_stale_exclusions("claude")
+ assert EVENTS_DISPATCHER_REL in exclusions
+
+
+# -- Cursor version-only stub deletion (C5) ----------------------------------
+
+class TestCursorVersionOnlyStubDeletion:
+ """C5: a Spec-Kit-created Cursor file retaining only {"version": 1} after
+ all owned hooks are removed is deleted, not left as a generated stub."""
+
+ def test_version_only_cursor_file_deleted_on_teardown(self, tmp_path):
+ integration = CursorAgentIntegration()
+ manifest = MagicMock(spec=IntegrationManifest)
+ manifest.files = {}
+ manifest.record_file = MagicMock()
+ manifest.record_existing = MagicMock()
+ manifest.remove = MagicMock()
+
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"session_start": [{"command": "speckit.boot"}]},
+ )
+ config_path = tmp_path / ".cursor/hooks.json"
+ assert config_path.is_file()
+ assert json.loads(config_path.read_text()).get("version") == 1
+
+ remove_integration_events(integration, tmp_path, manifest)
+ # No user content remained (only the Spec-Kit-managed version field) ā
+ # the file is deleted for a clean teardown, not left as a stub.
+ assert not config_path.exists()
+
+
+# -- Non-destructive refresh (C12) -------------------------------------------
+
+class TestNonDestructiveRefresh:
+ """C12: refresh resolves first then installs once; a failure during install
+ no longer destroys the working native config before the new one is written."""
+
+ def test_refresh_failure_preserves_existing_config(self, tmp_path):
+ from specify_cli.events import refresh_integration_events
+ from specify_cli.integrations.manifest import IntegrationManifest
+
+ integration = ClaudeIntegration()
+ manifest = IntegrationManifest(integration.key, tmp_path, version="test")
+ install_integration_events(
+ integration, tmp_path, manifest,
+ {"pre_tool_use": [{"command": "speckit.tdd.validate"}]},
+ )
+ manifest.save()
+ config_path = tmp_path / ".claude/settings.json"
+ original = config_path.read_text()
+
+ # Declare an extension event so refresh would try to re-emit.
+ ext_dir = tmp_path / ".specify" / "extensions" / "my-ext"
+ ext_dir.mkdir(parents=True)
+ (ext_dir / "extension.yml").write_text(
+ "events:\n session_start:\n command: speckit.my-ext.boot\n",
+ encoding="utf-8",
+ )
+ state_path = tmp_path / ".specify" / "integration.json"
+ state_path.parent.mkdir(parents=True, exist_ok=True)
+ state_path.write_text(json.dumps({
+ "default_integration": "claude",
+ "installed_integrations": ["claude"],
+ }))
+
+ # Force install_integration_events to fail mid-refresh. R3: the
+ # failure is now surfaced as EventRefreshError (aggregated) rather
+ # than silently swallowed.
+ from specify_cli.events import EventRefreshError
+ with patch(
+ "specify_cli.events.install_integration_events",
+ side_effect=RuntimeError("simulated write failure"),
+ ):
+ with pytest.raises(EventRefreshError, match="simulated write failure"):
+ refresh_integration_events(tmp_path)
+
+ # The pre-existing config was NOT destroyed before the failure
+ # (install handles cleanup atomically; refresh no longer pre-strips).
+ assert config_path.read_text() == original
diff --git a/tests/integrations/test_fork_agent_parity.py b/tests/integrations/test_fork_agent_parity.py
index 614dc348d1..35f3903524 100644
--- a/tests/integrations/test_fork_agent_parity.py
+++ b/tests/integrations/test_fork_agent_parity.py
@@ -102,7 +102,19 @@ def test_specify_init_succeeds(self, key, tmp_path):
assert result.exit_code == 0, f"init failed for {key}: {result.output}"
- commands_dir = project / integration.registrar_config["dir"]
+ # Dual-mode integrations (e.g., bob since upstream v0.15.0) default
+ # to skills layout on a fresh project; the output directory then is
+ # /skills, not registrar_config["dir"] (the legacy commands
+ # dir).
+ expected_dir = integration.registrar_config["dir"]
+ if hasattr(integration, "is_skills_mode"):
+ try:
+ skills = integration.is_skills_mode(None, project)
+ except Exception:
+ skills = False
+ if skills:
+ expected_dir = integration.config["folder"].rstrip("/") + "/skills"
+ commands_dir = project / expected_dir
assert commands_dir.exists(), (
f"{key}: expected commands dir {commands_dir} not created"
)
diff --git a/tests/integrations/test_integration_alquimia.py b/tests/integrations/test_integration_alquimia.py
new file mode 100644
index 0000000000..bdf4fa32cd
--- /dev/null
+++ b/tests/integrations/test_integration_alquimia.py
@@ -0,0 +1,607 @@
+"""Tests for AlquimiaAIIntegration."""
+
+import json
+import os
+from unittest.mock import patch
+
+import yaml
+
+from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
+from specify_cli.integrations.base import IntegrationBase, SkillsIntegration
+from specify_cli.integrations.alquimia import ARGUMENT_HINTS
+from specify_cli.integrations.manifest import IntegrationManifest
+
+
+class TestAlquimiaAIIntegration:
+ def test_registered(self):
+ assert "alquimia" in INTEGRATION_REGISTRY
+ assert get_integration("alquimia") is not None
+
+ def test_is_base_integration(self):
+ assert isinstance(get_integration("alquimia"), IntegrationBase)
+
+ def test_config_uses_skills(self):
+ integration = get_integration("alquimia")
+ assert integration.config["folder"] == ".alquimia/"
+ assert integration.config["commands_subdir"] == "skills"
+
+ def test_registrar_config_uses_skill_layout(self):
+ integration = get_integration("alquimia")
+ assert integration.registrar_config["dir"] == ".alquimia/skills"
+ assert integration.registrar_config["format"] == "markdown"
+ assert integration.registrar_config["args"] == "$ARGUMENTS"
+ assert integration.registrar_config["extension"] == "/SKILL.md"
+
+ def test_requires_cli_is_true(self):
+ integration = get_integration("alquimia")
+ assert integration.config["requires_cli"] is True
+ assert integration.multi_install_safe is True
+
+ def test_build_exec_args_uses_headless_prompt_flag(self):
+ """Workflow dispatch relies on the inherited
+ ``SkillsIntegration.build_exec_args()`` ā pin its argv shape so a
+ future change to the base class or this integration's config is
+ caught here rather than surfacing as a silent workflow failure."""
+ integration = get_integration("alquimia")
+ args = integration.build_exec_args(
+ "hello", model="alquimia-default", output_json=True
+ )
+ assert args is not None
+ assert args[0] == "alquimia" or args[0].endswith("/alquimia")
+ assert "-p" in args
+ assert "hello" in args
+ assert "--model" in args
+ assert "alquimia-default" in args
+ assert "--output-format" in args
+ assert "json" in args
+
+ def test_setup_creates_skill_files(self, tmp_path):
+ integration = get_integration("alquimia")
+ manifest = IntegrationManifest("alquimia", tmp_path)
+ created = integration.setup(tmp_path, manifest, script_type="sh")
+
+ skill_files = [path for path in created if path.name == "SKILL.md"]
+ assert skill_files
+
+ skills_dir = tmp_path / ".alquimia" / "skills"
+ assert skills_dir.is_dir()
+
+ plan_skill = skills_dir / "speckit-plan" / "SKILL.md"
+ assert plan_skill.exists()
+
+ content = plan_skill.read_text(encoding="utf-8")
+ assert "{SCRIPT}" not in content
+ assert "{ARGS}" not in content
+ assert "__AGENT__" not in content
+ assert "__SPECKIT_COMMAND_" not in content, "unprocessed __SPECKIT_COMMAND_*__"
+ assert "/speckit." not in content, (
+ "skills agent must use /speckit- not /speckit."
+ )
+
+ parts = content.split("---", 2)
+ parsed = yaml.safe_load(parts[1])
+ assert parsed["name"] == "speckit-plan"
+ assert parsed["user-invocable"] is True
+ assert parsed["disable-model-invocation"] is False
+ assert parsed["metadata"]["source"] == "templates/commands/plan.md"
+
+ def test_render_skill_unicode(self):
+ """Test rendering a skill preserves non-ASCII characters."""
+ integration = get_integration("alquimia")
+ rendered = integration._render_skill(
+ "constitution",
+ {"description": "Prüfe Konformität der Implementierung"},
+ "Body",
+ )
+ assert "Prüfe Konformität" in rendered
+
+ def test_setup_does_not_write_context_section(self, tmp_path):
+ """The CLI no longer manages the agent context file ā that is owned by
+ the opt-in agent-context extension. Setup must not create or touch it."""
+ integration = get_integration("alquimia")
+ manifest = IntegrationManifest("alquimia", tmp_path)
+ integration.setup(tmp_path, manifest, script_type="sh")
+
+ for path in tmp_path.rglob("*"):
+ if path.is_file():
+ text = path.read_text(encoding="utf-8", errors="ignore")
+ assert "" not in text
+
+ def test_teardown_does_not_touch_existing_context_file(self, tmp_path):
+ """A user-authored context file is left intact on teardown."""
+ integration = get_integration("alquimia")
+ ctx_path = tmp_path / "ALQUIMIA.md"
+ original = "# ALQUIMIA.md\n\nUser content.\n"
+ ctx_path.write_text(original, encoding="utf-8")
+
+ manifest = IntegrationManifest("alquimia", tmp_path)
+ integration.setup(tmp_path, manifest, script_type="sh")
+ integration.teardown(tmp_path, manifest)
+
+ assert ctx_path.read_text(encoding="utf-8") == original
+
+ def test_integration_flag_creates_skill_files_cli(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / "alquimia-promote"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(
+ app,
+ [
+ "init",
+ "--here",
+ "--integration",
+ "alquimia",
+ "--script",
+ "sh",
+ "--ignore-agent-tools",
+ ],
+ catch_exceptions=False,
+ )
+ finally:
+ os.chdir(old_cwd)
+
+ assert result.exit_code == 0, result.output
+ assert (project / ".alquimia" / "skills" / "speckit-plan" / "SKILL.md").exists()
+ assert not (project / ".alquimia" / "commands").exists()
+
+ init_options = json.loads(
+ (project / ".specify" / "init-options.json").read_text(encoding="utf-8")
+ )
+ assert init_options["ai"] == "alquimia"
+ assert init_options["ai_skills"] is True
+ assert init_options["integration"] == "alquimia"
+
+ def test_integration_flag_creates_skill_files(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / "alquimia-integration"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ result = runner.invoke(
+ app,
+ [
+ "init",
+ "--here",
+ "--integration",
+ "alquimia",
+ "--script",
+ "sh",
+ "--ignore-agent-tools",
+ ],
+ catch_exceptions=False,
+ )
+ finally:
+ os.chdir(old_cwd)
+
+ assert result.exit_code == 0, result.output
+ assert (
+ project / ".alquimia" / "skills" / "speckit-specify" / "SKILL.md"
+ ).exists()
+ assert (
+ project / ".specify" / "integrations" / "alquimia.manifest.json"
+ ).exists()
+
+ def test_interactive_alquimia_selection_uses_integration_path(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / "alquimia-interactive"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ runner = CliRunner()
+ with (
+ patch(
+ "specify_cli.commands.init._stdin_is_interactive", return_value=True
+ ),
+ patch(
+ "specify_cli.commands.init.select_with_arrows",
+ return_value="alquimia",
+ ),
+ ):
+ result = runner.invoke(
+ app,
+ [
+ "init",
+ "--here",
+ "--script",
+ "sh",
+ "--ignore-agent-tools",
+ ],
+ catch_exceptions=False,
+ )
+ finally:
+ os.chdir(old_cwd)
+
+ assert result.exit_code == 0, result.output
+ assert (project / ".specify" / "integration.json").exists()
+ assert (
+ project / ".specify" / "integrations" / "alquimia.manifest.json"
+ ).exists()
+
+ skill_file = project / ".alquimia" / "skills" / "speckit-plan" / "SKILL.md"
+ assert skill_file.exists()
+ skill_content = skill_file.read_text(encoding="utf-8")
+ assert "user-invocable: true" in skill_content
+ assert "disable-model-invocation: false" in skill_content
+
+ init_options = json.loads(
+ (project / ".specify" / "init-options.json").read_text(encoding="utf-8")
+ )
+ assert init_options["ai"] == "alquimia"
+ assert init_options["ai_skills"] is True
+ assert init_options["integration"] == "alquimia"
+
+ def test_alquimia_init_remains_usable_when_converter_fails(self, tmp_path):
+ """Alquimia init should succeed even without install_skills."""
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ runner = CliRunner()
+ target = tmp_path / "fail-proj"
+
+ result = runner.invoke(
+ app,
+ [
+ "init",
+ str(target),
+ "--integration",
+ "alquimia",
+ "--script",
+ "sh",
+ "--ignore-agent-tools",
+ ],
+ )
+
+ assert result.exit_code == 0
+ assert (
+ target / ".alquimia" / "skills" / "speckit-specify" / "SKILL.md"
+ ).exists()
+
+ def test_alquimia_preset_creates_new_skill_without_commands_dir(self, tmp_path):
+ from specify_cli import save_init_options
+ from specify_cli.presets import PresetManager
+
+ project = tmp_path / "alquimia-preset-skill"
+ project.mkdir()
+ save_init_options(
+ project, {"ai": "alquimia", "ai_skills": True, "script": "sh"}
+ )
+
+ skills_dir = project / ".alquimia" / "skills"
+ skills_dir.mkdir(parents=True, exist_ok=True)
+
+ preset_dir = tmp_path / "alquimia-skill-command"
+ preset_dir.mkdir()
+ (preset_dir / "commands").mkdir()
+ (preset_dir / "commands" / "speckit.research.md").write_text(
+ "---\n"
+ "description: Research workflow\n"
+ "---\n\n"
+ "preset:alquimia-skill-command\n"
+ )
+ manifest_data = {
+ "schema_version": "1.0",
+ "preset": {
+ "id": "alquimia-skill-command",
+ "name": "Alquimia Skill Command",
+ "version": "1.0.0",
+ "description": "Test",
+ },
+ "requires": {"speckit_version": ">=0.1.0"},
+ "provides": {
+ "templates": [
+ {
+ "type": "command",
+ "name": "speckit.research",
+ "file": "commands/speckit.research.md",
+ }
+ ]
+ },
+ }
+ with open(preset_dir / "preset.yml", "w") as f:
+ yaml.dump(manifest_data, f)
+
+ manager = PresetManager(project)
+ manager.install_from_directory(preset_dir, "0.1.5")
+
+ skill_file = skills_dir / "speckit-research" / "SKILL.md"
+ assert skill_file.exists()
+ content = skill_file.read_text(encoding="utf-8")
+ assert "preset:alquimia-skill-command" in content
+ assert "name: speckit-research" in content
+ assert "user-invocable: true" in content
+ assert "disable-model-invocation: false" in content
+
+ metadata = manager.registry.get("alquimia-skill-command")
+ assert "speckit-research" in metadata.get("registered_skills", {}).get(
+ "alquimia", []
+ )
+
+
+class TestAlquimiaArgumentHints:
+ """Verify that argument-hint frontmatter is injected for Alquimia skills."""
+
+ def test_converge_has_no_argument_hint(self):
+ """Converge should not advertise unsupported feature-name arguments."""
+ assert "converge" not in ARGUMENT_HINTS
+
+ def test_all_skills_have_hints(self, tmp_path):
+ """Every skill with a configured hint must contain an argument-hint line."""
+ i = get_integration("alquimia")
+ m = IntegrationManifest("alquimia", tmp_path)
+ created = i.setup(tmp_path, m, script_type="sh")
+ skill_files = [f for f in created if f.name == "SKILL.md"]
+ assert len(skill_files) > 0
+ for f in skill_files:
+ stem = f.parent.name
+ if stem.startswith("speckit-"):
+ stem = stem[len("speckit-") :]
+ content = f.read_text(encoding="utf-8")
+ if stem in ARGUMENT_HINTS:
+ assert "argument-hint:" in content, (
+ f"{f.parent.name}/SKILL.md is missing argument-hint frontmatter"
+ )
+ else:
+ assert "argument-hint:" not in content, (
+ f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
+ )
+
+ def test_hints_match_expected_values(self, tmp_path):
+ """Each skill's argument-hint must match the expected text."""
+ i = get_integration("alquimia")
+ m = IntegrationManifest("alquimia", tmp_path)
+ created = i.setup(tmp_path, m, script_type="sh")
+ skill_files = [f for f in created if f.name == "SKILL.md"]
+ for f in skill_files:
+ # Extract stem: speckit-plan -> plan
+ stem = f.parent.name
+ if stem.startswith("speckit-"):
+ stem = stem[len("speckit-") :]
+ expected_hint = ARGUMENT_HINTS.get(stem)
+ content = f.read_text(encoding="utf-8")
+ if expected_hint is None:
+ assert "argument-hint:" not in content, (
+ f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
+ )
+ else:
+ assert f'argument-hint: "{expected_hint}"' in content, (
+ f"{f.parent.name}/SKILL.md: expected hint '{expected_hint}' not found"
+ )
+
+ def test_hint_is_inside_frontmatter(self, tmp_path):
+ """argument-hint must appear between the --- delimiters, not in the body."""
+ i = get_integration("alquimia")
+ m = IntegrationManifest("alquimia", tmp_path)
+ created = i.setup(tmp_path, m, script_type="sh")
+ skill_files = [f for f in created if f.name == "SKILL.md"]
+ for f in skill_files:
+ content = f.read_text(encoding="utf-8")
+ parts = content.split("---", 2)
+ assert len(parts) >= 3, f"No frontmatter in {f.parent.name}/SKILL.md"
+ frontmatter = parts[1]
+ body = parts[2]
+ stem = f.parent.name
+ if stem.startswith("speckit-"):
+ stem = stem[len("speckit-") :]
+ if stem in ARGUMENT_HINTS:
+ assert "argument-hint:" in frontmatter, (
+ f"{f.parent.name}/SKILL.md: argument-hint not in frontmatter section"
+ )
+ assert "argument-hint:" not in body, (
+ f"{f.parent.name}/SKILL.md: argument-hint leaked into body"
+ )
+ else:
+ assert "argument-hint:" not in content, (
+ f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
+ )
+
+ def test_hint_appears_after_description(self, tmp_path):
+ """argument-hint must immediately follow the description line."""
+ i = get_integration("alquimia")
+ m = IntegrationManifest("alquimia", tmp_path)
+ created = i.setup(tmp_path, m, script_type="sh")
+ skill_files = [f for f in created if f.name == "SKILL.md"]
+ for f in skill_files:
+ content = f.read_text(encoding="utf-8")
+ lines = content.splitlines()
+ stem = f.parent.name
+ if stem.startswith("speckit-"):
+ stem = stem[len("speckit-") :]
+ if stem not in ARGUMENT_HINTS:
+ assert "argument-hint:" not in content, (
+ f"{f.parent.name}/SKILL.md unexpectedly has argument-hint frontmatter"
+ )
+ continue
+ found_description = False
+ for idx, line in enumerate(lines):
+ if line.startswith("description:"):
+ found_description = True
+ assert idx + 1 < len(lines), (
+ f"{f.parent.name}/SKILL.md: description is last line"
+ )
+ assert lines[idx + 1].startswith("argument-hint:"), (
+ f"{f.parent.name}/SKILL.md: argument-hint does not follow description"
+ )
+ break
+ assert found_description, (
+ f"{f.parent.name}/SKILL.md: no description: line found in output"
+ )
+
+ def test_inject_argument_hint_only_in_frontmatter(self):
+ """inject_argument_hint must not modify description: lines in the body."""
+ from specify_cli.integrations.alquimia import AlquimiaAIIntegration
+
+ content = (
+ "---\ndescription: My command\n---\n\ndescription: this is body text\n"
+ )
+ result = AlquimiaAIIntegration.inject_argument_hint(content, "Test hint")
+ lines = result.splitlines()
+ hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
+ assert hint_count == 1, (
+ f"Expected exactly 1 argument-hint line, found {hint_count}"
+ )
+
+ def test_inject_argument_hint_skips_if_already_present(self):
+ """inject_argument_hint must not duplicate if argument-hint already exists."""
+ from specify_cli.integrations.alquimia import AlquimiaAIIntegration
+
+ content = (
+ "---\n"
+ "description: My command\n"
+ 'argument-hint: "Existing hint"\n'
+ "---\n"
+ "\n"
+ "Body text\n"
+ )
+ result = AlquimiaAIIntegration.inject_argument_hint(content, "New hint")
+ assert result == content, "Content should be unchanged when hint already exists"
+ lines = result.splitlines()
+ hint_count = sum(1 for ln in lines if ln.startswith("argument-hint:"))
+ assert hint_count == 1
+
+
+class TestAlquimiaDisableModelInvocation:
+ """Verify disable-model-invocation is false for Alquimia skills."""
+
+ def test_setup_sets_disable_model_invocation_false(self, tmp_path):
+ """Generated SKILL.md files must have disable-model-invocation: false."""
+ i = get_integration("alquimia")
+ m = IntegrationManifest("alquimia", tmp_path)
+ created = i.setup(tmp_path, m, script_type="sh")
+ skill_files = [f for f in created if f.name == "SKILL.md"]
+ assert len(skill_files) > 0
+ for f in skill_files:
+ content = f.read_text(encoding="utf-8")
+ parts = content.split("---", 2)
+ parsed = yaml.safe_load(parts[1])
+ assert parsed["disable-model-invocation"] is False, (
+ f"{f.parent.name}: expected disable-model-invocation: false"
+ )
+
+ def test_disable_model_invocation_not_true(self, tmp_path):
+ """No Alquimia skill should have disable-model-invocation: true."""
+ i = get_integration("alquimia")
+ m = IntegrationManifest("alquimia", tmp_path)
+ created = i.setup(tmp_path, m, script_type="sh")
+ for f in created:
+ if f.name != "SKILL.md":
+ continue
+ content = f.read_text(encoding="utf-8")
+ assert "disable-model-invocation: true" not in content, (
+ f"{f.parent.name}: must not have disable-model-invocation: true"
+ )
+
+ def test_non_alquimia_agents_lack_disable_model_invocation(self, tmp_path):
+ """Non-Alquimia skill agents should not get disable-model-invocation."""
+ from specify_cli.agents import CommandRegistrar
+
+ fm = CommandRegistrar.build_skill_frontmatter(
+ "codex", "speckit-plan", "desc", "templates/commands/plan.md"
+ )
+ assert "disable-model-invocation" not in fm
+ assert "user-invocable" not in fm
+
+ def test_skills_default_post_process_preserves_content_without_hooks(
+ self, tmp_path
+ ):
+ """SkillsIntegration agents without an override preserve non-hook content."""
+ # ``agy`` is a plain SkillsIntegration with no post-process override,
+ # so it stands in for the base-class default behavior.
+ agy = get_integration("agy")
+ if agy is None:
+ return # agy not registered in this build
+ content = "---\nname: test\n---\nBody"
+ assert agy.post_process_skill_content(content) == content
+
+
+class TestAlquimiaHookCommandNote:
+ """Verify dot-to-hyphen normalization note is injected in hook sections."""
+
+ def test_hook_note_injected_in_skills_with_hooks(self, tmp_path):
+ """Skills that have hook sections should get the normalization note."""
+ i = get_integration("alquimia")
+ m = IntegrationManifest("alquimia", tmp_path)
+ i.setup(tmp_path, m, script_type="sh")
+ specify_skill = tmp_path / ".alquimia/skills/speckit-specify/SKILL.md"
+ assert specify_skill.exists()
+ content = specify_skill.read_text(encoding="utf-8")
+ # specify.md has hook sections
+ assert "replace dots" in content, (
+ "speckit-specify should have dot-to-hyphen hook note"
+ )
+
+ def test_hook_note_not_in_skills_without_hooks(self, tmp_path):
+ """Skills without hook sections should not get the note."""
+ content = "---\nname: test\ndescription: test\n---\n\nNo hooks here.\n"
+ result = SkillsIntegration._inject_hook_command_note(content)
+ assert "replace dots" not in result
+
+ def test_hook_note_idempotent(self, tmp_path):
+ """Injecting the note twice should not duplicate it."""
+ content = (
+ "---\nname: test\n---\n\n"
+ "- For each executable hook, output the following based on its flag:\n"
+ )
+ once = SkillsIntegration._inject_hook_command_note(content)
+ twice = SkillsIntegration._inject_hook_command_note(once)
+ assert once == twice, "Hook note injection should be idempotent"
+
+ def test_hook_note_fills_missing_repeated_instructions(self, tmp_path):
+ """Already-noted hook sections should not suppress later sections."""
+ from specify_cli.integrations.base import _HOOK_COMMAND_NOTE
+
+ content = (
+ "---\nname: test\n---\n\n"
+ f"{_HOOK_COMMAND_NOTE}"
+ "- For each executable hook, output the following based on its flag:\n"
+ "\n"
+ " - For each executable hook, output the following based on its flag:\n"
+ )
+ result = SkillsIntegration._inject_hook_command_note(content)
+ assert result.count("replace dots (`.`) with hyphens") == 2
+
+ def test_hook_note_not_suppressed_by_unrelated_phrase(self, tmp_path):
+ """Unrelated text should not trip the hook-note idempotence guard."""
+ content = (
+ "---\nname: test\n---\n\n"
+ "This paragraph says replace dots in a different context.\n"
+ "- For each executable hook, output the following based on its flag:\n"
+ )
+ result = SkillsIntegration._inject_hook_command_note(content)
+ assert "This paragraph says replace dots in a different context." in result
+ assert result.count("replace dots (`.`) with hyphens") == 1
+
+ def test_hook_note_preserves_indentation(self, tmp_path):
+ """The injected note should match the indentation of the target line."""
+ content = (
+ "---\nname: test\n---\n\n"
+ " - For each executable hook, output the following\n"
+ )
+ result = SkillsIntegration._inject_hook_command_note(content)
+ lines = result.splitlines()
+ note_line = [line for line in lines if "replace dots" in line][0]
+ assert note_line.startswith(" "), "Note should preserve indentation"
+
+ def test_post_process_injects_all_alquimia_flags(self):
+ """post_process_skill_content should inject all Alquimia-specific fields."""
+ i = get_integration("alquimia")
+ content = (
+ "---\nname: test\ndescription: test\n---\n\n"
+ "- For each executable hook, output the following\n"
+ )
+ result = i.post_process_skill_content(content)
+ assert "user-invocable: true" in result
+ assert "disable-model-invocation: false" in result
+ assert "replace dots" in result
diff --git a/tests/integrations/test_integration_base_skills.py b/tests/integrations/test_integration_base_skills.py
index 6a907bb08e..6943fb2648 100644
--- a/tests/integrations/test_integration_base_skills.py
+++ b/tests/integrations/test_integration_base_skills.py
@@ -191,7 +191,7 @@ def test_hook_note_injected_for_each_instruction_independently(self):
"---\n"
"name: test\n"
"---\n\n"
- "- When constructing slash commands from hook command names, "
+ "- When constructing command invocations from hook command names, "
"replace dots (`.`) with hyphens (`-`). "
"For example, `speckit.git.commit` ā `/speckit-git-commit`.\n"
"- For each executable hook, output the following first block:\n"
diff --git a/tests/integrations/test_integration_bob.py b/tests/integrations/test_integration_bob.py
index 8e0e72f0bd..52a25ae2c6 100644
--- a/tests/integrations/test_integration_bob.py
+++ b/tests/integrations/test_integration_bob.py
@@ -1,10 +1,927 @@
"""Tests for BobIntegration."""
-from .test_integration_base_markdown import MarkdownIntegrationTests
+import os
+import warnings
+import pytest
+import yaml
-class TestBobIntegration(MarkdownIntegrationTests):
- KEY = "bob"
- FOLDER = ".bob/"
- COMMANDS_SUBDIR = "commands"
- REGISTRAR_DIR = ".bob/commands"
+from specify_cli.integrations import INTEGRATION_REGISTRY, get_integration
+from specify_cli.integrations.base import SkillsIntegration
+from specify_cli.integrations.manifest import IntegrationManifest
+
+
+class TestBobIntegrationRegistration:
+ def test_registered(self):
+ assert "bob" in INTEGRATION_REGISTRY
+ assert get_integration("bob") is not None
+
+ def test_is_integration_base_not_skills_integration(self):
+ """BobIntegration extends IntegrationBase directly ā not SkillsIntegration.
+
+ Bob is dual-mode (skills by default, legacy commands via
+ ``--legacy-commands``), so its skills-ness is a per-project config
+ decision resolved by the ``is_skills_mode`` hook ā not a class-hierarchy
+ property. It therefore must NOT be a ``SkillsIntegration`` (which is
+ reserved for statically skills-only agents); shared code consults
+ ``is_skills_mode(parsed_options)`` instead of ``isinstance``.
+ ``invoke_separator='-'`` is set explicitly on the class to match the
+ default (skills) layout.
+ """
+ from specify_cli.integrations.base import IntegrationBase
+ bob = get_integration("bob")
+ assert isinstance(bob, IntegrationBase)
+ assert not isinstance(bob, SkillsIntegration)
+ assert bob.invoke_separator == "-"
+
+ def test_key_and_config(self):
+ bob = get_integration("bob")
+ assert bob.key == "bob"
+ assert bob.config["folder"] == ".bob/"
+ # registrar_config mirrors the legacy commands layout so that
+ # CommandRegistrar.AGENT_CONFIGS["bob"] follows the Copilot pattern:
+ # extension registration writes to .bob/commands/ for legacy-mode
+ # projects and is skipped for skills-mode projects (skills_mode_active).
+ assert bob.config["commands_subdir"] == "commands"
+ assert bob.registrar_config["dir"] == ".bob/commands"
+ assert bob.registrar_config["extension"] == ".md"
+
+ def test_invoke_separator_is_hyphen(self):
+ """Class-level invoke_separator must be '-' so CommandRegistrar.AGENT_CONFIGS
+ generates correct /speckit- refs without calling effective_invoke_separator."""
+ bob = get_integration("bob")
+ assert bob.invoke_separator == "-"
+
+
+class TestBobOptionsFlag:
+ def test_options_include_legacy_commands_flag(self):
+ bob = get_integration("bob")
+ opts = bob.options()
+ legacy_opts = [o for o in opts if o.name == "--legacy-commands"]
+ assert len(legacy_opts) == 1
+ opt = legacy_opts[0]
+ assert opt.is_flag is True
+ # Legacy must be OPT-IN (default=False) ā skills are the default
+ assert opt.default is False
+
+ def test_options_include_skills_migration_flag(self):
+ """Review #3415, 4724160183, comment 1: a ``--skills`` opt-in exists as
+ the supported migration path from legacy commands to the skills layout.
+ It is distinct from the pre-skills-default ``--skills`` flag: here it
+ *forces* skills mode over on-disk auto-detection.
+ """
+ bob = get_integration("bob")
+ opts = bob.options()
+ skills_opts = [o for o in opts if o.name == "--skills"]
+ assert len(skills_opts) == 1
+ opt = skills_opts[0]
+ assert opt.is_flag is True
+ # Opt-in: disk auto-detection remains the default behavior.
+ assert opt.default is False
+
+
+class TestBobIsSkillsModeHook:
+ """The is_skills_mode hook is the single source of truth for the mode."""
+
+ def test_default_is_skills(self):
+ bob = get_integration("bob")
+ assert bob.is_skills_mode(None) is True
+ assert bob.is_skills_mode({}) is True
+
+ def test_legacy_commands_disables_skills(self):
+ bob = get_integration("bob")
+ assert bob.is_skills_mode({"legacy_commands": True}) is False
+
+ def test_existing_commands_layout_preserved_on_use(self, tmp_path):
+ """Regression (review #3415): an existing Bob 1.x project (managed
+ ``.bob/commands/speckit.*.md`` on disk, no stored ``legacy_commands``)
+ must NOT be treated as skills mode when re-resolved with a
+ project_root, so ``use``/``switch``/``upgrade`` never silently migrate
+ it to skills.
+ """
+ bob = get_integration("bob")
+ cmds = tmp_path / ".bob" / "commands"
+ cmds.mkdir(parents=True)
+ (cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
+ # No parsed options at all ā the pre-existing-install scenario.
+ assert bob.is_skills_mode(None, project_root=tmp_path) is False
+ assert bob.is_skills_mode({}, project_root=tmp_path) is False
+
+ def test_existing_skills_layout_stays_skills_on_use(self, tmp_path):
+ """A project with managed ``speckit-*`` skills resolves to skills mode."""
+ bob = get_integration("bob")
+ (tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
+ assert bob.is_skills_mode(None, project_root=tmp_path) is True
+
+ def test_managed_commands_with_unrelated_skills_dir_stays_legacy(
+ self, tmp_path
+ ):
+ """Regression (review #3415, 4723246468): a legacy Spec Kit install
+ (managed ``.bob/commands/speckit.*.md``) that *also* carries unrelated
+ Bob 2 skills (a ``.bob/skills/`` dir with no managed ``speckit-*``
+ skills) must stay in command mode ā the mere presence of a skills
+ directory is not evidence that Spec Kit is skills-based.
+ """
+ bob = get_integration("bob")
+ cmds = tmp_path / ".bob" / "commands"
+ cmds.mkdir(parents=True)
+ (cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
+ # An unrelated (non-Spec-Kit) skill the user authored.
+ (tmp_path / ".bob" / "skills" / "my-own-skill").mkdir(parents=True)
+ assert bob.is_skills_mode(None, project_root=tmp_path) is False
+ assert bob.effective_invoke_separator(None, project_root=tmp_path) == "."
+
+ def test_managed_skills_win_when_both_layouts_present(self, tmp_path):
+ """When managed Spec Kit skills exist, skills mode wins even if a stale
+ managed command file is still on disk (upgrade leftover)."""
+ bob = get_integration("bob")
+ cmds = tmp_path / ".bob" / "commands"
+ cmds.mkdir(parents=True)
+ (cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
+ (tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
+ assert bob.is_skills_mode(None, project_root=tmp_path) is True
+
+ def test_fresh_project_defaults_to_skills_with_project_root(self, tmp_path):
+ """A project with no managed ``.bob/`` artifacts yet defaults to skills."""
+ bob = get_integration("bob")
+ assert bob.is_skills_mode(None, project_root=tmp_path) is True
+
+ def test_explicit_legacy_flag_wins_over_disk_layout(self, tmp_path):
+ """An explicit ``--legacy-commands`` overrides on-disk detection."""
+ bob = get_integration("bob")
+ (tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
+ assert (
+ bob.is_skills_mode({"legacy_commands": True}, project_root=tmp_path)
+ is False
+ )
+
+ def test_explicit_skills_flag_forces_skills_over_legacy_disk_layout(
+ self, tmp_path
+ ):
+ """Regression (review #3415, 4724160183, comment 1).
+
+ ``--skills`` is the supported migration / opt-in: it must force skills
+ mode even when a managed legacy ``.bob/commands`` layout is on disk
+ (which otherwise auto-detects to legacy). This gives
+ ``integration upgrade bob --integration-options="--skills"`` a path out
+ of legacy mode instead of being trapped by disk detection.
+ """
+ bob = get_integration("bob")
+ cmds = tmp_path / ".bob" / "commands"
+ cmds.mkdir(parents=True)
+ (cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
+ assert bob.is_skills_mode({"skills": True}, project_root=tmp_path) is True
+ assert (
+ bob.effective_invoke_separator({"skills": True}, project_root=tmp_path)
+ == "-"
+ )
+
+ def test_skills_and_legacy_flags_are_mutually_exclusive(self):
+ """Passing both ``--skills`` and ``--legacy-commands`` exits cleanly."""
+ import typer
+
+ bob = get_integration("bob")
+ with pytest.raises(typer.Exit):
+ bob.is_skills_mode({"skills": True, "legacy_commands": True})
+
+ def test_effective_invoke_separator_tracks_mode(self):
+ bob = get_integration("bob")
+ assert bob.effective_invoke_separator(None) == "-"
+ assert bob.effective_invoke_separator({"legacy_commands": True}) == "."
+ assert bob.effective_invoke_separator({"skills": True}) == "-"
+
+ def test_invoke_separator_for_mode_tracks_persisted_state(self):
+ """Registration paths resolve the separator from persisted ai_skills."""
+ bob = get_integration("bob")
+ assert bob.invoke_separator_for_mode(True) == "-"
+ assert bob.invoke_separator_for_mode(False) == "."
+
+ def test_no_skills_mode_method_leaks(self):
+ """The old callable _skills_mode method must be gone; consumers use the hook."""
+ bob = get_integration("bob")
+ assert not callable(getattr(bob, "_skills_mode", None))
+
+
+class TestBobDefaultSkillsMode:
+ """Default mode: .bob/skills/speckit-/SKILL.md layout."""
+
+ def test_setup_creates_skill_files(self, tmp_path):
+ bob = get_integration("bob")
+ m = IntegrationManifest("bob", tmp_path)
+ created = bob.setup(tmp_path, m)
+ assert len(created) > 0
+ for f in created:
+ assert f.exists()
+ assert f.name == "SKILL.md"
+ assert f.parent.name.startswith("speckit-")
+
+ def test_setup_writes_to_correct_directory(self, tmp_path):
+ bob = get_integration("bob")
+ m = IntegrationManifest("bob", tmp_path)
+ bob.setup(tmp_path, m)
+ skills_dir = tmp_path / ".bob" / "skills"
+ assert skills_dir.is_dir()
+
+ def test_setup_does_not_warn(self, tmp_path):
+ bob = get_integration("bob")
+ m = IntegrationManifest("bob", tmp_path)
+ with warnings.catch_warnings(record=True) as caught:
+ warnings.simplefilter("always")
+ bob.setup(tmp_path, m)
+ assert not any(
+ "legacy" in str(item.message).lower() for item in caught
+ )
+
+ def test_setup_no_commands_dir(self, tmp_path):
+ bob = get_integration("bob")
+ m = IntegrationManifest("bob", tmp_path)
+ bob.setup(tmp_path, m)
+ assert not (tmp_path / ".bob" / "commands").exists()
+
+ def test_skill_directory_structure(self, tmp_path):
+ """Each command produces speckit-/SKILL.md."""
+ bob = get_integration("bob")
+ m = IntegrationManifest("bob", tmp_path)
+ created = bob.setup(tmp_path, m)
+
+ expected_commands = {
+ "analyze", "clarify", "constitution", "converge", "implement",
+ "plan", "checklist", "specify", "tasks", "taskstoissues",
+ }
+ actual_commands = {f.parent.name.removeprefix("speckit-") for f in created}
+ assert actual_commands == expected_commands
+
+ def test_skill_frontmatter_structure(self, tmp_path):
+ """SKILL.md must have name, description, compatibility, metadata."""
+ bob = get_integration("bob")
+ m = IntegrationManifest("bob", tmp_path)
+ created = bob.setup(tmp_path, m)
+ for f in created:
+ content = f.read_text(encoding="utf-8")
+ assert content.startswith("---\n"), f"{f} missing frontmatter"
+ parts = content.split("---", 2)
+ fm = yaml.safe_load(parts[1])
+ assert "name" in fm
+ assert "description" in fm
+ assert "compatibility" in fm
+ assert "metadata" in fm
+ assert fm["metadata"]["author"] == "github-spec-kit"
+
+ def test_templates_are_processed(self, tmp_path):
+ bob = get_integration("bob")
+ m = IntegrationManifest("bob", tmp_path)
+ created = bob.setup(tmp_path, m)
+ for f in created:
+ content = f.read_text(encoding="utf-8")
+ assert "{SCRIPT}" not in content, f"{f.name} has unprocessed {{SCRIPT}}"
+ assert "__AGENT__" not in content, f"{f.name} has unprocessed __AGENT__"
+ assert "{ARGS}" not in content, f"{f.name} has unprocessed {{ARGS}}"
+ assert "__SPECKIT_COMMAND_" not in content, f"{f.name} has unprocessed __SPECKIT_COMMAND_*__"
+
+ def test_command_refs_use_hyphen_separator(self, tmp_path):
+ """Default skills layout must use /speckit-, not /speckit.."""
+ bob = get_integration("bob")
+ m = IntegrationManifest("bob", tmp_path)
+ created = bob.setup(tmp_path, m)
+ for f in created:
+ content = f.read_text(encoding="utf-8")
+ assert "/speckit." not in content, (
+ f"{f.name} contains dot-notation /speckit. reference; "
+ "skills must use /speckit-"
+ )
+
+ def test_all_files_tracked_in_manifest(self, tmp_path):
+ bob = get_integration("bob")
+ m = IntegrationManifest("bob", tmp_path)
+ created = bob.setup(tmp_path, m)
+ for f in created:
+ rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
+ assert rel in m.files, f"{rel} not tracked in manifest"
+
+ def test_install_uninstall_roundtrip(self, tmp_path):
+ bob = get_integration("bob")
+ m = IntegrationManifest("bob", tmp_path)
+ created = bob.install(tmp_path, m)
+ assert len(created) > 0
+ m.save()
+ for f in created:
+ assert f.exists()
+ removed, skipped = bob.uninstall(tmp_path, m)
+ assert len(removed) == len(created)
+ assert skipped == []
+
+
+class TestBobLegacyCommandsMode:
+ """Legacy opt-in mode: .bob/commands/speckit..md layout."""
+
+ def test_setup_legacy_creates_markdown_files(self, tmp_path):
+ from specify_cli.integrations.bob import BobIntegration
+ bob = BobIntegration()
+ m = IntegrationManifest("bob", tmp_path)
+ created = bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
+ assert len(created) > 0
+ for f in created:
+ assert f.exists()
+ assert f.suffix == ".md"
+ assert f.name.startswith("speckit.")
+ assert f.parent == tmp_path / ".bob" / "commands"
+
+ def test_setup_legacy_warns_deprecated(self, tmp_path):
+ from specify_cli.integrations.bob import BobIntegration
+ bob = BobIntegration()
+ m = IntegrationManifest("bob", tmp_path)
+ with pytest.warns(UserWarning, match="Bob legacy commands mode"):
+ bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
+
+ def test_setup_legacy_no_skills_dir(self, tmp_path):
+ from specify_cli.integrations.bob import BobIntegration
+ bob = BobIntegration()
+ m = IntegrationManifest("bob", tmp_path)
+ bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
+ assert not (tmp_path / ".bob" / "skills").exists()
+
+ def test_setup_legacy_templates_are_processed(self, tmp_path):
+ from specify_cli.integrations.bob import BobIntegration
+ bob = BobIntegration()
+ m = IntegrationManifest("bob", tmp_path)
+ bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
+ commands_dir = tmp_path / ".bob" / "commands"
+ for md_file in commands_dir.glob("speckit.*.md"):
+ content = md_file.read_text(encoding="utf-8")
+ assert "{SCRIPT}" not in content
+ assert "__AGENT__" not in content
+ assert "{ARGS}" not in content
+ assert "__SPECKIT_COMMAND_" not in content
+
+ def test_setup_legacy_all_files_tracked(self, tmp_path):
+ from specify_cli.integrations.bob import BobIntegration
+ bob = BobIntegration()
+ m = IntegrationManifest("bob", tmp_path)
+ created = bob.setup(tmp_path, m, parsed_options={"legacy_commands": True})
+ for f in created:
+ rel = f.resolve().relative_to(tmp_path.resolve()).as_posix()
+ assert rel in m.files, f"{rel} not tracked in manifest"
+
+ def test_setup_legacy_uninstall_roundtrip(self, tmp_path):
+ from specify_cli.integrations.bob import BobIntegration
+ bob = BobIntegration()
+ m = IntegrationManifest("bob", tmp_path)
+ created = bob.install(tmp_path, m, parsed_options={"legacy_commands": True})
+ assert len(created) > 0
+ m.save()
+ removed, skipped = bob.uninstall(tmp_path, m)
+ assert len(removed) == len(created)
+ assert skipped == []
+
+
+class TestBobInitFlowDefault:
+ """CLI init creates skills by default."""
+
+ def test_init_default_creates_skills(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ target = tmp_path / "test-proj"
+ result = CliRunner().invoke(app, [
+ "init", str(target), "--integration", "bob",
+ "--ignore-agent-tools", "--script", "sh",
+ ])
+ assert result.exit_code == 0, f"init --integration bob failed: {result.output}"
+ assert (target / ".bob" / "skills" / "speckit-plan" / "SKILL.md").exists()
+ assert not (target / ".bob" / "commands").exists()
+
+ def test_init_default_complete_file_inventory_sh(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ project = tmp_path / "inventory-sh-bob"
+ project.mkdir()
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(project)
+ result = CliRunner().invoke(app, [
+ "init", "--here", "--integration", "bob", "--script", "sh",
+ "--ignore-agent-tools",
+ ], catch_exceptions=False)
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"init failed: {result.output}"
+
+ commands = [
+ "analyze", "clarify", "constitution", "converge", "implement",
+ "plan", "checklist", "specify", "tasks", "taskstoissues",
+ ]
+ for cmd in commands:
+ assert (project / ".bob" / "skills" / f"speckit-{cmd}" / "SKILL.md").exists(), (
+ f"Missing .bob/skills/speckit-{cmd}/SKILL.md"
+ )
+
+
+class TestBobInitFlowLegacy:
+ """CLI init with --legacy-commands produces .bob/commands/*.md."""
+
+ def test_init_legacy_creates_commands(self, tmp_path):
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ target = tmp_path / "test-proj"
+ result = CliRunner().invoke(app, [
+ "init", str(target), "--integration", "bob",
+ "--integration-options", "--legacy-commands",
+ "--ignore-agent-tools", "--script", "sh",
+ ])
+ assert result.exit_code == 0, f"init --integration bob --legacy-commands failed: {result.output}"
+ assert (target / ".bob" / "commands" / "speckit.plan.md").exists()
+ assert not (target / ".bob" / "skills").exists()
+
+ def test_init_legacy_does_not_set_ai_skills(self, tmp_path):
+ """Legacy install must NOT write ai_skills=True to init-options.json.
+
+ Behavioral guard for the dual-mode contract: with --legacy-commands,
+ BobIntegration.is_skills_mode(parsed_options) returns False, so
+ _update_init_options_for_integration must not persist ai_skills=True.
+ (Regression origin: shared code previously probed a bound _skills_mode
+ method object, which is always truthy, and wrongly enabled skills for
+ legacy projects.)
+ """
+ from typer.testing import CliRunner
+ from specify_cli import app
+ from specify_cli import load_init_options
+
+ target = tmp_path / "test-proj"
+ result = CliRunner().invoke(app, [
+ "init", str(target), "--integration", "bob",
+ "--integration-options", "--legacy-commands",
+ "--ignore-agent-tools", "--script", "sh",
+ ])
+ assert result.exit_code == 0, f"init failed: {result.output}"
+ init_opts = load_init_options(target)
+ assert init_opts.get("ai_skills") is not True, (
+ "Legacy Bob project must not have ai_skills=True in init-options.json"
+ )
+
+
+class TestBobRegistrarConfig:
+ """Verify AGENT_CONFIGS["bob"] follows the Copilot pattern for extension registration."""
+
+ def test_registrar_config_uses_commands_layout(self):
+ """AGENT_CONFIGS["bob"] must use the legacy .md layout (not /SKILL.md).
+
+ This mirrors Copilot: the static registrar config targets the non-skills
+ format so that:
+ - skills_mode_active becomes True when ai_skills=True, preventing
+ extension registration from writing SKILL.md files into .bob/skills/
+ on projects that never asked for legacy files.
+ - legacy-mode projects receive extension .md files in .bob/commands/.
+ """
+ from specify_cli.agents import CommandRegistrar
+ registrar = CommandRegistrar()
+ bob_cfg = registrar.AGENT_CONFIGS.get("bob")
+ assert bob_cfg is not None, "bob must be in AGENT_CONFIGS"
+ assert bob_cfg["extension"] == ".md", (
+ "AGENT_CONFIGS['bob']['extension'] must be '.md' so that "
+ "skills_mode_active=True suppresses extension registration on "
+ "skills-mode projects (mirrors the Copilot pattern)"
+ )
+ assert bob_cfg["dir"] == ".bob/commands"
+
+ def test_skills_mode_project_extension_registration_skipped(self, tmp_path):
+ """Extension registrar skips Bob on skills-mode projects (no .bob/commands dir)."""
+ from specify_cli.agents import CommandRegistrar
+ # Simulate a skills-mode Bob project: .bob/skills exists, .bob/commands does not
+ (tmp_path / ".bob" / "skills").mkdir(parents=True)
+
+ registrar = CommandRegistrar()
+ results = registrar.register_commands_for_all_agents(
+ commands=[{"name": "speckit.test-cmd", "file": "test.md"}],
+ source_id="test",
+ source_dir=tmp_path,
+ project_root=tmp_path,
+ )
+ # Bob must not appear in results ā .bob/commands doesn't exist
+ assert "bob" not in results
+
+ def test_legacy_mode_project_extension_registration_runs(self, tmp_path):
+ """Extension registrar writes to .bob/commands/ for legacy-mode projects."""
+ import textwrap
+ from specify_cli.agents import CommandRegistrar
+
+ # Simulate a legacy-mode Bob project: .bob/commands exists, .bob/skills does not
+ commands_dir = tmp_path / ".bob" / "commands"
+ commands_dir.mkdir(parents=True)
+
+ # Provide a minimal command source file
+ cmd_file = tmp_path / "test.md"
+ cmd_file.write_text(
+ textwrap.dedent("""\
+ ---
+ description: "Test command"
+ ---
+ Test body.
+ """),
+ encoding="utf-8",
+ )
+
+ registrar = CommandRegistrar()
+ results = registrar.register_commands_for_all_agents(
+ commands=[{"name": "speckit.test-cmd", "file": "test.md"}],
+ source_id="test",
+ source_dir=tmp_path,
+ project_root=tmp_path,
+ )
+ assert "bob" in results, "bob must appear in results for legacy-mode project"
+ registered_file = commands_dir / "speckit.test-cmd.md"
+ assert registered_file.exists(), f"Expected {registered_file} to be written"
+
+ def test_legacy_extension_command_refs_use_dot_separator(self, tmp_path):
+ """Regression (review #3415): legacy .bob/commands/ extension commands must
+ render Bob 1.x ``/speckit.`` refs, not the skills-layout ``/speckit-``.
+
+ The single static AGENT_CONFIGS["bob"]["invoke_separator"] is "-" (the
+ default skills layout); register_commands must instead resolve the
+ separator from the project's persisted mode via
+ BobIntegration.invoke_separator_for_mode(False) -> ".".
+ """
+ import textwrap
+ from specify_cli.agents import CommandRegistrar
+
+ # Legacy-mode project: .bob/commands exists, ai_skills is NOT set.
+ commands_dir = tmp_path / ".bob" / "commands"
+ commands_dir.mkdir(parents=True)
+ cmd_file = tmp_path / "test.md"
+ cmd_file.write_text(
+ textwrap.dedent("""\
+ ---
+ description: "Test command"
+ ---
+ See __SPECKIT_COMMAND_SPECIFY__ for details.
+ """),
+ encoding="utf-8",
+ )
+
+ registrar = CommandRegistrar()
+ registrar.register_commands_for_all_agents(
+ commands=[{"name": "speckit.test-cmd", "file": "test.md"}],
+ source_id="test",
+ source_dir=tmp_path,
+ project_root=tmp_path,
+ )
+ rendered = (commands_dir / "speckit.test-cmd.md").read_text(encoding="utf-8")
+ assert "/speckit.specify" in rendered, (
+ "legacy Bob extension commands must render /speckit.specify (dot)"
+ )
+ assert "/speckit-specify" not in rendered
+
+
+class TestBobUseFlowPreservesLegacyLayout:
+ """Regression (review #3415): re-activating an existing Bob 1.x project
+ must not silently migrate it to the skills layout.
+ """
+
+ def test_update_init_options_preserves_legacy_commands_project(self, tmp_path):
+ """``use``/``switch``/``upgrade`` on a ``.bob/commands``-only project
+ (no stored ``legacy_commands``) must not write ``ai_skills=True``.
+ """
+ from specify_cli.integrations._helpers import (
+ _update_init_options_for_integration,
+ )
+ from specify_cli import load_init_options
+
+ # Existing Bob 1.x project: legacy commands dir on disk, no ai_skills.
+ cmds = tmp_path / ".bob" / "commands"
+ cmds.mkdir(parents=True)
+ (cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
+ bob = get_integration("bob")
+
+ # Simulate the use/switch path: no parsed options were stored.
+ _update_init_options_for_integration(tmp_path, bob, parsed_options=None)
+
+ opts = load_init_options(tmp_path)
+ assert opts.get("ai") == "bob"
+ assert opts.get("ai_skills") is not True, (
+ "an existing .bob/commands project must stay legacy on re-activation"
+ )
+
+ def test_update_init_options_keeps_skills_project_as_skills(self, tmp_path):
+ """A ``.bob/skills`` project stays skills on re-activation."""
+ from specify_cli.integrations._helpers import (
+ _update_init_options_for_integration,
+ )
+ from specify_cli import load_init_options
+
+ (tmp_path / ".bob" / "skills" / "speckit-plan").mkdir(parents=True)
+ bob = get_integration("bob")
+
+ _update_init_options_for_integration(tmp_path, bob, parsed_options=None)
+
+ opts = load_init_options(tmp_path)
+ assert opts.get("ai_skills") is True
+
+ def test_with_integration_setting_stores_dot_separator_for_legacy(self, tmp_path):
+ """Regression (review #3415): shared-infra refresh on the use/switch
+ path resolves the command-ref separator *before* init-options are
+ rewritten, via ``effective_invoke_separator``. For an existing
+ ``.bob/commands`` project with no stored options this must resolve to
+ ``"."`` (project-aware), not the skills-layout ``"-"``; otherwise core
+ command references get rewritten to ``/speckit-*``.
+ """
+ from specify_cli.integration_runtime import with_integration_setting
+
+ cmds = tmp_path / ".bob" / "commands"
+ cmds.mkdir(parents=True)
+ (cmds / "speckit.plan.md").write_text("# plan", encoding="utf-8")
+ bob = get_integration("bob")
+
+ # Simulate the use/switch path: no parsed options stored.
+ settings = with_integration_setting(
+ {}, "bob", bob, parsed_options=None, project_root=tmp_path
+ )
+ assert settings["bob"]["invoke_separator"] == ".", (
+ "legacy .bob/commands project must persist the dot separator so "
+ "shared templates render Bob 1.x /speckit. references"
+ )
+
+ def test_use_force_keeps_legacy_command_refs_in_shared_templates(self, tmp_path):
+ """End-to-end (review #3415): ``integration use bob --force`` on an
+ existing Bob 1.x project (legacy layout on disk, stored options
+ stripped as a pre-PR install would be) must re-render shared templates
+ with ``/speckit.`` (dot), not ``/speckit-``.
+ """
+ import json
+ from typer.testing import CliRunner
+ from specify_cli import app
+
+ # Create a real legacy Bob project (renders shared templates).
+ target = tmp_path / "proj"
+ runner = CliRunner()
+ result = runner.invoke(app, [
+ "init", str(target), "--integration", "bob",
+ "--integration-options", "--legacy-commands",
+ "--ignore-agent-tools", "--script", "sh",
+ ])
+ assert result.exit_code == 0, f"init failed: {result.output}"
+
+ template = target / ".specify" / "templates" / "plan-template.md"
+ assert template.is_file(), "expected a rendered shared plan template"
+ assert "/speckit.plan" in template.read_text(encoding="utf-8")
+
+ # Simulate a pre-PR Bob 1.x install: no stored options/separator.
+ integ_json = target / ".specify" / "integration.json"
+ data = json.loads(integ_json.read_text(encoding="utf-8"))
+ bob_settings = data["integration_settings"]["bob"]
+ for stale in ("raw_options", "parsed_options", "invoke_separator"):
+ bob_settings.pop(stale, None)
+ integ_json.write_text(json.dumps(data, indent=2), encoding="utf-8")
+
+ # Re-activate with --force so shared templates are re-rendered.
+ import os
+ old_cwd = os.getcwd()
+ try:
+ os.chdir(target)
+ result = runner.invoke(
+ app, ["integration", "use", "bob", "--force"]
+ )
+ finally:
+ os.chdir(old_cwd)
+ assert result.exit_code == 0, f"use failed: {result.output}"
+
+ rendered = template.read_text(encoding="utf-8")
+ assert "/speckit.plan" in rendered, (
+ "legacy Bob project must keep /speckit.plan (dot) after refresh"
+ )
+ assert "/speckit-plan" not in rendered, (
+ "shared templates must not be rewritten to the skills /speckit-plan"
+ )
+ # And the persisted separator must reflect the legacy layout.
+ data = json.loads(integ_json.read_text(encoding="utf-8"))
+ assert data["integration_settings"]["bob"].get("invoke_separator") == "."
+
+
+class TestBobCommandRefScopedToActiveAgent:
+ """Regression (review #3415, 4716424313).
+
+ ``CommandRegistrar.register_commands`` runs once per detected agent, but the
+ persisted ``ai_skills`` flag describes only the *active* integration
+ (``opts["ai"]``). When another agent (e.g. Copilot) is active in skills
+ mode while a legacy ``.bob/commands`` layout is also present, Bob's command
+ references must still render with the ``.`` separator (Bob 1.x
+ ``/speckit.``) rather than inheriting Copilot's ``ai_skills=True`` and
+ rendering ``/speckit-``.
+ """
+
+ def _write_command_ref_ext(self, source_dir):
+ source_dir.mkdir(parents=True, exist_ok=True)
+ cmd = source_dir / "run.md"
+ cmd.write_text(
+ "---\ndescription: Run\n---\n\nUse __SPECKIT_COMMAND_PLAN__ first.\n",
+ encoding="utf-8",
+ )
+ return [{"name": "speckit.ext.run", "file": "run.md"}]
+
+ def test_legacy_bob_ref_not_rewritten_when_other_agent_active_in_skills(
+ self, tmp_path
+ ):
+ from specify_cli._init_options import save_init_options
+ from specify_cli.agents import CommandRegistrar
+
+ # Legacy Bob layout on disk; skills layout absent.
+ (tmp_path / ".bob" / "commands").mkdir(parents=True)
+ # A different agent (Copilot) is the active integration, in skills mode.
+ save_init_options(tmp_path, {"ai": "copilot", "ai_skills": True})
+
+ source_dir = tmp_path / "ext-src"
+ commands = self._write_command_ref_ext(source_dir)
+
+ registrar = CommandRegistrar()
+ registered = registrar.register_commands(
+ "bob", commands, "ext", source_dir, tmp_path,
+ )
+ assert "speckit.ext.run" in registered
+
+ written = list((tmp_path / ".bob" / "commands").glob("*.md"))
+ assert written, "expected a rendered Bob command file"
+ content = written[0].read_text(encoding="utf-8")
+ assert "__SPECKIT_COMMAND_PLAN__" not in content
+ assert "/speckit.plan" in content, (
+ "legacy Bob command refs must use the dot separator even when "
+ "another agent is active in skills mode"
+ )
+ assert "/speckit-plan" not in content
+
+ def test_active_bob_skills_command_output_uses_dot(self, tmp_path):
+ """Regression (review #3415, 4724160183, comment 2).
+
+ The separator must match the *output layout* the registrar writes, not
+ the project's persisted ``ai_skills`` flag. Even when Bob itself is the
+ active agent in skills mode, a ``.bob/commands/*.md`` file is a
+ command-layout artifact and must render Bob 1.x ``/speckit.``.
+ Rendering ``/speckit-