Compare commits

..

1 Commits

Author SHA1 Message Date
github-actions[bot]
f0fc1b5741 chore: bump version to 0.11.5 2026-06-22 22:50:20 +00:00
49 changed files with 83 additions and 1992 deletions

View File

@@ -8,7 +8,7 @@ body:
value: |
Thanks for requesting a new agent! Before submitting, please check if the agent is already supported.
**Currently supported agents**: Amp, Antigravity, Auggie CLI, Claude Code, Cline, CodeBuddy, Codex CLI, Cursor, Devin for Terminal, Firebender, Forge, Gemini CLI, GitHub Copilot, Goose, Hermes Agent, IBM Bob, iFlow CLI, Junie, Kilo Code, Kimi Code, Kiro CLI, Lingma, Mistral Vibe, Oh My Pi, opencode, Pi Coding Agent, Qoder CLI, Qwen Code, Roo Code, RovoDev ACLI, SHAI, Tabnine CLI, Trae, Windsurf, ZCode, Zed
**Currently supported agents**: Claude Code, Gemini CLI, GitHub Copilot, Cursor, Qwen Code, opencode, Codex CLI, Windsurf, Kilo Code, Auggie CLI, Roo Code, CodeBuddy, Qoder CLI, Kiro CLI, Amp, SHAI, Tabnine CLI, Antigravity, IBM Bob, Mistral Vibe, Kimi Code, Trae, Pi Coding Agent, iFlow CLI, Devin for Terminal
- type: input
id: agent-name

View File

@@ -62,42 +62,24 @@ body:
label: AI Agent
description: Which AI agent are you using?
options:
- Amp
- Antigravity
- Auggie CLI
- Claude Code
- Cline
- CodeBuddy
- Codex CLI
- Cursor
- Devin for Terminal
- Firebender
- Forge
- Gemini CLI
- GitHub Copilot
- Goose
- Hermes Agent
- IBM Bob
- iFlow CLI
- Junie
- Kilo Code
- Kimi Code
- Kiro CLI
- Lingma
- Mistral Vibe
- Oh My Pi
- opencode
- Pi Coding Agent
- Qoder CLI
- Cursor
- Qwen Code
- Roo Code
- RovoDev ACLI
- SHAI
- Tabnine CLI
- Trae
- opencode
- Codex CLI
- Windsurf
- ZCode
- Zed
- Kilo Code
- Auggie CLI
- Roo Code
- CodeBuddy
- Qoder CLI
- Kiro CLI
- Amp
- SHAI
- IBM Bob
- Antigravity
- Not applicable
validations:
required: true

View File

@@ -56,42 +56,24 @@ body:
description: Does this feature relate to a specific AI agent?
options:
- All agents
- Amp
- Antigravity
- Auggie CLI
- Claude Code
- Cline
- CodeBuddy
- Codex CLI
- Cursor
- Devin for Terminal
- Firebender
- Forge
- Gemini CLI
- GitHub Copilot
- Goose
- Hermes Agent
- IBM Bob
- iFlow CLI
- Junie
- Kilo Code
- Kimi Code
- Kiro CLI
- Lingma
- Mistral Vibe
- Oh My Pi
- opencode
- Pi Coding Agent
- Qoder CLI
- Cursor
- Qwen Code
- Roo Code
- RovoDev ACLI
- SHAI
- Tabnine CLI
- Trae
- opencode
- Codex CLI
- Windsurf
- ZCode
- Zed
- Kilo Code
- Auggie CLI
- Roo Code
- CodeBuddy
- Qoder CLI
- Kiro CLI
- Amp
- SHAI
- IBM Bob
- Antigravity
- Not applicable
- type: textarea

View File

@@ -2,36 +2,6 @@
<!-- insert new changelog below this comment -->
## [0.11.7] - 2026-06-24
### Changed
- feat(extensions): verify catalog archive sha256 before install (#3080)
- fix(workflows): validate requires keys and reject phantom permissions gate (#3079)
- fix(scripts): use case-sensitive match for acronym retention in PS branch names (#3130)
- feat(integrations): add omp support (#3107)
- fix: render valid TOML when a command body contains backslashes (#3135)
- harden: reject shell=True in run_command (#3132)
- docs: add monorepo guide (#3084)
- fix(scripts): send check-prerequisites.ps1 errors to stderr (#3123)
- fix: write Codex dev skills as files (#2988)
- chore: release 0.11.6, begin 0.11.7.dev0 development (#3121)
## [0.11.6] - 2026-06-23
### Changed
- [extension] Update Spec Kit Preview extension to v1.1.0 and sync Firebender agent lists (#3116)
- Add Spec Kit Discovery Extension to community catalog (#3119)
- Update Architecture Workflow extension to v1.2.1 (#3118)
- docs: clarify project-defined constitution articles (#2994)
- Add Intake extension to community catalog (#3117)
- feat: add Firebender integration (Android Studio / IntelliJ) (#3077)
- Update DocGuard — CDD Enforcement extension to v0.28.0 (#3115)
- chore: sync issue template agent lists (#3052)
- fix(shared-infra): remove stale managed scripts the core no longer ships (#3076) (#3098)
- chore: release 0.11.5, begin 0.11.6.dev0 development (#3105)
## [0.11.5] - 2026-06-22
### Changed

View File

@@ -167,7 +167,7 @@ the command templates in templates/commands/ to understand what each command
invokes. Use these mapping rules:
- templates/commands/X.md → the command it defines
- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh), then every command invoking those downstream scripts is also affected
- scripts/bash/Y.sh or scripts/powershell/Y.ps1 → every command that invokes that script (grep templates/commands/ for the script name). Also check transitive dependencies: if the changed script is sourced by other scripts (e.g., common.sh is sourced by create-new-feature.sh, check-prerequisites.sh, setup-plan.sh, update-agent-context.sh), then every command invoking those downstream scripts is also affected
- templates/Z-template.md → every command that consumes that template during execution
- src/specify_cli/*.py → CLI commands (`specify init`, `specify check`, `specify extension *`, `specify preset *`); test the affected CLI command and, for init/scaffolding changes, at minimum test /speckit.specify
- extensions/X/commands/* → the extension command it defines

View File

@@ -403,7 +403,7 @@ specify init . --force --integration copilot
specify init --here --force --integration copilot
```
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Oh My Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
The CLI will check if you have Claude Code, Gemini CLI, Cursor CLI, Qwen CLI, opencode, Codex CLI, Qoder CLI, Tabnine CLI, Kiro CLI, Pi, Forge, Goose, Mistral Vibe, or ZCode installed. If you do not, or you prefer to get the templates without checking for the right tools, use `--ignore-agent-tools` with your command:
```bash
specify init <project_name> --integration copilot --ignore-agent-tools

View File

@@ -31,7 +31,7 @@ The following community-contributed extensions are available in [`catalog.commun
| API Evolve | Managed API contract evolution — breaking-change detection, semver enforcement, deprecation orchestration, and lifecycle gates across REST, GraphQL, and gRPC | `process` | Read+Write | [spec-kit-api-evolve](https://github.com/Quratulain-bilal/spec-kit-api-evolve) |
| Architect Impact Previewer | Predicts architectural impact, complexity, and risks of proposed changes before implementation. | `visibility` | Read-only | [spec-kit-architect-preview](https://github.com/UmmeHabiba1312/spec-kit-architect-preview) |
| Architecture Guard | Framework-agnostic architecture review extension for validating implementation against governance and architecture constitutions, detecting architectural drift, and generating non-blocking refactor tasks | `process` | Read+Write | [spec-kit-architecture-guard](https://github.com/DyanGalih/spec-kit-architecture-guard) |
| Architecture Workflow | Generate or reverse project-level 4+1 architecture views as separate commands | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
| Architecture Workflow | Generate or reverse project-level 4+1 architecture view artifacts and synthesis | `docs` | Read+Write | [spec-kit-arch](https://github.com/bigsmartben/spec-kit-arch) |
| Archive Extension | Archive merged features into main project memory. | `docs` | Read+Write | [spec-kit-archive](https://github.com/stn1slv/spec-kit-archive) |
| Azure DevOps Integration | Sync user stories and tasks to Azure DevOps work items using OAuth authentication | `integration` | Read+Write | [spec-kit-azure-devops](https://github.com/pragya247/spec-kit-azure-devops) |
| Blueprint | Stay code-literate in AI-driven development: review a complete code blueprint for every task from spec artifacts before /speckit.implement runs | `docs` | Read+Write | [spec-kit-blueprint](https://github.com/chordpli/spec-kit-blueprint) |
@@ -57,7 +57,7 @@ The following community-contributed extensions are available in [`catalog.commun
| GitHub Issues Integration 1 | Generate spec artifacts from GitHub Issues - import issues, sync updates, and maintain bidirectional traceability | `integration` | Read+Write | [spec-kit-github-issues](https://github.com/Fatima367/spec-kit-github-issues) |
| GitHub Issues Integration 2 | Creates and syncs local specs from an existing GitHub issue | `integration` | Read+Write | [spec-kit-issue](https://github.com/aaronrsun/spec-kit-issue) |
| Improve Extension | Audits any codebase as a senior advisor and writes prioritized, self-contained spec prompts under specs/ that the spec-kit lifecycle can process | `process` | Read+Write | [spec-kit-improve](https://github.com/d0whc3r/spec-kit-improve) |
| Intake | Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts | `docs` | Read+Write | [spec-kit-intake](https://github.com/bigsmartben/spec-kit-intake) |
| Interactive HTML Preview | Generate self-contained interactive HTML prototypes from Spec Kit artifacts | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Intelligent Agent Orchestrator | Cross-catalog agent discovery and intelligent prompt-to-command routing | `process` | Read+Write | [spec-kit-orchestrator](https://github.com/pragya247/spec-kit-orchestrator) |
| Iterate | Iterate on spec documents with a two-phase define-and-apply workflow — refine specs mid-implementation and go straight back to building | `docs` | Read+Write | [spec-kit-iterate](https://github.com/imviancagrace/spec-kit-iterate) |
| Jira Integration | Create Jira Epics, Stories, and Issues from spec-kit specifications and task breakdowns with configurable hierarchy and custom field support | `integration` | Read+Write | [spec-kit-jira](https://github.com/mbachorik/spec-kit-jira) |
@@ -110,8 +110,6 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Changelog | Auto-generate changelogs and release notes from spec git history and requirement diffs | `docs` | Read-only | [spec-kit-changelog](https://github.com/Quratulain-bilal/spec-kit-changelog) |
| Spec Critique Extension | Dual-lens critical review of spec and plan from product strategy and engineering risk perspectives | `docs` | Read-only | [spec-kit-critique](https://github.com/arunt14/spec-kit-critique) |
| Spec Diagram | Auto-generate Mermaid diagrams of SDD workflow state, feature progress, and task dependencies | `visibility` | Read-only | [spec-kit-diagram-](https://github.com/Quratulain-bilal/spec-kit-diagram-) |
| Spec Kit Discovery Extension | Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation | `process` | Read+Write | [spec-kit-discovery](https://github.com/bigsmartben/spec-kit-discovery) |
| Spec Kit Preview | Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML | `docs` | Read+Write | [spec-kit-preview](https://github.com/bigsmartben/spec-kit-preview) |
| Spec Kit Schedule | Optimal multi-agent task scheduling via CP-SAT — DAG precedence, hallucination-aware caps, file-conflict avoidance, stochastic durations, replanning, and interactive HTML output | `process` | Read+Write | [spec-kit-schedule](https://github.com/jfranc38/spec-kit-schedule) |
| Spec Kit TLDR | Render a feature's spec.md / plan.md into a review-oriented TLDR (self-contained HTML dashboard + PR-native Markdown) that surfaces risks for faster PR review. | `visibility` | Read+Write | [speckit-tldr](https://github.com/qurore/speckit-tldr) |
| Spec Orchestrator | Cross-feature orchestration — track state, select tasks, and detect conflicts across parallel specs | `process` | Read-only | [spec-kit-orchestrator](https://github.com/Quratulain-bilal/spec-kit-orchestrator) |

View File

@@ -1,111 +0,0 @@
# Using Spec Kit in a Monorepo
A Spec Kit project is **directory-scoped**: the project is whichever directory
contains `.specify/`. A monorepo can hold several independent Spec Kit projects
under one repository root, each with its own `.specify/`, `specs/`, constitution,
and feature numbering.
Root resolution already prefers the **nearest** `.specify/` over the Git
toplevel, so commands run from inside a member project resolve to that project,
not the repo root.
## Layout
```text
my-monorepo/
├── .git/ # one Git repository at the root
├── apps/
│ ├── web/
│ │ └── .specify/ # Spec Kit project "web"
│ │ └── memory/constitution.md
│ └── api/
│ └── .specify/ # Spec Kit project "api"
│ └── memory/constitution.md
└── packages/
└── ui/
└── .specify/ # Spec Kit project "ui"
```
Initialize each member project independently:
```bash
specify init apps/web --integration claude
specify init apps/api --integration claude
```
Each project keeps its own `specs/` directory and numbers features
independently (`apps/web/specs/001-…`, `apps/api/specs/001-…`).
## Working inside a member project
The default workflow is unchanged: change into the project directory and run the
slash commands. Root resolution finds the nearest `.specify/`.
```bash
cd apps/web
# then run /speckit.specify, /speckit.plan, … in your agent
```
## Targeting a member project from the repo root
For non-interactive or CI runs where you do not want to `cd`, set
**`SPECIFY_INIT_DIR`** to the member project root (the directory *containing*
`.specify/`). Relative paths resolve against the current directory.
```bash
# operate on apps/web from the monorepo root (no cd required)
export SPECIFY_INIT_DIR=apps/web
```
The path must exist and contain `.specify/`. If it does not, the command
**errors and does not fall back** to the current directory or the Git toplevel.
This is deliberate: a typo never writes specs into the wrong project. A
nonexistent path is reported as you typed it; a path that exists but is not a
Spec Kit project is reported as its resolved absolute path:
```text
# SPECIFY_INIT_DIR=apps/wbe (typo: no such directory)
ERROR: SPECIFY_INIT_DIR does not point to an existing directory: apps/wbe
# SPECIFY_INIT_DIR=apps (exists, but has no .specify/ of its own)
ERROR: SPECIFY_INIT_DIR is not a Spec Kit project (no .specify/ directory): /home/you/my-monorepo/apps
```
`SPECIFY_INIT_DIR` selects the **project**; `SPECIFY_FEATURE_DIRECTORY` selects
the **feature** within it. They compose: set both to pick a project and a
feature non-interactively. See the
[`SPECIFY_INIT_DIR` reference](../reference/core.md#environment-variables) for
the full contract and the two-axes model.
## How `SPECIFY_INIT_DIR` reaches your agent
`SPECIFY_INIT_DIR` is read by the shell scripts that the slash commands invoke
(`get_repo_root` in Bash, `Get-RepoRoot` in PowerShell). It takes effect only
when it is present in the environment of the shell that runs those scripts.
- **Scripted / CI runs:** export it in the same shell that drives the commands;
it is reliable there.
- **Interactive agents:** whether an exported variable reaches the shell tool an
agent uses is agent-specific. Export `SPECIFY_INIT_DIR` *before* launching the
agent, and verify once (e.g. run `/speckit.specify` and confirm the new feature
landed under the intended project's `specs/`).
## Git in a monorepo
> [!NOTE]
> Spec Kit project files are scoped to the **resolved project root**, but Git
> operations still run in the containing Git work tree. In a monorepo with a
> single Git repository at the root and projects in subdirectories, feature
> branch creation creates or switches branches in the shared root repository.
> Spec directories still live under the selected member project, while the Git
> branch namespace is shared by the whole monorepo. Manage branches and commits
> at the repository root, or initialize Git per member project if you want
> isolated per-project branch namespaces.
## Constitutions
Each member project has its own `.specify/memory/constitution.md` and
`/speckit.constitution` edits the local project's file. Spec Kit does not provide
a built-in base/inheritance mechanism; if you want one constitution to reference
shared rules elsewhere in the monorepo, you need to maintain that wiring yourself.
Otherwise, duplicate or sync shared engineering rules per project.

View File

@@ -3,7 +3,7 @@
## Prerequisites
- **Linux/macOS** (or Windows; PowerShell scripts now supported without WSL)
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Pi Coding Agent](https://pi.dev), or [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent)
- AI coding agent: [Claude Code](https://www.anthropic.com/claude-code), [GitHub Copilot](https://code.visualstudio.com/), [Codebuddy CLI](https://www.codebuddy.ai/cli), [Gemini CLI](https://github.com/google-gemini/gemini-cli), or [Pi Coding Agent](https://pi.dev)
- [uv](https://docs.astral.sh/uv/) for package management (recommended) or [pipx](https://pipx.pypa.io/) for persistent installation
- [Python 3.11+](https://www.python.org/downloads/)
- [Git](https://git-scm.com/downloads) _(optional — required only when the git extension is enabled)_
@@ -51,7 +51,6 @@ specify init <project_name> --integration gemini
specify init <project_name> --integration copilot
specify init <project_name> --integration codebuddy
specify init <project_name> --integration pi
specify init <project_name> --integration omp
```
### Specify Script Type (Shell vs PowerShell)

View File

@@ -15,7 +15,6 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Codex CLI](https://github.com/openai/codex) | `codex` | Skills-based integration; installs skills into `.agents/skills` and invokes them as `$speckit-<command>` |
| [Cursor](https://cursor.sh/) | `cursor-agent` | |
| [Devin for Terminal](https://cli.devin.ai/docs) | `devin` | Skills-based integration; installs skills into `.devin/skills/` and invokes them as `/speckit-<command>` |
| [Firebender](https://firebender.com/) | `firebender` | IDE-based agent for Android Studio / IntelliJ |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
@@ -29,7 +28,6 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [Kiro CLI](https://kiro.dev/docs/cli/) | `kiro-cli` | Kiro CLI does not substitute `$ARGUMENTS` in file-based prompts, so Spec Kit ships a prose fallback at render time (see [Manage prompts](https://kiro.dev/docs/cli/chat/manage-prompts/) and issue [#1926](https://github.com/github/spec-kit/issues/1926)). Alias: `--integration kiro` |
| [Lingma](https://lingma.aliyun.com/) | `lingma` | Skills-based integration; skills are installed automatically |
| [Mistral Vibe](https://github.com/mistralai/mistral-vibe) | `vibe` | |
| [Oh My Pi](https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent) | `omp` | Installs slash commands into `.omp/commands` |
| [opencode](https://opencode.ai/) | `opencode` | |
| [Pi Coding Agent](https://pi.dev) | `pi` | Pi doesn't have MCP support out of the box, so `taskstoissues` won't work as intended. MCP support can be added via [extensions](https://github.com/badlogic/pi-mono/tree/main/packages/coding-agent#extensions) |
| [Qoder CLI](https://qoder.com/cli) | `qodercli` | |
@@ -187,7 +185,6 @@ The currently declared multi-install safe integrations are:
| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
| `codex` | `.agents/skills`, `AGENTS.md` |
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
| `firebender` | `.firebender/commands`, `.firebender/rules/specify-rules.mdc` |
| `gemini` | `.gemini/commands`, `GEMINI.md` |
| `iflow` | `.iflow/commands`, `IFLOW.md` |
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |

View File

@@ -270,8 +270,6 @@ specify workflow run speckit -i spec="Build a kanban board with drag-and-drop ta
| `fan-out` | Dispatch a step for each item in a list |
| `fan-in` | Aggregate results from a fan-out step |
> **Security note:** a `shell` step runs a local command with **your** privileges. There is no capability sandbox — `requires` is an advisory pre-condition block (spec-kit version, integrations), not a runtime gate, so it does **not** restrict what a step can do. In particular there is no `requires.permissions` capability gate: it is rejected by validation precisely because it would imply a sandbox that does not exist. Review any catalog or downloaded workflow before running it, and use a `gate` step to require explicit approval before sensitive or destructive shell commands.
## Expressions
Steps can reference inputs and previous step outputs using `{{ expression }}` syntax:

View File

@@ -53,8 +53,6 @@
href: local-development.md
- name: Evolving Specs
href: guides/evolving-specs.md
- name: Monorepos
href: guides/monorepo.md
# Community
- name: Community

View File

@@ -308,7 +308,6 @@ Alternatively, run the `/speckit.specify` command which creates `.specify/featur
ls -la .gemini/commands/ # Gemini
ls -la .cursor/skills/ # Cursor
ls -la .pi/prompts/ # Pi Coding Agent
ls -la .omp/commands/ # Oh My Pi
```
3. **Check agent-specific setup:**
@@ -428,7 +427,7 @@ The `specify` CLI tool is used for:
- **Upgrades:** `specify init --here --force` to update templates and commands
- **Diagnostics:** `specify check` to verify tool installation
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, `.omp/commands/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.
Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/speckit.plan`, etc.) are **permanently installed** in your project's agent folder (`.claude/`, `.github/prompts/`, `.pi/prompts/`, etc.). Your AI coding agent reads these command files directly—no need to run `specify` again.
**If your agent isn't recognizing slash commands:**
@@ -443,9 +442,6 @@ Once you've run `specify init`, the slash commands (like `/speckit.specify`, `/s
# For Pi
ls -la .pi/prompts/
# For Oh My Pi
ls -la .omp/commands/
```
2. **Restart your IDE/editor completely** (not just reload window)

View File

@@ -320,7 +320,6 @@ A: Extensions should be free and open-source. Commercial support/services are al
"author": "string (required)",
"version": "string (required, semver)",
"download_url": "string (required, valid URL)",
"sha256": "string (optional, SHA-256 hex digest of the archive at download_url; verified before install)",
"repository": "string (required, valid URL)",
"homepage": "string (optional, valid URL)",
"documentation": "string (optional, valid URL)",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-06-22T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -187,10 +187,10 @@
"arch": {
"name": "Architecture Workflow",
"id": "arch",
"description": "Generate or reverse project-level 4+1 architecture views as separate commands",
"description": "Generate or reverse project-level 4+1 architecture view artifacts and synthesis",
"author": "bigsmartben",
"version": "1.2.1",
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.2.1.zip",
"version": "1.1.0",
"download_url": "https://github.com/bigsmartben/spec-kit-arch/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-arch",
"homepage": "https://github.com/bigsmartben/spec-kit-arch",
"documentation": "https://github.com/bigsmartben/spec-kit-arch/blob/main/README.md",
@@ -202,7 +202,7 @@
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"commands": 10,
"commands": 2,
"hooks": 0
},
"tags": [
@@ -215,7 +215,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-05-14T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z"
"updated_at": "2026-05-15T00:00:00Z"
},
"architect-preview": {
"name": "Architect Impact Previewer",
@@ -1001,47 +1001,13 @@
"created_at": "2026-04-08T00:00:00Z",
"updated_at": "2026-04-08T00:00:00Z"
},
"discovery": {
"name": "Spec Kit Discovery Extension",
"id": "discovery",
"description": "Run technical discovery commands for feasibility, technology selection, scenario-specific technical decisions, legacy codebase assessment, implementation understanding, and proof-of-concept validation.",
"author": "bigsmartben",
"version": "0.2.0",
"download_url": "https://github.com/bigsmartben/spec-kit-discovery/archive/refs/tags/v0.2.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-discovery",
"homepage": "https://github.com/bigsmartben/spec-kit-discovery",
"documentation": "https://github.com/bigsmartben/spec-kit-discovery/blob/main/docs/usage.md",
"changelog": "https://github.com/bigsmartben/spec-kit-discovery/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "process",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 6,
"hooks": 0
},
"tags": [
"discovery",
"workflow",
"validation",
"feasibility",
"decision"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z"
},
"docguard": {
"name": "DocGuard — CDD Enforcement",
"id": "docguard",
"description": "Canonical-Driven Development enforcement. Validates, scores, and traces project documentation with automated checks, AI-driven workflows, and spec-kit hooks. One pinned runtime dependency; pure Node.js otherwise.",
"author": "raccioly",
"version": "0.28.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.28.0/spec-kit-docguard-v0.28.0.zip",
"version": "0.27.0",
"download_url": "https://github.com/raccioly/docguard/releases/download/v0.27.0/spec-kit-docguard-v0.27.0.zip",
"repository": "https://github.com/raccioly/docguard",
"homepage": "https://www.npmjs.com/package/docguard-cli",
"documentation": "https://github.com/raccioly/docguard/blob/main/extensions/spec-kit-docguard/README.md",
@@ -1077,7 +1043,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-03-13T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z"
"updated_at": "2026-06-22T00:00:00Z"
},
"doctor": {
"name": "Project Health Check",
@@ -1404,46 +1370,6 @@
"created_at": "2026-06-16T00:00:00Z",
"updated_at": "2026-06-16T00:00:00Z"
},
"intake": {
"name": "Intake",
"id": "intake",
"description": "Normalize PRD, design, and test-case evidence into SDD-ready intake artifacts.",
"author": "bigsmartben",
"version": "0.1.2",
"download_url": "https://github.com/bigsmartben/spec-kit-intake/archive/refs/tags/v0.1.2.zip",
"repository": "https://github.com/bigsmartben/spec-kit-intake",
"homepage": "https://github.com/bigsmartben/spec-kit-intake",
"documentation": "https://github.com/bigsmartben/spec-kit-intake/blob/main/README.md",
"changelog": "https://github.com/bigsmartben/spec-kit-intake/blob/main/CHANGELOG.md",
"license": "MIT",
"category": "docs",
"effect": "read-write",
"requires": {
"speckit_version": ">=0.8.10.dev0",
"tools": [
{
"name": "figma-mcp",
"required": false
}
]
},
"provides": {
"commands": 3,
"hooks": 1
},
"tags": [
"intake",
"sdd",
"requirements",
"validation",
"figma"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z"
},
"issue": {
"name": "GitHub Issues Integration 2",
"id": "issue",
@@ -2421,12 +2347,12 @@
"updated_at": "2026-03-18T00:00:00Z"
},
"preview": {
"name": "Spec Kit Preview",
"name": "Interactive HTML Preview",
"id": "preview",
"description": "Generate evidence-backed low, mid, or high fidelity previews from Spec Kit artifacts as Markdown or self-contained HTML",
"description": "Generate self-contained interactive HTML prototypes from Spec Kit artifacts",
"author": "bigsmartben",
"version": "1.1.0",
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.1.0.zip",
"version": "1.0.0",
"download_url": "https://github.com/bigsmartben/spec-kit-preview/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/bigsmartben/spec-kit-preview",
"homepage": "https://github.com/bigsmartben/spec-kit-preview",
"documentation": "https://github.com/bigsmartben/spec-kit-preview/blob/main/README.md",
@@ -2438,21 +2364,20 @@
"speckit_version": ">=0.8.10.dev0"
},
"provides": {
"commands": 6,
"commands": 1,
"hooks": 0
},
"tags": [
"preview",
"prototype",
"html",
"markdown",
"ux"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-15T00:00:00Z",
"updated_at": "2026-06-23T00:00:00Z"
"updated_at": "2026-05-15T00:00:00Z"
},
"product": {
"name": "Product Spec Extension",

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-06-23T00:00:00Z",
"updated_at": "2026-06-02T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -102,15 +102,6 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"firebender": {
"id": "firebender",
"name": "Firebender",
"version": "1.0.0",
"description": "Firebender IDE integration for Android Studio / IntelliJ",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["ide"]
},
"forge": {
"id": "forge",
"name": "Forge",
@@ -255,15 +246,6 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"omp": {
"id": "omp",
"name": "Oh My Pi",
"version": "1.0.0",
"description": "Oh My Pi (omp) terminal coding agent prompt-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli"]
},
"iflow": {
"id": "iflow",
"name": "iFlow CLI",

View File

@@ -185,7 +185,6 @@ Edit `presets/catalog.community.json` and add your preset.
"author": "Your Name",
"version": "1.0.0",
"download_url": "https://github.com/your-org/spec-kit-preset-your-preset/archive/refs/tags/v1.0.0.zip",
"sha256": "OPTIONAL: SHA-256 hex digest of the archive above; verified before install",
"repository": "https://github.com/your-org/spec-kit-preset-your-preset",
"license": "MIT",
"requires": {

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.11.7"
version = "0.11.5"
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"
@@ -74,13 +74,3 @@ precision = 2
show_missing = true
skip_covered = false
[tool.ruff.lint]
# Lock in subprocess security posture: any reintroduction of shell=True
# (or os.system / popen2) must be acknowledged with an explicit `# noqa`
# pointing at the rule, making the deviation visible in review.
extend-select = [
"S602", # subprocess-popen-with-shell-equals-true
"S604", # call-with-shell-equals-true
"S605", # start-process-with-a-shell
]

View File

@@ -83,24 +83,24 @@ if ($PathsOnly) {
# Validate required directories and files
if (-not (Test-Path $paths.FEATURE_DIR -PathType Container)) {
[Console]::Error.WriteLine("ERROR: Feature directory not found: $($paths.FEATURE_DIR)")
Write-Output "ERROR: Feature directory not found: $($paths.FEATURE_DIR)"
$specifyCommand = Format-SpecKitCommand -CommandName 'specify' -RepoRoot $paths.REPO_ROOT
[Console]::Error.WriteLine("Run $specifyCommand first to create the feature structure.")
Write-Output "Run $specifyCommand first to create the feature structure."
exit 1
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
Write-Output "ERROR: plan.md not found in $($paths.FEATURE_DIR)"
$planCommand = Format-SpecKitCommand -CommandName 'plan' -RepoRoot $paths.REPO_ROOT
[Console]::Error.WriteLine("Run $planCommand first to create the implementation plan.")
Write-Output "Run $planCommand first to create the implementation plan."
exit 1
}
# Check for tasks.md if required
if ($RequireTasks -and -not (Test-Path $paths.TASKS -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: tasks.md not found in $($paths.FEATURE_DIR)")
Write-Output "ERROR: tasks.md not found in $($paths.FEATURE_DIR)"
$tasksCommand = Format-SpecKitCommand -CommandName 'tasks' -RepoRoot $paths.REPO_ROOT
[Console]::Error.WriteLine("Run $tasksCommand first to create the task list.")
Write-Output "Run $tasksCommand first to create the task list."
exit 1
}

View File

@@ -111,11 +111,8 @@ function Get-BranchName {
# Keep words that are length >= 3 OR appear as uppercase in original (likely acronyms)
if ($word.Length -ge 3) {
$meaningfulWords += $word
} elseif ($Description -cmatch "\b$($word.ToUpper())\b") {
# Keep short words only if they appear as uppercase in original (likely
# acronyms). Use -cmatch so the comparison is case-sensitive, matching the
# bash script's case-sensitive grep; -match would be case-insensitive and
# would keep every short word.
} elseif ($Description -match "\b$($word.ToUpper())\b") {
# Keep short words if they appear as uppercase in original (likely acronyms)
$meaningfulWords += $word
}
}

View File

@@ -318,12 +318,6 @@ No implementation code shall be written before:
This completely inverts traditional AI code generation. Instead of generating code and hoping it works, the LLM must first generate comprehensive tests that define behavior, get them approved, and only then generate implementation.
#### Articles IV, V & VI: Project-Defined Governance
Articles IV, V, and VI are intentionally defined by each project's constitution rather than prescribed by Spec Kit. The constitution template provides placeholder slots and example concerns such as integration testing, observability, versioning, and breaking changes, but teams replace those placeholders with the principles that match their system and organization.
This keeps the nine-article structure stable while allowing each project to encode its own non-negotiable standards. For one project, Article IV might govern security and access boundaries; for another, it might define integration test requirements. The `/speckit.analyze` command evaluates the concrete constitution in the project, so these project-defined articles participate in compliance checks just like the built-in examples.
#### Articles VII & VIII: Simplicity and Anti-Abstraction
These paired articles combat over-engineering:

View File

@@ -65,31 +65,14 @@ def dump_frontmatter(data: dict[str, Any]) -> str:
return yaml.safe_dump(data, sort_keys=False, allow_unicode=True).strip()
def run_command(
cmd: list[str],
check_return: bool = True,
capture: bool = False,
shell: bool = False,
) -> str | None:
"""Run a command without invoking a shell and optionally capture output.
The ``shell`` parameter is kept in the signature so existing keyword
callers (and the re-export from ``specify_cli``) don't raise ``TypeError``,
but only the default ``shell=False`` is honoured. ``shell=True`` is
rejected with ``ValueError`` rather than silently ignored, so the
unsupported mode fails loudly instead of running with a different meaning.
"""
if shell:
raise ValueError(
"run_command() does not support shell=True; pass argv as a list"
)
def run_command(cmd: list[str], check_return: bool = True, capture: bool = False, shell: bool = False) -> str | None:
"""Run a shell command and optionally capture output."""
try:
if capture:
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True)
result = subprocess.run(cmd, check=check_return, capture_output=True, text=True, shell=shell)
return result.stdout.strip()
else:
subprocess.run(cmd, check=check_return)
subprocess.run(cmd, check=check_return, shell=shell)
return None
except subprocess.CalledProcessError as e:
if check_return:

View File

@@ -37,8 +37,6 @@ def _build_agent_configs() -> dict[str, Any]:
# when register_commands() resolves __SPECKIT_COMMAND_*__ tokens.
if "invoke_separator" not in config:
config["invoke_separator"] = integration.invoke_separator
if integration.dev_no_symlink:
config["dev_no_symlink"] = True
configs[key] = config
return configs
@@ -236,14 +234,9 @@ class CommandRegistrar:
toml_lines.append(f"# Source: {source_id}")
toml_lines.append("")
# Keep TOML output valid even when body contains triple-quote delimiters
# or backslashes. Prefer multiline forms, then fall back to escaped basic
# string. A multiline *basic* string ("""...""") processes backslash escape
# sequences, so a body containing a backslash (e.g. a Windows path
# ``C:\\Users\\...`` whose ``\\U`` reads as an invalid unicode escape) would
# produce unparseable TOML — route those to the *literal* form ('''...'''),
# which does not process escapes, or to the escaped basic string.
if '"""' not in body and "\\" not in body:
# Keep TOML output valid even when body contains triple-quote delimiters.
# Prefer multiline forms, then fall back to escaped basic string.
if '"""' not in body:
toml_lines.append('prompt = """')
toml_lines.append(body)
toml_lines.append('"""')
@@ -721,7 +714,6 @@ class CommandRegistrar:
output_name,
agent_config["extension"],
link_outputs,
agent_config,
)
if agent_name == "copilot":
@@ -796,7 +788,6 @@ class CommandRegistrar:
alias_output_name,
agent_config["extension"],
link_outputs,
agent_config,
)
if agent_name == "copilot":
self.write_copilot_prompt(project_root, alias)
@@ -813,12 +804,9 @@ class CommandRegistrar:
output_name: str,
extension: str,
link_outputs: bool,
agent_config: dict[str, Any] | None = None,
) -> None:
"""Write a rendered agent artifact, optionally as a dev-mode symlink."""
if not link_outputs or (agent_config or {}).get("dev_no_symlink"):
if dest_file.is_symlink():
dest_file.unlink()
if not link_outputs:
dest_file.write_text(content, encoding="utf-8")
return
@@ -939,16 +927,6 @@ class CommandRegistrar:
self._active_skills_agent(project_root)
if create_missing_active_skills_dir else None
)
active_skills_dir: Optional[Path] = None
if active_skills_agent:
active_skills_config = self.AGENT_CONFIGS.get(active_skills_agent)
if (
active_skills_config
and active_skills_config.get("extension") == "/SKILL.md"
):
active_skills_dir = self._resolve_agent_dir(
active_skills_agent, active_skills_config, project_root,
)
active_created_skills_dir: Optional[Path] = None
for agent_name, agent_config in self.AGENT_CONFIGS.items():
active_skills_output = (
@@ -980,14 +958,6 @@ class CommandRegistrar:
agent_dir = self._resolve_agent_dir(
agent_name, agent_config, project_root,
)
shares_active_skills_dir = (
active_skills_dir is not None
and agent_name != active_skills_agent
and agent_config.get("extension") == "/SKILL.md"
and self._same_lexical_path(agent_dir, active_skills_dir)
)
if shares_active_skills_dir:
continue
agent_dir_existed = agent_dir.is_dir()
register_missing_active_skills_agent = (

View File

@@ -31,7 +31,6 @@ from .._invocation_style import is_dollar_skills_agent, is_slash_skills_agent
from .._utils import dump_frontmatter, relative_extension_path_violation
from ..catalogs import CatalogEntry as BaseCatalogEntry
from ..catalogs import CatalogStackBase
from ..shared_infra import verify_archive_sha256
_FALLBACK_CORE_COMMAND_NAMES = frozenset(
{
@@ -998,7 +997,6 @@ class ExtensionManager:
if not isinstance(selected_ai, str) or not selected_ai:
return []
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(selected_ai, {})
integration = get_integration(selected_ai)
for cmd_info in manifest.commands:
@@ -1032,16 +1030,15 @@ class ExtensionManager:
skill_file = skill_subdir / "SKILL.md"
cache_root = extension_dir / ".specify-dev" / "extension-skills"
cache_file = cache_root / skill_name / "SKILL.md"
use_dev_symlink = link_outputs and not agent_config.get("dev_no_symlink")
CommandRegistrar._ensure_inside(cache_file, cache_root)
if skill_file.exists() or skill_file.is_symlink():
is_expected_dev_symlink = self._is_expected_dev_symlink(
skill_file, cache_file
)
# Do not overwrite user-customized skills, but allow dev-mode
# symlinks that point back to this extension's generated cache
# to be refreshed on a subsequent dev install.
if not is_expected_dev_symlink:
if not (
link_outputs
and self._is_expected_dev_symlink(skill_file, cache_file)
):
continue
# Create skill directory; track whether we created it so we can clean
@@ -1096,7 +1093,7 @@ class ExtensionManager:
):
skill_content = integration.post_process_skill_content(skill_content)
if use_dev_symlink:
if link_outputs:
try:
cache_file.parent.mkdir(parents=True, exist_ok=True)
cache_file.write_text(skill_content, encoding="utf-8")
@@ -1109,8 +1106,6 @@ class ExtensionManager:
skill_file.unlink()
skill_file.write_text(skill_content, encoding="utf-8")
else:
if skill_file.is_symlink():
skill_file.unlink()
skill_file.write_text(skill_content, encoding="utf-8")
written.append(skill_name)
@@ -2622,10 +2617,6 @@ class ExtensionCatalog(CatalogStackBase):
) as response:
zip_data = response.read()
verify_archive_sha256(
zip_data, ext_info.get("sha256"), extension_id, ExtensionError
)
zip_path.write_bytes(zip_data)
return zip_path

View File

@@ -58,7 +58,6 @@ def _register_builtins() -> None:
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .devin import DevinIntegration
from .firebender import FirebenderIntegration
from .forge import ForgeIntegration
from .gemini import GeminiIntegration
from .generic import GenericIntegration
@@ -70,7 +69,6 @@ def _register_builtins() -> None:
from .kimi import KimiIntegration
from .kiro_cli import KiroCliIntegration
from .lingma import LingmaIntegration
from .omp import OmpIntegration
from .opencode import OpencodeIntegration
from .pi import PiIntegration
from .qodercli import QodercliIntegration
@@ -97,7 +95,6 @@ def _register_builtins() -> None:
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(DevinIntegration())
_register(FirebenderIntegration())
_register(ForgeIntegration())
_register(GeminiIntegration())
_register(GenericIntegration())
@@ -109,7 +106,6 @@ def _register_builtins() -> None:
_register(KimiIntegration())
_register(KiroCliIntegration())
_register(LingmaIntegration())
_register(OmpIntegration())
_register(OpencodeIntegration())
_register(PiIntegration())
_register(QodercliIntegration())

View File

@@ -119,9 +119,6 @@ class IntegrationBase(ABC):
invoke_separator: str = "."
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
dev_no_symlink: bool = False
"""Whether dev-mode registration should write files instead of symlinks."""
multi_install_safe: bool = False
"""Whether this integration is declared safe to install alongside others.

View File

@@ -27,7 +27,6 @@ class CodexIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
dev_no_symlink = True
multi_install_safe = True
def build_exec_args(

View File

@@ -1,33 +0,0 @@
"""Firebender IDE integration.
Firebender (https://firebender.com/) is an AI coding agent for Android Studio
and IntelliJ. It reads project-local custom slash commands from
``.firebender/commands/*.mdc`` and project rules from ``.firebender/rules/*.mdc``,
so Spec Kit installs its command templates as ``.mdc`` command files and writes
the managed context section into a ``.firebender/rules/`` rule file.
"""
from ..base import MarkdownIntegration
class FirebenderIntegration(MarkdownIntegration):
key = "firebender"
config = {
"name": "Firebender",
"folder": ".firebender/",
"commands_subdir": "commands",
"install_url": "https://firebender.com/",
"requires_cli": False,
}
registrar_config = {
"dir": ".firebender/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".mdc",
}
context_file = ".firebender/rules/specify-rules.mdc"
multi_install_safe = True
def command_filename(self, template_name: str) -> str:
"""Firebender reads custom slash commands from ``.firebender/commands/*.mdc``."""
return f"speckit.{template_name}.mdc"

View File

@@ -232,30 +232,6 @@ class IntegrationManifest:
# transition. ``discard`` is a no-op when the key is absent.
self._recovered_files.discard(normalized)
def remove(self, rel_path: str | Path) -> bool:
"""Drop *rel_path* from the tracked file set and any recovered marker.
Operates purely on the manifest's recorded key; it does NOT touch the
file on disk. Returns ``True`` if an entry was present and removed.
Used to keep the manifest consistent after a caller deletes a stale
managed file that the current install no longer ships.
Input is normalized through the same lexical pipeline as
``record_existing`` / ``is_recovered``: absolute paths and paths
containing ``..`` segments are rejected (return ``False``) — such paths
can never be canonical manifest keys, so there is nothing to remove.
"""
rel = Path(rel_path)
if rel.is_absolute() or ".." in rel.parts:
return False
try:
abs_path = _validate_rel_path(rel, self.project_root)
normalized = abs_path.relative_to(self.project_root).as_posix()
except ValueError:
return False
self._recovered_files.discard(normalized)
return self._files.pop(normalized, None) is not None
# -- Querying ---------------------------------------------------------
@property

View File

@@ -1,45 +0,0 @@
"""Oh My Pi (omp) coding agent integration."""
from __future__ import annotations
from ..base import MarkdownIntegration
class OmpIntegration(MarkdownIntegration):
key = "omp"
config = {
"name": "Oh My Pi",
"folder": ".omp/",
"commands_subdir": "commands",
"install_url": "https://www.npmjs.com/package/@oh-my-pi/pi-coding-agent",
"requires_cli": True,
}
registrar_config = {
"dir": ".omp/commands",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": ".md",
}
context_file = "AGENTS.md"
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
# Diverges from MarkdownIntegration.build_exec_args because OMP's
# CLI parser treats `-p`/`--print` as a boolean (one-shot mode) and
# consumes the prompt as a positional argument — see args.ts in
# can1357/oh-my-pi. JSON output is selected via `--mode json`.
if not self.config or not self.config.get("requires_cli"):
return None
args = [self._resolve_executable(), "--print"]
self._apply_extra_args_env_var(args)
if model:
args.extend(["--model", model])
if output_json:
args.extend(["--mode", "json"])
args.append(prompt)
return args

View File

@@ -31,7 +31,6 @@ from ..extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priorit
from .._init_options import is_ai_skills_enabled
from ..integrations.base import IntegrationBase
from .._utils import dump_frontmatter
from ..shared_infra import verify_archive_sha256
def _substitute_core_template(
@@ -2506,10 +2505,6 @@ class PresetCatalog:
with self._open_url(download_url, timeout=60, extra_headers=extra_headers) as response:
zip_data = response.read()
verify_archive_sha256(
zip_data, pack_info.get("sha256"), pack_id, PresetError
)
zip_path.write_bytes(zip_data)
return zip_path

View File

@@ -2,9 +2,6 @@
from __future__ import annotations
import hashlib
import hmac
import logging
import os
import re
import tempfile
@@ -14,74 +11,6 @@ from typing import Any
from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest
logger = logging.getLogger(__name__)
# Matches a SHA-256 digest in its normalized form: exactly 64 hexadecimal
# characters. Callers lowercase the declared value before matching (see
# ``expected_hex = raw.lower()`` below), so an uppercase digest is accepted and
# normalized rather than rejected.
_SHA256_HEX_RE = re.compile(r"^[0-9a-f]{64}$")
def verify_archive_sha256(
data: bytes,
expected: str | None,
name: str,
error_cls: type[Exception],
) -> None:
"""Verify downloaded archive bytes against a catalog-declared SHA-256.
Catalog entries may pin the expected digest of their release archive in a
``sha256`` field (optionally prefixed with ``"sha256:"``). When present, the
downloaded bytes must match before they are written to disk and installed,
so a corrupted or tampered archive is rejected even though the transport was
HTTPS. Entries without a declared digest are accepted unchanged, keeping the
check backwards compatible.
Args:
data: The raw downloaded archive bytes.
expected: The catalog-declared SHA-256 hex digest, or ``None``.
name: The extension/preset id, used in the error message.
error_cls: Exception type to raise on mismatch (e.g. ``ExtensionError``).
Raises:
error_cls: If ``expected`` is provided and is not a well-formed
SHA-256 hex digest, or does not match ``data``.
"""
# Skip only when no digest is declared at all (``None``). A declared but
# empty/blank value (e.g. ``sha256: ""``) is an authoring error, not an
# opt-out: let it fall through to the format check below so it is rejected
# rather than silently disabling verification.
if expected is None:
logger.debug(
"No sha256 declared for %r; archive integrity was not verified.",
name,
)
return
# Strip *only* a literal ``sha256:`` algorithm prefix (case-insensitive).
# Any other prefix is part of the value and must not be silently dropped,
# otherwise a malformed or wrong-algorithm digest (e.g. ``md5:...``) would
# be quietly accepted as if it were a valid SHA-256.
raw = str(expected).strip()
if raw[:7].lower() == "sha256:":
raw = raw[7:].strip()
expected_hex = raw.lower()
if not _SHA256_HEX_RE.match(expected_hex):
raise error_cls(
f"Invalid sha256 declared for {name!r}: expected 64 hexadecimal "
f"characters (optionally prefixed with 'sha256:'), got "
f"{expected!r}."
)
actual_hex = hashlib.sha256(data).hexdigest()
# Constant-time comparison: both sides are fixed-length hex digests, so use
# ``hmac.compare_digest`` to avoid leaking information through timing.
if not hmac.compare_digest(actual_hex, expected_hex):
raise error_cls(
f"Integrity check failed for {name!r}: the catalog declares "
f"sha256 {expected_hex}, but the downloaded archive is "
f"{actual_hex}. The archive may be corrupted or tampered with."
)
class SymlinkedSharedPathError(ValueError):
"""Raised when a shared infrastructure path or ancestor is a symlink.
@@ -375,7 +304,7 @@ def install_shared_infra(
customization warning to tell the user which flag would overwrite their
customizations.
"""
from .integrations.manifest import _sha256, _validate_rel_path
from .integrations.manifest import _sha256
manifest = load_speckit_manifest(project_path, version=version, console=console)
prior_hashes = dict(manifest.files)
@@ -396,11 +325,6 @@ def install_shared_infra(
symlinked_files: list[str] = []
planned_copies: list[tuple[Path, str, bytes, int]] = []
planned_templates: list[tuple[Path, str, str]] = []
# Track every shared path the current bundle produces so we can detect
# manifest entries the core no longer ships (stale-script cleanup, #3076).
seen_rels: set[str] = set()
scripts_scanned = False
variant_dir = "bash" if script_type == "sh" else "powershell"
def _decide_overwrite(rel: str, dst: Path) -> tuple[bool, str | None]:
"""Return (write, bucket) where bucket is 'skip', 'preserved', or None."""
@@ -455,6 +379,7 @@ def install_shared_infra(
if scripts_src.is_dir():
dest_scripts = project_path / ".specify" / "scripts"
if _ensure_or_bucket_dir(dest_scripts):
variant_dir = "bash" if script_type == "sh" else "powershell"
variant_src = scripts_src / variant_dir
if variant_src.is_dir():
dest_variant = dest_scripts / variant_dir
@@ -462,18 +387,10 @@ def install_shared_infra(
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
# Mark scanned only once a real source file is seen. An
# empty (or symlink-skipped) variant keeps this False, so
# stale-cleanup is skipped — otherwise it would treat every
# tracked script as obsolete and delete it. (The safety
# hinge is this flag, not ``seen_rels``, which also holds
# template paths populated later.)
scripts_scanned = True
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
rel = dst_path.relative_to(project_path).as_posix()
seen_rels.add(rel)
if not _safe_dest_or_bucket(dst_path, rel, parent_must_exist=False):
continue
write, bucket = _decide_overwrite(rel, dst_path)
@@ -525,7 +442,6 @@ def install_shared_infra(
dst = dest_templates / src.name
rel = dst.relative_to(project_path).as_posix()
seen_rels.add(rel)
if not _safe_dest_or_bucket(dst, rel):
continue
write, bucket = _decide_overwrite(rel, dst)
@@ -605,63 +521,5 @@ def install_shared_infra(
if refresh_hint:
console.print(refresh_hint)
# Remove stale managed scripts: paths a previous install recorded that the
# current core no longer ships — e.g. the legacy
# ``scripts/<variant>/update-agent-context.sh`` superseded by the bundled
# agent-context extension. Left behind, such an orphan can crash when it
# sources a refreshed ``common.sh`` (#3076). Only run when the script source
# was actually scanned (so a missing/empty source never triggers mass
# deletion), scoped to the active variant, and only for *managed* copies —
# a user-customized file (hash diverges), a symlink, or a recovered entry is
# preserved by ``_is_managed``.
if scripts_scanned:
stale_removed: list[str] = []
script_prefix = f".specify/scripts/{variant_dir}/"
for rel in list(prior_hashes):
if rel in seen_rels or not rel.startswith(script_prefix):
continue
# Guard corrupted/hand-edited manifest keys BEFORE any filesystem
# access: absolute, ``..``, or (on Windows) drive-relative keys such
# as ``C:tmp`` are not ``is_absolute()`` yet discard the project root
# when joined. The lexical check is a fast reject; ``_validate_rel_path``
# resolves the join and confirms containment, catching the rest. A key
# that still escapes is *skipped*, never turned into an install-time
# hard failure. Mirrors IntegrationManifest.is_recovered / remove.
rel_path = Path(rel)
if rel_path.is_absolute() or ".." in rel_path.parts:
continue
try:
_validate_rel_path(rel_path, project_path)
except ValueError:
continue
dst = project_path / rel_path
# Already gone from disk but still tracked: drop the orphaned manifest
# entry so the manifest stays consistent (nothing to unlink).
if not dst.exists() and not dst.is_symlink():
manifest.remove(rel)
continue
if not _is_managed(rel, dst):
continue # user-modified / symlink / recovered → preserve
# Never unlink through a symlinked ancestor (writes/deletes could
# escape the project root). The safe-destination check buckets such
# paths under ``symlinked_files`` and we leave them in place.
if not _safe_dest_or_bucket(dst, rel):
continue
try:
dst.unlink()
except OSError as exc:
console.print(f"[yellow]⚠[/yellow] could not remove stale {rel}: {exc}")
continue
manifest.remove(rel)
stale_removed.append(rel)
if stale_removed:
console.print(
f"[yellow]⚠[/yellow] Removed {len(stale_removed)} obsolete shared "
"script(s) left by a previous install:"
)
for path in stale_removed:
console.print(f" {path}")
manifest.save()
return True

View File

@@ -52,18 +52,9 @@ class WorkflowDefinition:
if not isinstance(self.default_options, dict):
self.default_options = {}
# Advisory pre-conditions (spec-kit version / integrations a workflow
# expects). Validated by ``validate_workflow`` (recognized keys only;
# see ``_RECOGNIZED_REQUIRES_KEYS``) but NOT enforced at run time — they
# are not a security boundary. In particular there is no
# ``requires.permissions`` capability gate: shell steps always run with
# the user's privileges.
#
# Holds the raw parsed value, so before ``validate_workflow`` runs it may
# be a non-mapping (``None`` for a bare ``requires:``, a list for
# ``requires: []``, etc.); typed ``Any`` rather than ``dict[str, Any]``
# to avoid implying it is always a mapping at this point.
self.requires: Any = data.get("requires", {})
# Requirements (declared but not yet enforced at runtime;
# enforcement is a planned enhancement)
self.requires: dict[str, Any] = data.get("requires", {})
# Inputs
self.inputs: dict[str, Any] = data.get("inputs", {})
@@ -96,15 +87,6 @@ class WorkflowDefinition:
# ID format: lowercase alphanumeric with hyphens
_ID_PATTERN = re.compile(r"^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$")
# Keys accepted under a workflow's ``requires`` block: the advisory
# pre-conditions documented for workflows (``speckit_version`` and
# ``integrations``). This is the *workflow* schema only — the bundle manifest's
# ``requires`` (see ``bundler/models/manifest.py``) is a separate schema that
# also carries ``tools``/``mcp``; those are not workflow ``requires`` keys.
# Any other key — notably ``permissions`` — is rejected by ``validate_workflow``
# so it is never mistaken for an enforced runtime control.
_RECOGNIZED_REQUIRES_KEYS = frozenset({"speckit_version", "integrations"})
# Valid step types (matching STEP_REGISTRY keys)
def _get_valid_step_types() -> set[str]:
"""Return valid step types from the registry, with a built-in fallback."""
@@ -195,36 +177,6 @@ def validate_workflow(definition: WorkflowDefinition) -> list[str]:
f"Input {input_name!r} has invalid default: {exc}"
)
# -- Requires ---------------------------------------------------------
# ``requires`` declares advisory pre-conditions (the spec-kit version and
# integrations a workflow expects). Only a fixed set of keys is recognized;
# reject anything else so authoring typos surface here instead of being
# silently ignored at runtime. In particular ``requires.permissions`` is
# rejected explicitly: it reads like a runtime capability gate, but no such
# gate exists — a ``shell`` step always runs with the user's privileges, so
# declaring it would give a false sense of sandboxing.
#
# Mirror ``inputs`` validation: an omitted block defaults to ``{}`` and is
# valid, but any present-but-non-mapping value — ``requires:`` (YAML null),
# ``requires: []`` or ``requires: ''`` — is an authoring error and must
# surface here rather than be silently ignored at runtime.
if not isinstance(definition.requires, dict):
errors.append("'requires' must be a mapping (or omitted).")
else:
for key in definition.requires:
if key == "permissions":
errors.append(
"'requires.permissions' is not a recognized or "
"enforced capability gate — shell steps always run "
"with the user's privileges. Remove it and gate "
"sensitive steps with a 'gate' step instead."
)
elif key not in _RECOGNIZED_REQUIRES_KEYS:
errors.append(
f"Unknown 'requires' key {key!r}. Recognized keys: "
f"{', '.join(sorted(_RECOGNIZED_REQUIRES_KEYS))}."
)
# -- Steps ------------------------------------------------------------
if not isinstance(definition.steps, list):
errors.append("'steps' must be a list.")

View File

@@ -31,7 +31,7 @@ class ShellStep(StepBase):
# control commands; catalog-installed workflows should be reviewed
# before use (see PUBLISHING.md for security guidance).
try:
proc = subprocess.run( # noqa: S602 -- intentional shell=True (see NOTE above)
proc = subprocess.run(
run_cmd,
shell=True,
capture_output=True,

View File

@@ -263,206 +263,6 @@ class TestInitIntegrationFlag:
assert (scripts_dir / "setup-plan.sh").exists()
assert (templates_dir / "plan-template.md").exists()
def test_shared_infra_removes_stale_managed_script(self, tmp_path):
"""A managed script the core no longer ships (e.g. the legacy
update-agent-context.sh, superseded by the agent-context extension) is
removed, and the manifest stops tracking it (#3076)."""
from specify_cli import _install_shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "stale-test"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
# Legacy orphan the current bundle no longer ships, recorded in the
# manifest as a managed file (hash matches on disk) — a pre-refactor install.
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
(scripts_dir / "update-agent-context.sh").write_text("# legacy orphan\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(stale_rel)
manifest.save()
_install_shared_infra(project, "sh", force=False)
# The orphan is gone and the manifest no longer tracks it.
assert not (scripts_dir / "update-agent-context.sh").exists()
refreshed = IntegrationManifest.load("speckit", project)
assert stale_rel not in refreshed.files
# Scripts the core DOES ship are installed and tracked.
assert (scripts_dir / "common.sh").exists()
assert ".specify/scripts/bash/common.sh" in refreshed.files
def test_shared_infra_preserves_modified_stale_script(self, tmp_path):
"""A user-modified stale script is preserved (hash diverges from the
managed baseline), never silently deleted (#3076)."""
from specify_cli import _install_shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "stale-modified"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
stale = scripts_dir / "update-agent-context.sh"
stale.write_text("# original managed\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(".specify/scripts/bash/update-agent-context.sh")
manifest.save()
# User customizes it after install → on-disk hash now diverges.
stale.write_text("# user customization\n", encoding="utf-8")
_install_shared_infra(project, "sh", force=False)
# Preserved: it is no longer a managed (hash-matching) copy.
assert stale.exists()
assert stale.read_text(encoding="utf-8") == "# user customization\n"
def test_shared_infra_prunes_orphan_manifest_entry_when_file_absent(self, tmp_path):
"""A stale manifest entry whose file is already gone from disk is pruned
so the manifest stays consistent, not left tracked forever (#3076 review)."""
from specify_cli import _install_shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "orphan-entry"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
stale = scripts_dir / "update-agent-context.sh"
stale.write_text("# legacy orphan\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(stale_rel)
manifest.save()
# File removed out of band, but the manifest still tracks it.
stale.unlink()
_install_shared_infra(project, "sh", force=False)
refreshed = IntegrationManifest.load("speckit", project)
assert stale_rel not in refreshed.files
def test_shared_infra_empty_script_source_keeps_tracked_scripts(self, tmp_path, monkeypatch):
"""If the bundle's script source dir exists but is empty, stale-cleanup
must NOT run (no source files seen → can't tell what's obsolete): a
previously-tracked script is preserved, never mass-deleted (#3076 review)."""
from specify_cli import _install_shared_infra, shared_infra
from specify_cli.integrations.manifest import IntegrationManifest
# Point the script source at an empty ``bash/`` directory.
empty_src = tmp_path / "empty-bundle" / "scripts"
(empty_src / "bash").mkdir(parents=True)
monkeypatch.setattr(shared_infra, "shared_scripts_source", lambda **kw: empty_src)
project = tmp_path / "empty-source"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
tracked_rel = ".specify/scripts/bash/common.sh"
(scripts_dir / "common.sh").write_text("# tracked\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(tracked_rel)
manifest.save()
_install_shared_infra(project, "sh", force=False)
# Empty source → scripts_scanned stays False → nothing deleted.
assert (scripts_dir / "common.sh").exists()
refreshed = IntegrationManifest.load("speckit", project)
assert tracked_rel in refreshed.files
def test_shared_infra_stale_cleanup_ignores_unsafe_manifest_keys(self, tmp_path):
"""A corrupted/hand-edited manifest key with a ``..`` segment is skipped
before any filesystem access — its traversal target is never deleted
(#3076 review, containment guard)."""
import hashlib
import json
from specify_cli import _install_shared_infra
project = tmp_path / "unsafe-key"
project.mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
manifest_dir = project / ".specify" / "integrations"
manifest_dir.mkdir(parents=True)
# A file the traversal key would resolve to (outside scripts/bash/).
victim = project / ".specify" / "scripts" / "keep-me.sh"
victim_bytes = b"# do not touch\n"
victim.write_bytes(victim_bytes)
# Hand-crafted manifest: a key under the script prefix but with a ``..``
# segment, with the *matching* hash so that — absent the containment guard
# — stale-cleanup would consider it managed and unlink the target.
traversal_key = ".specify/scripts/bash/../keep-me.sh"
(manifest_dir / "speckit.manifest.json").write_text(
json.dumps({
"integration": "speckit",
"version": "test",
"files": {traversal_key: hashlib.sha256(victim_bytes).hexdigest()},
}),
encoding="utf-8",
)
_install_shared_infra(project, "sh", force=False)
# The unsafe key was skipped; its target file is untouched.
assert victim.exists()
assert victim.read_bytes() == victim_bytes
def test_shared_infra_stale_cleanup_skips_escaping_key_without_failing(
self, tmp_path, monkeypatch
):
"""A key that passes the lexical guard but escapes containment — e.g. a
Windows drive-relative ``C:tmp`` that is not ``is_absolute()`` yet discards
the project root when joined — is skipped via ``_validate_rel_path``, never
unlinked, and never turned into an install-time hard failure (#3076 review
round 4). Simulated portably by forcing ``_validate_rel_path`` to reject the
managed key, since real drive-relative paths only escape on Windows."""
from specify_cli import _install_shared_infra
from specify_cli.integrations import manifest as manifest_mod
from specify_cli.integrations.manifest import IntegrationManifest
project = tmp_path / "escaping-key"
project.mkdir()
(project / ".specify").mkdir()
scripts_dir = project / ".specify" / "scripts" / "bash"
scripts_dir.mkdir(parents=True)
# A managed stale orphan that would normally be removed.
stale_rel = ".specify/scripts/bash/update-agent-context.sh"
stale = scripts_dir / "update-agent-context.sh"
stale.write_text("# legacy orphan\n", encoding="utf-8")
manifest = IntegrationManifest("speckit", project, version="test")
manifest.record_existing(stale_rel)
manifest.save()
# Force the containment check to reject this key, as it would for a
# drive-relative escape on Windows. The cleanup must skip it gracefully.
real_validate = manifest_mod._validate_rel_path
def fake_validate(rel, root):
if str(rel).endswith("update-agent-context.sh"):
raise ValueError("simulated drive-relative escape")
return real_validate(rel, root)
monkeypatch.setattr(manifest_mod, "_validate_rel_path", fake_validate)
# Must not raise (no install-time hard failure from a corrupted key).
_install_shared_infra(project, "sh", force=False)
# The escaping key was skipped, so its file is left untouched...
assert stale.exists()
assert stale.read_text(encoding="utf-8") == "# legacy orphan\n"
# ...yet the install otherwise completed: real scripts are installed.
assert (scripts_dir / "common.sh").exists()
def test_shared_infra_skip_warning_displayed(self, tmp_path, capsys):
"""Console warning is displayed when files are skipped."""
from specify_cli import _install_shared_infra

View File

@@ -1,45 +0,0 @@
"""Tests for FirebenderIntegration."""
from specify_cli.integrations import get_integration
from specify_cli.integrations.manifest import IntegrationManifest
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestFirebenderIntegration(MarkdownIntegrationTests):
KEY = "firebender"
FOLDER = ".firebender/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".firebender/commands"
CONTEXT_FILE = ".firebender/rules/specify-rules.mdc"
# Firebender reads custom slash commands from ``.firebender/commands/*.mdc``,
# so this integration uses the ``.mdc`` extension instead of the ``.md``
# default the base mixin assumes. Override the two extension-specific tests.
def test_registrar_config(self):
i = get_integration(self.KEY)
assert i.registrar_config["dir"] == self.REGISTRAR_DIR
assert i.registrar_config["format"] == "markdown"
assert i.registrar_config["args"] == "$ARGUMENTS"
assert i.registrar_config["extension"] == ".mdc"
def test_setup_creates_files(self, tmp_path):
i = get_integration(self.KEY)
m = IntegrationManifest(self.KEY, tmp_path)
created = i.setup(tmp_path, m)
assert len(created) > 0
cmd_files = [f for f in created if "scripts" not in f.parts]
for f in cmd_files:
assert f.exists()
assert f.name.startswith("speckit.")
assert f.name.endswith(".mdc")
def _expected_files(self, script_variant: str) -> list[str]:
# Firebender emits ``.mdc`` command files, so remap the base mixin's
# ``.md`` expectations for files under this integration's command dir.
cmd_dir = get_integration(self.KEY).registrar_config["dir"]
prefix = cmd_dir + "/"
return sorted(
f[:-3] + ".mdc" if f.startswith(prefix) and f.endswith(".md") else f
for f in super()._expected_files(script_variant)
)

View File

@@ -1,31 +0,0 @@
"""Tests for OmpIntegration."""
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
class TestOmpIntegration(MarkdownIntegrationTests):
KEY = "omp"
FOLDER = ".omp/"
COMMANDS_SUBDIR = "commands"
REGISTRAR_DIR = ".omp/commands"
CONTEXT_FILE = "AGENTS.md"
def test_build_exec_args_uses_omp_json_mode(self):
i = get_integration(self.KEY)
args = i.build_exec_args(
"/speckit.specify Build auth",
model="gpt-5",
)
assert args == [
"omp",
"--print",
"--model",
"gpt-5",
"--mode",
"json",
"/speckit.specify Build auth",
]

View File

@@ -116,34 +116,6 @@ class TestManifestPathTraversal:
assert len(removed) == 1
assert removed[0].name == "safe.txt"
def test_remove_drops_entry_and_is_noop_second_time(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
assert "f.txt" in m.files
assert m.remove("f.txt") is True
assert "f.txt" not in m.files
assert m.remove("f.txt") is False # already gone → no-op
def test_remove_rejects_absolute_path(self, tmp_path):
# Matches record_existing/is_recovered: an absolute key can never be a
# canonical manifest key, so remove() rejects it lexically and leaves
# the tracked entry untouched.
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
import sys
abs_input = "C:\\tmp\\f.txt" if sys.platform == "win32" else "/tmp/f.txt"
assert m.remove(abs_input) is False
assert "f.txt" in m.files
def test_remove_rejects_parent_traversal(self, tmp_path):
(tmp_path / "f.txt").write_text("x", encoding="utf-8")
m = IntegrationManifest("test", tmp_path)
m.record_existing("f.txt")
assert m.remove("../f.txt") is False
assert "f.txt" in m.files
class TestManifestCheckModified:
def test_unmodified_file(self, tmp_path):

View File

@@ -23,7 +23,7 @@ ALL_INTEGRATION_KEYS = [
# Stage 3 — standard markdown integrations
"claude", "qwen", "opencode", "junie", "kilocode", "auggie",
"roo", "rovodev", "codebuddy", "qodercli", "amp", "shai", "bob", "trae",
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent", "firebender",
"pi", "iflow", "kiro-cli", "windsurf", "vibe", "cursor-agent",
# Stage 4 — TOML integrations
"gemini", "tabnine",
# Stage 5 — skills, generic & option-driven integrations

View File

@@ -1,159 +1,15 @@
"""Consistency checks for agent configuration across runtime surfaces."""
import re
from pathlib import Path
import yaml
from specify_cli import AGENT_CONFIG
from specify_cli.extensions import CommandRegistrar
REPO_ROOT = Path(__file__).resolve().parent.parent
ISSUE_TEMPLATE_AGENT_KEYS = [
"amp",
"agy",
"auggie",
"claude",
"cline",
"codebuddy",
"codex",
"cursor-agent",
"devin",
"firebender",
"forge",
"gemini",
"copilot",
"goose",
"hermes",
"bob",
"iflow",
"junie",
"kilocode",
"kimi",
"kiro-cli",
"lingma",
"vibe",
"omp",
"opencode",
"pi",
"qodercli",
"qwen",
"roo",
"rovodev",
"shai",
"tabnine",
"trae",
"windsurf",
"zcode",
"zed",
]
def _issue_template(path: str) -> dict:
return yaml.safe_load((REPO_ROOT / path).read_text(encoding="utf-8"))
def _body_item_by_id(template: dict, item_id: str) -> dict:
for item in template["body"]:
if item.get("id") == item_id:
return item
raise AssertionError(f"Expected issue template body item {item_id!r}")
def _dropdown_options(path: str, item_id: str) -> list[str]:
item = _body_item_by_id(_issue_template(path), item_id)
return item["attributes"]["options"]
def _normalized_markdown(text: str) -> str:
return " ".join(text.split())
def _markdown_value_containing(path: str, marker: str) -> str:
template = _issue_template(path)
normalized_marker = _normalized_markdown(marker)
for item in template["body"]:
if item.get("type") != "markdown":
continue
value = item["attributes"]["value"]
if normalized_marker in _normalized_markdown(value):
return value
raise AssertionError(f"Expected issue template markdown containing {marker!r}")
def _markdown_paragraph_containing(path: str, marker: str) -> str:
value = _markdown_value_containing(path, marker)
normalized_marker = _normalized_markdown(marker)
for paragraph in re.split(r"\n\s*\n", value):
if normalized_marker in _normalized_markdown(paragraph):
return paragraph
raise AssertionError(f"Expected issue template paragraph containing {marker!r}")
def _supported_agent_names_from_agent_request_template() -> list[str]:
marker = "**Currently supported agents**:"
paragraph = _markdown_paragraph_containing(
".github/ISSUE_TEMPLATE/agent_request.yml",
marker,
)
supported_agents_text = _normalized_markdown(paragraph).split(marker, 1)[1].strip()
return [agent.strip() for agent in supported_agents_text.split(",")]
class TestAgentConfigConsistency:
"""Ensure agent configuration stays synchronized across key surfaces."""
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"}
issue_template_agent_keys = set(ISSUE_TEMPLATE_AGENT_KEYS)
missing_agent_keys = sorted(concrete_agent_keys - issue_template_agent_keys)
unexpected_agent_keys = sorted(issue_template_agent_keys - concrete_agent_keys)
duplicate_agent_keys = sorted(
key
for key in issue_template_agent_keys
if ISSUE_TEMPLATE_AGENT_KEYS.count(key) > 1
)
assert not missing_agent_keys, (
"Issue template agent list is missing AGENT_CONFIG keys: "
f"{missing_agent_keys}"
)
assert not unexpected_agent_keys, (
"Issue template agent list includes unknown AGENT_CONFIG keys: "
f"{unexpected_agent_keys}"
)
assert not duplicate_agent_keys, (
"Issue template agent list contains duplicate keys: "
f"{duplicate_agent_keys}"
)
issue_template_agent_names = [
AGENT_CONFIG[key]["name"] for key in ISSUE_TEMPLATE_AGENT_KEYS
]
assert "Generic (bring your own agent)" not in issue_template_agent_names
bug_options = _dropdown_options(
".github/ISSUE_TEMPLATE/bug_report.yml",
"ai-agent",
)
assert bug_options == issue_template_agent_names + ["Not applicable"]
feature_options = _dropdown_options(
".github/ISSUE_TEMPLATE/feature_request.yml",
"ai-agent",
)
assert feature_options == [
"All agents",
*issue_template_agent_names,
"Not applicable",
]
assert (
_supported_agent_names_from_agent_request_template()
== issue_template_agent_names
)
"""Ensure kiro-cli migration stays synchronized across key surfaces."""
def test_runtime_config_uses_kiro_cli_and_removes_q(self):
"""AGENT_CONFIG should include kiro-cli and exclude legacy q."""
@@ -361,12 +217,6 @@ class TestAgentConfigConsistency:
"expected '-' (propagated from SkillsIntegration.invoke_separator)"
)
def test_codex_dev_no_symlink_policy_in_agent_config(self):
"""Codex dev installs must expose the no-symlink policy as metadata."""
cfg = CommandRegistrar.AGENT_CONFIGS
assert cfg["codex"].get("dev_no_symlink") is True
def test_skills_agent_command_token_resolves_with_hyphen(self, tmp_path):
"""__SPECKIT_COMMAND_*__ tokens in extension commands resolve to /speckit-<cmd>
when registered for a skills-based agent (e.g. claude).

View File

@@ -143,11 +143,7 @@ def test_paths_only_text_mode_on_non_spec_branch(prereq_repo: Path) -> None:
@requires_bash
def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without --paths-only, feature directory validation must still fail on main.
The error must go to stderr and stdout must stay clean, so a caller that
parses stdout as JSON is not handed the error string instead (#3122).
"""
"""Without --paths-only, feature directory validation must still fail on main."""
script = prereq_repo / ".specify" / "scripts" / "bash" / "check-prerequisites.sh"
result = subprocess.run(
["bash", str(script), "--json"],
@@ -159,8 +155,6 @@ def test_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
assert "Feature directory not found" not in result.stdout
assert result.stdout.strip() == ""
# ── PowerShell tests ──────────────────────────────────────────────────────
@@ -219,11 +213,7 @@ def test_ps_paths_only_succeeds_on_spec_branch(prereq_repo: Path) -> None:
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
"""Without -PathsOnly, feature directory validation must still fail on main.
The error must land on stderr only, leaving stdout clean for -Json
callers that parse it as JSON (#3122).
"""
"""Without -PathsOnly, feature directory validation must still fail on main."""
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
@@ -235,51 +225,5 @@ def test_ps_normal_mode_still_validates_branch(prereq_repo: Path) -> None:
env=_clean_env(),
)
assert result.returncode != 0
assert "Feature directory not found" in result.stderr
assert "Feature directory not found" not in result.stdout
assert result.stdout.strip() == ""
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_missing_plan_error_goes_to_stderr(prereq_repo: Path) -> None:
"""A missing plan.md must report on stderr, not stdout (#3122)."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "plan.md not found" in result.stderr
assert "plan.md not found" not in result.stdout
assert result.stdout.strip() == ""
@pytest.mark.skipif(not (HAS_PWSH or _WINDOWS_POWERSHELL), reason="no PowerShell available")
def test_ps_missing_tasks_error_goes_to_stderr(prereq_repo: Path) -> None:
"""With -RequireTasks, a missing tasks.md must report on stderr only (#3122)."""
feat = prereq_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
_write_feature_json(prereq_repo)
script = prereq_repo / ".specify" / "scripts" / "powershell" / "check-prerequisites.ps1"
exe = "pwsh" if HAS_PWSH else _WINDOWS_POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json", "-RequireTasks"],
cwd=prereq_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "tasks.md not found" in result.stderr
assert "tasks.md not found" not in result.stdout
assert result.stdout.strip() == ""
combined = result.stdout + result.stderr
assert "Feature directory not found" in combined

View File

@@ -573,84 +573,6 @@ class TestExtensionSkillRegistration:
assert "speckit-test-ext-hello" in written
assert "Run this updated hello." in skill_file.read_text(encoding="utf-8")
def test_codex_dev_skill_registration_replaces_existing_dev_symlink(
self, project_dir, extension_dir, temp_dir
):
"""Codex dev skill registration should migrate prior dev symlinks to files."""
if not _can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
_create_init_options(project_dir, ai="codex", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="codex")
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
skill_file.parent.mkdir(parents=True, exist_ok=True)
cache_file = (
extension_dir
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
cache_file.parent.mkdir(parents=True)
cache_file.write_text("old linked content", encoding="utf-8")
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
written = manager._register_extension_skills(
manifest,
extension_dir,
link_outputs=True,
)
assert "speckit-test-ext-hello" in written
assert skill_file.exists()
assert not skill_file.is_symlink()
assert "Run this to say hello." in skill_file.read_text(encoding="utf-8")
assert cache_file.read_text(encoding="utf-8") == "old linked content"
def test_codex_dev_skill_registration_preserves_unrelated_symlink(
self, project_dir, extension_dir, temp_dir
):
"""Codex dev registration should not overwrite user-owned symlinks."""
if not _can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
_create_init_options(project_dir, ai="codex", ai_skills=True)
skills_dir = _create_skills_dir(project_dir, ai="codex")
manager = ExtensionManager(project_dir)
manifest = ExtensionManifest(extension_dir / "extension.yml")
skill_file = skills_dir / "speckit-test-ext-hello" / "SKILL.md"
skill_file.parent.mkdir(parents=True, exist_ok=True)
unrelated_cache_file = (
temp_dir
/ "other-extension"
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
unrelated_cache_file.parent.mkdir(parents=True)
unrelated_cache_file.write_text("user-owned linked content", encoding="utf-8")
os.symlink(
os.path.relpath(unrelated_cache_file, skill_file.parent), skill_file
)
written = manager._register_extension_skills(
manifest,
extension_dir,
link_outputs=True,
)
assert "speckit-test-ext-hello" not in written
assert skill_file.is_symlink()
assert skill_file.resolve(strict=True) == unrelated_cache_file.resolve()
assert unrelated_cache_file.read_text(encoding="utf-8") == (
"user-owned linked content"
)
def test_dev_skill_registration_falls_back_to_copy_when_symlink_fails(
self, skills_project, extension_dir, monkeypatch
):

View File

@@ -1669,47 +1669,6 @@ $ARGUMENTS
assert parsed["description"] == "first line\nsecond line\n"
def test_render_toml_command_preserves_backslashes_in_body(self):
"""A backslash in the body (e.g. a Windows path) must not break TOML.
A multiline basic string ("\"\"\"") processes backslash escapes, so
``C:\\Users`` (``\\U``) would render as invalid TOML; the body must
round-trip with backslashes intact.
"""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": "x"},
r"Run C:\Users\dev\tool.exe then report.",
"extension:test-ext",
)
parsed = tomllib.loads(output) # must not raise
assert parsed["prompt"].strip() == r"Run C:\Users\dev\tool.exe then report."
def test_render_toml_command_handles_trailing_backslash(self):
"""A body ending in a backslash must round-trip without corruption."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
output = registrar.render_toml_command(
{"description": "x"},
"path ends with sep\\",
"extension:test-ext",
)
parsed = tomllib.loads(output)
assert parsed["prompt"].strip() == "path ends with sep\\"
def test_render_toml_command_backslash_with_both_triple_quotes_escapes(self):
"""Body with a backslash and both triple-quote styles → escaped basic string."""
from specify_cli.agents import CommandRegistrar as AgentCommandRegistrar
registrar = AgentCommandRegistrar()
body = "a \\ b\nc \"\"\" d\ne ''' f"
output = registrar.render_toml_command({"description": "x"}, body, "extension:test-ext")
parsed = tomllib.loads(output)
assert parsed["prompt"] == body
def test_register_commands_for_claude(self, extension_dir, project_dir):
"""Test registering commands for Claude agent."""
# Create .claude directory
@@ -2289,50 +2248,6 @@ Run {SCRIPT}
assert target.is_file()
assert "Extension: test-ext" in cmd_file.read_text(encoding="utf-8")
def test_dev_register_commands_replaces_codex_dev_symlink(
self, extension_dir, project_dir, temp_dir
):
"""Codex dev registration should replace prior symlinks with real files."""
if not can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
skill_file = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
skill_file.parent.mkdir(parents=True)
cache_file = (
extension_dir
/ ".specify-dev"
/ "agent-commands"
/ "codex"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
cache_file.parent.mkdir(parents=True)
cache_file.write_text("old linked content", encoding="utf-8")
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
manifest = ExtensionManifest(extension_dir / "extension.yml")
registrar = CommandRegistrar()
registrar.register_commands_for_agent(
"codex",
manifest,
extension_dir,
project_dir,
link_outputs=True,
)
assert skill_file.exists()
assert not skill_file.is_symlink()
assert "name: speckit-test-ext-hello" in skill_file.read_text(
encoding="utf-8"
)
assert cache_file.read_text(encoding="utf-8") == "old linked content"
def test_dev_register_commands_falls_back_to_copy_when_symlink_fails(
self, extension_dir, project_dir, monkeypatch
):
@@ -3801,89 +3716,6 @@ class TestExtensionCatalog:
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].get_header("Accept") == "application/octet-stream"
def _make_zip_bytes(self):
"""Build a minimal valid extension ZIP in memory for download tests."""
import zipfile
import io
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("extension.yml", "id: test-ext\nname: Test\nversion: 1.0.0\n")
return buf.getvalue()
def _mock_response(self, data):
"""Build a context-manager mock HTTP response returning ``data``."""
from unittest.mock import MagicMock
resp = MagicMock()
resp.read.return_value = data
# Configure the context-manager protocol explicitly so `with resp`
# yields `resp` itself, independent of how the protocol is invoked.
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
return resp
def test_download_extension_accepts_matching_sha256(self, temp_dir):
"""A catalog ``sha256`` that matches the archive is accepted."""
import hashlib
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
zip_path = catalog.download_extension("test-ext", target_dir=temp_dir)
assert zip_path.read_bytes() == zip_bytes
def test_download_extension_rejects_sha256_mismatch(self, temp_dir):
"""A catalog ``sha256`` that does not match the downloaded archive
aborts the install — a tampered or swapped archive is rejected.
"""
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
"sha256": "0" * 64, # deliberately wrong
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
with pytest.raises(ExtensionError, match="[Ii]ntegrity"):
catalog.download_extension("test-ext", target_dir=temp_dir)
def test_download_extension_without_sha256_still_succeeds(self, temp_dir):
"""Entries without ``sha256`` keep working (backwards compatible)."""
from unittest.mock import patch
catalog = self._make_catalog(temp_dir)
zip_bytes = self._make_zip_bytes()
ext_info = {
"id": "test-ext",
"name": "Test Extension",
"version": "1.0.0",
"download_url": "https://example.com/test-ext.zip",
}
with patch.object(catalog, "get_extension_info", return_value=ext_info), \
patch.object(catalog, "_open_url", return_value=self._mock_response(zip_bytes)):
zip_path = catalog.download_extension("test-ext", target_dir=temp_dir)
assert zip_path.read_bytes() == zip_bytes
def test_download_extension_accepts_direct_github_rest_asset_url(self, temp_dir, monkeypatch):
"""download_extension can use a GitHub REST release asset URL directly."""
from unittest.mock import patch, MagicMock
@@ -5042,93 +4874,6 @@ class TestExtensionAddCLI:
else:
assert not agent_file.is_symlink()
def test_add_dev_writes_codex_skills_as_files(self, extension_dir, project_dir):
"""Codex dev skills should be written as files so Codex can load them."""
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(
json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8"
)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", str(extension_dir), "--dev"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
skill_file = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
assert skill_file.exists()
assert not skill_file.is_symlink()
content = skill_file.read_text(encoding="utf-8")
assert "name: speckit-test-ext-hello" in content
assert "metadata:" in content
assert "source: test-ext:commands/hello.md" in content
def test_add_dev_replaces_existing_codex_skill_symlink(
self, extension_dir, project_dir, temp_dir
):
"""Codex dev installs should migrate expected dev symlinks to files."""
if not can_create_symlink(temp_dir):
pytest.skip("Current platform/user cannot create symlinks")
from typer.testing import CliRunner
from unittest.mock import patch
from specify_cli import app
init_options = project_dir / ".specify" / "init-options.json"
init_options.write_text(
json.dumps({"ai": "codex", "ai_skills": True}), encoding="utf-8"
)
skill_file = (
project_dir
/ ".agents"
/ "skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
skill_file.parent.mkdir(parents=True)
cache_file = (
extension_dir
/ ".specify-dev"
/ "extension-skills"
/ "speckit-test-ext-hello"
/ "SKILL.md"
)
cache_file.parent.mkdir(parents=True)
cache_file.write_text("old linked content", encoding="utf-8")
os.symlink(os.path.relpath(cache_file, skill_file.parent), skill_file)
runner = CliRunner()
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app,
["extension", "add", str(extension_dir), "--dev"],
catch_exceptions=True,
)
assert result.exit_code == 0, result.output
assert skill_file.exists()
assert not skill_file.is_symlink()
content = skill_file.read_text(encoding="utf-8")
assert "name: speckit-test-ext-hello" in content
assert "source: test-ext:commands/hello.md" in content
assert cache_file.read_text(encoding="utf-8") == "old linked content"
def test_add_dev_falls_back_to_copy_when_windows_symlinks_unavailable(
self, extension_dir, project_dir, monkeypatch
):

View File

@@ -2019,90 +2019,6 @@ class TestPresetCatalog:
assert captured[1].get_header("Authorization") == "Bearer ghp_testtoken"
assert captured[1].get_header("Accept") == "application/octet-stream"
def _pack_zip_and_response(self):
"""Build a minimal preset ZIP and a context-manager mock response."""
from unittest.mock import MagicMock
import io
zip_buf = io.BytesIO()
with zipfile.ZipFile(zip_buf, "w") as zf:
zf.writestr("preset.yml", "id: test-pack\nname: Test\nversion: 1.0.0\n")
zip_bytes = zip_buf.getvalue()
resp = MagicMock()
resp.read.return_value = zip_bytes
# Configure the context-manager protocol explicitly so `with resp`
# yields `resp` itself, independent of how the protocol is invoked.
resp.__enter__.return_value = resp
resp.__exit__.return_value = False
return zip_bytes, resp
def test_download_pack_accepts_matching_sha256(self, project_dir):
"""A catalog ``sha256`` that matches the preset archive is accepted."""
import hashlib
from unittest.mock import patch
catalog = PresetCatalog(project_dir)
zip_bytes, resp = self._pack_zip_and_response()
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://example.com/test-pack.zip",
"sha256": hashlib.sha256(zip_bytes).hexdigest(),
"_install_allowed": True,
}
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url", return_value=resp):
zip_path = catalog.download_pack("test-pack", target_dir=project_dir)
assert zip_path.read_bytes() == zip_bytes
def test_download_pack_rejects_sha256_mismatch(self, project_dir):
"""A catalog ``sha256`` that does not match the archive aborts install."""
from unittest.mock import patch
catalog = PresetCatalog(project_dir)
_zip_bytes, resp = self._pack_zip_and_response()
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://example.com/test-pack.zip",
"sha256": "0" * 64, # deliberately wrong
"_install_allowed": True,
}
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url", return_value=resp):
with pytest.raises(PresetError, match="[Ii]ntegrity"):
catalog.download_pack("test-pack", target_dir=project_dir)
def test_download_pack_without_sha256_skips_verification(self, project_dir):
"""A catalog entry with no ``sha256`` keeps working: verification is
opt-in, so the backwards-compatible path (``pack_info.get("sha256")``
is ``None``) must download without aborting — mirrors the extensions
coverage so the helper never silently becomes mandatory for presets.
"""
from unittest.mock import patch
catalog = PresetCatalog(project_dir)
zip_bytes, resp = self._pack_zip_and_response()
pack_info = {
"id": "test-pack",
"name": "Test Pack",
"version": "1.0.0",
"download_url": "https://example.com/test-pack.zip",
"_install_allowed": True,
}
with patch.object(catalog, "get_pack_info", return_value=pack_info), \
patch.object(catalog, "_open_url", return_value=resp):
zip_path = catalog.download_pack("test-pack", target_dir=project_dir)
assert zip_path.read_bytes() == zip_bytes
def test_download_pack_accepts_direct_github_rest_asset_url(self, project_dir, monkeypatch):
"""download_pack can use a GitHub REST release asset URL directly."""
from unittest.mock import patch, MagicMock

View File

@@ -1,101 +0,0 @@
"""Unit tests for the shared archive-integrity helper.
These exercise ``verify_archive_sha256`` directly (independently of the
extension/preset download paths that call it) so the digest-matching,
mismatch, normalisation and "no digest declared" behaviours are pinned in
one place.
"""
from __future__ import annotations
import hashlib
import logging
import pytest
from specify_cli.shared_infra import verify_archive_sha256
class _BoomError(Exception):
"""Sentinel error type used to assert the helper raises ``error_cls``."""
def test_matching_digest_passes():
"""A digest that matches the data returns without raising."""
data = b"hello-archive"
digest = hashlib.sha256(data).hexdigest()
verify_archive_sha256(data, digest, "thing", _BoomError)
def test_mismatch_raises_error_cls():
"""A non-matching digest raises the caller-supplied error type."""
with pytest.raises(_BoomError, match="[Ii]ntegrity"):
verify_archive_sha256(b"data", "0" * 64, "thing", _BoomError)
def test_sha256_prefix_is_accepted():
"""A ``sha256:`` prefix on the expected digest is tolerated."""
data = b"prefixed"
digest = hashlib.sha256(data).hexdigest()
verify_archive_sha256(data, f"sha256:{digest}", "thing", _BoomError)
def test_comparison_is_case_insensitive():
"""An upper-cased expected digest still matches the lower-case actual."""
data = b"casing"
digest = hashlib.sha256(data).hexdigest().upper()
verify_archive_sha256(data, digest, "thing", _BoomError)
def test_malformed_digest_is_rejected():
"""A declared digest that is not 64 hex chars is rejected up front.
A too-short, too-long, or non-hex value is an authoring/catalog error and
must surface clearly instead of being treated as a digest that simply does
not match the archive.
"""
for bad in ("deadbeef", "z" * 64, "0" * 63, "0" * 65):
with pytest.raises(_BoomError, match="[Ii]nvalid sha256"):
verify_archive_sha256(b"data", bad, "thing", _BoomError)
def test_non_sha256_prefix_is_not_silently_stripped():
"""Only a literal ``sha256:`` prefix is stripped.
A different algorithm prefix (e.g. ``md5:``) must not be silently dropped
and accepted as if the remaining characters were a valid SHA-256 digest;
the value is rejected as malformed.
"""
data = b"prefixed"
digest = hashlib.sha256(data).hexdigest()
with pytest.raises(_BoomError, match="[Ii]nvalid sha256"):
verify_archive_sha256(data, f"md5:{digest}", "thing", _BoomError)
def test_absent_digest_skips_and_logs_debug(caplog):
"""When no digest is declared the helper returns and logs at DEBUG.
Installs stay backwards compatible (no error, no user-facing warning),
but the unverified download leaves an audit trail for operators who opt
into debug logging.
"""
with caplog.at_level(logging.DEBUG, logger="specify_cli.shared_infra"):
verify_archive_sha256(b"data", None, "thing", _BoomError)
assert any(
"not verified" in r.getMessage() and "thing" in r.getMessage()
for r in caplog.records
)
def test_blank_declared_digest_is_rejected():
"""A present-but-empty ``sha256`` is an authoring error, not an opt-out.
Catalog entries reach the helper via ``...get("sha256")``; a blank value
(``""``, whitespace, or a bare ``sha256:`` prefix) means the digest was
declared but left empty. It must surface as a malformed digest rather than
silently disabling the integrity check, which a bare ``if not expected``
guard would have done.
"""
for blank in ("", " ", "sha256:"):
with pytest.raises(_BoomError, match="[Ii]nvalid sha256"):
verify_archive_sha256(b"data", blank, "thing", _BoomError)

View File

@@ -869,52 +869,6 @@ class TestPowerShellDryRun:
assert "DRY_RUN" not in data, f"DRY_RUN should not be in normal JSON: {data}"
# ── Short-Word / Acronym Branch-Name Tests ──────────────────────────────────
def _branch_from_output(stdout: str) -> str | None:
for line in stdout.splitlines():
if line.startswith("BRANCH_NAME:"):
return line.split(":", 1)[1].strip()
return None
SHORT_WORD_CASES = [
# description, expected branch — "go" (lowercase short word) is dropped,
# "AI" (uppercase short word / acronym) is kept, "now" (>=3 chars) is kept.
("go AI now", "001-ai-now"),
# A short word that is lowercase everywhere is dropped entirely.
("go to the pub", "001-pub"),
]
@requires_bash
class TestShortWordRetentionBash:
"""A short word is kept only when it appears in uppercase (an acronym)."""
@pytest.mark.parametrize("description,expected", SHORT_WORD_CASES)
def test_short_word_retention(self, git_repo: Path, description: str, expected: str):
result = run_script(git_repo, "--dry-run", description)
assert result.returncode == 0, result.stderr
assert _branch_from_output(result.stdout) == expected
@pytest.mark.skipif(not _has_pwsh(), reason="pwsh not available")
class TestShortWordRetentionPowerShell:
"""PowerShell must match bash: a short word is kept only when uppercase.
Regression guard for the `-match` (case-insensitive) vs `-cmatch`
(case-sensitive) divergence — with `-match`, every short non-stop word
leaked into the branch name even when it was lowercase.
"""
@pytest.mark.parametrize("description,expected", SHORT_WORD_CASES)
def test_short_word_retention(self, ps_git_repo: Path, description: str, expected: str):
result = run_ps_script(ps_git_repo, "-DryRun", description)
assert result.returncode == 0, result.stderr
assert _branch_from_output(result.stdout) == expected
# ── GIT_BRANCH_NAME Override Tests ──────────────────────────────────────────

View File

@@ -1,15 +0,0 @@
"""Tests for specify_cli._utils.run_command."""
from __future__ import annotations
import inspect
import pytest
from specify_cli import run_command
def test_run_command_rejects_shell_execution_compatibly():
assert inspect.signature(run_command).parameters["shell"].default is False
with pytest.raises(ValueError, match="does not support shell=True"):
run_command(["echo", "blocked"], shell=True) # noqa: S604

View File

@@ -2115,148 +2115,6 @@ steps:
errors = validate_workflow(definition)
assert any("invalid type" in e.lower() for e in errors)
def test_requires_with_recognized_keys_is_valid(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
speckit_version: ">=0.7.2"
integrations:
any: ["claude", "gemini"]
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert errors == []
def test_requires_must_be_mapping(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires: "claude"
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("'requires' must be a mapping" in e for e in errors)
def test_requires_unknown_key_is_rejected(self):
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
speckit_version: ">=0.7.2"
typo_key: true
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("typo_key" in e and "requires" in e for e in errors)
def test_requires_permissions_is_rejected_as_not_enforced(self):
"""A `requires.permissions` block looks like a runtime capability gate
but no such gate exists — shell steps always run with the user's
privileges. Reject it explicitly so authors are not misled into
believing the declaration sandboxes execution.
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
permissions:
shell: true
steps:
- id: run
type: shell
run: "echo hi"
""")
errors = validate_workflow(definition)
# Assert on specific markers from the intended message (the offending
# key and the `gate` remediation) so the test fails if the validation
# path or wording drifts, rather than passing on any error that merely
# happens to contain "permissions" and "not".
assert any("requires.permissions" in e and "gate" in e for e in errors)
def test_requires_empty_sequence_is_rejected_as_non_mapping(self):
"""A non-mapping ``requires`` (e.g. an empty list) is an authoring
error. Mirroring ``inputs``, validation checks ``isinstance(..., dict)``
so ``requires: []`` surfaces instead of silently passing.
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires: []
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("'requires' must be a mapping" in e for e in errors)
def test_requires_yaml_null_is_rejected_as_non_mapping(self):
"""A bare ``requires:`` parses as YAML null. Like ``inputs``, a present
block must be a mapping, so YAML null is rejected as an authoring error
rather than being silently treated as an omitted block. (A truly
omitted ``requires`` defaults to ``{}`` and stays valid.)
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
requires:
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert any("'requires' must be a mapping" in e for e in errors)
def test_requires_omitted_is_valid(self):
"""A workflow with no ``requires`` block at all defaults to ``{}`` and
must validate cleanly — only a present-but-non-mapping value is an
error (guards against over-correcting YAML-null rejection into also
flagging the omitted case).
"""
from specify_cli.workflows.engine import WorkflowDefinition, validate_workflow
definition = WorkflowDefinition.from_string("""
workflow:
id: "test"
name: "Test"
version: "1.0.0"
steps:
- id: step-one
command: speckit.specify
""")
errors = validate_workflow(definition)
assert not any("requires" in e for e in errors)
# ===== Workflow Engine Tests =====

View File

@@ -268,22 +268,10 @@ When releasing a new version:
### Shell Steps
- **Shell runs with the user's privileges** — a `shell` step executes a local command directly; there is no capability sandbox. `requires` is an advisory pre-condition block (recognised keys: `speckit_version`, `integrations`), **not** a runtime permission gate — there is no `requires.permissions`. Gate sensitive commands explicitly with a `gate` step.
- **Avoid destructive commands** — don't delete files or directories without explicit confirmation via a gate
- **Quote variables** — use proper quoting in shell commands to handle spaces
- **Check exit codes** — shell step failures stop the workflow; make sure commands are robust
#### Security: shell steps execute arbitrary code
Workflow `shell` steps execute their `run` field through `/bin/sh` (POSIX) or the platform shell. There is no sandbox between the step and the user's machine: a malicious or buggy `run` block can read environment variables, modify files outside the project, exfiltrate data, or escalate privileges.
Catalog-listed workflows are reviewed at submission time (see [Verification Process](#verification-process)), but you should still treat every install as code-execution from an untrusted source until you have read the `workflow.yml`:
- **Before installing a workflow**, fetch the raw YAML and audit every `shell` step's `run` field directly. `specify workflow info <name>` only shows metadata (name, version, inputs, step IDs/types) — not the shell content that would actually execute.
- **Prefer explicit commands over interpolation** in `run` blocks: `{{ inputs.something }}` substitutions should be quoted and constrained via `enum` so a malicious input can't inject shell syntax.
- **Limit privilege**: shell steps inherit the user's environment. Workflows that need elevated access (sudo, secrets, GitHub tokens) should call them out explicitly in the README so reviewers can spot the requirement.
- **Authors**: if your workflow has shell steps that look risky out of context (deletions, network calls, credential reads), document the rationale in your README. Maintainers will reject submissions whose shell steps can't be justified at review time.
### Integration Flexibility
- **Set `integration` at workflow level** — use the `workflow.integration` field as the default