Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
b46ce37f6c chore: bump version to 0.14.3 2026-07-28 14:15:03 +00:00
55 changed files with 229 additions and 2418 deletions

View File

@@ -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 \

View File

@@ -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

View File

@@ -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

View File

@@ -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)
[![Spec Kit video header](/media/spec-kit-video-header.jpg)](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) 文件。

View File

@@ -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) |

View File

@@ -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",

View File

@@ -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)",

View File

@@ -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"

View File

@@ -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,
)

View File

@@ -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}"

View File

@@ -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.

View File

@@ -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)

View File

@@ -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",

View File

@@ -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

View File

@@ -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(

View File

@@ -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"

View File

@@ -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()

View File

@@ -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)

View File

@@ -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,

View File

@@ -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

View File

@@ -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,
)

View File

@@ -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)

View File

@@ -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.

View File

@@ -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:

View File

@@ -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"

View File

@@ -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]:

View File

@@ -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:

View File

@@ -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()

View File

@@ -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:

View File

@@ -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:

View File

@@ -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):

View File

@@ -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

View File

@@ -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."

View File

@@ -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

View File

@@ -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

View File

@@ -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 nonnegotiable 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.

View File

@@ -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.

View File

@@ -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, ".")

View File

@@ -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(

View File

@@ -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"

View File

@@ -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):

View File

@@ -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."""

View File

@@ -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")

View File

@@ -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)

View File

@@ -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).

View File

@@ -9,6 +9,7 @@ class TestZcodeIntegration(SkillsIntegrationTests):
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".zcode/skills"
class TestZcodeInvocation:
"""ZCode renders $speckit-* chat invocations (like Codex)."""

View File

@@ -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"

View File

@@ -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"}

View File

@@ -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

View File

@@ -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.

View File

@@ -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."""

View File

@@ -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

View File

@@ -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,

View File

@@ -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

View File

@@ -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)