mirror of
https://github.com/github/spec-kit.git
synced 2026-08-03 06:26:30 +08:00
Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b46ce37f6c |
6
.github/security-audit-requirements.txt
vendored
6
.github/security-audit-requirements.txt
vendored
@@ -1,6 +1,6 @@
|
||||
annotated-doc==0.0.5 \
|
||||
--hash=sha256:117bac03a25ede5df5440e855b32d556049ca169ead221505badf432fed4b101 \
|
||||
--hash=sha256:c7e58ce09192557605d8bbd92836d7e1d520ac9580096042c0bfd197efacf1bb
|
||||
annotated-doc==0.0.4 \
|
||||
--hash=sha256:571ac1dc6991c450b25a9c2d84a3705e2ae7a53467b5d111c24fa8baabbed320 \
|
||||
--hash=sha256:fbcda96e87e9c92ad167c2e53839e57503ecfda18804ea28102353485033faa4
|
||||
# via typer
|
||||
click==8.4.2 \
|
||||
--hash=sha256:9a6cea6e60b17ebe0a44c5cc636d94f09bd66142c1cd7d8b4cd731c4917a15f6 \
|
||||
|
||||
34
CHANGELOG.md
34
CHANGELOG.md
@@ -2,40 +2,6 @@
|
||||
|
||||
<!-- insert new changelog below this comment -->
|
||||
|
||||
## [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
|
||||
|
||||
@@ -15,11 +15,6 @@
|
||||
<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
|
||||
|
||||
361
README.zh-CN.md
361
README.zh-CN.md
@@ -1,361 +0,0 @@
|
||||
<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) 文件。
|
||||
@@ -7,11 +7,11 @@ The following community-contributed presets customize how Spec Kit behaves — o
|
||||
|
||||
| Preset | Purpose | Provides | Requires | URL |
|
||||
|--------|---------|----------|----------|-----|
|
||||
| A11Y Governance | Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit | 10 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
|
||||
| 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) |
|
||||
| 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) |
|
||||
| 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 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) |
|
||||
| Autonomous Run Governance | Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation. | 13 templates, 5 commands, 4 scripts | — | [spec-kit-preset-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-autonomous-run-governance) |
|
||||
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
|
||||
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
|
||||
| Command Density | Compacts the nine core Spec Kit command prompts while preserving scripts, handoffs, placeholders, hook output blocks, and rule structure | 9 commands | — | [spec-kit-preset-command-density](https://github.com/Xopoko/spec-kit-preset-command-density) |
|
||||
@@ -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, 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) |
|
||||
| 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) |
|
||||
| iSAQB Architecture Governance | Adds iSAQB/CPSA-F and arc42 architecture governance with audit-ready evidence for goals, views, resumability, partial-failure scenarios, ADRs, risks, and technical debt. | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
|
||||
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
|
||||
| Model Driven Engineering | Focuses on streamlined commands, app repository support, cross-spec support, and capability-aware project memory for model-driven engineering workflows | 6 templates, 11 commands | MDE extension | [spec-kit-preset-mde](https://github.com/AI-MDE/spec-kit-preset-mde) |
|
||||
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
|
||||
| Parallel Autonomous Run Governance | Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.2.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
|
||||
| Parallel Autonomous Run Governance | Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling. | 9 templates, 5 commands, 2 scripts | autonomous-run-governance >=0.3.2; optional: intake-review-governance >=0.1.0 | [spec-kit-preset-parallel-autonomous-run-governance](https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance) |
|
||||
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
|
||||
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
|
||||
| Security Governance | Adds memory-safe-language and secure-coding governance, exact-head and security-gate evidence, provider-failure classification, ASVS, supply-chain transparency, and EU regulatory screening. | 14 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"schema_version": "1.0",
|
||||
"updated_at": "2026-07-28T00:00:00Z",
|
||||
"updated_at": "2026-07-27T00:00:00Z",
|
||||
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
|
||||
"extensions": {
|
||||
"aide": {
|
||||
@@ -4820,9 +4820,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.2",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.4.2.zip",
|
||||
"sha256": "71dceef5bf81d7ac54faa26bb5cf279554815a4928ee8d0c8e9bfb4c3e2bb0ab",
|
||||
"version": "0.4.1",
|
||||
"download_url": "https://github.com/cadugevaerd/spec-kit-verify-review-ship/archive/refs/tags/v0.4.1.zip",
|
||||
"sha256": "cfa89b405fcf4857745653e923dfab92f101fbdda15e1e8757ad9f2ea55ae5e2",
|
||||
"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 +4851,7 @@
|
||||
"downloads": 0,
|
||||
"stars": 0,
|
||||
"created_at": "2026-07-10T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
"updated_at": "2026-07-27T00:00:00Z"
|
||||
},
|
||||
"verify-tasks": {
|
||||
"name": "Verify Tasks Extension",
|
||||
|
||||
@@ -7,13 +7,13 @@
|
||||
"a11y-governance": {
|
||||
"name": "A11Y Governance",
|
||||
"id": "a11y-governance",
|
||||
"version": "0.4.2",
|
||||
"description": "Adds accessibility (WCAG 2.2 AA), accessible text and JSON status parity, bilingual DE/EN delivery, CEFR-B2 readability, inclusive-content governance, didactic inline-code-comment review, and audit-ready Spec-Kit run evidence to Spec Kit.",
|
||||
"version": "0.4.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.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.4.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/v0.4.1/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-28T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00:00:00Z"
|
||||
},
|
||||
"agent-parity-governance": {
|
||||
"name": "Agent Parity Governance",
|
||||
"id": "agent-parity-governance",
|
||||
"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.",
|
||||
"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.",
|
||||
"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.1.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/archive/refs/tags/v0.4.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.1/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-agent-parity-governance/blob/v0.4.0/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-28T00:00:00Z"
|
||||
"updated_at": "2026-07-23T00: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.3",
|
||||
"description": "Adds permission-bounded autonomous delivery, an optional intake-review gate, and preservation of the project's learner and accessibility contract.",
|
||||
"version": "0.3.2",
|
||||
"description": "Adds permission-bounded, evidence-first governance for complete autonomous Spec Kit delivery, including validated status, stop, explicit resume, exact-head proof, post-merge closeout, retrospective learning, and an optional policy-driven intake-review gate before feature creation.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.3.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/archive/refs/tags/v0.3.2.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.3/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-autonomous-run-governance/blob/v0.3.2/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
@@ -156,10 +156,11 @@
|
||||
"governance",
|
||||
"evidence",
|
||||
"permissions",
|
||||
"accessibility"
|
||||
"resume",
|
||||
"intake-review"
|
||||
],
|
||||
"created_at": "2026-07-13T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
"updated_at": "2026-07-21T00:00:00Z"
|
||||
},
|
||||
"canon-core": {
|
||||
"name": "Canon Core",
|
||||
@@ -396,13 +397,13 @@
|
||||
"intake-review-governance": {
|
||||
"name": "Intake Review Governance",
|
||||
"id": "intake-review-governance",
|
||||
"version": "0.2.0",
|
||||
"description": "Reviews single, series, campaign, and language-aware requirements collections before Spec Kit execution.",
|
||||
"version": "0.1.1",
|
||||
"description": "Reviews single, series, and campaign intake files before Spec Kit execution and binds accepted outcomes to normalized content hashes.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.2.0.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/archive/refs/tags/v0.1.1.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-review-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.2.0/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-review-governance/blob/v0.1.1/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
@@ -410,36 +411,36 @@
|
||||
"provides": {
|
||||
"templates": 8,
|
||||
"commands": 3,
|
||||
"scripts": 4
|
||||
"scripts": 2
|
||||
},
|
||||
"tags": [
|
||||
"intake",
|
||||
"review",
|
||||
"governance",
|
||||
"requirements",
|
||||
"quality-gate"
|
||||
"quality-gate",
|
||||
"autonomous"
|
||||
],
|
||||
"created_at": "2026-07-21T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
"updated_at": "2026-07-24T00:00:00Z"
|
||||
},
|
||||
"intake-sequencing-governance": {
|
||||
"name": "Intake Sequencing Governance",
|
||||
"id": "intake-sequencing-governance",
|
||||
"version": "0.2.2",
|
||||
"description": "Manages language-aware intake-series order, typed dependencies, lifecycle, and authority-neutral next-candidate selection.",
|
||||
"version": "0.1.0",
|
||||
"description": "Manages traceable intake-series order, typed dependencies, lifecycle, and safe next-candidate selection without executing downstream workflows.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.2.2.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/archive/refs/tags/v0.1.0.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.2.2/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-intake-sequencing-governance/blob/v0.1.0/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 11,
|
||||
"templates": 10,
|
||||
"commands": 6,
|
||||
"scripts": 8
|
||||
"scripts": 5
|
||||
},
|
||||
"tags": [
|
||||
"intake",
|
||||
@@ -449,7 +450,7 @@
|
||||
"lifecycle"
|
||||
],
|
||||
"created_at": "2026-07-27T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
"updated_at": "2026-07-27T00:00:00Z"
|
||||
},
|
||||
"isaqb-architecture-governance": {
|
||||
"name": "iSAQB Architecture Governance",
|
||||
@@ -571,16 +572,16 @@
|
||||
"parallel-autonomous-run-governance": {
|
||||
"name": "Parallel Autonomous Run Governance",
|
||||
"id": "parallel-autonomous-run-governance",
|
||||
"version": "0.2.4",
|
||||
"description": "Coordinates permission-bounded autonomous campaigns while preserving the project's learner and accessibility contract across workers and consolidation.",
|
||||
"version": "0.2.3",
|
||||
"description": "Coordinates isolated autonomous Spec Kit campaigns with bounded concurrency, mixed agents, resumable consolidation, governed post-merge closeout, schema 1.2, and an optional current intake-review gate before worker scheduling.",
|
||||
"author": "Thorsten Hindermann",
|
||||
"repository": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.4.zip",
|
||||
"download_url": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/archive/refs/tags/v0.2.3.zip",
|
||||
"homepage": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.4/README.md",
|
||||
"documentation": "https://github.com/hindermath/spec-kit-preset-parallel-autonomous-run-governance/blob/v0.2.3/README.md",
|
||||
"license": "MIT",
|
||||
"requires": {
|
||||
"speckit_version": ">=0.8.3"
|
||||
"speckit_version": ">=0.8.3"
|
||||
},
|
||||
"provides": {
|
||||
"templates": 9,
|
||||
@@ -591,11 +592,12 @@
|
||||
"parallel",
|
||||
"autonomous",
|
||||
"governance",
|
||||
"accessibility",
|
||||
"orchestration"
|
||||
"orchestration",
|
||||
"resume",
|
||||
"intake-review"
|
||||
],
|
||||
"created_at": "2026-07-22T00:00:00Z",
|
||||
"updated_at": "2026-07-28T00:00:00Z"
|
||||
"updated_at": "2026-07-22T00:00:00Z"
|
||||
},
|
||||
"pirate": {
|
||||
"name": "Pirate Speak (Full)",
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[project]
|
||||
name = "specify-cli"
|
||||
version = "0.14.4"
|
||||
version = "0.14.3"
|
||||
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
|
||||
readme = "README.md"
|
||||
requires-python = ">=3.11"
|
||||
|
||||
@@ -114,7 +114,6 @@ 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."""
|
||||
@@ -125,7 +124,6 @@ def _refresh_shared_templates(
|
||||
repo_root=_repo_root(),
|
||||
console=console,
|
||||
invoke_separator=invoke_separator,
|
||||
invoke_prefix=invoke_prefix,
|
||||
force=force,
|
||||
)
|
||||
|
||||
@@ -136,7 +134,6 @@ 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:
|
||||
@@ -180,7 +177,6 @@ def _install_shared_infra(
|
||||
console=console,
|
||||
force=force,
|
||||
invoke_separator=invoke_separator,
|
||||
invoke_prefix=invoke_prefix,
|
||||
refresh_managed=refresh_managed,
|
||||
refresh_hint=refresh_hint,
|
||||
)
|
||||
@@ -192,7 +188,6 @@ 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:
|
||||
@@ -203,7 +198,6 @@ 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,
|
||||
)
|
||||
|
||||
@@ -159,9 +159,8 @@ def resolve_github_release_asset_api_url(
|
||||
if len(parts) < 6 or parts[2:4] != ["releases", "download"]:
|
||||
return None
|
||||
|
||||
owner, repo = parts[0], parts[1]
|
||||
tag = "/".join(parts[4:-1])
|
||||
asset_name = parts[-1]
|
||||
owner, repo, tag = parts[0], parts[1], parts[4]
|
||||
asset_name = "/".join(parts[5:])
|
||||
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", "droid", "grok", "trae", "zed"})
|
||||
ALWAYS_SLASH_AGENTS: frozenset[str] = frozenset({"devin", "grok", "trae", "zed"})
|
||||
|
||||
# Agents that render /speckit-<name> only when ai_skills is enabled.
|
||||
CONDITIONAL_SLASH_AGENTS: frozenset[str] = frozenset(
|
||||
@@ -29,9 +29,6 @@ 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.
|
||||
@@ -44,21 +41,6 @@ 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.
|
||||
|
||||
|
||||
@@ -15,7 +15,6 @@ 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
|
||||
@@ -599,7 +598,7 @@ class CommandRegistrar:
|
||||
source_dir: Path,
|
||||
project_root: Path,
|
||||
context_note: Optional[str] = None,
|
||||
_resolved_dir: Optional[Path] = None,
|
||||
_resolved_dir: Path = None,
|
||||
link_outputs: bool = False,
|
||||
extension_id: Optional[str] = None,
|
||||
) -> List[str]:
|
||||
@@ -660,16 +659,17 @@ 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 +772,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, _prefix)
|
||||
body = IntegrationBase.resolve_command_refs(body, _sep)
|
||||
|
||||
output_name = self._compute_output_name(agent_name, cmd_name, agent_config)
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json as _json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
from typing import TYPE_CHECKING
|
||||
|
||||
@@ -72,27 +71,9 @@ 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,12 +58,7 @@ def load_yaml(path: Path) -> Any:
|
||||
raise BundlerError(f"File not found: {path}")
|
||||
try:
|
||||
text = path.read_text(encoding="utf-8")
|
||||
except (OSError, UnicodeError) as exc:
|
||||
# A non-UTF-8 file raises UnicodeDecodeError, which is a ValueError --
|
||||
# NOT an OSError -- so it escaped this module's "IO failures degrade
|
||||
# into actionable BundlerError" contract as a raw traceback. Realistic
|
||||
# on Windows, where PowerShell 5.1's `Out-File`/`>` default to UTF-16.
|
||||
# Matches the sibling catalog readers (catalogs.py, workflows/catalog.py).
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not read {path}: {exc}") from exc
|
||||
try:
|
||||
has_node = yaml.compose(text) is not None
|
||||
@@ -103,15 +98,9 @@ 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, 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.
|
||||
except OSError as exc:
|
||||
raise BundlerError(f"Could not read {path}: {exc}") from exc
|
||||
|
||||
|
||||
|
||||
@@ -12,7 +12,6 @@ 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
|
||||
@@ -186,16 +185,11 @@ def bundle_search(
|
||||
else ""
|
||||
)
|
||||
console.print(
|
||||
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]"
|
||||
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}"
|
||||
)
|
||||
console.print(f" {r.entry.description}")
|
||||
console.print(f" [dim]source: {r.source.id}[/dim]")
|
||||
|
||||
|
||||
@bundle_app.command("info")
|
||||
@@ -248,31 +242,16 @@ def bundle_info(
|
||||
print(_json.dumps(payload, indent=2))
|
||||
return
|
||||
|
||||
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"\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" Trust: {_trust_badge(entry.verified)}")
|
||||
if entry.requires_speckit_version:
|
||||
console.print(
|
||||
f" Requires Spec Kit: "
|
||||
f"{_escape_markup(str(entry.requires_speckit_version))}"
|
||||
)
|
||||
console.print(f" Requires Spec Kit: {entry.requires_speckit_version}")
|
||||
if manifest and manifest.integration:
|
||||
console.print(
|
||||
f" Integration: {_escape_markup(str(manifest.integration.id))}"
|
||||
)
|
||||
console.print(f" Integration: {manifest.integration.id}")
|
||||
|
||||
if components:
|
||||
console.print("\n [bold]Components[/bold] (added on install):")
|
||||
@@ -282,22 +261,18 @@ def bundle_info(
|
||||
continue
|
||||
console.print(f" [bold]{kind}:[/bold]")
|
||||
for item in items:
|
||||
console.print(
|
||||
f" - {_escape_markup(_format_component(item))}"
|
||||
)
|
||||
console.print(f" - {_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}: {_escape_markup(str(count))}")
|
||||
console.print(f" {kind}: {count}")
|
||||
|
||||
if overlaps:
|
||||
console.print("\n [yellow]Overlaps with already-installed bundles:[/yellow]")
|
||||
for overlap in overlaps:
|
||||
console.print(
|
||||
f" [yellow]-[/yellow] {_escape_markup(str(overlap))}"
|
||||
)
|
||||
console.print(f" [yellow]-[/yellow] {overlap}")
|
||||
|
||||
if not resolved.install_allowed:
|
||||
console.print(
|
||||
|
||||
@@ -183,7 +183,6 @@ 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 (
|
||||
@@ -482,12 +481,6 @@ 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"
|
||||
|
||||
@@ -801,9 +801,8 @@ def extension_search(
|
||||
|
||||
# Metadata
|
||||
console.print(f"\n [dim]Author:[/dim] {_escape_markup(str(ext.get('author', 'Unknown')))}")
|
||||
ext_tags = ext.get('tags', [])
|
||||
if isinstance(ext_tags, list) and ext_tags:
|
||||
tags_str = ", ".join(str(t) for t in ext_tags)
|
||||
if ext.get('tags'):
|
||||
tags_str = ", ".join(str(t) for t in ext['tags'])
|
||||
console.print(f" [dim]Tags:[/dim] {_escape_markup(tags_str)}")
|
||||
|
||||
# Source catalog
|
||||
@@ -1026,9 +1025,8 @@ def _print_extension_info(ext_info: dict, manager):
|
||||
console.print()
|
||||
|
||||
# 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)
|
||||
if ext_info.get('tags'):
|
||||
tags_str = ", ".join(str(t) for t in ext_info['tags'])
|
||||
console.print(f"[bold]Tags:[/bold] {_escape_markup(tags_str)}")
|
||||
console.print()
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ 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
|
||||
|
||||
|
||||
@@ -100,14 +99,3 @@ 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,7 +11,6 @@ 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,
|
||||
@@ -334,9 +333,6 @@ 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,
|
||||
|
||||
@@ -8,7 +8,6 @@ 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,
|
||||
)
|
||||
@@ -131,9 +130,6 @@ 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
|
||||
|
||||
@@ -9,7 +9,6 @@ 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,
|
||||
)
|
||||
@@ -448,9 +447,6 @@ 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]."
|
||||
@@ -751,9 +747,6 @@ 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
|
||||
@@ -789,9 +782,6 @@ 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,
|
||||
)
|
||||
|
||||
@@ -318,26 +318,22 @@ 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_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", "?")))
|
||||
iid = integ.get("id", "?")
|
||||
name = integ.get("name", iid)
|
||||
version = integ.get("version", "?")
|
||||
console.print(f"[bold]{name}[/bold] ({iid}) v{version}")
|
||||
desc = integ.get("description", "")
|
||||
if desc:
|
||||
console.print(f" {_rich_escape(str(desc))}")
|
||||
console.print(f" {desc}")
|
||||
|
||||
author_value = _rich_escape(str(integ.get("author", "Unknown")))
|
||||
console.print(f"\n [dim]Author:[/dim] {author_value}")
|
||||
console.print(f"\n [dim]Author:[/dim] {integ.get('author', 'Unknown')}")
|
||||
tags = integ.get("tags", [])
|
||||
if isinstance(tags, list) and tags:
|
||||
safe_tags = _rich_escape(", ".join(str(t) for t in tags))
|
||||
console.print(f" [dim]Tags:[/dim] {safe_tags}")
|
||||
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
|
||||
|
||||
cat_name_value = integ.get("_catalog_name", "")
|
||||
cat_name = _rich_escape(str(cat_name_value))
|
||||
cat_name = integ.get("_catalog_name", "")
|
||||
install_allowed = integ.get("_install_allowed", True)
|
||||
if cat_name_value:
|
||||
if cat_name:
|
||||
if install_allowed:
|
||||
console.print(f" [dim]Catalog:[/dim] {cat_name}")
|
||||
else:
|
||||
@@ -346,9 +342,9 @@ def integration_search(
|
||||
"[yellow](discovery only — not installable)[/yellow]"
|
||||
)
|
||||
|
||||
if iid_value == installed_key:
|
||||
if iid == installed_key:
|
||||
console.print("\n [green]✓ Installed[/green] (currently active)")
|
||||
elif iid_value in INTEGRATION_REGISTRY:
|
||||
elif iid in INTEGRATION_REGISTRY:
|
||||
console.print(f"\n [cyan]Install:[/cyan] specify integration install {iid}")
|
||||
elif install_allowed:
|
||||
console.print(
|
||||
@@ -378,7 +374,6 @@ 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)
|
||||
@@ -391,38 +386,29 @@ def integration_info(
|
||||
catalog_error = None
|
||||
|
||||
if info:
|
||||
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}"
|
||||
)
|
||||
name = info.get("name", integration_id)
|
||||
version = info.get("version", "?")
|
||||
console.print(f"\n[bold cyan]{name}[/bold cyan] ({integration_id}) v{version}")
|
||||
if info.get("description"):
|
||||
console.print(f" {_rich_escape(str(info['description']))}")
|
||||
console.print(f" {info['description']}")
|
||||
console.print()
|
||||
|
||||
author_value = _rich_escape(str(info.get("author", "Unknown")))
|
||||
console.print(f" [dim]Author:[/dim] {author_value}")
|
||||
console.print(f" [dim]Author:[/dim] {info.get('author', 'Unknown')}")
|
||||
if info.get("license"):
|
||||
console.print(
|
||||
f" [dim]License:[/dim] {_rich_escape(str(info['license']))}"
|
||||
)
|
||||
console.print(f" [dim]License:[/dim] {info['license']}")
|
||||
|
||||
tags = info.get("tags", [])
|
||||
if isinstance(tags, list) and tags:
|
||||
safe_tags = _rich_escape(", ".join(str(t) for t in tags))
|
||||
console.print(f" [dim]Tags:[/dim] {safe_tags}")
|
||||
console.print(f" [dim]Tags:[/dim] {', '.join(str(t) for t in tags)}")
|
||||
|
||||
cat_name_value = info.get("_catalog_name", "")
|
||||
cat_name = _rich_escape(str(cat_name_value))
|
||||
cat_name = info.get("_catalog_name", "")
|
||||
install_allowed = info.get("_install_allowed", True)
|
||||
if cat_name_value:
|
||||
if cat_name:
|
||||
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] {_rich_escape(str(info['repository']))}"
|
||||
)
|
||||
console.print(f" [dim]Repository:[/dim] {info['repository']}")
|
||||
|
||||
if integration_id == installed_key:
|
||||
console.print("\n [green]✓ Installed[/green] (currently active)")
|
||||
@@ -458,7 +444,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 '{safe_integration_id}' not found")
|
||||
console.print(f"[red]Error:[/red] Integration '{integration_id}' not found")
|
||||
console.print("\nTry: specify integration search")
|
||||
raise typer.Exit(1)
|
||||
|
||||
|
||||
@@ -27,7 +27,6 @@ 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
|
||||
|
||||
@@ -35,7 +34,7 @@ if TYPE_CHECKING:
|
||||
from .manifest import IntegrationManifest
|
||||
|
||||
_HOOK_COMMAND_NOTE = (
|
||||
"- When constructing command invocations from hook command names, "
|
||||
"- When constructing slash commands from hook command names, "
|
||||
"replace dots (`.`) with hyphens (`-`). "
|
||||
"For example, `speckit.git.commit` → `/speckit-git-commit`.\n"
|
||||
)
|
||||
@@ -602,9 +601,7 @@ class IntegrationBase(ABC):
|
||||
return created
|
||||
|
||||
@staticmethod
|
||||
def resolve_command_refs(
|
||||
content: str, separator: str = ".", prefix: str = "/"
|
||||
) -> str:
|
||||
def resolve_command_refs(content: str, separator: str = ".") -> str:
|
||||
"""Replace ``__SPECKIT_COMMAND_<NAME>__`` placeholders with invocations.
|
||||
|
||||
Each placeholder encodes a command name in upper-case with
|
||||
@@ -614,16 +611,10 @@ 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: prefix
|
||||
+ "speckit"
|
||||
+ separator
|
||||
+ m.group(1).lower().replace("_", separator),
|
||||
lambda m: "/speckit" + separator + m.group(1).lower().replace("_", separator),
|
||||
content,
|
||||
)
|
||||
|
||||
@@ -847,12 +838,7 @@ class IntegrationBase(ABC):
|
||||
content = CommandRegistrar.rewrite_project_relative_paths(content)
|
||||
|
||||
# 8. Replace __SPECKIT_COMMAND_<NAME>__ with invocation strings
|
||||
invocation_prefix = get_invocation_prefix(
|
||||
agent_name, invoke_separator == "-"
|
||||
)
|
||||
content = IntegrationBase.resolve_command_refs(
|
||||
content, invoke_separator, invocation_prefix
|
||||
)
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
|
||||
return content
|
||||
|
||||
@@ -1534,21 +1520,18 @@ class SkillsIntegration(IntegrationBase):
|
||||
return project_root / folder / subdir
|
||||
|
||||
def build_command_invocation(self, command_name: str, args: str = "") -> str:
|
||||
"""Build the agent's native invocation for a hyphenated skill name."""
|
||||
"""Skills use ``/speckit-<stem>`` (hyphenated directory name)."""
|
||||
stem = command_name
|
||||
if stem.startswith("speckit."):
|
||||
stem = stem[len("speckit."):]
|
||||
|
||||
prefix = "$" if is_dollar_skills_agent(self.key, True) else "/"
|
||||
invocation = prefix + "speckit-" + stem.replace(".", "-")
|
||||
invocation = "/speckit-" + stem.replace(".", "-")
|
||||
if args:
|
||||
invocation = f"{invocation} {args}"
|
||||
return invocation
|
||||
|
||||
@staticmethod
|
||||
def _inject_hook_command_note(
|
||||
content: str, invocation_prefix: str = "/"
|
||||
) -> str:
|
||||
def _inject_hook_command_note(content: str) -> str:
|
||||
"""Insert a dot-to-hyphen note before each hook output instruction.
|
||||
|
||||
Targets the line ``- For each executable hook, output the following``
|
||||
@@ -1557,11 +1540,6 @@ 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)
|
||||
@@ -1595,13 +1573,10 @@ 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 the agent-native
|
||||
hyphenated command invocation (e.g. ``/speckit-git-commit`` or
|
||||
``$speckit-git-commit``). Subclasses may override -- see
|
||||
``ClaudeIntegration``.
|
||||
guidance for converting dotted hook command names to hyphenated
|
||||
slash commands. Subclasses may override — see ``ClaudeIntegration``.
|
||||
"""
|
||||
invocation_prefix = get_invocation_prefix(self.key, True)
|
||||
return self._inject_hook_command_note(content, invocation_prefix)
|
||||
return self._inject_hook_command_note(content)
|
||||
|
||||
def setup(
|
||||
self,
|
||||
@@ -1652,27 +1627,13 @@ class SkillsIntegration(IntegrationBase):
|
||||
command_name = src_file.stem # e.g. "plan"
|
||||
skill_name = f"speckit-{command_name.replace('.', '-')}"
|
||||
|
||||
# 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.
|
||||
# Parse frontmatter for description
|
||||
frontmatter: dict[str, Any] = {}
|
||||
if raw.startswith("---"):
|
||||
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:
|
||||
parts = raw.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
try:
|
||||
fm = yaml.safe_load("".join(fm_lines[1:fm_close]))
|
||||
fm = yaml.safe_load(parts[1])
|
||||
if isinstance(fm, dict):
|
||||
frontmatter = fm
|
||||
except yaml.YAMLError:
|
||||
@@ -1687,27 +1648,11 @@ 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 ---). 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.
|
||||
# the closing ---).
|
||||
if processed_body.startswith("---"):
|
||||
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 :]
|
||||
)
|
||||
parts = processed_body.split("---", 2)
|
||||
if len(parts) >= 3:
|
||||
processed_body = parts[2]
|
||||
|
||||
# Select description — use the original template description
|
||||
# to stay byte-for-byte identical with release ZIP output.
|
||||
|
||||
@@ -21,7 +21,6 @@ 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
|
||||
|
||||
|
||||
@@ -201,14 +200,7 @@ class IntegrationCatalog(CatalogStackBase):
|
||||
final_url = resp.geturl()
|
||||
if final_url != entry.url:
|
||||
self._validate_catalog_url(final_url)
|
||||
catalog_data = json.loads(
|
||||
read_response_limited(
|
||||
resp,
|
||||
max_bytes=MAX_JSON_METADATA_BYTES,
|
||||
error_type=IntegrationCatalogError,
|
||||
label=f"catalog from {entry.url}",
|
||||
)
|
||||
)
|
||||
catalog_data = json.loads(resp.read())
|
||||
|
||||
shape_error = _catalog_shape_error(catalog_data)
|
||||
if shape_error is not None:
|
||||
|
||||
@@ -53,16 +53,8 @@ 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 and (not isinstance(commands_dir, str) or commands_dir.strip()):
|
||||
if commands_dir:
|
||||
return commands_dir
|
||||
|
||||
# Fall back to raw_options (--integration-options="--commands-dir ...")
|
||||
@@ -72,13 +64,9 @@ class GenericIntegration(MarkdownIntegration):
|
||||
tokens = shlex.split(raw)
|
||||
for i, token in enumerate(tokens):
|
||||
if token == "--commands-dir" and i + 1 < len(tokens):
|
||||
candidate = tokens[i + 1]
|
||||
if candidate.strip():
|
||||
return candidate
|
||||
return tokens[i + 1]
|
||||
if token.startswith("--commands-dir="):
|
||||
candidate = token.split("=", 1)[1]
|
||||
if candidate.strip():
|
||||
return candidate
|
||||
return token.split("=", 1)[1]
|
||||
|
||||
raise ValueError(
|
||||
"--commands-dir is required for the generic integration"
|
||||
|
||||
@@ -59,7 +59,8 @@ class KimiIntegration(SkillsIntegration):
|
||||
|
||||
def post_process_skill_content(self, content: str) -> str:
|
||||
"""Ensure in-skill cross-command references use Kimi's `/skill:` syntax."""
|
||||
return super().post_process_skill_content(content)
|
||||
content = super().post_process_skill_content(content)
|
||||
return content.replace("/speckit-", "/skill:speckit-")
|
||||
|
||||
@classmethod
|
||||
def options(cls) -> list[IntegrationOption]:
|
||||
|
||||
@@ -40,7 +40,6 @@ 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 (
|
||||
@@ -322,37 +321,13 @@ class PresetManifest:
|
||||
|
||||
# Validate provides section
|
||||
provides = self.data["provides"]
|
||||
if "templates" not in provides:
|
||||
if "templates" not in provides or not provides["templates"]:
|
||||
raise PresetValidationError(
|
||||
"Preset must provide at least one template"
|
||||
)
|
||||
|
||||
# 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"
|
||||
)
|
||||
# Validate templates
|
||||
for tmpl in provides["templates"]:
|
||||
if "type" not in tmpl or "name" not in tmpl or "file" not in tmpl:
|
||||
raise PresetValidationError(
|
||||
"Template missing 'type', 'name', or 'file'"
|
||||
@@ -2449,15 +2424,13 @@ class PresetManager:
|
||||
|
||||
Looks up the agent's invoke separator and rewrites each
|
||||
``__SPECKIT_COMMAND_<NAME>__`` placeholder into the matching
|
||||
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()``.
|
||||
slash-command invocation — ``/speckit-<cmd>`` for a ``-`` separator,
|
||||
``/speckit.<cmd>`` for ``.`` — 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.
|
||||
"""
|
||||
@@ -2478,8 +2451,7 @@ class PresetManager:
|
||||
separator = registrar.AGENT_CONFIGS.get(selected_ai, {}).get(
|
||||
"invoke_separator", "."
|
||||
)
|
||||
prefix = get_invocation_prefix(selected_ai, separator == "-")
|
||||
return IntegrationBase.resolve_command_refs(body, separator, prefix)
|
||||
return IntegrationBase.resolve_command_refs(body, separator)
|
||||
|
||||
def _build_extension_skill_restore_index(self) -> Dict[str, Dict[str, Any]]:
|
||||
"""Index extension-backed skill restore data by skill directory name."""
|
||||
@@ -4537,34 +4509,23 @@ class PresetCatalog:
|
||||
results = []
|
||||
|
||||
for pack_id, pack_data in packs.items():
|
||||
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 author and pack_data.get("author", "").lower() != author.lower():
|
||||
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 tag and tag.lower() not in [
|
||||
str(t).lower() for t in pack_data.get("tags", [])
|
||||
]:
|
||||
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(
|
||||
[
|
||||
str(name_val) if name_val is not None else "",
|
||||
str(desc_val) if desc_val is not None else "",
|
||||
pack_data.get("name", ""),
|
||||
pack_data.get("description", ""),
|
||||
pack_id,
|
||||
]
|
||||
+ [str(t) for t in tags_list]
|
||||
+ [str(t) for t in pack_data.get("tags", [])]
|
||||
).lower()
|
||||
|
||||
if query_lower not in searchable_text:
|
||||
|
||||
@@ -61,9 +61,8 @@ def preset_list():
|
||||
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']}")
|
||||
tags = pack.get("tags", [])
|
||||
if isinstance(tags, list) and tags:
|
||||
tags_str = _escape_markup(", ".join(str(t) for t in tags))
|
||||
if pack.get("tags"):
|
||||
tags_str = _escape_markup(", ".join(str(t) for t in pack["tags"]))
|
||||
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||
console.print(f" [dim]Templates: {pack['template_count']}[/dim]")
|
||||
console.print()
|
||||
@@ -292,16 +291,10 @@ def preset_search(
|
||||
|
||||
console.print(f"\n[bold cyan]Presets ({len(results)} found):[/bold cyan]\n")
|
||||
for pack in results:
|
||||
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" [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"])
|
||||
console.print(f" [dim]Tags: {tags_str}[/dim]")
|
||||
console.print()
|
||||
|
||||
@@ -380,7 +373,6 @@ 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)
|
||||
@@ -392,9 +384,8 @@ def preset_info(
|
||||
console.print(f" Description: {local_pack.description}")
|
||||
if local_pack.author:
|
||||
console.print(f" Author: {local_pack.author}")
|
||||
local_tags = local_pack.tags
|
||||
if isinstance(local_tags, list) and local_tags:
|
||||
console.print(f" Tags: {', '.join(str(t) for t in local_tags)}")
|
||||
if local_pack.tags:
|
||||
console.print(f" Tags: {', '.join(str(t) for t in local_pack.tags)}")
|
||||
console.print(f" Templates: {len(local_pack.templates)}")
|
||||
for tmpl in local_pack.templates:
|
||||
console.print(f" - {tmpl['name']} ({tmpl['type']}): {tmpl.get('description', '')}")
|
||||
@@ -423,32 +414,20 @@ def preset_info(
|
||||
console.print(f"[red]Error:[/red] Preset '{preset_id}' not found (not installed and not in catalog)")
|
||||
raise typer.Exit(1)
|
||||
|
||||
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', '')))}"
|
||||
)
|
||||
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', '')}")
|
||||
if pack_info.get("author"):
|
||||
console.print(
|
||||
f" Author: {_escape_markup(str(pack_info['author']))}"
|
||||
)
|
||||
catalog_tags = pack_info.get("tags", [])
|
||||
if isinstance(catalog_tags, list) and catalog_tags:
|
||||
console.print(f" Tags: {', '.join(str(t) for t in catalog_tags)}")
|
||||
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'])}")
|
||||
if pack_info.get("repository"):
|
||||
console.print(
|
||||
f" Repository: {_escape_markup(str(pack_info['repository']))}"
|
||||
)
|
||||
console.print(f" Repository: {pack_info['repository']}")
|
||||
if pack_info.get("license"):
|
||||
console.print(
|
||||
f" License: {_escape_markup(str(pack_info['license']))}"
|
||||
)
|
||||
console.print(f" License: {pack_info['license']}")
|
||||
console.print("\n [yellow]Status: not installed[/yellow]")
|
||||
console.print(f" Install with: [cyan]specify preset add {safe_preset_id}[/cyan]")
|
||||
console.print(f" Install with: [cyan]specify preset add {preset_id}[/cyan]")
|
||||
console.print()
|
||||
|
||||
|
||||
|
||||
@@ -272,56 +272,27 @@ _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, prefix: str = "/"
|
||||
) -> str:
|
||||
def _format_speckit_command(command_name: str, separator: 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"{prefix}speckit{separator}{name}"
|
||||
return f"/speckit{separator}{name}"
|
||||
|
||||
|
||||
def _resolve_dynamic_command_refs(
|
||||
content: str, separator: str, prefix: str = "/"
|
||||
) -> str:
|
||||
def _resolve_dynamic_command_refs(content: str, separator: 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, bash_prefix
|
||||
),
|
||||
lambda match: _format_speckit_command(match.group(2), separator),
|
||||
content,
|
||||
)
|
||||
content = _POWERSHELL_FORMAT_COMMAND_RE.sub(
|
||||
lambda match: f"'{_format_speckit_command(match.group(2), separator, prefix)}'",
|
||||
content,
|
||||
)
|
||||
content = _BASH_FORMATTER_RETURN_RE.sub(
|
||||
f'''printf '{prefix}speckit%s%s\\\\n' "$separator" "$command_name"''',
|
||||
content,
|
||||
)
|
||||
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}}"',
|
||||
return _POWERSHELL_FORMAT_COMMAND_RE.sub(
|
||||
lambda match: f"'{_format_speckit_command(match.group(2), separator)}'",
|
||||
content,
|
||||
)
|
||||
|
||||
@@ -334,7 +305,6 @@ 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."""
|
||||
@@ -366,9 +336,7 @@ def refresh_shared_templates(
|
||||
continue
|
||||
|
||||
content = src.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(
|
||||
content, invoke_separator, invoke_prefix
|
||||
)
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
planned_updates.append((dst, rel, content))
|
||||
|
||||
for dst, rel, content in planned_updates:
|
||||
@@ -395,7 +363,6 @@ 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:
|
||||
@@ -549,12 +516,8 @@ 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, invoke_prefix
|
||||
)
|
||||
content = _resolve_dynamic_command_refs(
|
||||
content, invoke_separator, invoke_prefix
|
||||
)
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
content = _resolve_dynamic_command_refs(content, invoke_separator)
|
||||
planned_copies.append(
|
||||
(
|
||||
dst_path,
|
||||
@@ -603,9 +566,7 @@ def install_shared_infra(
|
||||
continue
|
||||
|
||||
content = src.read_text(encoding="utf-8")
|
||||
content = IntegrationBase.resolve_command_refs(
|
||||
content, invoke_separator, invoke_prefix
|
||||
)
|
||||
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
|
||||
planned_templates.append((dst, rel, content))
|
||||
|
||||
for dst_path, rel, content, mode in planned_copies:
|
||||
|
||||
@@ -1054,18 +1054,7 @@ def workflow_run(
|
||||
load_custom_steps(project_root)
|
||||
engine = WorkflowEngine(project_root)
|
||||
if not json_output:
|
||||
# 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"
|
||||
)
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
|
||||
err = _error_console(json_output)
|
||||
|
||||
@@ -1187,18 +1176,7 @@ def workflow_resume(
|
||||
load_custom_steps(project_root)
|
||||
engine = WorkflowEngine(project_root)
|
||||
if not json_output:
|
||||
# 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"
|
||||
)
|
||||
engine.on_step_start = lambda sid, label: console.print(f" \u25b8 [{sid}] {label} \u2026")
|
||||
|
||||
inputs = _parse_input_values(input_values, json_output=json_output)
|
||||
err = _error_console(json_output)
|
||||
@@ -2348,7 +2326,7 @@ def workflow_search(
|
||||
if desc:
|
||||
console.print(f" {_escape_markup(str(desc))}")
|
||||
tags = wf.get("tags", [])
|
||||
if isinstance(tags, list) and tags:
|
||||
if tags:
|
||||
safe_tags = _escape_markup(", ".join(str(t) for t in tags))
|
||||
console.print(f" [dim]Tags: {safe_tags}[/dim]")
|
||||
console.print()
|
||||
@@ -2446,9 +2424,8 @@ 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']))}")
|
||||
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))
|
||||
if info.get("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:
|
||||
|
||||
@@ -335,45 +335,26 @@ class WorkflowCatalog:
|
||||
if not config_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
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__}"
|
||||
)
|
||||
# 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:
|
||||
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.
|
||||
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):
|
||||
@@ -1037,33 +1018,24 @@ class StepCatalog:
|
||||
if not config_path.exists():
|
||||
return None
|
||||
try:
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
|
||||
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
|
||||
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 catalogs_data is None:
|
||||
catalogs_data = data.get("catalogs", [])
|
||||
if not catalogs_data:
|
||||
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):
|
||||
|
||||
@@ -70,24 +70,6 @@ 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
|
||||
|
||||
@@ -61,24 +61,6 @@ 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."
|
||||
|
||||
@@ -89,9 +89,8 @@ class PromptStep(StepBase):
|
||||
)
|
||||
|
||||
# Attempt CLI dispatch
|
||||
timeout = config.get("timeout", 300)
|
||||
dispatch_result = self._try_dispatch(
|
||||
prompt, integration, model, context, timeout=timeout
|
||||
prompt, integration, model, context
|
||||
)
|
||||
|
||||
output: dict[str, Any] = {
|
||||
@@ -137,7 +136,6 @@ class PromptStep(StepBase):
|
||||
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:
|
||||
@@ -180,7 +178,6 @@ class PromptStep(StepBase):
|
||||
exec_args,
|
||||
text=True,
|
||||
cwd=str(project_root),
|
||||
timeout=timeout,
|
||||
)
|
||||
return {
|
||||
"exit_code": result.returncode,
|
||||
@@ -193,12 +190,6 @@ 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
|
||||
|
||||
|
||||
@@ -79,24 +79,6 @@ 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.
|
||||
description: Create or update the project constitution from interactive or provided principle inputs, ensuring all dependent templates stay in sync.
|
||||
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 itself. Dependent templates
|
||||
and commands read the constitution at runtime and are not modified here.
|
||||
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.
|
||||
|
||||
- Classify every part of the user input as either constitution content or a separate,
|
||||
non-governance intent.
|
||||
@@ -25,7 +25,7 @@ and commands read the constitution at runtime and are not modified here.
|
||||
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.
|
||||
workflow and its required propagation.
|
||||
- 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 @@ and commands read the constitution at runtime and are not modified here.
|
||||
|
||||
## Outline
|
||||
|
||||
You are updating the project constitution at `.specify/memory/constitution.md`. This file is a TEMPLATE containing placeholder tokens in square brackets (e.g. `[PROJECT_NAME]`, `[PRINCIPLE_1_NAME]`). Your job is to (a) collect/derive concrete values and (b) fill the template precisely.
|
||||
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.
|
||||
|
||||
**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,24 +96,32 @@ 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. Produce a Sync Impact Report (prepend as an HTML comment at top of the constitution file after update):
|
||||
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):
|
||||
- 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.
|
||||
|
||||
5. Validation before final output:
|
||||
6. 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).
|
||||
|
||||
6. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||
7. Write the completed constitution back to `.specify/memory/constitution.md` (overwrite).
|
||||
|
||||
7. Output a final summary to the user with:
|
||||
8. Output a final summary to the user with:
|
||||
- New version and bump rationale.
|
||||
- Any TODO placeholders or deferred items requiring manual follow-up.
|
||||
- Any files flagged for 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,7 +17,6 @@ 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,
|
||||
@@ -26,42 +25,6 @@ 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:
|
||||
@@ -161,24 +124,6 @@ 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"])
|
||||
@@ -316,83 +261,6 @@ 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.
|
||||
|
||||
@@ -204,69 +204,19 @@ 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"
|
||||
|
||||
@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) == "/"
|
||||
assert i.build_command_invocation("speckit.git.commit", "fix typo") == "/speckit-git-commit fix typo"
|
||||
|
||||
def test_forge_core_command_hyphenated(self):
|
||||
"""Forge installs hyphenated slash-commands (/speckit-<name>), so the
|
||||
@@ -318,26 +268,6 @@ 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,7 +3,6 @@
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import runpy
|
||||
|
||||
import pytest
|
||||
import yaml
|
||||
@@ -1181,23 +1180,6 @@ 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."""
|
||||
@@ -1238,48 +1220,6 @@ 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
|
||||
@@ -1403,18 +1343,6 @@ 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"
|
||||
@@ -1878,25 +1806,6 @@ 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):
|
||||
@@ -1919,19 +1828,6 @@ 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.
|
||||
@@ -1940,30 +1836,6 @@ 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(
|
||||
|
||||
@@ -191,7 +191,7 @@ class SkillsIntegrationTests:
|
||||
"---\n"
|
||||
"name: test\n"
|
||||
"---\n\n"
|
||||
"- When constructing command invocations from hook command names, "
|
||||
"- When constructing slash commands 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"
|
||||
|
||||
@@ -220,33 +220,6 @@ class TestActiveCatalogs:
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class _OversizedResponse:
|
||||
"""Response stub that supports bounded streaming reads for oversized-catalog tests."""
|
||||
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
self._pos = 0
|
||||
|
||||
def read(self, n=-1):
|
||||
if n < 0:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos : self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
|
||||
def __enter__(self):
|
||||
return self
|
||||
|
||||
def __exit__(self, *a):
|
||||
pass
|
||||
|
||||
|
||||
class TestCatalogFetch:
|
||||
"""Tests that use a local HTTP server stub via monkeypatch."""
|
||||
|
||||
@@ -257,16 +230,9 @@ 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, 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 read(self):
|
||||
return self._data
|
||||
|
||||
def geturl(self):
|
||||
return self._url
|
||||
@@ -429,90 +395,6 @@ class TestCatalogFetch:
|
||||
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
|
||||
cat.search()
|
||||
|
||||
def test_oversized_catalog_response_rejected(self, tmp_path, monkeypatch):
|
||||
"""Response exceeding MAX_JSON_METADATA_BYTES is caught as IntegrationCatalogError.
|
||||
|
||||
The per-entry error is logged as a warning and skipped (not fatal).
|
||||
When ALL catalogs are oversized, search() raises the aggregate error.
|
||||
"""
|
||||
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
# Build a valid catalog dict whose JSON encoding exceeds the limit.
|
||||
oversized = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {},
|
||||
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
|
||||
}
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
def _oversized_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
return _OversizedResponse(oversized, url)
|
||||
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _oversized_urlopen)
|
||||
|
||||
# Both default + community catalogs are oversized → all fail → aggregate error.
|
||||
# The per-entry IntegrationCatalogError (with "exceeds maximum size") is
|
||||
# logged as a warning; the aggregate raise has a different message.
|
||||
with pytest.raises(IntegrationCatalogError, match="Failed to fetch any integration catalog"):
|
||||
cat.search()
|
||||
|
||||
def test_oversized_catalog_does_not_block_healthy_one(self, tmp_path, monkeypatch):
|
||||
"""When one catalog is oversized, the healthy catalog still returns results."""
|
||||
from specify_cli._download_security import MAX_JSON_METADATA_BYTES
|
||||
|
||||
monkeypatch.setenv("HOME", str(tmp_path))
|
||||
monkeypatch.setenv("USERPROFILE", str(tmp_path))
|
||||
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
|
||||
specify = tmp_path / ".specify"
|
||||
specify.mkdir()
|
||||
|
||||
healthy_catalog = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {
|
||||
"good-agent": {
|
||||
"id": "good-agent",
|
||||
"name": "Good Agent",
|
||||
"version": "1.0.0",
|
||||
"description": "A healthy integration",
|
||||
"author": "test-org",
|
||||
},
|
||||
},
|
||||
}
|
||||
oversized_catalog = {
|
||||
"schema_version": "1.0",
|
||||
"integrations": {},
|
||||
"padding": "x" * (MAX_JSON_METADATA_BYTES + 1),
|
||||
}
|
||||
cfg = specify / "integration-catalogs.yml"
|
||||
cfg.write_text(yaml.dump({"catalogs": [
|
||||
{"url": "https://healthy.example.com/catalog.json", "name": "healthy", "priority": 1, "install_allowed": True},
|
||||
{"url": "https://oversized.example.com/catalog.json", "name": "oversized", "priority": 2, "install_allowed": True},
|
||||
]}))
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
|
||||
import specify_cli.authentication.http as _auth_http
|
||||
|
||||
def _multi_catalog_urlopen(req, timeout=10):
|
||||
url = req if isinstance(req, str) else req.full_url
|
||||
if "oversized" in url:
|
||||
return _OversizedResponse(oversized_catalog, url)
|
||||
return _OversizedResponse(healthy_catalog, url)
|
||||
|
||||
monkeypatch.setattr(_auth_http.urllib.request, "urlopen", _multi_catalog_urlopen)
|
||||
|
||||
# The oversized catalog is skipped; the healthy catalog's integrations are returned.
|
||||
results = cat.search()
|
||||
ids = [r["id"] for r in results]
|
||||
assert "good-agent" in ids
|
||||
|
||||
def test_clear_cache(self, tmp_path):
|
||||
(tmp_path / ".specify").mkdir()
|
||||
cat = IntegrationCatalog(tmp_path)
|
||||
@@ -710,15 +592,8 @@ class TestIntegrationListCatalog:
|
||||
def __init__(self, data, url=""):
|
||||
self._data = json.dumps(data).encode()
|
||||
self._url = url if isinstance(url, str) else url.full_url
|
||||
self._pos = 0
|
||||
def read(self, n=-1):
|
||||
if n < 0:
|
||||
chunk = self._data[self._pos:]
|
||||
self._pos = len(self._data)
|
||||
return chunk
|
||||
chunk = self._data[self._pos:self._pos + n]
|
||||
self._pos += len(chunk)
|
||||
return chunk
|
||||
def read(self):
|
||||
return self._data
|
||||
def geturl(self):
|
||||
return self._url
|
||||
def __enter__(self):
|
||||
|
||||
@@ -12,6 +12,7 @@ class TestCodexIntegration(SkillsIntegrationTests):
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".agents/skills"
|
||||
|
||||
|
||||
class TestCodexInitFlow:
|
||||
"""--integration codex creates expected files."""
|
||||
|
||||
@@ -97,8 +98,6 @@ 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."""
|
||||
|
||||
@@ -43,20 +43,6 @@ 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,62 +55,6 @@ 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,24 +1276,6 @@ 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.
|
||||
|
||||
@@ -2743,7 +2725,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).
|
||||
|
||||
@@ -9,6 +9,7 @@ class TestZcodeIntegration(SkillsIntegrationTests):
|
||||
COMMANDS_SUBDIR = "skills"
|
||||
REGISTRAR_DIR = ".zcode/skills"
|
||||
|
||||
|
||||
class TestZcodeInvocation:
|
||||
"""ZCode renders $speckit-* chat invocations (like Codex)."""
|
||||
|
||||
|
||||
@@ -34,19 +34,6 @@ 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")
|
||||
@@ -103,62 +90,6 @@ 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,12 +2,10 @@
|
||||
|
||||
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
|
||||
@@ -106,14 +104,6 @@ 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,57 +535,6 @@ 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
|
||||
|
||||
@@ -2884,29 +2884,6 @@ 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
|
||||
@@ -4412,44 +4389,6 @@ 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.
|
||||
|
||||
|
||||
@@ -379,31 +379,6 @@ 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."""
|
||||
|
||||
@@ -197,45 +197,6 @@ class TestPresetManifest:
|
||||
with pytest.raises(PresetValidationError, match="YAML mapping"):
|
||||
PresetManifest(manifest_path)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"bad",
|
||||
[
|
||||
5, "oops", {"a": 1}, # truthy non-lists
|
||||
0, False, None, "", {}, # FALSY non-lists: must not fall through to
|
||||
# the misleading "at least one template"
|
||||
],
|
||||
)
|
||||
def test_non_list_templates_raises_validation_error(
|
||||
self, temp_dir, valid_pack_data, bad
|
||||
):
|
||||
"""A non-list provides.templates raises the accurate type error, not a raw
|
||||
'int object is not iterable' TypeError and not the misleading "must provide
|
||||
at least one template" (which a falsy non-list hit while the type check
|
||||
sat behind the emptiness check) — mirrors ExtensionManifest."""
|
||||
valid_pack_data["provides"]["templates"] = bad
|
||||
manifest_path = temp_dir / "preset.yml"
|
||||
manifest_path.write_text(yaml.dump(valid_pack_data), encoding="utf-8")
|
||||
with pytest.raises(PresetValidationError, match="templates.*expected a list"):
|
||||
PresetManifest(manifest_path)
|
||||
|
||||
# NOTE: the empty-list case (a well-typed container with no templates, which
|
||||
# must keep the "must provide at least one template" message after the
|
||||
# type-before-emptiness reordering) is already covered by
|
||||
# test_no_templates_provided below.
|
||||
|
||||
@pytest.mark.parametrize("bad_entry", [None, 5, "oops", ["nested"]])
|
||||
def test_non_mapping_template_entry_raises_validation_error(
|
||||
self, temp_dir, valid_pack_data, bad_entry
|
||||
):
|
||||
"""A non-mapping template entry (null/scalar/list) raises PresetValidationError,
|
||||
not a raw 'argument of type ... is not iterable' TypeError from the
|
||||
`"type" not in tmpl` membership test — mirrors ExtensionManifest."""
|
||||
valid_pack_data["provides"]["templates"] = [bad_entry]
|
||||
manifest_path = temp_dir / "preset.yml"
|
||||
manifest_path.write_text(yaml.dump(valid_pack_data), encoding="utf-8")
|
||||
with pytest.raises(PresetValidationError, match="must be a mapping"):
|
||||
PresetManifest(manifest_path)
|
||||
|
||||
def test_missing_schema_version(self, temp_dir, valid_pack_data):
|
||||
"""Test missing schema_version field."""
|
||||
del valid_pack_data["schema_version"]
|
||||
@@ -4428,35 +4389,6 @@ class TestPresetSkills:
|
||||
assert "__SPECKIT_COMMAND_" not in content, "raw command token leaked on restore"
|
||||
assert "/speckit-plan" in content
|
||||
|
||||
def test_restore_skill_preserves_dollar_command_refs(self, project_dir, temp_dir):
|
||||
"""Dollar-style core refs remain native when a preset skill is removed."""
|
||||
self._write_init_options(project_dir, ai="zcode")
|
||||
skills_dir = project_dir / ".zcode" / "skills"
|
||||
self._create_skill(skills_dir, "speckit-specify")
|
||||
|
||||
core_cmds = project_dir / ".specify" / "templates" / "commands"
|
||||
core_cmds.mkdir(parents=True, exist_ok=True)
|
||||
raw_core = (
|
||||
"---\ndescription: Core specify\n---\n\n"
|
||||
"Then run `__SPECKIT_COMMAND_PLAN__`.\n"
|
||||
)
|
||||
(core_cmds / "specify.md").write_text(raw_core)
|
||||
|
||||
preset_dir = self._create_command_preset(
|
||||
temp_dir,
|
||||
"dollar-cmdref-restore",
|
||||
"speckit.specify",
|
||||
"Override specify",
|
||||
"Override body\n",
|
||||
)
|
||||
manager = PresetManager(project_dir)
|
||||
manager.install_from_directory(preset_dir, "0.1.5")
|
||||
manager.remove("dollar-cmdref-restore")
|
||||
|
||||
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
|
||||
assert "$speckit-plan" in content
|
||||
assert "/speckit-plan" not in content
|
||||
|
||||
def test_reconcile_override_skill_resolves_command_refs(self, project_dir, temp_dir):
|
||||
"""Reconcile's project-override restore must resolve command tokens (issue #2717).
|
||||
|
||||
@@ -11972,21 +11904,18 @@ class TestPresetTagsNonString:
|
||||
this with ``str(t) for t in ...`` — presets must match.
|
||||
"""
|
||||
|
||||
def _seed_catalog(self, project_dir, tags, extra=None):
|
||||
def _seed_catalog(self, project_dir, tags):
|
||||
catalog = PresetCatalog(project_dir)
|
||||
catalog.cache_dir.mkdir(parents=True, exist_ok=True)
|
||||
pack = {
|
||||
"name": "Numeric Tags",
|
||||
"description": "Preset with non-string tags",
|
||||
"version": "1.0.0",
|
||||
"tags": tags,
|
||||
}
|
||||
if extra:
|
||||
pack.update(extra)
|
||||
catalog_data = {
|
||||
"schema_version": "1.0",
|
||||
"presets": {
|
||||
"numeric-tags": pack,
|
||||
"numeric-tags": {
|
||||
"name": "Numeric Tags",
|
||||
"description": "Preset with non-string tags",
|
||||
"version": "1.0.0",
|
||||
"tags": tags,
|
||||
},
|
||||
},
|
||||
}
|
||||
catalog.cache_file.write_text(json.dumps(catalog_data))
|
||||
@@ -12030,182 +11959,3 @@ class TestPresetTagsNonString:
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = strip_ansi(result.output)
|
||||
assert "Tags: 1, 2" in plain
|
||||
|
||||
def _default_only(self, catalog):
|
||||
return [PresetCatalogEntry(
|
||||
url=catalog.DEFAULT_CATALOG_URL, name="default", priority=1, install_allowed=True
|
||||
)]
|
||||
|
||||
def test_search_by_author_tolerates_non_string_author(self, project_dir):
|
||||
"""``--author`` must not crash on a numeric catalog ``author``.
|
||||
|
||||
``PresetCatalog.search`` called ``.lower()`` straight on the raw value,
|
||||
raising ``AttributeError: 'int' object has no attribute 'lower'``. The
|
||||
sibling extension/integration catalogs coerce with ``str(...)`` first.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
catalog = self._seed_catalog(project_dir, ["ci"], extra={"author": 789})
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch.object(PresetCatalog, "get_active_catalogs",
|
||||
return_value=self._default_only(catalog)):
|
||||
result = CliRunner().invoke(app, ["preset", "search", "--author", "789"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "Numeric Tags" in strip_ansi(result.output)
|
||||
|
||||
def test_search_query_tolerates_non_string_name_and_description(self, project_dir):
|
||||
"""A query search must not crash on numeric ``name``/``description``.
|
||||
|
||||
The searchable-text join passed the raw values through, raising
|
||||
``TypeError: sequence item 0: expected str instance, int found``.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
catalog = self._seed_catalog(
|
||||
project_dir, ["ci"], extra={"name": 123, "description": 456}
|
||||
)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch.object(PresetCatalog, "get_active_catalogs",
|
||||
return_value=self._default_only(catalog)):
|
||||
result = CliRunner().invoke(app, ["preset", "search", "123"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "numeric-tags" in strip_ansi(result.output)
|
||||
|
||||
def test_search_tolerates_non_list_tags(self, project_dir):
|
||||
"""A scalar ``tags:`` value must not crash the tag filter or display.
|
||||
|
||||
``tags: 5`` is truthy but not iterable, so both the ``--tag`` filter and
|
||||
the result-display join raised ``TypeError: 'int' object is not
|
||||
iterable``. Siblings guard with ``isinstance(raw_tags, list)``.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
catalog = self._seed_catalog(project_dir, 5)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch.object(PresetCatalog, "get_active_catalogs",
|
||||
return_value=self._default_only(catalog)):
|
||||
filtered = CliRunner().invoke(app, ["preset", "search", "--tag", "ci"])
|
||||
displayed = CliRunner().invoke(app, ["preset", "search", "Numeric"])
|
||||
|
||||
assert filtered.exit_code == 0, filtered.output
|
||||
assert "No presets found" in strip_ansi(filtered.output)
|
||||
|
||||
assert displayed.exit_code == 0, displayed.output
|
||||
plain = strip_ansi(displayed.output)
|
||||
assert "Numeric Tags" in plain
|
||||
assert "Tags:" not in plain
|
||||
|
||||
def test_info_tolerates_non_list_tags(self, project_dir):
|
||||
"""``preset info`` must not crash rendering a scalar ``tags:`` value."""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
catalog = self._seed_catalog(project_dir, 5)
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch.object(PresetCatalog, "get_active_catalogs",
|
||||
return_value=self._default_only(catalog)):
|
||||
result = CliRunner().invoke(app, ["preset", "info", "numeric-tags"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
plain = strip_ansi(result.output)
|
||||
assert "numeric-tags" in plain
|
||||
assert "Tags:" not in plain
|
||||
|
||||
def test_search_escapes_rich_markup_in_tags(self, project_dir):
|
||||
"""Bracketed tag text must survive Rich markup parsing.
|
||||
|
||||
``preset search`` printed tags unescaped, so a tag like ``[bold]`` was
|
||||
swallowed as a style tag. ``preset list`` already escaped this.
|
||||
"""
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
catalog = self._seed_catalog(project_dir, ["[bold]ci"])
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir), \
|
||||
patch.object(PresetCatalog, "get_active_catalogs",
|
||||
return_value=self._default_only(catalog)):
|
||||
result = CliRunner().invoke(app, ["preset", "search", "Numeric"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
assert "[bold]ci" in strip_ansi(result.output)
|
||||
|
||||
|
||||
class TestPresetCatalogRichMarkup:
|
||||
"""Catalog metadata must render as literal text in Rich output."""
|
||||
|
||||
MARKUP_PRESET = {
|
||||
"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]",
|
||||
"tags": ["[italic]markup-tag[/italic]"],
|
||||
"repository": "[bold]Markup Repository[/bold]",
|
||||
"license": "[cyan]Markup License[/cyan]",
|
||||
}
|
||||
|
||||
def test_search_escapes_catalog_markup(self, project_dir):
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir), patch.object(
|
||||
PresetCatalog,
|
||||
"search",
|
||||
return_value=[self.MARKUP_PRESET],
|
||||
):
|
||||
result = CliRunner().invoke(app, ["preset", "search"])
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
for value in (
|
||||
self.MARKUP_PRESET["id"],
|
||||
self.MARKUP_PRESET["name"],
|
||||
self.MARKUP_PRESET["version"],
|
||||
self.MARKUP_PRESET["description"],
|
||||
):
|
||||
assert value in output
|
||||
|
||||
def test_info_escapes_catalog_markup(self, project_dir):
|
||||
from typer.testing import CliRunner
|
||||
from unittest.mock import patch
|
||||
from specify_cli import app
|
||||
|
||||
with patch.object(Path, "cwd", return_value=project_dir), patch.object(
|
||||
PresetCatalog,
|
||||
"get_pack_info",
|
||||
return_value=self.MARKUP_PRESET,
|
||||
):
|
||||
result = CliRunner().invoke(
|
||||
app,
|
||||
["preset", "info", self.MARKUP_PRESET["id"]],
|
||||
)
|
||||
|
||||
assert result.exit_code == 0, result.output
|
||||
output = " ".join(strip_ansi(result.output).split())
|
||||
for field in (
|
||||
"id",
|
||||
"name",
|
||||
"version",
|
||||
"description",
|
||||
"author",
|
||||
"repository",
|
||||
"license",
|
||||
):
|
||||
value = self.MARKUP_PRESET[field]
|
||||
assert value in output
|
||||
|
||||
@@ -466,42 +466,6 @@ def test_bash_command_hint_preserves_hyphens_inside_segments(tasks_repo: Path) -
|
||||
assert result.stdout.strip() == "/speckit.jira.sync-status"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_installed_bash_formatter_uses_dollar_prefix(tmp_path: Path) -> None:
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "bash-dollar-prefix"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
_install_shared_infra(
|
||||
project, "sh", invoke_separator="-", invoke_prefix="$"
|
||||
)
|
||||
_write_integration_state(project, "codex", "-")
|
||||
|
||||
result = _run_bash_format_command(project, "plan")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "$speckit-plan"
|
||||
|
||||
|
||||
@requires_bash
|
||||
def test_installed_bash_formatter_uses_skill_colon_prefix(tmp_path: Path) -> None:
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "bash-skill-colon-prefix"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
_install_shared_infra(
|
||||
project, "sh", invoke_separator="-", invoke_prefix="/skill:"
|
||||
)
|
||||
_write_integration_state(project, "kimi", "-")
|
||||
|
||||
result = _run_bash_format_command(project, "plan")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "/skill:speckit-plan"
|
||||
|
||||
|
||||
def _install_broken_json_tool_stubs(repo: Path) -> Path:
|
||||
"""Create a bin dir with `jq` and `python3` stubs that exist but fail.
|
||||
|
||||
@@ -815,24 +779,6 @@ def test_powershell_command_hint_normalizes_mixed_separators(
|
||||
assert result.stdout.strip() == "/speckit-git-commit"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_installed_powershell_formatter_uses_dollar_prefix(tmp_path: Path) -> None:
|
||||
from specify_cli import _install_shared_infra
|
||||
|
||||
project = tmp_path / "powershell-dollar-prefix"
|
||||
project.mkdir()
|
||||
(project / ".specify").mkdir()
|
||||
_install_shared_infra(
|
||||
project, "ps", invoke_separator="-", invoke_prefix="$"
|
||||
)
|
||||
_write_integration_state(project, "codex", "-")
|
||||
|
||||
result = _run_powershell_format_command(project, "plan")
|
||||
|
||||
assert result.returncode == 0, result.stderr
|
||||
assert result.stdout.strip() == "$speckit-plan"
|
||||
|
||||
|
||||
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
|
||||
def test_powershell_command_hint_preserves_hyphens_inside_segments(
|
||||
tasks_repo: Path,
|
||||
|
||||
@@ -2490,33 +2490,6 @@ class TestIfThenStep:
|
||||
errors = step.validate({"id": "test", "then": []})
|
||||
assert any("missing 'condition'" in e for e in errors)
|
||||
|
||||
@pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5])
|
||||
def test_validate_rejects_non_string_condition(self, bad):
|
||||
# A list/dict/number condition is returned unchanged by
|
||||
# evaluate_expression, and evaluate_condition then bool()-coerces it, so
|
||||
# it silently resolves to its truthiness (e.g. [1, 2] is always True)
|
||||
# instead of erroring on the authoring mistake.
|
||||
from specify_cli.workflows.steps.if_then import IfThenStep
|
||||
|
||||
step = IfThenStep()
|
||||
errors = step.validate({"id": "test", "condition": bad, "then": []})
|
||||
assert any("'condition' must be a" in e for e in errors), bad
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"good",
|
||||
[
|
||||
"true", "false", "{{ inputs.flag }}",
|
||||
True, False, # unquoted YAML bool: resolved exactly, and it is the
|
||||
# default this step itself uses -- must stay valid
|
||||
],
|
||||
)
|
||||
def test_validate_accepts_string_or_bool_condition(self, good):
|
||||
from specify_cli.workflows.steps.if_then import IfThenStep
|
||||
|
||||
step = IfThenStep()
|
||||
errors = step.validate({"id": "test", "condition": good, "then": []})
|
||||
assert not any("'condition' must be a" in e for e in errors), good
|
||||
|
||||
@pytest.mark.parametrize("bad_branch", [{"id": "x"}, "oops", 5])
|
||||
def test_execute_non_list_then_fails_loudly(self, bad_branch):
|
||||
"""A non-list ``then`` must fail the step, not crash the run.
|
||||
@@ -2907,24 +2880,6 @@ class TestWhileStep:
|
||||
assert any("missing 'condition'" in e for e in errors)
|
||||
# max_iterations is optional (defaults to 10)
|
||||
|
||||
@pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5])
|
||||
def test_validate_rejects_non_string_condition(self, bad):
|
||||
from specify_cli.workflows.steps.while_loop import WhileStep
|
||||
|
||||
step = WhileStep()
|
||||
errors = step.validate({"id": "test", "condition": bad, "steps": []})
|
||||
assert any("'condition' must be a" in e for e in errors), bad
|
||||
|
||||
@pytest.mark.parametrize("good", [True, False, "true", "{{ inputs.go }}"])
|
||||
def test_validate_accepts_string_or_bool_condition(self, good):
|
||||
# ``condition: false`` unquoted is idiomatic YAML and is this step's own
|
||||
# default, so a literal bool must not be rejected.
|
||||
from specify_cli.workflows.steps.while_loop import WhileStep
|
||||
|
||||
step = WhileStep()
|
||||
errors = step.validate({"id": "test", "condition": good, "steps": []})
|
||||
assert not any("'condition' must be a" in e for e in errors), good
|
||||
|
||||
def test_validate_invalid_max_iterations(self):
|
||||
from specify_cli.workflows.steps.while_loop import WhileStep
|
||||
|
||||
@@ -3039,24 +2994,6 @@ class TestDoWhileStep:
|
||||
assert any("missing 'condition'" in e for e in errors)
|
||||
# max_iterations is optional (defaults to 10)
|
||||
|
||||
@pytest.mark.parametrize("bad", [["a", "b"], {"k": "v"}, 5, 1.5])
|
||||
def test_validate_rejects_non_string_condition(self, bad):
|
||||
from specify_cli.workflows.steps.do_while import DoWhileStep
|
||||
|
||||
step = DoWhileStep()
|
||||
errors = step.validate({"id": "test", "condition": bad, "steps": []})
|
||||
assert any("'condition' must be a" in e for e in errors), bad
|
||||
|
||||
@pytest.mark.parametrize("good", [True, False, "true", "{{ inputs.go }}"])
|
||||
def test_validate_accepts_string_or_bool_condition(self, good):
|
||||
# ``condition: false`` unquoted is idiomatic YAML; evaluate_condition
|
||||
# resolves a literal bool exactly, so it must not be rejected.
|
||||
from specify_cli.workflows.steps.do_while import DoWhileStep
|
||||
|
||||
step = DoWhileStep()
|
||||
errors = step.validate({"id": "test", "condition": good, "steps": []})
|
||||
assert not any("'condition' must be a" in e for e in errors), good
|
||||
|
||||
def test_validate_steps_not_list(self):
|
||||
from specify_cli.workflows.steps.do_while import DoWhileStep
|
||||
|
||||
@@ -6422,57 +6359,6 @@ class TestWorkflowCatalog:
|
||||
assert len(entries) == 1
|
||||
assert entries[0].name == "custom"
|
||||
|
||||
@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n"])
|
||||
def test_falsy_non_mapping_config_rejected(self, project_dir, body):
|
||||
"""A FALSY non-mapping top-level config ([], false, 0, '') must raise,
|
||||
like a truthy non-mapping (5, a bare list) already does. The previous
|
||||
``yaml.safe_load(...) or {}`` coerced these to {} and silently swallowed
|
||||
them, diverging from the truthy case."""
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
|
||||
|
||||
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
|
||||
config_path.write_text(body, encoding="utf-8")
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
with pytest.raises(WorkflowValidationError, match="expected a mapping"):
|
||||
catalog._load_catalog_config(config_path)
|
||||
|
||||
@pytest.mark.parametrize("body", ["catalogs: {}\n", "catalogs: ''\n", "catalogs: 0\n", "catalogs: false\n"])
|
||||
def test_falsy_non_list_catalogs_rejected(self, project_dir, body):
|
||||
"""A FALSY non-list ``catalogs:`` value must raise, like a truthy one
|
||||
(``catalogs: 5``) already does. The shape check sat behind the emptiness
|
||||
check, so these were silently swallowed as "no catalogs"."""
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog, WorkflowValidationError
|
||||
|
||||
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
|
||||
config_path.write_text(body, encoding="utf-8")
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
with pytest.raises(WorkflowValidationError, match="'catalogs' must be a list"):
|
||||
catalog._load_catalog_config(config_path)
|
||||
|
||||
@pytest.mark.parametrize("body", ["catalogs:\n", "catalogs: []\n"])
|
||||
def test_absent_or_empty_catalogs_is_noop(self, project_dir, body):
|
||||
"""An explicit ``catalogs:`` null or an empty list stays a valid no-op —
|
||||
the layer contributes nothing and resolution falls through."""
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
|
||||
config_path.write_text(body, encoding="utf-8")
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
assert catalog._load_catalog_config(config_path) is None
|
||||
|
||||
@pytest.mark.parametrize("body", ["", "# only a comment\n", "null\n", "~\n"])
|
||||
def test_empty_or_null_config_is_noop(self, project_dir, body):
|
||||
"""An empty document, comment-only file, or explicit top-level null is a
|
||||
valid no-op: the loader returns None so that config layer is skipped and
|
||||
get_active_catalogs falls through to the next one. It must NOT be
|
||||
confused with a falsy non-mapping, which raises."""
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
config_path = project_dir / ".specify" / "workflow-catalogs.yml"
|
||||
config_path.write_text(body, encoding="utf-8")
|
||||
catalog = WorkflowCatalog(project_dir)
|
||||
assert catalog._load_catalog_config(config_path) is None
|
||||
|
||||
@pytest.mark.parametrize("bad_priority", [True, False, float("inf")])
|
||||
def test_config_priority_bool_or_inf_rejected(self, project_dir, bad_priority):
|
||||
"""`priority: true` must not be silently coerced to 1, and `priority: .inf`
|
||||
@@ -7096,52 +6982,6 @@ class TestStepRegistryCustom:
|
||||
class TestStepCatalog:
|
||||
"""Test StepCatalog catalog resolution."""
|
||||
|
||||
# -- Config shape guards ----------------------------------------------
|
||||
# StepCatalog._load_catalog_config is a duplicated twin of
|
||||
# WorkflowCatalog._load_catalog_config, so it needs its own coverage: a
|
||||
# regression in one loader would not be caught by the other's tests.
|
||||
|
||||
@pytest.mark.parametrize("body", ["[]\n", "false\n", "0\n", "''\n"])
|
||||
def test_falsy_non_mapping_config_rejected(self, project_dir, body):
|
||||
"""A FALSY non-mapping top level had the same ``or {}`` coercion, which
|
||||
bypassed the isinstance guard. It must raise like a truthy non-mapping."""
|
||||
from specify_cli.workflows.catalog import StepCatalog, StepValidationError
|
||||
|
||||
config_path = project_dir / ".specify" / "step-catalogs.yml"
|
||||
config_path.write_text(body, encoding="utf-8")
|
||||
catalog = StepCatalog(project_dir)
|
||||
with pytest.raises(StepValidationError, match="expected a mapping"):
|
||||
catalog._load_catalog_config(config_path)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body", ["catalogs: {}\n", "catalogs: ''\n", "catalogs: 0\n", "catalogs: false\n"]
|
||||
)
|
||||
def test_falsy_non_list_catalogs_rejected(self, project_dir, body):
|
||||
"""...and the same nested guard: a FALSY non-list ``catalogs:`` value must
|
||||
raise rather than being swallowed as "no catalogs"."""
|
||||
from specify_cli.workflows.catalog import StepCatalog, StepValidationError
|
||||
|
||||
config_path = project_dir / ".specify" / "step-catalogs.yml"
|
||||
config_path.write_text(body, encoding="utf-8")
|
||||
catalog = StepCatalog(project_dir)
|
||||
with pytest.raises(StepValidationError, match="'catalogs' must be a list"):
|
||||
catalog._load_catalog_config(config_path)
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"body",
|
||||
["", "# only a comment\n", "null\n", "~\n", "catalogs:\n", "catalogs: []\n"],
|
||||
)
|
||||
def test_empty_or_null_config_is_noop(self, project_dir, body):
|
||||
"""An empty document, explicit null, or absent/empty ``catalogs:`` stays a
|
||||
valid no-op — the layer contributes nothing and resolution falls
|
||||
through."""
|
||||
from specify_cli.workflows.catalog import StepCatalog
|
||||
|
||||
config_path = project_dir / ".specify" / "step-catalogs.yml"
|
||||
config_path.write_text(body, encoding="utf-8")
|
||||
catalog = StepCatalog(project_dir)
|
||||
assert catalog._load_catalog_config(config_path) is None
|
||||
|
||||
def test_default_catalogs(self, project_dir, monkeypatch):
|
||||
from specify_cli.workflows.catalog import StepCatalog
|
||||
|
||||
@@ -9744,92 +9584,6 @@ steps:
|
||||
assert asset_calls[0][1] == {"Accept": "application/octet-stream"}
|
||||
|
||||
|
||||
class TestWorkflowStepStartProgressLine:
|
||||
"""The `run`/`resume` step-progress line must render the step id literally.
|
||||
|
||||
The line is built as ` ▸ [<id>] <label> …`, so Rich parsed the bracketed id
|
||||
as a style tag: it silently swallowed the id (the only identifying content
|
||||
on the line), applied it as formatting when the id happened to be a real
|
||||
style like `bold`, and raised MarkupError — failing the whole run — when the
|
||||
id formed a closing tag such as `/`. `validate_workflow` places no charset
|
||||
restriction on step ids, so all of these are accepted workflows.
|
||||
"""
|
||||
|
||||
def _write(self, tmp_path, step_id):
|
||||
path = tmp_path / "wf.yml"
|
||||
path.write_text(
|
||||
'schema_version: "1.0"\n'
|
||||
"workflow:\n"
|
||||
' id: "probe-wf"\n'
|
||||
' name: "Probe"\n'
|
||||
' version: "1.0.0"\n'
|
||||
"steps:\n"
|
||||
f' - id: "{step_id}"\n'
|
||||
" type: shell\n"
|
||||
' run: "exit 0"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
return path
|
||||
|
||||
@pytest.mark.parametrize("step_id", ["greet", "bold", "a]b"])
|
||||
def test_progress_line_shows_step_id(self, tmp_path, monkeypatch, step_id):
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = CliRunner().invoke(
|
||||
app, ["workflow", "run", str(self._write(tmp_path, step_id))]
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert f"[{step_id}]" in result.stdout
|
||||
|
||||
def test_step_id_forming_a_closing_tag_does_not_fail_the_run(
|
||||
self, tmp_path, monkeypatch
|
||||
):
|
||||
"""`id: "/"` raised MarkupError from inside the progress callback, which
|
||||
surfaced as a failed run with no step results."""
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
result = CliRunner().invoke(
|
||||
app, ["workflow", "run", str(self._write(tmp_path, "/"))]
|
||||
)
|
||||
assert result.exit_code == 0, result.stdout
|
||||
assert "Status: completed" in result.stdout
|
||||
assert "[/]" in result.stdout
|
||||
|
||||
def test_resume_progress_line_shows_step_id(self, tmp_path, monkeypatch):
|
||||
"""`workflow resume` installs its own copy of the same callback, so it
|
||||
needs independent coverage — a one-line fix would miss the twin."""
|
||||
import json as _json
|
||||
|
||||
from typer.testing import CliRunner
|
||||
from specify_cli import app
|
||||
|
||||
monkeypatch.chdir(tmp_path)
|
||||
path = tmp_path / "wf.yml"
|
||||
path.write_text(
|
||||
'schema_version: "1.0"\n'
|
||||
"workflow:\n"
|
||||
' id: "probe-resume"\n'
|
||||
' name: "Probe"\n'
|
||||
' version: "1.0.0"\n'
|
||||
"steps:\n"
|
||||
" - id: boom\n"
|
||||
" type: shell\n"
|
||||
' run: "exit 1"\n',
|
||||
encoding="utf-8",
|
||||
)
|
||||
runner = CliRunner()
|
||||
first = runner.invoke(app, ["workflow", "run", str(path), "--json"])
|
||||
run_id = _json.loads(first.stdout).get("run_id")
|
||||
assert run_id
|
||||
|
||||
resumed = runner.invoke(app, ["workflow", "resume", run_id])
|
||||
assert "[boom]" in resumed.stdout
|
||||
|
||||
|
||||
class TestWorkflowRunExitCodes:
|
||||
"""CLI-level tests for the run/resume process exit codes."""
|
||||
|
||||
@@ -10806,44 +10560,6 @@ steps:
|
||||
assert "desc [with] brackets" in result.output
|
||||
assert "tag[1]" in result.output
|
||||
|
||||
def test_search_and_info_tolerate_non_list_tags(self, project_dir, monkeypatch):
|
||||
"""A scalar ``tags:`` value must not crash the search/info display.
|
||||
|
||||
``WorkflowCatalog.search`` guards its tag *filter* with
|
||||
``isinstance(raw_tags, list)``, but the ``workflow search`` and
|
||||
``workflow 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 specify_cli import app
|
||||
from specify_cli.workflows.catalog import WorkflowCatalog
|
||||
|
||||
monkeypatch.chdir(project_dir)
|
||||
workflows = {
|
||||
"wf-a": {
|
||||
"name": "Workflow A",
|
||||
"version": "1.0.0",
|
||||
"description": "desc",
|
||||
"tags": 5,
|
||||
},
|
||||
}
|
||||
monkeypatch.setattr(
|
||||
WorkflowCatalog,
|
||||
"_get_merged_workflows",
|
||||
lambda self, force_refresh=False: {k: dict(v) for k, v in workflows.items()},
|
||||
)
|
||||
runner = CliRunner()
|
||||
searched = runner.invoke(app, ["workflow", "search"])
|
||||
info = runner.invoke(app, ["workflow", "info", "wf-a"])
|
||||
|
||||
assert searched.exit_code == 0, searched.output
|
||||
assert "Workflow A" in searched.output
|
||||
assert "Tags:" not in searched.output
|
||||
|
||||
assert info.exit_code == 0, info.output
|
||||
assert "Tags:" not in info.output
|
||||
|
||||
def test_catalog_list_escapes_rich_markup(self, project_dir, monkeypatch):
|
||||
"""User-editable catalog name/url/description must not be parsed as Rich markup."""
|
||||
from typer.testing import CliRunner
|
||||
|
||||
@@ -3,10 +3,7 @@ from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
|
||||
from specify_cli.bundler import BundlerError
|
||||
from specify_cli.bundler.lib.yamlio import dump_yaml, load_json, load_yaml
|
||||
from specify_cli.bundler.lib.yamlio import dump_yaml, load_yaml
|
||||
|
||||
|
||||
def test_dump_yaml_preserves_unicode(tmp_path: Path):
|
||||
@@ -27,32 +24,3 @@ def test_dump_yaml_round_trips_unicode(tmp_path: Path):
|
||||
data = {"note": "café", "city": "münchen"}
|
||||
dump_yaml(path, data)
|
||||
assert load_yaml(path) == data
|
||||
|
||||
|
||||
def test_load_yaml_non_utf8_raises_bundler_error(tmp_path: Path):
|
||||
"""A non-UTF-8 file must degrade into BundlerError, per this module's
|
||||
documented contract. UnicodeDecodeError is a ValueError, not an OSError, so
|
||||
it previously escaped as a raw traceback. UTF-16 is the realistic case:
|
||||
PowerShell 5.1's `Out-File`/`>` default to it."""
|
||||
path = tmp_path / "bundle-catalogs.yml"
|
||||
path.write_bytes('catalogs: []\n'.encode("utf-16"))
|
||||
with pytest.raises(BundlerError, match="Could not read"):
|
||||
load_yaml(path)
|
||||
|
||||
|
||||
def test_load_json_non_utf8_raises_bundler_error(tmp_path: Path):
|
||||
"""Same for the JSON reader: json.JSONDecodeError is a *sibling* of
|
||||
UnicodeDecodeError, so it does not cover a decode failure."""
|
||||
path = tmp_path / "records.json"
|
||||
path.write_bytes('{"bundles": []}'.encode("utf-16"))
|
||||
with pytest.raises(BundlerError, match="Could not read"):
|
||||
load_json(path)
|
||||
|
||||
|
||||
def test_load_json_malformed_still_reports_invalid_json(tmp_path: Path):
|
||||
"""Clause order regression guard: decodable-but-malformed JSON must keep the
|
||||
more specific 'Invalid JSON' message rather than the read-error one."""
|
||||
path = tmp_path / "records.json"
|
||||
path.write_text('{"bundles": [', encoding="utf-8")
|
||||
with pytest.raises(BundlerError, match="Invalid JSON"):
|
||||
load_json(path)
|
||||
|
||||
Reference in New Issue
Block a user