Compare commits

..

31 Commits

Author SHA1 Message Date
github-actions[bot]
41c06ed3b2 chore: bump version to 0.8.5 2026-05-04 16:37:48 +00:00
Alex Vieira
05d9aa3e90 feat(presets): add Spec2Cloud preset for Azure deployment workflow (#2413)
* feat(presets): add Spec2Cloud preset for Azure deployment workflow

Co-authored-by: Copilot <copilot@github.com>

* feat(presets): add Spec2Cloud preset details to community catalog

* fix(presets): update Spec2Cloud URL to point to the correct GitHub repository

* feat(presets): update Spec2Cloud entry with created_at and updated_at timestamps

* feat(presets): update Spec2Cloud version to 1.1.0 and adjust timestamps

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: update spec2cloud preset details and resolve merge conflicts

* fix: reorder Spec2Cloud entry in community presets for consistency

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-04 11:35:18 -05:00
Dyan Galih
521b0d9ef7 update security-review and memory-md extensions to latest versions (#2445)
* chore: update security-review extension to v1.4.2

* chore: update memory-md description and catalog updated_at
2026-05-04 10:07:58 -05:00
Nimra Akram
259494a328 fix: honor template overrides for tasks-template (#2278) (#2292)
* fix: honor template overrides for tasks-template (#2278)

- Add scripts/bash/setup-tasks.sh mirroring setup-plan.sh pattern
- Add scripts/powershell/setup-tasks.ps1 mirroring setup-plan.ps1 pattern
- Update tasks.md frontmatter to use dedicated setup-tasks scripts
- Resolve tasks template via override stack and emit path as TASKS_TEMPLATE in JSON output
- Reference resolved TASKS_TEMPLATE path in generate step instead of hardcoded path

* fix: remove stray EOF tokens from setup-tasks scripts

* fix: improve error messages for unresolved tasks-template

* test: update file inventory tests to include setup-tasks scripts

* fix: use Console::Error.WriteLine instead of Write-Error in setup-tasks.ps1

* fix: write prerequisite error messages to stderr in setup-tasks.ps1

* fix: validate tasks template is a file and normalize path in setup-tasks.ps1

* fix: improve tasks-template error message to mention full override stack

* test: add setup-tasks.sh to TestCopilotSkillsMode file inventory

* fix: skip feature-branch validation when feature.json pins FEATURE_DIR

* fix: correct override path in tasks-template error messages

* test: add integration tests for setup-tasks template resolution and branch validation

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* fix: correct fixture paths and add spec.md prerequisite checks

* fix: use correct .registry schema in preset priority test

* fix: remove stale aaa-preset block and duplicate comment in preset priority test

* fix: align preset directory names with registry IDs in priority test

---------

Co-authored-by: Nimraakram22 <nimra.akram123451@gmail.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-05-01 16:18:19 -05:00
Chris Roberts
94074064c5 Add token-analyzer to community catalog (#2433)
Extension ID: token-analyzer
Version: 0.1.0
Author: Chris Roberts | coderandhiker
Description: Captures, analyzes, and compares token consumption across SDD workflows
Repository: https://github.com/coderandhiker/spec-kit-token-analyzer

Co-authored-by: Chris Roberts <chris@Chriss-MacBook-Pro.local>
2026-05-01 13:41:28 -05:00
Manfred Riem
f60e28ddba docs: add April 2026 newsletter (#2434) 2026-05-01 13:38:25 -05:00
Manfred Riem
822a0e5c61 feat: emit init-time notice for git extension default change (#2165) (#2432)
Add a non-blocking Panel notice during `specify init` when the git
extension auto-enables, informing users that starting in v0.10.0 this
will require explicit opt-in via `specify extension add git`.

- src/specify_cli/__init__.py: track successful git extension install
  and display yellow "Notice: Git Default Changing" panel
- tests/integrations/test_cli.py: integration test validating notice
  content (v0.10.0 timeline, opt-in messaging, migration command)
- docs/reference/core.md: user-facing NOTE about the upcoming change

Closes #2165
2026-05-01 13:06:42 -05:00
Dyan Galih
6546026626 Update DyanGalih(Memory Hub and Security Review) community extensions (#2429)
* chore: update DyanGalih extensions to latest versions

* chore: update catalog root timestamp to current
2026-05-01 11:55:55 -05:00
Pascal THUET
38fd1f6cc2 Support controlled multi-install for safe AI agent integrations (#2389)
* support controlled multi-install integrations

* fix: harden multi-install integration state

* refactor: isolate integration runtime helpers

* fix: address copilot review feedback

* fix: address follow-up copilot feedback

* fix: tighten integration switch semantics

* fix: address final copilot review feedback

* fix: harden integration manifest read errors

* fix: refuse symlinked shared infra paths

* test: filter expected self-test preset warning

* test: address copilot review nits

* refactor: centralize safe shared infra writes

* fix: use no-follow writes for shared infra

* fix: keep default integration atomic on template refresh

* fix: harden shared infra error paths

* fix: preflight shared infra and future state schemas

* fix: support nested shared scripts during preflight

* test: tolerate wrapped schema error output

* fix: use safe default mode for shared text writes

* fix: use posix paths in shared skip output

* fix: share project guard for integration use

* fix: centralize spec-kit project guards

* fix: use posix project paths in cli output

* fix: harden shared manifest and upgrade refresh
2026-05-01 11:54:41 -05:00
Pascal THUET
63cad6ace6 chore(integrations): clean up docs and project guard (#2428) 2026-05-01 10:33:22 -05:00
Manfred Riem
fcd6a80a07 chore: release 0.8.4, begin 0.8.5.dev0 development (#2431)
* chore: bump version to 0.8.4

* chore: begin 0.8.5.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-05-01 10:17:58 -05:00
Ismael
bb8fd50763 fix(specify): correct self-referencing step number in validation flow (#2152) 2026-05-01 10:13:31 -05:00
dependabot[bot]
cc6f203dd9 chore(deps): bump DavidAnson/markdownlint-cli2-action (#2425)
Bumps [DavidAnson/markdownlint-cli2-action](https://github.com/davidanson/markdownlint-cli2-action) from 23.0.0 to 23.1.0.
- [Release notes](https://github.com/davidanson/markdownlint-cli2-action/releases)
- [Commits](ce4853d438...6b51ade7a9)

---
updated-dependencies:
- dependency-name: DavidAnson/markdownlint-cli2-action
  dependency-version: 23.1.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-05-01 08:56:13 -05:00
Thorsten Hindermann
de9d98683a Add security-governance to community catalog (#2386)
Co-authored-by: Your Name <your@email.example>
2026-05-01 08:17:12 -05:00
Thorsten Hindermann
4133c8a543 Add cross-platform-governance to community catalog (#2384)
Co-authored-by: Your Name <your@email.example>
2026-05-01 08:03:12 -05:00
Thorsten Hindermann
6ee8a887e0 Add architecture-governance to community catalog (#2383)
Co-authored-by: Your Name <your@email.example>
2026-05-01 07:57:02 -05:00
Thorsten Hindermann
b13eea1e27 Add a11y-governance to community catalog (#2381)
Co-authored-by: Your Name <your@email.example>
2026-05-01 07:47:22 -05:00
Alex Vieira
9fac01fb47 feat(extensions): add Spec2Cloud extension for Azure deployment workflow (#2412)
* feat(extensions): add Spec2Cloud extension for Azure deployment workflow

Co-authored-by: Copilot <copilot@github.com>

* Update extensions/catalog.community.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* Update extensions/catalog.community.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

* feat(extensions): update Spec2Cloud extension details and remove duplicate entry

Co-authored-by: Copilot <copilot@github.com>

* fix(extensions): correct formatting of updated_at timestamp

* fix(extensions): update Spec2Cloud extension version and timestamps

---------

Co-authored-by: Copilot <copilot@github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-01 07:46:48 -05:00
Chengyou Liu
5edc9a5358 fix: migrate extension commands on integration switch (#2404)
* fix: migrate extension commands on integration switch

When switching integrations (e.g. kimi → opencode), extension commands
were not re-registered for the new agent, leaving the new agent without
extension support and orphaning files in the old agent's directory.

Changes:
- Add ExtensionManager.unregister_agent_artifacts() to clean up old
  agent extension files and registry entries during switch
- Add ExtensionManager.register_enabled_extensions_for_agent() to
  re-register all enabled extensions for the new agent
- Wire both into integration_switch() after uninstall/install phases
- Handle skills mode (Copilot --skills) correctly
- Add tests for kimi→opencode→claude migration, Copilot skills mode,
  and disabled extension handling

Fixes extension commands not appearing after integration switch.

* Update src/specify_cli/extensions.py

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-05-01 07:41:38 -05:00
Jeff Williams
da1bf028ab feat: add Squad Bridge extension to community catalog (#2417)
* feat: add squad bridge extension to community catalog

Adds spec-kit-squad by jwill824 — a Spec Kit extension that bootstraps
and synchronizes a Squad agent team from your Speckit spec and tasks.

- 4 commands: init, generate, route, status
- 2 hooks: after_specify (generate), after_tasks (route)
- v1.0.0 release

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

* chore: add requires.tools for squad-cli in catalog entry

* Update extensions/catalog.community.json

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-29 17:05:08 -05:00
Manfred Riem
7cedd85f2a chore: release 0.8.3, begin 0.8.4.dev0 development (#2418)
* chore: bump version to 0.8.3

* chore: begin 0.8.4.dev0 development

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
2026-04-29 16:50:09 -05:00
Sakit
2cb848f0d3 Add Work IQ extension to community catalog (#2415)
* Add Work IQ extension to community catalog

Adds the Work IQ extension by sakitA to the community catalog.
Work IQ integrates Microsoft 365 organizational knowledge (emails,
meetings, documents, Teams) into spec-driven development workflows.

- 4 commands: ask, context, stakeholders, enrich
- 2 hooks: before_specify, after_specify
- Requires: speckit >=0.1.0, Node.js >=18.0.0, workiq CLI

Repository: https://github.com/sakitA/spec-kit-workiq

* Address PR review comments

- Fix download_url to use .zip (Spec Kit installer requires ZIP format)
- Bump top-level catalog updated_at to 2026-04-29

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Sakit Atakishiyev <satakishiyev@microsoft.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-04-29 16:41:19 -05:00
vishal-gandhi
237e918f11 feat(integrations): add Devin for Terminal skills-based integration (#2364)
* feat(integrations): add Devin for Terminal skills-based integration

- Register DevinIntegration as a SkillsIntegration with .devin/skills/ layout
- Add catalog entry, docs row, and supported-agents listing
- Display /speckit-<command> hyphen syntax in init "Next Steps" panel
  (matches Claude/Cursor/Copilot skills mode, since Devin invokes skills
  by directory name)

Closes #2346

* fix(devin): implement -p non-interactive dispatch; clarify skills comment

Addresses Copilot review on PR #2364:

- Override build_exec_args() in DevinIntegration to emit
  'devin -p <prompt> [--model X]' for non-interactive text dispatch
  (verified Devin CLI supports -p / --print). Returns None when
  output_json=True since Devin has no structured-output flag, so
  CommandStep workflows that require JSON cleanly raise
  NotImplementedError instead of crashing on an unknown CLI flag.
  requires_cli=True is retained for tool detection.

- Extend the skills-integrations enumeration comment in
  specify_cli/__init__.py to include copilot and devin so the
  comment matches the code below it.

* fix(devin): always return exec args; document plain-text stdout

Addresses third Copilot review comment on PR #2364.

Returning None from build_exec_args() when output_json=True
incorrectly used the codebase's IDE-only sentinel: workflow
CommandStep checks 'impl.build_exec_args("test") is None' to
detect non-dispatchable integrations (test_workflows.py exercises
this with WindsurfIntegration). The previous implementation made
Devin appear non-dispatchable to all command steps even though it
runs fine via 'devin -p'.

Always return the args list. When output_json is requested, Devin
is still dispatched and returns plain-text stdout instead of
structured JSON; the docstring documents this explicitly.

* docs(devin): include claude in skills-integrations enumeration comment

Addresses Copilot review on PR #2364: the comment listing skills
integrations omitted Claude, which is also a SkillsIntegration
subclass. Updated to keep the comment accurate for future readers.

* test(devin): add build_exec_args regression tests; bump catalog updated_at

Addresses Copilot review on PR #2364, per @mnriem's request to
'address the Copilot feedback, especially the testing ask':

- tests/integrations/test_integration_devin.py: add TestDevinBuildExecArgs
  with three regression assertions:
    * build_exec_args returns args (not the None IDE-only sentinel)
    * --output-format is never emitted, regardless of output_json
    * --model flag is passed through correctly
- integrations/catalog.json: bump top-level updated_at to reflect the
  Devin entry addition so downstream catalog consumers can detect the
  change reliably.
2026-04-29 16:22:06 -05:00
Quratulain-bilal
ab9c70262d fix: include --from git+... in upgrade hint to avoid PyPI squat package (#2411)
The compatibility-error messages in extensions.py and presets.py, plus the
extension troubleshooting guide, told users to upgrade with:

    uv tool install specify-cli --force

Without `--from git+https://github.com/github/spec-kit.git`, uv resolves
`specify-cli` from PyPI, where an unrelated package with the same name
(no author, no project URLs) ships a stub CLI that lacks `extension`,
`preset`, and most spec-kit commands. Users following the upgrade hint
land on the squat package and report "extension command removed"
(see #1982).

Reuse the existing `REINSTALL_COMMAND` constant in extensions.py and
import it from presets.py so all three call sites point at the GitHub
source. The doc fix also adds a one-line note explaining the PyPI
collision so the same advice doesn't get re-stripped later.

Refs #1982
2026-04-29 10:12:04 -05:00
Andrii Furmanets
c079b2cc32 fix: dispatch opencode commands via run (#2410) 2026-04-29 09:39:45 -05:00
Adrian Osorio Blanchard
1049e17a43 feat: add catalog discovery CLI commands (#2360)
* feat: add catalog discovery CLI commands

* fix: address second Copilot review

* fix: address third Copilot review

* fix: align catalog remove with displayed order

* fix: route local catalog config errors to local guidance

* fix: address integration catalog review feedback

* fix: accept numeric string catalog priorities

* fix: align catalog remove with visible entries

* fix: preserve invalid catalog root validation

* fix: include invalid catalog priority value

* fix: preserve falsy catalog root validation

* fix: clarify integration catalog guidance

* fix: align integration catalog list and remove

* fix: align integration catalog edge cases

* fix: clarify catalog error guidance tests

* fix: clarify integration catalog edge cases

* fix: harden integration catalog removal

* fix: validate integration state before catalog search

* fix: reject empty integration catalog URL

* fix: allow catalog remove to clean non-string URLs

* fix: address catalog env and priority review

* fix: align catalog source display names

* fix: align catalog fallback names
2026-04-29 07:24:30 -05:00
Dyan Galih
9cf3151a72 update security review extension catalog to v1.3.0 (#2374)
* chore: update security review catalog metadata

* fix: sync security review catalog with v1.3.0

* chore: refresh community catalog timestamp

* fix: update author information for Security Review catalog entry

* fix: correct author name format in Security Review catalog entry

* chore: refresh community catalog timestamps

* chore: reapply catalog formatting

* chore: align catalog formatting with main
2026-04-29 07:16:01 -05:00
Leonardo Nascimento
9483e5cb1f chore(catalog): bump v-model extension to v0.6.0 (#2399)
Update v-model extension entry in community catalog to reflect the v0.6.0
release: https://github.com/leocamello/spec-kit-v-model/releases/tag/v0.6.0

Highlights of v0.6.0:
- Domain Overlay Architecture (9 overlay manifests; automotive, medical,
  aerospace, general)
- ID Lifecycle Model (Proposed -> Active -> Deprecated -> Removed)
- Standards enrichment across all 11 commands (IEEE 1012:2016, ISO
  25010:2023, ISO 42030:2019, ISO 12207:2017, IEEE 1016, IEEE 29148,
  ISO 29119-4, ISO 14971, DO-178C, ARP4761A, INCOSE SE Handbook)
- Aerospace DO-178C support: Flight Warning Computer DAL-A golden fixture
- Test infrastructure: fixtures reorganized; +11 LLM-as-judge evals (42 -> 53)

Command count remains 14 (no new commands added in this release).
Stars updated to live count (21) from GitHub API.
2026-04-28 17:14:21 -05:00
NaviaSamal
38f99e8381 feat: add threatmodel extension to community catalog (#2369)
* feat: add threatmodel extension to community catalog

* update timestamp for catalogue freshness

* update timestamp for catalogue freshness

* Potential fix for pull request finding

Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>

* Update README.md

update readme.md with spec-kit-threatmodel

---------

Co-authored-by: Samal <navia.samal@sap.com>
Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com>
2026-04-28 17:02:42 -05:00
Thorsten Hindermann
16aa57fce4 Add isaqb-architecture-governance to community catalog (#2385)
Co-authored-by: Your Name <your@email.example>
2026-04-28 15:34:53 -05:00
Manfred Riem
bc3409e340 chore: release 0.8.2, begin 0.8.3.dev0 development (#2397)
* chore: bump version to 0.8.2

* chore: begin 0.8.3.dev0 development

* Update CHANGELOG.md

Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>

---------

Co-authored-by: github-actions[bot] <41898282+github-actions[bot]@users.noreply.github.com>
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-28 13:52:25 -05:00
65 changed files with 6789 additions and 1654 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**: 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
**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

@@ -15,7 +15,7 @@ jobs:
uses: actions/checkout@v6
- name: Run markdownlint-cli2
uses: DavidAnson/markdownlint-cli2-action@ce4853d43830c74c1753b39f3cf40f71c2031eb9 # v23
uses: DavidAnson/markdownlint-cli2-action@6b51ade7a9e4a75a7ad929842dd298a3804ebe8b # v23
with:
globs: |
'**/*.md'

View File

@@ -20,23 +20,17 @@ src/specify_cli/integrations/
├── base.py # IntegrationBase, MarkdownIntegration, TomlIntegration, YamlIntegration, SkillsIntegration
├── manifest.py # IntegrationManifest (file tracking)
├── claude/ # Example: SkillsIntegration subclass
── __init__.py # ClaudeIntegration class
│ └── scripts/ # Thin wrapper scripts
│ ├── update-context.sh
│ └── update-context.ps1
── __init__.py # ClaudeIntegration class
├── gemini/ # Example: TomlIntegration subclass
── __init__.py
│ └── scripts/
── __init__.py
├── windsurf/ # Example: MarkdownIntegration subclass
── __init__.py
│ └── scripts/
── __init__.py
├── copilot/ # Example: IntegrationBase subclass (custom setup)
── __init__.py
│ └── scripts/
── __init__.py
└── ... # One subpackage per supported agent
```
The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, and capabilities are derived from the integration classes for the Python integration layer. However, context-update behavior still requires explicit cases in the shared dispatcher scripts (`scripts/bash/update-agent-context.sh` and `scripts/powershell/update-agent-context.ps1`), which currently maintain their own supported-agent lists and agent-key→context-file mappings until they are migrated to registry-based dispatch.
The registry is the **single source of truth for Python integration metadata**. Supported agents, their directories, formats, capabilities, and context files are derived from the integration classes for the Python integration layer.
---
@@ -179,63 +173,11 @@ def _register_builtins() -> None:
# ...
```
### 4. Add scripts
### 4. Context file behavior
Create two thin wrapper scripts in `src/specify_cli/integrations/<package_dir>/scripts/` that delegate to the shared context-update scripts. Each is ~25 lines of boilerplate.
Set `context_file` on the integration class. The base integration setup creates or updates the managed Spec Kit section in that file, and uninstall removes the managed section when appropriate.
> **Note on `<package_dir>` vs `<key>`:** `<package_dir>` is the Python-safe directory name for your integration — it matches `<key>` exactly when the key contains no hyphens (e.g., key `"gemini"` → `gemini/`), but uses underscores when it does (e.g., key `"kiro-cli"` → `kiro_cli/`). The `IntegrationBase.key` class attribute always retains the original hyphenated value (e.g., `key = "kiro-cli"`), since that is what the CLI and registry use.
**`update-context.sh`:**
```bash
#!/usr/bin/env bash
# update-context.sh — <Agent Name> integration: create/update <context_file>
set -euo pipefail
_script_dir="$(cd "$(dirname "$0")" && pwd)"
_root="$_script_dir"
while [ "$_root" != "/" ] && [ ! -d "$_root/.specify" ]; do _root="$(dirname "$_root")"; done
if [ -z "${REPO_ROOT:-}" ]; then
if [ -d "$_root/.specify" ]; then
REPO_ROOT="$_root"
else
git_root="$(git rev-parse --show-toplevel 2>/dev/null || true)"
if [ -n "$git_root" ] && [ -d "$git_root/.specify" ]; then
REPO_ROOT="$git_root"
else
REPO_ROOT="$_root"
fi
fi
fi
exec "$REPO_ROOT/.specify/scripts/bash/update-agent-context.sh" <key>
```
**`update-context.ps1`:**
```powershell
# update-context.ps1 — <Agent Name> integration: create/update <context_file>
$ErrorActionPreference = 'Stop'
$scriptDir = Split-Path -Parent $MyInvocation.MyCommand.Definition
$repoRoot = try { git rev-parse --show-toplevel 2>$null } catch { $null }
if (-not $repoRoot -or -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = $scriptDir
$fsRoot = [System.IO.Path]::GetPathRoot($repoRoot)
while ($repoRoot -and $repoRoot -ne $fsRoot -and -not (Test-Path (Join-Path $repoRoot '.specify'))) {
$repoRoot = Split-Path -Parent $repoRoot
}
}
& "$repoRoot/.specify/scripts/powershell/update-agent-context.ps1" -AgentType <key>
```
Replace `<key>` with your integration key and `<Agent Name>` / `<context_file>` with the appropriate values.
You must also add the agent to the shared context-update scripts so the shared dispatcher recognises the new key:
- **`scripts/bash/update-agent-context.sh`** — add a file-path variable and a case in `update_specific_agent()`.
- **`scripts/powershell/update-agent-context.ps1`** — add a file-path variable, add the new key to the `AgentType` parameter's `[ValidateSet(...)]`, add a switch case in `Update-SpecificAgent`, and add an entry in `Update-AllExistingAgents`.
Only add custom setup logic when the agent needs non-standard behavior. Most integrations do not need wrapper scripts or separate context-update dispatch code.
### 5. Test it
@@ -422,7 +364,6 @@ Implementation: Extends `MarkdownIntegration` with custom `setup()` method that:
3. Applies Forge-specific transformations via `_apply_forge_transformations()`
4. Strips `handoffs` frontmatter key
5. Injects missing `name` fields
6. Ensures the shared `update-agent-context.*` scripts include a `forge` case that maps context updates to `AGENTS.md` and lists `forge` in their usage/help text
### Goose Integration
@@ -436,7 +377,7 @@ Implementation: Extends `YamlIntegration` (parallel to `TomlIntegration`):
2. Extracts title and description from frontmatter
3. Renders output as Goose recipe YAML (version, title, description, author, extensions, activities, prompt)
4. Uses `yaml.safe_dump()` for header fields to ensure proper escaping
5. Context updates map to `AGENTS.md` (shared with opencode/codex/pi/forge)
5. Sets `context_file = "AGENTS.md"` so the base setup manages the Spec Kit context section there
## Common Pitfalls

View File

@@ -2,6 +2,66 @@
<!-- insert new changelog below this comment -->
## [0.8.5] - 2026-05-04
### Changed
- feat(presets): add Spec2Cloud preset for Azure deployment workflow (#2413)
- update security-review and memory-md extensions to latest versions (#2445)
- fix: honor template overrides for tasks-template (#2278) (#2292)
- Add token-analyzer to community catalog (#2433)
- docs: add April 2026 newsletter (#2434)
- feat: emit init-time notice for git extension default change (#2165) (#2432)
- Update DyanGalih(Memory Hub and Security Review) community extensions (#2429)
- Support controlled multi-install for safe AI agent integrations (#2389)
- chore(integrations): clean up docs and project guard (#2428)
- chore: release 0.8.4, begin 0.8.5.dev0 development (#2431)
## [0.8.4] - 2026-05-01
### Changed
- fix(specify): correct self-referencing step number in validation flow (#2152)
- chore(deps): bump DavidAnson/markdownlint-cli2-action (#2425)
- Add security-governance to community catalog (#2386)
- Add cross-platform-governance to community catalog (#2384)
- Add architecture-governance to community catalog (#2383)
- Add a11y-governance to community catalog (#2381)
- feat(extensions): add Spec2Cloud extension for Azure deployment workflow (#2412)
- fix: migrate extension commands on integration switch (#2404)
- feat: add Squad Bridge extension to community catalog (#2417)
- chore: release 0.8.3, begin 0.8.4.dev0 development (#2418)
## [0.8.3] - 2026-04-29
### Changed
- Add Work IQ extension to community catalog (#2415)
- feat(integrations): add Devin for Terminal skills-based integration (#2364)
- fix: include --from git+... in upgrade hint to avoid PyPI squat package (#2411)
- fix: dispatch opencode commands via run (#2410)
- feat: add catalog discovery CLI commands (#2360)
- update security review extension catalog to v1.3.0 (#2374)
- chore(catalog): bump v-model extension to v0.6.0 (#2399)
- feat: add threatmodel extension to community catalog (#2369)
- Add isaqb-architecture-governance to community catalog (#2385)
- chore: release 0.8.2, begin 0.8.3.dev0 development (#2397)
## [0.8.2] - 2026-04-28
### Changed
- Add MarkItDown Document Converter extension to community catalog (#2390)
- feat: Speckit preset fiction book v1.7 - Support for RAG (Chroma DB) offline semantic search (#2367)
- fix(extensions): use explicit UTF-8 encoding when reading manifest YAML (#2370)
- catalog: add m365 community extension
- docs: replace deprecated --ai flag with --integration in all documentation (#2359)
- feat(extensions,presets): authenticate GitHub-hosted catalog and download requests with GITHUB_TOKEN/GH_TOKEN (#2331)
- Update extensify to v1.1.0 in community catalog (#2337)
- feat(init): deprecate --no-git flag, gate deprecations at v0.10.0 (#2357)
- Add Spec Orchestrator extension to community catalog (#2350)
- chore: release 0.8.1, begin 0.8.2.dev0 development (#2356)
## [0.8.1] - 2026-04-24
### Changed

0
EOF Normal file
View File

View File

@@ -230,11 +230,12 @@ The following community-contributed extensions are available in [`catalog.commun
| MAQA Trello Integration | Trello board integration for MAQA — populates board from specs, moves cards, real-time checklist ticking | `integration` | Read+Write | [spec-kit-maqa-trello](https://github.com/GenieRobot/spec-kit-maqa-trello) |
| MarkItDown Document Converter | Convert documents (PDF, Word, PowerPoint, Excel, and more) to Markdown for use as spec reference material | `docs` | Read+Write | [spec-kit-markitdown](https://github.com/BenBtg/spec-kit-markitdown) |
| Memory Loader | Loads .specify/memory/ files before lifecycle commands so LLM agents have project governance context | `docs` | Read-only | [spec-kit-memory-loader](https://github.com/KevinBrown5280/spec-kit-memory-loader) |
| Memory MD | Repository-native durable memory for Spec Kit projects | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
| Memory MD | Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context | `docs` | Read+Write | [spec-kit-memory-hub](https://github.com/DyanGalih/spec-kit-memory-hub) |
| MemoryLint | Agent memory governance tool: Automatically audits and fixes boundary conflicts between AGENTS.md and the constitution. | `process` | Read+Write | [memorylint](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/memorylint) |
| Microsoft 365 Integration | Fetch Teams messages, meeting transcripts, and SharePoint/OneDrive files as local Markdown for spec generation | `integration` | Read+Write | [spec-kit-m365](https://github.com/BenBtg/spec-kit-m365) |
| Onboard | Contextual onboarding and progressive growth for developers new to spec-kit projects. Explains specs, maps dependencies, validates understanding, and guides the next step | `process` | Read+Write | [spec-kit-onboard](https://github.com/dmux/spec-kit-onboard) |
| Optimize | Audit and optimize AI governance for context efficiency — token budgets, rule health, interpretability, compression, coherence, and echo detection | `process` | Read+Write | [spec-kit-optimize](https://github.com/sakitA/spec-kit-optimize) |
| OWASP LLM Threat Model | OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts | `code` | Read-only | [spec-kit-threatmodel](https://github.com/NaviaSamal/spec-kit-threatmodel) |
| Plan Review Gate | Require spec.md and plan.md to be merged via MR/PR before allowing task generation | `process` | Read-only | [spec-kit-plan-review-gate](https://github.com/luno/spec-kit-plan-review-gate) |
| PR Bridge | Auto-generate pull request descriptions, checklists, and summaries from spec artifacts | `process` | Read-only | [spec-kit-pr-bridge-](https://github.com/Quratulain-bilal/spec-kit-pr-bridge-) |
| Presetify | Create and validate presets and preset catalogs | `process` | Read+Write | [presetify](https://github.com/mnriem/spec-kit-extensions/tree/main/presetify) |
@@ -251,7 +252,7 @@ The following community-contributed extensions are available in [`catalog.commun
| Review Extension | Post-implementation comprehensive code review with specialized agents for code quality, comments, tests, error handling, type design, and simplification | `code` | Read-only | [spec-kit-review](https://github.com/ismaelJimenez/spec-kit-review) |
| Ripple | Detect side effects that tests can't catch after implementation — delta-anchored analysis across 9 domain-agnostic categories | `code` | Read+Write | [spec-kit-ripple](https://github.com/chordpli/spec-kit-ripple) |
| SDD Utilities | Resume interrupted workflows, validate project health, and verify spec-to-task traceability | `process` | Read+Write | [speckit-utils](https://github.com/mvanhorn/speckit-utils) |
| Security Review | Comprehensive security audit of codebases using AI-powered DevSecOps analysis | `code` | Read-only | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
| Security Review | Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews | `code` | Read+Write | [spec-kit-security-review](https://github.com/DyanGalih/spec-kit-security-review) |
| SFSpeckit | Enterprise Salesforce SDLC with 18 commands for the full SDD lifecycle. | `process` | Read+Write | [spec-kit-sf](https://github.com/ysumanth06/spec-kit-sf) |
| Ship Release Extension | Automates release pipeline: pre-flight checks, branch sync, changelog generation, CI verification, and PR creation | `process` | Read+Write | [spec-kit-ship](https://github.com/arunt14/spec-kit-ship) |
| Spec Reference Loader | Reads the ## References section from the feature spec and loads only the listed docs into context | `docs` | Read-only | [spec-kit-spec-reference-loader](https://github.com/KevinBrown5280/spec-kit-spec-reference-loader) |
@@ -262,18 +263,22 @@ The following community-contributed extensions are available in [`catalog.commun
| Spec Scope | Effort estimation and scope tracking — estimate work, detect creep, and budget time per phase | `process` | Read-only | [spec-kit-scope-](https://github.com/Quratulain-bilal/spec-kit-scope-) |
| Spec Sync | Detect and resolve drift between specs and implementation. AI-assisted resolution with human approval | `docs` | Read+Write | [spec-kit-sync](https://github.com/bgervin/spec-kit-sync) |
| Spec Validate | Comprehension validation, review gating, and approval state for spec-kit artifacts — staged quizzes, peer review SLA, and a hard gate before /speckit.implement | `process` | Read+Write | [spec-kit-spec-validate](https://github.com/aeltayeb/spec-kit-spec-validate) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure | `process` | Read+Write | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| SpecTest | Auto-generate test scaffolds from spec criteria, map coverage, and find untested requirements | `code` | Read+Write | [spec-kit-spectest](https://github.com/Quratulain-bilal/spec-kit-spectest) |
| Squad Bridge | Bootstrap and synchronize a Squad agent team from your Speckit spec and tasks | `process` | Read+Write | [spec-kit-squad](https://github.com/jwill824/spec-kit-squad) |
| Staff Review Extension | Staff-engineer-level code review that validates implementation against spec, checks security, performance, and test coverage | `code` | Read-only | [spec-kit-staff-review](https://github.com/arunt14/spec-kit-staff-review) |
| Status Report | Project status, feature progress, and next-action recommendations for spec-driven workflows | `visibility` | Read-only | [Open-Agent-Tools/spec-kit-status](https://github.com/Open-Agent-Tools/spec-kit-status) |
| Superpowers Bridge | Orchestrates obra/superpowers skills within the spec-kit SDD workflow across the full lifecycle (clarification, TDD, review, verification, critique, debugging, branch completion) | `process` | Read+Write | [superpowers-bridge](https://github.com/RbBtSn0w/spec-kit-extensions/tree/main/superpowers-bridge) |
| Superpowers Bridge (WangX0111) | Bridges spec-kit with obra/superpowers (brainstorming, TDD, subagent, code-review) into a unified, resumable workflow with graceful degradation and session progress tracking | `process` | Read+Write | [superspec](https://github.com/WangX0111/superspec) |
| TinySpec | Lightweight single-file workflow for small tasks — skip the heavy multi-step SDD process | `process` | Read+Write | [spec-kit-tinyspec](https://github.com/Quratulain-bilal/spec-kit-tinyspec) |
| Token Consumption Analyzer | Captures, analyzes, and compares token consumption across SDD workflows | `visibility` | Read-only | [spec-kit-token-analyzer](https://github.com/coderandhiker/spec-kit-token-analyzer) |
| V-Model Extension Pack | Enforces V-Model paired generation of development specs and test specs with full traceability | `docs` | Read+Write | [spec-kit-v-model](https://github.com/leocamello/spec-kit-v-model) |
| Verify Extension | Post-implementation quality gate that validates implemented code against specification artifacts | `code` | Read-only | [spec-kit-verify](https://github.com/ismaelJimenez/spec-kit-verify) |
| Verify Tasks Extension | Detect phantom completions: tasks marked [X] in tasks.md with no real implementation | `code` | Read-only | [spec-kit-verify-tasks](https://github.com/datastone-inc/spec-kit-verify-tasks) |
| Version Guard | Verify tech stack versions against live npm registries before planning and implementation | `process` | Read-only | [spec-kit-version-guard](https://github.com/KevinBrown5280/spec-kit-version-guard) |
| What-if Analysis | Preview the downstream impact (complexity, effort, tasks, risks) of requirement changes before committing to them | `visibility` | Read-only | [spec-kit-whatif](https://github.com/DevAbdullah90/spec-kit-whatif) |
| Wireframe Visual Feedback Loop | SVG wireframe generation, review, and sign-off for spec-driven development. Approved wireframes become spec constraints honored by /speckit.plan, /speckit.tasks, and /speckit.implement | `visibility` | Read+Write | [spec-kit-extension-wireframe](https://github.com/TortoiseWolfe/spec-kit-extension-wireframe) |
| Work IQ | Integrate Microsoft 365 organizational knowledge into spec-driven development workflows | `integration` | Read-only | [spec-kit-workiq](https://github.com/sakitA/spec-kit-workiq) |
| Worktree Isolation | Spawn isolated git worktrees for parallel feature development without checkout switching | `process` | Read+Write | [spec-kit-worktree](https://github.com/Quratulain-bilal/spec-kit-worktree) |
| Worktrees | Default-on worktree isolation for parallel agents — sibling or nested layout | `process` | Read+Write | [spec-kit-worktree-parallel](https://github.com/dango85/spec-kit-worktree-parallel) |

View File

@@ -7,15 +7,21 @@ The following community-contributed presets customize how Spec Kit behaves — o
| Preset | Purpose | Provides | Requires | URL |
|--------|---------|----------|----------|-----|
| A11Y Governance | Adds WCAG 2.2 AA accessibility checks, bilingual DE/EN delivery, CEFR-B2 readability, CLI accessibility, and inclusive-content guidance | 9 templates, 3 commands | — | [spec-kit-preset-a11y-governance](https://github.com/hindermath/spec-kit-preset-a11y-governance) |
| AIDE In-Place Migration | Adapts the AIDE extension workflow for in-place technology migrations (X → Y pattern) — adds migration objectives, verification gates, knowledge documents, and behavioral equivalence criteria | 2 templates, 8 commands | AIDE extension | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Architecture Governance | Adds secure architecture governance: trust boundaries, threat modeling, STRIDE/CAPEC, S-ADRs, Zero Trust applicability, and OWASP SAMM | 11 templates, 3 commands | — | [spec-kit-preset-architecture-governance](https://github.com/hindermath/spec-kit-preset-architecture-governance) |
| Canon Core | Adapts original Spec Kit workflow to work together with Canon extension | 2 templates, 8 commands | — | [spec-kit-canon](https://github.com/maximiliamus/spec-kit-canon) |
| Claude AskUserQuestion | Upgrades `/speckit.clarify` and `/speckit.checklist` on Claude Code from Markdown-table prompts to the native AskUserQuestion picker, with a recommended option and reasoning on every question | 2 commands | — | [spec-kit-preset-claude-ask-questions](https://github.com/0xrafasec/spec-kit-preset-claude-ask-questions) |
| Cross-Platform Governance | Adds Bash/PowerShell parity, dry-run/WhatIf parity, Unix man-page expectations, PowerShell comment-based help, and Verb-Noun Cmdlet discipline | 8 templates, 3 commands | — | [spec-kit-preset-cross-platform-governance](https://github.com/hindermath/spec-kit-preset-cross-platform-governance) |
| Explicit Task Dependencies | Adds explicit `(depends on T###)` dependency declarations and an Execution Wave DAG to tasks.md for parallel scheduling | 1 template, 1 command | — | [spec-kit-preset-explicit-task-dependencies](https://github.com/Quratulain-bilal/spec-kit-preset-explicit-task-dependencies) |
| Fiction Book Writing | It adapts the Spec-Driven Development workflow for storytelling to create books or audiobooks (with annotations) in 12 languages: features become story elements, specs become story briefs, plans become story structures, and tasks become scene-by-scene writing tasks. Supports single and multi-POV, all major plot structure frameworks, and two style modes: an author voice sample or humanized AI prose. Supports interactive elements like brainstorming, interview, roleplay and extras like statistics, cover builder and bio command. Export with templates for KDP, D2D etc. | 22 templates, 27 commands, 2 scripts | — | [speckit-preset-fiction-book-writing](https://github.com/adaumann/speckit-preset-fiction-book-writing) |
| iSAQB Architecture Governance | Adds general iSAQB/CPSA-F and arc42 architecture governance: goals, context, building blocks, runtime and deployment views, quality scenarios, ADRs, risks, and technical debt | 13 templates, 3 commands | — | [spec-kit-preset-isaqb-architecture-governance](https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance) |
| Jira Issue Tracking | Overrides `speckit.taskstoissues` to create Jira epics, stories, and tasks instead of GitHub Issues via Atlassian MCP tools | 1 command | — | [spec-kit-preset-jira](https://github.com/luno/spec-kit-preset-jira) |
| Multi-Repo Branching | Coordinates feature branch creation across multiple git repositories (independent repos and submodules) during plan and tasks phases | 2 commands | — | [spec-kit-preset-multi-repo-branching](https://github.com/sakitA/spec-kit-preset-multi-repo-branching) |
| Pirate Speak (Full) | Transforms all Spec Kit output into pirate speak — specs become "Voyage Manifests", plans become "Battle Plans", tasks become "Crew Assignments" | 6 templates, 9 commands | — | [spec-kit-presets](https://github.com/mnriem/spec-kit-presets) |
| Screenwriting | Spec-Driven Development for screenwriting/scriptwriting/tutorials: feature films, television (pilot, episode, limited series), and stage plays. Adapts the Spec Kit workflow to screenplay craft — slug lines, action lines, act breaks, beat sheets, and industry-standard pitch documents. Supports three-act, Save the Cat, TV pilot, network episode, cable/streaming episode, and stage-play structural frameworks. Export to Fountain, FTX, PDF | 26 templates, 32 commands, 1 script | — | [speckit-preset-screenwriting](https://github.com/adaumann/speckit-preset-screenwriting) |
| Security Governance | Adds secure development governance: memory-safe-language preference, secure code generation, NIST SSDF, CWE Top 25, OWASP ASVS, SBOM/VEX/SLSA, OpenSSF Scorecard, and EU CRA applicability | 12 templates, 3 commands | — | [spec-kit-preset-security-governance](https://github.com/hindermath/spec-kit-preset-security-governance) |
| Spec2Cloud | Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy | 5 templates, 8 commands | — | [spec2cloud](https://github.com/Azure-Samples/Spec2Cloud) |
| Table of Contents Navigation | Adds a navigable Table of Contents to generated spec.md, plan.md, and tasks.md documents | 3 templates, 3 commands | — | [spec-kit-preset-toc-navigation](https://github.com/Quratulain-bilal/spec-kit-preset-toc-navigation) |
| VS Code Ask Questions | Enhances the clarify command to use `vscode/askQuestions` for batched interactive questioning. | 1 command | — | [spec-kit-presets](https://github.com/fdcastel/spec-kit-presets) |

View File

@@ -22,6 +22,10 @@ specify init [<project_name>]
Creates a new Spec Kit project with the necessary directory structure, templates, scripts, and AI coding agent integration files.
> [!NOTE]
> The git extension is currently enabled by default during `specify init`.
> Starting in `v0.10.0`, it will require explicit opt-in. To add it after init, run `specify extension add git`.
Use `<project_name>` to create a new directory, or `--here` (or `.`) to initialize in the current directory. If the directory already has files, use `--force` to merge without confirmation.
### Examples

View File

@@ -13,6 +13,7 @@ The Specify CLI supports a wide range of AI coding agents. When you run `specify
| [CodeBuddy CLI](https://www.codebuddy.ai/cli) | `codebuddy` | |
| [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>` |
| [Forge](https://forgecode.dev/) | `forge` | |
| [Gemini CLI](https://github.com/google-gemini/gemini-cli) | `gemini` | |
| [GitHub Copilot](https://code.visualstudio.com/) | `copilot` | |
@@ -42,6 +43,8 @@ specify integration list
```
Shows all available integrations, which one is currently installed, and whether each requires a CLI tool or is IDE-based.
When multiple integrations are installed, the list marks the default integration separately from the other installed integrations.
The list also shows whether each built-in integration is declared multi-install safe.
## Install an Integration
@@ -52,9 +55,12 @@ specify integration install <key>
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--force` | Opt in to installing alongside integrations that are not declared multi-install safe |
| `--integration-options` | Integration-specific options (e.g. `--integration-options="--commands-dir .myagent/cmds"`) |
Installs the specified integration into the current project. Fails if another integration is already installed — use `switch` instead. If the installation fails partway through, it automatically rolls back to a clean state.
Installs the specified integration into the current project. If another integration is already installed, the command only proceeds automatically when all involved integrations are declared multi-install safe. Otherwise, use `switch` to replace the default integration or pass `--force` to explicitly opt in to multi-install. If the installation fails partway through, it automatically rolls back to a clean state.
Installing an additional integration does not change the default integration. Use `specify integration use <key>` to change the default.
> **Note:** All integration management commands require a project already initialized with `specify init`. To start a new project with a specific agent, use `specify init <project> --integration <key>` instead.
@@ -83,10 +89,22 @@ specify integration switch <key>
| Option | Description |
| ------------------------ | ------------------------------------------------------------------------ |
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--force` | Force removal of modified files during uninstall |
| `--integration-options` | Options for the target integration |
| `--force` | Force removal of modified files during uninstall; when the target is already installed, overwrite managed shared templates while changing the default |
| `--integration-options` | Options for the target integration when it is not already installed |
Equivalent to running `uninstall` followed by `install` in a single step.
If the target integration is not already installed, equivalent to running `uninstall` followed by `install` in a single step. In this mode, `--force` controls whether modified files from the removed integration are deleted. If the target integration is already installed, `switch` only changes the default integration, like `use`; in this mode, `--force` controls whether managed shared templates are overwritten while the default changes. `--integration-options` is rejected for already-installed targets because changing integration options requires reinstalling managed files; run `upgrade <key> --integration-options ...` first, then `use <key>`.
## Use an Installed Integration
```bash
specify integration use <key>
```
| Option | Description |
| --------- | --------------------------------------------------- |
| `--force` | Overwrite managed shared templates while changing the default |
Sets the default integration without uninstalling any other installed integrations. This also refreshes managed shared templates so command references match the new default integration's invocation style. Modified or untracked shared templates are preserved unless `--force` is used.
## Upgrade an Integration
@@ -100,7 +118,7 @@ specify integration upgrade [<key>]
| `--script sh\|ps` | Script type: `sh` (bash/zsh) or `ps` (PowerShell) |
| `--integration-options` | Options for the integration |
Reinstalls the current integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the currently installed integration; if a key is provided, it must match the installed one — otherwise the command fails and suggests using `switch` instead. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically.
Reinstalls an installed integration with updated templates and commands (e.g., after upgrading Spec Kit). Defaults to the default integration; if a key is provided, it must be one of the installed integrations. Detects locally modified files and blocks the upgrade unless `--force` is used. Stale files from the previous install that are no longer needed are removed automatically. Shared templates stay aligned with the default integration even when upgrading a non-default integration.
## Integration-Specific Options
@@ -119,9 +137,39 @@ specify integration install generic --integration-options="--commands-dir .myage
## FAQ
### Can I use multiple integrations at the same time?
### Can I install multiple integrations in the same project?
No. Only one AI coding agent integration can be installed per project. Use `specify integration switch <key>` to change to a different AI coding agent.
Yes, but it is intended for team portability rather than the default workflow. Multiple integrations are allowed automatically only when the installed integration and the new integration are declared multi-install safe by Spec Kit. For other combinations, pass `--force` to acknowledge that multiple agents may see unrelated agent-specific instructions or commands.
Spec Kit tracks one default integration in `.specify/integration.json` with `default_integration`, all installed integrations with `installed_integrations`, per-integration runtime settings with `integration_settings`, and a dedicated `integration_state_schema` for future state migrations. The legacy `integration` field remains as an alias for the default integration.
### Which integrations are multi-install safe?
An integration is multi-install safe when it uses isolated agent directories, a dedicated context file that does not collide with another safe integration, stable command invocation settings, and a separate install manifest. Shared Spec Kit templates remain aligned to the single default integration.
The currently declared multi-install safe integrations are:
| Key | Isolation |
| --- | --------- |
| `auggie` | `.augment/commands`, `.augment/rules/specify-rules.md` |
| `claude` | `.claude/skills`, `CLAUDE.md` |
| `codebuddy` | `.codebuddy/commands`, `CODEBUDDY.md` |
| `codex` | `.agents/skills`, `AGENTS.md` |
| `cursor-agent` | `.cursor/skills`, `.cursor/rules/specify-rules.mdc` |
| `gemini` | `.gemini/commands`, `GEMINI.md` |
| `iflow` | `.iflow/commands`, `IFLOW.md` |
| `junie` | `.junie/commands`, `.junie/AGENTS.md` |
| `kilocode` | `.kilocode/workflows`, `.kilocode/rules/specify-rules.md` |
| `kimi` | `.kimi/skills`, `KIMI.md` |
| `qodercli` | `.qoder/commands`, `QODER.md` |
| `qwen` | `.qwen/commands`, `QWEN.md` |
| `roo` | `.roo/commands`, `.roo/rules/specify-rules.md` |
| `shai` | `.shai/commands`, `SHAI.md` |
| `tabnine` | `.tabnine/agent/commands`, `TABNINE.md` |
| `trae` | `.trae/skills`, `.trae/rules/project_rules.md` |
| `windsurf` | `.windsurf/workflows`, `.windsurf/rules/specify-rules.md` |
Integrations that share a context file or command directory with another integration, require dynamic install paths such as `--commands-dir`, or merge shared tool settings are not declared safe by default. They can still be installed alongside another integration with `--force`.
### What happens to my changes when I uninstall or switch?
@@ -137,4 +185,4 @@ CLI-based integrations (like Claude Code, Gemini CLI) require the tool to be ins
### When should I use `upgrade` vs `switch`?
Use `upgrade` when you've upgraded Spec Kit and want to refresh the same integration's templates. Use `switch` when you want to change to a different AI coding agent.
Use `upgrade` when you've upgraded Spec Kit and want to refresh an installed integration's managed files. Use `switch` when you want to replace the current default with another integration; if the target is already installed, `switch` behaves like `use`.

View File

@@ -669,7 +669,7 @@ hooks:
**Error**: `Extension requires spec-kit >=0.2.0`
- **Fix**: Update spec-kit with `uv tool install specify-cli --force`
- **Fix**: Update spec-kit with `uv tool install specify-cli --force --from git+https://github.com/github/spec-kit.git`. The bare `specify-cli` package on PyPI is a different, unrelated project — installing it without `--from git+...` will give you a stub CLI that does not include `extension`, `preset`, or other spec-kit commands.
**Error**: `Command file not found`

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-28T00:00:00Z",
"updated_at": "2026-05-03T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/extensions/catalog.community.json",
"extensions": {
"aide": {
@@ -1278,20 +1278,20 @@
"memory-md": {
"name": "Memory MD",
"id": "memory-md",
"description": "Repository-native durable memory for Spec Kit projects",
"description": "Spec Kit extension for repository-native Markdown memory that captures durable decisions, bugs, and project context",
"author": "DyanGalih",
"version": "0.6.2",
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.6.2.zip",
"version": "0.7.5",
"download_url": "https://github.com/DyanGalih/spec-kit-memory-hub/archive/refs/tags/v0.7.5.zip",
"repository": "https://github.com/DyanGalih/spec-kit-memory-hub",
"homepage": "https://github.com/DyanGalih/spec-kit-memory-hub",
"documentation": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/README.md",
"changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/docs/memory-workflow-v0.6.md",
"changelog": "https://github.com/DyanGalih/spec-kit-memory-hub/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.6.0"
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 5,
"commands": 6,
"hooks": 0
},
"tags": [
@@ -1299,13 +1299,14 @@
"workflow",
"docs",
"copilot",
"markdown"
"markdown",
"ai-context"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-23T00:00:00Z",
"updated_at": "2026-04-23T00:00:00Z"
"updated_at": "2026-05-03T00:00:00Z"
},
"memorylint": {
"name": "MemoryLint",
@@ -1929,10 +1930,10 @@
"security-review": {
"name": "Security Review",
"id": "security-review",
"description": "Comprehensive security audit of codebases using AI-powered DevSecOps analysis",
"description": "Full-project secure-by-design security audits plus staged, branch/PR, plan, task, follow-up, and apply reviews",
"author": "DyanGalih",
"version": "1.1.1",
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.1.1.zip",
"version": "1.4.2",
"download_url": "https://github.com/DyanGalih/spec-kit-security-review/archive/refs/tags/v1.4.2.zip",
"repository": "https://github.com/DyanGalih/spec-kit-security-review",
"homepage": "https://github.com/DyanGalih/spec-kit-security-review",
"documentation": "https://github.com/DyanGalih/spec-kit-security-review/blob/main/README.md",
@@ -1942,7 +1943,7 @@
"speckit_version": ">=0.1.0"
},
"provides": {
"commands": 3,
"commands": 7,
"hooks": 0
},
"tags": [
@@ -1956,7 +1957,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-03T03:24:03Z",
"updated_at": "2026-04-03T04:15:00Z"
"updated_at": "2026-05-03T00:00:00Z"
},
"sf": {
"name": "SFSpeckit — Salesforce Spec-Driven Development",
@@ -2095,6 +2096,38 @@
"created_at": "2026-04-20T00:00:00Z",
"updated_at": "2026-04-21T00:00:00Z"
},
"spec2cloud": {
"name": "Spec2Cloud",
"id": "spec2cloud",
"description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy.",
"author": "Azure Samples",
"version": "1.1.0",
"download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/extension.zip",
"repository": "https://github.com/Azure-Samples/Spec2Cloud",
"homepage": "https://aka.ms/spec2cloud",
"documentation": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/README.md",
"changelog": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.4.0"
},
"provides": {
"commands": 2,
"hooks": 0
},
"tags": [
"spec2cloud",
"azure",
"cloud",
"deploy",
"workflow"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-30T00:00:00Z",
"updated_at": "2026-04-30T00:00:00Z"
},
"speckit-utils": {
"name": "SDD Utilities",
"id": "speckit-utils",
@@ -2160,6 +2193,45 @@
"created_at": "2026-04-10T16:00:00Z",
"updated_at": "2026-04-10T16:00:00Z"
},
"squad": {
"name": "Squad Bridge",
"id": "squad",
"description": "Bootstrap and synchronize a Squad agent team from your Spec Kit spec and tasks.",
"author": "jwill824",
"version": "1.1.0",
"download_url": "https://github.com/jwill824/spec-kit-squad/archive/refs/tags/v1.1.0.zip",
"repository": "https://github.com/jwill824/spec-kit-squad",
"homepage": "https://github.com/jwill824/spec-kit-squad",
"documentation": "https://github.com/jwill824/spec-kit-squad/blob/main/README.md",
"changelog": "https://github.com/jwill824/spec-kit-squad/blob/main/docs/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "@bradygaster/squad-cli",
"version": ">=0.1.0",
"required": true
}
]
},
"provides": {
"commands": 4,
"hooks": 2
},
"tags": [
"multi-agent",
"agents",
"orchestration",
"process",
"integration"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-29T00:00:00Z",
"updated_at": "2026-04-29T00:00:00Z"
},
"staff-review": {
"name": "Staff Review Extension",
"id": "staff-review",
@@ -2392,13 +2464,76 @@
"created_at": "2026-04-10T00:00:00Z",
"updated_at": "2026-04-10T00:00:00Z"
},
"threatmodel": {
"name": "OWASP LLM Threat Model",
"id": "threatmodel",
"description": "OWASP Top 10 for LLM Applications 2025 threat analysis on agent artifacts",
"author": "NaviaSamal",
"version": "1.0.0",
"download_url": "https://github.com/NaviaSamal/spec-kit-threatmodel/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/NaviaSamal/spec-kit-threatmodel",
"homepage": "https://github.com/NaviaSamal/spec-kit-threatmodel",
"documentation": "https://github.com/NaviaSamal/spec-kit-threatmodel/blob/main/README.md",
"changelog": "https://github.com/NaviaSamal/spec-kit-threatmodel/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.6.0"
},
"provides": {
"commands": 1,
"hooks": 1
},
"tags": [
"security",
"owasp",
"threat-model",
"llm",
"analysis"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-25T00:00:00Z",
"updated_at": "2026-04-25T00:00:00Z"
},
"token-analyzer": {
"name": "Token Consumption Analyzer",
"id": "token-analyzer",
"description": "Captures, analyzes, and compares token consumption across SDD workflows",
"author": "Chris Roberts | coderandhiker",
"version": "0.1.0",
"download_url": "https://github.com/coderandhiker/spec-kit-token-analyzer/archive/refs/tags/v0.1.0.zip",
"repository": "https://github.com/coderandhiker/spec-kit-token-analyzer",
"homepage": "https://github.com/coderandhiker/spec-kit-token-analyzer",
"documentation": "https://github.com/coderandhiker/spec-kit-token-analyzer/blob/main/README.md",
"changelog": "https://github.com/coderandhiker/spec-kit-token-analyzer/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.2.0"
},
"provides": {
"commands": 3,
"hooks": 4
},
"tags": [
"tokens",
"measurement",
"optimization",
"analysis"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-05-01T00:00:00Z",
"updated_at": "2026-05-01T00:00:00Z"
},
"v-model": {
"name": "V-Model Extension Pack",
"id": "v-model",
"description": "Enforces V-Model paired generation of development specs and test specs with full traceability.",
"author": "leocamello",
"version": "0.5.0",
"download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.5.0.zip",
"version": "0.6.0",
"download_url": "https://github.com/leocamello/spec-kit-v-model/archive/refs/tags/v0.6.0.zip",
"repository": "https://github.com/leocamello/spec-kit-v-model",
"homepage": "https://github.com/leocamello/spec-kit-v-model",
"documentation": "https://github.com/leocamello/spec-kit-v-model/blob/main/README.md",
@@ -2420,9 +2555,9 @@
],
"verified": false,
"downloads": 0,
"stars": 0,
"stars": 21,
"created_at": "2026-02-20T00:00:00Z",
"updated_at": "2026-04-06T00:00:00Z"
"updated_at": "2026-04-25T00:00:00Z"
},
"verify": {
"name": "Verify Extension",
@@ -2581,6 +2716,50 @@
"created_at": "2026-04-22T00:00:00Z",
"updated_at": "2026-04-22T00:00:00Z"
},
"workiq": {
"name": "Work IQ",
"id": "workiq",
"description": "Integrate Microsoft 365 organizational knowledge into spec-driven development workflows",
"author": "sakitA",
"version": "1.0.0",
"download_url": "https://github.com/sakitA/spec-kit-workiq/archive/refs/tags/v1.0.0.zip",
"repository": "https://github.com/sakitA/spec-kit-workiq",
"homepage": "https://github.com/sakitA/spec-kit-workiq",
"documentation": "https://github.com/sakitA/spec-kit-workiq/blob/main/README.md",
"changelog": "https://github.com/sakitA/spec-kit-workiq/blob/main/CHANGELOG.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0",
"tools": [
{
"name": "workiq",
"version": ">=1.0.0",
"required": true
},
{
"name": "node",
"version": ">=18.0.0",
"required": true
}
]
},
"provides": {
"commands": 4,
"hooks": 2
},
"tags": [
"microsoft-365",
"work-iq",
"context",
"integration",
"productivity"
],
"verified": false,
"downloads": 0,
"stars": 0,
"created_at": "2026-04-29T00:00:00Z",
"updated_at": "2026-04-29T00:00:00Z"
},
"worktree": {
"name": "Worktree Isolation",
"id": "worktree",
@@ -2643,7 +2822,7 @@
"downloads": 0,
"stars": 0,
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
"updated_at": "2026-04-13T00:00:00Z"
}
}
}

View File

@@ -1,6 +1,6 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-08T00:00:00Z",
"updated_at": "2026-04-28T00:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/integrations/catalog.json",
"integrations": {
"claude": {
@@ -66,6 +66,15 @@
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
},
"devin": {
"id": "devin",
"name": "Devin for Terminal",
"version": "1.0.0",
"description": "Devin for Terminal CLI skills-based integration",
"author": "spec-kit-core",
"repository": "https://github.com/github/spec-kit",
"tags": ["cli", "skills"]
},
"qwen": {
"id": "qwen",
"name": "Qwen Code",

147
newsletters/2026-April.md Normal file
View File

@@ -0,0 +1,147 @@
# Spec Kit - April 2026 Newsletter
This edition covers Spec Kit activity in April 2026. Seventeen releases shipped (v0.4.4 through v0.8.3), delivering a full integration plugin architecture, a workflow engine, preset composition strategies, an integration catalog, and comprehensive documentation. The community extension catalog tripled from 26 to 83 entries, community presets grew from 2 to 12, and Spec Kit appeared on the Thoughtworks Technology Radar. A summary is in the table below, followed by details.
| **Spec Kit Core (Apr 2026)** | **Community & Content** | **SDD Ecosystem & Next** |
| --- | --- | --- |
| Seventeen releases shipped with major features: integration plugin architecture, workflow engine, preset composition, integration catalog, bundled lean preset, documentation site, and academic citation support. Three new agents added (Forgecode, Goose, Devin for Terminal). The repo grew from ~82k to **92,038 stars**. [\[github.com\]](https://github.com/github/spec-kit/releases) | Thoughtworks Technology Radar placed Spec Kit in the "Assess" ring. Community catalog grew from 26 to **83 extensions** and from 2 to **12 presets**. 12 substantive external articles published. XB Software documented a real legacy project. Fabián Silva shipped the Caramelo VS Code extension. | Matt Rickard argued for "smaller specs, harder checks." Will Torber's three-framework comparison recommended OpenSpec for most teams. The "Spec Layer" debate emerged: specs as constraint surfaces for AI agents. Spec Kit leads in breadth and portability; competitors differentiate on drift detection and orchestration depth. |
***
> **Important:** April's release pace outran external coverage. Most analyses published during the month (Rickard on April 1, Thoughtworks Radar on April 15, XB Software on April 17, Torber on April 23) were evaluating versions that predated the workflow engine (v0.7.0), integration catalog (v0.7.2), preset composition (v0.8.0), and catalog discovery CLI (v0.8.3). The ceremony and flexibility concerns they raised are precisely what these features address — the lean preset, pluggable workflows, composable presets, and community extensions like Conduct, MAQA, and Fleet Orchestrator already deliver alternative workflows beyond the default SDD process. We look forward to seeing how upcoming reviews account for these capabilities.
## Spec Kit Project Updates
### Releases Overview
**v0.4.4** (April 1) delivered the first stage of the **integration plugin architecture** — base classes, a manifest system, and a registry that replaced the hard-coded agent scaffolding. It also added the Product Forge, Superpowers Bridge, MAQA suite (7 extensions), Spec Kit Onboard, and Plan Review Gate to the community catalog, fixed Claude Code CLI detection for npm-local installs, and added `--allow-existing-branch` to `create-new-feature`. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.4.4)
**v0.4.5** (April 2) completed the integration migration in five stages: standard markdown integrations for 19 agents, TOML integrations (Gemini, Tabnine), skills and generic integrations, and removal of the legacy scaffold path. It also installed Claude Code as native skills, added a `--dry-run` flag for `create-new-feature`, support for 4+ digit feature branch numbers, the Fix Findings extension, and five lifecycle extensions to the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.4.5)
**v0.5.0** (April 2) was a significant packaging change: **template zip bundles were removed from releases**, with the CLI itself now handling all scaffolding. This ensured CLI and templates stay in sync. It also introduced `DEVELOPMENT.md` for contributor onboarding. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.5.0)
**v0.5.1** (April 8) was a large patch release. It added the **bundled Git extension** (stages 1 and 2) with hooks on all core commands and `GIT_BRANCH_NAME` override support, **Forgecode** agent support, and the `specify integration` subcommand for post-init integration management. Argument hints were added to Claude Code commands. Numerous community extensions joined the catalog (Confluence, Canon, Spec Diagram, Branch Convention, Spec Refine, FixIt, Optimize, Security Review) along with presets (explicit-task-dependencies, toc-navigation, VS Code Ask Questions). Bug fixes included pinning typer≥0.24.0/click≥8.2.1 to fix an import crash, BSD-portable sed escaping, Trae agent fix, TOML frontmatter stripping, and preventing ambiguous TOML closing quotes. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.5.1)
**v0.6.0** (April 9) rewrote **AGENTS.md for the new integration architecture**, added the SpecKit Companion to Community Friends, and brought Bugfix Workflow, Worktree Isolation, and MemoryLint to the community catalog. A new multi-repo-branching preset arrived. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.0)
**v0.6.1** (April 10) added the **bundled lean preset** with a minimal workflow command set — a lighter-weight alternative to the full SDD ceremony. It also migrated **Cursor** from `.cursor/commands` to `.cursor/skills` and added Brownfield Bootstrap, CI Guard, SpecTest, PR Bridge, TinySpec, and Status Report to the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.1)
**v0.6.2** (April 13) added **Goose AI agent** support (YAML-based recipe format), the GitHub Issues Integration extension, and the What-if Analysis extension. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.6.2)
**v0.7.0** (April 14) delivered the **workflow engine with catalog system**, enabling pluggable, multi-step workflow definitions. It added SFSpeckit (Salesforce SDD), the Worktrees extension, optional single-segment branch prefix for gitflow compatibility, and the claude-ask-questions and fiction-book-writing presets. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.0)
**v0.7.1** (April 15) deprecated the `--ai` flag in favor of `--integration` on `specify init`, added Windows to the CI test matrix, fixed Claude skill chaining for hook execution, merged TESTING.md into CONTRIBUTING.md, and added the Agent Assign and Architect Preview extensions. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.1)
**v0.7.2** (April 16) delivered the **integration catalog** for discovery, versioning, and community distribution of agent integrations. It also produced a major **documentation overhaul**: reference pages for core commands, extensions, presets, workflows, and integrations were added to `docs/reference/`, and the README CLI section was simplified. The Issues extension and Catalog CI extension joined the community catalog. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.2)
**v0.7.3** (April 17) replaced shell-based context updates with a **marker-based upsert** mechanism, eliminating accidental context file bloat. It added a **Community Friends page** to the docs site, the Spec Scope and Blueprint extensions, and a Claude Code/Copilot CLI plugin marketplace reference in the README. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.3)
**v0.7.4** (April 21) added **CITATION.cff and .zenodo.json** for academic citation support. It introduced Ripple (side-effect detection), Spec Validate, Version Guard, Spec Reference Loader, and Memory Loader extensions. A fix stripped UTF-8 BOM from agent context files, and the Antigravity (agy) agent layout was migrated to `.agents/` with `--skills` deprecated. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.4)
**v0.7.5** (April 22) added `specify self check` and `self upgrade` stubs, the **preset wrap strategy** (completing the composition trifecta alongside prepend and append), the Red Team adversarial review extension, the Wireframe extension, and a **directory traversal security fix** in command write paths. Skill placeholder resolution was expanded to all SKILL.md agents. Community content (walkthroughs and presets) was moved from the README to the docs site. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.7.5)
**v0.8.0** (April 23) delivered **preset composition strategies** (prepend, append, wrap) for templates, commands, and scripts — enabling presets to layer content around existing artifacts. It also added Copilot `--integration-options="--skills"` for skills-based scaffolding, `pipx` as an alternative installation method, and the Memory MD extension. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.0)
**v0.8.1** (April 24) fixed `/speckit.plan` on custom git branches via `.specify/feature.json`, migrated the **Mistral Vibe** integration to SkillsIntegration, added the **Screenwriting** and **Jira** presets, and resolved command reference formats per integration type (dot vs. hyphen notation). [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.1)
**v0.8.2** (April 28) introduced **GITHUB_TOKEN/GH_TOKEN authentication** for private catalog and extension downloads, deprecated the `--no-git` flag (removal gated at v0.10.0), replaced all deprecated `--ai` references with `--integration` in documentation, and added MarkItDown Document Converter, Microsoft 365 Integration, Spec Orchestrator, and the Fiction Book Writing v1.7 preset with RAG (Chroma DB) offline semantic search. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.2)
**v0.8.3** (April 29) closed the month with **catalog discovery CLI commands** (search, info, catalog list/add/remove), support for **Devin for Terminal** as a skills-based integration, a fix for the opencode command dispatch, and the OWASP LLM Threat Model, iSAQB Architecture Governance, and Work IQ extensions. A fix was also added to the upgrade hint to prevent users from accidentally installing a PyPI squat package. [\[github.com\]](https://github.com/github/spec-kit/releases/tag/v0.8.3)
### Architecture & Infrastructure Highlights
The most significant architectural change in April was the **integration plugin architecture** (v0.4.4v0.4.5), which replaced hard-coded agent scaffolding with a registry of self-describing integration classes. Each agent is now a self-contained subpackage under `src/specify_cli/integrations/<key>/` with base classes for Markdown, TOML, YAML, and Skills formats. This six-stage migration touched all 28 supported agents and laid the groundwork for the integration catalog (v0.7.2) and community-distributed integrations.
The **workflow engine** (v0.7.0) introduced a catalog-based system for pluggable, multi-step workflow definitions — moving beyond the fixed seven-step SDD sequence.
**Preset composition strategies** (v0.7.5/v0.8.0) completed the preset system with prepend, append, and wrap modes. Presets can now layer content around existing templates, commands, and scripts rather than only replacing them.
The **marker-based context upsert** (v0.7.3) replaced fragile shell-based sed operations for updating agent context files, eliminating a class of bugs around context bloat and encoding issues.
**Template zip bundles were removed** (v0.5.0), coupling the CLI and templates into a single distributable artifact.
### Bug Fixes and Security
The most critical fix was **blocking directory traversal in command write paths** (#2229, v0.7.5), which prevented a potential path traversal vulnerability in the CommandRegistrar. Other security-adjacent fixes included hardening against a **PyPI squat package** in upgrade hints (v0.8.3) and adding **GITHUB_TOKEN authentication** for private catalog downloads (v0.8.2).
Notable bug fixes: typer/click import crash (v0.5.1), BSD-portable sed escaping (v0.5.1), UTF-8 BOM stripping from context files (v0.7.4), CRLF warning suppression in PowerShell auto-commit (v0.7.3), Claude skill chaining for hooks (v0.7.1), TOML ambiguous closing quotes (v0.5.1), and custom branch support for `/speckit.plan` (v0.8.1). [\[github.com\]](https://github.com/github/spec-kit/releases)
### The Extension & Preset Ecosystem
The community extension catalog **tripled** during April, growing from 26 to **83 entries**. 59 new extensions were added and 2 were removed (Cognitive Squad and Understanding, whose repositories were no longer available). Community presets grew from 2 to **12 entries**, with 10 new presets added.
Notable new extensions by category:
- **Project management**: GitHub Issues Integration (Fatima367, aaronrsun), Spec Orchestrator (Quratulain-bilal), Agent Assign (xuyang), Status Report (Open-Agent-Tools)
- **Quality & security**: Red Team adversarial review (Ash Brener), Security Review (DyanGalih), Ripple side-effect detection (chordpli), Spec Validate (Ahmed Eltayeb), CI Guard (Quratulain-bilal), OWASP LLM Threat Model (NaviaSamal)
- **Multi-agent & orchestration**: MAQA suite with 7 extensions covering multi-agent QA, Jira, Azure DevOps, GitHub Projects, Linear, and Trello integrations (GenieRobot), Product Forge (VaiYav)
- **Spec lifecycle**: Spec Refine (Quratulain-bilal), Bugfix Workflow (Quratulain-bilal), Fix Findings (Quratulain-bilal), Brownfield Bootstrap (Quratulain-bilal), TinySpec (Quratulain-bilal)
- **Developer experience**: Blueprint code review (chordpli), Confluence (aaronrsun), MarkItDown Document Converter (BenBtg), Microsoft 365 Integration (BenBtg), Memory MD (DyanGalih), Memory Loader (KevinBrown5280), MemoryLint (RbBtSn0w)
- **Domain-specific**: SFSpeckit for Salesforce (Sumanth Yanamala), iSAQB Architecture Governance preset (Thorsten Hindermann), Canon baseline-driven workflows (Maxim Stupakov)
- **Creative**: Fiction Book Writing preset v1.7 with RAG/Chroma DB support (Andreas Daumann), Screenwriting preset (Andreas Daumann)
Notable contributor **Quratulain-bilal** contributed 15 extensions during the month, spanning spec lifecycle, workflow management, and CI/CD integration. **GenieRobot** contributed the 7-extension MAQA suite. **BenBtg** contributed both MarkItDown and Microsoft 365 integrations. [\[github.com\]](https://github.com/github/spec-kit/releases)
### Documentation Overhaul
April saw a comprehensive documentation effort. Reference pages for **core commands, extensions, presets, workflows, and integrations** were created under `docs/reference/`. Community content — **walkthroughs, presets, and a Community Friends page** — was moved from the README to `docs/community/`, reducing README length while improving discoverability. The deprecated `--ai` flag references were replaced with `--integration` across all documentation. TESTING.md was merged into CONTRIBUTING.md, and `DEVELOPMENT.md` was introduced for contributor onboarding. [\[github.com\]](https://github.com/github/spec-kit/releases)
## Community & Content
### Thoughtworks Technology Radar
On **April 15**, the **Thoughtworks Technology Radar Volume 34** placed GitHub Spec Kit in the **"Assess" ring** under Languages & Frameworks. The blip noted that teams report value in brownfield projects, that the constitution captures project scope and architecture, but flagged potential **instruction bloat, context rot, and verbose markdown output** as concerns to watch. This is the first appearance of any SDD-specific tool on the Radar. [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit)
### Developer Articles and Blog Posts
April produced 12 substantive external articles (plus one excluded as AI-generated SEO spam).
**Matt Rickard** published *"The Spec Layer: Why Spec-Driven Development (SDD) Works"* on April 1. His thesis: specs reduce execution freedom for AI agents, functioning as constraint surfaces. He compared Spec Kit, Kiro, OpenSpec, Tessl, Intent, and Symphony, and advocated for **"smaller specs, harder checks, less guessing."** [\[blog.matt-rickard.com\]](https://blog.matt-rickard.com/p/the-spec-layer)
**Fabián Silva** published *"I Built a Visual Spec-Driven Development Extension for VS Code That Works With Any LLM"* on April 3 on DEV Community. His **Caramelo** VS Code extension adds a visual UI, approval gates, Jira integration, and multi-LLM support on top of Spec Kit's workflow, reading and writing the standard `specs/` directory. [\[dev.to\]](https://dev.to/fabian_silva_/i-built-a-visual-spec-driven-development-extension-for-vs-code-that-works-with-any-llm-36ok)
**James M** published *"GitHub Spec Kit in 2026: SDD Goes Mainstream"* on April 4, calling the transition "from framework to platform" and highlighting Claude Code native skills, multi-agent support, and the massive ecosystem growth. [\[jamesm.blog\]](https://jamesm.blog/ai/github-spec-kit-2026-update/)
**Peter Saktor** published a detailed tutorial on DEV Community on April 6: *"GitHub Spec-Kit: From Vibe Coding to Spec-Driven Development,"* walking through a full 7-step SDD workflow refactoring an Azure Container App with 33 tasks across 6 phases. [\[dev.to\]](https://dev.to/petersaktor/github-spec-kit-from-vibe-coding-to-spec-driven-development-1pgd)
**Codexplorer** published *"Spec Kit: GitHub's Answer to 'The AI Built the Wrong Thing Again'"* on Medium (April 11), framing Spec Kit as flipping the spec-code relationship, with Go code examples covering the seven slash commands. [\[medium.com\]](https://codexplorer.medium.com/spec-kit-githubs-answer-to-the-ai-built-the-wrong-thing-again-22f122f142fb)
**XB Software** published *"Spec Kit on a Real Project: Implementation Experience in Large Legacy Code"* on April 17 — a field report from applying SDD to legacy systems. A week-long task was completed in half the time. The AI surfaced hidden requirements gaps. They noted API integration weakness, that SDD is overkill for small tasks, and that an experienced reviewer is still essential. [\[xbsoftware.com\]](https://xbsoftware.com/blog/ai-in-legacy-systems-spec-driven-development/)
**What IT Is** published *"Perspectives in Spec Driven Development"* on April 21, surveying the SDD landscape (Spec Kit, Kiro, Tessl) and calling Spec Kit "a good entry point." [\[theitsolutionist.com\]](https://theitsolutionist.com/2026/04/21/perspectives-in-spec-driven-development/)
**Will Torber** published *"Spec Kit vs BMAD vs OpenSpec: Choosing an SDD Framework in 2026"* on DEV Community on April 23. He recommended Spec Kit for greenfield but flagged brownfield friction and the branch-per-spec limitation, ultimately **recommending OpenSpec for most teams**. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j)
**Truong Phung** published *"Spec Kit vs. Superpowers: A Comprehensive Comparison & Practical Guide to Combining Both"* on DEV Community on April 25 — an 11-section comparison proposing a hybrid workflow: "Spec Kit plans WHAT, Superpowers controls HOW," with a step-by-step playbook. [\[dev.to\]](https://dev.to/truongpx396/spec-kit-vs-superpowers-a-comprehensive-comparison-practical-guide-to-combining-both-52jj)
**Markus Wondrak** published *"Re-evaluating GitHub's Spec Kit: Structured SDLC Automation"* on LinkedIn on April 26, examining Spec Kit as a structured SDLC automation approach requiring human review at phase boundaries. [\[linkedin.com\]](https://www.linkedin.com/pulse/re-evaluating-githubs-spec-kit-structured-sdlc-markus-wondrak-eewqf/)
**FintechExtra** published a factual release-notes summary of v0.8.2 on April 28, highlighting authenticated catalog downloads, the UTF-8 manifest fix, and the Chroma DB semantic search in the fiction writing preset. [\[fintechextra.com\]](https://www.fintechextra.com/news/github-spec-kit-v082-expands-catalog-support-and-tightens-cli-behavior-331)
### Community Friends and Tools
The **SpecKit Companion** VS Code extension was added to the Community Friends section (v0.6.0). A community-maintained plugin for **Claude Code and GitHub Copilot CLI** that installs Spec Kit skills via the plugin marketplace was referenced in the README (v0.7.3). Fabián Silva's **Caramelo** VS Code extension demonstrated a visual UI approach to SDD. [\[github.com\]](https://github.com/github/spec-kit)
## SDD Ecosystem & Industry Trends
### The "Spec Layer" Debate
Matt Rickard's "The Spec Layer" essay established a new framing for SDD: specifications as **constraint surfaces** that reduce execution freedom for AI agents. His comparison of six SDD tools argued for smaller, more focused specs with harder verification checks — a departure from comprehensive specification documents. This framing resonated across the community, with the Thoughtworks Radar entry and multiple comparison articles echoing the tension between spec depth and practical overhead.
### Competitive Landscape
**Will Torber's** three-framework comparison (Spec Kit, BMAD, OpenSpec) recommended **OpenSpec for most teams**, citing lower ceremony and better brownfield support. **Truong Phung** proposed combining Spec Kit with **Superpowers** (Jesse Vincent) for a "plan WHAT + control HOW" hybrid. These comparisons reflected a maturing market where practitioners combine tools rather than picking one.
The **Thoughtworks Radar** placement validated SDD as a category worth tracking but flagged instruction bloat and context rot as open concerns — the same issues the Augment Code comparison raised in March. XB Software's field report confirmed these in practice: SDD adds value for complex legacy work but creates unnecessary overhead for small tasks.
Spec Kit continued to lead in **GitHub popularity** (92k stars) and **agent breadth** (29 integrations). The market continued to differentiate along several axes: Spec Kit on portability and ecosystem breadth, Intent on living specs and drift detection, BMAD-METHOD on multi-agent orchestration, and OpenSpec on simplicity. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j) [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit)
## Roadmap
Areas under discussion or in progress for future development:
- **Spec lifecycle management** — context rot and spec drift remained the most cited concern across articles (Thoughtworks Radar, XB Software, Will Torber). The marker-based upsert (v0.7.3) addressed context file drift; spec-level drift detection remains an open area. The Reconcile and Archive extensions are community steps toward this. [\[thoughtworks.com\]](https://www.thoughtworks.com/radar/languages-and-frameworks/github-spec-kit)
- **Workflow customization** — the workflow engine (v0.7.0) and preset composition strategies (v0.8.0) provide the foundation. Community presets for fiction writing, screenwriting, Jira tracking, and architecture governance demonstrate the breadth of possible workflows beyond standard SDD. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Catalog discovery and distribution** — the integration catalog (v0.7.2) and catalog discovery CLI (v0.8.3) bring `specify` closer to a package-manager experience for extensions, presets, and integrations. Private catalog authentication (v0.8.2) supports enterprise distribution. [\[github.com\]](https://github.com/github/spec-kit/releases)
- **Experience simplification** — the bundled lean preset (v0.6.1), `specify self check` (v0.7.5), and the deprecation of `--ai` in favor of `--integration` (v0.7.1) reflect ongoing work to reduce ceremony and improve the onboarding experience. Multiple external articles (Torber, XB Software) noted SDD overhead as a barrier. [\[dev.to\]](https://dev.to/willtorber/spec-kit-vs-bmad-vs-openspec-choosing-an-sdd-framework-in-2026-d3j)
- **Cross-platform and enterprise** — Windows CI (v0.7.1), GITHUB_TOKEN authentication (v0.8.2), Salesforce-specific extensions, and the iSAQB architecture governance preset indicate growing enterprise adoption. [\[github.com\]](https://github.com/github/spec-kit)

View File

@@ -1,8 +1,36 @@
{
"schema_version": "1.0",
"updated_at": "2026-04-15T00:00:00Z",
"updated_at": "2026-05-05T10:00:00Z",
"catalog_url": "https://raw.githubusercontent.com/github/spec-kit/main/presets/catalog.community.json",
"presets": {
"a11y-governance": {
"name": "A11Y Governance",
"id": "a11y-governance",
"version": "0.2.0",
"description": "Adds accessibility, bilingual DE/EN delivery, CEFR-B2 readability, and inclusive-content governance to Spec Kit.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-a11y-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-a11y-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-a11y-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 9,
"commands": 3
},
"tags": [
"a11y",
"accessibility",
"bilingual",
"wcag",
"inclusion"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-04-27T00:00:00Z"
},
"aide-in-place": {
"name": "AIDE In-Place Migration",
"id": "aide-in-place",
@@ -16,7 +44,9 @@
"license": "MIT",
"requires": {
"speckit_version": ">=0.2.0",
"extensions": ["aide"]
"extensions": [
"aide"
]
},
"provides": {
"templates": 2,
@@ -29,6 +59,34 @@
"aide"
]
},
"architecture-governance": {
"name": "Architecture Governance",
"id": "architecture-governance",
"version": "0.2.0",
"description": "Adds secure architecture governance, threat modeling, STRIDE/CAPEC, Zero Trust, S-ADRs, and OWASP SAMM to Spec Kit.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-architecture-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-architecture-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-architecture-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 11,
"commands": 3
},
"tags": [
"architecture",
"governance",
"threat-modeling",
"stride",
"zero-trust"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-04-27T00:00:00Z"
},
"canon-core": {
"name": "Canon Core",
"id": "canon-core",
@@ -80,6 +138,34 @@
"created_at": "2026-04-13T00:00:00Z",
"updated_at": "2026-04-13T00:00:00Z"
},
"cross-platform-governance": {
"name": "Cross-Platform Governance",
"id": "cross-platform-governance",
"version": "0.1.0",
"description": "Adds Bash and PowerShell parity, dry-run/WhatIf parity, man-page expectations, and Verb-Noun Cmdlet discipline.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/archive/refs/tags/v0.1.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-cross-platform-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 8,
"commands": 3
},
"tags": [
"cross-platform",
"bash",
"powershell",
"man-page",
"cmdlet"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-04-27T00:00:00Z"
},
"explicit-task-dependencies": {
"name": "Explicit Task Dependencies",
"id": "explicit-task-dependencies",
@@ -142,6 +228,34 @@
"created_at": "2026-04-09T08:00:00Z",
"updated_at": "2026-04-27T08:00:00Z"
},
"isaqb-architecture-governance": {
"name": "iSAQB Architecture Governance",
"id": "isaqb-architecture-governance",
"version": "0.1.0",
"description": "Adds general iSAQB/CPSA-F and arc42 architecture governance, including views, quality scenarios, ADRs, risks, and technical debt.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/archive/refs/tags/v0.1.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-isaqb-architecture-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 13,
"commands": 3
},
"tags": [
"architecture",
"governance",
"isaqb",
"arc42",
"adr"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-04-27T00:00:00Z"
},
"jira": {
"name": "Jira Issue Tracking",
"id": "jira",
@@ -259,6 +373,61 @@
"created_at": "2026-04-23T08:00:00Z",
"updated_at": "2026-04-23T08:00:00Z"
},
"security-governance": {
"name": "Security Governance",
"id": "security-governance",
"version": "0.2.0",
"description": "Adds secure development governance, MSL preference, ASVS verification, supply-chain transparency, and EU CRA awareness.",
"author": "Thorsten Hindermann",
"repository": "https://github.com/hindermath/spec-kit-preset-security-governance",
"download_url": "https://github.com/hindermath/spec-kit-preset-security-governance/archive/refs/tags/v0.2.0.zip",
"homepage": "https://github.com/hindermath/spec-kit-preset-security-governance",
"documentation": "https://github.com/hindermath/spec-kit-preset-security-governance/blob/main/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.8.0"
},
"provides": {
"templates": 12,
"commands": 3
},
"tags": [
"security",
"governance",
"msl",
"asvs",
"supply-chain"
],
"created_at": "2026-04-27T00:00:00Z",
"updated_at": "2026-04-27T00:00:00Z"
},
"spec2cloud": {
"name": "Spec2Cloud",
"id": "spec2cloud",
"version": "1.1.0",
"description": "Spec-driven workflow tuned for shipping to Azure: spec → plan → tasks → implement → deploy.",
"author": "Azure Samples",
"repository": "https://github.com/Azure-Samples/Spec2Cloud",
"download_url": "https://github.com/Azure-Samples/Spec2Cloud/releases/download/spec-kit-spec2cloud-v1.1.0/preset.zip",
"homepage": "https://aka.ms/spec2cloud",
"documentation": "https://github.com/Azure-Samples/Spec2Cloud/blob/main/spec-kit/README.md",
"license": "MIT",
"requires": {
"speckit_version": ">=0.1.0"
},
"provides": {
"templates": 5,
"commands": 8
},
"tags": [
"azure",
"spec2cloud",
"workflow",
"deployment"
],
"created_at": "2026-04-30T00:00:00Z",
"updated_at": "2026-04-30T00:00:00Z"
},
"toc-navigation": {
"name": "Table of Contents Navigation",
"id": "toc-navigation",

View File

@@ -1,6 +1,6 @@
[project]
name = "specify-cli"
version = "0.8.2.dev0"
version = "0.8.5"
description = "Specify CLI, part of GitHub Spec Kit. A tool to bootstrap your projects for Spec-Driven Development (SDD)."
requires-python = ">=3.11"
dependencies = [

View File

@@ -0,0 +1,96 @@
#!/usr/bin/env bash
set -e
# Parse command line arguments
JSON_MODE=false
for arg in "$@"; do
case "$arg" in
--json) JSON_MODE=true ;;
--help|-h)
echo "Usage: $0 [--json]"
echo " --json Output results in JSON format"
echo " --help Show this help message"
exit 0
;;
*) echo "ERROR: Unknown option '$arg'" >&2; exit 1 ;;
esac
done
# Source common functions
SCRIPT_DIR="$(CDPATH="" cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
source "$SCRIPT_DIR/common.sh"
# Get feature paths
_paths_output=$(get_feature_paths) || { echo "ERROR: Failed to resolve feature paths" >&2; exit 1; }
eval "$_paths_output"
unset _paths_output
# Validate branch
# If feature.json pins an existing feature directory, branch naming is not required.
if ! feature_json_matches_feature_dir "$REPO_ROOT" "$FEATURE_DIR"; then
check_feature_branch "$CURRENT_BRANCH" "$HAS_GIT" || exit 1
fi
if [[ ! -f "$IMPL_PLAN" ]]; then
echo "ERROR: plan.md not found in $FEATURE_DIR" >&2
echo "Run /speckit.plan first to create the implementation plan." >&2
exit 1
fi
if [[ ! -f "$FEATURE_SPEC" ]]; then
echo "ERROR: spec.md not found in $FEATURE_DIR" >&2
echo "Run /speckit.specify first to create the feature structure." >&2
exit 1
fi
# Build available docs list
docs=()
[[ -f "$RESEARCH" ]] && docs+=("research.md")
[[ -f "$DATA_MODEL" ]] && docs+=("data-model.md")
if [[ -d "$CONTRACTS_DIR" ]] && [[ -n "$(ls -A "$CONTRACTS_DIR" 2>/dev/null)" ]]; then
docs+=("contracts/")
fi
[[ -f "$QUICKSTART" ]] && docs+=("quickstart.md")
# Resolve tasks template through override stack
TASKS_TEMPLATE=$(resolve_template "tasks-template" "$REPO_ROOT") || true
if [[ -z "$TASKS_TEMPLATE" ]] || [[ ! -f "$TASKS_TEMPLATE" ]]; then
echo "ERROR: Could not resolve required tasks-template from the template override stack for $REPO_ROOT" >&2
echo "Template 'tasks-template' was not found in any supported location (overrides, presets, extensions, or shared core). Add an override at .specify/templates/overrides/tasks-template.md, or run 'specify init' / reinstall shared infra to restore the core .specify/templates/tasks-template.md template." >&2
exit 1
fi
# Output results
if $JSON_MODE; then
if has_jq; then
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(printf '%s\n' "${docs[@]}" | jq -R . | jq -s .)
fi
jq -cn \
--arg feature_dir "$FEATURE_DIR" \
--argjson docs "$json_docs" \
--arg tasks_template "${TASKS_TEMPLATE:-}" \
'{FEATURE_DIR:$feature_dir,AVAILABLE_DOCS:$docs,TASKS_TEMPLATE:$tasks_template}'
else
if [[ ${#docs[@]} -eq 0 ]]; then
json_docs="[]"
else
json_docs=$(for d in "${docs[@]}"; do printf '"%s",' "$(json_escape "$d")"; done)
json_docs="[${json_docs%,}]"
fi
printf '{"FEATURE_DIR":"%s","AVAILABLE_DOCS":%s,"TASKS_TEMPLATE":"%s"}\n' \
"$(json_escape "$FEATURE_DIR")" "$json_docs" "$(json_escape "${TASKS_TEMPLATE:-}")"
fi
else
echo "FEATURE_DIR: $FEATURE_DIR"
echo "TASKS_TEMPLATE: ${TASKS_TEMPLATE:-not found}"
echo "AVAILABLE_DOCS:"
check_file "$RESEARCH" "research.md"
check_file "$DATA_MODEL" "data-model.md"
check_dir "$CONTRACTS_DIR" "contracts/"
check_file "$QUICKSTART" "quickstart.md"
fi

View File

@@ -0,0 +1,74 @@
#!/usr/bin/env pwsh
[CmdletBinding()]
param(
[switch]$Json,
[switch]$Help
)
$ErrorActionPreference = 'Stop'
if ($Help) {
Write-Output "Usage: setup-tasks.ps1 [-Json] [-Help]"
exit 0
}
# Source common functions
. "$PSScriptRoot/common.ps1"
# Get feature paths and validate branch
$paths = Get-FeaturePathsEnv
# If feature.json pins an existing feature directory, branch naming is not required.
if (-not (Test-FeatureJsonMatchesFeatureDir -RepoRoot $paths.REPO_ROOT -ActiveFeatureDir $paths.FEATURE_DIR)) {
if (-not (Test-FeatureBranch -Branch $paths.CURRENT_BRANCH -HasGit $paths.HAS_GIT)) {
exit 1
}
}
if (-not (Test-Path $paths.IMPL_PLAN -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: plan.md not found in $($paths.FEATURE_DIR)")
[Console]::Error.WriteLine("Run /speckit.plan first to create the implementation plan.")
exit 1
}
if (-not (Test-Path $paths.FEATURE_SPEC -PathType Leaf)) {
[Console]::Error.WriteLine("ERROR: spec.md not found in $($paths.FEATURE_DIR)")
[Console]::Error.WriteLine("Run /speckit.specify first to create the feature structure.")
exit 1
}
# Build available docs list
$docs = @()
if (Test-Path $paths.RESEARCH) { $docs += 'research.md' }
if (Test-Path $paths.DATA_MODEL) { $docs += 'data-model.md' }
if ((Test-Path $paths.CONTRACTS_DIR) -and (Get-ChildItem -Path $paths.CONTRACTS_DIR -ErrorAction SilentlyContinue | Select-Object -First 1)) {
$docs += 'contracts/'
}
if (Test-Path $paths.QUICKSTART) { $docs += 'quickstart.md' }
# Resolve tasks template through override stack
$tasksTemplate = Resolve-Template -TemplateName 'tasks-template' -RepoRoot $paths.REPO_ROOT
if (-not $tasksTemplate -or -not (Test-Path -LiteralPath $tasksTemplate -PathType Leaf)) {
$expectedCoreTemplate = Join-Path $paths.REPO_ROOT '.specify/templates/tasks-template.md'
[Console]::Error.WriteLine("ERROR: Tasks template not found for repository root: $($paths.REPO_ROOT)`nTemplate resolution order: overrides -> presets -> extensions -> core.`nExpected shared/core template location: $expectedCoreTemplate`nTo continue, verify whether 'tasks-template.md' is available in '.specify/templates/overrides/', preset templates, extension templates, or restore the shared/core templates (for example by re-running 'specify init') so that '.specify/templates/tasks-template.md' exists.")
exit 1
}
$tasksTemplate = (Resolve-Path -LiteralPath $tasksTemplate).Path
# Output results
if ($Json) {
[PSCustomObject]@{
FEATURE_DIR = $paths.FEATURE_DIR
AVAILABLE_DOCS = $docs
TASKS_TEMPLATE = $tasksTemplate
} | ConvertTo-Json -Compress
} else {
Write-Output "FEATURE_DIR: $($paths.FEATURE_DIR)"
Write-Output "TASKS_TEMPLATE: $(if ($tasksTemplate) { $tasksTemplate } else { 'not found' })"
Write-Output "AVAILABLE_DOCS:"
Test-FileExists -Path $paths.RESEARCH -Description 'research.md' | Out-Null
Test-FileExists -Path $paths.DATA_MODEL -Description 'data-model.md' | Out-Null
Test-DirHasFiles -Path $paths.CONTRACTS_DIR -Description 'contracts/' | Out-Null
Test-FileExists -Path $paths.QUICKSTART -Description 'quickstart.md' | Out-Null
}

File diff suppressed because it is too large Load Diff

View File

@@ -9,8 +9,6 @@ without bloating the core framework.
import json
import hashlib
import os
import sys
import tarfile
import tempfile
import zipfile
import shutil
@@ -108,137 +106,6 @@ def normalize_priority(value: Any, default: int = 10) -> int:
return priority if priority >= 1 else default
def detect_archive_format(url: str, content_type: str = "") -> str:
"""Detect archive format from URL path extension or Content-Type header.
Args:
url: URL or file path to inspect.
content_type: Optional ``Content-Type`` header value from the HTTP response.
Returns:
``"zip"`` for ZIP archives, ``"tar.gz"`` for gzipped tarballs, or ``""``
when the format cannot be determined.
"""
# Strip query-string / fragment before examining the path extension.
url_path = url.split("?")[0].split("#")[0].lower()
if url_path.endswith(".zip"):
return "zip"
if url_path.endswith(".tar.gz") or url_path.endswith(".tgz"):
return "tar.gz"
# Fall back to Content-Type header inspection.
ct = content_type.lower()
if "application/zip" in ct or "application/x-zip" in ct:
return "zip"
if any(
t in ct
for t in (
"application/gzip",
"application/x-gzip",
"application/x-tar+gzip",
)
):
return "tar.gz"
return ""
def safe_extract_tarball(
archive_path: Path,
dest_dir: Path,
error_class: "type[Exception]" = Exception,
) -> None:
"""Safely extract a ``.tar.gz`` or ``.tgz`` archive into *dest_dir*.
All members are validated before extraction to prevent *tar slip*
(path traversal) attacks. Symlinks, hard links, and special files
(devices, FIFOs, etc.) are rejected.
On Python 3.12 and later the ``"data"`` extraction filter is applied
for an additional layer of OS-level protection. On earlier versions
the explicit member list (containing only pre-validated regular files
and directories) is passed to ``extractall()`` — since all symlinks are
already rejected in the validation phase, no archive-introduced symlink
can be followed during extraction.
Args:
archive_path: Path to the ``.tar.gz``/``.tgz`` archive.
dest_dir: Destination directory (must already exist).
error_class: Exception class to raise on unsafe entries.
Raises:
error_class: If any member is unsafe or the archive cannot be read.
"""
dest_resolved = dest_dir.resolve()
# Tar metadata member types to skip during validation — they carry no
# extractable payload and are generated automatically by many common
# archiving tools (e.g. PAX headers, GNU longname/longlink entries).
# GNUTYPE_SPARSE is intentionally excluded: it carries a real file payload
# and tarfile.TarInfo.isreg() returns True for it, so it passes the
# regular-file check below and is extracted correctly.
_TAR_METADATA_TYPES = (
tarfile.XHDTYPE, # PAX extended header
tarfile.XGLTYPE, # PAX global extended header
tarfile.SOLARIS_XHDTYPE, # Solaris PAX extended header
tarfile.GNUTYPE_LONGNAME, # GNU long path name (metadata only)
tarfile.GNUTYPE_LONGLINK, # GNU long link name (metadata only)
)
try:
with tarfile.open(archive_path, "r:gz") as tf:
members = tf.getmembers()
safe_members = []
# Validate every member before extracting anything.
for member in members:
# Reject absolute paths and any path component that is "..".
if os.path.isabs(member.name) or any(
part == ".." for part in member.name.replace("\\", "/").split("/")
):
raise error_class(
f"Unsafe path in tar archive: {member.name} (potential path traversal)"
)
# Confirm the resolved path stays inside dest_dir.
member_path = (dest_dir / member.name).resolve()
try:
member_path.relative_to(dest_resolved)
except ValueError:
raise error_class(
f"Unsafe path in tar archive: {member.name} (potential path traversal)"
)
# Skip tar metadata members — they carry no extractable payload.
if member.type in _TAR_METADATA_TYPES:
continue
# Reject symlinks and hard links.
if member.issym() or member.islnk():
raise error_class(
f"Symlinks are not allowed in archive: {member.name}"
)
# Reject devices, FIFOs and other special file types.
if not (member.isreg() or member.isdir()):
raise error_class(
f"Non-regular file in archive: {member.name}"
)
safe_members.append(member)
# Extract — use the "data" filter on Python 3.12+ for extra hardening.
# On all versions pass only the pre-validated members so that no
# unvetted entry (added concurrently or via a race) slips through.
if sys.version_info >= (3, 12):
tf.extractall(dest_dir, members=safe_members, filter="data") # type: ignore[call-arg]
else:
tf.extractall(dest_dir, members=safe_members) # noqa: S202 — validated above
except error_class:
raise
except (tarfile.TarError, OSError) as e:
raise error_class(f"Failed to read archive {archive_path}: {e}") from e
@dataclass
class CatalogEntry:
"""Represents a single catalog entry in the catalog stack."""
@@ -1095,29 +962,40 @@ class ExtensionManager:
return written
def _unregister_extension_skills(self, skill_names: List[str], extension_id: str) -> None:
def _unregister_extension_skills(
self,
skill_names: List[str],
extension_id: str,
skills_dir: Optional[Path] = None,
) -> None:
"""Remove SKILL.md directories for extension skills.
Called during extension removal to clean up skill files that
were created by ``_register_extension_skills()``.
If ``_get_skills_dir()`` returns ``None`` (e.g. the user removed
init-options.json or toggled ai_skills after installation), we
fall back to scanning all known agent skills directories so that
orphaned skill directories are still cleaned up. In that case
each candidate directory is verified against the SKILL.md
``metadata.source`` field before removal to avoid accidentally
deleting user-created skills with the same name.
If *skills_dir* is not provided and ``_get_skills_dir()`` returns
``None`` (e.g. the user removed init-options.json or toggled
ai_skills after installation), we fall back to scanning all known
agent skills directories so that orphaned skill directories are
still cleaned up. In that case each candidate directory is
verified against the SKILL.md ``metadata.source`` field before
removal to avoid accidentally deleting user-created skills with
the same name.
Args:
skill_names: List of skill names to remove.
extension_id: Extension ID used to verify ownership during
fallback candidate scanning.
skills_dir: Optional explicit skills directory to use instead
of resolving via ``_get_skills_dir()``. Useful when the
caller needs to target a specific agent's skills directory
regardless of the currently-active agent in init-options.
"""
if not skill_names:
return
skills_dir = self._get_skills_dir()
if skills_dir is None:
skills_dir = self._get_skills_dir()
if skills_dir:
# Fast path: we know the exact skills directory
@@ -1241,7 +1119,7 @@ class ExtensionManager:
raise CompatibilityError(
f"Extension requires spec-kit {required}, "
f"but {speckit_version} is installed.\n"
f"Upgrade spec-kit with: uv tool install specify-cli --force"
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
)
except InvalidSpecifier:
raise CompatibilityError(f"Invalid version specifier: {required}")
@@ -1335,10 +1213,10 @@ class ExtensionManager:
speckit_version: str,
priority: int = 10,
) -> ExtensionManifest:
"""Install extension from a ZIP or ``.tar.gz``/``.tgz`` archive.
"""Install extension from ZIP file.
Args:
zip_path: Path to the extension archive (ZIP or gzipped tarball).
zip_path: Path to extension ZIP file
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
@@ -1346,8 +1224,7 @@ class ExtensionManager:
Installed extension manifest
Raises:
ValidationError: If manifest is invalid, the archive is unsafe, or
priority is invalid
ValidationError: If manifest is invalid or priority is invalid
CompatibilityError: If extension is incompatible
"""
# Validate priority early
@@ -1357,27 +1234,21 @@ class ExtensionManager:
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
archive_fmt = detect_archive_format(str(zip_path))
if archive_fmt == "tar.gz":
# Extract tarball safely (prevent tar slip attack)
safe_extract_tarball(zip_path, temp_path, ValidationError)
else:
# Extract ZIP safely (prevent Zip Slip attack)
with zipfile.ZipFile(zip_path, 'r') as zf:
# Validate all paths first before extracting anything
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
# Use is_relative_to for safe path containment check
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise ValidationError(
f"Unsafe path in ZIP archive: {member} (potential path traversal)"
)
# Only extract after all paths are validated
zf.extractall(temp_path)
# Extract ZIP safely (prevent Zip Slip attack)
with zipfile.ZipFile(zip_path, 'r') as zf:
# Validate all paths first before extracting anything
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
# Use is_relative_to for safe path containment check
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise ValidationError(
f"Unsafe path in ZIP archive: {member} (potential path traversal)"
)
# Only extract after all paths are validated
zf.extractall(temp_path)
# Find extension directory (may be nested)
extension_dir = temp_path
@@ -1391,7 +1262,7 @@ class ExtensionManager:
manifest_path = extension_dir / "extension.yml"
if not manifest_path.exists():
raise ValidationError("No extension.yml found in archive")
raise ValidationError("No extension.yml found in ZIP file")
# Install from extracted directory
return self.install_from_directory(extension_dir, speckit_version, priority=priority)
@@ -1472,6 +1343,156 @@ class ExtensionManager:
return True
@staticmethod
def _valid_name_list(value: Any) -> List[str]:
"""Return string entries from a registry list, ignoring corrupt values."""
if not isinstance(value, list):
return []
return [item for item in value if isinstance(item, str)]
def unregister_agent_artifacts(self, agent_name: str) -> None:
"""Remove extension files registered for a specific agent.
Extension command files are tracked per agent in ``registered_commands``.
Extension skills are scoped to the provided *agent_name*; they are removed
from that agent's skills directory (resolved via its integration config)
and the registry field is cleared.
Skips cleanup when *agent_name* is not a supported agent to avoid
losing registry entries while leaving orphaned files on disk.
"""
if not agent_name:
return
registrar = CommandRegistrar()
if agent_name not in registrar.AGENT_CONFIGS:
return
# Resolve the skills directory for the specific agent so cleanup is
# agent-scoped and does not depend on the currently-active agent in
# init-options. Use the same helper that extension install uses.
from . import _get_skills_dir as resolve_skills_dir
agent_skills_dir = resolve_skills_dir(self.project_root, agent_name)
for ext_id, metadata in self.registry.list().items():
updates: Dict[str, Any] = {}
registered_commands = metadata.get("registered_commands", {})
if isinstance(registered_commands, dict) and agent_name in registered_commands:
command_names = self._valid_name_list(registered_commands.get(agent_name))
if command_names:
registrar.unregister_commands({agent_name: command_names}, self.project_root)
new_registered = copy.deepcopy(registered_commands)
new_registered.pop(agent_name, None)
updates["registered_commands"] = new_registered
registered_skills = self._valid_name_list(metadata.get("registered_skills", []))
if registered_skills:
# Only pass the resolved skills_dir when it actually exists.
# Otherwise let _unregister_extension_skills fall back to
# scanning all known agent skills directories, which is useful
# for cleaning up stale entries created by earlier installs.
skills_dir = agent_skills_dir if agent_skills_dir.is_dir() else None
self._unregister_extension_skills(
registered_skills, ext_id, skills_dir=skills_dir
)
# Only reconcile registry state when cleanup was scoped to a
# specific existing directory. When skills_dir is None,
# _unregister_extension_skills falls back to scanning multiple
# candidate directories, so agent_skills_dir cannot be used to
# infer what was removed. When skills_dir is set,
# _unregister_extension_skills may intentionally skip deletion
# when ownership cannot be verified (e.g., corrupted/missing
# SKILL.md or mismatching metadata.source). Only drop registry
# entries for skill directories that were actually removed so
# future cleanup attempts can still find skipped ones.
if skills_dir is not None:
remaining_skills = [
skill_name
for skill_name in registered_skills
if (skills_dir / skill_name).is_dir()
]
if remaining_skills != registered_skills:
updates["registered_skills"] = remaining_skills
if updates:
self.registry.update(ext_id, updates)
def register_enabled_extensions_for_agent(self, agent_name: str) -> None:
"""Register installed, enabled extensions for ``agent_name``.
This is intended to be called after switching integrations. Command
registration is scoped to the explicit ``agent_name`` argument, but some
behavior still depends on the current init-options state (for example,
skills-mode handling uses the active ``ai`` / ``ai_skills`` settings).
Callers should therefore pass the agent that has just been made active
in init-options; in normal use, ``agent_name`` is expected to match the
current ``ai`` value. This mirrors extension install behavior while
avoiding stale default-mode command directories when that active agent
is running in skills mode (notably Copilot ``--skills``).
"""
if not agent_name:
return
from . import load_init_options
registrar = CommandRegistrar()
agent_config = registrar.AGENT_CONFIGS.get(agent_name)
init_options = load_init_options(self.project_root)
if not isinstance(init_options, dict):
init_options = {}
active_agent = init_options.get("ai")
skills_mode_active = (
active_agent == agent_name
and bool(init_options.get("ai_skills"))
and bool(agent_config)
and agent_config.get("extension") != "/SKILL.md"
)
for ext_id, metadata in self.registry.list().items():
if not metadata.get("enabled", True):
continue
manifest = self.get_extension(ext_id)
if manifest is None:
continue
ext_dir = self.extensions_dir / ext_id
updates: Dict[str, Any] = {}
if agent_config and not skills_mode_active:
registered = registrar.register_commands_for_agent(
agent_name, manifest, ext_dir, self.project_root
)
registered_commands = metadata.get("registered_commands", {})
if not isinstance(registered_commands, dict):
registered_commands = {}
new_registered = copy.deepcopy(registered_commands)
if registered:
new_registered[agent_name] = registered
else:
# Registration returned empty list (e.g., corrupted
# manifest pointing at missing command files). Clear
# stale entry so later cleanup doesn't try to remove
# files that were never written.
new_registered.pop(agent_name, None)
if new_registered != registered_commands:
updates["registered_commands"] = new_registered
registered_skills = self._register_extension_skills(manifest, ext_dir)
if registered_skills:
existing_skills = self._valid_name_list(metadata.get("registered_skills", []))
merged_skills = list(dict.fromkeys(existing_skills + registered_skills))
updates["registered_skills"] = merged_skills
if updates:
self.registry.update(ext_id, updates)
def list_installed(self) -> List[Dict[str, Any]]:
"""List all installed extensions with metadata.
@@ -2105,18 +2126,14 @@ class ExtensionCatalog:
return None
def download_extension(self, extension_id: str, target_dir: Optional[Path] = None) -> Path:
"""Download extension archive from catalog.
Supports both ZIP (``.zip``) and gzipped tarball (``.tar.gz``/``.tgz``)
archives. The format is detected from the download URL's path extension;
when ambiguous the ``Content-Type`` header is used as a fallback.
"""Download extension ZIP from catalog.
Args:
extension_id: ID of the extension to download
target_dir: Directory to save the archive (defaults to cache directory)
target_dir: Directory to save ZIP file (defaults to temp directory)
Returns:
Path to downloaded archive file
Path to downloaded ZIP file
Raises:
ExtensionError: If extension not found or download fails
@@ -2155,60 +2172,21 @@ class ExtensionCatalog:
target_dir.mkdir(parents=True, exist_ok=True)
version = ext_info.get("version", "unknown")
zip_filename = f"{extension_id}-{version}.zip"
zip_path = target_dir / zip_filename
# Download the archive. Determine the archive format from the
# post-redirect URL first (with Content-Type fallback); only use the
# original `download_url` as a last hint if the final URL gives no
# signal.
final_url = download_url
archive_fmt = ""
# Download the ZIP file
try:
with self._open_url(download_url, timeout=60) as response:
final_url = response.geturl()
# Re-validate scheme after any redirect to guard against
# scheme-downgrade. Validate BEFORE reading the body so a
# malicious redirect cannot cause us to fetch the payload
# over an insecure scheme.
_final_parsed = urlparse(final_url)
_final_is_localhost = _final_parsed.hostname in (
"localhost",
"127.0.0.1",
"::1",
)
if _final_parsed.scheme != "https" and not (
_final_parsed.scheme == "http" and _final_is_localhost
):
raise ExtensionError(
f"Extension download URL was redirected to a non-HTTPS URL: {final_url}"
)
content_type = response.headers.get("Content-Type", "")
archive_fmt = detect_archive_format(final_url, content_type)
if not archive_fmt:
archive_fmt = detect_archive_format(download_url)
archive_data = response.read()
zip_data = response.read()
zip_path.write_bytes(zip_data)
return zip_path
except urllib.error.URLError as e:
raise ExtensionError(f"Failed to download extension from {download_url}: {e}")
except IOError as e:
raise ExtensionError(f"Failed to read extension archive from {download_url}: {e}")
# Choose file extension based on detected format.
if not archive_fmt:
raise ExtensionError(
f"Could not determine archive format for {download_url}. "
"Ensure the URL points to a .zip or .tar.gz/.tgz file."
)
if archive_fmt == "tar.gz":
archive_filename = f"{extension_id}-{version}.tar.gz"
else:
archive_filename = f"{extension_id}-{version}.zip"
archive_path = target_dir / archive_filename
try:
archive_path.write_bytes(archive_data)
except IOError as e:
raise ExtensionError(f"Failed to save extension archive: {e}")
return archive_path
raise ExtensionError(f"Failed to save extension ZIP: {e}")
def clear_cache(self):
"""Clear the catalog cache (both legacy and URL-hash-based files)."""

View File

@@ -0,0 +1,90 @@
"""Runtime helpers for integration commands."""
from __future__ import annotations
from collections.abc import Callable
from typing import Any
from .integration_state import integration_setting, integration_settings
ParseOptions = Callable[[Any, str], dict[str, Any] | None]
def resolve_integration_options(
integration: Any,
state: dict[str, Any],
key: str,
raw_options: str | None,
*,
parse_options: ParseOptions,
) -> tuple[str | None, dict[str, Any] | None]:
"""Resolve raw and parsed options for an integration operation."""
if raw_options is not None:
return raw_options, parse_options(integration, raw_options)
setting = integration_setting(state, key)
stored_raw = setting.get("raw_options")
if not isinstance(stored_raw, str):
stored_raw = None
stored_parsed = setting.get("parsed_options")
if isinstance(stored_parsed, dict):
return stored_raw, stored_parsed or None
if stored_raw:
return stored_raw, parse_options(integration, stored_raw)
return None, None
def with_integration_setting(
state: dict[str, Any],
key: str,
integration: Any,
*,
script_type: str | None = None,
raw_options: str | None = None,
parsed_options: dict[str, Any] | None = None,
) -> dict[str, dict[str, Any]]:
"""Return integration settings with *key* updated."""
settings = integration_settings(state)
current = dict(settings.get(key, {}))
if script_type:
current["script"] = script_type
if raw_options is not None:
current["raw_options"] = raw_options
elif "raw_options" in current and not current.get("raw_options"):
current.pop("raw_options", None)
if parsed_options is not None:
current["parsed_options"] = parsed_options
elif raw_options is not None:
current.pop("parsed_options", None)
current["invoke_separator"] = integration.effective_invoke_separator(parsed_options)
settings[key] = current
return settings
def invoke_separator_for_integration(
integration: Any,
state: dict[str, Any],
key: str,
parsed_options: dict[str, Any] | None = None,
) -> str:
"""Resolve the invocation separator for stored/default integration state."""
if parsed_options is not None:
return integration.effective_invoke_separator(parsed_options)
setting = integration_setting(state, key)
stored_separator = setting.get("invoke_separator")
if isinstance(stored_separator, str) and stored_separator:
return stored_separator
stored_parsed = setting.get("parsed_options")
if isinstance(stored_parsed, dict):
return integration.effective_invoke_separator(stored_parsed)
return integration.effective_invoke_separator(None)

View File

@@ -0,0 +1,161 @@
"""State helpers for installed AI agent integrations."""
from __future__ import annotations
import json
from pathlib import Path
from typing import Any
INTEGRATION_JSON = ".specify/integration.json"
INTEGRATION_STATE_SCHEMA = 1
def clean_integration_key(key: Any) -> str | None:
"""Return a stripped integration key, or None for empty/non-string values."""
if not isinstance(key, str) or not key.strip():
return None
return key.strip()
def dedupe_integration_keys(keys: list[Any]) -> list[str]:
"""Return a de-duplicated list of non-empty integration keys."""
seen: set[str] = set()
deduped: list[str] = []
for key in keys:
clean = clean_integration_key(key)
if clean is None:
continue
if clean in seen:
continue
seen.add(clean)
deduped.append(clean)
return deduped
def normalize_integration_settings(settings: Any) -> dict[str, dict[str, Any]]:
"""Return JSON-safe per-integration runtime settings."""
if not isinstance(settings, dict):
return {}
normalized: dict[str, dict[str, Any]] = {}
for key, value in settings.items():
if not isinstance(key, str) or not key.strip() or not isinstance(value, dict):
continue
clean: dict[str, Any] = {}
script = value.get("script")
if isinstance(script, str) and script.strip():
clean["script"] = script.strip()
raw_options = value.get("raw_options")
if isinstance(raw_options, str):
clean["raw_options"] = raw_options
parsed_options = value.get("parsed_options")
if isinstance(parsed_options, dict):
clean["parsed_options"] = parsed_options
invoke_separator = value.get("invoke_separator")
if isinstance(invoke_separator, str) and invoke_separator.strip():
clean["invoke_separator"] = invoke_separator.strip()
if clean:
normalized[key.strip()] = clean
return normalized
def _normalized_integration_state_schema(value: Any) -> int:
if isinstance(value, int) and not isinstance(value, bool) and value > INTEGRATION_STATE_SCHEMA:
return value
return INTEGRATION_STATE_SCHEMA
def normalize_integration_state(data: dict[str, Any]) -> dict[str, Any]:
"""Normalize legacy and multi-install integration metadata."""
legacy_key = clean_integration_key(data.get("integration"))
default_key = clean_integration_key(data.get("default_integration")) or legacy_key
installed = data.get("installed_integrations")
installed_keys = dedupe_integration_keys(installed if isinstance(installed, list) else [])
if not default_key and installed_keys:
default_key = installed_keys[0]
if default_key and default_key not in installed_keys:
installed_keys.insert(0, default_key)
settings = normalize_integration_settings(data.get("integration_settings"))
normalized = dict(data)
normalized["integration_state_schema"] = _normalized_integration_state_schema(
data.get("integration_state_schema")
)
if default_key:
normalized["integration"] = default_key
normalized["default_integration"] = default_key
else:
normalized.pop("integration", None)
normalized.pop("default_integration", None)
normalized["installed_integrations"] = installed_keys
normalized["integration_settings"] = {
key: settings[key] for key in installed_keys if key in settings
}
return normalized
def default_integration_key(state: dict[str, Any]) -> str | None:
"""Return the default integration key from normalized state."""
key = state.get("default_integration") or state.get("integration")
return clean_integration_key(key)
def installed_integration_keys(state: dict[str, Any]) -> list[str]:
"""Return installed integration keys from normalized state."""
return dedupe_integration_keys(state.get("installed_integrations", []))
def integration_settings(state: dict[str, Any]) -> dict[str, dict[str, Any]]:
"""Return normalized per-integration settings from state."""
return normalize_integration_settings(state.get("integration_settings"))
def integration_setting(state: dict[str, Any], key: str) -> dict[str, Any]:
"""Return stored runtime settings for *key*."""
return dict(integration_settings(state).get(key, {}))
def write_integration_json(
project_root: Path,
*,
version: str,
integration_key: str | None,
installed_integrations: list[str] | None = None,
settings: dict[str, dict[str, Any]] | None = None,
) -> None:
"""Write ``.specify/integration.json`` with legacy-compatible state."""
dest = project_root / INTEGRATION_JSON
dest.parent.mkdir(parents=True, exist_ok=True)
integration_key = clean_integration_key(integration_key)
installed = dedupe_integration_keys(installed_integrations or [])
if integration_key and integration_key not in installed:
installed.insert(0, integration_key)
if not integration_key and installed:
integration_key = installed[0]
normalized_settings = normalize_integration_settings(settings or {})
normalized_settings = {
key: normalized_settings[key] for key in installed if key in normalized_settings
}
data: dict[str, Any] = {
"version": version,
"integration_state_schema": INTEGRATION_STATE_SCHEMA,
"installed_integrations": installed,
"integration_settings": normalized_settings,
}
if integration_key:
data["integration"] = integration_key
data["default_integration"] = integration_key
dest.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")

View File

@@ -56,6 +56,7 @@ def _register_builtins() -> None:
from .codex import CodexIntegration
from .copilot import CopilotIntegration
from .cursor_agent import CursorAgentIntegration
from .devin import DevinIntegration
from .forge import ForgeIntegration
from .gemini import GeminiIntegration
from .generic import GenericIntegration
@@ -86,6 +87,7 @@ def _register_builtins() -> None:
_register(CodexIntegration())
_register(CopilotIntegration())
_register(CursorAgentIntegration())
_register(DevinIntegration())
_register(ForgeIntegration())
_register(GeminiIntegration())
_register(GenericIntegration())

View File

@@ -19,3 +19,4 @@ class AuggieIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = ".augment/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -87,6 +87,14 @@ class IntegrationBase(ABC):
invoke_separator: str = "."
"""Separator used in slash-command invocations (``"."`` → ``/speckit.plan``)."""
multi_install_safe: bool = False
"""Whether this integration is declared safe to install alongside others.
Safe integrations must use a static, unique agent root, command directory,
and context file. Registry tests enforce those invariants for every
integration that sets this flag.
"""
# -- Markers for managed context section ------------------------------
CONTEXT_MARKER_START = "<!-- SPECKIT START -->"

View File

@@ -16,7 +16,7 @@ import re
from dataclasses import dataclass
from datetime import datetime, timezone
from pathlib import Path
from typing import Any, Dict, List, Optional
from typing import Any, Dict, List, Optional, Tuple
import yaml
from packaging import version as pkg_version
@@ -30,6 +30,10 @@ class IntegrationCatalogError(Exception):
"""Raised when a catalog operation fails."""
class IntegrationValidationError(IntegrationCatalogError):
"""Validation error for catalog config or catalog management operations."""
class IntegrationDescriptorError(Exception):
"""Raised when an integration.yml descriptor is invalid."""
@@ -96,28 +100,36 @@ class IntegrationCatalog:
Returns None when the file does not exist.
Raises:
IntegrationCatalogError: on invalid content
IntegrationValidationError: on any local-config / YAML problem
(parse failures, wrong shape, missing/invalid fields,
invalid catalog URLs, etc.). This is a subclass of
:class:`IntegrationCatalogError`, so any caller that already
catches ``IntegrationCatalogError`` keeps working — but
callers that want to distinguish *local config* problems
from *remote/network* problems can match the subclass.
"""
if not config_path.exists():
return None
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8")) or {}
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise IntegrationCatalogError(
raise IntegrationValidationError(
f"Failed to read catalog config {config_path}: {exc}"
)
) from exc
if data is None:
data = {}
if not isinstance(data, dict):
raise IntegrationCatalogError(
raise IntegrationValidationError(
f"Invalid catalog config {config_path}: expected a YAML mapping at the root"
)
catalogs_data = data.get("catalogs", [])
if not isinstance(catalogs_data, list):
raise IntegrationCatalogError(
f"Invalid catalog config: 'catalogs' must be a list, "
raise IntegrationValidationError(
f"Invalid catalog config {config_path}: 'catalogs' must be a list, "
f"got {type(catalogs_data).__name__}"
)
if not catalogs_data:
raise IntegrationCatalogError(
raise IntegrationValidationError(
f"Catalog config {config_path} exists but contains no 'catalogs' entries. "
f"Remove the file to use built-in defaults, or add valid catalog entries."
)
@@ -125,31 +137,52 @@ class IntegrationCatalog:
skipped: List[int] = []
for idx, item in enumerate(catalogs_data):
if not isinstance(item, dict):
raise IntegrationCatalogError(
f"Invalid catalog entry at index {idx}: "
raise IntegrationValidationError(
f"Invalid catalog config {config_path}: catalog entry at index {idx}: "
f"expected a mapping, got {type(item).__name__}"
)
url = str(item.get("url", "")).strip()
if not url:
skipped.append(idx)
continue
self._validate_catalog_url(url)
try:
priority = int(item.get("priority", idx + 1))
except (TypeError, ValueError):
raise IntegrationCatalogError(
self._validate_catalog_url(url)
except IntegrationCatalogError as exc:
# ``_validate_catalog_url`` raises the base class for direct
# callers (e.g. ``add_catalog`` validating user input); when
# the bad URL came from a local config file, surface it as a
# validation error so CLI handlers can route it accordingly.
raise IntegrationValidationError(
f"Invalid catalog URL in {config_path} at index {idx}: {exc}"
) from exc
raw_priority = item.get("priority", idx + 1)
if isinstance(raw_priority, bool):
raise IntegrationValidationError(
f"Invalid catalog config {config_path}: "
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {item.get('priority')!r}"
f"expected integer, got {raw_priority!r}"
)
try:
priority = int(raw_priority)
except (TypeError, ValueError):
raise IntegrationValidationError(
f"Invalid catalog config {config_path}: "
f"Invalid priority for catalog '{item.get('name', idx + 1)}': "
f"expected integer, got {raw_priority!r}"
)
raw_install = item.get("install_allowed", False)
if isinstance(raw_install, str):
install_allowed = raw_install.strip().lower() in ("true", "yes", "1")
else:
install_allowed = bool(raw_install)
raw_name = item.get("name")
name = str(raw_name).strip() if raw_name is not None else ""
if not name:
name = f"catalog-{len(entries) + 1}"
entries.append(
IntegrationCatalogEntry(
url=url,
name=str(item.get("name", f"catalog-{idx + 1}")),
name=name,
priority=priority,
install_allowed=install_allowed,
description=str(item.get("description", "")),
@@ -157,7 +190,7 @@ class IntegrationCatalog:
)
entries.sort(key=lambda e: e.priority)
if not entries:
raise IntegrationCatalogError(
raise IntegrationValidationError(
f"Catalog config {config_path} contains {len(catalogs_data)} "
f"entries but none have valid URLs (entries at indices {skipped} "
f"were skipped). Each catalog entry must have a 'url' field."
@@ -196,12 +229,12 @@ class IntegrationCatalog:
)
]
project_cfg = self.project_root / ".specify" / "integration-catalogs.yml"
project_cfg = self.project_root / ".specify" / self.CONFIG_FILENAME
catalogs = self._load_catalog_config(project_cfg)
if catalogs is not None:
return catalogs
user_cfg = Path.home() / ".specify" / "integration-catalogs.yml"
user_cfg = Path.home() / ".specify" / self.CONFIG_FILENAME
catalogs = self._load_catalog_config(user_cfg)
if catalogs is not None:
return catalogs
@@ -408,6 +441,288 @@ class IntegrationCatalog:
for f in self.cache_dir.glob(pattern):
f.unlink(missing_ok=True)
# -- Catalog-source management ----------------------------------------
CONFIG_FILENAME = "integration-catalogs.yml"
def get_catalog_configs(self) -> List[Dict[str, Any]]:
"""Return the active catalog stack as a list of dicts.
Thin adapter over :meth:`get_active_catalogs` that yields plain dicts
suitable for CLI rendering and JSON-like consumers.
"""
return [
{
"name": e.name,
"url": e.url,
"priority": e.priority,
"install_allowed": e.install_allowed,
"description": e.description,
}
for e in self.get_active_catalogs()
]
def get_project_catalog_configs(self) -> Optional[List[Dict[str, Any]]]:
"""Return removable project-level catalog config entries, if configured."""
config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
entries = self._load_catalog_config(config_path)
if entries is None:
return None
return [
{
"name": e.name,
"url": e.url,
"priority": e.priority,
"install_allowed": e.install_allowed,
"description": e.description,
}
for e in entries
]
def add_catalog(self, url: str, name: Optional[str] = None) -> None:
"""Add a catalog source to the project-level config file.
The URL is normalized (whitespace stripped) and validated before being
written. Duplicate URLs are rejected, including near-duplicates that
differ only by surrounding whitespace. Priority is derived as
``max(existing) + 1`` so the new entry sorts last in the resolution
order unless the user edits the file manually.
"""
url = url.strip()
if not url:
raise IntegrationValidationError("Catalog URL must be non-empty.")
self._validate_catalog_url(url)
config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
data: Dict[str, Any] = {"catalogs": []}
if config_path.exists():
try:
raw = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise IntegrationValidationError(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
if raw is None:
raw = {}
if not isinstance(raw, dict):
raise IntegrationValidationError(
f"Catalog config file {config_path} is corrupted "
"(expected a mapping)."
)
data = raw
catalogs = data.get("catalogs", [])
if not isinstance(catalogs, list):
raise IntegrationValidationError(
f"Catalog config {config_path} has invalid 'catalogs' value: "
"must be a list."
)
# Validate each existing entry before mutating anything. Fail fast so
# we don't silently preserve a corrupt sibling entry or derive a new
# priority from a bogus value.
existing_priorities: List[int] = []
valid_catalog_count = 0
for idx, cat in enumerate(catalogs):
if not isinstance(cat, dict):
raise IntegrationValidationError(
f"Invalid catalog entry at index {idx} in {config_path}: "
f"expected a mapping, got {type(cat).__name__}."
)
existing_url = str(cat.get("url", "")).strip()
if not existing_url:
continue
# Re-run the same URL validation used when loading, so a corrupt
# entry surfaces here instead of at the next `integration` call.
try:
self._validate_catalog_url(existing_url)
except IntegrationCatalogError as exc:
raise IntegrationValidationError(
f"Invalid catalog entry at index {idx} in {config_path}: {exc}"
) from exc
if existing_url == url:
raise IntegrationValidationError(
f"Catalog URL already configured: {url}"
)
valid_catalog_count += 1
if "priority" in cat:
raw_priority = cat.get("priority")
if isinstance(raw_priority, bool):
raise IntegrationValidationError(
f"Invalid catalog entry at index {idx} in {config_path}: "
f"'priority' must be an integer, got "
f"{type(raw_priority).__name__}."
)
try:
normalized_priority = int(raw_priority)
except (TypeError, ValueError):
raise IntegrationValidationError(
f"Invalid catalog entry at index {idx} in {config_path}: "
f"'priority' must be an integer, got "
f"{raw_priority!r}."
) from None
existing_priorities.append(normalized_priority)
else:
# Match `_load_catalog_config()`'s defaulting rule so the new
# entry still sorts after implicit-priority siblings.
existing_priorities.append(idx + 1)
max_priority = max(existing_priorities, default=0)
normalized_name = str(name).strip() if name is not None else ""
generated_name = f"catalog-{valid_catalog_count + 1}"
catalogs.append(
{
"name": normalized_name or generated_name,
"url": url,
"priority": max_priority + 1,
"install_allowed": True,
"description": "",
}
)
data["catalogs"] = catalogs
config_path.parent.mkdir(parents=True, exist_ok=True)
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(
data,
f,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
)
def remove_catalog(self, index: int) -> str:
"""Remove a catalog source by 0-based index.
``index`` is interpreted in the same display order shown by
``integration catalog list`` (i.e. sorted ascending by priority,
with missing priority defaulting to ``yaml_index + 1``, matching
``_load_catalog_config()``). This way, the index a user sees in
``catalog list`` is the index they pass to ``catalog remove``,
even if the underlying YAML lists entries in a different order
from how they sort by priority.
Returns the removed catalog's name.
"""
config_path = self.project_root / ".specify" / self.CONFIG_FILENAME
if not config_path.exists():
raise IntegrationValidationError("No catalog config file found.")
try:
data = yaml.safe_load(config_path.read_text(encoding="utf-8"))
except (yaml.YAMLError, OSError, UnicodeError) as exc:
raise IntegrationValidationError(
f"Failed to read catalog config {config_path}: {exc}"
) from exc
if data is None:
data = {}
if not isinstance(data, dict):
raise IntegrationValidationError(
f"Catalog config file {config_path} is corrupted "
"(expected a mapping)."
)
catalogs = data.get("catalogs", [])
if not isinstance(catalogs, list):
raise IntegrationValidationError(
f"Catalog config {config_path} has invalid 'catalogs' value: "
"must be a list."
)
if not catalogs:
# An empty list is the kind of state that only happens if the
# user hand-edited the file; our own `remove_catalog` deletes
# the file when the last entry is popped. Surface a clear
# message instead of `out of range (0--1)`.
raise IntegrationValidationError(
"Catalog config contains no catalog entries."
)
# Map displayed index -> raw YAML index using the same priority
# defaulting as ``_load_catalog_config``. We deliberately stay
# tolerant here (no new validation errors) because the goal is
# only to mirror the order shown by ``catalog list``; entries
# that ``_load_catalog_config`` would have rejected outright
# would have failed ``catalog list`` already.
def _is_removable_catalog_entry(item: Any) -> bool:
if not isinstance(item, dict):
return False
raw_url = item.get("url")
if raw_url is None:
return False
return bool(str(raw_url).strip())
priority_pairs: List[Tuple[int, int]] = []
for yaml_idx, item in enumerate(catalogs):
if not _is_removable_catalog_entry(item):
continue
raw_priority = item.get("priority", yaml_idx + 1)
if isinstance(raw_priority, bool):
priority = yaml_idx + 1
else:
try:
priority = int(raw_priority)
except (TypeError, ValueError):
priority = yaml_idx + 1
priority_pairs.append((priority, yaml_idx))
if not priority_pairs:
raise IntegrationValidationError(
"Catalog config contains no removable catalog entries."
)
# Stable sort: ties keep their YAML order, matching list-view ordering.
priority_pairs.sort(key=lambda p: p[0])
display_order: List[int] = [yaml_idx for _, yaml_idx in priority_pairs]
if index < 0 or index >= len(display_order):
raise IntegrationValidationError(
f"Catalog index {index} out of range (0-{len(display_order) - 1})."
)
target_yaml_idx = display_order[index]
removed = catalogs.pop(target_yaml_idx)
if any(_is_removable_catalog_entry(item) for item in catalogs):
data["catalogs"] = catalogs
with open(config_path, "w", encoding="utf-8") as f:
yaml.dump(
data,
f,
default_flow_style=False,
sort_keys=False,
allow_unicode=True,
)
else:
# Removing the final entry: delete the config file rather than
# leaving behind an empty `catalogs:` list. `_load_catalog_config`
# treats an empty list as an error, so leaving the file would
# break every subsequent `integration` command until the user
# manually deletes `.specify/integration-catalogs.yml`.
# Deleting the file lets the project fall back to built-in
# defaults, which matches the behavior before any
# `catalog add` was ever run.
try:
config_path.unlink(missing_ok=True)
except OSError as exc:
raise IntegrationValidationError(
f"Failed to delete catalog config {config_path}: {exc}"
) from exc
fallback_name = f"catalog-{index + 1}"
if isinstance(removed, dict):
removed_name = removed.get("name")
if removed_name is not None:
normalized_name = str(removed_name).strip()
if normalized_name:
return normalized_name
removed_url = removed.get("url")
if removed_url is not None:
normalized_url = str(removed_url).strip()
if normalized_url:
return normalized_url
return fallback_name
# ---------------------------------------------------------------------------
# IntegrationDescriptor (integration.yml)

View File

@@ -53,6 +53,7 @@ class ClaudeIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
context_file = "CLAUDE.md"
multi_install_safe = True
@staticmethod
def inject_argument_hint(content: str, hint: str) -> str:

View File

@@ -19,3 +19,4 @@ class CodebuddyIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = "CODEBUDDY.md"
multi_install_safe = True

View File

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

View File

@@ -26,6 +26,7 @@ class CursorAgentIntegration(SkillsIntegration):
}
context_file = ".cursor/rules/specify-rules.mdc"
multi_install_safe = True
@classmethod
def options(cls) -> list[IntegrationOption]:

View File

@@ -0,0 +1,65 @@
"""Devin for Terminal integration — skills-based agent.
Devin uses the ``.devin/skills/speckit-<name>/SKILL.md`` layout and
reads project context from ``AGENTS.md`` at the repo root. The CLI
binary is ``devin`` and skills are invoked via ``/<name>`` inside an
interactive ``devin`` session.
See: https://cli.devin.ai/docs/extensibility/skills/overview
"""
from __future__ import annotations
from ..base import IntegrationOption, SkillsIntegration
class DevinIntegration(SkillsIntegration):
"""Integration for Cognition AI's Devin for Terminal."""
key = "devin"
config = {
"name": "Devin for Terminal",
"folder": ".devin/",
"commands_subdir": "skills",
"install_url": "https://cli.devin.ai/docs",
"requires_cli": True,
}
registrar_config = {
"dir": ".devin/skills",
"format": "markdown",
"args": "$ARGUMENTS",
"extension": "/SKILL.md",
}
context_file = "AGENTS.md"
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
"""Build non-interactive CLI args for Devin for Terminal.
Devin supports ``devin -p <prompt>`` for single-turn execution
and ``--model`` for model selection, but its CLI has no flag
for structured JSON output. When ``output_json`` is requested,
Devin is still dispatched normally and returns plain-text
stdout instead of structured JSON. ``requires_cli=True`` is
kept on the integration for tool detection.
"""
args = [self.key, "-p", prompt]
if model:
args.extend(["--model", model])
return args
@classmethod
def options(cls) -> list[IntegrationOption]:
return [
IntegrationOption(
"--skills",
is_flag=True,
default=True,
help="Install as agent skills (default for Devin)",
),
]

View File

@@ -19,3 +19,4 @@ class GeminiIntegration(TomlIntegration):
"extension": ".toml",
}
context_file = "GEMINI.md"
multi_install_safe = True

View File

@@ -19,3 +19,4 @@ class IflowIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = "IFLOW.md"
multi_install_safe = True

View File

@@ -19,3 +19,4 @@ class JunieIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = ".junie/AGENTS.md"
multi_install_safe = True

View File

@@ -19,3 +19,4 @@ class KilocodeIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = ".kilocode/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -36,6 +36,7 @@ class KimiIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
context_file = "KIMI.md"
multi_install_safe = True
@classmethod
def options(cls) -> list[IntegrationOption]:

View File

@@ -11,6 +11,7 @@ from __future__ import annotations
import hashlib
import json
import os
import tempfile
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
@@ -47,6 +48,59 @@ def _validate_rel_path(rel: Path, root: Path) -> Path:
return resolved
def _manifest_path_label(root: Path, path: Path) -> str:
try:
return path.relative_to(root).as_posix()
except ValueError:
return path.as_posix()
def _ensure_safe_manifest_directory(root: Path, directory: Path) -> None:
"""Create a manifest directory without following symlinked parents."""
root_resolved = root.resolve()
try:
rel = directory.relative_to(root)
except ValueError:
label = _manifest_path_label(root, directory)
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
current = root
for part in rel.parts:
current = current / part
label = _manifest_path_label(root, current)
if current.is_symlink():
raise ValueError(f"Refusing to use symlinked integration manifest directory: {label}")
if current.exists():
if not current.is_dir():
raise ValueError(f"Integration manifest directory path is not a directory: {label}")
try:
current.resolve().relative_to(root_resolved)
except (OSError, ValueError):
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
continue
current.mkdir()
try:
current.resolve().relative_to(root_resolved)
except (OSError, ValueError):
raise ValueError(f"Integration manifest directory escapes project root: {label}") from None
def _ensure_safe_manifest_destination(root: Path, path: Path) -> None:
"""Refuse manifest writes that would escape the project or follow symlinks."""
root_resolved = root.resolve()
_ensure_safe_manifest_directory(root, path.parent)
label = _manifest_path_label(root, path)
if path.is_symlink():
raise ValueError(f"Refusing to overwrite symlinked integration manifest path: {label}")
if path.exists():
if not path.is_file():
raise ValueError(f"Integration manifest path is not a file: {label}")
try:
path.resolve().relative_to(root_resolved)
except (OSError, ValueError):
raise ValueError(f"Integration manifest path escapes project root: {label}") from None
class IntegrationManifest:
"""Tracks files installed by a single integration.
@@ -217,8 +271,19 @@ class IntegrationManifest:
"files": self._files,
}
path = self.manifest_path
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
content = json.dumps(data, indent=2) + "\n"
_ensure_safe_manifest_destination(self.project_root, path)
fd, temp_name = tempfile.mkstemp(prefix=f".{path.name}.", dir=path.parent)
temp_path = Path(temp_name)
try:
with os.fdopen(fd, "w", encoding="utf-8") as fh:
fh.write(content)
temp_path.chmod(0o644)
_ensure_safe_manifest_destination(self.project_root, path)
os.replace(temp_path, path)
finally:
if temp_path.exists():
temp_path.unlink()
return path
@classmethod

View File

@@ -19,3 +19,27 @@ class OpencodeIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = "AGENTS.md"
def build_exec_args(
self,
prompt: str,
*,
model: str | None = None,
output_json: bool = True,
) -> list[str] | None:
args = [self.key, "run"]
message = prompt
if prompt.startswith("/"):
command, _, remainder = prompt[1:].partition(" ")
if command:
args.extend(["--command", command])
message = remainder
if model:
args.extend(["-m", model])
if output_json:
args.extend(["--format", "json"])
if message:
args.append(message)
return args

View File

@@ -19,3 +19,4 @@ class QodercliIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = "QODER.md"
multi_install_safe = True

View File

@@ -19,3 +19,4 @@ class QwenIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = "QWEN.md"
multi_install_safe = True

View File

@@ -19,3 +19,4 @@ class RooIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = ".roo/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -19,3 +19,4 @@ class ShaiIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = "SHAI.md"
multi_install_safe = True

View File

@@ -19,3 +19,4 @@ class TabnineIntegration(TomlIntegration):
"extension": ".toml",
}
context_file = "TABNINE.md"
multi_install_safe = True

View File

@@ -27,6 +27,7 @@ class TraeIntegration(SkillsIntegration):
"extension": "/SKILL.md",
}
context_file = ".trae/rules/project_rules.md"
multi_install_safe = True
@classmethod
def options(cls) -> list[IntegrationOption]:

View File

@@ -19,3 +19,4 @@ class WindsurfIntegration(MarkdownIntegration):
"extension": ".md",
}
context_file = ".windsurf/rules/specify-rules.md"
multi_install_safe = True

View File

@@ -27,7 +27,7 @@ import yaml
from packaging import version as pkg_version
from packaging.specifiers import SpecifierSet, InvalidSpecifier
from .extensions import ExtensionRegistry, normalize_priority, detect_archive_format, safe_extract_tarball
from .extensions import REINSTALL_COMMAND, ExtensionRegistry, normalize_priority
def _substitute_core_template(
@@ -576,7 +576,7 @@ class PresetManager:
raise PresetCompatibilityError(
f"Preset requires spec-kit {required}, "
f"but {speckit_version} is installed.\n"
f"Upgrade spec-kit with: uv tool install specify-cli --force"
f"Upgrade spec-kit with: {REINSTALL_COMMAND}"
)
except InvalidSpecifier:
raise PresetCompatibilityError(
@@ -1604,10 +1604,10 @@ class PresetManager:
speckit_version: str,
priority: int = 10,
) -> PresetManifest:
"""Install preset from a ZIP or ``.tar.gz``/``.tgz`` archive.
"""Install preset from ZIP file.
Args:
zip_path: Path to the preset archive (ZIP or gzipped tarball).
zip_path: Path to preset ZIP file
speckit_version: Current spec-kit version
priority: Resolution priority (lower = higher precedence, default 10)
@@ -1615,8 +1615,7 @@ class PresetManager:
Installed preset manifest
Raises:
PresetValidationError: If manifest is invalid, the archive is unsafe,
or priority is invalid
PresetValidationError: If manifest is invalid or priority is invalid
PresetCompatibilityError: If pack is incompatible
"""
# Validate priority early
@@ -1626,24 +1625,18 @@ class PresetManager:
with tempfile.TemporaryDirectory() as tmpdir:
temp_path = Path(tmpdir)
archive_fmt = detect_archive_format(str(zip_path))
if archive_fmt == "tar.gz":
# Extract tarball safely (prevent tar slip attack)
safe_extract_tarball(zip_path, temp_path, PresetValidationError)
else:
with zipfile.ZipFile(zip_path, 'r') as zf:
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise PresetValidationError(
f"Unsafe path in ZIP archive: {member} "
"(potential path traversal)"
)
zf.extractall(temp_path)
with zipfile.ZipFile(zip_path, 'r') as zf:
temp_path_resolved = temp_path.resolve()
for member in zf.namelist():
member_path = (temp_path / member).resolve()
try:
member_path.relative_to(temp_path_resolved)
except ValueError:
raise PresetValidationError(
f"Unsafe path in ZIP archive: {member} "
"(potential path traversal)"
)
zf.extractall(temp_path)
pack_dir = temp_path
manifest_path = pack_dir / "preset.yml"
@@ -1656,7 +1649,7 @@ class PresetManager:
if not manifest_path.exists():
raise PresetValidationError(
"No preset.yml found in archive"
"No preset.yml found in ZIP file"
)
return self.install_from_directory(pack_dir, speckit_version, priority)
@@ -2249,18 +2242,14 @@ class PresetCatalog:
def download_pack(
self, pack_id: str, target_dir: Optional[Path] = None
) -> Path:
"""Download preset archive from catalog.
Supports both ZIP (``.zip``) and gzipped tarball (``.tar.gz``/``.tgz``)
archives. The format is detected from the download URL's path extension;
when ambiguous the ``Content-Type`` header is used as a fallback.
"""Download preset ZIP from catalog.
Args:
pack_id: ID of the preset to download
target_dir: Directory to save the archive (defaults to cache directory)
target_dir: Directory to save ZIP file (defaults to cache directory)
Returns:
Path to downloaded archive file
Path to downloaded ZIP file
Raises:
PresetError: If pack not found or download fails
@@ -2312,61 +2301,22 @@ class PresetCatalog:
target_dir.mkdir(parents=True, exist_ok=True)
version = pack_info.get("version", "unknown")
zip_filename = f"{pack_id}-{version}.zip"
zip_path = target_dir / zip_filename
# Determine the archive format from the post-redirect URL first
# (with Content-Type fallback); only use the original `download_url`
# as a last hint if the final URL gives no signal.
final_url = download_url
archive_fmt = ""
try:
with self._open_url(download_url, timeout=60) as response:
final_url = response.geturl()
# Re-validate scheme after any redirect to guard against
# scheme-downgrade. Validate BEFORE reading the body so a
# malicious redirect cannot cause us to fetch the payload
# over an insecure scheme.
_final_parsed = urlparse(final_url)
_final_is_localhost = _final_parsed.hostname in (
"localhost",
"127.0.0.1",
"::1",
)
if _final_parsed.scheme != "https" and not (
_final_parsed.scheme == "http" and _final_is_localhost
):
raise PresetError(
f"Preset download URL was redirected to a non-HTTPS URL: {final_url}"
)
content_type = response.headers.get("Content-Type", "")
archive_fmt = detect_archive_format(final_url, content_type)
if not archive_fmt:
archive_fmt = detect_archive_format(download_url)
archive_data = response.read()
zip_data = response.read()
zip_path.write_bytes(zip_data)
return zip_path
except urllib.error.URLError as e:
raise PresetError(
f"Failed to download preset from {download_url}: {e}"
)
except IOError as e:
raise PresetError(f"Failed to read preset archive from {download_url}: {e}")
# Choose file extension based on detected format.
if not archive_fmt:
raise PresetError(
f"Could not determine archive format for {download_url}. "
"Ensure the URL points to a .zip or .tar.gz/.tgz file."
)
if archive_fmt == "tar.gz":
archive_filename = f"{pack_id}-{version}.tar.gz"
else:
archive_filename = f"{pack_id}-{version}.zip"
archive_path = target_dir / archive_filename
try:
archive_path.write_bytes(archive_data)
except IOError as e:
raise PresetError(f"Failed to save preset archive: {e}")
return archive_path
raise PresetError(f"Failed to save preset ZIP: {e}")
def clear_cache(self):
"""Clear all catalog cache files, including per-URL hashed caches."""

View File

@@ -0,0 +1,317 @@
"""Shared Spec Kit infrastructure installation helpers."""
from __future__ import annotations
import os
import tempfile
from pathlib import Path
from typing import Any
from .integrations.base import IntegrationBase
from .integrations.manifest import IntegrationManifest
def load_speckit_manifest(
project_path: Path,
*,
version: str,
console: Any | None = None,
) -> IntegrationManifest:
"""Load the shared infrastructure manifest, preserving existing entries."""
manifest_path = project_path / ".specify" / "integrations" / "speckit.manifest.json"
if manifest_path.exists():
try:
manifest = IntegrationManifest.load("speckit", project_path)
manifest.version = version
return manifest
except (ValueError, FileNotFoundError, OSError, UnicodeDecodeError) as exc:
if console is not None:
console.print(
f"[yellow]Warning:[/yellow] Could not read shared infrastructure "
f"manifest at {manifest_path}: {exc}"
)
console.print(
"A new shared manifest will be created; previously tracked "
"shared files may be treated as untracked."
)
return IntegrationManifest("speckit", project_path, version=version)
def shared_templates_source(
*,
core_pack: Path | None,
repo_root: Path,
) -> Path:
"""Return the bundled/source shared templates directory."""
if core_pack and (core_pack / "templates").is_dir():
return core_pack / "templates"
return repo_root / "templates"
def shared_scripts_source(
*,
core_pack: Path | None,
repo_root: Path,
) -> Path:
"""Return the bundled/source shared scripts directory."""
if core_pack and (core_pack / "scripts").is_dir():
return core_pack / "scripts"
return repo_root / "scripts"
def _shared_destination_label(project_path: Path, dest: Path) -> str:
try:
return dest.relative_to(project_path).as_posix()
except ValueError:
return str(dest)
def _shared_relative_path(project_path: Path, dest: Path) -> Path:
try:
rel = dest.relative_to(project_path)
except ValueError:
label = _shared_destination_label(project_path, dest)
raise ValueError(f"Shared infrastructure path escapes project root: {label}") from None
if rel.is_absolute() or ".." in rel.parts:
label = _shared_destination_label(project_path, dest)
raise ValueError(f"Shared infrastructure path escapes project root: {label}")
return rel
def _ensure_safe_shared_directory(project_path: Path, directory: Path, *, create: bool = True) -> None:
"""Create a shared infra directory without following symlinked parents."""
root = project_path.resolve()
rel = _shared_relative_path(project_path, directory)
current = project_path
for part in rel.parts:
current = current / part
label = _shared_destination_label(project_path, current)
if current.is_symlink():
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
if current.exists():
if not current.is_dir():
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
try:
current.resolve().relative_to(root)
except (OSError, ValueError):
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
continue
if not create:
raise ValueError(f"Shared infrastructure directory does not exist: {label}")
current.mkdir()
if current.is_symlink():
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
try:
current.resolve().relative_to(root)
except (OSError, ValueError):
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
def _validate_safe_shared_directory(project_path: Path, directory: Path) -> None:
"""Validate existing directory parents while allowing missing directories."""
root = project_path.resolve()
rel = _shared_relative_path(project_path, directory)
current = project_path
for part in rel.parts:
current = current / part
label = _shared_destination_label(project_path, current)
if current.is_symlink():
raise ValueError(f"Refusing to use symlinked shared infrastructure directory: {label}")
if not current.exists():
continue
if not current.is_dir():
raise ValueError(f"Shared infrastructure directory path is not a directory: {label}")
try:
current.resolve().relative_to(root)
except (OSError, ValueError):
raise ValueError(f"Shared infrastructure directory escapes project root: {label}") from None
def _ensure_safe_shared_destination(
project_path: Path,
dest: Path,
*,
parent_must_exist: bool = True,
) -> None:
"""Refuse shared infra writes that would escape or follow symlinks."""
root = project_path.resolve()
_shared_relative_path(project_path, dest)
if parent_must_exist:
_ensure_safe_shared_directory(project_path, dest.parent, create=False)
else:
_validate_safe_shared_directory(project_path, dest.parent)
label = _shared_destination_label(project_path, dest)
if dest.is_symlink():
raise ValueError(f"Refusing to overwrite symlinked shared infrastructure path: {label}")
if dest.exists():
try:
dest.resolve().relative_to(root)
except (OSError, ValueError):
raise ValueError(f"Shared infrastructure destination escapes project root: {label}") from None
def _write_shared_text(project_path: Path, dest: Path, content: str) -> None:
_write_shared_bytes(project_path, dest, content.encode("utf-8"))
def _write_shared_bytes(
project_path: Path,
dest: Path,
content: bytes,
*,
mode: int = 0o644,
) -> None:
_ensure_safe_shared_destination(project_path, dest)
fd, temp_name = tempfile.mkstemp(prefix=f".{dest.name}.", dir=dest.parent)
temp_path = Path(temp_name)
try:
with os.fdopen(fd, "wb") as fh:
fh.write(content)
temp_path.chmod(mode)
_ensure_safe_shared_destination(project_path, dest)
os.replace(temp_path, dest)
finally:
if temp_path.exists():
temp_path.unlink()
def refresh_shared_templates(
project_path: Path,
*,
version: str,
core_pack: Path | None,
repo_root: Path,
console: Any,
invoke_separator: str,
force: bool = False,
) -> None:
"""Refresh default-sensitive shared templates without touching scripts."""
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
if not templates_src.is_dir():
return
manifest = load_speckit_manifest(project_path, version=version, console=console)
tracked_files = manifest.files
modified = set(manifest.check_modified())
skipped_files: list[str] = []
planned_updates: list[tuple[Path, str, str]] = []
dest_templates = project_path / ".specify" / "templates"
_ensure_safe_shared_directory(project_path, dest_templates)
for src in templates_src.iterdir():
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
continue
dst = dest_templates / src.name
_ensure_safe_shared_destination(project_path, dst)
rel = dst.relative_to(project_path).as_posix()
if dst.exists() and not force:
if rel not in tracked_files or rel in modified:
skipped_files.append(rel)
continue
content = src.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
planned_updates.append((dst, rel, content))
for dst, rel, content in planned_updates:
_write_shared_text(project_path, dst, content)
manifest.record_existing(rel)
manifest.save()
if skipped_files:
console.print(
f"[yellow]⚠[/yellow] {len(skipped_files)} modified or untracked shared template file(s) were not updated:"
)
for rel in skipped_files:
console.print(f" {rel}")
def install_shared_infra(
project_path: Path,
script_type: str,
*,
version: str,
core_pack: Path | None,
repo_root: Path,
console: Any,
force: bool = False,
invoke_separator: str = ".",
) -> bool:
"""Install shared scripts and templates into *project_path*."""
manifest = load_speckit_manifest(project_path, version=version, console=console)
skipped_files: list[str] = []
planned_copies: list[tuple[Path, str, bytes, int]] = []
planned_templates: list[tuple[Path, str, str]] = []
scripts_src = shared_scripts_source(core_pack=core_pack, repo_root=repo_root)
if scripts_src.is_dir():
dest_scripts = project_path / ".specify" / "scripts"
_ensure_safe_shared_directory(project_path, 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
_ensure_safe_shared_directory(project_path, dest_variant)
for src_path in variant_src.rglob("*"):
if not src_path.is_file():
continue
rel_path = src_path.relative_to(variant_src)
dst_path = dest_variant / rel_path
_ensure_safe_shared_destination(project_path, dst_path, parent_must_exist=False)
if dst_path.exists() and not force:
skipped_files.append(dst_path.relative_to(project_path).as_posix())
continue
_ensure_safe_shared_directory(project_path, dst_path.parent)
rel = dst_path.relative_to(project_path).as_posix()
planned_copies.append((dst_path, rel, src_path.read_bytes(), src_path.stat().st_mode & 0o777))
templates_src = shared_templates_source(core_pack=core_pack, repo_root=repo_root)
if templates_src.is_dir():
dest_templates = project_path / ".specify" / "templates"
_ensure_safe_shared_directory(project_path, dest_templates)
for src in templates_src.iterdir():
if not src.is_file() or src.name == "vscode-settings.json" or src.name.startswith("."):
continue
dst = dest_templates / src.name
_ensure_safe_shared_destination(project_path, dst)
if dst.exists() and not force:
skipped_files.append(dst.relative_to(project_path).as_posix())
continue
content = src.read_text(encoding="utf-8")
content = IntegrationBase.resolve_command_refs(content, invoke_separator)
rel = dst.relative_to(project_path).as_posix()
planned_templates.append((dst, rel, content))
for dst_path, rel, content, mode in planned_copies:
_ensure_safe_shared_directory(project_path, dst_path.parent)
_write_shared_bytes(project_path, dst_path, content, mode=mode)
manifest.record_existing(rel)
for dst, rel, content in planned_templates:
_write_shared_text(project_path, dst, content)
manifest.record_existing(rel)
if skipped_files:
console.print(
f"[yellow]⚠[/yellow] {len(skipped_files)} shared infrastructure file(s) already exist and were not updated:"
)
for path in skipped_files:
console.print(f" {path}")
console.print(
"To refresh shared infrastructure, run "
"[cyan]specify init --here --force[/cyan] or "
"[cyan]specify integration upgrade --force[/cyan]."
)
manifest.save()
return True

View File

@@ -183,7 +183,7 @@ Given that feature description, do this:
c. **Handle Validation Results**:
- **If all items pass**: Mark checklist complete and proceed to step 7
- **If all items pass**: Mark checklist complete and proceed to step 8
- **If items fail (excluding [NEEDS CLARIFICATION])**:
1. List the failing items and specific issues

View File

@@ -10,8 +10,8 @@ handoffs:
prompt: Start the implementation in phases
send: true
scripts:
sh: scripts/bash/check-prerequisites.sh --json
ps: scripts/powershell/check-prerequisites.ps1 -Json
sh: scripts/bash/setup-tasks.sh --json
ps: scripts/powershell/setup-tasks.ps1 -Json
---
## User Input
@@ -58,7 +58,7 @@ You **MUST** consider the user input before proceeding (if not empty).
## Outline
1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR and AVAILABLE_DOCS list. All paths must be absolute. For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
1. **Setup**: Run `{SCRIPT}` from repo root and parse FEATURE_DIR, TASKS_TEMPLATE, and AVAILABLE_DOCS list. `FEATURE_DIR` and `TASKS_TEMPLATE` must be absolute paths when provided. `AVAILABLE_DOCS` is a list of document names/relative paths available under `FEATURE_DIR` (for example `research.md` or `contracts/`). For single quotes in args like "I'm Groot", use escape syntax: e.g 'I'\''m Groot' (or double-quote if possible: "I'm Groot").
2. **Load design documents**: Read from FEATURE_DIR:
- **Required**: plan.md (tech stack, libraries, structure), spec.md (user stories with priorities)
@@ -76,7 +76,7 @@ You **MUST** consider the user input before proceeding (if not empty).
- Create parallel execution examples per user story
- Validate task completeness (each user story has all needed tasks, independently testable)
4. **Generate tasks.md**: Use `templates/tasks-template.md` as structure, fill with:
4. **Generate tasks.md**: Read the tasks template from TASKS_TEMPLATE (from the JSON output above) and use it as structure. If TASKS_TEMPLATE is empty, fall back to `.specify/templates/tasks-template.md`. Fill with:
- Correct feature name from plan.md
- Phase 1: Setup tasks (project initialization)
- Phase 2: Foundational tasks (blocking prerequisites for all user stories)

File diff suppressed because it is too large Load Diff

View File

@@ -274,11 +274,11 @@ class MarkdownIntegrationTests:
if script_variant == "sh":
for name in ["check-prerequisites.sh", "common.sh", "create-new-feature.sh",
"setup-plan.sh"]:
"setup-plan.sh", "setup-tasks.sh"]:
files.append(f".specify/scripts/bash/{name}")
else:
for name in ["check-prerequisites.ps1", "common.ps1", "create-new-feature.ps1",
"setup-plan.ps1"]:
"setup-plan.ps1", "setup-tasks.ps1"]:
files.append(f".specify/scripts/powershell/{name}")
for name in ["checklist-template.md",

View File

@@ -387,6 +387,7 @@ class SkillsIntegrationTests:
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
]
else:
files += [
@@ -394,6 +395,7 @@ class SkillsIntegrationTests:
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/setup-tasks.ps1",
]
# Templates
files += [

View File

@@ -516,6 +516,7 @@ class TomlIntegrationTests:
"common.sh",
"create-new-feature.sh",
"setup-plan.sh",
"setup-tasks.sh",
]:
files.append(f".specify/scripts/bash/{name}")
else:
@@ -524,6 +525,7 @@ class TomlIntegrationTests:
"common.ps1",
"create-new-feature.ps1",
"setup-plan.ps1",
"setup-tasks.ps1",
]:
files.append(f".specify/scripts/powershell/{name}")

View File

@@ -395,6 +395,7 @@ class YamlIntegrationTests:
"common.sh",
"create-new-feature.sh",
"setup-plan.sh",
"setup-tasks.sh",
]:
files.append(f".specify/scripts/bash/{name}")
else:
@@ -403,6 +404,7 @@ class YamlIntegrationTests:
"common.ps1",
"create-new-feature.ps1",
"setup-plan.ps1",
"setup-tasks.ps1",
]:
files.append(f".specify/scripts/powershell/{name}")

View File

@@ -12,6 +12,7 @@ from specify_cli.integrations.catalog import (
IntegrationCatalogError,
IntegrationDescriptor,
IntegrationDescriptorError,
IntegrationValidationError,
)
@@ -115,8 +116,45 @@ class TestActiveCatalogs:
cfg = specify / "integration-catalogs.yml"
cfg.write_text(yaml.dump({"catalogs": []}))
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries"):
with pytest.raises(IntegrationCatalogError, match="no 'catalogs' entries") as exc_info:
cat.get_active_catalogs()
assert str(cfg) in str(exc_info.value)
def test_empty_config_file_raises_no_catalogs(self, tmp_path, monkeypatch):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
specify = tmp_path / ".specify"
specify.mkdir()
cfg = specify / "integration-catalogs.yml"
cfg.write_text("", encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="no 'catalogs' entries"
) as exc_info:
cat.get_active_catalogs()
assert str(cfg) in str(exc_info.value)
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
def test_load_catalog_config_rejects_falsy_non_mapping_roots(
self, tmp_path, monkeypatch, config_content
):
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
specify = tmp_path / ".specify"
specify.mkdir()
cfg = specify / "integration-catalogs.yml"
cfg.write_text(config_content, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError,
match="expected a YAML mapping at the root",
) as exc_info:
cat.get_active_catalogs()
assert str(cfg) in str(exc_info.value)
# ---------------------------------------------------------------------------
@@ -632,7 +670,7 @@ class TestIntegrationUpgrade:
finally:
os.chdir(old)
assert result.exit_code != 0
assert "not the currently installed integration" in result.output
assert "not installed" in result.output
def test_upgrade_no_manifest(self, tmp_path):
"""Upgrade with missing manifest suggests fresh install."""
@@ -654,3 +692,838 @@ class TestIntegrationUpgrade:
os.chdir(old)
assert result.exit_code == 0
assert "Nothing to upgrade" in result.output
# ---------------------------------------------------------------------------
# IntegrationCatalog — catalog source management (get_catalog_configs / add / remove)
# ---------------------------------------------------------------------------
class TestCatalogSourceManagement:
"""Unit tests for add_catalog / remove_catalog / get_catalog_configs."""
def _isolate(self, tmp_path, monkeypatch):
"""Point HOME at tmp_path and clear the env override so we read built-ins."""
monkeypatch.setenv("HOME", str(tmp_path))
monkeypatch.setenv("USERPROFILE", str(tmp_path))
monkeypatch.delenv("SPECKIT_INTEGRATION_CATALOG_URL", raising=False)
(tmp_path / ".specify").mkdir()
def test_get_catalog_configs_returns_builtin_stack(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
configs = cat.get_catalog_configs()
assert [c["name"] for c in configs] == ["default", "community"]
assert all(isinstance(c["url"], str) and c["url"] for c in configs)
assert configs[0]["install_allowed"] is True
assert configs[1]["install_allowed"] is False
def test_add_catalog_creates_config_file(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://new.example.com/catalog.json", name="mine")
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
assert cfg_path.exists()
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"] == [
{
"name": "mine",
"url": "https://new.example.com/catalog.json",
"priority": 1,
"install_allowed": True,
"description": "",
}
]
# Round-trip: active catalogs should now come from the config file.
active = cat.get_active_catalogs()
assert [e.name for e in active] == ["mine"]
def test_add_catalog_recovers_from_empty_config_file(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text("", encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://example.com/catalog.json")
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"] == [
{
"name": "catalog-1",
"url": "https://example.com/catalog.json",
"priority": 1,
"install_allowed": True,
"description": "",
}
]
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
def test_add_catalog_rejects_falsy_non_mapping_config_roots(
self, tmp_path, monkeypatch, config_content
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(config_content, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError,
match="corrupted.*expected a mapping",
) as exc_info:
cat.add_catalog("https://example.com/catalog.json")
assert str(cfg_path) in str(exc_info.value)
def test_add_catalog_auto_derives_name_and_priority(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://a.example.com/catalog.json")
cat.add_catalog("https://b.example.com/catalog.json")
data = yaml.safe_load(
(tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8")
)
entries = data["catalogs"]
assert [e["name"] for e in entries] == ["catalog-1", "catalog-2"]
assert [e["priority"] for e in entries] == [1, 2]
def test_add_catalog_normalizes_name(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://a.example.com/catalog.json", name=" mine ")
cat.add_catalog("https://b.example.com/catalog.json", name=" ")
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
entries = data["catalogs"]
assert [e["name"] for e in entries] == ["mine", "catalog-2"]
def test_add_catalog_rejects_duplicate_url(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://dup.example.com/catalog.json")
with pytest.raises(IntegrationValidationError, match="already configured"):
cat.add_catalog("https://dup.example.com/catalog.json")
def test_add_catalog_rejects_invalid_url(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationCatalogError, match="HTTPS"):
cat.add_catalog("http://insecure.example.com/catalog.json")
assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists()
def test_add_catalog_rejects_empty_url(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationValidationError, match="must be non-empty"):
cat.add_catalog(" ")
assert not (tmp_path / ".specify" / "integration-catalogs.yml").exists()
def test_remove_catalog_without_config_errors(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationValidationError, match="No catalog config"):
cat.remove_catalog(0)
def test_remove_catalog_happy_path(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://a.example.com/catalog.json", name="a")
cat.add_catalog("https://b.example.com/catalog.json", name="b")
removed = cat.remove_catalog(0)
assert removed == "a"
data = yaml.safe_load(
(tmp_path / ".specify" / "integration-catalogs.yml").read_text(encoding="utf-8")
)
assert [e["name"] for e in data["catalogs"]] == ["b"]
def test_remove_catalog_index_out_of_range(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://a.example.com/catalog.json", name="a")
with pytest.raises(IntegrationValidationError, match="out of range"):
cat.remove_catalog(5)
with pytest.raises(IntegrationValidationError, match="out of range"):
cat.remove_catalog(-1)
def test_corrupt_config_rejected_on_add(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text("- just\n- a\n- list\n", encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationValidationError, match="corrupted") as exc_info:
cat.add_catalog("https://new.example.com/catalog.json")
assert str(cfg_path) in str(exc_info.value)
def test_add_catalog_rejects_non_list_catalogs_with_config_path(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8"
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="invalid 'catalogs' value"
) as exc_info:
cat.add_catalog("https://new.example.com/catalog.json")
assert str(cfg_path) in str(exc_info.value)
def test_add_catalog_rejects_non_mapping_entry_with_config_path(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump({"catalogs": ["not-a-mapping"]}), encoding="utf-8"
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="Invalid catalog entry at index 0"
) as exc_info:
cat.add_catalog("https://new.example.com/catalog.json")
message = str(exc_info.value)
assert str(cfg_path) in message
assert "expected a mapping" in message
def test_add_catalog_skips_blank_url_entries(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": " ", "name": "blank", "priority": 99},
{
"url": "https://a.example.com/catalog.json",
"name": "a",
"priority": 5,
},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://b.example.com/catalog.json", name="b")
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"][-1]["name"] == "b"
assert data["catalogs"][-1]["priority"] == 6
def test_add_catalog_default_name_ignores_blank_url_entries(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump({"catalogs": [{"url": " ", "name": "blank"}]}),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://example.com/catalog.json")
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"][-1]["name"] == "catalog-1"
def test_add_catalog_rejects_non_integer_priority(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://a.example.com/catalog.json",
"name": "a",
"priority": "first",
}
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError,
match="'priority' must be an integer, got 'first'",
):
cat.add_catalog("https://b.example.com/catalog.json")
def test_add_catalog_accepts_numeric_string_priority(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://a.example.com/catalog.json",
"name": "a",
"priority": "10",
}
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://b.example.com/catalog.json", name="b")
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"][-1]["name"] == "b"
assert data["catalogs"][-1]["priority"] == 11
@pytest.mark.parametrize(
("bad_url", "reason"),
[
("http://insecure.example.com/catalog.json", "HTTPS"),
(123, "HTTPS"),
],
)
def test_add_catalog_rejects_existing_entry_with_bad_url(
self, tmp_path, monkeypatch, bad_url, reason
):
"""A sibling entry with an http:// URL should block a new add."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": bad_url,
"name": "bad",
}
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(IntegrationValidationError) as exc_info:
cat.add_catalog("https://good.example.com/catalog.json")
message = str(exc_info.value)
assert str(cfg_path) in message
assert "index 0" in message
assert reason in message
def test_add_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch):
"""Invalid YAML on disk surfaces as IntegrationValidationError, not a raw YAMLError."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n"
cfg_path.write_text(invalid_yaml, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="Failed to read catalog config"
):
cat.add_catalog("https://b.example.com/catalog.json")
def test_remove_catalog_wraps_yaml_parse_errors(self, tmp_path, monkeypatch):
"""Invalid YAML on disk surfaces as IntegrationValidationError from remove_catalog too."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
invalid_yaml = "catalogs:\n - url: 'https://a.example.com/cat.json'\n - [bad\n"
cfg_path.write_text(invalid_yaml, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="Failed to read catalog config"
):
cat.remove_catalog(0)
def test_add_catalog_defaults_missing_priority_to_index_plus_one(
self, tmp_path, monkeypatch
):
"""Existing entries without `priority` should be treated as idx + 1.
Matches the rule in `_load_catalog_config()`: a valid catalog entry
without an explicit `priority` sorts at `idx + 1`, so the new entry
should get `max(...) + 1` from those derived values.
"""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
# No explicit priority → should be treated as 1
{"url": "https://a.example.com/cat.json", "name": "a"},
# No explicit priority → should be treated as 2
{"url": "https://b.example.com/cat.json", "name": "b"},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://c.example.com/cat.json", name="c")
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
new_entry = data["catalogs"][-1]
assert new_entry["name"] == "c"
# max(implicit [1, 2]) + 1 == 3
assert new_entry["priority"] == 3
def test_add_catalog_strips_whitespace_in_url(self, tmp_path, monkeypatch):
"""Whitespace around the incoming URL should be normalized before write."""
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog(" https://a.example.com/catalog.json\n", name="a")
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert data["catalogs"][0]["url"] == "https://a.example.com/catalog.json"
def test_add_catalog_rejects_whitespace_only_duplicate(self, tmp_path, monkeypatch):
"""A second add with only whitespace differences must be rejected as a duplicate."""
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://a.example.com/catalog.json", name="a")
with pytest.raises(IntegrationValidationError, match="already configured"):
cat.add_catalog(" https://a.example.com/catalog.json ")
def test_remove_catalog_wraps_unlink_oserror(self, tmp_path, monkeypatch):
"""An OSError from `Path.unlink` surfaces as IntegrationValidationError."""
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://only.example.com/catalog.json", name="only")
from pathlib import Path as _Path
def boom(self, *args, **kwargs):
raise OSError("simulated unlink failure")
monkeypatch.setattr(_Path, "unlink", boom)
with pytest.raises(
IntegrationValidationError, match="Failed to delete catalog config"
):
cat.remove_catalog(0)
def test_remove_catalog_ignores_missing_final_config_during_unlink(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://only.example.com/catalog.json", name="only")
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
from pathlib import Path as _Path
original_unlink = _Path.unlink
def delete_first_then_unlink(self, *args, **kwargs):
if self == cfg_path and self.exists():
original_unlink(self)
return original_unlink(self, *args, **kwargs)
monkeypatch.setattr(_Path, "unlink", delete_first_then_unlink)
assert cat.remove_catalog(0) == "only"
assert not cfg_path.exists()
def test_remove_catalog_empty_list_gives_clear_error(self, tmp_path, monkeypatch):
"""Hand-edited empty `catalogs:` produces a clear error, not '0--1'."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(yaml.dump({"catalogs": []}), encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="contains no catalog entries"
):
cat.remove_catalog(0)
def test_remove_catalog_empty_config_file_gives_clear_error(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text("", encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="contains no catalog entries"
):
cat.remove_catalog(0)
def test_remove_catalog_rejects_non_list_catalogs_with_config_path(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump({"catalogs": "not-a-list"}), encoding="utf-8"
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="invalid 'catalogs' value"
) as exc_info:
cat.remove_catalog(0)
assert str(cfg_path) in str(exc_info.value)
@pytest.mark.parametrize("config_content", ["[]\n", "false\n", "0\n", "''\n"])
def test_remove_catalog_rejects_falsy_non_mapping_config_roots(
self, tmp_path, monkeypatch, config_content
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(config_content, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError,
match="corrupted.*expected a mapping",
) as exc_info:
cat.remove_catalog(0)
assert str(cfg_path) in str(exc_info.value)
def test_remove_last_catalog_deletes_file_and_restores_defaults(
self, tmp_path, monkeypatch
):
"""Removing the final catalog must not leave behind `catalogs: []`.
`_load_catalog_config` treats an empty `catalogs` list as an error,
so writing that file would break every subsequent `integration`
command. Removing the last entry should delete the config file so the
project falls back to built-in defaults.
"""
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cat.add_catalog("https://only.example.com/catalog.json", name="only")
assert cfg_path.exists()
assert [e.name for e in cat.get_active_catalogs()] == ["only"]
removed = cat.remove_catalog(0)
assert removed == "only"
assert not cfg_path.exists(), (
"remove_catalog should delete the config file when emptying it"
)
# Follow-up loads fall back to built-in defaults, not an error.
active = cat.get_active_catalogs()
assert [e.name for e in active] == ["default", "community"]
def test_load_catalog_config_raises_validation_error_for_invalid_yaml(
self, tmp_path, monkeypatch
):
"""Local-config problems must surface as IntegrationValidationError so
CLI handlers can route them to local-config (not network) guidance."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
invalid_yaml = "catalogs:\n - [bad\n"
cfg_path.write_text(invalid_yaml, encoding="utf-8")
cat = IntegrationCatalog(tmp_path)
# Subclass match: IntegrationValidationError (specifically), not the
# bare IntegrationCatalogError parent that callers used previously.
with pytest.raises(IntegrationValidationError, match="Failed to read catalog config"):
cat.get_active_catalogs()
def test_load_catalog_config_rejects_boolean_priority(self, tmp_path, monkeypatch):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": "https://a.example.com/catalog.json",
"name": "a",
"priority": True,
}
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError, match="Invalid priority|expected integer"
) as exc_info:
cat.get_active_catalogs()
assert str(cfg_path) in str(exc_info.value)
@pytest.mark.parametrize("raw_name", [None, " "])
def test_load_catalog_config_defaults_blank_names(
self, tmp_path, monkeypatch, raw_name
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{
"url": " ",
"name": "skipped",
},
{
"url": "https://example.com/catalog.json",
"name": raw_name,
}
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
assert [entry.name for entry in cat.get_active_catalogs()] == ["catalog-1"]
@pytest.mark.parametrize(
("raw_name", "expected"),
[
(None, "https://one.example.com/c.json"),
(" ", "https://one.example.com/c.json"),
(123, "123"),
],
)
def test_remove_catalog_normalizes_removed_display_name(
self, tmp_path, monkeypatch, raw_name, expected
):
self._isolate(tmp_path, monkeypatch)
cat = IntegrationCatalog(tmp_path)
cat.add_catalog("https://one.example.com/c.json", name="one")
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
data["catalogs"][0]["name"] = raw_name
cfg_path.write_text(yaml.dump(data), encoding="utf-8")
assert cat.remove_catalog(0) == expected
def test_remove_catalog_uses_display_order_with_explicit_priorities(
self, tmp_path, monkeypatch
):
"""`remove_catalog(index)` must remove the entry shown at that index by
`catalog list`, not the entry at that raw YAML position."""
self._isolate(tmp_path, monkeypatch)
# YAML order: alpha (priority=20), beta (priority=10), gamma (priority=15).
# Display (sorted by priority asc): beta (10), gamma (15), alpha (20).
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": "https://alpha.example.com/c.json", "name": "alpha", "priority": 20},
{"url": "https://beta.example.com/c.json", "name": "beta", "priority": 10},
{"url": "https://gamma.example.com/c.json", "name": "gamma", "priority": 15},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
# Display index 0 = beta (lowest priority), not alpha (raw YAML idx 0).
removed = cat.remove_catalog(0)
assert removed == "beta"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
remaining_names = [c["name"] for c in data["catalogs"]]
# YAML order is preserved for the survivors; only beta is gone.
assert remaining_names == ["alpha", "gamma"]
def test_remove_catalog_display_order_with_missing_priorities(
self, tmp_path, monkeypatch
):
"""Entries without `priority` default to `idx + 1` (matching
`_load_catalog_config`), so display order tracks YAML order and the
first display entry is the first YAML entry."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": "https://one.example.com/c.json", "name": "one"},
{"url": "https://two.example.com/c.json", "name": "two"},
{"url": "https://three.example.com/c.json", "name": "three"},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
# Implicit priorities: one=1, two=2, three=3 → display order matches YAML.
removed = cat.remove_catalog(0)
assert removed == "one"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert [c["name"] for c in data["catalogs"]] == ["two", "three"]
def test_remove_catalog_bool_priority_falls_back_to_yaml_index(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": "https://one.example.com/c.json", "name": "one"},
{
"url": "https://bool.example.com/c.json",
"name": "bool",
"priority": False,
},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
removed = cat.remove_catalog(0)
assert removed == "one"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert [c["name"] for c in data["catalogs"]] == ["bool"]
def test_remove_catalog_display_order_skips_blank_url_entries(
self, tmp_path, monkeypatch
):
"""Blank-url entries are not shown by catalog list, so remove skips them too."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": " ", "name": "blank", "priority": 0},
{"url": "https://one.example.com/c.json", "name": "one"},
{"url": "https://two.example.com/c.json", "name": "two"},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
removed = cat.remove_catalog(0)
assert removed == "one"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert [c["name"] for c in data["catalogs"]] == ["blank", "two"]
def test_remove_catalog_deletes_file_when_only_skipped_entries_remain(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": " ", "name": "blank", "priority": 0},
{"url": "https://one.example.com/c.json", "name": "one"},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
removed = cat.remove_catalog(0)
assert removed == "one"
assert not cfg_path.exists()
active = cat.get_active_catalogs()
assert [e.name for e in active] == ["default", "community"]
def test_remove_catalog_allows_numeric_url_entry_cleanup(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump({"catalogs": [{"name": "numeric-url", "url": 123}]}),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
removed = cat.remove_catalog(0)
assert removed == "numeric-url"
assert not cfg_path.exists()
def test_remove_catalog_errors_when_no_entries_are_removable(
self, tmp_path, monkeypatch
):
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": "", "name": "empty"},
{"name": "missing"},
"not-a-mapping",
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
with pytest.raises(
IntegrationValidationError,
match="no removable catalog entries",
):
cat.remove_catalog(0)
def test_remove_catalog_display_order_mixes_explicit_and_default(
self, tmp_path, monkeypatch
):
"""An explicit low priority should sort ahead of default-priority
siblings, even if it appears later in the YAML."""
self._isolate(tmp_path, monkeypatch)
cfg_path = tmp_path / ".specify" / "integration-catalogs.yml"
cfg_path.parent.mkdir(parents=True, exist_ok=True)
# Defaults: a=1, b=2 (implicit). Explicit c=0 → display: c, a, b.
# The blank name should fall back to the removed URL, not raw YAML idx.
cfg_path.write_text(
yaml.dump(
{
"catalogs": [
{"url": "https://a.example.com/c.json", "name": "a"},
{"url": "https://b.example.com/c.json", "name": "b"},
{
"url": "https://c.example.com/c.json",
"name": " ",
"priority": 0,
},
]
}
),
encoding="utf-8",
)
cat = IntegrationCatalog(tmp_path)
removed = cat.remove_catalog(0)
assert removed == "https://c.example.com/c.json"
data = yaml.safe_load(cfg_path.read_text(encoding="utf-8"))
assert [c["name"] for c in data["catalogs"]] == ["a", "b"]

View File

@@ -206,6 +206,7 @@ class TestCopilotIntegration:
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
@@ -265,6 +266,7 @@ class TestCopilotIntegration:
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/setup-tasks.ps1",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
@@ -614,6 +616,7 @@ class TestCopilotSkillsMode:
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
# Templates
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",

View File

@@ -0,0 +1,75 @@
"""Tests for DevinIntegration."""
from .test_integration_base_skills import SkillsIntegrationTests
class TestDevinIntegration(SkillsIntegrationTests):
KEY = "devin"
FOLDER = ".devin/"
COMMANDS_SUBDIR = "skills"
REGISTRAR_DIR = ".devin/skills"
CONTEXT_FILE = "AGENTS.md"
class TestDevinBuildExecArgs:
"""Regression tests for DevinIntegration.build_exec_args.
Devin's CLI has no --output-format flag, so build_exec_args must
omit it regardless of the output_json argument. The integration
must also remain dispatchable (must not return None, which is the
codebase's IDE-only sentinel checked by CommandStep).
"""
def test_returns_args_not_none_for_dispatch(self):
"""Devin is CLI-dispatchable; build_exec_args must not return None."""
from specify_cli.integrations.devin import DevinIntegration
impl = DevinIntegration()
args = impl.build_exec_args("test prompt")
assert args is not None, (
"DevinIntegration.build_exec_args must not return None. "
"None is the codebase sentinel for IDE-only integrations "
"(see WindsurfIntegration); Devin is dispatchable via 'devin -p'."
)
assert args[:3] == ["devin", "-p", "test prompt"]
def test_output_json_does_not_emit_output_format_flag(self):
"""Devin has no --output-format flag; output_json=True must not add it."""
from specify_cli.integrations.devin import DevinIntegration
impl = DevinIntegration()
args_json = impl.build_exec_args("hello", output_json=True)
args_text = impl.build_exec_args("hello", output_json=False)
assert "--output-format" not in args_json
assert "json" not in args_json[3:]
# The two should be identical: output_json is documented as having
# no effect on the command line for Devin (plain-text stdout).
assert args_json == args_text
def test_model_flag_passed_through(self):
"""--model is supported and should appear when provided."""
from specify_cli.integrations.devin import DevinIntegration
impl = DevinIntegration()
args = impl.build_exec_args("hi", model="claude-sonnet-4")
assert args == ["devin", "-p", "hi", "--model", "claude-sonnet-4"]
class TestDevinAutoPromote:
"""--ai devin auto-promotes to integration path."""
def test_ai_devin_without_ai_skills_auto_promotes(self, tmp_path):
"""--ai devin should work the same as --integration devin."""
from typer.testing import CliRunner
from specify_cli import app
runner = CliRunner()
target = tmp_path / "test-proj"
result = runner.invoke(
app,
["init", str(target), "--ai", "devin", "--no-git", "--ignore-agent-tools", "--script", "sh"],
)
assert result.exit_code == 0, f"init --ai devin failed: {result.output}"
assert (target / ".devin" / "skills" / "speckit-plan" / "SKILL.md").exists()

View File

@@ -264,6 +264,7 @@ class TestGenericIntegration:
".specify/scripts/bash/common.sh",
".specify/scripts/bash/create-new-feature.sh",
".specify/scripts/bash/setup-plan.sh",
".specify/scripts/bash/setup-tasks.sh",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",
@@ -319,6 +320,7 @@ class TestGenericIntegration:
".specify/scripts/powershell/common.ps1",
".specify/scripts/powershell/create-new-feature.ps1",
".specify/scripts/powershell/setup-plan.ps1",
".specify/scripts/powershell/setup-tasks.ps1",
".specify/templates/checklist-template.md",
".specify/templates/constitution-template.md",
".specify/templates/plan-template.md",

View File

@@ -1,5 +1,7 @@
"""Tests for OpencodeIntegration."""
from specify_cli.integrations import get_integration
from .test_integration_base_markdown import MarkdownIntegrationTests
@@ -9,3 +11,49 @@ class TestOpencodeIntegration(MarkdownIntegrationTests):
COMMANDS_SUBDIR = "command"
REGISTRAR_DIR = ".opencode/command"
CONTEXT_FILE = "AGENTS.md"
def test_build_exec_args_uses_run_command_dispatch(self):
integration = get_integration(self.KEY)
args = integration.build_exec_args(
"/speckit.specify build a login page",
output_json=False,
)
assert args == [
"opencode",
"run",
"--command",
"speckit.specify",
"build a login page",
]
assert "-p" not in args
assert "--output-format" not in args
def test_build_exec_args_maps_model_and_json_flags(self):
integration = get_integration(self.KEY)
args = integration.build_exec_args(
"/speckit.plan add OAuth",
model="anthropic/claude-sonnet-4",
output_json=True,
)
assert args == [
"opencode",
"run",
"--command",
"speckit.plan",
"-m",
"anthropic/claude-sonnet-4",
"--format",
"json",
"add OAuth",
]
def test_build_exec_args_keeps_plain_prompt_dispatch(self):
integration = get_integration(self.KEY)
args = integration.build_exec_args("explain this repository", output_json=False)
assert args == ["opencode", "run", "explain this repository"]

View File

@@ -0,0 +1,86 @@
"""Tests for integration state normalization helpers."""
import json
from specify_cli.integration_state import (
INTEGRATION_JSON,
default_integration_key,
integration_setting,
normalize_integration_state,
write_integration_json,
)
def test_normalize_integration_state_strips_default_key_without_duplicates():
state = normalize_integration_state(
{
"default_integration": " claude ",
"integration": " claude ",
"installed_integrations": ["claude"],
}
)
assert state["integration"] == "claude"
assert state["default_integration"] == "claude"
assert state["installed_integrations"] == ["claude"]
def test_normalize_integration_state_strips_legacy_key_fallback():
state = normalize_integration_state(
{
"integration": " codex ",
"installed_integrations": [],
}
)
assert state["integration"] == "codex"
assert state["default_integration"] == "codex"
assert state["installed_integrations"] == ["codex"]
def test_normalize_integration_state_preserves_newer_schema():
state = normalize_integration_state(
{
"integration_state_schema": 99,
"integration": "claude",
"installed_integrations": ["claude"],
"future_field": {"keep": True},
}
)
assert state["integration_state_schema"] == 99
assert state["future_field"] == {"keep": True}
def test_default_integration_key_strips_raw_state_values():
assert default_integration_key({"default_integration": " claude "}) == "claude"
assert default_integration_key({"integration": " codex "}) == "codex"
def test_integration_settings_strip_invoke_separator():
setting = integration_setting(
{
"integration_settings": {
"claude": {
"invoke_separator": " - ",
}
}
},
"claude",
)
assert setting["invoke_separator"] == "-"
def test_write_integration_json_strips_integration_key(tmp_path):
write_integration_json(
tmp_path,
version="1.2.3",
integration_key=" claude ",
installed_integrations=["claude"],
)
state = json.loads((tmp_path / INTEGRATION_JSON).read_text(encoding="utf-8"))
assert state["integration"] == "claude"
assert state["default_integration"] == "claude"
assert state["installed_integrations"] == ["claude"]

View File

@@ -3,6 +3,7 @@
import json
import os
import pytest
from typer.testing import CliRunner
from specify_cli import app
@@ -31,6 +32,27 @@ def _init_project(tmp_path, integration="copilot"):
return project
def _run_in_project(project, args):
"""Run a CLI command from inside a generated project."""
old_cwd = os.getcwd()
try:
os.chdir(project)
return runner.invoke(app, args, catch_exceptions=False)
finally:
os.chdir(old_cwd)
def _write_invalid_manifest(project, key):
manifest = project / ".specify" / "integrations" / f"{key}.manifest.json"
manifest.write_bytes(b"\xff\xfe\x00")
return manifest
def _integration_list_row_cells(output: str, key: str) -> list[str]:
row = next(line for line in output.splitlines() if line.startswith(f"{key}"))
return [cell.strip() for cell in row.split("")[1:-1]]
# ── list ─────────────────────────────────────────────────────────────
@@ -70,6 +92,39 @@ class TestIntegrationList:
assert "claude" in result.output
assert "gemini" in result.output
def test_list_shows_multi_install_safe_status(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "Multi-install" in result.output
assert "Safe" in result.output
assert _integration_list_row_cells(result.output, "claude")[-1] == "yes"
assert _integration_list_row_cells(result.output, "copilot")[-1] == "no"
def test_list_rejects_newer_integration_state_schema(self, tmp_path):
project = _init_project(tmp_path, "claude")
int_json = project / ".specify" / "integration.json"
data = json.loads(int_json.read_text(encoding="utf-8"))
data["integration_state_schema"] = 99
int_json.write_text(json.dumps(data), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "list"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
normalized = " ".join(result.output.split())
assert "schema 99" in normalized
assert "only supports schema 1" in normalized
# ── install ──────────────────────────────────────────────────────────
@@ -106,7 +161,9 @@ class TestIntegrationInstall:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "already installed" in result.output
assert "uninstall" in result.output
normalized = " ".join(result.output.split())
assert "specify integration upgrade copilot" in normalized
assert "specify integration uninstall copilot" in normalized
def test_install_different_when_one_exists(self, tmp_path):
project = _init_project(tmp_path, "copilot")
@@ -117,8 +174,112 @@ class TestIntegrationInstall:
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "already installed" in result.output
assert "uninstall" in result.output
assert "Installed integrations: copilot" in result.output
assert "Default integration: copilot" in result.output
assert "--force" in result.output
def test_install_multi_safe_integration(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "installed successfully" in result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "claude"
assert data["default_integration"] == "claude"
assert data["integration_state_schema"] == 1
assert data["installed_integrations"] == ["claude", "codex"]
assert data["integration_settings"]["claude"]["invoke_separator"] == "-"
assert data["integration_settings"]["codex"]["invoke_separator"] == "-"
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
def test_install_additional_preserves_shared_manifest(self, tmp_path):
project = _init_project(tmp_path, "claude")
shared_manifest = project / ".specify" / "integrations" / "speckit.manifest.json"
before = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
assert before
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
after = set(json.loads(shared_manifest.read_text(encoding="utf-8"))["files"])
assert before <= after
def test_install_multi_safe_migrates_legacy_state(self, tmp_path):
project = _init_project(tmp_path, "claude")
int_json = project / ".specify" / "integration.json"
int_json.write_text(json.dumps({
"integration": "claude",
"version": "0.0.0",
}), encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
data = json.loads(int_json.read_text(encoding="utf-8"))
assert data["integration"] == "claude"
assert data["default_integration"] == "claude"
assert data["installed_integrations"] == ["claude", "codex"]
def test_install_multi_unsafe_requires_force(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Installed integrations: copilot" in result.output
assert "multi-install safe" in result.output
assert "--force" in result.output
def test_install_multi_unsafe_allowed_with_force(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
"--force",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "copilot"
assert data["installed_integrations"] == ["copilot", "claude"]
def test_install_into_bare_project(self, tmp_path):
"""Install into a project with .specify/ but no integration."""
@@ -236,6 +397,7 @@ class TestIntegrationUninstall:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "preserved" in result.output
assert ".claude/skills/speckit-plan/SKILL.md" in result.output
# Modified file kept
assert plan_file.exists()
@@ -250,7 +412,68 @@ class TestIntegrationUninstall:
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "not the currently installed" in result.output
assert "not installed" in result.output
def test_uninstall_invalid_manifest_reports_cli_error(self, tmp_path):
project = _init_project(tmp_path, "claude")
_write_invalid_manifest(project, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "uninstall", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "manifest" in result.output
assert "unreadable" in result.output
def test_uninstall_non_default_preserves_default(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, [
"integration", "uninstall", "codex",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert not (project / ".agents" / "skills" / "speckit-plan" / "SKILL.md").exists()
assert (project / ".claude" / "skills" / "speckit-plan" / "SKILL.md").exists()
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "claude"
assert data["installed_integrations"] == ["claude"]
def test_uninstall_default_refreshes_templates_for_fallback(self, tmp_path):
project = _init_project(tmp_path, "gemini")
template = project / ".specify" / "templates" / "plan-template.md"
assert "/speckit.plan" in template.read_text(encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, ["integration", "uninstall", "gemini"], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "claude"
assert "/speckit-plan" in template.read_text(encoding="utf-8")
def test_uninstall_preserves_shared_infra(self, tmp_path):
"""Shared scripts and templates are not removed by integration uninstall."""
@@ -271,6 +494,135 @@ class TestIntegrationUninstall:
assert (project / ".specify" / "templates").is_dir()
class TestIntegrationUse:
def test_use_installed_integration_sets_default(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, ["integration", "use", "codex"], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "codex"
assert data["default_integration"] == "codex"
assert data["installed_integrations"] == ["claude", "codex"]
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
assert opts["integration"] == "codex"
assert opts["ai"] == "codex"
def test_use_requires_installed_integration(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "use", "codex"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "not installed" in result.output
def test_use_refreshes_shared_templates_between_command_styles(self, tmp_path):
project = _init_project(tmp_path, "claude")
template = project / ".specify" / "templates" / "plan-template.md"
assert "/speckit-plan" in template.read_text(encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "gemini",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
assert use_gemini.exit_code == 0, use_gemini.output
assert "/speckit.plan" in template.read_text(encoding="utf-8")
use_claude = runner.invoke(app, ["integration", "use", "claude"], catch_exceptions=False)
assert use_claude.exit_code == 0, use_claude.output
assert "/speckit-plan" in template.read_text(encoding="utf-8")
finally:
os.chdir(old_cwd)
def test_use_preserves_modified_templates_unless_forced(self, tmp_path):
project = _init_project(tmp_path, "claude")
template = project / ".specify" / "templates" / "plan-template.md"
template.write_text("custom template with /speckit-plan\n", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "gemini",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
use_gemini = runner.invoke(app, ["integration", "use", "gemini"], catch_exceptions=False)
assert use_gemini.exit_code == 0, use_gemini.output
assert template.read_text(encoding="utf-8") == "custom template with /speckit-plan\n"
force_use = runner.invoke(app, [
"integration", "use", "gemini",
"--force",
], catch_exceptions=False)
assert force_use.exit_code == 0, force_use.output
finally:
os.chdir(old_cwd)
updated = template.read_text(encoding="utf-8")
assert "/speckit.plan" in updated
assert "custom template" not in updated
@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are unavailable")
def test_use_does_not_persist_default_when_template_refresh_fails(self, tmp_path):
project = _init_project(tmp_path, "claude")
int_json = project / ".specify" / "integration.json"
init_options = project / ".specify" / "init-options.json"
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
before_state = json.loads(int_json.read_text(encoding="utf-8"))
before_options = json.loads(init_options.read_text(encoding="utf-8"))
outside = tmp_path / "outside-template.md"
outside.write_text("# outside\n", encoding="utf-8")
template = project / ".specify" / "templates" / "plan-template.md"
template.unlink()
os.symlink(outside, template)
result = runner.invoke(app, [
"integration", "use", "codex",
"--force",
])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Failed to refresh shared templates" in result.output
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
assert outside.read_text(encoding="utf-8") == "# outside\n"
# ── switch ───────────────────────────────────────────────────────────
@@ -296,6 +648,22 @@ class TestIntegrationSwitch:
assert result.exit_code != 0
assert "Unknown integration" in result.output
def test_switch_invalid_current_manifest_reports_cli_error(self, tmp_path):
project = _init_project(tmp_path, "claude")
_write_invalid_manifest(project, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "codex",
"--script", "sh",
])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "Could not read integration manifest" in result.output
def test_switch_same_noop(self, tmp_path):
project = _init_project(tmp_path, "copilot")
old_cwd = os.getcwd()
@@ -305,7 +673,48 @@ class TestIntegrationSwitch:
finally:
os.chdir(old_cwd)
assert result.exit_code == 0
assert "already installed" in result.output
assert "already the default integration" in result.output
def test_switch_same_force_refreshes_shared_templates(self, tmp_path):
project = _init_project(tmp_path, "claude")
template = project / ".specify" / "templates" / "plan-template.md"
template.write_text("# custom shared template\n", encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, [
"integration", "switch", "claude",
"--force",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
assert "managed shared templates refreshed" in result.output
assert "/speckit-plan" in template.read_text(encoding="utf-8")
def test_switch_installed_target_rejects_integration_options(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, [
"integration", "switch", "codex",
"--integration-options", "--bogus",
])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "--integration-options cannot be used" in result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["default_integration"] == "claude"
def test_switch_between_integrations(self, tmp_path):
project = _init_project(tmp_path, "claude")
@@ -334,6 +743,142 @@ class TestIntegrationSwitch:
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "copilot"
def test_switch_migrates_extension_commands(self, tmp_path):
"""Switching should migrate extension commands to the new agent directory."""
project = _init_project(tmp_path, "kimi")
# Install the bundled git extension
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
# Verify git extension skills exist for kimi
kimi_git_feature = project / ".kimi" / "skills" / "speckit-git-feature" / "SKILL.md"
assert kimi_git_feature.exists(), "Git extension skill should exist for kimi"
result = _run_in_project(project, [
"integration", "switch", "opencode",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Git extension commands should exist for opencode
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
# Old kimi extension skills should be removed
assert not kimi_git_feature.exists(), "Old kimi extension skill should be removed"
# Extension registry should be updated
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
registered_commands = registry["extensions"]["git"]["registered_commands"]
assert "opencode" in registered_commands
assert "kimi" not in registered_commands
# Switch to claude
result = _run_in_project(project, [
"integration", "switch", "claude",
"--script", "sh",
])
assert result.exit_code == 0, result.output
# Git extension skills should exist for claude
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
assert claude_git_feature.exists(), "Git extension skill should exist for claude"
# Old opencode extension commands should be removed
assert not opencode_git_feature.exists(), "Old opencode extension command should be removed"
# Extension registry should be updated
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
registered_commands = registry["extensions"]["git"]["registered_commands"]
assert "claude" in registered_commands
assert "opencode" not in registered_commands
def test_switch_migrates_copilot_skills_extension_commands(self, tmp_path):
"""Copilot --skills should receive extension skills, not .agent.md files."""
project = _init_project(tmp_path, "opencode")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, [
"integration", "switch", "copilot",
"--script", "sh",
"--integration-options", "--skills",
])
assert result.exit_code == 0, result.output
copilot_git_feature = project / ".github" / "skills" / "speckit-git-feature" / "SKILL.md"
copilot_agent_file = project / ".github" / "agents" / "speckit.git.feature.agent.md"
assert copilot_git_feature.exists(), "Git extension skill should exist for Copilot skills mode"
assert not copilot_agent_file.exists(), "Copilot skills mode should not create extension .agent.md files"
# Verify Copilot-specific frontmatter: mode field should map from
# skill name (speckit-git-feature) back to dot notation (speckit.git-feature)
skill_content = copilot_git_feature.read_text(encoding="utf-8")
assert "mode: speckit.git-feature" in skill_content, (
"Copilot skill frontmatter should contain mode mapped from skill name"
)
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
git_meta = registry["extensions"]["git"]
assert "speckit-git-feature" in git_meta["registered_skills"]
assert "copilot" not in git_meta["registered_commands"]
result = _run_in_project(project, [
"integration", "switch", "opencode",
"--script", "sh",
])
assert result.exit_code == 0, result.output
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Git extension command should exist for opencode"
assert not copilot_git_feature.exists(), "Old Copilot extension skill should be removed"
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
git_meta = registry["extensions"]["git"]
assert git_meta["registered_skills"] == []
assert "opencode" in git_meta["registered_commands"]
assert "copilot" not in git_meta["registered_commands"]
def test_switch_does_not_register_disabled_extensions(self, tmp_path):
"""Disabled extensions should stay disabled and should not migrate commands."""
project = _init_project(tmp_path, "opencode")
result = _run_in_project(project, ["extension", "add", "git"])
assert result.exit_code == 0, f"extension add failed: {result.output}"
result = _run_in_project(project, ["extension", "disable", "git"])
assert result.exit_code == 0, result.output
opencode_git_feature = project / ".opencode" / "command" / "speckit.git.feature.md"
assert opencode_git_feature.exists(), "Disabled extension command remains until integration switch"
result = _run_in_project(project, [
"integration", "switch", "claude",
"--script", "sh",
])
assert result.exit_code == 0, result.output
claude_git_feature = project / ".claude" / "skills" / "speckit-git-feature" / "SKILL.md"
assert not claude_git_feature.exists(), "Disabled extension should not be registered for new agent"
assert not opencode_git_feature.exists(), "Old disabled extension command should be removed on switch"
registry = json.loads(
(project / ".specify" / "extensions" / ".registry").read_text(encoding="utf-8")
)
git_meta = registry["extensions"]["git"]
assert git_meta["enabled"] is False
assert "claude" not in git_meta["registered_commands"]
assert "opencode" not in git_meta["registered_commands"]
def test_switch_preserves_shared_infra(self, tmp_path):
"""Switching preserves shared scripts, templates, and memory."""
project = _init_project(tmp_path, "claude")
@@ -376,6 +921,107 @@ class TestIntegrationSwitch:
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "claude"
def test_failed_switch_keeps_fallback_metadata_consistent(self, tmp_path):
project = _init_project(tmp_path, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "codex",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, [
"integration", "switch", "generic",
"--script", "sh",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "codex"
assert data["installed_integrations"] == ["codex"]
opts = json.loads((project / ".specify" / "init-options.json").read_text(encoding="utf-8"))
assert opts["integration"] == "codex"
assert opts["ai"] == "codex"
template = project / ".specify" / "templates" / "plan-template.md"
assert "/speckit-plan" in template.read_text(encoding="utf-8")
class TestIntegrationUpgrade:
def test_upgrade_invalid_manifest_reports_cli_error(self, tmp_path):
project = _init_project(tmp_path, "claude")
_write_invalid_manifest(project, "claude")
old_cwd = os.getcwd()
try:
os.chdir(project)
result = runner.invoke(app, ["integration", "upgrade", "claude"])
finally:
os.chdir(old_cwd)
assert result.exit_code != 0
assert "manifest" in result.output
assert "unreadable" in result.output
def test_upgrade_does_not_persist_state_when_template_refresh_fails(self, tmp_path, monkeypatch):
project = _init_project(tmp_path, "claude")
int_json = project / ".specify" / "integration.json"
init_options = project / ".specify" / "init-options.json"
manifest_path = project / ".specify" / "integrations" / "claude.manifest.json"
before_state = json.loads(int_json.read_text(encoding="utf-8"))
before_options = json.loads(init_options.read_text(encoding="utf-8"))
before_manifest = manifest_path.read_text(encoding="utf-8")
import specify_cli
def fail_refresh(*args, **kwargs):
raise ValueError("refuse refresh")
monkeypatch.setattr(specify_cli, "_refresh_shared_templates", fail_refresh)
result = _run_in_project(project, [
"integration", "upgrade", "claude",
"--force",
])
assert result.exit_code != 0
assert "Failed to refresh shared templates" in result.output
assert json.loads(int_json.read_text(encoding="utf-8")) == before_state
assert json.loads(init_options.read_text(encoding="utf-8")) == before_options
assert manifest_path.read_text(encoding="utf-8") == before_manifest
def test_upgrade_non_default_keeps_default_template_invocations(self, tmp_path):
project = _init_project(tmp_path, "gemini")
template = project / ".specify" / "templates" / "plan-template.md"
assert "/speckit.plan" in template.read_text(encoding="utf-8")
old_cwd = os.getcwd()
try:
os.chdir(project)
install = runner.invoke(app, [
"integration", "install", "claude",
"--script", "sh",
], catch_exceptions=False)
assert install.exit_code == 0, install.output
result = runner.invoke(app, [
"integration", "upgrade", "claude",
"--script", "sh",
"--force",
], catch_exceptions=False)
finally:
os.chdir(old_cwd)
assert result.exit_code == 0, result.output
data = json.loads((project / ".specify" / "integration.json").read_text(encoding="utf-8"))
assert data["integration"] == "gemini"
assert "/speckit.plan" in template.read_text(encoding="utf-8")
# ── Full lifecycle ───────────────────────────────────────────────────

View File

@@ -1,7 +1,13 @@
"""Tests for INTEGRATION_REGISTRY — mechanics, completeness, and registrar alignment."""
import pytest
import json
import os
from pathlib import PurePosixPath
import pytest
from typer.testing import CliRunner
from specify_cli import app
from specify_cli.integrations import (
INTEGRATION_REGISTRY,
_register,
@@ -25,6 +31,72 @@ ALL_INTEGRATION_KEYS = [
]
def _multi_install_safe_keys() -> list[str]:
return sorted(
key
for key, integration in INTEGRATION_REGISTRY.items()
if integration.multi_install_safe
)
def _multi_install_safe_pairs() -> list[tuple[str, str]]:
safe_keys = _multi_install_safe_keys()
return [
(safe_keys[left], safe_keys[right])
for left in range(len(safe_keys))
for right in range(left + 1, len(safe_keys))
]
def _posix_path(value: str | None) -> str | None:
if not value:
return None
return PurePosixPath(value).as_posix()
def _integration_root_dir(key: str) -> str | None:
integration = INTEGRATION_REGISTRY[key]
cfg = integration.config if isinstance(integration.config, dict) else {}
return _posix_path(cfg.get("folder"))
def _integration_commands_dir(key: str) -> str | None:
integration = INTEGRATION_REGISTRY[key]
cfg = integration.config if isinstance(integration.config, dict) else {}
folder = cfg.get("folder")
if not folder:
return None
subdir = cfg.get("commands_subdir", "commands")
return (PurePosixPath(folder) / subdir).as_posix()
def _paths_overlap(first: str | None, second: str | None) -> bool:
if not first or not second:
return False
left = PurePosixPath(first)
right = PurePosixPath(second)
try:
left.relative_to(right)
return True
except ValueError:
pass
try:
right.relative_to(left)
return True
except ValueError:
return False
def _path_is_inside(path: str | None, directory: str | None) -> bool:
if not path or not directory:
return False
try:
PurePosixPath(path).relative_to(PurePosixPath(directory))
return True
except ValueError:
return False
class TestRegistry:
def test_registry_is_dict(self):
assert isinstance(INTEGRATION_REGISTRY, dict)
@@ -85,3 +157,134 @@ class TestRegistrarKeyAlignment:
"""The old 'cursor' shorthand must not appear in AGENT_CONFIGS."""
from specify_cli.agents import CommandRegistrar
assert "cursor" not in CommandRegistrar.AGENT_CONFIGS
class TestMultiInstallSafeContracts:
"""Declared safe integrations must stay isolated from each other."""
@pytest.mark.parametrize("key", _multi_install_safe_keys())
def test_safe_integrations_have_static_isolated_paths(self, key):
integration = INTEGRATION_REGISTRY[key]
assert _integration_root_dir(key), (
f"{key} is declared multi-install safe but has no static root directory"
)
assert _integration_commands_dir(key), (
f"{key} is declared multi-install safe but has no static commands directory"
)
assert integration.context_file, (
f"{key} is declared multi-install safe but has no context file"
)
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
def test_safe_integrations_have_distinct_agent_roots(self, first, second):
assert not _paths_overlap(_integration_root_dir(first), _integration_root_dir(second)), (
f"{first} and {second} are declared multi-install safe but have "
f"overlapping agent roots {_integration_root_dir(first)!r} and "
f"{_integration_root_dir(second)!r}"
)
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
def test_safe_integrations_have_distinct_command_dirs(self, first, second):
assert not _paths_overlap(_integration_commands_dir(first), _integration_commands_dir(second)), (
f"{first} and {second} are declared multi-install safe but have "
f"overlapping command directories {_integration_commands_dir(first)!r} and "
f"{_integration_commands_dir(second)!r}"
)
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
def test_safe_integrations_have_distinct_context_files(self, first, second):
first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file)
second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file)
assert first_context != second_context, (
f"{first} and {second} are declared multi-install safe but share "
f"context file {first_context!r}"
)
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
def test_safe_context_files_do_not_overlap_other_agent_roots(self, first, second):
first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file)
second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file)
assert not _path_is_inside(first_context, _integration_root_dir(second)), (
f"{first} context file {first_context!r} lives under {second} "
f"agent root {_integration_root_dir(second)!r}"
)
assert not _path_is_inside(second_context, _integration_root_dir(first)), (
f"{second} context file {second_context!r} lives under {first} "
f"agent root {_integration_root_dir(first)!r}"
)
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
def test_safe_context_files_do_not_overlap_other_command_dirs(self, first, second):
first_context = _posix_path(INTEGRATION_REGISTRY[first].context_file)
second_context = _posix_path(INTEGRATION_REGISTRY[second].context_file)
assert not _path_is_inside(first_context, _integration_commands_dir(second)), (
f"{first} context file {first_context!r} lives under {second} "
f"commands directory {_integration_commands_dir(second)!r}"
)
assert not _path_is_inside(second_context, _integration_commands_dir(first)), (
f"{second} context file {second_context!r} lives under {first} "
f"commands directory {_integration_commands_dir(first)!r}"
)
@pytest.mark.parametrize(("first", "second"), _multi_install_safe_pairs())
def test_safe_integrations_have_disjoint_manifests(
self,
tmp_path,
first,
second,
):
for initial, additional in ((first, second), (second, first)):
project_root = tmp_path / f"project-{initial}-{additional}"
project_root.mkdir()
runner = CliRunner()
original_cwd = os.getcwd()
try:
os.chdir(project_root)
init_result = runner.invoke(
app,
[
"init",
"--here",
"--integration",
initial,
"--script",
"sh",
"--no-git",
"--ignore-agent-tools",
],
catch_exceptions=False,
)
assert init_result.exit_code == 0, init_result.output
install_result = runner.invoke(
app,
["integration", "install", additional, "--script", "sh"],
catch_exceptions=False,
)
assert install_result.exit_code == 0, install_result.output
finally:
os.chdir(original_cwd)
initial_manifest = json.loads(
(
project_root / ".specify" / "integrations" / f"{initial}.manifest.json"
).read_text(encoding="utf-8")
)
additional_manifest = json.loads(
(
project_root / ".specify" / "integrations" / f"{additional}.manifest.json"
).read_text(encoding="utf-8")
)
initial_files = set(initial_manifest.get("files", {}))
additional_files = set(additional_manifest.get("files", {}))
assert initial_files.isdisjoint(additional_files), (
f"{initial} and {additional} are declared multi-install safe but both manage "
f"these files: {sorted(initial_files & additional_files)}"
)

View File

@@ -178,47 +178,6 @@ class TestNormalizePriority:
assert normalize_priority("invalid", default=1) == 1
# ===== detect_archive_format Tests =====
class TestDetectArchiveFormat:
"""Test the detect_archive_format helper."""
def _fmt(self, url, ct=""):
from specify_cli.extensions import detect_archive_format
return detect_archive_format(url, ct)
def test_zip_url_extension(self):
assert self._fmt("https://example.com/ext-1.0.0.zip") == "zip"
def test_tar_gz_url_extension(self):
assert self._fmt("https://example.com/ext-1.0.0.tar.gz") == "tar.gz"
def test_tgz_url_extension(self):
assert self._fmt("https://example.com/ext-1.0.0.tgz") == "tar.gz"
def test_zip_uppercase_url_extension(self):
assert self._fmt("https://example.com/ext.ZIP") == "zip"
def test_tar_gz_with_query_string(self):
assert self._fmt("https://example.com/ext.tar.gz?token=abc") == "tar.gz"
def test_zip_content_type_fallback(self):
assert self._fmt("https://example.com/download", "application/zip") == "zip"
def test_gzip_content_type_fallback(self):
assert self._fmt("https://example.com/download", "application/gzip") == "tar.gz"
def test_x_gzip_content_type_fallback(self):
assert self._fmt("https://example.com/download", "application/x-gzip") == "tar.gz"
def test_unknown_returns_empty_string(self):
assert self._fmt("https://example.com/workflow.yml") == ""
def test_url_extension_takes_precedence_over_content_type(self):
# URL says .zip — content-type claiming gzip should not override.
assert self._fmt("https://example.com/ext.zip", "application/gzip") == "zip"
# ===== ExtensionManifest Tests =====
class TestExtensionManifest:
@@ -1054,97 +1013,6 @@ class TestExtensionManager:
assert backup_file.read_text() == "test: config"
# ===== install_from_zip Tarball Tests =====
class TestInstallFromTarball:
"""Tests for install_from_zip accepting .tar.gz/.tgz archives."""
def _make_tarball(self, dest: Path, extension_dir: Path, nested: bool = False) -> None:
"""Create a minimal .tar.gz archive from *extension_dir*."""
import tarfile
with tarfile.open(dest, "w:gz") as tf:
for file_path in extension_dir.rglob("*"):
if file_path.is_file():
arcname = file_path.relative_to(extension_dir)
if nested:
arcname = Path("test-ext-v1.0.0") / arcname
tf.add(file_path, arcname=str(arcname))
def test_install_from_tar_gz(self, extension_dir, project_dir, temp_dir):
"""install_from_zip should accept a .tar.gz archive."""
archive = temp_dir / "test-ext-1.0.0.tar.gz"
self._make_tarball(archive, extension_dir)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.0")
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_from_tgz(self, extension_dir, project_dir, temp_dir):
"""install_from_zip should accept a .tgz archive."""
archive = temp_dir / "test-ext-1.0.0.tgz"
self._make_tarball(archive, extension_dir)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.0")
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_from_tar_gz_nested(self, extension_dir, project_dir, temp_dir):
"""install_from_zip should handle a single nested directory inside the tarball."""
archive = temp_dir / "test-ext-nested.tar.gz"
self._make_tarball(archive, extension_dir, nested=True)
manager = ExtensionManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.0")
assert manifest.id == "test-ext"
assert manager.registry.is_installed("test-ext")
def test_install_from_tar_gz_no_manifest(self, project_dir, temp_dir):
"""install_from_zip raises ValidationError when tarball has no extension.yml."""
import tarfile
import io
archive = temp_dir / "bad.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
data = b"no manifest here"
info = tarfile.TarInfo(name="readme.txt")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="No extension.yml found"):
manager.install_from_zip(archive, "0.1.0")
def test_install_from_tar_gz_rejects_path_traversal(self, project_dir, temp_dir):
"""install_from_zip must reject tarballs with path traversal entries."""
import tarfile
import io
archive = temp_dir / "evil.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
info = tarfile.TarInfo(name="../../evil.txt")
data = b"evil"
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="Unsafe path"):
manager.install_from_zip(archive, "0.1.0")
def test_install_from_tar_gz_rejects_symlinks(self, project_dir, temp_dir):
"""install_from_zip must reject tarballs containing symlinks."""
import tarfile
archive = temp_dir / "symlink.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
info = tarfile.TarInfo(name="link")
info.type = tarfile.SYMTYPE
info.linkname = "/etc/passwd"
tf.addfile(info)
manager = ExtensionManager(project_dir)
with pytest.raises(ValidationError, match="Symlinks"):
manager.install_from_zip(archive, "0.1.0")
# ===== CommandRegistrar Tests =====
class TestCommandRegistrar:
@@ -2759,7 +2627,6 @@ class TestExtensionCatalog:
mock_response = MagicMock()
mock_response.read.return_value = zip_bytes
mock_response.geturl.return_value = "https://github.com/org/repo/releases/download/v1/test-ext.zip"
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
@@ -3662,7 +3529,6 @@ class TestDownloadExtensionBundled:
mock_response = MagicMock()
mock_response.read.return_value = b"fake zip data"
mock_response.geturl.return_value = "https://example.com/git-2.0.0.zip"
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)

View File

@@ -14,6 +14,7 @@ import pytest
import json
import tempfile
import shutil
import warnings
import zipfile
from pathlib import Path
from datetime import datetime, timezone
@@ -649,90 +650,6 @@ class TestPresetManager:
with pytest.raises(PresetValidationError, match="No preset.yml found"):
manager.install_from_zip(zip_path, "0.1.5")
def _make_tarball(self, dest, pack_dir, nested=False):
import tarfile
with tarfile.open(dest, "w:gz") as tf:
for file_path in pack_dir.rglob("*"):
if file_path.is_file():
arcname = file_path.relative_to(pack_dir)
if nested:
arcname = Path("test-pack-v1.0.0") / arcname
tf.add(file_path, arcname=str(arcname))
def test_install_from_tar_gz(self, project_dir, pack_dir, temp_dir):
"""Test installing a preset from a .tar.gz archive."""
archive = temp_dir / "test-pack-1.0.tar.gz"
self._make_tarball(archive, pack_dir)
manager = PresetManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.5")
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
def test_install_from_tgz(self, project_dir, pack_dir, temp_dir):
"""Test installing a preset from a .tgz archive."""
archive = temp_dir / "test-pack-1.0.tgz"
self._make_tarball(archive, pack_dir)
manager = PresetManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.5")
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
def test_install_from_tar_gz_nested(self, project_dir, pack_dir, temp_dir):
"""Test installing a preset from a .tar.gz archive with a single nested directory."""
archive = temp_dir / "test-pack-nested.tar.gz"
self._make_tarball(archive, pack_dir, nested=True)
manager = PresetManager(project_dir)
manifest = manager.install_from_zip(archive, "0.1.5")
assert manifest.id == "test-pack"
assert manager.registry.is_installed("test-pack")
def test_install_from_tar_gz_no_manifest(self, project_dir, temp_dir):
"""Test installing a preset from a .tar.gz without preset.yml raises error."""
import tarfile
import io
archive = temp_dir / "bad.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
data = b"no manifest here"
info = tarfile.TarInfo(name="readme.txt")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
manager = PresetManager(project_dir)
with pytest.raises(PresetValidationError, match="No preset.yml found"):
manager.install_from_zip(archive, "0.1.5")
def test_install_from_tar_gz_rejects_path_traversal(self, project_dir, temp_dir):
"""install_from_zip must reject tarballs with path traversal entries."""
import tarfile
import io
archive = temp_dir / "evil.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
info = tarfile.TarInfo(name="../../evil.txt")
data = b"evil"
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
manager = PresetManager(project_dir)
with pytest.raises(PresetValidationError, match="Unsafe path"):
manager.install_from_zip(archive, "0.1.5")
def test_install_from_tar_gz_rejects_symlinks(self, project_dir, temp_dir):
"""install_from_zip must reject tarballs containing symlinks."""
import tarfile
archive = temp_dir / "symlink.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
info = tarfile.TarInfo(name="link")
info.type = tarfile.SYMTYPE
info.linkname = "/etc/passwd"
tf.addfile(info)
manager = PresetManager(project_dir)
with pytest.raises(PresetValidationError, match="Symlinks"):
manager.install_from_zip(archive, "0.1.5")
def test_remove(self, project_dir, pack_dir):
"""Test removing a preset."""
manager = PresetManager(project_dir)
@@ -1613,7 +1530,6 @@ class TestPresetCatalog:
mock_response = MagicMock()
mock_response.read.return_value = zip_bytes
mock_response.geturl.return_value = "https://github.com/org/repo/releases/download/v1/test-pack.zip"
mock_response.__enter__ = lambda s: s
mock_response.__exit__ = MagicMock(return_value=False)
@@ -2006,6 +1922,10 @@ class TestPresetCatalogMultiCatalog:
SELF_TEST_PRESET_DIR = Path(__file__).parent.parent / "presets" / "self-test"
SELF_TEST_WRAP_WARNING = (
r"Cannot compose command 'speckit\.wrap-test': no base layer\. "
r"Stale command files may remain\."
)
CORE_TEMPLATE_NAMES = [
"spec-template",
@@ -2016,6 +1936,18 @@ CORE_TEMPLATE_NAMES = [
]
def install_self_test_preset(manager: PresetManager, speckit_version: str = "0.1.5") -> PresetManifest:
"""Install self-test while filtering its intentionally missing wrap base."""
with warnings.catch_warnings():
warnings.filterwarnings(
"ignore",
message=SELF_TEST_WRAP_WARNING,
category=UserWarning,
module=r"specify_cli\.presets",
)
return manager.install_from_directory(SELF_TEST_PRESET_DIR, speckit_version)
class TestSelfTestPreset:
"""Tests using the self-test preset that ships with the repo."""
@@ -2056,7 +1988,7 @@ class TestSelfTestPreset:
def test_install_self_test_preset(self, project_dir):
"""Test installing the self-test preset from its directory."""
manager = PresetManager(project_dir)
manifest = manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
manifest = install_self_test_preset(manager)
assert manifest.id == "self-test"
assert manager.registry.is_installed("self-test")
@@ -2069,7 +2001,7 @@ class TestSelfTestPreset:
# Install self-test preset
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
install_self_test_preset(manager)
# Every core template should now resolve from the preset
resolver = PresetResolver(project_dir)
@@ -2088,7 +2020,7 @@ class TestSelfTestPreset:
(templates_dir / f"{name}.md").write_text(f"# Core {name}\n")
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
install_self_test_preset(manager)
resolver = PresetResolver(project_dir)
for name in CORE_TEMPLATE_NAMES:
@@ -2105,7 +2037,7 @@ class TestSelfTestPreset:
(templates_dir / f"{name}.md").write_text(f"# Core {name}\n")
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
install_self_test_preset(manager)
manager.remove("self-test")
resolver = PresetResolver(project_dir)
@@ -2141,7 +2073,7 @@ class TestSelfTestPreset:
claude_dir.mkdir(parents=True)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
install_self_test_preset(manager)
# Check the skill was registered
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
@@ -2157,7 +2089,7 @@ class TestSelfTestPreset:
gemini_dir.mkdir(parents=True)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
install_self_test_preset(manager)
# Check the command was registered in TOML format
cmd_file = gemini_dir / "speckit.specify.toml"
@@ -2172,7 +2104,7 @@ class TestSelfTestPreset:
claude_dir.mkdir(parents=True)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
install_self_test_preset(manager)
cmd_file = claude_dir / "speckit-specify" / "SKILL.md"
assert cmd_file.exists()
@@ -2183,7 +2115,7 @@ class TestSelfTestPreset:
def test_self_test_no_commands_without_agent_dirs(self, project_dir):
"""Test that no commands are registered when no agent dirs exist."""
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
install_self_test_preset(manager)
metadata = manager.registry.get("self-test")
assert metadata["registered_commands"] == {}
@@ -2332,8 +2264,7 @@ class TestPresetSkills:
# Install self-test preset (has a command override for speckit.specify)
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
install_self_test_preset(manager)
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert skill_file.exists()
@@ -2352,8 +2283,7 @@ class TestPresetSkills:
self._create_skill(skills_dir, "speckit-specify", body="untouched")
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
install_self_test_preset(manager)
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
content = skill_file.read_text()
@@ -2385,8 +2315,7 @@ class TestPresetSkills:
self._create_skill(skills_dir, "speckit-specify", body="untouched")
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
install_self_test_preset(manager)
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
file_content = skill_file.read_text()
@@ -2406,8 +2335,7 @@ class TestPresetSkills:
(core_cmds / "specify.md").write_text("---\ndescription: Core specify command\n---\n\nCore specify body\n")
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
install_self_test_preset(manager)
# Verify preset content is in the skill
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
@@ -2443,8 +2371,7 @@ class TestPresetSkills:
)
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
install_self_test_preset(manager)
manager.remove("self-test")
content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
@@ -2460,8 +2387,7 @@ class TestPresetSkills:
(skills_dir / "speckit-specify").write_text("not-a-directory")
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
install_self_test_preset(manager)
assert (skills_dir / "speckit-specify").is_file()
metadata = manager.registry.get("self-test")
@@ -2473,8 +2399,7 @@ class TestPresetSkills:
# Don't create skills dir — simulate --ai-skills never created them
manager = PresetManager(project_dir)
SELF_TEST_DIR = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(SELF_TEST_DIR, "0.1.5")
install_self_test_preset(manager)
metadata = manager.registry.get("self-test")
assert metadata.get("registered_skills", []) == []
@@ -2675,8 +2600,7 @@ class TestPresetSkills:
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
manager = PresetManager(project_dir)
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(self_test_dir, "0.1.5")
install_self_test_preset(manager)
skill_file = skills_dir / "speckit.specify" / "SKILL.md"
assert skill_file.exists()
@@ -2696,8 +2620,7 @@ class TestPresetSkills:
(project_dir / ".kimi" / "commands").mkdir(parents=True, exist_ok=True)
manager = PresetManager(project_dir)
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(self_test_dir, "0.1.5")
install_self_test_preset(manager)
skill_file = skills_dir / "speckit-specify" / "SKILL.md"
assert skill_file.exists()
@@ -2876,8 +2799,7 @@ class TestPresetSkills:
self._create_skill(skills_dir, "speckit-specify", body="untouched")
manager = PresetManager(project_dir)
self_test_dir = Path(__file__).parent.parent / "presets" / "self-test"
manager.install_from_directory(self_test_dir, "0.1.5")
install_self_test_preset(manager)
skill_content = (skills_dir / "speckit-specify" / "SKILL.md").read_text()
assert "untouched" in skill_content
@@ -3536,7 +3458,7 @@ class TestWrapStrategy:
)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
install_self_test_preset(manager)
written = (skill_subdir / "SKILL.md").read_text()
assert "{CORE_TEMPLATE}" not in written
@@ -3588,7 +3510,7 @@ class TestWrapStrategy:
)
manager = PresetManager(project_dir)
manager.install_from_directory(SELF_TEST_PRESET_DIR, "0.1.5")
install_self_test_preset(manager)
written = (skill_subdir / "SKILL.md").read_text()
# {SCRIPT} should have been resolved (not left as a literal placeholder)

584
tests/test_setup_tasks.py Normal file
View File

@@ -0,0 +1,584 @@
"""Tests for setup-tasks.{sh,ps1} template resolution and branch validation."""
import json
import os
import shutil
import subprocess
from pathlib import Path
import pytest
from tests.conftest import requires_bash
PROJECT_ROOT = Path(__file__).resolve().parent.parent
COMMON_SH = PROJECT_ROOT / "scripts" / "bash" / "common.sh"
SETUP_TASKS_SH = PROJECT_ROOT / "scripts" / "bash" / "setup-tasks.sh"
COMMON_PS = PROJECT_ROOT / "scripts" / "powershell" / "common.ps1"
SETUP_TASKS_PS = PROJECT_ROOT / "scripts" / "powershell" / "setup-tasks.ps1"
TASKS_TEMPLATE = PROJECT_ROOT / "templates" / "tasks-template.md"
HAS_PWSH = shutil.which("pwsh") is not None
_POWERSHELL = shutil.which("powershell.exe") or shutil.which("powershell")
# ---------------------------------------------------------------------------
# Helpers
# ---------------------------------------------------------------------------
def _install_bash_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "bash"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_SH, d / "common.sh")
shutil.copy(SETUP_TASKS_SH, d / "setup-tasks.sh")
def _install_ps_scripts(repo: Path) -> None:
d = repo / ".specify" / "scripts" / "powershell"
d.mkdir(parents=True, exist_ok=True)
shutil.copy(COMMON_PS, d / "common.ps1")
shutil.copy(SETUP_TASKS_PS, d / "setup-tasks.ps1")
def _install_core_tasks_template(repo: Path) -> None:
"""Copy the real tasks-template.md into the core template location."""
tdir = repo / ".specify" / "templates"
tdir.mkdir(parents=True, exist_ok=True)
shutil.copy(TASKS_TEMPLATE, tdir / "tasks-template.md")
def _minimal_feature(repo: Path) -> Path:
"""
Create a numbered branch-style feature directory with spec.md and plan.md
so all prerequisite checks in setup-tasks pass.
Returns the feature directory path.
"""
feat = repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
return feat
def _clean_env() -> dict[str, str]:
"""
Return os.environ with all SPECIFY_* variables stripped so the scripts
rely purely on git branch + feature.json state set up by each fixture.
"""
env = os.environ.copy()
for key in list(env):
if key.startswith("SPECIFY_"):
env.pop(key)
return env
def _git_init(repo: Path) -> None:
subprocess.run(["git", "init", "-q"], cwd=repo, check=True)
subprocess.run(
["git", "config", "user.email", "test@example.com"], cwd=repo, check=True
)
subprocess.run(["git", "config", "user.name", "Test User"], cwd=repo, check=True)
subprocess.run(
["git", "commit", "--allow-empty", "-m", "init", "-q"], cwd=repo, check=True
)
# ---------------------------------------------------------------------------
# Shared fixture
# ---------------------------------------------------------------------------
@pytest.fixture
def tasks_repo(tmp_path: Path) -> Path:
"""
A minimal repo with:
- git initialised on a numbered branch (001-my-feature)
- core tasks-template.md in place
- both bash and PowerShell scripts installed
"""
repo = tmp_path / "proj"
repo.mkdir()
_git_init(repo)
# Switch to a numbered branch so branch validation passes without feature.json
subprocess.run(
["git", "checkout", "-q", "-b", "001-my-feature"],
cwd=repo,
check=True,
)
(repo / ".specify").mkdir()
_install_core_tasks_template(repo)
_install_bash_scripts(repo)
_install_ps_scripts(repo)
return repo
# ===========================================================================
# BASH TESTS
# ===========================================================================
@requires_bash
def test_setup_tasks_bash_core_template_resolved(tasks_repo: Path) -> None:
"""
When the core tasks-template.md is present and all prerequisites are met,
setup-tasks.sh --json should exit 0 and return an absolute, existing
TASKS_TEMPLATE path pointing to the core template.
"""
feat = _minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl.name == "tasks-template.md"
@requires_bash
def test_setup_tasks_bash_override_wins(tasks_repo: Path) -> None:
"""
When an override exists at .specify/templates/overrides/tasks-template.md,
setup-tasks.sh --json must return the override path, not the core path.
"""
feat = _minimal_feature(tasks_repo)
# Create the override
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True, exist_ok=True)
override_file = overrides_dir / "tasks-template.md"
override_file.write_text("# override tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
# The resolved path must be inside the overrides directory
assert "overrides" in tasks_tmpl.parts, (
f"Expected override path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_extension_wins_over_core(tasks_repo: Path) -> None:
"""
When an extension template exists, setup-tasks.sh --json must resolve
tasks-template.md from the extension before falling back to the core path.
"""
feat = _minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
extension_dir = (
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
)
extension_dir.mkdir(parents=True, exist_ok=True)
extension_file = extension_dir / "tasks-template.md"
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl == extension_file.resolve(), (
f"Expected extension path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_preset_wins_over_extension(tasks_repo: Path) -> None:
"""
When both preset and extension templates exist, setup-tasks.sh --json must
resolve the preset path because presets outrank extensions.
"""
feat = _minimal_feature(tasks_repo)
# FIX: real extension layout is .specify/extensions/<id>/templates/<name>.md
extension_dir = (
tasks_repo / ".specify" / "extensions" / "test-extension" / "templates"
)
extension_dir.mkdir(parents=True, exist_ok=True)
extension_file = extension_dir / "tasks-template.md"
extension_file.write_text("# extension tasks template\n", encoding="utf-8")
# FIX: real preset layout is .specify/presets/<id>/templates/<name>.md
preset_dir = tasks_repo / ".specify" / "presets" / "test-preset" / "templates"
preset_dir.mkdir(parents=True, exist_ok=True)
preset_file = preset_dir / "tasks-template.md"
preset_file.write_text("# preset tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl == preset_file.resolve(), (
f"Expected preset path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_preset_priority_order(tasks_repo: Path) -> None:
"""
When two presets both provide tasks-template.md, the one listed first in
.specify/presets/.registry wins.
"""
feat = _minimal_feature(tasks_repo)
# resolve_template reads .specify/presets/.registry as a JSON object with a
# "presets" map where each entry has a numeric "priority" (lower = higher
# precedence). Create two presets; priority-1-preset wins over priority-2-preset.
high_priority_dir = (
tasks_repo / ".specify" / "presets" / "priority-1-preset" / "templates"
)
high_priority_dir.mkdir(parents=True, exist_ok=True)
high_priority_file = high_priority_dir / "tasks-template.md"
high_priority_file.write_text("# high priority preset tasks template\n", encoding="utf-8")
low_priority_dir = (
tasks_repo / ".specify" / "presets" / "priority-2-preset" / "templates"
)
low_priority_dir.mkdir(parents=True, exist_ok=True)
low_priority_file = low_priority_dir / "tasks-template.md"
low_priority_file.write_text("# low priority preset tasks template\n", encoding="utf-8")
# Write .registry JSON using the correct schema: object with "presets" map,
# each preset has a numeric "priority" (lower number = higher precedence).
registry_json = tasks_repo / ".specify" / "presets" / ".registry"
registry_json.write_text(
json.dumps({
"presets": {
"priority-1-preset": {"priority": 1, "enabled": True},
"priority-2-preset": {"priority": 2, "enabled": True},
}
}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl == high_priority_file.resolve(), (
f"Expected high-priority preset path but got: {tasks_tmpl}"
)
@requires_bash
def test_setup_tasks_bash_missing_template_errors(tasks_repo: Path) -> None:
"""
When tasks-template.md is absent from all locations, setup-tasks.sh must
exit non-zero and print a helpful ERROR message to stderr.
"""
feat = _minimal_feature(tasks_repo)
# Remove the core template so no template exists anywhere
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
core.unlink()
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "ERROR" in result.stderr
assert "tasks-template" in result.stderr
@requires_bash
def test_setup_tasks_bash_passes_custom_branch_when_feature_json_valid(
tasks_repo: Path,
) -> None:
"""
On a non-standard branch, setup-tasks.sh must succeed when feature.json
pins a valid FEATURE_DIR (branch validation should be skipped).
"""
subprocess.run(
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(tasks_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-my-feature"}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
@requires_bash
def test_setup_tasks_bash_fails_custom_branch_without_feature_json(
tasks_repo: Path,
) -> None:
"""
On a non-standard branch with no feature.json, setup-tasks.sh must fail
and report that we are not on a feature branch.
"""
subprocess.run(
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
script = tasks_repo / ".specify" / "scripts" / "bash" / "setup-tasks.sh"
result = subprocess.run(
["bash", str(script), "--json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Not on a feature branch" in result.stderr
# ===========================================================================
# POWERSHELL TESTS
# ===========================================================================
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_core_template_resolved(tasks_repo: Path) -> None:
"""
When the core tasks-template.md is present and all prerequisites are met,
setup-tasks.ps1 -Json should exit 0 and return an absolute, existing
TASKS_TEMPLATE path.
"""
feat = _minimal_feature(tasks_repo)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert tasks_tmpl.name == "tasks-template.md"
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_override_wins(tasks_repo: Path) -> None:
"""
When an override exists at .specify/templates/overrides/tasks-template.md,
setup-tasks.ps1 -Json must return the override path, not the core path.
"""
feat = _minimal_feature(tasks_repo)
overrides_dir = tasks_repo / ".specify" / "templates" / "overrides"
overrides_dir.mkdir(parents=True, exist_ok=True)
override_file = overrides_dir / "tasks-template.md"
override_file.write_text("# override tasks template\n", encoding="utf-8")
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
data = json.loads(result.stdout)
tasks_tmpl = Path(data["TASKS_TEMPLATE"])
assert tasks_tmpl.is_absolute(), "TASKS_TEMPLATE must be an absolute path"
assert tasks_tmpl.is_file(), "TASKS_TEMPLATE must point to an existing file"
assert "overrides" in tasks_tmpl.parts, (
f"Expected override path but got: {tasks_tmpl}"
)
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_missing_template_errors(tasks_repo: Path) -> None:
"""
When tasks-template.md is absent from all locations, setup-tasks.ps1 must
exit non-zero and write a helpful error to stderr.
"""
feat = _minimal_feature(tasks_repo)
core = tasks_repo / ".specify" / "templates" / "tasks-template.md"
core.unlink()
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "tasks-template" in result.stderr.lower() or "tasks-template" in result.stdout.lower()
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_passes_custom_branch_when_feature_json_valid(
tasks_repo: Path,
) -> None:
"""
On a non-standard branch, setup-tasks.ps1 must succeed when feature.json
pins a valid FEATURE_DIR (branch validation should be skipped).
"""
subprocess.run(
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
feat = tasks_repo / "specs" / "001-my-feature"
feat.mkdir(parents=True, exist_ok=True)
(feat / "spec.md").write_text("# spec\n", encoding="utf-8")
(feat / "plan.md").write_text("# plan\n", encoding="utf-8")
(tasks_repo / ".specify" / "feature.json").write_text(
json.dumps({"feature_directory": "specs/001-my-feature"}),
encoding="utf-8",
)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode == 0, result.stderr + result.stdout
@pytest.mark.skipif(not (HAS_PWSH or _POWERSHELL), reason="no PowerShell available")
def test_setup_tasks_ps_fails_custom_branch_without_feature_json(
tasks_repo: Path,
) -> None:
"""
On a non-standard branch with no feature.json, setup-tasks.ps1 must fail
and report that we are not on a feature branch.
"""
subprocess.run(
["git", "checkout", "-q", "-b", "feature/custom-branch"],
cwd=tasks_repo,
check=True,
)
script = tasks_repo / ".specify" / "scripts" / "powershell" / "setup-tasks.ps1"
exe = "pwsh" if HAS_PWSH else _POWERSHELL
result = subprocess.run(
[exe, "-NoProfile", "-File", str(script), "-Json"],
cwd=tasks_repo,
capture_output=True,
text=True,
check=False,
env=_clean_env(),
)
assert result.returncode != 0
assert "Not on a feature branch" in result.stderr

View File

@@ -1843,230 +1843,3 @@ steps:
assert state.status == RunStatus.COMPLETED
assert "do-plan" in state.step_results
assert "do-specify" not in state.step_results
# ===== workflow add archive CLI tests =====
MINIMAL_WORKFLOW_YAML = """\
schema_version: "1.0"
workflow:
id: "arc-workflow"
name: "Archive Workflow"
version: "1.0.0"
description: "Installed from archive"
steps:
- id: step-one
type: shell
run: "echo hello"
"""
class TestWorkflowAddArchive:
"""CLI-level tests for `workflow add` with local archive files."""
@pytest.fixture
def project_dir(self, tmp_path):
"""Create a minimal spec-kit project."""
specify = tmp_path / ".specify"
specify.mkdir()
(specify / "workflows").mkdir()
return tmp_path
def _runner_and_app(self):
from typer.testing import CliRunner
from specify_cli import app
return CliRunner(), app
# -- Local ZIP archive --------------------------------------------------
def test_workflow_add_local_zip_flat(self, project_dir):
"""workflow add installs from a local ZIP with workflow.yml at root."""
import zipfile
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "workflow.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("workflow.yml", MINIMAL_WORKFLOW_YAML)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
installed = project_dir / ".specify" / "workflows" / "arc-workflow" / "workflow.yml"
assert installed.exists()
def test_workflow_add_local_zip_nested(self, project_dir):
"""workflow add installs from a local ZIP with workflow.yml in a subdirectory."""
import zipfile
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "workflow.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("repo-1.0/workflow.yml", MINIMAL_WORKFLOW_YAML)
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
def test_workflow_add_local_zip_missing_workflow_yml(self, project_dir):
"""workflow add exits with an error when the ZIP has no workflow.yml."""
import zipfile
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "empty.zip"
with zipfile.ZipFile(archive, "w") as zf:
zf.writestr("README.md", "nothing here")
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=True)
assert result.exit_code != 0
assert "extract" in result.output.lower() or "workflow" in result.output.lower()
# -- Local tar.gz archive -----------------------------------------------
def test_workflow_add_local_tar_gz_flat(self, project_dir):
"""workflow add installs from a local .tar.gz with workflow.yml at root."""
import tarfile, io
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "workflow.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
data = MINIMAL_WORKFLOW_YAML.encode()
info = tarfile.TarInfo(name="workflow.yml")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
installed = project_dir / ".specify" / "workflows" / "arc-workflow" / "workflow.yml"
assert installed.exists()
def test_workflow_add_local_tar_gz_nested(self, project_dir):
"""workflow add installs from a local .tar.gz with workflow.yml in a subdirectory."""
import tarfile, io
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "workflow.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
data = MINIMAL_WORKFLOW_YAML.encode()
info = tarfile.TarInfo(name="repo-1.0/workflow.yml")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
def test_workflow_add_local_tgz_flat(self, project_dir):
"""workflow add recognises the .tgz extension as a gzipped tarball."""
import tarfile, io
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "workflow.tgz"
with tarfile.open(archive, "w:gz") as tf:
data = MINIMAL_WORKFLOW_YAML.encode()
info = tarfile.TarInfo(name="workflow.yml")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=False)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
def test_workflow_add_local_tar_gz_missing_workflow_yml(self, project_dir):
"""workflow add exits with an error when the .tar.gz has no workflow.yml."""
import tarfile, io
from unittest.mock import patch
runner, app = self._runner_and_app()
archive = project_dir / "empty.tar.gz"
with tarfile.open(archive, "w:gz") as tf:
data = b"nothing"
info = tarfile.TarInfo(name="README.md")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
with patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(app, ["workflow", "add", str(archive)], catch_exceptions=True)
assert result.exit_code != 0
assert "extract" in result.output.lower() or "workflow" in result.output.lower()
# -- URL archive download -----------------------------------------------
def test_workflow_add_url_tar_gz(self, project_dir):
"""workflow add downloads a .tar.gz from a URL and installs the workflow."""
import tarfile, io
from unittest.mock import patch, MagicMock
runner, app = self._runner_and_app()
# Build an in-memory tar.gz archive containing workflow.yml.
buf = io.BytesIO()
with tarfile.open(fileobj=buf, mode="w:gz") as tf:
data = MINIMAL_WORKFLOW_YAML.encode()
info = tarfile.TarInfo(name="workflow.yml")
info.size = len(data)
tf.addfile(info, io.BytesIO(data))
raw_bytes = buf.getvalue()
mock_resp = MagicMock()
mock_resp.geturl.return_value = "https://example.com/workflow.tar.gz"
mock_resp.headers.get.return_value = "application/gzip"
mock_resp.read.return_value = raw_bytes
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["workflow", "add", "https://example.com/workflow.tar.gz"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output
def test_workflow_add_url_zip(self, project_dir):
"""workflow add downloads a .zip from a URL and installs the workflow."""
import zipfile, io
from unittest.mock import patch, MagicMock
runner, app = self._runner_and_app()
buf = io.BytesIO()
with zipfile.ZipFile(buf, "w") as zf:
zf.writestr("workflow.yml", MINIMAL_WORKFLOW_YAML)
raw_bytes = buf.getvalue()
mock_resp = MagicMock()
mock_resp.geturl.return_value = "https://example.com/workflow.zip"
mock_resp.headers.get.return_value = "application/zip"
mock_resp.read.return_value = raw_bytes
mock_resp.__enter__ = lambda s: s
mock_resp.__exit__ = MagicMock(return_value=False)
with patch("urllib.request.urlopen", return_value=mock_resp), \
patch.object(Path, "cwd", return_value=project_dir):
result = runner.invoke(
app, ["workflow", "add", "https://example.com/workflow.zip"],
catch_exceptions=False,
)
assert result.exit_code == 0, result.output
assert "arc-workflow" in result.output