mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
82 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d1e86f6382 | ||
|
|
400ad01f12 | ||
|
|
642fa56c0a | ||
|
|
521020bc3a | ||
|
|
14e82353cb | ||
|
|
1831fffde6 | ||
|
|
ba7ae79c66 | ||
|
|
36cb7e3c11 | ||
|
|
cf71d00dfe | ||
|
|
7f40c82945 | ||
|
|
184de79749 | ||
|
|
d82c915f9f | ||
|
|
acd8b801fd | ||
|
|
a3e183d069 | ||
|
|
6bf51e728a | ||
|
|
5e2f9bcd9b | ||
|
|
e4318a3d1a | ||
|
|
43a54bf2d6 | ||
|
|
515d2810fb | ||
|
|
6577ffc92b | ||
|
|
0f3f2aa45e | ||
|
|
81bf741b92 | ||
|
|
4803a22b33 | ||
|
|
e916fd1b3b | ||
|
|
296fdf2ee7 | ||
|
|
fdfc5ae330 | ||
|
|
227b4f5e11 | ||
|
|
675143591d | ||
|
|
99cd5e21b1 | ||
|
|
edc1699481 | ||
|
|
f36634b5c1 | ||
|
|
6712665bba | ||
|
|
5827db5359 | ||
|
|
afbb2c7b65 | ||
|
|
6337ebfe59 | ||
|
|
e543147ccb | ||
|
|
6033c6957b | ||
|
|
13f2b135cc | ||
|
|
54396780f3 | ||
|
|
db5802b39b | ||
|
|
8394c8d536 | ||
|
|
89126f3a33 | ||
|
|
623466dc42 | ||
|
|
2ef96532d2 | ||
|
|
de54ff73fe | ||
|
|
f4a9b890cc | ||
|
|
b7b0e966cc | ||
|
|
884950f88a | ||
|
|
f8e474d6fd | ||
|
|
1fff7a196d | ||
|
|
b048e339a5 | ||
|
|
d99170fb5e | ||
|
|
f04a36a629 | ||
|
|
be33d2a5f6 | ||
|
|
751eae727e | ||
|
|
4ad7ef2b42 | ||
|
|
6ef96373e2 | ||
|
|
4bc79fe243 | ||
|
|
596a31ee0f | ||
|
|
52a6514e38 | ||
|
|
3dad624e5d | ||
|
|
0231ee056b | ||
|
|
ca2b494335 | ||
|
|
86c4610b7d | ||
|
|
a2b0d0d3c1 | ||
|
|
56b1839fba | ||
|
|
98b2551ade | ||
|
|
9bb7206d3b | ||
|
|
89e204ca3c | ||
|
|
88e997306f | ||
|
|
186ca25c99 | ||
|
|
a9c9905400 | ||
|
|
054fb7723d | ||
|
|
8bfe6e14d1 | ||
|
|
a482fb2fce | ||
|
|
05cb3cba34 | ||
|
|
2e44ed60e8 | ||
|
|
999f8e6497 | ||
|
|
655a3cb8ca | ||
|
|
809b4c5e26 | ||
|
|
1354eade99 | ||
|
|
2a29b534ae |
12
.github/scripts/check_security_requirements.py
vendored
12
.github/scripts/check_security_requirements.py
vendored
@@ -29,12 +29,20 @@ def _dependency_diff_refs() -> tuple[str, str]:
|
||||
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",
|
||||
base_ref,
|
||||
merge_base,
|
||||
head_ref,
|
||||
"--",
|
||||
*DEPENDENCY_INPUTS,
|
||||
@@ -77,6 +85,7 @@ def main() -> int:
|
||||
|
||||
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(
|
||||
[
|
||||
@@ -87,7 +96,6 @@ def main() -> int:
|
||||
"--extra",
|
||||
"test",
|
||||
"--universal",
|
||||
"--upgrade",
|
||||
"--generate-hashes",
|
||||
"--quiet",
|
||||
"--no-header",
|
||||
|
||||
6
.github/security-audit-requirements.txt
vendored
6
.github/security-audit-requirements.txt
vendored
@@ -1,6 +1,6 @@
|
||||
annotated-doc==0.0.4 \
|
||||
--hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
|
||||
--hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4
|
||||
annotated-doc==0.0.5 \
|
||||
--hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
|
||||
--hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
|
||||
# via typer
|
||||
click==8.4.2 \
|
||||
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
|
||||
|
||||
2
.github/workflows/publish-pypi.yml
vendored
2
.github/workflows/publish-pypi.yml
vendored
@@ -35,7 +35,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.13"
|
||||
|
||||
|
||||
6
.github/workflows/security.yml
vendored
6
.github/workflows/security.yml
vendored
@@ -27,14 +27,14 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
- name: Check committed audit requirements are current
|
||||
env:
|
||||
DEPENDENCY_DIFF_BASE: ${{ github.event.pull_request.base.sha || github.event.before || '' }}
|
||||
DEPENDENCY_DIFF_HEAD: ${{ github.sha }}
|
||||
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
|
||||
|
||||
@@ -58,7 +58,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
||||
2
.github/workflows/stale.yml
vendored
2
.github/workflows/stale.yml
vendored
@@ -14,7 +14,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@1e223db275d687790206a7acac4d1a11bd6fe629 # v10
|
||||
- uses: actions/stale@4391f3da665fdf50b6810c1a66712fb9ba21aa93 # v11.0.0
|
||||
with:
|
||||
# Days of inactivity before an issue or PR becomes stale
|
||||
days-before-stale: 150
|
||||
|
||||
4
.github/workflows/test.yml
vendored
4
.github/workflows/test.yml
vendored
@@ -19,7 +19,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: "3.14"
|
||||
|
||||
@@ -40,7 +40,7 @@ jobs:
|
||||
uses: astral-sh/setup-uv@c771a70e6277c0a99b617c7a806ffedaca235ff9 # v9.0.0
|
||||
|
||||
- name: Set up Python ${{ matrix.python-version }}
|
||||
uses: actions/setup-python@ece7cb06caefa5fff74198d8649806c4678c61a1 # v6
|
||||
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
|
||||
|
||||
87
CHANGELOG.md
87
CHANGELOG.md
@@ -2,6 +2,93 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [0.15.1] - 2026-07-31
|
||||
|
||||
### Changed
|
||||
|
||||
- fix: escape Rich markup in `workflow resolve` output (#3879)
|
||||
- chore(deps): bump actions/stale from 10.4.0 to 11.0.0 (#3877)
|
||||
- chore(deps): bump actions/setup-python from 6.3.0 to 7.0.0 (#3876)
|
||||
- feat: support tar archives for installs (#3874)
|
||||
- fix: eliminate TOCTOU race in file unlink calls (#3819)
|
||||
- fix(scripts): tolerate an unusable integration.json in the Python helper (#3785)
|
||||
- fix(catalogs): validate the port in the shared catalog-URL validator, like its mirrors do (#3804)
|
||||
- feat(presets): add opt-in constitution-sync preset (#3873)
|
||||
- fix: reject non-object workflow caches (#3860)
|
||||
- Harden extension URL download cache against symlink and junction races (#3869)
|
||||
- fix: escape workflow step metadata (#3863)
|
||||
- [bug-fix] Fix bundle-update-force-mislead: add refresh() to DefaultPrimitiveInstaller (#3452)
|
||||
- fix: use chunked read for extension manifest hash (#3841)
|
||||
- fix: preserve unreadable event config files (#3861)
|
||||
- fix(scripts): use a .NET Framework-safe trim in the PowerShell init-dir resolver (#3872)
|
||||
- Add ContextForge MCP extension to community catalog (#3487)
|
||||
- fix: normalize non-UTF-8 integration manifests (#3862)
|
||||
- feat: bind gate verdict to workflow input via verdict_input (#3725)
|
||||
- docs: use absolute image URLs in README for PyPI rendering (#3867)
|
||||
- chore: release 0.15.0, begin 0.15.1.dev0 development (#3871)
|
||||
|
||||
## [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-<name> 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
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div align="center">
|
||||
<img src="./media/logo_large.webp" alt="Spec Kit Logo" width="200" height="200"/>
|
||||
<img src="https://raw.githubusercontent.com/github/spec-kit/main/media/logo_large.webp" alt="Spec Kit Logo" width="200" height="200"/>
|
||||
<h1>🌱 Spec Kit</h1>
|
||||
<h3><em>Define what to build before building it — with any AI coding agent.</em></h3>
|
||||
</div>
|
||||
@@ -15,6 +15,11 @@
|
||||
<a href="https://github.github.io/spec-kit/"><img src="https://img.shields.io/badge/docs-GitHub_Pages-blue" alt="Documentation"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<strong>English</strong> ·
|
||||
<a href="./README.zh-CN.md">简体中文</a>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## Table of Contents
|
||||
@@ -131,7 +136,7 @@ For detailed step-by-step instructions, see our [comprehensive guide](./spec-dri
|
||||
|
||||
Want to see Spec Kit in action? Watch our [video overview](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)!
|
||||
|
||||
[](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
|
||||
[](https://www.youtube.com/watch?v=a9eR1xsfvHg&pp=0gcJCckJAYcqIYzv)
|
||||
|
||||
## 🌍 Community
|
||||
|
||||
|
||||
361
README.zh-CN.md
Normal file
361
README.zh-CN.md
Normal file
@@ -0,0 +1,361 @@
|
||||
<div align="center">
|
||||
<img src="./media/logo_large.webp" alt="Spec Kit Logo" width="200" height="200"/>
|
||||
<h1>🌱 Spec Kit</h1>
|
||||
<h3><em>在动手编码之前,先定义要构建什么 —— 适配任意 AI 编码助手。</em></h3>
|
||||
</div>
|
||||
|
||||
<p align="center">
|
||||
<strong>一个开源工具套件,帮助你借助任意 AI 编码助手构建高质量软件 —— 内置开箱即用的规范驱动流程(也可自带流程),可无限扩展、由社区驱动,并为整个组织的协作而设计。</strong>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/github/spec-kit/releases/latest"><img src="https://img.shields.io/github/v/release/github/spec-kit" alt="Latest Release"/></a>
|
||||
<a href="https://github.com/github/spec-kit/stargazers"><img src="https://img.shields.io/github/stars/github/spec-kit?style=social" alt="GitHub stars"/></a>
|
||||
<a href="https://github.com/github/spec-kit/blob/main/LICENSE"><img src="https://img.shields.io/github/license/github/spec-kit" alt="License"/></a>
|
||||
<a href="https://github.github.io/spec-kit/"><img src="https://img.shields.io/badge/docs-GitHub_Pages-blue" alt="Documentation"/></a>
|
||||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="./README.md">English</a> ·
|
||||
<strong>简体中文</strong>
|
||||
</p>
|
||||
|
||||
---
|
||||
|
||||
## 目录
|
||||
|
||||
- [🤔 什么是规范驱动开发?](#-什么是规范驱动开发)
|
||||
- [⚡ 快速开始](#-快速开始)
|
||||
- [📽️ 视频概览](#️-视频概览)
|
||||
- [🌍 社区](#-社区)
|
||||
- [🤖 支持的 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 <git ref>`,因此锁定的发布标签同样有效,包括 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 <agent> --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 <extension-name>
|
||||
```
|
||||
|
||||
举例来说,扩展可以添加 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 <preset-name>
|
||||
```
|
||||
|
||||
举例来说,预设可以重构规范模板以要求监管追溯性,将工作流适配为你所用的方法论(如敏捷、看板、瀑布、用户任务驱动或领域驱动设计),在方案中添加强制安全审查关卡,强制要求测试优先的任务排序,或将整个工作流本地化为其他语言。[海盗语演示](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 [<query>]
|
||||
|
||||
# 查看捆绑包将添加的确切组件集合(与实际安装的内容一致)
|
||||
specify bundle info <bundle-id>
|
||||
|
||||
# 一步安装捆绑包的完整组件集合
|
||||
specify bundle install <bundle-id>
|
||||
|
||||
# 查看已安装内容,然后以非破坏性方式更新或移除
|
||||
specify bundle list
|
||||
specify bundle update <bundle-id> # 或 --all
|
||||
specify bundle remove <bundle-id> # 仅移除此捆绑包的组件
|
||||
```
|
||||
|
||||
捆绑包从一个**按优先级排序的目录栈**(项目 > 用户 > 内置)中解析。每个来源都带有安装策略:`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") | 从零生成 | <ul><li>从高层需求出发</li><li>生成规范</li><li>规划实现步骤</li><li>构建生产就绪的应用</li></ul> |
|
||||
| **创意探索** | 并行实现 | <ul><li>探索多样化的解决方案</li><li>支持多种技术栈与架构</li><li>试验不同的用户体验模式</li></ul> |
|
||||
| **迭代增强**("棕地/Brownfield") | 存量系统现代化 | <ul><li>迭代式添加功能</li><li>现代化改造遗留系统</li><li>调整流程</li></ul> |
|
||||
|
||||
对于已有项目,请将 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) 文件。
|
||||
@@ -50,6 +50,7 @@ The following community-contributed extensions are available in [`catalog.commun
|
||||
| Coding Standards Drift Control | Generate coding-standards drift reports and remediation tasks for active Spec Kit features | `code` | Read+Write | [spec-kit-coding-standards-drift-control](https://github.com/benizzio/spec-kit-coding-standards-drift-control) |
|
||||
| Conduct Extension | Orchestrates spec-kit phases via sub-agent delegation to reduce context pollution. | `process` | Read+Write | [spec-kit-conduct-ext](https://github.com/twbrandon7/spec-kit-conduct-ext) |
|
||||
| Confluence Extension | Create a doc in Confluence summarizing the specifications and planning files | `integration` | Read+Write | [spec-kit-confluence](https://github.com/aaronrsun/spec-kit-confluence) |
|
||||
| ContextForge MCP | Integrates codebase-memory-mcp + headroom into Spec Kit — graph-based code intelligence and context compression for the implement phase | `code` | Read+Write | [contextforge-mcp](https://github.com/capatinore/contextforge-mcp) |
|
||||
| Cost Tracker | Track real LLM dollar cost across SDD workflows — per-feature budgets, per-integration comparison, and finance-ready exports | `visibility` | Read+Write | [spec-kit-cost](https://github.com/Quratulain-bilal/spec-kit-cost) |
|
||||
| Data Model Diagram | Generates Mermaid ER diagrams from Spec Kit data models after planning | `docs` | Read+Write | [spec-kit-data-model-diagram](https://github.com/benizzio/spec-kit-data-model-diagram) |
|
||||
| DocGuard — CDD Enforcement | The only doc-integrity engine with an MCP server, SARIF/JUnit output, and a deterministic zero-LLM core. Validates, scores, and traces documentation against code — 27 validators, stable finding codes, adoption baseline for legacy repos, compliance-evidence reports, GitHub Action with PR annotations, spec-kit hooks. Pure Node.js, one pinned dep. | `docs` | Read+Write | [spec-kit-docguard](https://github.com/raccioly/docguard) |
|
||||
@@ -66,6 +67,7 @@ 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) |
|
||||
|
||||
@@ -7,11 +7,11 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| Agent Parity Governance | Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing. | 6 templates, 3 commands | — | [spec-kit-preset-agent-parity-governance](https://github.com/hindermath/spec-kit-preset-agent-parity-governance) |
|
||||
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), 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, resumable remote-transaction boundaries, STRIDE+CAPEC threat modeling, arc42 security cross-cutting concepts, S-ADRs, Zero Trust applicability, OWASP SAMM governance, BSI C3A cloud autonomy, BSI C5 cloud compliance assurance, and audit-ready Spec Kit run evidence | 13 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| 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) |
|
||||
@@ -20,13 +20,13 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose principles. Supports interactive elements like brainstorming, interview, roleplay, and extras like statistics, cover builder, illustration builder, and bio command. Export with templates for KDP, D2D, etc. | 26 templates, 34 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
|
||||
| Game Narrative Writing | Preset for game narrative design and interactive storytelling. It adapts the Spec-Driven Development workflow for game narratives: features become story mechanics, specs become narrative briefs, plans become story maps, and tasks become dialogue and scene-writing tasks. Supports branching narratives, player agency systems, state machines, and interactive dialogue trees. | 37 templates, 34 commands, 5 scripts | — | [speckit-preset-game-narrative-writing](https://github.com/adaumann/speckit-preset-game-narrative-writing) |
|
||||
| Intake Authoring Governance | Governs traceable intake CRUD and language-aware requirements collections with atomic migrations, rollback evidence, and safe series authoring. | 12 templates, 5 commands, 7 scripts | — | [spec-kit-preset-intake-authoring-governance](https://github.com/hindermath/spec-kit-preset-intake-authoring-governance) |
|
||||
| Intake Review Governance | Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes. | 8 templates, 3 commands, 2 scripts | — | [spec-kit-preset-intake-review-governance](https://github.com/hindermath/spec-kit-preset-intake-review-governance) |
|
||||
| Intake Sequencing Governance | Manages traceable intake-series order, typed dependencies, lifecycle, and safe next-candidate selection without executing downstream workflows. | 10 templates, 6 commands, 5 scripts | — | [spec-kit-preset-intake-sequencing-governance](https://github.com/hindermath/spec-kit-preset-intake-sequencing-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 isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
|
||||
| Parallel Autonomous Run Governance | Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
|
||||
@@ -39,6 +39,21 @@ specify workflow run my-pipeline.yml --json
|
||||
|
||||
`workflow_id` is the `workflow.id` declared inside the YAML, not the file name. The object is printed exactly as shown — pretty-printed with two-space indentation, on plain stdout with no Rich markup — so it always parses. While the workflow runs under `--json`, any progress a step would print (for example a gate prompt, or output from a prompt step's CLI subprocess) is redirected to stderr, so stdout carries only the JSON object. Read the object from stdout; leave stderr attached to the terminal or capture it separately.
|
||||
|
||||
For `failed` and `aborted` runs, the payload includes an `error` field carrying the terminal step's error message:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "662bf791",
|
||||
"workflow_id": "build-and-review",
|
||||
"status": "failed",
|
||||
"current_step_id": "boom",
|
||||
"current_step_index": 0,
|
||||
"error": "Command exited with code 3"
|
||||
}
|
||||
```
|
||||
|
||||
`completed` and `paused` runs omit the `error` field. The error is persisted in the run's `state.json`, so `specify workflow status <run_id> --json` surfaces the same message after the fact.
|
||||
|
||||
> **Note:** Most workflow commands require a project already initialized with `specify init`. The exception is `specify workflow run <local-file.{yml,yaml}>`, which can run outside a project; in that case, run state is stored under the current directory's `.specify/workflows/runs/<run_id>/`.
|
||||
|
||||
## Resume a Workflow
|
||||
@@ -88,10 +103,17 @@ specify workflow add <source>
|
||||
|
||||
| Option | Description |
|
||||
| --------------- | ------------------------------------------------------ |
|
||||
| `--dev` | Install from a local workflow YAML file or directory |
|
||||
| `--dev` | Install from a local YAML file, package directory, or archive |
|
||||
| `--from <url>` | Install from a custom URL (`<source>` names the expected workflow ID) |
|
||||
|
||||
Installs a workflow from the catalog, a URL (HTTPS required), a local YAML file, or a local directory containing `workflow.yml`.
|
||||
Installs a workflow from the catalog, an HTTPS URL, a local YAML file, a
|
||||
directory containing `workflow.yml`, or a `.zip`, `.tar.gz`, or `.tgz`
|
||||
archive. Archives may contain `workflow.yml` at the root or inside one
|
||||
top-level directory.
|
||||
|
||||
Directory and archive installs preserve the complete workflow package,
|
||||
including scripts and other companion files. ZIP, `.tar.gz`, and `.tgz`
|
||||
archives follow the same validation and installation behavior.
|
||||
|
||||
## Workflow Overlays
|
||||
|
||||
@@ -266,7 +288,9 @@ Lower priority values have higher precedence. Change this overlay to `priority:
|
||||
|
||||
### Interaction with Bundles and Updates
|
||||
|
||||
`specify workflow add <local-directory>` installs `workflow.yml` from the local directory into `.specify/workflows/<id>/`.
|
||||
`specify workflow add <local-directory>` installs the complete local workflow
|
||||
package into `.specify/workflows/<id>/`. Archive installs preserve the same
|
||||
package contents.
|
||||
|
||||
When an installed workflow is refreshed or reinstalled, project overlays in `.specify/workflows/overlays/<id>/` are preserved because they live outside the installed workflow directory.
|
||||
|
||||
@@ -554,6 +578,64 @@ Each workflow run persists its state at `.specify/workflows/runs/<run_id>/`:
|
||||
|
||||
This enables `specify workflow resume` to continue from the exact step where a run was paused (e.g., at a gate) or failed.
|
||||
|
||||
### Gate Verdict Inputs
|
||||
|
||||
`verdict_input` binds a gate's verdict to a named workflow input. The input must be declared in the workflow's `inputs` block; `specify workflow validate` reports an undeclared reference.
|
||||
|
||||
`verdict_input` is not supported inside a `fan-out` template. Fan-out items
|
||||
share workflow inputs, while workflow state can represent only one paused
|
||||
gate. Place a gate before the fan-out to approve the whole batch, or after a
|
||||
fan-in to review the aggregated results.
|
||||
|
||||
**Input value semantics:**
|
||||
|
||||
| Value | Behavior |
|
||||
|---|---|
|
||||
| Non-empty string, matches an option (case-insensitive) | Gate auto-decides; `output.choice` is set to the configured option spelling |
|
||||
| Non-empty string, no match | Gate fails immediately |
|
||||
| Non-string | Gate fails immediately |
|
||||
| Missing or empty | Gate prompts on a TTY; pauses otherwise |
|
||||
|
||||
**Default value semantics:** A non-empty `default` is consumed as a verdict on the first run — matching an option auto-decides the gate, not matching fails it immediately.
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
spec_verdict:
|
||||
type: string
|
||||
default: ""
|
||||
steps:
|
||||
- id: review-spec
|
||||
type: gate
|
||||
message: "Approve the specification?"
|
||||
options: [approve, reject]
|
||||
on_reject: retry
|
||||
verdict_input: spec_verdict
|
||||
```
|
||||
|
||||
Supply a verdict when resuming:
|
||||
|
||||
```bash
|
||||
specify workflow resume <run_id> --input spec_verdict=approve
|
||||
```
|
||||
|
||||
For `on_reject: retry`, a bound reject verdict is consumed before the gate
|
||||
pauses: the named stored input is reset to `""`. A later resume therefore
|
||||
prompts or pauses again until another verdict is supplied. Approve, abort, and
|
||||
skip outcomes leave the input unchanged.
|
||||
|
||||
Because of that reset, a verdict input used with `on_reject: retry` must accept
|
||||
`""`. If it declares an `enum`, include the empty string — otherwise the reset
|
||||
value violates the input's own `enum` and the run can no longer be resumed with
|
||||
any input. `specify workflow add` reports this as a validation error.
|
||||
|
||||
```yaml
|
||||
inputs:
|
||||
spec_verdict:
|
||||
type: string
|
||||
enum: ["", approve, reject]
|
||||
default: ""
|
||||
```
|
||||
|
||||
## FAQ
|
||||
|
||||
### What happens when a workflow hits a gate step?
|
||||
|
||||
@@ -208,6 +208,74 @@ Restart your IDE to refresh the command list.
|
||||
|
||||
---
|
||||
|
||||
## Behavior change: `/constitution` no longer propagates into templates
|
||||
|
||||
The `/constitution` command ([#3790](https://github.com/github/spec-kit/pull/3790)) is scoped to
|
||||
its own artifact. It updates
|
||||
`.specify/memory/constitution.md` and writes a Sync Impact Report, and **no longer edits**
|
||||
`plan-template.md`, `spec-template.md`, `tasks-template.md`, installed command files, or
|
||||
guidance docs.
|
||||
|
||||
### Why
|
||||
|
||||
Spec Kit uses **runtime resolution**: `plan`, `tasks`, and `analyze` read
|
||||
`.specify/memory/constitution.md` live on every run, and `analyze` is the dedicated drift
|
||||
checker. The governed templates carry a pointer, not a copy — `plan-template.md` ships
|
||||
`[Gates determined based on constitution file]`, and `/plan` fills that section from the live
|
||||
constitution each run. Propagation duplicated the single source of truth and fought the
|
||||
preset/override composition system (a `replace` preset shadows an edited core template).
|
||||
|
||||
More broadly, presets and extensions — not in-place file edits — are how Spec Kit now governs
|
||||
shared assets. Composing policy through the resolution stack keeps it centrally owned, versioned,
|
||||
and auditable across repositories, instead of frozen into per-repo copies no core team can see.
|
||||
|
||||
### Is this a breaking change for existing projects?
|
||||
|
||||
**No — your workflow keeps working.** You would only notice a difference if you relied on
|
||||
`/constitution` editing those files in place. The templates are scaffolds, not authorities. When you
|
||||
run `/plan`, it copies the template into a per-feature `plan.md` and re-derives the Constitution
|
||||
Check from the live constitution; `/analyze` validates against it. Even if a previous
|
||||
`/constitution` run materialized concrete gate text into `.specify/templates/plan-template.md`,
|
||||
the live constitution remains the source of truth at runtime.
|
||||
|
||||
On a **non-forced upgrade**, a materialized template is *preserved* (its hash diverges from the
|
||||
recorded managed copy, so the refresh treats it as a customization and does not overwrite it).
|
||||
Nothing regresses.
|
||||
|
||||
### Optional cleanup — return to the runtime pointer
|
||||
|
||||
A frozen, pre-filled Constitution Check is a slightly misleading scaffold and can bias the first
|
||||
`/plan` pass. To move fully back to runtime resolution, reset the section body in
|
||||
`.specify/templates/plan-template.md` to the pointer:
|
||||
|
||||
```text
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
[Gates determined based on constitution file]
|
||||
```
|
||||
|
||||
Leave the rest of the file untouched. This is cleanup, not a required migration.
|
||||
|
||||
### Keeping the old behavior (opt-in)
|
||||
|
||||
If your team treats the materialized templates as **reviewed, committed artifacts** and wants
|
||||
`/constitution` to keep propagating, install the bundled **`constitution-sync`** preset:
|
||||
|
||||
```bash
|
||||
specify preset add constitution-sync
|
||||
```
|
||||
|
||||
It wraps the core `/constitution` command and re-adds the propagation pass. It does **not** edit
|
||||
versioned preset- or extension-provided templates or command files (those are owned by their
|
||||
packages and are recomposed on reconciliation). Note that this edit-in-place propagation model
|
||||
conflicts with the composition model used by the rest of the SDD commands when they are
|
||||
preset/extension-managed — see the "Interaction with the resolution stack" section in
|
||||
`presets/constitution-sync/README.md` for the tradeoffs and when to prefer the default instead.
|
||||
|
||||
---
|
||||
|
||||
## Common Scenarios
|
||||
|
||||
### Scenario 1: "I just want new slash commands"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-27T00: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": {
|
||||
@@ -1070,6 +1070,41 @@
|
||||
"created_at": "2026-03-29T00:00:00Z",
|
||||
"updated_at": "2026-03-29T00:00:00Z"
|
||||
},
|
||||
"contextforge-mcp": {
|
||||
"name": "ContextForge MCP",
|
||||
"id": "contextforge-mcp",
|
||||
"description": "Integrates codebase-memory-mcp + headroom into Spec Kit — graph-based code intelligence and context compression for the implement phase.",
|
||||
"author": "capatinore",
|
||||
"version": "0.1.0",
|
||||
"download_url": "https://github.com/capatinore/contextforge-mcp/releases/download/ext-v0.1.0/contextforge-mcp-speckit-extension.zip",
|
||||
"repository": "https://github.com/capatinore/contextforge-mcp",
|
||||
"homepage": "https://github.com/capatinore/contextforge-mcp",
|
||||
"documentation": "https://github.com/capatinore/contextforge-mcp/blob/main/README.md",
|
||||
"changelog": "",
|
||||
"license": "MIT",
|
||||
"category": "code",
|
||||
"effect": "read-write",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.10.0"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 4,
|
||||
"hooks": 0
|
||||
},
|
||||
"tags": [
|
||||
"mcp",
|
||||
"code-intelligence",
|
||||
"context-compression",
|
||||
"tokens",
|
||||
"claude",
|
||||
"spec-driven-development"
|
||||
],
|
||||
"verified": false,
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-13T00:00:00Z"
|
||||
},
|
||||
"cost": {
|
||||
"name": "Cost Tracker",
|
||||
"id": "cost",
|
||||
@@ -1861,6 +1896,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",
|
||||
@@ -4820,9 +4889,9 @@
|
||||
"id": "verify-review-ship",
|
||||
"description": "Post-convergence operational verification, technical review, learning governance, and transactional delivery.",
|
||||
"author": "Carlos Eduardo Gevaerd Araujo",
|
||||
"version": "0.4.1",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.4.1.zip",
|
||||
"sha256": "cfa89b405fcf4857745653e923dfab92f101fbdda15e1e8757ad9f2ea55ae5e2",
|
||||
"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",
|
||||
@@ -4851,7 +4920,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-27T00:00:00Z"
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
},
|
||||
"verify-tasks": {
|
||||
"name": "Verify Tasks Extension",
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
"a11y-governance": {
|
||||
"name": "A11Y Governance",
|
||||
"id": "a11y-governance",
|
||||
"version": "0.4.1",
|
||||
"description": "Adds WCAG 2.2 AA governance, accessible text/JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive content, didactic-comment review, and audit-ready evidence.",
|
||||
"version": "0.4.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.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.1/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"
|
||||
@@ -34,18 +34,18 @@
|
||||
"didactic-comments"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
"id": "agent-parity-governance",
|
||||
"version": "0.4.0",
|
||||
"description": "Adds shared-guidance and generated-command parity, fleet-completion evidence, secret-free runner/status metadata, audit-ready evidence, and agent-neutral model routing.",
|
||||
"version": "0.4.1",
|
||||
"description": "Adds shared-guidance and generated-command parity, repository-fleet completion evidence, secret-free runner/status metadata, audit-ready Spec-Kit run evidence, and agent-neutral model-routing guidance across declared AI-agent surfaces.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.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/v0.4.0/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"
|
||||
@@ -64,7 +64,7 @@
|
||||
"multi-agent"
|
||||
],
|
||||
"created_at": "2026-04-27T00:00:00Z",
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
},
|
||||
"aide-in-place": {
|
||||
"name": "AIDE In-Place Migration",
|
||||
@@ -135,13 +135,13 @@
|
||||
"autonomous-run-governance": {
|
||||
"name": "Autonomous Run Governance",
|
||||
"id": "autonomous-run-governance",
|
||||
"version": "0.3.2",
|
||||
"description": "Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation.",
|
||||
"version": "0.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.3.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.3.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"
|
||||
@@ -156,11 +156,10 @@
|
||||
"governance",
|
||||
"evidence",
|
||||
"permissions",
|
||||
"resume",
|
||||
"intake-review"
|
||||
"accessibility"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
@@ -397,13 +396,13 @@
|
||||
"intake-review-governance": {
|
||||
"name": "Intake Review Governance",
|
||||
"id": "intake-review-governance",
|
||||
"version": "0.1.1",
|
||||
"description": "Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes.",
|
||||
"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.1.1.zip",
|
||||
"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.1.1/README.md",
|
||||
"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"
|
||||
@@ -411,36 +410,36 @@
|
||||
"provides": {
|
||||
"templates": 8,
|
||||
"commands": 3,
|
||||
"scripts": 2
|
||||
"scripts": 4
|
||||
},
|
||||
"tags": [
|
||||
"intake",
|
||||
"review",
|
||||
"governance",
|
||||
"quality-gate",
|
||||
"autonomous"
|
||||
"requirements",
|
||||
"quality-gate"
|
||||
],
|
||||
"created_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
},
|
||||
"intake-sequencing-governance": {
|
||||
"name": "Intake Sequencing Governance",
|
||||
"id": "intake-sequencing-governance",
|
||||
"version": "0.1.0",
|
||||
"description": "Manages traceable intake-series order, typed dependencies, lifecycle, and safe next-candidate selection without executing downstream workflows.",
|
||||
"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.1.0.zip",
|
||||
"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.1.0/README.md",
|
||||
"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": 10,
|
||||
"templates": 11,
|
||||
"commands": 6,
|
||||
"scripts": 5
|
||||
"scripts": 8
|
||||
},
|
||||
"tags": [
|
||||
"intake",
|
||||
@@ -450,7 +449,7 @@
|
||||
"lifecycle"
|
||||
],
|
||||
"created_at": "2026-07-27T00:00:00Z",
|
||||
"updated_at": "2026-07-27T00:00:00Z"
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
},
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
@@ -572,16 +571,16 @@
|
||||
"parallel-autonomous-run-governance": {
|
||||
"name": "Parallel Autonomous Run Governance",
|
||||
"id": "parallel-autonomous-run-governance",
|
||||
"version": "0.2.3",
|
||||
"description": "Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling.",
|
||||
"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.3.zip",
|
||||
"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.3/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.4/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 9,
|
||||
@@ -592,12 +591,11 @@
|
||||
"parallel",
|
||||
"autonomous",
|
||||
"governance",
|
||||
"orchestration",
|
||||
"resume",
|
||||
"intake-review"
|
||||
"accessibility",
|
||||
"orchestration"
|
||||
],
|
||||
"created_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-22T00:00:00Z"
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
},
|
||||
"pirate": {
|
||||
"name": "Pirate Speak (Full)",
|
||||
|
||||
@@ -25,6 +25,29 @@
|
||||
"workflow",
|
||||
"core"
|
||||
]
|
||||
},
|
||||
"constitution-sync": {
|
||||
"name": "Constitution Template Sync",
|
||||
"id": "constitution-sync",
|
||||
"version": "1.0.0",
|
||||
"description": "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts.",
|
||||
"author": "github",
|
||||
"repository": "https://github.com/github/spec-kit",
|
||||
"license": "MIT",
|
||||
"bundled": true,
|
||||
"requires": {
|
||||
"speckit_version": ">=0.14.4"
|
||||
},
|
||||
"provides": {
|
||||
"commands": 1,
|
||||
"templates": 0
|
||||
},
|
||||
"tags": [
|
||||
"constitution",
|
||||
"governance",
|
||||
"templates",
|
||||
"compatibility"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
126
presets/constitution-sync/README.md
Normal file
126
presets/constitution-sync/README.md
Normal file
@@ -0,0 +1,126 @@
|
||||
# Constitution Template Sync
|
||||
|
||||
An **opt-in** preset that restores `/constitution`'s ability to propagate amended guidance into your
|
||||
project's own templates and command files. After you update the constitution, it aligns
|
||||
`plan-template.md`, `spec-template.md`, `tasks-template.md`, project-local command files, and
|
||||
guidance docs so they reflect the current principles.
|
||||
|
||||
This propagation used to be built into `/constitution`; it was dropped when the command moved to the
|
||||
preset model. Installing this preset opts you back into it: you get the guidance materialized into
|
||||
reviewed, committed artifacts instead of relying on runtime resolution alone.
|
||||
|
||||
> **What you're opting into.** Propagation was removed deliberately — it duplicates the constitution
|
||||
> as the source of truth and can fight the composition stack (materialized edits get shadowed or
|
||||
> clobbered on the next recompose). This preset knowingly **reintroduces** that behavior, and those
|
||||
> tradeoffs, for teams that want it. Read the [caveats](#caveats-you-take-on) before installing.
|
||||
|
||||
For most projects the default composable stack is the **recommended** approach, and at organization
|
||||
scale it is usually the stronger governance model. Runtime resolution keeps the live constitution as
|
||||
the single source of truth (nothing to re-sync, so nothing drifts), and the stack composes the
|
||||
**entire** Spec Kit ecosystem — not just the SDD commands, but every command, template, script and
|
||||
extension — with explicit priority levels, strategies, and independent versioning. It is a
|
||||
capability, not automatic governance: a core team authors its own organizational presets and
|
||||
extensions, then owns, versions, and audits that policy in one place and rolls it across many
|
||||
repositories, instead of scattering frozen, per-repo copies no central team can see. This preset is
|
||||
a supported escape hatch for teams whose workflow depends on reviewing materialized artifacts
|
||||
directly — useful as a bridge, though for org-wide policy the better long-term path is usually a
|
||||
versioned preset a core team maintains.
|
||||
|
||||
## What it does
|
||||
|
||||
Ships a single `wrap`-strategy override of `speckit.constitution`. It composes on top of the
|
||||
current core command (via `{CORE_TEMPLATE}`), so it stays forward-compatible with core changes, and
|
||||
appends a propagation pass that, after the constitution is written:
|
||||
|
||||
- Aligns `plan/spec/tasks-template.md` in `.specify/templates/` with the updated principles.
|
||||
- Updates **project-local** command files and guidance docs to correct stale references.
|
||||
- Extends the Sync Impact Report in `.specify/memory/constitution.md` with the files it touched.
|
||||
|
||||
## What it does not do
|
||||
|
||||
- It does **not** change behavior for anyone who does not install it — the default runtime
|
||||
resolution model is untouched.
|
||||
- It does **not** disable runtime resolution. `plan`, `tasks`, and `analyze` still read the live
|
||||
constitution every run; this preset adds materialized copies on top — it does not replace the
|
||||
source of truth.
|
||||
- It does **not** edit versioned, package-owned files — templates or command files provided or
|
||||
wrapped by another preset or extension. Those are recomposed from the resolution stack, so it
|
||||
only ever writes into your project's own `.specify/templates/` scaffolds and command files that
|
||||
are not managed by a preset/extension.
|
||||
|
||||
## When to use it
|
||||
|
||||
Install it **only** if your team treats the materialized templates and commands as
|
||||
**reviewed, committed artifacts** — for example, if `plan-template.md`'s Constitution Check is
|
||||
read in PRs as "here are our current gates" and is expected to track the constitution.
|
||||
|
||||
If you rely on the default runtime-resolution model, you do **not** need this preset: the live
|
||||
constitution is already the single source of truth and there is nothing to sync.
|
||||
|
||||
## Caveats you take on
|
||||
|
||||
The preset resolution stack is how Spec Kit composes templates and commands going forward: they are
|
||||
**layered, package-owned artifacts recomposed on demand**, not frozen files you edit in place.
|
||||
Propagation is the opposite idea — it **materializes** guidance into files and freezes it. That
|
||||
tension is the main thing to understand before installing:
|
||||
|
||||
- **Materialized copies can drift.** Anything propagated is a snapshot; if you amend the
|
||||
constitution and do not re-run `/constitution`, the copies fall out of sync. The default runtime
|
||||
model has no drift because it reads the live constitution every run.
|
||||
|
||||
- **Edits to composed files do not survive reconciliation.** If the rest of your SDD flow is
|
||||
preset/extension-managed, the commands it materializes (`speckit.plan`, `speckit.specify`,
|
||||
`speckit.tasks`, `speckit.analyze`, `speckit.implement`, …) are recomputed from the stack. Any
|
||||
guidance propagated into them is clobbered the next time the stack reconciles — on
|
||||
`specify integration use <key>` / `switch`, `specify integration upgrade`, or any preset/extension
|
||||
install or remove. The same applies to templates owned by another preset/extension. This is why
|
||||
the preset restricts itself to project-local files; propagation is reliable **only** for
|
||||
artifacts you own outright.
|
||||
|
||||
- **A pre-filled Constitution Check can bias `/plan`.** Materializing concrete gates into
|
||||
`plan-template.md` replaces the runtime pointer, so the first `/plan` pass may anchor on the
|
||||
frozen text. Keep the pointer unless you specifically want committed gates.
|
||||
|
||||
**Bottom line:** this preset fits projects whose governed templates and commands are project-local
|
||||
artifacts they review, with the rest of the SDD flow on the plain bundled core. If your
|
||||
`plan`/`specify`/`tasks`/`analyze` commands or templates come from other presets or extensions,
|
||||
prefer the default runtime-resolution model.
|
||||
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
# constitution-sync is a bundled preset — no download needed
|
||||
specify preset add constitution-sync
|
||||
```
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
# Test from local directory
|
||||
specify preset add --dev ./presets/constitution-sync
|
||||
|
||||
# Verify the wrapped command resolves
|
||||
specify preset resolve speckit.constitution
|
||||
|
||||
# Remove when done
|
||||
specify preset remove constitution-sync
|
||||
```
|
||||
|
||||
## Migrating back to the default
|
||||
|
||||
To move back to runtime resolution, reset each materialized `## Constitution Check` section in
|
||||
`.specify/templates/plan-template.md` to the pointer:
|
||||
|
||||
```text
|
||||
## Constitution Check
|
||||
|
||||
*GATE: Must pass before Phase 0 research. Re-check after Phase 1 design.*
|
||||
|
||||
[Gates determined based on constitution file]
|
||||
```
|
||||
|
||||
Then remove this preset. See `docs/upgrade.md` for details.
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
54
presets/constitution-sync/commands/speckit.constitution.md
Normal file
54
presets/constitution-sync/commands/speckit.constitution.md
Normal file
@@ -0,0 +1,54 @@
|
||||
---
|
||||
description: Create or update the project constitution, then propagate the amended guidance into dependent templates and installed command files (opt-in template sync).
|
||||
strategy: wrap
|
||||
handoffs:
|
||||
- label: Build Specification
|
||||
agent: speckit.specify
|
||||
prompt: Implement the feature specification based on the updated constitution. I want to build...
|
||||
---
|
||||
|
||||
{CORE_TEMPLATE}
|
||||
|
||||
## Constitution Template Sync
|
||||
|
||||
> **This section supersedes the "Scope Guard" above for template and command propagation.**
|
||||
> The core Scope Guard states that dependent templates and commands "are not modified here" —
|
||||
> that restriction is intentionally lifted by this preset. Propagating amended guidance into the
|
||||
> project's own dependent scaffolds and installed command files (as described below) **is in
|
||||
> scope** whenever this preset is installed. The Scope Guard's other constraints still apply:
|
||||
> do not implement features, generate application code, or touch artifacts unrelated to the
|
||||
> constitution/template workflow.
|
||||
|
||||
After you have written the updated constitution above, perform a consistency propagation pass
|
||||
so the dependent artifacts reflect the amended principles:
|
||||
|
||||
1. Read `.specify/templates/plan-template.md` and ensure any "Constitution Check" or rules align
|
||||
with the updated principles. Only materialize concrete gate text here if your team intends to
|
||||
review it as committed content; otherwise leave the runtime pointer
|
||||
`[Gates determined based on constitution file]` in place so `/plan` fills it from the live
|
||||
constitution.
|
||||
2. Read `.specify/templates/spec-template.md` for scope/requirements alignment — update if the
|
||||
constitution adds/removes mandatory sections or constraints.
|
||||
3. Read `.specify/templates/tasks-template.md` and ensure task categorization reflects new or
|
||||
removed principle-driven task types (e.g., observability, versioning, testing discipline).
|
||||
4. 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-<name>/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. **Only hand-edit a command file if it is a project-local file not managed by a
|
||||
preset or extension.** Command files that are composed from the resolution stack (anything
|
||||
provided or wrapped by a preset/extension) must be regenerated through the stack — do **not**
|
||||
edit them in place, because reconciliation (`specify integration use`, `specify integration
|
||||
upgrade`, or any preset/extension install/remove) will clobber the edits.
|
||||
5. Read any runtime guidance docs (e.g., `README.md`, `docs/quickstart.md`, or agent-specific
|
||||
guidance files if present) and update references to principles that changed.
|
||||
|
||||
Then extend the Sync Impact Report at the top of `.specify/memory/constitution.md` with:
|
||||
|
||||
- Templates requiring updates (✅ updated / ⚠ pending) with file paths.
|
||||
|
||||
**Do not edit versioned preset- or extension-provided template or command files directly.** Those
|
||||
artifacts are owned by their packages and are recomposed on the package's next update or on stack
|
||||
reconciliation — hand edits are clobbered. Limit propagation to the project's own
|
||||
`.specify/templates/` scaffolds and to command files that are not managed by a preset or extension.
|
||||
30
presets/constitution-sync/preset.yml
Normal file
30
presets/constitution-sync/preset.yml
Normal file
@@ -0,0 +1,30 @@
|
||||
schema_version: "1.0"
|
||||
|
||||
preset:
|
||||
id: "constitution-sync"
|
||||
name: "Constitution Template Sync"
|
||||
version: "1.0.0"
|
||||
description: "Opt-in: restores /constitution propagation of amended guidance into plan/spec/tasks templates and installed command files, for teams that treat materialized templates as reviewed artifacts."
|
||||
author: "github"
|
||||
repository: "https://github.com/github/spec-kit"
|
||||
license: "MIT"
|
||||
|
||||
requires:
|
||||
# Requires the runtime-resolution baseline (#3790, shipped in 0.14.4) where the
|
||||
# core /constitution command no longer propagates. Installing this preset on an
|
||||
# older core would double-apply propagation.
|
||||
speckit_version: ">=0.14.4"
|
||||
|
||||
provides:
|
||||
templates:
|
||||
- type: "command"
|
||||
name: "speckit.constitution"
|
||||
file: "commands/speckit.constitution.md"
|
||||
description: "Wrap /constitution to also propagate guidance into dependent templates and command files"
|
||||
strategy: "wrap"
|
||||
|
||||
tags:
|
||||
- "constitution"
|
||||
- "governance"
|
||||
- "templates"
|
||||
- "compatibility"
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.14.3"
|
||||
version = "0.15.2.dev0"
|
||||
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"
|
||||
@@ -49,6 +49,7 @@ packages = ["src/specify_cli"]
|
||||
"workflows/speckit" = "specify_cli/core_pack/workflows/speckit"
|
||||
# Bundled presets (installable via `specify preset add <name>` or `specify init --preset <name>`)
|
||||
"presets/lean" = "specify_cli/core_pack/presets/lean"
|
||||
"presets/constitution-sync" = "specify_cli/core_pack/presets/constitution-sync"
|
||||
# Community bundle catalog snapshot (used for offline discovery)
|
||||
"bundles/catalog.community.json" = "specify_cli/core_pack/bundles/catalog.community.json"
|
||||
|
||||
|
||||
@@ -55,9 +55,17 @@ function Resolve-SpecifyInitDir {
|
||||
}
|
||||
# Resolve-Path echoes back any trailing separator from the input; trim it so
|
||||
# the returned root matches the bash resolver, whose `cd && pwd` never yields
|
||||
# one. TrimEndingDirectorySeparator is a no-op on a bare root and on a path
|
||||
# that already has no trailing separator.
|
||||
$initRoot = [System.IO.Path]::TrimEndingDirectorySeparator($resolved.Path)
|
||||
# one. TrimEnd (not [Path]::TrimEndingDirectorySeparator, which is .NET Core
|
||||
# only) keeps this working on Windows PowerShell 5.1 / .NET Framework, as
|
||||
# Get-FeaturePathsEnv already does below. Unlike a bare TrimEnd, the
|
||||
# GetPathRoot check preserves a path that *is* its own root ('C:\' must not
|
||||
# become 'C:', which every later API re-resolves against the current
|
||||
# directory instead of the drive root). No-op on a path with no trailing
|
||||
# separator.
|
||||
$initRoot = $resolved.Path.TrimEnd('/', '\')
|
||||
if ($initRoot.Length -lt [System.IO.Path]::GetPathRoot($resolved.Path).Length) {
|
||||
$initRoot = $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 }
|
||||
|
||||
@@ -258,16 +258,27 @@ def get_invoke_separator(repo_root: Path) -> str:
|
||||
integration_json = repo_root / ".specify" / "integration.json"
|
||||
if not integration_json.is_file():
|
||||
return "."
|
||||
# Split the parse out of the lookup and guard the top-level shape, matching
|
||||
# read_feature_json_feature_directory above and the bash/PowerShell twins,
|
||||
# which both fall back to "." for any unusable integration.json:
|
||||
# * a non-mapping top level ([], "forge", 42, null) is valid JSON, so
|
||||
# json.JSONDecodeError never fires and state.get(...) raised
|
||||
# AttributeError;
|
||||
# * a non-UTF-8 file raises UnicodeDecodeError, which is a ValueError --
|
||||
# not an OSError -- so it escaped the except tuple. Realistic on
|
||||
# Windows, where PowerShell 5.1's Out-File/`>` default to UTF-16.
|
||||
try:
|
||||
state = json.loads(integration_json.read_text(encoding="utf-8"))
|
||||
key = state.get("default_integration") or state.get("integration") or ""
|
||||
settings = state.get("integration_settings")
|
||||
if isinstance(key, str) and isinstance(settings, dict):
|
||||
entry = settings.get(key)
|
||||
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||
return entry["invoke_separator"]
|
||||
except (OSError, json.JSONDecodeError):
|
||||
pass
|
||||
except (OSError, UnicodeError, json.JSONDecodeError):
|
||||
return "."
|
||||
if not isinstance(state, dict):
|
||||
return "."
|
||||
key = state.get("default_integration") or state.get("integration") or ""
|
||||
settings = state.get("integration_settings")
|
||||
if isinstance(key, str) and isinstance(settings, dict):
|
||||
entry = settings.get(key)
|
||||
if isinstance(entry, dict) and entry.get("invoke_separator") in {".", "-"}:
|
||||
return entry["invoke_separator"]
|
||||
return "."
|
||||
|
||||
|
||||
|
||||
@@ -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,6 +136,7 @@ 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:
|
||||
@@ -177,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,
|
||||
)
|
||||
@@ -188,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:
|
||||
@@ -198,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,
|
||||
)
|
||||
@@ -508,6 +514,11 @@ _register_extension_cmds(app)
|
||||
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,
|
||||
|
||||
@@ -7,6 +7,7 @@ import re
|
||||
import socket
|
||||
import stat
|
||||
import struct
|
||||
import tarfile
|
||||
import unicodedata
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
@@ -14,11 +15,12 @@ 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 typing import BinaryIO, Literal, NoReturn, TypeVar
|
||||
from urllib.parse import ParseResult, urlparse
|
||||
|
||||
|
||||
ErrorT = TypeVar("ErrorT", bound=Exception)
|
||||
ArchiveFormat = Literal["zip", "tar.gz"]
|
||||
|
||||
MAX_DOWNLOAD_BYTES = 50 * 1024 * 1024
|
||||
MAX_ZIP_ENTRIES = 512
|
||||
@@ -67,6 +69,130 @@ _ZIP_MAX_COMMENT_BYTES = (1 << 16) - 1
|
||||
_BOUNDED_ZIP_COMPRESSION_METHODS = frozenset(
|
||||
(zipfile.ZIP_STORED, zipfile.ZIP_DEFLATED)
|
||||
)
|
||||
_ARCHIVE_CONTENT_TYPES: dict[str, ArchiveFormat] = {
|
||||
"application/gzip": "tar.gz",
|
||||
"application/x-gzip": "tar.gz",
|
||||
"application/x-tar+gzip": "tar.gz",
|
||||
"application/zip": "zip",
|
||||
"application/x-zip-compressed": "zip",
|
||||
}
|
||||
|
||||
|
||||
def archive_format_from_name(name: str) -> ArchiveFormat | None:
|
||||
"""Return the supported archive format declared by a path or URL."""
|
||||
try:
|
||||
path = urlparse(name).path.lower()
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if path.endswith(".tar.gz") or path.endswith(".tgz"):
|
||||
return "tar.gz"
|
||||
if path.endswith(".zip"):
|
||||
return "zip"
|
||||
return None
|
||||
|
||||
|
||||
def archive_format_from_content_type(content_type: str | None) -> ArchiveFormat | None:
|
||||
"""Return the supported archive format declared by an HTTP Content-Type."""
|
||||
if not isinstance(content_type, str):
|
||||
return None
|
||||
media_type = content_type.partition(";")[0].strip().lower()
|
||||
return _ARCHIVE_CONTENT_TYPES.get(media_type)
|
||||
|
||||
|
||||
def archive_suffix(archive_format: ArchiveFormat) -> str:
|
||||
"""Return the canonical filename suffix for *archive_format*."""
|
||||
if archive_format == "zip":
|
||||
return ".zip"
|
||||
if archive_format == "tar.gz":
|
||||
return ".tar.gz"
|
||||
raise ValueError(f"Unsupported archive format: {archive_format!r}")
|
||||
|
||||
|
||||
def detect_archive_format(
|
||||
archive_path: Path,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
source_name: str | None = None,
|
||||
content_type: str | None = None,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
) -> ArchiveFormat:
|
||||
"""Validate the declared archive format against the file contents.
|
||||
|
||||
A recognized path/URL suffix is authoritative. For remote responses whose
|
||||
final URL has no archive suffix, a recognized Content-Type may declare the
|
||||
format instead. When both declarations are recognized they must agree, and
|
||||
the resulting declaration must match the archive bytes.
|
||||
"""
|
||||
archive_path = Path(archive_path)
|
||||
name_format = archive_format_from_name(
|
||||
source_name if source_name is not None else str(archive_path)
|
||||
)
|
||||
content_format = archive_format_from_content_type(content_type)
|
||||
if (
|
||||
name_format is not None
|
||||
and content_format is not None
|
||||
and name_format != content_format
|
||||
):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Archive format mismatch: filename declares {name_format} but "
|
||||
f"Content-Type declares {content_format}",
|
||||
)
|
||||
declared_format = name_format or content_format
|
||||
|
||||
with ExitStack() as stack:
|
||||
if archive_file is None:
|
||||
try:
|
||||
archive_file = stack.enter_context(archive_path.open("rb"))
|
||||
except OSError as exc:
|
||||
_raise_from(error_type, f"Invalid archive: {archive_path}", exc)
|
||||
try:
|
||||
archive_file.seek(0)
|
||||
is_zip = zipfile.is_zipfile(archive_file)
|
||||
archive_file.seek(0)
|
||||
signature = archive_file.read(4)
|
||||
# Let the bounded ZIP preflight report structural errors such as
|
||||
# impossible entry counts. ``is_zipfile`` rejects those before the
|
||||
# extractor can produce the established security diagnostic.
|
||||
is_zip = is_zip or signature in {
|
||||
b"PK\x03\x04",
|
||||
b"PK\x05\x06",
|
||||
b"PK\x07\x08",
|
||||
}
|
||||
is_gzip = signature[:2] == b"\x1f\x8b"
|
||||
archive_file.seek(0)
|
||||
is_tar_gz = False
|
||||
if is_gzip:
|
||||
try:
|
||||
with tarfile.open(fileobj=archive_file, mode="r:gz"):
|
||||
is_tar_gz = True
|
||||
except tarfile.TarError:
|
||||
pass
|
||||
archive_file.seek(0)
|
||||
except OSError as exc:
|
||||
_raise_from(error_type, f"Invalid archive: {archive_path}", exc)
|
||||
|
||||
actual_format: ArchiveFormat | None
|
||||
if is_zip and not is_tar_gz:
|
||||
actual_format = "zip"
|
||||
elif is_tar_gz and not is_zip:
|
||||
actual_format = "tar.gz"
|
||||
else:
|
||||
actual_format = None
|
||||
if declared_format is None:
|
||||
if actual_format is None:
|
||||
_raise(
|
||||
error_type,
|
||||
"Unsupported archive format; expected .zip, .tar.gz, or .tgz",
|
||||
)
|
||||
declared_format = actual_format
|
||||
if actual_format != declared_format:
|
||||
actual_label = actual_format or "invalid/unsupported data"
|
||||
_raise(
|
||||
error_type,
|
||||
f"Archive format mismatch: expected {declared_format}, got {actual_label}",
|
||||
)
|
||||
return declared_format
|
||||
|
||||
|
||||
def _ip_address_without_scope(
|
||||
@@ -292,6 +418,7 @@ def build_safe_download_path(
|
||||
*,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
label: str = "archive",
|
||||
suffix: str = ".zip",
|
||||
) -> Path:
|
||||
"""Build a portable single-component archive path inside *target_dir*."""
|
||||
if not isinstance(identifier, str) or not isinstance(version, str):
|
||||
@@ -301,7 +428,9 @@ def build_safe_download_path(
|
||||
f"{identifier!r} and {version!r}",
|
||||
)
|
||||
|
||||
filename = f"{identifier}-{version}.zip"
|
||||
if suffix not in {".zip", ".tar.gz", ".tgz"}:
|
||||
_raise(error_type, f"Unsupported archive download suffix: {suffix!r}")
|
||||
filename = f"{identifier}-{version}{suffix}"
|
||||
try:
|
||||
filename_too_long = (
|
||||
len(filename.encode("utf-8")) > MAX_ZIP_COMPONENT_BYTES
|
||||
@@ -378,24 +507,25 @@ def read_zip_member_limited(
|
||||
)
|
||||
|
||||
|
||||
def normalize_zip_member_name(
|
||||
def normalize_archive_member_name(
|
||||
name: str,
|
||||
*,
|
||||
archive_label: str = "archive",
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
) -> str:
|
||||
"""Return a normalized, portable ZIP member name or raise if unsafe."""
|
||||
"""Return a normalized, portable archive member name or raise if unsafe."""
|
||||
if "\x00" in name:
|
||||
_raise(error_type, f"Unsafe path in ZIP archive: {name!r}")
|
||||
_raise(error_type, f"Unsafe path in {archive_label} 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}")
|
||||
_raise(error_type, f"Unsafe path in {archive_label} archive: {name!r}")
|
||||
if len(encoded_name) > MAX_ZIP_PATH_BYTES:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in ZIP archive: {name!r} "
|
||||
f"Unsafe path in {archive_label} archive: {name!r} "
|
||||
"(not portable across supported filesystems)",
|
||||
)
|
||||
path = PurePosixPath(normalized)
|
||||
@@ -415,7 +545,8 @@ def normalize_zip_member_name(
|
||||
):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in ZIP archive: {name!r} (potential path traversal)",
|
||||
f"Unsafe path in {archive_label} archive: {name!r} "
|
||||
"(potential path traversal)",
|
||||
)
|
||||
for part in raw_parts:
|
||||
reserved_stem = part.partition(".")[0].partition(":")[0].rstrip(" ")
|
||||
@@ -432,13 +563,26 @@ def normalize_zip_member_name(
|
||||
):
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in ZIP archive: {name!r} "
|
||||
f"Unsafe path in {archive_label} archive: {name!r} "
|
||||
"(not portable across supported filesystems)",
|
||||
)
|
||||
return normalized
|
||||
|
||||
|
||||
def portable_zip_path_key(name: str) -> tuple[str, ...]:
|
||||
def normalize_zip_member_name(
|
||||
name: str,
|
||||
*,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
) -> str:
|
||||
"""Return a normalized, portable ZIP member name or raise if unsafe."""
|
||||
return normalize_archive_member_name(
|
||||
name,
|
||||
archive_label="ZIP",
|
||||
error_type=error_type,
|
||||
)
|
||||
|
||||
|
||||
def portable_archive_path_key(name: str) -> tuple[str, ...]:
|
||||
"""Return a comparison key for filesystems with case/Unicode folding."""
|
||||
normalized_name = name.replace("\\", "/")
|
||||
return tuple(
|
||||
@@ -447,6 +591,11 @@ def portable_zip_path_key(name: str) -> tuple[str, ...]:
|
||||
)
|
||||
|
||||
|
||||
def portable_zip_path_key(name: str) -> tuple[str, ...]:
|
||||
"""Backward-compatible ZIP-specific alias for portable archive keys."""
|
||||
return portable_archive_path_key(name)
|
||||
|
||||
|
||||
def _raise_zip64(error_type: type[ErrorT]) -> NoReturn:
|
||||
_raise(
|
||||
error_type,
|
||||
@@ -705,6 +854,7 @@ def _preflight_zip_central_directory(
|
||||
def open_zip_bounded(
|
||||
zip_path: Path,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
max_entries: int = MAX_ZIP_ENTRIES,
|
||||
) -> Iterator[zipfile.ZipFile]:
|
||||
@@ -712,10 +862,11 @@ def open_zip_bounded(
|
||||
_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)
|
||||
if archive_file is None:
|
||||
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,
|
||||
@@ -737,6 +888,7 @@ def safe_extract_zip(
|
||||
zip_path: Path,
|
||||
target_dir: Path,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
error_type: type[ErrorT] = ValueError,
|
||||
max_entries: int = MAX_ZIP_ENTRIES,
|
||||
max_member_bytes: int = MAX_ZIP_MEMBER_BYTES,
|
||||
@@ -752,6 +904,7 @@ def safe_extract_zip(
|
||||
|
||||
with open_zip_bounded(
|
||||
zip_path,
|
||||
archive_file=archive_file,
|
||||
error_type=error_type,
|
||||
max_entries=max_entries,
|
||||
) as zf:
|
||||
@@ -774,7 +927,7 @@ def safe_extract_zip(
|
||||
error_type=error_type,
|
||||
)
|
||||
is_dir = member.is_dir() or normalized_name.endswith("/")
|
||||
path_key = portable_zip_path_key(normalized_name)
|
||||
path_key = portable_archive_path_key(normalized_name)
|
||||
|
||||
existing = validated_paths.get(path_key)
|
||||
if existing is not None:
|
||||
@@ -894,3 +1047,211 @@ def safe_extract_zip(
|
||||
)
|
||||
if limit_error is not None:
|
||||
_raise(error_type, limit_error)
|
||||
|
||||
|
||||
def safe_extract_tar(
|
||||
archive_path: Path,
|
||||
target_dir: Path,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
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 gzip-compressed tar after ZIP-equivalent safety validation."""
|
||||
_validate_non_negative_int(max_entries, "max_entries")
|
||||
_validate_non_negative_int(max_member_bytes, "max_member_bytes")
|
||||
_validate_non_negative_int(max_total_bytes, "max_total_bytes")
|
||||
archive_path = Path(archive_path)
|
||||
try:
|
||||
target_root = target_dir.resolve()
|
||||
except OSError as exc:
|
||||
_raise_from(error_type, f"Invalid tar extraction target: {target_dir}", exc)
|
||||
|
||||
try:
|
||||
if archive_file is not None:
|
||||
archive_file.seek(0)
|
||||
archive = tarfile.open(
|
||||
archive_path if archive_file is None else None,
|
||||
mode="r:gz",
|
||||
fileobj=archive_file,
|
||||
)
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
_raise_from(error_type, f"Invalid tar.gz archive: {archive_path}", exc)
|
||||
|
||||
with archive:
|
||||
validated: list[tuple[tarfile.TarInfo, str, bool]] = []
|
||||
validated_paths: dict[tuple[str, ...], tuple[str, bool]] = {}
|
||||
total_size = 0
|
||||
try:
|
||||
for index, member in enumerate(archive, start=1):
|
||||
if index > max_entries:
|
||||
_raise(
|
||||
error_type,
|
||||
f"tar.gz archive contains too many entries "
|
||||
f"({index} > {max_entries})",
|
||||
)
|
||||
normalized_name = normalize_archive_member_name(
|
||||
member.name,
|
||||
archive_label="tar.gz",
|
||||
error_type=error_type,
|
||||
)
|
||||
is_dir = member.isdir()
|
||||
if member.issym():
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe symlink in tar.gz archive: {member.name}",
|
||||
)
|
||||
if member.islnk():
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe hard link in tar.gz archive: {member.name}",
|
||||
)
|
||||
if not is_dir and not member.isreg():
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe member type in tar.gz archive: {member.name}",
|
||||
)
|
||||
|
||||
path_key = portable_archive_path_key(normalized_name)
|
||||
existing = validated_paths.get(path_key)
|
||||
if existing is not None:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Conflicting path in tar.gz archive: {member.name} "
|
||||
f"conflicts with {existing[0]}",
|
||||
)
|
||||
validated_paths[path_key] = (member.name, is_dir)
|
||||
|
||||
member_path = (target_dir / normalized_name).resolve()
|
||||
try:
|
||||
member_path.relative_to(target_root)
|
||||
except ValueError:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Unsafe path in tar.gz archive: {member.name} "
|
||||
"(potential path traversal)",
|
||||
)
|
||||
|
||||
if not is_dir:
|
||||
if member.size > max_member_bytes:
|
||||
_raise(
|
||||
error_type,
|
||||
f"tar.gz member {member.name} exceeds maximum size "
|
||||
f"of {max_member_bytes} bytes",
|
||||
)
|
||||
total_size += member.size
|
||||
if total_size > max_total_bytes:
|
||||
_raise(
|
||||
error_type,
|
||||
f"tar.gz archive exceeds maximum uncompressed size "
|
||||
f"of {max_total_bytes} bytes",
|
||||
)
|
||||
validated.append((member, normalized_name, is_dir))
|
||||
except (tarfile.TarError, OSError) as exc:
|
||||
_raise_from(
|
||||
error_type,
|
||||
f"Invalid tar.gz archive: {archive_path}",
|
||||
exc,
|
||||
)
|
||||
|
||||
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 tar.gz archive: {original} conflicts "
|
||||
f"with {next_original}",
|
||||
)
|
||||
|
||||
total_written = 0
|
||||
for member, normalized_name, is_dir in validated:
|
||||
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 tar.gz directory {member.name}: {exc}",
|
||||
exc,
|
||||
)
|
||||
continue
|
||||
try:
|
||||
member_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
source = archive.extractfile(member)
|
||||
if source is None:
|
||||
_raise(
|
||||
error_type,
|
||||
f"Failed to read tar.gz member {member.name}",
|
||||
)
|
||||
written = 0
|
||||
limit_error: str | None = None
|
||||
with 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"tar.gz member {member.name} exceeds maximum size "
|
||||
f"of {max_member_bytes} bytes"
|
||||
)
|
||||
break
|
||||
total_written += len(chunk)
|
||||
if total_written > max_total_bytes:
|
||||
limit_error = (
|
||||
f"tar.gz 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 tar.gz member {member.name}: {exc}",
|
||||
exc,
|
||||
)
|
||||
if limit_error is not None:
|
||||
_raise(error_type, limit_error)
|
||||
|
||||
|
||||
def safe_extract_archive(
|
||||
archive_path: Path,
|
||||
target_dir: Path,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
source_name: str | None = None,
|
||||
content_type: str | None = None,
|
||||
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,
|
||||
) -> ArchiveFormat:
|
||||
"""Detect and securely extract a supported archive."""
|
||||
archive_format = detect_archive_format(
|
||||
archive_path,
|
||||
archive_file=archive_file,
|
||||
source_name=source_name,
|
||||
content_type=content_type,
|
||||
error_type=error_type,
|
||||
)
|
||||
extractor = safe_extract_zip if archive_format == "zip" else safe_extract_tar
|
||||
extractor(
|
||||
archive_path,
|
||||
target_dir,
|
||||
archive_file=archive_file,
|
||||
error_type=error_type,
|
||||
max_entries=max_entries,
|
||||
max_member_bytes=max_member_bytes,
|
||||
max_total_bytes=max_total_bytes,
|
||||
)
|
||||
return archive_format
|
||||
|
||||
@@ -159,8 +159,9 @@ def resolve_github_release_asset_api_url(
|
||||
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}"
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@ from __future__ import annotations
|
||||
DOLLAR_SKILLS_AGENTS: frozenset[str] = frozenset({"codex", "zcode"})
|
||||
|
||||
# Agents that always render /speckit-<name>, regardless of ai_skills.
|
||||
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "grok", "trae", "zed"})
|
||||
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "droid", "grok", "trae", "zed"})
|
||||
|
||||
# Agents that render /speckit-<name> only when ai_skills is enabled.
|
||||
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
|
||||
@@ -29,6 +29,9 @@ CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
|
||||
}
|
||||
)
|
||||
|
||||
# Agents that render /skill:<name> (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-<name>`` invocations.
|
||||
@@ -41,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-<name>`` invocations.
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ from pathlib import Path
|
||||
|
||||
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
|
||||
@@ -1230,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:
|
||||
@@ -1270,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)
|
||||
|
||||
@@ -15,6 +15,7 @@ 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
|
||||
@@ -301,8 +302,20 @@ class CommandRegistrar:
|
||||
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("")
|
||||
|
||||
@@ -598,7 +611,7 @@ class CommandRegistrar:
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: Optional[str] = None,
|
||||
_resolved_dir: Path = None,
|
||||
_resolved_dir: Optional[Path] = None,
|
||||
link_outputs: bool = False,
|
||||
extension_id: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
@@ -659,17 +672,16 @@ class CommandRegistrar:
|
||||
# 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:
|
||||
registrar_writes_skills = (
|
||||
agent_config.get("extension") == "/SKILL.md"
|
||||
)
|
||||
_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"]
|
||||
@@ -772,7 +784,7 @@ class CommandRegistrar:
|
||||
# (base.py itself imports CommandRegistrar lazily).
|
||||
from specify_cli.integrations.base import IntegrationBase # noqa: PLC0415
|
||||
|
||||
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)
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json as _json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -71,9 +72,27 @@ class AzureDevOpsAuth(AuthProvider):
|
||||
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",
|
||||
|
||||
@@ -58,7 +58,12 @@ def load_yaml(path: Path) -> Any:
|
||||
raise BundlerError(f"File not found: {path}")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except OSError as exc:
|
||||
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
|
||||
@@ -98,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
|
||||
|
||||
|
||||
|
||||
@@ -96,19 +96,19 @@ class 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")
|
||||
@@ -117,7 +117,7 @@ class BundleManifest:
|
||||
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"),
|
||||
)
|
||||
@@ -220,6 +220,22 @@ class BundleManifest:
|
||||
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.
|
||||
|
||||
@@ -247,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,
|
||||
|
||||
@@ -18,7 +18,7 @@ from urllib.request import url2pathname
|
||||
from ..._assets import _locate_core_pack, _repo_root
|
||||
from ..._download_security import MAX_JSON_CATALOG_BYTES, read_response_limited
|
||||
from .. import BundlerError
|
||||
from ..lib.yamlio import loads_json
|
||||
from ..lib.yamlio import load_json, loads_json
|
||||
from ..models.catalog import CatalogSource
|
||||
from ..models.manifest import ComponentRef
|
||||
|
||||
@@ -145,13 +145,13 @@ def make_catalog_fetcher(*, allow_network: bool = True):
|
||||
path = _file_url_to_path(parsed)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"Catalog file not found: {path}")
|
||||
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
|
||||
return load_json(path)
|
||||
|
||||
if scheme == "" or _is_windows_drive_path(url):
|
||||
path = Path(url)
|
||||
if not path.exists():
|
||||
raise BundlerError(f"Catalog file not found: {path}")
|
||||
return loads_json(path.read_text(encoding="utf-8"), origin=str(path))
|
||||
return load_json(path)
|
||||
|
||||
if scheme in ("http", "https"):
|
||||
if not allow_network:
|
||||
@@ -227,6 +227,10 @@ class DefaultPrimitiveInstaller:
|
||||
manager = self._manager_for(component, project_root)
|
||||
manager.install(component)
|
||||
|
||||
def refresh(self, project_root: Path, component: ComponentRef) -> None:
|
||||
manager = self._manager_for(component, project_root)
|
||||
manager.refresh(component)
|
||||
|
||||
def remove(self, project_root: Path, component: ComponentRef) -> None:
|
||||
manager = self._manager_for(component, project_root)
|
||||
manager.remove(component)
|
||||
|
||||
@@ -93,9 +93,11 @@ def build_bundle(
|
||||
# extraction, but collapse to two canonical modes (0755 when any
|
||||
# execute bit is set on the source, otherwise 0644) so identical
|
||||
# inputs yield a byte-for-byte identical artifact.
|
||||
mode = 0o755 if file_path.stat().st_mode & 0o111 else 0o644
|
||||
info.external_attr = mode << 16
|
||||
archive.writestr(info, file_path.read_bytes())
|
||||
with file_path.open("rb") as fh:
|
||||
st = os.fstat(fh.fileno())
|
||||
mode = 0o755 if st.st_mode & 0o111 else 0o644
|
||||
info.external_attr = mode << 16
|
||||
archive.writestr(info, fh.read())
|
||||
|
||||
return BuildResult(artifact_path=artifact_path, file_count=len(files))
|
||||
|
||||
|
||||
@@ -85,11 +85,17 @@ def _bundled_manifest_version(manifest_path: Path, root_key: str) -> str | None:
|
||||
|
||||
|
||||
class _KindManager(Protocol):
|
||||
def is_installed(self, component: ComponentRef) -> bool: ...
|
||||
def is_installed(self, component: ComponentRef) -> bool:
|
||||
pass
|
||||
|
||||
def install(self, component: ComponentRef) -> None: ...
|
||||
def install(self, component: ComponentRef) -> None:
|
||||
pass
|
||||
|
||||
def remove(self, component: ComponentRef) -> None: ...
|
||||
def refresh(self, component: ComponentRef) -> None:
|
||||
pass
|
||||
|
||||
def remove(self, component: ComponentRef) -> None:
|
||||
pass
|
||||
|
||||
|
||||
def primitive_manager(
|
||||
@@ -151,6 +157,12 @@ class _PresetKindManager:
|
||||
return False
|
||||
|
||||
def install(self, component: ComponentRef) -> None:
|
||||
self._do_install(component, force=False)
|
||||
|
||||
def refresh(self, component: ComponentRef) -> None:
|
||||
self._do_install(component, force=True)
|
||||
|
||||
def _do_install(self, component: ComponentRef, *, force: bool) -> None:
|
||||
from ... import get_speckit_version
|
||||
from ..._assets import _locate_bundled_preset
|
||||
|
||||
@@ -168,7 +180,9 @@ class _PresetKindManager:
|
||||
component.version,
|
||||
_bundled_manifest_version(bundled / "preset.yml", "preset"),
|
||||
)
|
||||
self._manager.install_from_directory(bundled, speckit_version, priority)
|
||||
self._manager.install_from_directory(
|
||||
bundled, speckit_version, priority, **({"force": True} if force else {})
|
||||
)
|
||||
return
|
||||
|
||||
if not self._allow_network:
|
||||
@@ -194,7 +208,9 @@ class _PresetKindManager:
|
||||
)
|
||||
zip_path = catalog.download_pack(component.id)
|
||||
try:
|
||||
self._manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
self._manager.install_from_zip(
|
||||
zip_path, speckit_version, priority, **({"force": True} if force else {})
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
if zip_path.exists():
|
||||
@@ -224,6 +240,12 @@ class _ExtensionKindManager:
|
||||
return False
|
||||
|
||||
def install(self, component: ComponentRef) -> None:
|
||||
self._do_install(component, force=False)
|
||||
|
||||
def refresh(self, component: ComponentRef) -> None:
|
||||
self._do_install(component, force=True)
|
||||
|
||||
def _do_install(self, component: ComponentRef, *, force: bool) -> None:
|
||||
from ... import get_speckit_version
|
||||
from ..._assets import _locate_bundled_extension
|
||||
|
||||
@@ -242,7 +264,7 @@ class _ExtensionKindManager:
|
||||
_bundled_manifest_version(bundled / "extension.yml", "extension"),
|
||||
)
|
||||
self._manager.install_from_directory(
|
||||
bundled, speckit_version, priority=priority
|
||||
bundled, speckit_version, priority=priority, force=force
|
||||
)
|
||||
return
|
||||
|
||||
@@ -272,7 +294,7 @@ class _ExtensionKindManager:
|
||||
zip_path = catalog.download_extension(component.id)
|
||||
try:
|
||||
self._manager.install_from_zip(
|
||||
zip_path, speckit_version, priority=priority
|
||||
zip_path, speckit_version, priority=priority, force=force
|
||||
)
|
||||
finally:
|
||||
with contextlib.suppress(Exception):
|
||||
@@ -318,6 +340,11 @@ class _WorkflowKindManager:
|
||||
lambda: workflow_add(component.id),
|
||||
)
|
||||
|
||||
def refresh(self, component: ComponentRef) -> None:
|
||||
# workflow_add is idempotent for already-installed workflows; delegate
|
||||
# to the standard install path which handles version refresh correctly.
|
||||
self.install(component)
|
||||
|
||||
def _assert_pinned_version(self, component: ComponentRef) -> None:
|
||||
if not component.version:
|
||||
return
|
||||
@@ -378,6 +405,35 @@ class _StepKindManager:
|
||||
lambda: workflow_step_add(component.id),
|
||||
)
|
||||
|
||||
def refresh(self, component: ComponentRef) -> None:
|
||||
# Preserve an existing step until we've validated we can perform refresh.
|
||||
# For already-installed steps, keep a backup and restore it if the
|
||||
# remove+reinstall path fails.
|
||||
if not (self._allow_network and self.is_installed(component)):
|
||||
self.install(component)
|
||||
return
|
||||
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
step_dir = self._registry.steps_dir / component.id
|
||||
metadata = self._registry.get(component.id)
|
||||
backup_dir = Path(tempfile.mkdtemp(prefix="speckit-step-refresh-")) / component.id
|
||||
try:
|
||||
if step_dir.exists():
|
||||
shutil.copytree(step_dir, backup_dir)
|
||||
self.remove(component)
|
||||
try:
|
||||
self.install(component)
|
||||
except BundlerError:
|
||||
if backup_dir.exists():
|
||||
shutil.copytree(backup_dir, step_dir, dirs_exist_ok=True)
|
||||
if metadata is not None and not self._registry.is_installed(component.id):
|
||||
self._registry.add(component.id, metadata)
|
||||
raise
|
||||
finally:
|
||||
shutil.rmtree(backup_dir.parent, ignore_errors=True)
|
||||
|
||||
def remove(self, component: ComponentRef) -> None:
|
||||
from ... import workflow_step_remove
|
||||
|
||||
|
||||
@@ -74,6 +74,13 @@ class CatalogStackBase:
|
||||
try:
|
||||
parsed = urlparse(url)
|
||||
hostname = parsed.hostname
|
||||
# Accessing ``port`` performs urllib's syntax/range validation;
|
||||
# ``hostname`` alone does not, so a non-numeric or out-of-range
|
||||
# port would otherwise pass validation here and only fail later,
|
||||
# at fetch time, as an error this module does not translate --
|
||||
# a raw http.client.InvalidURL for a non-numeric port, and a
|
||||
# socket-layer failure for one that is merely out of range.
|
||||
_ = parsed.port
|
||||
except ValueError:
|
||||
raise cls._error(f"Catalog URL is malformed: {url}") from None
|
||||
is_localhost = hostname in ("localhost", "127.0.0.1", "::1")
|
||||
|
||||
@@ -12,6 +12,7 @@ import re
|
||||
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
|
||||
@@ -118,6 +119,8 @@ def _run_init(integration: str, *, script_type: str, offline: bool = False) -> N
|
||||
preset=None,
|
||||
integration=integration,
|
||||
integration_options=None,
|
||||
extensions=None,
|
||||
trust_extension_urls=False,
|
||||
)
|
||||
except typer.Exit as exc:
|
||||
if exc.exit_code:
|
||||
@@ -185,11 +188,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")
|
||||
@@ -242,16 +250,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):")
|
||||
@@ -261,18 +284,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(
|
||||
|
||||
39
src/specify_cli/commands/event.py
Normal file
39
src/specify_cli/commands/event.py
Normal file
@@ -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")
|
||||
@@ -30,6 +30,145 @@ def _stdin_is_interactive() -> bool:
|
||||
return sys.stdin.isatty()
|
||||
|
||||
|
||||
def _ext_spec_is_url(ext_spec: str) -> bool:
|
||||
"""Return True when *ext_spec* is an http(s) URL rather than a name/path."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
try:
|
||||
return urlparse(ext_spec).scheme in ("http", "https")
|
||||
except ValueError:
|
||||
return False
|
||||
|
||||
|
||||
def _confirm_extension_url_trust(
|
||||
url_specs: list[str], *, trust_override: bool
|
||||
) -> dict[str, bool]:
|
||||
"""Resolve trust for each URL-based extension before the Live display.
|
||||
|
||||
URL installs pull an arbitrary external extension, so they get the same
|
||||
default-deny confirmation as ``extension add --from``. Returns a mapping of
|
||||
``url_spec -> approved``. With *trust_override* every URL is pre-approved.
|
||||
In a non-interactive session without the override, every URL is denied
|
||||
(the prompt cannot be answered), mirroring the default-deny posture.
|
||||
"""
|
||||
from rich.markup import escape as _escape_markup
|
||||
from rich.panel import Panel
|
||||
|
||||
approvals: dict[str, bool] = {}
|
||||
interactive = _stdin_is_interactive()
|
||||
for spec in url_specs:
|
||||
if trust_override:
|
||||
approvals[spec] = True
|
||||
continue
|
||||
if not interactive:
|
||||
approvals[spec] = False
|
||||
continue
|
||||
console.print()
|
||||
console.print(
|
||||
Panel(
|
||||
"[bold]You are installing an extension from an external URL that is not\n"
|
||||
"listed in any of your configured extension catalogs.[/bold]\n\n"
|
||||
f"URL: {_escape_markup(spec)}\n\n"
|
||||
"Only install extensions from sources you trust.",
|
||||
title="[bold yellow]⚠ Untrusted Source[/bold yellow]",
|
||||
border_style="yellow",
|
||||
padding=(1, 2),
|
||||
)
|
||||
)
|
||||
console.print()
|
||||
approvals[spec] = typer.confirm(
|
||||
f"Install extension from {spec}?", default=False
|
||||
)
|
||||
return approvals
|
||||
|
||||
|
||||
def _install_extension_during_init(project_path: Path, ext_spec: str, speckit_version: str) -> str:
|
||||
"""Install a single extension during ``specify init``.
|
||||
|
||||
Handles bundled extension names, local directory paths, and HTTPS URLs.
|
||||
Returns a short status message on success.
|
||||
Raises ``ValueError`` on failure so the caller can convert it to a
|
||||
tracker error without aborting the entire init.
|
||||
"""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
from .._assets import _locate_bundled_extension
|
||||
from ..extensions import ExtensionCatalog, ExtensionError, ExtensionManager
|
||||
from ..extensions._commands import (
|
||||
_resolve_catalog_extension,
|
||||
install_extension_from_url,
|
||||
)
|
||||
|
||||
manager = ExtensionManager(project_path)
|
||||
|
||||
# --- URL ---
|
||||
parsed = urlparse(ext_spec)
|
||||
if parsed.scheme in ("http", "https"):
|
||||
try:
|
||||
manifest = install_extension_from_url(
|
||||
manager, project_path, ext_spec, speckit_version
|
||||
)
|
||||
except ExtensionError as exc:
|
||||
raise ValueError(str(exc)) from exc
|
||||
return f"{manifest.name} v{manifest.version} installed"
|
||||
|
||||
# --- Local path ---
|
||||
if ext_spec.startswith(("./", "../", "/", "~/", ".\\", "..\\")) or Path(ext_spec).is_absolute():
|
||||
source_path = Path(ext_spec).expanduser().resolve()
|
||||
if not source_path.exists():
|
||||
raise ValueError(f"Directory not found: {source_path}")
|
||||
if not (source_path / "extension.yml").exists():
|
||||
raise ValueError(f"No extension.yml found in {source_path}")
|
||||
manifest = manager.install_from_directory(source_path, speckit_version)
|
||||
return f"{manifest.name} v{manifest.version} installed"
|
||||
|
||||
# --- Bundled extension name or catalog ID ---
|
||||
bundled_path = _locate_bundled_extension(ext_spec)
|
||||
if bundled_path is not None:
|
||||
if manager.registry.is_installed(ext_spec):
|
||||
return "already installed"
|
||||
manifest = manager.install_from_directory(bundled_path, speckit_version)
|
||||
return f"{manifest.name} v{manifest.version} installed"
|
||||
|
||||
# Fall back to catalog
|
||||
catalog = ExtensionCatalog(project_path)
|
||||
ext_info, catalog_error = _resolve_catalog_extension(ext_spec, catalog, "add")
|
||||
if catalog_error:
|
||||
raise ValueError(f"Could not query extension catalog: {catalog_error}")
|
||||
if not ext_info:
|
||||
raise ValueError(f"Extension '{ext_spec}' not found in bundled extensions or catalog")
|
||||
|
||||
resolved_id = ext_info["id"]
|
||||
if resolved_id != ext_spec:
|
||||
bundled_path = _locate_bundled_extension(resolved_id)
|
||||
if bundled_path is not None:
|
||||
if manager.registry.is_installed(resolved_id):
|
||||
return "already installed"
|
||||
manifest = manager.install_from_directory(bundled_path, speckit_version)
|
||||
return f"{manifest.name} v{manifest.version} installed"
|
||||
|
||||
if ext_info.get("bundled") and not ext_info.get("download_url"):
|
||||
from ..extensions import REINSTALL_COMMAND
|
||||
|
||||
raise ValueError(
|
||||
f"Extension '{resolved_id}' is bundled with spec-kit but not found in the installed package. "
|
||||
f"Try reinstalling spec-kit: {REINSTALL_COMMAND}"
|
||||
)
|
||||
|
||||
if not ext_info.get("_install_allowed", True):
|
||||
catalog_name = ext_info.get("_catalog_name", "community")
|
||||
raise ValueError(
|
||||
f"Extension '{ext_spec}' is in the '{catalog_name}' catalog but installation is not allowed from that catalog"
|
||||
)
|
||||
|
||||
zip_path = catalog.download_extension(resolved_id)
|
||||
try:
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version)
|
||||
finally:
|
||||
zip_path.unlink(missing_ok=True)
|
||||
return f"{manifest.name} v{manifest.version} installed"
|
||||
|
||||
|
||||
def ensure_constitution_from_template(
|
||||
project_path: Path, tracker: StepTracker | None = None
|
||||
) -> None:
|
||||
@@ -142,6 +281,16 @@ def register(app: typer.Typer) -> None:
|
||||
"--integration-options",
|
||||
help='Options for the integration (e.g. --integration-options="--commands-dir .myagent/cmds")',
|
||||
),
|
||||
extensions: list[str] | None = typer.Option(
|
||||
None,
|
||||
"--extension",
|
||||
help="Install an extension during initialization (bundled name, local path, or HTTPS URL). Repeatable.",
|
||||
),
|
||||
trust_extension_urls: bool = typer.Option(
|
||||
False,
|
||||
"--trust-extension-urls",
|
||||
help="Pre-authorize installing extensions from external URLs without the interactive trust prompt (required for non-interactive URL installs).",
|
||||
),
|
||||
):
|
||||
"""
|
||||
Initialize a new Specify project.
|
||||
@@ -174,6 +323,10 @@ def register(app: typer.Typer) -> None:
|
||||
specify init --here --integration gemini
|
||||
specify init my-project --integration generic --integration-options="--commands-dir .myagent/commands/" # Bring your own agent; requires --commands-dir
|
||||
specify init my-project --integration claude --preset healthcare-compliance # With preset
|
||||
specify init my-project --integration copilot --extension git # With bundled extension
|
||||
specify init my-project --extension git --extension selftest # Multiple extensions
|
||||
specify init my-project --extension ./my-extensions/custom-ext # Local path extension
|
||||
specify init my-project --extension https://example.com/extensions/my-ext.zip --trust-extension-urls # URL extension (non-interactive)
|
||||
"""
|
||||
# Lazy imports to avoid circular dependency — __init__.py imports this module
|
||||
from .. import (
|
||||
@@ -183,6 +336,7 @@ def register(app: typer.Typer) -> None:
|
||||
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 (
|
||||
@@ -412,10 +566,31 @@ def register(app: typer.Typer) -> None:
|
||||
("chmod", "Ensure scripts executable"),
|
||||
("constitution", "Constitution setup"),
|
||||
("workflow", "Install bundled workflow"),
|
||||
("final", "Finalize"),
|
||||
]:
|
||||
tracker.add(key, label)
|
||||
|
||||
if extensions:
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
for i, ext_spec in enumerate(extensions):
|
||||
tracker.add(
|
||||
f"extension-{i}", f"Install extension: {_escape_markup(ext_spec)}"
|
||||
)
|
||||
|
||||
tracker.add("final", "Finalize")
|
||||
|
||||
# Resolve trust for URL-based extensions BEFORE entering the Live
|
||||
# display: the confirmation prompt cannot be shown/answered underneath
|
||||
# the Rich Live spinner. URL installs are default-deny unless the user
|
||||
# confirms interactively or passes --trust-extension-urls.
|
||||
extension_url_approvals: dict[str, bool] = {}
|
||||
if extensions:
|
||||
url_specs = [e for e in extensions if _ext_spec_is_url(e)]
|
||||
if url_specs:
|
||||
extension_url_approvals = _confirm_extension_url_trust(
|
||||
url_specs, trust_override=trust_extension_urls
|
||||
)
|
||||
|
||||
# Disable transient mode on Windows: PowerShell 5.1's legacy console
|
||||
# hangs when Rich tries to restore cursor state via VT escape sequences.
|
||||
_transient = sys.platform != "win32"
|
||||
@@ -442,12 +617,20 @@ def register(app: typer.Typer) -> None:
|
||||
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()
|
||||
|
||||
@@ -481,6 +664,12 @@ def register(app: typer.Typer) -> None:
|
||||
invoke_separator=resolved_integration.effective_invoke_separator(
|
||||
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(
|
||||
"shared-infra", f"scripts ({selected_script}) + templates"
|
||||
@@ -611,6 +800,46 @@ def register(app: typer.Typer) -> None:
|
||||
continuing="Continuing without the optional preset.",
|
||||
)
|
||||
|
||||
# Install extensions specified via --extension
|
||||
if extensions:
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from ..extensions._commands import _refresh_events_and_warn
|
||||
|
||||
speckit_ver = get_speckit_version()
|
||||
any_extension_installed = False
|
||||
for i, ext_spec in enumerate(extensions):
|
||||
tracker.start(f"extension-{i}")
|
||||
# Skip URL extensions the user did not confirm as trusted
|
||||
# (default-deny; resolved before the Live display).
|
||||
if _ext_spec_is_url(ext_spec) and not extension_url_approvals.get(
|
||||
ext_spec, False
|
||||
):
|
||||
tracker.error(
|
||||
f"extension-{i}",
|
||||
"skipped: untrusted URL not confirmed "
|
||||
"(use --trust-extension-urls)",
|
||||
)
|
||||
continue
|
||||
try:
|
||||
status_msg = _install_extension_during_init(
|
||||
project_path, ext_spec, speckit_ver
|
||||
)
|
||||
tracker.complete(f"extension-{i}", status_msg)
|
||||
any_extension_installed = True
|
||||
except Exception as ext_err:
|
||||
sanitized_ext = str(ext_err).replace("\n", " ").strip()
|
||||
tracker.error(
|
||||
f"extension-{i}",
|
||||
f"failed: {_escape_markup(sanitized_ext[:120])}",
|
||||
)
|
||||
|
||||
# Refresh native event configuration once after the batch so
|
||||
# that an extension declaring ``events:`` has its hooks
|
||||
# activated, mirroring the ``extension add`` path.
|
||||
if any_extension_installed:
|
||||
_refresh_events_and_warn(project_path)
|
||||
|
||||
# Seed the constitution AFTER preset installation so that a
|
||||
# preset-provided constitution-template (resolved via the
|
||||
# priority stack) wins over the core template.
|
||||
|
||||
2098
src/specify_cli/events.py
Normal file
2098
src/specify_cli/events.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -20,7 +20,7 @@ import tempfile
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime, timezone
|
||||
from pathlib import Path
|
||||
from typing import Any, Callable, Dict, List, Optional, Set
|
||||
from typing import Any, BinaryIO, Callable, Dict, List, Optional, Set
|
||||
|
||||
import pathspec
|
||||
import yaml
|
||||
@@ -29,11 +29,14 @@ from packaging.specifiers import InvalidSpecifier, SpecifierSet
|
||||
|
||||
from .._assets import _locate_core_pack, _repo_root
|
||||
from .._download_security import (
|
||||
archive_format_from_name,
|
||||
archive_suffix,
|
||||
MAX_JSON_CATALOG_BYTES,
|
||||
build_safe_download_path,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
read_response_limited,
|
||||
safe_extract_zip,
|
||||
safe_extract_archive,
|
||||
)
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
|
||||
@@ -263,8 +266,25 @@ class ExtensionManifest:
|
||||
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}")
|
||||
@@ -299,24 +319,37 @@ class ExtensionManifest:
|
||||
|
||||
# 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.
|
||||
@@ -440,6 +473,33 @@ class ExtensionManifest:
|
||||
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.<id>.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.
|
||||
@@ -509,8 +569,11 @@ class ExtensionManifest:
|
||||
|
||||
def get_hash(self) -> str:
|
||||
"""Calculate SHA256 hash of manifest file."""
|
||||
h = hashlib.sha256()
|
||||
with open(self.path, "rb") as f:
|
||||
return f"sha256:{hashlib.sha256(f.read()).hexdigest()}"
|
||||
for chunk in iter(lambda: f.read(8192), b""):
|
||||
h.update(chunk)
|
||||
return f"sha256:{h.hexdigest()}"
|
||||
|
||||
|
||||
class ExtensionRegistry:
|
||||
@@ -535,7 +598,7 @@ class ExtensionRegistry:
|
||||
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):
|
||||
@@ -551,7 +614,7 @@ class ExtensionRegistry:
|
||||
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):
|
||||
@@ -1267,6 +1330,7 @@ class ExtensionManager:
|
||||
manifest: ExtensionManifest,
|
||||
extension_dir: Path,
|
||||
link_outputs: bool = False,
|
||||
force: bool = False,
|
||||
) -> List[str]:
|
||||
"""Generate SKILL.md files for extension commands as agent skills.
|
||||
|
||||
@@ -1280,6 +1344,11 @@ class ExtensionManager:
|
||||
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).
|
||||
@@ -1367,13 +1436,16 @@ class ExtensionManager:
|
||||
)
|
||||
# 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:
|
||||
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
|
||||
@@ -2334,7 +2406,7 @@ class ExtensionManager:
|
||||
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
|
||||
# archive extraction in install_from_archive, which delegates here, does
|
||||
# not restore a stripped Unix mode, so a bundled *.sh would land non-executable
|
||||
# and a documented `.specify/extensions/<id>/scripts/...` invocation would fail
|
||||
# with "Permission denied". This is the single sink every install route funnels
|
||||
@@ -2353,21 +2425,27 @@ class ExtensionManager:
|
||||
|
||||
return manifest
|
||||
|
||||
def install_from_zip(
|
||||
def install_from_archive(
|
||||
self,
|
||||
zip_path: Path,
|
||||
archive_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
source_name: str | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> ExtensionManifest:
|
||||
"""Install extension from ZIP file.
|
||||
"""Install an extension from a supported archive.
|
||||
|
||||
Args:
|
||||
zip_path: Path to extension ZIP file
|
||||
archive_path: Path to a .zip, .tar.gz, or .tgz archive
|
||||
speckit_version: Current spec-kit version
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
force: If True and extension is already installed, remove it first
|
||||
before proceeding with installation
|
||||
archive_file: Already-open archive stream to consume instead of
|
||||
reopening ``zip_path``
|
||||
|
||||
Returns:
|
||||
Installed extension manifest
|
||||
@@ -2383,7 +2461,14 @@ class ExtensionManager:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
temp_path = Path(tmpdir)
|
||||
|
||||
safe_extract_zip(zip_path, temp_path, error_type=ValidationError)
|
||||
safe_extract_archive(
|
||||
archive_path,
|
||||
temp_path,
|
||||
archive_file=archive_file,
|
||||
source_name=source_name,
|
||||
content_type=content_type,
|
||||
error_type=ValidationError,
|
||||
)
|
||||
|
||||
# Find extension directory (may be nested)
|
||||
extension_dir = temp_path
|
||||
@@ -2397,13 +2482,35 @@ class ExtensionManager:
|
||||
manifest_path = extension_dir / "extension.yml"
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise ValidationError("No extension.yml found in ZIP file")
|
||||
raise ValidationError("No extension.yml found in archive")
|
||||
|
||||
# Install from extracted directory
|
||||
return self.install_from_directory(
|
||||
extension_dir, speckit_version, priority=priority, force=force
|
||||
)
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
*,
|
||||
archive_file: BinaryIO | None = None,
|
||||
source_name: str | None = None,
|
||||
content_type: str | None = None,
|
||||
) -> ExtensionManifest:
|
||||
"""Backward-compatible wrapper for archive installation."""
|
||||
return self.install_from_archive(
|
||||
zip_path,
|
||||
speckit_version,
|
||||
priority=priority,
|
||||
force=force,
|
||||
archive_file=archive_file,
|
||||
source_name=source_name,
|
||||
content_type=content_type,
|
||||
)
|
||||
|
||||
def remove(self, extension_id: str, keep_config: bool = False) -> bool:
|
||||
"""Remove an installed extension.
|
||||
|
||||
@@ -2612,7 +2719,7 @@ class ExtensionManager:
|
||||
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``
|
||||
@@ -2730,7 +2837,7 @@ class ExtensionManager:
|
||||
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
|
||||
@@ -3721,14 +3828,14 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
def download_extension(
|
||||
self, extension_id: str, target_dir: Optional[Path] = None
|
||||
) -> Path:
|
||||
"""Download extension ZIP from catalog.
|
||||
"""Download an extension archive from a catalog.
|
||||
|
||||
Args:
|
||||
extension_id: ID of the extension to download
|
||||
target_dir: Directory to save ZIP file (defaults to temp directory)
|
||||
target_dir: Directory to save the archive
|
||||
|
||||
Returns:
|
||||
Path to downloaded ZIP file
|
||||
Path to the downloaded archive
|
||||
|
||||
Raises:
|
||||
ExtensionError: If extension not found or download fails
|
||||
@@ -3787,52 +3894,93 @@ class ExtensionCatalog(CatalogStackBase):
|
||||
target_dir = self.cache_dir / "downloads"
|
||||
target_dir = Path(target_dir)
|
||||
version = ext_info.get("version", "unknown")
|
||||
zip_path = build_safe_download_path(
|
||||
declared_format = archive_format_from_name(download_url)
|
||||
build_safe_download_path(
|
||||
target_dir,
|
||||
extension_id,
|
||||
version,
|
||||
error_type=ExtensionError,
|
||||
label="extension",
|
||||
suffix=archive_suffix(declared_format or "tar.gz"),
|
||||
)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_download_url = download_url
|
||||
extra_headers = None
|
||||
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
|
||||
if resolved_download_url:
|
||||
download_url = resolved_download_url
|
||||
extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
# Download the ZIP file
|
||||
staging_path: Path | None = None
|
||||
try:
|
||||
with self._open_url(
|
||||
download_url, timeout=60, extra_headers=extra_headers
|
||||
) as response:
|
||||
zip_data = read_response_limited(
|
||||
archive_data = read_response_limited(
|
||||
response,
|
||||
error_type=ExtensionError,
|
||||
label=f"extension '{extension_id}' download",
|
||||
)
|
||||
final_url = (
|
||||
response.geturl()
|
||||
if hasattr(response, "geturl")
|
||||
else download_url
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
|
||||
verify_archive_sha256(
|
||||
zip_data, ext_info.get("sha256"), extension_id, ExtensionError
|
||||
archive_data, ext_info.get("sha256"), extension_id, ExtensionError
|
||||
)
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
return zip_path
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="extension-download-",
|
||||
suffix=".archive",
|
||||
dir=target_dir,
|
||||
delete=False,
|
||||
) as staging_file:
|
||||
staging_path = Path(staging_file.name)
|
||||
staging_file.write(archive_data)
|
||||
archive_format = detect_archive_format(
|
||||
staging_path,
|
||||
source_name=(
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else original_download_url
|
||||
),
|
||||
content_type=content_type,
|
||||
error_type=ExtensionError,
|
||||
)
|
||||
archive_path = build_safe_download_path(
|
||||
target_dir,
|
||||
extension_id,
|
||||
version,
|
||||
error_type=ExtensionError,
|
||||
label="extension",
|
||||
suffix=archive_suffix(archive_format),
|
||||
)
|
||||
os.replace(staging_path, archive_path)
|
||||
staging_path = None
|
||||
return archive_path
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
raise ExtensionError(
|
||||
f"Failed to download extension from {download_url}: {e}"
|
||||
)
|
||||
except IOError as e:
|
||||
raise ExtensionError(f"Failed to save extension ZIP: {e}")
|
||||
raise ExtensionError(f"Failed to save extension archive: {e}")
|
||||
finally:
|
||||
if staging_path is not None:
|
||||
staging_path.unlink(missing_ok=True)
|
||||
|
||||
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"):
|
||||
|
||||
@@ -8,11 +8,12 @@ which re-fetch from the parent package at call time so test monkeypatching of
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import errno
|
||||
import hashlib
|
||||
import os
|
||||
import shutil
|
||||
import stat
|
||||
import tempfile
|
||||
import zipfile
|
||||
from pathlib import Path
|
||||
from typing import Optional
|
||||
from uuid import uuid4
|
||||
@@ -26,12 +27,11 @@ from rich.table import Table
|
||||
from .._console import console
|
||||
from .._assets import get_speckit_version
|
||||
from .._download_security import (
|
||||
archive_format_from_name,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
normalize_zip_member_name,
|
||||
open_zip_bounded,
|
||||
portable_zip_path_key,
|
||||
read_response_limited,
|
||||
read_zip_member_limited,
|
||||
safe_extract_archive,
|
||||
)
|
||||
from .._init_options import is_ai_skills_enabled
|
||||
|
||||
@@ -71,6 +71,165 @@ 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"<key>[cyan][/cyan][/cyan] to retry."
|
||||
)
|
||||
for key, detail in exc.failures:
|
||||
console.print(f" {key}: {_escape_markup(detail)}")
|
||||
|
||||
|
||||
def install_extension_from_url(
|
||||
manager,
|
||||
project_root: Path,
|
||||
url: str,
|
||||
speckit_version: str,
|
||||
*,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
):
|
||||
"""Download an archive from *url* and install it, reusing the hardened path.
|
||||
|
||||
Shares the same download hardening as ``extension add --from``:
|
||||
HTTPS enforcement, the catalog's authenticated + redirect-guarded
|
||||
``_open_url`` fetch, a bounded (50 MiB) response read, archive-format
|
||||
detection (ZIP or tar.gz/tgz), and a TOCTOU-safe transient download file
|
||||
consumed directly by ``install_from_zip``.
|
||||
|
||||
Returns the installed manifest. Raises ``ExtensionError`` on any failure so
|
||||
callers can present a uniform message without a second downloader.
|
||||
"""
|
||||
import urllib.error
|
||||
|
||||
from . import ExtensionCatalog, ExtensionError
|
||||
|
||||
if not is_https_or_localhost_http(url):
|
||||
raise ExtensionError(
|
||||
"URL must use HTTPS (HTTP is only allowed for localhost)"
|
||||
)
|
||||
|
||||
download_dir = _validate_safe_cache_dir(project_root)
|
||||
archive_filename = f"extension-url-download-{uuid4().hex}.archive"
|
||||
# Only used for diagnostic messages: the real archive is a transient inode
|
||||
# (unlinked on POSIX, O_TEMPORARY on Windows) consumed via ``archive_file``
|
||||
# below, so this path is never opened again.
|
||||
archive_path = download_dir / archive_filename
|
||||
|
||||
try:
|
||||
dl_catalog = ExtensionCatalog(project_root)
|
||||
download_url = url
|
||||
extra_headers = None
|
||||
resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url)
|
||||
if resolved_url:
|
||||
download_url = resolved_url
|
||||
extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
with dl_catalog._open_url(
|
||||
download_url, timeout=60, extra_headers=extra_headers
|
||||
) as response:
|
||||
archive_data = read_response_limited(
|
||||
response,
|
||||
error_type=ExtensionError,
|
||||
label=f"extension {url}",
|
||||
)
|
||||
final_url = (
|
||||
response.geturl() if hasattr(response, "geturl") else download_url
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
except urllib.error.URLError as exc:
|
||||
raise ExtensionError(f"Failed to download from {url}: {exc}") from exc
|
||||
|
||||
download_fd = -1
|
||||
download_file = None
|
||||
try:
|
||||
try:
|
||||
download_fd = _safe_open_download_zip(
|
||||
project_root, download_dir, archive_filename
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ExtensionError(
|
||||
f"Could not safely create download file: {exc}"
|
||||
) from exc
|
||||
|
||||
try:
|
||||
download_file = os.fdopen(download_fd, "w+b")
|
||||
download_fd = -1
|
||||
download_file.write(archive_data)
|
||||
download_file.flush()
|
||||
download_file.seek(0)
|
||||
except OSError as exc:
|
||||
raise ExtensionError(
|
||||
f"Could not safely write download file: {exc}"
|
||||
) from exc
|
||||
|
||||
format_source = (
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else url
|
||||
)
|
||||
try:
|
||||
detect_archive_format(
|
||||
archive_path,
|
||||
archive_file=download_file,
|
||||
source_name=format_source,
|
||||
content_type=content_type,
|
||||
error_type=ExtensionError,
|
||||
)
|
||||
except ExtensionError as exc:
|
||||
raise ExtensionError(
|
||||
f"{url} did not return a ZIP archive or tar.gz/tgz archive "
|
||||
f"(got {len(archive_data)} bytes). This usually means the request "
|
||||
"was not authenticated and a login/HTML page was returned. "
|
||||
"Verify the URL and configured credentials."
|
||||
) from exc
|
||||
|
||||
# Consume the transient inode reserved above rather than reopening the
|
||||
# cache pathname during extraction.
|
||||
try:
|
||||
return manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority=priority,
|
||||
force=force,
|
||||
archive_file=download_file,
|
||||
)
|
||||
except OSError as exc:
|
||||
raise ExtensionError(
|
||||
f"Could not install extension from downloaded archive: {exc}"
|
||||
) from exc
|
||||
finally:
|
||||
if download_file is not None:
|
||||
try:
|
||||
download_file.close()
|
||||
except OSError:
|
||||
pass
|
||||
elif download_fd >= 0:
|
||||
try:
|
||||
os.close(download_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _load_catalog_command_config(project_root: Path, config_path: Path) -> dict:
|
||||
"""Load extension catalog CLI config with user-facing shape errors."""
|
||||
try:
|
||||
@@ -413,6 +572,278 @@ def catalog_remove(
|
||||
console.print("\n[dim]No catalogs remain in config. Built-in defaults will be used.[/dim]")
|
||||
|
||||
|
||||
# Relative path, below the project root, of the extension URL download cache.
|
||||
_CACHE_REL_PARTS = (".specify", "extensions", ".cache", "downloads")
|
||||
|
||||
|
||||
def _has_secure_dir_fd() -> bool:
|
||||
"""Whether this platform supports the strongest (POSIX) hardening path.
|
||||
|
||||
The descriptor-anchored walk needs ``O_NOFOLLOW`` plus ``dir_fd`` support
|
||||
for ``os.open``/``os.mkdir``/``os.unlink``. When any of those is missing
|
||||
(notably on Windows) the caller falls back to the portable path-wise walk,
|
||||
which reproduces the same guarantees using symlink/reparse-point rejection,
|
||||
resolve-under-root containment checks, and post-open inode-identity
|
||||
verification instead of file descriptors.
|
||||
"""
|
||||
return bool(
|
||||
getattr(os, "O_NOFOLLOW", 0)
|
||||
and os.open in os.supports_dir_fd
|
||||
and os.mkdir in os.supports_dir_fd
|
||||
and os.unlink in os.supports_dir_fd
|
||||
)
|
||||
|
||||
|
||||
def _is_symlink_refusal_errno(exc: OSError) -> bool:
|
||||
"""Whether an ``os.open``/``os.mkdir`` error means a component is a symlink.
|
||||
|
||||
Opening an ``O_NOFOLLOW`` path whose final component is a symlink raises
|
||||
``ELOOP`` on Linux and ``EMLINK`` on some BSDs, while a symlinked component
|
||||
that no longer resolves to a directory surfaces as ``ENOTDIR``.
|
||||
"""
|
||||
return exc.errno in (errno.ELOOP, errno.ENOTDIR, getattr(errno, "EMLINK", -1))
|
||||
|
||||
|
||||
def _verify_leaf_identity(fd: int, path: Path) -> None:
|
||||
"""Confirm ``fd`` still refers to the regular file at ``path``.
|
||||
|
||||
Mirrors the workflow installer's staged-file check: comparing the open
|
||||
descriptor's ``fstat`` against a ``lstat`` of the pathname detects a leaf
|
||||
that was swapped for a symlink/reparse point between creation and use, so
|
||||
the portable (dir_fd-less) path is not vulnerable to an ancestor swap race.
|
||||
"""
|
||||
path_stat = path.stat(follow_symlinks=False)
|
||||
open_stat = os.fstat(fd)
|
||||
if (
|
||||
not stat.S_ISREG(path_stat.st_mode)
|
||||
or path_stat.st_dev != open_stat.st_dev
|
||||
or path_stat.st_ino != open_stat.st_ino
|
||||
):
|
||||
raise OSError(
|
||||
errno.ENOTDIR, "Download file changed between creation and open"
|
||||
)
|
||||
|
||||
|
||||
def _validate_safe_cache_dir(project_root: Path) -> Path:
|
||||
"""Create and validate the extension URL download cache one component at a
|
||||
time, refusing symlinked/junctioned components on every supported platform."""
|
||||
download_dir = project_root.joinpath(*_CACHE_REL_PARTS)
|
||||
try:
|
||||
if _has_secure_dir_fd():
|
||||
_validate_cache_dir_via_dir_fd(project_root, download_dir)
|
||||
else:
|
||||
_validate_cache_dir_via_paths(project_root, download_dir)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except FileExistsError:
|
||||
console.print(
|
||||
"[red]Error:[/red] Refusing to use symlinked download cache directory"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
except OSError as exc:
|
||||
if _is_symlink_refusal_errno(exc):
|
||||
console.print(
|
||||
"[red]Error:[/red] Refusing to use symlinked download cache directory"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
console.print(
|
||||
"[red]Error:[/red] Could not prepare download cache directory: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
return download_dir
|
||||
|
||||
|
||||
def _validate_cache_dir_via_dir_fd(project_root: Path, download_dir: Path) -> None:
|
||||
"""POSIX cache-dir walk anchored on ``dir_fd`` + ``O_NOFOLLOW`` descriptors."""
|
||||
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
|
||||
o_directory = getattr(os, "O_DIRECTORY", 0)
|
||||
o_cloexec = getattr(os, "O_CLOEXEC", 0)
|
||||
walk_flags = os.O_RDONLY | o_directory | o_nofollow | o_cloexec
|
||||
|
||||
project_root_resolved = project_root.resolve()
|
||||
parent_fd = os.open(project_root, walk_flags)
|
||||
current_path = project_root
|
||||
try:
|
||||
for part in _CACHE_REL_PARTS:
|
||||
current_path = current_path / part
|
||||
|
||||
try:
|
||||
child_fd = os.open(part, walk_flags, dir_fd=parent_fd)
|
||||
except FileNotFoundError:
|
||||
try:
|
||||
os.mkdir(part, dir_fd=parent_fd)
|
||||
except FileExistsError:
|
||||
pass
|
||||
child_fd = os.open(part, walk_flags, dir_fd=parent_fd)
|
||||
|
||||
try:
|
||||
current_path.resolve().relative_to(project_root_resolved)
|
||||
except (OSError, ValueError):
|
||||
try:
|
||||
os.close(child_fd)
|
||||
except OSError:
|
||||
pass
|
||||
console.print(
|
||||
"[red]Error:[/red] Download cache directory escapes project root"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
os.close(parent_fd)
|
||||
parent_fd = child_fd
|
||||
finally:
|
||||
if parent_fd >= 0:
|
||||
try:
|
||||
os.close(parent_fd)
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def _validate_cache_dir_via_paths(project_root: Path, download_dir: Path) -> None:
|
||||
"""Portable cache-dir walk for platforms without ``dir_fd`` (e.g. Windows).
|
||||
|
||||
Each component is created individually while a symlink/junction is rejected
|
||||
both before and after creation, and every component is required to resolve
|
||||
back under the project root so a mount-point alias or reparse point cannot
|
||||
redirect the cache outside the project.
|
||||
"""
|
||||
project_root_resolved = project_root.resolve()
|
||||
current_path = project_root
|
||||
for part in _CACHE_REL_PARTS:
|
||||
current_path = current_path / part
|
||||
|
||||
if current_path.is_symlink():
|
||||
console.print(
|
||||
"[red]Error:[/red] Refusing to use symlinked download cache directory"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
current_path.mkdir()
|
||||
except FileExistsError:
|
||||
pass
|
||||
|
||||
# Re-check after creation: a component swapped for a symlink/junction
|
||||
# (or an existing non-directory) between the check and mkdir is caught
|
||||
# here before the walk descends into it.
|
||||
if current_path.is_symlink() or not current_path.is_dir():
|
||||
console.print(
|
||||
"[red]Error:[/red] Refusing to use symlinked download cache directory"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
try:
|
||||
current_path.resolve().relative_to(project_root_resolved)
|
||||
except (OSError, ValueError):
|
||||
console.print(
|
||||
"[red]Error:[/red] Download cache directory escapes project root"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
def _safe_open_download_zip(
|
||||
project_root: Path, download_dir: Path, zip_filename: str
|
||||
) -> int:
|
||||
"""Exclusively create a download ZIP and return an owned descriptor.
|
||||
|
||||
The archive never persists as a nameable on-disk file: the POSIX path
|
||||
unlinks the leaf immediately after exclusive creation (anonymous inode),
|
||||
while the portable path opens it with ``O_TEMPORARY`` so the OS deletes it
|
||||
when the last handle closes. Installation proceeds entirely through the
|
||||
returned descriptor, removing the pathname-reopen and cleanup-walk TOCTOU
|
||||
classes on every supported platform.
|
||||
"""
|
||||
if _has_secure_dir_fd():
|
||||
return _open_download_zip_via_dir_fd(
|
||||
project_root, download_dir, zip_filename
|
||||
)
|
||||
return _open_download_zip_via_paths(project_root, download_dir, zip_filename)
|
||||
|
||||
|
||||
def _open_download_zip_via_dir_fd(
|
||||
project_root: Path, download_dir: Path, zip_filename: str
|
||||
) -> int:
|
||||
"""POSIX leaf create: descriptor walk, ``O_EXCL`` create, immediate unlink."""
|
||||
o_nofollow = getattr(os, "O_NOFOLLOW", 0)
|
||||
o_directory = getattr(os, "O_DIRECTORY", 0)
|
||||
o_cloexec = getattr(os, "O_CLOEXEC", 0)
|
||||
walk_flags = os.O_RDONLY | o_directory | o_nofollow | o_cloexec
|
||||
|
||||
rel_parts = download_dir.relative_to(project_root).parts
|
||||
parent_fd = os.open(project_root, walk_flags)
|
||||
try:
|
||||
for part in rel_parts:
|
||||
new_fd = os.open(part, walk_flags, dir_fd=parent_fd)
|
||||
os.close(parent_fd)
|
||||
parent_fd = new_fd
|
||||
|
||||
download_fd = os.open(
|
||||
zip_filename,
|
||||
os.O_RDWR | os.O_CREAT | os.O_EXCL | o_nofollow | o_cloexec,
|
||||
0o600,
|
||||
dir_fd=parent_fd,
|
||||
)
|
||||
try:
|
||||
os.unlink(zip_filename, dir_fd=parent_fd)
|
||||
except OSError:
|
||||
os.close(download_fd)
|
||||
raise
|
||||
return download_fd
|
||||
finally:
|
||||
os.close(parent_fd)
|
||||
|
||||
|
||||
def _open_download_zip_via_paths(
|
||||
project_root: Path, download_dir: Path, zip_filename: str
|
||||
) -> int:
|
||||
"""Portable leaf create for platforms without ``dir_fd`` (e.g. Windows).
|
||||
|
||||
The cache directory is re-validated (real directory, under the project
|
||||
root) immediately before an exclusive create. ``O_EXCL`` guarantees an
|
||||
attacker cannot pre-stage the leaf as a symlink/junction, ``O_TEMPORARY``
|
||||
makes the OS delete it on close, and a post-open inode-identity check
|
||||
detects a leaf swapped underneath us. The returned descriptor is the only
|
||||
handle installation ever uses, so the cache pathname is never reopened.
|
||||
"""
|
||||
zip_path = download_dir / zip_filename
|
||||
project_root_resolved = project_root.resolve()
|
||||
|
||||
if download_dir.is_symlink() or not download_dir.is_dir():
|
||||
raise OSError(
|
||||
errno.ENOTDIR, "Download cache directory is not a real directory"
|
||||
)
|
||||
try:
|
||||
download_dir.resolve().relative_to(project_root_resolved)
|
||||
except (OSError, ValueError):
|
||||
raise OSError(errno.ENOTDIR, "Download cache directory escapes project root")
|
||||
if zip_path.is_symlink():
|
||||
raise OSError(errno.ELOOP, "Refusing to write through a symlinked download file")
|
||||
|
||||
flags = os.O_RDWR | os.O_CREAT | os.O_EXCL
|
||||
flags |= getattr(os, "O_NOFOLLOW", 0)
|
||||
flags |= getattr(os, "O_CLOEXEC", 0)
|
||||
flags |= getattr(os, "O_BINARY", 0)
|
||||
o_temporary = getattr(os, "O_TEMPORARY", 0)
|
||||
flags |= o_temporary
|
||||
|
||||
download_fd = os.open(zip_path, flags, 0o600)
|
||||
try:
|
||||
_verify_leaf_identity(download_fd, zip_path)
|
||||
except OSError:
|
||||
os.close(download_fd)
|
||||
# Without O_TEMPORARY the leaf is not auto-deleted, so remove the file
|
||||
# we just exclusively created (best effort, never through a symlink).
|
||||
if not o_temporary:
|
||||
try:
|
||||
if not zip_path.is_symlink():
|
||||
zip_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
return download_fd
|
||||
|
||||
|
||||
@extension_app.command("add")
|
||||
def extension_add(
|
||||
extension: str = typer.Argument(help="Extension name or path"),
|
||||
@@ -514,69 +945,19 @@ def extension_add(
|
||||
)
|
||||
|
||||
elif from_url:
|
||||
# Install from URL (ZIP file)
|
||||
import io
|
||||
import urllib.error
|
||||
|
||||
# Install from URL archive via the shared hardened downloader
|
||||
# (HTTPS enforcement, authenticated redirect-guarded fetch,
|
||||
# bounded read, archive-format detection, TOCTOU-safe transient
|
||||
# archive). Same path used by ``specify init --extension <url>``.
|
||||
console.print(f"Downloading from {safe_url}...")
|
||||
|
||||
# Download ZIP to temp location
|
||||
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="extension-url-download-",
|
||||
suffix=".zip",
|
||||
dir=download_dir,
|
||||
delete=False,
|
||||
) as download_file:
|
||||
zip_path = Path(download_file.name)
|
||||
|
||||
try:
|
||||
# Use the catalog's authenticated fetch so configured
|
||||
# credentials (incl. GitHub Enterprise Server) are applied
|
||||
# and GHES release-asset URLs resolve via /api/v3 — keeping
|
||||
# --from consistent with catalog-based installs.
|
||||
dl_catalog = ExtensionCatalog(project_root)
|
||||
download_url = from_url
|
||||
extra_headers = None
|
||||
resolved_url = dl_catalog._resolve_github_release_asset_api_url(download_url)
|
||||
if resolved_url:
|
||||
download_url = resolved_url
|
||||
extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
with dl_catalog._open_url(
|
||||
download_url, timeout=60, extra_headers=extra_headers
|
||||
) as response:
|
||||
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(
|
||||
f"[red]Error:[/red] {safe_url} did not return a ZIP archive "
|
||||
f"(got {len(zip_data)} bytes). This usually means the request "
|
||||
f"was not authenticated and a login/HTML page was returned. "
|
||||
f"Verify the URL is correct and that credentials for its host "
|
||||
f"are configured in ~/.specify/auth.json."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
|
||||
# Install from downloaded ZIP
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
|
||||
except urllib.error.URLError as e:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to download from {safe_url}: "
|
||||
f"{_escape_markup(str(e))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
# Clean up downloaded ZIP
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
manifest = install_extension_from_url(
|
||||
manager,
|
||||
project_root,
|
||||
from_url,
|
||||
speckit_version,
|
||||
priority=priority,
|
||||
force=force,
|
||||
)
|
||||
|
||||
else:
|
||||
# Try bundled extensions first (shipped with spec-kit)
|
||||
@@ -636,23 +1017,30 @@ def extension_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
# Download extension ZIP (use resolved ID, not original argument which may be display name)
|
||||
# Download extension archive (use the resolved catalog ID).
|
||||
extension_id = ext_info['id']
|
||||
console.print(f"Downloading {_escape_markup(str(ext_info['name']))} v{_escape_markup(str(ext_info.get('version', 'unknown')))}...")
|
||||
zip_path = catalog.download_extension(extension_id)
|
||||
archive_path = catalog.download_extension(extension_id)
|
||||
|
||||
try:
|
||||
# Install from downloaded ZIP
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority=priority, force=force)
|
||||
manifest = manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority=priority,
|
||||
force=force,
|
||||
)
|
||||
finally:
|
||||
# Clean up downloaded ZIP
|
||||
if zip_path.exists():
|
||||
zip_path.unlink()
|
||||
if archive_path.exists():
|
||||
archive_path.unlink()
|
||||
|
||||
console.print("\n[green]✓[/green] Extension installed successfully!")
|
||||
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))}")
|
||||
|
||||
@@ -759,6 +1147,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")
|
||||
@@ -801,8 +1193,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
|
||||
@@ -849,7 +1242,7 @@ def extension_search(
|
||||
console.print(f"\n [yellow]⚠[/yellow] Not directly installable from '{catalog_name}'.")
|
||||
console.print(
|
||||
f" Add to an approved catalog with install_allowed: true, "
|
||||
f"or install from a ZIP URL: specify extension add {safe_id} --from <zip-url>"
|
||||
f"or install from an archive URL: specify extension add {safe_id} --from <archive-url>"
|
||||
)
|
||||
console.print()
|
||||
|
||||
@@ -1025,8 +1418,9 @@ 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()
|
||||
|
||||
@@ -1476,131 +1870,105 @@ def extension_update(
|
||||
backup_hooks[hook_name] = ext_hooks
|
||||
|
||||
# 5. Download new version
|
||||
zip_path = catalog.download_extension(extension_id)
|
||||
archive_path = catalog.download_extension(extension_id)
|
||||
try:
|
||||
# 6. Validate extension ID from ZIP BEFORE modifying installation
|
||||
# Handle both root-level and nested extension.yml (GitHub auto-generated ZIPs)
|
||||
with open_zip_bounded(zip_path) as zf:
|
||||
import yaml
|
||||
manifest_data = None
|
||||
manifest_bytes = None
|
||||
namelist = zf.namelist()
|
||||
|
||||
# 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)
|
||||
)
|
||||
# 6. Validate the archive and extension ID before modifying
|
||||
# the existing installation. The shared extractor applies
|
||||
# the same bounded security checks to ZIP and tar archives.
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-update-archive-"
|
||||
) as archive_tmpdir:
|
||||
extracted_root = Path(archive_tmpdir)
|
||||
try:
|
||||
safe_extract_archive(archive_path, extracted_root)
|
||||
except ValueError as exc:
|
||||
if (
|
||||
len(parts) in {1, 2}
|
||||
and path_key[-1] == "extension.yml"
|
||||
"Conflicting path" in str(exc)
|
||||
and "extension.yml" in str(exc).casefold()
|
||||
):
|
||||
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(
|
||||
) from exc
|
||||
raise
|
||||
manifest_root = extracted_root
|
||||
top_level = list(extracted_root.iterdir())
|
||||
root_manifest_entries = [
|
||||
entry
|
||||
for entry in top_level
|
||||
if entry.name.casefold() == "extension.yml"
|
||||
]
|
||||
if any(
|
||||
entry.name != "extension.yml"
|
||||
for entry in root_manifest_entries
|
||||
):
|
||||
raise ValueError(
|
||||
"Archive must use canonical 'extension.yml' casing"
|
||||
)
|
||||
canonical_root_manifest = next(
|
||||
(
|
||||
name
|
||||
for name, _normalized_name, path_key
|
||||
in manifest_candidates
|
||||
if path_key == ("extension.yml",)
|
||||
entry
|
||||
for entry in root_manifest_entries
|
||||
if entry.name == "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}:
|
||||
if canonical_root_manifest is not None:
|
||||
manifest_path = canonical_root_manifest
|
||||
else:
|
||||
top_level_dirs = [
|
||||
entry for entry in top_level if entry.is_dir()
|
||||
]
|
||||
if len(top_level_dirs) != 1:
|
||||
raise ValueError(
|
||||
"Downloaded extension archive with a "
|
||||
"nested extension.yml must contain exactly "
|
||||
"Downloaded extension archive must contain exactly "
|
||||
"one top-level directory"
|
||||
)
|
||||
|
||||
if manifest_path is not None:
|
||||
manifest_bytes = read_zip_member_limited(
|
||||
zf, manifest_path
|
||||
manifest_root = top_level_dirs[0]
|
||||
nested_manifest_entries = [
|
||||
entry
|
||||
for entry in manifest_root.iterdir()
|
||||
if entry.name.casefold() == "extension.yml"
|
||||
]
|
||||
if any(
|
||||
entry.name != "extension.yml"
|
||||
for entry in nested_manifest_entries
|
||||
):
|
||||
raise ValueError(
|
||||
"Archive must use canonical 'extension.yml' casing"
|
||||
)
|
||||
manifest_path = next(
|
||||
(
|
||||
entry
|
||||
for entry in nested_manifest_entries
|
||||
if entry.name == "extension.yml"
|
||||
),
|
||||
manifest_root / "extension.yml",
|
||||
)
|
||||
parsed_manifest = yaml.safe_load(
|
||||
manifest_bytes
|
||||
if not manifest_path.is_file():
|
||||
raise ValueError(
|
||||
"Downloaded extension archive is missing 'extension.yml'"
|
||||
)
|
||||
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'")
|
||||
manifest_bytes = manifest_path.read_bytes()
|
||||
parsed_manifest = yaml.safe_load(manifest_bytes)
|
||||
manifest_data = (
|
||||
parsed_manifest if parsed_manifest is not None else {}
|
||||
)
|
||||
if not isinstance(manifest_data, dict):
|
||||
raise ValueError(
|
||||
"Invalid extension manifest in downloaded archive: expected YAML mapping"
|
||||
"Invalid extension manifest in downloaded archive: "
|
||||
"expected YAML mapping"
|
||||
)
|
||||
extension_data = manifest_data.get("extension", {})
|
||||
if not isinstance(extension_data, dict):
|
||||
raise ValueError(
|
||||
"Invalid extension manifest in downloaded archive: expected 'extension' mapping"
|
||||
"Invalid extension manifest in downloaded archive: "
|
||||
"expected 'extension' mapping"
|
||||
)
|
||||
|
||||
# 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:
|
||||
@@ -1796,7 +2164,7 @@ def extension_update(
|
||||
manager.remove(extension_id, keep_config=True)
|
||||
|
||||
# 8. Install new version
|
||||
_ = manager.install_from_zip(zip_path, speckit_version)
|
||||
_ = manager.install_from_zip(archive_path, speckit_version)
|
||||
|
||||
# Restore user config files from backup after successful install.
|
||||
new_extension_dir = manager.extensions_dir / extension_id
|
||||
@@ -1842,12 +2210,12 @@ def extension_update(
|
||||
hook["enabled"] = False
|
||||
hook_executor.save_project_config(config)
|
||||
finally:
|
||||
# ZIP cleanup is housekeeping: never replace an install
|
||||
# Archive 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():
|
||||
if archive_path.exists():
|
||||
try:
|
||||
zip_path.unlink()
|
||||
archive_path.unlink()
|
||||
except OSError as error:
|
||||
zip_cleanup_error = error
|
||||
|
||||
@@ -2124,6 +2492,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)
|
||||
@@ -2173,6 +2548,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(
|
||||
@@ -2217,6 +2596,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(
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
from collections.abc import Callable
|
||||
from typing import Any
|
||||
|
||||
from ._invocation_style import get_invocation_prefix
|
||||
from .integration_state import integration_setting, integration_settings
|
||||
|
||||
|
||||
@@ -99,3 +100,14 @@ def invoke_separator_for_integration(
|
||||
return integration.effective_invoke_separator(stored_parsed, project_root)
|
||||
|
||||
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)
|
||||
|
||||
@@ -11,6 +11,7 @@ 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,
|
||||
@@ -120,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)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -333,6 +333,9 @@ def _set_default_integration(
|
||||
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,
|
||||
refresh_hint=refresh_hint,
|
||||
@@ -391,6 +394,7 @@ def _register_extensions_for_agent(
|
||||
agent_key: str,
|
||||
*,
|
||||
continuing: str,
|
||||
force: bool = False,
|
||||
) -> None:
|
||||
"""Register all enabled extensions' commands/skills for ``agent_key``.
|
||||
|
||||
@@ -404,6 +408,11 @@ def _register_extensions_for_agent(
|
||||
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
|
||||
failure here cannot trigger a rollback.
|
||||
@@ -411,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,
|
||||
)
|
||||
|
||||
@@ -8,6 +8,7 @@ import typer
|
||||
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,
|
||||
)
|
||||
@@ -130,6 +131,9 @@ def integration_install(
|
||||
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":
|
||||
from .. import ensure_executable_scripts
|
||||
@@ -139,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])
|
||||
|
||||
@@ -9,6 +9,7 @@ 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,
|
||||
)
|
||||
@@ -447,6 +448,9 @@ def integration_switch(
|
||||
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 "
|
||||
"[cyan]specify integration switch ... --refresh-shared-infra[/cyan]."
|
||||
@@ -462,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(
|
||||
@@ -747,6 +759,9 @@ def integration_upgrade(
|
||||
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":
|
||||
from .. import ensure_executable_scripts
|
||||
@@ -756,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,
|
||||
@@ -763,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,
|
||||
@@ -782,6 +805,9 @@ def integration_upgrade(
|
||||
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,
|
||||
)
|
||||
@@ -860,6 +886,7 @@ def integration_upgrade(
|
||||
_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(
|
||||
|
||||
@@ -318,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:
|
||||
@@ -342,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(
|
||||
@@ -374,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)
|
||||
@@ -386,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)")
|
||||
@@ -444,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)
|
||||
|
||||
|
||||
@@ -27,14 +27,16 @@ from typing import TYPE_CHECKING, Any
|
||||
|
||||
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"
|
||||
)
|
||||
@@ -158,7 +160,17 @@ class IntegrationBase(ABC):
|
||||
@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,
|
||||
@@ -479,7 +491,11 @@ class IntegrationBase(ABC):
|
||||
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.
|
||||
@@ -601,7 +617,9 @@ class IntegrationBase(ABC):
|
||||
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_<NAME>__`` placeholders with invocations.
|
||||
|
||||
Each placeholder encodes a command name in upper-case with
|
||||
@@ -611,10 +629,16 @@ class IntegrationBase(ABC):
|
||||
|
||||
* ``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,
|
||||
)
|
||||
|
||||
@@ -838,7 +862,12 @@ class IntegrationBase(ABC):
|
||||
content = CommandRegistrar.rewrite_project_relative_paths(content)
|
||||
|
||||
# 8. Replace __SPECKIT_COMMAND_<NAME>__ 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
|
||||
|
||||
@@ -902,8 +931,32 @@ class IntegrationBase(ABC):
|
||||
|
||||
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(
|
||||
@@ -1008,6 +1061,12 @@ class MarkdownIntegration(IntegrationBase):
|
||||
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
|
||||
|
||||
|
||||
@@ -1215,6 +1274,12 @@ class TomlIntegration(IntegrationBase):
|
||||
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
|
||||
|
||||
|
||||
@@ -1451,6 +1516,12 @@ class YamlIntegration(IntegrationBase):
|
||||
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
|
||||
|
||||
|
||||
@@ -1520,18 +1591,21 @@ class SkillsIntegration(IntegrationBase):
|
||||
return project_root / folder / subdir
|
||||
|
||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||
"""Skills use ``/speckit-<stem>`` (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``
|
||||
@@ -1540,6 +1614,11 @@ class SkillsIntegration(IntegrationBase):
|
||||
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)
|
||||
@@ -1573,10 +1652,13 @@ class SkillsIntegration(IntegrationBase):
|
||||
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,
|
||||
@@ -1627,13 +1709,27 @@ class SkillsIntegration(IntegrationBase):
|
||||
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:
|
||||
@@ -1648,11 +1744,27 @@ class SkillsIntegration(IntegrationBase):
|
||||
# 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.
|
||||
@@ -1686,4 +1798,10 @@ class SkillsIntegration(IntegrationBase):
|
||||
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
|
||||
|
||||
@@ -21,6 +21,7 @@ from typing import Any, Dict, List, Optional, Tuple
|
||||
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
|
||||
|
||||
|
||||
@@ -200,7 +201,14 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
final_url = resp.geturl()
|
||||
if final_url != entry.url:
|
||||
self._validate_catalog_url(final_url)
|
||||
catalog_data = json.loads(resp.read())
|
||||
catalog_data = json.loads(
|
||||
read_response_limited(
|
||||
resp,
|
||||
max_bytes=MAX_JSON_METADATA_BYTES,
|
||||
error_type=IntegrationCatalogError,
|
||||
label=f"catalog from {entry.url}",
|
||||
)
|
||||
)
|
||||
|
||||
shape_error = _catalog_shape_error(catalog_data)
|
||||
if shape_error is not None:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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 @@ class CodexIntegration(SkillsIntegration):
|
||||
|
||||
@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
|
||||
|
||||
@@ -118,6 +118,19 @@ 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
|
||||
|
||||
@@ -162,14 +175,19 @@ class CopilotIntegration(IntegrationBase):
|
||||
|
||||
@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-<name>/SKILL.md) instead of .agent.md files",
|
||||
),
|
||||
]
|
||||
)
|
||||
return opts
|
||||
|
||||
def _resolve_executable(self) -> str:
|
||||
"""Return the Copilot CLI executable, respecting the env-var override.
|
||||
@@ -328,7 +346,9 @@ class CopilotIntegration(IntegrationBase):
|
||||
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.
|
||||
@@ -355,10 +375,18 @@ class CopilotIntegration(IntegrationBase):
|
||||
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,
|
||||
@@ -497,7 +525,7 @@ class CopilotIntegration(IntegrationBase):
|
||||
"""
|
||||
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.
|
||||
|
||||
@@ -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 @@ class CursorAgentIntegration(SkillsIntegration):
|
||||
|
||||
@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
|
||||
|
||||
@@ -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 @@ class DevinIntegration(SkillsIntegration):
|
||||
|
||||
@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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -53,8 +53,16 @@ class GenericIntegration(MarkdownIntegration):
|
||||
"""
|
||||
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 @@ class GenericIntegration(MarkdownIntegration):
|
||||
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"
|
||||
|
||||
@@ -59,8 +59,7 @@ class KimiIntegration(SkillsIntegration):
|
||||
|
||||
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]:
|
||||
|
||||
@@ -400,7 +400,19 @@ class IntegrationManifest:
|
||||
# Remove the manifest file itself
|
||||
manifest = root / ".specify" / "integrations" / f"{self.key}.manifest.json"
|
||||
if remove_manifest and manifest.exists():
|
||||
manifest.unlink()
|
||||
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:
|
||||
@@ -459,6 +471,10 @@ class IntegrationManifest:
|
||||
path = inst.manifest_path
|
||||
try:
|
||||
data = json.loads(path.read_text(encoding="utf-8"))
|
||||
except UnicodeDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"Integration manifest at {path} is not valid UTF-8"
|
||||
) from exc
|
||||
except json.JSONDecodeError as exc:
|
||||
raise ValueError(
|
||||
f"Integration manifest at {path} contains invalid JSON"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -27,11 +27,14 @@ from packaging import version as pkg_version
|
||||
from packaging.specifiers import SpecifierSet, InvalidSpecifier
|
||||
|
||||
from .._download_security import (
|
||||
archive_format_from_name,
|
||||
archive_suffix,
|
||||
MAX_JSON_CATALOG_BYTES,
|
||||
build_safe_download_path,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
read_response_limited,
|
||||
safe_extract_zip,
|
||||
safe_extract_archive,
|
||||
)
|
||||
from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
|
||||
from .._init_options import (
|
||||
@@ -40,6 +43,7 @@ from .._init_options import (
|
||||
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 (
|
||||
@@ -295,6 +299,12 @@ class PresetManifest:
|
||||
f"(expected {self.SCHEMA_VERSION})"
|
||||
)
|
||||
|
||||
for section in ("preset", "requires", "provides"):
|
||||
if not isinstance(self.data[section], dict):
|
||||
raise PresetValidationError(
|
||||
f"Invalid {section}: expected a mapping"
|
||||
)
|
||||
|
||||
# Validate preset metadata
|
||||
pack = self.data["preset"]
|
||||
for field in ["id", "name", "version", "description"]:
|
||||
@@ -321,13 +331,37 @@ class PresetManifest:
|
||||
|
||||
# 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'"
|
||||
@@ -456,7 +490,7 @@ class PresetRegistry:
|
||||
}
|
||||
|
||||
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):
|
||||
@@ -477,7 +511,7 @@ class PresetRegistry:
|
||||
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):
|
||||
@@ -2424,13 +2458,15 @@ class PresetManager:
|
||||
|
||||
Looks up the agent's invoke separator and rewrites each
|
||||
``__SPECKIT_COMMAND_<NAME>__`` placeholder into the matching
|
||||
slash-command invocation — ``/speckit-<cmd>`` for a ``-`` separator,
|
||||
``/speckit.<cmd>`` for ``.`` — the same rendering the command layer
|
||||
applies via ``CommandRegistrar.register_commands()``.
|
||||
agent-native invocation -- ``/speckit-<cmd>`` or ``$speckit-<cmd>`` for
|
||||
a ``-`` separator, ``/speckit.<cmd>`` for ``.``, or
|
||||
``/skill:speckit-<cmd>`` 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
|
||||
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.
|
||||
"""
|
||||
@@ -2451,7 +2487,8 @@ class PresetManager:
|
||||
separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
|
||||
"invoke_separator", "."
|
||||
)
|
||||
return IntegrationBase.resolve_command_refs(body, 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."""
|
||||
@@ -3331,6 +3368,7 @@ class PresetManager:
|
||||
source_dir: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
) -> PresetManifest:
|
||||
"""Install preset from a local directory.
|
||||
|
||||
@@ -3338,6 +3376,7 @@ class PresetManager:
|
||||
source_dir: Path to preset directory
|
||||
speckit_version: Current spec-kit version
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
force: If True and the preset is already installed, remove it first
|
||||
|
||||
Returns:
|
||||
Installed preset manifest
|
||||
@@ -3356,10 +3395,12 @@ class PresetManager:
|
||||
self.check_compatibility(manifest, speckit_version)
|
||||
|
||||
if self.registry.is_installed(manifest.id):
|
||||
raise PresetError(
|
||||
f"Preset '{manifest.id}' is already installed. "
|
||||
f"Use 'specify preset remove {manifest.id}' first."
|
||||
)
|
||||
if not force:
|
||||
raise PresetError(
|
||||
f"Preset '{manifest.id}' is already installed. "
|
||||
f"Use 'specify preset remove {manifest.id}' first."
|
||||
)
|
||||
self.remove(manifest.id)
|
||||
|
||||
dest_dir = self.presets_dir / manifest.id
|
||||
if dest_dir.exists():
|
||||
@@ -3502,18 +3543,20 @@ class PresetManager:
|
||||
return
|
||||
_materialize_constitution_template(self.project_root, memory_constitution)
|
||||
|
||||
def install_from_zip(
|
||||
def install_from_archive(
|
||||
self,
|
||||
zip_path: Path,
|
||||
archive_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
) -> PresetManifest:
|
||||
"""Install preset from ZIP file.
|
||||
"""Install a preset from a supported archive.
|
||||
|
||||
Args:
|
||||
zip_path: Path to preset ZIP file
|
||||
archive_path: Path to a .zip, .tar.gz, or .tgz archive
|
||||
speckit_version: Current spec-kit version
|
||||
priority: Resolution priority (lower = higher precedence, default 10)
|
||||
force: If True and the preset is already installed, remove it first
|
||||
|
||||
Returns:
|
||||
Installed preset manifest
|
||||
@@ -3529,7 +3572,11 @@ class PresetManager:
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
temp_path = Path(tmpdir)
|
||||
|
||||
safe_extract_zip(zip_path, temp_path, error_type=PresetValidationError)
|
||||
safe_extract_archive(
|
||||
archive_path,
|
||||
temp_path,
|
||||
error_type=PresetValidationError,
|
||||
)
|
||||
|
||||
pack_dir = temp_path
|
||||
manifest_path = pack_dir / "preset.yml"
|
||||
@@ -3542,10 +3589,25 @@ class PresetManager:
|
||||
|
||||
if not manifest_path.exists():
|
||||
raise PresetValidationError(
|
||||
"No preset.yml found in ZIP file"
|
||||
"No preset.yml found in archive"
|
||||
)
|
||||
|
||||
return self.install_from_directory(pack_dir, speckit_version, priority)
|
||||
return self.install_from_directory(pack_dir, speckit_version, priority, force=force)
|
||||
|
||||
def install_from_zip(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
) -> PresetManifest:
|
||||
"""Backward-compatible wrapper for archive installation."""
|
||||
return self.install_from_archive(
|
||||
zip_path,
|
||||
speckit_version,
|
||||
priority,
|
||||
force=force,
|
||||
)
|
||||
|
||||
def remove(self, pack_id: str) -> bool:
|
||||
"""Remove an installed preset.
|
||||
@@ -4509,23 +4571,34 @@ class PresetCatalog:
|
||||
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 [
|
||||
str(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,
|
||||
]
|
||||
+ [str(t) for t in pack_data.get("tags", [])]
|
||||
+ [str(t) for t in tags_list]
|
||||
).lower()
|
||||
|
||||
if query_lower not in searchable_text:
|
||||
@@ -4560,14 +4633,14 @@ class PresetCatalog:
|
||||
def download_pack(
|
||||
self, pack_id: str, target_dir: Optional[Path] = None
|
||||
) -> Path:
|
||||
"""Download preset ZIP from catalog.
|
||||
"""Download a preset archive from a catalog.
|
||||
|
||||
Args:
|
||||
pack_id: ID of the preset to download
|
||||
target_dir: Directory to save ZIP file (defaults to cache directory)
|
||||
target_dir: Directory to save the archive
|
||||
|
||||
Returns:
|
||||
Path to downloaded ZIP file
|
||||
Path to the downloaded archive
|
||||
|
||||
Raises:
|
||||
PresetError: If pack not found or download fails
|
||||
@@ -4636,42 +4709,86 @@ class PresetCatalog:
|
||||
target_dir = self.cache_dir / "downloads"
|
||||
target_dir = Path(target_dir)
|
||||
version = pack_info.get("version", "unknown")
|
||||
zip_path = build_safe_download_path(
|
||||
declared_format = archive_format_from_name(download_url)
|
||||
build_safe_download_path(
|
||||
target_dir,
|
||||
pack_id,
|
||||
version,
|
||||
error_type=PresetError,
|
||||
label="preset",
|
||||
suffix=archive_suffix(declared_format or "tar.gz"),
|
||||
)
|
||||
target_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
original_download_url = download_url
|
||||
extra_headers = None
|
||||
resolved_download_url = self._resolve_github_release_asset_api_url(download_url)
|
||||
if resolved_download_url:
|
||||
download_url = resolved_download_url
|
||||
extra_headers = {"Accept": "application/octet-stream"}
|
||||
|
||||
staging_path: Path | None = None
|
||||
try:
|
||||
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
|
||||
zip_data = read_response_limited(
|
||||
archive_data = read_response_limited(
|
||||
response,
|
||||
error_type=PresetError,
|
||||
label=f"preset '{pack_id}' download",
|
||||
)
|
||||
final_url = (
|
||||
response.geturl()
|
||||
if hasattr(response, "geturl")
|
||||
else download_url
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
|
||||
verify_archive_sha256(
|
||||
zip_data, pack_info.get("sha256"), pack_id, PresetError
|
||||
archive_data, pack_info.get("sha256"), pack_id, PresetError
|
||||
)
|
||||
|
||||
zip_path.write_bytes(zip_data)
|
||||
return zip_path
|
||||
with tempfile.NamedTemporaryFile(
|
||||
prefix="preset-download-",
|
||||
suffix=".archive",
|
||||
dir=target_dir,
|
||||
delete=False,
|
||||
) as staging_file:
|
||||
staging_path = Path(staging_file.name)
|
||||
staging_file.write(archive_data)
|
||||
archive_format = detect_archive_format(
|
||||
staging_path,
|
||||
source_name=(
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else original_download_url
|
||||
),
|
||||
content_type=content_type,
|
||||
error_type=PresetError,
|
||||
)
|
||||
archive_path = build_safe_download_path(
|
||||
target_dir,
|
||||
pack_id,
|
||||
version,
|
||||
error_type=PresetError,
|
||||
label="preset",
|
||||
suffix=archive_suffix(archive_format),
|
||||
)
|
||||
os.replace(staging_path, archive_path)
|
||||
staging_path = None
|
||||
return archive_path
|
||||
|
||||
except urllib.error.URLError as e:
|
||||
raise PresetError(
|
||||
f"Failed to download preset from {download_url}: {e}"
|
||||
)
|
||||
except IOError as e:
|
||||
raise PresetError(f"Failed to save preset ZIP: {e}")
|
||||
raise PresetError(f"Failed to save preset archive: {e}")
|
||||
finally:
|
||||
if staging_path is not None:
|
||||
staging_path.unlink(missing_ok=True)
|
||||
|
||||
def clear_cache(self):
|
||||
"""Clear all catalog cache files, including per-URL hashed caches."""
|
||||
|
||||
@@ -17,6 +17,9 @@ from rich.markup import escape as _escape_markup
|
||||
|
||||
from .._console import console
|
||||
from .._download_security import (
|
||||
archive_format_from_name,
|
||||
archive_suffix,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
is_safe_download_redirect,
|
||||
read_response_limited,
|
||||
@@ -59,10 +62,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 = _escape_markup(", ".join(str(t) for t in 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()
|
||||
@@ -71,7 +78,11 @@ def preset_list():
|
||||
@preset_app.command("add")
|
||||
def preset_add(
|
||||
preset_id: str = typer.Argument(None, help="Preset ID to install from catalog"),
|
||||
from_url: str = typer.Option(None, "--from", help="Install from a URL (ZIP file)"),
|
||||
from_url: str = typer.Option(
|
||||
None,
|
||||
"--from",
|
||||
help="Install from a .zip, .tar.gz, or .tgz URL",
|
||||
),
|
||||
dev: str = typer.Option(None, "--dev", help="Install from local directory (development mode)"),
|
||||
priority: int = typer.Option(10, "--priority", help="Resolution priority (lower = higher precedence, default 10)"),
|
||||
):
|
||||
@@ -138,7 +149,7 @@ def preset_add(
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory() as tmpdir:
|
||||
zip_path = Path(tmpdir) / "preset.zip"
|
||||
archive_path = Path(tmpdir) / "preset.archive"
|
||||
try:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
from specify_cli.authentication.http import github_provider_hosts
|
||||
@@ -166,13 +177,33 @@ def preset_add(
|
||||
"or HTTP for localhost (127.0.0.1, ::1)."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
zip_path.write_bytes(
|
||||
read_response_limited(
|
||||
response,
|
||||
error_type=PresetError,
|
||||
label=f"preset {from_url}",
|
||||
)
|
||||
archive_data = read_response_limited(
|
||||
response,
|
||||
error_type=PresetError,
|
||||
label=f"preset {from_url}",
|
||||
)
|
||||
content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
archive_path.write_bytes(archive_data)
|
||||
format_source = (
|
||||
final_url
|
||||
if archive_format_from_name(final_url) is not None
|
||||
else from_url
|
||||
)
|
||||
archive_format = detect_archive_format(
|
||||
archive_path,
|
||||
source_name=format_source,
|
||||
content_type=content_type,
|
||||
error_type=PresetError,
|
||||
)
|
||||
detected_path = archive_path.with_suffix(
|
||||
archive_suffix(archive_format)
|
||||
)
|
||||
os.replace(archive_path, detected_path)
|
||||
archive_path = detected_path
|
||||
except (urllib.error.URLError, PresetError) as e:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to download: "
|
||||
@@ -180,7 +211,11 @@ def preset_add(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
manifest = manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority,
|
||||
)
|
||||
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
|
||||
@@ -223,12 +258,16 @@ def preset_add(
|
||||
console.print(f"Installing preset [cyan]{pack_info.get('name', preset_id)}[/cyan]...")
|
||||
|
||||
try:
|
||||
zip_path = catalog.download_pack(preset_id)
|
||||
manifest = manager.install_from_zip(zip_path, speckit_version, priority)
|
||||
archive_path = catalog.download_pack(preset_id)
|
||||
manifest = manager.install_from_zip(
|
||||
archive_path,
|
||||
speckit_version,
|
||||
priority,
|
||||
)
|
||||
console.print(f"[green]✓[/green] Preset '{manifest.name}' v{manifest.version} installed (priority {priority})")
|
||||
finally:
|
||||
if 'zip_path' in locals() and zip_path.exists():
|
||||
zip_path.unlink(missing_ok=True)
|
||||
if 'archive_path' in locals() and archive_path.exists():
|
||||
archive_path.unlink(missing_ok=True)
|
||||
else:
|
||||
console.print("[red]Error:[/red] Specify a preset ID, --from URL, or --dev path")
|
||||
raise typer.Exit(1)
|
||||
@@ -291,10 +330,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(str(t) for t in 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()
|
||||
|
||||
@@ -310,13 +355,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"
|
||||
@@ -328,7 +380,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:
|
||||
@@ -351,15 +406,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 `[<strategy>]`
|
||||
# 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]")
|
||||
|
||||
|
||||
@@ -373,28 +440,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(str(t) for t in 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)
|
||||
@@ -414,20 +491,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(str(t) for t in 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()
|
||||
|
||||
|
||||
@@ -662,10 +752,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)
|
||||
|
||||
@@ -681,10 +776,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")
|
||||
@@ -712,17 +808,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]")
|
||||
|
||||
|
||||
@@ -272,27 +272,56 @@ _BASH_FORMAT_COMMAND_RE = re.compile(
|
||||
_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,
|
||||
)
|
||||
return _POWERSHELL_FORMAT_COMMAND_RE.sub(
|
||||
lambda match: f"'{_format_speckit_command(match.group(2), separator)}'",
|
||||
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,
|
||||
)
|
||||
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:
|
||||
@@ -516,8 +549,12 @@ def install_shared_infra(
|
||||
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)
|
||||
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,
|
||||
@@ -566,7 +603,9 @@ def install_shared_infra(
|
||||
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:
|
||||
|
||||
@@ -21,10 +21,17 @@ from rich.markup import escape as _escape_markup
|
||||
|
||||
from .._console import console, err_console
|
||||
from .._download_security import (
|
||||
archive_format_from_content_type,
|
||||
archive_format_from_name,
|
||||
archive_suffix,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
is_safe_download_redirect,
|
||||
read_response_limited,
|
||||
safe_extract_archive,
|
||||
)
|
||||
from .._project import _resolve_init_dir_override
|
||||
from ..shared_infra import verify_archive_sha256
|
||||
|
||||
workflow_app = typer.Typer(
|
||||
name="workflow",
|
||||
@@ -455,6 +462,42 @@ def _read_response_within_limit(response, max_bytes: int | None = None) -> bytes
|
||||
return b"".join(chunks)
|
||||
|
||||
|
||||
def _workflow_yaml_is_declared(
|
||||
source_name: str, content_type: str | None
|
||||
) -> bool:
|
||||
"""Return whether response metadata explicitly identifies workflow YAML."""
|
||||
from urllib.parse import urlparse
|
||||
|
||||
path = urlparse(source_name).path.casefold()
|
||||
media_type = (content_type or "").split(";", 1)[0].strip().casefold()
|
||||
return path.endswith((".yml", ".yaml")) or media_type in {
|
||||
"application/yaml",
|
||||
"application/x-yaml",
|
||||
"text/yaml",
|
||||
"text/x-yaml",
|
||||
}
|
||||
|
||||
|
||||
def _sniff_workflow_archive_format(data: bytes):
|
||||
"""Return a supported archive format when suffixless response bytes match."""
|
||||
from io import BytesIO
|
||||
|
||||
try:
|
||||
return detect_archive_format(
|
||||
Path("workflow-download"),
|
||||
archive_file=BytesIO(data),
|
||||
)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _enforce_workflow_yaml_size(data: bytes) -> None:
|
||||
if len(data) > _MAX_WORKFLOW_YAML_BYTES:
|
||||
raise ValueError(
|
||||
f"response exceeds the {_MAX_WORKFLOW_YAML_BYTES}-byte workflow size limit"
|
||||
)
|
||||
|
||||
|
||||
def _validate_workflow_id_or_exit(workflow_id: str) -> None:
|
||||
"""Validate that ``workflow_id`` is a safe installed-workflow directory name."""
|
||||
if (
|
||||
@@ -879,6 +922,231 @@ def _discard_committed_backup_file(backup_file: Path | None) -> None:
|
||||
)
|
||||
|
||||
|
||||
def _workflow_package_root(extracted_root: Path) -> Path:
|
||||
"""Resolve a root-level or single-nested workflow package."""
|
||||
if (extracted_root / "workflow.yml").is_file():
|
||||
return extracted_root
|
||||
entries = list(extracted_root.iterdir())
|
||||
if (
|
||||
len(entries) == 1
|
||||
and entries[0].is_dir()
|
||||
and not entries[0].is_symlink()
|
||||
and (entries[0] / "workflow.yml").is_file()
|
||||
):
|
||||
return entries[0]
|
||||
raise ValueError(
|
||||
"Archive must contain workflow.yml at its root or in exactly one "
|
||||
"top-level directory"
|
||||
)
|
||||
|
||||
|
||||
def _validate_local_workflow_package(package_dir: Path) -> None:
|
||||
"""Reject links and special files before copying a local package."""
|
||||
import stat
|
||||
|
||||
for root, dirnames, filenames in os.walk(package_dir, followlinks=False):
|
||||
root_path = Path(root)
|
||||
for name in [*dirnames, *filenames]:
|
||||
path = root_path / name
|
||||
mode = path.lstat().st_mode
|
||||
if stat.S_ISLNK(mode):
|
||||
raise ValueError(f"Workflow package contains symlink: {path}")
|
||||
if not stat.S_ISDIR(mode) and not stat.S_ISREG(mode):
|
||||
raise ValueError(f"Workflow package contains unsupported file: {path}")
|
||||
|
||||
|
||||
def _workflow_package_has_companions(package_dir: Path) -> bool:
|
||||
"""Return whether a directory contains anything beyond workflow.yml."""
|
||||
return any(path.name != "workflow.yml" for path in package_dir.iterdir())
|
||||
|
||||
|
||||
def _install_workflow_package(
|
||||
project_root: Path,
|
||||
workflows_dir: Path,
|
||||
package_dir: Path,
|
||||
source_label: str,
|
||||
*,
|
||||
expected_id: str | None = None,
|
||||
expected_version: str | None = None,
|
||||
expected_installed_version: str | None = None,
|
||||
catalog_info: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
"""Validate and atomically install a complete workflow package directory."""
|
||||
import shutil
|
||||
import tempfile
|
||||
|
||||
from .engine import WorkflowDefinition, validate_workflow
|
||||
|
||||
workflow_file = package_dir / "workflow.yml"
|
||||
try:
|
||||
_validate_local_workflow_package(package_dir)
|
||||
workflow_bytes = workflow_file.read_bytes()
|
||||
definition = WorkflowDefinition.from_string(workflow_bytes.decode("utf-8"))
|
||||
except (OSError, UnicodeDecodeError, ValueError, yaml.YAMLError) as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid workflow package: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
errors = validate_workflow(definition)
|
||||
if errors:
|
||||
console.print("[red]Error:[/red] Workflow validation failed:")
|
||||
for error in errors:
|
||||
console.print(f" • {_escape_markup(str(error))}")
|
||||
raise typer.Exit(1)
|
||||
if not isinstance(definition.id, str) or not definition.id.strip():
|
||||
console.print("[red]Error:[/red] Workflow definition has an empty or missing 'id'")
|
||||
raise typer.Exit(1)
|
||||
if expected_id is not None and definition.id != expected_id:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Workflow ID in YAML "
|
||||
f"({_escape_markup(repr(definition.id))}) does not match the requested "
|
||||
f"workflow ID ({_escape_markup(repr(expected_id))})."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if expected_version is not None and str(definition.version) != expected_version:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Downloaded workflow version "
|
||||
f"({_escape_markup(str(definition.version))}) does not match the catalog "
|
||||
f"version ({_escape_markup(expected_version)})."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
dest_dir = _safe_workflow_id_dir(workflows_dir, definition.id)
|
||||
staged_dir = Path(
|
||||
tempfile.mkdtemp(prefix=f".{definition.id}.installing-", dir=workflows_dir)
|
||||
)
|
||||
try:
|
||||
package_root = package_dir.resolve()
|
||||
|
||||
def ignore_reserved_package_entries(
|
||||
source: str, names: list[str]
|
||||
) -> set[str]:
|
||||
if Path(source).resolve() == package_root and "overlays" in names:
|
||||
return {"overlays"}
|
||||
return set()
|
||||
|
||||
shutil.copytree(
|
||||
package_dir,
|
||||
staged_dir,
|
||||
dirs_exist_ok=True,
|
||||
ignore=ignore_reserved_package_entries,
|
||||
)
|
||||
except OSError as exc:
|
||||
shutil.rmtree(staged_dir, ignore_errors=True)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to stage workflow package: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
backup_dir: Path | None = None
|
||||
try:
|
||||
with _workflow_install_transaction(project_root):
|
||||
registry = _open_workflow_registry(project_root)
|
||||
existing = registry.get(definition.id)
|
||||
if expected_installed_version is not None and (
|
||||
not isinstance(existing, dict)
|
||||
or existing.get("source") != "catalog"
|
||||
or str(existing.get("version")) != expected_installed_version
|
||||
):
|
||||
console.print(
|
||||
f"[yellow]Warning:[/yellow] Workflow "
|
||||
f"'{_escape_markup(definition.id)}' changed during update; "
|
||||
"rerun the command."
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
if dest_dir.exists():
|
||||
backup_dir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f".{definition.id}.backup-",
|
||||
dir=workflows_dir,
|
||||
)
|
||||
)
|
||||
backup_dir.rmdir()
|
||||
os.replace(dest_dir, backup_dir)
|
||||
try:
|
||||
os.replace(staged_dir, dest_dir)
|
||||
except BaseException:
|
||||
if backup_dir is not None:
|
||||
os.replace(backup_dir, dest_dir)
|
||||
backup_dir = None
|
||||
raise
|
||||
|
||||
entry = {
|
||||
"name": definition.name,
|
||||
"version": definition.version,
|
||||
"description": definition.description,
|
||||
"source": source_label,
|
||||
}
|
||||
if catalog_info is not None:
|
||||
entry.update(
|
||||
{
|
||||
"source": "catalog",
|
||||
"catalog_name": catalog_info.get("_catalog_name", ""),
|
||||
"url": catalog_info.get("url", ""),
|
||||
}
|
||||
)
|
||||
if isinstance(existing, dict) and not existing.get("enabled", True):
|
||||
entry["enabled"] = False
|
||||
try:
|
||||
registry.add(definition.id, entry)
|
||||
except (OSError, TypeError, ValueError):
|
||||
failed_dir: Path | None = None
|
||||
try:
|
||||
failed_dir = Path(
|
||||
tempfile.mkdtemp(
|
||||
prefix=f".{definition.id}.failed-",
|
||||
dir=workflows_dir,
|
||||
)
|
||||
)
|
||||
failed_dir.rmdir()
|
||||
os.replace(dest_dir, failed_dir)
|
||||
if backup_dir is not None:
|
||||
os.replace(backup_dir, dest_dir)
|
||||
backup_dir = None
|
||||
except OSError as rollback_exc:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Failed to fully restore the prior "
|
||||
f"workflow package: {_escape_markup(str(rollback_exc))}"
|
||||
)
|
||||
finally:
|
||||
if failed_dir is not None and failed_dir.exists():
|
||||
try:
|
||||
shutil.rmtree(failed_dir)
|
||||
except OSError as cleanup_exc:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Could not remove failed "
|
||||
f"workflow package: {_escape_markup(str(cleanup_exc))}"
|
||||
)
|
||||
raise
|
||||
except typer.Exit:
|
||||
raise
|
||||
except (OSError, TypeError, ValueError) as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Failed to install workflow package: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
finally:
|
||||
if staged_dir.exists():
|
||||
shutil.rmtree(staged_dir, ignore_errors=True)
|
||||
|
||||
if backup_dir is not None:
|
||||
try:
|
||||
shutil.rmtree(backup_dir)
|
||||
except OSError as exc:
|
||||
console.print(
|
||||
"[yellow]Warning:[/yellow] Workflow installed, but its backup "
|
||||
f"directory could not be removed: {_escape_markup(str(exc))}"
|
||||
)
|
||||
console.print(
|
||||
f"[green]✓[/green] Workflow '{_escape_markup(definition.name)}' "
|
||||
f"({_escape_markup(definition.id)}) installed"
|
||||
)
|
||||
|
||||
|
||||
# Root helper re-fetched at call time so test monkeypatching of
|
||||
# `specify_cli._require_specify_project` keeps working after the move.
|
||||
def _require_specify_project(*args, **kwargs):
|
||||
@@ -889,6 +1157,18 @@ def _require_specify_project(*args, **kwargs):
|
||||
return project_root
|
||||
|
||||
|
||||
def _failed_step_error(state: Any) -> str | None:
|
||||
"""Terminal error for a failed/aborted run, if any.
|
||||
|
||||
Returns the run-level error persisted by the engine at the moment
|
||||
the run terminated. Returns ``None`` for non-terminal statuses so
|
||||
the caller can print unconditionally.
|
||||
"""
|
||||
if getattr(state.status, "value", state.status) not in ("failed", "aborted"):
|
||||
return None
|
||||
return getattr(state, "error", None)
|
||||
|
||||
|
||||
def _workflow_run_payload(state: Any) -> dict[str, Any]:
|
||||
"""Machine-readable summary of a run/resume outcome."""
|
||||
payload = {
|
||||
@@ -901,6 +1181,9 @@ def _workflow_run_payload(state: Any) -> dict[str, Any]:
|
||||
gate = _gate_outcome(state)
|
||||
if gate is not None:
|
||||
payload["gate"] = gate
|
||||
error = _failed_step_error(state)
|
||||
if error is not None:
|
||||
payload["error"] = error
|
||||
return payload
|
||||
|
||||
|
||||
@@ -1054,7 +1337,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 `[<step id>]` 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 `\[<type>]` 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)
|
||||
|
||||
@@ -1150,6 +1444,10 @@ def workflow_run(
|
||||
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
||||
console.print(f"[dim]Run ID: {state.run_id}[/dim]")
|
||||
|
||||
err_msg = _failed_step_error(state)
|
||||
if err_msg:
|
||||
console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}")
|
||||
|
||||
if state.status.value == "paused":
|
||||
console.print(f"\nResume with: [cyan]specify workflow resume {state.run_id}[/cyan]")
|
||||
|
||||
@@ -1176,7 +1474,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 `[<step id>]` 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 `\[<type>]` 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)
|
||||
@@ -1249,6 +1558,10 @@ def workflow_resume(
|
||||
color = status_colors.get(state.status.value, "white")
|
||||
console.print(f"\n[{color}]Status: {state.status.value}[/{color}]")
|
||||
|
||||
err_msg = _failed_step_error(state)
|
||||
if err_msg:
|
||||
console.print(f"[red]Error:[/red] {_escape_markup(err_msg)}")
|
||||
|
||||
raise typer.Exit(_run_outcome_exit_code(state.status.value))
|
||||
|
||||
|
||||
@@ -1316,6 +1629,10 @@ def workflow_status(
|
||||
if state.current_step_id:
|
||||
console.print(f" Current: {state.current_step_id}")
|
||||
|
||||
err_msg = _failed_step_error(state)
|
||||
if err_msg:
|
||||
console.print(f" [red]Error: {_escape_markup(err_msg)}[/red]")
|
||||
|
||||
if state.step_results:
|
||||
console.print(f"\n [bold]Steps ({len(state.step_results)}):[/bold]")
|
||||
for step_id, step_data in state.step_results.items():
|
||||
@@ -1553,16 +1870,48 @@ def workflow_add(
|
||||
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_file() and archive_format_from_name(str(dev_path)) is not None:
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-workflow-archive-"
|
||||
) as tmpdir:
|
||||
extracted_root = Path(tmpdir)
|
||||
try:
|
||||
safe_extract_archive(dev_path, extracted_root)
|
||||
package_root = _workflow_package_root(extracted_root)
|
||||
except ValueError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid workflow archive: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
package_root,
|
||||
str(dev_path),
|
||||
)
|
||||
return
|
||||
if dev_path.is_dir():
|
||||
dev_wf_file = dev_path / "workflow.yml"
|
||||
if not dev_wf_file.is_file():
|
||||
console.print(f"[red]Error:[/red] No workflow.yml found in {_escape_markup(source)}")
|
||||
raise typer.Exit(1)
|
||||
_validate_and_install_local(dev_wf_file, str(dev_path))
|
||||
if _workflow_package_has_companions(dev_path):
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
dev_path,
|
||||
str(dev_path),
|
||||
)
|
||||
else:
|
||||
_validate_and_install_local(dev_wf_file, str(dev_path))
|
||||
return
|
||||
console.print(
|
||||
"[red]Error:[/red] --dev source must be a workflow YAML file or a "
|
||||
f"directory containing workflow.yml: {_escape_markup(source)}"
|
||||
"[red]Error:[/red] --dev source must be a workflow YAML file, "
|
||||
"supported archive, or directory containing workflow.yml: "
|
||||
f"{_escape_markup(source)}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
@@ -1625,6 +1974,7 @@ def workflow_add(
|
||||
|
||||
import tempfile
|
||||
tmp_path: Path | None = None
|
||||
downloaded_archive_format = None
|
||||
try:
|
||||
with _open_url(
|
||||
download_url,
|
||||
@@ -1638,13 +1988,48 @@ def workflow_add(
|
||||
f"[red]Error:[/red] URL redirected to non-HTTPS: {_escape_markup(final_url)}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
with tempfile.NamedTemporaryFile(suffix=".yml", delete=False) as tmp:
|
||||
content_type = (
|
||||
resp.getheader("Content-Type")
|
||||
if hasattr(resp, "getheader")
|
||||
else None
|
||||
)
|
||||
downloaded_archive_format = (
|
||||
archive_format_from_name(final_url)
|
||||
or archive_format_from_name(download_url)
|
||||
or archive_format_from_content_type(content_type)
|
||||
)
|
||||
declared_yaml = _workflow_yaml_is_declared(final_url, content_type)
|
||||
suffix = (
|
||||
archive_suffix(downloaded_archive_format)
|
||||
if downloaded_archive_format is not None
|
||||
else ".yml" if declared_yaml else ".download"
|
||||
)
|
||||
with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
|
||||
# Assign tmp_path immediately: NamedTemporaryFile(delete=False)
|
||||
# creates the file on disk right away, before any bytes are
|
||||
# written, so a failure in the size-limited read below must
|
||||
# still be able to find and remove it.
|
||||
tmp_path = Path(tmp.name)
|
||||
tmp.write(_read_response_within_limit(resp))
|
||||
if downloaded_archive_format is not None:
|
||||
downloaded_content = read_response_limited(
|
||||
resp,
|
||||
error_type=ValueError,
|
||||
label="workflow archive download",
|
||||
)
|
||||
elif declared_yaml:
|
||||
downloaded_content = _read_response_within_limit(resp)
|
||||
else:
|
||||
downloaded_content = read_response_limited(
|
||||
resp,
|
||||
error_type=ValueError,
|
||||
label="workflow download",
|
||||
)
|
||||
downloaded_archive_format = (
|
||||
_sniff_workflow_archive_format(downloaded_content)
|
||||
)
|
||||
if downloaded_archive_format is None:
|
||||
_enforce_workflow_yaml_size(downloaded_content)
|
||||
tmp.write(downloaded_content)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except Exception as exc:
|
||||
@@ -1664,13 +2049,38 @@ def workflow_add(
|
||||
console.print(f"[red]Error:[/red] Failed to download workflow: {_escape_markup(str(exc))}")
|
||||
raise typer.Exit(1)
|
||||
try:
|
||||
# When installed via --from, the positional argument names the
|
||||
# workflow the user expects — enforce it like the catalog branch.
|
||||
_validate_and_install_local(
|
||||
tmp_path,
|
||||
download_url,
|
||||
expected_id=source if from_url else None,
|
||||
)
|
||||
if downloaded_archive_format is None:
|
||||
_validate_and_install_local(
|
||||
tmp_path,
|
||||
download_url,
|
||||
expected_id=source if from_url else None,
|
||||
)
|
||||
else:
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-workflow-archive-"
|
||||
) as extract_dir:
|
||||
extracted_root = Path(extract_dir)
|
||||
try:
|
||||
safe_extract_archive(
|
||||
tmp_path,
|
||||
extracted_root,
|
||||
source_name=final_url,
|
||||
content_type=content_type,
|
||||
)
|
||||
package_root = _workflow_package_root(extracted_root)
|
||||
except ValueError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid workflow archive: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
package_root,
|
||||
download_url,
|
||||
expected_id=source if from_url else None,
|
||||
)
|
||||
finally:
|
||||
# Best-effort: _validate_and_install_local may already have
|
||||
# committed the file + registry entry (success) or already
|
||||
@@ -1694,12 +2104,46 @@ def workflow_add(
|
||||
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_file()
|
||||
and archive_format_from_name(str(source_path)) is not None
|
||||
):
|
||||
import tempfile
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-workflow-archive-"
|
||||
) as tmpdir:
|
||||
extracted_root = Path(tmpdir)
|
||||
try:
|
||||
safe_extract_archive(source_path, extracted_root)
|
||||
package_root = _workflow_package_root(extracted_root)
|
||||
except ValueError as exc:
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid workflow archive: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
package_root,
|
||||
str(source_path),
|
||||
)
|
||||
return
|
||||
elif source_path.is_dir():
|
||||
wf_file = source_path / "workflow.yml"
|
||||
if not wf_file.is_file():
|
||||
console.print(f"[red]Error:[/red] No workflow.yml found in {_escape_markup(source)}")
|
||||
raise typer.Exit(1)
|
||||
_validate_and_install_local(wf_file, str(source_path))
|
||||
if _workflow_package_has_companions(source_path):
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
source_path,
|
||||
str(source_path),
|
||||
)
|
||||
else:
|
||||
_validate_and_install_local(wf_file, str(source_path))
|
||||
return
|
||||
|
||||
# Try from catalog
|
||||
@@ -1804,6 +2248,9 @@ def _install_workflow_from_catalog(
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
|
||||
original_workflow_url = workflow_url
|
||||
downloaded_archive_format = None
|
||||
archive_content_type = None
|
||||
try:
|
||||
from specify_cli.authentication.http import open_url as _open_url
|
||||
from specify_cli.authentication.http import github_provider_hosts as _github_provider_hosts
|
||||
@@ -1835,10 +2282,38 @@ def _install_workflow_from_catalog(
|
||||
f"[red]Error:[/red] Workflow '{safe_wf_id}' redirected to non-HTTPS URL: {_escape_markup(final_url)}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
archive_content_type = (
|
||||
response.getheader("Content-Type")
|
||||
if hasattr(response, "getheader")
|
||||
else None
|
||||
)
|
||||
downloaded_archive_format = (
|
||||
archive_format_from_name(final_url)
|
||||
or archive_format_from_name(original_workflow_url)
|
||||
or archive_format_from_content_type(archive_content_type)
|
||||
)
|
||||
# Written to the staging file, never workflow_file directly, so a
|
||||
# reinstall's prior working copy is never touched until the
|
||||
# atomic commit below runs.
|
||||
downloaded_content = _read_response_within_limit(response)
|
||||
if downloaded_archive_format is not None:
|
||||
downloaded_content = read_response_limited(
|
||||
response,
|
||||
error_type=ValueError,
|
||||
label=f"workflow '{workflow_id}' archive download",
|
||||
)
|
||||
elif _workflow_yaml_is_declared(final_url, archive_content_type):
|
||||
downloaded_content = _read_response_within_limit(response)
|
||||
else:
|
||||
downloaded_content = read_response_limited(
|
||||
response,
|
||||
error_type=ValueError,
|
||||
label=f"workflow '{workflow_id}' download",
|
||||
)
|
||||
downloaded_archive_format = _sniff_workflow_archive_format(
|
||||
downloaded_content
|
||||
)
|
||||
if downloaded_archive_format is None:
|
||||
_enforce_workflow_yaml_size(downloaded_content)
|
||||
staged_file.write_bytes(downloaded_content)
|
||||
except typer.Exit:
|
||||
raise
|
||||
@@ -1847,6 +2322,59 @@ def _install_workflow_from_catalog(
|
||||
console.print(f"[red]Error:[/red] Failed to install workflow '{safe_wf_id}' from catalog: {_escape_markup(str(exc))}")
|
||||
raise typer.Exit(1)
|
||||
|
||||
if downloaded_archive_format is not None:
|
||||
try:
|
||||
verify_archive_sha256(
|
||||
downloaded_content,
|
||||
info.get("sha256"),
|
||||
workflow_id,
|
||||
ValueError,
|
||||
)
|
||||
import tempfile
|
||||
from io import BytesIO
|
||||
|
||||
with tempfile.TemporaryDirectory(
|
||||
prefix="speckit-workflow-archive-"
|
||||
) as extract_dir:
|
||||
extracted_root = Path(extract_dir)
|
||||
safe_extract_archive(
|
||||
staged_file.path,
|
||||
extracted_root,
|
||||
archive_file=BytesIO(downloaded_content),
|
||||
source_name=original_workflow_url,
|
||||
content_type=archive_content_type,
|
||||
)
|
||||
package_root = _workflow_package_root(extracted_root)
|
||||
_safe_discard_staged_workflow_file(
|
||||
staged_file,
|
||||
workflow_dir,
|
||||
existed_before,
|
||||
)
|
||||
_install_workflow_package(
|
||||
project_root,
|
||||
workflows_dir,
|
||||
package_root,
|
||||
workflow_url,
|
||||
expected_id=workflow_id,
|
||||
expected_version=expected_version,
|
||||
expected_installed_version=expected_installed_version,
|
||||
catalog_info={**info, "url": workflow_url},
|
||||
)
|
||||
except typer.Exit:
|
||||
raise
|
||||
except (OSError, ValueError) as exc:
|
||||
_safe_discard_staged_workflow_file(
|
||||
staged_file,
|
||||
workflow_dir,
|
||||
existed_before,
|
||||
)
|
||||
console.print(
|
||||
f"[red]Error:[/red] Invalid workflow archive: "
|
||||
f"{_escape_markup(str(exc))}"
|
||||
)
|
||||
raise typer.Exit(1)
|
||||
return
|
||||
|
||||
# Validate the downloaded workflow (still staged, not yet committed)
|
||||
# before registering.
|
||||
try:
|
||||
@@ -2326,7 +2854,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()
|
||||
@@ -2424,8 +2952,9 @@ def workflow_info(
|
||||
console.print(f" Version: {_escape_markup(str(info.get('version', '?')))}")
|
||||
if info.get("description"):
|
||||
console.print(f" Description: {_escape_markup(str(info['description']))}")
|
||||
if info.get("tags"):
|
||||
safe_tags = _escape_markup(", ".join(str(t) for t in info["tags"]))
|
||||
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:
|
||||
@@ -2526,9 +3055,10 @@ def workflow_step_list():
|
||||
console.print(" [bold]Custom (installed):[/bold]")
|
||||
for key in sorted(installed):
|
||||
meta = installed[key] or {}
|
||||
name = meta.get("name", key)
|
||||
version = meta.get("version", "?")
|
||||
console.print(f" • [bold]{name}[/bold] ({key}) v{version}")
|
||||
name = _escape_markup(str(meta.get("name", key)))
|
||||
safe_key = _escape_markup(str(key))
|
||||
version = _escape_markup(str(meta.get("version", "?")))
|
||||
console.print(f" • [bold]{name}[/bold] ({safe_key}) v{version}")
|
||||
console.print()
|
||||
|
||||
if not built_in and not installed:
|
||||
@@ -3072,13 +3602,15 @@ def workflow_step_search(
|
||||
install_note = (
|
||||
"" if step.get("_install_allowed", True) else " [dim](discovery only)[/dim]"
|
||||
)
|
||||
name = _escape_markup(str(step.get("name", step.get("id", "?"))))
|
||||
step_id = _escape_markup(str(step.get("id", "?")))
|
||||
version = _escape_markup(str(step.get("version", "?")))
|
||||
console.print(
|
||||
f" [bold]{step.get('name', step.get('id', '?'))}[/bold]"
|
||||
f" ({step.get('id', '?')}) v{step.get('version', '?')}{install_note}"
|
||||
f" [bold]{name}[/bold] ({step_id}) v{version}{install_note}"
|
||||
)
|
||||
desc = step.get("description", "")
|
||||
if desc:
|
||||
console.print(f" {desc}")
|
||||
console.print(f" {_escape_markup(str(desc))}")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -3091,6 +3623,7 @@ def workflow_step_info(
|
||||
from .catalog import StepCatalog, StepCatalogError, StepRegistry
|
||||
|
||||
project_root = _require_specify_project()
|
||||
safe_step_id = _escape_markup(str(step_id))
|
||||
|
||||
registry = StepRegistry(project_root)
|
||||
installed_meta = registry.get(step_id)
|
||||
@@ -3100,20 +3633,27 @@ def workflow_step_info(
|
||||
is_builtin = builtin_step is not None and not installed_meta
|
||||
|
||||
if is_builtin:
|
||||
console.print(f"\n[bold cyan]{step_id}[/bold cyan] [dim](built-in)[/dim]")
|
||||
console.print(f" Type key: {step_id}")
|
||||
console.print(f"\n[bold cyan]{safe_step_id}[/bold cyan] [dim](built-in)[/dim]")
|
||||
console.print(f" Type key: {safe_step_id}")
|
||||
console.print(" [green]Built-in step type[/green]")
|
||||
return
|
||||
|
||||
if installed_meta:
|
||||
name = _escape_markup(str(installed_meta.get("name", step_id)))
|
||||
version = _escape_markup(str(installed_meta.get("version", "?")))
|
||||
console.print(
|
||||
f"\n[bold cyan]{installed_meta.get('name', step_id)}[/bold cyan] ({step_id})"
|
||||
f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})"
|
||||
)
|
||||
console.print(f" Version: {installed_meta.get('version', '?')}")
|
||||
console.print(f" Version: {version}")
|
||||
if installed_meta.get("author"):
|
||||
console.print(f" Author: {installed_meta['author']}")
|
||||
console.print(
|
||||
f" Author: {_escape_markup(str(installed_meta['author']))}"
|
||||
)
|
||||
if installed_meta.get("description"):
|
||||
console.print(f" Description: {installed_meta['description']}")
|
||||
console.print(
|
||||
f" Description: "
|
||||
f"{_escape_markup(str(installed_meta['description']))}"
|
||||
)
|
||||
console.print(" [green]Installed[/green]")
|
||||
return
|
||||
|
||||
@@ -3125,20 +3665,24 @@ def workflow_step_info(
|
||||
info = None
|
||||
|
||||
if info:
|
||||
name = _escape_markup(str(info.get("name", step_id)))
|
||||
version = _escape_markup(str(info.get("version", "?")))
|
||||
console.print(
|
||||
f"\n[bold cyan]{info.get('name', step_id)}[/bold cyan] ({step_id})"
|
||||
f"\n[bold cyan]{name}[/bold cyan] ({safe_step_id})"
|
||||
)
|
||||
console.print(f" Version: {info.get('version', '?')}")
|
||||
console.print(f" Version: {version}")
|
||||
if info.get("author"):
|
||||
console.print(f" Author: {info['author']}")
|
||||
console.print(f" Author: {_escape_markup(str(info['author']))}")
|
||||
if info.get("description"):
|
||||
console.print(f" Description: {info['description']}")
|
||||
console.print(
|
||||
f" Description: {_escape_markup(str(info['description']))}"
|
||||
)
|
||||
console.print(" [yellow]Not installed[/yellow]")
|
||||
console.print(
|
||||
f"\n Install with: [cyan]specify workflow step add {step_id}[/cyan]"
|
||||
f"\n Install with: [cyan]specify workflow step add {safe_step_id}[/cyan]"
|
||||
)
|
||||
else:
|
||||
console.print(f"[red]Error:[/red] Step type '{step_id}' not found")
|
||||
console.print(f"[red]Error:[/red] Step type '{safe_step_id}' not found")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
|
||||
@@ -56,6 +56,9 @@ class StepContext:
|
||||
#: Current fan-out item (set only inside fan-out iterations).
|
||||
item: Any = None
|
||||
|
||||
#: Whether the current step is executing inside a fan-out template.
|
||||
inside_fan_out: bool = False
|
||||
|
||||
#: Fan-in aggregated results (set only for fan-in steps).
|
||||
fan_in: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@@ -335,26 +335,45 @@ class WorkflowCatalog:
|
||||
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):
|
||||
@@ -476,6 +495,8 @@ class WorkflowCatalog:
|
||||
try:
|
||||
with open(meta_file, encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
if not isinstance(meta, dict):
|
||||
return False
|
||||
fetched_at = float(meta.get("fetched_at", 0))
|
||||
return (time.time() - fetched_at) < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
@@ -490,8 +511,10 @@ class WorkflowCatalog:
|
||||
if not force_refresh and self._is_url_cache_valid(entry.url):
|
||||
try:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
except (json.JSONDecodeError, OSError):
|
||||
cached = json.load(f)
|
||||
if isinstance(cached, dict):
|
||||
return cached
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, OSError):
|
||||
# Ignore invalid/unreadable cache and fall back to fetching from source.
|
||||
pass
|
||||
|
||||
@@ -555,7 +578,9 @@ class WorkflowCatalog:
|
||||
if cache_file.exists():
|
||||
try:
|
||||
with open(cache_file, encoding="utf-8") as f:
|
||||
return json.load(f)
|
||||
cached = json.load(f)
|
||||
if isinstance(cached, dict):
|
||||
return cached
|
||||
except (json.JSONDecodeError, ValueError, OSError):
|
||||
# Stale-cache read failed; let the original fetch error propagate.
|
||||
pass
|
||||
@@ -1018,24 +1043,33 @@ class StepCatalog:
|
||||
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):
|
||||
@@ -1156,6 +1190,8 @@ class StepCatalog:
|
||||
try:
|
||||
with open(meta_file, encoding="utf-8") as f:
|
||||
meta = json.load(f)
|
||||
if not isinstance(meta, dict):
|
||||
return False
|
||||
fetched_at = float(meta.get("fetched_at", 0))
|
||||
return (time.time() - fetched_at) < self.CACHE_DURATION
|
||||
except (json.JSONDecodeError, OSError, TypeError, ValueError):
|
||||
@@ -1174,7 +1210,7 @@ class StepCatalog:
|
||||
cached = json.load(f)
|
||||
if isinstance(cached, dict):
|
||||
return cached
|
||||
except (json.JSONDecodeError, OSError):
|
||||
except (UnicodeDecodeError, json.JSONDecodeError, OSError):
|
||||
# Ignore invalid/unreadable cache and fall back to fetching from source.
|
||||
pass
|
||||
|
||||
|
||||
@@ -308,7 +308,16 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
|
||||
errors.append("Workflow has no steps defined.")
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
_validate_steps(definition.steps, seen_ids, errors)
|
||||
# ``input_defs`` maps declared workflow input names to their definitions —
|
||||
# used by ``_validate_steps`` to cross-reference gate ``verdict_input``
|
||||
# bindings (both that the name exists and that its ``enum`` permits the
|
||||
# reset sentinel). ``None`` means the inputs block itself is malformed
|
||||
# (already reported above); the cross-check is then disabled so one
|
||||
# authoring mistake does not cascade into N spurious "undeclared" errors.
|
||||
input_defs: dict[str, Any] | None = (
|
||||
dict(definition.inputs) if isinstance(definition.inputs, dict) else None
|
||||
)
|
||||
_validate_steps(definition.steps, seen_ids, errors, input_defs)
|
||||
|
||||
return errors
|
||||
|
||||
@@ -317,8 +326,16 @@ def _validate_steps(
|
||||
steps: list[dict[str, Any]],
|
||||
seen_ids: set[str],
|
||||
errors: list[str],
|
||||
input_defs: dict[str, Any] | None = None,
|
||||
inside_fan_out: bool = False,
|
||||
) -> None:
|
||||
"""Recursively validate a list of steps."""
|
||||
"""Recursively validate a list of steps.
|
||||
|
||||
``input_defs`` maps declared workflow input names to their definitions (or
|
||||
is ``None`` when the inputs block is malformed). ``inside_fan_out`` is
|
||||
threaded through nested control-flow steps so gate verdict bindings can be
|
||||
rejected anywhere inside a fan-out template.
|
||||
"""
|
||||
from . import STEP_REGISTRY
|
||||
|
||||
for step_config in steps:
|
||||
@@ -411,30 +428,101 @@ def _validate_steps(
|
||||
f"unknown or not-yet-declared step id {wid!r}."
|
||||
)
|
||||
|
||||
# Gate verdict_input: fan-out items cannot bind shared workflow inputs
|
||||
# as per-item verdicts. Outside fan-out, the binding must reference a
|
||||
# declared workflow input because ``_resolve_inputs`` drops undeclared
|
||||
# names at both initial run and resume. Only check a non-empty string;
|
||||
# malformed shapes are already reported by ``GateStep.validate()``.
|
||||
if step_type == "gate":
|
||||
verdict_input = step_config.get("verdict_input")
|
||||
if isinstance(verdict_input, str) and verdict_input:
|
||||
if inside_fan_out:
|
||||
errors.append(
|
||||
f"Gate step {step_id!r}: 'verdict_input' is not "
|
||||
"supported inside fan-out templates."
|
||||
)
|
||||
elif input_defs is not None and verdict_input not in input_defs:
|
||||
errors.append(
|
||||
f"Gate step {step_id!r}: 'verdict_input' references "
|
||||
f"undeclared input {verdict_input!r}."
|
||||
)
|
||||
elif input_defs is not None:
|
||||
# ``on_reject: retry`` resets the bound input to "" before
|
||||
# pausing, and every later resume re-resolves the persisted
|
||||
# inputs through ``_coerce_input``. If the input declares an
|
||||
# ``enum`` that omits "", that reset value is instantly
|
||||
# illegal: the run pauses fine, but the next resume that
|
||||
# supplies any input raises "value '' not in allowed
|
||||
# values", and no verdict can be routed through the gate
|
||||
# again. Require the enum to admit the sentinel so the
|
||||
# retry cycle the field advertises is actually reachable.
|
||||
verdict_def = input_defs.get(verdict_input)
|
||||
enum_values = (
|
||||
verdict_def.get("enum")
|
||||
if isinstance(verdict_def, dict)
|
||||
else None
|
||||
)
|
||||
if (
|
||||
step_config.get("on_reject") == "retry"
|
||||
and isinstance(enum_values, list)
|
||||
and "" not in enum_values
|
||||
):
|
||||
errors.append(
|
||||
f"Gate step {step_id!r}: on_reject='retry' resets "
|
||||
f"verdict input {verdict_input!r} to '' when the "
|
||||
f"gate is rejected, but that input's 'enum' does "
|
||||
f"not allow ''. Add '' to the enum or use "
|
||||
f"on_reject='abort'/'skip'."
|
||||
)
|
||||
|
||||
# Recursively validate nested steps
|
||||
for nested_key in ("then", "else", "steps"):
|
||||
nested = step_config.get(nested_key)
|
||||
if isinstance(nested, list):
|
||||
_validate_steps(nested, seen_ids, errors)
|
||||
_validate_steps(
|
||||
nested,
|
||||
seen_ids,
|
||||
errors,
|
||||
input_defs,
|
||||
inside_fan_out=inside_fan_out,
|
||||
)
|
||||
|
||||
# Validate switch cases
|
||||
cases = step_config.get("cases")
|
||||
if isinstance(cases, dict):
|
||||
for _case_key, case_steps in cases.items():
|
||||
if isinstance(case_steps, list):
|
||||
_validate_steps(case_steps, seen_ids, errors)
|
||||
_validate_steps(
|
||||
case_steps,
|
||||
seen_ids,
|
||||
errors,
|
||||
input_defs,
|
||||
inside_fan_out=inside_fan_out,
|
||||
)
|
||||
|
||||
# Validate switch default
|
||||
default = step_config.get("default")
|
||||
if isinstance(default, list):
|
||||
_validate_steps(default, seen_ids, errors)
|
||||
_validate_steps(
|
||||
default,
|
||||
seen_ids,
|
||||
errors,
|
||||
input_defs,
|
||||
inside_fan_out=inside_fan_out,
|
||||
)
|
||||
|
||||
# Validate fan-out nested step (template — not added to seen_ids
|
||||
# since the engine generates parentId:templateId:index at runtime)
|
||||
fan_step = step_config.get("step")
|
||||
if isinstance(fan_step, dict):
|
||||
fan_errors: list[str] = []
|
||||
_validate_steps([fan_step], set(), fan_errors)
|
||||
_validate_steps(
|
||||
[fan_step],
|
||||
set(),
|
||||
fan_errors,
|
||||
input_defs,
|
||||
inside_fan_out=True,
|
||||
)
|
||||
errors.extend(fan_errors)
|
||||
|
||||
|
||||
@@ -560,6 +648,7 @@ class RunState:
|
||||
self.created_at = datetime.now(timezone.utc).isoformat()
|
||||
self.updated_at = self.created_at
|
||||
self.log_entries: list[dict[str, Any]] = []
|
||||
self.error: str | None = None
|
||||
|
||||
@property
|
||||
def runs_dir(self) -> Path:
|
||||
@@ -614,6 +703,7 @@ class RunState:
|
||||
"workflow_dir": self.workflow_dir,
|
||||
"created_at": self.created_at,
|
||||
"updated_at": self.updated_at,
|
||||
"error": self.error,
|
||||
}
|
||||
self._atomic_write_json(runs_dir / "state.json", state_data)
|
||||
self._atomic_write_json(runs_dir / "inputs.json", {"inputs": self.inputs})
|
||||
@@ -707,6 +797,7 @@ class RunState:
|
||||
state.workflow_dir = state_data.get("workflow_dir")
|
||||
state.created_at = state_data.get("created_at", "")
|
||||
state.updated_at = state_data.get("updated_at", "")
|
||||
state.error = state_data.get("error")
|
||||
|
||||
inputs_path = runs_dir / "inputs.json"
|
||||
if inputs_path.exists():
|
||||
@@ -901,6 +992,7 @@ class WorkflowEngine:
|
||||
return state
|
||||
except Exception as exc:
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = str(exc)
|
||||
state.append_log({"event": "workflow_failed", "error": str(exc)})
|
||||
state.save()
|
||||
raise
|
||||
@@ -959,6 +1051,7 @@ class WorkflowEngine:
|
||||
|
||||
from . import STEP_REGISTRY
|
||||
|
||||
state.error = None
|
||||
state.status = RunStatus.RUNNING
|
||||
state.save()
|
||||
|
||||
@@ -979,6 +1072,7 @@ class WorkflowEngine:
|
||||
return state
|
||||
except Exception as exc:
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = str(exc)
|
||||
state.append_log({"event": "resume_failed", "error": str(exc)})
|
||||
state.save()
|
||||
raise
|
||||
@@ -1038,6 +1132,7 @@ class WorkflowEngine:
|
||||
step_impl = registry.get(step_type)
|
||||
if not step_impl:
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = f"Unknown step type: {step_type!r}"
|
||||
state.append_log(
|
||||
{
|
||||
"event": "step_failed",
|
||||
@@ -1065,6 +1160,7 @@ class WorkflowEngine:
|
||||
or step_config.get("input", {}),
|
||||
"output": result.output,
|
||||
"status": result.status.value,
|
||||
"error": result.error,
|
||||
}
|
||||
self._record_result(context, state, step_id, step_data)
|
||||
|
||||
@@ -1090,6 +1186,7 @@ class WorkflowEngine:
|
||||
# is for transient/expected step failures only.
|
||||
if result.output.get("aborted"):
|
||||
state.status = RunStatus.ABORTED
|
||||
state.error = result.error
|
||||
state.append_log(
|
||||
{
|
||||
"event": "workflow_aborted",
|
||||
@@ -1132,6 +1229,7 @@ class WorkflowEngine:
|
||||
continue
|
||||
|
||||
state.status = RunStatus.FAILED
|
||||
state.error = result.error
|
||||
state.append_log(
|
||||
{
|
||||
"event": "step_failed",
|
||||
@@ -1305,11 +1403,18 @@ class WorkflowEngine:
|
||||
# Sequential path — identical to the historical behavior.
|
||||
if workers <= 1:
|
||||
results: list[Any] = []
|
||||
for item_idx, item_val in enumerate(items):
|
||||
context.item = item_val
|
||||
results.append(run_item(item_idx, context))
|
||||
if state.status in halting:
|
||||
break
|
||||
previous_item = context.item
|
||||
previous_inside_fan_out = context.inside_fan_out
|
||||
context.inside_fan_out = True
|
||||
try:
|
||||
for item_idx, item_val in enumerate(items):
|
||||
context.item = item_val
|
||||
results.append(run_item(item_idx, context))
|
||||
if state.status in halting:
|
||||
break
|
||||
finally:
|
||||
context.item = previous_item
|
||||
context.inside_fan_out = previous_inside_fan_out
|
||||
return results
|
||||
|
||||
# Concurrent path — bounded sliding window; results assembled in item order.
|
||||
@@ -1320,7 +1425,14 @@ class WorkflowEngine:
|
||||
# Each item runs against its own context copy so context.item is not
|
||||
# clobbered across threads; the shared steps dict is written only on the
|
||||
# disjoint parentId:templateId:index key (GIL-safe on distinct keys).
|
||||
return run_item(idx, dataclasses.replace(context, item=items[idx]))
|
||||
return run_item(
|
||||
idx,
|
||||
dataclasses.replace(
|
||||
context,
|
||||
item=items[idx],
|
||||
inside_fan_out=True,
|
||||
),
|
||||
)
|
||||
|
||||
def item_halt_status(idx: int) -> RunStatus | None:
|
||||
# If THIS item's own execution halted the run, return the resulting run
|
||||
@@ -1401,6 +1513,16 @@ class WorkflowEngine:
|
||||
# pool joined; restore the halting item's own outcome so the final run
|
||||
# status matches the sequential semantics.
|
||||
state.status = halted_status
|
||||
# Restore the halting item's error so it matches the terminal
|
||||
# status — a concurrent item may have overwritten state.error
|
||||
# before the pool joined. Assign unconditionally when a record
|
||||
# exists (even when the halting item's own error is falsy) so a
|
||||
# third-party step returning FAILED with no message never inherits
|
||||
# an unrelated concurrent item's error; this mirrors the sequential
|
||||
# path, which sets state.error = result.error verbatim.
|
||||
halt_rec = context.steps.get(item_id(halted_at))
|
||||
if isinstance(halt_rec, dict):
|
||||
state.error = halt_rec.get("error")
|
||||
return slots[: halted_at + 1]
|
||||
return slots[:collected]
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ from typing import Any
|
||||
|
||||
import typer
|
||||
import yaml
|
||||
from rich.markup import escape as _escape_markup
|
||||
|
||||
from ..._console import console, err_console
|
||||
from ...extensions import normalize_priority
|
||||
@@ -412,14 +413,23 @@ def workflow_resolve(project_root: Path, workflow_id: str) -> dict[str, Any] | N
|
||||
priority = (
|
||||
"n/a" if layer.tier == "base" else str(normalize_priority(layer.priority))
|
||||
)
|
||||
# ``\[`` keeps the literal bracket: unescaped, Rich parses ``[base]`` /
|
||||
# ``[project-overlay]`` as a style tag and swallows the tier label whole.
|
||||
console.print(
|
||||
f" \u2022 [{layer.tier}] {layer.source} "
|
||||
f" \u2022 \\[{_escape_markup(layer.tier)}] "
|
||||
f"{_escape_markup(layer.source)} "
|
||||
f"(priority={priority})"
|
||||
)
|
||||
|
||||
console.print("Step attribution:")
|
||||
for composed in attribution:
|
||||
console.print(f" \u2022 {composed.step_id}: {composed.source}")
|
||||
# Step IDs come from base-workflow / overlay YAML, which only bans ``:``
|
||||
# \u2014 brackets pass validation, so they reach Rich as markup. A balanced
|
||||
# ``[stuff]`` is swallowed; an unbalanced ``[/red]`` raises MarkupError.
|
||||
console.print(
|
||||
f" \u2022 {_escape_markup(composed.step_id)}: "
|
||||
f"{_escape_markup(composed.source)}"
|
||||
)
|
||||
|
||||
return {
|
||||
"workflow_id": workflow_id,
|
||||
|
||||
@@ -70,6 +70,24 @@ class DoWhileStep(StepBase):
|
||||
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
|
||||
|
||||
@@ -20,9 +20,31 @@ class FanInStep(StepBase):
|
||||
|
||||
def execute(self, config: dict[str, Any], context: StepContext) -> StepResult:
|
||||
wait_for = config.get("wait_for", [])
|
||||
output_config = config.get("output") or {}
|
||||
if not isinstance(output_config, dict):
|
||||
output_config = config.get("output")
|
||||
if output_config is None:
|
||||
output_config = {}
|
||||
elif not isinstance(output_config, dict):
|
||||
# ``validate`` rejects a non-mapping ``output`` and its comment says
|
||||
# why: "execute() silently coerces a non-mapping output to {}, so the
|
||||
# author's declared aggregation keys would vanish with no error."
|
||||
# The engine does not auto-validate before ``execute``, so on an
|
||||
# unvalidated run that is exactly what happened -- and ``x or {}``
|
||||
# masked the falsy shapes ([], false, 0, '') before the isinstance
|
||||
# check even ran. Every declared key vanished while the step still
|
||||
# reported COMPLETED, so downstream ``steps.<id>.output.<key>``
|
||||
# resolved to None and interpolated as "": the same "silent empty
|
||||
# result + COMPLETED" wiring bug the ``wait_for`` guard below
|
||||
# rejects. Fail loudly with validate()'s own message instead. An
|
||||
# explicit ``output:`` (YAML null) stays valid, matching validate.
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Fan-in step {config.get('id', '?')!r}: 'output' must be a "
|
||||
f"mapping of key -> expression, got "
|
||||
f"{type(output_config).__name__}."
|
||||
),
|
||||
output={"results": []},
|
||||
)
|
||||
|
||||
# The engine does not auto-validate step config, so an unvalidated run
|
||||
# with a non-list ``wait_for`` reaches here raw. Iterating it then
|
||||
|
||||
@@ -26,7 +26,8 @@ class GateStep(StepBase):
|
||||
later with ``specify workflow resume``.
|
||||
|
||||
The user's choice is stored in ``output.choice``. ``on_reject``
|
||||
controls abort / skip / retry behaviour.
|
||||
controls abort / skip / retry behaviour. ``verdict_input`` can name a
|
||||
workflow input to use as the choice when resuming non-interactively.
|
||||
"""
|
||||
|
||||
type_key = "gate"
|
||||
@@ -42,6 +43,8 @@ class GateStep(StepBase):
|
||||
|
||||
options = config.get("options", ["approve", "reject"])
|
||||
on_reject = config.get("on_reject", "abort")
|
||||
has_verdict_input = "verdict_input" in config
|
||||
verdict_input = config.get("verdict_input")
|
||||
|
||||
# ``validate`` rejects a non-list (or empty) ``options``, and requires
|
||||
# every option to be a string, but the engine does not auto-validate
|
||||
@@ -72,6 +75,51 @@ class GateStep(StepBase):
|
||||
},
|
||||
)
|
||||
|
||||
# ``validate`` rejects an ``on_reject`` outside abort/skip/retry, but the
|
||||
# engine does not auto-validate before ``execute``. The reject branch
|
||||
# below handles only "abort" and "retry" and then falls through to its
|
||||
# ``on_reject == "skip"`` case, so on an unvalidated run any other value
|
||||
# makes a REJECTED gate report COMPLETED and the run walks straight past
|
||||
# the review the gate exists to enforce. Reachable by a capitalisation
|
||||
# slip ("Abort"), a guessed verb ("fail", "stop"), a non-string, or the
|
||||
# ``None`` that a bare ``on_reject:`` yields -- note ``config.get(k,
|
||||
# default)`` does NOT substitute the default for an explicit null. Fail
|
||||
# loudly instead, mirroring the ``options``/``verdict_input`` guards here.
|
||||
if on_reject not in ("abort", "skip", "retry"):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
|
||||
f"'abort', 'skip', or 'retry', got {on_reject!r}."
|
||||
),
|
||||
output={
|
||||
"message": message,
|
||||
"options": options,
|
||||
"on_reject": on_reject,
|
||||
"choice": None,
|
||||
},
|
||||
)
|
||||
|
||||
if has_verdict_input and (
|
||||
not isinstance(verdict_input, str) or not verdict_input
|
||||
):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: 'verdict_input' must be "
|
||||
"a non-empty string."
|
||||
),
|
||||
)
|
||||
|
||||
if has_verdict_input and context.inside_fan_out:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: 'verdict_input' is "
|
||||
"not supported inside fan-out templates."
|
||||
),
|
||||
)
|
||||
|
||||
show_file = config.get("show_file")
|
||||
if isinstance(show_file, str) and "{{" in show_file:
|
||||
show_file = evaluate_expression(show_file, context)
|
||||
@@ -90,16 +138,48 @@ class GateStep(StepBase):
|
||||
"choice": None,
|
||||
}
|
||||
|
||||
# Non-interactive: pause for later resume (the file is not read here)
|
||||
if not sys.stdin.isatty():
|
||||
return StepResult(status=StepStatus.PAUSED, output=output)
|
||||
choice: str | None = None
|
||||
bound_verdict_input: str | None = None
|
||||
if verdict_input is not None:
|
||||
value = context.inputs.get(verdict_input)
|
||||
if value is not None and value != "":
|
||||
if not isinstance(value, str):
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output=output,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: verdict input "
|
||||
f"{verdict_input!r} must be a string, got "
|
||||
f"{type(value).__name__}."
|
||||
),
|
||||
)
|
||||
choice = next(
|
||||
(option for option in options if option.lower() == value.lower()),
|
||||
None,
|
||||
)
|
||||
if choice is None:
|
||||
return StepResult(
|
||||
status=StepStatus.FAILED,
|
||||
output=output,
|
||||
error=(
|
||||
f"Gate step {config.get('id', '?')!r}: verdict input "
|
||||
f"{verdict_input!r} value {value!r} does not match any "
|
||||
"configured option."
|
||||
),
|
||||
)
|
||||
bound_verdict_input = verdict_input
|
||||
|
||||
# Interactive: prompt the user. ``show_file`` contents are folded
|
||||
# into the displayed message so the operator can review the
|
||||
# referenced material before choosing. Composing the prompt text
|
||||
# here keeps ``_prompt`` to its ``(message, options)`` contract, so
|
||||
# adding review material never widens the interactive seam.
|
||||
choice = self._prompt(self._compose_prompt(message, show_file), options)
|
||||
if choice is None:
|
||||
# Non-interactive: pause for later resume (the file is not read here)
|
||||
if not sys.stdin.isatty():
|
||||
return StepResult(status=StepStatus.PAUSED, output=output)
|
||||
|
||||
# Interactive: prompt the user. ``show_file`` contents are folded
|
||||
# into the displayed message so the operator can review the
|
||||
# referenced material before choosing. Composing the prompt text
|
||||
# here keeps ``_prompt`` to its ``(message, options)`` contract, so
|
||||
# adding review material never widens the interactive seam.
|
||||
choice = self._prompt(self._compose_prompt(message, show_file), options)
|
||||
output["choice"] = choice
|
||||
|
||||
# Match rejection case-insensitively. ``_prompt`` echoes the option's
|
||||
@@ -119,6 +199,8 @@ class GateStep(StepBase):
|
||||
)
|
||||
if on_reject == "retry":
|
||||
# Pause so the next resume re-executes this gate
|
||||
if bound_verdict_input is not None:
|
||||
context.inputs[bound_verdict_input] = ""
|
||||
return StepResult(status=StepStatus.PAUSED, output=output)
|
||||
# on_reject == "skip" → completed, downstream steps decide
|
||||
return StepResult(status=StepStatus.COMPLETED, output=output)
|
||||
@@ -234,6 +316,13 @@ class GateStep(StepBase):
|
||||
f"Gate step {config.get('id', '?')!r}: 'on_reject' must be "
|
||||
f"'abort', 'skip', or 'retry'."
|
||||
)
|
||||
if "verdict_input" in config and (
|
||||
not isinstance(config["verdict_input"], str) or not config["verdict_input"]
|
||||
):
|
||||
errors.append(
|
||||
f"Gate step {config.get('id', '?')!r}: 'verdict_input' must be "
|
||||
"a non-empty string."
|
||||
)
|
||||
# Only inspect option text when every option is a string; otherwise the
|
||||
# `o.lower()` below would raise AttributeError on a non-string option
|
||||
# (already reported above) and break validate_workflow's never-raise contract.
|
||||
|
||||
@@ -61,6 +61,24 @@ class IfThenStep(StepBase):
|
||||
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."
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
@@ -88,9 +89,19 @@ class PromptStep(StepBase):
|
||||
),
|
||||
)
|
||||
|
||||
# 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] = {
|
||||
@@ -130,12 +141,48 @@ class PromptStep(StepBase):
|
||||
),
|
||||
)
|
||||
|
||||
@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 isinstance(integration_key, str) or not prompt:
|
||||
@@ -167,6 +214,17 @@ class PromptStep(StepBase):
|
||||
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 = (
|
||||
@@ -178,6 +236,7 @@ class PromptStep(StepBase):
|
||||
exec_args,
|
||||
text=True,
|
||||
cwd=str(project_root),
|
||||
timeout=timeout,
|
||||
)
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
@@ -190,6 +249,12 @@ class PromptStep(StepBase):
|
||||
"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
|
||||
|
||||
@@ -230,4 +295,7 @@ class PromptStep(StepBase):
|
||||
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
|
||||
|
||||
@@ -121,12 +121,20 @@ class ShellStep(StepBase):
|
||||
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}."
|
||||
|
||||
@@ -79,6 +79,24 @@ class WhileStep(StepBase):
|
||||
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
|
||||
|
||||
@@ -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
|
||||
@@ -16,8 +16,8 @@ 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 and propagating
|
||||
constitution-driven changes to the dependent artifacts identified in this command.
|
||||
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.
|
||||
@@ -25,7 +25,7 @@ constitution-driven changes to the dependent artifacts identified in this comman
|
||||
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 and its required propagation.
|
||||
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
|
||||
@@ -70,7 +70,7 @@ constitution-driven changes to the dependent artifacts identified in this comman
|
||||
|
||||
## 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.
|
||||
|
||||
@@ -96,32 +96,24 @@ 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-<name>/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.
|
||||
|
||||
|
||||
@@ -17,6 +17,7 @@ from typer.testing import CliRunner
|
||||
|
||||
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 @@ from tests.bundler_helpers import (
|
||||
|
||||
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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
53
tests/contract/test_wheel_bundled_presets.py
Normal file
53
tests/contract/test_wheel_bundled_presets.py
Normal file
@@ -0,0 +1,53 @@
|
||||
"""Contract tests: every bundled preset must ship inside the wheel's core_pack.
|
||||
|
||||
``specify preset add <id>`` resolves a bundled preset via
|
||||
``specify_cli._assets._locate_bundled_preset``, which checks the wheel's
|
||||
``specify_cli/core_pack/presets/<id>/`` directory first. Any preset marked
|
||||
``bundled: true`` in ``presets/catalog.json`` must therefore be force-included
|
||||
at build time; otherwise the released wheel advertises a bundled preset it does
|
||||
not actually ship, and ``specify preset add <id>`` falls through and reports the
|
||||
preset as missing.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
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 _bundled_preset_ids() -> list[str]:
|
||||
catalog = json.loads((REPO_ROOT / "presets" / "catalog.json").read_text())
|
||||
return sorted(
|
||||
preset_id
|
||||
for preset_id, entry in catalog["presets"].items()
|
||||
if entry.get("bundled")
|
||||
)
|
||||
|
||||
|
||||
def test_every_bundled_preset_is_force_included():
|
||||
force_include = _force_include()
|
||||
bundled = _bundled_preset_ids()
|
||||
|
||||
assert bundled, "expected at least one bundled preset in presets/catalog.json"
|
||||
for preset_id in bundled:
|
||||
assert force_include.get(f"presets/{preset_id}") == (
|
||||
f"specify_cli/core_pack/presets/{preset_id}"
|
||||
), f"bundled preset '{preset_id}' is missing from the wheel force-include list"
|
||||
|
||||
|
||||
def test_constitution_sync_is_bundled_and_shipped():
|
||||
# Explicit regression guard: constitution-sync was advertised as bundled
|
||||
# before it was added to the wheel force-include list.
|
||||
assert "constitution-sync" in _bundled_preset_ids()
|
||||
assert _force_include()["presets/constitution-sync"] == (
|
||||
"specify_cli/core_pack/presets/constitution-sync"
|
||||
)
|
||||
@@ -204,19 +204,69 @@ class TestBuildCommandInvocation:
|
||||
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-<name>), so the
|
||||
@@ -268,6 +318,26 @@ class TestResolveCommandRefs:
|
||||
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, ".")
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import runpy
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
@@ -1180,6 +1181,23 @@ class TestSharedInfraCommandRefs:
|
||||
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-<name> 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.<name> in shared script hints."""
|
||||
@@ -1220,6 +1238,48 @@ class TestSharedInfraCommandRefs:
|
||||
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 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
# 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 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
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 @@ class TestIntegrationCatalogDiscoveryCLI:
|
||||
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(
|
||||
@@ -2244,3 +2372,279 @@ def test_refresh_shared_templates_preserves_recovered_user_file(tmp_path):
|
||||
|
||||
# Recovered user content must survive (fail-before: replaced by bundled body).
|
||||
assert user_file.read_text(encoding="utf-8") == "# USER CUSTOM CONTENT\n"
|
||||
|
||||
|
||||
class TestExtensionFlag:
|
||||
"""Tests for the --extension flag on specify init."""
|
||||
|
||||
def _run_init(self, tmp_path, args, project_name="ext-test"):
|
||||
from unittest.mock import patch
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
project = tmp_path / project_name
|
||||
project.mkdir(exist_ok=True)
|
||||
old_cwd = os.getcwd()
|
||||
try:
|
||||
os.chdir(project)
|
||||
runner = CliRunner()
|
||||
# Patch get_speckit_version to return a stable (non-dev) version so that
|
||||
# the extension compatibility check (SpecifierSet(">=0.2.0")) passes.
|
||||
with patch(
|
||||
"specify_cli.commands.init.get_speckit_version",
|
||||
return_value="0.8.2",
|
||||
):
|
||||
result = runner.invoke(app, [
|
||||
"init", "--here",
|
||||
"--integration", "copilot",
|
||||
"--script", "sh",
|
||||
"--ignore-agent-tools",
|
||||
] + args, catch_exceptions=False)
|
||||
finally:
|
||||
os.chdir(old_cwd)
|
||||
return project, result
|
||||
|
||||
def test_bundled_extension_installed(self, tmp_path):
|
||||
"""--extension git installs the bundled git extension."""
|
||||
project, result = self._run_init(tmp_path, ["--extension", "git"], project_name="ext-bundled")
|
||||
|
||||
assert result.exit_code == 0, f"init failed:\n{result.output}"
|
||||
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert ext_dir.exists(), "git extension directory not found"
|
||||
assert (ext_dir / "extension.yml").exists(), "extension.yml not found"
|
||||
|
||||
# Tracker should show extension step as done
|
||||
normalized = _normalize_cli_output(result.output)
|
||||
assert "Install extension: git" in normalized
|
||||
|
||||
def test_multiple_extensions_installed(self, tmp_path):
|
||||
"""--extension can be specified multiple times."""
|
||||
project, result = self._run_init(
|
||||
tmp_path,
|
||||
["--extension", "git", "--extension", "selftest"],
|
||||
project_name="ext-multi",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init failed:\n{result.output}"
|
||||
|
||||
ext_dir_git = project / ".specify" / "extensions" / "git"
|
||||
ext_dir_selftest = project / ".specify" / "extensions" / "selftest"
|
||||
assert ext_dir_git.exists(), "git extension not installed"
|
||||
assert ext_dir_selftest.exists(), "selftest extension not installed"
|
||||
|
||||
def test_local_path_extension_installed(self, tmp_path):
|
||||
"""--extension /abs/path installs from a local absolute directory path."""
|
||||
from specify_cli import _locate_bundled_extension
|
||||
|
||||
# Use the bundled git extension directory as our "local" extension source
|
||||
bundled_git = _locate_bundled_extension("git")
|
||||
assert bundled_git is not None, "bundled git extension not found; cannot run test"
|
||||
|
||||
# Pass the absolute path directly (starts with "/")
|
||||
project, result = self._run_init(
|
||||
tmp_path,
|
||||
["--extension", str(bundled_git)],
|
||||
project_name="ext-local",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init failed:\n{result.output}"
|
||||
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert ext_dir.exists(), "extension from local path not installed"
|
||||
|
||||
def test_unknown_extension_shows_error_in_tracker(self, tmp_path):
|
||||
"""An unknown extension name records a tracker error but does not abort init."""
|
||||
project, result = self._run_init(
|
||||
tmp_path,
|
||||
["--extension", "nonexistent-xyz-ext"],
|
||||
project_name="ext-unknown",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, "init should not abort on unknown extension"
|
||||
normalized = _normalize_cli_output(result.output)
|
||||
assert "failed" in normalized.lower(), "expected 'failed' for unknown extension"
|
||||
|
||||
def test_extension_flag_works_with_preset(self, tmp_path):
|
||||
"""--extension and --preset can be combined."""
|
||||
project, result = self._run_init(
|
||||
tmp_path,
|
||||
["--extension", "git", "--preset", "lean"],
|
||||
project_name="ext-preset",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init failed:\n{result.output}"
|
||||
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert ext_dir.exists(), "git extension not installed alongside preset"
|
||||
|
||||
@staticmethod
|
||||
def _zip_bytes_from_dir(source_dir):
|
||||
"""Build in-memory ZIP bytes from an extension directory (yml at root)."""
|
||||
import io
|
||||
import zipfile
|
||||
|
||||
buf = io.BytesIO()
|
||||
with zipfile.ZipFile(buf, "w", zipfile.ZIP_DEFLATED) as zf:
|
||||
for path in sorted(source_dir.rglob("*")):
|
||||
if path.is_file():
|
||||
zf.write(path, arcname=str(path.relative_to(source_dir)))
|
||||
return buf.getvalue()
|
||||
|
||||
def test_url_extension_rejects_non_https(self, tmp_path):
|
||||
"""A non-HTTPS URL is rejected before any download; init is not aborted."""
|
||||
project, result = self._run_init(
|
||||
tmp_path,
|
||||
["--extension", "http://example.com/ext.zip", "--trust-extension-urls"],
|
||||
project_name="ext-http",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, "init should not abort on a rejected URL"
|
||||
normalized = _normalize_cli_output(result.output)
|
||||
assert "failed" in normalized.lower()
|
||||
# No extension directory should have been created for the bad URL.
|
||||
assert not (project / ".specify" / "extensions" / "ext").exists()
|
||||
|
||||
def test_url_extension_skipped_without_trust(self, tmp_path):
|
||||
"""Non-interactive URL install without --trust-extension-urls is denied."""
|
||||
from unittest.mock import patch
|
||||
|
||||
with patch(
|
||||
"specify_cli.commands.init._stdin_is_interactive", return_value=False
|
||||
), patch("specify_cli.authentication.http.open_url") as mock_open:
|
||||
project, result = self._run_init(
|
||||
tmp_path,
|
||||
["--extension", "https://example.com/git.zip"],
|
||||
project_name="ext-url-denied",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init failed:\n{result.output}"
|
||||
# Default-deny: no download attempted, nothing installed.
|
||||
mock_open.assert_not_called()
|
||||
normalized = _normalize_cli_output(result.output)
|
||||
assert "untrusted url" in normalized.lower()
|
||||
assert not (project / ".specify" / "extensions" / "git").exists()
|
||||
|
||||
def test_url_extension_interactive_confirm_installs(self, tmp_path):
|
||||
"""An interactive 'yes' to the trust prompt allows the URL install."""
|
||||
import io
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from specify_cli import _locate_bundled_extension
|
||||
|
||||
bundled_git = _locate_bundled_extension("git")
|
||||
assert bundled_git is not None, "bundled git extension not found"
|
||||
zip_bytes = self._zip_bytes_from_dir(bundled_git)
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def _cache_dir_stand_in(project_root):
|
||||
d = project_root / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
def _open_download_zip(project_root, download_dir, zip_filename):
|
||||
target = download_dir / zip_filename
|
||||
o_temporary = getattr(os, "O_TEMPORARY", 0)
|
||||
if o_temporary:
|
||||
return os.open(
|
||||
target, os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary, 0o600
|
||||
)
|
||||
fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
os.unlink(target)
|
||||
except OSError:
|
||||
os.close(fd)
|
||||
raise
|
||||
return fd
|
||||
|
||||
with patch(
|
||||
"specify_cli.commands.init._stdin_is_interactive", return_value=True
|
||||
), patch("typer.confirm", return_value=True), patch(
|
||||
"specify_cli.authentication.http.open_url",
|
||||
return_value=FakeResponse(zip_bytes),
|
||||
), patch(
|
||||
"specify_cli.extensions._commands._validate_safe_cache_dir",
|
||||
side_effect=_cache_dir_stand_in,
|
||||
), patch(
|
||||
"specify_cli.extensions._commands._safe_open_download_zip",
|
||||
side_effect=_open_download_zip,
|
||||
):
|
||||
project, result = self._run_init(
|
||||
tmp_path,
|
||||
["--extension", "https://example.com/git.zip"],
|
||||
project_name="ext-url-confirm",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init failed:\n{result.output}"
|
||||
assert (project / ".specify" / "extensions" / "git").exists()
|
||||
|
||||
def test_url_extension_installs_zip(self, tmp_path):
|
||||
"""A successful HTTPS ZIP download installs via the shared hardened path."""
|
||||
import io
|
||||
|
||||
from unittest.mock import patch
|
||||
|
||||
from specify_cli import _locate_bundled_extension
|
||||
|
||||
bundled_git = _locate_bundled_extension("git")
|
||||
assert bundled_git is not None, "bundled git extension not found"
|
||||
zip_bytes = self._zip_bytes_from_dir(bundled_git)
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def _cache_dir_stand_in(project_root):
|
||||
d = project_root / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
d.mkdir(parents=True, exist_ok=True)
|
||||
return d
|
||||
|
||||
def _open_download_zip(project_root, download_dir, zip_filename):
|
||||
target = download_dir / zip_filename
|
||||
o_temporary = getattr(os, "O_TEMPORARY", 0)
|
||||
if o_temporary:
|
||||
return os.open(
|
||||
target, os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary, 0o600
|
||||
)
|
||||
fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
os.unlink(target)
|
||||
except OSError:
|
||||
os.close(fd)
|
||||
raise
|
||||
return fd
|
||||
|
||||
with patch(
|
||||
"specify_cli.authentication.http.open_url",
|
||||
return_value=FakeResponse(zip_bytes),
|
||||
), patch(
|
||||
"specify_cli.extensions._commands._validate_safe_cache_dir",
|
||||
side_effect=_cache_dir_stand_in,
|
||||
), patch(
|
||||
"specify_cli.extensions._commands._safe_open_download_zip",
|
||||
side_effect=_open_download_zip,
|
||||
):
|
||||
project, result = self._run_init(
|
||||
tmp_path,
|
||||
["--extension", "https://example.com/git.zip", "--trust-extension-urls"],
|
||||
project_name="ext-url",
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, f"init failed:\n{result.output}"
|
||||
ext_dir = project / ".specify" / "extensions" / "git"
|
||||
assert ext_dir.exists(), "extension from URL not installed"
|
||||
assert (ext_dir / "extension.yml").exists()
|
||||
# Transient download archive must not linger in the cache.
|
||||
cache_dir = project / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
leftover = list(cache_dir.glob("*.zip")) if cache_dir.exists() else []
|
||||
assert not leftover, f"download cache not cleaned: {leftover}"
|
||||
|
||||
2239
tests/integrations/test_events.py
Normal file
2239
tests/integrations/test_events.py
Normal file
File diff suppressed because it is too large
Load Diff
@@ -191,7 +191,7 @@ class SkillsIntegrationTests:
|
||||
"---\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"
|
||||
|
||||
@@ -116,12 +116,15 @@ class TestCatalogURLValidation:
|
||||
[
|
||||
"https://[::1", # unclosed ipv6 bracket
|
||||
"https://[not-an-ip]/c.json", # bracketed non-ip host
|
||||
"https://example.com:notaport/c.json", # non-numeric port
|
||||
"https://example.com:65536/c.json", # out-of-range port
|
||||
],
|
||||
)
|
||||
def test_malformed_url_rejected_cleanly(self, url):
|
||||
# A malformed authority makes urlparse/hostname raise ValueError. The
|
||||
# validator must turn that into its normal catalog error, not leak a
|
||||
# raw ValueError to the caller.
|
||||
# A malformed authority makes urlparse/hostname raise ValueError, and a
|
||||
# bad port makes ``parsed.port`` raise it. The validator must turn that
|
||||
# into its normal catalog error, not leak a raw ValueError to the caller
|
||||
# (or, for a bad port, accept the URL and fail later at fetch time).
|
||||
with pytest.raises(IntegrationCatalogError, match="malformed"):
|
||||
IntegrationCatalog._validate_catalog_url(url)
|
||||
|
||||
@@ -220,6 +223,33 @@ class TestActiveCatalogs:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _OversizedResponse:
|
||||
"""Response stub that supports bounded streaming reads for oversized-catalog tests."""
|
||||
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
self._pos = 0
|
||||
|
||||
def read(self, n=-1):
|
||||
if n < 0:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos : self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class TestCatalogFetch:
|
||||
"""Tests that use a local HTTP server stub via monkeypatch."""
|
||||
|
||||
@@ -230,9 +260,16 @@ class TestCatalogFetch:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
self._pos = 0
|
||||
|
||||
def read(self):
|
||||
return self._data
|
||||
def read(self, n=-1):
|
||||
if n < 0:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
@@ -395,6 +432,90 @@ class TestCatalogFetch:
|
||||
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
|
||||
cat.search()
|
||||
|
||||
def test_oversized_catalog_response_rejected(self, tmp_path, monkeypatch):
|
||||
"""Response exceeding MAX_JSON_METADATA_BYTES is caught as IntegrationCatalogError.
|
||||
|
||||
The per-entry error is logged as a warning and skipped (not fatal).
|
||||
When ALL catalogs are oversized, search() raises the aggregate error.
|
||||
"""
|
||||
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
# Build a valid catalog dict whose JSON encoding exceeds the limit.
|
||||
oversized = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {},
|
||||
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
|
||||
}
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
def _oversized_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
return _OversizedResponse(oversized, url)
|
||||
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _oversized_urlopen)
|
||||
|
||||
# Both default + community catalogs are oversized → all fail → aggregate error.
|
||||
# The per-entry IntegrationCatalogError (with "exceeds maximum size") is
|
||||
# logged as a warning; the aggregate raise has a different message.
|
||||
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
|
||||
cat.search()
|
||||
|
||||
def test_oversized_catalog_does_not_block_healthy_one(self, tmp_path, monkeypatch):
|
||||
"""When one catalog is oversized, the healthy catalog still returns results."""
|
||||
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir()
|
||||
|
||||
healthy_catalog = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {
|
||||
"good-agent": {
|
||||
"id": "good-agent",
|
||||
"name": "Good Agent",
|
||||
"version": "1.0.0",
|
||||
"description": "A healthy integration",
|
||||
"author": "test-org",
|
||||
},
|
||||
},
|
||||
}
|
||||
oversized_catalog = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {},
|
||||
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
|
||||
}
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text(yaml.dump({"catalogs": [
|
||||
{"url": "https://healthy.example.com/catalog.json", "name": "healthy", "priority": 1, "install_allowed": True},
|
||||
{"url": "https://oversized.example.com/catalog.json", "name": "oversized", "priority": 2, "install_allowed": True},
|
||||
]}))
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
def _multi_catalog_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
if "oversized" in url:
|
||||
return _OversizedResponse(oversized_catalog, url)
|
||||
return _OversizedResponse(healthy_catalog, url)
|
||||
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _multi_catalog_urlopen)
|
||||
|
||||
# The oversized catalog is skipped; the healthy catalog's integrations are returned.
|
||||
results = cat.search()
|
||||
ids = [r["id"] for r in results]
|
||||
assert "good-agent" in ids
|
||||
|
||||
def test_clear_cache(self, tmp_path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
@@ -592,8 +713,15 @@ class TestIntegrationListCatalog:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
def read(self):
|
||||
return self._data
|
||||
self._pos = 0
|
||||
def read(self, n=-1):
|
||||
if n < 0:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
def geturl(self):
|
||||
return self._url
|
||||
def __enter__(self):
|
||||
|
||||
@@ -12,7 +12,6 @@ class TestCodexIntegration(SkillsIntegrationTests):
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".agents/skills"
|
||||
|
||||
|
||||
class TestCodexInitFlow:
|
||||
"""--integration codex creates expected files."""
|
||||
|
||||
@@ -98,6 +97,8 @@ class TestCodexHookCommandNote:
|
||||
assert "replace dots" in content, (
|
||||
"speckit-specify should have dot-to-hyphen hook note"
|
||||
)
|
||||
assert "constructing command invocations" in content
|
||||
assert "constructing slash commands" not in content
|
||||
|
||||
def test_hook_note_not_in_skills_without_hooks(self):
|
||||
"""Skills without hook sections should not get the note."""
|
||||
|
||||
@@ -109,6 +109,21 @@ class TestCopilotIntegration:
|
||||
assert settings not in created
|
||||
assert not any("settings.json" in k for k in m.files)
|
||||
|
||||
def test_setup_preserves_non_utf8_vscode_settings(self, tmp_path, caplog):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
vscode_dir = tmp_path / ".vscode"
|
||||
vscode_dir.mkdir(parents=True)
|
||||
settings = vscode_dir / "settings.json"
|
||||
original = b'{"editor.fontSize": 14}\xff'
|
||||
settings.write_bytes(original)
|
||||
m = IntegrationManifest("copilot", tmp_path)
|
||||
|
||||
copilot.setup(tmp_path, m)
|
||||
|
||||
assert settings.read_bytes() == original
|
||||
assert "Could not parse" in caplog.text
|
||||
|
||||
def test_all_created_files_tracked_in_manifest(self, tmp_path):
|
||||
from specify_cli.integrations.copilot import CopilotIntegration
|
||||
copilot = CopilotIntegration()
|
||||
|
||||
@@ -43,6 +43,20 @@ class TestDroidIntegration(SkillsIntegrationTests):
|
||||
i = get_integration(self.KEY)
|
||||
assert i.multi_install_safe is True
|
||||
|
||||
def test_is_slash_skills_agent(self):
|
||||
"""Droid is an always-skills agent whose commands install as
|
||||
/speckit-<name>, so is_slash_skills_agent must report True — otherwise
|
||||
hook invocations and the init next-steps panel render the dotted
|
||||
/speckit.<name> form Droid never registers (mirrors grok/trae/zed/devin)."""
|
||||
from specify_cli._invocation_style import is_slash_skills_agent
|
||||
|
||||
# True in BOTH the enabled and disabled cases: Droid is *always* slash,
|
||||
# not conditional. The disabled case is what distinguishes an
|
||||
# ALWAYS_SLASH agent from a CONDITIONAL_SLASH one (which would be False
|
||||
# when ai_skills is disabled).
|
||||
assert is_slash_skills_agent("droid", True) is True
|
||||
assert is_slash_skills_agent("droid", False) is True
|
||||
|
||||
def test_install_url_points_to_factory(self):
|
||||
i = get_integration(self.KEY)
|
||||
url = i.config.get("install_url")
|
||||
|
||||
@@ -55,6 +55,62 @@ class TestGenericIntegration:
|
||||
with pytest.raises(ValueError, match="--commands-dir is required"):
|
||||
i.setup(tmp_path, m, parsed_options={"commands_dir": ""})
|
||||
|
||||
@pytest.mark.parametrize("blank", [" ", "\t"])
|
||||
def test_resolve_commands_dir_rejects_blank_parsed_value(self, blank):
|
||||
"""A whitespace-only value must raise too: it resolves to a directory
|
||||
literally named " ", scattering command files just like the empty case."""
|
||||
from specify_cli.integrations.generic import GenericIntegration
|
||||
|
||||
with pytest.raises(ValueError, match="--commands-dir is required"):
|
||||
GenericIntegration._resolve_commands_dir({"commands_dir": blank}, {})
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"raw", ["--commands-dir ' '", "--commands-dir=' '", "--commands-dir '\t'"]
|
||||
)
|
||||
def test_resolve_commands_dir_rejects_blank_raw_value(self, raw):
|
||||
"""Same rule on the raw_options branch, so the two cannot drift apart."""
|
||||
from specify_cli.integrations.generic import GenericIntegration
|
||||
|
||||
with pytest.raises(ValueError, match="--commands-dir is required"):
|
||||
GenericIntegration._resolve_commands_dir({}, {"raw_options": raw})
|
||||
|
||||
@pytest.mark.parametrize("padded", [" .myagent/cmds ", "\t.myagent/cmds"])
|
||||
def test_resolve_commands_dir_returns_padded_value_verbatim(self, padded):
|
||||
"""A padded but non-blank value is accepted and returned UNCHANGED: the
|
||||
blankness test uses strip(), but rewriting the value would silently
|
||||
retarget a directory the user asked for by name."""
|
||||
from specify_cli.integrations.generic import GenericIntegration
|
||||
|
||||
assert GenericIntegration._resolve_commands_dir(
|
||||
{"commands_dir": padded}, {}
|
||||
) == padded
|
||||
# Quoted in raw_options, since shlex.split() would otherwise consume the
|
||||
# surrounding whitespace before this code ever sees it.
|
||||
assert GenericIntegration._resolve_commands_dir(
|
||||
{}, {"raw_options": f"--commands-dir='{padded}'"}
|
||||
) == padded
|
||||
|
||||
@pytest.mark.parametrize("raw", ["--commands-dir=", "--commands-dir ''", '--commands-dir ""'])
|
||||
def test_resolve_commands_dir_rejects_empty_raw_value(self, raw):
|
||||
"""An empty --commands-dir in raw_options must raise the same "required"
|
||||
error as the parsed-options path — not return "" (which resolves to the
|
||||
project root and writes command files there). Mirrors the parsed branch."""
|
||||
from specify_cli.integrations.generic import GenericIntegration
|
||||
|
||||
with pytest.raises(ValueError, match="--commands-dir is required"):
|
||||
GenericIntegration._resolve_commands_dir({}, {"raw_options": raw})
|
||||
|
||||
def test_resolve_commands_dir_accepts_nonempty_raw_value(self):
|
||||
"""A non-empty raw --commands-dir still resolves unchanged."""
|
||||
from specify_cli.integrations.generic import GenericIntegration
|
||||
|
||||
assert GenericIntegration._resolve_commands_dir(
|
||||
{}, {"raw_options": "--commands-dir .myagent/commands"}
|
||||
) == ".myagent/commands"
|
||||
assert GenericIntegration._resolve_commands_dir(
|
||||
{}, {"raw_options": "--commands-dir=.myagent/commands"}
|
||||
) == ".myagent/commands"
|
||||
|
||||
def test_setup_writes_to_correct_directory(self, tmp_path):
|
||||
i = get_integration("generic")
|
||||
m = IntegrationManifest("generic", tmp_path)
|
||||
|
||||
@@ -1276,6 +1276,24 @@ class TestIntegrationInstall:
|
||||
assert "/speckit-specify" in script_content
|
||||
assert "/speckit.specify" not in script_content
|
||||
|
||||
def test_install_dollar_skill_into_bare_project_gets_native_shared_refs(
|
||||
self, tmp_path
|
||||
):
|
||||
"""A dollar-style integration supplies its prefix without a default."""
|
||||
project = tmp_path / "bare-codex"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
|
||||
result = _run_in_project(
|
||||
project, ["integration", "install", "codex", "--script", "sh"]
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plan = project / ".specify" / "templates" / "plan-template.md"
|
||||
plan_content = plan.read_text(encoding="utf-8")
|
||||
assert "$speckit-plan" in plan_content
|
||||
assert "/speckit-plan" not in plan_content
|
||||
|
||||
def test_install_defers_extension_commands_until_use(self, tmp_path):
|
||||
"""Installing a second integration does not register enabled extensions.
|
||||
|
||||
@@ -2725,7 +2743,7 @@ class TestIntegrationSwitch:
|
||||
assert opts["ai"] == "codex"
|
||||
|
||||
template = project / ".specify" / "templates" / "plan-template.md"
|
||||
assert "/speckit-plan" in template.read_text(encoding="utf-8")
|
||||
assert "$speckit-plan" in template.read_text(encoding="utf-8")
|
||||
|
||||
def test_failed_switch_rescaffolds_fallback_extensions(self, tmp_path):
|
||||
"""Regression (review 3624184343).
|
||||
@@ -3813,6 +3831,68 @@ class TestIntegrationUpgrade:
|
||||
"upgrade of the active integration re-registers extension commands"
|
||||
)
|
||||
|
||||
def test_upgrade_copilot_skills_restores_extension_skill_over_regenerated_dir(
|
||||
self, tmp_path
|
||||
):
|
||||
"""End-to-end regression for #3849 (upgrade-overwrites-copilot-skills).
|
||||
|
||||
In Copilot skills mode, ``integration upgrade`` runs ``setup()`` — which
|
||||
regenerates the core-template skill directories — *before* re-registering
|
||||
installed extensions. The extension re-registration then hits the
|
||||
``skill_dir_preexists`` guard in ``_register_extension_skills`` (the skill
|
||||
sub-directory exists, courtesy of ``setup()``, but its ``SKILL.md`` has
|
||||
not been rewritten with extension content), so pre-fix the extension
|
||||
skill was silently left missing — its command content lost even though the
|
||||
extension remained installed and registered.
|
||||
|
||||
The fix threads ``force=True`` from ``integration_upgrade()`` down to
|
||||
``_register_extension_skills`` so the guard is bypassed and the extension
|
||||
content is re-composed on top of the just-regenerated directory. This test
|
||||
exercises the full ``specify integration upgrade`` command path and fails
|
||||
without the fix (the skill is never recreated).
|
||||
"""
|
||||
project = _init_project(
|
||||
tmp_path, "copilot", integration_options="--skills"
|
||||
)
|
||||
|
||||
result = _run_in_project(project, ["extension", "add", "git"])
|
||||
assert result.exit_code == 0, f"extension add failed: {result.output}"
|
||||
|
||||
skill_dir = project / ".github" / "skills" / "speckit-git-feature"
|
||||
skill_file = skill_dir / "SKILL.md"
|
||||
assert skill_file.exists(), (
|
||||
"precondition: git extension renders as a Copilot skill"
|
||||
)
|
||||
original = skill_file.read_text(encoding="utf-8")
|
||||
assert "source: extension:git" in original, (
|
||||
"precondition: skill carries the git extension ownership marker"
|
||||
)
|
||||
|
||||
# Simulate the exact pre-condition the bug depends on: the skill file is
|
||||
# gone but its directory survives (as it does once setup() regenerates the
|
||||
# core-template layout during upgrade), triggering the skill_dir_preexists
|
||||
# skip guard on re-registration.
|
||||
skill_file.unlink()
|
||||
assert skill_dir.exists() and not skill_file.exists()
|
||||
|
||||
result = _run_in_project(project, [
|
||||
"integration", "upgrade", "copilot",
|
||||
"--integration-options", "--skills",
|
||||
"--script", "sh", "--force",
|
||||
])
|
||||
assert result.exit_code == 0, result.output
|
||||
|
||||
assert skill_file.exists(), (
|
||||
"upgrade must restore the extension skill even when its directory "
|
||||
"already exists (regression #3849)"
|
||||
)
|
||||
restored = skill_file.read_text(encoding="utf-8")
|
||||
assert "source: extension:git" in restored, (
|
||||
"restored skill must contain the git extension content, not a bare "
|
||||
"core-template stub"
|
||||
)
|
||||
assert "# Git Feature Skill" in restored
|
||||
|
||||
def test_upgrade_active_integration_reregisters_presets(self, tmp_path):
|
||||
"""Upgrading the active integration restores missing preset artifacts."""
|
||||
import yaml
|
||||
|
||||
@@ -9,7 +9,6 @@ class TestZcodeIntegration(SkillsIntegrationTests):
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".zcode/skills"
|
||||
|
||||
|
||||
class TestZcodeInvocation:
|
||||
"""ZCode renders $speckit-* chat invocations (like Codex)."""
|
||||
|
||||
|
||||
@@ -242,6 +242,34 @@ class TestManifestUninstall:
|
||||
"remove_manifest=False must keep the manifest file on disk"
|
||||
)
|
||||
|
||||
def test_undeletable_manifest_is_skipped_not_raised(self, tmp_path):
|
||||
"""An undeletable manifest must not abort the whole uninstall.
|
||||
|
||||
The tracked files are removed *before* the manifest, so raising here
|
||||
loses the ``(removed, skipped)`` result the caller needs: the CLI's
|
||||
post-uninstall bookkeeping (reassigning the default integration,
|
||||
rewriting/removing ``integration.json``, clearing init options) never
|
||||
runs, leaving a removed integration still recorded as installed.
|
||||
|
||||
Leaving a directory at the manifest path is a portable way to make
|
||||
``unlink()`` fail with no chmod and no monkeypatch: it raises
|
||||
``IsADirectoryError`` on Linux and ``PermissionError`` on
|
||||
Windows/macOS, both ``OSError`` subclasses.
|
||||
"""
|
||||
m = IntegrationManifest("test", tmp_path, version="1.0")
|
||||
m.record_file("f.txt", "content")
|
||||
m.save()
|
||||
m.manifest_path.unlink()
|
||||
m.manifest_path.mkdir()
|
||||
|
||||
removed, skipped = m.uninstall()
|
||||
|
||||
assert removed == [tmp_path / "f.txt"]
|
||||
assert not (tmp_path / "f.txt").exists()
|
||||
assert m.manifest_path in skipped, (
|
||||
"an undeletable manifest must be reported in skipped"
|
||||
)
|
||||
|
||||
def test_cleans_empty_parent_dirs(self, tmp_path):
|
||||
m = IntegrationManifest("test", tmp_path)
|
||||
m.record_file("a/b/c/f.txt", "content")
|
||||
@@ -347,6 +375,14 @@ class TestManifestLoadValidation:
|
||||
with pytest.raises(ValueError, match="invalid JSON"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
def test_load_non_utf8_json_raises_value_error(self, tmp_path):
|
||||
path = tmp_path / ".specify" / "integrations" / "bad.manifest.json"
|
||||
path.parent.mkdir(parents=True)
|
||||
path.write_bytes(b"\xff\xfe")
|
||||
|
||||
with pytest.raises(ValueError, match="valid UTF-8"):
|
||||
IntegrationManifest.load("bad", tmp_path)
|
||||
|
||||
def test_load_filters_recovered_files_not_in_files(self, tmp_path):
|
||||
# Finding B (Round-9): a recovered_files entry referencing a path
|
||||
# not present in files indicates an internally-inconsistent manifest
|
||||
|
||||
@@ -34,6 +34,19 @@ description: "ding\\aling"
|
||||
Body of the command.
|
||||
"""
|
||||
|
||||
# A description whose value contains an embedded ``---``. A substring split
|
||||
# (``raw.split("---", 2)``) stops at this inner marker, truncating the parsed
|
||||
# frontmatter — the closing document separator on its own line is the real
|
||||
# boundary. See TestSkillFrontmatterEmbeddedDashes below.
|
||||
DASHED_DESCRIPTION = "Separate sections with --- markers"
|
||||
DASHED_TEMPLATE = """---
|
||||
description: Separate sections with --- markers
|
||||
name-marker: sentinel
|
||||
---
|
||||
|
||||
Body of the command.
|
||||
"""
|
||||
|
||||
|
||||
def _parse_frontmatter(skill_file: Path) -> dict:
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
@@ -90,6 +103,62 @@ class TestSkillFrontmatterQuoting:
|
||||
assert fm["description"] == CONTROL
|
||||
|
||||
|
||||
def _parse_frontmatter_line_anchored(skill_file: Path) -> dict:
|
||||
"""Parse SKILL.md frontmatter using the closing ``---`` on its own line.
|
||||
|
||||
Unlike ``_parse_frontmatter`` (which uses ``split("---", 2)``), this is
|
||||
robust to a ``---`` embedded in a value, so it can validate that the
|
||||
generated frontmatter is itself well formed.
|
||||
"""
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert content.startswith("---\n")
|
||||
lines = content.splitlines(keepends=True)
|
||||
end = next(i for i in range(1, len(lines)) if lines[i].rstrip() == "---")
|
||||
return yaml.safe_load("".join(lines[1:end]))
|
||||
|
||||
|
||||
class TestSkillFrontmatterEmbeddedDashes:
|
||||
"""A ``---`` inside a description value must not truncate parsing (#3634).
|
||||
|
||||
The skills setup path parsed template frontmatter with
|
||||
``raw.split("---", 2)``, which stops at the first ``---`` *anywhere* —
|
||||
including one inside a value such as ``description: ... --- ...``. That
|
||||
dropped every frontmatter key after the marker (so the description fell
|
||||
back to the generic default) and spilled the leftover frontmatter into
|
||||
the skill body. The parser must match the closing ``---`` on its own line.
|
||||
"""
|
||||
|
||||
def _generate(self, tmp_path, monkeypatch, template: str) -> Path:
|
||||
integration = get_integration("agy")
|
||||
monkeypatch.setattr(
|
||||
integration,
|
||||
"shared_commands_dir",
|
||||
lambda: _fake_templates(tmp_path, template),
|
||||
)
|
||||
manifest = IntegrationManifest("agy", tmp_path)
|
||||
created = integration.setup(tmp_path, manifest)
|
||||
skill_files = [f for f in created if f.name == "SKILL.md"]
|
||||
assert len(skill_files) == 1
|
||||
return skill_files[0]
|
||||
|
||||
def test_dashed_description_is_preserved(self, tmp_path, monkeypatch):
|
||||
skill_file = self._generate(tmp_path, monkeypatch, DASHED_TEMPLATE)
|
||||
fm = _parse_frontmatter_line_anchored(skill_file)
|
||||
# Buggy split("---", 2) truncates the value to "Separate sections with"
|
||||
# (or drops it entirely, falling back to "Spec Kit: plan workflow").
|
||||
assert fm["description"] == DASHED_DESCRIPTION
|
||||
|
||||
def test_leftover_frontmatter_not_spilled_into_body(self, tmp_path, monkeypatch):
|
||||
skill_file = self._generate(tmp_path, monkeypatch, DASHED_TEMPLATE)
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
lines = content.splitlines(keepends=True)
|
||||
end = next(i for i in range(1, len(lines)) if lines[i].rstrip() == "---")
|
||||
body = "".join(lines[end + 1 :])
|
||||
# The template's trailing frontmatter key must not leak into the body.
|
||||
assert "name-marker: sentinel" not in body
|
||||
assert "Body of the command." in body
|
||||
|
||||
|
||||
class TestHermesSkillFrontmatterQuoting:
|
||||
def test_multiline_description_survives(self, tmp_path, monkeypatch):
|
||||
home = tmp_path / "home"
|
||||
|
||||
@@ -2,10 +2,12 @@
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import get_args, get_type_hints
|
||||
|
||||
import yaml
|
||||
|
||||
from specify_cli import AGENT_CONFIG
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
from specify_cli.extensions import CommandRegistrar
|
||||
|
||||
REPO_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -104,6 +106,14 @@ def _supported_agent_names_from_agent_request_template() -> list[str]:
|
||||
class TestAgentConfigConsistency:
|
||||
"""Ensure agent configuration stays synchronized across key surfaces."""
|
||||
|
||||
def test_register_commands_resolved_dir_annotation_accepts_none(self):
|
||||
"""The internal resolved-directory override defaults to None."""
|
||||
resolved_dir_type = get_type_hints(
|
||||
AgentCommandRegistrar.register_commands
|
||||
)["_resolved_dir"]
|
||||
|
||||
assert type(None) in get_args(resolved_dir_type)
|
||||
|
||||
def test_issue_template_agent_lists_match_runtime_integrations(self):
|
||||
"""GitHub issue templates should list all concrete built-in agents."""
|
||||
concrete_agent_keys = set(AGENT_CONFIG) - {"generic"}
|
||||
|
||||
@@ -535,6 +535,57 @@ class TestAzureDevOpsAuth:
|
||||
with patch("specify_cli.authentication.azure_devops.subprocess.run", return_value=result):
|
||||
assert AzureDevOpsAuth().resolve_token(entry) is None
|
||||
|
||||
def test_resolve_token_azure_cli_resolves_executable(self):
|
||||
"""The az executable is resolved via shutil.which before invocation, so
|
||||
the .cmd/.bat shim on Windows (CreateProcess ignores PATHEXT) is used."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
# Build the absolute path with the HOST's rules: the production code
|
||||
# calls os.path.isabs(), so a hardcoded Windows path would read as
|
||||
# RELATIVE on POSIX runners and silently exercise the fallback branch
|
||||
# instead of the one under test.
|
||||
resolved_path = os.path.join(os.path.abspath(os.sep), "opt", "az", "az.CMD")
|
||||
assert os.path.isabs(resolved_path)
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stdout = '{"accessToken": "tok"}'
|
||||
with patch(
|
||||
"specify_cli.authentication.azure_devops.shutil.which",
|
||||
return_value=resolved_path,
|
||||
), patch(
|
||||
"specify_cli.authentication.azure_devops.subprocess.run",
|
||||
return_value=result,
|
||||
) as run:
|
||||
assert AzureDevOpsAuth().resolve_token(entry) == "tok"
|
||||
argv = run.call_args.args[0]
|
||||
assert argv[0] == resolved_path
|
||||
assert argv[1:4] == ["account", "get-access-token", "--resource"]
|
||||
|
||||
@pytest.mark.parametrize("which_result", [None, r".\az.CMD", "az.cmd", "./az"])
|
||||
def test_resolve_token_azure_cli_falls_back_to_bare_az(self, which_result):
|
||||
"""Fall back to the bare "az" when shutil.which finds nothing OR returns a
|
||||
NON-ABSOLUTE path. On Windows shutil.which searches the current directory
|
||||
first, so a stray .\\az.cmd must never be executed for a credential
|
||||
operation; the bare name also preserves the not-installed OSError path."""
|
||||
from unittest.mock import patch, MagicMock
|
||||
entry = AuthConfigEntry(
|
||||
hosts=("dev.azure.com",), provider="azure-devops", auth="azure-cli",
|
||||
)
|
||||
result = MagicMock()
|
||||
result.returncode = 0
|
||||
result.stdout = '{"accessToken": "tok"}'
|
||||
with patch(
|
||||
"specify_cli.authentication.azure_devops.shutil.which",
|
||||
return_value=which_result,
|
||||
), patch(
|
||||
"specify_cli.authentication.azure_devops.subprocess.run",
|
||||
return_value=result,
|
||||
) as run:
|
||||
assert AzureDevOpsAuth().resolve_token(entry) == "tok"
|
||||
assert run.call_args.args[0][0] == "az", which_result
|
||||
|
||||
def test_resolve_token_azure_cli_not_installed_returns_none(self):
|
||||
"""azure-cli returns None when az is not installed."""
|
||||
from unittest.mock import patch
|
||||
|
||||
@@ -370,3 +370,69 @@ def test_python_branch_falls_back_to_feature_dir_basename(prereq_repo: Path) ->
|
||||
|
||||
assert py.returncode == 0, py.stderr
|
||||
assert _json_stdout(py)["BRANCH"] == "001-my-feature"
|
||||
|
||||
|
||||
class TestGetInvokeSeparatorTolerance:
|
||||
"""`get_invoke_separator` must fall back to "." for an unusable
|
||||
`integration.json`, matching its bash and PowerShell twins.
|
||||
|
||||
The bash twin tries jq -> python3 -> awk and keeps its `separator="."`
|
||||
default on any parse failure; the PowerShell twin likewise returns ".".
|
||||
The Python twin instead indexed the parsed value directly, so two shapes
|
||||
escaped its `except (OSError, json.JSONDecodeError)`:
|
||||
|
||||
* a non-mapping top level (`[]`, `"forge"`, `42`, `null`) is valid JSON,
|
||||
so JSONDecodeError never fires and `.get()` raised AttributeError;
|
||||
* a non-UTF-8 file raises UnicodeDecodeError -- a ValueError, not an
|
||||
OSError. Realistic on Windows, where PowerShell 5.1's `Out-File`/`>`
|
||||
default to UTF-16.
|
||||
|
||||
The sibling `read_feature_json_feature_directory` in the same module
|
||||
already guards both.
|
||||
"""
|
||||
|
||||
@staticmethod
|
||||
def _load_common():
|
||||
import importlib.util
|
||||
|
||||
spec = importlib.util.spec_from_file_location("_speckit_common_py", COMMON_PY)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
# Register before exec: the module defines @dataclass types, and
|
||||
# dataclasses resolves cls.__module__ through sys.modules.
|
||||
sys.modules[spec.name] = module
|
||||
try:
|
||||
spec.loader.exec_module(module)
|
||||
except Exception: # pragma: no cover - defensive cleanup
|
||||
sys.modules.pop(spec.name, None)
|
||||
raise
|
||||
return module
|
||||
|
||||
def _repo(self, tmp_path: Path, body: str | bytes) -> Path:
|
||||
(tmp_path / ".specify").mkdir(parents=True, exist_ok=True)
|
||||
target = tmp_path / ".specify" / "integration.json"
|
||||
if isinstance(body, bytes):
|
||||
target.write_bytes(body)
|
||||
else:
|
||||
target.write_text(body, encoding="utf-8")
|
||||
return tmp_path
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body", ["[]", '[{"a": 1}]', '"forge"', "42", "true", "null"]
|
||||
)
|
||||
def test_non_mapping_integration_json_falls_back(self, tmp_path: Path, body: str):
|
||||
common = self._load_common()
|
||||
assert common.get_invoke_separator(self._repo(tmp_path, body)) == "."
|
||||
|
||||
def test_non_utf8_integration_json_falls_back(self, tmp_path: Path):
|
||||
common = self._load_common()
|
||||
raw = '{"default_integration": "forge"}'.encode("utf-16")
|
||||
assert common.get_invoke_separator(self._repo(tmp_path, raw)) == "."
|
||||
|
||||
def test_hyphen_separator_is_still_honoured(self, tmp_path: Path):
|
||||
"""Regression guard: the real feature must keep working."""
|
||||
common = self._load_common()
|
||||
body = json.dumps({
|
||||
"default_integration": "droid",
|
||||
"integration_settings": {"droid": {"invoke_separator": "-"}},
|
||||
})
|
||||
assert common.get_invoke_separator(self._repo(tmp_path, body)) == "-"
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import io
|
||||
import stat
|
||||
import struct
|
||||
import tarfile
|
||||
import weakref
|
||||
import zipfile
|
||||
import zlib
|
||||
@@ -13,11 +14,16 @@ import pytest
|
||||
|
||||
from specify_cli._download_security import (
|
||||
MAX_ZIP_CENTRAL_DIRECTORY_BYTES,
|
||||
archive_format_from_content_type,
|
||||
archive_format_from_name,
|
||||
build_safe_download_path,
|
||||
detect_archive_format,
|
||||
is_https_or_localhost_http,
|
||||
is_loopback_url,
|
||||
read_response_limited,
|
||||
read_zip_member_limited,
|
||||
safe_extract_archive,
|
||||
safe_extract_tar,
|
||||
safe_extract_zip,
|
||||
)
|
||||
|
||||
@@ -314,6 +320,176 @@ def test_build_safe_download_path_rejects_nonportable_identifiers(
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("name", "expected"),
|
||||
[
|
||||
("package.zip", "zip"),
|
||||
("PACKAGE.TAR.GZ", "tar.gz"),
|
||||
("https://example.com/package.tgz?download=1", "tar.gz"),
|
||||
("package.tar", None),
|
||||
],
|
||||
)
|
||||
def test_archive_format_from_name(name, expected):
|
||||
assert archive_format_from_name(name) == expected
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("content_type", "expected"),
|
||||
[
|
||||
("application/zip", "zip"),
|
||||
("application/x-zip-compressed; charset=binary", "zip"),
|
||||
("application/gzip", "tar.gz"),
|
||||
("application/x-gzip", "tar.gz"),
|
||||
("application/octet-stream", None),
|
||||
],
|
||||
)
|
||||
def test_archive_format_from_content_type(content_type, expected):
|
||||
assert archive_format_from_content_type(content_type) == expected
|
||||
|
||||
|
||||
def _write_tar_gz(path, members):
|
||||
with tarfile.open(path, "w:gz") as archive:
|
||||
for name, content in members:
|
||||
info = tarfile.TarInfo(name)
|
||||
info.size = len(content)
|
||||
archive.addfile(info, io.BytesIO(content))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
|
||||
def test_detect_archive_format_accepts_tar_suffixes(tmp_path, suffix):
|
||||
archive_path = tmp_path / f"package{suffix}"
|
||||
_write_tar_gz(archive_path, [("file.txt", b"contents")])
|
||||
|
||||
assert detect_archive_format(archive_path) == "tar.gz"
|
||||
|
||||
|
||||
def test_detect_archive_format_allows_content_type_fallback(tmp_path):
|
||||
archive_path = tmp_path / "download"
|
||||
_write_tar_gz(archive_path, [("file.txt", b"contents")])
|
||||
|
||||
assert (
|
||||
detect_archive_format(
|
||||
archive_path,
|
||||
source_name="https://example.com/download",
|
||||
content_type="application/gzip",
|
||||
)
|
||||
== "tar.gz"
|
||||
)
|
||||
|
||||
|
||||
def test_detect_archive_format_rejects_suffix_content_mismatch(tmp_path):
|
||||
archive_path = tmp_path / "package.zip"
|
||||
_write_tar_gz(archive_path, [("file.txt", b"contents")])
|
||||
|
||||
with pytest.raises(ValueError, match="format mismatch"):
|
||||
detect_archive_format(archive_path)
|
||||
|
||||
|
||||
def test_detect_archive_format_rejects_suffix_header_mismatch(tmp_path):
|
||||
archive_path = tmp_path / "package.zip"
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("file.txt", "contents")
|
||||
|
||||
with pytest.raises(ValueError, match="Content-Type"):
|
||||
detect_archive_format(
|
||||
archive_path,
|
||||
content_type="application/gzip",
|
||||
)
|
||||
|
||||
|
||||
def test_build_safe_download_path_uses_archive_suffix(tmp_path):
|
||||
path = build_safe_download_path(tmp_path, "package", "1.0.0", suffix=".tar.gz")
|
||||
assert path.name == "package-1.0.0.tar.gz"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_name",
|
||||
["../evil.txt", "nested/../../evil.txt", "C:/Windows/evil.txt"],
|
||||
)
|
||||
def test_safe_extract_tar_rejects_traversal(tmp_path, member_name):
|
||||
archive_path = tmp_path / "bad.tar.gz"
|
||||
_write_tar_gz(archive_path, [(member_name, b"nope")])
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe path"):
|
||||
safe_extract_tar(archive_path, tmp_path / "out")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("link_type", "message"),
|
||||
[(tarfile.SYMTYPE, "symlink"), (tarfile.LNKTYPE, "hard link")],
|
||||
)
|
||||
def test_safe_extract_tar_rejects_links_without_partial_extraction(
|
||||
tmp_path, link_type, message
|
||||
):
|
||||
archive_path = tmp_path / "bad.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
safe = tarfile.TarInfo("safe.txt")
|
||||
safe.size = 4
|
||||
archive.addfile(safe, io.BytesIO(b"safe"))
|
||||
link = tarfile.TarInfo("escape")
|
||||
link.type = link_type
|
||||
link.linkname = "../../outside"
|
||||
archive.addfile(link)
|
||||
|
||||
out_dir = tmp_path / "out"
|
||||
with pytest.raises(ValueError, match=message):
|
||||
safe_extract_tar(archive_path, out_dir)
|
||||
|
||||
assert not out_dir.exists() or not any(out_dir.rglob("*"))
|
||||
|
||||
|
||||
def test_safe_extract_tar_rejects_special_file(tmp_path):
|
||||
archive_path = tmp_path / "bad.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
fifo = tarfile.TarInfo("pipe")
|
||||
fifo.type = tarfile.FIFOTYPE
|
||||
archive.addfile(fifo)
|
||||
|
||||
with pytest.raises(ValueError, match="Unsafe member type"):
|
||||
safe_extract_tar(archive_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_tar_rejects_conflicting_paths(tmp_path):
|
||||
archive_path = tmp_path / "bad.tar.gz"
|
||||
_write_tar_gz(
|
||||
archive_path,
|
||||
[("Folder/file.txt", b"one"), ("folder/FILE.txt", b"two")],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="Conflicting path"):
|
||||
safe_extract_tar(archive_path, tmp_path / "out")
|
||||
|
||||
|
||||
def test_safe_extract_tar_enforces_entry_and_size_limits(tmp_path):
|
||||
archive_path = tmp_path / "bad.tar.gz"
|
||||
_write_tar_gz(
|
||||
archive_path,
|
||||
[("one.txt", b"1234"), ("two.txt", b"5678")],
|
||||
)
|
||||
|
||||
with pytest.raises(ValueError, match="too many entries"):
|
||||
safe_extract_tar(archive_path, tmp_path / "entries", max_entries=1)
|
||||
with pytest.raises(ValueError, match="member.*maximum size"):
|
||||
safe_extract_tar(archive_path, tmp_path / "member", max_member_bytes=3)
|
||||
with pytest.raises(ValueError, match="uncompressed size"):
|
||||
safe_extract_tar(archive_path, tmp_path / "total", max_total_bytes=7)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".zip", ".tar.gz", ".tgz"])
|
||||
def test_safe_extract_archive_has_format_parity(tmp_path, suffix):
|
||||
archive_path = tmp_path / f"package{suffix}"
|
||||
if suffix == ".zip":
|
||||
with zipfile.ZipFile(archive_path, "w") as archive:
|
||||
archive.writestr("nested/file.txt", b"contents")
|
||||
else:
|
||||
_write_tar_gz(archive_path, [("nested/file.txt", b"contents")])
|
||||
|
||||
out_dir = tmp_path / f"out-{suffix.replace('.', '-')}"
|
||||
safe_extract_archive(archive_path, out_dir)
|
||||
|
||||
assert (out_dir / "nested" / "file.txt").read_bytes() == b"contents"
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"member_name",
|
||||
[
|
||||
|
||||
401
tests/test_extension_add_path_traversal.py
Normal file
401
tests/test_extension_add_path_traversal.py
Normal file
@@ -0,0 +1,401 @@
|
||||
"""Security tests for the extension URL download cache."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import io
|
||||
import os
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
from types import SimpleNamespace
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
import typer
|
||||
from typer.testing import CliRunner
|
||||
|
||||
from specify_cli import app
|
||||
from specify_cli.extensions import ExtensionCatalog, ExtensionManager
|
||||
from specify_cli.extensions import _commands
|
||||
|
||||
|
||||
_MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18
|
||||
runner = CliRunner()
|
||||
|
||||
|
||||
def _require_secure_dir_fd() -> None:
|
||||
if (
|
||||
not getattr(os, "O_NOFOLLOW", 0)
|
||||
or os.open not in os.supports_dir_fd
|
||||
or os.mkdir not in os.supports_dir_fd
|
||||
):
|
||||
pytest.skip("requires dir_fd and O_NOFOLLOW support")
|
||||
|
||||
|
||||
def _symlink_directory(link: Path, target: Path) -> None:
|
||||
try:
|
||||
link.symlink_to(target, target_is_directory=True)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
pytest.skip(f"directory symlinks are unavailable: {exc}")
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def project_dir(tmp_path: Path, monkeypatch: pytest.MonkeyPatch) -> Path:
|
||||
project = tmp_path / "project"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
monkeypatch.chdir(project)
|
||||
return project
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ancestor_parts",
|
||||
[
|
||||
("extensions",),
|
||||
("extensions", ".cache"),
|
||||
("extensions", ".cache", "downloads"),
|
||||
],
|
||||
)
|
||||
def test_symlinked_cache_ancestor_is_refused(
|
||||
project_dir: Path, tmp_path: Path, ancestor_parts: tuple[str, ...]
|
||||
) -> None:
|
||||
_require_secure_dir_fd()
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
|
||||
parent = project_dir / ".specify"
|
||||
for part in ancestor_parts[:-1]:
|
||||
parent = parent / part
|
||||
parent.mkdir()
|
||||
_symlink_directory(parent / ancestor_parts[-1], outside)
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_commands._validate_safe_cache_dir(project_dir)
|
||||
|
||||
assert list(outside.iterdir()) == []
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"ancestor_parts",
|
||||
[
|
||||
("extensions",),
|
||||
("extensions", ".cache"),
|
||||
("extensions", ".cache", "downloads"),
|
||||
],
|
||||
)
|
||||
def test_symlinked_cache_ancestor_is_refused_without_dir_fd(
|
||||
project_dir: Path,
|
||||
tmp_path: Path,
|
||||
monkeypatch: pytest.MonkeyPatch,
|
||||
ancestor_parts: tuple[str, ...],
|
||||
) -> None:
|
||||
"""The portable (Windows) validation path must also refuse a symlinked
|
||||
cache ancestor and never create anything under the symlink target."""
|
||||
monkeypatch.setattr(os, "supports_dir_fd", set())
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
|
||||
parent = project_dir / ".specify"
|
||||
for part in ancestor_parts[:-1]:
|
||||
parent = parent / part
|
||||
parent.mkdir()
|
||||
_symlink_directory(parent / ancestor_parts[-1], outside)
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_commands._validate_safe_cache_dir(project_dir)
|
||||
|
||||
assert list(outside.iterdir()) == []
|
||||
|
||||
|
||||
def test_cache_ancestor_resolving_outside_project_is_refused(
|
||||
project_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_require_secure_dir_fd()
|
||||
cache_root = project_dir / ".specify" / "extensions" / ".cache"
|
||||
cache_root.mkdir(parents=True)
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
real_resolve = Path.resolve
|
||||
|
||||
def fake_resolve(self: Path, *args, **kwargs) -> Path:
|
||||
if self == cache_root:
|
||||
return real_resolve(outside, *args, **kwargs)
|
||||
return real_resolve(self, *args, **kwargs)
|
||||
|
||||
monkeypatch.setattr(Path, "resolve", fake_resolve)
|
||||
|
||||
with pytest.raises(typer.Exit):
|
||||
_commands._validate_safe_cache_dir(project_dir)
|
||||
|
||||
assert list(outside.iterdir()) == []
|
||||
|
||||
|
||||
def test_safe_open_refuses_exclusive_leaf_collision(project_dir: Path) -> None:
|
||||
_require_secure_dir_fd()
|
||||
download_dir = _commands._validate_safe_cache_dir(project_dir)
|
||||
zip_filename = "extension-url-download-collision.zip"
|
||||
collision = download_dir / zip_filename
|
||||
collision.write_bytes(b"sentinel")
|
||||
|
||||
with pytest.raises(OSError):
|
||||
_commands._safe_open_download_zip(
|
||||
project_dir, download_dir, zip_filename
|
||||
)
|
||||
|
||||
assert collision.read_bytes() == b"sentinel"
|
||||
|
||||
|
||||
def test_safe_open_refuses_swapped_cache_ancestor(
|
||||
project_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
_require_secure_dir_fd()
|
||||
download_dir = _commands._validate_safe_cache_dir(project_dir)
|
||||
cache_root = project_dir / ".specify" / "extensions" / ".cache"
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
|
||||
shutil.rmtree(cache_root)
|
||||
_symlink_directory(cache_root, outside)
|
||||
|
||||
with pytest.raises(OSError):
|
||||
_commands._safe_open_download_zip(
|
||||
project_dir,
|
||||
download_dir,
|
||||
"extension-url-download-swapped.zip",
|
||||
)
|
||||
|
||||
assert list(outside.iterdir()) == []
|
||||
|
||||
|
||||
def test_safe_open_refuses_symlinked_project_root(
|
||||
project_dir: Path, tmp_path: Path
|
||||
) -> None:
|
||||
_require_secure_dir_fd()
|
||||
project_link = tmp_path / "project-link"
|
||||
_symlink_directory(project_link, project_dir)
|
||||
download_dir = project_link / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
|
||||
with pytest.raises(OSError):
|
||||
_commands._safe_open_download_zip(
|
||||
project_link,
|
||||
download_dir,
|
||||
"extension-url-download-project-link.zip",
|
||||
)
|
||||
|
||||
|
||||
def test_safe_open_succeeds_without_dir_fd_support(
|
||||
project_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""On a platform without dir_fd (e.g. Windows) the portable path must
|
||||
still hand back a usable, exclusively-created descriptor rather than
|
||||
failing closed."""
|
||||
monkeypatch.setattr(os, "supports_dir_fd", set())
|
||||
|
||||
download_dir = _commands._validate_safe_cache_dir(project_dir)
|
||||
assert download_dir == (
|
||||
project_dir / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
)
|
||||
|
||||
fd = _commands._safe_open_download_zip(
|
||||
project_dir, download_dir, "extension-url-download-portable.zip"
|
||||
)
|
||||
try:
|
||||
os.write(fd, b"payload")
|
||||
os.lseek(fd, 0, os.SEEK_SET)
|
||||
assert os.read(fd, 7) == b"payload"
|
||||
finally:
|
||||
os.close(fd)
|
||||
|
||||
|
||||
def test_safe_open_without_dir_fd_refuses_symlinked_leaf(
|
||||
project_dir: Path, tmp_path: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""The portable path must refuse a leaf pre-staged as a symlink so an
|
||||
attacker cannot redirect the exclusive create outside the project."""
|
||||
monkeypatch.setattr(os, "supports_dir_fd", set())
|
||||
download_dir = _commands._validate_safe_cache_dir(project_dir)
|
||||
outside = tmp_path / "outside.zip"
|
||||
zip_filename = "extension-url-download-symlink-leaf.zip"
|
||||
try:
|
||||
(download_dir / zip_filename).symlink_to(outside)
|
||||
except (OSError, NotImplementedError) as exc:
|
||||
pytest.skip(f"symlinks are unavailable: {exc}")
|
||||
|
||||
with pytest.raises(OSError):
|
||||
_commands._safe_open_download_zip(project_dir, download_dir, zip_filename)
|
||||
|
||||
assert not outside.exists()
|
||||
|
||||
|
||||
def test_url_install_succeeds_without_dir_fd_support(
|
||||
project_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""A full ``--from`` install must work on platforms without dir_fd rather
|
||||
than failing closed, exercising the portable hardened download path."""
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def fake_install(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
*,
|
||||
archive_file=None,
|
||||
):
|
||||
captured["bytes"] = archive_file.read()
|
||||
archive_file.seek(0)
|
||||
return SimpleNamespace(
|
||||
id="test-ext",
|
||||
name="Test Extension",
|
||||
version="1.0.0",
|
||||
description="",
|
||||
warnings=[],
|
||||
commands=[],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(os, "supports_dir_fd", set())
|
||||
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(
|
||||
ExtensionCatalog,
|
||||
"_open_url",
|
||||
lambda *args, **kwargs: FakeResponse(_MINIMAL_ZIP_BYTES),
|
||||
)
|
||||
monkeypatch.setattr(ExtensionManager, "install_from_zip", fake_install)
|
||||
monkeypatch.setattr(_commands, "_refresh_events_and_warn", lambda root: None)
|
||||
monkeypatch.setattr(_commands, "load_init_options", lambda root: {})
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"extension",
|
||||
"add",
|
||||
"test-ext",
|
||||
"--from",
|
||||
"https://example.com/test-ext.zip",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["bytes"] == _MINIMAL_ZIP_BYTES
|
||||
|
||||
|
||||
def test_url_install_writes_and_cleans_up_secure_download(
|
||||
project_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
_require_secure_dir_fd()
|
||||
captured: dict[str, object] = {}
|
||||
|
||||
class FakeResponse(io.BytesIO):
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, exc_type, exc, tb):
|
||||
return False
|
||||
|
||||
def fake_install(
|
||||
self,
|
||||
zip_path: Path,
|
||||
speckit_version: str,
|
||||
priority: int = 10,
|
||||
force: bool = False,
|
||||
*,
|
||||
archive_file=None,
|
||||
):
|
||||
captured["path"] = zip_path
|
||||
captured["mode"] = os.fstat(archive_file.fileno()).st_mode & 0o777
|
||||
captured["exists_during_install"] = zip_path.exists()
|
||||
captured["bytes"] = archive_file.read()
|
||||
archive_file.seek(0)
|
||||
return SimpleNamespace(
|
||||
id="test-ext",
|
||||
name="Test Extension",
|
||||
version="1.0.0",
|
||||
description="",
|
||||
warnings=[],
|
||||
commands=[],
|
||||
)
|
||||
|
||||
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(
|
||||
ExtensionCatalog,
|
||||
"_open_url",
|
||||
lambda *args, **kwargs: FakeResponse(_MINIMAL_ZIP_BYTES),
|
||||
)
|
||||
monkeypatch.setattr(ExtensionManager, "install_from_zip", fake_install)
|
||||
monkeypatch.setattr(_commands, "_refresh_events_and_warn", lambda root: None)
|
||||
monkeypatch.setattr(_commands, "load_init_options", lambda root: {})
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"extension",
|
||||
"add",
|
||||
"test-ext",
|
||||
"--from",
|
||||
"https://example.com/test-ext.zip",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert captured["bytes"] == _MINIMAL_ZIP_BYTES
|
||||
assert captured["mode"] == 0o600
|
||||
# The archive is an anonymous inode: it is never visible on disk, even
|
||||
# while installation consumes the open descriptor.
|
||||
assert captured["exists_during_install"] is False
|
||||
zip_path = captured["path"]
|
||||
assert isinstance(zip_path, Path)
|
||||
assert zip_path.parent == (
|
||||
project_dir / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
)
|
||||
assert zip_path.name.startswith("extension-url-download-")
|
||||
assert not zip_path.exists()
|
||||
|
||||
|
||||
def test_url_install_open_error_surfaces_as_controlled_exit(
|
||||
project_dir: Path, monkeypatch: pytest.MonkeyPatch
|
||||
) -> None:
|
||||
"""An ``OSError`` from the hardened create (e.g. an exclusive-leaf
|
||||
collision or a swapped ancestor) must fail closed as ``typer.Exit(1)``
|
||||
rather than escaping as an unhandled traceback, and installation must
|
||||
not run."""
|
||||
download_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
|
||||
monkeypatch.setattr(typer, "confirm", lambda *args, **kwargs: True)
|
||||
monkeypatch.setattr(
|
||||
ExtensionCatalog,
|
||||
"_open_url",
|
||||
lambda *args, **kwargs: io.BytesIO(_MINIMAL_ZIP_BYTES),
|
||||
)
|
||||
monkeypatch.setattr(
|
||||
_commands, "_validate_safe_cache_dir", lambda root: download_dir
|
||||
)
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def _raise_collision(project_root, dir_, zip_filename):
|
||||
raise FileExistsError("leaf already exists")
|
||||
|
||||
monkeypatch.setattr(_commands, "_safe_open_download_zip", _raise_collision)
|
||||
install_spy = MagicMock()
|
||||
monkeypatch.setattr(ExtensionManager, "install_from_zip", install_spy)
|
||||
|
||||
result = runner.invoke(
|
||||
app,
|
||||
[
|
||||
"extension",
|
||||
"add",
|
||||
"test-ext",
|
||||
"--from",
|
||||
"https://example.com/test-ext.zip",
|
||||
],
|
||||
)
|
||||
|
||||
assert result.exit_code == 1
|
||||
assert "Could not safely create download file" in result.output
|
||||
install_spy.assert_not_called()
|
||||
@@ -2943,6 +2943,135 @@ class TestExtensionSkillRegistration:
|
||||
assert "speckit-early-ext-world" in metadata["registered_skills"]
|
||||
|
||||
|
||||
|
||||
# ===== Regression test: upgrade-overwrites-copilot-skills (#3849) =====
|
||||
|
||||
class TestRegisterExtensionSkillsForceFlag:
|
||||
"""Regression tests for the ``force`` flag on ``_register_extension_skills``.
|
||||
|
||||
Issue #3849: ``integration upgrade --force`` called ``setup()`` which
|
||||
regenerated all core-template SKILL.md files, then called
|
||||
``register_enabled_extensions_for_agent()``. The skip-guard in
|
||||
``_register_extension_skills`` treated the freshly-written core files as
|
||||
existing user content and skipped every extension skill, leaving only core
|
||||
template content on disk.
|
||||
|
||||
The fix introduces ``force=True`` in the upgrade path so the guard does not
|
||||
fire for core-template files that setup() just wrote.
|
||||
"""
|
||||
|
||||
def test_force_false_skips_existing_skill(self, project_dir, temp_dir):
|
||||
"""Without force=True the skip guard must still protect existing files."""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
# Manually pre-create a SKILL.md as if setup() had already written it
|
||||
skill_subdir = skills_dir / "speckit-test-ext-hello"
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
skill_file.write_text("core-template content only", encoding="utf-8")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
|
||||
# Default (force=False): existing file must not be overwritten
|
||||
written = manager._register_extension_skills(manifest, ext_dir, force=False)
|
||||
assert "speckit-test-ext-hello" not in written
|
||||
assert skill_file.read_text(encoding="utf-8") == "core-template content only"
|
||||
|
||||
def test_force_true_overwrites_existing_skill(self, project_dir, temp_dir):
|
||||
"""With force=True the function must overwrite the existing SKILL.md.
|
||||
|
||||
This is the core regression test for #3849: calling
|
||||
``_register_extension_skills(force=True)`` after ``setup()`` has
|
||||
written a fresh core-template SKILL.md must replace it with the
|
||||
composed extension content.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
# Simulate what setup() writes: a bare core-template SKILL.md
|
||||
skill_subdir = skills_dir / "speckit-test-ext-hello"
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
skill_file.write_text("core-template content only", encoding="utf-8")
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
|
||||
# Upgrade path (force=True): extension content should replace the core file
|
||||
written = manager._register_extension_skills(manifest, ext_dir, force=True)
|
||||
assert "speckit-test-ext-hello" in written, (
|
||||
"force=True should overwrite the core-template file and return the skill name"
|
||||
)
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "Run this to say hello." in content, (
|
||||
"Extension command body must appear in the overwritten SKILL.md"
|
||||
)
|
||||
assert "core-template content only" not in content, (
|
||||
"Core-template placeholder must have been replaced by extension content"
|
||||
)
|
||||
|
||||
def test_register_enabled_extensions_for_agent_force_flag_threads_through(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""force=True on register_enabled_extensions_for_agent must reach _register_extension_skills.
|
||||
|
||||
End-to-end check: after an upgrade writes a fresh core-template SKILL.md,
|
||||
``register_enabled_extensions_for_agent(force=True)`` must produce a
|
||||
SKILL.md that contains the extension content.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
# Install extension so it is in the registry
|
||||
manager.install_from_directory(ext_dir, "0.1.0", register_commands=False)
|
||||
|
||||
# Simulate a freshly-regenerated core-template SKILL.md (as setup() would write)
|
||||
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||
skill_file.write_text("core-template content only", encoding="utf-8")
|
||||
|
||||
# Re-register with force=True (upgrade path)
|
||||
manager.register_enabled_extensions_for_agent("claude", force=True)
|
||||
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "Run this to say hello." in content, (
|
||||
"After register_enabled_extensions_for_agent(force=True), the SKILL.md "
|
||||
"must contain the extension body, not just the core-template stub."
|
||||
)
|
||||
|
||||
def test_force_true_with_preexisting_dir_but_no_skill_file(
|
||||
self, project_dir, temp_dir
|
||||
):
|
||||
"""force=True must write into a pre-existing directory with no SKILL.md.
|
||||
|
||||
The second skip guard (``elif skill_dir_preexists``) should also be
|
||||
bypassed by force=True so an upgrade can create a missing SKILL.md
|
||||
even when the skill sub-directory already exists.
|
||||
"""
|
||||
_create_init_options(project_dir, ai="claude", ai_skills=True)
|
||||
skills_dir = _create_skills_dir(project_dir, ai="claude")
|
||||
ext_dir = _create_extension_dir(temp_dir)
|
||||
|
||||
# Create the skill directory without the SKILL.md file
|
||||
skill_subdir = skills_dir / "speckit-test-ext-hello"
|
||||
skill_subdir.mkdir(parents=True, exist_ok=True)
|
||||
skill_file = skill_subdir / "SKILL.md"
|
||||
assert not skill_file.exists()
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = ExtensionManifest(ext_dir / "extension.yml")
|
||||
|
||||
written = manager._register_extension_skills(manifest, ext_dir, force=True)
|
||||
assert "speckit-test-ext-hello" in written
|
||||
assert skill_file.exists()
|
||||
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
|
||||
|
||||
|
||||
# ===== Extension Skill Unregistration Tests =====
|
||||
|
||||
class TestExtensionSkillUnregistration:
|
||||
|
||||
@@ -49,6 +49,38 @@ from specify_cli._utils import version_satisfies
|
||||
_MINIMAL_ZIP_BYTES = b"PK\x05\x06" + b"\x00" * 18
|
||||
|
||||
|
||||
def _open_test_download_zip(project_root, download_dir, zip_filename):
|
||||
"""Cross-platform stand-in for the POSIX-only secure cache primitive.
|
||||
|
||||
Mirrors production behavior by making the leaf disappear from disk while
|
||||
the descriptor stays open. On POSIX the file is unlinked immediately; on
|
||||
Windows an in-use file cannot be unlinked, so it is opened with
|
||||
``O_TEMPORARY`` and removed automatically when the descriptor closes.
|
||||
"""
|
||||
target = download_dir / zip_filename
|
||||
o_temporary = getattr(os, "O_TEMPORARY", 0)
|
||||
if o_temporary:
|
||||
return os.open(
|
||||
target,
|
||||
os.O_RDWR | os.O_CREAT | os.O_EXCL | o_temporary,
|
||||
0o600,
|
||||
)
|
||||
fd = os.open(target, os.O_RDWR | os.O_CREAT | os.O_EXCL, 0o600)
|
||||
try:
|
||||
os.unlink(target)
|
||||
except OSError:
|
||||
os.close(fd)
|
||||
raise
|
||||
return fd
|
||||
|
||||
|
||||
def _validate_safe_cache_dir_test_stand_in(project_root):
|
||||
"""Cross-platform stand-in for the secure cache validator."""
|
||||
download_dir = project_root / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
download_dir.mkdir(parents=True, exist_ok=True)
|
||||
return download_dir
|
||||
|
||||
|
||||
def can_create_symlink(tmp_path: Path) -> bool:
|
||||
"""Return True when the current platform/user can create file symlinks."""
|
||||
target = tmp_path / "symlink-target.txt"
|
||||
@@ -576,7 +608,7 @@ class TestExtensionManifest:
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match="must provide at least one command or hook"):
|
||||
with pytest.raises(ValidationError, match="must provide at least one command, hook, or event"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_hooks_only_extension(self, temp_dir, valid_manifest_data):
|
||||
@@ -614,6 +646,67 @@ class TestExtensionManifest:
|
||||
with pytest.raises(ValidationError, match="Invalid provides.commands"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize("section", ["extension", "requires", "provides"])
|
||||
@pytest.mark.parametrize("bad", [None, [], "text"])
|
||||
def test_required_section_not_mapping_rejected(
|
||||
self, temp_dir, valid_manifest_data, section, bad
|
||||
):
|
||||
"""A required section that is written but empty or wrongly shaped must
|
||||
raise ValidationError, not a raw TypeError/AttributeError.
|
||||
|
||||
REQUIRED_FIELDS only checks key presence, so `provides:` with no value
|
||||
passed it and then hit `None.get(...)`. That AttributeError escaped
|
||||
list_installed()'s ValidationError-only "Corrupted extension" fallback,
|
||||
so one bad extension made `specify extension list` exit 1 instead of
|
||||
listing the others.
|
||||
"""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data[section] = bad
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(ValidationError, match=f"Invalid {section}"):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_empty_provides_mapping_is_still_accepted_with_hooks(
|
||||
self, temp_dir, valid_manifest_data
|
||||
):
|
||||
"""Regression guard: `provides: {}` is a well-SHAPED mapping, so the new
|
||||
shape check must not reject it — an extension may provide only hooks."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"] = {}
|
||||
assert valid_manifest_data.get("hooks"), "fixture is expected to define hooks"
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
ExtensionManifest(manifest_path) # must not raise
|
||||
|
||||
def test_empty_provides_and_no_hooks_keeps_its_own_message(
|
||||
self, temp_dir, valid_manifest_data
|
||||
):
|
||||
"""...and with no hooks (or events) either, it reports the "nothing
|
||||
provided" message rather than the new shape error."""
|
||||
import yaml
|
||||
|
||||
valid_manifest_data["provides"] = {}
|
||||
valid_manifest_data.pop("hooks", None)
|
||||
valid_manifest_data.pop("events", None)
|
||||
|
||||
manifest_path = temp_dir / "extension.yml"
|
||||
with open(manifest_path, 'w') as f:
|
||||
yaml.dump(valid_manifest_data, f)
|
||||
|
||||
with pytest.raises(
|
||||
ValidationError, match="at least one command, hook, or event"
|
||||
):
|
||||
ExtensionManifest(manifest_path)
|
||||
|
||||
def test_hooks_not_dict_rejected(self, temp_dir, valid_manifest_data):
|
||||
"""Test manifest with hooks as a list is rejected."""
|
||||
import yaml
|
||||
@@ -2168,6 +2261,79 @@ class TestExtensionManager:
|
||||
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
|
||||
@pytest.mark.skipif(os.name == "nt", reason="requires replacing an open file")
|
||||
def test_install_from_zip_uses_open_archive_after_path_replacement(
|
||||
self, extension_dir, project_dir, temp_dir
|
||||
):
|
||||
"""An authoritative archive stream must survive pathname replacement."""
|
||||
import zipfile
|
||||
|
||||
zip_path = temp_dir / "original-extension.zip"
|
||||
with zipfile.ZipFile(zip_path, "w") as archive:
|
||||
for file_path in extension_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
archive.write(file_path, file_path.relative_to(extension_dir))
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
with zip_path.open("rb") as archive_file:
|
||||
zip_path.unlink()
|
||||
with zipfile.ZipFile(zip_path, "w"):
|
||||
pass
|
||||
manifest = manager.install_from_zip(
|
||||
zip_path,
|
||||
"0.1.0",
|
||||
archive_file=archive_file,
|
||||
)
|
||||
|
||||
assert manifest.id == "test-ext"
|
||||
assert manager.registry.is_installed("test-ext")
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
|
||||
@pytest.mark.parametrize("nested", [False, True])
|
||||
def test_install_from_tar_archive(
|
||||
self, extension_dir, project_dir, temp_dir, suffix, nested
|
||||
):
|
||||
"""Tar archives install with the same flat/nested behavior as ZIP."""
|
||||
import tarfile
|
||||
|
||||
archive_path = temp_dir / f"test-ext{suffix}"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
for file_path in extension_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
relative = file_path.relative_to(extension_dir)
|
||||
arcname = Path("test-ext-v1") / relative if nested else relative
|
||||
archive.add(file_path, arcname=arcname)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
manifest = manager.install_from_archive(archive_path, "0.1.0")
|
||||
|
||||
assert manifest.id == "test-ext"
|
||||
assert manager.registry.is_installed("test-ext")
|
||||
|
||||
def test_install_from_tar_rejects_symlink_entry(
|
||||
self, extension_dir, project_dir, temp_dir
|
||||
):
|
||||
import tarfile
|
||||
|
||||
archive_path = temp_dir / "symlink-extension.tar.gz"
|
||||
with tarfile.open(archive_path, "w:gz") as archive:
|
||||
for file_path in extension_dir.rglob("*"):
|
||||
if file_path.is_file():
|
||||
archive.add(
|
||||
file_path,
|
||||
arcname=file_path.relative_to(extension_dir),
|
||||
)
|
||||
link = tarfile.TarInfo("templates/escape")
|
||||
link.type = tarfile.SYMTYPE
|
||||
link.linkname = "../../outside"
|
||||
archive.addfile(link)
|
||||
|
||||
manager = ExtensionManager(project_dir)
|
||||
with pytest.raises(ValidationError, match="Unsafe symlink"):
|
||||
manager.install_from_archive(archive_path, "0.1.0")
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
assert not manager.registry.is_installed("test-ext")
|
||||
|
||||
def test_install_duplicate_error_mentions_force(self, extension_dir, project_dir):
|
||||
"""Test that duplicate install error message suggests --force."""
|
||||
manager = ExtensionManager(project_dir)
|
||||
@@ -2662,6 +2828,36 @@ Real body starts here.
|
||||
|
||||
assert parsed["description"] == "first line\nsecond line\n"
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("description", "expected"),
|
||||
[
|
||||
(None, ""), # "description:" with no value
|
||||
(42, "42"), # unquoted number
|
||||
(True, "True"), # unquoted boolean
|
||||
(["a", "b"], "['a', 'b']"), # was silently concatenated to "ab"
|
||||
],
|
||||
)
|
||||
def test_render_toml_command_coerces_non_string_description(
|
||||
self, description, expected
|
||||
):
|
||||
"""Frontmatter comes from yaml.safe_load, so description can be any type.
|
||||
|
||||
_render_basic_toml_string iterates the value and calls ord() per
|
||||
character, so a non-string raised a raw TypeError and a list of
|
||||
single-character items was silently concatenated into a wrong value.
|
||||
render_yaml_command (same class) already coerces; this brings the TOML
|
||||
branch to parity.
|
||||
"""
|
||||
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
|
||||
|
||||
registrar = AgentCommandRegistrar()
|
||||
output = registrar.render_toml_command(
|
||||
{"description": description}, "body", "extension:test-ext"
|
||||
)
|
||||
|
||||
parsed = tomllib.loads(output)
|
||||
assert parsed["description"] == expected
|
||||
|
||||
def test_render_toml_command_escapes_control_characters(self):
|
||||
"""Control characters and a lone CR must be escaped so the TOML parses.
|
||||
|
||||
@@ -2884,6 +3080,29 @@ Real body starts here.
|
||||
assert "source: test-ext:commands/hello.md" in content
|
||||
assert "<!-- Extension:" not in content
|
||||
|
||||
def test_codex_skill_registration_uses_dollar_command_refs(
|
||||
self, extension_dir, project_dir
|
||||
):
|
||||
"""Codex extension skills use the native dollar invocation prefix."""
|
||||
skills_dir = project_dir / ".agents" / "skills"
|
||||
skills_dir.mkdir(parents=True)
|
||||
command = extension_dir / "commands" / "hello.md"
|
||||
command.write_text(
|
||||
"---\ndescription: Test hello command\n---\n\nRun __SPECKIT_COMMAND_PLAN__.",
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
manifest = ExtensionManifest(extension_dir / "extension.yml")
|
||||
registrar = CommandRegistrar()
|
||||
registrar.register_commands_for_agent(
|
||||
"codex", manifest, extension_dir, project_dir
|
||||
)
|
||||
|
||||
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
|
||||
content = skill_file.read_text(encoding="utf-8")
|
||||
assert "$speckit-plan" in content
|
||||
assert "/speckit-plan" not in content
|
||||
|
||||
def test_codex_skill_registration_resolves_script_placeholders(self, project_dir, temp_dir):
|
||||
"""Codex SKILL.md overrides should resolve script placeholders."""
|
||||
import yaml
|
||||
@@ -4389,6 +4608,44 @@ class TestExtensionCatalog:
|
||||
results = catalog.search(query="jira")
|
||||
assert {r["id"] for r in results} == {"jira"}
|
||||
|
||||
def test_search_and_info_tolerate_non_list_tags(self, temp_dir):
|
||||
"""A scalar ``tags:`` value must not crash the search/info display.
|
||||
|
||||
``ExtensionCatalog.search`` guards its tag *filter* with
|
||||
``isinstance(raw_tags, list)``, but the ``extension search`` and
|
||||
``extension info`` display paths only tested truthiness before
|
||||
iterating. ``tags: 5`` is truthy and not iterable, so both raised
|
||||
``TypeError: 'int' object is not iterable``.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
project_dir = temp_dir / "project"
|
||||
project_dir.mkdir()
|
||||
(project_dir / ".specify").mkdir()
|
||||
|
||||
merged = [{
|
||||
"id": "jira",
|
||||
"name": "Jira",
|
||||
"version": "1.0.0",
|
||||
"description": "Jira",
|
||||
"tags": 5,
|
||||
}]
|
||||
|
||||
with patch.object(ExtensionCatalog, "_get_merged_extensions", return_value=merged), \
|
||||
patch("specify_cli.extensions._commands._require_specify_project",
|
||||
return_value=project_dir):
|
||||
searched = CliRunner().invoke(app, ["extension", "search", "Jira"])
|
||||
info = CliRunner().invoke(app, ["extension", "info", "jira"])
|
||||
|
||||
assert searched.exit_code == 0, searched.output
|
||||
assert "Jira" in searched.output
|
||||
assert "Tags:" not in searched.output
|
||||
|
||||
assert info.exit_code == 0, info.output
|
||||
assert "Tags:" not in info.output
|
||||
|
||||
def test_search_tolerates_non_string_author_and_name(self, temp_dir):
|
||||
"""Non-string catalog author/name must not crash author/query search.
|
||||
|
||||
@@ -4746,9 +5003,9 @@ class TestExtensionCatalog:
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
mock_response.read.side_effect = io.BytesIO(json.dumps(
|
||||
{"schema_version": "1.0", "extensions": {}}
|
||||
).encode()
|
||||
).encode()).read
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "http://evil.test/catalog.json"
|
||||
@@ -4794,9 +5051,9 @@ class TestExtensionCatalog:
|
||||
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.return_value = json.dumps(
|
||||
mock_response.read.side_effect = io.BytesIO(json.dumps(
|
||||
{"schema_version": "1.0", "extensions": {}}
|
||||
).encode()
|
||||
).encode()).read
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "http://evil.test/catalog.json"
|
||||
@@ -5554,6 +5811,35 @@ class TestExtensionCatalog:
|
||||
assert captured[0].get_header("Authorization") == "Bearer ghp_testtoken"
|
||||
assert captured[0].get_header("Accept") == "application/octet-stream"
|
||||
|
||||
@pytest.mark.parametrize("suffix", [".tar.gz", ".tgz"])
|
||||
def test_download_extension_preserves_tar_archive_format(
|
||||
self, temp_dir, suffix
|
||||
):
|
||||
import tarfile
|
||||
from unittest.mock import patch
|
||||
|
||||
archive_buffer = io.BytesIO()
|
||||
with tarfile.open(fileobj=archive_buffer, mode="w:gz") as archive:
|
||||
content = b"extension:\n id: test-ext\n"
|
||||
member = tarfile.TarInfo("extension.yml")
|
||||
member.size = len(content)
|
||||
archive.addfile(member, io.BytesIO(content))
|
||||
archive_bytes = archive_buffer.getvalue()
|
||||
catalog = self._make_catalog(temp_dir)
|
||||
ext_info = {
|
||||
"id": "test-ext",
|
||||
"name": "Test Extension",
|
||||
"version": "1.0.0",
|
||||
"download_url": f"https://example.com/test-ext{suffix}",
|
||||
}
|
||||
|
||||
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
|
||||
patch.object(catalog, "_open_url", return_value=self._mock_response(archive_bytes)):
|
||||
archive_path = catalog.download_extension("test-ext", target_dir=temp_dir)
|
||||
|
||||
assert archive_path.name == "test-ext-1.0.0.tar.gz"
|
||||
assert archive_path.read_bytes() == archive_bytes
|
||||
|
||||
|
||||
|
||||
# ===== CatalogEntry Tests =====
|
||||
@@ -7239,7 +7525,15 @@ class TestExtensionAddCLI:
|
||||
|
||||
manifest_id = "[red]bad[/red]"
|
||||
|
||||
def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False):
|
||||
def fake_install_from_zip(
|
||||
self_obj,
|
||||
zip_path,
|
||||
speckit_version,
|
||||
priority=10,
|
||||
force=False,
|
||||
*,
|
||||
archive_file=None,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
id=manifest_id,
|
||||
name="Bad Extension",
|
||||
@@ -7253,7 +7547,9 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \
|
||||
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
|
||||
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip), \
|
||||
patch.object(ExtensionRegistry, "get", return_value={}):
|
||||
result = runner.invoke(
|
||||
@@ -7301,6 +7597,7 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch(
|
||||
"specify_cli.authentication.http.open_url",
|
||||
side_effect=urllib.error.URLError("bad [red]download[/red]"),
|
||||
@@ -7342,6 +7639,7 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch(
|
||||
"specify_cli.authentication.http.open_url",
|
||||
return_value=FakeResponse(b"<!DOCTYPE html><html>Sign in</html>"),
|
||||
@@ -7392,6 +7690,7 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch(
|
||||
"specify_cli.authentication.http.open_url",
|
||||
return_value=FakeResponse(_MINIMAL_ZIP_BYTES),
|
||||
@@ -7447,7 +7746,15 @@ class TestExtensionAddCLI:
|
||||
seen["headers"] = extra_headers
|
||||
return FakeResponse(_MINIMAL_ZIP_BYTES)
|
||||
|
||||
def fake_install(self_obj, zip_path, speckit_version, priority=10, force=False):
|
||||
def fake_install(
|
||||
self_obj,
|
||||
zip_path,
|
||||
speckit_version,
|
||||
priority=10,
|
||||
force=False,
|
||||
*,
|
||||
archive_file=None,
|
||||
):
|
||||
return SimpleNamespace(
|
||||
id="x", name="X", version="1.0.0", description="", warnings=[], commands=[], hooks=[]
|
||||
)
|
||||
@@ -7455,8 +7762,10 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch("specify_cli.authentication.http.github_provider_hosts", return_value=("ghes.example",)), \
|
||||
patch("specify_cli.authentication.http.open_url", side_effect=fake_open_url), \
|
||||
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
|
||||
patch.object(ExtensionManager, "install_from_zip", fake_install):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -7529,10 +7838,19 @@ class TestExtensionAddCLI:
|
||||
downloads_dir = project_dir / ".specify" / "extensions" / ".cache" / "downloads"
|
||||
installed = {}
|
||||
|
||||
def fake_install_from_zip(self_obj, zip_path, speckit_version, priority=10, force=False):
|
||||
def fake_install_from_zip(
|
||||
self_obj,
|
||||
zip_path,
|
||||
speckit_version,
|
||||
priority=10,
|
||||
force=False,
|
||||
*,
|
||||
archive_file=None,
|
||||
):
|
||||
captured_path = Path(zip_path)
|
||||
installed["zip_path"] = captured_path
|
||||
installed["zip_bytes"] = captured_path.read_bytes()
|
||||
installed["zip_bytes"] = archive_file.read()
|
||||
archive_file.seek(0)
|
||||
return SimpleNamespace(
|
||||
id="escape",
|
||||
name="Escape Test",
|
||||
@@ -7546,7 +7864,9 @@ class TestExtensionAddCLI:
|
||||
runner = CliRunner()
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch("typer.confirm", return_value=True), \
|
||||
patch("specify_cli.extensions._commands._validate_safe_cache_dir", side_effect=_validate_safe_cache_dir_test_stand_in), \
|
||||
patch("specify_cli.authentication.http.open_url", return_value=FakeResponse(_MINIMAL_ZIP_BYTES)), \
|
||||
patch("specify_cli.extensions._commands._safe_open_download_zip", side_effect=_open_test_download_zip), \
|
||||
patch.object(ExtensionManager, "install_from_zip", fake_install_from_zip):
|
||||
result = runner.invoke(
|
||||
app,
|
||||
@@ -7607,7 +7927,7 @@ class TestDownloadExtensionBundled:
|
||||
}
|
||||
|
||||
mock_response = MagicMock()
|
||||
mock_response.read.side_effect = io.BytesIO(b"fake zip data").read
|
||||
mock_response.read.side_effect = io.BytesIO(_MINIMAL_ZIP_BYTES).read
|
||||
mock_response.__enter__ = lambda s: s
|
||||
mock_response.__exit__ = MagicMock(return_value=False)
|
||||
mock_response.geturl.return_value = "https://example.com/catalog.json"
|
||||
|
||||
@@ -379,6 +379,31 @@ class TestResolveGitHubReleaseAssetApiUrl:
|
||||
assert result == "https://api.github.com/repos/org/repo/releases/assets/99"
|
||||
assert captured == ["https://api.github.com/repos/org/repo/releases/tags/v1.0"]
|
||||
|
||||
def test_tag_with_literal_slash_in_path(self):
|
||||
"""A tag containing a literal '/' (e.g. feature/v1.0.0) splits across
|
||||
multiple URL path segments. The implementation must join all segments
|
||||
between 'download/' and the asset name to reconstruct the full tag."""
|
||||
captured_urls = []
|
||||
asset_url = "https://api.github.com/repos/org/repo/releases/assets/77"
|
||||
|
||||
@contextmanager
|
||||
def capturing_open(url, timeout=None, extra_headers=None):
|
||||
captured_urls.append(url)
|
||||
resp = MagicMock()
|
||||
resp.read.side_effect = io.BytesIO(json.dumps({
|
||||
"assets": [{"name": "asset.zip", "url": asset_url}]
|
||||
}).encode()).read
|
||||
yield resp
|
||||
|
||||
result = resolve_github_release_asset_api_url(
|
||||
"https://github.com/org/repo/releases/download/feature/v1.0.0/asset.zip",
|
||||
capturing_open,
|
||||
)
|
||||
assert result == asset_url
|
||||
# Tag must be the full "feature/v1.0.0", not just "v1.0.0"
|
||||
assert len(captured_urls) == 1
|
||||
assert "releases/tags/feature%2Fv1.0.0" in captured_urls[0]
|
||||
|
||||
|
||||
class TestGitHubRedirectAuth:
|
||||
"""Tests for GitHub-owned redirect auth handling."""
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user